mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: bump version to 0.64.0
This commit is contained in:
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.63.0",
|
||||
"version": "0.64.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+15
-15
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
¤t_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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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|"));
|
||||
}
|
||||
}
|
||||
@@ -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,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,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
@@ -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,
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,6 +311,8 @@ export interface SessionInfo {
|
||||
created_at: string;
|
||||
last_activity: string;
|
||||
messages_count: number;
|
||||
workspace_id?: string;
|
||||
working_dir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user