release: bump version to 0.64.0

This commit is contained in:
coso
2026-02-12 20:30:57 +08:00
parent 6c53cd6ef5
commit 8da33ec9a0
88 changed files with 6412 additions and 953 deletions
-31
View File
@@ -1,31 +0,0 @@
# 批量任务支持实施计划
## Stage 1: 创建 Scheduler Crate
**Goal**: 创建基础调度器模块,定义批量任务数据结构
**Success Criteria**: Crate 编译通过,基础数据结构定义完成
**Tests**: 单元测试验证数据结构序列化
**Status**: Completed
## Stage 2: 实现批量任务执行器
**Goal**: 实现 BatchTaskExecutor,支持并发控制和 Orchestrator Fallback
**Success Criteria**: 执行器能够处理批量任务,支持并发控制
**Tests**: 集成测试验证批量任务执行逻辑
**Status**: In Progress
## Stage 3: 创建 Batch API 端点
**Goal**: 实现批量任务的 REST API
**Success Criteria**: POST /api/batch/tasks 和 GET /api/batch/tasks/:id 可用
**Tests**: API 测试验证创建和查询功能
**Status**: Completed
## Stage 4: 数据库持久化
**Goal**: 实现批量任务和模板的数据库存储
**Success Criteria**: 数据可以持久化到 SQLite
**Tests**: DAO 层单元测试
**Status**: In Progress
## Stage 5: 前端页面实现
**Goal**: 创建批量任务管理界面
**Success Criteria**: 任务列表、创建页面、结果展示完整
**Tests**: 手动测试 UI 交互流程
**Status**: Not Started
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.63.0",
"version": "0.64.0",
"type": "module",
"repository": {
"type": "git",
+15 -15
View File
@@ -6621,7 +6621,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"anyhow",
"arboard",
@@ -6717,7 +6717,7 @@ dependencies = [
[[package]]
name = "proxycast-agent"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"aster",
"async-trait",
@@ -6740,7 +6740,7 @@ dependencies = [
[[package]]
name = "proxycast-config"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6756,7 +6756,7 @@ dependencies = [
[[package]]
name = "proxycast-core"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"async-trait",
"axum 0.7.9",
@@ -6795,7 +6795,7 @@ dependencies = [
[[package]]
name = "proxycast-credential"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -6813,7 +6813,7 @@ dependencies = [
[[package]]
name = "proxycast-infra"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -6833,7 +6833,7 @@ dependencies = [
[[package]]
name = "proxycast-mcp"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"async-trait",
"glob",
@@ -6848,7 +6848,7 @@ dependencies = [
[[package]]
name = "proxycast-processor"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6867,7 +6867,7 @@ dependencies = [
[[package]]
name = "proxycast-providers"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"anyhow",
"async-stream",
@@ -6919,7 +6919,7 @@ dependencies = [
[[package]]
name = "proxycast-server"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"async-stream",
"axum 0.7.9",
@@ -6958,7 +6958,7 @@ dependencies = [
[[package]]
name = "proxycast-server-utils"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -6973,7 +6973,7 @@ dependencies = [
[[package]]
name = "proxycast-services"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"anyhow",
"aster",
@@ -7014,7 +7014,7 @@ dependencies = [
[[package]]
name = "proxycast-skills"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -7030,7 +7030,7 @@ dependencies = [
[[package]]
name = "proxycast-terminal"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7057,7 +7057,7 @@ dependencies = [
[[package]]
name = "proxycast-websocket"
version = "0.63.0"
version = "0.64.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.63.0"
version = "0.64.0"
edition = "2021"
authors = ["you"]
repository = "https://github.com/aiclientproxy/proxycast"
@@ -181,7 +181,7 @@ version = "2.4"
[package]
name = "proxycast"
version = "0.63.0"
version = "0.64.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -289,6 +289,39 @@ impl BatchTaskDao {
Ok(())
}
/// 更新批量任务结果、状态和时间戳
///
/// 用于执行器在每个子任务完成后实时更新数据库
pub fn update_results(
db: &DbConnection,
id: &Uuid,
status: BatchTaskStatus,
results: &[super::batch::TaskResult],
started_at: Option<chrono::DateTime<chrono::Utc>>,
completed_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<()> {
let conn = db.lock().unwrap();
let results_json = if results.is_empty() {
None
} else {
Some(serde_json::to_string(results)?)
};
conn.execute(
"UPDATE batch_tasks SET status = ?1, results_json = ?2, started_at = ?3, completed_at = ?4 WHERE id = ?5",
params![
serde_json::to_string(&status)?,
results_json,
started_at.map(|t| t.to_rfc3339()),
completed_at.map(|t| t.to_rfc3339()),
id.to_string(),
],
)?;
Ok(())
}
}
/// 模板 DAO
+133 -2
View File
@@ -23,6 +23,7 @@ use axum::{
Json,
};
use serde_json::json;
use std::future::Future;
use crate::client_detector::ClientType;
use crate::{record_request_telemetry, record_token_usage, AppState};
@@ -124,6 +125,119 @@ async fn select_credential_for_request(
}
}
async fn call_with_single_provider_resilience<F, Fut>(
state: &AppState,
request_id: &str,
provider_label: &str,
is_stream: bool,
mut operation: F,
) -> Response
where
F: FnMut() -> Fut,
Fut: Future<Output = Response>,
{
let retrier = state.processor.retrier.clone();
let timeout_controller = state.processor.timeout.clone();
let max_retries = if is_stream {
0
} else {
retrier.config().max_retries
};
let total_attempts = max_retries + 1;
let mut attempt = 0u32;
loop {
attempt += 1;
let response = match timeout_controller.execute_with_timeout(operation()).await {
Ok(resp) => resp,
Err(timeout_err) => {
if attempt <= max_retries {
let delay = retrier.backoff_delay(attempt - 1);
state.logs.write().await.add(
"warn",
&format!(
"[RETRY] request_id={} provider={} attempt={}/{} timeout={} delay_ms={}",
request_id,
provider_label,
attempt,
total_attempts,
timeout_err,
delay.as_millis()
),
);
tokio::time::sleep(delay).await;
continue;
}
state.logs.write().await.add(
"error",
&format!(
"[TIMEOUT] request_id={} provider={} attempts={} error={}",
request_id, provider_label, attempt, timeout_err
),
);
return (
StatusCode::GATEWAY_TIMEOUT,
Json(json!({
"error": {
"type": "timeout_error",
"code": "provider_timeout",
"message": format!("Provider request timeout: {}", timeout_err)
}
})),
)
.into_response();
}
};
let status_code = response.status().as_u16();
let should_retry = attempt <= max_retries && retrier.config().is_retryable(status_code);
if should_retry {
let delay = retrier.backoff_delay(attempt - 1);
if status_code == StatusCode::TOO_MANY_REQUESTS.as_u16() {
state.logs.write().await.add(
"warn",
&format!(
"[QUOTA] request_id={} provider={} attempt={}/{} status=429",
request_id, provider_label, attempt, total_attempts
),
);
}
state.logs.write().await.add(
"warn",
&format!(
"[RETRY] request_id={} provider={} attempt={}/{} status={} delay_ms={}",
request_id,
provider_label,
attempt,
total_attempts,
status_code,
delay.as_millis()
),
);
tokio::time::sleep(delay).await;
continue;
}
if attempt > 1 {
state.logs.write().await.add(
"info",
&format!(
"[RETRY] request_id={} provider={} completed attempts={} final_status={}",
request_id, provider_label, attempt, status_code
),
);
}
return response;
}
}
// ============================================================================
// Provider 选择辅助函数
// ============================================================================
@@ -393,7 +507,15 @@ pub async fn chat_completions(
// **Validates: Requirements 2.1, 2.3, 2.5**
eprintln!("[CHAT_COMPLETIONS] 调用 Provider: {}", cred.provider_type);
let response = call_provider_openai(&state, &cred, &request, None).await;
let provider_label = cred.provider_type.to_string();
let response = call_with_single_provider_resilience(
&state,
&ctx.request_id,
&provider_label,
request.stream,
|| async { call_provider_openai(&state, &cred, &request, None).await },
)
.await;
eprintln!(
"[CHAT_COMPLETIONS] Provider 响应状态: {}",
response.status()
@@ -411,6 +533,7 @@ pub async fn chat_completions(
// 如果成功且需要 Flow 捕获,提取响应体内容和响应头
// 注意:非流式响应需要读取 body,所以必须在这里处理
return response;
}
// 回退到旧的单凭证模式(仅当选择的 Provider 是 Kiro 时)
@@ -909,7 +1032,15 @@ pub async fn anthropic_messages(
// 检查是否需要拦截请求
// **Validates: Requirements 2.1, 2.3, 2.5**
let response = call_provider_anthropic(&state, &cred, &request, None).await;
let provider_label = cred.provider_type.to_string();
let response = call_with_single_provider_resilience(
&state,
&ctx.request_id,
&provider_label,
request.stream,
|| async { call_provider_anthropic(&state, &cred, &request, None).await },
)
.await;
// 记录请求统计
let is_success = response.status().is_success();
@@ -146,8 +146,10 @@ pub async fn create_batch_task(
),
);
// TODO: 启动异步执行任务
// 这里需要集成 BatchTaskExecutor
// 启动异步执行任务
if let Some(executor) = state.batch_executor.read().await.as_ref() {
executor.start_batch(batch_id).await;
}
// 返回响应
(
@@ -256,23 +258,87 @@ pub async fn list_batch_tasks(State(state): State<AppState>) -> Response {
/// DELETE /api/batch/tasks/:id - 取消批量任务
pub async fn cancel_batch_task(State(state): State<AppState>, Path(id): Path<Uuid>) -> Response {
// TODO: 取消批量任务
let db = match &state.db {
Some(db) => db,
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": "数据库未初始化",
"type": "database_error"
}
})),
)
.into_response();
}
};
// 检查任务是否存在
let batch_task = match BatchTaskDao::get_by_id(db, &id) {
Ok(Some(task)) => task,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": {
"message": format!("批量任务不存在: {}", id),
"type": "not_found"
}
})),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": format!("查询批量任务失败: {}", e),
"type": "database_error"
}
})),
)
.into_response();
}
};
// 只能取消运行中的任务
if batch_task.status != proxycast_scheduler::BatchTaskStatus::Running
&& batch_task.status != proxycast_scheduler::BatchTaskStatus::Pending
{
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": {
"message": format!("任务状态为 {:?},无法取消", batch_task.status),
"type": "invalid_state"
}
})),
)
.into_response();
}
// 通过执行器取消
let cancelled = if let Some(executor) = state.batch_executor.read().await.as_ref() {
executor.cancel_batch(&id).await
} else {
false
};
if !cancelled {
// 如果执行器中没有找到(可能还没开始执行),直接更新 DB 状态
let _ =
BatchTaskDao::update_status(db, &id, proxycast_scheduler::BatchTaskStatus::Cancelled);
}
state
.logs
.write()
.await
.add("info", &format!("[BATCH] 取消批量任务: id={}", id));
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": {
"message": format!("批量任务不存在: {}", id),
"type": "not_found"
}
})),
)
.into_response()
(StatusCode::OK, Json(serde_json::json!({"cancelled": true}))).into_response()
}
/// POST /api/batch/templates - 创建任务模板
@@ -0,0 +1,420 @@
//! 批量任务执行器
//!
//! 负责异步执行批量任务,支持并发控制、重试、超时和取消
use std::collections::HashMap;
use std::sync::Arc;
use axum::http::StatusCode;
use proxycast_core::models::openai::{
ChatCompletionRequest, ChatCompletionResponse, ChatMessage, MessageContent,
};
use proxycast_scheduler::{BatchTaskDao, BatchTaskStatus, TaskResult, TemplateDao, TokenUsage};
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::AppState;
/// 批量任务执行器
#[derive(Clone)]
pub struct BatchTaskExecutor {
state: AppState,
cancel_tokens: Arc<RwLock<HashMap<Uuid, CancellationToken>>>,
}
impl BatchTaskExecutor {
pub fn new(state: AppState) -> Self {
Self {
state,
cancel_tokens: Arc::new(RwLock::new(HashMap::new())),
}
}
/// 启动批量任务执行(spawn 后台任务)
pub async fn start_batch(&self, batch_id: Uuid) {
let cancel_token = CancellationToken::new();
self.cancel_tokens
.write()
.await
.insert(batch_id, cancel_token.clone());
let state = self.state.clone();
let cancel_tokens = self.cancel_tokens.clone();
tokio::spawn(async move {
Self::execute_batch(state, batch_id, cancel_token).await;
// 执行完毕后清理 cancel token
cancel_tokens.write().await.remove(&batch_id);
});
}
/// 取消运行中的批量任务
pub async fn cancel_batch(&self, batch_id: &Uuid) -> bool {
if let Some(token) = self.cancel_tokens.read().await.get(batch_id) {
token.cancel();
true
} else {
false
}
}
/// 核心执行逻辑
async fn execute_batch(state: AppState, batch_id: Uuid, cancel_token: CancellationToken) {
let db = match &state.db {
Some(db) => db,
None => {
tracing::error!("[BATCH] 数据库未初始化, batch_id={}", batch_id);
return;
}
};
// 1. 从 DB 加载 BatchTask
let mut batch_task = match BatchTaskDao::get_by_id(db, &batch_id) {
Ok(Some(task)) => task,
Ok(None) => {
tracing::error!("[BATCH] 批量任务不存在: {}", batch_id);
return;
}
Err(e) => {
tracing::error!("[BATCH] 加载批量任务失败: {}", e);
return;
}
};
// 2. 加载模板
let template = match TemplateDao::get_by_id(db, &batch_task.template_id) {
Ok(Some(t)) => t,
Ok(None) => {
tracing::error!("[BATCH] 模板不存在: {}", batch_task.template_id);
let _ = BatchTaskDao::update_status(db, &batch_id, BatchTaskStatus::Failed);
return;
}
Err(e) => {
tracing::error!("[BATCH] 加载模板失败: {}", e);
let _ = BatchTaskDao::update_status(db, &batch_id, BatchTaskStatus::Failed);
return;
}
};
// 3. 更新状态为 Running
let now = chrono::Utc::now();
batch_task.status = BatchTaskStatus::Running;
batch_task.started_at = Some(now);
let _ = BatchTaskDao::update_results(
db,
&batch_id,
BatchTaskStatus::Running,
&batch_task.results,
batch_task.started_at,
None,
);
tracing::info!(
"[BATCH] 开始执行批量任务: id={}, name={}, task_count={}",
batch_id,
batch_task.name,
batch_task.tasks.len()
);
// 4. 用 Semaphore 控制并发
let concurrency = batch_task.options.concurrency.max(1);
let semaphore = Arc::new(tokio::sync::Semaphore::new(concurrency));
let results = Arc::new(RwLock::new(Vec::<TaskResult>::new()));
let mut handles = Vec::new();
for task_def in &batch_task.tasks {
let task_id = task_def.id.unwrap_or_else(Uuid::new_v4);
let variables = task_def.variables.clone();
let sem = semaphore.clone();
let state = state.clone();
let cancel = cancel_token.clone();
let results = results.clone();
let model = template.model.clone();
let system_prompt = template.system_prompt.clone();
let user_message = template.render_user_message(&variables);
let temperature = template.temperature;
let max_tokens = template.max_tokens;
let retry_count = batch_task.options.retry_count;
let timeout_secs = batch_task.options.timeout_seconds;
let db_clone = db.clone();
let batch_id_clone = batch_id;
let handle = tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
// 检查取消
if cancel.is_cancelled() {
let result = TaskResult {
task_id,
status: proxycast_scheduler::BatchTaskStatus2::Cancelled,
content: None,
error: Some("任务已取消".to_string()),
usage: TokenUsage::default(),
started_at: chrono::Utc::now(),
completed_at: Some(chrono::Utc::now()),
};
results.write().await.push(result);
return;
}
let result = Self::execute_single_task(
&state,
task_id,
&model,
system_prompt.as_deref(),
&user_message,
temperature,
max_tokens,
retry_count,
timeout_secs,
&cancel,
)
.await;
results.write().await.push(result);
// 实时更新 DB 进度
let current_results = results.read().await.clone();
let _ = BatchTaskDao::update_results(
&db_clone,
&batch_id_clone,
BatchTaskStatus::Running,
&current_results,
None,
None,
);
});
handles.push(handle);
}
// 等待所有任务完成
for handle in handles {
let _ = handle.await;
}
// 5. 计算最终状态
let final_results = results.read().await.clone();
let total = batch_task.tasks.len();
let completed = final_results
.iter()
.filter(|r| r.status == proxycast_scheduler::BatchTaskStatus2::Completed)
.count();
let cancelled = final_results
.iter()
.filter(|r| r.status == proxycast_scheduler::BatchTaskStatus2::Cancelled)
.count();
let final_status = if cancel_token.is_cancelled() {
BatchTaskStatus::Cancelled
} else if completed == total {
BatchTaskStatus::Completed
} else if completed == 0 {
BatchTaskStatus::Failed
} else {
BatchTaskStatus::PartiallyCompleted
};
let completed_at = chrono::Utc::now();
let _ = BatchTaskDao::update_results(
db,
&batch_id,
final_status,
&final_results,
batch_task.started_at,
Some(completed_at),
);
tracing::info!(
"[BATCH] 批量任务完成: id={}, status={:?}, completed={}/{}, cancelled={}",
batch_id,
final_status,
completed,
total,
cancelled
);
}
/// 执行单个子任务(含重试和超时)
async fn execute_single_task(
state: &AppState,
task_id: Uuid,
model: &str,
system_prompt: Option<&str>,
user_message: &str,
temperature: Option<f32>,
max_tokens: Option<u32>,
retry_count: usize,
timeout_secs: u64,
cancel: &CancellationToken,
) -> TaskResult {
let started_at = chrono::Utc::now();
let max_attempts = retry_count + 1;
for attempt in 0..max_attempts {
if cancel.is_cancelled() {
return TaskResult {
task_id,
status: proxycast_scheduler::BatchTaskStatus2::Cancelled,
content: None,
error: Some("任务已取消".to_string()),
usage: TokenUsage::default(),
started_at,
completed_at: Some(chrono::Utc::now()),
};
}
if attempt > 0 {
tracing::info!(
"[BATCH] 重试任务: task_id={}, attempt={}/{}",
task_id,
attempt + 1,
max_attempts
);
}
// 构建请求
let mut messages = Vec::new();
if let Some(sys) = system_prompt {
messages.push(ChatMessage {
role: "system".to_string(),
content: Some(MessageContent::Text(sys.to_string())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
messages.push(ChatMessage {
role: "user".to_string(),
content: Some(MessageContent::Text(user_message.to_string())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
let request = ChatCompletionRequest {
model: model.to_string(),
messages,
temperature,
max_tokens,
top_p: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_effort: None,
};
// 调用 LLM(带超时)
let result = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
Self::call_llm(state, &request),
)
.await;
match result {
Ok(Ok((content, usage))) => {
return TaskResult {
task_id,
status: proxycast_scheduler::BatchTaskStatus2::Completed,
content: Some(content),
error: None,
usage,
started_at,
completed_at: Some(chrono::Utc::now()),
};
}
Ok(Err(e)) => {
if attempt == max_attempts - 1 {
return TaskResult {
task_id,
status: proxycast_scheduler::BatchTaskStatus2::Failed,
content: None,
error: Some(e),
usage: TokenUsage::default(),
started_at,
completed_at: Some(chrono::Utc::now()),
};
}
// 重试前等待
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
Err(_) => {
if attempt == max_attempts - 1 {
return TaskResult {
task_id,
status: proxycast_scheduler::BatchTaskStatus2::Failed,
content: None,
error: Some(format!("任务超时 ({}s)", timeout_secs)),
usage: TokenUsage::default(),
started_at,
completed_at: Some(chrono::Utc::now()),
};
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
// 不应到达这里
TaskResult {
task_id,
status: proxycast_scheduler::BatchTaskStatus2::Failed,
content: None,
error: Some("未知错误".to_string()),
usage: TokenUsage::default(),
started_at,
completed_at: Some(chrono::Utc::now()),
}
}
/// 调用 LLM:选择凭证 + 调用 provider
async fn call_llm(
state: &AppState,
request: &ChatCompletionRequest,
) -> Result<(String, TokenUsage), String> {
let db = state.db.as_ref().ok_or("数据库未初始化")?;
// 选择凭证
let credential = state
.pool_service
.select_credential_with_fallback(
db,
&state.api_key_service,
"",
Some(&request.model),
None,
None,
)
.await?
.ok_or_else(|| format!("没有可用的凭证来调用模型: {}", request.model))?;
// 调用 provider
let response =
super::provider_calls::call_provider_openai(state, &credential, request, None).await;
// 解析响应
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 10 * 1024 * 1024)
.await
.map_err(|e| format!("读取响应体失败: {}", e))?;
if status != StatusCode::OK {
let error_text = String::from_utf8_lossy(&body);
return Err(format!("LLM 调用失败 ({}): {}", status, error_text));
}
let resp: ChatCompletionResponse =
serde_json::from_slice(&body).map_err(|e| format!("解析响应失败: {}", e))?;
let content = resp
.choices
.first()
.and_then(|c| c.message.content.clone())
.unwrap_or_default();
let usage = TokenUsage::new(resp.usage.prompt_tokens, resp.usage.completion_tokens);
Ok((content, usage))
}
}
@@ -5,6 +5,7 @@
pub mod api;
pub mod api_key_provider_utils;
pub mod batch_api;
pub mod batch_executor;
pub mod credentials_api;
pub mod image_handler;
pub mod kiro_credential;
+10
View File
@@ -458,6 +458,9 @@ pub struct AppState {
pub kiro_event_service: Arc<KiroEventService>,
/// API Key Provider 服务(用于智能降级)
pub api_key_service: Arc<proxycast_services::api_key_provider_service::ApiKeyProviderService>,
/// 批量任务执行器
pub batch_executor:
Arc<tokio::sync::RwLock<Option<handlers::batch_executor::BatchTaskExecutor>>>,
}
/// 启动配置文件监控
@@ -863,8 +866,15 @@ async fn run_server(
endpoint_providers,
kiro_event_service,
api_key_service,
batch_executor: Arc::new(tokio::sync::RwLock::new(None)),
};
// 初始化批量任务执行器
{
let executor = handlers::batch_executor::BatchTaskExecutor::new(state.clone());
*state.batch_executor.write().await = Some(executor);
}
// ========== 开发模式:通过回调启动桥接服务器 ==========
if let Some(callback) = dev_bridge_callback {
callback(state.clone());
+2 -2
View File
@@ -19,6 +19,6 @@ pub use llm_provider::{LlmProvider, SkillError};
pub use proxycast_llm_provider::ProxyCastLlmProvider;
pub use skill_loader::{
find_skill_by_name, get_proxycast_skills_dir, load_skill_from_file, load_skills_from_directory,
parse_allowed_tools, parse_boolean, parse_skill_frontmatter, LoadedSkillDefinition,
SkillFrontmatter,
parse_allowed_tools, parse_boolean, parse_skill_frontmatter, parse_workflow_steps,
LoadedSkillDefinition, SkillFrontmatter, WorkflowStep,
};
@@ -6,6 +6,28 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Workflow 步骤定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowStep {
/// 步骤 ID
pub id: String,
/// 步骤名称
pub name: String,
/// 步骤提示词(作为该步骤的 system_prompt 或追加指令)
pub prompt: String,
/// 可选的模型覆盖
pub model: Option<String>,
/// 可选的温度参数
pub temperature: Option<f32>,
/// 执行模式:prompt(默认)、elicitation
#[serde(default = "default_step_execution_mode")]
pub execution_mode: String,
}
fn default_step_execution_mode() -> String {
"prompt".to_string()
}
/// Skill 前置元数据
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SkillFrontmatter {
@@ -24,6 +46,9 @@ pub struct SkillFrontmatter {
pub disable_model_invocation: Option<String>,
#[serde(rename = "execution-mode")]
pub execution_mode: Option<String>,
/// Workflow 步骤定义(JSON 格式)
#[serde(rename = "steps-json")]
pub steps_json: Option<String>,
}
/// 内部 Skill 定义(用于加载和执行)
@@ -40,6 +65,8 @@ pub struct LoadedSkillDefinition {
pub provider: Option<String>,
pub disable_model_invocation: bool,
pub execution_mode: String,
/// Workflow 步骤定义(仅 execution_mode == "workflow" 时有效)
pub workflow_steps: Vec<WorkflowStep>,
}
/// 解析 Skill 文件的 frontmatter
@@ -77,6 +104,7 @@ pub fn parse_skill_frontmatter(content: &str) -> (SkillFrontmatter, String) {
frontmatter.disable_model_invocation = Some(clean_value)
}
"execution-mode" => frontmatter.execution_mode = Some(clean_value),
"steps-json" => frontmatter.steps_json = Some(clean_value),
_ => {}
}
}
@@ -117,6 +145,33 @@ pub fn parse_boolean(value: Option<&str>, default: bool) -> bool {
.unwrap_or(default)
}
/// 解析 workflow steps
///
/// 支持两种来源:
/// 1. frontmatter 中的 `steps-json` 字段(单行 JSON 数组)
/// 2. markdown body 中的 `<!-- steps: [...] -->` 注释块
pub fn parse_workflow_steps(steps_json: Option<&str>, markdown_content: &str) -> Vec<WorkflowStep> {
// 优先使用 frontmatter 中的 steps-json
if let Some(json) = steps_json {
if let Ok(steps) = serde_json::from_str::<Vec<WorkflowStep>>(json) {
return steps;
}
}
// 回退:从 markdown body 中解析 <!-- steps: [...] -->
let re = regex::Regex::new(r"<!--\s*steps:\s*([\s\S]*?)-->").unwrap();
if let Some(captures) = re.captures(markdown_content) {
if let Some(json_match) = captures.get(1) {
if let Ok(steps) = serde_json::from_str::<Vec<WorkflowStep>>(json_match.as_str().trim())
{
return steps;
}
}
}
Vec::new()
}
/// 从文件加载 Skill 定义
pub fn load_skill_from_file(
skill_name: &str,
@@ -140,6 +195,15 @@ pub fn load_skill_from_file(
.clone()
.unwrap_or_else(|| "prompt".to_string());
let workflow_steps = parse_workflow_steps(frontmatter.steps_json.as_deref(), &markdown_content);
// 如果有 steps 但 execution_mode 未显式设置,自动升级为 workflow
let execution_mode = if !workflow_steps.is_empty() && execution_mode == "prompt" {
"workflow".to_string()
} else {
execution_mode
};
Ok(LoadedSkillDefinition {
skill_name: skill_name.to_string(),
display_name,
@@ -152,6 +216,7 @@ pub fn load_skill_from_file(
provider: frontmatter.provider,
disable_model_invocation,
execution_mode,
workflow_steps,
})
}
@@ -3,12 +3,19 @@
"provider": "codex",
"description": "OpenAI Codex CLI 支持的模型",
"models": [
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2"
],
"aliases": {
"gpt-5.3-codex": {
"actual": "gpt-5.3-codex",
"internal_name": "gpt-5.3-codex",
"provider": "openai",
"description": "Codex 最新一代模型,编码与推理能力增强"
},
"gpt-5.2-codex": {
"actual": "gpt-5.2-codex",
"internal_name": "gpt-5.2-codex",
@@ -34,5 +41,5 @@
"description": "最新前沿模型,跨知识、推理和编码的全面提升"
}
},
"updated_at": "2026-01-13T00:00:00Z"
"updated_at": "2026-02-11T00:00:00Z"
}
@@ -186,8 +186,35 @@
"status": "active",
"release_date": "2025-12-22",
"is_latest": true
},
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"cache_read": 0.1,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "aihubmix.com"
}
@@ -264,8 +264,34 @@
"status": "active",
"release_date": "2024-09-01",
"is_latest": false
},
{
"id": "kimi-k2.5",
"name": "Moonshot Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.574,
"output": 2.411,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 32768
},
"status": "active",
"release_date": "2025-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "alibaba.com"
}
@@ -266,8 +266,146 @@
"status": "active",
"release_date": "2024-04-30",
"is_latest": false
},
{
"id": "us.anthropic.claude-opus-4-6-v1",
"name": "Claude Opus 4.6 (US)",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 1000000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
},
{
"id": "anthropic.claude-opus-4-6-v1",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 1000000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
},
{
"id": "moonshotai.kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"currency": "USD"
},
"limits": {
"context": 256000,
"max_output": 256000
},
"status": "active",
"release_date": "2026-02-06",
"is_latest": true
},
{
"id": "global.anthropic.claude-opus-4-6-v1",
"name": "Claude Opus 4.6 (Global)",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 1000000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
},
{
"id": "eu.anthropic.claude-opus-4-6-v1",
"name": "Claude Opus 4.6 (EU)",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 1000000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "aws.amazon.com"
}
@@ -116,8 +116,36 @@
"status": "active",
"release_date": "2025-10-01",
"is_latest": false
},
{
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "anthropic.com"
}
@@ -245,8 +245,89 @@
"status": "active",
"release_date": "2024-01-25",
"is_latest": false
},
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-02-06",
"is_latest": true
},
{
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
},
{
"id": "gpt-5.2-codex",
"name": "GPT-5.2 Codex",
"family": "gpt-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1.75,
"output": 14,
"cache_read": 0.175,
"currency": "USD"
},
"limits": {
"context": 400000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-01-14",
"is_latest": false
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "azure.microsoft.com"
}
@@ -299,8 +299,89 @@
"status": "active",
"release_date": "2024-01-25",
"is_latest": false
},
{
"id": "gpt-5.2-codex",
"name": "GPT-5.2 Codex",
"family": "gpt-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1.75,
"output": 14,
"cache_read": 0.175,
"currency": "USD"
},
"limits": {
"context": 400000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-01-14",
"is_latest": false
},
{
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
},
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-02-06",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "azure.microsoft.com"
}
@@ -186,8 +186,34 @@
"status": "active",
"release_date": "2025-08-05",
"is_latest": true
},
{
"id": "moonshotai/Kimi-K2.5-TEE",
"name": "Kimi K2.5 TEE",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 65535
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "chutes.ai"
}
@@ -245,8 +245,36 @@
"status": "active",
"release_date": "2024-11-01",
"is_latest": true
},
{
"id": "anthropic/claude-opus-4-6",
"name": "Claude Opus 4.6 (latest)",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 1000000,
"max_output": 64000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "cloudflare.com"
}
+13 -13
View File
@@ -32,8 +32,8 @@
"is_latest": true
},
{
"id": "gpt-5.1-codex-max",
"name": "GPT-5.1 Codex Max",
"id": "gpt-5.3-codex",
"name": "GPT-5.3 Codex",
"family": "gpt-5-codex",
"tier": "pro",
"capabilities": {
@@ -50,18 +50,18 @@
"currency": "USD"
},
"limits": {
"context": 400000,
"context": 272000,
"max_output": 128000
},
"status": "active",
"release_date": "2025-11-13",
"is_latest": false
"release_date": "2026-02-05",
"is_latest": true
},
{
"id": "gpt-5.1-codex-mini",
"name": "GPT-5.1 Codex Mini",
"family": "gpt-5-codex-mini",
"tier": "mini",
"id": "gpt-5.2-codex",
"name": "GPT-5.2 Codex",
"family": "gpt-5-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
@@ -76,14 +76,14 @@
"currency": "USD"
},
"limits": {
"context": 400000,
"context": 272000,
"max_output": 128000
},
"status": "active",
"release_date": "2025-11-13",
"release_date": "2025-12-11",
"is_latest": false
}
],
"updated_at": "2026-01-13T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "codex-cli"
}
}
@@ -239,8 +239,34 @@
"status": "active",
"release_date": "2025-07-11",
"is_latest": false
},
{
"id": "moonshotai/Kimi-K2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.5,
"output": 2.8,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 32768
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-06T10:24:55.372Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "models.dev"
}
@@ -215,8 +215,35 @@
"status": "active",
"release_date": "2025-04-29",
"is_latest": false
},
{
"id": "accounts/fireworks/models/kimi-k2p5",
"name": "Kimi K2.5",
"family": "kimi-thinking",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"cache_read": 0.1,
"currency": "USD"
},
"limits": {
"context": 256000,
"max_output": 256000
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "fireworks.ai"
}
@@ -160,8 +160,60 @@
"status": "active",
"release_date": "2025-03-20",
"is_latest": true
},
{
"id": "gpt-5.2-codex",
"name": "GPT-5.2-Codex",
"family": "gpt-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0,
"output": 0,
"currency": "USD"
},
"limits": {
"context": 272000,
"max_output": 128000
},
"status": "active",
"release_date": "2025-12-11",
"is_latest": false
},
{
"id": "claude-opus-4.6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0,
"output": 0,
"currency": "USD"
},
"limits": {
"context": 128000,
"max_output": 64000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "github.com"
}
@@ -34,64 +34,8 @@
"is_latest": false
},
{
"id": "claude-haiku-4-5@20251001",
"name": "Claude Haiku 4.5",
"family": "claude-haiku",
"tier": "mini",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1,
"output": 5,
"cache_read": 0.1,
"cache_write": 1.25,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 64000
},
"status": "active",
"release_date": "2025-10-15",
"is_latest": false
},
{
"id": "claude-sonnet-4-5@20250929",
"name": "Claude Sonnet 4.5",
"family": "claude-sonnet",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 64000
},
"status": "active",
"release_date": "2025-09-29",
"is_latest": false
},
{
"id": "claude-opus-4-1@20250805",
"name": "Claude Opus 4.1",
"id": "claude-opus-4-6@default",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
@@ -103,161 +47,21 @@
"reasoning": true
},
"pricing": {
"input": 15,
"output": 75,
"cache_read": 1.5,
"cache_write": 18.75,
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 32000
"context": 1000000,
"max_output": 128000
},
"status": "active",
"release_date": "2025-08-05",
"is_latest": false
},
{
"id": "claude-sonnet-4@20250514",
"name": "Claude Sonnet 4",
"family": "claude-sonnet",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 64000
},
"status": "active",
"release_date": "2025-05-22",
"is_latest": false
},
{
"id": "claude-opus-4@20250514",
"name": "Claude Opus 4",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 15,
"output": 75,
"cache_read": 1.5,
"cache_write": 18.75,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 32000
},
"status": "active",
"release_date": "2025-05-22",
"is_latest": false
},
{
"id": "claude-3-7-sonnet@20250219",
"name": "Claude Sonnet 3.7",
"family": "claude-sonnet",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 64000
},
"status": "active",
"release_date": "2025-02-19",
"is_latest": false
},
{
"id": "claude-3-5-sonnet@20241022",
"name": "Claude Sonnet 3.5 v2",
"family": "claude-sonnet",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": false
},
"pricing": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 8192
},
"status": "active",
"release_date": "2024-10-22",
"is_latest": false
},
{
"id": "claude-3-5-haiku@20241022",
"name": "Claude Haiku 3.5",
"family": "claude-haiku",
"tier": "mini",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": false
},
"pricing": {
"input": 0.8,
"output": 4,
"cache_read": 0.08,
"cache_write": 1,
"currency": "USD"
},
"limits": {
"context": 200000,
"max_output": 8192
},
"status": "active",
"release_date": "2024-10-22",
"is_latest": false
"release_date": "2026-02-05",
"is_latest": true
}
],
"updated_at": "2026-01-06T10:24:55.362Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "models.dev"
}
}
@@ -134,8 +134,35 @@
"status": "active",
"release_date": "2024-11-01",
"is_latest": true
},
{
"id": "moonshotai/Kimi-K2.5",
"name": "Kimi-K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"cache_read": 0.1,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01-01",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "huggingface.co"
}
@@ -32,8 +32,36 @@
"status": "active",
"release_date": "2025-11",
"is_latest": false
},
{
"id": "k2p5",
"name": "Kimi K2.5",
"family": "kimi-thinking",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0,
"output": 0,
"cache_read": 0,
"cache_write": 0,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 32768
},
"status": "active",
"release_date": "2026-01",
"is_latest": true
}
],
"updated_at": "2026-01-06T10:24:55.364Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "models.dev"
}
@@ -139,8 +139,35 @@
"status": "active",
"release_date": "2025-07-14",
"is_latest": false
},
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"cache_read": 0.1,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01",
"is_latest": true
}
],
"updated_at": "2026-01-06T10:24:55.353Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "models.dev"
}
@@ -139,8 +139,35 @@
"status": "active",
"release_date": "2025-07-14",
"is_latest": false
},
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"cache_read": 0.1,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01",
"is_latest": true
}
],
"updated_at": "2026-01-06T10:24:55.356Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "models.dev"
}
@@ -238,8 +238,34 @@
"status": "active",
"release_date": "2024-06-14",
"is_latest": false
},
{
"id": "moonshotai/kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0,
"output": 0,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "nvidia.com"
}
@@ -134,8 +134,34 @@
"status": "active",
"release_date": "2024-11-01",
"is_latest": true
},
{
"id": "kimi-k2.5",
"name": "kimi-k2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0,
"output": 0,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "ollama.com"
}
@@ -58,6 +58,60 @@
"release_date": "2025-12-11",
"is_latest": true
},
{
"id": "gpt-5.3-codex",
"name": "GPT-5.3 Codex",
"family": "gpt-5-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1.75,
"output": 14,
"cache_read": 0.175,
"currency": "USD"
},
"limits": {
"context": 272000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
},
{
"id": "gpt-5.2-codex",
"name": "GPT-5.2 Codex",
"family": "gpt-5-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1.75,
"output": 14,
"cache_read": 0.175,
"currency": "USD"
},
"limits": {
"context": 272000,
"max_output": 128000
},
"status": "active",
"release_date": "2025-12-11",
"is_latest": false
},
{
"id": "gpt-5.1-codex",
"name": "GPT-5.1 Codex",
@@ -160,8 +160,117 @@
"status": "active",
"release_date": "2025-01-20",
"is_latest": true
},
{
"id": "gpt-5.2-codex",
"name": "GPT-5.2 Codex",
"family": "gpt-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1.75,
"output": 14,
"cache_read": 0.175,
"currency": "USD"
},
"limits": {
"context": 272000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-01-14",
"is_latest": false
},
{
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 1000000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
},
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"cache_read": 0.08,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
},
{
"id": "kimi-k2.5-free",
"name": "Kimi K2.5 Free",
"family": "kimi-free",
"tier": "mini",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0,
"output": 0,
"cache_read": 0,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "opencode.ai"
}
@@ -536,8 +536,90 @@
"status": "active",
"release_date": "2024-04-04",
"is_latest": false
},
{
"id": "moonshotai/kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 3,
"cache_read": 0.1,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
},
{
"id": "openai/gpt-5.2-codex",
"name": "GPT-5.2-Codex",
"family": "gpt-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1.75,
"output": 14,
"cache_read": 0.175,
"currency": "USD"
},
"limits": {
"context": 400000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-01-14",
"is_latest": false
},
{
"id": "anthropic/claude-opus-4.6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 1000000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "openrouter.ai"
}
+82 -1
View File
@@ -316,8 +316,89 @@
"status": "active",
"release_date": "2025-04-05",
"is_latest": true
},
{
"id": "openai/gpt-5.2-codex",
"name": "gpt-5.2-codex",
"family": "gpt-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1.6,
"output": 13,
"cache_read": 0.16,
"currency": "USD"
},
"limits": {
"context": 400000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-01-14",
"is_latest": false
},
{
"id": "anthropic/claude-opus-4-6",
"name": "claude-opus-4-6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 4.3,
"output": 21,
"cache_read": 0.43,
"cache_write": 5.3,
"currency": "USD"
},
"limits": {
"context": 983040,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-04",
"is_latest": true
},
{
"id": "novita/kimi-k2.5",
"name": "kimi-k2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0,
"output": 0,
"currency": "USD"
},
"limits": {
"context": 256000,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "poe.com"
}
@@ -109,32 +109,6 @@
"release_date": "2025-10-10",
"is_latest": true
},
{
"id": "deepseek-ai/DeepSeek-R1",
"name": "DeepSeek R1",
"family": "deepseek-r1",
"tier": "pro",
"capabilities": {
"vision": false,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.5,
"output": 2.18,
"currency": "USD"
},
"limits": {
"context": 164000,
"max_output": 164000
},
"status": "active",
"release_date": "2025-05-28",
"is_latest": false
},
{
"id": "Qwen/Qwen3-VL-235B-A22B-Thinking",
"name": "Qwen3-VL 235B Thinking",
@@ -316,8 +290,34 @@
"status": "active",
"release_date": "2025-03-06",
"is_latest": false
},
{
"id": "Pro/moonshotai/Kimi-K2.5",
"name": "Pro/moonshotai/Kimi-K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.55,
"output": 3,
"currency": "USD"
},
"limits": {
"context": 262000,
"max_output": 262000
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "siliconflow.cn"
}
}
@@ -5,58 +5,6 @@
"name": "SiliconFlow"
},
"models": [
{
"id": "moonshotai/Kimi-K2-Thinking",
"name": "Kimi K2 Thinking",
"family": "kimi-k2",
"tier": "pro",
"capabilities": {
"vision": false,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.55,
"output": 2.5,
"currency": "USD"
},
"limits": {
"context": 262000,
"max_output": 262000
},
"status": "active",
"release_date": "2025-11-07",
"is_latest": true
},
{
"id": "moonshotai/Kimi-K2-Instruct-0905",
"name": "Kimi K2 Instruct",
"family": "kimi-k2",
"tier": "pro",
"capabilities": {
"vision": false,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": false
},
"pricing": {
"input": 0.4,
"output": 2,
"currency": "USD"
},
"limits": {
"context": 262000,
"max_output": 262000
},
"status": "active",
"release_date": "2025-09-08",
"is_latest": false
},
{
"id": "MiniMaxAI/MiniMax-M2",
"name": "MiniMax M2",
@@ -109,32 +57,6 @@
"release_date": "2025-10-10",
"is_latest": true
},
{
"id": "deepseek-ai/DeepSeek-R1",
"name": "DeepSeek R1",
"family": "deepseek-r1",
"tier": "pro",
"capabilities": {
"vision": false,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.5,
"output": 2.18,
"currency": "USD"
},
"limits": {
"context": 164000,
"max_output": 164000
},
"status": "active",
"release_date": "2025-05-28",
"is_latest": false
},
{
"id": "Qwen/Qwen3-VL-235B-A22B-Thinking",
"name": "Qwen3-VL 235B Thinking",
@@ -316,8 +238,34 @@
"status": "active",
"release_date": "2025-03-06",
"is_latest": false
},
{
"id": "moonshotai/Kimi-K2.5",
"name": "moonshotai/Kimi-K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.55,
"output": 3,
"currency": "USD"
},
"limits": {
"context": 262000,
"max_output": 262000
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "siliconflow.cn"
}
}
@@ -134,8 +134,34 @@
"status": "active",
"release_date": "2025-12-01",
"is_latest": true
},
{
"id": "hf:moonshotai/Kimi-K2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.55,
"output": 2.19,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 65536
},
"status": "active",
"release_date": "2026-01",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "synthetic.ai"
}
@@ -5,110 +5,6 @@
"name": "Together AI"
},
"models": [
{
"id": "moonshotai/Kimi-K2-Thinking",
"name": "Kimi K2 Thinking",
"family": "kimi-k2",
"tier": "pro",
"capabilities": {
"vision": false,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1.2,
"output": 4,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 32768
},
"status": "active",
"release_date": "2025-11-06",
"is_latest": true
},
{
"id": "moonshotai/Kimi-K2-Instruct",
"name": "Kimi K2 Instruct",
"family": "kimi-k2",
"tier": "pro",
"capabilities": {
"vision": false,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": false
},
"pricing": {
"input": 1,
"output": 3,
"currency": "USD"
},
"limits": {
"context": 131072,
"max_output": 32768
},
"status": "active",
"release_date": "2025-07-14",
"is_latest": false
},
{
"id": "deepseek-ai/DeepSeek-V3-1",
"name": "DeepSeek V3.1",
"family": "deepseek-v3",
"tier": "max",
"capabilities": {
"vision": false,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 1.7,
"currency": "USD"
},
"limits": {
"context": 131072,
"max_output": 12288
},
"status": "active",
"release_date": "2025-08-21",
"is_latest": true
},
{
"id": "deepseek-ai/DeepSeek-R1",
"name": "DeepSeek R1",
"family": "deepseek-r1",
"tier": "pro",
"capabilities": {
"vision": false,
"tools": false,
"streaming": true,
"json_mode": true,
"function_calling": false,
"reasoning": true
},
"pricing": {
"input": 3,
"output": 7,
"currency": "USD"
},
"limits": {
"context": 163839,
"max_output": 12288
},
"status": "active",
"release_date": "2024-12-26",
"is_latest": false
},
{
"id": "openai/gpt-oss-120b",
"name": "GPT OSS 120B",
@@ -186,8 +82,34 @@
"status": "active",
"release_date": "2024-12-06",
"is_latest": false
},
{
"id": "moonshotai/Kimi-K2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.5,
"output": 2.8,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "together.ai"
}
}
@@ -57,32 +57,6 @@
"release_date": "2025-04-29",
"is_latest": true
},
{
"id": "deepseek-r1",
"name": "DeepSeek R1",
"family": "deepseek-r1",
"tier": "pro",
"capabilities": {
"vision": false,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.55,
"output": 2.19,
"currency": "USD"
},
"limits": {
"context": 163840,
"max_output": 65536
},
"status": "active",
"release_date": "2025-01-20",
"is_latest": true
},
{
"id": "mistral-large",
"name": "Mistral Large",
@@ -134,8 +108,36 @@
"status": "active",
"release_date": "2024-12-06",
"is_latest": false
},
{
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 6,
"output": 30,
"cache_read": 0.6,
"cache_write": 7.5,
"currency": "USD"
},
"limits": {
"context": 1000000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-05",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "venice.ai"
}
}
@@ -193,32 +193,6 @@
"release_date": "2025-09-29",
"is_latest": true
},
{
"id": "deepseek/deepseek-r1",
"name": "DeepSeek R1",
"family": "deepseek-r1",
"tier": "pro",
"capabilities": {
"vision": false,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.55,
"output": 2.19,
"currency": "USD"
},
"limits": {
"context": 163840,
"max_output": 65536
},
"status": "active",
"release_date": "2025-01-20",
"is_latest": false
},
{
"id": "x-ai/grok-4.1-fast",
"name": "Grok 4.1 Fast",
@@ -323,8 +297,89 @@
"status": "active",
"release_date": "2025-04-05",
"is_latest": true
},
{
"id": "moonshotai/kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.6,
"output": 1.2,
"currency": "USD"
},
"limits": {
"context": 262144,
"max_output": 262144
},
"status": "active",
"release_date": "2026-01-26",
"is_latest": true
},
{
"id": "openai/gpt-5.2-codex",
"name": "GPT-5.2-Codex",
"family": "gpt-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1.75,
"output": 14,
"cache_read": 0.175,
"currency": "USD"
},
"limits": {
"context": 400000,
"max_output": 128000
},
"status": "active",
"release_date": "2025-12",
"is_latest": false
},
{
"id": "anthropic/claude-opus-4.6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 1000000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "vercel.com"
}
}
@@ -135,32 +135,6 @@
"release_date": "2025-12-01",
"is_latest": true
},
{
"id": "deepseek/deepseek-r1",
"name": "DeepSeek R1",
"family": "deepseek-r1",
"tier": "pro",
"capabilities": {
"vision": false,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.55,
"output": 2.19,
"currency": "USD"
},
"limits": {
"context": 163840,
"max_output": 65536
},
"status": "active",
"release_date": "2025-01-20",
"is_latest": true
},
{
"id": "z-ai/glm-4.6v-flash-free",
"name": "GLM 4.6V Flash (Free)",
@@ -185,8 +159,90 @@
"status": "active",
"release_date": "2025-12-30",
"is_latest": true
},
{
"id": "moonshotai/kimi-k2.5",
"name": "Kimi K2.5",
"family": "kimi",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 0.58,
"output": 3.02,
"cache_read": 0.1,
"currency": "USD"
},
"limits": {
"context": 262000,
"max_output": 64000
},
"status": "active",
"release_date": "2026-01-27",
"is_latest": true
},
{
"id": "openai/gpt-5.2-codex",
"name": "GPT-5.2-Codex",
"family": "gpt-codex",
"tier": "pro",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 1.75,
"output": 14,
"cache_read": 0.17,
"currency": "USD"
},
"limits": {
"context": 400000,
"max_output": 64000
},
"status": "active",
"release_date": "2026-01-15",
"is_latest": false
},
{
"id": "anthropic/claude-opus-4.6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"tier": "max",
"capabilities": {
"vision": true,
"tools": true,
"streaming": true,
"json_mode": true,
"function_calling": true,
"reasoning": true
},
"pricing": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25,
"currency": "USD"
},
"limits": {
"context": 1000000,
"max_output": 128000
},
"status": "active",
"release_date": "2026-02-06",
"is_latest": true
}
],
"updated_at": "2026-01-12T00:00:00.000Z",
"updated_at": "2026-02-11T00:00:00.000Z",
"source": "zenmux.ai"
}
}
+5
View File
@@ -89,6 +89,11 @@ pub fn init_states(config: &Config) -> Result<AppStates, String> {
// 数据库
let db = database::init_database().map_err(|e| format!("数据库初始化失败: {e}"))?;
// 初始化批量任务表
if let Err(e) = proxycast_scheduler::BatchTaskDao::init_tables(&db) {
tracing::warn!("[Bootstrap] 批量任务表初始化失败: {}", e);
}
// 服务状态
let skill_service = SkillService::new().map_err(|e| format!("SkillService 初始化失败: {e}"))?;
let skill_service_state = SkillServiceState(Arc::new(skill_service));
+28
View File
@@ -29,6 +29,23 @@ fn truncate_string(s: &str, max_chars: usize) -> String {
}
}
fn resolve_workspace_id_by_working_dir(
conn: &rusqlite::Connection,
working_dir: Option<&str>,
) -> Option<String> {
let resolved_working_dir = working_dir?.trim();
if resolved_working_dir.is_empty() {
return None;
}
conn.query_row(
"SELECT id FROM workspaces WHERE root_path = ? LIMIT 1",
rusqlite::params![resolved_working_dir],
|row| row.get::<_, String>(0),
)
.ok()
}
/// Agent 进程状态响应
#[derive(Debug, Serialize)]
pub struct AgentProcessStatus {
@@ -270,6 +287,8 @@ pub struct SessionInfo {
pub created_at: String,
pub last_activity: String,
pub messages_count: usize,
pub workspace_id: Option<String>,
pub working_dir: Option<String>,
}
/// 获取会话列表
@@ -283,6 +302,9 @@ pub async fn agent_list_sessions(db: State<'_, DbConnection>) -> Result<Vec<Sess
.into_iter()
.map(|s| {
let messages_count = AgentDao::get_message_count(&conn, &s.id).unwrap_or(0);
let working_dir = s.working_dir.clone();
let workspace_id = resolve_workspace_id_by_working_dir(&conn, working_dir.as_deref());
SessionInfo {
session_id: s.id,
provider_type: "aster".to_string(),
@@ -291,6 +313,8 @@ pub async fn agent_list_sessions(db: State<'_, DbConnection>) -> Result<Vec<Sess
created_at: s.created_at.clone(),
last_activity: s.updated_at,
messages_count,
workspace_id,
working_dir,
}
})
.collect();
@@ -311,6 +335,8 @@ pub async fn agent_get_session(
.ok_or_else(|| "会话不存在".to_string())?;
let messages_count = AgentDao::get_message_count(&conn, &session_id).unwrap_or(0);
let working_dir = session.working_dir.clone();
let workspace_id = resolve_workspace_id_by_working_dir(&conn, working_dir.as_deref());
Ok(SessionInfo {
session_id: session.id,
@@ -320,6 +346,8 @@ pub async fn agent_get_session(
created_at: session.created_at.clone(),
last_activity: session.updated_at,
messages_count,
workspace_id,
working_dir,
})
}
+8 -1
View File
@@ -768,8 +768,15 @@ pub async fn aster_agent_chat_stream(
{
let session_dir = session.working_dir.unwrap_or_default();
if !session_dir.is_empty() && session_dir != workspace_root {
tracing::warn!(
"[AsterAgent] workspace mismatch: session_id={}, workspace_id={}, session_dir={}, workspace_root={}",
session_id,
workspace_id,
session_dir,
workspace_root
);
return Err(format!(
"会话工作目录与 workspace 不匹配: session={}, workspace={}",
"workspace_mismatch|会话工作目录与 workspace 不匹配: session={}, workspace={}",
session_dir, workspace_root
));
}
+1
View File
@@ -39,6 +39,7 @@ pub mod route_cmd;
pub mod screenshot_cmd;
pub mod session_files_cmd;
pub mod skill_cmd;
pub mod skill_error;
pub mod skill_exec_cmd;
pub mod subagent_cmd;
pub mod switch_cmd;
+50
View File
@@ -0,0 +1,50 @@
//! Skill 命令错误码与格式化工具
//!
//! 约定错误消息格式:`<code>|<message>`
//! 例如:`skill_not_found|未找到名为 "writer" 的 Skill`
pub const SKILL_ERR_CATALOG_UNAVAILABLE: &str = "skill_catalog_unavailable";
pub const SKILL_ERR_NOT_FOUND: &str = "skill_not_found";
pub const SKILL_ERR_SESSION_INIT_FAILED: &str = "skill_session_init_failed";
pub const SKILL_ERR_PROVIDER_UNAVAILABLE: &str = "skill_provider_unavailable";
pub const SKILL_ERR_STREAM_FAILED: &str = "skill_stream_failed";
pub const SKILL_ERR_EXECUTE_FAILED: &str = "skill_execute_failed";
pub fn format_skill_error(code: &str, message: impl AsRef<str>) -> String {
format!("{code}|{}", message.as_ref())
}
pub fn map_find_skill_error(error: String) -> String {
let normalized = error.to_lowercase();
if normalized.contains("not found") || error.contains("不存在") {
return format_skill_error(SKILL_ERR_NOT_FOUND, error);
}
format_skill_error(
SKILL_ERR_EXECUTE_FAILED,
format!("加载 Skill 失败: {error}"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_skill_error() {
let result = format_skill_error(SKILL_ERR_NOT_FOUND, "foo");
assert_eq!(result, "skill_not_found|foo");
}
#[test]
fn test_map_find_skill_error_not_found() {
let result = map_find_skill_error("Skill not found: demo".to_string());
assert!(result.starts_with("skill_not_found|"));
}
#[test]
fn test_map_find_skill_error_generic() {
let result = map_find_skill_error("io failure".to_string());
assert!(result.starts_with("skill_execute_failed|"));
}
}
+277 -48
View File
@@ -24,6 +24,11 @@ use aster::conversation::message::Message;
use crate::agent::aster_state::SessionConfigBuilder;
use crate::agent::{AsterAgentState, TauriAgentEvent};
use crate::commands::skill_error::{
format_skill_error, map_find_skill_error, SKILL_ERR_CATALOG_UNAVAILABLE,
SKILL_ERR_EXECUTE_FAILED, SKILL_ERR_PROVIDER_UNAVAILABLE, SKILL_ERR_SESSION_INIT_FAILED,
SKILL_ERR_STREAM_FAILED,
};
use crate::database::DbConnection;
use crate::skills::TauriExecutionCallback;
use proxycast_agent::event_converter::convert_agent_event;
@@ -169,11 +174,14 @@ pub async fn execute_skill(
);
// 1. 从 registry 加载 skill(Requirements 3.2)
let skill = find_skill_by_name(&skill_name)?;
let skill = find_skill_by_name(&skill_name).map_err(map_find_skill_error)?;
// 检查是否禁用了模型调用
if skill.disable_model_invocation {
return Err(format!("Skill '{}' 已禁用模型调用,无法执行", skill_name));
return Err(format_skill_error(
SKILL_ERR_EXECUTE_FAILED,
format!("Skill '{}' 已禁用模型调用,无法执行", skill_name),
));
}
// 2. 创建 TauriExecutionCallback
@@ -182,7 +190,12 @@ pub async fn execute_skill(
// 3. 初始化 Agent(如果未初始化)
if !aster_state.is_initialized().await {
tracing::info!("[execute_skill] Agent 未初始化,开始初始化...");
aster_state.init_agent_with_db(&db).await?;
aster_state.init_agent_with_db(&db).await.map_err(|e| {
format_skill_error(
SKILL_ERR_SESSION_INIT_FAILED,
format!("初始化 Agent 失败: {e}"),
)
})?;
tracing::info!("[execute_skill] Agent 初始化完成");
}
@@ -239,7 +252,12 @@ pub async fn execute_skill(
}
configure_result.map_err(|e| {
format!("无法配置任何可用的 Provider(需要支持工具调用的 Provider,如 Anthropic、OpenAI 或 Google): {e}")
format_skill_error(
SKILL_ERR_PROVIDER_UNAVAILABLE,
format!(
"无法配置任何可用的 Provider(需要支持工具调用的 Provider,如 Anthropic、OpenAI 或 Google): {e}"
),
)
})?;
tracing::info!(
@@ -248,36 +266,69 @@ pub async fn execute_skill(
preferred_model
);
// 5. 发送步骤开始事件
// 5. 根据 execution_mode 分支执行
if skill.execution_mode == "workflow" && !skill.workflow_steps.is_empty() {
// ========== Workflow 模式:按步骤顺序执行 ==========
execute_skill_workflow(
&app_handle,
&aster_state,
&skill,
&user_input,
&execution_id,
&session_id,
&callback,
)
.await
} else {
// ========== Prompt 模式:单次执行 ==========
execute_skill_prompt(
&app_handle,
&aster_state,
&skill,
&user_input,
&execution_id,
&session_id,
&callback,
)
.await
}
}
/// Prompt 模式执行(单步)
async fn execute_skill_prompt(
app_handle: &tauri::AppHandle,
aster_state: &AsterAgentState,
skill: &proxycast_skills::LoadedSkillDefinition,
user_input: &str,
execution_id: &str,
session_id: &str,
callback: &TauriExecutionCallback,
) -> Result<SkillExecutionResult, String> {
// 发送步骤开始事件
callback.on_step_start("main", &skill.display_name, 1, 1);
// 6. 构建 SessionConfig,将 skill 内容作为 system_prompt
let session_config = SessionConfigBuilder::new(&session_id)
// 构建 SessionConfig
let session_config = SessionConfigBuilder::new(session_id)
.system_prompt(&skill.markdown_content)
.build();
// 7. 创建用户消息
let user_message = Message::user().with_text(&user_input);
let user_message = Message::user().with_text(user_input);
// 8. 获取 Agent 并执行
// 获取 Agent 并执行
let agent_arc = aster_state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or("Agent not initialized")?;
let agent = guard.as_ref().ok_or_else(|| {
format_skill_error(SKILL_ERR_SESSION_INIT_FAILED, "Agent not initialized")
})?;
// 创建取消令牌
let cancel_token = aster_state.create_cancel_token(&session_id).await;
// 获取事件流
let cancel_token = aster_state.create_cancel_token(session_id).await;
let stream_result = agent
.reply(user_message, session_config, Some(cancel_token.clone()))
.await;
// 9. 处理流式事件并收集结果
let mut final_output = String::new();
let mut has_error = false;
let mut error_message: Option<String> = None;
// 用于发送流式事件的 event_name
let event_name = format!("skill-exec-{}", execution_id);
match stream_result {
@@ -285,16 +336,11 @@ pub async fn execute_skill(
while let Some(event_result) = stream.next().await {
match event_result {
Ok(agent_event) => {
// 转换 Aster 事件为 Tauri 事件
let tauri_events = convert_agent_event(agent_event);
for tauri_event in tauri_events {
// 收集文本输出
if let TauriAgentEvent::TextDelta { ref text } = tauri_event {
final_output.push_str(text);
}
// 发送事件到前端
if let Err(e) = app_handle.emit(&event_name, &tauri_event) {
tracing::error!("[execute_skill] 发送事件失败: {}", e);
}
@@ -302,13 +348,15 @@ pub async fn execute_skill(
}
Err(e) => {
has_error = true;
error_message = Some(format!("Stream error: {e}"));
error_message = Some(format_skill_error(
SKILL_ERR_STREAM_FAILED,
format!("Stream error: {e}"),
));
tracing::error!("[execute_skill] 流处理错误: {}", e);
}
}
}
// 发送完成事件
let done_event = TauriAgentEvent::FinalDone { usage: None };
if let Err(e) = app_handle.emit(&event_name, &done_event) {
tracing::error!("[execute_skill] 发送完成事件失败: {}", e);
@@ -316,33 +364,29 @@ pub async fn execute_skill(
}
Err(e) => {
has_error = true;
error_message = Some(format!("Agent error: {e}"));
error_message = Some(format_skill_error(
SKILL_ERR_STREAM_FAILED,
format!("Agent error: {e}"),
));
tracing::error!("[execute_skill] Agent 错误: {}", e);
}
}
// 清理取消令牌
aster_state.remove_cancel_token(&session_id).await;
aster_state.remove_cancel_token(session_id).await;
// 10. 返回执行结果(Requirements 3.5)
if has_error {
let err_msg = error_message.unwrap_or_else(|| "Unknown error".to_string());
let err_msg = error_message
.unwrap_or_else(|| format_skill_error(SKILL_ERR_EXECUTE_FAILED, "Unknown error"));
callback.on_step_error("main", &err_msg, false);
callback.on_complete(false, None, Some(&err_msg));
tracing::error!(
"[execute_skill] Skill 执行失败: name={}, error={}",
skill_name,
err_msg
);
Ok(SkillExecutionResult {
success: false,
output: None,
error: Some(err_msg.clone()),
steps_completed: vec![StepResult {
step_id: "main".to_string(),
step_name: skill.display_name,
step_name: skill.display_name.clone(),
success: false,
output: None,
error: Some(err_msg),
@@ -352,19 +396,13 @@ pub async fn execute_skill(
callback.on_step_complete("main", &final_output);
callback.on_complete(true, Some(&final_output), None);
tracing::info!(
"[execute_skill] Skill 执行成功: name={}, output_len={}",
skill_name,
final_output.len()
);
Ok(SkillExecutionResult {
success: true,
output: Some(final_output.clone()),
error: None,
steps_completed: vec![StepResult {
step_id: "main".to_string(),
step_name: skill.display_name,
step_name: skill.display_name.clone(),
success: true,
output: Some(final_output),
error: None,
@@ -373,6 +411,183 @@ pub async fn execute_skill(
}
}
/// Workflow 模式执行(多步骤顺序执行)
async fn execute_skill_workflow(
app_handle: &tauri::AppHandle,
aster_state: &AsterAgentState,
skill: &proxycast_skills::LoadedSkillDefinition,
user_input: &str,
execution_id: &str,
session_id: &str,
callback: &TauriExecutionCallback,
) -> Result<SkillExecutionResult, String> {
let steps = &skill.workflow_steps;
let total_steps = steps.len();
let event_name = format!("skill-exec-{}", execution_id);
let mut steps_completed = Vec::new();
let mut accumulated_context = user_input.to_string();
let mut final_output = String::new();
tracing::info!(
"[execute_skill_workflow] 开始 workflow 执行: steps={}, skill={}",
total_steps,
skill.skill_name
);
for (idx, step) in steps.iter().enumerate() {
let step_num = idx + 1;
// 发送步骤开始事件
callback.on_step_start(&step.id, &step.name, step_num, total_steps);
tracing::info!(
"[execute_skill_workflow] 执行步骤 {}/{}: id={}, name={}",
step_num,
total_steps,
step.id,
step.name
);
// 构建该步骤的 system_prompt:基础 skill prompt + 步骤 prompt
let step_system_prompt = format!(
"{}\n\n---\n\n## 当前步骤: {} ({}/{})\n\n{}",
skill.markdown_content, step.name, step_num, total_steps, step.prompt
);
let step_session_id = format!("{}-step-{}", session_id, step.id);
let session_config = SessionConfigBuilder::new(&step_session_id)
.system_prompt(&step_system_prompt)
.build();
// 用户消息 = 原始输入 + 前序步骤的累积上下文
let step_input = if idx == 0 {
accumulated_context.clone()
} else {
format!(
"原始需求:{}\n\n前序步骤输出:\n{}",
user_input, accumulated_context
)
};
let user_message = Message::user().with_text(&step_input);
// 获取 Agent 并执行
let agent_arc = aster_state.get_agent_arc();
let guard = agent_arc.read().await;
let agent = guard.as_ref().ok_or_else(|| {
format_skill_error(SKILL_ERR_SESSION_INIT_FAILED, "Agent not initialized")
})?;
let cancel_token = aster_state.create_cancel_token(&step_session_id).await;
let stream_result = agent
.reply(user_message, session_config, Some(cancel_token.clone()))
.await;
let mut step_output = String::new();
let mut step_error: Option<String> = None;
match stream_result {
Ok(mut stream) => {
while let Some(event_result) = stream.next().await {
match event_result {
Ok(agent_event) => {
let tauri_events = convert_agent_event(agent_event);
for tauri_event in tauri_events {
if let TauriAgentEvent::TextDelta { ref text } = tauri_event {
step_output.push_str(text);
}
if let Err(e) = app_handle.emit(&event_name, &tauri_event) {
tracing::error!("[execute_skill_workflow] 发送事件失败: {}", e);
}
}
}
Err(e) => {
step_error = Some(format!("Stream error: {e}"));
tracing::error!(
"[execute_skill_workflow] 步骤 {} 流处理错误: {}",
step.id,
e
);
break;
}
}
}
}
Err(e) => {
step_error = Some(format!("Agent error: {e}"));
tracing::error!(
"[execute_skill_workflow] 步骤 {} Agent 错误: {}",
step.id,
e
);
}
}
aster_state.remove_cancel_token(&step_session_id).await;
if let Some(err) = &step_error {
callback.on_step_error(&step.id, err, false);
steps_completed.push(StepResult {
step_id: step.id.clone(),
step_name: step.name.clone(),
success: false,
output: None,
error: Some(err.clone()),
});
// 步骤失败,终止 workflow
let err_msg = format_skill_error(
SKILL_ERR_EXECUTE_FAILED,
format!("步骤 '{}' 执行失败: {}", step.name, err),
);
callback.on_complete(false, None, Some(&err_msg));
let done_event = TauriAgentEvent::FinalDone { usage: None };
let _ = app_handle.emit(&event_name, &done_event);
return Ok(SkillExecutionResult {
success: false,
output: None,
error: Some(err_msg),
steps_completed,
});
}
// 步骤成功
callback.on_step_complete(&step.id, &step_output);
steps_completed.push(StepResult {
step_id: step.id.clone(),
step_name: step.name.clone(),
success: true,
output: Some(step_output.clone()),
error: None,
});
// 累积上下文供下一步使用
accumulated_context = step_output.clone();
final_output = step_output;
}
// 所有步骤完成
callback.on_complete(true, Some(&final_output), None);
let done_event = TauriAgentEvent::FinalDone { usage: None };
let _ = app_handle.emit(&event_name, &done_event);
tracing::info!(
"[execute_skill_workflow] Workflow 执行完成: skill={}, steps_completed={}",
skill.skill_name,
steps_completed.len()
);
Ok(SkillExecutionResult {
success: true,
output: Some(final_output),
error: None,
steps_completed,
})
}
/// 列出可执行的 Skills
///
/// 返回所有可以执行的 Skills 列表,过滤掉 disable_model_invocation=true 的 Skills。
@@ -388,8 +603,8 @@ pub async fn execute_skill(
/// - 4.4: 过滤 disable_model_invocation=true 的 skills
#[tauri::command]
pub async fn list_executable_skills() -> Result<Vec<ExecutableSkillInfo>, String> {
let skills_dir =
get_proxycast_skills_dir().ok_or_else(|| "无法获取 Skills 目录".to_string())?;
let skills_dir = get_proxycast_skills_dir()
.ok_or_else(|| format_skill_error(SKILL_ERR_CATALOG_UNAVAILABLE, "无法获取 Skills 目录"))?;
// 加载所有 skills
let all_skills = load_skills_from_directory(&skills_dir);
@@ -437,7 +652,7 @@ pub async fn list_executable_skills() -> Result<Vec<ExecutableSkillInfo>, String
#[tauri::command]
pub async fn get_skill_detail(skill_name: String) -> Result<SkillDetailInfo, String> {
// 查找 skill(Requirements 5.1, 5.4)
let skill = find_skill_by_name(&skill_name)?;
let skill = find_skill_by_name(&skill_name).map_err(map_find_skill_error)?;
// 转换为 SkillDetailInfo(Requirements 5.2, 5.3)
let detail = SkillDetailInfo {
@@ -452,7 +667,21 @@ pub async fn get_skill_detail(skill_name: String) -> Result<SkillDetailInfo, Str
argument_hint: skill.argument_hint,
},
markdown_content: skill.markdown_content,
workflow_steps: None, // TODO: 解析 workflow 步骤(如果有)
workflow_steps: if skill.workflow_steps.is_empty() {
None
} else {
Some(
skill
.workflow_steps
.iter()
.map(|s| WorkflowStepInfo {
id: s.id.clone(),
name: s.name.clone(),
dependencies: Vec::new(),
})
.collect(),
)
},
allowed_tools: skill.allowed_tools,
when_to_use: skill.when_to_use,
};
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.63.0",
"version": "0.64.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.63.0",
"version": "0.64.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+13
View File
@@ -19,6 +19,7 @@ import { MemoryPage } from "./components/memory";
import { AgentChatPage } from "./components/agent";
import { PluginsPage } from "./components/plugins/PluginsPage";
import { ImageGenPage } from "./components/image-gen";
import { BatchPage } from "./components/batch";
import { CreateProjectDialog } from "./components/projects/CreateProjectDialog";
import { WorkbenchPage } from "./components/workspace";
import {
@@ -355,6 +356,7 @@ function AppContent() {
contentId={(pageParams as AgentPageParams).contentId}
theme={theme}
viewMode={(pageParams as AgentPageParams).workspaceViewMode}
resetAt={(pageParams as AgentPageParams).workspaceResetAt}
/>
</div>
);
@@ -375,6 +377,17 @@ function AppContent() {
<ImageGenPage onNavigate={handleNavigate} />
</div>
<div
style={{
flex: 1,
minHeight: 0,
display: currentPage === "batch" ? "flex" : "none",
flexDirection: "column",
}}
>
<BatchPage onNavigate={handleNavigate} />
</div>
<div
style={{
flex: 1,
+14 -21
View File
@@ -26,6 +26,7 @@ import {
FileType,
ChevronDown,
Activity,
Layers,
LucideIcon,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
@@ -39,6 +40,10 @@ import {
ThemeWorkspacePage,
} from "@/types/page";
import { getConfig } from "@/hooks/useTauri";
import {
buildHomeAgentParams,
buildWorkspaceResetParams,
} from "@/lib/workspace/navigation";
interface AppSidebarProps {
currentPage: Page;
@@ -262,6 +267,7 @@ const MAIN_MENU_ITEMS: SidebarNavItem[] = [
isActive: (currentPage) => currentPage === "agent",
},
{ id: "image-gen", label: "绘画", icon: Image, page: "image-gen" },
{ id: "batch", label: "批量任务", icon: Layers, page: "batch" },
{ id: "plugins", label: "插件中心", icon: Compass, page: "plugins" },
];
@@ -522,11 +528,12 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
const params: PageParams | undefined =
item.id === "home-general"
? ({
...(item.params as AgentPageParams | undefined),
newChatAt: Date.now(),
} as AgentPageParams)
: item.params;
? buildHomeAgentParams(item.params as AgentPageParams | undefined)
: isThemeWorkspacePage(item.page)
? buildWorkspaceResetParams(
item.params as AgentPageParams | undefined,
)
: item.params;
onNavigate(item.page, params);
};
@@ -534,15 +541,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
return (
<Container>
<HeaderArea>
<UserButton
onClick={() =>
onNavigate("agent", {
theme: "general",
lockTheme: false,
newChatAt: Date.now(),
})
}
>
<UserButton onClick={() => onNavigate("agent", buildHomeAgentParams())}>
<Avatar>
<img src="/logo.png" alt="ProxyCast" />
</Avatar>
@@ -551,13 +550,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
</UserButton>
<SearchButton
onClick={() =>
onNavigate("agent", {
theme: "general",
lockTheme: false,
newChatAt: Date.now(),
})
}
onClick={() => onNavigate("agent", buildHomeAgentParams())}
>
<Search size={14} />
<span>搜索</span>
@@ -7,6 +7,7 @@ import { Navbar } from "../styles";
interface ChatNavbarProps {
isRunning: boolean;
onToggleHistory: () => void;
showHistoryToggle?: boolean;
onToggleFullscreen: () => void;
onToggleSettings?: () => void;
onBackHome?: () => void;
@@ -18,6 +19,7 @@ interface ChatNavbarProps {
export const ChatNavbar: React.FC<ChatNavbarProps> = ({
isRunning: _isRunning,
onToggleHistory,
showHistoryToggle = true,
onToggleFullscreen: _onToggleFullscreen,
onToggleSettings,
onBackHome,
@@ -39,14 +41,16 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
<Home size={18} />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={onToggleHistory}
>
<Box size={18} />
</Button>
{showHistoryToggle && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={onToggleHistory}
>
<Box size={18} />
</Button>
)}
</div>
<div className="flex-1" />
@@ -1,15 +1,6 @@
import React from "react";
import {
Paperclip,
Lightbulb,
Globe,
Zap,
Brush,
MessageSquareDiff,
Maximize2,
PanelRight,
} from "lucide-react";
import { ToolButton, Divider } from "../styles";
import { Paperclip, Lightbulb, Globe, MessageSquareDiff } from "lucide-react";
import { ToolButton } from "../styles";
import {
Tooltip,
TooltipContent,
@@ -20,14 +11,13 @@ import {
interface InputbarToolsProps {
onToolClick?: (tool: string) => void;
activeTools?: Record<string, boolean>;
/** 画布是否打开 */
/** 画布是否打开(兼容保留,不再展示画布图标) */
isCanvasOpen?: boolean;
}
export const InputbarTools: React.FC<InputbarToolsProps> = ({
onToolClick,
activeTools = {},
isCanvasOpen = false,
}) => {
return (
<TooltipProvider>
@@ -81,49 +71,6 @@ export const InputbarTools: React.FC<InputbarToolsProps> = ({
联网搜索 {activeTools["web_search"] ? "(已开启)" : ""}
</TooltipContent>
</Tooltip>
<Divider />
<Tooltip>
<TooltipTrigger asChild>
<ToolButton onClick={() => onToolClick?.("quick_action")}>
<Zap />
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">快捷指令</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToolButton onClick={() => onToolClick?.("fullscreen")}>
<Maximize2 />
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">全屏编辑</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToolButton
onClick={() => onToolClick?.("canvas")}
className={isCanvasOpen ? "active" : ""}
>
<PanelRight className={isCanvasOpen ? "text-primary" : ""} />
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">
{isCanvasOpen ? "关闭画布" : "打开画布"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToolButton onClick={() => onToolClick?.("clear")}>
<Brush />
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">清除输入</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
@@ -14,10 +14,10 @@ import { ChatModelSelector } from "../ChatModelSelector";
const TaskFilesArea = styled.div`
display: flex;
justify-content: flex-end;
padding: 0 18px 8px 18px;
padding: 0 8px 8px 8px;
width: 100%;
max-width: 900px;
margin: 0 auto;
max-width: none;
margin: 0;
`;
// 按钮和面板的包装容器
@@ -163,8 +163,6 @@ export const Inputbar: React.FC<InputbarProps> = ({
fileInputRef.current?.click();
break;
case "quick_action":
toast.info("快捷指令功能开发中...");
break;
case "translate":
toast.info("翻译功能开发中...");
break;
@@ -27,10 +27,10 @@ export const Container = styled.div`
flex-direction: column;
position: relative;
z-index: 2;
padding: 0 18px 18px 18px; /* Cherry Studio Exact: 0 18px 18px 18px */
padding: 0 8px 12px 8px;
width: 100%;
max-width: 900px;
margin: 0 auto;
max-width: none;
margin: 0;
`;
export const InputBarContainer = styled.div`
@@ -102,6 +102,7 @@ export const BottomBar = styled.div`
position: relative;
z-index: 2;
flex-shrink: 0;
min-width: 0;
`;
// ... (LeftSection and RightSection seem fine without vars, skipping for brevity of replace block if possible but might as well include to be safe or target specific chunks)
@@ -112,13 +113,26 @@ export const LeftSection = styled.div`
align-items: center;
flex: 1;
min-width: 0;
/* Cherry Studio uses ToolWrapper with margin-right: 6px, handled in component or here */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
> * {
flex-shrink: 0;
}
`;
export const RightSection = styled.div`
display: flex;
align-items: center;
gap: 6px; /* Cherry Studio Exact: 6px */
flex-shrink: 0;
margin-left: 4px;
`;
// --- InputbarTools Styles ---
+138 -19
View File
@@ -7,6 +7,10 @@ import {
type ExecutableSkillInfo,
} from "@/lib/api/skill-execution";
import type { ActionRequired, Message } from "../types";
import {
formatSkillFailureMessage,
resolveSkillFailure,
} from "../utils/skillFailure";
/** 解析 /skill-name args 命令 */
export interface ParsedSkillCommand {
@@ -26,6 +30,8 @@ export interface SlashSkillExecutionContext {
setIsSending: (value: boolean) => void;
setCurrentAssistantMsgId: (id: string | null) => void;
setStreamUnlisten: (unlisten: UnlistenFn | null) => void;
setActiveSessionIdForStop: (sessionId: string | null) => void;
isExecutionCancelled: () => boolean;
playTypewriterSound: () => void;
playToolcallSound: () => void;
onWriteFile?: (content: string, fileName: string) => void;
@@ -176,15 +182,26 @@ function tryHandleToolWriteFile(
}
}
interface MatchedSkillResult {
matchedSkill: ExecutableSkillInfo | null;
catalogLoadFailed: boolean;
}
async function findMatchedSkill(
skillName: string,
): Promise<ExecutableSkillInfo | null> {
): Promise<MatchedSkillResult> {
try {
const skills = await skillExecutionApi.listExecutableSkills();
return skills.find((skill) => skill.name === skillName) || null;
return {
matchedSkill: skills.find((skill) => skill.name === skillName) || null,
catalogLoadFailed: false,
};
} catch (error) {
console.warn("[SkillCommand] 获取可执行 Skills 失败,回退普通对话:", error);
return null;
console.warn("[SkillCommand] 获取可执行 Skills 失败:", error);
return {
matchedSkill: null,
catalogLoadFailed: true,
};
}
}
@@ -207,38 +224,71 @@ export async function tryExecuteSlashSkillCommand(
setIsSending,
setCurrentAssistantMsgId,
setStreamUnlisten,
setActiveSessionIdForStop,
isExecutionCancelled,
playTypewriterSound,
playToolcallSound,
onWriteFile,
} = ctx;
const matchedSkill = await findMatchedSkill(command.skillName);
const { matchedSkill, catalogLoadFailed } = await findMatchedSkill(
command.skillName,
);
if (!matchedSkill) {
return false;
}
const failure = resolveSkillFailure(
catalogLoadFailed
? "skill_catalog_unavailable|无法加载可执行 Skills 列表"
: `skill_not_found|未找到名为 "${command.skillName}" 的 Skill`,
);
const failureText = formatSkillFailureMessage(failure);
const activeSessionId = await ensureSession();
if (!activeSessionId) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
content: "Skill 执行失败:无法创建会话",
content: failureText,
isThinking: false,
thinkingContent: undefined,
contentParts: [
{ type: "text" as const, text: "Skill 执行失败:无法创建会话" },
],
contentParts: [{ type: "text" as const, text: failureText }],
}
: msg,
),
);
setIsSending(false);
setCurrentAssistantMsgId(null);
setActiveSessionIdForStop(null);
return true;
}
const activeSessionId = await ensureSession();
if (!activeSessionId) {
const failure = resolveSkillFailure(
"skill_session_init_failed|无法创建或恢复 Skill 会话",
);
const failureText = formatSkillFailureMessage(failure);
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
? {
...msg,
content: failureText,
isThinking: false,
thinkingContent: undefined,
contentParts: [{ type: "text" as const, text: failureText }],
}
: msg,
),
);
setIsSending(false);
setCurrentAssistantMsgId(null);
setActiveSessionIdForStop(null);
return true;
}
setActiveSessionIdForStop(activeSessionId);
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMsgId
@@ -265,20 +315,47 @@ export async function tryExecuteSlashSkillCommand(
let accumulatedContent = "";
let skillUnlisten: UnlistenFn | null = null;
let stepUnlisteners: UnlistenFn[] = [];
const cleanup = () => {
if (skillUnlisten) {
skillUnlisten();
skillUnlisten = null;
}
for (const ul of stepUnlisteners) {
ul();
}
stepUnlisteners = [];
setStreamUnlisten(null);
setIsSending(false);
setCurrentAssistantMsgId(null);
setActiveSessionIdForStop(null);
};
try {
const eventName = `skill-exec-${assistantMsgId}`;
// 监听 workflow 步骤事件
const stepStartUl = await safeListen<{
execution_id: string;
step_id: string;
step_name: string;
current_step: number;
total_steps: number;
}>("skill:step_start", ({ payload }) => {
if (payload.execution_id !== assistantMsgId) return;
// 注入步骤分隔标记
const marker =
payload.total_steps > 1
? `\n\n---\n**步骤 ${payload.current_step}/${payload.total_steps}: ${payload.step_name}**\n\n`
: "";
if (marker) {
accumulatedContent += marker;
setMessages((prev) => appendTextPart(prev, assistantMsgId, marker));
}
});
stepUnlisteners.push(stepStartUl);
skillUnlisten = await safeListen<StreamEvent>(eventName, ({ payload }) => {
const streamEvent = parseStreamEvent(payload as unknown);
if (!streamEvent) return;
@@ -477,10 +554,38 @@ export async function tryExecuteSlashSkillCommand(
`[SkillCommand] 执行完成: name=${command.skillName}, success=${result.success}, output_len=${result.output?.length ?? 0}, stream_stats=${JSON.stringify(streamCounters)}`,
);
if (isExecutionCancelled()) {
console.info(
`[SkillCommand] 执行结果已忽略(用户已取消): ${command.skillName}`,
);
cleanup();
return true;
}
const hasStreamedContent = accumulatedContent.trim().length > 0;
const finalContent = hasStreamedContent
? accumulatedContent
: result.output || result.error || "Skill 执行完成";
const failure = !result.success
? resolveSkillFailure(
result.error || "skill_execute_failed|Skill 执行返回失败",
)
: null;
const failureText = failure ? formatSkillFailureMessage(failure) : "";
const finalContent = failure
? hasStreamedContent
? `${accumulatedContent}
${failureText}`
: failureText
: hasStreamedContent
? accumulatedContent
: result.output || "Skill 执行完成";
if (failure) {
console.warn(
`[SkillCommand] 执行完成但返回失败: name=${command.skillName}, code=${failure.code}, message=${failure.message}`,
);
}
setMessages((prev) =>
prev.map((msg) => {
@@ -504,7 +609,21 @@ export async function tryExecuteSlashSkillCommand(
cleanup();
return true;
} catch (error) {
console.error(`[SkillCommand] 执行失败: ${command.skillName}`, error);
if (isExecutionCancelled()) {
console.info(
`[SkillCommand] 执行异常已忽略(用户已取消): ${command.skillName}`,
);
cleanup();
return true;
}
const failure = resolveSkillFailure(error);
const failureText = formatSkillFailureMessage(failure);
console.error(
`[SkillCommand] 执行失败: ${command.skillName}, code=${failure.code}`,
error,
);
setMessages((prev) =>
prev.map((msg) =>
@@ -513,11 +632,11 @@ export async function tryExecuteSlashSkillCommand(
...msg,
isThinking: false,
thinkingContent: undefined,
content: `Skill 执行失败: ${error instanceof Error ? error.message : String(error)}`,
content: failureText,
contentParts: [
{
type: "text",
text: `Skill 执行失败: ${error instanceof Error ? error.message : String(error)}`,
text: failureText,
},
],
}
+206 -44
View File
@@ -15,6 +15,7 @@ import {
generateAgentTitle,
parseStreamEvent,
sendPermissionResponse,
stopAsterSession,
type AgentProcessStatus,
type SessionInfo,
type StreamEvent,
@@ -36,6 +37,10 @@ import {
parseSkillSlashCommand,
tryExecuteSlashSkillCommand,
} from "./skillCommand";
import {
isValidSessionId,
resolveRestorableSessionId,
} from "../utils/sessionRecovery";
/** 话题(会话)信息 */
export interface Topic {
@@ -264,6 +269,8 @@ export function useAgentChat(options: UseAgentChatOptions) {
const unlistenRef = useRef<UnlistenFn | null>(null);
// 用于保存当前正在处理的消息 ID
const currentAssistantMsgIdRef = useRef<string | null>(null);
// 当前流式请求对应的会话 ID(用于 stop 时通知后端取消)
const currentStreamingSessionIdRef = useRef<string | null>(null);
// 自动恢复/水合状态跟踪
const restoredWorkspaceRef = useRef<string | null>(null);
const hydratedSessionRef = useRef<string | null>(null);
@@ -365,6 +372,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
if (!workspaceId?.trim()) {
setSessionId(null);
setMessages([]);
currentStreamingSessionIdRef.current = null;
_setRoundCount(0);
setA2uiFormDataMap({});
restoredWorkspaceRef.current = null;
@@ -395,7 +403,35 @@ export function useAgentChat(options: UseAgentChatOptions) {
const loadTopics = async () => {
try {
const sessions = await listAgentSessions();
const topicList: Topic[] = sessions.map((s: SessionInfo) => ({
const resolvedWorkspaceId = workspaceId?.trim();
const validSessions = sessions.filter((s) =>
isValidSessionId(s.session_id),
);
for (const session of validSessions) {
if (session.workspace_id) {
savePersisted(
`agent_session_workspace_${session.session_id}`,
session.workspace_id,
);
}
}
const filteredSessions = resolvedWorkspaceId
? validSessions.filter((session) => {
const mappedWorkspaceId =
session.workspace_id ||
loadPersisted<string | null>(
`agent_session_workspace_${session.session_id}`,
null,
);
return mappedWorkspaceId === resolvedWorkspaceId;
})
: validSessions;
const topicList: Topic[] = filteredSessions.map((s: SessionInfo) => ({
id: s.session_id,
title: s.title || generateTopicTitle(s),
createdAt: new Date(s.created_at),
@@ -515,13 +551,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId]);
// Ensure an active session exists (internal helper)
const _ensureSession = async (): Promise<string | null> => {
// If we already have a session, we might want to continue using it.
// However, check if we need to "re-initialize" if critical params changed?
// User said: "选择模型后,不用和会话绑定". So we keep the session ID if it exists.
if (sessionId) return sessionId;
const createFreshSession = async (): Promise<string | null> => {
try {
// TEMPORARY FIX: Disable skills integration due to API type mismatch (Backend expects []SystemMessage, Client sends String)
// const [claudeSkills, proxyCastSkills] = await Promise.all([
@@ -565,6 +595,104 @@ export function useAgentChat(options: UseAgentChatOptions) {
}
};
// Ensure an active session exists (internal helper)
const _ensureSession = async (): Promise<string | null> => {
// If we already have a session, we might want to continue using it.
// However, check if we need to "re-initialize" if critical params changed?
// User said: "选择模型后,不用和会话绑定". So we keep the session ID if it exists.
if (sessionId) return sessionId;
return createFreshSession();
};
const isSessionWorkspaceMismatchError = (error: unknown): boolean => {
const errorMessage = `${error}`;
return (
errorMessage.includes("workspace_mismatch|") ||
errorMessage.includes("会话工作目录与 workspace 不匹配")
);
};
const resetBrokenSessionBinding = (staleSessionId: string | null) => {
const resolvedWorkspaceId = workspaceId?.trim();
sessionResetVersionRef.current += 1;
setSessionId(null);
currentStreamingSessionIdRef.current = null;
hydratedSessionRef.current = null;
skipAutoRestoreRef.current = true;
restoredWorkspaceRef.current = resolvedWorkspaceId || null;
if (resolvedWorkspaceId) {
saveTransient(`agent_curr_sessionId_${resolvedWorkspaceId}`, null);
savePersisted(`agent_last_sessionId_${resolvedWorkspaceId}`, null);
}
if (staleSessionId) {
savePersisted(`agent_session_workspace_${staleSessionId}`, "__invalid__");
}
};
const sendStreamWithSessionRecovery = async (
message: string,
eventName: string,
resolvedWorkspaceId: string,
activeSessionId: string,
modelName?: string,
images?: Array<{ data: string; media_type: string }>,
projectId?: string,
): Promise<void> => {
currentStreamingSessionIdRef.current = activeSessionId;
try {
await sendAgentMessageStream(
message,
eventName,
resolvedWorkspaceId,
activeSessionId,
modelName,
images,
providerType,
undefined,
projectId,
);
return;
} catch (error) {
if (!isSessionWorkspaceMismatchError(error)) {
throw error;
}
console.warn("[AgentChat] 检测到会话工作目录不匹配,准备自动重建会话", {
sessionId: activeSessionId,
workspaceId: resolvedWorkspaceId,
});
resetBrokenSessionBinding(activeSessionId);
const freshSessionId = await createFreshSession();
if (!freshSessionId) {
throw error;
}
currentStreamingSessionIdRef.current = freshSessionId;
toast.info("检测到旧会话目录异常,已自动切换新会话");
console.info("[AgentChat] session_auto_recovered", {
staleSessionId: activeSessionId,
freshSessionId,
workspaceId: resolvedWorkspaceId,
});
await sendAgentMessageStream(
message,
eventName,
resolvedWorkspaceId,
freshSessionId,
modelName,
images,
providerType,
undefined,
projectId,
);
}
};
const sendMessage = async (
content: string,
images: MessageImage[],
@@ -631,6 +759,11 @@ export function useAgentChat(options: UseAgentChatOptions) {
setStreamUnlisten: (unlistenFn) => {
unlistenRef.current = unlistenFn;
},
setActiveSessionIdForStop: (sessionIdForStop) => {
currentStreamingSessionIdRef.current = sessionIdForStop;
},
isExecutionCancelled: () =>
currentAssistantMsgIdRef.current !== assistantMsgId,
playTypewriterSound,
playToolcallSound,
onWriteFile,
@@ -707,6 +840,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
if (!activeSessionId) {
throw new Error("无法创建或获取会话");
}
currentStreamingSessionIdRef.current = activeSessionId;
// 3. 创建唯一事件名称
const eventName = `agent_stream_${assistantMsgId}`;
@@ -815,6 +949,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
// 清理 ref
unlistenRef.current = null;
currentAssistantMsgIdRef.current = null;
currentStreamingSessionIdRef.current = null;
if (unlisten) {
unlisten();
unlisten = null;
@@ -862,6 +997,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
// 清理 ref
unlistenRef.current = null;
currentAssistantMsgIdRef.current = null;
currentStreamingSessionIdRef.current = null;
if (unlisten) {
unlisten();
unlisten = null;
@@ -1073,14 +1209,13 @@ export function useAgentChat(options: UseAgentChatOptions) {
});
const resolvedWorkspaceId = getRequiredWorkspaceId();
await sendAgentMessageStream(
await sendStreamWithSessionRecovery(
messageToSend,
eventName,
resolvedWorkspaceId,
activeSessionId, // 传递 sessionId 以保持上下文
activeSessionId,
model || undefined,
imagesToSend,
providerType, // 传递用户选择的 provider
);
} catch (error) {
console.error("[AgentChat] Send failed:", error);
@@ -1091,6 +1226,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
// Remove the optimistic assistant message on failure
setMessages((prev) => prev.filter((msg) => msg.id !== assistantMsgId));
setIsSending(false);
currentStreamingSessionIdRef.current = null;
if (unlisten) {
unlisten();
}
@@ -1124,6 +1260,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
setMessages([]);
setSessionId(null);
currentStreamingSessionIdRef.current = null;
_setRoundCount(0);
setA2uiFormDataMap({});
restoredWorkspaceRef.current = resolvedWorkspaceId || null;
@@ -1149,6 +1286,23 @@ export function useAgentChat(options: UseAgentChatOptions) {
const switchTopic = async (topicId: string) => {
if (topicId === sessionId && messages.length > 0) return;
const resolvedWorkspaceId = workspaceId?.trim();
if (resolvedWorkspaceId) {
const topicWorkspaceId = loadPersisted<string | null>(
`agent_session_workspace_${topicId}`,
null,
);
if (topicWorkspaceId && topicWorkspaceId !== resolvedWorkspaceId) {
console.warn("[AgentChat] cross_workspace_topic_blocked", {
topicId,
topicWorkspaceId,
currentWorkspaceId: resolvedWorkspaceId,
});
toast.error("该话题不属于当前项目,已阻止切换");
return;
}
}
const restoreRequestVersion = sessionResetVersionRef.current;
skipAutoRestoreRef.current = false;
console.log("[useAgentChat] 切换话题:", topicId);
@@ -1232,6 +1386,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
// 加载失败时回退到新会话态,避免卡在无效会话
setMessages([]);
setSessionId(null);
currentStreamingSessionIdRef.current = null;
saveTransient(getScopedSessionKey(), null);
savePersisted(getScopedPersistedSessionKey(), null);
toast.error("加载对话历史失败");
@@ -1249,39 +1404,31 @@ export function useAgentChat(options: UseAgentChatOptions) {
restoredWorkspaceRef.current = resolvedWorkspaceId;
const scopedCandidate =
loadTransient<string | null>(getScopedSessionKey(), null) ||
loadPersisted<string | null>(getScopedPersistedSessionKey(), null);
const legacyCandidateRaw = loadTransient<string | null>(
const scopedTransientCandidate = loadTransient<string | null>(
getScopedSessionKey(),
null,
);
const scopedPersistedCandidate = loadPersisted<string | null>(
getScopedPersistedSessionKey(),
null,
);
const legacyCandidate = loadTransient<string | null>(
"agent_curr_sessionId",
null,
);
const legacyCandidateWorkspace = legacyCandidateRaw
? loadPersisted<string | null>(
`agent_session_workspace_${legacyCandidateRaw}`,
null,
)
: null;
const legacyCandidate =
legacyCandidateRaw &&
(!legacyCandidateWorkspace ||
legacyCandidateWorkspace === resolvedWorkspaceId)
? legacyCandidateRaw
: null;
const mappedFallbackCandidate =
topics.find(
(topic) =>
loadPersisted<string | null>(
`agent_session_workspace_${topic.id}`,
null,
) === resolvedWorkspaceId,
)?.id || null;
const fallbackCandidate =
mappedFallbackCandidate || (topics.length === 1 ? topics[0]?.id : null);
const targetSessionId =
scopedCandidate || legacyCandidate || fallbackCandidate;
const targetSessionId = resolveRestorableSessionId({
workspaceId: resolvedWorkspaceId,
topics,
scopedTransientCandidate,
scopedPersistedCandidate,
legacyCandidate,
resolveWorkspaceIdBySessionId: (candidate) =>
loadPersisted<string | null>(
`agent_session_workspace_${candidate}`,
null,
),
});
if (!targetSessionId) {
return;
@@ -1333,6 +1480,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
if (topicId === sessionId) {
setSessionId(null);
setMessages([]);
currentStreamingSessionIdRef.current = null;
}
toast.success("话题已删除");
} catch (_error) {
@@ -1355,13 +1503,16 @@ export function useAgentChat(options: UseAgentChatOptions) {
await stopAgentProcess();
setProcessStatus({ running: false });
setSessionId(null); // Reset session on stop
currentStreamingSessionIdRef.current = null;
} catch (_e) {
toast.error("Stop failed");
}
};
// 停止当前发送中的消息
const stopSending = () => {
const stopSending = async () => {
const streamingSessionId = currentStreamingSessionIdRef.current;
// 取消事件监听
if (unlistenRef.current) {
unlistenRef.current();
@@ -1384,8 +1535,17 @@ export function useAgentChat(options: UseAgentChatOptions) {
currentAssistantMsgIdRef.current = null;
}
currentStreamingSessionIdRef.current = null;
setIsSending(false);
toast.info("已停止生成");
if (streamingSessionId) {
try {
await stopAsterSession(streamingSessionId);
} catch (error) {
console.warn("[useAgentChat] 停止会话失败:", error);
}
}
};
// 触发 AI 引导(不显示用户消息,直接让 AI 开始引导)
@@ -1464,6 +1624,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
if (!activeSessionId) {
throw new Error("无法创建或获取会话");
}
currentStreamingSessionIdRef.current = activeSessionId;
// 创建唯一事件名称
const eventName = `agent_stream_${assistantMsgId}`;
@@ -1556,6 +1717,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
setIsSending(false);
unlistenRef.current = null;
currentAssistantMsgIdRef.current = null;
currentStreamingSessionIdRef.current = null;
if (unlisten) {
unlisten();
unlisten = null;
@@ -1585,6 +1747,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
setIsSending(false);
unlistenRef.current = null;
currentAssistantMsgIdRef.current = null;
currentStreamingSessionIdRef.current = null;
if (unlisten) {
unlisten();
unlisten = null;
@@ -1739,14 +1902,12 @@ export function useAgentChat(options: UseAgentChatOptions) {
// 发送空消息,让 AI 根据系统提示词开始引导
console.log("[AgentChat] triggerAIGuide 发送空消息触发引导");
const resolvedWorkspaceId = getRequiredWorkspaceId();
await sendAgentMessageStream(
await sendStreamWithSessionRecovery(
"", // 空消息,让 AI 根据系统提示词开始引导
eventName,
resolvedWorkspaceId,
activeSessionId,
model || undefined,
undefined,
providerType,
);
} catch (error) {
console.error("[AgentChat] triggerAIGuide failed:", error);
@@ -1756,6 +1917,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
});
setMessages((prev) => prev.filter((msg) => msg.id !== assistantMsgId));
setIsSending(false);
currentStreamingSessionIdRef.current = null;
if (unlisten) {
unlisten();
}
+43 -11
View File
@@ -61,6 +61,7 @@ import {
} from "@/lib/api/memory";
import type { Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
import type { MessageImage } from "./types";
import type { ThemeType, LayoutMode } from "@/components/content-creator/types";
@@ -116,7 +117,7 @@ const ChatContent = styled.div`
flex-direction: column;
flex: 1;
min-height: 0;
padding: 0 16px;
padding: 0 6px;
overflow: hidden;
height: 100%;
`;
@@ -179,7 +180,9 @@ export function AgentChatPage({
projectId: externalProjectId,
contentId,
theme: initialTheme,
initialCreationMode,
lockTheme = false,
hideHistoryToggle = false,
newChatAt,
onRecommendationClick: _onRecommendationClick,
onHasMessagesChange,
@@ -188,7 +191,9 @@ export function AgentChatPage({
projectId?: string;
contentId?: string;
theme?: string;
initialCreationMode?: CreationMode;
lockTheme?: boolean;
hideHistoryToggle?: boolean;
newChatAt?: number;
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
onHasMessagesChange?: (hasMessages: boolean) => void;
@@ -200,7 +205,9 @@ export function AgentChatPage({
const [activeTheme, setActiveTheme] = useState<string>(
normalizeInitialTheme(initialTheme),
);
const [creationMode, setCreationMode] = useState<CreationMode>("guided");
const [creationMode, setCreationMode] = useState<CreationMode>(
initialCreationMode ?? "guided",
);
const [layoutMode, setLayoutMode] = useState<LayoutMode>("chat");
useEffect(() => {
@@ -208,6 +215,11 @@ export function AgentChatPage({
setActiveTheme(normalizeInitialTheme(initialTheme));
}, [initialTheme]);
useEffect(() => {
if (!initialCreationMode) return;
setCreationMode(initialCreationMode);
}, [initialCreationMode]);
// 内部 projectId 状态(当外部未提供时使用)
const [internalProjectId, setInternalProjectId] = useState<string | null>(
null,
@@ -764,7 +776,7 @@ export function AgentChatPage({
setProjectMemory(null);
setActiveTheme("general");
setCreationMode("guided");
_onNavigate?.("agent", { theme: "general", lockTheme: false });
_onNavigate?.("agent", buildHomeAgentParams());
}, [clearMessages, _onNavigate]);
// 当开始对话时自动折叠侧边栏
@@ -780,21 +792,40 @@ export function AgentChatPage({
}
}, [hasMessages]);
// 当有文件时默认在画布中显示最后一个文件
// 当有文件时默认在画布中显示最新文件(按更新时间)
useEffect(() => {
if (taskFiles.length > 0) {
const lastFile = taskFiles[taskFiles.length - 1];
const latestFile = taskFiles.reduce<TaskFile | null>(
(candidate, file) => {
if (!candidate) {
return file;
}
const candidateTimestamp = Math.max(
candidate.updatedAt,
candidate.createdAt,
);
const fileTimestamp = Math.max(file.updatedAt, file.createdAt);
return fileTimestamp >= candidateTimestamp ? file : candidate;
},
null,
);
if (!latestFile) {
return;
}
// 设置选中的文件
setSelectedFileId(lastFile.id);
setSelectedFileId(latestFile.id);
// 如果文件有内容,在画布中显示
if (lastFile.content) {
const latestContent = latestFile.content;
if (latestContent) {
setCanvasState((prev) => {
if (mappedTheme === "music") {
const sections = parseLyrics(lastFile.content!);
const sections = parseLyrics(latestContent);
if (!prev || prev.type !== "music") {
const musicState = createInitialMusicState();
musicState.sections = sections;
const titleMatch = lastFile.content!.match(/^#\s*(.+)$/m);
const titleMatch = latestContent.match(/^#\s*(.+)$/m);
if (titleMatch) {
musicState.spec.title = titleMatch[1].trim();
}
@@ -803,9 +834,9 @@ export function AgentChatPage({
return { ...prev, sections };
}
if (!prev || prev.type !== "document") {
return createInitialDocumentState(lastFile.content!);
return createInitialDocumentState(latestContent);
}
return { ...prev, content: lastFile.content! };
return { ...prev, content: latestContent };
});
setLayoutMode("chat-canvas");
}
@@ -1510,6 +1541,7 @@ export function AgentChatPage({
<ChatNavbar
isRunning={isSending}
onToggleHistory={handleToggleSidebar}
showHistoryToggle={!hideHistoryToggle}
onToggleFullscreen={() => {}}
projectId={projectId ?? null}
onProjectChange={(newProjectId) => setInternalProjectId(newProjectId)}
+4 -4
View File
@@ -69,11 +69,11 @@ export const MessageWrapper = styled.div<{ $isUser: boolean }>`
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 16px 24px;
gap: 16px;
padding: 12px 6px;
gap: 12px;
width: 100%;
max-width: 900px;
margin: 0 auto;
max-width: none;
margin: 0;
&:hover .message-actions {
opacity: 1;
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
isValidSessionId,
resolveRestorableSessionId,
} from "./sessionRecovery";
describe("isValidSessionId", () => {
it("应拒绝空值与空字符串", () => {
expect(isValidSessionId(null)).toBe(false);
expect(isValidSessionId(undefined)).toBe(false);
expect(isValidSessionId(" ")).toBe(false);
});
it("应拒绝已知非法会话 ID", () => {
expect(isValidSessionId("abc/def")).toBe(false);
expect(isValidSessionId("[object Promise]")).toBe(false);
});
it("应接受正常会话 ID", () => {
expect(isValidSessionId("session_123")).toBe(true);
expect(isValidSessionId("f65b8b87-9b5b-4312-9cd4-8f55f20cb5dd")).toBe(true);
});
});
describe("resolveRestorableSessionId", () => {
const workspaceMap: Record<string, string | null> = {
s1: "ws-a",
s2: "ws-a",
s3: "ws-a",
s4: "ws-b",
};
const resolveWorkspaceIdBySessionId = (sessionId: string) =>
workspaceMap[sessionId] ?? null;
const topics = [{ id: "s1" }, { id: "s2" }, { id: "s4" }];
it("应优先使用 scoped transient 候选", () => {
const result = resolveRestorableSessionId({
workspaceId: "ws-a",
topics,
scopedTransientCandidate: "s2",
scopedPersistedCandidate: "s1",
legacyCandidate: "s3",
resolveWorkspaceIdBySessionId,
});
expect(result).toBe("s2");
});
it("应在 transient 非法时使用 scoped persisted", () => {
const result = resolveRestorableSessionId({
workspaceId: "ws-a",
topics,
scopedTransientCandidate: "[object Promise]",
scopedPersistedCandidate: "s1",
legacyCandidate: "s3",
resolveWorkspaceIdBySessionId,
});
expect(result).toBe("s1");
});
it("应在 scoped 候选失效时回退到 legacy", () => {
const result = resolveRestorableSessionId({
workspaceId: "ws-a",
topics,
scopedTransientCandidate: "s4",
scopedPersistedCandidate: "unknown",
legacyCandidate: "s1",
resolveWorkspaceIdBySessionId,
});
expect(result).toBe("s1");
});
it("应拒绝跨 workspace 候选并回退到 topics 首项", () => {
const result = resolveRestorableSessionId({
workspaceId: "ws-a",
topics,
scopedTransientCandidate: "s4",
scopedPersistedCandidate: "s4",
legacyCandidate: "s4",
resolveWorkspaceIdBySessionId,
});
expect(result).toBe("s1");
});
it("应在缺失 topics 时返回 null", () => {
const result = resolveRestorableSessionId({
workspaceId: "ws-a",
topics: [],
scopedTransientCandidate: "s1",
scopedPersistedCandidate: "s1",
legacyCandidate: "s1",
resolveWorkspaceIdBySessionId,
});
expect(result).toBeNull();
});
});
@@ -0,0 +1,86 @@
export interface TopicSessionRef {
id: string;
}
export interface ResolveRestorableSessionIdOptions {
workspaceId: string;
topics: TopicSessionRef[];
scopedTransientCandidate: string | null;
scopedPersistedCandidate: string | null;
legacyCandidate: string | null;
resolveWorkspaceIdBySessionId: (sessionId: string) => string | null;
}
export function isValidSessionId(
sessionId: string | null | undefined,
): sessionId is string {
const normalized = sessionId?.trim();
if (!normalized) {
return false;
}
if (normalized.includes("/") || normalized.includes("[object Promise]")) {
return false;
}
return true;
}
export function resolveRestorableSessionId({
workspaceId,
topics,
scopedTransientCandidate,
scopedPersistedCandidate,
legacyCandidate,
resolveWorkspaceIdBySessionId,
}: ResolveRestorableSessionIdOptions): string | null {
if (!workspaceId || topics.length === 0) {
return null;
}
const topicIdSet = new Set(topics.map((topic) => topic.id));
const normalizeCandidate = (candidate: string | null): string | null => {
if (!isValidSessionId(candidate)) {
return null;
}
if (!topicIdSet.has(candidate)) {
return null;
}
const candidateWorkspaceId = resolveWorkspaceIdBySessionId(candidate);
if (candidateWorkspaceId !== workspaceId) {
return null;
}
return candidate;
};
const scopedTransient = normalizeCandidate(scopedTransientCandidate);
if (scopedTransient) {
return scopedTransient;
}
const scopedPersisted = normalizeCandidate(scopedPersistedCandidate);
if (scopedPersisted) {
return scopedPersisted;
}
const legacy = normalizeCandidate(legacyCandidate);
if (legacy) {
return legacy;
}
const fallback = topics[0]?.id ?? null;
if (!isValidSessionId(fallback)) {
return null;
}
const fallbackWorkspaceId = resolveWorkspaceIdBySessionId(fallback);
if (fallbackWorkspaceId && fallbackWorkspaceId !== workspaceId) {
return null;
}
return fallback;
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { formatSkillFailureMessage, resolveSkillFailure } from "./skillFailure";
describe("resolveSkillFailure", () => {
it("应解析显式错误码", () => {
const result = resolveSkillFailure("skill_not_found|Skill 'writer' 不存在");
expect(result.code).toBe("skill_not_found");
expect(result.message).toContain("不存在");
});
it("应识别 provider 不可用错误", () => {
const result = resolveSkillFailure("无法配置任何可用的 Provider");
expect(result.code).toBe("skill_provider_unavailable");
});
it("应识别取消执行错误", () => {
const result = resolveSkillFailure("Execution cancelled by user");
expect(result.code).toBe("skill_cancelled");
});
it("应识别 stream 错误", () => {
const result = resolveSkillFailure("Stream error: connection reset");
expect(result.code).toBe("skill_stream_failed");
});
it("未知错误应回退为通用错误码", () => {
const result = resolveSkillFailure("some unknown failure");
expect(result.code).toBe("skill_execute_failed");
});
});
describe("formatSkillFailureMessage", () => {
it("应输出包含错误码与恢复建议的文案", () => {
const text = formatSkillFailureMessage(
resolveSkillFailure("skill_session_init_failed|无法创建会话"),
);
expect(text).toContain("skill_session_init_failed");
expect(text).toContain("建议:");
});
});
@@ -0,0 +1,200 @@
export type SkillFailureCode =
| "skill_catalog_unavailable"
| "skill_not_found"
| "skill_session_init_failed"
| "skill_provider_unavailable"
| "skill_workspace_mismatch"
| "skill_stream_failed"
| "skill_cancelled"
| "skill_execute_failed";
export interface SkillFailureInfo {
code: SkillFailureCode;
message: string;
recoveryHint: string;
}
const DEFAULT_FAILURE: SkillFailureInfo = {
code: "skill_execute_failed",
message: "执行过程中发生未知错误",
recoveryHint: "请重试;若持续失败,请切换模型或新建话题后再试。",
};
function normalizeErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message || DEFAULT_FAILURE.message;
}
if (typeof error === "string") {
return error.trim() || DEFAULT_FAILURE.message;
}
if (error && typeof error === "object") {
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && message.trim()) {
return message.trim();
}
}
return DEFAULT_FAILURE.message;
}
function splitErrorCode(rawMessage: string): {
explicitCode: string | null;
message: string;
} {
const separatorIndex = rawMessage.indexOf("|");
if (separatorIndex <= 0) {
return { explicitCode: null, message: rawMessage };
}
const maybeCode = rawMessage.slice(0, separatorIndex).trim();
if (!/^[a-z0-9_:-]+$/i.test(maybeCode)) {
return { explicitCode: null, message: rawMessage };
}
const message = rawMessage.slice(separatorIndex + 1).trim();
return {
explicitCode: maybeCode,
message: message || rawMessage,
};
}
function byCode(code: SkillFailureCode, message: string): SkillFailureInfo {
switch (code) {
case "skill_catalog_unavailable":
return {
code,
message,
recoveryHint: "技能目录暂不可用,请稍后重试或检查本地技能配置。",
};
case "skill_not_found":
return {
code,
message,
recoveryHint: "请确认技能名称拼写,并在技能管理页查看可用技能。",
};
case "skill_session_init_failed":
return {
code,
message,
recoveryHint: "请先新建话题或重新选择项目后再执行技能。",
};
case "skill_provider_unavailable":
return {
code,
message,
recoveryHint: "当前凭证或模型不可用,请切换模型/Provider 后重试。",
};
case "skill_workspace_mismatch":
return {
code,
message,
recoveryHint: "项目目录已变化,请回到首页重新进入该项目后再试。",
};
case "skill_stream_failed":
return {
code,
message,
recoveryHint: "流式执行中断,请重试;必要时先停止当前会话。",
};
case "skill_cancelled":
return {
code,
message,
recoveryHint: "已取消执行,可直接重新发送同一技能命令。",
};
case "skill_execute_failed":
default:
return {
code: "skill_execute_failed",
message,
recoveryHint: DEFAULT_FAILURE.recoveryHint,
};
}
}
function inferSkillFailureCode(message: string): SkillFailureCode {
const lower = message.toLowerCase();
if (
lower.includes("cancel") ||
lower.includes("取消") ||
lower.includes("stopped")
) {
return "skill_cancelled";
}
if (
lower.includes("workspace_mismatch") ||
lower.includes("工作目录") ||
lower.includes("workspace 不匹配")
) {
return "skill_workspace_mismatch";
}
if (
lower.includes("provider") ||
lower.includes("无法配置任何可用") ||
lower.includes("credential")
) {
return "skill_provider_unavailable";
}
if (lower.includes("session") || lower.includes("无法创建会话")) {
return "skill_session_init_failed";
}
if (lower.includes("stream error") || lower.includes("agent error")) {
return "skill_stream_failed";
}
if (
lower.includes("skill") &&
(lower.includes("not found") || lower.includes("不存在"))
) {
return "skill_not_found";
}
return "skill_execute_failed";
}
function normalizeSkillFailureCode(
code: string | null,
): SkillFailureCode | null {
if (!code) {
return null;
}
const normalized = code.trim().toLowerCase();
const allowed: SkillFailureCode[] = [
"skill_catalog_unavailable",
"skill_not_found",
"skill_session_init_failed",
"skill_provider_unavailable",
"skill_workspace_mismatch",
"skill_stream_failed",
"skill_cancelled",
"skill_execute_failed",
];
return allowed.includes(normalized as SkillFailureCode)
? (normalized as SkillFailureCode)
: null;
}
export function resolveSkillFailure(error: unknown): SkillFailureInfo {
const raw = normalizeErrorMessage(error);
const { explicitCode, message } = splitErrorCode(raw);
const normalizedCode = normalizeSkillFailureCode(explicitCode);
if (normalizedCode) {
return byCode(normalizedCode, message);
}
return byCode(inferSkillFailureCode(message), message);
}
export function formatSkillFailureMessage(failure: SkillFailureInfo): string {
return `Skill 执行失败(${failure.code}):${failure.message}\n建议:${failure.recoveryHint}`;
}
+311
View File
@@ -0,0 +1,311 @@
/**
* 批量任务主页面
*
* 展示任务列表、创建入口和模板管理
*/
import React, { useState, useEffect, useCallback } from "react";
import styled from "styled-components";
import { Plus, RefreshCw, Layers, FileText } from "lucide-react";
import type { Page } from "@/types/page";
import type { BatchTask, TaskTemplate } from "@/lib/api/batch";
import { listBatchTasks, listTemplates } from "@/lib/api/batch";
import { CreateBatchDialog } from "./CreateBatchDialog";
import { BatchTaskDetail } from "./BatchTaskDetail";
import { TemplateManager } from "./TemplateManager";
interface BatchPageProps {
onNavigate?: (page: Page) => void;
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
padding: 24px;
gap: 16px;
overflow: auto;
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const Title = styled.h1`
font-size: 20px;
font-weight: 600;
color: hsl(var(--foreground));
`;
const Actions = styled.div`
display: flex;
gap: 8px;
`;
const Btn = styled.button`
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 13px;
cursor: pointer;
&:hover {
background: hsl(var(--accent));
}
svg {
width: 14px;
height: 14px;
}
`;
const PrimaryBtn = styled(Btn)`
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
&:hover {
opacity: 0.9;
background: hsl(var(--primary));
}
`;
const Tabs = styled.div`
display: flex;
gap: 4px;
border-bottom: 1px solid hsl(var(--border));
padding-bottom: 0;
`;
const Tab = styled.button<{ $active: boolean }>`
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
background: none;
color: ${(p) =>
p.$active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"};
border-bottom: 2px solid
${(p) => (p.$active ? "hsl(var(--primary))" : "transparent")};
cursor: pointer;
font-size: 13px;
font-weight: ${(p) => (p.$active ? 600 : 400)};
svg {
width: 14px;
height: 14px;
}
`;
const TaskList = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const TaskCard = styled.div`
padding: 12px 16px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
cursor: pointer;
&:hover {
background: hsl(var(--accent));
}
`;
const TaskHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
const TaskName = styled.span`
font-weight: 500;
font-size: 14px;
`;
const StatusBadge = styled.span<{ $status: string }>`
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
background: ${(p) => {
switch (p.$status) {
case "completed":
return "hsl(142 76% 36% / 0.15)";
case "running":
return "hsl(217 91% 60% / 0.15)";
case "failed":
return "hsl(0 84% 60% / 0.15)";
case "cancelled":
return "hsl(0 0% 50% / 0.15)";
case "partiallycompleted":
return "hsl(38 92% 50% / 0.15)";
default:
return "hsl(0 0% 50% / 0.1)";
}
}};
color: ${(p) => {
switch (p.$status) {
case "completed":
return "hsl(142 76% 36%)";
case "running":
return "hsl(217 91% 60%)";
case "failed":
return "hsl(0 84% 60%)";
case "cancelled":
return "hsl(0 0% 50%)";
case "partiallycompleted":
return "hsl(38 92% 50%)";
default:
return "hsl(0 0% 50%)";
}
}};
`;
const TaskMeta = styled.div`
display: flex;
gap: 16px;
margin-top: 6px;
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
const Empty = styled.div`
text-align: center;
padding: 48px;
color: hsl(var(--muted-foreground));
font-size: 14px;
`;
const STATUS_LABELS: Record<string, string> = {
pending: "等待中",
running: "运行中",
completed: "已完成",
partiallycompleted: "部分完成",
failed: "失败",
cancelled: "已取消",
};
export const BatchPage: React.FC<BatchPageProps> = () => {
const [tab, setTab] = useState<"tasks" | "templates">("tasks");
const [tasks, setTasks] = useState<BatchTask[]>([]);
const [templates, setTemplates] = useState<TaskTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
const [t, tpl] = await Promise.all([listBatchTasks(), listTemplates()]);
setTasks(t);
setTemplates(tpl);
} catch (e) {
console.error("[BatchPage] 加载失败:", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
// 自动刷新运行中的任务
useEffect(() => {
const hasRunning = tasks.some((t) => t.status === "running");
if (!hasRunning) return;
const timer = setInterval(refresh, 3000);
return () => clearInterval(timer);
}, [tasks, refresh]);
if (selectedTaskId) {
return (
<BatchTaskDetail
taskId={selectedTaskId}
onBack={() => {
setSelectedTaskId(null);
refresh();
}}
/>
);
}
return (
<Container>
<Header>
<Title>批量任务</Title>
<Actions>
<Btn onClick={refresh} disabled={loading}>
<RefreshCw /> 刷新
</Btn>
<PrimaryBtn onClick={() => setShowCreate(true)}>
<Plus /> 创建任务
</PrimaryBtn>
</Actions>
</Header>
<Tabs>
<Tab $active={tab === "tasks"} onClick={() => setTab("tasks")}>
<Layers /> 任务列表
</Tab>
<Tab $active={tab === "templates"} onClick={() => setTab("templates")}>
<FileText /> 模板管理
</Tab>
</Tabs>
{tab === "tasks" && (
<TaskList>
{tasks.length === 0 ? (
<Empty>暂无批量任务,点击"创建任务"开始</Empty>
) : (
tasks.map((task) => (
<TaskCard
key={task.id}
onClick={() => setSelectedTaskId(task.id)}
>
<TaskHeader>
<TaskName>{task.name}</TaskName>
<StatusBadge $status={task.status}>
{STATUS_LABELS[task.status] || task.status}
</StatusBadge>
</TaskHeader>
<TaskMeta>
<span>子任务: {task.tasks.length}</span>
<span>
完成:{" "}
{
task.results.filter((r) => r.status === "completed")
.length
}
</span>
<span>{new Date(task.created_at).toLocaleString()}</span>
</TaskMeta>
</TaskCard>
))
)}
</TaskList>
)}
{tab === "templates" && (
<TemplateManager templates={templates} onRefresh={refresh} />
)}
{showCreate && (
<CreateBatchDialog
templates={templates}
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false);
refresh();
}}
/>
)}
</Container>
);
};
+306
View File
@@ -0,0 +1,306 @@
/**
* 批量任务详情页
*
* 展示任务进度、结果和统计信息
*/
import React, { useState, useEffect, useCallback } from "react";
import styled from "styled-components";
import {
ArrowLeft,
XCircle,
CheckCircle,
AlertCircle,
Clock,
RefreshCw,
} from "lucide-react";
import type { BatchTaskDetail as BatchTaskDetailType } from "@/lib/api/batch";
import { getBatchTask, cancelBatchTask } from "@/lib/api/batch";
interface Props {
taskId: string;
onBack: () => void;
}
const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
padding: 24px;
gap: 16px;
overflow: auto;
`;
const Header = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const BackBtn = styled.button`
background: none;
border: none;
cursor: pointer;
color: hsl(var(--muted-foreground));
padding: 4px;
&:hover {
color: hsl(var(--foreground));
}
svg {
width: 18px;
height: 18px;
}
`;
const Title = styled.h2`
font-size: 18px;
font-weight: 600;
flex: 1;
`;
const Btn = styled.button`
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 13px;
cursor: pointer;
&:hover {
background: hsl(var(--accent));
}
svg {
width: 14px;
height: 14px;
}
`;
const DangerBtn = styled(Btn)`
color: hsl(0 84% 60%);
border-color: hsl(0 84% 60% / 0.3);
&:hover {
background: hsl(0 84% 60% / 0.1);
}
`;
const StatsGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
`;
const StatCard = styled.div`
padding: 12px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
text-align: center;
`;
const StatValue = styled.div`
font-size: 24px;
font-weight: 700;
color: hsl(var(--foreground));
`;
const StatLabel = styled.div`
font-size: 11px;
color: hsl(var(--muted-foreground));
margin-top: 2px;
`;
const ProgressBar = styled.div`
height: 8px;
background: hsl(var(--muted) / 0.3);
border-radius: 4px;
overflow: hidden;
`;
const ProgressFill = styled.div<{ $pct: number; $color: string }>`
height: 100%;
width: ${(p) => p.$pct}%;
background: ${(p) => p.$color};
transition: width 0.3s ease;
`;
const ResultList = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const ResultCard = styled.div`
padding: 12px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
`;
const ResultHeader = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
`;
const ResultContent = styled.pre`
font-size: 12px;
color: hsl(var(--muted-foreground));
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow: auto;
margin: 0;
padding: 8px;
background: hsl(var(--muted) / 0.2);
border-radius: 4px;
`;
const StatusIcon: React.FC<{ status: string }> = ({ status }) => {
switch (status) {
case "completed":
return (
<CheckCircle
style={{ color: "hsl(142 76% 36%)", width: 14, height: 14 }}
/>
);
case "failed":
return (
<AlertCircle
style={{ color: "hsl(0 84% 60%)", width: 14, height: 14 }}
/>
);
case "cancelled":
return (
<XCircle style={{ color: "hsl(0 0% 50%)", width: 14, height: 14 }} />
);
case "running":
return (
<RefreshCw
style={{ color: "hsl(217 91% 60%)", width: 14, height: 14 }}
/>
);
default:
return (
<Clock style={{ color: "hsl(0 0% 50%)", width: 14, height: 14 }} />
);
}
};
export const BatchTaskDetail: React.FC<Props> = ({ taskId, onBack }) => {
const [detail, setDetail] = useState<BatchTaskDetailType | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const d = await getBatchTask(taskId);
setDetail(d);
} catch (e) {
console.error("[BatchTaskDetail] 加载失败:", e);
} finally {
setLoading(false);
}
}, [taskId]);
useEffect(() => {
refresh();
}, [refresh]);
// 自动刷新运行中的任务
useEffect(() => {
if (!detail || detail.batch_task.status !== "running") return;
const timer = setInterval(refresh, 2000);
return () => clearInterval(timer);
}, [detail, refresh]);
const handleCancel = async () => {
try {
await cancelBatchTask(taskId);
refresh();
} catch (e) {
console.error("[BatchTaskDetail] 取消失败:", e);
}
};
if (loading || !detail) {
return <Container>加载中...</Container>;
}
const { batch_task: task, statistics: stats } = detail;
const pct =
stats.total_tasks > 0
? ((stats.completed_tasks + stats.failed_tasks) / stats.total_tasks) * 100
: 0;
return (
<Container>
<Header>
<BackBtn onClick={onBack}>
<ArrowLeft />
</BackBtn>
<Title>{task.name}</Title>
{(task.status === "running" || task.status === "pending") && (
<DangerBtn onClick={handleCancel}>
<XCircle /> 取消
</DangerBtn>
)}
</Header>
<ProgressBar>
<ProgressFill $pct={pct} $color="hsl(142 76% 36%)" />
</ProgressBar>
<StatsGrid>
<StatCard>
<StatValue>{stats.total_tasks}</StatValue>
<StatLabel>总任务</StatLabel>
</StatCard>
<StatCard>
<StatValue>{stats.completed_tasks}</StatValue>
<StatLabel>已完成</StatLabel>
</StatCard>
<StatCard>
<StatValue>{stats.failed_tasks}</StatValue>
<StatLabel>失败</StatLabel>
</StatCard>
<StatCard>
<StatValue>{stats.running_tasks}</StatValue>
<StatLabel>运行中</StatLabel>
</StatCard>
<StatCard>
<StatValue>{stats.pending_tasks}</StatValue>
<StatLabel>等待中</StatLabel>
</StatCard>
<StatCard>
<StatValue>{stats.total_tokens.total_tokens}</StatValue>
<StatLabel>总 Tokens</StatLabel>
</StatCard>
</StatsGrid>
<ResultList>
{task.results.map((r, i) => (
<ResultCard key={r.task_id || i}>
<ResultHeader>
<StatusIcon status={r.status} />
<span>任务 #{i + 1}</span>
<span
style={{ fontSize: 11, color: "hsl(var(--muted-foreground))" }}
>
{r.usage.total_tokens} tokens
</span>
</ResultHeader>
{r.content && <ResultContent>{r.content}</ResultContent>}
{r.error && (
<ResultContent style={{ color: "hsl(0 84% 60%)" }}>
{r.error}
</ResultContent>
)}
</ResultCard>
))}
</ResultList>
</Container>
);
};
+308
View File
@@ -0,0 +1,308 @@
/**
* 创建批量任务对话框
*/
import React, { useState } from "react";
import styled from "styled-components";
import { X } from "lucide-react";
import type { TaskTemplate } from "@/lib/api/batch";
import { createBatchTask } from "@/lib/api/batch";
interface Props {
templates: TaskTemplate[];
onClose: () => void;
onCreated: () => void;
}
const Overlay = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
`;
const Dialog = styled.div`
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 12px;
width: 560px;
max-height: 80vh;
overflow: auto;
padding: 24px;
`;
const DialogHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
`;
const DialogTitle = styled.h2`
font-size: 16px;
font-weight: 600;
`;
const CloseBtn = styled.button`
background: none;
border: none;
cursor: pointer;
color: hsl(var(--muted-foreground));
padding: 4px;
&:hover {
color: hsl(var(--foreground));
}
svg {
width: 16px;
height: 16px;
}
`;
const Field = styled.div`
margin-bottom: 16px;
`;
const Label = styled.label`
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
color: hsl(var(--foreground));
`;
const Input = styled.input`
width: 100%;
padding: 8px 12px;
border: 1px solid hsl(var(--border));
border-radius: 6px;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 13px;
box-sizing: border-box;
`;
const Select = styled.select`
width: 100%;
padding: 8px 12px;
border: 1px solid hsl(var(--border));
border-radius: 6px;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 13px;
`;
const Textarea = styled.textarea`
width: 100%;
padding: 8px 12px;
border: 1px solid hsl(var(--border));
border-radius: 6px;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 13px;
font-family: monospace;
min-height: 120px;
resize: vertical;
box-sizing: border-box;
`;
const HelpText = styled.p`
font-size: 11px;
color: hsl(var(--muted-foreground));
margin-top: 4px;
`;
const Footer = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
`;
const Btn = styled.button`
padding: 8px 16px;
border-radius: 6px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 13px;
cursor: pointer;
&:hover {
background: hsl(var(--accent));
}
`;
const PrimaryBtn = styled(Btn)`
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
&:hover {
opacity: 0.9;
background: hsl(var(--primary));
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ErrorMsg = styled.p`
color: hsl(0 84% 60%);
font-size: 12px;
margin-top: 8px;
`;
export const CreateBatchDialog: React.FC<Props> = ({
templates,
onClose,
onCreated,
}) => {
const [name, setName] = useState("");
const [templateId, setTemplateId] = useState(templates[0]?.id || "");
const [tasksJson, setTasksJson] = useState(
JSON.stringify([{ variables: { content: "示例内容" } }], null, 2),
);
const [concurrency, setConcurrency] = useState(3);
const [retryCount, setRetryCount] = useState(0);
const [timeoutSecs, setTimeoutSecs] = useState(120);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
setError("");
if (!name.trim()) {
setError("请输入任务名称");
return;
}
if (!templateId) {
setError("请选择模板");
return;
}
let tasks;
try {
tasks = JSON.parse(tasksJson);
if (!Array.isArray(tasks) || tasks.length === 0) {
setError("任务列表必须是非空数组");
return;
}
} catch {
setError("任务列表 JSON 格式错误");
return;
}
setSubmitting(true);
try {
await createBatchTask({
name: name.trim(),
template_id: templateId,
tasks,
options: {
concurrency,
retry_count: retryCount,
timeout_seconds: timeoutSecs,
continue_on_error: true,
},
});
onCreated();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "创建失败");
} finally {
setSubmitting(false);
}
};
return (
<Overlay onClick={onClose}>
<Dialog onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>创建批量任务</DialogTitle>
<CloseBtn onClick={onClose}>
<X />
</CloseBtn>
</DialogHeader>
<Field>
<Label>任务名称</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:批量翻译文档"
/>
</Field>
<Field>
<Label>选择模板</Label>
<Select
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
>
{templates.length === 0 && (
<option value="">暂无模板,请先创建</option>
)}
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.model})
</option>
))}
</Select>
</Field>
<Field>
<Label>任务列表 (JSON)</Label>
<Textarea
value={tasksJson}
onChange={(e) => setTasksJson(e.target.value)}
/>
<HelpText>
每个任务包含 variables 对象,键名对应模板中的 {"{{变量名}}"} 占位符
</HelpText>
</Field>
<Field>
<Label>并发数</Label>
<Input
type="number"
min={1}
max={20}
value={concurrency}
onChange={(e) => setConcurrency(Number(e.target.value))}
/>
</Field>
<Field>
<Label>重试次数</Label>
<Input
type="number"
min={0}
max={5}
value={retryCount}
onChange={(e) => setRetryCount(Number(e.target.value))}
/>
</Field>
<Field>
<Label>超时时间 (秒)</Label>
<Input
type="number"
min={10}
max={600}
value={timeoutSecs}
onChange={(e) => setTimeoutSecs(Number(e.target.value))}
/>
</Field>
{error && <ErrorMsg>{error}</ErrorMsg>}
<Footer>
<Btn onClick={onClose}>取消</Btn>
<PrimaryBtn onClick={handleSubmit} disabled={submitting}>
{submitting ? "创建中..." : "创建并执行"}
</PrimaryBtn>
</Footer>
</Dialog>
</Overlay>
);
};
+324
View File
@@ -0,0 +1,324 @@
/**
* 模板管理组件
*/
import React, { useState } from "react";
import styled from "styled-components";
import { Plus, Trash2, X } from "lucide-react";
import type { TaskTemplate } from "@/lib/api/batch";
import { createTemplate, deleteTemplate } from "@/lib/api/batch";
interface Props {
templates: TaskTemplate[];
onRefresh: () => void;
}
const List = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const Card = styled.div`
padding: 12px 16px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: flex-start;
`;
const CardInfo = styled.div`
flex: 1;
`;
const CardName = styled.div`
font-weight: 500;
font-size: 14px;
`;
const CardMeta = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-top: 4px;
`;
const IconBtn = styled.button`
background: none;
border: none;
cursor: pointer;
color: hsl(var(--muted-foreground));
padding: 4px;
&:hover {
color: hsl(0 84% 60%);
}
svg {
width: 14px;
height: 14px;
}
`;
const Btn = styled.button`
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 13px;
cursor: pointer;
&:hover {
background: hsl(var(--accent));
}
svg {
width: 14px;
height: 14px;
}
`;
const PrimaryBtn = styled(Btn)`
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
&:hover {
opacity: 0.9;
background: hsl(var(--primary));
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const Empty = styled.div`
text-align: center;
padding: 48px;
color: hsl(var(--muted-foreground));
font-size: 14px;
`;
// 创建模板对话框
const Overlay = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
`;
const Dialog = styled.div`
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 12px;
width: 480px;
max-height: 80vh;
overflow: auto;
padding: 24px;
`;
const DialogHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
`;
const DialogTitle = styled.h3`
font-size: 16px;
font-weight: 600;
`;
const CloseBtn = styled.button`
background: none;
border: none;
cursor: pointer;
color: hsl(var(--muted-foreground));
padding: 4px;
&:hover {
color: hsl(var(--foreground));
}
svg {
width: 16px;
height: 16px;
}
`;
const Field = styled.div`
margin-bottom: 14px;
`;
const Label = styled.label`
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
`;
const Input = styled.input`
width: 100%;
padding: 8px 12px;
border: 1px solid hsl(var(--border));
border-radius: 6px;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 13px;
box-sizing: border-box;
`;
const Textarea = styled.textarea`
width: 100%;
padding: 8px 12px;
border: 1px solid hsl(var(--border));
border-radius: 6px;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 13px;
min-height: 80px;
resize: vertical;
box-sizing: border-box;
`;
const Footer = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
`;
export const TemplateManager: React.FC<Props> = ({ templates, onRefresh }) => {
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState("");
const [model, setModel] = useState("gpt-4");
const [systemPrompt, setSystemPrompt] = useState("");
const [userTemplate, setUserTemplate] = useState("请处理: {{content}}");
const [submitting, setSubmitting] = useState(false);
const handleCreate = async () => {
if (!name.trim() || !userTemplate.trim()) return;
setSubmitting(true);
try {
await createTemplate({
id: crypto.randomUUID(),
name: name.trim(),
model,
system_prompt: systemPrompt || undefined,
user_message_template: userTemplate,
});
setShowCreate(false);
setName("");
setSystemPrompt("");
setUserTemplate("请处理: {{content}}");
onRefresh();
} catch (e) {
console.error("[TemplateManager] 创建失败:", e);
} finally {
setSubmitting(false);
}
};
const handleDelete = async (id: string) => {
try {
await deleteTemplate(id);
onRefresh();
} catch (e) {
console.error("[TemplateManager] 删除失败:", e);
}
};
return (
<>
<div
style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}
>
<Btn onClick={() => setShowCreate(true)}>
<Plus /> 创建模板
</Btn>
</div>
<List>
{templates.length === 0 ? (
<Empty>暂无模板,点击"创建模板"开始</Empty>
) : (
templates.map((t) => (
<Card key={t.id}>
<CardInfo>
<CardName>{t.name}</CardName>
<CardMeta>
模型: {t.model} | 模板:{" "}
{t.user_message_template.substring(0, 50)}
{t.user_message_template.length > 50 ? "..." : ""}
</CardMeta>
</CardInfo>
<IconBtn onClick={() => handleDelete(t.id)}>
<Trash2 />
</IconBtn>
</Card>
))
)}
</List>
{showCreate && (
<Overlay onClick={() => setShowCreate(false)}>
<Dialog onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>创建模板</DialogTitle>
<CloseBtn onClick={() => setShowCreate(false)}>
<X />
</CloseBtn>
</DialogHeader>
<Field>
<Label>模板名称</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:文档翻译"
/>
</Field>
<Field>
<Label>模型</Label>
<Input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="gpt-4"
/>
</Field>
<Field>
<Label>系统提示词 (可选)</Label>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="你是一个专业的翻译助手..."
/>
</Field>
<Field>
<Label>用户消息模板</Label>
<Textarea
value={userTemplate}
onChange={(e) => setUserTemplate(e.target.value)}
placeholder="请处理: {{content}}"
/>
</Field>
<Footer>
<Btn onClick={() => setShowCreate(false)}>取消</Btn>
<PrimaryBtn
onClick={handleCreate}
disabled={submitting || !name.trim()}
>
{submitting ? "创建中..." : "创建"}
</PrimaryBtn>
</Footer>
</Dialog>
</Overlay>
)}
</>
);
};
+1
View File
@@ -0,0 +1 @@
export { BatchPage } from "./BatchPage";
@@ -1,6 +1,7 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import React, { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import {
Heading1,
Heading2,
@@ -350,13 +351,15 @@ export const CommandList: React.FC<CommandListProps> = ({
const top = clientRect ? clientRect.bottom + 4 : 0;
const left = clientRect ? clientRect.left : 0;
const maxLeft = Math.max(window.innerWidth - 280, 8);
const popupLeft = Math.min(Math.max(left, 8), maxLeft);
return (
const popup = (
<div
className="fixed z-[9999] w-64 max-h-72 overflow-y-auto rounded-lg border border-border shadow-lg"
style={{
top,
left,
left: popupLeft,
background: "hsl(var(--background))",
}}
>
@@ -390,4 +393,6 @@ export const CommandList: React.FC<CommandListProps> = ({
))}
</div>
);
return createPortal(popup, document.body);
};
@@ -25,6 +25,7 @@ const ToolbarContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 16px;
background: hsl(var(--background));
border-bottom: 1px solid hsl(var(--border));
@@ -35,18 +36,22 @@ const LeftSection = styled.div`
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
`;
const CenterSection = styled.div`
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
`;
const RightSection = styled.div`
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
`;
const SongTitle = styled.h2`
@@ -64,6 +69,7 @@ const SongMeta = styled.span`
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-left: 8px;
white-space: nowrap;
`;
const IconButton = styled.button<{ $active?: boolean }>`
@@ -104,6 +110,8 @@ const ViewModeButton = styled.button<{ $active: boolean }>`
padding: 6px 12px;
border: none;
border-radius: 6px;
white-space: nowrap;
flex-shrink: 0;
background: ${({ $active }) =>
$active ? "hsl(var(--accent))" : "transparent"};
color: ${({ $active }) =>
@@ -41,8 +41,9 @@ const SectionHeader = styled.div`
const SectionTag = styled.span`
font-size: 12px;
font-weight: 600;
color: hsl(var(--accent));
background: hsl(var(--accent) / 0.1);
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary) / 0.2);
padding: 2px 8px;
border-radius: 4px;
`;
@@ -18,7 +18,7 @@ const Container = styled.div`
display: flex;
flex-direction: column;
height: 100%;
background: hsl(var(--background));
background: hsl(var(--muted) / 0.18);
border-right: 1px solid hsl(var(--border));
`;
@@ -26,26 +26,43 @@ const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
padding: 14px 18px;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--background));
`;
const HeaderInfo = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
`;
const Title = styled.h3`
font-size: 14px;
font-size: 15px;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
`;
const HeaderMeta = styled.span`
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
const Content = styled.div`
display: flex;
flex: 1;
min-height: 0;
background: hsl(var(--background));
`;
const ChapterList = styled.div`
width: 220px;
width: 236px;
min-width: 236px;
border-right: 1px solid hsl(var(--border));
display: flex;
flex-direction: column;
background: hsl(var(--muted) / 0.28);
`;
const ChapterListHeader = styled.div`
@@ -54,17 +71,30 @@ const ChapterListHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
background: hsl(var(--background));
`;
const ChapterListBody = styled.div`
padding: 8px;
`;
const ChapterItem = styled.div<{ $active?: boolean }>`
padding: 12px;
padding: 10px 12px;
margin-bottom: 8px;
cursor: pointer;
border-bottom: 1px solid hsl(var(--border));
border: 1px solid
${({ $active }) =>
$active ? "hsl(var(--primary) / 0.4)" : "hsl(var(--border))"};
border-radius: 10px;
background: ${({ $active }) =>
$active ? "hsl(var(--accent))" : "transparent"};
$active ? "hsl(var(--accent) / 0.55)" : "hsl(var(--background))"};
box-shadow: ${({ $active }) =>
$active ? "0 2px 8px hsl(var(--primary) / 0.12)" : "none"};
transition: all 0.18s ease;
&:hover {
background: hsl(var(--accent));
background: hsl(var(--accent) / 0.42);
border-color: hsl(var(--primary) / 0.28);
}
`;
@@ -74,12 +104,21 @@ const ChapterTitle = styled.div`
display: flex;
align-items: center;
gap: 6px;
color: hsl(var(--foreground));
`;
const ChapterTitleText = styled.span`
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ChapterMeta = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-top: 4px;
margin-top: 6px;
`;
const EditorArea = styled.div`
@@ -87,46 +126,64 @@ const EditorArea = styled.div`
display: flex;
flex-direction: column;
min-width: 0;
background: hsl(var(--background));
`;
const ChapterHeader = styled.div`
padding: 16px;
padding: 14px 18px;
border-bottom: 1px solid hsl(var(--border));
display: flex;
gap: 12px;
align-items: center;
background: hsl(var(--muted) / 0.16);
`;
const EditorContainer = styled.div`
flex: 1;
padding: 24px;
padding: 18px;
display: flex;
flex-direction: column;
min-height: 0;
`;
const Editor = styled(Textarea)`
flex: 1;
min-height: 400px;
min-height: 0;
font-size: 16px;
line-height: 1.8;
line-height: 1.95;
resize: none;
border: none;
background: transparent;
border: 1px solid hsl(var(--border));
border-radius: 12px;
background: hsl(var(--background));
padding: 18px 20px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
&:focus {
outline: none;
box-shadow: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12);
}
`;
const EmptyEditorState = styled.div`
flex: 1;
border: 1px dashed hsl(var(--border));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
font-size: 14px;
`;
const StatusBar = styled.div`
padding: 8px 16px;
padding: 9px 16px;
border-top: 1px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: hsl(var(--muted-foreground));
background: hsl(var(--background));
`;
interface NovelCanvasProps {
@@ -196,7 +253,12 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
return (
<Container>
<Header>
<Title>小说编辑器</Title>
<HeaderInfo>
<Title>小说编辑器</Title>
<HeaderMeta>
{state.chapters.length} 章 · {totalWords} 字
</HeaderMeta>
</HeaderInfo>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
@@ -211,23 +273,25 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
</Button>
</ChapterListHeader>
<ScrollArea className="flex-1">
{state.chapters.map((chapter) => (
<ChapterItem
key={chapter.id}
$active={chapter.id === state.currentChapterId}
onClick={() => handleChapterSelect(chapter.id)}
>
<ChapterTitle>
{chapter.status === "completed" ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<FileText className="h-4 w-4 text-muted-foreground" />
)}
{chapter.title}
</ChapterTitle>
<ChapterMeta>{chapter.wordCount} 字</ChapterMeta>
</ChapterItem>
))}
<ChapterListBody>
{state.chapters.map((chapter) => (
<ChapterItem
key={chapter.id}
$active={chapter.id === state.currentChapterId}
onClick={() => handleChapterSelect(chapter.id)}
>
<ChapterTitle>
{chapter.status === "completed" ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<FileText className="h-4 w-4 text-muted-foreground" />
)}
<ChapterTitleText>{chapter.title}</ChapterTitleText>
</ChapterTitle>
<ChapterMeta>{chapter.wordCount} 字</ChapterMeta>
</ChapterItem>
))}
</ChapterListBody>
</ScrollArea>
</ChapterList>
@@ -276,6 +340,14 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
</EditorContainer>
</>
)}
{!currentChapter && (
<EditorContainer>
<EmptyEditorState>
请先选择章节,或在左侧新建章节开始创作
</EmptyEditorState>
</EditorContainer>
)}
</EditorArea>
</Content>
@@ -45,12 +45,17 @@ const ToolbarContainer = styled.div`
background: hsl(var(--background));
border-bottom: 1px solid hsl(var(--border));
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
`;
const ToolbarGroup = styled.div`
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
flex-wrap: nowrap;
`;
const ZoomDisplay = styled.span`
@@ -69,9 +74,13 @@ const Divider = styled.div`
`;
const SizeDisplay = styled.span`
display: inline-flex;
align-items: center;
font-size: 12px;
color: hsl(var(--muted-foreground));
font-variant-numeric: tabular-nums;
white-space: nowrap;
line-height: 1;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
@@ -339,11 +348,11 @@ export const PosterToolbar: React.FC<PosterToolbarProps> = memo(
<Button
variant="ghost"
size="sm"
className="h-8 gap-1"
className="h-8 gap-1 px-2"
onClick={onExport}
>
<Download className="h-4 w-4" />
<span className="text-xs">导出</span>
<span className="text-xs whitespace-nowrap">导出</span>
</Button>
</TooltipTrigger>
<TooltipContent>导出图片</TooltipContent>
@@ -21,6 +21,7 @@ const ChatPanel = styled.div<{ $width: string; $duration: number }>`
overflow: hidden;
transition: width ${({ $duration }) => $duration}ms ease-out;
width: ${({ $width }) => $width};
min-width: 460px;
will-change: width;
display: flex;
flex-direction: column;
@@ -119,10 +119,10 @@ export function useLayoutTransition(
};
}
// chat 区域 - 左边 40%(画布打开时)
// chat 区域 - 画布打开时提升右侧聊天区宽度
return {
transition: `width ${duration}ms ease-out`,
width: mode === "chat-canvas" ? "40%" : "100%",
width: mode === "chat-canvas" ? "46%" : "100%",
};
},
[transitionState, mergedConfig, mode],
+161
View File
@@ -11,6 +11,7 @@ import {
Image as ImageIcon,
ImagePlus,
Loader2,
Plus,
Send,
Settings,
Sparkles,
@@ -629,6 +630,110 @@ const Status = styled.div`
color: hsl(var(--muted-foreground));
`;
const HistorySidebar = styled.aside`
width: 96px;
min-width: 96px;
border-left: 1px solid hsl(var(--border));
background: hsl(var(--card) / 0.3);
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 10px;
`;
const HistoryNewButton = styled.button`
width: 100%;
height: 40px;
border: 1px dashed hsl(var(--border));
border-radius: 10px;
background: transparent;
color: hsl(var(--muted-foreground));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover {
border-color: hsl(var(--primary));
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.06);
}
`;
const HistoryList = styled.div`
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 2px;
`;
const HistoryItem = styled.div<{ $active: boolean }>`
width: 100%;
aspect-ratio: 1;
border-radius: 10px;
border: 1px solid
${({ $active }) => ($active ? "hsl(var(--primary))" : "hsl(var(--border))")};
background: hsl(var(--background));
overflow: hidden;
cursor: pointer;
position: relative;
&:hover {
border-color: hsl(var(--primary) / 0.55);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
`;
const HistoryPlaceholder = styled.div`
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
`;
const HistoryDeleteButton = styled.button`
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border: 1px solid hsl(var(--destructive) / 0.35);
border-radius: 50%;
background: hsl(var(--background) / 0.92);
color: hsl(var(--destructive));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: all 0.15s;
${HistoryItem}:hover & {
opacity: 1;
}
&:hover {
background: hsl(var(--destructive));
color: hsl(var(--destructive-foreground));
}
`;
const HistoryEmpty = styled.div`
margin-top: 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
text-align: center;
`;
export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
const {
availableProviders,
@@ -649,6 +754,7 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
generating,
generateImage,
deleteImage,
newImage,
} = useImageGen();
const [prompt, setPrompt] = useState("");
@@ -1071,6 +1177,61 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
</Status>
)}
</Workspace>
<HistorySidebar>
<HistoryNewButton
title="新建图片"
onClick={() => {
newImage();
}}
>
<Plus size={18} />
</HistoryNewButton>
<HistoryList>
{images.map((image) => (
<HistoryItem
key={image.id}
$active={image.id === selectedImageId}
role="button"
tabIndex={0}
onClick={() => setSelectedImageId(image.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedImageId(image.id);
}
}}
>
{image.status === "complete" && image.url ? (
<img src={image.url} alt={image.prompt || "历史图片"} />
) : (
<HistoryPlaceholder>
{image.status === "generating" ? (
<Loader2 size={16} className="animate-spin" />
) : (
<ImageIcon size={16} />
)}
</HistoryPlaceholder>
)}
{image.status !== "generating" && (
<HistoryDeleteButton
title="删除"
onClick={(event) => {
event.stopPropagation();
deleteImage(image.id);
}}
>
<Trash2 size={10} />
</HistoryDeleteButton>
)}
</HistoryItem>
))}
{images.length === 0 && <HistoryEmpty>暂无历史</HistoryEmpty>}
</HistoryList>
</HistorySidebar>
</Container>
</PageLayout>
);
+1
View File
@@ -38,6 +38,7 @@ export interface ImageGenResponse {
created: number;
data: Array<{
url: string;
b64_json?: string;
revised_prompt?: string;
}>;
}
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -30,6 +30,7 @@ import {
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
import type { Page, PageParams } from "@/types/page";
import {
cleanupMemory,
@@ -757,10 +758,7 @@ export function MemoryPage({ onNavigate }: MemoryPageProps) {
const handleBackToHome = useCallback(() => {
if (onNavigate) {
onNavigate("agent", {
theme: "general",
lockTheme: false,
});
onNavigate("agent", buildHomeAgentParams());
}
}, [onNavigate]);
+2 -1
View File
@@ -10,6 +10,7 @@ import styled from "styled-components";
import { Home } from "lucide-react";
import { SettingsSidebar } from "./SettingsSidebar";
import { SettingsTabs } from "@/types/settings";
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
import { Page, PageParams } from "@/types/page";
// 外观设置(迁移自原 GeneralSettings)
@@ -339,7 +340,7 @@ export function SettingsLayoutV2({
const handleBackToHome = () => {
if (onNavigate) {
onNavigate("agent");
onNavigate("agent", buildHomeAgentParams());
}
};
+208 -30
View File
@@ -47,6 +47,7 @@ import {
resolveProjectRootPath,
listContents,
listProjects,
getContent,
updateContent,
} from "@/lib/api/project";
import type {
@@ -57,7 +58,9 @@ import type {
} from "@/types/page";
import { toast } from "sonner";
import { AgentChatPage } from "@/components/agent";
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
import { ProjectDetailPage } from "@/components/projects/ProjectDetailPage";
import type { CreationMode } from "@/components/content-creator/types";
export interface WorkbenchPageProps {
onNavigate?: (page: Page, params?: PageParams) => void;
@@ -65,16 +68,59 @@ export interface WorkbenchPageProps {
contentId?: string;
theme: WorkspaceTheme;
viewMode?: WorkspaceViewMode;
resetAt?: number;
}
type WorkspaceMode = WorkspaceViewMode;
const DEFAULT_CREATION_MODE: CreationMode = "guided";
const CREATION_MODE_OPTIONS: Array<{
value: CreationMode;
label: string;
description: string;
}> = [
{
value: "guided",
label: "引导模式",
description: "AI 分步骤提问引导,适合精细创作",
},
{
value: "fast",
label: "快速模式",
description: "AI 先生成初稿,适合快速起稿",
},
{
value: "hybrid",
label: "混合模式",
description: "AI 与你协作,平衡质量和效率",
},
{
value: "framework",
label: "框架模式",
description: "你定结构,AI 按框架补全内容",
},
];
function parseCreationMode(value: unknown): CreationMode | null {
if (
value === "guided" ||
value === "fast" ||
value === "hybrid" ||
value === "framework"
) {
return value;
}
return null;
}
export function WorkbenchPage({
onNavigate,
projectId: initialProjectId,
contentId: initialContentId,
theme,
viewMode: initialViewMode,
resetAt,
}: WorkbenchPageProps) {
const [showLeftSidebar, setShowLeftSidebar] = useState(true);
const [showRightSidebar, setShowRightSidebar] = useState(false);
@@ -98,9 +144,16 @@ export function WorkbenchPage({
const [contentQuery, setContentQuery] = useState("");
const [createProjectDialogOpen, setCreateProjectDialogOpen] = useState(false);
const [createContentDialogOpen, setCreateContentDialogOpen] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [workspaceProjectsRoot, setWorkspaceProjectsRoot] = useState("");
const [creatingProject, setCreatingProject] = useState(false);
const [creatingContent, setCreatingContent] = useState(false);
const [selectedCreationMode, setSelectedCreationMode] =
useState<CreationMode>(DEFAULT_CREATION_MODE);
const [contentCreationModes, setContentCreationModes] = useState<
Record<string, CreationMode>
>({});
const [resolvedProjectPath, setResolvedProjectPath] = useState("");
const [pathChecking, setPathChecking] = useState(false);
const [pathConflictMessage, setPathConflictMessage] = useState("");
@@ -258,27 +311,52 @@ export function WorkbenchPage({
}
}, [loadProjects, newProjectName, theme]);
const handleCreateContent = useCallback(async () => {
const handleOpenCreateContentDialog = useCallback(() => {
if (!selectedProjectId) {
return;
}
try {
const defaultType = getDefaultContentTypeForProject(theme as ProjectType);
const created = await createContent({
project_id: selectedProjectId,
title: `新${getContentTypeLabel(defaultType)}`,
content_type: defaultType,
});
setSelectedCreationMode(DEFAULT_CREATION_MODE);
setCreateContentDialogOpen(true);
}, [selectedProjectId]);
await loadContents(selectedProjectId);
handleEnterWorkspace(created.id);
toast.success("已创建新文稿");
} catch (error) {
console.error("创建文稿失败:", error);
toast.error("创建文稿失败");
}
}, [handleEnterWorkspace, loadContents, selectedProjectId, theme]);
const handleCreateContent = useCallback(
async (creationMode: CreationMode) => {
if (!selectedProjectId) {
return;
}
setCreatingContent(true);
try {
const defaultType = getDefaultContentTypeForProject(
theme as ProjectType,
);
const created = await createContent({
project_id: selectedProjectId,
title: `新${getContentTypeLabel(defaultType)}`,
content_type: defaultType,
metadata: {
creationMode,
},
});
setContentCreationModes((previous) => ({
...previous,
[created.id]: creationMode,
}));
setCreateContentDialogOpen(false);
await loadContents(selectedProjectId);
handleEnterWorkspace(created.id);
toast.success("已创建新文稿");
} catch (error) {
console.error("创建文稿失败:", error);
toast.error("创建文稿失败");
} finally {
setCreatingContent(false);
}
},
[handleEnterWorkspace, loadContents, selectedProjectId, theme],
);
const handleQuickSaveCurrent = useCallback(async () => {
if (!selectedContentId || !selectedProjectId) {
@@ -319,6 +397,7 @@ export function WorkbenchPage({
initialProjectId,
initialViewMode,
loadProjects,
resetAt,
theme,
]);
@@ -435,11 +514,43 @@ export function WorkbenchPage({
void loadContents(selectedProjectId);
}, [loadContents, selectedProjectId]);
useEffect(() => {
if (!selectedContentId || contentCreationModes[selectedContentId]) {
return;
}
let mounted = true;
const loadCreationMode = async () => {
try {
const content = await getContent(selectedContentId);
const metadata = content?.metadata;
const mode = parseCreationMode(
metadata && typeof metadata === "object"
? (metadata as Record<string, unknown>).creationMode
: null,
);
if (mounted && mode) {
setContentCreationModes((previous) => ({
...previous,
[selectedContentId]: mode,
}));
}
} catch (error) {
console.error("读取文稿创作模式失败:", error);
}
};
void loadCreationMode();
return () => {
mounted = false;
};
}, [contentCreationModes, selectedContentId]);
const handleBackHome = useCallback(() => {
onNavigate?.("agent", {
theme: "general",
lockTheme: false,
});
onNavigate?.("agent", buildHomeAgentParams());
}, [onNavigate]);
const handleBackToProjectManagement = useCallback(() => {
@@ -519,7 +630,7 @@ export function WorkbenchPage({
<div className="flex flex-1 min-h-0">
{shouldRenderLeftSidebar && (
<aside className="w-[320px] min-w-[300px] border-r bg-muted/20 flex flex-col">
<aside className="w-[260px] min-w-[240px] border-r bg-muted/20 flex flex-col">
<div className="px-3 py-3 border-b space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
@@ -616,9 +727,7 @@ export function WorkbenchPage({
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
void handleCreateContent();
}}
onClick={handleOpenCreateContentDialog}
disabled={!selectedProjectId}
title="新建文稿"
>
@@ -695,9 +804,7 @@ export function WorkbenchPage({
</Button>
<Button
variant="outline"
onClick={() => {
void handleCreateContent();
}}
onClick={handleOpenCreateContentDialog}
disabled={!selectedProjectId}
>
<Plus className="h-4 w-4 mr-1" />
@@ -742,9 +849,7 @@ export function WorkbenchPage({
</Button>
<Button
variant="outline"
onClick={() => {
void handleCreateContent();
}}
onClick={handleOpenCreateContentDialog}
disabled={!selectedProjectId}
>
<Plus className="h-4 w-4 mr-1" />
@@ -760,7 +865,13 @@ export function WorkbenchPage({
projectId={selectedProjectId}
contentId={selectedContentId}
theme={theme}
initialCreationMode={
(selectedContentId &&
contentCreationModes[selectedContentId]) ||
undefined
}
lockTheme={true}
hideHistoryToggle={true}
/>
</div>
)}
@@ -883,6 +994,73 @@ export function WorkbenchPage({
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={createContentDialogOpen}
onOpenChange={(open) => {
if (!creatingContent) {
setCreateContentDialogOpen(open);
}
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>新建文稿</DialogTitle>
<DialogDescription>
请选择本次创作模式,创建后将直接进入作业界面。
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
{CREATION_MODE_OPTIONS.map((modeOption) => (
<Button
key={modeOption.value}
type="button"
variant={
selectedCreationMode === modeOption.value
? "default"
: "outline"
}
className="h-auto justify-start py-3"
onClick={() => setSelectedCreationMode(modeOption.value)}
disabled={creatingContent}
>
<div className="text-left">
<div className="text-sm font-medium">{modeOption.label}</div>
<div
className={cn(
"text-xs mt-1",
selectedCreationMode === modeOption.value
? "text-primary-foreground/80"
: "text-muted-foreground",
)}
>
{modeOption.description}
</div>
</div>
</Button>
))}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCreateContentDialogOpen(false)}
disabled={creatingContent}
>
取消
</Button>
<Button
onClick={() => {
void handleCreateContent(selectedCreationMode);
}}
disabled={!selectedProjectId || creatingContent}
>
{creatingContent ? "创建中..." : "创建并进入作业"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+2
View File
@@ -311,6 +311,8 @@ export interface SessionInfo {
created_at: string;
last_activity: string;
messages_count: number;
workspace_id?: string;
working_dir?: string;
}
/**
+178
View File
@@ -0,0 +1,178 @@
/**
* 批量任务 API 客户端
*
* 通过 fetch 调用本地代理服务器的 REST API
*/
import { getServerStatus } from "@/hooks/useTauri";
async function getBaseUrl(): Promise<string> {
const status = await getServerStatus();
return `http://${status.host}:${status.port}`;
}
// ============================================================
// 类型定义
// ============================================================
export interface TaskTemplate {
id: string;
name: string;
description?: string;
model: string;
system_prompt?: string;
user_message_template: string;
temperature?: number;
max_tokens?: number;
created_at: string;
updated_at: string;
}
export interface TaskDefinition {
id?: string;
variables: Record<string, string>;
metadata?: Record<string, string>;
}
export interface BatchOptions {
concurrency?: number;
continue_on_error?: boolean;
retry_count?: number;
timeout_seconds?: number;
}
export interface TaskResult {
task_id: string;
status: "pending" | "running" | "completed" | "failed" | "cancelled";
content?: string;
error?: string;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
started_at: string;
completed_at?: string;
}
export interface BatchTask {
id: string;
name: string;
template_id: string;
status:
| "pending"
| "running"
| "completed"
| "partiallycompleted"
| "failed"
| "cancelled";
options: BatchOptions;
tasks: TaskDefinition[];
results: TaskResult[];
created_at: string;
started_at?: string;
completed_at?: string;
}
export interface BatchTaskStatistics {
total_tasks: number;
completed_tasks: number;
failed_tasks: number;
running_tasks: number;
pending_tasks: number;
total_tokens: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface BatchTaskDetail {
batch_task: BatchTask;
statistics: BatchTaskStatistics;
}
// ============================================================
// API 方法
// ============================================================
export async function createTemplate(
template: Omit<TaskTemplate, "created_at" | "updated_at">,
): Promise<TaskTemplate> {
const base = await getBaseUrl();
const now = new Date().toISOString();
const body = { ...template, created_at: now, updated_at: now };
const res = await fetch(`${base}/api/batch/templates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function listTemplates(): Promise<TaskTemplate[]> {
const base = await getBaseUrl();
const res = await fetch(`${base}/api/batch/templates`);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
return data.templates;
}
export async function getTemplate(id: string): Promise<TaskTemplate> {
const base = await getBaseUrl();
const res = await fetch(`${base}/api/batch/templates/${id}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteTemplate(id: string): Promise<void> {
const base = await getBaseUrl();
const res = await fetch(`${base}/api/batch/templates/${id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(await res.text());
}
export interface CreateBatchTaskRequest {
name: string;
template_id: string;
tasks: TaskDefinition[];
options?: BatchOptions;
}
export async function createBatchTask(
req: CreateBatchTaskRequest,
): Promise<{ id: string; name: string; task_count: number }> {
const base = await getBaseUrl();
const res = await fetch(`${base}/api/batch/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function listBatchTasks(): Promise<BatchTask[]> {
const base = await getBaseUrl();
const res = await fetch(`${base}/api/batch/tasks`);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
return data.tasks;
}
export async function getBatchTask(id: string): Promise<BatchTaskDetail> {
const base = await getBaseUrl();
const res = await fetch(`${base}/api/batch/tasks/${id}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function cancelBatchTask(id: string): Promise<void> {
const base = await getBaseUrl();
const res = await fetch(`${base}/api/batch/tasks/${id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(await res.text());
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { buildHomeAgentParams, buildWorkspaceResetParams } from "./navigation";
describe("buildHomeAgentParams", () => {
it("应返回 general 主题且解锁", () => {
const params = buildHomeAgentParams();
expect(params.theme).toBe("general");
expect(params.lockTheme).toBe(false);
});
it("应生成 newChatAt 时间戳", () => {
const before = Date.now();
const params = buildHomeAgentParams();
const after = Date.now();
expect(params.newChatAt).toBeGreaterThanOrEqual(before);
expect(params.newChatAt).toBeLessThanOrEqual(after);
});
it("应允许 overrides 但不覆盖核心字段", () => {
const params = buildHomeAgentParams({ projectId: "proj-1" });
expect(params.projectId).toBe("proj-1");
// theme 和 lockTheme 始终被覆盖
expect(params.theme).toBe("general");
expect(params.lockTheme).toBe(false);
});
it("多次调用应生成不同的 newChatAt(幂等性验证)", () => {
const p1 = buildHomeAgentParams();
const p2 = buildHomeAgentParams();
// 两次调用的 newChatAt 可能相同(同毫秒),但结构一致
expect(p1.theme).toBe(p2.theme);
expect(p1.lockTheme).toBe(p2.lockTheme);
});
});
describe("buildWorkspaceResetParams", () => {
it("应默认使用 project-management 视图模式", () => {
const params = buildWorkspaceResetParams();
expect(params.workspaceViewMode).toBe("project-management");
});
it("应支持自定义视图模式", () => {
const params = buildWorkspaceResetParams({}, "workspace");
expect(params.workspaceViewMode).toBe("workspace");
});
it("应生成 workspaceResetAt 时间戳", () => {
const before = Date.now();
const params = buildWorkspaceResetParams();
const after = Date.now();
expect(params.workspaceResetAt).toBeGreaterThanOrEqual(before);
expect(params.workspaceResetAt).toBeLessThanOrEqual(after);
});
it("应允许 overrides 传递额外参数", () => {
const params = buildWorkspaceResetParams({ projectId: "proj-2" });
expect(params.projectId).toBe("proj-2");
expect(params.workspaceViewMode).toBe("project-management");
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { AgentPageParams, WorkspaceViewMode } from "@/types/page";
export function buildHomeAgentParams(
overrides: Partial<AgentPageParams> = {},
): AgentPageParams {
return {
...overrides,
theme: "general",
lockTheme: false,
newChatAt: Date.now(),
};
}
export function buildWorkspaceResetParams(
overrides: Partial<AgentPageParams> = {},
workspaceViewMode: WorkspaceViewMode = "project-management",
): AgentPageParams {
return {
...overrides,
workspaceViewMode,
workspaceResetAt: Date.now(),
};
}
+3
View File
@@ -72,6 +72,7 @@ export type Page =
| "workspace"
| ThemeWorkspacePage
| "image-gen"
| "batch"
| "mcp"
| "tools"
| "plugins"
@@ -124,6 +125,8 @@ export interface AgentPageParams {
lockTheme?: boolean;
/** 首页点击触发的新会话标记(时间戳) */
newChatAt?: number;
/** 主题工作台重置标记(时间戳) */
workspaceResetAt?: number;
/** 工作台视图模式(仅主题工作台使用) */
workspaceViewMode?: WorkspaceViewMode;
}