新增: u-claw 集成
This commit is contained in:
Generated
+4
@@ -1038,8 +1038,10 @@ dependencies = [
|
|||||||
"df-types",
|
"df-types",
|
||||||
"df-workflow",
|
"df-workflow",
|
||||||
"futures",
|
"futures",
|
||||||
|
"futures-util",
|
||||||
"htmd",
|
"htmd",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"larksuite-oapi-sdk-rs",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -1054,6 +1056,7 @@ dependencies = [
|
|||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.23.1",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -1130,6 +1133,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"axum",
|
||||||
"df-tunnel",
|
"df-tunnel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"larksuite-oapi-sdk-rs",
|
"larksuite-oapi-sdk-rs",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
|
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
|
||||||
|
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
|
||||||
//!
|
//!
|
||||||
//! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明;
|
//! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明;
|
||||||
//! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。
|
//! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。
|
||||||
@@ -379,6 +380,7 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -434,6 +436,7 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
|||||||
idea_id: existing.idea_id,
|
idea_id: existing.idea_id,
|
||||||
path: existing.path,
|
path: existing.path,
|
||||||
stack: existing.stack,
|
stack: existing.stack,
|
||||||
|
archived: existing.archived,
|
||||||
created_at: existing.created_at,
|
created_at: existing.created_at,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1263,6 +1266,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! AI 编排节点 — 直连 agentic 会话执行工作流任务
|
//! AI 编排节点 — 直连 agentic 会话执行工作流任务
|
||||||
|
//! AI 编排节点 — 直连 agentic 会话执行工作流任务
|
||||||
//!
|
//!
|
||||||
//! 与 AiNode(单次 LLM 补全)的区别:AiAgentNode 在节点 execute 时以编程方式启动一个
|
//! 与 AiNode(单次 LLM 补全)的区别:AiAgentNode 在节点 execute 时以编程方式启动一个
|
||||||
//! 独立 agentic 会话(新 conv_id,与用户活跃会话隔离,防 epoch 互踩),由 run_agentic_loop
|
//! 独立 agentic 会话(新 conv_id,与用户活跃会话隔离,防 epoch 互踩),由 run_agentic_loop
|
||||||
@@ -241,6 +242,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! AI 自审节点(决策 a 步骤③)— AiSelfReviewNode
|
//! AI 自审节点(决策 a 步骤③)— AiSelfReviewNode
|
||||||
|
//! AI 自审节点(决策 a 步骤③)— AiSelfReviewNode
|
||||||
//!
|
//!
|
||||||
//! 从 ai_node.rs 拆出的独立节点(纯结构搬运,逻辑等价)。复用 AiNode 的 provider
|
//! 从 ai_node.rs 拆出的独立节点(纯结构搬运,逻辑等价)。复用 AiNode 的 provider
|
||||||
//! 调用链(resolve_and_parse + provider_from_params),差异在 prompt 模板/JSON 解析/
|
//! 调用链(resolve_and_parse + provider_from_params),差异在 prompt 模板/JSON 解析/
|
||||||
@@ -316,6 +317,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 任务推进节点 — advance_task 推进链触发器。
|
//! 任务推进节点 — advance_task 推进链触发器。
|
||||||
|
//! 任务推进节点 — advance_task 推进链触发器。
|
||||||
//!
|
//!
|
||||||
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
|
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
|
||||||
//! 1. 读当前 TaskRecord(取 from status)
|
//! 1. 读当前 TaskRecord(取 from status)
|
||||||
@@ -492,6 +493,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: "0".to_string(),
|
created_at: "0".to_string(),
|
||||||
updated_at: "0".to_string(),
|
updated_at: "0".to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ sys-locale = "0.3"
|
|||||||
# 后端 crate
|
# 后端 crate
|
||||||
df-types = { path = "../crates/df-types" }
|
df-types = { path = "../crates/df-types" }
|
||||||
df-storage = { path = "../crates/df-storage" }
|
df-storage = { path = "../crates/df-storage" }
|
||||||
|
larksuite-oapi-sdk-rs = { version = "0.3.12", features = ["channel"] }
|
||||||
df-workflow = { path = "../crates/df-workflow" }
|
df-workflow = { path = "../crates/df-workflow" }
|
||||||
df-nodes = { path = "../crates/df-nodes" }
|
df-nodes = { path = "../crates/df-nodes" }
|
||||||
df-execute = { path = "../crates/df-execute" }
|
df-execute = { path = "../crates/df-execute" }
|
||||||
@@ -69,6 +70,11 @@ regex = { workspace = true }
|
|||||||
# src-tauri 经 workspace 引用(转发壳 build_provider_for 不直接碰 keyring,但旧路径/兼容保留)。
|
# src-tauri 经 workspace 引用(转发壳 build_provider_for 不直接碰 keyring,但旧路径/兼容保留)。
|
||||||
# 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md
|
# 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md
|
||||||
keyring = { workspace = true }
|
keyring = { workspace = true }
|
||||||
|
# ask_uclaw AI 工具:u-claw 委托(tools/uclaw.rs)经 u-relay commander 协议 WS 直连。
|
||||||
|
# 版本对齐 df-tunnel 0.23(同依赖树零新增面);u-claw 协议为裸应用层帧,不走 df-tunnel
|
||||||
|
# TunnelMessage 包装,故不复用 WsTunnelClient,仅共享底层库。
|
||||||
|
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
|
||||||
|
futures-util = "0.3"
|
||||||
# http_request AI 工具:结构化 HTTP(GET/POST/...)。
|
# http_request AI 工具:结构化 HTTP(GET/POST/...)。
|
||||||
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。
|
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。
|
||||||
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
|
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
|
||||||
|
|||||||
@@ -300,6 +300,11 @@ pub(crate) async fn execute_with_heartbeat(
|
|||||||
// 外层必须晚于子循环自身三重熔断(轮次/token/挂钟)触发,让超预算的子代理
|
// 外层必须晚于子循环自身三重熔断(轮次/token/挂钟)触发,让超预算的子代理
|
||||||
// 以截断报告优雅收尾,而非被外层硬腰斩成裸超时错误(报告蒸发)。
|
// 以截断报告优雅收尾,而非被外层硬腰斩成裸超时错误(报告蒸发)。
|
||||||
crate::commands::ai::agentic::sub_agent::SUB_AGENT_WALL_TIMEOUT_SECS + 60
|
crate::commands::ai::agentic::sub_agent::SUB_AGENT_WALL_TIMEOUT_SECS + 60
|
||||||
|
} else if name == "ask_uclaw" {
|
||||||
|
// u-claw 委托:外层取工具内 REQUEST_TIMEOUT(16min,u-claw 15min 硬上限之上)
|
||||||
|
// 加缓冲(含重试序列 3×16min 理论上限,但实际互斥锁内串行,单次 16min+60s 已够
|
||||||
|
// ——重试在 handler 内部串行,外层只需覆盖单次 run_once 最长路径)。见 tools/uclaw.rs。
|
||||||
|
16 * 60 + 60
|
||||||
} else if name == "run_command" {
|
} else if name == "run_command" {
|
||||||
args.get("timeout_secs")
|
args.get("timeout_secs")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
@@ -321,6 +326,23 @@ pub(crate) async fn execute_with_heartbeat(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
let exec_fut = crate::commands::ai::command_stream::scope(sink, tools.execute(name, args));
|
let exec_fut = crate::commands::ai::command_stream::scope(sink, tools.execute(name, args));
|
||||||
|
// ask_uclaw 进度流式:u-claw agent_progress 块级(2s 节流)经独立 task-local sink 注入,
|
||||||
|
// handler 内 emit_progress_line 每块 emit AiCommandOutput(前端工具卡实时展示)。
|
||||||
|
// 非 ask_uclaw 不注入(handler 内 emit_progress_line 静默 noop),零行为变更。
|
||||||
|
// 与上方 command_stream 的 SINK / spawn_agent 的 CALLER_CONV 三 task-local 嵌套互不干扰。
|
||||||
|
// 两分支都走 scope(else 包 None):if/else 类型一致(future 输出 R 相同,scope 包装后
|
||||||
|
// 具体类型不同会 E0308,统一 None/Some 走同一泛型路径)。
|
||||||
|
let uclaw_sink = if name == "ask_uclaw" {
|
||||||
|
Some(crate::commands::ai::tools::uclaw::UclawProgressSink::new(
|
||||||
|
app.clone(),
|
||||||
|
tool_call_id.to_string(),
|
||||||
|
Some(conv_id.to_string()),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let exec_fut =
|
||||||
|
crate::commands::ai::tools::uclaw::scope_progress_sink(uclaw_sink, exec_fut);
|
||||||
// 父会话 task-local 注入:spawn_agent handler 经 caller_conv() 定位发起调用的父会话
|
// 父会话 task-local 注入:spawn_agent handler 经 caller_conv() 定位发起调用的父会话
|
||||||
// (直调防御兜底用)。与上方 command_stream 的 SINK scope 嵌套,两个 task-local 互不干扰。
|
// (直调防御兜底用)。与上方 command_stream 的 SINK scope 嵌套,两个 task-local 互不干扰。
|
||||||
let exec_fut = crate::commands::ai::tools::spawn_agent::scope_caller_conv(
|
let exec_fut = crate::commands::ai::tools::spawn_agent::scope_caller_conv(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! AI 工具注册表构建 + 文件路径校验
|
//! AI 工具注册表构建 + 文件路径校验
|
||||||
|
//! AI 工具注册表构建 + 文件路径校验
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -556,9 +557,20 @@ pub fn build_ai_tool_registry(
|
|||||||
register_local_proxy_tools(&mut registry, local_proxy_ctx);
|
register_local_proxy_tools(&mut registry, local_proxy_ctx);
|
||||||
register_read_skill_tool(&mut registry);
|
register_read_skill_tool(&mut registry);
|
||||||
register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
|
register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
|
||||||
|
register_uclaw_tool(&mut registry, db);
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ask_uclaw AI 工具注册(1 个:委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议)。
|
||||||
|
///
|
||||||
|
/// 工具职责:LLM 调 ask_uclaw(prompt) 把任务下发给 u-claw(channel 级单链粘性会话),
|
||||||
|
/// 经 u-relay /ws/commander 路由,agent_progress 块级流式回传 + agent_result 全文交付。
|
||||||
|
/// 风险 Medium(委托外部 agent 执行,与 spawn_agent 同档)。持 db:Arc<Database>(配置 KV 读)。
|
||||||
|
/// 声明式注册在 tools/uclaw.rs。
|
||||||
|
fn register_uclaw_tool(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
|
super::tools::uclaw::register(registry, db);
|
||||||
|
}
|
||||||
|
|
||||||
/// spawn_agent AI 工具注册(1 个:派发子代理,隔离上下文执行专项任务)。
|
/// spawn_agent AI 工具注册(1 个:派发子代理,隔离上下文执行专项任务)。
|
||||||
///
|
///
|
||||||
/// 工具职责:LLM 调 spawn_agent(task, tools?, max_rounds?, model?) 派发子代理,
|
/// 工具职责:LLM 调 spawn_agent(task, tools?, max_rounds?, model?) 派发子代理,
|
||||||
@@ -1494,10 +1506,14 @@ mod tests {
|
|||||||
// Medium 审批档;独立 agentic 循环在 agentic/sub_agent.rs,报告经返回值交付。
|
// Medium 审批档;独立 agentic 循环在 agentic/sub_agent.rs,报告经返回值交付。
|
||||||
// SpawnAgentCtx 绕 AppState 自引用同 GetAppConfigCtx 先例,注册表自引用走进程级 OnceLock)。
|
// SpawnAgentCtx 绕 AppState 自引用同 GetAppConfigCtx 先例,注册表自引用走进程级 OnceLock)。
|
||||||
// 78 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent。
|
// 78 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent。
|
||||||
|
// ask_uclaw(2026-10-05): uclaw 层 0→1(委托 u-claw 常驻 Claude Code 网关,
|
||||||
|
// u-relay commander 协议,Medium 审批档同 spawn_agent;配置 KV 三键读,
|
||||||
|
// 声明式注册 tools/uclaw.rs)。
|
||||||
|
// 79 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.len(),
|
registry.len(),
|
||||||
78,
|
79,
|
||||||
"工具总数应为 78(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent),实际 {}", registry.len()
|
"工具总数应为 79(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw),实际 {}", registry.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||||
@@ -1568,6 +1584,9 @@ mod tests {
|
|||||||
// ── spawn_agent 层 (1) ──(派发子代理隔离上下文执行专项任务,Medium 审批档,
|
// ── spawn_agent 层 (1) ──(派发子代理隔离上下文执行专项任务,Medium 审批档,
|
||||||
// 独立 agentic 循环 agentic/sub_agent.rs,声明式注册 tools/spawn_agent.rs)
|
// 独立 agentic 循环 agentic/sub_agent.rs,声明式注册 tools/spawn_agent.rs)
|
||||||
"spawn_agent",
|
"spawn_agent",
|
||||||
|
// ── uclaw 层 (1) ──(委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议,
|
||||||
|
// Medium 审批档,声明式注册 tools/uclaw.rs)
|
||||||
|
"ask_uclaw",
|
||||||
];
|
];
|
||||||
expected.sort_unstable();
|
expected.sort_unstable();
|
||||||
|
|
||||||
@@ -2405,6 +2424,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: now_millis(),
|
created_at: now_millis(),
|
||||||
updated_at: now_millis(),
|
updated_at: now_millis(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ mod tests {
|
|||||||
idea_id: None,
|
idea_id: None,
|
||||||
path: Some(format!("C:/projects/{}", name)),
|
path: Some(format!("C:/projects/{}", name)),
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ pub mod read_skill;
|
|||||||
// spawn_agent 子代理派发(独立 agentic 循环入口,audit 与 ai_approve 经本模块
|
// spawn_agent 子代理派发(独立 agentic 循环入口,audit 与 ai_approve 经本模块
|
||||||
// scope_caller_conv 注入父会话 task-local,故需 pub 可达)。
|
// scope_caller_conv 注入父会话 task-local,故需 pub 可达)。
|
||||||
pub mod spawn_agent;
|
pub mod spawn_agent;
|
||||||
|
// ask_uclaw(u-claw 委托,u-relay commander 协议;audit 经 scope_progress_sink 注入
|
||||||
|
// 进度流式 task-local,故需 pub 可达)。
|
||||||
|
pub mod uclaw;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 项目类 AI 工具声明式注册(register_project_tools 剩余 7 个迁入)。
|
//! 项目类 AI 工具声明式注册(register_project_tools 剩余 7 个迁入)。
|
||||||
|
//! 项目类 AI 工具声明式注册(register_project_tools 剩余 7 个迁入)。
|
||||||
//!
|
//!
|
||||||
//! 第一步试点已迁 `list_projects`(见 tools/list_projects.rs),本文件迁完
|
//! 第一步试点已迁 `list_projects`(见 tools/list_projects.rs),本文件迁完
|
||||||
//! `register_project_tools` 的剩余 7 个:update_project/create_project/bind_directory/
|
//! `register_project_tools` 的剩余 7 个:update_project/create_project/bind_directory/
|
||||||
@@ -94,6 +95,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
id: new_id(), name: name.to_string(), description: description.to_string(),
|
id: new_id(), name: name.to_string(), description: description.to_string(),
|
||||||
status: ProjectStatus::Planning, idea_id: None,
|
status: ProjectStatus::Planning, idea_id: None,
|
||||||
path: None, stack: None,
|
path: None, stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: now_millis(), updated_at: now_millis(),
|
created_at: now_millis(), updated_at: now_millis(),
|
||||||
};
|
};
|
||||||
let id = record.id.clone();
|
let id = record.id.clone();
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
//! ask_uclaw AI 工具(u-claw 委托:经 u-relay commander 协议下发指令给常驻 claude 网关)。
|
||||||
|
//!
|
||||||
|
//! ## 协议(2026-10-05 三方源码终核:u-claw wsch.go 405 行 / u-relay relay.rs+conn.rs / u-ppt relay.ts)
|
||||||
|
//!
|
||||||
|
//! commander 侧线格式(全部裸应用层帧,与 df-tunnel 的 TunnelMessage{dir} 包装不兼容):
|
||||||
|
//! 1. 连 `{relay}/ws/commander`,10s 内首帧 hello `{kind:"commander", channel_id, token}`;
|
||||||
|
//! 2. relay 回 `{"kind":"control","payload":{"control_kind":"hello_ack"}}` = 握手成功
|
||||||
|
//! (错误帧 `{"kind":"control","error":"..."}` = 被拒);
|
||||||
|
//! 3. 心跳:裸顶层帧 `{"control_kind":"ping"}`(⚠️不能包 kind/payload——会被 relay 当
|
||||||
|
//! 普通消息转发给对端,pong 永远不来);relay 回 `{"control_kind":"pong"}` 本地响应;
|
||||||
|
//! 4. 发指令:裸帧 `{"type":"agent_request","request_id":"<uuid>","prompt":"..."}` →
|
||||||
|
//! relay 自动包装 BroadcastMessage(kind=command) 路由到同 channel 的 agent;
|
||||||
|
//! 5. 收下行:relay 包装帧 `{kind:"command",from:"agent",payload:{...}}`,payload 内
|
||||||
|
//! `{type:"agent_progress"|"agent_result","request_id":"...","text":"..."}`;
|
||||||
|
//! progress 为 u-claw 侧 2s 节流块级(assistant 阶段叙述 + 🔧tool 名),result 为全文;
|
||||||
|
//! 6. 帧上限 1MiB(relay SetReadLimit + 入站断开);channel_id 格式:非空 ≤128B 无控制字符。
|
||||||
|
//!
|
||||||
|
//! ## 执行语义(u-claw 侧实证)
|
||||||
|
//! - 单 channel 单链串行 worker(reqCh 缓冲 4,满静默丢)→ 本工具进程级互斥:同一时刻
|
||||||
|
//! 仅允许 1 个在飞请求,第二个直接报「渠道忙」(对齐串行事实,避免排队后丢帧不可感知);
|
||||||
|
//! - u-claw 单条指令 15min 硬上限(wsExecTimeout,超时 AbortRunning 回「处理超时」文本)→
|
||||||
|
//! 本工具客户端超时 16min(>硬上限,让 u-claw 自己的超时结果先回来);外层兜底超时取
|
||||||
|
//! 16min+60s 缓冲(audit/mod.rs outer_secs);
|
||||||
|
//! - 队列满静默丢无回执 → 超时后最多重发 2 次,文案「u-claw 渠道忙」;
|
||||||
|
//! - 空闲链回收后 --resume 冷启动 ~7s 属正常(16min 超时覆盖足够);
|
||||||
|
//! - 会话锚定:channel 级单链粘性会话,所有 ask_uclaw 共享同一 claude 上下文(多轮追问
|
||||||
|
//! 天然连续;不同 devflow 会话共享同一 u-claw 上下文,属已知 MVP 语义,工具描述注明)。
|
||||||
|
//!
|
||||||
|
//! ## 连接模型:每请求独立短连接
|
||||||
|
//! 常驻连接需管理跨会话生命周期/重连/分发路由(AppHandle 级 spawn),短连接把生命周期
|
||||||
|
//! 收敛到单次工具调用内(connect→hello→探活→request→progress/result→close),无全局态、
|
||||||
|
//! 无泄漏面。探活 ping 在同一连接内完成(连接前探活无意义——relay 与 agent 可独立断)。
|
||||||
|
//! 代价是每请求 ~3 次 WS 握手 RTT,相对 15min 级任务量级可忽略。
|
||||||
|
//!
|
||||||
|
//! ## 配置(KV,前端设置页可改;默认关)
|
||||||
|
//! - uclaw_relay_url:如 `wss://relay.example.com`(自动补 /ws/commander);
|
||||||
|
//! - uclaw_channel_id:配对 channel(须与 u-claw ~/.u-claw/u-claw.yml ws.channel_id 一致);
|
||||||
|
//! - uclaw_token:U_RELAY_TOKEN 同源。
|
||||||
|
//! 三项任一为空 → 工具返回明确错误「未配置」,LLM 可向用户澄清。
|
||||||
|
//!
|
||||||
|
//! ## 流式
|
||||||
|
//! handler 内经 task-local sink(command_stream 同款新槽)把 agent_progress 块级文本
|
||||||
|
//! emit AiCommandOutput(前端工具卡 Started→Completed 间实时展示);audit/mod.rs 对
|
||||||
|
//! ask_uclaw 分支注入 sink + outer_secs 分档(与 run_command/spawn_agent 同模式)。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use df_ai::ai_tools::{AiToolRegistry, GoalMeta, GoalStyle, RiskLevel, SigMeta, SigValKind, ToolMeta};
|
||||||
|
use df_ai::declare_tool;
|
||||||
|
use df_storage::db::Database;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
use crate::state::SettingsNorm;
|
||||||
|
|
||||||
|
// ── 协议常量(与 u-relay/u-claw 源码对齐,勿凭记忆改) ──
|
||||||
|
|
||||||
|
/// 握手看门狗:10s 内未收 hello_ack 视为失败(u-ppt relay.ts HANDSHAKE_TIMEOUT 对齐)。
|
||||||
|
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// 探活 ping → pong 等待窗(30s 内无 pong 判渠道不可达,不进请求,D-5)。
|
||||||
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
/// 单请求硬超时:16min(> u-claw wsExecTimeout 15min,让对端超时结果先回来)。
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(16 * 60);
|
||||||
|
/// 超时重发上限(对齐任务描述 D-4「最多 2 次重试」;含首发共 3 次尝试)。
|
||||||
|
const MAX_ATTEMPTS: u32 = 3;
|
||||||
|
/// 帧上限 1MiB(relay 入站断开线,客户端先拒省一次白连)。
|
||||||
|
const MAX_FRAME_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
// ── KV 键(前端设置页同键写入) ──
|
||||||
|
|
||||||
|
const KV_RELAY_URL: &str = "uclaw_relay_url";
|
||||||
|
const KV_CHANNEL_ID: &str = "uclaw_channel_id";
|
||||||
|
const KV_TOKEN: &str = "uclaw_token";
|
||||||
|
|
||||||
|
/// 从 settings 读三项配置并校验(任一空 → Err 明确提示,LLM 可澄清)。
|
||||||
|
/// get_string_norm 归一去引号(前端 JSON.stringify 落库带引号,SettingsNorm 正典同款)。
|
||||||
|
async fn load_config(db: &Database) -> anyhow::Result<(String, String, String)> {
|
||||||
|
use df_storage::crud::SettingsRepo;
|
||||||
|
let settings = SettingsRepo::new(db);
|
||||||
|
let url = settings
|
||||||
|
.get_string_norm(KV_RELAY_URL)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let channel = settings
|
||||||
|
.get_string_norm(KV_CHANNEL_ID)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let token = settings
|
||||||
|
.get_string_norm(KV_TOKEN)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
if url.trim().is_empty() || channel.trim().is_empty() || token.trim().is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"u-claw 集成未配置:需在设置页填写 uclaw_relay_url / uclaw_channel_id / uclaw_token(三项缺一不可)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok((url.trim().to_string(), channel.trim().to_string(), token.trim().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// relay URL 归一:去掉尾斜杠,无路径时补 /ws/commander(用户可填裸域名或完整端点)。
|
||||||
|
fn normalize_relay_url(url: &str) -> String {
|
||||||
|
let u = url.trim_end_matches('/');
|
||||||
|
if u.ends_with("/ws/commander") {
|
||||||
|
u.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{u}/ws/commander")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// channel_id 格式校验(对齐 u-relay validate_channel_id:非空 ≤128B 无控制字符,
|
||||||
|
/// 客户端先拒省一次握手往返)。
|
||||||
|
fn validate_channel_id(channel: &str) -> bool {
|
||||||
|
!channel.is_empty()
|
||||||
|
&& channel.len() <= 128
|
||||||
|
&& !channel.chars().any(|c| c.is_control())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL 协议校验:仅 ws:// 与 wss://(拒 http/https/其他 scheme,防误配置成 relay 的 HTTP 面板)。
|
||||||
|
fn validate_ws_url(url: &str) -> bool {
|
||||||
|
url.starts_with("ws://") || url.starts_with("wss://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── task-local 进度 sink(audit/mod.rs 对 ask_uclaw 分支注入,handler 内读取) ──
|
||||||
|
|
||||||
|
tokio::task_local! {
|
||||||
|
static UCLAW_SINK: Option<UclawProgressSink>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进度 sink:emit 所需上下文(AppHandle + tool_call_id + conv_id)。
|
||||||
|
/// 结构对齐 command_stream::CommandSink(无合批——u-claw 侧已 2s 节流,双端节流叠加无必要)。
|
||||||
|
pub struct UclawProgressSink {
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
tool_call_id: String,
|
||||||
|
conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UclawProgressSink {
|
||||||
|
pub fn new(app: tauri::AppHandle, tool_call_id: String, conversation_id: Option<String>) -> Self {
|
||||||
|
Self { app, tool_call_id, conversation_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_progress(&self, text: &str) {
|
||||||
|
let ev = crate::commands::ai::AiChatEvent::AiCommandOutput {
|
||||||
|
id: self.tool_call_id.clone(),
|
||||||
|
stream: "stdout".to_string(),
|
||||||
|
line: text.to_string(),
|
||||||
|
conversation_id: self.conversation_id.clone(),
|
||||||
|
};
|
||||||
|
crate::commands::ai::event_bus::emit_ai_chat_event(&self.app, ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在 sink 作用域内执行 future(audit/mod.rs 调用;作用域结束自动清理)。
|
||||||
|
pub async fn scope_progress_sink<F, R>(sink: Option<UclawProgressSink>, fut: F) -> R
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = R>,
|
||||||
|
{
|
||||||
|
UCLAW_SINK.scope(sink, fut).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// handler 读取 sink(task-local 未注入时 noop,兜底不报错)。
|
||||||
|
fn emit_progress_line(text: &str) {
|
||||||
|
let _ = UCLAW_SINK.with(|maybe| {
|
||||||
|
if let Some(sink) = maybe {
|
||||||
|
sink.emit_progress(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WS 客户端(每请求短连接) ──
|
||||||
|
|
||||||
|
/// 单连接会话:connect → hello → hello_ack → (探活 ping) → agent_request → 收帧直到 result。
|
||||||
|
/// 返回 Ok(result_text) 或 Err(带语义错误)。progress 帧经 emit_progress_line 流式透出。
|
||||||
|
async fn run_once(
|
||||||
|
ws_url: &str,
|
||||||
|
channel: &str,
|
||||||
|
token: &str,
|
||||||
|
prompt: &str,
|
||||||
|
request_id: &str,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
// 帧体预校验(prompt 序列化后超 1MiB 直接拒,relay 会断连不如本地先拒)
|
||||||
|
let request_frame = serde_json::json!({
|
||||||
|
"type": "agent_request",
|
||||||
|
"request_id": request_id,
|
||||||
|
"prompt": prompt,
|
||||||
|
});
|
||||||
|
let request_text = serde_json::to_string(&request_frame)?;
|
||||||
|
if request_text.len() > MAX_FRAME_BYTES {
|
||||||
|
anyhow::bail!(
|
||||||
|
"prompt 序列化后 {} 字节超过 relay 1MiB 帧上限,请精简任务描述",
|
||||||
|
request_text.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (ws, _resp) = tokio_tungstenite::connect_async(ws_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("连接 relay 失败({ws_url}): {e}"))?;
|
||||||
|
let (mut sink, mut stream) = ws.split();
|
||||||
|
|
||||||
|
// hello 握手(10s 看门狗)
|
||||||
|
let hello = serde_json::json!({
|
||||||
|
"kind": "commander",
|
||||||
|
"channel_id": channel,
|
||||||
|
"token": token,
|
||||||
|
});
|
||||||
|
tokio::time::timeout(HANDSHAKE_TIMEOUT, sink.send(Message::Text(hello.to_string())))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("hello 发送超时(10s)"))?
|
||||||
|
.map_err(|e| anyhow::anyhow!("hello 发送失败: {e}"))?;
|
||||||
|
|
||||||
|
// 等 hello_ack(容错跳过 pong 帧——理论上 pong 只在 ping 后,防御性跳过)
|
||||||
|
let ack_deadline = tokio::time::timeout(HANDSHAKE_TIMEOUT, async {
|
||||||
|
loop {
|
||||||
|
let msg = stream.next().await.ok_or_else(|| anyhow::anyhow!("连接在握手期关闭"))?;
|
||||||
|
match msg? {
|
||||||
|
Message::Text(t) => return Ok::<String, anyhow::Error>(t),
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("hello_ack 超时(10s,relay 未响应)"))??;
|
||||||
|
let ack: serde_json::Value = serde_json::from_str(&ack_deadline)
|
||||||
|
.map_err(|e| anyhow::anyhow!("hello_ack 非 JSON: {e}"))?;
|
||||||
|
if ack.get("kind").and_then(|v| v.as_str()) == Some("control") {
|
||||||
|
if let Some(err) = ack.get("error").and_then(|v| v.as_str()) {
|
||||||
|
anyhow::bail!("relay 握手被拒: {err}(检查 token/channel_id)");
|
||||||
|
}
|
||||||
|
let ck = ack.pointer("/payload/control_kind").and_then(|v| v.as_str());
|
||||||
|
if ck != Some("hello_ack") {
|
||||||
|
anyhow::bail!("relay 握手异常控制帧: control_kind={ck:?}");
|
||||||
|
}
|
||||||
|
} else if ack.get("kind").is_some() {
|
||||||
|
// 带 kind 但非 control:非预期(应为 hello_ack),报错退出
|
||||||
|
anyhow::bail!("relay 握手返回非 ack 帧: {}", ack_deadline);
|
||||||
|
}
|
||||||
|
// 无 kind 字段的帧也当 ack 放行(协议演进容错);走到这里 = 握手成功。
|
||||||
|
|
||||||
|
// 探活 ping:确认 agent 在线(channel 无任何连接时发请求=石沉大海,relay 无离线缓存)。
|
||||||
|
// ping 必须是裸顶层 {"control_kind":"ping"}(包 kind/payload 会被当普通消息转发)。
|
||||||
|
sink.send(Message::Text(r#"{"control_kind":"ping"}"#.to_string()))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("探活 ping 发送失败: {e}"))?;
|
||||||
|
tokio::time::timeout(PROBE_TIMEOUT, async {
|
||||||
|
loop {
|
||||||
|
let msg = stream.next().await.ok_or_else(|| anyhow::anyhow!("探活期连接关闭"))?;
|
||||||
|
match msg? {
|
||||||
|
Message::Text(t) => {
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&t)
|
||||||
|
.map_err(|e| anyhow::anyhow!("探活期非 JSON 帧: {e}"))?;
|
||||||
|
let ck = v.get("control_kind").and_then(|x| x.as_str());
|
||||||
|
match ck {
|
||||||
|
Some("pong") => return Ok::<(), anyhow::Error>(()),
|
||||||
|
Some("hello_ack") => continue, // 迟到的 ack,忽略
|
||||||
|
_ => anyhow::bail!("探活期收到非控制帧(协议异常): {}", &t[..t.len().min(120)]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"u-claw 探活超时({}s 无 pong):渠道不可达(agent 离线或 relay 故障),不进入队列",
|
||||||
|
PROBE_TIMEOUT.as_secs()
|
||||||
|
)
|
||||||
|
})??;
|
||||||
|
|
||||||
|
// 发指令(裸应用层帧,relay 自动包装 kind=command 路由)
|
||||||
|
sink.send(Message::Text(request_text))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("指令发送失败: {e}"))?;
|
||||||
|
|
||||||
|
// 收循环:按 request_id 配对,直到 agent_result;progress 流式透出。
|
||||||
|
// 超时总闸 16min(u-claw 15min 硬上限会先回超时文本,此闸只兜协议级静默)。
|
||||||
|
let mut last_progress = String::new();
|
||||||
|
let result = tokio::time::timeout(REQUEST_TIMEOUT, async {
|
||||||
|
loop {
|
||||||
|
let msg = stream
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("连接在等待结果期关闭(agent 可能已下线)"))?;
|
||||||
|
match msg? {
|
||||||
|
Message::Text(t) => {
|
||||||
|
let v: serde_json::Value = match serde_json::from_str(&t) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue, // 非 JSON 忽略(协议前向兼容)
|
||||||
|
};
|
||||||
|
// control 帧:pong/其他,忽略
|
||||||
|
if v.get("kind").and_then(|x| x.as_str()) == Some("control") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// relay 包装帧取 payload;裸帧(u-relay 演进直透)兜底取自身
|
||||||
|
let body = v.get("payload").unwrap_or(&v);
|
||||||
|
let rtype = body.get("type").and_then(|x| x.as_str());
|
||||||
|
let rid = body.get("request_id").and_then(|x| x.as_str());
|
||||||
|
if rid != Some(request_id) {
|
||||||
|
continue; // 不配对的帧忽略(u-ppt 同语义)
|
||||||
|
}
|
||||||
|
let text = body.get("text").and_then(|x| x.as_str()).unwrap_or("");
|
||||||
|
match rtype {
|
||||||
|
Some("agent_progress") => {
|
||||||
|
if !text.is_empty() && text != last_progress {
|
||||||
|
emit_progress_line(text);
|
||||||
|
last_progress = text.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("agent_result") => {
|
||||||
|
return Ok::<String, anyhow::Error>(text.to_string());
|
||||||
|
}
|
||||||
|
_ => {} // 未知 type 静默忽略(前向兼容,对齐 u-claw handleFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Ping(p) => {
|
||||||
|
let _ = sink.send(Message::Pong(p)).await;
|
||||||
|
}
|
||||||
|
Message::Close(_) => {
|
||||||
|
return Err(anyhow::anyhow!("relay 主动关闭连接(帧上限超限或服务重启)"));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("等待 u-claw 结果超时({}s):渠道静默,可能队列已满被丢弃", REQUEST_TIMEOUT.as_secs()))??;
|
||||||
|
|
||||||
|
let _ = sink.close().await;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带互斥与重试的顶层调用:进程级串行锁(对齐 u-claw 单 worker 事实)+ 超时重发。
|
||||||
|
async fn ask_uclaw_inner(
|
||||||
|
db: &Database,
|
||||||
|
prompt: &str,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let (url, channel, token) = load_config(db).await?;
|
||||||
|
let ws_url = normalize_relay_url(&url);
|
||||||
|
if !validate_ws_url(&ws_url) {
|
||||||
|
anyhow::bail!("uclaw_relay_url 非法(须 ws:// 或 wss:// 开头): {ws_url}");
|
||||||
|
}
|
||||||
|
if !validate_channel_id(&channel) {
|
||||||
|
anyhow::bail!("uclaw_channel_id 非法(非空 ≤128B 无控制字符): {channel}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let request_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let mut last_err = String::new();
|
||||||
|
for attempt in 1..=MAX_ATTEMPTS {
|
||||||
|
if attempt > 1 {
|
||||||
|
tracing::info!(attempt, request_id = %request_id, "[u-claw] 重试(上次: {last_err})");
|
||||||
|
emit_progress_line(&format!("(重试 {attempt}/{MAX_ATTEMPTS}: {last_err})"));
|
||||||
|
}
|
||||||
|
match run_once(&ws_url, &channel, &token, prompt, &request_id).await {
|
||||||
|
Ok(text) => {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"status": "completed",
|
||||||
|
"request_id": request_id,
|
||||||
|
"result": text,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
// 不可重试类:配置错/握手被拒/帧超限 —— 重发必然同败,直接返回
|
||||||
|
if msg.contains("未配置") || msg.contains("握手被拒") || msg.contains("帧上限") || msg.contains("非法") {
|
||||||
|
anyhow::bail!(msg);
|
||||||
|
}
|
||||||
|
// agent_result 已带回的超时/失败文本是 u-claw 的明确答复,不重发
|
||||||
|
if msg.contains("处理超时") || msg.contains("处理失败") {
|
||||||
|
anyhow::bail!(msg);
|
||||||
|
}
|
||||||
|
last_err = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::bail!("u-claw 渠道忙:连续 {} 次尝试失败({last_err}),请稍后重试", MAX_ATTEMPTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册 ask_uclaw 工具(RiskLevel::Medium,委托外部 agent 执行需用户审批,与 spawn_agent 同档)。
|
||||||
|
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
|
let schema = serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompt": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "下发给 u-claw(常驻 Claude Code 网关)的任务指令。注意:u-claw 是 channel 级单链粘性会话——所有调用共享同一上下文,多轮追问天然连续,但跨 devflow 会话也会串上下文;长任务(u-claw 侧 15min 硬上限)超时会被 u-claw 主动中止并回超时文本"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["prompt"]
|
||||||
|
});
|
||||||
|
declare_tool!(
|
||||||
|
registry,
|
||||||
|
db: Arc<Database>,
|
||||||
|
"ask_uclaw",
|
||||||
|
"委托 u-claw(常驻 Claude Code 能力网关,经 u-relay commander 协议)执行任务并等待结果。适用:需要另一路 Claude Code 会话独立完成的任务(如远端环境操作/长任务委托)。prompt 为任务指令;u-claw 侧会话粘性(同一 channel 所有调用共享上下文),多轮追问无需重复背景;进度实时流式回传(2s 块级)。超时 16min,u-claw 侧单任务 15min 硬上限,队列忙/离线会明确报错",
|
||||||
|
RiskLevel::Medium,
|
||||||
|
meta: ToolMeta {
|
||||||
|
approval_hint: Some(("委托 u-claw: {}", &["prompt"])),
|
||||||
|
display_verb: Some("委托"),
|
||||||
|
goal: Some(GoalMeta {
|
||||||
|
verb: "委托 u-claw",
|
||||||
|
keys: &["prompt"],
|
||||||
|
style: GoalStyle::Truncate,
|
||||||
|
fallback: Some("委托 u-claw 执行任务"),
|
||||||
|
}),
|
||||||
|
sig: Some(SigMeta {
|
||||||
|
groups: &[(&["prompt"], SigValKind::Str)],
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
schema: schema,
|
||||||
|
args => {
|
||||||
|
let prompt = args["prompt"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("参数 prompt 不能为空:请传下发给 u-claw 的任务指令"))?;
|
||||||
|
if prompt.chars().count() > 100_000 {
|
||||||
|
anyhow::bail!("prompt 过长({} 字符),请精简后重试", prompt.chars().count());
|
||||||
|
}
|
||||||
|
// 进程级互斥:u-claw 单 channel 单 worker 串行,并发请求只会在 reqCh(缓冲4)
|
||||||
|
// 排队后可能被静默丢弃(D-4)。锁失败=已有请求在飞,直接报忙(LLM 可稍后重试)。
|
||||||
|
static IN_FLIGHT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
|
let _guard = match IN_FLIGHT.try_lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => anyhow::bail!("u-claw 渠道忙:已有任务在执行(u-claw 串行处理,同一时刻仅一个在飞请求),请稍后重试"),
|
||||||
|
};
|
||||||
|
ask_uclaw_inner(&db, &prompt).await
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_relay_url_appends_endpoint() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relay_url("wss://relay.example.com"),
|
||||||
|
"wss://relay.example.com/ws/commander"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relay_url("wss://relay.example.com/"),
|
||||||
|
"wss://relay.example.com/ws/commander"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relay_url("ws://localhost:9180/ws/commander"),
|
||||||
|
"ws://localhost:9180/ws/commander"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_ws_url_schemes() {
|
||||||
|
assert!(validate_ws_url("wss://relay.example.com/ws/commander"));
|
||||||
|
assert!(validate_ws_url("ws://localhost:9180/ws/commander"));
|
||||||
|
assert!(!validate_ws_url("https://relay.example.com"), "https 拒绝");
|
||||||
|
assert!(!validate_ws_url("http://localhost:9180"), "http 拒绝");
|
||||||
|
assert!(!validate_ws_url("relay.example.com"), "无 scheme 拒绝");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_channel_id_rules() {
|
||||||
|
assert!(validate_channel_id("devflow-conv-1"));
|
||||||
|
assert!(validate_channel_id(&"a".repeat(128)), "128 上限放行");
|
||||||
|
assert!(!validate_channel_id(""), "空拒绝");
|
||||||
|
assert!(!validate_channel_id(&"a".repeat(129)), "超长拒绝");
|
||||||
|
assert!(!validate_channel_id("dev\u{0001}flow"), "控制字符拒绝");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn config_missing_fails_with_hint() {
|
||||||
|
let db = Database::open_in_memory().await.expect("db");
|
||||||
|
let err = load_config(&db).await.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("未配置"), "应提示未配置: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn config_partial_fails() {
|
||||||
|
let db = Database::open_in_memory().await.expect("db");
|
||||||
|
use df_storage::crud::SettingsRepo;
|
||||||
|
let settings = SettingsRepo::new(&db);
|
||||||
|
settings.set(KV_RELAY_URL, "wss://relay.example.com").await.unwrap();
|
||||||
|
let err = load_config(&db).await.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("未配置"), "缺 channel/token 仍应拒绝: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn config_complete_passes() {
|
||||||
|
let db = Database::open_in_memory().await.expect("db");
|
||||||
|
use df_storage::crud::SettingsRepo;
|
||||||
|
let settings = SettingsRepo::new(&db);
|
||||||
|
// 模拟前端 JSON.stringify 落库(带引号),验证 get_string_norm 归一
|
||||||
|
settings.set(KV_RELAY_URL, "\"wss://relay.example.com\"").await.unwrap();
|
||||||
|
settings.set(KV_CHANNEL_ID, "\"u-ppt-dev-01\"").await.unwrap();
|
||||||
|
settings.set(KV_TOKEN, "\"tok-123\"").await.unwrap();
|
||||||
|
let (url, channel, token) = load_config(&db).await.expect("完整配置应通过");
|
||||||
|
assert_eq!(url, "wss://relay.example.com");
|
||||||
|
assert_eq!(channel, "u-ppt-dev-01");
|
||||||
|
assert_eq!(token, "tok-123");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 灵感相关命令
|
//! 灵感相关命令
|
||||||
|
//! 灵感相关命令
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -249,6 +250,7 @@ pub async fn promote_idea(
|
|||||||
idea_id: Some(id.clone()),
|
idea_id: Some(id.clone()),
|
||||||
path: None,
|
path: None,
|
||||||
stack: None,
|
stack: None,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now.clone(),
|
updated_at: now.clone(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 项目相关命令
|
//! 项目相关命令
|
||||||
|
//! 项目相关命令
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -201,6 +202,7 @@ async fn create_with_binding(
|
|||||||
idea_id,
|
idea_id,
|
||||||
path,
|
path,
|
||||||
stack,
|
stack,
|
||||||
|
archived: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -426,6 +428,32 @@ pub async fn list_deleted_projects(
|
|||||||
state.projects.list_deleted().await.map_err(err_str)
|
state.projects.list_deleted().await.map_err(err_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 归档/取消归档项目(归档功能 P2,V48)。对标 archive_idea/archive_task 同款 thin 入口。
|
||||||
|
///
|
||||||
|
/// 纯元数据标记,仅作用于未软删项目(回收站优先级高于归档,先恢复再操作)。
|
||||||
|
/// archived=true 后项目退出主视图(list_active 等活跃查询恒带 archived = 0),
|
||||||
|
/// 仅在归档视图(list_archived_projects)可见。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn archive_project(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
archived: bool,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
state
|
||||||
|
.projects
|
||||||
|
.set_archived(&id, archived)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出归档项目(archived = 1 且未软删),归档视图数据源。对标 list_archived_ideas。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_archived_projects(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<ProjectRecord>, String> {
|
||||||
|
state.projects.list_archived().await.map_err(err_str)
|
||||||
|
}
|
||||||
|
|
||||||
/// 恢复项目(从回收站还原,清 deleted_at)
|
/// 恢复项目(从回收站还原,清 deleted_at)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn restore_project(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
pub async fn restore_project(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||||
|
|||||||
+15
-2
@@ -7,6 +7,7 @@ mod mcp;
|
|||||||
// 本地工具代理统一网关:内嵌 axum 监听 127.0.0.1:8899,路由到 mysql/ssh/mongo/redis 4 代理端口
|
// 本地工具代理统一网关:内嵌 axum 监听 127.0.0.1:8899,路由到 mysql/ssh/mongo/redis 4 代理端口
|
||||||
mod local_proxy_gateway;
|
mod local_proxy_gateway;
|
||||||
mod tray;
|
mod tray;
|
||||||
|
mod feishu_channel;
|
||||||
|
|
||||||
use tauri::{Emitter, Listener, Manager};
|
use tauri::{Emitter, Listener, Manager};
|
||||||
// UX-P2 走查⑥:首次关窗托盘化提示走 OS 系统通知(应用内气泡窗口即隐看不见),需 NotificationExt
|
// UX-P2 走查⑥:首次关窗托盘化提示走 OS 系统通知(应用内气泡窗口即隐看不见),需 NotificationExt
|
||||||
@@ -95,6 +96,9 @@ pub fn run() {
|
|||||||
// setup 阶段回注进程级 OnceLock(重复注入忽略,首次生效)。
|
// setup 阶段回注进程级 OnceLock(重复注入忽略,首次生效)。
|
||||||
crate::commands::ai::tools::spawn_agent::init_app_handle(app.handle().clone());
|
crate::commands::ai::tools::spawn_agent::init_app_handle(app.handle().clone());
|
||||||
|
|
||||||
|
// 飞书直连渠道:配置启用时起长连接(读 feishu-config;未配置/disabled 静默跳过)
|
||||||
|
feishu_channel::start_if_enabled(app.handle().clone());
|
||||||
|
|
||||||
// ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ──
|
// ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ──
|
||||||
// 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。
|
// 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。
|
||||||
// mcp_get_status IPC 经 State<Mutex<McpStatus>> 读取,spawn 内写锁同步。
|
// mcp_get_status IPC 经 State<Mutex<McpStatus>> 读取,spawn 内写锁同步。
|
||||||
@@ -225,8 +229,15 @@ pub fn run() {
|
|||||||
.await
|
.await
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string());
|
.unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string());
|
||||||
// token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)
|
// token:KV 取,无则回退默认常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)。
|
||||||
let token = "devflow-relay-default-token".to_string();
|
// 2026-10-05 配置化(原硬编码):relay 部署改 token 后桌面端无需重编译,
|
||||||
|
// 前端高级-连接面板(或 settings KV)写 relay_token 即生效。
|
||||||
|
let token = state
|
||||||
|
.settings
|
||||||
|
.get_string_norm("relay_token")
|
||||||
|
.await
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "devflow-relay-default-token".to_string());
|
||||||
|
|
||||||
// subscriber task:subscribe ai_event_bus → tunnel.send_raw_event 透传 miniapp
|
// subscriber task:subscribe ai_event_bus → tunnel.send_raw_event 透传 miniapp
|
||||||
// 治本:先查 is_connected()(AtomicBool 无锁读),未连接时静默丢弃事件,
|
// 治本:先查 is_connected()(AtomicBool 无锁读),未连接时静默丢弃事件,
|
||||||
@@ -357,6 +368,8 @@ pub fn run() {
|
|||||||
commands::project::update_project,
|
commands::project::update_project,
|
||||||
commands::project::delete_project,
|
commands::project::delete_project,
|
||||||
commands::project::list_deleted_projects,
|
commands::project::list_deleted_projects,
|
||||||
|
commands::project::archive_project,
|
||||||
|
commands::project::list_archived_projects,
|
||||||
commands::project::restore_project,
|
commands::project::restore_project,
|
||||||
commands::project::purge_project,
|
commands::project::purge_project,
|
||||||
commands::project::scan_project_stack,
|
commands::project::scan_project_stack,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
// knowledge 知识库(auto_extract/trigger_mode/embedding_*)
|
// knowledge 知识库(auto_extract/trigger_mode/embedding_*)
|
||||||
// performance 性能(global/per-conv concurrency/max-iterations/max-retries)
|
// performance 性能(global/per-conv concurrency/max-iterations/max-retries)
|
||||||
// security 安全(allowed_dirs)
|
// security 安全(allowed_dirs)
|
||||||
|
// integrations 集成(飞书渠道凭证)
|
||||||
// advanced 高级(autoExecuteMode/logLevel + 连接)
|
// advanced 高级(autoExecuteMode/logLevel + 连接)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ const categories: NavCategory[] = [
|
|||||||
{ value: 'knowledge', icon: '📚', labelKey: 'settings.navKnowledge' },
|
{ value: 'knowledge', icon: '📚', labelKey: 'settings.navKnowledge' },
|
||||||
{ value: 'performance', icon: '⚡', labelKey: 'settings.navPerformance' },
|
{ value: 'performance', icon: '⚡', labelKey: 'settings.navPerformance' },
|
||||||
{ value: 'security', icon: '🔒', labelKey: 'settings.navSecurity' },
|
{ value: 'security', icon: '🔒', labelKey: 'settings.navSecurity' },
|
||||||
|
{ value: 'integrations', icon: '🔌', labelKey: 'settings.navIntegrations' },
|
||||||
{ value: 'advanced', icon: '⚙️', labelKey: 'settings.navAdvanced' },
|
{ value: 'advanced', icon: '⚙️', labelKey: 'settings.navAdvanced' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<!-- ═══ u-claw 集成(ask_uclaw 工具:经 u-relay commander 协议委托常驻 Claude Code 网关) ═══ -->
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>{{ $t('settings.panelUclaw') }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="uclaw-settings">
|
||||||
|
<SettingRow :label="$t('settings.labelUclawRelayUrl')" :desc="$t('settings.descUclawRelayUrl')">
|
||||||
|
<input
|
||||||
|
v-model="form.relay_url"
|
||||||
|
type="text"
|
||||||
|
class="setting-input uclaw-input"
|
||||||
|
:placeholder="$t('settings.phUclawRelayUrl')"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow :label="$t('settings.labelUclawChannel')" :desc="$t('settings.descUclawChannel')">
|
||||||
|
<input
|
||||||
|
v-model="form.channel_id"
|
||||||
|
type="text"
|
||||||
|
class="setting-input uclaw-input"
|
||||||
|
:placeholder="$t('settings.phUclawChannel')"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow :label="$t('settings.labelUclawToken')" :desc="$t('settings.descUclawToken')">
|
||||||
|
<input
|
||||||
|
v-model="form.token"
|
||||||
|
:type="secretVisible ? 'text' : 'password'"
|
||||||
|
class="setting-input uclaw-input"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<!-- 掩码显隐复用 Provider 密钥的「显示/隐藏」文案,语义一致不另造键 -->
|
||||||
|
<button class="btn btn-ghost btn-sm" type="button" @click="secretVisible = !secretVisible">
|
||||||
|
{{ secretVisible ? $t('settings.apiKeyHide') : $t('settings.apiKeyShow') }}
|
||||||
|
</button>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<div class="uclaw-hint">{{ $t('settings.descUclawHint') }}</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary btn-sm" :disabled="saving" @click="save">
|
||||||
|
{{ $t('settings.uclawSave') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
|
import SettingRow from './SettingRow.vue'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// u-claw 集成配置 — ask_uclaw 工具的连接三要素(relay URL / channel / token)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 持久化走 appSettings KV,三键独立落库(与后端 tools/uclaw.rs load_config 同键名):
|
||||||
|
// uclaw_relay_url / uclaw_channel_id / uclaw_token
|
||||||
|
// 与 FeishuPanel 的单键整体存法不同:后端 load_config 逐键读,前端逐键写保证两端同形。
|
||||||
|
// store set() 会 JSON.stringify 落库,后端 get_string_norm 归一去引号(两端契约已对齐)。
|
||||||
|
// 三项全空 = 未配置(默认关),ask_uclaw 工具会返回明确错误;部分填写保存时警告但不阻止
|
||||||
|
// (半份配置无害——后端三项校验任一空即拒绝执行)。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const KV_RELAY_URL = 'uclaw_relay_url'
|
||||||
|
const KV_CHANNEL_ID = 'uclaw_channel_id'
|
||||||
|
const KV_TOKEN = 'uclaw_token'
|
||||||
|
|
||||||
|
// 接共享 toast 通道(Settings.vue showToast),与 FeishuPanel 同范式
|
||||||
|
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
|
const form = reactive({ relay_url: '', channel_id: '', token: '' })
|
||||||
|
const secretVisible = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// KV 值由 store 启动 loadAll 统一 JSON.parse 进缓存,此处同步读;
|
||||||
|
// 逐字段回退默认,防历史脏数据缺字段时 v-model 绑到 undefined
|
||||||
|
form.relay_url = typeof appSettings.get<string>(KV_RELAY_URL, '') === 'string' ? appSettings.get<string>(KV_RELAY_URL, '') : ''
|
||||||
|
form.channel_id = typeof appSettings.get<string>(KV_CHANNEL_ID, '') === 'string' ? appSettings.get<string>(KV_CHANNEL_ID, '') : ''
|
||||||
|
form.token = typeof appSettings.get<string>(KV_TOKEN, '') === 'string' ? appSettings.get<string>(KV_TOKEN, '') : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 保存:部分填写警告(不阻止);三键独立落库(与后端 load_config 同键) */
|
||||||
|
async function save() {
|
||||||
|
const filled = [form.relay_url.trim(), form.channel_id.trim(), form.token.trim()].filter(Boolean).length
|
||||||
|
if (filled > 0 && filled < 3) {
|
||||||
|
emit('toast', t('settings.uclawValidateIncomplete'), 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await appSettings.set(KV_RELAY_URL, form.relay_url.trim())
|
||||||
|
await appSettings.set(KV_CHANNEL_ID, form.channel_id.trim())
|
||||||
|
await appSettings.set(KV_TOKEN, form.token.trim())
|
||||||
|
emit('toast', t('settings.uclawSaveOk'), 'success')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UCLAW-CONFIG] 保存失败:', e)
|
||||||
|
emit('toast', t('settings.toastSaveFail', { msg: String(e) }), 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* panel/panel-header/btn 系列/setting-input 均在 settings.css 全局,此处仅本面板特有样式 */
|
||||||
|
.uclaw-settings { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.uclaw-input { width: 320px; font-family: var(--df-font-mono, monospace); }
|
||||||
|
.uclaw-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-tertiary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
|
</style>
|
||||||
@@ -18,6 +18,7 @@ export type SettingsCategory =
|
|||||||
| 'knowledge'
|
| 'knowledge'
|
||||||
| 'performance'
|
| 'performance'
|
||||||
| 'security'
|
| 'security'
|
||||||
|
| 'integrations'
|
||||||
| 'advanced'
|
| 'advanced'
|
||||||
|
|
||||||
/** 搜索索引项:category 所属分类 + labelKey/descKey(i18n key) + key 唯一标识 */
|
/** 搜索索引项:category 所属分类 + labelKey/descKey(i18n key) + key 唯一标识 */
|
||||||
@@ -42,6 +43,7 @@ export interface SettingIndexEntry {
|
|||||||
* performance 性能(global-concurrency/per-conv-concurrency/
|
* performance 性能(global-concurrency/per-conv-concurrency/
|
||||||
* max-iterations/max-retries)
|
* max-iterations/max-retries)
|
||||||
* security 安全(allowed_dirs)
|
* security 安全(allowed_dirs)
|
||||||
|
* integrations 集成(飞书渠道凭证)
|
||||||
* advanced 高级(autoExecuteMode/logLevel + 连接)
|
* advanced 高级(autoExecuteMode/logLevel + 连接)
|
||||||
*/
|
*/
|
||||||
export const SETTINGS_INDEX: SettingIndexEntry[] = [
|
export const SETTINGS_INDEX: SettingIndexEntry[] = [
|
||||||
@@ -83,6 +85,18 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [
|
|||||||
{ category: 'security', key: 'scriptWhitelist', labelKey: 'settings.labelScriptWhitelist', descKey: 'settings.descScriptWhitelist' },
|
{ category: 'security', key: 'scriptWhitelist', labelKey: 'settings.labelScriptWhitelist', descKey: 'settings.descScriptWhitelist' },
|
||||||
{ category: 'security', key: 'scriptBlacklist', labelKey: 'settings.labelScriptBlacklist', descKey: 'settings.descScriptBlacklist' },
|
{ category: 'security', key: 'scriptBlacklist', labelKey: 'settings.labelScriptBlacklist', descKey: 'settings.descScriptBlacklist' },
|
||||||
|
|
||||||
|
// ===== 集成 =====
|
||||||
|
{ category: 'integrations', key: 'feishu', labelKey: 'settings.panelFeishu' },
|
||||||
|
{ category: 'integrations', key: 'feishuEnabled', labelKey: 'settings.labelFeishuEnabled', descKey: 'settings.descFeishuEnabled' },
|
||||||
|
{ category: 'integrations', key: 'feishuAppId', labelKey: 'settings.labelFeishuAppId', descKey: 'settings.descFeishuAppId' },
|
||||||
|
{ category: 'integrations', key: 'feishuAppSecret', labelKey: 'settings.labelFeishuAppSecret', descKey: 'settings.descFeishuAppSecret' },
|
||||||
|
{ category: 'integrations', key: 'feishuWhitelist', labelKey: 'settings.labelFeishuWhitelist', descKey: 'settings.descFeishuWhitelist' },
|
||||||
|
// u-claw 集成(ask_uclaw 工具三键配置)
|
||||||
|
{ category: 'integrations', key: 'uclaw', labelKey: 'settings.panelUclaw' },
|
||||||
|
{ category: 'integrations', key: 'uclawRelayUrl', labelKey: 'settings.labelUclawRelayUrl', descKey: 'settings.descUclawRelayUrl' },
|
||||||
|
{ category: 'integrations', key: 'uclawChannel', labelKey: 'settings.labelUclawChannel', descKey: 'settings.descUclawChannel' },
|
||||||
|
{ category: 'integrations', key: 'uclawToken', labelKey: 'settings.labelUclawToken', descKey: 'settings.descUclawToken' },
|
||||||
|
|
||||||
// ===== 高级 =====
|
// ===== 高级 =====
|
||||||
// F-#97:autoExecute(boolean) → autoExecuteMode(low/medium/all 三档),key 同步更名
|
// F-#97:autoExecute(boolean) → autoExecuteMode(low/medium/all 三档),key 同步更名
|
||||||
// dataDir(应用数据目录):AdvancedSection.vue 渲染行,此前漏入索引
|
// dataDir(应用数据目录):AdvancedSection.vue 渲染行,此前漏入索引
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default {
|
|||||||
navKnowledge: 'Knowledge',
|
navKnowledge: 'Knowledge',
|
||||||
navPerformance: 'Performance',
|
navPerformance: 'Performance',
|
||||||
navSecurity: 'Security',
|
navSecurity: 'Security',
|
||||||
|
navIntegrations: 'Integrations',
|
||||||
navAdvanced: 'Advanced',
|
navAdvanced: 'Advanced',
|
||||||
|
|
||||||
// ===== Settings search (phase 4) =====
|
// ===== Settings search (phase 4) =====
|
||||||
@@ -254,6 +255,38 @@ export default {
|
|||||||
embeddingProviderNone: 'Not selected',
|
embeddingProviderNone: 'Not selected',
|
||||||
phEmbeddingModel: 'embedding-3',
|
phEmbeddingModel: 'embedding-3',
|
||||||
|
|
||||||
|
// ===== Integrations: Feishu (built-in Feishu channel, WebSocket to the Feishu Open Platform) =====
|
||||||
|
panelFeishu: 'Feishu Integration',
|
||||||
|
labelFeishuEnabled: 'Enable Feishu integration',
|
||||||
|
descFeishuEnabled: 'Connect to the Feishu Open Platform over WebSocket to send and receive messages',
|
||||||
|
labelFeishuAppId: 'App ID',
|
||||||
|
descFeishuAppId: 'App ID of your Feishu app (Developer Console → Credentials & Basic Info)',
|
||||||
|
phFeishuAppId: 'cli_xxx',
|
||||||
|
labelFeishuAppSecret: 'App Secret',
|
||||||
|
descFeishuAppSecret: 'App Secret of your Feishu app, stored only in the local database and read/written with the config as a whole',
|
||||||
|
labelFeishuWhitelist: 'open_id whitelist',
|
||||||
|
descFeishuWhitelist: 'Comma-separated open_id list; leave empty to allow all users',
|
||||||
|
phFeishuWhitelist: 'ou_xxx, ou_yyy',
|
||||||
|
feishuSave: 'Save',
|
||||||
|
feishuSaveOk: 'Feishu integration settings saved',
|
||||||
|
// Submit guard: when enabled=true, either of id/secret empty blocks the write
|
||||||
|
feishuValidateIncomplete: 'Fill in App ID and App Secret to enable the Feishu integration',
|
||||||
|
|
||||||
|
// ===== Integrations: u-claw (ask_uclaw tool, u-relay commander protocol) =====
|
||||||
|
panelUclaw: 'u-claw Integration',
|
||||||
|
labelUclawRelayUrl: 'Relay URL',
|
||||||
|
descUclawRelayUrl: 'u-relay address (wss:// host is enough, /ws/commander auto-appended)',
|
||||||
|
phUclawRelayUrl: 'wss://relay.example.com',
|
||||||
|
labelUclawChannel: 'Channel ID',
|
||||||
|
descUclawChannel: 'Pairing channel, must match u-claw ws.channel_id',
|
||||||
|
phUclawChannel: 'u-ppt-dev-01',
|
||||||
|
labelUclawToken: 'Token',
|
||||||
|
descUclawToken: 'Relay auth token (same as u-relay U_RELAY_TOKEN)',
|
||||||
|
descUclawHint: 'Once configured, the AI can delegate tasks to u-claw (resident Claude Code gateway) via the ask_uclaw tool, with progress streamed back in real time. Leave all three empty to disable.',
|
||||||
|
uclawSave: 'Save',
|
||||||
|
uclawSaveOk: 'u-claw integration settings saved',
|
||||||
|
uclawValidateIncomplete: 'Fill in all three fields (Relay URL / Channel / Token), or leave all empty',
|
||||||
|
|
||||||
// ===== Toast =====
|
// ===== Toast =====
|
||||||
toastLoadProviderFail: 'Failed to load providers',
|
toastLoadProviderFail: 'Failed to load providers',
|
||||||
toastSaveIncomplete: 'Please fill in all fields (Name / Base URL / API Key / Model)',
|
toastSaveIncomplete: 'Please fill in all fields (Name / Base URL / API Key / Model)',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default {
|
|||||||
navKnowledge: '知识库',
|
navKnowledge: '知识库',
|
||||||
navPerformance: '性能',
|
navPerformance: '性能',
|
||||||
navSecurity: '安全',
|
navSecurity: '安全',
|
||||||
|
navIntegrations: '集成',
|
||||||
navAdvanced: '高级',
|
navAdvanced: '高级',
|
||||||
|
|
||||||
// ===== 设置搜索(阶段4) =====
|
// ===== 设置搜索(阶段4) =====
|
||||||
@@ -253,6 +254,38 @@ export default {
|
|||||||
embeddingProviderNone: '未选择',
|
embeddingProviderNone: '未选择',
|
||||||
phEmbeddingModel: 'embedding-3',
|
phEmbeddingModel: 'embedding-3',
|
||||||
|
|
||||||
|
// ===== 集成:飞书(桌面端内置飞书渠道,WS 直连飞书开放平台) =====
|
||||||
|
panelFeishu: '飞书集成',
|
||||||
|
labelFeishuEnabled: '启用飞书集成',
|
||||||
|
descFeishuEnabled: '开启后桌面端经 WebSocket 直连飞书开放平台收发消息',
|
||||||
|
labelFeishuAppId: 'App ID',
|
||||||
|
descFeishuAppId: '飞书开放平台应用的 App ID(开发者后台 → 凭证与基础信息)',
|
||||||
|
phFeishuAppId: 'cli_xxx',
|
||||||
|
labelFeishuAppSecret: 'App Secret',
|
||||||
|
descFeishuAppSecret: '飞书开放平台应用的 App Secret,仅保存在本地数据库,随配置整体读写',
|
||||||
|
labelFeishuWhitelist: 'open_id 白名单',
|
||||||
|
descFeishuWhitelist: '逗号分隔的 open_id,留空表示放行所有用户',
|
||||||
|
phFeishuWhitelist: 'ou_xxx, ou_yyy',
|
||||||
|
feishuSave: '保存',
|
||||||
|
feishuSaveOk: '飞书集成配置已保存',
|
||||||
|
// 提交校验:enabled=true 时 id/secret 任一为空都拦在写库前
|
||||||
|
feishuValidateIncomplete: '启用飞书集成需先填写 App ID 和 App Secret',
|
||||||
|
|
||||||
|
// ===== 集成:u-claw(ask_uclaw 工具,u-relay commander 协议) =====
|
||||||
|
panelUclaw: 'u-claw 集成',
|
||||||
|
labelUclawRelayUrl: 'Relay 地址',
|
||||||
|
descUclawRelayUrl: 'u-relay 中继地址(wss:// 域名即可,自动补 /ws/commander)',
|
||||||
|
phUclawRelayUrl: 'wss://relay.example.com',
|
||||||
|
labelUclawChannel: 'Channel ID',
|
||||||
|
descUclawChannel: '配对通道,须与 u-claw 配置 ws.channel_id 一致',
|
||||||
|
phUclawChannel: 'u-ppt-dev-01',
|
||||||
|
labelUclawToken: 'Token',
|
||||||
|
descUclawToken: '中继鉴权令牌(与 u-relay 的 U_RELAY_TOKEN 同源)',
|
||||||
|
descUclawHint: '配置后 AI 可用 ask_uclaw 工具把任务委托给 u-claw(常驻 Claude Code 网关)执行,进度实时回传。三项留空 = 未启用。',
|
||||||
|
uclawSave: '保存',
|
||||||
|
uclawSaveOk: 'u-claw 集成配置已保存',
|
||||||
|
uclawValidateIncomplete: '三项(Relay 地址/Channel/Token)需完整填写,或全部留空',
|
||||||
|
|
||||||
// ===== Toast 提示 =====
|
// ===== Toast 提示 =====
|
||||||
toastLoadProviderFail: '加载提供商失败',
|
toastLoadProviderFail: '加载提供商失败',
|
||||||
toastSaveIncomplete: '请填写完整(名称 / Base URL / API Key / 模型)',
|
toastSaveIncomplete: '请填写完整(名称 / Base URL / API Key / 模型)',
|
||||||
|
|||||||
@@ -69,6 +69,13 @@
|
|||||||
@toast="showToast"
|
@toast="showToast"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 集成:飞书渠道凭证(WS 直连飞书开放平台,整体保存到 KV feishu-config)
|
||||||
|
+ u-claw(ask_uclaw 工具,u-relay commander 协议三键配置) -->
|
||||||
|
<template v-else-if="activeCategory === 'integrations'">
|
||||||
|
<FeishuPanel @toast="showToast" />
|
||||||
|
<UclawPanel @toast="showToast" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 高级:autoExecuteMode/logLevel + 连接(暂留待定) -->
|
<!-- 高级:autoExecuteMode/logLevel + 连接(暂留待定) -->
|
||||||
<template v-else-if="activeCategory === 'advanced'">
|
<template v-else-if="activeCategory === 'advanced'">
|
||||||
<AdvancedSection />
|
<AdvancedSection />
|
||||||
@@ -98,6 +105,8 @@ import PerformanceSection from '@/components/settings/PerformanceSection.vue'
|
|||||||
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
|
import AdvancedSection from '@/components/settings/AdvancedSection.vue'
|
||||||
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
|
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
|
||||||
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
|
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
|
||||||
|
import FeishuPanel from '@/components/settings/FeishuPanel.vue'
|
||||||
|
import UclawPanel from '@/components/settings/UclawPanel.vue'
|
||||||
// SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite)
|
// SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite)
|
||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
|
|||||||
Reference in New Issue
Block a user