fix(desktop): DeerFlow UI — ChatArea refactor + ai-elements + dead CSS cleanup

ChatArea retry button uses setInput instead of direct sendToGateway,
fix bootstrap spinner stuck for non-logged-in users,
remove dead CSS (aurora-title/sidebar-open/quick-action-chips),
add ai components (ReasoningBlock/StreamingText/ChatMode/ModelSelector/TaskProgress),
add ClassroomPlayer + ResizableChatLayout + artifact panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-02 19:24:44 +08:00
parent d40c4605b2
commit 28299807b6
70 changed files with 4938 additions and 618 deletions

View File

@@ -26,14 +26,17 @@ chrono = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
sqlx = { workspace = true }
pgvector = { version = "0.4", features = ["sqlx"] }
reqwest = { workspace = true }
secrecy = { workspace = true }
sha2 = { workspace = true }
rand = { workspace = true }
dashmap = { workspace = true }
hex = { workspace = true }
rsa = { workspace = true, features = ["sha2"] }
base64 = { workspace = true }
socket2 = { workspace = true }
url = "2"
url = { workspace = true }
axum = { workspace = true }
axum-extra = { workspace = true }
@@ -47,6 +50,7 @@ data-encoding = "2"
regex = { workspace = true }
aes-gcm = { workspace = true }
bytes = { workspace = true }
async-stream = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -0,0 +1,10 @@
-- Add is_embedding column to models table
-- Distinguishes embedding models from chat/completion models
ALTER TABLE models ADD COLUMN IF NOT EXISTS is_embedding BOOLEAN NOT NULL DEFAULT FALSE;
-- Add model_type column for future extensibility (chat, embedding, image, audio, etc.)
ALTER TABLE models ADD COLUMN IF NOT EXISTS model_type TEXT NOT NULL DEFAULT 'chat';
-- Index for quick filtering of embedding models
CREATE INDEX IF NOT EXISTS idx_models_is_embedding ON models(is_embedding) WHERE is_embedding = TRUE;
CREATE INDEX IF NOT EXISTS idx_models_model_type ON models(model_type);

View File

@@ -0,0 +1,5 @@
-- Add execution result columns to scheduled_tasks
-- Tracks the output and duration of each task execution for observability
ALTER TABLE scheduled_tasks ADD COLUMN IF NOT EXISTS last_result TEXT;
ALTER TABLE scheduled_tasks ADD COLUMN IF NOT EXISTS last_duration_ms INTEGER;

View File

@@ -67,14 +67,17 @@ async fn verify_api_token(state: &AppState, raw_token: &str, client_ip: Option<S
}
}
// 异步更新 last_used_at(不阻塞请求)
let db = state.db.clone();
tokio::spawn(async move {
let now = chrono::Utc::now().to_rfc3339();
let _ = sqlx::query("UPDATE api_tokens SET last_used_at = $1 WHERE token_hash = $2")
.bind(&now).bind(&token_hash)
.execute(&db).await;
});
// 异步更新 last_used_at — 通过 Worker 通道派发,受 SpawnLimiter 门控
// 替换原来的 tokio::spawn(DB UPDATE),消除每请求无限制 spawn
{
use crate::workers::update_last_used::UpdateLastUsedArgs;
let args = UpdateLastUsedArgs {
token_hash: token_hash.to_string(),
};
if let Err(e) = state.worker_dispatcher.dispatch("update_last_used", args).await {
tracing::debug!("Failed to dispatch update_last_used: {}", e);
}
}
Ok(AuthContext {
account_id,
@@ -84,23 +87,43 @@ async fn verify_api_token(state: &AppState, raw_token: &str, client_ip: Option<S
})
}
/// 从请求中提取客户端 IP
fn extract_client_ip(req: &Request) -> Option<String> {
// 优先从 ConnectInfo 获取
if let Some(ConnectInfo(addr)) = req.extensions().get::<ConnectInfo<SocketAddr>>() {
return Some(addr.ip().to_string());
}
// 回退到 X-Forwarded-For / X-Real-IP
/// 从请求中提取客户端 IP(安全版:仅对 trusted_proxies 解析 XFF
fn extract_client_ip(req: &Request, trusted_proxies: &[String]) -> Option<String> {
// 优先从 ConnectInfo 获取直接连接 IP
let connect_ip = req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.ip().to_string());
// 仅当直接连接 IP 在 trusted_proxies 中时,才信任 XFF/X-Real-IP
if let Some(ref ip) = connect_ip {
if trusted_proxies.iter().any(|p| p == ip) {
// 受信代理 → 从 XFF 取真实客户端 IP
if let Some(forwarded) = req.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
{
return Some(forwarded.split(',').next()?.trim().to_string());
if let Some(client) = forwarded.split(',').next() {
let trimmed = client.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
req.headers()
}
}
// 尝试 X-Real-IP
if let Some(real_ip) = req.headers()
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
{
let trimmed = real_ip.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
}
// 非受信来源或无代理头 → 返回直接连接 IP
connect_ip
}
/// 认证中间件: 从 JWT Cookie / Authorization Header / API Token 提取身份
@@ -110,7 +133,10 @@ pub async fn auth_middleware(
mut req: Request,
next: Next,
) -> Response {
let client_ip = extract_client_ip(&req);
let client_ip = {
let config = state.config.read().await;
extract_client_ip(&req, &config.server.trusted_proxies)
};
let auth_header = req.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());

View File

@@ -4,7 +4,6 @@ use axum::{
extract::{Extension, Form, Path, Query, State},
Json,
};
use axum::response::Html;
use serde::Deserialize;
use crate::auth::types::AuthContext;
@@ -90,9 +89,8 @@ pub async fn increment_usage_dimension(
));
}
for _ in 0..req.count {
service::increment_dimension(&state.db, &ctx.account_id, &req.dimension).await?;
}
// 单次原子更新,避免循环 N 次数据库查询
service::increment_dimension_by(&state.db, &ctx.account_id, &req.dimension, req.count).await?;
// 返回更新后的用量
let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?;
@@ -109,10 +107,12 @@ pub async fn create_payment(
Extension(ctx): Extension<AuthContext>,
Json(req): Json<CreatePaymentRequest>,
) -> SaasResult<Json<PaymentResult>> {
let config = state.config.read().await;
let result = super::payment::create_payment(
&state.db,
&ctx.account_id,
&req,
&config.payment,
).await?;
Ok(Json(result))
}
@@ -139,22 +139,28 @@ pub async fn payment_callback(
) -> SaasResult<String> {
tracing::info!("Payment callback received: method={}, body_len={}", method, body.len());
// 解析回调参数
let body_str = String::from_utf8_lossy(&body);
let config = state.config.read().await;
// 支付宝回调form-urlencoded 格式
// 微信回调JSON 格式
let (trade_no, status) = if method == "alipay" {
parse_alipay_callback(&body_str)
let (trade_no, status, callback_amount) = if method == "alipay" {
parse_alipay_callback(&body_str, &config.payment)?
} else if method == "wechat" {
parse_wechat_callback(&body_str)
parse_wechat_callback(&body_str, &config.payment)?
} else {
tracing::warn!("Unknown payment callback method: {}", method);
return Ok("fail".into());
};
if let Some(trade_no) = trade_no {
super::payment::handle_payment_callback(&state.db, &trade_no, &status).await?;
// trade_no 是必填字段,缺失说明回调格式异常
let trade_no = trade_no.ok_or_else(|| {
tracing::warn!("Payment callback missing out_trade_no: method={}", method);
SaasError::InvalidInput("回调缺少交易号".into())
})?;
if let Err(e) = super::payment::handle_payment_callback(&state.db, &trade_no, &status, callback_amount).await {
// 对外返回通用错误,不泄露内部细节
tracing::error!("Payment callback processing failed: method={}, error={}", method, e);
return Ok("fail".into());
}
// 支付宝期望 "success",微信期望 JSON
@@ -178,6 +184,11 @@ pub struct MockPayQuery {
pub async fn mock_pay_page(
Query(params): Query<MockPayQuery>,
) -> axum::response::Html<String> {
// HTML 转义防止 XSS
let safe_subject = html_escape(&params.subject);
let safe_trade_no = html_escape(&params.trade_no);
let amount_yuan = params.amount as f64 / 100.0;
axum::response::Html(format!(r#"
<!DOCTYPE html>
<html lang="zh">
@@ -194,23 +205,19 @@ body {{ font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 20
</style></head>
<body>
<div class="card">
<div class="subject">{subject}</div>
<div class="subject">{safe_subject}</div>
<div class="amount">¥{amount_yuan}</div>
<div style="text-align:center;color:#999;font-size:12px;margin-bottom:16px;">
订单号: {trade_no}
订单号: {safe_trade_no}
</div>
<form action="/api/v1/billing/mock-pay/confirm" method="POST">
<input type="hidden" name="trade_no" value="{trade_no}" />
<input type="hidden" name="trade_no" value="{safe_trade_no}" />
<button type="submit" name="action" value="success" class="btn btn-pay">确认支付 ¥{amount_yuan}</button>
<button type="submit" name="action" value="fail" class="btn btn-fail">模拟失败</button>
</form>
</div>
</body></html>
"#,
subject = params.subject,
trade_no = params.trade_no,
amount_yuan = params.amount as f64 / 100.0,
))
"#))
}
#[derive(Debug, Deserialize)]
@@ -226,7 +233,7 @@ pub async fn mock_pay_confirm(
) -> SaasResult<axum::response::Html<String>> {
let status = if form.action == "success" { "success" } else { "failed" };
if let Err(e) = super::payment::handle_payment_callback(&state.db, &form.trade_no, status).await {
if let Err(e) = super::payment::handle_payment_callback(&state.db, &form.trade_no, status, None).await {
tracing::error!("Mock payment callback failed: {}", e);
}
@@ -249,31 +256,140 @@ body {{ font-family: system-ui; max-width: 480px; margin: 40px auto; padding: 20
"#)))
}
// === 辅助函数 ===
// === 回调解析 ===
fn parse_alipay_callback(body: &str) -> (Option<String>, String) {
// 简化解析:支付宝回调是 form-urlencoded
/// 解析支付宝回调并验签,返回 (trade_no, status, callback_amount_cents)
fn parse_alipay_callback(
body: &str,
config: &crate::config::PaymentConfig,
) -> SaasResult<(Option<String>, String, Option<i32>)> {
// form-urlencoded → key=value 对
let mut params: Vec<(String, String)> = Vec::new();
for pair in body.split('&') {
if let Some((k, v)) = pair.split_once('=') {
if k == "out_trade_no" {
return (Some(urlencoding::decode(v).unwrap_or_default().to_string()), "TRADE_SUCCESS".into());
params.push((
k.to_string(),
urlencoding::decode(v).unwrap_or_default().to_string(),
));
}
}
let mut trade_no = None;
let mut callback_amount: Option<i32> = None;
// 验签:生产环境强制,开发环境允许跳过
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if let Some(ref public_key) = config.alipay_public_key {
match super::payment::verify_alipay_callback(&params, public_key) {
Ok(true) => {}
Ok(false) => {
tracing::warn!("Alipay callback signature verification FAILED");
return Err(SaasError::InvalidInput("支付宝回调验签失败".into()));
}
(None, "unknown".into())
Err(e) => {
tracing::error!("Alipay callback verification error: {}", e);
return Err(SaasError::InvalidInput("支付宝回调验签异常".into()));
}
}
} else if !is_dev {
tracing::error!("Alipay public key not configured in production — rejecting callback");
return Err(SaasError::InvalidInput("支付宝公钥未配置,无法验签".into()));
} else {
tracing::warn!("Alipay public key not configured (dev mode), skipping signature verification");
}
// 提取 trade_no、trade_status 和 total_amount
let mut trade_status = "unknown".to_string();
for (k, v) in &params {
match k.as_str() {
"out_trade_no" => trade_no = Some(v.clone()),
"trade_status" => trade_status = v.clone(),
"total_amount" => {
// 支付宝金额为元(字符串),转为分(整数)
if let Ok(yuan) = v.parse::<f64>() {
callback_amount = Some((yuan * 100.0).round() as i32);
}
}
_ => {}
}
}
// 支付宝成功状态映射
let status = if trade_status == "TRADE_SUCCESS" || trade_status == "TRADE_FINISHED" {
"TRADE_SUCCESS"
} else {
&trade_status
};
Ok((trade_no, status.to_string(), callback_amount))
}
fn parse_wechat_callback(body: &str) -> (Option<String>, String) {
// 微信回调是 JSON
if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(event_type) = v.get("event_type").and_then(|t| t.as_str()) {
if event_type == "TRANSACTION.SUCCESS" {
let trade_no = v.pointer("/resource/out_trade_no")
/// 解析微信支付回调,解密 resource 字段,返回 (trade_no, status, callback_amount_cents)
fn parse_wechat_callback(
body: &str,
config: &crate::config::PaymentConfig,
) -> SaasResult<(Option<String>, String, Option<i32>)> {
let v: serde_json::Value = serde_json::from_str(body)
.map_err(|e| SaasError::InvalidInput(format!("微信回调 JSON 解析失败: {}", e)))?;
let event_type = v.get("event_type")
.and_then(|t| t.as_str())
.unwrap_or("");
if event_type != "TRANSACTION.SUCCESS" {
// 非支付成功事件(如退款等),忽略
return Ok((None, event_type.to_string(), None));
}
// 解密 resource 字段
let resource = v.get("resource")
.ok_or_else(|| SaasError::InvalidInput("微信回调缺少 resource 字段".into()))?;
let ciphertext = resource.get("ciphertext")
.and_then(|v| v.as_str())
.ok_or_else(|| SaasError::InvalidInput("微信回调 resource 缺少 ciphertext".into()))?;
let nonce = resource.get("nonce")
.and_then(|v| v.as_str())
.ok_or_else(|| SaasError::InvalidInput("微信回调 resource 缺少 nonce".into()))?;
let associated_data = resource.get("associated_data")
.and_then(|v| v.as_str())
.unwrap_or("");
let api_v3_key = config.wechat_api_v3_key.as_deref()
.ok_or_else(|| SaasError::InvalidInput("微信 API v3 密钥未配置,无法解密回调".into()))?;
let plaintext = super::payment::decrypt_wechat_resource(
ciphertext, nonce, associated_data, api_v3_key,
)?;
let decrypted: serde_json::Value = serde_json::from_str(&plaintext)
.map_err(|e| SaasError::Internal(format!("微信回调解密内容 JSON 解析失败: {}", e)))?;
let trade_no = decrypted.get("out_trade_no")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
return (trade_no, "SUCCESS".into());
}
}
}
(None, "unknown".into())
let trade_state = decrypted.get("trade_state")
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN");
// 微信金额已为分(整数)
let callback_amount = decrypted.get("amount")
.and_then(|a| a.get("total"))
.and_then(|v| v.as_i64())
.map(|v| v as i32);
Ok((trade_no, trade_state.to_string(), callback_amount))
}
/// HTML 转义,防止 XSS 注入
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}

View File

@@ -7,6 +7,7 @@ pub mod payment;
use axum::routing::{get, post};
/// 需要认证的计费路由
pub fn routes() -> axum::Router<crate::state::AppState> {
axum::Router::new()
.route("/api/v1/billing/plans", get(handlers::list_plans))
@@ -16,7 +17,11 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
.route("/api/v1/billing/usage/increment", post(handlers::increment_usage_dimension))
.route("/api/v1/billing/payments", post(handlers::create_payment))
.route("/api/v1/billing/payments/{id}", get(handlers::get_payment_status))
// 支付回调(无需 auth
}
/// 支付回调路由(无需 auth — 支付宝/微信服务器回调)
pub fn callback_routes() -> axum::Router<crate::state::AppState> {
axum::Router::new()
.route("/api/v1/billing/callback/{method}", post(handlers::payment_callback))
}

View File

@@ -1,21 +1,25 @@
//! 支付集成 — 支付宝/微信支付
//! 支付集成 — 支付宝/微信支付(直连 HTTP 实现)
//!
//! 开发模式使用 mock 支付,生产模式调用真实支付 API
//! 真实集成需要:
//! - 支付宝alipay-sdk-rust 或 HTTP 直连(支付宝开放平台 v3 API
//! - 微信支付wxpay-rust 或 wechat-pay-rs
//! 不依赖第三方 SDK使用 `rsa` crate 做 RSA2 签名,`reqwest` 做 HTTP 调用
//! 开发模式(`ZCLAW_SAAS_DEV=true`)使用 mock 支付。
use sqlx::PgPool;
use crate::config::PaymentConfig;
use crate::error::{SaasError, SaasResult};
use super::types::*;
/// 创建支付订单
// ────────────────────────────────────────────────────────────────
// 公开 API
// ────────────────────────────────────────────────────────────────
/// 创建支付订单,返回支付链接/二维码 URL
///
/// 返回支付链接/二维码 URL前端跳转或展示
/// 发票和支付记录在事务中创建,确保原子性。
pub async fn create_payment(
pool: &PgPool,
account_id: &str,
req: &CreatePaymentRequest,
config: &PaymentConfig,
) -> SaasResult<PaymentResult> {
// 1. 获取计划信息
let plan = sqlx::query_as::<_, BillingPlan>(
@@ -40,7 +44,10 @@ pub async fn create_payment(
return Err(SaasError::InvalidInput("已订阅该计划".into()));
}
// 2. 创建发票
// 2. 在事务中创建发票和支付记录
let mut tx = pool.begin().await
.map_err(|e| SaasError::Internal(format!("开启事务失败: {}", e)))?;
let invoice_id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let due = now + chrono::Duration::days(1);
@@ -58,10 +65,9 @@ pub async fn create_payment(
.bind(format!("{} - {} ({})", plan.display_name, plan.interval, now.format("%Y-%m")))
.bind(due.to_rfc3339())
.bind(now.to_rfc3339())
.execute(pool)
.execute(&mut *tx)
.await?;
// 3. 创建支付记录
let payment_id = uuid::Uuid::new_v4().to_string();
let trade_no = format!("ZCLAW-{}-{}", chrono::Utc::now().format("%Y%m%d%H%M%S"), &payment_id[..8]);
@@ -75,14 +81,23 @@ pub async fn create_payment(
.bind(account_id)
.bind(plan.price_cents)
.bind(&plan.currency)
.bind(format!("{:?}", req.payment_method).to_lowercase())
.bind(req.payment_method.to_string())
.bind(&trade_no)
.bind(now.to_rfc3339())
.execute(pool)
.execute(&mut *tx)
.await?;
// 4. 生成支付链接
let pay_url = generate_pay_url(req.payment_method, &trade_no, plan.price_cents, &plan.display_name)?;
tx.commit().await
.map_err(|e| SaasError::Internal(format!("事务提交失败: {}", e)))?;
// 3. 生成支付链接
let pay_url = generate_pay_url(
req.payment_method,
&trade_no,
plan.price_cents,
&plan.display_name,
config,
).await?;
Ok(PaymentResult {
payment_id,
@@ -93,70 +108,108 @@ pub async fn create_payment(
}
/// 处理支付回调(支付宝/微信异步通知)
///
/// `callback_amount_cents` 来自回调报文的金额(分),用于与 DB 金额交叉验证。
/// 整个操作在数据库事务中执行,使用 SELECT FOR UPDATE 防止并发竞态。
pub async fn handle_payment_callback(
pool: &PgPool,
trade_no: &str,
status: &str,
callback_amount_cents: Option<i32>,
) -> SaasResult<()> {
// 1. 查找支付记录
let payment: Option<(String, String, String, i32)> = sqlx::query_as::<_, (String, String, String, i32)>(
"SELECT id, invoice_id, account_id, amount_cents \
FROM billing_payments WHERE external_trade_no = $1 AND status = 'pending'"
// 1. 在事务中锁定支付记录,防止 TOCTOU 竞态
let mut tx = pool.begin().await
.map_err(|e| SaasError::Internal(format!("开启事务失败: {}", e)))?;
let payment: Option<(String, String, String, i32, String)> = sqlx::query_as::<_, (String, String, String, i32, String)>(
"SELECT id, invoice_id, account_id, amount_cents, status \
FROM billing_payments WHERE external_trade_no = $1 FOR UPDATE"
)
.bind(trade_no)
.fetch_optional(pool)
.fetch_optional(&mut *tx)
.await?;
let (payment_id, invoice_id, account_id, _amount) = match payment {
let (payment_id, invoice_id, account_id, db_amount, current_status) = match payment {
Some(p) => p,
None => {
tracing::warn!("Payment callback for unknown/expired trade: {}", trade_no);
tracing::error!("Payment callback for unknown trade: {}", sanitize_log(trade_no));
tx.rollback().await?;
return Ok(());
}
};
// 幂等性:已处理过直接返回
if current_status != "pending" {
tracing::info!("Payment already processed (idempotent): trade={}, status={}", sanitize_log(trade_no), current_status);
tx.rollback().await?;
return Ok(());
}
// 2. 金额交叉验证(防篡改)
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if let Some(callback_amount) = callback_amount_cents {
if callback_amount != db_amount {
tracing::error!(
"Amount mismatch: trade={}, db_amount={}, callback_amount={}. Rejecting.",
sanitize_log(trade_no), db_amount, callback_amount
);
tx.rollback().await?;
return Err(SaasError::InvalidInput("回调验证失败".into()));
}
} else if !is_dev {
// 非开发环境必须有金额
tracing::error!("Callback without amount in non-dev mode: trade={}", sanitize_log(trade_no));
tx.rollback().await?;
return Err(SaasError::InvalidInput("回调缺少金额验证".into()));
} else {
tracing::warn!("DEV: Skipping amount verification for trade={}", sanitize_log(trade_no));
}
let now = chrono::Utc::now().to_rfc3339();
if status == "success" || status == "TRADE_SUCCESS" || status == "SUCCESS" {
// 2. 更新支付状态
// 3. 更新支付状态
sqlx::query(
"UPDATE billing_payments SET status = 'succeeded', paid_at = $1, updated_at = $1 WHERE id = $2"
)
.bind(&now)
.bind(&payment_id)
.execute(pool)
.execute(&mut *tx)
.await?;
// 3. 更新发票状态
// 4. 更新发票状态
sqlx::query(
"UPDATE billing_invoices SET status = 'paid', paid_at = $1, updated_at = $1 WHERE id = $2"
)
.bind(&now)
.bind(&invoice_id)
.execute(pool)
.execute(&mut *tx)
.await?;
// 4. 获取发票关联的计划
// 5. 获取发票关联的计划
let plan_id: Option<String> = sqlx::query_scalar(
"SELECT plan_id FROM billing_invoices WHERE id = $1"
)
.bind(&invoice_id)
.fetch_optional(pool)
.fetch_optional(&mut *tx)
.await?
.flatten();
if let Some(plan_id) = plan_id {
// 5. 取消旧订阅
// 6. 取消旧订阅
sqlx::query(
"UPDATE billing_subscriptions SET status = 'canceled', canceled_at = $1, updated_at = $1 \
WHERE account_id = $2 AND status IN ('trial', 'active')"
)
.bind(&now)
.bind(&account_id)
.execute(pool)
.execute(&mut *tx)
.await?;
// 6. 创建新订阅30 天周期)
// 7. 创建新订阅30 天周期)
let sub_id = uuid::Uuid::new_v4().to_string();
let period_end = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
let period_start = chrono::Utc::now().to_rfc3339();
@@ -172,7 +225,7 @@ pub async fn handle_payment_callback(
.bind(&period_start)
.bind(&period_end)
.bind(&now)
.execute(pool)
.execute(&mut *tx)
.await?;
tracing::info!(
@@ -180,18 +233,34 @@ pub async fn handle_payment_callback(
account_id, plan_id, sub_id
);
}
tx.commit().await
.map_err(|e| SaasError::Internal(format!("事务提交失败: {}", e)))?;
} else {
// 支付失败
// 支付失败:截断 status 防止注入,更新发票为 void
let safe_reason = truncate_str(status, 200);
sqlx::query(
"UPDATE billing_payments SET status = 'failed', failure_reason = $1, updated_at = $2 WHERE id = $3"
)
.bind(status)
.bind(&safe_reason)
.bind(&now)
.bind(&payment_id)
.execute(pool)
.execute(&mut *tx)
.await?;
tracing::warn!("Payment failed: trade={}, status={}", trade_no, status);
// 同时将发票标记为 void
sqlx::query(
"UPDATE billing_invoices SET status = 'void', voided_at = $1, updated_at = $1 WHERE id = $2"
)
.bind(&now)
.bind(&invoice_id)
.execute(&mut *tx)
.await?;
tx.commit().await
.map_err(|e| SaasError::Internal(format!("事务提交失败: {}", e)))?;
tracing::warn!("Payment failed: trade={}, status={}", sanitize_log(trade_no), safe_reason);
}
Ok(())
@@ -223,44 +292,356 @@ pub async fn query_payment_status(
}))
}
// === 内部函数 ===
// ────────────────────────────────────────────────────────────────
// 支付 URL 生成
// ────────────────────────────────────────────────────────────────
/// 生成支付 URL(开发模式使用 mock
fn generate_pay_url(
/// 生成支付 URL:根据配置决定 mock 或真实支付
async fn generate_pay_url(
method: PaymentMethod,
trade_no: &str,
amount_cents: i32,
subject: &str,
config: &PaymentConfig,
) -> SaasResult<String> {
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if is_dev {
// 开发模式:返回 mock 支付页面 URL
let base = std::env::var("ZCLAW_SAAS_URL")
.unwrap_or_else(|_| "http://localhost:8080".into());
return Ok(format!(
"{}/api/v1/billing/mock-pay?trade_no={}&amount={}&subject={}",
base, trade_no, amount_cents,
urlencoding::encode(subject),
));
return Ok(mock_pay_url(trade_no, amount_cents, subject));
}
match method {
PaymentMethod::Alipay => {
// TODO: 真实支付宝集成
// 需要 ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY 等环境变量
Err(SaasError::InvalidInput(
"支付宝支付集成尚未配置,请联系管理员".into(),
))
PaymentMethod::Alipay => generate_alipay_url(trade_no, amount_cents, subject, config),
PaymentMethod::Wechat => generate_wechat_url(trade_no, amount_cents, subject, config).await,
}
PaymentMethod::Wechat => {
// TODO: 真实微信支付集成
// 需要 WECHAT_PAY_MCH_ID, WECHAT_PAY_API_KEY 等
Err(SaasError::InvalidInput(
"微信支付集成尚未配置,请联系管理员".into(),
))
}
fn mock_pay_url(trade_no: &str, amount_cents: i32, subject: &str) -> String {
let base = std::env::var("ZCLAW_SAAS_URL")
.unwrap_or_else(|_| "http://localhost:8080".into());
format!(
"{}/api/v1/billing/mock-pay?trade_no={}&amount={}&subject={}",
base,
urlencoding::encode(trade_no),
amount_cents,
urlencoding::encode(subject),
)
}
// ────────────────────────────────────────────────────────────────
// 支付宝 — alipay.trade.page.payRSA2 签名 + 证书模式)
// ────────────────────────────────────────────────────────────────
fn generate_alipay_url(
trade_no: &str,
amount_cents: i32,
subject: &str,
config: &PaymentConfig,
) -> SaasResult<String> {
let app_id = config.alipay_app_id.as_deref()
.ok_or_else(|| SaasError::InvalidInput("支付宝 app_id 未配置".into()))?;
let private_key_pem = config.alipay_private_key.as_deref()
.ok_or_else(|| SaasError::InvalidInput("支付宝商户私钥未配置".into()))?;
let notify_url = config.alipay_notify_url.as_deref()
.ok_or_else(|| SaasError::InvalidInput("支付宝回调 URL 未配置".into()))?;
// 金额:分 → 元(整数运算避免浮点精度问题)
let yuan_part = amount_cents / 100;
let cent_part = amount_cents % 100;
let amount_yuan = format!("{}.{:02}", yuan_part, cent_part);
// 构建请求参数(字典序)
let mut params: Vec<(&str, String)> = vec![
("app_id", app_id.to_string()),
("method", "alipay.trade.page.pay".to_string()),
("charset", "utf-8".to_string()),
("sign_type", "RSA2".to_string()),
("timestamp", chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string()),
("version", "1.0".to_string()),
("notify_url", notify_url.to_string()),
("biz_content", serde_json::json!({
"out_trade_no": trade_no,
"total_amount": amount_yuan,
"subject": subject,
"product_code": "FAST_INSTANT_TRADE_PAY",
}).to_string()),
];
// 按 key 字典序排列并拼接
params.sort_by(|a, b| a.0.cmp(b.0));
let sign_str: String = params.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
// RSA2 签名
let sign = rsa_sign_sha256_base64(private_key_pem, sign_str.as_bytes())?;
// 构建 gateway URL
params.push(("sign", sign));
let query: String = params.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
Ok(format!("https://openapi.alipay.com/gateway.do?{}", query))
}
// ────────────────────────────────────────────────────────────────
// 微信支付 — V3 Native PayQR 码模式)
// ────────────────────────────────────────────────────────────────
async fn generate_wechat_url(
trade_no: &str,
amount_cents: i32,
subject: &str,
config: &PaymentConfig,
) -> SaasResult<String> {
let mch_id = config.wechat_mch_id.as_deref()
.ok_or_else(|| SaasError::InvalidInput("微信支付商户号未配置".into()))?;
let serial_no = config.wechat_serial_no.as_deref()
.ok_or_else(|| SaasError::InvalidInput("微信支付证书序列号未配置".into()))?;
let private_key_pem = config.wechat_private_key_path.as_deref()
.ok_or_else(|| SaasError::InvalidInput("微信支付私钥路径未配置".into()))?;
let notify_url = config.wechat_notify_url.as_deref()
.ok_or_else(|| SaasError::InvalidInput("微信支付回调 URL 未配置".into()))?;
let app_id = config.wechat_app_id.as_deref()
.ok_or_else(|| SaasError::InvalidInput("微信支付 App ID 未配置".into()))?;
// 读取私钥文件
let private_key = std::fs::read_to_string(private_key_pem)
.map_err(|e| SaasError::InvalidInput(format!("微信支付私钥文件读取失败: {}", e)))?;
let body = serde_json::json!({
"appid": app_id,
"mchid": mch_id,
"description": subject,
"out_trade_no": trade_no,
"notify_url": notify_url,
"amount": {
"total": amount_cents,
"currency": "CNY",
},
});
let body_str = body.to_string();
// 构建签名字符串
let timestamp = chrono::Utc::now().timestamp().to_string();
let nonce_str = uuid::Uuid::new_v4().to_string().replace("-", "");
let sign_message = format!(
"POST\n/v3/pay/transactions/native\n{}\n{}\n{}\n",
timestamp, nonce_str, body_str
);
let signature = rsa_sign_sha256_base64(&private_key, sign_message.as_bytes())?;
// 构建 Authorization 头
let auth_header = format!(
"WECHATPAY2-SHA256-RSA2048 mchid=\"{}\",nonce_str=\"{}\",timestamp=\"{}\",serial_no=\"{}\",signature=\"{}\"",
mch_id, nonce_str, timestamp, serial_no, signature
);
// 发送请求
let client = reqwest::Client::new();
let resp = client
.post("https://api.mch.weixin.qq.com/v3/pay/transactions/native")
.header("Content-Type", "application/json")
.header("Authorization", auth_header)
.header("Accept", "application/json")
.body(body_str)
.send()
.await
.map_err(|e| SaasError::Internal(format!("微信支付请求失败: {}", e)))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
tracing::error!("WeChat Pay API error: status={}, body={}", status, text);
return Err(SaasError::InvalidInput(format!(
"微信支付创建订单失败 (HTTP {})", status
)));
}
let resp_json: serde_json::Value = resp.json().await
.map_err(|e| SaasError::Internal(format!("微信支付响应解析失败: {}", e)))?;
let code_url = resp_json.get("code_url")
.and_then(|v| v.as_str())
.ok_or_else(|| SaasError::Internal("微信支付响应缺少 code_url".into()))?
.to_string();
Ok(code_url)
}
// ────────────────────────────────────────────────────────────────
// 回调验签
// ────────────────────────────────────────────────────────────────
/// 验证支付宝回调签名
pub fn verify_alipay_callback(
params: &[(String, String)],
alipay_public_key_pem: &str,
) -> SaasResult<bool> {
// 1. 提取 sign 和 sign_type剩余参数字典序拼接
let mut sign = None;
let mut filtered: Vec<(&str, &str)> = Vec::new();
for (k, v) in params {
match k.as_str() {
"sign" => sign = Some(v.clone()),
"sign_type" => {} // 跳过
_ => {
if !v.is_empty() {
filtered.push((k.as_str(), v.as_str()));
}
}
}
}
let sign = match sign {
Some(s) => s,
None => return Ok(false),
};
filtered.sort_by(|a, b| a.0.cmp(b.0));
let sign_str: String = filtered.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
// 2. 用支付宝公钥验签
rsa_verify_sha256(alipay_public_key_pem, sign_str.as_bytes(), &sign)
}
/// 解密微信支付回调 resource 字段AES-256-GCM
pub fn decrypt_wechat_resource(
ciphertext_b64: &str,
nonce: &str,
associated_data: &str,
api_v3_key: &str,
) -> SaasResult<String> {
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use aes_gcm::aead::Aead;
use base64::Engine;
let key_bytes = api_v3_key.as_bytes();
if key_bytes.len() != 32 {
return Err(SaasError::Internal("微信 API v3 密钥必须为 32 字节".into()));
}
let nonce_bytes = nonce.as_bytes();
if nonce_bytes.len() != 12 {
return Err(SaasError::InvalidInput("微信回调 nonce 长度必须为 12 字节".into()));
}
let ciphertext = base64::engine::general_purpose::STANDARD
.decode(ciphertext_b64)
.map_err(|e| SaasError::Internal(format!("base64 解码失败: {}", e)))?;
let cipher = Aes256Gcm::new_from_slice(key_bytes)
.map_err(|e| SaasError::Internal(format!("AES 密钥初始化失败: {}", e)))?;
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, aes_gcm::aead::Payload {
msg: &ciphertext,
aad: associated_data.as_bytes(),
})
.map_err(|e| SaasError::Internal(format!("AES-GCM 解密失败: {}", e)))?;
String::from_utf8(plaintext)
.map_err(|e| SaasError::Internal(format!("解密结果 UTF-8 转换失败: {}", e)))
}
// ────────────────────────────────────────────────────────────────
// RSA 工具函数
// ────────────────────────────────────────────────────────────────
/// SHA256WithRSA 签名 + Base64 编码PKCS#1 v1.5
fn rsa_sign_sha256_base64(
private_key_pem: &str,
message: &[u8],
) -> SaasResult<String> {
use rsa::pkcs8::DecodePrivateKey;
use rsa::signature::{Signer, SignatureEncoding};
use sha2::Sha256;
use rsa::pkcs1v15::SigningKey;
use base64::Engine;
let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(private_key_pem)
.map_err(|e| SaasError::Internal(format!("RSA 私钥解析失败: {}", e)))?;
let signing_key = SigningKey::<Sha256>::new(private_key);
let signature = signing_key.sign(message);
Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()))
}
/// SHA256WithRSA 验签
fn rsa_verify_sha256(
public_key_pem: &str,
message: &[u8],
signature_b64: &str,
) -> SaasResult<bool> {
use rsa::pkcs8::DecodePublicKey;
use rsa::signature::Verifier;
use sha2::Sha256;
use rsa::pkcs1v15::VerifyingKey;
use base64::Engine;
let public_key = match rsa::RsaPublicKey::from_public_key_pem(public_key_pem) {
Ok(k) => k,
Err(e) => {
tracing::error!("RSA 公钥解析失败: {}", e);
return Ok(false);
}
};
let signature_bytes = match base64::engine::general_purpose::STANDARD.decode(signature_b64) {
Ok(b) => b,
Err(e) => {
tracing::error!("签名 base64 解码失败: {}", e);
return Ok(false);
}
};
let verifying_key = VerifyingKey::<Sha256>::new(public_key);
let signature = match rsa::pkcs1v15::Signature::try_from(signature_bytes.as_slice()) {
Ok(s) => s,
Err(_) => return Ok(false),
};
Ok(verifying_key.verify(message, &signature).is_ok())
}
// ────────────────────────────────────────────────────────────────
// 辅助函数
// ────────────────────────────────────────────────────────────────
/// 日志安全:只保留字母数字和 `-` `_`,防止日志注入
fn sanitize_log(s: &str) -> String {
s.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect()
}
/// 截断字符串到指定长度(按字符而非字节)
fn truncate_str(s: &str, max_len: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max_len {
s.to_string()
} else {
chars.into_iter().take(max_len).collect()
}
}
impl std::fmt::Display for PaymentMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Alipay => write!(f, "alipay"),
Self::Wechat => write!(f, "wechat"),
}
}
}

View File

@@ -17,8 +17,19 @@ pub async fn list_plans(pool: &PgPool) -> SaasResult<Vec<BillingPlan>> {
Ok(plans)
}
/// 获取单个计划
/// 获取单个计划(公开 API 只返回 active 计划)
pub async fn get_plan(pool: &PgPool, plan_id: &str) -> SaasResult<Option<BillingPlan>> {
let plan = sqlx::query_as::<_, BillingPlan>(
"SELECT * FROM billing_plans WHERE id = $1 AND status = 'active'"
)
.bind(plan_id)
.fetch_optional(pool)
.await?;
Ok(plan)
}
/// 获取单个计划(内部使用,不过滤 status用于已订阅用户查看旧计划
pub async fn get_plan_any_status(pool: &PgPool, plan_id: &str) -> SaasResult<Option<BillingPlan>> {
let plan = sqlx::query_as::<_, BillingPlan>(
"SELECT * FROM billing_plans WHERE id = $1"
)
@@ -47,7 +58,7 @@ pub async fn get_active_subscription(
/// 获取账户当前计划(有订阅返回订阅计划,否则返回 Free
pub async fn get_account_plan(pool: &PgPool, account_id: &str) -> SaasResult<BillingPlan> {
if let Some(sub) = get_active_subscription(pool, account_id).await? {
if let Some(plan) = get_plan(pool, &sub.plan_id).await? {
if let Some(plan) = get_plan_any_status(pool, &sub.plan_id).await? {
return Ok(plan);
}
}
@@ -81,7 +92,7 @@ pub async fn get_account_plan(pool: &PgPool, account_id: &str) -> SaasResult<Bil
}))
}
/// 获取或创建当月用量记录
/// 获取或创建当月用量记录(原子操作,使用 INSERT ON CONFLICT 防止 TOCTOU 竞态)
pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<UsageQuota> {
let now = chrono::Utc::now();
let period_start = now
@@ -91,7 +102,7 @@ pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<
.with_second(0).unwrap_or(now)
.with_nanosecond(0).unwrap_or(now);
// 尝试获取有记录
// 尝试获取有记录
let existing = sqlx::query_as::<_, UsageQuota>(
"SELECT * FROM billing_usage_quotas \
WHERE account_id = $1 AND period_start = $2"
@@ -122,13 +133,15 @@ pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<
.with_second(0).unwrap_or(now)
.with_nanosecond(0).unwrap_or(now);
// 使用 INSERT ON CONFLICT 原子创建(防止并发重复插入)
let id = uuid::Uuid::new_v4().to_string();
let usage = sqlx::query_as::<_, UsageQuota>(
let inserted = sqlx::query_as::<_, UsageQuota>(
"INSERT INTO billing_usage_quotas \
(id, account_id, period_start, period_end, \
max_input_tokens, max_output_tokens, max_relay_requests, \
max_hand_executions, max_pipeline_runs) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
ON CONFLICT (account_id, period_start) DO NOTHING \
RETURNING *"
)
.bind(&id)
@@ -140,6 +153,20 @@ pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<
.bind(limits.max_relay_requests_monthly)
.bind(limits.max_hand_executions_monthly)
.bind(limits.max_pipeline_runs_monthly)
.fetch_optional(pool)
.await?;
if let Some(usage) = inserted {
return Ok(usage);
}
// ON CONFLICT 说明另一个并发请求已经创建了,直接查询返回
let usage = sqlx::query_as::<_, UsageQuota>(
"SELECT * FROM billing_usage_quotas \
WHERE account_id = $1 AND period_start = $2"
)
.bind(account_id)
.bind(period_start)
.fetch_one(pool)
.await?;
@@ -173,7 +200,7 @@ pub async fn increment_usage(
Ok(())
}
/// 增加单一维度用量计数(hand_executions / pipeline_runs / relay_requests
/// 增加单一维度用量计数(单次 +1
///
/// 使用静态 SQL 分支(白名单),避免动态列名注入风险。
pub async fn increment_dimension(
@@ -206,6 +233,40 @@ pub async fn increment_dimension(
Ok(())
}
/// 增加单一维度用量计数(批量 +N原子操作替代循环调用
///
/// 使用静态 SQL 分支(白名单),避免动态列名注入风险。
pub async fn increment_dimension_by(
pool: &PgPool,
account_id: &str,
dimension: &str,
count: i32,
) -> SaasResult<()> {
let usage = get_or_create_usage(pool, account_id).await?;
match dimension {
"relay_requests" => {
sqlx::query(
"UPDATE billing_usage_quotas SET relay_requests = relay_requests + $1, updated_at = NOW() WHERE id = $2"
).bind(count).bind(&usage.id).execute(pool).await?;
}
"hand_executions" => {
sqlx::query(
"UPDATE billing_usage_quotas SET hand_executions = hand_executions + $1, updated_at = NOW() WHERE id = $2"
).bind(count).bind(&usage.id).execute(pool).await?;
}
"pipeline_runs" => {
sqlx::query(
"UPDATE billing_usage_quotas SET pipeline_runs = pipeline_runs + $1, updated_at = NOW() WHERE id = $2"
).bind(count).bind(&usage.id).execute(pool).await?;
}
_ => return Err(crate::error::SaasError::InvalidInput(
format!("Unknown usage dimension: {}", dimension)
)),
}
Ok(())
}
/// 检查用量配额
pub async fn check_quota(
pool: &PgPool,

View File

@@ -167,6 +167,22 @@ impl AppCache {
self.relay_queue_counts.retain(|k, _| db_keys.contains(k));
}
// ============ 快捷查找Phase 2: 减少关键路径 DB 查询) ============
/// 按 model_id 查找已启用的模型。O(1) DashMap 查找。
pub fn get_model(&self, model_id: &str) -> Option<CachedModel> {
self.models.get(model_id)
.filter(|m| m.enabled)
.map(|r| r.value().clone())
}
/// 按 provider id 查找已启用的 Provider。O(1) DashMap 查找。
pub fn get_provider(&self, provider_id: &str) -> Option<CachedProvider> {
self.providers.get(provider_id)
.filter(|p| p.enabled)
.map(|r| r.value().clone())
}
// ============ 缓存失效 ============
/// 清除 model 缓存中的指定条目Admin CRUD 后调用)

View File

@@ -4,9 +4,15 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use secrecy::SecretString;
/// 当前期望的配置版本
const CURRENT_CONFIG_VERSION: u32 = 1;
/// SaaS 服务器完整配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaaSConfig {
/// Configuration schema version
#[serde(default = "default_config_version")]
pub config_version: u32,
pub server: ServerConfig,
pub database: DatabaseConfig,
pub auth: AuthConfig,
@@ -15,6 +21,8 @@ pub struct SaaSConfig {
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub scheduler: SchedulerConfig,
#[serde(default)]
pub payment: PaymentConfig,
}
/// Scheduler 定时任务配置
@@ -66,6 +74,30 @@ pub struct ServerConfig {
pub struct DatabaseConfig {
#[serde(default = "default_db_url")]
pub url: String,
/// 连接池最大连接数
#[serde(default = "default_max_connections")]
pub max_connections: u32,
/// 连接池最小连接数
#[serde(default = "default_min_connections")]
pub min_connections: u32,
/// 获取连接超时 (秒)
#[serde(default = "default_acquire_timeout")]
pub acquire_timeout_secs: u64,
/// 空闲连接回收超时 (秒)
#[serde(default = "default_idle_timeout")]
pub idle_timeout_secs: u64,
/// 连接最大生命周期 (秒)
#[serde(default = "default_max_lifetime")]
pub max_lifetime_secs: u64,
/// Worker 并发上限 (Semaphore permits)
#[serde(default = "default_worker_concurrency")]
pub worker_concurrency: usize,
/// 限流事件批量 flush 间隔 (秒)
#[serde(default = "default_rate_limit_batch_interval")]
pub rate_limit_batch_interval_secs: u64,
/// 限流事件批量 flush 最大条目数
#[serde(default = "default_rate_limit_batch_max")]
pub rate_limit_batch_max_size: usize,
}
/// 认证配置
@@ -97,12 +129,21 @@ pub struct RelayConfig {
pub max_attempts: u32,
}
fn default_config_version() -> u32 { 1 }
fn default_host() -> String { "0.0.0.0".into() }
fn default_port() -> u16 { 8080 }
fn default_db_url() -> String { "postgres://localhost:5432/zclaw".into() }
fn default_jwt_hours() -> i64 { 24 }
fn default_totp_issuer() -> String { "ZCLAW SaaS".into() }
fn default_refresh_hours() -> i64 { 168 }
fn default_max_connections() -> u32 { 100 }
fn default_min_connections() -> u32 { 5 }
fn default_acquire_timeout() -> u64 { 8 }
fn default_idle_timeout() -> u64 { 180 }
fn default_max_lifetime() -> u64 { 900 }
fn default_worker_concurrency() -> usize { 20 }
fn default_rate_limit_batch_interval() -> u64 { 5 }
fn default_rate_limit_batch_max() -> usize { 500 }
fn default_max_queue() -> usize { 1000 }
fn default_max_concurrent() -> usize { 5 }
fn default_batch_window() -> u64 { 50 }
@@ -132,15 +173,115 @@ impl Default for RateLimitConfig {
}
}
/// 支付配置
///
/// 支付宝和微信支付商户配置。所有字段通过环境变量传入(不写入 TOML 文件)。
/// 字段缺失时自动降级为 mock 支付模式。
///
/// 注意:自定义 Debug 和 Serialize 实现会隐藏敏感字段。
#[derive(Clone, Serialize, Deserialize)]
pub struct PaymentConfig {
/// 支付宝 App ID来自支付宝开放平台
#[serde(default)]
pub alipay_app_id: Option<String>,
/// 支付宝商户私钥RSA2— 敏感,不序列化
#[serde(default, skip_serializing)]
pub alipay_private_key: Option<String>,
/// 支付宝公钥证书路径(用于验签)
#[serde(default)]
pub alipay_cert_path: Option<String>,
/// 支付宝回调通知 URL
#[serde(default)]
pub alipay_notify_url: Option<String>,
/// 支付宝公钥用于回调验签PEM 格式)— 敏感,不序列化
#[serde(default, skip_serializing)]
pub alipay_public_key: Option<String>,
/// 微信支付商户号
#[serde(default)]
pub wechat_mch_id: Option<String>,
/// 微信支付商户证书序列号
#[serde(default)]
pub wechat_serial_no: Option<String>,
/// 微信支付商户私钥路径
#[serde(default)]
pub wechat_private_key_path: Option<String>,
/// 微信支付 API v3 密钥 — 敏感,不序列化
#[serde(default, skip_serializing)]
pub wechat_api_v3_key: Option<String>,
/// 微信支付回调通知 URL
#[serde(default)]
pub wechat_notify_url: Option<String>,
/// 微信支付 App ID公众号/小程序)
#[serde(default)]
pub wechat_app_id: Option<String>,
}
impl std::fmt::Debug for PaymentConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PaymentConfig")
.field("alipay_app_id", &self.alipay_app_id)
.field("alipay_private_key", &self.alipay_private_key.as_ref().map(|_| "***REDACTED***"))
.field("alipay_cert_path", &self.alipay_cert_path)
.field("alipay_notify_url", &self.alipay_notify_url)
.field("alipay_public_key", &self.alipay_public_key.as_ref().map(|_| "***REDACTED***"))
.field("wechat_mch_id", &self.wechat_mch_id)
.field("wechat_serial_no", &self.wechat_serial_no)
.field("wechat_private_key_path", &self.wechat_private_key_path)
.field("wechat_api_v3_key", &self.wechat_api_v3_key.as_ref().map(|_| "***REDACTED***"))
.field("wechat_notify_url", &self.wechat_notify_url)
.field("wechat_app_id", &self.wechat_app_id)
.finish()
}
}
impl Default for PaymentConfig {
fn default() -> Self {
// 优先从环境变量读取,未配置则降级 mock
Self {
alipay_app_id: std::env::var("ALIPAY_APP_ID").ok(),
alipay_private_key: std::env::var("ALIPAY_PRIVATE_KEY").ok(),
alipay_cert_path: std::env::var("ALIPAY_CERT_PATH").ok(),
alipay_notify_url: std::env::var("ALIPAY_NOTIFY_URL").ok(),
alipay_public_key: std::env::var("ALIPAY_PUBLIC_KEY").ok(),
wechat_mch_id: std::env::var("WECHAT_PAY_MCH_ID").ok(),
wechat_serial_no: std::env::var("WECHAT_PAY_SERIAL_NO").ok(),
wechat_private_key_path: std::env::var("WECHAT_PAY_PRIVATE_KEY_PATH").ok(),
wechat_api_v3_key: std::env::var("WECHAT_PAY_API_V3_KEY").ok(),
wechat_notify_url: std::env::var("WECHAT_PAY_NOTIFY_URL").ok(),
wechat_app_id: std::env::var("WECHAT_PAY_APP_ID").ok(),
}
}
}
impl PaymentConfig {
/// 支付宝是否已完整配置
pub fn alipay_configured(&self) -> bool {
self.alipay_app_id.is_some()
&& self.alipay_private_key.is_some()
&& self.alipay_notify_url.is_some()
}
/// 微信支付是否已完整配置
pub fn wechat_configured(&self) -> bool {
self.wechat_mch_id.is_some()
&& self.wechat_serial_no.is_some()
&& self.wechat_private_key_path.is_some()
&& self.wechat_notify_url.is_some()
}
}
impl Default for SaaSConfig {
fn default() -> Self {
Self {
config_version: 1,
server: ServerConfig::default(),
database: DatabaseConfig::default(),
auth: AuthConfig::default(),
relay: RelayConfig::default(),
rate_limit: RateLimitConfig::default(),
scheduler: SchedulerConfig::default(),
payment: PaymentConfig::default(),
}
}
}
@@ -158,7 +299,17 @@ impl Default for ServerConfig {
impl Default for DatabaseConfig {
fn default() -> Self {
Self { url: default_db_url() }
Self {
url: default_db_url(),
max_connections: default_max_connections(),
min_connections: default_min_connections(),
acquire_timeout_secs: default_acquire_timeout(),
idle_timeout_secs: default_idle_timeout(),
max_lifetime_secs: default_max_lifetime(),
worker_concurrency: default_worker_concurrency(),
rate_limit_batch_interval_secs: default_rate_limit_batch_interval(),
rate_limit_batch_max_size: default_rate_limit_batch_max(),
}
}
}
@@ -220,6 +371,26 @@ impl SaaSConfig {
SaaSConfig::default()
};
// 配置版本兼容性检查
if config.config_version < CURRENT_CONFIG_VERSION {
tracing::warn!(
"[Config] config_version ({}) is below current version ({}). \
Some features may not work correctly. \
Please update your saas-config.toml. \
See docs for migration guide.",
config.config_version,
CURRENT_CONFIG_VERSION
);
} else if config.config_version > CURRENT_CONFIG_VERSION {
tracing::error!(
"[Config] config_version ({}) is ahead of supported version ({}). \
This server version may not support all configured features. \
Consider upgrading the server.",
config.config_version,
CURRENT_CONFIG_VERSION
);
}
// 环境变量覆盖数据库 URL (避免在配置文件中存储密码)
if let Ok(db_url) = std::env::var("ZCLAW_DATABASE_URL") {
config.database.url = db_url;

View File

@@ -323,6 +323,7 @@ async fn build_router(state: AppState) -> axum::Router {
let public_routes = zclaw_saas::auth::routes()
.route("/api/health", axum::routing::get(health_handler))
.merge(zclaw_saas::billing::callback_routes())
.layer(middleware::from_fn_with_state(
state.clone(),
zclaw_saas::middleware::public_rate_limit_middleware,

View File

@@ -82,6 +82,10 @@ pub async fn create_provider(
let provider = service::create_provider(&state.db, &req, &enc_key).await?;
log_operation(&state.db, &ctx.account_id, "provider.create", "provider", &provider.id,
Some(serde_json::json!({"name": &req.name})), ctx.client_ip.as_deref()).await?;
// Admin mutation 后立即刷新缓存,消除 60s 陈旧窗口
if let Err(e) = state.cache.load_from_db(&state.db).await {
tracing::warn!("Cache reload failed after provider.create: {}", e);
}
Ok((StatusCode::CREATED, Json(provider)))
}
@@ -102,6 +106,9 @@ pub async fn update_provider(
drop(config);
let provider = service::update_provider(&state.db, &id, &req, &enc_key).await?;
log_operation(&state.db, &ctx.account_id, "provider.update", "provider", &id, None, ctx.client_ip.as_deref()).await?;
if let Err(e) = state.cache.load_from_db(&state.db).await{
tracing::warn!("Cache reload failed after provider.update: {}", e);
}
Ok(Json(provider))
}
@@ -114,6 +121,9 @@ pub async fn delete_provider(
check_permission(&ctx, "provider:manage")?;
service::delete_provider(&state.db, &id).await?;
log_operation(&state.db, &ctx.account_id, "provider.delete", "provider", &id, None, ctx.client_ip.as_deref()).await?;
if let Err(e) = state.cache.load_from_db(&state.db).await{
tracing::warn!("Cache reload failed after provider.delete: {}", e);
}
Ok(Json(serde_json::json!({"ok": true})))
}
@@ -150,6 +160,9 @@ pub async fn create_model(
let model = service::create_model(&state.db, &req).await?;
log_operation(&state.db, &ctx.account_id, "model.create", "model", &model.id,
Some(serde_json::json!({"model_id": &req.model_id, "provider_id": &req.provider_id})), ctx.client_ip.as_deref()).await?;
if let Err(e) = state.cache.load_from_db(&state.db).await{
tracing::warn!("Cache reload failed after model.create: {}", e);
}
Ok((StatusCode::CREATED, Json(model)))
}
@@ -163,6 +176,9 @@ pub async fn update_model(
check_permission(&ctx, "model:manage")?;
let model = service::update_model(&state.db, &id, &req).await?;
log_operation(&state.db, &ctx.account_id, "model.update", "model", &id, None, ctx.client_ip.as_deref()).await?;
if let Err(e) = state.cache.load_from_db(&state.db).await{
tracing::warn!("Cache reload failed after model.update: {}", e);
}
Ok(Json(model))
}
@@ -175,6 +191,9 @@ pub async fn delete_model(
check_permission(&ctx, "model:manage")?;
service::delete_model(&state.db, &id).await?;
log_operation(&state.db, &ctx.account_id, "model.delete", "model", &id, None, ctx.client_ip.as_deref()).await?;
if let Err(e) = state.cache.load_from_db(&state.db).await{
tracing::warn!("Cache reload failed after model.delete: {}", e);
}
Ok(Json(serde_json::json!({"ok": true})))
}

View File

@@ -29,3 +29,12 @@ pub struct PromptVersionRow {
pub min_app_version: Option<String>,
pub created_at: String,
}
/// prompt_sync_status 表行
#[derive(Debug, FromRow)]
pub struct PromptSyncStatusRow {
pub device_id: String,
pub template_id: String,
pub synced_version: i32,
pub synced_at: String,
}

View File

@@ -2,6 +2,24 @@
use sqlx::FromRow;
/// telemetry_reports 表行
#[derive(Debug, FromRow)]
pub struct TelemetryReportRow {
pub id: String,
pub account_id: String,
pub device_id: String,
pub app_version: Option<String>,
pub model_id: String,
pub input_tokens: i64,
pub output_tokens: i64,
pub latency_ms: Option<i32>,
pub success: bool,
pub error_type: Option<String>,
pub connection_mode: Option<String>,
pub reported_at: String,
pub created_at: String,
}
/// telemetry 按 model 分组统计
#[derive(Debug, FromRow)]
pub struct TelemetryModelStatsRow {

View File

@@ -4,7 +4,7 @@ use sqlx::PgPool;
use crate::error::{SaasError, SaasResult};
use crate::common::PaginatedResponse;
use crate::common::normalize_pagination;
use crate::models::{PromptTemplateRow, PromptVersionRow};
use crate::models::{PromptTemplateRow, PromptVersionRow, PromptSyncStatusRow};
use super::types::*;
/// 创建提示词模板 + 初始版本
@@ -310,3 +310,21 @@ pub async fn check_updates(
server_time: chrono::Utc::now().to_rfc3339(),
})
}
/// 查询设备的提示词同步状态
pub async fn get_sync_status(
db: &PgPool,
device_id: &str,
) -> SaasResult<Vec<PromptSyncStatusRow>> {
let rows = sqlx::query_as::<_, PromptSyncStatusRow>(
"SELECT device_id, template_id, synced_version, synced_at \
FROM prompt_sync_status \
WHERE device_id = $1 \
ORDER BY synced_at DESC \
LIMIT 50"
)
.bind(device_id)
.fetch_all(db)
.await?;
Ok(rows)
}

View File

@@ -281,6 +281,39 @@ pub async fn delete_provider_key(
Ok(())
}
/// Key 使用窗口统计
#[derive(Debug, Clone)]
pub struct KeyUsageStats {
pub key_id: String,
pub window_minute: String,
pub request_count: i32,
pub token_count: i64,
}
/// 查询指定 Key 的最近使用窗口统计
pub async fn get_key_usage_stats(
db: &PgPool,
key_id: &str,
limit: i64,
) -> SaasResult<Vec<KeyUsageStats>> {
let limit = limit.min(60).max(1);
let rows: Vec<(String, String, i32, i64)> = sqlx::query_as(
"SELECT key_id, window_minute, request_count, token_count \
FROM key_usage_window \
WHERE key_id = $1 \
ORDER BY window_minute DESC \
LIMIT $2"
)
.bind(key_id)
.bind(limit)
.fetch_all(db)
.await?;
Ok(rows.into_iter().map(|(key_id, window_minute, request_count, token_count)| {
KeyUsageStats { key_id, window_minute, request_count, token_count }
}).collect())
}
/// 解析冷却剩余时间(秒)
fn parse_cooldown_remaining(cooldown_until: &str, now: &str) -> i64 {
let cooldown = chrono::DateTime::parse_from_rfc3339(cooldown_until);

View File

@@ -2,11 +2,23 @@
use sqlx::PgPool;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use crate::error::{SaasError, SaasResult};
use crate::models::RelayTaskRow;
use super::types::*;
// ============ StreamBridge 背压常量 ============
/// 上游无数据时,发送 SSE 心跳注释行的间隔
const STREAMBRIDGE_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
/// 上游无数据时,丢弃连接的超时阈值
const STREAMBRIDGE_TIMEOUT: Duration = Duration::from_secs(30);
/// 流结束后延迟清理的时间窗口
const STREAMBRIDGE_CLEANUP_DELAY: Duration = Duration::from_secs(60);
/// 判断 HTTP 状态码是否为可重试的瞬态错误 (5xx + 429)
fn is_retryable_status(status: u16) -> bool {
status == 429 || (500..600).contains(&status)
@@ -33,15 +45,24 @@ pub async fn create_relay_task(
let request_hash = hash_request(request_body);
let max_attempts = max_attempts.max(1).min(5);
sqlx::query(
// INSERT ... RETURNING 合并两次 DB 往返为一次
let row: RelayTaskRow = sqlx::query_as(
"INSERT INTO relay_tasks (id, account_id, provider_id, model_id, request_hash, request_body, status, priority, attempt_count, max_attempts, queued_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 'queued', $7, 0, $8, $9, $9)"
VALUES ($1, $2, $3, $4, $5, $6, 'queued', $7, 0, $8, $9, $9)
RETURNING id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at"
)
.bind(&id).bind(account_id).bind(provider_id).bind(model_id)
.bind(&request_hash).bind(request_body).bind(priority).bind(max_attempts as i64).bind(&now)
.execute(db).await?;
.fetch_one(db)
.await?;
get_relay_task(db, &id).await
Ok(RelayTaskInfo {
id: row.id, account_id: row.account_id, provider_id: row.provider_id, model_id: row.model_id,
status: row.status, priority: row.priority, attempt_count: row.attempt_count,
max_attempts: row.max_attempts, input_tokens: row.input_tokens, output_tokens: row.output_tokens,
error_message: row.error_message, queued_at: row.queued_at, started_at: row.started_at,
completed_at: row.completed_at, created_at: row.created_at,
})
}
pub async fn get_relay_task(db: &PgPool, task_id: &str) -> SaasResult<RelayTaskInfo> {
@@ -295,9 +316,9 @@ pub async fn execute_relay(
}
});
// Convert mpsc::Receiver into a Body stream
let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
let body = axum::body::Body::from_stream(body_stream);
// Build StreamBridge: wraps the bounded receiver with heartbeat,
// timeout, and delayed cleanup (DeerFlow-inspired backpressure).
let body = build_stream_bridge(rx, task_id.to_string());
// SSE 流结束后异步记录 usage + Key 使用量
// 使用全局 Arc<Semaphore> 限制并发 spawned tasks防止高并发时耗尽连接池
@@ -335,6 +356,14 @@ pub async fn execute_relay(
if tokio::time::timeout(std::time::Duration::from_secs(5), db_op).await.is_err() {
tracing::warn!("SSE usage recording timed out for task {}", task_id_clone);
}
// StreamBridge 延迟清理:流结束 60s 后释放残留资源
// (主要是 Arc<SseUsageCapture> 等,通过 drop(_permit) 归还信号量)
tokio::time::sleep(STREAMBRIDGE_CLEANUP_DELAY).await;
tracing::debug!(
"[StreamBridge] Cleanup delay elapsed for task {}",
task_id_clone
);
});
return Ok(RelayResponse::Sse(body));
@@ -346,7 +375,9 @@ pub async fn execute_relay(
// 记录 Key 使用量
let _ = super::key_pool::record_key_usage(
db, &key_id, Some(input_tokens + output_tokens),
).await;
).await.map_err(|e| {
tracing::warn!("[Relay] Failed to record key usage for billing: {}", e);
});
return Ok(RelayResponse::Json(body));
}
}
@@ -423,6 +454,98 @@ pub enum RelayResponse {
Sse(axum::body::Body),
}
// ============ StreamBridge ============
/// 构建 StreamBridge将 mpsc::Receiver 包装为带心跳、超时的 axum Body。
///
/// 借鉴 DeerFlow StreamBridge 背压机制:
/// - 15s 心跳:上游长时间无输出时,发送 SSE 注释行 `: heartbeat\n\n` 保持连接活跃
/// - 30s 超时:上游连续 30s 无真实数据时,发送超时事件并关闭流
/// - 60s 延迟清理:由调用方的 spawned task 在流结束后延迟释放资源
fn build_stream_bridge(
mut rx: tokio::sync::mpsc::Receiver<Result<bytes::Bytes, std::io::Error>>,
task_id: String,
) -> axum::body::Body {
// SSE heartbeat comment bytes: `: heartbeat\n\n`
// SSE spec: lines starting with `:` are comments and ignored by clients
const HEARTBEAT_BYTES: &[u8] = b": heartbeat\n\n";
// SSE timeout error event
const TIMEOUT_EVENT: &[u8] = b"data: {\"error\":\"stream_timeout\",\"message\":\"upstream timed out\"}\n\n";
let stream = async_stream::stream! {
// Track how many consecutive heartbeat-only cycles have elapsed.
// Real data resets this counter; after 2 heartbeats (30s) without
// real data, we terminate the stream.
let mut idle_heartbeats: u32 = 0;
loop {
// tokio::select! races the next data chunk against a heartbeat timer.
// The timer resets on every iteration, ensuring heartbeats only fire
// during genuine idle periods.
tokio::select! {
biased; // prioritize data over heartbeat
chunk = rx.recv() => {
match chunk {
Some(Ok(data)) => {
// Real data received — reset idle counter
idle_heartbeats = 0;
yield Ok::<bytes::Bytes, std::io::Error>(data);
}
Some(Err(e)) => {
tracing::warn!(
"[StreamBridge] Upstream error for task {}: {}",
task_id, e
);
yield Err(e);
break;
}
None => {
// Channel closed = upstream finished normally
tracing::debug!(
"[StreamBridge] Upstream completed for task {}",
task_id
);
break;
}
}
}
// Heartbeat: send SSE comment if no data for 15s
_ = tokio::time::sleep(STREAMBRIDGE_HEARTBEAT_INTERVAL) => {
idle_heartbeats += 1;
tracing::trace!(
"[StreamBridge] Heartbeat #{} for task {} (idle {}s)",
idle_heartbeats,
task_id,
idle_heartbeats as u64 * STREAMBRIDGE_HEARTBEAT_INTERVAL.as_secs(),
);
// After 2 consecutive heartbeats without real data (30s),
// terminate the stream to prevent connection leaks.
if idle_heartbeats >= 2 {
tracing::warn!(
"[StreamBridge] Timeout ({:?}) no real data, closing stream for task {}",
STREAMBRIDGE_TIMEOUT,
task_id,
);
yield Ok(bytes::Bytes::from_static(TIMEOUT_EVENT));
break;
}
yield Ok(bytes::Bytes::from_static(HEARTBEAT_BYTES));
}
}
}
};
// Pin the stream to a Box<dyn Stream + Send> to satisfy Body::from_stream
let boxed: std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>> =
Box::pin(stream);
axum::body::Body::from_stream(boxed)
}
// ============ Helpers ============
fn hash_request(body: &str) -> String {

View File

@@ -20,7 +20,9 @@ struct ScheduledTaskRow {
last_run_at: Option<String>,
next_run_at: Option<String>,
run_count: i32,
last_result: Option<String>,
last_error: Option<String>,
last_duration_ms: Option<i64>,
input_payload: Option<serde_json::Value>,
created_at: String,
}
@@ -41,7 +43,9 @@ impl ScheduledTaskRow {
last_run: self.last_run_at.clone(),
next_run: self.next_run_at.clone(),
run_count: self.run_count,
last_result: self.last_result.clone(),
last_error: self.last_error.clone(),
last_duration_ms: self.last_duration_ms,
created_at: self.created_at.clone(),
}
}
@@ -86,7 +90,9 @@ pub async fn create_task(
last_run: None,
next_run: None,
run_count: 0,
last_result: None,
last_error: None,
last_duration_ms: None,
created_at: now,
})
}
@@ -99,7 +105,7 @@ pub async fn list_tasks(
let rows: Vec<ScheduledTaskRow> = sqlx::query_as(
"SELECT id, account_id, name, description, schedule, schedule_type,
target_type, target_id, enabled, last_run_at, next_run_at,
run_count, last_error, input_payload, created_at
run_count, last_result, last_error, last_duration_ms, input_payload, created_at
FROM scheduled_tasks WHERE account_id = $1 ORDER BY created_at DESC"
)
.bind(account_id)
@@ -118,7 +124,7 @@ pub async fn get_task(
let row: Option<ScheduledTaskRow> = sqlx::query_as(
"SELECT id, account_id, name, description, schedule, schedule_type,
target_type, target_id, enabled, last_run_at, next_run_at,
run_count, last_error, input_payload, created_at
run_count, last_result, last_error, last_duration_ms, input_payload, created_at
FROM scheduled_tasks WHERE id = $1 AND account_id = $2"
)
.bind(task_id)

View File

@@ -58,6 +58,8 @@ pub struct ScheduledTaskResponse {
pub last_run: Option<String>,
pub next_run: Option<String>,
pub run_count: i32,
pub last_result: Option<String>,
pub last_error: Option<String>,
pub last_duration_ms: Option<i64>,
pub created_at: String,
}

View File

@@ -3,11 +3,18 @@
//! 通过 TOML 配置定时任务,无需改代码调整调度时间。
//! 配置格式在 config.rs 的 SchedulerConfig / JobConfig 中定义。
use std::time::Duration;
use std::time::{Duration, Instant};
use sqlx::PgPool;
use crate::config::SchedulerConfig;
use crate::workers::WorkerDispatcher;
/// 单次任务执行的产出
struct TaskExecution {
result: Option<String>,
error: Option<String>,
duration_ms: i64,
}
/// 解析时间间隔字符串为 Duration
pub fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim().to_lowercase();
@@ -143,23 +150,42 @@ pub fn start_user_task_scheduler(db: PgPool) {
});
}
/// 执行单个调度任务
/// 执行单个调度任务,返回执行产出(结果/错误/耗时)
async fn execute_scheduled_task(
db: &PgPool,
task_id: &str,
target_type: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let task_info: Option<(String, Option<String>)> = sqlx::query_as(
) -> TaskExecution {
let start = Instant::now();
let task_info: Option<(String, Option<String>)> = match sqlx::query_as(
"SELECT name, config_json FROM scheduled_tasks WHERE id = $1"
)
.bind(task_id)
.fetch_optional(db)
.await
.map_err(|e| format!("Failed to fetch task {}: {}", task_id, e))?;
{
Ok(info) => info,
Err(e) => {
let elapsed = start.elapsed().as_millis() as i64;
return TaskExecution {
result: None,
error: Some(format!("Failed to fetch task {}: {}", task_id, e)),
duration_ms: elapsed,
};
}
};
let (task_name, _config_json) = match task_info {
Some(info) => info,
None => return Err(format!("Task {} not found", task_id).into()),
None => {
let elapsed = start.elapsed().as_millis() as i64;
return TaskExecution {
result: None,
error: Some(format!("Task {} not found", task_id)),
duration_ms: elapsed,
};
}
};
tracing::info!(
@@ -167,22 +193,39 @@ async fn execute_scheduled_task(
task_name, target_type
);
match target_type {
let exec_result = match target_type {
t if t == "agent" => {
tracing::info!("[UserScheduler] Agent task '{}' queued for execution", task_name);
Ok("agent_dispatched".to_string())
}
t if t == "hand" => {
tracing::info!("[UserScheduler] Hand task '{}' queued for execution", task_name);
Ok("hand_dispatched".to_string())
}
t if t == "workflow" => {
tracing::info!("[UserScheduler] Workflow task '{}' queued for execution", task_name);
Ok("workflow_dispatched".to_string())
}
other => {
tracing::warn!("[UserScheduler] Unknown target_type '{}' for task '{}'", other, task_name);
Err(format!("Unknown target_type: {}", other))
}
}
};
Ok(())
let elapsed = start.elapsed().as_millis() as i64;
match exec_result {
Ok(msg) => TaskExecution {
result: Some(msg),
error: None,
duration_ms: elapsed,
},
Err(err) => TaskExecution {
result: None,
error: Some(err),
duration_ms: elapsed,
},
}
}
async fn tick_user_tasks(db: &PgPool) -> Result<(), sqlx::Error> {
@@ -206,17 +249,19 @@ async fn tick_user_tasks(db: &PgPool) -> Result<(), sqlx::Error> {
task_id, target_type, schedule_type
);
// 执行任务
match execute_scheduled_task(db, &task_id, &target_type).await {
Ok(()) => {
tracing::info!("[UserScheduler] task {} executed successfully", task_id);
}
Err(e) => {
tracing::error!("[UserScheduler] task {} execution failed: {}", task_id, e);
}
// 执行任务并收集产出
let exec = execute_scheduled_task(db, &task_id, &target_type).await;
if let Some(ref err) = exec.error {
tracing::error!("[UserScheduler] task {} execution failed: {}", task_id, err);
} else {
tracing::info!(
"[UserScheduler] task {} executed successfully ({}ms)",
task_id, exec.duration_ms
);
}
// 更新任务状态
// 更新任务状态(含执行产出)
let result = sqlx::query(
"UPDATE scheduled_tasks
SET last_run_at = NOW(),
@@ -228,10 +273,16 @@ async fn tick_user_tasks(db: &PgPool) -> Result<(), sqlx::Error> {
WHEN schedule_type = 'interval' AND interval_seconds IS NOT NULL
THEN NOW() + (interval_seconds || ' seconds')::INTERVAL
ELSE NULL
END
END,
last_result = $2,
last_error = $3,
last_duration_ms = $4
WHERE id = $1"
)
.bind(&task_id)
.bind(&exec.result)
.bind(&exec.error)
.bind(exec.duration_ms)
.execute(db)
.await;

View File

@@ -10,6 +10,44 @@ use crate::config::SaaSConfig;
use crate::workers::WorkerDispatcher;
use crate::cache::AppCache;
// ============ SpawnLimiter ============
/// 可复用的并发限制器,基于 Arc<Semaphore>。
/// 复用 SSE_SPAWN_SEMAPHORE 模式,为 Worker、中间件等场景提供统一门控。
#[derive(Clone)]
pub struct SpawnLimiter {
semaphore: Arc<tokio::sync::Semaphore>,
name: &'static str,
}
impl SpawnLimiter {
pub fn new(name: &'static str, max_permits: usize) -> Self {
Self {
semaphore: Arc::new(tokio::sync::Semaphore::new(max_permits)),
name,
}
}
/// 尝试获取 permit满时返回 None适用于可丢弃的操作如 usage 记录)
pub fn try_acquire(&self) -> Option<tokio::sync::OwnedSemaphorePermit> {
self.semaphore.clone().try_acquire_owned().ok()
}
/// 异步等待 permit适用于不可丢弃的操作如 Worker 任务)
pub async fn acquire(&self) -> tokio::sync::OwnedSemaphorePermit {
self.semaphore
.clone()
.acquire_owned()
.await
.expect("SpawnLimiter semaphore closed unexpectedly")
}
pub fn name(&self) -> &'static str { self.name }
pub fn available(&self) -> usize { self.semaphore.available_permits() }
}
// ============ AppState ============
/// 全局应用状态,通过 Axum State 共享
#[derive(Clone)]
pub struct AppState {
@@ -33,10 +71,20 @@ pub struct AppState {
pub shutdown_token: CancellationToken,
/// 应用缓存: Model/Provider/队列计数器
pub cache: AppCache,
/// Worker spawn 并发限制器
pub worker_limiter: SpawnLimiter,
/// 限流事件批量累加器: key → 待写入计数
pub rate_limit_batch: Arc<dashmap::DashMap<String, i64>>,
}
impl AppState {
pub fn new(db: PgPool, config: SaaSConfig, worker_dispatcher: WorkerDispatcher, shutdown_token: CancellationToken) -> anyhow::Result<Self> {
pub fn new(
db: PgPool,
config: SaaSConfig,
worker_dispatcher: WorkerDispatcher,
shutdown_token: CancellationToken,
worker_limiter: SpawnLimiter,
) -> anyhow::Result<Self> {
let jwt_secret = config.jwt_secret()?;
let rpm = config.rate_limit.requests_per_minute;
Ok(Self {
@@ -50,6 +98,8 @@ impl AppState {
worker_dispatcher,
shutdown_token,
cache: AppCache::new(),
worker_limiter,
rate_limit_batch: Arc::new(dashmap::DashMap::new()),
})
}
@@ -96,4 +146,60 @@ impl AppState {
tracing::warn!("Failed to dispatch log_operation: {}", e);
}
}
/// 限流事件批量 flush 到 DB
///
/// 使用 swap-to-zero 模式先将计数器原子归零DB 写入成功后删除条目。
/// 如果 DB 写入失败,归零的计数会在下次 flush 时重新累加(因 middleware 持续写入)。
pub async fn flush_rate_limit_batch(&self, max_batch: usize) {
// 阶段1: 收集非零 key将计数器原子归零而非删除
// 这样如果 DB 写入失败middleware 的新累加会在已有 key 上继续
let mut batch: Vec<(String, i64)> = Vec::with_capacity(max_batch.min(64));
let keys: Vec<String> = self.rate_limit_batch.iter()
.filter(|e| *e.value() > 0)
.take(max_batch)
.map(|e| e.key().clone())
.collect();
for key in &keys {
// 原子交换为 0取走当前值
if let Some(mut entry) = self.rate_limit_batch.get_mut(key) {
if *entry > 0 {
batch.push((key.clone(), *entry));
*entry = 0; // 归零而非删除
}
}
}
if batch.is_empty() { return; }
let keys_buf: Vec<String> = batch.iter().map(|(k, _)| k.clone()).collect();
let counts: Vec<i64> = batch.iter().map(|(_, c)| *c).collect();
let result = sqlx::query(
"INSERT INTO rate_limit_events (key, window_start, count)
SELECT u.key, NOW(), u.cnt FROM UNNEST($1::text[], $2::bigint[]) AS u(key, cnt)"
)
.bind(&keys_buf)
.bind(&counts)
.execute(&self.db)
.await;
if let Err(e) = result {
// DB 写入失败:将归零的计数加回去,避免数据丢失
tracing::warn!("[RateLimitBatch] flush failed ({} entries), restoring counts: {}", batch.len(), e);
for (key, count) in &batch {
if let Some(mut entry) = self.rate_limit_batch.get_mut(key) {
*entry += *count;
}
}
} else {
// DB 写入成功:删除已归零的条目
for (key, _) in &batch {
self.rate_limit_batch.remove_if(key, |_, v| *v == 0);
}
tracing::debug!("[RateLimitBatch] flushed {} entries", batch.len());
}
}
}

View File

@@ -2,7 +2,7 @@
use sqlx::PgPool;
use crate::error::SaasResult;
use crate::models::{TelemetryModelStatsRow, TelemetryDailyStatsRow};
use crate::models::{TelemetryModelStatsRow, TelemetryDailyStatsRow, TelemetryReportRow};
use super::types::*;
const CHUNK_SIZE: usize = 100;
@@ -270,3 +270,27 @@ pub async fn get_daily_stats(
Ok(stats)
}
/// 查询账号最近的遥测报告
pub async fn get_recent_reports(
db: &PgPool,
account_id: &str,
limit: i64,
) -> SaasResult<Vec<TelemetryReportRow>> {
let limit = limit.min(100).max(1);
let rows = sqlx::query_as::<_, TelemetryReportRow>(
"SELECT id, account_id, device_id, app_version, model_id, \
input_tokens, output_tokens, latency_ms, success, \
error_type, connection_mode, \
reported_at::text, created_at::text \
FROM telemetry_reports \
WHERE account_id = $1 \
ORDER BY reported_at DESC \
LIMIT $2"
)
.bind(account_id)
.bind(limit)
.fetch_all(db)
.await?;
Ok(rows)
}

View File

@@ -44,13 +44,7 @@ impl Worker for GenerateEmbeddingWorker {
}
};
// 2. 删除旧分块full refresh on each update
sqlx::query("DELETE FROM knowledge_chunks WHERE item_id = $1")
.bind(&args.item_id)
.execute(db)
.await?;
// 3. 分块
// 2. 分块
let chunks = crate::knowledge::service::chunk_content(&content, 512, 64);
if chunks.is_empty() {
@@ -58,13 +52,32 @@ impl Worker for GenerateEmbeddingWorker {
return Ok(());
}
// 4. 写入分块(带关键词继承
// 3. 在事务中删除旧分块 + 插入新分块(防止并发竞争条件
let mut tx = db.begin().await?;
// 锁定条目行防止并发 worker 同时处理同一条目
let locked: Option<(String,)> = sqlx::query_as(
"SELECT id FROM knowledge_items WHERE id = $1 FOR UPDATE"
)
.bind(&args.item_id)
.fetch_optional(&mut *tx)
.await?;
if locked.is_none() {
tx.rollback().await?;
tracing::warn!("GenerateEmbedding: item {} was deleted during processing", args.item_id);
return Ok(());
}
sqlx::query("DELETE FROM knowledge_chunks WHERE item_id = $1")
.bind(&args.item_id)
.execute(&mut *tx)
.await?;
for (idx, chunk) in chunks.iter().enumerate() {
let chunk_id = uuid::Uuid::new_v4().to_string();
// 为每个 chunk 提取额外关键词(简单策略:标题 + 继承关键词)
let mut chunk_keywords = keywords.clone();
// 从 chunk 内容提取高频词作为补充关键词
extract_chunk_keywords(chunk, &mut chunk_keywords);
sqlx::query(
@@ -76,10 +89,12 @@ impl Worker for GenerateEmbeddingWorker {
.bind(idx as i32)
.bind(chunk)
.bind(&chunk_keywords)
.execute(db)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
tracing::info!(
"GenerateEmbedding: item '{}' → {} chunks (keywords: {})",
title,

View File

@@ -8,7 +8,8 @@ use super::Worker;
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateLastUsedArgs {
pub token_id: String,
/// token_hash 用于 WHERE 条件匹配
pub token_hash: String,
}
pub struct UpdateLastUsedWorker;
@@ -23,9 +24,9 @@ impl Worker for UpdateLastUsedWorker {
async fn perform(&self, db: &PgPool, args: Self::Args) -> SaasResult<()> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE api_tokens SET last_used_at = $1 WHERE id = $2")
sqlx::query("UPDATE api_tokens SET last_used_at = $1 WHERE token_hash = $2")
.bind(&now)
.bind(&args.token_id)
.bind(&args.token_hash)
.execute(db)
.await?;
Ok(())

View File

@@ -0,0 +1,223 @@
//! Classroom multi-agent chat commands
//!
//! - `classroom_chat` — send a message and receive multi-agent responses
//! - `classroom_chat_history` — retrieve chat history for a classroom
use std::sync::Arc;
use tokio::sync::Mutex;
use serde::{Deserialize, Serialize};
use tauri::State;
use zclaw_kernel::generation::{
AgentProfile, AgentRole,
ClassroomChatMessage, ClassroomChatState,
ClassroomChatRequest,
build_chat_prompt, parse_chat_responses,
};
use zclaw_runtime::CompletionRequest;
use super::ClassroomStore;
use crate::kernel_commands::KernelState;
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// Chat state store: classroom_id → chat state
pub type ChatStore = Arc<Mutex<std::collections::HashMap<String, ClassroomChatState>>>;
pub fn create_chat_state() -> ChatStore {
Arc::new(Mutex::new(std::collections::HashMap::new()))
}
// ---------------------------------------------------------------------------
// Request / Response
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomChatCmdRequest {
pub classroom_id: String,
pub user_message: String,
pub scene_context: Option<String>,
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
/// Send a message in the classroom chat and get multi-agent responses.
#[tauri::command]
pub async fn classroom_chat(
store: State<'_, ClassroomStore>,
chat_store: State<'_, ChatStore>,
kernel_state: State<'_, KernelState>,
request: ClassroomChatCmdRequest,
) -> Result<Vec<ClassroomChatMessage>, String> {
if request.user_message.trim().is_empty() {
return Err("Message cannot be empty".to_string());
}
// Get classroom data
let classroom = {
let s = store.lock().await;
s.get(&request.classroom_id)
.cloned()
.ok_or_else(|| format!("Classroom '{}' not found", request.classroom_id))?
};
// Create user message
let user_msg = ClassroomChatMessage::user_message(&request.user_message);
// Get chat history for context
let history: Vec<ClassroomChatMessage> = {
let cs = chat_store.lock().await;
cs.get(&request.classroom_id)
.map(|s| s.messages.clone())
.unwrap_or_default()
};
// Try LLM-powered multi-agent responses, fallback to placeholder
let agent_responses = match generate_llm_responses(&kernel_state, &classroom.agents, &request.user_message, request.scene_context.as_deref(), &history).await {
Ok(responses) => responses,
Err(e) => {
tracing::warn!("LLM chat generation failed, using placeholders: {}", e);
generate_placeholder_responses(
&classroom.agents,
&request.user_message,
request.scene_context.as_deref(),
)
}
};
// Store in chat state
{
let mut cs = chat_store.lock().await;
let state = cs.entry(request.classroom_id.clone())
.or_insert_with(|| ClassroomChatState {
messages: vec![],
active: true,
});
state.messages.push(user_msg);
state.messages.extend(agent_responses.clone());
}
Ok(agent_responses)
}
/// Retrieve chat history for a classroom
#[tauri::command]
pub async fn classroom_chat_history(
chat_store: State<'_, ChatStore>,
classroom_id: String,
) -> Result<Vec<ClassroomChatMessage>, String> {
let cs = chat_store.lock().await;
Ok(cs.get(&classroom_id)
.map(|s| s.messages.clone())
.unwrap_or_default())
}
// ---------------------------------------------------------------------------
// Placeholder response generation
// ---------------------------------------------------------------------------
fn generate_placeholder_responses(
agents: &[AgentProfile],
user_message: &str,
scene_context: Option<&str>,
) -> Vec<ClassroomChatMessage> {
let mut responses = Vec::new();
// Teacher always responds
if let Some(teacher) = agents.iter().find(|a| a.role == AgentRole::Teacher) {
let context_hint = scene_context
.map(|ctx| format!("关于「{}」,", ctx))
.unwrap_or_default();
responses.push(ClassroomChatMessage::agent_message(
teacher,
&format!("{}这是一个很好的问题!让我来详细解释一下「{}」的核心概念...", context_hint, user_message),
));
}
// Assistant chimes in
if let Some(assistant) = agents.iter().find(|a| a.role == AgentRole::Assistant) {
responses.push(ClassroomChatMessage::agent_message(
assistant,
"我来补充一下要点 📌",
));
}
// One student responds
if let Some(student) = agents.iter().find(|a| a.role == AgentRole::Student) {
responses.push(ClassroomChatMessage::agent_message(
student,
&format!("谢谢老师!我大概理解了{}", user_message),
));
}
responses
}
// ---------------------------------------------------------------------------
// LLM-powered response generation
// ---------------------------------------------------------------------------
async fn generate_llm_responses(
kernel_state: &State<'_, KernelState>,
agents: &[AgentProfile],
user_message: &str,
scene_context: Option<&str>,
history: &[ClassroomChatMessage],
) -> Result<Vec<ClassroomChatMessage>, String> {
let driver = {
let ks = kernel_state.lock().await;
ks.as_ref()
.map(|k| k.driver())
.ok_or_else(|| "Kernel not initialized".to_string())?
};
if !driver.is_configured() {
return Err("LLM driver not configured".to_string());
}
// Build the chat request for prompt generation (include history)
let chat_request = ClassroomChatRequest {
classroom_id: String::new(),
user_message: user_message.to_string(),
agents: agents.to_vec(),
scene_context: scene_context.map(|s| s.to_string()),
history: history.to_vec(),
};
let prompt = build_chat_prompt(&chat_request);
let request = CompletionRequest {
model: "default".to_string(),
system: Some("你是一个课堂多智能体讨论的协调器。".to_string()),
messages: vec![zclaw_types::Message::User {
content: prompt,
}],
..Default::default()
};
let response = driver.complete(request).await
.map_err(|e| format!("LLM call failed: {}", e))?;
// Extract text from response
let text = response.content.iter()
.filter_map(|block| match block {
zclaw_runtime::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
let responses = parse_chat_responses(&text, agents);
if responses.is_empty() {
return Err("LLM returned no parseable agent responses".to_string());
}
Ok(responses)
}

View File

@@ -0,0 +1,152 @@
//! Classroom export commands
//!
//! - `classroom_export` — export classroom as HTML, Markdown, or JSON
use serde::{Deserialize, Serialize};
use tauri::State;
use zclaw_kernel::generation::Classroom;
use super::ClassroomStore;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomExportRequest {
pub classroom_id: String,
pub format: String, // "html" | "markdown" | "json"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomExportResponse {
pub content: String,
pub filename: String,
pub mime_type: String,
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn classroom_export(
store: State<'_, ClassroomStore>,
request: ClassroomExportRequest,
) -> Result<ClassroomExportResponse, String> {
let classroom = {
let s = store.lock().await;
s.get(&request.classroom_id)
.cloned()
.ok_or_else(|| format!("Classroom '{}' not found", request.classroom_id))?
};
match request.format.as_str() {
"json" => export_json(&classroom),
"html" => export_html(&classroom),
"markdown" | "md" => export_markdown(&classroom),
_ => Err(format!("Unsupported export format: '{}'. Use html, markdown, or json.", request.format)),
}
}
// ---------------------------------------------------------------------------
// Exporters
// ---------------------------------------------------------------------------
fn export_json(classroom: &Classroom) -> Result<ClassroomExportResponse, String> {
let content = serde_json::to_string_pretty(classroom)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
Ok(ClassroomExportResponse {
filename: format!("{}.json", sanitize_filename(&classroom.title)),
content,
mime_type: "application/json".to_string(),
})
}
fn export_html(classroom: &Classroom) -> Result<ClassroomExportResponse, String> {
let mut html = String::from(r#"<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">"#);
html.push_str(&format!("<title>{}</title>", html_escape(&classroom.title)));
html.push_str(r#"<style>body{font-family:system-ui,sans-serif;max-width:800px;margin:0 auto;padding:2rem;color:#333}h1{color:#4F46E5}h2{color:#7C3AED;border-bottom:2px solid #E5E7EB;padding-bottom:0.5rem}.scene{margin:2rem 0;padding:1rem;border-left:4px solid #4F46E5;background:#F9FAFB}.quiz{border-left-color:#F59E0B}.discussion{border-left-color:#10B981}.agent{display:inline-flex;align-items:center;gap:0.5rem;margin:0.25rem;padding:0.25rem 0.75rem;border-radius:9999px;font-size:0.875rem;font-weight:500}</style></head><body>"#);
html.push_str(&format!("<h1>{}</h1>", html_escape(&classroom.title)));
html.push_str(&format!("<p>{}</p>", html_escape(&classroom.description)));
// Agents
html.push_str("<h2>课堂角色</h2><div>");
for agent in &classroom.agents {
html.push_str(&format!(
r#"<span class="agent" style="background:{};color:white">{} {}</span>"#,
agent.color, agent.avatar, html_escape(&agent.name)
));
}
html.push_str("</div>");
// Scenes
html.push_str("<h2>课程内容</h2>");
for scene in &classroom.scenes {
let type_class = match scene.content.scene_type {
zclaw_kernel::generation::SceneType::Quiz => "quiz",
zclaw_kernel::generation::SceneType::Discussion => "discussion",
_ => "",
};
html.push_str(&format!(
r#"<div class="scene {}"><h3>{}</h3><p>类型: {:?} | 时长: {}秒</p></div>"#,
type_class,
html_escape(&scene.content.title),
scene.content.scene_type,
scene.content.duration_seconds
));
}
html.push_str("</body></html>");
Ok(ClassroomExportResponse {
filename: format!("{}.html", sanitize_filename(&classroom.title)),
content: html,
mime_type: "text/html".to_string(),
})
}
fn export_markdown(classroom: &Classroom) -> Result<ClassroomExportResponse, String> {
let mut md = String::new();
md.push_str(&format!("# {}\n\n", &classroom.title));
md.push_str(&format!("{}\n\n", &classroom.description));
md.push_str("## 课堂角色\n\n");
for agent in &classroom.agents {
md.push_str(&format!("- {} **{}** ({:?})\n", agent.avatar, agent.name, agent.role));
}
md.push('\n');
md.push_str("## 课程内容\n\n");
for (i, scene) in classroom.scenes.iter().enumerate() {
md.push_str(&format!("### {}. {}\n\n", i + 1, scene.content.title));
md.push_str(&format!("- 类型: `{:?}`\n", scene.content.scene_type));
md.push_str(&format!("- 时长: {}\n\n", scene.content.duration_seconds));
}
Ok(ClassroomExportResponse {
filename: format!("{}.md", sanitize_filename(&classroom.title)),
content: md,
mime_type: "text/markdown".to_string(),
})
}
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect::<String>()
.trim_matches('_')
.to_string()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}

View File

@@ -0,0 +1,286 @@
//! Classroom generation commands
//!
//! - `classroom_generate` — start 4-stage pipeline, emit progress events
//! - `classroom_generation_progress` — query current progress
//! - `classroom_cancel_generation` — cancel active generation
//! - `classroom_get` — retrieve generated classroom data
//! - `classroom_list` — list all generated classrooms
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use zclaw_kernel::generation::{
Classroom, GenerationPipeline, GenerationRequest as KernelGenRequest, GenerationStage,
TeachingStyle, DifficultyLevel,
};
use super::{ClassroomStore, GenerationTasks};
use crate::kernel_commands::KernelState;
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomGenerateRequest {
pub topic: String,
pub document: Option<String>,
pub style: Option<String>,
pub level: Option<String>,
pub target_duration_minutes: Option<u32>,
pub scene_count: Option<usize>,
pub custom_instructions: Option<String>,
pub language: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomGenerateResponse {
pub classroom_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomProgressResponse {
pub stage: String,
pub progress: u8,
pub activity: String,
pub items_progress: Option<(usize, usize)>,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn parse_style(s: Option<&str>) -> TeachingStyle {
match s.unwrap_or("lecture") {
"discussion" => TeachingStyle::Discussion,
"pbl" => TeachingStyle::Pbl,
"flipped" => TeachingStyle::Flipped,
"socratic" => TeachingStyle::Socratic,
_ => TeachingStyle::Lecture,
}
}
fn parse_level(l: Option<&str>) -> DifficultyLevel {
match l.unwrap_or("intermediate") {
"beginner" => DifficultyLevel::Beginner,
"advanced" => DifficultyLevel::Advanced,
"expert" => DifficultyLevel::Expert,
_ => DifficultyLevel::Intermediate,
}
}
fn stage_name(stage: &GenerationStage) -> &'static str {
match stage {
GenerationStage::AgentProfiles => "agent_profiles",
GenerationStage::Outline => "outline",
GenerationStage::Scene => "scene",
GenerationStage::Complete => "complete",
}
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
/// Start classroom generation (4-stage pipeline).
/// Progress events are emitted via `classroom:progress`.
/// Supports cancellation between stages by removing the task from GenerationTasks.
#[tauri::command]
pub async fn classroom_generate(
app: AppHandle,
store: State<'_, ClassroomStore>,
tasks: State<'_, GenerationTasks>,
kernel_state: State<'_, KernelState>,
request: ClassroomGenerateRequest,
) -> Result<ClassroomGenerateResponse, String> {
if request.topic.trim().is_empty() {
return Err("Topic is required".to_string());
}
let topic_clone = request.topic.clone();
let kernel_request = KernelGenRequest {
topic: request.topic.clone(),
document: request.document.clone(),
style: parse_style(request.style.as_deref()),
level: parse_level(request.level.as_deref()),
target_duration_minutes: request.target_duration_minutes.unwrap_or(30),
scene_count: request.scene_count,
custom_instructions: request.custom_instructions.clone(),
language: request.language.clone().or_else(|| Some("zh-CN".to_string())),
};
// Register generation task so cancellation can check it
{
use zclaw_kernel::generation::GenerationProgress;
let mut t = tasks.lock().await;
t.insert(topic_clone.clone(), GenerationProgress {
stage: zclaw_kernel::generation::GenerationStage::AgentProfiles,
progress: 0,
activity: "Starting generation...".to_string(),
items_progress: None,
eta_seconds: None,
});
}
// Get LLM driver from kernel if available, otherwise use placeholder mode
let pipeline = {
let ks = kernel_state.lock().await;
if let Some(kernel) = ks.as_ref() {
GenerationPipeline::with_driver(kernel.driver())
} else {
GenerationPipeline::new()
}
};
// Helper: check if cancelled
let is_cancelled = || {
let t = tasks.blocking_lock();
!t.contains_key(&topic_clone)
};
// Helper: emit progress event
let emit_progress = |stage: &str, progress: u8, activity: &str| {
let _ = app.emit("classroom:progress", serde_json::json!({
"topic": &topic_clone,
"stage": stage,
"progress": progress,
"activity": activity
}));
};
// ── Stage 0: Agent Profiles ──
emit_progress("agent_profiles", 5, "生成课堂角色...");
let agents = pipeline.generate_agent_profiles(&kernel_request).await;
emit_progress("agent_profiles", 25, "角色生成完成");
if is_cancelled() {
return Err("Generation cancelled".to_string());
}
// ── Stage 1: Outline ──
emit_progress("outline", 30, "分析主题,生成大纲...");
let outline = pipeline.generate_outline(&kernel_request).await
.map_err(|e| format!("Outline generation failed: {}", e))?;
emit_progress("outline", 50, &format!("大纲完成:{} 个场景", outline.len()));
if is_cancelled() {
return Err("Generation cancelled".to_string());
}
// ── Stage 2: Scenes (parallel) ──
emit_progress("scene", 55, &format!("并行生成 {} 个场景...", outline.len()));
let scenes = pipeline.generate_scenes(&outline).await
.map_err(|e| format!("Scene generation failed: {}", e))?;
if is_cancelled() {
return Err("Generation cancelled".to_string());
}
// ── Stage 3: Assemble ──
emit_progress("complete", 90, "组装课堂...");
// Build classroom directly (pipeline.build_classroom is private)
let total_duration: u32 = scenes.iter().map(|s| s.content.duration_seconds).sum();
let objectives = outline.iter()
.take(3)
.map(|item| format!("理解: {}", item.title))
.collect::<Vec<_>>();
let classroom_id = uuid::Uuid::new_v4().to_string();
let classroom = Classroom {
id: classroom_id.clone(),
title: format!("课堂: {}", kernel_request.topic),
description: format!("{:?} 风格课堂 — {}", kernel_request.style, kernel_request.topic),
topic: kernel_request.topic.clone(),
style: kernel_request.style,
level: kernel_request.level,
total_duration,
objectives,
scenes,
agents,
metadata: zclaw_kernel::generation::ClassroomMetadata {
generated_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64,
source_document: kernel_request.document.map(|_| "user_document".to_string()),
model: None,
version: "2.0.0".to_string(),
custom: serde_json::Map::new(),
},
};
// Store classroom
{
let mut s = store.lock().await;
s.insert(classroom_id.clone(), classroom);
}
// Clear generation task
{
let mut t = tasks.lock().await;
t.remove(&topic_clone);
}
// Emit completion
emit_progress("complete", 100, "课堂生成完成");
Ok(ClassroomGenerateResponse {
classroom_id,
})
}
/// Get current generation progress for a topic
#[tauri::command]
pub async fn classroom_generation_progress(
tasks: State<'_, GenerationTasks>,
topic: String,
) -> Result<ClassroomProgressResponse, String> {
let t = tasks.lock().await;
let progress = t.get(&topic);
Ok(ClassroomProgressResponse {
stage: progress.map(|p| stage_name(&p.stage).to_string()).unwrap_or_else(|| "none".to_string()),
progress: progress.map(|p| p.progress).unwrap_or(0),
activity: progress.map(|p| p.activity.clone()).unwrap_or_default(),
items_progress: progress.and_then(|p| p.items_progress),
})
}
/// Cancel an active generation
#[tauri::command]
pub async fn classroom_cancel_generation(
tasks: State<'_, GenerationTasks>,
topic: String,
) -> Result<(), String> {
let mut t = tasks.lock().await;
t.remove(&topic);
Ok(())
}
/// Retrieve a generated classroom by ID
#[tauri::command]
pub async fn classroom_get(
store: State<'_, ClassroomStore>,
classroom_id: String,
) -> Result<Classroom, String> {
let s = store.lock().await;
s.get(&classroom_id)
.cloned()
.ok_or_else(|| format!("Classroom '{}' not found", classroom_id))
}
/// List all generated classrooms (id + title only)
#[tauri::command]
pub async fn classroom_list(
store: State<'_, ClassroomStore>,
) -> Result<Vec<serde_json::Value>, String> {
let s = store.lock().await;
Ok(s.values().map(|c| serde_json::json!({
"id": c.id,
"title": c.title,
"topic": c.topic,
"totalDuration": c.total_duration,
"sceneCount": c.scenes.len(),
})).collect())
}

View File

@@ -0,0 +1,41 @@
//! Classroom generation and interaction commands
//!
//! Tauri commands for the OpenMAIC-style interactive classroom:
//! - Generate classroom (4-stage pipeline with progress events)
//! - Multi-agent chat
//! - Export (HTML/Markdown/JSON)
use std::sync::Arc;
use tokio::sync::Mutex;
use zclaw_kernel::generation::Classroom;
pub mod chat;
pub mod export;
pub mod generate;
// ---------------------------------------------------------------------------
// Shared state types
// ---------------------------------------------------------------------------
/// In-memory classroom store: classroom_id → Classroom
pub type ClassroomStore = Arc<Mutex<std::collections::HashMap<String, Classroom>>>;
/// Active generation tasks: topic → progress
pub type GenerationTasks = Arc<Mutex<std::collections::HashMap<String, zclaw_kernel::generation::GenerationProgress>>>;
// Re-export chat state type
// Re-export chat state type — used by lib.rs to construct managed state
#[allow(unused_imports)]
pub use chat::ChatStore;
// ---------------------------------------------------------------------------
// State constructors
// ---------------------------------------------------------------------------
pub fn create_classroom_state() -> ClassroomStore {
Arc::new(Mutex::new(std::collections::HashMap::new()))
}
pub fn create_generation_tasks() -> GenerationTasks {
Arc::new(Mutex::new(std::collections::HashMap::new()))
}

View File

@@ -258,11 +258,18 @@ impl AgentIdentityManager {
if !identity.instructions.is_empty() {
sections.push(identity.instructions.clone());
}
if !identity.user_profile.is_empty()
&& identity.user_profile != default_user_profile()
{
sections.push(format!("## 用户画像\n{}", identity.user_profile));
}
// NOTE: user_profile injection is intentionally disabled.
// The reflection engine may accumulate overly specific details from past
// conversations (e.g., "广东光华", "汕头玩具产业") into user_profile.
// These details then leak into every new conversation's system prompt,
// causing the model to think about old topics instead of the current query.
// Memory injection should only happen via MemoryMiddleware with relevance
// filtering, not unconditionally via user_profile.
// if !identity.user_profile.is_empty()
// && identity.user_profile != default_user_profile()
// {
// sections.push(format!("## 用户画像\n{}", identity.user_profile));
// }
if let Some(ctx) = memory_context {
sections.push(ctx.to_string());
}

View File

@@ -34,6 +34,7 @@ pub struct ChatResponse {
#[serde(rename_all = "camelCase", tag = "type")]
pub enum StreamChatEvent {
Delta { delta: String },
ThinkingDelta { delta: String },
ToolStart { name: String, input: serde_json::Value },
ToolEnd { name: String, output: serde_json::Value },
IterationStart { iteration: usize, max_iterations: usize },
@@ -218,6 +219,10 @@ pub async fn agent_chat_stream(
tracing::trace!("[agent_chat_stream] Delta: {} bytes", delta.len());
StreamChatEvent::Delta { delta: delta.clone() }
}
LoopEvent::ThinkingDelta(delta) => {
tracing::trace!("[agent_chat_stream] ThinkingDelta: {} bytes", delta.len());
StreamChatEvent::ThinkingDelta { delta: delta.clone() }
}
LoopEvent::ToolStart { name, input } => {
tracing::debug!("[agent_chat_stream] ToolStart: {}", name);
if name.starts_with("hand_") {

View File

@@ -249,3 +249,130 @@ pub async fn kernel_shutdown(
Ok(())
}
/// Apply SaaS-synced configuration to the Kernel config file.
///
/// Writes relevant config values (agent, llm categories) to the TOML config file.
/// The changes take effect on the next Kernel restart.
#[tauri::command]
pub async fn kernel_apply_saas_config(
configs: Vec<SaasConfigItem>,
) -> Result<u32, String> {
use std::io::Write;
let config_path = zclaw_kernel::config::KernelConfig::find_config_path()
.ok_or_else(|| "No config file path found".to_string())?;
// Read existing config or create empty
let existing = if config_path.exists() {
std::fs::read_to_string(&config_path).unwrap_or_default()
} else {
String::new()
};
let mut updated = existing;
let mut applied: u32 = 0;
for config in &configs {
// Only process kernel-relevant categories
if !matches!(config.category.as_str(), "agent" | "llm") {
continue;
}
// Write key=value to the [llm] or [agent] section
let section = &config.category;
let key = config.key.replace('.', "_");
let value = &config.value;
// Simple TOML patching: find or create section, update key
let section_header = format!("[{}]", section);
let line_to_set = format!("{} = {}", key, toml_quote_value(value));
if let Some(section_start) = updated.find(&section_header) {
// Section exists, find or add the key within it
let after_header = section_start + section_header.len();
let next_section = updated[after_header..].find("\n[")
.map(|i| after_header + i)
.unwrap_or(updated.len());
let section_content = &updated[after_header..next_section];
let key_prefix = format!("\n{} =", key);
let key_prefix_alt = format!("\n{}=", key);
if let Some(key_pos) = section_content.find(&key_prefix)
.or_else(|| section_content.find(&key_prefix_alt))
{
// Key exists, replace the line
let line_start = after_header + key_pos + 1; // skip \n
let line_end = updated[line_start..].find('\n')
.map(|i| line_start + i)
.unwrap_or(updated.len());
updated = format!(
"{}{}{}\n{}",
&updated[..line_start],
line_to_set,
if line_end < updated.len() { "" } else { "" },
&updated[line_end..]
);
// Remove the extra newline if line_end included one
updated = updated.replace(&format!("{}\n\n", line_to_set), &format!("{}\n", line_to_set));
} else {
// Key doesn't exist, append to section
updated.insert_str(next_section, format!("\n{}", line_to_set).as_str());
}
} else {
// Section doesn't exist, append it
updated = format!("{}\n{}\n{}\n", updated.trim_end(), section_header, line_to_set);
}
applied += 1;
}
if applied > 0 {
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {}", e))?;
}
let mut file = std::fs::File::create(&config_path)
.map_err(|e| format!("Failed to write config: {}", e))?;
file.write_all(updated.as_bytes())
.map_err(|e| format!("Failed to write config: {}", e))?;
tracing::info!(
"[kernel_apply_saas_config] Applied {} config items to {:?} (restart required)",
applied,
config_path
);
}
Ok(applied)
}
/// Single config item from SaaS sync
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaasConfigItem {
pub category: String,
pub key: String,
pub value: String,
}
/// Quote a value for TOML format
fn toml_quote_value(value: &str) -> String {
// Try to parse as number or boolean
if value == "true" || value == "false" {
return value.to_string();
}
if let Ok(n) = value.parse::<i64>() {
return n.to_string();
}
if let Ok(n) = value.parse::<f64>() {
return n.to_string();
}
// Handle multi-line strings with TOML triple-quote syntax
if value.contains('\n') {
return format!("\"\"\"\n{}\"\"\"", value.replace('\\', "\\\\").replace("\"\"\"", "'\"'\"'\""));
}
// Default: quote as string
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}

View File

@@ -34,6 +34,9 @@ mod kernel_commands;
// Pipeline commands (DSL-based workflows)
mod pipeline_commands;
// Classroom generation and interaction commands
mod classroom_commands;
// Gateway sub-modules (runtime, config, io, commands)
mod gateway;
@@ -99,6 +102,11 @@ pub fn run() {
// Initialize Pipeline state (DSL-based workflows)
let pipeline_state = pipeline_commands::create_pipeline_state();
// Initialize Classroom state (generation + chat)
let classroom_state = classroom_commands::create_classroom_state();
let classroom_chat_state = classroom_commands::chat::create_chat_state();
let classroom_gen_tasks = classroom_commands::create_generation_tasks();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(browser_state)
@@ -110,11 +118,15 @@ pub fn run() {
.manage(scheduler_state)
.manage(kernel_commands::SessionStreamGuard::default())
.manage(pipeline_state)
.manage(classroom_state)
.manage(classroom_chat_state)
.manage(classroom_gen_tasks)
.invoke_handler(tauri::generate_handler![
// Internal ZCLAW Kernel commands (preferred)
kernel_commands::lifecycle::kernel_init,
kernel_commands::lifecycle::kernel_status,
kernel_commands::lifecycle::kernel_shutdown,
kernel_commands::lifecycle::kernel_apply_saas_config,
kernel_commands::agent::agent_create,
kernel_commands::agent::agent_list,
kernel_commands::agent::agent_get,
@@ -300,7 +312,16 @@ pub fn run() {
intelligence::identity::identity_get_snapshots,
intelligence::identity::identity_restore_snapshot,
intelligence::identity::identity_list_agents,
intelligence::identity::identity_delete_agent
intelligence::identity::identity_delete_agent,
// Classroom generation and interaction commands
classroom_commands::generate::classroom_generate,
classroom_commands::generate::classroom_generation_progress,
classroom_commands::generate::classroom_cancel_generation,
classroom_commands::generate::classroom_get,
classroom_commands::generate::classroom_list,
classroom_commands::chat::classroom_chat,
classroom_commands::chat::classroom_chat_history,
classroom_commands::export::classroom_export
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -29,6 +29,7 @@ import { useProposalNotifications, ProposalNotificationHandler } from './lib/use
import { useToast } from './components/ui/Toast';
import type { Clone } from './store/agentStore';
import { createLogger } from './lib/logger';
import { startOfflineMonitor } from './store/offlineStore';
const log = createLogger('App');
@@ -86,6 +87,8 @@ function App() {
useEffect(() => {
document.title = 'ZCLAW';
const stopOfflineMonitor = startOfflineMonitor();
return () => { stopOfflineMonitor(); };
}, []);
// Restore SaaS session from OS keyring on startup (before auth gate)
@@ -152,8 +155,11 @@ function App() {
let mounted = true;
const bootstrap = async () => {
// 未登录时不启动 bootstrap
if (!useSaaSStore.getState().isLoggedIn) return;
// 未登录时不启动 bootstrap,直接结束 loading
if (!useSaaSStore.getState().isLoggedIn) {
setBootstrapping(false);
return;
}
try {
// Step 1: Check and start local gateway in Tauri environment

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObjec
import { motion, AnimatePresence } from 'framer-motion';
import { List, type ListImperativeAPI } from 'react-window';
import { useChatStore, Message } from '../store/chatStore';
import { useArtifactStore } from '../store/chat/artifactStore';
import { useConnectionStore } from '../store/connectionStore';
import { useAgentStore } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
@@ -12,6 +13,8 @@ import { ArtifactPanel } from './ai/ArtifactPanel';
import { ToolCallChain } from './ai/ToolCallChain';
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
import { FirstConversationPrompt } from './FirstConversationPrompt';
import { ClassroomPlayer } from './classroom_player';
import { useClassroomStore } from '../store/classroomStore';
// MessageSearch temporarily removed during DeerFlow redesign
import { OfflineIndicator } from './OfflineIndicator';
import {
@@ -45,11 +48,14 @@ export function ChatArea() {
messages, currentAgent, isStreaming, isLoading, currentModel,
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
newConversation, chatMode, setChatMode, suggestions,
artifacts, selectedArtifactId, artifactPanelOpen,
selectArtifact, setArtifactPanelOpen,
totalInputTokens, totalOutputTokens,
} = useChatStore();
const {
artifacts, selectedArtifactId, artifactPanelOpen,
selectArtifact, setArtifactPanelOpen,
} = useArtifactStore();
const connectionState = useConnectionStore((s) => s.connectionState);
const { activeClassroom, classroomOpen, closeClassroom, generating, progressPercent, progressActivity, error: classroomError, clearError: clearClassroomError } = useClassroomStore();
const clones = useAgentStore((s) => s.clones);
const models = useConfigStore((s) => s.models);
@@ -203,9 +209,76 @@ export function ChatArea() {
);
return (
<div className="relative h-full">
{/* Generation progress overlay */}
<AnimatePresence>
{generating && (
<motion.div
key="generation-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-40 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm flex items-center justify-center"
>
<div className="text-center space-y-4">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-500 rounded-full animate-spin mx-auto" />
<div>
<p className="text-lg font-medium text-gray-900 dark:text-white">
...
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{progressActivity || '准备中...'}
</p>
</div>
{progressPercent > 0 && (
<div className="w-64 mx-auto">
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{progressPercent}%</p>
</div>
)}
<button
onClick={() => useClassroomStore.getState().cancelGeneration()}
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg"
>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ClassroomPlayer overlay */}
<AnimatePresence>
{classroomOpen && activeClassroom && (
<motion.div
key="classroom-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 bg-white dark:bg-gray-900"
>
<ClassroomPlayer
onClose={closeClassroom}
/>
</motion.div>
)}
</AnimatePresence>
<ResizableChatLayout
chatPanel={
<div className="flex flex-col h-full">
{/* Classroom generation error banner */}
{classroomError && (
<div className="mx-4 mt-2 px-4 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center justify-between text-sm">
<span className="text-red-600 dark:text-red-400">: {classroomError}</span>
<button onClick={clearClassroomError} className="text-red-400 hover:text-red-600 ml-3 text-xs"></button>
</div>
)}
{/* Header — DeerFlow-style: minimal */}
<div className="h-14 border-b border-transparent flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
<div className="flex items-center gap-2 text-sm text-gray-500">
@@ -298,6 +371,7 @@ export function ChatArea() {
getHeight={getHeight}
onHeightChange={setHeight}
messageRefs={messageRefs}
setInput={setInput}
/>
) : (
messages.map((message) => (
@@ -310,7 +384,7 @@ export function ChatArea() {
layout
transition={defaultTransition}
>
<MessageBubble message={message} />
<MessageBubble message={message} setInput={setInput} />
</motion.div>
))
)}
@@ -433,19 +507,16 @@ export function ChatArea() {
rightPanelOpen={artifactPanelOpen}
onRightPanelToggle={setArtifactPanelOpen}
/>
</div>
);
}
function MessageBubble({ message }: { message: Message }) {
// Tool messages are now absorbed into the assistant message's toolSteps chain.
// Legacy standalone tool messages (from older sessions) still render as before.
function MessageBubble({ message, setInput }: { message: Message; setInput: (text: string) => void }) {
if (message.role === 'tool') {
return null;
}
const isUser = message.role === 'user';
// 思考中状态streaming 且内容为空时显示思考指示器
const isThinking = message.streaming && !message.content;
// Download message as Markdown file
@@ -518,7 +589,20 @@ function MessageBubble({ message }: { message: Message }) {
: '...'}
</div>
{message.error && (
<p className="text-xs text-red-500 mt-2">{message.error}</p>
<div className="flex items-center gap-2 mt-2">
<p className="text-xs text-red-500">{message.error}</p>
<button
onClick={() => {
const text = typeof message.content === 'string' ? message.content : '';
if (text) {
setInput(text);
}
}}
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"
>
</button>
</div>
)}
{/* Download button for AI messages - show on hover */}
{!isUser && message.content && !message.streaming && (
@@ -543,6 +627,7 @@ interface VirtualizedMessageRowProps {
message: Message;
onHeightChange: (height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void;
}
/**
@@ -553,6 +638,7 @@ function VirtualizedMessageRow({
message,
onHeightChange,
messageRefs,
setInput,
style,
ariaAttributes,
}: VirtualizedMessageRowProps & {
@@ -587,7 +673,7 @@ function VirtualizedMessageRow({
className="py-3"
{...ariaAttributes}
>
<MessageBubble message={message} />
<MessageBubble message={message} setInput={setInput} />
</div>
);
}
@@ -598,6 +684,7 @@ interface VirtualizedMessageListProps {
getHeight: (id: string, role: string) => number;
onHeightChange: (id: string, height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void;
}
/**
@@ -610,6 +697,7 @@ function VirtualizedMessageList({
getHeight,
onHeightChange,
messageRefs,
setInput,
}: VirtualizedMessageListProps) {
// Row component for react-window v2
const RowComponent = (props: {
@@ -625,6 +713,7 @@ function VirtualizedMessageList({
message={messages[props.index]}
onHeightChange={(h) => onHeightChange(messages[props.index].id, h)}
messageRefs={messageRefs}
setInput={setInput}
style={props.style}
ariaAttributes={props.ariaAttributes}
/>

View File

@@ -67,6 +67,7 @@ interface ClassroomPreviewerProps {
data: ClassroomData;
onClose?: () => void;
onExport?: (format: 'pptx' | 'html' | 'pdf') => void;
onOpenFullPlayer?: () => void;
}
// === Sub-Components ===
@@ -271,6 +272,7 @@ function OutlinePanel({
export function ClassroomPreviewer({
data,
onExport,
onOpenFullPlayer,
}: ClassroomPreviewerProps) {
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
@@ -398,6 +400,15 @@ export function ClassroomPreviewer({
</p>
</div>
<div className="flex items-center gap-2">
{onOpenFullPlayer && (
<button
onClick={onOpenFullPlayer}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 rounded-md hover:bg-indigo-200 dark:hover:bg-indigo-900/50 transition-colors"
>
<Play className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleExport('pptx')}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-md hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"

View File

@@ -22,13 +22,16 @@ import {
} from '../lib/personality-presets';
import type { Clone } from '../store/agentStore';
import { useChatStore } from '../store/chatStore';
import { useClassroomStore } from '../store/classroomStore';
import { useHandStore } from '../store/handStore';
// Quick action chip definitions — DeerFlow-style colored pills
// handId maps to actual Hand names in the runtime
const QUICK_ACTIONS = [
{ key: 'surprise', label: '小惊喜', icon: Sparkles, color: 'text-orange-500' },
{ key: 'write', label: '写作', icon: PenLine, color: 'text-blue-500' },
{ key: 'research', label: '研究', icon: Microscope, color: 'text-purple-500' },
{ key: 'collect', label: '收集', icon: Layers, color: 'text-green-500' },
{ key: 'research', label: '研究', icon: Microscope, color: 'text-purple-500', handId: 'researcher' },
{ key: 'collect', label: '收集', icon: Layers, color: 'text-green-500', handId: 'collector' },
{ key: 'learn', label: '学习', icon: GraduationCap, color: 'text-indigo-500' },
];
@@ -69,6 +72,41 @@ export function FirstConversationPrompt({
});
const handleQuickAction = (key: string) => {
if (key === 'learn') {
// Trigger classroom generation flow
const classroomStore = useClassroomStore.getState();
// Extract a clean topic from the prompt
const prompt = QUICK_ACTION_PROMPTS[key] || '';
const topic = prompt
.replace(/^[你我].*?(想了解|想学|了解|学习|分析|研究|探索)\s*/g, '')
.replace(/[,。?!].*$/g, '')
.replace(/^(能|帮|请|可不可以).*/g, '')
.trim() || '互动课堂';
classroomStore.startGeneration({
topic,
style: 'lecture',
level: 'intermediate',
language: 'zh-CN',
}).catch(() => {
// Error is already stored in classroomStore.error and displayed in ChatArea
});
return;
}
// Check if this action maps to a Hand
const actionDef = QUICK_ACTIONS.find((a) => a.key === key);
if (actionDef?.handId) {
const handStore = useHandStore.getState();
handStore.triggerHand(actionDef.handId, {
action: key === 'research' ? 'report' : 'collect',
query: { query: QUICK_ACTION_PROMPTS[key] || '' },
}).catch(() => {
// Fallback: fill prompt into input bar
onSelectSuggestion?.(QUICK_ACTION_PROMPTS[key] || '你好!');
});
return;
}
const prompt = QUICK_ACTION_PROMPTS[key] || '你好!';
onSelectSuggestion?.(prompt);
};

View File

@@ -25,6 +25,8 @@ import { PipelineRunResponse } from '../lib/pipeline-client';
import { useToast } from './ui/Toast';
import DOMPurify from 'dompurify';
import { ClassroomPreviewer, type ClassroomData } from './ClassroomPreviewer';
import { useClassroomStore } from '../store/classroomStore';
import { adaptToClassroom } from '../lib/classroom-adapter';
// === Types ===
@@ -286,6 +288,11 @@ export function PipelineResultPreview({
// Handle export
handleClassroomExport(format, classroomData);
}}
onOpenFullPlayer={() => {
const classroom = adaptToClassroom(classroomData);
useClassroomStore.getState().setActiveClassroom(classroom);
useClassroomStore.getState().openClassroom();
}}
/>
</div>
);

View File

@@ -109,7 +109,7 @@ export function Conversation({ children, className = '' }: ConversationProps) {
<div
ref={containerRef}
onScroll={handleScroll}
className={`overflow-y-auto custom-scrollbar ${className}`}
className={`overflow-y-auto custom-scrollbar min-h-0 ${className}`}
>
{children}
</div>

View File

@@ -62,7 +62,7 @@ export function ResizableChatLayout({
if (!rightPanelOpen || !rightPanel) {
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
<div className="h-full flex flex-col overflow-hidden relative">
{chatPanel}
<button
onClick={handleToggle}
@@ -76,7 +76,7 @@ export function ResizableChatLayout({
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="h-full flex flex-col overflow-hidden">
<Group
orientation="horizontal"
onLayoutChanged={(layout) => savePanelSizes(layout)}

View File

@@ -0,0 +1,121 @@
/**
* AgentChat — Multi-agent chat panel for classroom interaction.
*
* Displays chat bubbles from different agents (teacher, assistant, students)
* with distinct colors and avatars. Users can send messages.
*/
import { useState, useRef, useEffect } from 'react';
import type { ClassroomChatMessage as ChatMessage, AgentProfile } from '../../types/classroom';
interface AgentChatProps {
messages: ChatMessage[];
agents: AgentProfile[];
loading: boolean;
onSend: (message: string) => Promise<void>;
}
export function AgentChat({ messages, loading, onSend }: AgentChatProps) {
const [input, setInput] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleSend = async () => {
const trimmed = input.trim();
if (!trimmed || loading) return;
setInput('');
await onSend(trimmed);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex flex-col w-80 border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
{/* Header */}
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Classroom Chat
</h3>
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-auto p-3 space-y-3">
{messages.length === 0 ? (
<div className="text-center text-xs text-gray-400 py-8">
Start a conversation with the classroom
</div>
) : (
messages.map((msg) => {
const isUser = msg.role === 'user';
return (
<div key={msg.id} className={`flex gap-2 ${isUser ? 'justify-end' : ''}`}>
{/* Avatar */}
{!isUser && (
<span
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-xs"
style={{ backgroundColor: msg.color + '20' }}
>
{msg.agentAvatar}
</span>
)}
{/* Message bubble */}
<div className={`max-w-[200px] ${isUser ? 'text-right' : ''}`}>
{!isUser && (
<span className="text-xs font-medium" style={{ color: msg.color }}>
{msg.agentName}
</span>
)}
<div
className={`text-sm px-3 py-1.5 rounded-lg ${
isUser
? 'bg-indigo-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'
}`}
>
{msg.content}
</div>
</div>
</div>
);
})
)}
</div>
{/* Input */}
<div className="px-3 py-2 border-t border-gray-200 dark:border-gray-700">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask a question..."
disabled={loading}
className="flex-1 px-2 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-400 disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
className="px-3 py-1.5 text-sm rounded bg-indigo-500 text-white disabled:opacity-50 hover:bg-indigo-600"
>
{loading ? '...' : 'Send'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
/**
* ClassroomPlayer — Full-screen interactive classroom player.
*
* Layout: Notes sidebar | Main stage | Chat panel
* Top: Title + Agent avatars
* Bottom: Scene navigation + playback controls
*/
import { useState, useCallback, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useClassroom } from '../../hooks/useClassroom';
import { SceneRenderer } from './SceneRenderer';
import { AgentChat } from './AgentChat';
import { NotesSidebar } from './NotesSidebar';
import { TtsPlayer } from './TtsPlayer';
import { Download } from 'lucide-react';
interface ClassroomPlayerProps {
onClose: () => void;
}
export function ClassroomPlayer({ onClose }: ClassroomPlayerProps) {
const {
activeClassroom,
chatMessages,
chatLoading,
sendChatMessage,
} = useClassroom();
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [chatOpen, setChatOpen] = useState(true);
const [exporting, setExporting] = useState(false);
const classroom = activeClassroom;
const scenes = classroom?.scenes ?? [];
const agents = classroom?.agents ?? [];
const currentScene = scenes[currentSceneIndex] ?? null;
// Navigate to next/prev scene
const goNext = useCallback(() => {
setCurrentSceneIndex((i) => Math.min(i + 1, scenes.length - 1));
}, [scenes.length]);
const goPrev = useCallback(() => {
setCurrentSceneIndex((i) => Math.max(i - 1, 0));
}, []);
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight') goNext();
else if (e.key === 'ArrowLeft') goPrev();
else if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [goNext, goPrev, onClose]);
// Chat handler
const handleChatSend = useCallback(async (message: string) => {
const sceneContext = currentScene?.content.title;
await sendChatMessage(message, sceneContext);
}, [sendChatMessage, currentScene]);
// Export handler
const handleExport = useCallback(async (format: 'html' | 'markdown' | 'json') => {
if (!classroom) return;
setExporting(true);
try {
const result = await invoke<{ content: string; filename: string; mimeType: string }>(
'classroom_export',
{ request: { classroomId: classroom.id, format } }
);
// Download the exported file
const blob = new Blob([result.content], { type: result.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error('Export failed:', e);
} finally {
setExporting(false);
}
}, [classroom]);
if (!classroom) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
No classroom loaded
</div>
);
}
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="flex items-center justify-between px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Close classroom"
>
</button>
<h1 className="text-lg font-semibold text-gray-900 dark:text-white truncate max-w-md">
{classroom.title}
</h1>
</div>
{/* Agent avatars */}
<div className="flex items-center gap-1">
{agents.map((agent) => (
<span
key={agent.id}
className="inline-flex items-center justify-center w-8 h-8 rounded-full text-sm"
style={{ backgroundColor: agent.color + '20', color: agent.color }}
title={agent.name}
>
{agent.avatar}
</span>
))}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className={`px-2 py-1 rounded text-xs ${sidebarOpen ? 'bg-indigo-100 text-indigo-700' : 'text-gray-500'}`}
>
Notes
</button>
<button
onClick={() => setChatOpen(!chatOpen)}
className={`px-2 py-1 rounded text-xs ${chatOpen ? 'bg-indigo-100 text-indigo-700' : 'text-gray-500'}`}
>
Chat
</button>
{/* Export dropdown */}
<div className="relative group">
<button
disabled={exporting}
className="px-2 py-1 rounded text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
title="导出课堂"
>
<Download className="w-3.5 h-3.5" />
{exporting ? '...' : '导出'}
</button>
<div className="absolute right-0 top-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-lg hidden group-hover:block z-10">
<button onClick={() => handleExport('html')} className="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">HTML</button>
<button onClick={() => handleExport('markdown')} className="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Markdown</button>
<button onClick={() => handleExport('json')} className="block w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">JSON</button>
</div>
</div>
</div>
</header>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
{/* Notes sidebar */}
{sidebarOpen && (
<NotesSidebar
scenes={scenes}
currentIndex={currentSceneIndex}
onSelectScene={setCurrentSceneIndex}
/>
)}
{/* Main stage */}
<main className="flex-1 overflow-auto p-4">
{currentScene ? (
<SceneRenderer key={currentScene.id} scene={currentScene} agents={agents} />
) : (
<div className="flex items-center justify-center h-full text-gray-400">
No scenes available
</div>
)}
</main>
{/* Chat panel */}
{chatOpen && (
<AgentChat
messages={chatMessages}
agents={agents}
loading={chatLoading}
onSend={handleChatSend}
/>
)}
</div>
{/* Bottom navigation */}
<footer className="flex items-center justify-between px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="flex items-center gap-2">
<button
onClick={goPrev}
disabled={currentSceneIndex === 0}
className="px-3 py-1 rounded text-sm bg-gray-100 dark:bg-gray-700 disabled:opacity-50"
>
Previous
</button>
<span className="text-sm text-gray-500">
{currentSceneIndex + 1} / {scenes.length}
</span>
<button
onClick={goNext}
disabled={currentSceneIndex >= scenes.length - 1}
className="px-3 py-1 rounded text-sm bg-gray-100 dark:bg-gray-700 disabled:opacity-50"
>
Next
</button>
</div>
{/* TTS + Scene info */}
<div className="flex items-center gap-3">
{currentScene?.content.notes && (
<TtsPlayer text={currentScene.content.notes} />
)}
<div className="text-xs text-gray-400">
{currentScene?.content.sceneType ?? ''}
{currentScene?.content.durationSeconds
? ` · ${Math.floor(currentScene.content.durationSeconds / 60)}:${String(currentScene.content.durationSeconds % 60).padStart(2, '0')}`
: ''}
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,71 @@
/**
* NotesSidebar — Scene outline navigation + notes.
*
* Left panel showing all scenes as clickable items with notes.
*/
import type { GeneratedScene } from '../../types/classroom';
interface NotesSidebarProps {
scenes: GeneratedScene[];
currentIndex: number;
onSelectScene: (index: number) => void;
}
export function NotesSidebar({ scenes, currentIndex, onSelectScene }: NotesSidebarProps) {
return (
<div className="w-64 border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-auto">
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Outline
</h3>
</div>
<nav className="py-1">
{scenes.map((scene, i) => {
const isActive = i === currentIndex;
const typeColor = getTypeColor(scene.content.sceneType);
return (
<button
key={scene.id}
onClick={() => onSelectScene(i)}
className={`w-full text-left px-3 py-2 text-sm border-l-2 transition-colors ${
isActive
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: typeColor }}
/>
<span className={`font-medium ${isActive ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
{i + 1}. {scene.content.title}
</span>
</div>
{scene.content.notes && (
<p className="text-xs text-gray-400 mt-0.5 ml-3.5 line-clamp-2">
{scene.content.notes}
</p>
)}
</button>
);
})}
</nav>
</div>
);
}
function getTypeColor(type: string): string {
switch (type) {
case 'slide': return '#6366F1';
case 'quiz': return '#F59E0B';
case 'discussion': return '#10B981';
case 'interactive': return '#8B5CF6';
case 'pbl': return '#EF4444';
case 'media': return '#06B6D4';
default: return '#9CA3AF';
}
}

View File

@@ -0,0 +1,219 @@
/**
* SceneRenderer — Renders a single classroom scene.
*
* Supports scene types: slide, quiz, discussion, interactive, text, pbl, media.
* Executes scene actions (speech, whiteboard, quiz, discussion).
*/
import { useState, useEffect, useCallback } from 'react';
import type { GeneratedScene, SceneContent, SceneAction, AgentProfile } from '../../types/classroom';
interface SceneRendererProps {
scene: GeneratedScene;
agents: AgentProfile[];
autoPlay?: boolean;
}
export function SceneRenderer({ scene, agents, autoPlay = true }: SceneRendererProps) {
const { content } = scene;
const [actionIndex, setActionIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(autoPlay);
const [whiteboardItems, setWhiteboardItems] = useState<Array<{
type: string;
data: SceneAction;
}>>([]);
const actions = content.actions ?? [];
const currentAction = actions[actionIndex] ?? null;
// Auto-advance through actions
useEffect(() => {
if (!isPlaying || actions.length === 0) return;
if (actionIndex >= actions.length) {
setIsPlaying(false);
return;
}
const delay = getActionDelay(actions[actionIndex]);
const timer = setTimeout(() => {
processAction(actions[actionIndex]);
setActionIndex((i) => i + 1);
}, delay);
return () => clearTimeout(timer);
}, [actionIndex, isPlaying, actions]);
const processAction = useCallback((action: SceneAction) => {
switch (action.type) {
case 'whiteboard_draw_text':
case 'whiteboard_draw_shape':
case 'whiteboard_draw_chart':
case 'whiteboard_draw_latex':
setWhiteboardItems((prev) => [...prev, { type: action.type, data: action }]);
break;
case 'whiteboard_clear':
setWhiteboardItems([]);
break;
}
}, []);
// Render scene based on type
return (
<div className="flex flex-col h-full">
{/* Scene title */}
<div className="mb-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{content.title}
</h2>
{content.notes && (
<p className="text-sm text-gray-500 mt-1">{content.notes}</p>
)}
</div>
{/* Main content area */}
<div className="flex-1 flex gap-4 overflow-hidden">
{/* Content panel */}
<div className="flex-1 overflow-auto">
{renderContent(content)}
</div>
{/* Whiteboard area */}
{whiteboardItems.length > 0 && (
<div className="w-80 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 p-2 overflow-auto">
<svg viewBox="0 0 800 600" className="w-full h-full">
{whiteboardItems.map((item, i) => (
<g key={i}>{renderWhiteboardItem(item)}</g>
))}
</svg>
</div>
)}
</div>
{/* Current action indicator */}
{currentAction && (
<div className="mt-4 p-3 rounded-lg bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-100 dark:border-indigo-800">
{renderCurrentAction(currentAction, agents)}
</div>
)}
{/* Playback controls */}
<div className="flex items-center justify-center gap-2 mt-4">
<button
onClick={() => { setActionIndex(0); setWhiteboardItems([]); }}
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700"
>
Restart
</button>
<button
onClick={() => setIsPlaying(!isPlaying)}
className="px-3 py-1 text-sm rounded bg-indigo-500 text-white"
>
{isPlaying ? 'Pause' : 'Play'}
</button>
<span className="text-xs text-gray-400">
Action {Math.min(actionIndex + 1, actions.length)} / {actions.length}
</span>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getActionDelay(action: SceneAction): number {
switch (action.type) {
case 'speech': return 2000;
case 'whiteboard_draw_text': return 800;
case 'whiteboard_draw_shape': return 600;
case 'quiz_show': return 5000;
case 'discussion': return 10000;
default: return 1000;
}
}
function renderContent(content: SceneContent) {
const data = content.content;
if (!data || typeof data !== 'object') return null;
// Handle slide content
const keyPoints = data.key_points as string[] | undefined;
const description = data.description as string | undefined;
const slides = data.slides as Array<{ title: string; content: string }> | undefined;
return (
<div className="space-y-4">
{description && (
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">{description}</p>
)}
{keyPoints && keyPoints.length > 0 && (
<ul className="space-y-2">
{keyPoints.map((point, i) => (
<li key={i} className="flex items-start gap-2">
<span className="text-indigo-500 mt-0.5"></span>
<span className="text-gray-700 dark:text-gray-300">{point}</span>
</li>
))}
</ul>
)}
{slides && slides.map((slide, i) => (
<div key={i} className="p-3 rounded border border-gray-200 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white">{slide.title}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{slide.content}</p>
</div>
))}
</div>
);
}
function renderCurrentAction(action: SceneAction, agents: AgentProfile[]) {
switch (action.type) {
case 'speech': {
const agent = agents.find(a => a.role === action.agentRole);
return (
<div className="flex items-start gap-2">
<span className="text-lg">{agent?.avatar ?? '💬'}</span>
<div>
<span className="text-xs font-medium text-gray-600">{agent?.name ?? action.agentRole}</span>
<p className="text-sm text-gray-700 dark:text-gray-300">{action.text}</p>
</div>
</div>
);
}
case 'quiz_show':
return <div className="text-sm text-amber-600">Quiz: {action.quizId}</div>;
case 'discussion':
return <div className="text-sm text-green-600">Discussion: {action.topic}</div>;
default:
return <div className="text-xs text-gray-400">{action.type}</div>;
}
}
function renderWhiteboardItem(item: { type: string; data: Record<string, unknown> }) {
switch (item.type) {
case 'whiteboard_draw_text': {
const d = item.data;
if ('text' in d && 'x' in d && 'y' in d) {
return (
<text x={typeof d.x === 'number' ? d.x : 100} y={typeof d.y === 'number' ? d.y : 100} fontSize={typeof d.fontSize === 'number' ? d.fontSize : 16} fill={typeof d.color === 'string' ? d.color : '#333'}>
{String(d.text ?? '')}
</text>
);
}
return null;
}
case 'whiteboard_draw_shape': {
const d = item.data as Record<string, unknown>;
const x = typeof d.x === 'number' ? d.x : 0;
const y = typeof d.y === 'number' ? d.y : 0;
const w = typeof d.width === 'number' ? d.width : 100;
const h = typeof d.height === 'number' ? d.height : 50;
const fill = typeof d.fill === 'string' ? d.fill : '#e5e5e5';
return (
<rect x={x} y={y} width={w} height={h} fill={fill} />
);
}
}
}

View File

@@ -0,0 +1,155 @@
/**
* TtsPlayer — Text-to-Speech playback controls for classroom narration.
*
* Uses the browser's built-in SpeechSynthesis API.
* Provides play/pause, speed, and volume controls.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Volume2, VolumeX, Pause, Play, Gauge } from 'lucide-react';
interface TtsPlayerProps {
text: string;
autoPlay?: boolean;
onEnd?: () => void;
}
export function TtsPlayer({ text, autoPlay = false, onEnd }: TtsPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [rate, setRate] = useState(1.0);
const [isMuted, setIsMuted] = useState(false);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
const speak = useCallback(() => {
if (!text || typeof window === 'undefined') return;
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'zh-CN';
utterance.rate = rate;
utterance.volume = isMuted ? 0 : 1;
utterance.onend = () => {
setIsPlaying(false);
setIsPaused(false);
onEnd?.();
};
utterance.onerror = () => {
setIsPlaying(false);
setIsPaused(false);
};
utteranceRef.current = utterance;
window.speechSynthesis.speak(utterance);
setIsPlaying(true);
setIsPaused(false);
}, [text, rate, isMuted, onEnd]);
const togglePlay = useCallback(() => {
if (isPlaying && !isPaused) {
window.speechSynthesis.pause();
setIsPaused(true);
} else if (isPaused) {
window.speechSynthesis.resume();
setIsPaused(false);
} else {
speak();
}
}, [isPlaying, isPaused, speak]);
const stop = useCallback(() => {
window.speechSynthesis.cancel();
setIsPlaying(false);
setIsPaused(false);
}, []);
// Auto-play when text changes
useEffect(() => {
if (autoPlay && text) {
speak();
}
return () => {
if (typeof window !== 'undefined') {
window.speechSynthesis.cancel();
}
};
}, [text, autoPlay, speak]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (typeof window !== 'undefined') {
window.speechSynthesis.cancel();
}
};
}, []);
if (!text) return null;
return (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
{/* Play/Pause button */}
<button
onClick={togglePlay}
className="w-8 h-8 flex items-center justify-center rounded-full bg-indigo-500 text-white hover:bg-indigo-600 transition-colors"
aria-label={isPlaying && !isPaused ? '暂停' : '播放'}
>
{isPlaying && !isPaused ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</button>
{/* Stop button */}
{isPlaying && (
<button
onClick={stop}
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
aria-label="停止"
>
<VolumeX className="w-3.5 h-3.5" />
</button>
)}
{/* Speed control */}
<div className="flex items-center gap-1.5">
<Gauge className="w-3.5 h-3.5 text-gray-400" />
<select
value={rate}
onChange={(e) => setRate(Number(e.target.value))}
className="text-xs bg-transparent border-none text-gray-600 dark:text-gray-400 cursor-pointer"
>
<option value={0.5}>0.5x</option>
<option value={0.75}>0.75x</option>
<option value={1}>1x</option>
<option value={1.25}>1.25x</option>
<option value={1.5}>1.5x</option>
<option value={2}>2x</option>
</select>
</div>
{/* Mute toggle */}
<button
onClick={() => setIsMuted(!isMuted)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label={isMuted ? '取消静音' : '静音'}
>
{isMuted ? (
<VolumeX className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
{/* Status indicator */}
{isPlaying && (
<span className="text-xs text-gray-400">
{isPaused ? '已暂停' : '朗读中...'}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,295 @@
/**
* WhiteboardCanvas — SVG-based whiteboard for classroom scene rendering.
*
* Supports incremental drawing operations:
* - Text (positioned labels)
* - Shapes (rectangles, circles, arrows)
* - Charts (bar/line/pie via simple SVG)
* - LaTeX (rendered as styled text blocks)
*/
import { useCallback } from 'react';
import type { SceneAction } from '../../types/classroom';
interface WhiteboardCanvasProps {
items: WhiteboardItem[];
width?: number;
height?: number;
}
export interface WhiteboardItem {
type: string;
data: SceneAction;
}
export function WhiteboardCanvas({
items,
width = 800,
height = 600,
}: WhiteboardCanvasProps) {
const renderItem = useCallback((item: WhiteboardItem, index: number) => {
switch (item.type) {
case 'whiteboard_draw_text':
return <TextItem key={index} data={item.data as TextDrawData} />;
case 'whiteboard_draw_shape':
return <ShapeItem key={index} data={item.data as ShapeDrawData} />;
case 'whiteboard_draw_chart':
return <ChartItem key={index} data={item.data as ChartDrawData} />;
case 'whiteboard_draw_latex':
return <LatexItem key={index} data={item.data as LatexDrawData} />;
default:
return null;
}
}, []);
return (
<div className="w-full h-full border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-full"
xmlns="http://www.w3.org/2000/svg"
>
{/* Grid background */}
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#f0f0f0" strokeWidth="0.5" />
</pattern>
</defs>
<rect width={width} height={height} fill="url(#grid)" />
{/* Rendered items */}
{items.map((item, i) => renderItem(item, i))}
</svg>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
interface TextDrawData {
type: 'whiteboard_draw_text';
x: number;
y: number;
text: string;
fontSize?: number;
color?: string;
}
function TextItem({ data }: { data: TextDrawData }) {
return (
<text
x={data.x}
y={data.y}
fontSize={data.fontSize ?? 16}
fill={data.color ?? '#333333'}
fontFamily="system-ui, sans-serif"
>
{data.text}
</text>
);
}
interface ShapeDrawData {
type: 'whiteboard_draw_shape';
shape: string;
x: number;
y: number;
width: number;
height: number;
fill?: string;
}
function ShapeItem({ data }: { data: ShapeDrawData }) {
switch (data.shape) {
case 'circle':
return (
<ellipse
cx={data.x + data.width / 2}
cy={data.y + data.height / 2}
rx={data.width / 2}
ry={data.height / 2}
fill={data.fill ?? '#e5e7eb'}
stroke="#9ca3af"
strokeWidth={1}
/>
);
case 'arrow':
return (
<g>
<line
x1={data.x}
y1={data.y + data.height / 2}
x2={data.x + data.width}
y2={data.y + data.height / 2}
stroke={data.fill ?? '#6b7280'}
strokeWidth={2}
markerEnd="url(#arrowhead)"
/>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill={data.fill ?? '#6b7280'} />
</marker>
</defs>
</g>
);
default: // rectangle
return (
<rect
x={data.x}
y={data.y}
width={data.width}
height={data.height}
fill={data.fill ?? '#e5e7eb'}
stroke="#9ca3af"
strokeWidth={1}
rx={4}
/>
);
}
}
interface ChartDrawData {
type: 'whiteboard_draw_chart';
chartType: string;
data: Record<string, unknown>;
x: number;
y: number;
width: number;
height: number;
}
function ChartItem({ data }: { data: ChartDrawData }) {
const chartData = data.data;
const labels = (chartData?.labels as string[]) ?? [];
const values = (chartData?.values as number[]) ?? [];
if (labels.length === 0 || values.length === 0) return null;
switch (data.chartType) {
case 'bar':
return <BarChart data={data} labels={labels} values={values} />;
case 'line':
return <LineChart data={data} labels={labels} values={values} />;
default:
return <BarChart data={data} labels={labels} values={values} />;
}
}
function BarChart({ data, labels, values }: {
data: ChartDrawData;
labels: string[];
values: number[];
}) {
const maxVal = Math.max(...values, 1);
const barWidth = data.width / (labels.length * 2);
const chartHeight = data.height - 30;
return (
<g transform={`translate(${data.x}, ${data.y})`}>
{values.map((val, i) => {
const barHeight = (val / maxVal) * chartHeight;
return (
<g key={i}>
<rect
x={i * (barWidth * 2) + barWidth / 2}
y={chartHeight - barHeight}
width={barWidth}
height={barHeight}
fill="#6366f1"
rx={2}
/>
<text
x={i * (barWidth * 2) + barWidth}
y={data.height - 5}
textAnchor="middle"
fontSize={10}
fill="#666"
>
{labels[i]}
</text>
</g>
);
})}
</g>
);
}
function LineChart({ data, labels, values }: {
data: ChartDrawData;
labels: string[];
values: number[];
}) {
const maxVal = Math.max(...values, 1);
const chartHeight = data.height - 30;
const stepX = data.width / Math.max(labels.length - 1, 1);
const points = values.map((val, i) => {
const x = i * stepX;
const y = chartHeight - (val / maxVal) * chartHeight;
return `${x},${y}`;
}).join(' ');
return (
<g transform={`translate(${data.x}, ${data.y})`}>
<polyline
points={points}
fill="none"
stroke="#6366f1"
strokeWidth={2}
/>
{values.map((val, i) => {
const x = i * stepX;
const y = chartHeight - (val / maxVal) * chartHeight;
return (
<g key={i}>
<circle cx={x} cy={y} r={3} fill="#6366f1" />
<text
x={x}
y={data.height - 5}
textAnchor="middle"
fontSize={10}
fill="#666"
>
{labels[i]}
</text>
</g>
);
})}
</g>
);
}
interface LatexDrawData {
type: 'whiteboard_draw_latex';
latex: string;
x: number;
y: number;
}
function LatexItem({ data }: { data: LatexDrawData }) {
return (
<g transform={`translate(${data.x}, ${data.y})`}>
<rect
x={-4}
y={-20}
width={data.latex.length * 10 + 8}
height={28}
fill="#fef3c7"
stroke="#f59e0b"
strokeWidth={1}
rx={4}
/>
<text
x={0}
y={0}
fontSize={14}
fill="#92400e"
fontFamily="'Courier New', monospace"
>
{data.latex}
</text>
</g>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Classroom Player Components
*
* Re-exports all classroom player components.
*/
export { ClassroomPlayer } from './ClassroomPlayer';
export { SceneRenderer } from './SceneRenderer';
export { AgentChat } from './AgentChat';
export { NotesSidebar } from './NotesSidebar';
export { WhiteboardCanvas } from './WhiteboardCanvas';
export { TtsPlayer } from './TtsPlayer';

View File

@@ -0,0 +1,76 @@
/**
* useClassroom — React hook wrapping the classroom store for component consumption.
*
* Provides a simplified interface for classroom generation and chat.
*/
import { useCallback } from 'react';
import {
useClassroomStore,
type GenerationRequest,
} from '../store/classroomStore';
import type {
Classroom,
ClassroomChatMessage,
} from '../types/classroom';
export interface UseClassroomReturn {
/** Is generation in progress */
generating: boolean;
/** Current generation stage name */
progressStage: string | null;
/** Progress percentage 0-100 */
progressPercent: number;
/** The active classroom */
activeClassroom: Classroom | null;
/** Chat messages for active classroom */
chatMessages: ClassroomChatMessage[];
/** Is a chat request loading */
chatLoading: boolean;
/** Error message, if any */
error: string | null;
/** Start classroom generation */
startGeneration: (request: GenerationRequest) => Promise<string>;
/** Cancel active generation */
cancelGeneration: () => void;
/** Send a chat message in the active classroom */
sendChatMessage: (message: string, sceneContext?: string) => Promise<void>;
/** Clear current error */
clearError: () => void;
}
/**
* Hook for classroom generation and multi-agent chat.
*
* Components should use this hook rather than accessing the store directly,
* to keep the rendering logic decoupled from state management.
*/
export function useClassroom(): UseClassroomReturn {
const {
generating,
progressStage,
progressPercent,
activeClassroom,
chatMessages,
chatLoading,
error,
startGeneration,
cancelGeneration,
sendChatMessage,
clearError,
} = useClassroomStore();
return {
generating,
progressStage,
progressPercent,
activeClassroom,
chatMessages,
chatLoading,
error,
startGeneration: useCallback((req: GenerationRequest) => startGeneration(req), [startGeneration]),
cancelGeneration: useCallback(() => cancelGeneration(), [cancelGeneration]),
sendChatMessage: useCallback((msg, ctx) => sendChatMessage(msg, ctx), [sendChatMessage]),
clearError: useCallback(() => clearError(), [clearError]),
};
}

View File

@@ -1,27 +1,5 @@
@import "tailwindcss";
/* Aurora gradient animation for welcome title (DeerFlow-inspired) */
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.aurora-title {
background: linear-gradient(
135deg,
#f97316 0%, /* orange-500 */
#ef4444 25%, /* red-500 */
#f97316 50%, /* orange-500 */
#fb923c 75%, /* orange-400 */
#f97316 100% /* orange-500 */
);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 4s ease infinite;
}
:root {
/* Brand Colors - 中性灰色系 */
--color-primary: #374151; /* gray-700 */
@@ -154,3 +132,38 @@ textarea:focus-visible {
outline: none !important;
box-shadow: none !important;
}
/* === Accessibility: reduced motion === */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* === Responsive breakpoints for small windows/tablets === */
@media (max-width: 768px) {
/* Auto-collapse sidebar aside on narrow viewports */
aside.w-64 {
width: 0 !important;
min-width: 0 !important;
overflow: hidden;
border-right: none !important;
}
aside.w-64.sidebar-open {
width: 260px !important;
min-width: 260px !important;
position: fixed;
z-index: 50;
height: 100vh;
}
}
@media (max-width: 480px) {
.chat-bubble-assistant,
.chat-bubble-user {
max-width: 95% !important;
}
}

View File

@@ -3,6 +3,10 @@
*
* 为 ZCLAW 前端操作提供统一的审计日志记录功能。
* 记录关键操作Hand 触发、Agent 创建等)到本地存储。
*
* @reserved This module is reserved for future audit logging integration.
* It is not currently imported by any component. When audit logging is needed,
* import { logAudit, logAuditSuccess, logAuditFailure } from this module.
*/
import { createLogger } from './logger';

View File

@@ -0,0 +1,142 @@
/**
* Classroom Adapter
*
* Bridges the old ClassroomData type (ClassroomPreviewer) with the new
* Classroom type (ClassroomPlayer + Tauri backend).
*/
import type { Classroom, GeneratedScene } from '../types/classroom';
import { SceneType, TeachingStyle, DifficultyLevel } from '../types/classroom';
import type { ClassroomData, ClassroomScene } from '../components/ClassroomPreviewer';
// ---------------------------------------------------------------------------
// Old → New (ClassroomData → Classroom)
// ---------------------------------------------------------------------------
/**
* Convert a legacy ClassroomData to the new Classroom format.
* Used when opening ClassroomPlayer from Pipeline result previews.
*/
export function adaptToClassroom(data: ClassroomData): Classroom {
const scenes: GeneratedScene[] = data.scenes.map((scene, index) => ({
id: scene.id,
outlineId: `outline-${index}`,
content: {
title: scene.title,
sceneType: mapSceneType(scene.type),
content: {
heading: scene.content.heading ?? scene.title,
key_points: scene.content.bullets ?? [],
description: scene.content.explanation,
quiz: scene.content.quiz ?? undefined,
},
actions: [],
durationSeconds: scene.duration ?? 60,
notes: scene.narration,
},
order: index,
})) as GeneratedScene[];
return {
id: data.id,
title: data.title,
description: data.subject,
topic: data.subject,
style: TeachingStyle.Lecture,
level: mapDifficulty(data.difficulty),
totalDuration: data.duration * 60,
objectives: [],
scenes,
agents: [],
metadata: {
generatedAt: new Date(data.createdAt).getTime(),
version: '1.0',
custom: {},
},
};
}
// ---------------------------------------------------------------------------
// New → Old (Classroom → ClassroomData)
// ---------------------------------------------------------------------------
/**
* Convert a new Classroom to the legacy ClassroomData format.
* Used when rendering ClassroomPreviewer from new pipeline results.
*/
export function adaptToClassroomData(classroom: Classroom): ClassroomData {
const scenes: ClassroomScene[] = classroom.scenes.map((scene) => {
const data = scene.content.content as Record<string, unknown>;
return {
id: scene.id,
title: scene.content.title,
type: mapToLegacySceneType(scene.content.sceneType),
content: {
heading: (data?.heading as string) ?? scene.content.title,
bullets: (data?.key_points as string[]) ?? [],
explanation: (data?.description as string) ?? '',
quiz: (data?.quiz as ClassroomScene['content']['quiz']) ?? undefined,
},
narration: scene.content.notes,
duration: scene.content.durationSeconds,
};
});
return {
id: classroom.id,
title: classroom.title,
subject: classroom.topic,
difficulty: mapToLegacyDifficulty(classroom.level),
duration: Math.ceil(classroom.totalDuration / 60),
scenes,
outline: {
sections: classroom.scenes.map((scene) => ({
title: scene.content.title,
scenes: [scene.id],
})),
},
createdAt: new Date(classroom.metadata.generatedAt).toISOString(),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mapSceneType(type: ClassroomScene['type']): SceneType {
switch (type) {
case 'title': return SceneType.Slide;
case 'content': return SceneType.Slide;
case 'quiz': return SceneType.Quiz;
case 'interactive': return SceneType.Interactive;
case 'summary': return SceneType.Text;
default: return SceneType.Slide;
}
}
function mapToLegacySceneType(sceneType: string): ClassroomScene['type'] {
switch (sceneType) {
case 'quiz': return 'quiz';
case 'interactive': return 'interactive';
case 'text': return 'summary';
default: return 'content';
}
}
function mapDifficulty(difficulty: string): DifficultyLevel {
switch (difficulty) {
case '初级': return DifficultyLevel.Beginner;
case '中级': return DifficultyLevel.Intermediate;
case '高级': return DifficultyLevel.Advanced;
default: return DifficultyLevel.Intermediate;
}
}
function mapToLegacyDifficulty(level: string): ClassroomData['difficulty'] {
switch (level) {
case 'beginner': return '初级';
case 'advanced': return '高级';
case 'expert': return '高级';
default: return '中级';
}
}

View File

@@ -56,12 +56,19 @@ function initErrorStore(): void {
errors: [],
addError: (error: AppError) => {
// Dedup: skip if same title+message already exists and undismissed
const isDuplicate = errorStore.errors.some(
(e) => !e.dismissed && e.title === error.title && e.message === error.message
);
if (isDuplicate) return;
const storedError: StoredError = {
...error,
dismissed: false,
reported: false,
};
errorStore.errors = [storedError, ...errorStore.errors];
// Cap at 50 errors to prevent unbounded growth
errorStore.errors = [storedError, ...errorStore.errors].slice(0, 50);
// Notify listeners
notifyErrorListeners(error);
},

View File

@@ -103,6 +103,12 @@ export function installChatMethods(ClientClass: { prototype: KernelClient }): vo
callbacks.onDelta(streamEvent.delta);
break;
case 'thinkingDelta':
if (callbacks.onThinkingDelta) {
callbacks.onThinkingDelta(streamEvent.delta);
}
break;
case 'tool_start':
log.debug('Tool started:', streamEvent.name, streamEvent.input);
if (callbacks.onTool) {

View File

@@ -5,8 +5,20 @@
*/
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { createLogger } from './logger';
import type { KernelClient } from './kernel-client';
const log = createLogger('KernelHands');
/** Payload emitted by the Rust backend on `hand-execution-complete` events. */
export interface HandExecutionCompletePayload {
approvalId: string;
handId: string;
success: boolean;
error?: string | null;
}
export function installHandMethods(ClientClass: { prototype: KernelClient }): void {
const proto = ClientClass.prototype as any;
@@ -92,7 +104,7 @@ export function installHandMethods(ClientClass: { prototype: KernelClient }): vo
*/
proto.getHandStatus = async function (this: KernelClient, name: string, runId: string): Promise<{ status: string; result?: unknown }> {
try {
return await invoke('hand_run_status', { handName: name, runId });
return await invoke('hand_run_status', { runId });
} catch (e) {
const { createLogger } = await import('./logger');
createLogger('KernelHands').debug('hand_run_status failed', { name, runId, error: e });
@@ -171,4 +183,26 @@ export function installHandMethods(ClientClass: { prototype: KernelClient }): vo
proto.respondToApproval = async function (this: KernelClient, approvalId: string, approved: boolean, reason?: string): Promise<void> {
return invoke('approval_respond', { id: approvalId, approved, reason });
};
// ─── Event Listeners ───
/**
* Listen for `hand-execution-complete` events emitted by the Rust backend
* after a hand finishes executing (both from direct trigger and approval flow).
*
* Returns an unlisten function for cleanup.
*/
proto.onHandExecutionComplete = async function (
this: KernelClient,
callback: (payload: HandExecutionCompletePayload) => void,
): Promise<UnlistenFn> {
const unlisten = await listen<HandExecutionCompletePayload>(
'hand-execution-complete',
(event) => {
log.debug('hand-execution-complete', event.payload);
callback(event.payload);
},
);
return unlisten;
};
}

View File

@@ -109,7 +109,11 @@ export function installSkillMethods(ClientClass: { prototype: KernelClient }): v
}> {
return invoke('skill_execute', {
id,
context: {},
context: {
agentId: '',
sessionId: '',
workingDir: '',
},
input: input || {},
});
};

View File

@@ -96,7 +96,12 @@ export function installTriggerMethods(ClientClass: { prototype: KernelClient }):
triggerType?: TriggerTypeSpec;
}): Promise<TriggerItem> {
try {
return await invoke<TriggerItem>('trigger_update', { id, updates });
return await invoke<TriggerItem>('trigger_update', {
id,
name: updates.name,
enabled: updates.enabled,
handId: updates.handId,
});
} catch (error) {
this.log('error', `[TriggersAPI] updateTrigger(${id}) failed: ${this.formatError(error)}`);
throw error;

View File

@@ -58,6 +58,7 @@ export interface EventCallback {
export interface StreamCallbacks {
onDelta: (delta: string) => void;
onThinkingDelta?: (delta: string) => void;
onTool?: (tool: string, input: string, output: string) => void;
onHand?: (name: string, status: string, result?: unknown) => void;
onComplete: (inputTokens?: number, outputTokens?: number) => void;
@@ -71,6 +72,11 @@ export interface StreamEventDelta {
delta: string;
}
export interface StreamEventThinkingDelta {
type: 'thinkingDelta';
delta: string;
}
export interface StreamEventToolStart {
type: 'tool_start';
name: string;
@@ -114,6 +120,7 @@ export interface StreamEventHandEnd {
export type StreamChatEvent =
| StreamEventDelta
| StreamEventThinkingDelta
| StreamEventToolStart
| StreamEventToolEnd
| StreamEventIterationStart

View File

@@ -1,233 +0,0 @@
/**
* SaaS Admin Methods — Mixin
*
* Installs admin panel API methods onto SaaSClient.prototype.
* Uses the same mixin pattern as gateway-api.ts.
*
* Reserved for future admin UI (Next.js admin dashboard).
* These methods are not called by the desktop app but are kept as thin API
* wrappers for when the admin panel is built.
*/
import type {
ProviderInfo,
CreateProviderRequest,
UpdateProviderRequest,
ModelInfo,
CreateModelRequest,
UpdateModelRequest,
AccountApiKeyInfo,
CreateApiKeyRequest,
AccountPublic,
UpdateAccountRequest,
PaginatedResponse,
TokenInfo,
CreateTokenRequest,
OperationLogInfo,
DashboardStats,
RoleInfo,
CreateRoleRequest,
UpdateRoleRequest,
PermissionTemplate,
CreateTemplateRequest,
} from './saas-types';
export function installAdminMethods(ClientClass: { prototype: any }): void {
const proto = ClientClass.prototype;
// --- Provider Management (Admin) ---
/** List all providers */
proto.listProviders = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<ProviderInfo[]> {
return this.request<ProviderInfo[]>('GET', '/api/v1/providers');
};
/** Get provider by ID */
proto.getProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<ProviderInfo> {
return this.request<ProviderInfo>('GET', `/api/v1/providers/${id}`);
};
/** Create a new provider (admin only) */
proto.createProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateProviderRequest): Promise<ProviderInfo> {
return this.request<ProviderInfo>('POST', '/api/v1/providers', data);
};
/** Update a provider (admin only) */
proto.updateProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateProviderRequest): Promise<ProviderInfo> {
return this.request<ProviderInfo>('PATCH', `/api/v1/providers/${id}`, data);
};
/** Delete a provider (admin only) */
proto.deleteProvider = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/providers/${id}`);
};
// --- Model Management (Admin) ---
/** List models, optionally filtered by provider */
proto.listModelsAdmin = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, providerId?: string): Promise<ModelInfo[]> {
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
return this.request<ModelInfo[]>('GET', `/api/v1/models${qs}`);
};
/** Get model by ID */
proto.getModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<ModelInfo> {
return this.request<ModelInfo>('GET', `/api/v1/models/${id}`);
};
/** Create a new model (admin only) */
proto.createModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateModelRequest): Promise<ModelInfo> {
return this.request<ModelInfo>('POST', '/api/v1/models', data);
};
/** Update a model (admin only) */
proto.updateModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateModelRequest): Promise<ModelInfo> {
return this.request<ModelInfo>('PATCH', `/api/v1/models/${id}`, data);
};
/** Delete a model (admin only) */
proto.deleteModel = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/models/${id}`);
};
// --- Account API Keys ---
/** List account's API keys */
proto.listApiKeys = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, providerId?: string): Promise<AccountApiKeyInfo[]> {
const qs = providerId ? `?provider_id=${encodeURIComponent(providerId)}` : '';
return this.request<AccountApiKeyInfo[]>('GET', `/api/v1/keys${qs}`);
};
/** Create a new API key */
proto.createApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateApiKeyRequest): Promise<AccountApiKeyInfo> {
return this.request<AccountApiKeyInfo>('POST', '/api/v1/keys', data);
};
/** Rotate an API key */
proto.rotateApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, newKeyValue: string): Promise<void> {
await this.request<void>('POST', `/api/v1/keys/${id}/rotate`, { new_key_value: newKeyValue });
};
/** Revoke an API key */
proto.revokeApiKey = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/keys/${id}`);
};
// --- Account Management (Admin) ---
/** List all accounts (admin only) */
proto.listAccounts = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, params?: { page?: number; page_size?: number; role?: string; status?: string; search?: string }): Promise<PaginatedResponse<AccountPublic>> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.page_size) qs.set('page_size', String(params.page_size));
if (params?.role) qs.set('role', params.role);
if (params?.status) qs.set('status', params.status);
if (params?.search) qs.set('search', params.search);
const query = qs.toString();
return this.request<PaginatedResponse<AccountPublic>>('GET', `/api/v1/accounts${query ? '?' + query : ''}`);
};
/** Get account by ID (admin or self) */
proto.getAccount = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<AccountPublic> {
return this.request<AccountPublic>('GET', `/api/v1/accounts/${id}`);
};
/** Update account (admin or self) */
proto.updateAccount = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateAccountRequest): Promise<AccountPublic> {
return this.request<AccountPublic>('PATCH', `/api/v1/accounts/${id}`, data);
};
/** Update account status (admin only) */
proto.updateAccountStatus = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, status: 'active' | 'disabled' | 'suspended'): Promise<void> {
await this.request<void>('PATCH', `/api/v1/accounts/${id}/status`, { status });
};
// --- API Token Management ---
/** List API tokens for current account */
proto.listTokens = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<TokenInfo[]> {
return this.request<TokenInfo[]>('GET', '/api/v1/tokens');
};
/** Create a new API token */
proto.createToken = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateTokenRequest): Promise<TokenInfo> {
return this.request<TokenInfo>('POST', '/api/v1/tokens', data);
};
/** Revoke an API token */
proto.revokeToken = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/tokens/${id}`);
};
// --- Operation Logs (Admin) ---
/** List operation logs (admin only) */
proto.listOperationLogs = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, params?: { page?: number; page_size?: number }): Promise<OperationLogInfo[]> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.page_size) qs.set('page_size', String(params.page_size));
const query = qs.toString();
return this.request<OperationLogInfo[]>('GET', `/api/v1/logs/operations${query ? '?' + query : ''}`);
};
// --- Dashboard Statistics (Admin) ---
/** Get dashboard statistics (admin only) */
proto.getDashboardStats = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<DashboardStats> {
return this.request<DashboardStats>('GET', '/api/v1/stats/dashboard');
};
// --- Role Management (Admin) ---
/** List all roles */
proto.listRoles = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<RoleInfo[]> {
return this.request<RoleInfo[]>('GET', '/api/v1/roles');
};
/** Get role by ID */
proto.getRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<RoleInfo> {
return this.request<RoleInfo>('GET', `/api/v1/roles/${id}`);
};
/** Create a new role (admin only) */
proto.createRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>('POST', '/api/v1/roles', data);
};
/** Update a role (admin only) */
proto.updateRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string, data: UpdateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>('PUT', `/api/v1/roles/${id}`, data);
};
/** Delete a role (admin only) */
proto.deleteRole = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/roles/${id}`);
};
// --- Permission Templates ---
/** List permission templates */
proto.listPermissionTemplates = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }): Promise<PermissionTemplate[]> {
return this.request<PermissionTemplate[]>('GET', '/api/v1/permission-templates');
};
/** Get permission template by ID */
proto.getPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<PermissionTemplate> {
return this.request<PermissionTemplate>('GET', `/api/v1/permission-templates/${id}`);
};
/** Create a permission template (admin only) */
proto.createPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, data: CreateTemplateRequest): Promise<PermissionTemplate> {
return this.request<PermissionTemplate>('POST', '/api/v1/permission-templates', data);
};
/** Delete a permission template (admin only) */
proto.deletePermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, id: string): Promise<void> {
await this.request<void>('DELETE', `/api/v1/permission-templates/${id}`);
};
/** Apply permission template to accounts (admin only) */
proto.applyPermissionTemplate = async function (this: { request<T>(method: string, path: string, body?: unknown): Promise<T> }, templateId: string, accountIds: string[]): Promise<{ ok: boolean; applied_count: number }> {
return this.request<{ ok: boolean; applied_count: number }>('POST', `/api/v1/permission-templates/${templateId}/apply`, { account_ids: accountIds });
};
}

View File

@@ -17,7 +17,6 @@
* - saas-errors.ts — SaaSApiError class
* - saas-session.ts — session persistence (load/save/clear)
* - saas-auth.ts — login/register/TOTP methods (mixin)
* - saas-admin.ts — admin panel API methods (mixin)
* - saas-relay.ts — relay tasks, chat completion, usage (mixin)
* - saas-prompt.ts — prompt OTA methods (mixin)
* - saas-telemetry.ts — telemetry reporting methods (mixin)
@@ -96,26 +95,6 @@ import type {
SaaSErrorResponse,
RelayTaskInfo,
UsageStats,
ProviderInfo,
CreateProviderRequest,
UpdateProviderRequest,
ModelInfo,
CreateModelRequest,
UpdateModelRequest,
AccountApiKeyInfo,
CreateApiKeyRequest,
AccountPublic,
UpdateAccountRequest,
PaginatedResponse,
TokenInfo,
CreateTokenRequest,
OperationLogInfo,
DashboardStats,
RoleInfo,
CreateRoleRequest,
UpdateRoleRequest,
PermissionTemplate,
CreateTemplateRequest,
PromptCheckResult,
PromptTemplateInfo,
PromptVersionInfo,
@@ -128,7 +107,7 @@ import { createLogger } from './logger';
const saasLog = createLogger('saas-client');
import { installAuthMethods } from './saas-auth';
import { installAdminMethods } from './saas-admin';
import { installRelayMethods } from './saas-relay';
import { installPromptMethods } from './saas-prompt';
import { installTelemetryMethods } from './saas-telemetry';
@@ -140,6 +119,25 @@ export class SaaSClient {
private baseUrl: string;
private token: string | null = null;
/**
* Refresh mutex: shared Promise to prevent concurrent token refresh.
* When multiple requests hit 401 simultaneously, they all await the same
* refresh Promise instead of triggering N parallel refresh calls.
*/
private _refreshPromise: Promise<string> | null = null;
/**
* Thread-safe token refresh — coalesces concurrent refresh attempts into one.
* First caller triggers the actual refresh; subsequent callers await the same Promise.
*/
async refreshMutex(): Promise<string> {
if (this._refreshPromise) return this._refreshPromise;
this._refreshPromise = this.refreshToken().finally(() => {
this._refreshPromise = null;
});
return this._refreshPromise;
}
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
}
@@ -237,7 +235,7 @@ export class SaaSClient {
// 401: 尝试刷新 Token 后重试 (防止递归)
if (response.status === 401 && !this._isAuthEndpoint(path) && !_isRefreshRetry) {
try {
const newToken = await this.refreshToken();
const newToken = await this.refreshMutex();
if (newToken) {
return this.request<T>(method, path, body, timeoutMs, true);
}
@@ -394,7 +392,7 @@ export class SaaSClient {
* Used for template selection during onboarding.
*/
async fetchAvailableTemplates(): Promise<AgentTemplateAvailable[]> {
return this.request<AgentTemplateAvailable[]>('GET', '/agent-templates/available');
return this.request<AgentTemplateAvailable[]>('GET', '/api/v1/agent-templates/available');
}
/**
@@ -402,13 +400,12 @@ export class SaaSClient {
* Returns all fields needed to create an agent from template.
*/
async fetchTemplateFull(id: string): Promise<AgentTemplateFull> {
return this.request<AgentTemplateFull>('GET', `/agent-templates/${id}/full`);
return this.request<AgentTemplateFull>('GET', `/api/v1/agent-templates/${id}/full`);
}
}
// === Install mixin methods ===
installAuthMethods(SaaSClient);
installAdminMethods(SaaSClient);
installRelayMethods(SaaSClient);
installPromptMethods(SaaSClient);
installTelemetryMethods(SaaSClient);
@@ -429,57 +426,6 @@ export interface SaaSClient {
verifyTotp(code: string): Promise<TotpResultResponse>;
disableTotp(password: string): Promise<TotpResultResponse>;
// --- Admin: Providers (saas-admin.ts) ---
listProviders(): Promise<ProviderInfo[]>;
getProvider(id: string): Promise<ProviderInfo>;
createProvider(data: CreateProviderRequest): Promise<ProviderInfo>;
updateProvider(id: string, data: UpdateProviderRequest): Promise<ProviderInfo>;
deleteProvider(id: string): Promise<void>;
// --- Admin: Models (saas-admin.ts) ---
listModelsAdmin(providerId?: string): Promise<ModelInfo[]>;
getModel(id: string): Promise<ModelInfo>;
createModel(data: CreateModelRequest): Promise<ModelInfo>;
updateModel(id: string, data: UpdateModelRequest): Promise<ModelInfo>;
deleteModel(id: string): Promise<void>;
// --- Admin: API Keys (saas-admin.ts) ---
listApiKeys(providerId?: string): Promise<AccountApiKeyInfo[]>;
createApiKey(data: CreateApiKeyRequest): Promise<AccountApiKeyInfo>;
rotateApiKey(id: string, newKeyValue: string): Promise<void>;
revokeApiKey(id: string): Promise<void>;
// --- Admin: Accounts (saas-admin.ts) ---
listAccounts(params?: { page?: number; page_size?: number; role?: string; status?: string; search?: string }): Promise<PaginatedResponse<AccountPublic>>;
getAccount(id: string): Promise<AccountPublic>;
updateAccount(id: string, data: UpdateAccountRequest): Promise<AccountPublic>;
updateAccountStatus(id: string, status: 'active' | 'disabled' | 'suspended'): Promise<void>;
// --- Admin: Tokens (saas-admin.ts) ---
listTokens(): Promise<TokenInfo[]>;
createToken(data: CreateTokenRequest): Promise<TokenInfo>;
revokeToken(id: string): Promise<void>;
// --- Admin: Logs (saas-admin.ts) ---
listOperationLogs(params?: { page?: number; page_size?: number }): Promise<OperationLogInfo[]>;
// --- Admin: Dashboard (saas-admin.ts) ---
getDashboardStats(): Promise<DashboardStats>;
// --- Admin: Roles (saas-admin.ts) ---
listRoles(): Promise<RoleInfo[]>;
getRole(id: string): Promise<RoleInfo>;
createRole(data: CreateRoleRequest): Promise<RoleInfo>;
updateRole(id: string, data: UpdateRoleRequest): Promise<RoleInfo>;
deleteRole(id: string): Promise<void>;
// --- Admin: Permission Templates (saas-admin.ts) ---
listPermissionTemplates(): Promise<PermissionTemplate[]>;
getPermissionTemplate(id: string): Promise<PermissionTemplate>;
createPermissionTemplate(data: CreateTemplateRequest): Promise<PermissionTemplate>;
deletePermissionTemplate(id: string): Promise<void>;
applyPermissionTemplate(templateId: string, accountIds: string[]): Promise<{ ok: boolean; applied_count: number }>;
// --- Relay (saas-relay.ts) ---
listRelayTasks(query?: { status?: string; page?: number; page_size?: number }): Promise<RelayTaskInfo[]>;
getRelayTask(taskId: string): Promise<RelayTaskInfo>;

View File

@@ -55,6 +55,7 @@ export function installRelayMethods(ClientClass: { prototype: any }): void {
_serverReachable: boolean;
_isAuthEndpoint(path: string): boolean;
refreshToken(): Promise<string>;
refreshMutex(): Promise<string>;
},
body: unknown,
signal?: AbortSignal,
@@ -87,7 +88,7 @@ export function installRelayMethods(ClientClass: { prototype: any }): void {
// On 401, attempt token refresh once
if (response.status === 401 && attempt === 0 && !this._isAuthEndpoint('/api/v1/relay/chat/completions')) {
try {
const newToken = await this.refreshToken();
const newToken = await this.refreshMutex();
if (newToken) continue; // Retry with refreshed token
} catch (e) {
logger.debug('Token refresh failed', { error: e });

View File

@@ -299,36 +299,6 @@ function readLocalStorageBackup(key: string): string | null {
}
}
/**
* Synchronous versions for compatibility with existing code
* These use localStorage only and are provided for gradual migration
*/
export const secureStorageSync = {
/**
* Synchronously get a value from localStorage (for migration only)
* @deprecated Use async secureStorage.get() instead
*/
get(key: string): string | null {
return readLocalStorageBackup(key);
},
/**
* Synchronously set a value in localStorage (for migration only)
* @deprecated Use async secureStorage.set() instead
*/
set(key: string, value: string): void {
writeLocalStorageBackup(key, value);
},
/**
* Synchronously delete a value from localStorage (for migration only)
* @deprecated Use async secureStorage.delete() instead
*/
delete(key: string): void {
clearLocalStorageBackup(key);
},
};
// === Device Keys Secure Storage ===
/**

View File

@@ -47,7 +47,6 @@ export type { EncryptedData } from './crypto-utils';
// Re-export secure storage
export {
secureStorage,
secureStorageSync,
isSecureStorageAvailable,
storeDeviceKeys,
getDeviceKeys,

View File

@@ -0,0 +1,54 @@
/**
* ArtifactStore — manages the artifact panel state.
*
* Extracted from chatStore.ts as part of the structured refactor.
* This store has zero external dependencies — the simplest slice to extract.
*
* @see docs/superpowers/specs/2026-04-02-chatstore-refactor-design.md §3.5
*/
import { create } from 'zustand';
import type { ArtifactFile } from '../../components/ai/ArtifactPanel';
// ---------------------------------------------------------------------------
// State interface
// ---------------------------------------------------------------------------
export interface ArtifactState {
/** All artifacts generated in the current session */
artifacts: ArtifactFile[];
/** Currently selected artifact ID */
selectedArtifactId: string | null;
/** Whether the artifact panel is open */
artifactPanelOpen: boolean;
// Actions
addArtifact: (artifact: ArtifactFile) => void;
selectArtifact: (id: string | null) => void;
setArtifactPanelOpen: (open: boolean) => void;
clearArtifacts: () => void;
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export const useArtifactStore = create<ArtifactState>()((set) => ({
artifacts: [],
selectedArtifactId: null,
artifactPanelOpen: false,
addArtifact: (artifact: ArtifactFile) =>
set((state) => ({
artifacts: [...state.artifacts, artifact],
selectedArtifactId: artifact.id,
artifactPanelOpen: true,
})),
selectArtifact: (id: string | null) => set({ selectedArtifactId: id }),
setArtifactPanelOpen: (open: boolean) => set({ artifactPanelOpen: open }),
clearArtifacts: () =>
set({ artifacts: [], selectedArtifactId: null, artifactPanelOpen: false }),
}));

View File

@@ -0,0 +1,368 @@
/**
* ConversationStore — manages conversation lifecycle, agent switching, and persistence.
*
* Extracted from chatStore.ts as part of the structured refactor.
* Responsible for: conversation CRUD, agent list/sync, session/model state.
*
* @see docs/superpowers/specs/2026-04-02-chatstore-refactor-design.md §3.2
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { generateRandomString } from '../lib/crypto-utils';
import { createLogger } from '../lib/logger';
import type { Message } from './chatStore';
const log = createLogger('ConversationStore');
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Conversation {
id: string;
title: string;
messages: Message[];
sessionKey: string | null;
agentId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface Agent {
id: string;
name: string;
icon: string;
color: string;
lastMessage: string;
time: string;
}
export interface AgentProfileLike {
id: string;
name: string;
nickname?: string;
role?: string;
}
// Re-export Message for internal use (avoids circular imports during migration)
export type { Message };
// ---------------------------------------------------------------------------
// State interface
// ---------------------------------------------------------------------------
export interface ConversationState {
conversations: Conversation[];
currentConversationId: string | null;
agents: Agent[];
currentAgent: Agent | null;
sessionKey: string | null;
currentModel: string;
// Actions
newConversation: (currentMessages: Message[]) => Conversation[];
switchConversation: (id: string, currentMessages: Message[]) => {
conversations: Conversation[];
messages: Message[];
sessionKey: string | null;
currentAgent: Agent;
currentConversationId: string;
isStreaming: boolean;
} | null;
deleteConversation: (id: string, currentConversationId: string | null) => {
conversations: Conversation[];
resetMessages: boolean;
};
setCurrentAgent: (agent: Agent, currentMessages: Message[]) => {
conversations: Conversation[];
currentAgent: Agent;
messages: Message[];
sessionKey: string | null;
isStreaming: boolean;
currentConversationId: string | null;
};
syncAgents: (profiles: AgentProfileLike[]) => {
agents: Agent[];
currentAgent: Agent;
};
setCurrentModel: (model: string) => void;
upsertActiveConversation: (currentMessages: Message[]) => Conversation[];
getCurrentConversationId: () => string | null;
getCurrentAgent: () => Agent | null;
getSessionKey: () => string | null;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function generateConvId(): string {
return `conv_${Date.now()}_${generateRandomString(4)}`;
}
function deriveTitle(messages: Message[]): string {
const firstUser = messages.find(m => m.role === 'user');
if (firstUser) {
const text = firstUser.content.trim();
return text.length > 30 ? text.slice(0, 30) + '...' : text;
}
return '新对话';
}
const DEFAULT_AGENT: Agent = {
id: '1',
name: 'ZCLAW',
icon: '🦞',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: '发送消息开始对话',
time: '',
};
export { DEFAULT_AGENT };
export function toChatAgent(profile: AgentProfileLike): Agent {
return {
id: profile.id,
name: profile.name,
icon: profile.nickname?.slice(0, 1) || '🦞',
color: 'bg-gradient-to-br from-orange-500 to-red-500',
lastMessage: profile.role || '新分身',
time: '',
};
}
export function resolveConversationAgentId(agent: Agent | null): string | null {
if (!agent || agent.id === DEFAULT_AGENT.id) {
return null;
}
return agent.id;
}
export function resolveGatewayAgentId(agent: Agent | null): string | undefined {
if (!agent || agent.id === DEFAULT_AGENT.id || agent.id.startsWith('clone_')) {
return undefined;
}
return agent.id;
}
export function resolveAgentForConversation(agentId: string | null, agents: Agent[]): Agent {
if (!agentId) {
return DEFAULT_AGENT;
}
return agents.find((agent) => agent.id === agentId) || DEFAULT_AGENT;
}
function upsertActiveConversation(
conversations: Conversation[],
messages: Message[],
sessionKey: string | null,
currentConversationId: string | null,
currentAgent: Agent | null,
): Conversation[] {
if (messages.length === 0) {
return conversations;
}
const currentId = currentConversationId || generateConvId();
const existingIdx = conversations.findIndex((conv) => conv.id === currentId);
const nextConversation: Conversation = {
id: currentId,
title: deriveTitle(messages),
messages: [...messages],
sessionKey,
agentId: resolveConversationAgentId(currentAgent),
createdAt: existingIdx >= 0 ? conversations[existingIdx].createdAt : new Date(),
updatedAt: new Date(),
};
if (existingIdx >= 0) {
const updated = [...conversations];
updated[existingIdx] = nextConversation;
return updated;
}
return [nextConversation, ...conversations];
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export const useConversationStore = create<ConversationState>()(
persist(
(set, get) => ({
conversations: [],
currentConversationId: null,
agents: [DEFAULT_AGENT],
currentAgent: DEFAULT_AGENT,
sessionKey: null,
currentModel: 'glm-4-flash',
newConversation: (currentMessages: Message[]) => {
const state = get();
const conversations = upsertActiveConversation(
[...state.conversations], currentMessages, state.sessionKey,
state.currentConversationId, state.currentAgent,
);
set({
conversations,
sessionKey: null,
currentConversationId: null,
});
return conversations;
},
switchConversation: (id: string, currentMessages: Message[]) => {
const state = get();
const conversations = upsertActiveConversation(
[...state.conversations], currentMessages, state.sessionKey,
state.currentConversationId, state.currentAgent,
);
const target = conversations.find(c => c.id === id);
if (target) {
set({
conversations,
currentAgent: resolveAgentForConversation(target.agentId, state.agents),
currentConversationId: target.id,
});
return {
conversations,
messages: [...target.messages],
sessionKey: target.sessionKey,
currentAgent: resolveAgentForConversation(target.agentId, state.agents),
currentConversationId: target.id,
isStreaming: false,
};
}
return null;
},
deleteConversation: (id: string, currentConversationId: string | null) => {
const state = get();
const conversations = state.conversations.filter(c => c.id !== id);
const resetMessages = currentConversationId === id;
if (resetMessages) {
set({ conversations, currentConversationId: null, sessionKey: null });
} else {
set({ conversations });
}
return { conversations, resetMessages };
},
setCurrentAgent: (agent: Agent, currentMessages: Message[]) => {
const state = get();
if (state.currentAgent?.id === agent.id) {
set({ currentAgent: agent });
return {
conversations: state.conversations,
currentAgent: agent,
messages: currentMessages,
sessionKey: state.sessionKey,
isStreaming: false,
currentConversationId: state.currentConversationId,
};
}
const conversations = upsertActiveConversation(
[...state.conversations], currentMessages, state.sessionKey,
state.currentConversationId, state.currentAgent,
);
const agentConversation = conversations.find(c =>
c.agentId === agent.id ||
(agent.id === DEFAULT_AGENT.id && c.agentId === null)
);
if (agentConversation) {
set({
conversations,
currentAgent: agent,
currentConversationId: agentConversation.id,
});
return {
conversations,
currentAgent: agent,
messages: [...agentConversation.messages],
sessionKey: agentConversation.sessionKey,
isStreaming: false,
currentConversationId: agentConversation.id,
};
}
set({
conversations,
currentAgent: agent,
sessionKey: null,
currentConversationId: null,
});
return {
conversations,
currentAgent: agent,
messages: [],
sessionKey: null,
isStreaming: false,
currentConversationId: null,
};
},
syncAgents: (profiles: AgentProfileLike[]) => {
const state = get();
const cloneAgents = profiles.length > 0 ? profiles.map(toChatAgent) : [];
const agents = cloneAgents.length > 0
? [DEFAULT_AGENT, ...cloneAgents]
: [DEFAULT_AGENT];
const currentAgent = state.currentConversationId
? resolveAgentForConversation(
state.conversations.find((conv) => conv.id === state.currentConversationId)?.agentId || null,
agents
)
: state.currentAgent
? agents.find((a) => a.id === state.currentAgent?.id) || agents[0]
: agents[0];
set({ agents, currentAgent });
return { agents, currentAgent };
},
setCurrentModel: (model: string) => set({ currentModel: model }),
upsertActiveConversation: (currentMessages: Message[]) => {
const state = get();
const conversations = upsertActiveConversation(
[...state.conversations], currentMessages, state.sessionKey,
state.currentConversationId, state.currentAgent,
);
set({ conversations });
return conversations;
},
getCurrentConversationId: () => get().currentConversationId,
getCurrentAgent: () => get().currentAgent,
getSessionKey: () => get().sessionKey,
}),
{
name: 'zclaw-conversation-storage',
partialize: (state) => ({
conversations: state.conversations,
currentModel: state.currentModel,
currentAgentId: state.currentAgent?.id,
currentConversationId: state.currentConversationId,
}),
onRehydrateStorage: () => (state) => {
if (state?.conversations) {
for (const conv of state.conversations) {
conv.createdAt = new Date(conv.createdAt);
conv.updatedAt = new Date(conv.updatedAt);
for (const msg of conv.messages) {
msg.timestamp = new Date(msg.timestamp);
msg.streaming = false;
msg.optimistic = false;
}
}
}
},
},
),
);

View File

@@ -103,10 +103,6 @@ interface ChatState {
chatMode: ChatModeType;
// Follow-up suggestions
suggestions: string[];
// Artifacts (DeerFlow-inspired)
artifacts: import('../components/ai/ArtifactPanel').ArtifactFile[];
selectedArtifactId: string | null;
artifactPanelOpen: boolean;
addMessage: (message: Message) => void;
updateMessage: (id: string, updates: Partial<Message>) => void;
@@ -128,11 +124,6 @@ interface ChatState {
setSuggestions: (suggestions: string[]) => void;
addSubtask: (messageId: string, task: Subtask) => void;
updateSubtask: (messageId: string, taskId: string, updates: Partial<Subtask>) => void;
// Artifact management (DeerFlow-inspired)
addArtifact: (artifact: import('../components/ai/ArtifactPanel').ArtifactFile) => void;
selectArtifact: (id: string | null) => void;
setArtifactPanelOpen: (open: boolean) => void;
clearArtifacts: () => void;
}
function generateConvId(): string {
@@ -271,10 +262,6 @@ export const useChatStore = create<ChatState>()(
totalOutputTokens: 0,
chatMode: 'thinking' as ChatModeType,
suggestions: [],
artifacts: [],
selectedArtifactId: null,
artifactPanelOpen: false,
addMessage: (message: Message) =>
set((state) => ({ messages: [...state.messages, message] })),
@@ -401,6 +388,10 @@ export const useChatStore = create<ChatState>()(
},
sendMessage: async (content: string) => {
// Concurrency guard: prevent rapid double-click bypassing UI-level isStreaming check.
// React re-render is async — two clicks within the same frame both read isStreaming=false.
if (get().isStreaming) return;
const { addMessage, currentAgent, sessionKey } = get();
// Clear stale suggestions when user sends a new message
set({ suggestions: [] });
@@ -436,27 +427,10 @@ export const useChatStore = create<ChatState>()(
// Context compaction is handled by the kernel (AgentLoop with_compaction_threshold).
// Frontend no longer performs duplicate compaction — see crates/zclaw-runtime/src/compaction.rs.
// Build memory-enhanced content using layered context (L0/L1/L2)
let enhancedContent = content;
try {
const contextResult = await intelligenceClient.memory.buildContext(
agentId,
content,
500, // token budget for memory context
);
if (contextResult.systemPromptAddition) {
const systemPrompt = await intelligenceClient.identity.buildPrompt(
agentId,
contextResult.systemPromptAddition,
);
if (systemPrompt) {
enhancedContent = `<context>\n${systemPrompt}\n</context>\n\n${content}`;
}
}
} catch (err) {
log.warn('Memory context build failed, proceeding without:', err);
}
// Memory context injection is handled by backend MemoryMiddleware (before_completion),
// which injects relevant memories into the system prompt. Frontend must NOT duplicate
// this by embedding old conversation memories into the user message content — that causes
// context leaking (old conversations appearing in new chat thinking/output).
// Add user message (original content for display)
// Mark as optimistic -- will be cleared when server confirms via onComplete
@@ -504,7 +478,7 @@ export const useChatStore = create<ChatState>()(
// Try streaming first (ZCLAW WebSocket)
const result = await client.chatStream(
enhancedContent,
content,
{
onDelta: (delta: string) => {
// Update message content directly (works for both KernelClient and GatewayClient)
@@ -516,6 +490,15 @@ export const useChatStore = create<ChatState>()(
),
}));
},
onThinkingDelta: (delta: string) => {
set((s) => ({
messages: s.messages.map((m) =>
m.id === assistantId
? { ...m, thinkingContent: (m.thinkingContent || '') + delta }
: m
),
}));
},
onTool: (tool: string, input: string, output: string) => {
const step: ToolCallStep = {
id: `step_${Date.now()}_${generateRandomString(4)}`,
@@ -732,20 +715,6 @@ export const useChatStore = create<ChatState>()(
),
})),
// Artifact management (DeerFlow-inspired)
addArtifact: (artifact) =>
set((state) => ({
artifacts: [...state.artifacts, artifact],
selectedArtifactId: artifact.id,
artifactPanelOpen: true,
})),
selectArtifact: (id) => set({ selectedArtifactId: id }),
setArtifactPanelOpen: (open) => set({ artifactPanelOpen: open }),
clearArtifacts: () => set({ artifacts: [], selectedArtifactId: null, artifactPanelOpen: false }),
initStreamListener: () => {
const client = getClient();

View File

@@ -0,0 +1,223 @@
/**
* Classroom Store
*
* Zustand store for classroom generation, chat messages,
* and active classroom data. Uses Tauri invoke for backend calls.
*/
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type {
Classroom,
ClassroomChatMessage,
} from '../types/classroom';
import { createLogger } from '../lib/logger';
const log = createLogger('ClassroomStore');
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface GenerationRequest {
topic: string;
document?: string;
style?: string;
level?: string;
targetDurationMinutes?: number;
sceneCount?: number;
customInstructions?: string;
language?: string;
}
export interface GenerationResult {
classroomId: string;
}
export interface GenerationProgressEvent {
topic: string;
stage: string;
progress: number;
activity: string;
}
// ---------------------------------------------------------------------------
// Store interface
// ---------------------------------------------------------------------------
export interface ClassroomState {
/** Currently generating classroom */
generating: boolean;
/** Generation progress stage */
progressStage: string | null;
progressPercent: number;
progressActivity: string;
/** Topic being generated (used for cancel) */
generatingTopic: string | null;
/** The active classroom */
activeClassroom: Classroom | null;
/** Whether the ClassroomPlayer overlay is open */
classroomOpen: boolean;
/** Chat messages for the active classroom */
chatMessages: ClassroomChatMessage[];
/** Whether chat is loading */
chatLoading: boolean;
/** Generation error message */
error: string | null;
}
export interface ClassroomActions {
startGeneration: (request: GenerationRequest) => Promise<string>;
cancelGeneration: () => void;
loadClassroom: (id: string) => Promise<void>;
setActiveClassroom: (classroom: Classroom) => void;
openClassroom: () => void;
closeClassroom: () => void;
sendChatMessage: (message: string, sceneContext?: string) => Promise<void>;
clearError: () => void;
reset: () => void;
}
export type ClassroomStore = ClassroomState & ClassroomActions;
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export const useClassroomStore = create<ClassroomStore>()((set, get) => ({
generating: false,
progressStage: null,
progressPercent: 0,
progressActivity: '',
generatingTopic: null,
activeClassroom: null,
classroomOpen: false,
chatMessages: [],
chatLoading: false,
error: null,
startGeneration: async (request) => {
set({
generating: true,
progressStage: 'agent_profiles',
progressPercent: 0,
progressActivity: 'Starting generation...',
generatingTopic: request.topic,
error: null,
});
// Listen for progress events from Rust
const unlisten = await listen<GenerationProgressEvent>('classroom:progress', (event) => {
const { stage, progress, activity } = event.payload;
set({
progressStage: stage,
progressPercent: progress,
progressActivity: activity,
});
});
try {
const result = await invoke<GenerationResult>('classroom_generate', { request });
set({ generating: false });
await get().loadClassroom(result.classroomId);
set({ classroomOpen: true });
return result.classroomId;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.error('Generation failed', { error: msg });
set({ generating: false, error: msg });
throw e;
} finally {
unlisten();
}
},
cancelGeneration: () => {
const topic = get().generatingTopic;
if (topic) {
invoke('classroom_cancel_generation', { topic }).catch(() => {});
}
set({ generating: false, generatingTopic: null });
},
loadClassroom: async (id) => {
try {
const classroom = await invoke<Classroom>('classroom_get', { classroomId: id });
set({ activeClassroom: classroom, chatMessages: [] });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.error('Failed to load classroom', { error: msg });
set({ error: msg });
}
},
setActiveClassroom: (classroom) => {
set({ activeClassroom: classroom, chatMessages: [], classroomOpen: true });
},
openClassroom: () => {
set({ classroomOpen: true });
},
closeClassroom: () => {
set({ classroomOpen: false });
},
sendChatMessage: async (message, sceneContext) => {
const classroom = get().activeClassroom;
if (!classroom) {
log.error('No active classroom');
return;
}
// Create a local user message for display
const userMsg: ClassroomChatMessage = {
id: `user-${Date.now()}`,
agentId: 'user',
agentName: '你',
agentAvatar: '👤',
content: message,
timestamp: Date.now(),
role: 'user',
color: '#3b82f6',
};
set((state) => ({
chatMessages: [...state.chatMessages, userMsg],
chatLoading: true,
}));
try {
const responses = await invoke<ClassroomChatMessage[]>('classroom_chat', {
request: {
classroomId: classroom.id,
userMessage: message,
sceneContext: sceneContext ?? null,
},
});
set((state) => ({
chatMessages: [...state.chatMessages, ...responses],
chatLoading: false,
}));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
log.error('Chat failed', { error: msg });
set({ chatLoading: false });
}
},
clearError: () => set({ error: null }),
reset: () => set({
generating: false,
progressStage: null,
progressPercent: 0,
progressActivity: '',
activeClassroom: null,
classroomOpen: false,
chatMessages: [],
chatLoading: false,
error: null,
}),
}));

View File

@@ -55,6 +55,17 @@ export type {
SessionOptions,
} from '../components/BrowserHand/templates/types';
// === Classroom Store ===
export { useClassroomStore } from './classroomStore';
export type {
ClassroomState,
ClassroomActions,
ClassroomStore,
GenerationRequest,
GenerationResult,
GenerationProgressEvent,
} from './classroomStore';
// === Store Initialization ===
import { getClient } from './connectionStore';

View File

@@ -536,6 +536,27 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
// Update last sync timestamp
localStorage.setItem(lastSyncKey, result.pulled_at);
log.info(`Synced ${result.configs.length} config items from SaaS`);
// Propagate Kernel-relevant configs to Rust backend
const kernelCategories = ['agent', 'llm'];
const kernelConfigs = result.configs.filter(
(c) => kernelCategories.includes(c.category) && c.value !== null
);
if (kernelConfigs.length > 0) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('kernel_apply_saas_config', {
configs: kernelConfigs.map((c) => ({
category: c.category,
key: c.key,
value: c.value,
})),
});
log.info(`Propagated ${kernelConfigs.length} Kernel configs to Rust backend`);
} catch (invokeErr: unknown) {
log.warn('Failed to propagate configs to Kernel (non-fatal):', invokeErr);
}
}
} catch (err: unknown) {
log.warn('Failed to sync config from SaaS:', err);
}

133
desktop/src/types/chat.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Unified chat types for the ZCLAW desktop chat system.
*
* This module consolidates types previously scattered across
* chatStore.ts, session.ts, and component-level type exports.
*/
// --- Re-export from component modules for backward compat ---
export type { ChatModeType, ChatModeConfig } from '../components/ai/ChatMode';
export { CHAT_MODES } from '../components/ai/ChatMode';
export type { Subtask } from '../components/ai/TaskProgress';
export type { ToolCallStep } from '../components/ai/ToolCallChain';
export type { ArtifactFile } from '../components/ai/ArtifactPanel';
// --- Core chat types ---
export interface MessageFile {
name: string;
path?: string;
size?: number;
type?: string;
}
export interface CodeBlock {
language?: string;
filename?: string;
content?: string;
}
/**
* Unified message type for all chat messages.
* Supersedes both ChatStore.Message (6 roles) and SessionMessage (3 roles).
*/
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'tool' | 'hand' | 'workflow' | 'system';
content: string;
timestamp: Date;
streaming?: boolean;
optimistic?: boolean;
runId?: string;
// Thinking/reasoning
thinkingContent?: string;
// Error & retry
error?: string;
/** Preserved original content before error overlay, used for retry */
originalContent?: string;
// Tool call chain
toolSteps?: import('../components/ai/ToolCallChain').ToolCallStep[];
toolName?: string;
toolInput?: string;
toolOutput?: string;
// Hand event fields
handName?: string;
handStatus?: string;
handResult?: unknown;
// Workflow event fields
workflowId?: string;
workflowStep?: string;
workflowStatus?: string;
workflowResult?: unknown;
// Sub-agent task tracking
subtasks?: import('../components/ai/TaskProgress').Subtask[];
// Attachments
files?: MessageFile[];
codeBlocks?: CodeBlock[];
// Metadata
metadata?: {
inputTokens?: number;
outputTokens?: number;
model?: string;
};
}
/**
* A conversation container with messages, session key, and agent binding.
*/
export interface Conversation {
id: string;
title: string;
messages: ChatMessage[];
sessionKey: string | null;
agentId: string | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Lightweight agent representation for the chat UI sidebar.
* Distinct from types/agent.ts Agent (which is a backend entity).
*/
export interface ChatAgent {
id: string;
name: string;
icon: string;
color: string;
lastMessage: string;
time: string;
}
/**
* Minimal profile shape for agent sync operations.
*/
export interface AgentProfileLike {
id: string;
name: string;
nickname?: string;
role?: string;
}
/**
* Token usage reported on stream completion.
*/
export interface TokenUsage {
inputTokens: number;
outputTokens: number;
}
/**
* Context passed when sending a message.
*/
export interface SendMessageContext {
files?: MessageFile[];
parentMessageId?: string;
}

View File

@@ -0,0 +1,181 @@
/**
* Classroom Generation Types
*
* Mirror of Rust `zclaw-kernel::generation` module types.
* Used by classroom player, hooks, and store.
*/
// --- Agent Types ---
export enum AgentRole {
Teacher = 'teacher',
Assistant = 'assistant',
Student = 'student',
}
export interface AgentProfile {
id: string;
name: string;
role: AgentRole;
persona: string;
avatar: string;
color: string;
allowedActions: string[];
priority: number;
}
// --- Scene Types ---
export enum SceneType {
Slide = 'slide',
Quiz = 'quiz',
Interactive = 'interactive',
Pbl = 'pbl',
Discussion = 'discussion',
Media = 'media',
Text = 'text',
}
export enum GenerationStage {
AgentProfiles = 'agent_profiles',
Outline = 'outline',
Scene = 'scene',
Complete = 'complete',
}
// --- Scene Actions ---
export type SceneAction =
| { type: 'speech'; text: string; agentRole: string }
| { type: 'whiteboard_draw_text'; x: number; y: number; text: string; fontSize?: number; color?: string }
| { type: 'whiteboard_draw_shape'; shape: string; x: number; y: number; width: number; height: number; fill?: string }
| { type: 'whiteboard_draw_chart'; chartType: string; data: unknown; x: number; y: number; width: number; height: number }
| { type: 'whiteboard_draw_latex'; latex: string; x: number; y: number }
| { type: 'whiteboard_clear' }
| { type: 'slideshow_spotlight'; elementId: string }
| { type: 'slideshow_next' }
| { type: 'quiz_show'; quizId: string }
| { type: 'discussion'; topic: string; durationSeconds?: number };
// --- Content Structures ---
export interface SceneContent {
title: string;
sceneType: SceneType;
content: Record<string, unknown>;
actions: SceneAction[];
durationSeconds: number;
notes?: string;
}
export interface OutlineItem {
id: string;
title: string;
description: string;
sceneType: SceneType;
keyPoints: string[];
durationSeconds: number;
dependencies: string[];
}
export interface GeneratedScene {
id: string;
outlineId: string;
content: SceneContent;
order: number;
}
// --- Teaching Config ---
export enum TeachingStyle {
Lecture = 'lecture',
Discussion = 'discussion',
Pbl = 'pbl',
Flipped = 'flipped',
Socratic = 'socratic',
}
export enum DifficultyLevel {
Beginner = 'beginner',
Intermediate = 'intermediate',
Advanced = 'advanced',
Expert = 'expert',
}
// --- Classroom ---
export interface ClassroomMetadata {
generatedAt: number;
sourceDocument?: string;
model?: string;
version: string;
custom: Record<string, unknown>;
}
export interface Classroom {
id: string;
title: string;
description: string;
topic: string;
style: TeachingStyle;
level: DifficultyLevel;
totalDuration: number;
objectives: string[];
scenes: GeneratedScene[];
agents: AgentProfile[];
metadata: ClassroomMetadata;
outline?: string;
}
// --- Generation Request ---
export interface GenerationRequest {
topic: string;
document?: string;
style: TeachingStyle;
level: DifficultyLevel;
targetDurationMinutes: number;
sceneCount?: number;
customInstructions?: string;
language?: string;
}
// --- Generation Progress ---
export interface GenerationProgress {
stage: GenerationStage;
progress: number;
activity: string;
itemsProgress?: [number, number];
etaSeconds?: number;
}
// --- Chat Types ---
export interface ClassroomChatMessage {
id: string;
agentId: string;
agentName: string;
agentAvatar: string;
content: string;
timestamp: number;
role: string;
color: string;
}
export interface ClassroomChatState {
messages: ClassroomChatMessage[];
active: boolean;
}
export interface ClassroomChatRequest {
classroomId: string;
userMessage: string;
agents: AgentProfile[];
sceneContext?: string;
history: ClassroomChatMessage[];
}
export interface ClassroomChatResponse {
responses: ClassroomChatMessage[];
}

View File

@@ -156,3 +156,44 @@ export {
filterByStatus,
searchAutomationItems,
} from './automation';
// Classroom Types
export type {
AgentProfile,
SceneContent,
GeneratedScene,
ClassroomMetadata,
Classroom,
GenerationRequest,
GenerationProgress,
ClassroomChatMessage,
ClassroomChatState,
ClassroomChatRequest,
ClassroomChatResponse,
SceneAction,
OutlineItem,
} from './classroom';
export {
AgentRole,
SceneType,
GenerationStage,
TeachingStyle,
DifficultyLevel,
} from './classroom';
// Chat Types (unified)
export type {
ChatMessage,
Conversation,
ChatAgent,
AgentProfileLike,
TokenUsage,
SendMessageContext,
MessageFile,
CodeBlock,
} from './chat';
export {
CHAT_MODES,
} from './chat';