diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..6954a4cb3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + pull_request: + paths: + - ".github/workflows/ci.yml" + - "src-tauri/**" + - "src-tauri/Cargo.lock" + push: + branches: + - main + paths: + - ".github/workflows/ci.yml" + - "src-tauri/**" + - "src-tauri/Cargo.lock" + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_NET_RETRY: 10 + RUSTUP_MAX_RETRIES: 10 + CARGO_TERM_COLOR: always + +jobs: + windows-openclaw-regression: + name: Windows OpenClaw Regression + runs-on: windows-2022 + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + shared-key: ci-windows-openclaw + cache-on-failure: true + + - name: Run OpenClaw install regression tests + shell: pwsh + run: cargo test -p proxycast-core openclaw_install --manifest-path "src-tauri/Cargo.toml" diff --git a/.gitignore b/.gitignore index 326cda3cf..459ebc280 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,9 @@ __pycache__/ .history docs/prd/ -# Internal roadmap (private) +# Internal roadmap&gongzonghao (private) docs/roadmap/ +docs/gongzonghao/ # Issues tracking (internal use only) .issues/ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2941b910c..dffe9d890 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,37 +1,42 @@ -## ProxyCast v0.83.2 +## ProxyCast v0.84.0 ### ✨ 新功能 -- 新增跨平台应用路径解析模块 `app_paths`,支持 macOS/Windows 目录迁移 -- Agent 事件转换器增强,支持更多事件类型处理 -- Agent 请求工具策略扩展,新增策略规则 -- 流式渲染器增强,新增流诊断工具和 Provider 模型兼容性检测 -- 终端 AI 模式选择器功能增强 -- OpenClaw 页面功能扩展 -- Windows 启动命令模块增强 +- 新增 API 网关层架构,将 useTauri 聚合层拆分为独立的 API 模块(appConfig、serverRuntime、logs、experimentalFeatures、channelsRuntime 等) +- 新增 OpenClaw 安装与运行时集成(openclaw_install、OpenClaw 配置/安装/运行页面) +- 新增环境变量管理服务(environment_service),支持 Shell 导入预览与环境变量覆盖 +- 新增 Harness 状态面板,实时展示 Agent 运行状态 +- 新增 Aster Agent 执行策略与 Web 搜索集成,大幅扩展 Aster 命令能力 +- 新增 General Chat 统一消息桥接层(bridge.ts),支持跨模块消息同步 +- 新增 Poster 主题系统(themes/poster) +- 新增 Agent 流式传输运行时(agentStream、agentRuntime、agentCompat) +- 新增持久化记忆文件系统(durable_memory_fs)与工具 IO 卸载(tool_io_offload) +- 新增 CI 工作流配置(.github/workflows/ci.yml) +- 新增应用更新检测 API(appUpdate) +- 新增 Sub-Agent 调度器测试覆盖 +- 新增 Skill 模型层与技能服务增强 ### 🐛 修复 -- 修复 useMemo 依赖缺失导致的 React Hook 警告 -- 修复 Kiro Provider 凭证处理逻辑 -- 修复心跳服务适配器和心跳命令的稳定性问题 -- 修复日志模块和遥测日志的路径处理 -- 修复数据库模块初始化问题 -- 修复托盘菜单事件处理逻辑 +- 修复 Web 搜索运行时 priority 列表包含无效引擎的问题 +- 修复 ESLint 导入限制违规:将受限导入从 useTauri 迁移到专用 API 模块 +- 修复 SkillsPage 导出非组件函数导致 Fast Refresh 失效的问题 +- 修复 OpenClaw 安装候选路径类型复杂度 clippy 警告 ### 🔧 优化与重构 -- Provider 模型选择器组件重构,提升可维护性 -- ModelSelector 组件优化,增加测试覆盖 -- 通用聊天 useProvider Hook 重构 -- Workbench 页面布局优化 -- 频道设置页面改进 -- 终端工作区组件优化 -- 语音润色模型选择器改进 -- useProjects Hook 优化 +- 重构 General Chat 命令层,统一消息处理流程(+1200 行) +- 重构 Aster Agent 命令层,增强执行策略与自动续写能力(+950 行) +- 重构 Agent 会话存储,支持持久化与恢复 +- 重构事件转换器,增强流式事件处理 +- 重构设置页面 v2 多个子模块(channels、developer、experimental、environment) +- 重构终端 AI 集成与控制器 +- 优化 ESLint 配置,新增命令调用与导入来源限制规则 +- 优化 Skill 服务与默认技能注册 +- 优化 DevBridge 调度器,增强浏览器开发模式兼容性 ### 📦 其他 -- 新增多个组件单元测试(StreamingRenderer、ProviderModelSelector、TerminalAIModeSelector、ModelSelector) -- 新增流诊断和 Provider 模型兼容性工具测试 -- Cargo.lock 依赖更新 +- 更新 Cargo 依赖锁文件 +- 更新 AI 提示词文档(aster-integration、content-creator、governance) +- 更新 AI Agent 开发指南 --- -**完整变更**: v0.83.1...v0.83.2 +**完整变更**: v0.83.2...v0.84.0 diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index ec2b9584a..9ed437758 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -11,6 +11,7 @@ AI Agent 专用文档目录,提供模块级别的详细说明。 ### 核心系统 - `overview.md` - 项目架构概览 +- `governance.md` - **治理第一原则**(新旧并存、迁移收口、禁止回流) - `providers.md` - Provider 系统(OAuth/API Key 认证) - `credential-pool.md` - 凭证池管理(负载均衡、健康检查) - `converter.md` - 协议转换(OpenAI ↔ CW/Claude) @@ -47,6 +48,9 @@ AI Agent 在处理特定模块时,应先阅读对应的 aiprompts 文档: # 处理 Provider 相关任务 → 先读 docs/aiprompts/providers.md +# 处理新旧并存、迁移、重构、架构收口 +→ 先读 docs/aiprompts/governance.md + # 处理凭证池相关任务 → 先读 docs/aiprompts/credential-pool.md diff --git a/docs/aiprompts/aster-integration.md b/docs/aiprompts/aster-integration.md index 2ba715dc0..67b65001f 100644 --- a/docs/aiprompts/aster-integration.md +++ b/docs/aiprompts/aster-integration.md @@ -5,12 +5,14 @@ ProxyCast 已完整集成 aster-rust 框架,包括凭证池桥接。 **后端模块** (`src-tauri/src/agent/`): + - `aster_state.rs` - Agent 状态管理 - `aster_agent.rs` - Agent 包装器 - `event_converter.rs` - 事件转换器 - `credential_bridge.rs` - 凭证池桥接 **Tauri 命令** (`src-tauri/src/commands/aster_agent_cmd.rs`): + - `aster_agent_init` - 初始化 Agent - `aster_agent_configure_provider` - 手动配置 Provider - `aster_agent_configure_from_pool` - 从凭证池配置 Provider(推荐) @@ -67,37 +69,43 @@ ProxyCast 已完整集成 aster-rust 框架,包括凭证池桥接。 ### 支持的凭证类型映射 -| ProxyCast 凭证类型 | Aster Provider | -|-------------------|----------------| -| OpenAIKey | openai | -| ClaudeKey / AnthropicKey | anthropic | -| KiroOAuth | bedrock | -| GeminiOAuth / GeminiApiKey | google | -| VertexKey | gcpvertexai | -| CodexOAuth | codex | -| ClaudeOAuth | anthropic | -| AntigravityOAuth | google | +| ProxyCast 凭证类型 | Aster Provider | +| -------------------------- | -------------- | +| OpenAIKey | openai | +| ClaudeKey / AnthropicKey | anthropic | +| KiroOAuth | bedrock | +| GeminiOAuth / GeminiApiKey | google | +| VertexKey | gcpvertexai | +| CodexOAuth | codex | +| ClaudeOAuth | anthropic | +| AntigravityOAuth | google | ### 使用方式 +> 治理约定:前端业务层不要直接 `invoke('aster_*')`,统一通过 `src/lib/api/agentRuntime.ts` 调用现役 Aster API。 + ```typescript -// 从凭证池配置(推荐) -const status = await invoke('aster_agent_configure_from_pool', { - request: { - provider_type: 'openai', - model_name: 'gpt-4', - }, - session_id: 'my-session', -}); +import { + configureAsterProvider, + sendAsterMessageStream, +} from "@/lib/api/agentRuntime"; + +// 配置 Provider +const status = await configureAsterProvider( + { + provider_name: "openai", + model_name: "gpt-4", + }, + "my-session", +); // 流式对话 -await invoke('aster_agent_chat_stream', { - request: { - message: 'Hello', - session_id: 'my-session', - event_name: 'agent_stream', - }, -}); +await sendAsterMessageStream( + "Hello", + "my-session", + "agent_stream", + "workspace-id", +); ``` ## 相关文档 diff --git a/docs/aiprompts/content-creator.md b/docs/aiprompts/content-creator.md index b958f2407..600e0975c 100644 --- a/docs/aiprompts/content-creator.md +++ b/docs/aiprompts/content-creator.md @@ -82,6 +82,7 @@ export function generateContentCreationPrompt( **重要规则**: + - 标签前:先写一句引导语 - 标签后:写完成总结 - 标签内的内容会实时流式显示在右侧画布 @@ -104,11 +105,12 @@ interface ParseResult { // 解析 AI 响应 export function parseAIResponse( content: string, - isStreaming: boolean + isStreaming: boolean, ): ParseResult; ``` **支持的标签类型**: + - `write_file` - 完整的文件写入 - `pending_write_file` - 流式传输中的文件写入 @@ -128,12 +130,12 @@ interface UseAgentChatOptions { const sendMessage = async (content: string, ...) => { let messageToSend = content; const isFirstMessage = messages.filter(m => m.role === "user").length === 0; - + if (systemPrompt && isFirstMessage) { messageToSend = `${systemPrompt}\n\n---\n\n用户请求:${content}`; } - - await sendAgentMessageStream(messageToSend, ...); + + await sendAsterMessageStream(messageToSend, ...); }; ``` @@ -154,7 +156,7 @@ interface Props { // 解析 write_file 并触发回调 useEffect(() => { if (!onWriteFile) return; - + for (const part of parsedContent.parts) { if (part.type === "write_file" && part.filePath) { onWriteFile(part.content, part.filePath); @@ -170,44 +172,47 @@ useEffect(() => { ```typescript // src/components/agent/chat/index.tsx -const handleWriteFile = useCallback((content: string, fileName: string) => { - // General 主题使用专门的画布 - if (activeTheme === "general") { - setGeneralCanvasState({ - isOpen: true, - contentType: "markdown", - content, - filename: fileName, - }); +const handleWriteFile = useCallback( + (content: string, fileName: string) => { + // General 主题使用专门的画布 + if (activeTheme === "general") { + setGeneralCanvasState({ + isOpen: true, + contentType: "markdown", + content, + filename: fileName, + }); + setLayoutMode("chat-canvas"); + return; + } + + // 其他主题使用 CanvasFactory + setCanvasState(createInitialDocumentState(content)); setLayoutMode("chat-canvas"); - return; - } - - // 其他主题使用 CanvasFactory - setCanvasState(createInitialDocumentState(content)); - setLayoutMode("chat-canvas"); -}, [activeTheme]); + }, + [activeTheme], +); ``` ## 主题类型 -| 主题 | 说明 | 文件体系 | -|------|------|----------| -| general | 通用对话 | 无固定文件 | -| social-media | 社媒内容 | brief.md → draft.md → article.md | -| poster | 图文海报 | brief.md → copywriting.md → design.md | -| music | 歌词曲谱 | song-spec.md → lyrics-draft.md → lyrics-final.txt | -| video | 短视频 | brief.md → outline.md → script.md | -| novel | 小说创作 | brief.md → outline.md → chapter.md | -| document | 办公文档 | brief.md → outline.md → draft.md | +| 主题 | 说明 | 文件体系 | +| ------------ | -------- | ------------------------------------------------- | +| general | 通用对话 | 无固定文件 | +| social-media | 社媒内容 | brief.md → draft.md → article.md | +| poster | 图文海报 | brief.md → copywriting.md → design.md | +| music | 歌词曲谱 | song-spec.md → lyrics-draft.md → lyrics-final.txt | +| video | 短视频 | brief.md → outline.md → script.md | +| novel | 小说创作 | brief.md → outline.md → chapter.md | +| document | 办公文档 | brief.md → outline.md → draft.md | ## 创作模式 -| 模式 | 说明 | AI 行为 | -|------|------|---------| -| guided | 引导模式 | 通过表单逐步引导用户创作 | -| fast | 快速模式 | 收集需求后直接生成完整内容 | -| hybrid | 混合模式 | AI 写框架,用户填核心内容 | +| 模式 | 说明 | AI 行为 | +| --------- | -------- | --------------------------- | +| guided | 引导模式 | 通过表单逐步引导用户创作 | +| fast | 快速模式 | 收集需求后直接生成完整内容 | +| hybrid | 混合模式 | AI 写框架,用户填核心内容 | | framework | 框架模式 | 用户提供框架,AI 按框架填充 | ## 注意事项 @@ -215,6 +220,7 @@ const handleWriteFile = useCallback((content: string, fileName: string) => { ### Aster 框架限制 Aster 框架的 `SessionConfig` 不支持 session 级别的 system prompt,因此采用**消息注入**方案: + - 在第一条用户消息前注入 systemPrompt - 后续消息不再注入(避免重复) diff --git a/docs/aiprompts/governance.md b/docs/aiprompts/governance.md new file mode 100644 index 000000000..b1fa34042 --- /dev/null +++ b/docs/aiprompts/governance.md @@ -0,0 +1,190 @@ +# 治理第一原则 + +## 核心规则 + +**同一种能力,在同一时期只能存在一个继续演进的事实源。** + +其余实现必须被明确归类为: + +- `current`:当前唯一主路径,后续需求只允许往这里收 +- `compat`:兼容层,只允许做委托/适配,不允许继续长新逻辑 +- `deprecated`:废弃层,只允许迁移,不允许新增依赖 +- `dead`:无入口或已停用,尽快删除 + +如果做不到这件事,系统就会持续膨胀而不是持续演进。 + +## 适用场景 + +当出现以下任一情况时,必须先读本文件,再决定是否改代码: + +- 新旧 Hook、新旧组件、新旧命令并存 +- 前端已经有新抽象,Rust 后端仍保留多套入口 +- 新服务已经落地,但旧数据表、旧 DAO、旧旁路查询仍在使用 +- 需求迭代后,AI 倾向继续沿用旧实现 +- 想“先补功能,后面再统一” + +## 强制执行规则 + +### 1. 先盘点,再修改 + +开始改动前,必须先盘点这项能力在 4 层里的实际分布: + +- 入口层:页面、组件、Hook、前端 API 调用 +- 服务层:Tauri 命令、Service、Workflow、事件入口 +- 存储层:表、DAO、Repository、缓存 +- 旁路层:统计、记忆、搜索、审计、报表、任务系统 + +如果没有盘点清楚,禁止直接开始“统一”。 + +### 2. 先定事实源,再谈迁移 + +必须先明确一句话: + +> 从现在开始,这个能力以后只允许向哪里收敛。 + +这个事实源可以是: + +- 一个 Hook +- 一个组件入口 +- 一组 Rust 命令 +- 一个 Service / Repository +- 一组数据表 + +没有唯一事实源,任何迁移都会继续长出新分支。 + +### 3. 兼容层只能做收口,不能做增强 + +兼容层存在的唯一理由是迁移。 + +兼容层允许: + +- 参数转换 +- 返回值适配 +- 委托到新实现 +- 迁移期埋点和告警 + +兼容层禁止: + +- 新增业务逻辑 +- 新增状态来源 +- 新增独立存储 +- 新增旁路能力 + +一旦兼容层承载新需求,它就不再是兼容层,而是新的分叉点。 + +### 4. 禁止回流,优先于“推荐新方案” + +治理不能靠口头约定,必须靠守卫机制。 + +至少建立以下一种或多种守卫: + +- ESLint / 静态规则禁止 import 旧入口 +- Rust 对旧命令输出 `warn` 与调用统计 +- CI 阻止新代码继续引用废弃路径 +- 脚本扫描旧表、旧 DAO、旧命令、旧 Hook 的新增使用点 + +原则只有一句: + +**不是鼓励走新路,而是封住老路。** + +### 5. 主链路和旁路必须一起治理 + +如果只迁: + +- 页面 +- Hook +- 主命令 + +但没有迁: + +- 统计查询 +- 记忆系统 +- 搜索召回 +- 报表分析 + +那么旧表、旧命令、旧 DAO 永远删不掉。 + +治理完成的标准不是“页面能跑”,而是“系统生态都已收口”。 + +### 6. 删除必须有退出条件 + +每一个 `compat` 或 `deprecated` 路径,都必须有明确退出条件: + +- 哪些调用迁完即可删 +- 哪个版本必须删除 +- 删除前要验证哪些指标 + +没有退出条件的兼容层,最终一定会常驻。 + +## 禁止事项 + +出现以下行为,视为违反治理原则: + +- 在旧 Hook / 旧组件 / 旧命令上继续叠加新需求 +- 新增与现役路径平级的第二套实现 +- 前端迁了新入口,但 Rust 仍保留旧主逻辑继续演进 +- 已有统一 Service,却继续让命令层各自写 SQL +- 主链路改到新表,旁路系统仍直接查旧表 +- 看到“旧代码还能用”,就继续让 AI 沿旧上下文生成 + +## 推荐工作流 + +### 第一步:出迁移地图 + +至少列清楚: + +- 当前主路径 +- 兼容路径 +- 废弃路径 +- 无入口路径 + +### 第二步:写一句事实源声明 + +例如: + +> 聊天能力后续统一收敛到 `useUnifiedChat + chat_* + ChatDao`。 + +### 第三步:让旧路径变成壳 + +旧入口不再承载真正逻辑,只负责: + +- 兼容参数 +- 委托新实现 +- 输出告警 + +### 第四步:加守卫 + +至少加一条能自动失败的规则,阻止旧路径继续增长。 + +### 第五步:迁旁路 + +确认统计、记忆、搜索、报表等不再依赖旧实现。 + +### 第六步:删除 + +只有当新增依赖被封住、调用量清零、旁路迁完,才允许删旧路径。 + +## Proxycast 中的典型判断方式 + +以聊天系统为例,遇到新旧并存时,必须同时问这几个问题: + +- 前端唯一入口是不是 `useUnifiedChat`,还是 `useChat` / `useAgentChat` 还在继续长逻辑? +- Rust 唯一入口是不是 `chat_*`,还是 `general_chat_*` / `agent_*` / `aster_agent_*` 还在平行演进? +- 数据事实源是不是同一组表 / 同一套 Repository,还是还在同时写 `agent_*` 与 `general_chat_*`? +- 统计、记忆等旁路是不是已经切到新路径,还是还在读旧表? + +只要其中任意一个答案是否定的,就说明治理还没完成。 + +## AI 执行要求 + +未来 AI 在处理“新旧并存、迁移、重构、统一”类任务时,默认遵守以下要求: + +1. 不允许直接在旧路径上继续扩展新功能,除非用户明确要求做兼容补丁。 +2. 必须优先识别唯一事实源,并围绕事实源收口,而不是继续新增平级实现。 +3. 必须显式说明当前改动属于 `current`、`compat`、`deprecated`、`dead` 中哪一类。 +4. 如果发现主链路与旁路系统割裂,必须指出,不得假装治理已经完成。 +5. 如果无法在本次改动中完成收口,至少要建立守卫,阻止问题继续扩散。 + +## 一句话总结 + +**治理不是继续写一个“更新的版本”,而是让系统以后只能向一个版本收敛。** diff --git a/docs/develop/ai-agent-development-guide.md b/docs/develop/ai-agent-development-guide.md index 39c043df4..b99a729dc 100644 --- a/docs/develop/ai-agent-development-guide.md +++ b/docs/develop/ai-agent-development-guide.md @@ -91,13 +91,13 @@ pub struct AgentConstraints { pub trait ToolExecutor: Send + Sync { /// 执行工具 async fn execute(&self, input: ToolInput) -> Result; - + /// 工具名称 fn name(&self) -> &str; - + /// 工具描述 fn description(&self) -> &str; - + /// 参数 Schema fn parameters_schema(&self) -> serde_json::Value; } @@ -135,19 +135,19 @@ impl AgentRuntime { /// 执行 Agent 循环 pub async fn run(&mut self, user_input: &str) -> Result { self.messages.push(Message::user(user_input)); - + loop { // 1. 调用 LLM let response = self.provider.chat(&self.messages).await?; - + // 2. 检查是否有工具调用 if let Some(tool_calls) = response.tool_calls { // 3. 执行工具 let results = self.execute_tools(tool_calls).await?; - + // 4. 将结果加入对话 self.messages.extend(results); - + // 5. 检查约束 if self.check_constraints().is_err() { break; @@ -161,7 +161,6 @@ impl AgentRuntime { } ``` - --- ## 三、消息格式与协议转换 @@ -204,10 +203,10 @@ pub struct ToolCall { pub trait ProtocolConverter { /// 转换为 Provider 格式 fn to_provider(&self, messages: &[Message]) -> ProviderRequest; - + /// 从 Provider 格式转换 fn from_provider(&self, response: ProviderResponse) -> Message; - + /// 转换工具定义 fn convert_tools(&self, tools: &[ToolDefinition]) -> Vec; } @@ -215,7 +214,7 @@ pub trait ProtocolConverter { /// OpenAI 格式转换器 pub struct OpenAIConverter; -/// Claude 格式转换器 +/// Claude 格式转换器 pub struct ClaudeConverter; /// Gemini 格式转换器 @@ -241,11 +240,11 @@ impl StreamProcessor { /// 处理流式数据块 pub fn process_chunk(&mut self, chunk: &str) -> Vec { let mut events = Vec::new(); - + // 解析数据块 // 处理文本、工具调用等 // 生成事件 - + events } } @@ -266,14 +265,14 @@ pub enum StreamEvent { ### 4.1 内置工具 -| 工具 | 描述 | 参数 | -|------|------|------| -| `read_file` | 读取文件内容 | `path: string` | -| `write_file` | 写入文件 | `path: string, content: string` | -| `list_directory` | 列出目录内容 | `path: string, pattern?: string` | -| `search_files` | 搜索文件内容 | `pattern: string, path?: string` | -| `shell_command` | 执行 Shell 命令 | `command: string, cwd?: string` | -| `http_request` | 发送 HTTP 请求 | `url: string, method: string, ...` | +| 工具 | 描述 | 参数 | +| ---------------- | --------------- | ---------------------------------- | +| `read_file` | 读取文件内容 | `path: string` | +| `write_file` | 写入文件 | `path: string, content: string` | +| `list_directory` | 列出目录内容 | `path: string, pattern?: string` | +| `search_files` | 搜索文件内容 | `pattern: string, path?: string` | +| `shell_command` | 执行 Shell 命令 | `command: string, cwd?: string` | +| `http_request` | 发送 HTTP 请求 | `url: string, method: string, ...` | ### 4.2 工具注册机制 @@ -293,12 +292,12 @@ impl ToolRegistry { self.register(Box::new(ShellCommandTool::new())); // ... } - + /// 注册自定义工具 pub fn register(&mut self, tool: Box) { self.tools.insert(tool.name().to_string(), tool); } - + /// 获取工具 pub fn get(&self, name: &str) -> Option<&dyn ToolExecutor> { self.tools.get(name).map(|t| t.as_ref()) @@ -329,7 +328,7 @@ impl SecurityPolicy { // 检查路径是否在允许范围内 // 防止路径遍历攻击 } - + /// 检查命令是否允许 pub fn check_command(&self, command: &str) -> Result<(), SecurityError> { // 检查命令是否在黑名单中 @@ -383,18 +382,20 @@ pub struct TokenUsage { ### 5.2 前端状态同步 ```typescript -// src/stores/agentStore.ts +// 历史示例:现代实现请优先使用 +// `src/lib/api/agentRuntime.ts` + `src/lib/api/agentStream.ts` +// 不要在业务层直接 invoke Agent/Aster 命令。 interface AgentState { // Agent 定义 agents: AgentDefinition[]; currentAgent: string | null; - + // 运行状态 isRunning: boolean; phase: AgentPhase; messages: Message[]; - + // 统计 tokenUsage: TokenUsage; toolCallCount: number; @@ -406,26 +407,25 @@ export const useAgentStore = create((set, get) => ({ agents: [], currentAgent: null, isRunning: false, - phase: 'idle', + phase: "idle", messages: [], tokenUsage: { input: 0, output: 0 }, toolCallCount: 0, - + // Actions startAgent: async (agentId: string, input: string) => { - set({ isRunning: true, phase: 'thinking' }); + set({ isRunning: true, phase: "thinking" }); // 调用 Tauri 命令 - await invoke('run_agent', { agentId, input }); + await invoke("run_agent", { agentId, input }); }, - + stopAgent: async () => { - await invoke('stop_agent'); - set({ isRunning: false, phase: 'idle' }); + await invoke("stop_agent"); + set({ isRunning: false, phase: "idle" }); }, })); ``` - --- ## 六、开发路线图 @@ -512,4 +512,4 @@ agent/ --- -*本文档定义了 ProxyCast AI Agent 功能的架构设计,随着开发进展会持续更新。* +_本文档定义了 ProxyCast AI Agent 功能的架构设计,随着开发进展会持续更新。_ diff --git a/eslint.config.js b/eslint.config.js index 4e6c69b1c..9cd0afc35 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,723 @@ import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import globals from "globals"; +const legacyChatRestrictedPatterns = [ + "@/components/chat", + "@/components/chat/*", + "@/components/chat/**", + "**/components/chat", + "**/components/chat/*", + "**/components/chat/**", +]; + +const generalChatRestrictedPaths = [ + { + name: "@/components/general-chat", + importNames: ["useChat"], + message: + "general-chat 的 useChat 属于旧路径,请优先使用 @/hooks/useUnifiedChat 或当前现役聊天入口。", + }, + { + name: "@/components/general-chat", + importNames: ["useSession", "useStreaming"], + message: + "general-chat 当前属于兼容链路,请不要在新代码中继续引入页面入口或旧 Hook。", + }, + { + name: "@/components/general-chat", + importNames: ["GeneralChatPage"], + message: + "general-chat 当前属于兼容链路,请不要在新代码中继续引入页面入口或旧 Hook。", + }, + { + name: "@/components/general-chat/hooks", + importNames: ["useChat"], + message: + "general-chat/hooks/useChat 属于旧路径,请优先使用 @/hooks/useUnifiedChat 或当前现役聊天入口。", + }, + { + name: "@/components/general-chat/hooks", + importNames: ["useSession", "useStreaming"], + message: + "general-chat/hooks 下的 useSession/useStreaming 属于兼容实现,请优先接入统一对话链路。", + }, + { + name: "@/components/general-chat/hooks/useChat", + message: + "general-chat/hooks/useChat 属于旧路径,请优先使用 @/hooks/useUnifiedChat 或当前现役聊天入口。", + }, + { + name: "@/components/general-chat/GeneralChatPage", + message: + "GeneralChatPage 属于旧版 general-chat 入口,请不要在新代码中继续引入。", + }, + { + name: "@/components/general-chat/hooks/useSession", + message: + "general-chat/hooks/useSession 属于旧版会话兼容 Hook,请优先接入统一对话链路。", + }, + { + name: "@/components/general-chat/hooks/useStreaming", + message: + "general-chat/hooks/useStreaming 依赖旧流事件协议,请优先接入统一对话链路。", + }, + { + name: "@/components/general-chat/canvas", + message: + "请不要直接深导入 general-chat/canvas;跨模块复用请改用 @/components/general-chat/bridge。", + }, + { + name: "@/components/general-chat/types", + message: + "请不要直接深导入 general-chat/types;跨模块复用请改用 @/components/general-chat/bridge 或现役共享类型。", + }, + { + name: "@/components/general-chat/store/useGeneralChatStore", + message: + "请不要直接深导入 general-chat 内部 store;如需兼容桥接,请显式放在 compat 层。", + }, + { + name: "@/lib/api/generalChatCompat", + message: + "generalChatCompat 属于兼容网关,请仅在 general-chat store 中消费,避免 compat 逻辑再次向业务层扩散。", + }, + { + name: "@/lib/api/agent", + message: + "agent.ts 现在只是兼容门面;新代码请改用 @/lib/api/agentRuntime、@/lib/api/agentStream 或 @/lib/api/agentCompat。", + }, + { + name: "@/lib/terminal-api", + message: "terminal-api 现在只是兼容门面;新代码请改用 @/lib/api/terminal。", + }, + { + name: "@/hooks/useTauri", + importNames: [ + "startServer", + "stopServer", + "getServerStatus", + "getServerDiagnostics", + "getLogStorageDiagnostics", + "exportSupportBundle", + "getWindowsStartupDiagnostics", + "ServerStatus", + "ServerDiagnostics", + "LogStorageDiagnostics", + "SupportBundleExportResult", + "WindowsStartupDiagnostics", + ], + message: + "server/diagnostics 相关能力已迁移到 @/lib/api/serverRuntime,请不要继续从 useTauri 聚合层引入。", + }, + { + name: "@/hooks/useTauri", + importNames: [ + "getLogs", + "getPersistedLogsTail", + "clearLogs", + "clearDiagnosticLogHistory", + "LogEntry", + ], + message: + "日志相关能力已迁移到 @/lib/api/logs,请不要继续从 useTauri 聚合层引入。", + }, + { + name: "@/hooks/useTauri", + importNames: [ + "getConfig", + "saveConfig", + "getEnvironmentPreview", + "getDefaultProvider", + "setDefaultProvider", + "updateProviderEnvVars", + "Config", + "EnvironmentPreview", + ], + message: + "配置/环境预览相关能力已迁移到 @/lib/api/appConfig,请不要继续从 useTauri 聚合层引入。", + }, + { + name: "@/hooks/useTauri", + importNames: [ + "ChannelsConfig", + "GatewayConfig", + "TelegramBotConfig", + "DiscordBotConfig", + "FeishuBotConfig", + "GatewayChannelStatusResponse", + "TelegramProbeResult", + "FeishuProbeResult", + "DiscordProbeResult", + "GatewayTunnelStatus", + "GatewayTunnelProbeResult", + "CloudflaredInstallStatus", + "CloudflaredInstallResult", + "GatewayTunnelCreateResponse", + "GatewayTunnelSyncWebhookResponse", + "gatewayChannelStart", + "gatewayChannelStop", + "gatewayChannelStatus", + "telegramChannelProbe", + "feishuChannelProbe", + "discordChannelProbe", + "gatewayTunnelProbe", + "gatewayTunnelDetectCloudflared", + "gatewayTunnelInstallCloudflared", + "gatewayTunnelCreate", + "gatewayTunnelStart", + "gatewayTunnelStop", + "gatewayTunnelRestart", + "gatewayTunnelStatus", + "gatewayTunnelSyncWebhookUrl", + ], + message: + "channels/gateway 相关能力已迁移到 @/lib/api/channelsRuntime,请不要继续从 useTauri 聚合层引入。", + }, + { + name: "@/hooks/useTauri", + importNames: [ + "getExperimentalConfig", + "saveExperimentalConfig", + "validateShortcut", + "updateScreenshotShortcut", + "ExperimentalFeatures", + "SmartInputConfig", + ], + message: + "实验室配置/截图快捷键相关能力已迁移到 @/lib/api/experimentalFeatures,请不要继续从 useTauri 聚合层引入。", + }, + { + name: "@/hooks/useTauri", + importNames: [ + "getMemoryOverview", + "getMemoryEffectiveSources", + "getMemoryAutoIndex", + "toggleMemoryAuto", + "updateMemoryAutoNote", + "MemoryOverviewResponse", + "EffectiveMemorySourcesResponse", + "AutoMemoryIndexResponse", + "MemoryAutoConfig", + "MemoryAutoToggleResponse", + "MemoryConfig", + "MemoryProfileConfig", + "MemoryResolveConfig", + "MemorySourcesConfig", + ], + message: + "记忆运行时相关能力已迁移到 @/lib/api/memoryRuntime,请不要继续从 useTauri 聚合层引入。", + }, + { + name: "@/hooks/useTauri", + importNames: ["getAvailableModels", "ModelInfo"], + message: + "模型列表查询已迁移到 @/lib/api/modelCatalog,请不要继续从 useTauri 聚合层引入。", + }, + { + name: "@/hooks/useTauri", + importNames: [ + "getUsageStats", + "getModelUsageRanking", + "getDailyUsageTrends", + "UsageStatsResponse", + "ModelUsage", + "DailyUsage", + ], + message: + "使用统计查询已迁移到 @/lib/api/usageStats,请不要继续从 useTauri 聚合层引入。", + }, + { + name: "@/hooks/useTauri", + importNames: [ + "reloadCredentials", + "refreshKiroToken", + "getKiroCredentials", + "getEnvVariables", + "getTokenFileHash", + "checkAndReloadCredentials", + "getGeminiCredentials", + "reloadGeminiCredentials", + "refreshGeminiToken", + "getGeminiEnvVariables", + "getGeminiTokenFileHash", + "checkAndReloadGeminiCredentials", + "getQwenCredentials", + "reloadQwenCredentials", + "refreshQwenToken", + "getQwenEnvVariables", + "getQwenTokenFileHash", + "checkAndReloadQwenCredentials", + "getOpenAICustomStatus", + "setOpenAICustomConfig", + "getClaudeCustomStatus", + "setClaudeCustomConfig", + "KiroCredentialStatus", + "EnvVariable", + "CheckResult", + "GeminiCredentialStatus", + "QwenCredentialStatus", + "OpenAICustomStatus", + "ClaudeCustomStatus", + "CredentialEntry", + "GeminiApiKeyEntry", + "VertexApiKeyEntry", + "VertexModelAlias", + "AmpConfig", + "AmpModelMapping", + ], + message: + "provider 凭证/自定义状态相关能力已迁移到 @/lib/api/providerRuntime,请不要继续从 useTauri 聚合层引入。", + }, + { + name: "@/hooks/useTauri", + importNames: ["testApi", "TestResult", "getNetworkInfo", "NetworkInfo"], + message: + "API 测试/网络信息相关能力已迁移到 @/lib/api/serverTools,请不要继续从 useTauri 聚合层引入。", + }, + { + name: "@/stores/agentStore", + message: + "agentStore 属于遗留状态容器,请改用现役 useAgentChat / useAsterAgentChat 链路。", + }, + { + name: "@/stores", + importNames: [ + "useAgentStore", + "useAgentMessages", + "useAgentStreaming", + "useAgentSessions", + "usePendingActions", + ], + message: + "agentStore 相关导出属于遗留状态容器,请改用现役 useAgentChat / useAsterAgentChat 链路。", + }, + { + name: "@/lib/api/agentCompat", + message: + "agentCompat 属于遗留兼容层,请仅在历史桥接或兼容测试中使用,避免继续向业务层扩散。", + }, + { + name: "@/lib/api/agent", + importNames: ["sendAgentMessage", "sendAgentMessageStream"], + message: "旧 Agent 发送 API 已废弃,请优先使用 sendAsterMessageStream。", + }, + { + name: "@/lib/api/agent", + importNames: [ + "initasterAgent", + "getasterAgentStatus", + "resetasterAgent", + "createasterSession", + "sendasterMessage", + "listasterProviders", + ], + message: + "旧 aster 命名 API 已废弃,请使用现役 Aster API 或 Provider 配置流程。", + }, +]; + +const generalChatRestrictedPathsWithoutPage = generalChatRestrictedPaths.filter( + (entry) => + !( + entry.name === "@/components/general-chat" && + Array.isArray(entry.importNames) && + entry.importNames.includes("GeneralChatPage") + ), +); + +const generalChatRestrictedPathsWithoutPageAndStore = + generalChatRestrictedPathsWithoutPage.filter( + (entry) => + entry.name !== "@/components/general-chat/store/useGeneralChatStore", + ); + +const generalChatRestrictedPathsWithoutCompatApi = + generalChatRestrictedPaths.filter( + (entry) => entry.name !== "@/lib/api/generalChatCompat", + ); + +const createLegacyChatImportRule = (paths) => [ + "error", + { + paths, + patterns: [ + { + group: legacyChatRestrictedPatterns, + message: + "components/chat 为遗留聊天模块,禁止新增依赖;请优先使用现役聊天入口。", + }, + ], + }, +]; + +const generalChatCompatCommandSelectors = [ + "general_chat_get_session", + "general_chat_list_sessions", + "general_chat_create_session", + "general_chat_delete_session", + "general_chat_rename_session", + "general_chat_get_messages", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "general_chat_* compat 命令只允许集中放在 src/lib/api/generalChatCompat.ts,禁止在业务层直接扩散。", +})); + +const agentRuntimeCommandSelectors = [ + "agent_start_process", + "agent_stop_process", + "agent_get_process_status", + "agent_create_session", + "agent_list_sessions", + "agent_get_session", + "agent_delete_session", + "agent_get_session_messages", + "agent_rename_session", + "agent_generate_title", + "agent_terminal_command_response", + "agent_term_scrollback_response", + "aster_agent_init", + "aster_agent_status", + "aster_agent_chat_stream", + "aster_agent_stop", + "aster_agent_confirm", + "aster_agent_submit_elicitation_response", + "aster_agent_configure_provider", + "aster_agent_reset", + "aster_session_create", + "aster_session_list", + "aster_session_get", + "aster_session_rename", + "aster_session_set_execution_strategy", + "aster_session_delete", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "agent_/aster_ 命令只允许集中放在 `src/lib/api/agentRuntime.ts` / `src/lib/api/agentCompat.ts` 或历史兼容 store 中,禁止在其他业务模块直接扩散。", +})); + +const projectGatewayCommandSelectors = [ + "workspace_create", + "workspace_get_projects_root", + "workspace_resolve_project_path", + "workspace_list", + "workspace_get_default", + "workspace_ensure_default_ready", + "workspace_set_default", + "workspace_get_by_path", + "workspace_get", + "workspace_update", + "workspace_delete", + "workspace_ensure_ready", + "get_or_create_default_project", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "workspace/project 相关后端命令请统一通过 `src/lib/api/project.ts` 暴露的网关函数调用,避免业务层继续拼接命令名并扩散兼容逻辑。", +})); + +const materialGatewayCommandSelectors = [ + "list_materials", + "get_material_count", + "upload_material", + "update_material", + "delete_material", + "get_material_content", + "import_material_from_url", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "素材相关后端命令请统一通过 `src/lib/api/materials.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", +})); + +const templateGatewayCommandSelectors = [ + "list_templates", + "get_default_template", + "create_template", + "update_template", + "delete_template", + "set_default_template", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "模板相关后端命令请统一通过 `src/lib/api/templates.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", +})); + +const personaGatewayCommandSelectors = [ + "list_personas", + "get_default_persona", + "create_persona", + "update_persona", + "delete_persona", + "set_default_persona", + "list_persona_templates", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "人设相关后端命令请统一通过 `src/lib/api/personas.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", +})); + +const brandPersonaGatewayCommandSelectors = [ + "get_brand_persona", + "get_brand_extension", + "save_brand_extension", + "update_brand_extension", + "delete_brand_extension", + "list_brand_persona_templates", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "品牌人设相关后端命令请统一通过 `src/lib/api/brandPersona.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", +})); + +const posterMaterialGatewayCommandSelectors = [ + "get_poster_material", + "create_poster_metadata", + "update_poster_metadata", + "delete_poster_metadata", + "list_by_image_category", + "list_by_layout_category", + "list_by_mood", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "海报素材相关后端命令请统一通过 `src/lib/api/posterMaterials.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", +})); + +const subAgentSchedulerCommandSelectors = [ + "execute_subagent_tasks", + "cancel_subagent_tasks", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "SubAgent 调度相关后端命令请统一通过 `src/lib/api/subAgentScheduler.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", +})); + +const fileSystemCommandSelectors = [ + "reveal_in_finder", + "open_with_default_app", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "文件打开/定位相关后端命令请统一通过 `src/lib/api/fileSystem.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", +})); + +const pluginGatewayCommandSelectors = [ + "get_plugin_status", + "get_plugins", + "list_installed_plugins", + "list_plugin_tasks", + "get_plugin_queue_stats", + "get_plugin_task", + "enable_plugin", + "disable_plugin", + "reload_plugins", + "unload_plugin", + "uninstall_plugin", + "cancel_plugin_task", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "插件运行态/管理相关后端命令请统一通过 `src/lib/api/plugins.ts` 暴露的网关函数调用,避免在 Hook / 组件中继续直接拼接命令名。", +})); + +const fileBrowserCommandSelectors = [ + "list_dir", + "read_file_preview_cmd", + "create_file", + "create_directory", + "rename_file", + "delete_file", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "文件浏览/文件树相关后端命令请统一通过 `src/lib/api/fileBrowser.ts` 暴露的网关函数调用,避免在组件中继续直接拼接命令名。", +})); + +const appUpdateCommandSelectors = [ + "check_for_updates", + "download_update", + "close_update_window", + "dismiss_update_notification", + "record_update_notification_action", + "remind_update_later", + "skip_update_version", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "更新检查/更新提醒相关后端命令请统一通过 `src/lib/api/appUpdate.ts` 暴露的网关函数调用,避免在页面中继续直接拼接命令名。", +})); + +const screenshotChatCommandSelectors = [ + "send_screenshot_chat", + "close_screenshot_chat_window", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "截图聊天窗口相关后端命令请统一通过 `src/lib/api/screenshotChat.ts` 暴露的网关函数调用,避免在页面/组件中继续直接拼接命令名。", +})); + +const systemSupportCommandSelectors = [ + "show_notification", + "auto_fix_configuration", + "report_frontend_crash", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "系统支持类后端命令请统一通过对应 API 网关(`src/lib/api/notification.ts` / `autoFix.ts` / `frontendCrash.ts`)调用,避免在 lib / hook 中继续直接拼接命令名。", +})); + +const terminalCommandSelectors = [ + "terminal_create_session", + "terminal_write", + "terminal_resize", + "terminal_close", + "terminal_list_sessions", + "terminal_get_session", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "终端相关后端命令请统一通过 `src/lib/api/terminal.ts` 暴露的网关函数调用,避免在其他模块中继续直接拼接命令名。", +})); + +const serverRuntimeCommandSelectors = [ + "start_server", + "stop_server", + "get_server_status", + "get_server_diagnostics", + "get_log_storage_diagnostics", + "export_support_bundle", + "get_windows_startup_diagnostics", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "服务控制/诊断相关后端命令请统一通过 `src/lib/api/serverRuntime.ts` 暴露的网关函数调用,避免继续在其他模块中直接拼接命令名。", +})); + +const logCommandSelectors = [ + "get_logs", + "get_persisted_logs_tail", + "clear_logs", + "clear_diagnostic_log_history", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "日志相关后端命令请统一通过 `src/lib/api/logs.ts` 暴露的网关函数调用,避免继续在其他模块中直接拼接命令名。", +})); + +const appConfigCommandSelectors = [ + "get_config", + "save_config", + "get_environment_preview", + "get_default_provider", + "set_default_provider", + "update_provider_env_vars", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "配置/环境预览相关后端命令请统一通过 `src/lib/api/appConfig.ts` 暴露的网关函数调用,避免继续在其他模块中直接拼接命令名。", +})); + +const channelsRuntimeCommandSelectors = [ + "gateway_channel_start", + "gateway_channel_stop", + "gateway_channel_status", + "telegram_channel_probe", + "feishu_channel_probe", + "discord_channel_probe", + "gateway_tunnel_probe", + "gateway_tunnel_detect_cloudflared", + "gateway_tunnel_install_cloudflared", + "gateway_tunnel_create", + "gateway_tunnel_start", + "gateway_tunnel_stop", + "gateway_tunnel_restart", + "gateway_tunnel_status", + "gateway_tunnel_sync_webhook_url", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "channels/gateway 相关后端命令请统一通过 `src/lib/api/channelsRuntime.ts` 暴露的网关函数调用,避免继续在其他模块中直接拼接命令名。", +})); + +const experimentalFeaturesCommandSelectors = [ + "get_experimental_config", + "save_experimental_config", + "validate_shortcut", + "update_screenshot_shortcut", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "实验室配置/截图快捷键相关后端命令请统一通过 `src/lib/api/experimentalFeatures.ts` 暴露的网关函数调用,避免继续在其他模块中直接拼接命令名。", +})); + +const memoryRuntimeCommandSelectors = [ + "get_conversation_memory_overview", + "memory_get_effective_sources", + "memory_get_auto_index", + "memory_toggle_auto", + "memory_update_auto_note", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "记忆运行时相关后端命令请统一通过 `src/lib/api/memoryRuntime.ts` 暴露的网关函数调用,避免继续在其他模块中直接拼接命令名。", +})); + +const modelCatalogCommandSelectors = ["get_available_models"].map( + (command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "模型列表查询命令请统一通过 `src/lib/api/modelCatalog.ts` 暴露的网关函数调用,避免继续在其他模块中直接拼接命令名。", + }), +); + +const usageStatsCommandSelectors = [ + "get_usage_stats", + "get_model_usage_ranking", + "get_daily_usage_trends", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "使用统计命令请统一通过 `src/lib/api/usageStats.ts` 暴露的网关函数调用,避免继续在其他模块中直接拼接命令名。", +})); + +const providerRuntimeCommandSelectors = [ + "refresh_kiro_token", + "reload_credentials", + "get_kiro_credentials", + "get_env_variables", + "get_token_file_hash", + "check_and_reload_credentials", + "get_gemini_credentials", + "reload_gemini_credentials", + "refresh_gemini_token", + "get_gemini_env_variables", + "get_gemini_token_file_hash", + "check_and_reload_gemini_credentials", + "get_qwen_credentials", + "reload_qwen_credentials", + "refresh_qwen_token", + "get_qwen_env_variables", + "get_qwen_token_file_hash", + "check_and_reload_qwen_credentials", + "get_openai_custom_status", + "set_openai_custom_config", + "get_claude_custom_status", + "set_claude_custom_config", +].map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "provider 凭证/自定义状态相关后端命令请统一通过 `src/lib/api/providerRuntime.ts` 暴露的网关函数调用,避免继续在其他模块中直接拼接命令名。", +})); + +const serverToolsCommandSelectors = ["test_api", "get_network_info"].map( + (command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "API 测试/网络信息相关后端命令请统一通过 `src/lib/api/serverTools.ts` 暴露的网关函数调用,避免继续在其他模块中直接拼接命令名。", + }), +); + export default [ { ignores: ["dist", "src-tauri", "node_modules"] }, { @@ -68,8 +785,129 @@ export default [ ], }, ], - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }], + "no-restricted-imports": createLegacyChatImportRule( + generalChatRestrictedPaths, + ), + "no-restricted-syntax": [ + "error", + ...generalChatCompatCommandSelectors, + ...agentRuntimeCommandSelectors, + ...projectGatewayCommandSelectors, + ...materialGatewayCommandSelectors, + ...templateGatewayCommandSelectors, + ...personaGatewayCommandSelectors, + ...brandPersonaGatewayCommandSelectors, + ...posterMaterialGatewayCommandSelectors, + ...subAgentSchedulerCommandSelectors, + ...fileSystemCommandSelectors, + ...pluginGatewayCommandSelectors, + ...fileBrowserCommandSelectors, + ...appUpdateCommandSelectors, + ...screenshotChatCommandSelectors, + ...systemSupportCommandSelectors, + ...terminalCommandSelectors, + ...serverRuntimeCommandSelectors, + ...logCommandSelectors, + ...appConfigCommandSelectors, + ...channelsRuntimeCommandSelectors, + ...experimentalFeaturesCommandSelectors, + ...memoryRuntimeCommandSelectors, + ...modelCatalogCommandSelectors, + ...usageStatsCommandSelectors, + ...providerRuntimeCommandSelectors, + ...serverToolsCommandSelectors, + ], + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], "@typescript-eslint/no-explicit-any": "off", }, }, + { + files: ["src/components/chat/ChatPage.tsx"], + rules: { + "no-restricted-imports": createLegacyChatImportRule( + generalChatRestrictedPathsWithoutPage, + ), + }, + }, + { + files: ["src/components/chat/hooks/useChat.ts"], + rules: { + "no-restricted-imports": createLegacyChatImportRule( + generalChatRestrictedPathsWithoutPageAndStore, + ), + }, + }, + { + files: ["src/components/general-chat/store/useGeneralChatStore.ts"], + rules: { + "no-restricted-imports": createLegacyChatImportRule( + generalChatRestrictedPathsWithoutCompatApi, + ), + }, + }, + { + files: ["src/lib/api/generalChatCompat.ts"], + rules: { + "no-restricted-syntax": "off", + }, + }, + { + files: ["src/lib/api/appConfig.ts"], + rules: { + "no-restricted-imports": "off", + }, + }, + { + files: [ + "src/lib/api/agentRuntime.ts", + "src/lib/api/agentCompat.ts", + "src/lib/api/project.ts", + "src/lib/api/materials.ts", + "src/lib/api/templates.ts", + "src/lib/api/personas.ts", + "src/lib/api/brandPersona.ts", + "src/lib/api/posterMaterials.ts", + "src/lib/api/subAgentScheduler.ts", + "src/lib/api/fileSystem.ts", + "src/lib/api/plugins.ts", + "src/lib/api/pluginUI.ts", + "src/lib/api/fileBrowser.ts", + "src/lib/api/appUpdate.ts", + "src/lib/api/screenshotChat.ts", + "src/lib/api/notification.ts", + "src/lib/api/autoFix.ts", + "src/lib/api/frontendCrash.ts", + "src/lib/api/terminal.ts", + "src/lib/api/serverRuntime.ts", + "src/lib/api/logs.ts", + "src/lib/api/appConfig.ts", + "src/lib/api/channelsRuntime.ts", + "src/lib/api/experimentalFeatures.ts", + "src/lib/api/memoryRuntime.ts", + "src/lib/api/modelCatalog.ts", + "src/lib/api/usageStats.ts", + "src/lib/api/providerRuntime.ts", + "src/lib/api/serverTools.ts", + ], + rules: { + "no-restricted-syntax": "off", + }, + }, + { + files: [ + "src/lib/api/channelsRuntime.ts", + "src/lib/api/experimentalFeatures.ts", + "src/lib/api/memoryRuntime.ts", + ], + rules: { + "no-restricted-imports": "off", + }, + }, ]; diff --git a/package.json b/package.json index b141ca220..b0520e6ce 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.83.2", + "version": "0.84.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c053a8364..5dc29614e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -29,7 +29,7 @@ dependencies = [ "actix-rt", "actix-service", "actix-utils", - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "bytestring", "derive_more 2.1.1", @@ -246,7 +246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "libc", ] @@ -306,7 +306,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -317,14 +317,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arboard" @@ -348,9 +348,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" dependencies = [ "rustversion", ] @@ -369,7 +369,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.16.0" +version = "0.17.0" dependencies = [ "ahash", "anyhow", @@ -461,7 +461,7 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.16.0" +version = "0.17.0" dependencies = [ "serde", "serde_json", @@ -493,9 +493,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +checksum = "7d67d43201f4d20c78bcda740c142ca52482d81da80681533d33bf3f0596c8e2" dependencies = [ "compression-codecs", "compression-core", @@ -572,7 +572,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -612,7 +612,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -629,7 +629,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -731,9 +731,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "zeroize", @@ -741,9 +741,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.0" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" dependencies = [ "cc", "cmake", @@ -916,9 +916,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.11" +version = "1.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52eec3db979d18cb807fc1070961cc51d87d069abe9ab57917769687368a8c6c" +checksum = "5cc50d0f63e714784b84223abd7abbc8577de8c35d699e0edd19f0a88a08ae13" dependencies = [ "futures-util", "pin-project-lite", @@ -1085,9 +1085,9 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.13" +version = "0.60.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa" dependencies = [ "xmlparser", ] @@ -1287,7 +1287,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cexpr", "clang-sys", "itertools 0.12.1", @@ -1300,7 +1300,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.114", + "syn 2.0.117", "which 4.4.2", ] @@ -1310,7 +1310,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1319,7 +1319,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1360,9 +1360,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ "serde_core", ] @@ -1452,7 +1452,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc119a5ad34c3f459062a96907f53358989b173d104258891bb74f95d93747e8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "boa_interner", "boa_macros", "boa_string", @@ -1469,7 +1469,7 @@ checksum = "e637ec52ea66d76b0ca86180c259d6c7bb6e6a6e14b2f36b85099306d8b00cc3" dependencies = [ "aligned-vec", "arrayvec", - "bitflags 2.10.0", + "bitflags 2.11.0", "boa_ast", "boa_gc", "boa_interner", @@ -1551,7 +1551,7 @@ dependencies = [ "cow-utils", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -1561,7 +1561,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02f99bf5b684f0de946378fcfe5f38c3a0fbd51cbf83a0f39ff773a0e218541f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "boa_ast", "boa_interner", "boa_macros", @@ -1654,7 +1654,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1723,7 +1723,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cairo-sys-rs", "glib", "libc", @@ -1781,14 +1781,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", ] [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -1916,9 +1916,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.57" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -1926,9 +1926,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.57" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -1945,14 +1945,14 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "clipboard-win" @@ -1978,7 +1978,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block", "cocoa-foundation", "core-foundation 0.10.1", @@ -1994,7 +1994,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block", "core-foundation 0.10.1", "core-graphics-types 0.2.0", @@ -2019,9 +2019,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "brotli", "compression-core", @@ -2124,9 +2124,9 @@ dependencies = [ [[package]] name = "cookie_store" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" dependencies = [ "cookie", "document-features", @@ -2198,7 +2198,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -2222,7 +2222,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.1", "libc", ] @@ -2399,7 +2399,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.10.1", + "phf 0.8.0", "proc-macro2", "quote", "smallvec", @@ -2415,7 +2415,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.3", + "phf 0.8.0", "smallvec", ] @@ -2426,7 +2426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2436,7 +2436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2480,7 +2480,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2494,7 +2494,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2507,7 +2507,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2518,7 +2518,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2529,7 +2529,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2540,7 +2540,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2627,9 +2627,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -2653,7 +2653,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2663,7 +2663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2676,7 +2676,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2698,7 +2698,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.114", + "syn 2.0.117", "unicode-xid", ] @@ -2773,7 +2773,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2788,7 +2788,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.6.2", "libc", "objc2 0.6.3", @@ -2802,7 +2802,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2825,7 +2825,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2911,7 +2911,7 @@ checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2947,7 +2947,7 @@ dependencies = [ "cc", "memchr", "rustc_version 0.4.1", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -3034,7 +3034,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3060,7 +3060,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3087,7 +3087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3201,7 +3201,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3356,7 +3356,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3392,9 +3392,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.2.2" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" dependencies = [ "autocfg", "tokio", @@ -3437,9 +3437,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -3452,9 +3452,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -3475,15 +3475,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -3503,9 +3503,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -3522,32 +3522,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -3557,7 +3557,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -3736,6 +3735,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" version = "0.32.3" @@ -3780,7 +3792,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "futures-channel", "futures-core", "futures-executor", @@ -3808,7 +3820,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3918,7 +3930,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4096,7 +4108,7 @@ dependencies = [ "markup5ever 0.12.1", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4445,6 +4457,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -4508,7 +4526,7 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png 0.18.0", + "png 0.18.1", "tiff", ] @@ -4595,7 +4613,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "inotify-sys", "libc", ] @@ -4857,7 +4875,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "serde", "unicode-segmentation", ] @@ -4873,7 +4891,7 @@ dependencies = [ "log", "openssl", "security-framework 2.11.1", - "security-framework 3.5.1", + "security-framework 3.7.0", "windows-sys 0.60.2", "zeroize", ] @@ -4931,6 +4949,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libappindicator" version = "0.9.0" @@ -5003,7 +5027,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", "redox_syscall 0.7.0", ] @@ -5165,7 +5189,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5268,9 +5292,9 @@ dependencies = [ [[package]] name = "minijinja" -version = "2.15.1" +version = "2.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b479616bb6f0779fb0f3964246beda02d4b01144e1b0d5519616e012ccc2a245" +checksum = "5c54f3bcc034dd74496b5ca929fd0b710186672d5ff0b0f255a9ceb259042ece" dependencies = [ "memo-map", "self_cell", @@ -5370,17 +5394,17 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.1.6", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -5391,7 +5415,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "jni-sys", "log", "ndk-sys 0.5.0+25.2.9519653", @@ -5405,7 +5429,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "jni-sys", "log", "ndk-sys 0.6.0+11769913", @@ -5464,7 +5488,19 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" +dependencies = [ + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -5492,7 +5528,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "filetime", "fsevent-sys", "inotify 0.9.6", @@ -5510,7 +5546,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "fsevent-sys", "inotify 0.11.0", "kqueue", @@ -5528,7 +5564,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -5546,7 +5582,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5619,7 +5655,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5679,10 +5715,10 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5755,7 +5791,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -5771,7 +5807,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.6.2", "objc2 0.6.3", "objc2-core-foundation", @@ -5785,7 +5821,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2 0.6.3", "objc2-foundation 0.3.2", ] @@ -5796,7 +5832,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5818,7 +5854,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2 0.6.3", ] @@ -5829,7 +5865,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2 0.6.3", "objc2-core-foundation", @@ -5874,7 +5910,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", @@ -5901,7 +5937,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -5913,7 +5949,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.6.2", "libc", "objc2 0.6.3", @@ -5926,7 +5962,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2 0.6.3", "objc2-core-foundation", ] @@ -5937,7 +5973,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5949,7 +5985,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5962,7 +5998,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2 0.6.3", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5974,7 +6010,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.6.2", "objc2 0.6.3", "objc2-cloud-kit", @@ -6005,7 +6041,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.6.2", "objc2 0.6.3", "objc2-app-kit 0.3.2", @@ -6081,7 +6117,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "foreign-types 0.3.2", "libc", @@ -6098,15 +6134,9 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -6277,7 +6307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -6412,7 +6442,9 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ + "phf_macros 0.8.0", "phf_shared 0.8.0", + "proc-macro-hack", ] [[package]] @@ -6421,9 +6453,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ - "phf_macros 0.10.0", "phf_shared 0.10.0", - "proc-macro-hack", ] [[package]] @@ -6527,12 +6557,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_generator 0.8.0", + "phf_shared 0.8.0", "proc-macro-hack", "proc-macro2", "quote", @@ -6549,7 +6579,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6562,7 +6592,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6627,7 +6657,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6708,11 +6738,11 @@ dependencies = [ [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -6823,7 +6853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6896,13 +6926,13 @@ dependencies = [ [[package]] name = "process-wrap" -version = "9.0.1" +version = "9.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd1395947e69c07400ef4d43db0051d6f773c21f647ad8b97382fc01f0204c60" +checksum = "ccd9713fe2c91c3c85ac388b31b89de339365d2c995146e630b5e0da9d06526a" dependencies = [ "futures", "indexmap 2.13.0", - "nix 0.30.1", + "nix 0.31.1", "tokio", "tracing", "windows 0.62.2", @@ -6916,7 +6946,7 @@ checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.10.0", + "bitflags 2.11.0", "num-traits", "rand 0.9.2", "rand_chacha 0.9.0", @@ -6944,15 +6974,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "proxycast" -version = "0.83.1" +version = "0.84.0" dependencies = [ "anyhow", "arboard", @@ -7054,7 +7084,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.83.1" +version = "0.84.0" dependencies = [ "aster-core", "async-trait", @@ -7079,7 +7109,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.83.1" +version = "0.84.0" dependencies = [ "async-trait", "parking_lot", @@ -7095,7 +7125,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.83.1" +version = "0.84.0" dependencies = [ "aster-models", "async-trait", @@ -7135,7 +7165,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.83.1" +version = "0.84.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -7170,7 +7200,7 @@ dependencies = [ [[package]] name = "proxycast-gateway" -version = "0.83.1" +version = "0.84.0" dependencies = [ "axum 0.7.9", "chrono", @@ -7191,7 +7221,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.83.1" +version = "0.84.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -7211,7 +7241,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.83.1" +version = "0.84.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7243,7 +7273,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.83.1" +version = "0.84.0" dependencies = [ "async-trait", "parking_lot", @@ -7262,7 +7292,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.83.1" +version = "0.84.0" dependencies = [ "anyhow", "async-stream", @@ -7316,7 +7346,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.83.1" +version = "0.84.0" dependencies = [ "aster-core", "async-stream", @@ -7361,7 +7391,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.83.1" +version = "0.84.0" dependencies = [ "axum 0.7.9", "futures", @@ -7376,7 +7406,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.83.1" +version = "0.84.0" dependencies = [ "anyhow", "aster-core", @@ -7385,6 +7415,7 @@ dependencies = [ "chrono", "dashmap 5.5.3", "dirs 5.0.1", + "futures", "glob", "indexmap 2.13.0", "md5", @@ -7417,7 +7448,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.83.1" +version = "0.84.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7433,7 +7464,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.83.1" +version = "0.84.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7460,7 +7491,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.83.1" +version = "0.84.0" dependencies = [ "axum 0.7.9", "chrono", @@ -7745,7 +7776,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -7754,7 +7785,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -7796,7 +7827,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8062,7 +8093,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8091,7 +8122,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -8151,7 +8182,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -8164,11 +8195,11 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8204,10 +8235,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework 3.7.0", ] [[package]] @@ -8280,9 +8311,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "ryu-js" @@ -8358,7 +8389,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8370,7 +8401,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8411,7 +8442,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -8420,11 +8451,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -8433,9 +8464,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -8465,7 +8496,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cssparser 0.31.2", "derive_more 0.99.20", "fxhash", @@ -8606,7 +8637,7 @@ version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c044617771f460a3c2ca7cf8f516c3341ba886ca38d53cb550997d8bf636a3c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "sentry-backtrace", "sentry-core", "tracing-core", @@ -8669,7 +8700,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8680,7 +8711,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8715,7 +8746,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8776,7 +8807,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8853,7 +8884,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8939,7 +8970,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 6.0.0", + "dirs 4.0.0", ] [[package]] @@ -9003,9 +9034,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "simple_asn1" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", @@ -9212,7 +9243,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9235,7 +9266,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.114", + "syn 2.0.117", "tempfile", "tokio", "url", @@ -9249,7 +9280,7 @@ checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.11.0", "byteorder", "bytes", "chrono", @@ -9292,7 +9323,7 @@ checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.11.0", "byteorder", "chrono", "crc", @@ -9366,7 +9397,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", "libssh2-sys", "parking_lot", @@ -9456,9 +9487,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -9488,7 +9519,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9532,7 +9563,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -9582,7 +9613,7 @@ version = "0.34.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics 0.24.0", @@ -9624,7 +9655,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9720,7 +9751,7 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -9742,7 +9773,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.114", + "syn 2.0.117", "tauri-utils", "thiserror 2.0.18", "time", @@ -9760,7 +9791,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "tauri-codegen", "tauri-utils", ] @@ -9778,7 +9809,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -9853,7 +9884,7 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.18", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "url", ] @@ -9990,7 +10021,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "url", "urlpattern", "uuid", @@ -10005,20 +10036,20 @@ checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" dependencies = [ "dunce", "embed-resource", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", ] [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10073,7 +10104,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10084,7 +10115,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10235,7 +10266,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10336,9 +10367,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.13.0", "serde_core", @@ -10405,9 +10436,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow 0.7.14", ] @@ -10491,7 +10522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", @@ -10539,7 +10570,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10766,9 +10797,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -10936,16 +10967,16 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "uuid" -version = "1.20.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.1", "js-sys", "serde_core", "wasm-bindgen", @@ -11087,6 +11118,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -11139,7 +11179,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -11152,6 +11192,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -11178,6 +11240,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver 1.0.27", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -11304,7 +11378,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11401,7 +11475,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -11626,7 +11700,7 @@ checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11637,7 +11711,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11648,7 +11722,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11659,7 +11733,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11670,7 +11744,7 @@ checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11681,7 +11755,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11692,7 +11766,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11703,7 +11777,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12214,6 +12288,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver 1.0.27", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "write16" @@ -12229,9 +12385,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.54.1" +version = "0.54.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0" +checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a" dependencies = [ "base64 0.22.1", "block2 0.6.2", @@ -12379,7 +12535,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -12427,7 +12583,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -12461,7 +12617,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12481,7 +12637,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -12502,7 +12658,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12536,7 +12692,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -12561,9 +12717,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" @@ -12650,7 +12806,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -12663,6 +12819,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", + "syn 2.0.117", "winnow 0.7.14", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aa5bbe629..15b85ec7e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.83.2" +version = "0.84.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/proxycast" @@ -124,11 +124,11 @@ enigo = "0.3" # Aster Agent Framework # 开发时使用本地 aster-rust,CI/CD 使用远程 GitHub 仓库 # 本地开发: path = "../../../astercloud/aster-rust/crates/aster" (相对 src-tauri/) -# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.16.0" +# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.17.0" # aster = { package = "aster-core", path = "../../../astercloud/aster-rust/crates/aster" } -aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.16.0" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.17.0" } # 本地开发: aster-models = { path = "../../../astercloud/aster-rust/crates/aster-models" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.16.0" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.17.0" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -191,7 +191,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.83.2" +version = "0.84.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/durable_memory_fs.rs b/src-tauri/crates/agent/src/durable_memory_fs.rs new file mode 100644 index 000000000..f2b499283 --- /dev/null +++ b/src-tauri/crates/agent/src/durable_memory_fs.rs @@ -0,0 +1,209 @@ +use std::fs; +use std::path::{Component, Path, PathBuf}; + +pub const DURABLE_MEMORY_VIRTUAL_ROOT: &str = "/memories"; +pub const DURABLE_MEMORY_ROOT_ENV: &str = "PROXYCAST_DURABLE_MEMORY_DIR"; + +const DURABLE_MEMORY_SUBDIR: &str = "harness/memories"; + +fn normalize_virtual_input(path: &str) -> String { + let raw = path.trim(); + let starts_absolute = raw.starts_with('/') || raw.starts_with('\\'); + let mut normalized = raw.replace('\\', "/"); + while normalized.contains("//") { + normalized = normalized.replace("//", "/"); + } + if starts_absolute && !normalized.starts_with('/') { + normalized.insert(0, '/'); + } + normalized +} + +pub fn durable_memory_permission_pattern() -> &'static str { + r"^/memories(?:/.*)?$" +} + +pub fn resolve_durable_memory_root() -> Result { + let root = if let Ok(override_dir) = std::env::var(DURABLE_MEMORY_ROOT_ENV) { + let trimmed = override_dir.trim(); + if trimmed.is_empty() { + None + } else { + Some(PathBuf::from(trimmed)) + } + } else { + None + }; + + let root = match root { + Some(path) => path, + None => { + #[cfg(test)] + { + std::env::temp_dir() + .join("proxycast-tests") + .join(DURABLE_MEMORY_SUBDIR) + } + #[cfg(not(test))] + { + proxycast_core::app_paths::preferred_data_dir()?.join(DURABLE_MEMORY_SUBDIR) + } + } + }; + + fs::create_dir_all(&root) + .map_err(|e| format!("创建 durable memory 根目录失败 {}: {e}", root.display()))?; + Ok(root) +} + +pub fn virtual_memory_relative_path(path: &str) -> Option { + let normalized = normalize_virtual_input(path); + if normalized == DURABLE_MEMORY_VIRTUAL_ROOT + || normalized == format!("{DURABLE_MEMORY_VIRTUAL_ROOT}/") + { + return Some(String::new()); + } + + normalized + .strip_prefix(&format!("{DURABLE_MEMORY_VIRTUAL_ROOT}/")) + .map(str::to_string) +} + +pub fn is_virtual_memory_path(path: &str) -> bool { + virtual_memory_relative_path(path).is_some() +} + +pub fn resolve_virtual_memory_path(path: &str) -> Result, String> { + let Some(relative) = virtual_memory_relative_path(path) else { + return Ok(None); + }; + + let root = resolve_durable_memory_root()?; + if relative.trim().is_empty() { + return Ok(Some(root)); + } + + let mut target = root.clone(); + for component in Path::new(&relative).components() { + match component { + Component::Normal(segment) => target.push(segment), + Component::CurDir => {} + Component::ParentDir => { + return Err("`/memories/` 路径不允许包含 `..`".to_string()); + } + Component::RootDir | Component::Prefix(_) => { + return Err("`/memories/` 路径格式无效".to_string()); + } + } + } + + Ok(Some(target)) +} + +pub fn to_virtual_memory_path(path: &Path) -> Result, String> { + let root = resolve_durable_memory_root()?; + let normalized_root = root.canonicalize().unwrap_or(root.clone()); + let normalized_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + + let relative = normalized_path + .strip_prefix(&normalized_root) + .or_else(|_| path.strip_prefix(&root)); + + let Ok(relative) = relative else { + return Ok(None); + }; + + if relative.as_os_str().is_empty() { + return Ok(Some(DURABLE_MEMORY_VIRTUAL_ROOT.to_string())); + } + + let suffix = relative + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().to_string()), + _ => None, + }) + .collect::>() + .join("/"); + + if suffix.is_empty() { + Ok(Some(DURABLE_MEMORY_VIRTUAL_ROOT.to_string())) + } else { + Ok(Some(format!("{DURABLE_MEMORY_VIRTUAL_ROOT}/{suffix}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + use std::sync::{Mutex, OnceLock}; + use tempfile::TempDir; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvOverrideGuard { + previous: Option, + } + + impl EnvOverrideGuard { + fn set(path: &Path) -> Self { + let previous = std::env::var_os(DURABLE_MEMORY_ROOT_ENV); + std::env::set_var(DURABLE_MEMORY_ROOT_ENV, path.as_os_str()); + Self { previous } + } + } + + impl Drop for EnvOverrideGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous { + std::env::set_var(DURABLE_MEMORY_ROOT_ENV, value); + } else { + std::env::remove_var(DURABLE_MEMORY_ROOT_ENV); + } + } + } + + #[test] + fn should_map_virtual_memory_path_to_override_root() { + let _guard = env_lock().lock().expect("lock env"); + let tmp = TempDir::new().expect("create temp dir"); + let _env = EnvOverrideGuard::set(tmp.path()); + + let resolved = resolve_virtual_memory_path("/memories/preferences.md") + .expect("resolve path") + .expect("mapped path"); + + assert_eq!(resolved, tmp.path().join("preferences.md")); + } + + #[test] + fn should_reject_parent_segments_in_virtual_memory_path() { + let _guard = env_lock().lock().expect("lock env"); + let tmp = TempDir::new().expect("create temp dir"); + let _env = EnvOverrideGuard::set(tmp.path()); + + let error = resolve_virtual_memory_path("/memories/../escape.md") + .expect_err("should reject parent dir"); + assert!(error.contains("`..`")); + } + + #[test] + fn should_convert_real_path_back_to_virtual_memory_path() { + let _guard = env_lock().lock().expect("lock env"); + let tmp = TempDir::new().expect("create temp dir"); + let _env = EnvOverrideGuard::set(tmp.path()); + + let real_path = tmp.path().join("team").join("preferences.md"); + fs::create_dir_all(real_path.parent().expect("parent")).expect("create subdir"); + fs::write(&real_path, "# preferences").expect("write file"); + + let virtual_path = to_virtual_memory_path(&real_path) + .expect("convert") + .expect("virtual path"); + assert_eq!(virtual_path, "/memories/team/preferences.md"); + } +} diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index b608c9d28..c0aeaae01 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -8,6 +8,8 @@ use aster::conversation::message::{ActionRequiredData, Message, MessageContent}; use regex::Regex; use serde::{Deserialize, Serialize}; +use crate::tool_io_offload::{maybe_offload_tool_arguments, maybe_offload_tool_result_payload}; + const JSON_RECURSION_LIMIT: usize = 50; const JSON_TRAVERSAL_NODE_LIMIT: usize = 4_096; const TOOL_RESULT_MAX_TEXT_PARTS: usize = 256; @@ -462,6 +464,59 @@ fn extract_tool_result_data(result: &T) -> ExtractedToolRes } } +fn extract_tool_result_metadata( + result: &T, +) -> Option> { + fn find_metadata( + value: &serde_json::Value, + depth: usize, + ) -> Option> { + if depth >= JSON_RECURSION_LIMIT { + return None; + } + + let object = value.as_object()?; + + for key in [ + "metadata", + "meta", + "structured_content", + "structuredContent", + ] { + let Some(nested) = object.get(key) else { + continue; + }; + + if let Some(record) = nested.as_object() { + if !record.is_empty() { + return Some( + record + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + ); + } + } + + if let Some(found) = find_metadata(nested, depth + 1) { + return Some(found); + } + } + + for nested in object.values() { + if let Some(found) = find_metadata(nested, depth + 1) { + return Some(found); + } + } + + None + } + + serde_json::to_value(result) + .ok() + .and_then(|value| find_metadata(&value, 0)) +} + /// Tauri Agent 事件 /// /// 用于前端消费的事件格式,与现有的 StreamEvent 兼容 @@ -558,6 +613,8 @@ pub struct TauriToolResult { pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub images: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, } /// Token 使用量 @@ -610,6 +667,8 @@ pub enum TauriMessageContent { error: Option, #[serde(skip_serializing_if = "Option::is_none")] images: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option>, }, #[serde(rename = "action_required")] @@ -637,11 +696,12 @@ pub fn convert_agent_event(event: AgentEvent) -> Vec { AgentEvent::ModelChange { model, mode } => { vec![TauriAgentEvent::ModelChange { model, mode }] } - AgentEvent::HistoryReplaced(_conversation) => { - // 历史替换事件,可能需要特殊处理 - tracing::debug!("History replaced"); - vec![] - } + AgentEvent::HistoryReplaced(_conversation) => vec![TauriAgentEvent::ContextTrace { + steps: vec![TauriContextTraceStep { + stage: "context_management".to_string(), + detail: "会话历史已自动压缩,以继续当前对话。".to_string(), + }], + }], AgentEvent::ContextTrace { steps } => vec![TauriAgentEvent::ContextTrace { steps: steps .into_iter() @@ -672,10 +732,15 @@ fn convert_message(message: Message) -> Vec { } MessageContent::ToolRequest(tool_request) => match &tool_request.tool_call { Ok(call) => { + let arguments_value = serde_json::to_value(&call.arguments).unwrap_or_default(); events.push(TauriAgentEvent::ToolStart { tool_name: call.name.to_string(), tool_id: tool_request.id.clone(), - arguments: serde_json::to_string(&call.arguments).ok(), + arguments: serde_json::to_string(&maybe_offload_tool_arguments( + &tool_request.id, + &arguments_value, + )) + .ok(), }); } Err(e) => { @@ -685,22 +750,33 @@ fn convert_message(message: Message) -> Vec { } }, MessageContent::ToolResponse(tool_response) => { - let (success, output, error, images) = match &tool_response.tool_result { + let (success, output, error, images, metadata) = match &tool_response.tool_result { Ok(result) => { let extracted = extract_tool_result_data(result); log_tool_result_diagnostics(&tool_response.id, &extracted.diagnostics); + let offloaded = maybe_offload_tool_result_payload( + &tool_response.id, + &extracted.output, + result, + extract_tool_result_metadata(result), + ); ( true, - extracted.output, + offloaded.output, None, if extracted.images.is_empty() { None } else { Some(extracted.images) }, + if offloaded.metadata.is_empty() { + None + } else { + Some(offloaded.metadata) + }, ) } - Err(e) => (false, String::new(), Some(e.to_string()), None), + Err(e) => (false, String::new(), Some(e.to_string()), None, None), }; events.push(TauriAgentEvent::ToolEnd { @@ -710,6 +786,7 @@ fn convert_message(message: Message) -> Vec { output, error, images, + metadata, }, }); } @@ -825,32 +902,41 @@ fn convert_message_content(content: &MessageContent) -> Option Some(TauriMessageContent::Thinking { text: thinking.thinking.clone(), }), - MessageContent::ToolRequest(req) => { - req.tool_call - .as_ref() - .ok() - .map(|call| TauriMessageContent::ToolRequest { - id: req.id.clone(), - tool_name: call.name.to_string(), - arguments: serde_json::to_value(&call.arguments).unwrap_or_default(), - }) - } + MessageContent::ToolRequest(req) => req.tool_call.as_ref().ok().map(|call| { + let arguments_value = serde_json::to_value(&call.arguments).unwrap_or_default(); + TauriMessageContent::ToolRequest { + id: req.id.clone(), + tool_name: call.name.to_string(), + arguments: maybe_offload_tool_arguments(&req.id, &arguments_value), + } + }), MessageContent::ToolResponse(resp) => { - let (success, output, error, images) = match &resp.tool_result { + let (success, output, error, images, metadata) = match &resp.tool_result { Ok(result) => { let extracted = extract_tool_result_data(result); + let offloaded = maybe_offload_tool_result_payload( + &resp.id, + &extracted.output, + result, + extract_tool_result_metadata(result), + ); ( true, - extracted.output, + offloaded.output, None, if extracted.images.is_empty() { None } else { Some(extracted.images) }, + if offloaded.metadata.is_empty() { + None + } else { + Some(offloaded.metadata) + }, ) } - Err(e) => (false, String::new(), Some(e.to_string()), None), + Err(e) => (false, String::new(), Some(e.to_string()), None, None), }; Some(TauriMessageContent::ToolResponse { id: resp.id.clone(), @@ -858,6 +944,7 @@ fn convert_message_content(content: &MessageContent) -> Option { @@ -965,6 +1052,22 @@ mod tests { } } + #[test] + fn test_convert_history_replaced_to_context_management_trace() { + let event = AgentEvent::HistoryReplaced(aster::conversation::Conversation::empty()); + + let events = convert_agent_event(event); + assert_eq!(events.len(), 1); + match &events[0] { + TauriAgentEvent::ContextTrace { steps } => { + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].stage, "context_management"); + assert!(steps[0].detail.contains("自动压缩")); + } + _ => panic!("Expected ContextTrace event"), + } + } + #[test] fn test_extract_tool_result_text_should_handle_nested_content_and_error() { let payload = serde_json::json!({ @@ -1102,4 +1205,27 @@ mod tests { assert_eq!(extracted.diagnostics.text_truncated, false); assert!(extracted.diagnostics.raw_json_bytes.is_some()); } + + #[test] + fn test_extract_tool_result_metadata_should_read_meta_object() { + let payload = serde_json::json!({ + "content": [ + { + "type": "text", + "text": "任务已完成" + } + ], + "meta": { + "exit_code": 1, + "output_file": "/tmp/aster_tasks/task-1.log" + } + }); + + let metadata = extract_tool_result_metadata(&payload).expect("metadata should exist"); + assert_eq!(metadata.get("exit_code"), Some(&serde_json::json!(1))); + assert_eq!( + metadata.get("output_file"), + Some(&serde_json::json!("/tmp/aster_tasks/task-1.log")) + ); + } } diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index 017e9dc97..eec5c9499 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -7,6 +7,7 @@ pub mod ask_bridge; pub mod aster_state; pub mod aster_state_support; pub mod credential_bridge; +pub mod durable_memory_fs; pub mod event_converter; pub mod hooks; pub mod lsp_bridge; @@ -16,6 +17,7 @@ pub mod request_tool_policy; pub mod session_store; pub mod shell_security; pub mod subagent_scheduler; +pub mod tool_io_offload; pub mod tool_permissions; pub mod tools; @@ -29,6 +31,11 @@ pub use aster_state_support::{ pub use credential_bridge::{ create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError, }; +pub use durable_memory_fs::{ + durable_memory_permission_pattern, is_virtual_memory_path, resolve_durable_memory_root, + resolve_virtual_memory_path, to_virtual_memory_path, virtual_memory_relative_path, + DURABLE_MEMORY_ROOT_ENV, DURABLE_MEMORY_VIRTUAL_ROOT, +}; pub use event_converter::{convert_agent_event, convert_to_tauri_message, TauriAgentEvent}; pub use lsp_bridge::create_lsp_callback; pub use prompt::SystemPromptBuilder; diff --git a/src-tauri/crates/agent/src/prompt/templates.rs b/src-tauri/crates/agent/src/prompt/templates.rs index b538512a7..6a3d747c6 100644 --- a/src-tauri/crates/agent/src/prompt/templates.rs +++ b/src-tauri/crates/agent/src/prompt/templates.rs @@ -34,16 +34,25 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略 ### 系统工具 - **bash**: 执行 shell 命令 +- **Task** / **TaskOutput** / **KillShell**: 管理长时终端任务 ### 任务管理工具 - **TodoWrite**: 创建和管理任务列表 +- **EnterPlanMode** / **ExitPlanMode**: 显式进入或结束规划阶段 + +### 委派工具 +- **SubAgentTask**: 将独立子问题委派给隔离上下文的子代理执行 + +### 人在环工具 +- **ask**: 向用户请求确认或补充信息 ## 使用原则 1. **优先使用专用工具**:文件操作使用 read/write/edit,不要用 bash 的 cat/echo 2. **并行调用**:如果多个工具调用之间没有依赖关系,应该并行调用 3. **先读后改**:修改文件前必须先读取文件内容 -4. **最小权限**:只执行必要的操作,避免不必要的文件修改"#; +4. **最小权限**:只执行必要的操作,避免不必要的文件修改 +5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 SubAgentTask"#; /// 代码编写指南 pub const CODING_GUIDELINES: &str = r#"# 代码编写指南 @@ -52,8 +61,9 @@ pub const CODING_GUIDELINES: &str = r#"# 代码编写指南 1. **先理解再修改**:在修改代码之前,先阅读相关文件理解现有模式和架构 2. **使用 TodoWrite 规划**:对于复杂任务,先用 TodoWrite 工具规划步骤 -3. **安全第一**:避免引入安全漏洞(命令注入、XSS、SQL 注入等) -4. **避免过度工程**:只做必要的修改,保持解决方案简单 +3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 SubAgentTask +4. **安全第一**:避免引入安全漏洞(命令注入、XSS、SQL 注入等) +5. **避免过度工程**:只做必要的修改,保持解决方案简单 ## 代码质量 @@ -84,7 +94,9 @@ pub const TASK_MANAGEMENT: &str = r#"# 任务管理 3. 完成后立即标记为已完成 4. 继续下一个任务 -不要批量完成多个任务后再标记,应该完成一个标记一个。"#; +不要批量完成多个任务后再标记,应该完成一个标记一个。 + +如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 SubAgentTask 委派出去。"#; /// Git 操作指南 pub const GIT_GUIDELINES: &str = r#"# Git 操作 diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index 76e20158e..b7f0ac109 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -513,6 +513,7 @@ pub async fn execute_web_search_preflight_if_needed( output: tool_result.output.unwrap_or_default(), error: tool_result.error, images: None, + metadata: None, }, }; events.push(event); @@ -546,6 +547,7 @@ pub async fn execute_web_search_preflight_if_needed( output: String::new(), error: Some(error.clone()), images: None, + metadata: None, }, }); Err(error) diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index 6a9d7aa92..72a422f12 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -11,6 +11,11 @@ use proxycast_core::workspace::WorkspaceManager; use uuid::Uuid; use crate::event_converter::{TauriMessage, TauriMessageContent}; +use crate::tool_io_offload::{ + build_history_tool_io_eviction_plan_for_model, force_offload_plain_tool_output_for_history, + force_offload_tool_arguments_for_history, maybe_offload_plain_tool_output, + maybe_offload_tool_arguments, +}; /// 会话信息(简化版) #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -141,10 +146,7 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result = messages - .into_iter() - .map(|message| convert_agent_message(&message)) - .collect(); + let tauri_messages = convert_agent_messages(&messages, Some(session.model.as_str())); tracing::debug!( "[SessionStore] 会话消息转换完成: session_id={}, messages_count={}", @@ -248,7 +250,21 @@ fn convert_image_part(image_url: &str) -> Option { } /// 将 AgentMessage 转换为 TauriMessage -fn convert_agent_message(message: &AgentMessage) -> TauriMessage { +fn convert_agent_messages( + messages: &[AgentMessage], + model_name: Option<&str>, +) -> Vec { + let eviction_plan = build_history_tool_io_eviction_plan_for_model(messages, model_name); + messages + .iter() + .map(|message| convert_agent_message(message, &eviction_plan)) + .collect() +} + +fn convert_agent_message( + message: &AgentMessage, + eviction_plan: &crate::tool_io_offload::HistoryToolIoEvictionPlan, +) -> TauriMessage { let mut content = match &message.content { MessageContent::Text(text) => { if text.trim().is_empty() { @@ -284,16 +300,27 @@ fn convert_agent_message(message: &AgentMessage) -> TauriMessage { if let Some(tool_calls) = &message.tool_calls { for call in tool_calls { + let parsed_arguments = parse_tool_call_arguments(&call.function.arguments); + let arguments = if eviction_plan.request_ids.contains(&call.id) { + force_offload_tool_arguments_for_history(&call.id, &parsed_arguments) + } else { + maybe_offload_tool_arguments(&call.id, &parsed_arguments) + }; content.push(TauriMessageContent::ToolRequest { id: call.id.clone(), tool_name: call.function.name.clone(), - arguments: parse_tool_call_arguments(&call.function.arguments), + arguments, }); } } if let Some(tool_call_id) = &message.tool_call_id { let tool_output = message.content.as_text(); + let offloaded = if eviction_plan.response_ids.contains(tool_call_id) { + force_offload_plain_tool_output_for_history(tool_call_id, &tool_output, None) + } else { + maybe_offload_plain_tool_output(tool_call_id, &tool_output, None) + }; // tool/user 的工具结果协议消息都不应作为普通文本重复渲染。 if message.role.eq_ignore_ascii_case("tool") || message.role.eq_ignore_ascii_case("user") { @@ -303,9 +330,14 @@ fn convert_agent_message(message: &AgentMessage) -> TauriMessage { content.push(TauriMessageContent::ToolResponse { id: tool_call_id.clone(), success: true, - output: tool_output, + output: offloaded.output, error: None, images: None, + metadata: if offloaded.metadata.is_empty() { + None + } else { + Some(offloaded.metadata) + }, }); } @@ -334,6 +366,40 @@ fn convert_agent_message(message: &AgentMessage) -> TauriMessage { mod tests { use super::*; use proxycast_core::agent::types::{FunctionCall, ImageUrl, ToolCall}; + use std::ffi::OsString; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvGuard { + values: Vec<(&'static str, Option)>, + } + + impl EnvGuard { + fn set(entries: &[(&'static str, OsString)]) -> Self { + let mut values = Vec::new(); + for (key, value) in entries { + values.push((*key, std::env::var_os(key))); + std::env::set_var(key, value); + } + Self { values } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, previous) in self.values.drain(..) { + if let Some(value) = previous { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } + } + } #[test] fn parse_tool_call_arguments_should_parse_json_or_keep_raw() { @@ -362,7 +428,10 @@ mod tests { reasoning_content: None, }; - let assistant_converted = convert_agent_message(&assistant); + let assistant_converted = convert_agent_message( + &assistant, + &crate::tool_io_offload::HistoryToolIoEvictionPlan::default(), + ); assert!(assistant_converted.content.iter().any(|part| { matches!( part, @@ -380,7 +449,10 @@ mod tests { reasoning_content: None, }; - let tool_converted = convert_agent_message(&tool); + let tool_converted = convert_agent_message( + &tool, + &crate::tool_io_offload::HistoryToolIoEvictionPlan::default(), + ); assert!(!tool_converted .content .iter() @@ -415,7 +487,10 @@ mod tests { reasoning_content: None, }; - let converted = convert_agent_message(&user_with_image); + let converted = convert_agent_message( + &user_with_image, + &crate::tool_io_offload::HistoryToolIoEvictionPlan::default(), + ); assert!(converted.content.iter().any(|part| { matches!( part, @@ -440,7 +515,10 @@ mod tests { reasoning_content: None, }; - let converted = convert_agent_message(&user_tool_response); + let converted = convert_agent_message( + &user_tool_response, + &crate::tool_io_offload::HistoryToolIoEvictionPlan::default(), + ); assert!(!converted .content .iter() @@ -453,4 +531,81 @@ mod tests { ) })); } + + #[test] + fn convert_agent_messages_should_force_offload_old_large_tool_calls_under_context_pressure() { + let _lock = env_lock().lock().expect("lock env"); + let _env = EnvGuard::set(&[ + ( + crate::tool_io_offload::PROXYCAST_TOOL_TOKEN_LIMIT_BEFORE_EVICT_ENV, + OsString::from("50"), + ), + ( + crate::tool_io_offload::PROXYCAST_CONTEXT_MAX_INPUT_TOKENS_ENV, + OsString::from("600"), + ), + ( + crate::tool_io_offload::PROXYCAST_CONTEXT_WINDOW_TRIGGER_RATIO_ENV, + OsString::from("0.5"), + ), + ( + crate::tool_io_offload::PROXYCAST_CONTEXT_KEEP_RECENT_MESSAGES_ENV, + OsString::from("1"), + ), + ]); + + let messages = vec![ + AgentMessage { + role: "assistant".to_string(), + content: MessageContent::Text(String::new()), + timestamp: "2026-03-11T00:00:00Z".to_string(), + tool_calls: Some(vec![ToolCall { + id: "call-history-1".to_string(), + call_type: "function".to_string(), + function: FunctionCall { + name: "Write".to_string(), + arguments: serde_json::json!({ + "path": "docs/huge.md", + "content": "token ".repeat(220), + }) + .to_string(), + }, + }]), + tool_call_id: None, + reasoning_content: None, + }, + AgentMessage { + role: "user".to_string(), + content: MessageContent::Text("token ".repeat(320)), + timestamp: "2026-03-11T00:00:01Z".to_string(), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + AgentMessage { + role: "assistant".to_string(), + content: MessageContent::Text("最近一条消息".to_string()), + timestamp: "2026-03-11T00:00:02Z".to_string(), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + ]; + + let converted = convert_agent_messages(&messages, Some("gpt-4")); + let first = converted.first().expect("first message"); + let request = first + .content + .iter() + .find_map(|part| match part { + TauriMessageContent::ToolRequest { arguments, .. } => Some(arguments), + _ => None, + }) + .expect("tool request"); + + let record = request + .as_object() + .expect("offloaded request should be object"); + assert!(record.contains_key(crate::tool_io_offload::PROXYCAST_TOOL_ARGUMENTS_OFFLOAD_KEY)); + } } diff --git a/src-tauri/crates/agent/src/tool_io_offload.rs b/src-tauri/crates/agent/src/tool_io_offload.rs new file mode 100644 index 000000000..3788c8862 --- /dev/null +++ b/src-tauri/crates/agent/src/tool_io_offload.rs @@ -0,0 +1,858 @@ +use aster::context::{ + analyze_tool_io_text_payload as analyze_text_payload_stats, + analyze_tool_io_value_payload as analyze_value_payload_stats, + build_tool_io_history_eviction_plan as build_aster_tool_io_history_eviction_plan, + build_tool_io_notice_text as build_aster_tool_io_notice_text, + build_tool_io_payload_envelope as build_aster_tool_io_payload_envelope, + build_tool_io_preview as build_aster_tool_io_preview, + estimate_tool_io_tokens as estimate_text_token_count, + resolve_tool_io_eviction_policy as resolve_aster_tool_io_eviction_policy, + resolve_tool_io_offload_decision as resolve_aster_tool_io_offload_decision, + ToolIoEvictionConfig, ToolIoEvictionPolicy, + ToolIoHistoryEvictionCandidate as AsterToolIoHistoryEvictionCandidate, + ToolIoHistoryMessageAnalysis as AsterToolIoHistoryMessageAnalysis, ToolIoOffloadThresholds, + ToolIoOffloadTrigger, ToolIoPayloadStats, ToolIoPreviewConfig, + DEFAULT_CONTEXT_WINDOW_KEEP_RECENT_MESSAGES, DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS, + DEFAULT_CONTEXT_WINDOW_TRIGGER_RATIO, DEFAULT_TOOL_IO_PREVIEW_MAX_CHARS, + DEFAULT_TOOL_IO_PREVIEW_MAX_LINES, DEFAULT_TOOL_TOKEN_LIMIT_BEFORE_EVICT, +}; +use chrono::Utc; +use proxycast_core::agent::types::AgentMessage; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; + +const TOOL_IO_OFFLOAD_DIR: &str = "harness/tool-io"; +const TOOL_ARGUMENTS_DIR: &str = "inputs"; +const TOOL_RESULTS_DIR: &str = "results"; +const TOOL_RESULT_SAFETY_TRIGGER_BYTES: usize = 64 * 1024; +const TOOL_ARGUMENTS_SAFETY_TRIGGER_BYTES: usize = 128 * 1024; +const TOOL_RESULT_SAFETY_TRIGGER_CHARS: usize = 24_000; +const TOOL_ARGUMENTS_SAFETY_TRIGGER_CHARS: usize = 32_000; +const ESTIMATED_OFFLOADED_PREVIEW_TOKENS: usize = 256; +const TOOL_ARGUMENTS_OFFLOAD_THRESHOLDS: ToolIoOffloadThresholds = ToolIoOffloadThresholds { + max_bytes: TOOL_ARGUMENTS_SAFETY_TRIGGER_BYTES, + max_chars: TOOL_ARGUMENTS_SAFETY_TRIGGER_CHARS, +}; +const TOOL_RESULT_OFFLOAD_THRESHOLDS: ToolIoOffloadThresholds = ToolIoOffloadThresholds { + max_bytes: TOOL_RESULT_SAFETY_TRIGGER_BYTES, + max_chars: TOOL_RESULT_SAFETY_TRIGGER_CHARS, +}; +const TOOL_OFFLOAD_PREVIEW_CONFIG: ToolIoPreviewConfig = ToolIoPreviewConfig { + max_lines: DEFAULT_TOOL_IO_PREVIEW_MAX_LINES, + max_chars: DEFAULT_TOOL_IO_PREVIEW_MAX_CHARS, +}; +const PROVIDER_NAME_HINTS: &[&str] = &[ + "openai", + "anthropic", + "google", + "azure", + "bedrock", + "gcpvertexai", + "ollama", + "fal", + "codex", + "xai", + "grok", +]; + +pub const PROXYCAST_TOOL_ARGUMENTS_OFFLOAD_KEY: &str = "__proxycast_offload"; +pub const PROXYCAST_TOOL_TOKEN_LIMIT_BEFORE_EVICT_ENV: &str = + "PROXYCAST_TOOL_TOKEN_LIMIT_BEFORE_EVICT"; +pub const PROXYCAST_CONTEXT_MAX_INPUT_TOKENS_ENV: &str = "PROXYCAST_CONTEXT_MAX_INPUT_TOKENS"; +pub const PROXYCAST_CONTEXT_WINDOW_TRIGGER_RATIO_ENV: &str = + "PROXYCAST_CONTEXT_WINDOW_TRIGGER_RATIO"; +pub const PROXYCAST_CONTEXT_KEEP_RECENT_MESSAGES_ENV: &str = + "PROXYCAST_CONTEXT_KEEP_RECENT_MESSAGES"; + +#[derive(Debug, Clone)] +pub struct ToolOutputOffload { + pub output: String, + pub metadata: HashMap, +} + +#[derive(Debug, Clone, Default)] +pub struct HistoryToolIoEvictionPlan { + pub request_ids: HashSet, + pub response_ids: HashSet, + pub total_tokens: usize, + pub trigger_tokens: usize, + pub projected_tokens: usize, + pub keep_recent_messages: usize, +} + +#[derive(Debug, Clone)] +struct OffloadInfo { + file_path_string: String, + payload_bytes: usize, + original_chars: usize, + original_tokens: usize, +} + +#[derive(Debug, Clone)] +struct HistoryEvictionCandidate { + kind: HistoryEvictionCandidateKind, + reduction_tokens: usize, +} + +#[derive(Debug, Clone)] +enum HistoryEvictionCandidateKind { + Request(String), + Response(String), +} + +fn sanitize_identifier(input: &str) -> String { + let mut normalized = input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect::(); + + normalized.truncate(64); + let normalized = normalized.trim_matches('_'); + if normalized.is_empty() { + "tool".to_string() + } else { + normalized.to_string() + } +} + +fn stable_hash(value: &str) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() +} + +fn parse_optional_usize_env(names: &[&str]) -> Option { + names + .iter() + .find_map(|name| std::env::var(name).ok()) + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| *value > 0) +} + +fn parse_usize_env(names: &[&str], default: usize) -> usize { + parse_optional_usize_env(names).unwrap_or(default) +} + +fn parse_f64_env(names: &[&str], default: f64) -> f64 { + names + .iter() + .find_map(|name| std::env::var(name).ok()) + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| value.is_finite() && *value > 0.1 && *value <= 1.0) + .unwrap_or(default) +} + +pub fn resolve_tool_io_eviction_policy() -> ToolIoEvictionPolicy { + resolve_tool_io_eviction_policy_for_model(None) +} + +fn normalize_model_hint(model_name: Option<&str>) -> Option<&str> { + let trimmed = model_name + .map(str::trim) + .filter(|value| !value.is_empty())?; + if trimmed.eq_ignore_ascii_case("agent:default") { + return None; + } + + let normalized = trimmed.to_ascii_lowercase(); + if PROVIDER_NAME_HINTS + .iter() + .any(|provider_name| normalized == *provider_name) + { + return None; + } + + Some(trimmed) +} + +pub fn resolve_tool_io_eviction_policy_for_model(model_name: Option<&str>) -> ToolIoEvictionPolicy { + let explicit_context_max_input_tokens = parse_optional_usize_env(&[ + PROXYCAST_CONTEXT_MAX_INPUT_TOKENS_ENV, + "PROXYCAST_MAX_INPUT_TOKENS", + ]); + let config = ToolIoEvictionConfig { + token_limit_before_evict: parse_usize_env( + &[ + PROXYCAST_TOOL_TOKEN_LIMIT_BEFORE_EVICT_ENV, + "PROXYCAST_TOOL_IO_TOKEN_LIMIT_BEFORE_EVICT", + ], + DEFAULT_TOOL_TOKEN_LIMIT_BEFORE_EVICT, + ), + fallback_context_max_input_tokens: explicit_context_max_input_tokens + .unwrap_or(DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS), + context_window_trigger_ratio: parse_f64_env( + &[PROXYCAST_CONTEXT_WINDOW_TRIGGER_RATIO_ENV], + DEFAULT_CONTEXT_WINDOW_TRIGGER_RATIO, + ), + keep_recent_messages: parse_usize_env( + &[PROXYCAST_CONTEXT_KEEP_RECENT_MESSAGES_ENV], + DEFAULT_CONTEXT_WINDOW_KEEP_RECENT_MESSAGES, + ), + }; + let resolved_model_name = if explicit_context_max_input_tokens.is_some() { + None + } else { + normalize_model_hint(model_name) + }; + + resolve_aster_tool_io_eviction_policy(resolved_model_name, config) +} + +fn resolve_offload_root() -> Result { + if let Ok(override_dir) = std::env::var("PROXYCAST_TOOL_IO_OFFLOAD_DIR") { + let trimmed = override_dir.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + + #[cfg(test)] + { + Ok(std::env::temp_dir() + .join("proxycast-tests") + .join(TOOL_IO_OFFLOAD_DIR)) + } + + #[cfg(not(test))] + { + Ok(proxycast_core::app_paths::preferred_data_dir()?.join(TOOL_IO_OFFLOAD_DIR)) + } +} + +fn ensure_dir(path: &Path) -> Result<(), String> { + fs::create_dir_all(path).map_err(|e| format!("创建目录失败 {}: {e}", path.display())) +} + +fn write_offload_payload( + root: &Path, + subdir: &str, + key: &str, + payload: &Value, + stats: ToolIoPayloadStats, +) -> Result { + let target_dir = root.join(subdir); + ensure_dir(&target_dir)?; + + let payload_text = serde_json::to_string_pretty(payload) + .map_err(|e| format!("序列化 offload 载荷失败: {e}"))?; + let file_name = format!( + "{}-{:016x}.json", + sanitize_identifier(key), + stable_hash(&payload_text) + ); + let file_path = target_dir.join(file_name); + + if !file_path.exists() { + fs::write(&file_path, payload_text.as_bytes()) + .map_err(|e| format!("写入 offload 文件失败 {}: {e}", file_path.display()))?; + } + + Ok(OffloadInfo { + file_path_string: file_path.to_string_lossy().to_string(), + payload_bytes: payload_text.len(), + original_chars: stats.chars, + original_tokens: stats.tokens, + }) +} + +fn merge_metadata( + base: Option>, + extra: HashMap, +) -> HashMap { + let mut merged = base.unwrap_or_default(); + merged.extend(extra); + merged +} + +fn scalar_or_short_value(value: &Value) -> Option { + match value { + Value::Null | Value::Bool(_) | Value::Number(_) => Some(value.clone()), + Value::String(text) => { + if text.chars().count() <= 200 { + Some(Value::String(text.clone())) + } else { + None + } + } + _ => None, + } +} + +fn build_compact_arguments_value( + arguments: &Value, + info: &OffloadInfo, + preview: &str, + trigger: ToolIoOffloadTrigger, +) -> Value { + let mut compact = Map::new(); + compact.insert( + PROXYCAST_TOOL_ARGUMENTS_OFFLOAD_KEY.to_string(), + json!({ + "kind": "tool_arguments", + "file": info.file_path_string, + "preview_lines": DEFAULT_TOOL_IO_PREVIEW_MAX_LINES, + "original_chars": info.original_chars, + "original_tokens": info.original_tokens, + "payload_bytes": info.payload_bytes, + "trigger": trigger.as_str(), + }), + ); + + if let Some(record) = arguments.as_object() { + for key in [ + "path", + "file_path", + "filePath", + "command", + "pattern", + "query", + "task_id", + "taskId", + "id", + "tool", + ] { + if compact.contains_key(key) { + continue; + } + if let Some(value) = record.get(key).and_then(scalar_or_short_value) { + compact.insert(key.to_string(), value); + } + } + } + + compact.insert("preview".to_string(), Value::String(preview.to_string())); + Value::Object(compact) +} + +fn resolve_argument_offload_trigger( + stats: ToolIoPayloadStats, + policy: ToolIoEvictionPolicy, +) -> Option { + resolve_aster_tool_io_offload_decision(stats, policy, TOOL_ARGUMENTS_OFFLOAD_THRESHOLDS) + .map(|decision| decision.trigger) +} + +fn resolve_result_offload_trigger( + stats: ToolIoPayloadStats, + policy: ToolIoEvictionPolicy, +) -> Option { + resolve_aster_tool_io_offload_decision(stats, policy, TOOL_RESULT_OFFLOAD_THRESHOLDS) + .map(|decision| decision.trigger) +} + +fn offload_output_metadata( + info: &OffloadInfo, + kind: &str, + trigger: ToolIoOffloadTrigger, +) -> HashMap { + let mut extra = HashMap::new(); + extra.insert("proxycast_offloaded".to_string(), json!(true)); + extra.insert("offload_kind".to_string(), json!(kind)); + extra.insert( + "offload_file".to_string(), + json!(info.file_path_string.clone()), + ); + extra.insert( + "offload_payload_bytes".to_string(), + json!(info.payload_bytes), + ); + extra.insert( + "offload_original_chars".to_string(), + json!(info.original_chars), + ); + extra.insert( + "offload_original_tokens".to_string(), + json!(info.original_tokens), + ); + extra.insert( + "offload_preview_lines".to_string(), + json!(DEFAULT_TOOL_IO_PREVIEW_MAX_LINES), + ); + extra.insert("offload_trigger".to_string(), json!(trigger.as_str())); + extra +} + +fn offload_tool_arguments_internal( + key: &str, + arguments: &Value, + trigger: ToolIoOffloadTrigger, +) -> Value { + let serialized = match serde_json::to_string(arguments) { + Ok(value) => value, + Err(_) => return arguments.clone(), + }; + let stats = analyze_text_payload_stats(&serialized); + let preview = build_aster_tool_io_preview(&serialized, TOOL_OFFLOAD_PREVIEW_CONFIG); + let payload = build_aster_tool_io_payload_envelope("tool_arguments", arguments.clone()); + let Ok(root) = resolve_offload_root() else { + return arguments.clone(); + }; + let Ok(info) = write_offload_payload(&root, TOOL_ARGUMENTS_DIR, key, &payload, stats) else { + return arguments.clone(); + }; + + build_compact_arguments_value(arguments, &info, &preview, trigger) +} + +fn offload_tool_output_internal( + key: &str, + preview_source: &str, + payload: Value, + stats: ToolIoPayloadStats, + metadata: Option>, + kind: &str, + trigger: ToolIoOffloadTrigger, +) -> ToolOutputOffload { + let Ok(root) = resolve_offload_root() else { + return ToolOutputOffload { + output: preview_source.to_string(), + metadata: metadata.unwrap_or_default(), + }; + }; + let Ok(info) = write_offload_payload(&root, TOOL_RESULTS_DIR, key, &payload, stats) else { + return ToolOutputOffload { + output: preview_source.to_string(), + metadata: metadata.unwrap_or_default(), + }; + }; + + let preview = build_aster_tool_io_preview(preview_source, TOOL_OFFLOAD_PREVIEW_CONFIG); + ToolOutputOffload { + output: build_aster_tool_io_notice_text( + &preview, + &format!( + "[ProxyCast Offload] 完整输出已转存到文件:{}", + &info.file_path_string + ), + ), + metadata: merge_metadata(metadata, offload_output_metadata(&info, kind, trigger)), + } +} + +pub fn maybe_offload_tool_arguments(key: &str, arguments: &Value) -> Value { + let stats = analyze_value_payload_stats(arguments); + let policy = resolve_tool_io_eviction_policy(); + let Some(trigger) = resolve_argument_offload_trigger(stats, policy) else { + return arguments.clone(); + }; + + offload_tool_arguments_internal(key, arguments, trigger) +} + +pub fn force_offload_tool_arguments_for_history(key: &str, arguments: &Value) -> Value { + offload_tool_arguments_internal(key, arguments, ToolIoOffloadTrigger::HistoryContextPressure) +} + +pub fn maybe_offload_tool_result_payload( + key: &str, + preview_source: &str, + payload: &T, + metadata: Option>, +) -> ToolOutputOffload { + let payload_value = match serde_json::to_value(payload) { + Ok(value) => value, + Err(_) => { + return ToolOutputOffload { + output: preview_source.to_string(), + metadata: metadata.unwrap_or_default(), + } + } + }; + + let stats = analyze_value_payload_stats(&payload_value); + let policy = resolve_tool_io_eviction_policy(); + let Some(trigger) = resolve_result_offload_trigger(stats, policy) else { + return ToolOutputOffload { + output: preview_source.to_string(), + metadata: metadata.unwrap_or_default(), + }; + }; + + offload_tool_output_internal( + key, + preview_source, + build_aster_tool_io_payload_envelope("tool_result", payload_value), + stats, + metadata, + "tool_result", + trigger, + ) +} + +pub fn maybe_offload_plain_tool_output( + key: &str, + output: &str, + metadata: Option>, +) -> ToolOutputOffload { + let stats = analyze_text_payload_stats(output); + let policy = resolve_tool_io_eviction_policy(); + let Some(trigger) = resolve_result_offload_trigger(stats, policy) else { + return ToolOutputOffload { + output: output.to_string(), + metadata: metadata.unwrap_or_default(), + }; + }; + + offload_tool_output_internal( + key, + output, + build_aster_tool_io_payload_envelope("tool_result_text", Value::String(output.to_string())), + stats, + metadata, + "tool_result_text", + trigger, + ) +} + +pub fn force_offload_plain_tool_output_for_history( + key: &str, + output: &str, + metadata: Option>, +) -> ToolOutputOffload { + offload_tool_output_internal( + key, + output, + build_aster_tool_io_payload_envelope("tool_result_text", Value::String(output.to_string())), + analyze_text_payload_stats(output), + metadata, + "tool_result_text", + ToolIoOffloadTrigger::HistoryContextPressure, + ) +} + +fn parse_tool_arguments_value(arguments: &str) -> Value { + let trimmed = arguments.trim(); + if trimmed.is_empty() { + return json!({}); + } + serde_json::from_str::(trimmed).unwrap_or_else(|_| json!({ "raw": arguments })) +} + +fn estimate_message_tokens( + message: &AgentMessage, + policy: ToolIoEvictionPolicy, +) -> (usize, Vec) { + let mut total_tokens = estimate_text_token_count(&message.content.as_text()) + + message + .reasoning_content + .as_deref() + .map(estimate_text_token_count) + .unwrap_or(0) + + 4; + let mut candidates = Vec::new(); + + if let Some(tool_calls) = &message.tool_calls { + for call in tool_calls { + let arguments = parse_tool_arguments_value(&call.function.arguments); + let stats = analyze_value_payload_stats(&arguments); + total_tokens += stats.tokens; + if stats.tokens > policy.token_limit_before_evict { + candidates.push(HistoryEvictionCandidate { + kind: HistoryEvictionCandidateKind::Request(call.id.clone()), + reduction_tokens: stats + .tokens + .saturating_sub(ESTIMATED_OFFLOADED_PREVIEW_TOKENS) + .max(1), + }); + } + } + } + + if let Some(tool_call_id) = &message.tool_call_id { + let stats = analyze_text_payload_stats(&message.content.as_text()); + if stats.tokens > policy.token_limit_before_evict { + candidates.push(HistoryEvictionCandidate { + kind: HistoryEvictionCandidateKind::Response(tool_call_id.clone()), + reduction_tokens: stats + .tokens + .saturating_sub(ESTIMATED_OFFLOADED_PREVIEW_TOKENS) + .max(1), + }); + } + } + + (total_tokens, candidates) +} + +fn build_aster_history_message_analysis( + total_tokens: usize, + candidates: &[HistoryEvictionCandidate], +) -> AsterToolIoHistoryMessageAnalysis { + AsterToolIoHistoryMessageAnalysis { + total_tokens, + candidates: candidates + .iter() + .map(|candidate| AsterToolIoHistoryEvictionCandidate { + reduction_tokens: candidate.reduction_tokens, + }) + .collect(), + } +} + +pub fn build_history_tool_io_eviction_plan(messages: &[AgentMessage]) -> HistoryToolIoEvictionPlan { + build_history_tool_io_eviction_plan_for_model(messages, None) +} + +pub fn build_history_tool_io_eviction_plan_for_model( + messages: &[AgentMessage], + model_name: Option<&str>, +) -> HistoryToolIoEvictionPlan { + let policy = resolve_tool_io_eviction_policy_for_model(model_name); + let trigger_tokens = policy.context_trigger_tokens(); + let keep_recent_messages = policy.keep_recent_messages.min(messages.len()); + + let mut plan = HistoryToolIoEvictionPlan { + trigger_tokens, + projected_tokens: 0, + keep_recent_messages, + ..HistoryToolIoEvictionPlan::default() + }; + + let mut per_message_candidates = Vec::with_capacity(messages.len()); + let mut analysis = Vec::with_capacity(messages.len()); + for message in messages { + let (tokens, candidates) = estimate_message_tokens(message, policy); + plan.total_tokens += tokens; + analysis.push(build_aster_history_message_analysis(tokens, &candidates)); + per_message_candidates.push(candidates); + } + plan.projected_tokens = plan.total_tokens; + + if plan.total_tokens <= trigger_tokens { + return plan; + } + + let framework_plan = build_aster_tool_io_history_eviction_plan(&analysis, policy); + plan.projected_tokens = framework_plan.projected_tokens; + + for selection in framework_plan.selections { + let Some(candidate) = per_message_candidates + .get(selection.message_index) + .and_then(|candidates| candidates.get(selection.candidate_index)) + else { + continue; + }; + + match &candidate.kind { + HistoryEvictionCandidateKind::Request(id) => { + plan.request_ids.insert(id.clone()); + } + HistoryEvictionCandidateKind::Response(id) => { + plan.response_ids.insert(id.clone()); + } + } + } + + plan +} + +#[cfg(test)] +mod tests { + use super::*; + use proxycast_core::agent::types::{AgentMessage, FunctionCall, MessageContent, ToolCall}; + use std::ffi::OsString; + use std::sync::{Mutex, OnceLock}; + + fn unique_test_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "proxycast-tool-io-offload-{name}-{}", + Utc::now().timestamp_nanos_opt().unwrap_or_default() + )) + } + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvGuard { + values: Vec<(&'static str, Option)>, + } + + impl EnvGuard { + fn set(entries: &[(&'static str, OsString)]) -> Self { + let mut values = Vec::new(); + for (key, value) in entries { + values.push((*key, std::env::var_os(key))); + std::env::set_var(key, value); + } + Self { values } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, previous) in self.values.drain(..) { + if let Some(value) = previous { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } + } + } + + #[test] + fn build_compact_arguments_value_should_keep_path_and_preview() { + let base_dir = unique_test_dir("args"); + let payload = json!({ + "kind": "tool_arguments", + "payload": { + "path": "docs/output.md", + "content": "x".repeat(5000) + } + }); + let info = write_offload_payload( + &base_dir, + TOOL_ARGUMENTS_DIR, + "tool-1", + &payload, + ToolIoPayloadStats { + chars: 5000, + bytes: 5000, + tokens: 1400, + }, + ) + .expect("should write offload payload"); + let compact = build_compact_arguments_value( + &json!({ + "path": "docs/output.md", + "content": "x".repeat(5000) + }), + &info, + "preview text", + ToolIoOffloadTrigger::TokenLimitBeforeEvict, + ); + + let record = compact.as_object().expect("should be object"); + assert_eq!(record.get("path"), Some(&json!("docs/output.md"))); + assert_eq!(record.get("preview"), Some(&json!("preview text"))); + assert!(record.contains_key(PROXYCAST_TOOL_ARGUMENTS_OFFLOAD_KEY)); + } + + #[test] + fn maybe_offload_plain_tool_output_should_emit_metadata() { + let _lock = env_lock().lock().expect("lock env"); + let _env = EnvGuard::set(&[( + PROXYCAST_TOOL_TOKEN_LIMIT_BEFORE_EVICT_ENV, + OsString::from("50"), + )]); + let output = "token ".repeat(500); + let offloaded = maybe_offload_plain_tool_output("tool-plain", &output, None); + + assert!(offloaded.output.contains("[ProxyCast Offload]")); + assert_eq!( + offloaded.metadata.get("proxycast_offloaded"), + Some(&json!(true)) + ); + assert!(offloaded.metadata.contains_key("offload_original_tokens")); + let offload_file = offloaded + .metadata + .get("offload_file") + .and_then(Value::as_str) + .expect("offload file should exist"); + assert!(PathBuf::from(offload_file).exists()); + } + + #[test] + fn build_history_tool_io_eviction_plan_should_mark_old_large_tool_calls() { + let _lock = env_lock().lock().expect("lock env"); + let _env = EnvGuard::set(&[ + ( + PROXYCAST_TOOL_TOKEN_LIMIT_BEFORE_EVICT_ENV, + OsString::from("50"), + ), + ( + PROXYCAST_CONTEXT_MAX_INPUT_TOKENS_ENV, + OsString::from("600"), + ), + ( + PROXYCAST_CONTEXT_WINDOW_TRIGGER_RATIO_ENV, + OsString::from("0.5"), + ), + ( + PROXYCAST_CONTEXT_KEEP_RECENT_MESSAGES_ENV, + OsString::from("1"), + ), + ]); + + let messages = vec![ + AgentMessage { + role: "assistant".to_string(), + content: MessageContent::Text(String::new()), + timestamp: "2026-03-11T00:00:00Z".to_string(), + tool_calls: Some(vec![ToolCall { + id: "call-1".to_string(), + call_type: "function".to_string(), + function: FunctionCall { + name: "Write".to_string(), + arguments: json!({ + "path": "docs/big.md", + "content": "token ".repeat(220), + }) + .to_string(), + }, + }]), + tool_call_id: None, + reasoning_content: None, + }, + AgentMessage { + role: "user".to_string(), + content: MessageContent::Text("token ".repeat(320)), + timestamp: "2026-03-11T00:00:01Z".to_string(), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + AgentMessage { + role: "assistant".to_string(), + content: MessageContent::Text("最近一条消息".to_string()), + timestamp: "2026-03-11T00:00:02Z".to_string(), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + ]; + + let plan = build_history_tool_io_eviction_plan(&messages); + assert!(plan.total_tokens > plan.trigger_tokens); + assert!(plan.request_ids.contains("call-1")); + assert!(!plan.response_ids.contains("call-1")); + } + + #[test] + fn resolve_tool_io_eviction_policy_should_use_aster_model_context_limit() { + let _lock = env_lock().lock().expect("lock env"); + let policy = resolve_tool_io_eviction_policy_for_model(Some("gpt-4.1")); + assert_eq!(policy.context_max_input_tokens, 1_000_000); + + let fallback_policy = resolve_tool_io_eviction_policy_for_model(Some("openai")); + assert_eq!( + fallback_policy.context_max_input_tokens, + DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS + ); + } + + #[test] + fn resolve_tool_io_eviction_policy_should_allow_proxycast_env_override() { + let _lock = env_lock().lock().expect("lock env"); + let _env = EnvGuard::set(&[( + PROXYCAST_CONTEXT_MAX_INPUT_TOKENS_ENV, + OsString::from("4096"), + )]); + + let policy = resolve_tool_io_eviction_policy_for_model(Some("gpt-4.1")); + assert_eq!(policy.context_max_input_tokens, 4096); + assert_eq!(policy.context_trigger_tokens(), 3481); + } +} diff --git a/src-tauri/crates/core/src/config/mod.rs b/src-tauri/crates/core/src/config/mod.rs index 660ba5af1..4318a19c5 100644 --- a/src-tauri/crates/core/src/config/mod.rs +++ b/src-tauri/crates/core/src/config/mod.rs @@ -28,16 +28,17 @@ pub use types::{ DiscordAgentComponentsConfig, DiscordAutoPresenceConfig, DiscordBotConfig, DiscordChannelConfig, DiscordExecApprovalsConfig, DiscordGuildConfig, DiscordIntentsConfig, DiscordThreadBindingsConfig, DiscordUiComponentsConfig, DiscordUiConfig, - DiscordVoiceAutoJoinConfig, DiscordVoiceConfig, EndpointProvidersConfig, ExperimentalFeatures, - FeishuAccountConfig, FeishuBotConfig, FeishuGroupConfig, GatewayConfig, GatewayTunnelConfig, - GeminiApiKeyEntry, HeartbeatExecutionMode, HeartbeatSecurityConfig, HeartbeatSettings, - HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig, InjectionRuleConfig, - InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig, MemoryProfileConfig, - MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig, MultiSearchConfig, - MultiSearchEngineEntryConfig, NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, - PairingSettings, ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, - RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig, - ScreenshotChatConfig, SearchEngine, ServerConfig, TaskSchedule, TelegramAccountConfig, + DiscordVoiceAutoJoinConfig, DiscordVoiceConfig, EndpointProvidersConfig, EnvironmentConfig, + EnvironmentVariableOverride, ExperimentalFeatures, FeishuAccountConfig, FeishuBotConfig, + FeishuGroupConfig, GatewayConfig, GatewayTunnelConfig, GeminiApiKeyEntry, + HeartbeatExecutionMode, HeartbeatSecurityConfig, HeartbeatSettings, HintRouteSettingsEntry, + HintRouterSettings, ImageGenConfig, InjectionRuleConfig, InjectionSettings, LoggingConfig, + MemoryAutoConfig, MemoryConfig, MemoryProfileConfig, MemoryResolveConfig, MemorySourcesConfig, + ModelInfo, ModelsConfig, MultiSearchConfig, MultiSearchEngineEntryConfig, NativeAgentConfig, + NavigationConfig, OpenAIAsrConfig, PairingSettings, ProviderConfig, ProviderModelsConfig, + ProvidersConfig, QuotaExceededConfig, RateLimitSettings, RemoteManagementConfig, + ResponseCacheSettings, RetrySettings, RoutingConfig, ScreenshotChatConfig, SearchEngine, + ServerConfig, ShellEnvironmentImportConfig, TaskSchedule, TelegramAccountConfig, TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig, ToolCallingConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry, VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 7bb1cc4ab..43064f7d1 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -401,6 +401,9 @@ pub struct Config { /// 聊天外观配置 #[serde(default)] pub chat_appearance: ChatAppearanceConfig, + /// 统一环境变量配置 + #[serde(default, skip_serializing_if = "EnvironmentConfig::is_default")] + pub environment: EnvironmentConfig, /// 网络搜索偏好配置 #[serde(default)] pub web_search: WebSearchConfig, @@ -1990,6 +1993,7 @@ impl Default for Config { content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), chat_appearance: ChatAppearanceConfig::default(), + environment: EnvironmentConfig::default(), web_search: WebSearchConfig::default(), memory: MemoryConfig::default(), voice: VoiceConfig::default(), @@ -2010,6 +2014,72 @@ impl Default for Config { // ============ 设置页面配置类型 ============ +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ShellEnvironmentImportConfig { + /// 是否启用登录 Shell 环境导入 + #[serde(default)] + pub enabled: bool, + /// Shell 环境解析超时时间(毫秒) + #[serde(default = "default_shell_import_timeout_ms")] + pub timeout_ms: u64, +} + +fn default_shell_import_timeout_ms() -> u64 { + 1500 +} + +impl Default for ShellEnvironmentImportConfig { + fn default() -> Self { + Self { + enabled: false, + timeout_ms: default_shell_import_timeout_ms(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EnvironmentVariableOverride { + /// 环境变量名 + #[serde(default)] + pub key: String, + /// 环境变量值 + #[serde(default)] + pub value: String, + /// 是否启用 + #[serde(default = "default_environment_variable_enabled")] + pub enabled: bool, +} + +fn default_environment_variable_enabled() -> bool { + true +} + +impl Default for EnvironmentVariableOverride { + fn default() -> Self { + Self { + key: String::new(), + value: String::new(), + enabled: default_environment_variable_enabled(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct EnvironmentConfig { + /// Shell 环境导入配置 + #[serde(default)] + pub shell_import: ShellEnvironmentImportConfig, + /// 显式环境变量覆盖 + #[serde(default)] + pub variables: Vec, +} + +impl EnvironmentConfig { + pub fn is_default(value: &Self) -> bool { + value == &Self::default() + } +} + /// 网络搜索引擎类型 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] diff --git a/src-tauri/crates/core/src/database/dao/general_chat.rs b/src-tauri/crates/core/src/database/dao/general_chat.rs index c5a6e2ce5..fb72698a9 100644 --- a/src-tauri/crates/core/src/database/dao/general_chat.rs +++ b/src-tauri/crates/core/src/database/dao/general_chat.rs @@ -218,13 +218,40 @@ impl GeneralChatDao { limit: Option, before_id: Option<&str>, ) -> Result, rusqlite::Error> { + let before_filter = r#" + AND ( + NOT EXISTS ( + SELECT 1 + FROM general_chat_messages before_message + WHERE before_message.session_id = ?1 + AND before_message.id = ?2 + ) + OR created_at < ( + SELECT before_message.created_at + FROM general_chat_messages before_message + WHERE before_message.session_id = ?1 + AND before_message.id = ?2 + ) + OR ( + created_at = ( + SELECT before_message.created_at + FROM general_chat_messages before_message + WHERE before_message.session_id = ?1 + AND before_message.id = ?2 + ) + AND id < ?2 + ) + ) + "#; + let query = match (limit, before_id) { (Some(lim), Some(_bid)) => { format!( "SELECT id, session_id, role, content, blocks, status, created_at, metadata FROM general_chat_messages - WHERE session_id = ?1 AND id < ?2 - ORDER BY created_at DESC + WHERE session_id = ?1 + {before_filter} + ORDER BY created_at DESC, id DESC LIMIT {lim}" ) } @@ -233,22 +260,24 @@ impl GeneralChatDao { "SELECT id, session_id, role, content, blocks, status, created_at, metadata FROM general_chat_messages WHERE session_id = ?1 - ORDER BY created_at DESC + ORDER BY created_at DESC, id DESC LIMIT {lim}" ) } (None, Some(_)) => { - "SELECT id, session_id, role, content, blocks, status, created_at, metadata - FROM general_chat_messages - WHERE session_id = ?1 AND id < ?2 - ORDER BY created_at ASC" - .to_string() + format!( + "SELECT id, session_id, role, content, blocks, status, created_at, metadata + FROM general_chat_messages + WHERE session_id = ?1 + {before_filter} + ORDER BY created_at ASC, id ASC" + ) } (None, None) => { "SELECT id, session_id, role, content, blocks, status, created_at, metadata FROM general_chat_messages WHERE session_id = ?1 - ORDER BY created_at ASC" + ORDER BY created_at ASC, id ASC" .to_string() } }; @@ -414,6 +443,25 @@ mod tests { } } + fn create_test_message_with_timestamp( + id: &str, + session_id: &str, + role: MessageRole, + content: &str, + created_at: i64, + ) -> ChatMessage { + ChatMessage { + id: id.to_string(), + session_id: session_id.to_string(), + role, + content: content.to_string(), + blocks: None, + status: "complete".to_string(), + created_at, + metadata: None, + } + } + #[test] fn test_create_and_get_session() { let conn = setup_test_db(); @@ -610,4 +658,43 @@ mod tests { assert_eq!(blocks[0].r#type, "code"); assert_eq!(blocks[0].language, Some("rust".to_string())); } + + #[test] + fn test_get_messages_before_id_uses_created_at_pagination() { + let conn = setup_test_db(); + let session = create_test_session("session-1", "测试会话"); + GeneralChatDao::create_session(&conn, &session).unwrap(); + + let oldest = create_test_message_with_timestamp( + "z-message", + "session-1", + MessageRole::User, + "第一条", + 1_700_000_000_001, + ); + let middle = create_test_message_with_timestamp( + "a-message", + "session-1", + MessageRole::Assistant, + "第二条", + 1_700_000_000_002, + ); + let newest = create_test_message_with_timestamp( + "m-message", + "session-1", + MessageRole::User, + "第三条", + 1_700_000_000_003, + ); + + GeneralChatDao::add_message(&conn, &oldest).unwrap(); + GeneralChatDao::add_message(&conn, &middle).unwrap(); + GeneralChatDao::add_message(&conn, &newest).unwrap(); + + let messages = + GeneralChatDao::get_messages(&conn, "session-1", Some(10), Some("a-message")).unwrap(); + + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].id, "z-message"); + } } diff --git a/src-tauri/crates/core/src/lib.rs b/src-tauri/crates/core/src/lib.rs index 2b9c6ec47..50714a09f 100644 --- a/src-tauri/crates/core/src/lib.rs +++ b/src-tauri/crates/core/src/lib.rs @@ -60,6 +60,7 @@ pub mod event_emit; // 网络工具 pub mod network; +pub mod openclaw_install; // 凭证清理(敏感信息过滤) pub mod sanitizer; diff --git a/src-tauri/crates/core/src/models/mod.rs b/src-tauri/crates/core/src/models/mod.rs index cdfb36b71..0993deec6 100644 --- a/src-tauri/crates/core/src/models/mod.rs +++ b/src-tauri/crates/core/src/models/mod.rs @@ -37,5 +37,12 @@ pub use provider_model::Provider; #[allow(unused_imports)] pub use provider_pool_model::*; pub use provider_type::ProviderType; -pub use skill_model::{Skill, SkillMetadata, SkillRepo, SkillState, SkillStates}; +pub use skill_model::{ + resolve_skill_source_kind, Skill, SkillMetadata, SkillRepo, SkillSourceKind, SkillState, + SkillStates, BROADCAST_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY, + DEFAULT_PROXYCAST_SKILL_DIRECTORIES, IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, + MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, RESEARCH_SKILL_DIRECTORY, + SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY, + VIDEO_GENERATE_SKILL_DIRECTORY, +}; pub use vertex_model::{VertexApiKeyEntry, VertexModelAlias}; diff --git a/src-tauri/crates/core/src/models/skill_model.rs b/src-tauri/crates/core/src/models/skill_model.rs index 09c69d9eb..141c6f8e6 100644 --- a/src-tauri/crates/core/src/models/skill_model.rs +++ b/src-tauri/crates/core/src/models/skill_model.rs @@ -1,7 +1,39 @@ +use super::app_type::AppType; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +pub const VIDEO_GENERATE_SKILL_DIRECTORY: &str = "video_generate"; +pub const BROADCAST_GENERATE_SKILL_DIRECTORY: &str = "broadcast_generate"; +pub const COVER_GENERATE_SKILL_DIRECTORY: &str = "cover_generate"; +pub const MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY: &str = "modal_resource_search"; +pub const IMAGE_GENERATE_SKILL_DIRECTORY: &str = "image_generate"; +pub const LIBRARY_SKILL_DIRECTORY: &str = "library"; +pub const URL_PARSE_SKILL_DIRECTORY: &str = "url_parse"; +pub const RESEARCH_SKILL_DIRECTORY: &str = "research"; +pub const TYPESETTING_SKILL_DIRECTORY: &str = "typesetting"; +pub const SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY: &str = "social_post_with_cover"; + +pub const DEFAULT_PROXYCAST_SKILL_DIRECTORIES: [&str; 10] = [ + VIDEO_GENERATE_SKILL_DIRECTORY, + BROADCAST_GENERATE_SKILL_DIRECTORY, + COVER_GENERATE_SKILL_DIRECTORY, + MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, + IMAGE_GENERATE_SKILL_DIRECTORY, + LIBRARY_SKILL_DIRECTORY, + URL_PARSE_SKILL_DIRECTORY, + RESEARCH_SKILL_DIRECTORY, + TYPESETTING_SKILL_DIRECTORY, + SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, +]; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SkillSourceKind { + Builtin, + Other, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Skill { pub key: String, @@ -11,6 +43,8 @@ pub struct Skill { #[serde(rename = "readmeUrl", skip_serializing_if = "Option::is_none")] pub readme_url: Option, pub installed: bool, + #[serde(rename = "sourceKind")] + pub source_kind: SkillSourceKind, #[serde(rename = "repoOwner", skip_serializing_if = "Option::is_none")] pub repo_owner: Option, #[serde(rename = "repoName", skip_serializing_if = "Option::is_none")] @@ -103,6 +137,18 @@ pub fn get_default_skill_repos() -> Vec { ] } +pub fn is_default_proxycast_skill(directory: &str) -> bool { + DEFAULT_PROXYCAST_SKILL_DIRECTORIES.contains(&directory) +} + +pub fn resolve_skill_source_kind(app_type: &AppType, directory: &str) -> SkillSourceKind { + if matches!(app_type, AppType::ProxyCast) && is_default_proxycast_skill(directory) { + SkillSourceKind::Builtin + } else { + SkillSourceKind::Other + } +} + pub type SkillStates = HashMap; #[cfg(test)] @@ -170,4 +216,29 @@ mod tests { assert_eq!(repo.branch, "main"); assert!(repo.enabled); } + + #[test] + fn test_default_proxycast_skill_directories_include_embedded_defaults() { + assert!(is_default_proxycast_skill(VIDEO_GENERATE_SKILL_DIRECTORY)); + assert!(is_default_proxycast_skill( + SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY + )); + assert!(!is_default_proxycast_skill("custom-skill")); + } + + #[test] + fn test_resolve_skill_source_kind_only_marks_proxycast_defaults_as_builtin() { + assert_eq!( + resolve_skill_source_kind(&AppType::ProxyCast, VIDEO_GENERATE_SKILL_DIRECTORY), + SkillSourceKind::Builtin + ); + assert_eq!( + resolve_skill_source_kind(&AppType::ProxyCast, "custom-skill"), + SkillSourceKind::Other + ); + assert_eq!( + resolve_skill_source_kind(&AppType::Claude, VIDEO_GENERATE_SKILL_DIRECTORY), + SkillSourceKind::Other + ); + } } diff --git a/src-tauri/crates/core/src/openclaw_install.rs b/src-tauri/crates/core/src/openclaw_install.rs new file mode 100644 index 000000000..9847f1234 --- /dev/null +++ b/src-tauri/crates/core/src/openclaw_install.rs @@ -0,0 +1,424 @@ +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShellPlatform { + Windows, + Unix, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OpenClawInstallDependencyKind { + Node, + Git, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowsDependencyInstallPlan { + Winget { package_id: &'static str }, + OfficialInstaller, + ManualDownload, +} + +pub fn command_bin_dir_for(platform: ShellPlatform, binary_path: &str) -> Option { + let separators: &[char] = match platform { + ShellPlatform::Windows => &['\\', '/'], + ShellPlatform::Unix => &['/'], + }; + + let index = binary_path.rfind(separators)?; + if index == 0 { + Some(binary_path[..1].to_string()) + } else { + Some(binary_path[..index].to_string()) + } +} + +fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +pub fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String { + match platform { + ShellPlatform::Windows => format!("\"{}\"", value.replace('"', "\"\"")), + ShellPlatform::Unix => shell_escape(value), + } +} + +pub fn shell_npm_prefix_assignment_for(platform: ShellPlatform, value: &str) -> String { + match platform { + ShellPlatform::Windows => { + format!( + "set \"NPM_CONFIG_PREFIX={}\" && ", + value.replace('"', "\"\"") + ) + } + ShellPlatform::Unix => format!("NPM_CONFIG_PREFIX={} ", shell_escape(value)), + } +} + +pub fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) -> String { + let Some(bin_dir) = command_bin_dir_for(platform, binary_path) else { + return String::new(); + }; + + match platform { + ShellPlatform::Windows => { + format!("set \"PATH={};%PATH%\" && ", bin_dir.replace('"', "\"\"")) + } + ShellPlatform::Unix => format!("PATH={}:$PATH ", shell_escape(&bin_dir)), + } +} + +fn shell_environment_prefix( + platform: ShellPlatform, + binary_path: &str, + npm_prefix: Option<&str>, +) -> String { + format!( + "{}{}", + shell_path_assignment_for(platform, binary_path), + npm_prefix + .map(|prefix| shell_npm_prefix_assignment_for(platform, prefix)) + .unwrap_or_default() + ) +} + +pub fn build_openclaw_cleanup_command( + platform: ShellPlatform, + npm_path: &str, + npm_prefix: Option<&str>, +) -> String { + format!( + "{}{} uninstall -g openclaw @qingchencloud/openclaw-zh", + shell_environment_prefix(platform, npm_path, npm_prefix), + shell_command_escape_for(platform, npm_path) + ) +} + +pub fn build_openclaw_install_command( + platform: ShellPlatform, + npm_path: &str, + npm_prefix: Option<&str>, + package: &str, + registry: Option<&str>, +) -> String { + let registry_suffix = registry + .map(|value| format!(" --registry={value}")) + .unwrap_or_default(); + format!( + "{}{} install -g {}{}", + shell_environment_prefix(platform, npm_path, npm_prefix), + shell_command_escape_for(platform, npm_path), + package, + registry_suffix + ) +} + +pub fn resolve_windows_dependency_install_plan( + dependency: OpenClawInstallDependencyKind, + has_winget: bool, +) -> WindowsDependencyInstallPlan { + match (dependency, has_winget) { + (OpenClawInstallDependencyKind::Node, true) => WindowsDependencyInstallPlan::Winget { + package_id: "OpenJS.NodeJS.LTS", + }, + (OpenClawInstallDependencyKind::Node, false) => { + WindowsDependencyInstallPlan::OfficialInstaller + } + (OpenClawInstallDependencyKind::Git, true) => WindowsDependencyInstallPlan::Winget { + package_id: "Git.Git", + }, + (OpenClawInstallDependencyKind::Git, false) => WindowsDependencyInstallPlan::ManualDownload, + } +} + +pub fn build_winget_install_command(winget_path: &str, package_id: &str) -> String { + format!( + "{}{} install --id {} -e --accept-source-agreements --accept-package-agreements", + shell_path_assignment_for(ShellPlatform::Windows, winget_path), + shell_command_escape_for(ShellPlatform::Windows, winget_path), + package_id + ) +} + +pub fn windows_manual_install_message(dependency: OpenClawInstallDependencyKind) -> &'static str { + match dependency { + OpenClawInstallDependencyKind::Node => { + "当前系统缺少 winget,暂时无法一键安装 Node.js,请点击“手动下载 Node.js”完成安装后重试。" + } + OpenClawInstallDependencyKind::Git => { + "当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。" + } + } +} + +fn command_path_rank(path: &Path) -> u8 { + match path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("exe") => 0, + Some("cmd") => 1, + Some("bat") => 2, + _ => 3, + } +} + +pub fn select_preferred_path_candidate(candidates: Vec) -> Option { + candidates + .into_iter() + .min_by_key(|path| command_path_rank(path)) +} + +fn is_better_semver_candidate( + current_best: Option<&(PathBuf, (u64, u64, u64))>, + candidate_path: &Path, + candidate_version: (u64, u64, u64), +) -> bool { + let Some((best_path, best_version)) = current_best else { + return true; + }; + + candidate_version > *best_version + || (candidate_version == *best_version + && command_path_rank(candidate_path) < command_path_rank(best_path)) +} + +type SemVer = (u64, u64, u64); + +pub fn select_best_semver_candidate( + candidates: Vec<(PathBuf, Option)>, + min_version: SemVer, +) -> Option { + let fallback = + select_preferred_path_candidate(candidates.iter().map(|(path, _)| path.clone()).collect()); + let mut best_supported: Option<(PathBuf, (u64, u64, u64))> = None; + let mut best_any: Option<(PathBuf, (u64, u64, u64))> = None; + + for (path, version) in candidates { + let Some(version) = version else { + continue; + }; + + if is_better_semver_candidate(best_any.as_ref(), &path, version) { + best_any = Some((path.clone(), version)); + } + + if version >= min_version + && is_better_semver_candidate(best_supported.as_ref(), &path, version) + { + best_supported = Some((path, version)); + } + } + + best_supported + .or(best_any) + .map(|(path, _)| path) + .or(fallback) +} + +#[cfg(test)] +mod tests { + use super::{ + build_openclaw_cleanup_command, build_openclaw_install_command, + build_winget_install_command, command_bin_dir_for, resolve_windows_dependency_install_plan, + select_best_semver_candidate, select_preferred_path_candidate, shell_command_escape_for, + shell_npm_prefix_assignment_for, shell_path_assignment_for, windows_manual_install_message, + OpenClawInstallDependencyKind, ShellPlatform, WindowsDependencyInstallPlan, + }; + use std::path::PathBuf; + + const OPENCLAW_CN_PACKAGE: &str = "@qingchencloud/openclaw-zh@latest"; + const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest"; + const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com"; + + #[test] + fn windows_command_bin_dir_supports_backslash_paths() { + assert_eq!( + command_bin_dir_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), + Some(r"C:\Program Files\nodejs".to_string()) + ); + } + + #[test] + fn windows_shell_command_escape_keeps_cmd_compatible_quotes() { + assert_eq!( + shell_command_escape_for(ShellPlatform::Windows, r#"C:\Program Files\nodejs\npm.cmd"#), + r#""C:\Program Files\nodejs\npm.cmd""# + ); + assert_eq!( + shell_command_escape_for(ShellPlatform::Windows, "C:\\demo\\na\"me\\npm.cmd"), + r#""C:\demo\na""me\npm.cmd""# + ); + } + + #[test] + fn windows_shell_npm_prefix_assignment_uses_set_syntax() { + assert_eq!( + shell_npm_prefix_assignment_for( + ShellPlatform::Windows, + r"C:\Users\demo\AppData\Roaming\npm" + ), + r#"set "NPM_CONFIG_PREFIX=C:\Users\demo\AppData\Roaming\npm" && "# + ); + } + + #[test] + fn windows_shell_path_assignment_prepends_binary_directory() { + assert_eq!( + shell_path_assignment_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), + r#"set "PATH=C:\Program Files\nodejs;%PATH%" && "# + ); + } + + #[test] + fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() { + let command = build_openclaw_cleanup_command( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd", + Some(r"C:\Users\demo\AppData\Roaming\npm"), + ); + + assert_eq!( + command, + concat!( + "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", + "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", + "\"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh" + ) + ); + assert!(!command.contains("|| true")); + } + + #[test] + fn windows_install_command_adds_registry_when_using_china_package() { + let command = build_openclaw_install_command( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd", + Some(r"C:\Users\demo\AppData\Roaming\npm"), + OPENCLAW_CN_PACKAGE, + Some(NPM_MIRROR_CN), + ); + + assert_eq!( + command, + concat!( + "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", + "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", + "\"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ", + "--registry=https://registry.npmmirror.com" + ) + ); + } + + #[test] + fn windows_install_command_omits_registry_for_default_package() { + let command = build_openclaw_install_command( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd", + None, + OPENCLAW_DEFAULT_PACKAGE, + None, + ); + + assert_eq!( + command, + concat!( + "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", + "\"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest" + ) + ); + assert!(!command.contains("--registry=")); + } + + #[test] + fn preferred_path_candidate_prioritizes_windows_executable_extensions() { + let preferred = select_preferred_path_candidate(vec![ + PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.bat"), + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe"), + ]); + + assert_eq!( + preferred, + Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe")) + ); + } + + #[test] + fn semver_selection_prefers_windows_launcher_over_bare_file_when_versions_equal() { + let preferred = select_best_semver_candidate( + vec![ + (PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), Some((23, 1, 0))), + ( + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), + Some((23, 1, 0)), + ), + ], + (22, 0, 0), + ); + + assert_eq!( + preferred, + Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd")) + ); + } + + #[test] + fn windows_node_prefers_winget_when_available() { + assert_eq!( + resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Node, true), + WindowsDependencyInstallPlan::Winget { + package_id: "OpenJS.NodeJS.LTS" + } + ); + } + + #[test] + fn windows_node_falls_back_to_official_installer_without_winget() { + assert_eq!( + resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Node, false), + WindowsDependencyInstallPlan::OfficialInstaller + ); + } + + #[test] + fn windows_git_prefers_winget_when_available() { + assert_eq!( + resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Git, true), + WindowsDependencyInstallPlan::Winget { + package_id: "Git.Git" + } + ); + } + + #[test] + fn windows_git_requires_manual_download_without_winget() { + assert_eq!( + resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Git, false), + WindowsDependencyInstallPlan::ManualDownload + ); + assert_eq!( + windows_manual_install_message(OpenClawInstallDependencyKind::Git), + "当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。" + ); + } + + #[test] + fn winget_install_command_uses_expected_windows_flags() { + assert_eq!( + build_winget_install_command( + r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe", + "OpenJS.NodeJS.LTS" + ), + concat!( + "set \"PATH=C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps;%PATH%\" && ", + "\"C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps\\winget.exe\" install ", + "--id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements" + ) + ); + } +} diff --git a/src-tauri/crates/services/Cargo.toml b/src-tauri/crates/services/Cargo.toml index 069a9f53f..4cfb18d7a 100644 --- a/src-tauri/crates/services/Cargo.toml +++ b/src-tauri/crates/services/Cargo.toml @@ -23,6 +23,7 @@ serde_yaml.workspace = true # 异步运行时 tokio.workspace = true async-trait.workspace = true +futures.workspace = true # 错误处理 thiserror.workspace = true diff --git a/src-tauri/crates/services/src/aster_session_store.rs b/src-tauri/crates/services/src/aster_session_store.rs index b4a204b16..fe7766e20 100644 --- a/src-tauri/crates/services/src/aster_session_store.rs +++ b/src-tauri/crates/services/src/aster_session_store.rs @@ -268,7 +268,10 @@ impl SessionStore for ProxyCastSessionStore { conversation, message_count, provider_name: None, - model_config: None, + model_config: match model.trim() { + "" | "agent:default" => None, + normalized => ModelConfig::new(normalized).ok(), + }, }) } @@ -593,13 +596,18 @@ impl SessionStore for ProxyCastSessionStore { &self, session_id: &str, provider_name: Option, - _model_config: Option, + model_config: Option, ) -> Result<()> { - if let Some(provider) = provider_name { + if let Some(model_name) = model_config + .as_ref() + .map(|config| config.model_name.trim().to_string()) + .filter(|value| !value.is_empty()) + .or(provider_name.filter(|value| !value.trim().is_empty())) + { let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?; conn.execute( "UPDATE agent_sessions SET model = ? WHERE id = ?", - rusqlite::params![provider, session_id], + rusqlite::params![model_name, session_id], )?; } Ok(()) @@ -770,3 +778,51 @@ impl ProxyCastSessionStore { Ok(count as usize) } } + +#[cfg(test)] +mod tests { + use super::*; + use aster::session::{SessionStore, SessionType}; + use proxycast_core::database::schema::create_tables; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + + fn setup_test_store() -> ProxyCastSessionStore { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + ProxyCastSessionStore::new(Arc::new(Mutex::new(conn))) + } + + #[tokio::test] + async fn update_provider_config_should_persist_model_name_first() { + let store = setup_test_store(); + let session = store + .create_session( + PathBuf::from("."), + "测试会话".to_string(), + SessionType::User, + ) + .await + .expect("创建会话失败"); + + store + .update_provider_config( + &session.id, + Some("openai".to_string()), + Some(ModelConfig::new("gpt-4.1").expect("model config")), + ) + .await + .expect("更新 provider 配置失败"); + + let conn = store.db.lock().expect("锁数据库"); + let persisted_model: String = conn + .query_row( + "SELECT model FROM agent_sessions WHERE id = ?", + [session.id.as_str()], + |row| row.get(0), + ) + .expect("查询 model 失败"); + + assert_eq!(persisted_model, "gpt-4.1"); + } +} diff --git a/src-tauri/crates/services/src/skill_service.rs b/src-tauri/crates/services/src/skill_service.rs index 4cca04403..827b483e7 100644 --- a/src-tauri/crates/services/src/skill_service.rs +++ b/src-tauri/crates/services/src/skill_service.rs @@ -8,7 +8,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::time::timeout; -use proxycast_core::models::{AppType, Skill, SkillMetadata, SkillRepo, SkillState}; +use proxycast_core::models::{ + resolve_skill_source_kind, AppType, Skill, SkillMetadata, SkillRepo, SkillState, +}; const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); const REMOTE_SKILLS_CACHE_TTL: Duration = Duration::from_secs(300); @@ -102,6 +104,61 @@ impl SkillService { Ok(skills_dir) } + /// 仅列出内置 + 本地技能(不访问远程仓库,速度快) + pub fn list_local_skills( + &self, + app_type: &AppType, + _installed_states: &HashMap, + ) -> Result> { + let mut all_skills: HashMap = HashMap::new(); + + // 扫描本地目录 + let skills_dir = Self::get_skills_dir(app_type)?; + if skills_dir.exists() { + if let Ok(entries) = fs::read_dir(&skills_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + let directory = entry.file_name().to_string_lossy().to_string(); + let key = format!("local:{directory}"); + let skill_md = entry.path().join("SKILL.md"); + let (name, description) = if skill_md.exists() { + self.parse_skill_metadata(&skill_md) + .map(|m| { + ( + m.name.unwrap_or_else(|| directory.clone()), + m.description.unwrap_or_default(), + ) + }) + .unwrap_or_else(|_| (directory.clone(), String::new())) + } else { + (directory.clone(), String::new()) + }; + + all_skills.insert( + key.clone(), + Skill { + key, + name, + description, + directory: directory.clone(), + readme_url: None, + installed: true, + source_kind: resolve_skill_source_kind(app_type, &directory), + repo_owner: None, + repo_name: None, + repo_branch: None, + }, + ); + } + } + } + } + + let mut skills: Vec = all_skills.into_values().collect(); + skills.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(skills) + } + /// 列出所有技能 pub async fn list_skills( &self, @@ -180,6 +237,7 @@ impl SkillService { directory: directory.clone(), readme_url: None, installed: true, + source_kind: resolve_skill_source_kind(app_type, &directory), repo_owner: None, repo_name: None, repo_branch: None, @@ -358,6 +416,7 @@ impl SkillService { directory, readme_url, installed: false, + source_kind: proxycast_core::models::SkillSourceKind::Other, repo_owner: Some(repo.owner.clone()), repo_name: Some(repo.name.clone()), repo_branch: Some(branch.to_string()), @@ -522,6 +581,11 @@ impl SkillService { Ok(meta) } + + /// 清空技能仓库缓存 + pub fn refresh_cache(&self) { + self.repo_cache.write().clear(); + } } #[cfg(test)] diff --git a/src-tauri/src/agent/subagent_scheduler.rs b/src-tauri/src/agent/subagent_scheduler.rs index e0c4ff8d6..f74a744a4 100644 --- a/src-tauri/src/agent/subagent_scheduler.rs +++ b/src-tauri/src/agent/subagent_scheduler.rs @@ -23,6 +23,8 @@ pub struct ProxyCastScheduler { inner: proxycast_agent::subagent_scheduler::ProxyCastScheduler, /// Tauri AppHandle app_handle: Option, + /// 调度事件归属的会话 ID + event_session_id: Option, } impl ProxyCastScheduler { @@ -31,6 +33,7 @@ impl ProxyCastScheduler { Self { inner: proxycast_agent::subagent_scheduler::ProxyCastScheduler::new(db), app_handle: None, + event_session_id: None, } } @@ -40,6 +43,13 @@ impl ProxyCastScheduler { self } + /// 绑定调度事件的会话 ID + pub fn with_event_session_id(mut self, session_id: impl Into) -> Self { + let normalized = session_id.into(); + self.event_session_id = (!normalized.trim().is_empty()).then_some(normalized); + self + } + /// 设置默认角色 pub fn with_default_role(mut self, role: SubAgentRole) -> Self { self.inner = self.inner.with_default_role(role); @@ -48,9 +58,11 @@ impl ProxyCastScheduler { /// 初始化调度器 pub async fn init(&self, config: Option) { + let event_session_id = self.event_session_id.clone(); let event_emitter = self.app_handle.clone().map(|handle| { Arc::new(move |event: &serde_json::Value| { - if let Err(err) = handle.emit("subagent-scheduler-event", event) { + let payload = enrich_scheduler_event_payload(event, event_session_id.as_deref()); + if let Err(err) = handle.emit("subagent-scheduler-event", payload) { tracing::warn!("发送 Tauri 事件失败: {}", err); } }) as SchedulerEventEmitter @@ -87,3 +99,58 @@ impl ProxyCastScheduler { self.inner.cancel().await; } } + +fn enrich_scheduler_event_payload( + event: &serde_json::Value, + session_id: Option<&str>, +) -> serde_json::Value { + let Some(session_id) = session_id.map(str::trim).filter(|value| !value.is_empty()) else { + return event.clone(); + }; + + match event { + serde_json::Value::Object(map) => { + let mut next = map.clone(); + next.insert( + "sessionId".to_string(), + serde_json::Value::String(session_id.to_string()), + ); + serde_json::Value::Object(next) + } + other => serde_json::json!({ + "type": "unknown", + "payload": other, + "sessionId": session_id, + }), + } +} + +#[cfg(test)] +mod tests { + use super::enrich_scheduler_event_payload; + + #[test] + fn should_append_session_id_for_object_event() { + let payload = serde_json::json!({ + "type": "started", + "totalTasks": 1, + }); + + let enriched = enrich_scheduler_event_payload(&payload, Some("session-a")); + + assert_eq!(enriched["type"], serde_json::json!("started")); + assert_eq!(enriched["sessionId"], serde_json::json!("session-a")); + } + + #[test] + fn should_keep_original_event_when_session_id_missing() { + let payload = serde_json::json!({ + "type": "completed", + "success": true, + }); + + let enriched = enrich_scheduler_event_payload(&payload, None); + + assert_eq!(enriched, payload); + } +} diff --git a/src-tauri/src/app/commands/config.rs b/src-tauri/src/app/commands/config.rs index 65353092c..a228d519a 100644 --- a/src-tauri/src/app/commands/config.rs +++ b/src-tauri/src/app/commands/config.rs @@ -9,6 +9,9 @@ use crate::config::{ observer::{ConfigChangeEvent, RoutingChangeEvent}, ConfigChangeSource, GlobalConfigManagerState, }; +use crate::services::environment_service::{ + apply_configured_environment, build_environment_preview, +}; /// 获取配置 #[tauri::command] @@ -55,6 +58,7 @@ pub async fn save_config( let save_result = config_manager.0.save_config(&config).await; match save_result { Ok(()) => { + apply_configured_environment(&config).await; tracing::info!("[CONFIG] 配置保存成功: host={}", config.server.host); Ok(()) } @@ -65,6 +69,18 @@ pub async fn save_config( } } +/// 获取统一环境变量预览 +#[tauri::command] +pub async fn get_environment_preview( + state: tauri::State<'_, AppState>, +) -> Result { + let config = { + let s = state.read().await; + s.config.clone() + }; + Ok(build_environment_preview(&config).await) +} + /// 获取默认 Provider #[tauri::command] pub async fn get_default_provider(state: tauri::State<'_, AppState>) -> Result { @@ -202,6 +218,8 @@ pub async fn set_endpoint_provider( /// 会更新 ~/.claude/settings.json 和 shell 配置文件中的环境变量 #[tauri::command] pub async fn update_provider_env_vars( + state: tauri::State<'_, AppState>, + config_manager: tauri::State<'_, GlobalConfigManagerState>, logs: tauri::State<'_, LogState>, provider_type: String, api_host: String, @@ -267,10 +285,18 @@ pub async fn update_provider_env_vars( // 不中断流程 } + let next_config = { + let mut s = state.write().await; + upsert_environment_overrides(&mut s.config, &env_vars); + s.config.clone() + }; + config_manager.0.save_config(&next_config).await?; + apply_configured_environment(&next_config).await; + logs.write().await.add( "info", &format!( - "已更新 {} 环境变量: {}", + "已更新 {} 环境变量,并同步到统一环境配置: {}", provider_type, env_vars .iter() @@ -289,6 +315,37 @@ pub async fn update_provider_env_vars( Ok(()) } +fn upsert_environment_overrides(config: &mut config::Config, env_vars: &[(String, String)]) { + for (key, value) in env_vars { + let trimmed_key = key.trim(); + if trimmed_key.is_empty() { + continue; + } + + if let Some(existing) = config + .environment + .variables + .iter_mut() + .rev() + .find(|entry| entry.key.trim().eq_ignore_ascii_case(trimmed_key)) + { + existing.key = trimmed_key.to_string(); + existing.value = value.clone(); + existing.enabled = true; + continue; + } + + config + .environment + .variables + .push(proxycast_core::config::EnvironmentVariableOverride { + key: trimmed_key.to_string(), + value: value.clone(), + enabled: true, + }); + } +} + fn build_provider_env_vars( provider_type: &str, api_host: &str, @@ -391,7 +448,8 @@ fn build_provider_env_vars( #[cfg(test)] mod tests { - use super::build_provider_env_vars; + use super::{build_provider_env_vars, upsert_environment_overrides}; + use proxycast_core::config::Config; #[test] fn test_build_provider_env_vars_explicit_anthropic_compatible() { @@ -443,4 +501,26 @@ mod tests { )] ); } + + #[test] + fn test_upsert_environment_overrides_updates_existing_key() { + let mut config = Config::default(); + config + .environment + .variables + .push(proxycast_core::config::EnvironmentVariableOverride { + key: "OPENAI_BASE_URL".to_string(), + value: "http://old".to_string(), + enabled: false, + }); + + upsert_environment_overrides( + &mut config, + &[("OPENAI_BASE_URL".to_string(), "http://new".to_string())], + ); + + assert_eq!(config.environment.variables.len(), 1); + assert_eq!(config.environment.variables[0].value, "http://new"); + assert!(config.environment.variables[0].enabled); + } } diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index e7538e798..7bd84a5c9 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -41,6 +41,10 @@ pub fn run() { } }; + tauri::async_runtime::block_on( + crate::services::environment_service::apply_configured_environment(&config), + ); + // 初始化崩溃上报(保持 guard 生命周期直到应用退出) let _crash_reporting_guard = crate::crash_reporting::init_from_config(&config); @@ -903,6 +907,7 @@ pub fn run() { // Config commands (from app::commands) app_commands::get_config, app_commands::save_config, + app_commands::get_environment_preview, app_commands::get_default_provider, app_commands::set_default_provider, app_commands::get_endpoint_providers, @@ -1054,6 +1059,7 @@ pub fn run() { // Skill commands commands::skill_cmd::get_skills, commands::skill_cmd::get_skills_for_app, + commands::skill_cmd::get_local_skills_for_app, commands::skill_cmd::install_skill, commands::skill_cmd::install_skill_for_app, commands::skill_cmd::uninstall_skill, @@ -1061,6 +1067,7 @@ pub fn run() { commands::skill_cmd::get_skill_repos, commands::skill_cmd::add_skill_repo, commands::skill_cmd::remove_skill_repo, + commands::skill_cmd::refresh_skill_cache, commands::skill_cmd::get_installed_proxycast_skills, commands::skill_cmd::get_local_skill_content, // Skill Execution commands @@ -1459,7 +1466,7 @@ pub fn run() { commands::document_import_cmd::import_document, commands::document_import_cmd::import_document_to_session, commands::document_import_cmd::save_exported_document, - // General Chat commands + // General Chat commands(兼容旧链路,禁止新增依赖) commands::general_chat_cmd::general_chat_create_session, commands::general_chat_cmd::general_chat_list_sessions, commands::general_chat_cmd::general_chat_get_session, @@ -1470,7 +1477,7 @@ pub fn run() { commands::general_chat_cmd::general_chat_send_message, commands::general_chat_cmd::general_chat_stop_generation, commands::general_chat_cmd::general_chat_generate_title, - // Unified Chat commands (统一对话 API) + // Unified Chat commands(统一对话 API,后续治理收口入口) commands::unified_chat_cmd::chat_create_session, commands::unified_chat_cmd::chat_list_sessions, commands::unified_chat_cmd::chat_get_session, diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index f0fa1d933..55cbf6f73 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -8,7 +8,9 @@ use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered; use crate::config::GlobalConfigManagerState; use crate::database::dao::agent::AgentDao; use crate::database::DbConnection; -use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile; +use crate::services::memory_profile_prompt_service::{ + merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources, +}; use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; use crate::services::web_search_runtime_service::apply_web_search_runtime_env; use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; @@ -231,7 +233,12 @@ pub async fn agent_create_session( let base_system_prompt = build_system_prompt_with_skills(system_prompt, skills.as_ref()); let config = config_manager.config(); apply_web_search_runtime_env(&config); - let prompt_with_memory = merge_system_prompt_with_memory_profile(base_system_prompt, &config); + let prompt_with_memory = merge_system_prompt_with_memory_sources( + merge_system_prompt_with_memory_profile(base_system_prompt, &config), + &config, + std::path::Path::new(&workspace_root), + None, + ); let final_system_prompt = merge_system_prompt_with_web_search(prompt_with_memory, &config); // 保存会话到数据库 diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index 71b9e69a9..915309f0e 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -6,8 +6,8 @@ use crate::agent::aster_state::{ProviderConfig, SessionConfigBuilder}; use crate::agent::{ - AsterAgentState, AsterAgentWrapper, HeartbeatServiceAdapter, SessionDetail, SessionInfo, - TauriAgentEvent, + AsterAgentState, AsterAgentWrapper, HeartbeatServiceAdapter, ProxyCastScheduler, SessionDetail, + SessionInfo, SubAgentRole, TauriAgentEvent, }; use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; use crate::commands::webview_cmd::{ @@ -19,13 +19,16 @@ use crate::database::DbConnection; use crate::mcp::{McpManagerState, McpServerConfig}; use crate::services::execution_tracker_service::{ExecutionTracker, RunFinalizeOptions, RunSource}; use crate::services::heartbeat_service::HeartbeatServiceState; -use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile; +use crate::services::memory_profile_prompt_service::{ + merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources, +}; use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; use crate::services::web_search_runtime_service::apply_web_search_runtime_env; use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; use crate::workspace::WorkspaceManager; use crate::LogState; use aster::agents::extension::{Envs, ExtensionConfig}; +use aster::agents::subagent_scheduler::{SchedulerExecutionResult, SubAgentTask}; use aster::agents::{Agent, AgentEvent}; use aster::chrome_mcp::get_chrome_mcp_tools; use aster::conversation::message::{Message, MessageContent}; @@ -36,6 +39,7 @@ use aster::permission::{Permission, PermissionConfirmation, PrincipalType}; use aster::sandbox::{ detect_best_sandbox, execute_in_sandbox, ResourceLimits, SandboxConfig as ProcessSandboxConfig, }; +use aster::tools::task_output_tool::TaskOutputInput; use aster::tools::{ BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager, TaskOutputTool, TaskTool, Tool, ToolContext, ToolError, ToolOptions, ToolResult, @@ -49,6 +53,10 @@ use proxycast_agent::request_tool_policy::{ merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy, stream_reply_with_policy, ReplyAttemptError, RequestToolPolicy, }; +use proxycast_agent::{ + durable_memory_permission_pattern, is_virtual_memory_path, resolve_virtual_memory_path, + virtual_memory_relative_path, DURABLE_MEMORY_VIRTUAL_ROOT, +}; use proxycast_services::api_key_provider_service::ApiKeyProviderService; use proxycast_services::mcp_service::McpService; use proxycast_services::video_generation_service::{ @@ -84,6 +92,8 @@ const PROXYCAST_CREATE_IMAGE_TASK_TOOL_NAME: &str = "proxycast_create_image_gene const PROXYCAST_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "proxycast_create_url_parse_task"; const PROXYCAST_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "proxycast_create_typesetting_task"; const AUTO_CONTINUE_PROMPT_MARKER: &str = "【自动续写策略】"; +const PROXYCAST_TOOL_METADATA_BEGIN: &str = "[ProxyCast 工具元数据开始]"; +const PROXYCAST_TOOL_METADATA_END: &str = "[ProxyCast 工具元数据结束]"; static SHARED_TASK_MANAGER: OnceLock> = OnceLock::new(); @@ -847,6 +857,321 @@ fn normalize_workspace_tool_permission_behavior( } } +fn append_workspace_bash_summary( + mut output: String, + exit_code: i32, + stdout_length: usize, + stderr_length: usize, + sandboxed: bool, + sandbox_type: &str, +) -> String { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + + let output_truncated = output.contains("[output truncated:"); + output.push_str("\n[ProxyCast 执行摘要]\n"); + output.push_str(&format!("exit_code: {exit_code}\n")); + output.push_str(&format!("stdout_length: {stdout_length}\n")); + output.push_str(&format!("stderr_length: {stderr_length}\n")); + output.push_str(&format!("sandboxed: {sandboxed}\n")); + output.push_str(&format!("sandbox_type: {sandbox_type}\n")); + output.push_str(&format!("output_truncated: {output_truncated}")); + output +} + +fn output_contains_proxycast_metadata_block(output: &str) -> bool { + output.contains(PROXYCAST_TOOL_METADATA_BEGIN) && output.contains(PROXYCAST_TOOL_METADATA_END) +} + +fn append_proxycast_tool_metadata_block( + mut content: String, + metadata: &serde_json::Map, +) -> String { + if output_contains_proxycast_metadata_block(&content) { + return content; + } + + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + if !content.is_empty() { + content.push('\n'); + } + + let metadata_json = serde_json::to_string(metadata).unwrap_or_else(|_| "{}".to_string()); + content.push_str(PROXYCAST_TOOL_METADATA_BEGIN); + content.push('\n'); + content.push_str(&metadata_json); + content.push('\n'); + content.push_str(PROXYCAST_TOOL_METADATA_END); + content +} + +fn encode_tool_result_for_harness_observability(result: ToolResult) -> ToolResult { + let mut metadata = result.metadata.clone(); + let base_content = if result.success { + result.output.unwrap_or_default() + } else { + metadata + .entry("reported_success".to_string()) + .or_insert_with(|| serde_json::json!(false)); + result + .error + .unwrap_or_else(|| "工具执行失败,但未返回错误详情".to_string()) + }; + + if result.success && metadata.is_empty() { + return ToolResult::success(base_content); + } + + let encoded_output = + if metadata.is_empty() || output_contains_proxycast_metadata_block(&base_content) { + base_content + } else { + let metadata_object = metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + append_proxycast_tool_metadata_block(base_content, &metadata_object) + }; + + ToolResult::success(encoded_output).with_metadata_map(metadata) +} + +fn remap_virtual_memory_path_param( + params: &mut serde_json::Map, + key: &str, +) -> Result { + let Some(raw_path) = params.get(key).and_then(|value| value.as_str()) else { + return Ok(false); + }; + + let Some(mapped_path) = + resolve_virtual_memory_path(raw_path).map_err(ToolError::invalid_params)? + else { + return Ok(false); + }; + + params.insert( + key.to_string(), + serde_json::Value::String(mapped_path.to_string_lossy().to_string()), + ); + Ok(true) +} + +fn remap_virtual_memory_glob_pattern( + params: &mut serde_json::Map, +) -> Result { + let Some(pattern) = params.get("pattern").and_then(|value| value.as_str()) else { + return Ok(false); + }; + if !is_virtual_memory_path(pattern) { + return Ok(false); + } + + let relative_pattern = virtual_memory_relative_path(pattern).unwrap_or_default(); + if relative_pattern.split('/').any(|segment| segment == "..") { + return Err(ToolError::invalid_params( + "glob.pattern 中的 `/memories/` 路径不允许包含 `..`".to_string(), + )); + } + + let root_path = resolve_virtual_memory_path(DURABLE_MEMORY_VIRTUAL_ROOT) + .map_err(ToolError::invalid_params)? + .ok_or_else(|| ToolError::invalid_params("无法解析 durable memory 根目录".to_string()))?; + + let normalized_pattern = relative_pattern.trim_start_matches('/'); + let normalized_pattern = if normalized_pattern.is_empty() { + "**/*".to_string() + } else { + normalized_pattern.to_string() + }; + + params.insert( + "path".to_string(), + serde_json::Value::String(root_path.to_string_lossy().to_string()), + ); + params.insert( + "pattern".to_string(), + serde_json::Value::String(normalized_pattern), + ); + Ok(true) +} + +fn normalize_params_for_durable_memory_support( + tool_name: &str, + params: &serde_json::Value, +) -> Result { + let Some(map) = params.as_object() else { + return Ok(params.clone()); + }; + + let mut normalized = map.clone(); + let mut changed = false; + + match tool_name { + "read" | "write" | "edit" | "grep" => { + changed |= remap_virtual_memory_path_param(&mut normalized, "path")?; + } + "glob" => { + changed |= remap_virtual_memory_path_param(&mut normalized, "path")?; + changed |= remap_virtual_memory_glob_pattern(&mut normalized)?; + } + _ => {} + } + + if changed { + Ok(serde_json::Value::Object(normalized)) + } else { + Ok(params.clone()) + } +} + +struct DurableMemoryMappedTool { + delegate: Box, +} + +impl DurableMemoryMappedTool { + fn new(delegate: Box) -> Self { + Self { delegate } + } +} + +#[async_trait] +impl Tool for DurableMemoryMappedTool { + fn name(&self) -> &str { + self.delegate.name() + } + + fn description(&self) -> &str { + self.delegate.description() + } + + fn dynamic_description(&self) -> Option { + self.delegate.dynamic_description() + } + + fn input_schema(&self) -> serde_json::Value { + self.delegate.input_schema() + } + + fn options(&self) -> ToolOptions { + self.delegate.options() + } + + async fn check_permissions( + &self, + params: &serde_json::Value, + context: &ToolContext, + ) -> PermissionCheckResult { + let normalized_params = + match normalize_params_for_durable_memory_support(self.name(), params) { + Ok(value) => value, + Err(error) => { + return PermissionCheckResult::deny(format!( + "durable memory 参数无效: {error}" + )); + } + }; + + let mut result = self + .delegate + .check_permissions(&normalized_params, context) + .await; + + if result.updated_params.is_none() && normalized_params != *params { + result.updated_params = Some(normalized_params); + } + result + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let normalized_params = normalize_params_for_durable_memory_support(self.name(), ¶ms)?; + self.delegate.execute(normalized_params, context).await + } +} + +struct HarnessObservedTool { + delegate: Box, +} + +impl HarnessObservedTool { + fn new(delegate: Box) -> Self { + Self { delegate } + } +} + +#[async_trait] +impl Tool for HarnessObservedTool { + fn name(&self) -> &str { + self.delegate.name() + } + + fn description(&self) -> &str { + self.delegate.description() + } + + fn dynamic_description(&self) -> Option { + self.delegate.dynamic_description() + } + + fn input_schema(&self) -> serde_json::Value { + self.delegate.input_schema() + } + + fn options(&self) -> ToolOptions { + self.delegate.options() + } + + async fn check_permissions( + &self, + params: &serde_json::Value, + context: &ToolContext, + ) -> PermissionCheckResult { + self.delegate.check_permissions(params, context).await + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + self.delegate + .execute(params, context) + .await + .map(encode_tool_result_for_harness_observability) + } +} + +fn wrap_registry_native_tools_for_harness_observability(registry: &mut aster::tools::ToolRegistry) { + let tool_names = registry + .native_tool_names() + .into_iter() + .map(|name| name.to_string()) + .collect::>(); + + for tool_name in tool_names { + let Some(tool) = registry.unregister(&tool_name) else { + continue; + }; + registry.register(Box::new(HarnessObservedTool::new(tool))); + } +} + +fn wrap_registry_native_tools_for_durable_memory_fs(registry: &mut aster::tools::ToolRegistry) { + for tool_name in ["read", "write", "edit", "glob", "grep"] { + let Some(tool) = registry.unregister(tool_name) else { + continue; + }; + registry.register(Box::new(DurableMemoryMappedTool::new(tool))); + } +} + #[async_trait] impl Tool for WorkspaceSandboxedBashTool { fn name(&self) -> &str { @@ -938,7 +1263,14 @@ impl Tool for WorkspaceSandboxedBashTool { .map_err(|_| ToolError::timeout(Duration::from_secs(timeout_secs)))? .map_err(|e| ToolError::execution_failed(format!("sandbox 执行失败: {e}")))?; - let output = Self::format_output(&execution.stdout, &execution.stderr, execution.exit_code); + let output = append_workspace_bash_summary( + Self::format_output(&execution.stdout, &execution.stderr, execution.exit_code), + execution.exit_code, + execution.stdout.len(), + execution.stderr.len(), + execution.sandboxed, + &format!("{:?}", execution.sandbox_type), + ); if execution.exit_code == 0 { Ok(ToolResult::success(output) .with_metadata("exit_code", serde_json::json!(execution.exit_code)) @@ -950,7 +1282,7 @@ impl Tool for WorkspaceSandboxedBashTool { serde_json::json!(format!("{:?}", execution.sandbox_type)), )) } else { - Ok(ToolResult::error(output) + Ok(ToolResult::success(output) .with_metadata("exit_code", serde_json::json!(execution.exit_code)) .with_metadata("stdout_length", serde_json::json!(execution.stdout.len())) .with_metadata("stderr_length", serde_json::json!(execution.stderr.len())) @@ -958,7 +1290,8 @@ impl Tool for WorkspaceSandboxedBashTool { .with_metadata( "sandbox_type", serde_json::json!(format!("{:?}", execution.sandbox_type)), - )) + ) + .with_metadata("reported_success", serde_json::json!(false))) } } } @@ -1019,6 +1352,354 @@ impl Tool for WorkspaceTaskTool { } } +struct WorkspaceTaskOutputTool { + delegate: TaskOutputTool, + task_manager: Arc, +} + +impl WorkspaceTaskOutputTool { + fn new(task_manager: Arc) -> Self { + Self { + delegate: TaskOutputTool::with_manager(task_manager.clone()), + task_manager, + } + } +} + +#[async_trait] +impl Tool for WorkspaceTaskOutputTool { + fn name(&self) -> &str { + self.delegate.name() + } + + fn description(&self) -> &str { + self.delegate.description() + } + + fn input_schema(&self) -> serde_json::Value { + self.delegate.input_schema() + } + + fn options(&self) -> ToolOptions { + self.delegate.options() + } + + async fn check_permissions( + &self, + params: &serde_json::Value, + context: &ToolContext, + ) -> PermissionCheckResult { + self.delegate.check_permissions(params, context).await + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input = serde_json::from_value::(params.clone()).ok(); + let mut result = self.delegate.execute(params, context).await?; + + let Some(task_id) = input.map(|value| value.task_id) else { + return Ok(result); + }; + + let Some(state) = self.task_manager.get_status(&task_id).await else { + return Ok(result); + }; + + result = result + .with_metadata( + "output_file", + serde_json::json!(state.output_file.to_string_lossy().to_string()), + ) + .with_metadata( + "working_directory", + serde_json::json!(state.working_directory.to_string_lossy().to_string()), + ) + .with_metadata("session_id", serde_json::json!(state.session_id)) + .with_metadata("status", serde_json::json!(state.status.to_string())); + + if let Some(exit_code) = state.exit_code { + result = result.with_metadata("exit_code", serde_json::json!(exit_code)); + } + + Ok(result) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SubAgentTaskToolInput { + prompt: String, + task_type: Option, + description: Option, + role: Option, + timeout_secs: Option, + model: Option, + return_summary: Option, + allowed_tools: Option>, + denied_tools: Option>, + max_tokens: Option, +} + +fn parse_subagent_role(raw: Option<&str>) -> Result { + let normalized = raw + .map(|value| value.trim().to_ascii_lowercase()) + .unwrap_or_else(|| "executor".to_string()); + + match normalized.as_str() { + "" | "executor" | "execute" | "code" => Ok(SubAgentRole::Executor), + "planner" | "plan" => Ok(SubAgentRole::Planner), + "explorer" | "explore" | "research" => Ok(SubAgentRole::Explorer), + _ => Err(ToolError::invalid_params(format!( + "未知 SubAgent 角色: {},支持 explorer/planner/executor", + normalized + ))), + } +} + +fn default_subagent_task_type(role: SubAgentRole) -> &'static str { + match role { + SubAgentRole::Explorer => "explore", + SubAgentRole::Planner => "plan", + SubAgentRole::Executor => "code", + } +} + +fn build_subagent_task_definition( + input: &SubAgentTaskToolInput, + role: SubAgentRole, +) -> Result { + let prompt = input.prompt.trim(); + if prompt.is_empty() { + return Err(ToolError::invalid_params( + "SubAgentTask.prompt 不能为空".to_string(), + )); + } + + let task_type = input + .task_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(default_subagent_task_type(role)); + + let mut task = SubAgentTask::new(uuid::Uuid::new_v4().to_string(), task_type, prompt); + + if let Some(description) = input + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + task = task.with_description(description.to_string()); + } + + if let Some(timeout_secs) = input.timeout_secs.filter(|value| *value > 0) { + task = task.with_timeout(Duration::from_secs(timeout_secs)); + } + + if let Some(model) = input + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + task = task.with_model(model.to_string()); + } + + if let Some(return_summary) = input.return_summary { + task = task.with_summary(return_summary); + } + + if let Some(allowed_tools) = input + .allowed_tools + .as_ref() + .filter(|items| !items.is_empty()) + { + task = task.with_allowed_tools(allowed_tools.clone()); + } + + if let Some(denied_tools) = input + .denied_tools + .as_ref() + .filter(|items| !items.is_empty()) + { + task = task.with_denied_tools(denied_tools.clone()); + } + + if let Some(max_tokens) = input.max_tokens.filter(|value| *value > 0) { + task = task.with_max_tokens(max_tokens); + } + + Ok(task) +} + +fn summarize_subagent_execution( + role: SubAgentRole, + execution: &SchedulerExecutionResult, +) -> String { + let merged_summary = execution + .merged_summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| { + execution.results.iter().find_map(|result| { + result + .summary + .as_deref() + .or(result.output.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + }) + .unwrap_or_else(|| "未返回摘要".to_string()); + + format!( + "SubAgent({}) 完成:成功 {},失败 {},跳过 {}。{}", + role, + execution.successful_count, + execution.failed_count, + execution.skipped_count, + merged_summary + ) +} + +#[derive(Debug, Clone)] +struct SubAgentTaskTool { + db: DbConnection, + app_handle: AppHandle, +} + +impl SubAgentTaskTool { + fn new(db: DbConnection, app_handle: AppHandle) -> Self { + Self { db, app_handle } + } +} + +#[async_trait] +impl Tool for SubAgentTaskTool { + fn name(&self) -> &str { + "SubAgentTask" + } + + fn description(&self) -> &str { + "将独立子问题委派给隔离上下文的子代理执行,并返回摘要结果" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "子代理要执行的任务说明" + }, + "taskType": { + "type": "string", + "description": "任务类型,例如 explore、plan、code、review" + }, + "description": { + "type": "string", + "description": "展示给用户的任务标题" + }, + "role": { + "type": "string", + "description": "子代理角色:explorer、planner、executor" + }, + "timeoutSecs": { + "type": "integer", + "minimum": 1, + "description": "单个子任务超时时间(秒)" + }, + "model": { + "type": "string", + "description": "可选模型名" + }, + "returnSummary": { + "type": "boolean", + "description": "是否优先返回摘要" + }, + "allowedTools": { + "type": "array", + "items": { "type": "string" }, + "description": "显式允许的工具列表" + }, + "deniedTools": { + "type": "array", + "items": { "type": "string" }, + "description": "显式拒绝的工具列表" + }, + "maxTokens": { + "type": "integer", + "minimum": 1, + "description": "子代理最大 token 限制" + } + }, + "required": ["prompt"], + "additionalProperties": false + }) + } + + fn options(&self) -> ToolOptions { + ToolOptions::new() + .with_max_retries(0) + .with_base_timeout(Duration::from_secs(900)) + .with_dynamic_timeout(false) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: SubAgentTaskToolInput = serde_json::from_value(params) + .map_err(|err| ToolError::invalid_params(format!("SubAgentTask 参数无效: {err}")))?; + let role = parse_subagent_role(input.role.as_deref())?; + let task = build_subagent_task_definition(&input, role)?; + let task_id = task.id.clone(); + + let mut scheduler = + ProxyCastScheduler::new(self.db.clone()).with_app_handle(self.app_handle.clone()); + if !context.session_id.trim().is_empty() { + scheduler = scheduler.with_event_session_id(context.session_id.clone()); + } + scheduler.init(None).await; + + let execution = scheduler + .execute_with_role(vec![task], None, role) + .await + .map_err(|err| ToolError::execution_failed(format!("SubAgentTask 执行失败: {err}")))?; + + let summary = summarize_subagent_execution(role, &execution); + let metadata = serde_json::json!({ + "task_id": task_id, + "role": role.to_string(), + "success": execution.success, + "successful_count": execution.successful_count, + "failed_count": execution.failed_count, + "skipped_count": execution.skipped_count, + "merged_summary": execution.merged_summary, + "results": execution.results, + "total_token_usage": execution.total_token_usage, + }); + + if execution.success { + Ok(ToolResult::success(summary) + .with_metadata("subagent", metadata) + .with_metadata("role", serde_json::json!(role.to_string()))) + } else { + Ok(ToolResult::error(summary) + .with_metadata("subagent", metadata) + .with_metadata("role", serde_json::json!(role.to_string()))) + } + } +} + #[derive(Debug, Clone)] struct ProxycastBrowserMcpTool { tool_name: String, @@ -2586,7 +3267,9 @@ async fn apply_workspace_sandbox_permissions( }; let escaped_root = regex::escape(workspace_root); - let workspace_path_pattern = format!(r"^({escaped_root}|\.|\./|\.\./).*$"); + let virtual_memory_path_pattern = durable_memory_permission_pattern(); + let workspace_path_pattern = + format!(r"^(?:({escaped_root}|\.|\./|\.\./).*$|{virtual_memory_path_pattern})"); let workspace_abs_path_pattern = format!(r"^({escaped_root}).*$"); let analyze_image_path_pattern = format!( r"^(base64:[A-Za-z0-9+/=]+|file://({escaped_root}).*|({escaped_root}|\.|\./|\.\./).*)$" @@ -2610,14 +3293,16 @@ async fn apply_workspace_sandbox_permissions( min: None, max: None, required: true, - description: Some("read.path 必须在 workspace 内或相对路径".to_string()), + description: Some( + "read.path 必须在 workspace、相对路径或 `/memories/...` 内".to_string(), + ), }] }, scope: PermissionScope::Session, reason: Some(if auto_mode { "Auto 模式:允许读取任意路径".to_string() } else { - "仅允许读取当前 workspace 内容".to_string() + "仅允许读取当前 workspace 或 `/memories/` 内容".to_string() }), expires_at: None, metadata: HashMap::new(), @@ -2639,14 +3324,16 @@ async fn apply_workspace_sandbox_permissions( min: None, max: None, required: true, - description: Some("write.path 必须在 workspace 内或相对路径".to_string()), + description: Some( + "write.path 必须在 workspace、相对路径或 `/memories/...` 内".to_string(), + ), }] }, scope: PermissionScope::Session, reason: Some(if auto_mode { "Auto 模式:允许写入任意路径".to_string() } else { - "仅允许写入当前 workspace 内容".to_string() + "仅允许写入当前 workspace 或 `/memories/` 内容".to_string() }), expires_at: None, metadata: HashMap::new(), @@ -2668,14 +3355,16 @@ async fn apply_workspace_sandbox_permissions( min: None, max: None, required: true, - description: Some("edit.path 必须在 workspace 内或相对路径".to_string()), + description: Some( + "edit.path 必须在 workspace、相对路径或 `/memories/...` 内".to_string(), + ), }] }, scope: PermissionScope::Session, reason: Some(if auto_mode { "Auto 模式:允许编辑任意路径".to_string() } else { - "仅允许编辑当前 workspace 内容".to_string() + "仅允许编辑当前 workspace 或 `/memories/` 内容".to_string() }), expires_at: None, metadata: HashMap::new(), @@ -2697,14 +3386,16 @@ async fn apply_workspace_sandbox_permissions( min: None, max: None, required: false, - description: Some("glob.path 必须在 workspace 内或相对路径".to_string()), + description: Some( + "glob.path 必须在 workspace、相对路径或 `/memories/...` 内".to_string(), + ), }] }, scope: PermissionScope::Session, reason: Some(if auto_mode { "Auto 模式:允许任意路径搜索文件".to_string() } else { - "仅允许在当前 workspace 搜索文件".to_string() + "仅允许在当前 workspace 或 `/memories/` 搜索文件".to_string() }), expires_at: None, metadata: HashMap::new(), @@ -2726,14 +3417,16 @@ async fn apply_workspace_sandbox_permissions( min: None, max: None, required: false, - description: Some("grep.path 必须在 workspace 内或相对路径".to_string()), + description: Some( + "grep.path 必须在 workspace、相对路径或 `/memories/...` 内".to_string(), + ), }] }, scope: PermissionScope::Session, reason: Some(if auto_mode { "Auto 模式:允许任意路径搜索内容".to_string() } else { - "仅允许在当前 workspace 搜索内容".to_string() + "仅允许在当前 workspace 或 `/memories/` 搜索内容".to_string() }), expires_at: None, metadata: HashMap::new(), @@ -2982,6 +3675,7 @@ async fn apply_workspace_sandbox_permissions( for tool_name in [ "Skill", + "SubAgentTask", "TaskOutput", "KillShell", "TodoWrite", @@ -3067,7 +3761,11 @@ async fn apply_workspace_sandbox_permissions( auto_mode, task_manager.clone(), ))); - registry.register(Box::new(TaskOutputTool::with_manager(task_manager.clone()))); + registry.register(Box::new(SubAgentTaskTool::new( + db.clone(), + app_handle.clone(), + ))); + registry.register(Box::new(WorkspaceTaskOutputTool::new(task_manager.clone()))); registry.register(Box::new(KillShellTool::with_task_manager(task_manager))); if let Some(workspace_bash_tool) = sandboxed_bash_tool { @@ -3090,6 +3788,8 @@ async fn apply_workspace_sandbox_permissions( // 注册浏览器 MCP 工具 register_browser_mcp_tools_to_registry(&mut registry); + wrap_registry_native_tools_for_durable_memory_fs(&mut registry); + wrap_registry_native_tools_for_harness_observability(&mut registry); Ok(apply_outcome) } @@ -3345,12 +4045,15 @@ pub async fn aster_agent_chat_stream( } }; + let prompt_with_memory = merge_system_prompt_with_memory_sources( + merge_system_prompt_with_memory_profile(resolved_prompt, &runtime_config), + &runtime_config, + Path::new(&workspace_root), + None, + ); let merged_prompt = merge_system_prompt_with_auto_continue( merge_system_prompt_with_request_tool_policy( - merge_system_prompt_with_web_search( - merge_system_prompt_with_memory_profile(resolved_prompt, &runtime_config), - &runtime_config, - ), + merge_system_prompt_with_web_search(prompt_with_memory, &runtime_config), &request_tool_policy, ), auto_continue_config.as_ref(), @@ -3856,7 +4559,10 @@ mod tests { use super::*; use async_trait::async_trait; use regex::Regex; - use std::path::PathBuf; + use std::ffi::OsString; + use std::path::{Path, PathBuf}; + use std::sync::{Mutex, OnceLock}; + use tempfile::TempDir; struct DummyTool { name: String, @@ -3897,6 +4603,33 @@ mod tests { } } + fn durable_memory_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct DurableMemoryEnvGuard { + previous: Option, + } + + impl DurableMemoryEnvGuard { + fn set(path: &Path) -> Self { + let previous = std::env::var_os("PROXYCAST_DURABLE_MEMORY_DIR"); + std::env::set_var("PROXYCAST_DURABLE_MEMORY_DIR", path.as_os_str()); + Self { previous } + } + } + + impl Drop for DurableMemoryEnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous { + std::env::set_var("PROXYCAST_DURABLE_MEMORY_DIR", value); + } else { + std::env::remove_var("PROXYCAST_DURABLE_MEMORY_DIR"); + } + } + } + #[test] fn test_aster_chat_request_deserialize() { let json = r#"{ @@ -4234,6 +4967,113 @@ mod tests { ); } + #[test] + fn test_normalize_params_for_durable_memory_support_maps_read_path() { + let _lock = durable_memory_test_lock().lock().expect("lock env"); + let tmp = TempDir::new().expect("create temp dir"); + let _env = DurableMemoryEnvGuard::set(tmp.path()); + + let input = serde_json::json!({ + "path": "/memories/preferences.md" + }); + let normalized = normalize_params_for_durable_memory_support("read", &input) + .expect("normalize read params"); + let expected = tmp + .path() + .join("preferences.md") + .to_string_lossy() + .to_string(); + + assert_eq!( + normalized.get("path").and_then(|value| value.as_str()), + Some(expected.as_str()) + ); + } + + #[test] + fn test_normalize_params_for_durable_memory_support_rewrites_glob_pattern() { + let _lock = durable_memory_test_lock().lock().expect("lock env"); + let tmp = TempDir::new().expect("create temp dir"); + let _env = DurableMemoryEnvGuard::set(tmp.path()); + + let input = serde_json::json!({ + "pattern": "/memories/**/*.md" + }); + let normalized = normalize_params_for_durable_memory_support("glob", &input) + .expect("normalize glob params"); + let expected_root = tmp.path().to_string_lossy().to_string(); + + assert_eq!( + normalized.get("path").and_then(|value| value.as_str()), + Some(expected_root.as_str()) + ); + assert_eq!( + normalized.get("pattern").and_then(|value| value.as_str()), + Some("**/*.md") + ); + } + + #[test] + fn test_normalize_params_for_durable_memory_support_rejects_glob_parent_segments() { + let _lock = durable_memory_test_lock().lock().expect("lock env"); + let tmp = TempDir::new().expect("create temp dir"); + let _env = DurableMemoryEnvGuard::set(tmp.path()); + + let input = serde_json::json!({ + "pattern": "/memories/../escape.md" + }); + let error = normalize_params_for_durable_memory_support("glob", &input) + .expect_err("should reject parent path"); + + assert!(error.to_string().contains("不允许包含 `..`")); + } + + #[test] + fn test_encode_tool_result_for_harness_observability_appends_metadata_block() { + let result = ToolResult::success("任务已完成") + .with_metadata("output_file", serde_json::json!("/tmp/task.log")) + .with_metadata("exit_code", serde_json::json!(0)); + + let encoded = encode_tool_result_for_harness_observability(result); + assert!(encoded.success); + assert!(encoded + .output + .as_deref() + .unwrap_or_default() + .contains(PROXYCAST_TOOL_METADATA_BEGIN)); + assert!(encoded + .output + .as_deref() + .unwrap_or_default() + .contains("\"output_file\":\"/tmp/task.log\"")); + } + + #[test] + fn test_encode_tool_result_for_harness_observability_converts_error_to_success_output() { + let result = + ToolResult::error("执行失败").with_metadata("failed_count", serde_json::json!(1)); + + let encoded = encode_tool_result_for_harness_observability(result); + assert!(encoded.success); + let output = encoded.output.as_deref().unwrap_or_default(); + assert!(output.contains("执行失败")); + assert!(output.contains(PROXYCAST_TOOL_METADATA_BEGIN)); + assert!(output.contains("\"reported_success\":false")); + } + + #[test] + fn test_encode_tool_result_for_harness_observability_is_idempotent() { + let initial = ToolResult::success(format!( + "ok\n\n{PROXYCAST_TOOL_METADATA_BEGIN}\n{{\"reported_success\":false}}\n{PROXYCAST_TOOL_METADATA_END}" + )) + .with_metadata("reported_success", serde_json::json!(false)); + + let encoded = encode_tool_result_for_harness_observability(initial); + let output = encoded.output.as_deref().unwrap_or_default(); + assert_eq!(output.matches(PROXYCAST_TOOL_METADATA_BEGIN).count(), 1); + assert_eq!(output.matches(PROXYCAST_TOOL_METADATA_END).count(), 1); + } + #[test] fn test_shared_task_manager_returns_same_instance() { let first = shared_task_manager(); @@ -4241,6 +5081,72 @@ mod tests { assert!(Arc::ptr_eq(&first, &second)); } + #[test] + fn test_parse_subagent_role_supports_aliases() { + assert_eq!( + parse_subagent_role(Some("explore")).unwrap(), + SubAgentRole::Explorer + ); + assert_eq!( + parse_subagent_role(Some("plan")).unwrap(), + SubAgentRole::Planner + ); + assert_eq!( + parse_subagent_role(Some("code")).unwrap(), + SubAgentRole::Executor + ); + assert_eq!(parse_subagent_role(None).unwrap(), SubAgentRole::Executor); + } + + #[test] + fn test_build_subagent_task_definition_uses_role_defaults() { + let input = SubAgentTaskToolInput { + prompt: "分析当前 harness 缺口".to_string(), + task_type: None, + description: None, + role: Some("explorer".to_string()), + timeout_secs: Some(45), + model: None, + return_summary: None, + allowed_tools: None, + denied_tools: None, + max_tokens: None, + }; + + let task = build_subagent_task_definition(&input, SubAgentRole::Explorer).unwrap(); + assert_eq!(task.task_type, "explore"); + assert_eq!(task.timeout.map(|value| value.as_secs()), Some(45)); + assert!(task.return_summary); + } + + #[test] + fn test_build_subagent_task_definition_applies_optional_fields() { + let input = SubAgentTaskToolInput { + prompt: "实现 harness 面板".to_string(), + task_type: Some("code".to_string()), + description: Some("实现前端面板".to_string()), + role: Some("executor".to_string()), + timeout_secs: Some(120), + model: Some("claude-sonnet-4-20250514".to_string()), + return_summary: Some(false), + allowed_tools: Some(vec!["read_file".to_string(), "write_file".to_string()]), + denied_tools: Some(vec!["execute_command".to_string()]), + max_tokens: Some(4096), + }; + + let task = build_subagent_task_definition(&input, SubAgentRole::Executor).unwrap(); + assert_eq!(task.task_type, "code"); + assert_eq!(task.description.as_deref(), Some("实现前端面板")); + assert_eq!(task.model.as_deref(), Some("claude-sonnet-4-20250514")); + assert!(!task.return_summary); + assert_eq!( + task.allowed_tools, + Some(vec!["read_file".to_string(), "write_file".to_string()]) + ); + assert_eq!(task.denied_tools, Some(vec!["execute_command".to_string()])); + assert_eq!(task.max_tokens, Some(4096)); + } + #[test] fn test_tool_search_parse_schema_metadata() { let schema = serde_json::json!({ diff --git a/src-tauri/src/commands/general_chat_cmd.rs b/src-tauri/src/commands/general_chat_cmd.rs index b64f99372..5c446b4ae 100644 --- a/src-tauri/src/commands/general_chat_cmd.rs +++ b/src-tauri/src/commands/general_chat_cmd.rs @@ -1,6 +1,7 @@ -//! 通用对话 Tauri 命令模块 +//! 通用对话 Tauri 命令兼容层 //! -//! 提供通用对话功能的前端调用接口 +//! 该模块仅用于兼容旧版 `general-chat` 前端链路。 +//! 新功能和后续治理请统一收口到 `unified_chat_cmd`。 //! //! ## 主要命令 //! - `general_chat_create_session` - 创建新会话 @@ -8,22 +9,646 @@ //! - `general_chat_get_session` - 获取会话详情 //! - `general_chat_delete_session` - 删除会话 //! - `general_chat_rename_session` - 重命名会话 -//! - `general_chat_send_message` - 发送消息(流式响应) -//! - `general_chat_stop_generation` - 停止生成 +//! - `general_chat_add_message` - 已废弃直接写消息(显式报错) +//! - `general_chat_send_message` - 已废弃流式发送(显式报错) +//! - `general_chat_stop_generation` - 已废弃停止生成(显式报错) +//! - `general_chat_generate_title` - 已废弃标题生成(显式报错) //! - `general_chat_get_messages` - 获取消息列表 +use crate::database::dao::chat::{ + ChatDao, ChatMessage as UnifiedChatMessage, ChatMode, ChatSession as UnifiedChatSession, +}; use crate::database::dao::general_chat::GeneralChatDao; use crate::database::DbConnection; +use once_cell::sync::Lazy; use proxycast_services::general_chat::{ ChatMessage, ChatSession, ContentBlock, MessageRole, SessionDetail, }; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; use tauri::State; use uuid::Uuid; +static LEGACY_WARNED_COMMANDS: Lazy>> = + Lazy::new(|| Mutex::new(HashSet::new())); + +fn warn_general_chat_legacy(command: &'static str, replacement: &'static str) { + let should_warn = match LEGACY_WARNED_COMMANDS.lock() { + Ok(mut warned) => warned.insert(command), + Err(error) => { + tracing::warn!( + "[GeneralChat][Compat] 废弃命令告警状态异常: {}。命令 {} 仍通过兼容层提供,建议迁移到 {}", + error, + command, + replacement + ); + true + } + }; + + if should_warn { + tracing::warn!( + "[GeneralChat][Compat] 命令 {} 仍通过兼容层提供,建议迁移到 {}。该入口仅用于兼容旧 UI,禁止继续叠加新逻辑。", + command, + replacement + ); + } +} + +fn general_chat_deprecated_error(command: &'static str, replacement: &'static str) -> String { + format!( + "命令 {command} 已废弃,请迁移到 {replacement}。该兼容入口已停止维护,禁止继续叠加新逻辑。" + ) +} + +fn timestamp_ms_to_rfc3339(timestamp_ms: i64) -> String { + use chrono::{TimeZone, Utc}; + + let secs = timestamp_ms / 1000; + let nsecs = ((timestamp_ms % 1000) * 1_000_000) as u32; + + match Utc.timestamp_opt(secs, nsecs) { + chrono::LocalResult::Single(dt) => dt.to_rfc3339(), + _ => Utc::now().to_rfc3339(), + } +} + +fn rfc3339_to_timestamp_ms(timestamp: &str) -> i64 { + chrono::DateTime::parse_from_rfc3339(timestamp) + .map(|value| value.timestamp_millis()) + .unwrap_or_else(|_| chrono::Utc::now().timestamp_millis()) +} + +fn general_message_role_name(role: &MessageRole) -> &'static str { + match role { + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::System => "system", + } +} + +fn overlay_general_session( + legacy_session: Option, + unified_session: Option<&UnifiedChatSession>, +) -> Option { + match (legacy_session, unified_session) { + (None, None) => None, + (Some(mut session), Some(unified)) if unified.mode == ChatMode::General => { + if let Some(title) = unified + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + { + session.name = title.to_string(); + } + session.created_at = session + .created_at + .min(rfc3339_to_timestamp_ms(&unified.created_at)); + session.updated_at = session + .updated_at + .max(rfc3339_to_timestamp_ms(&unified.updated_at)); + Some(session) + } + (Some(session), _) => Some(session), + (None, Some(unified)) if unified.mode == ChatMode::General => Some(ChatSession { + id: unified.id.clone(), + name: unified + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .unwrap_or("新对话") + .to_string(), + created_at: rfc3339_to_timestamp_ms(&unified.created_at), + updated_at: rfc3339_to_timestamp_ms(&unified.updated_at), + metadata: unified.metadata.clone(), + }), + _ => None, + } +} + +fn json_value_as_str(value: Option<&serde_json::Value>) -> Option { + value + .and_then(|item| item.as_str()) + .map(ToString::to_string) + .filter(|item| !item.trim().is_empty()) +} + +fn convert_unified_content_part_to_general_block( + object: &serde_json::Map, +) -> Option { + if let Some(text) = object.get("Text").and_then(|value| value.as_str()) { + return Some(ContentBlock { + r#type: "text".to_string(), + content: text.to_string(), + language: None, + filename: None, + mime_type: None, + }); + } + + if let Some(text_obj) = object.get("Text").and_then(|value| value.as_object()) { + if let Some(text) = json_value_as_str(text_obj.get("text")) + .or_else(|| json_value_as_str(text_obj.get("content"))) + { + return Some(ContentBlock { + r#type: "text".to_string(), + content: text, + language: None, + filename: None, + mime_type: None, + }); + } + } + + if let Some(text) = json_value_as_str(object.get("text")) { + return Some(ContentBlock { + r#type: "text".to_string(), + content: text, + language: None, + filename: None, + mime_type: None, + }); + } + + let part_type = object.get("type").and_then(|value| value.as_str()); + + if matches!( + part_type, + Some("text" | "input_text" | "output_text" | "thinking") + ) { + if let Some(text) = json_value_as_str(object.get("content")) + .or_else(|| json_value_as_str(object.get("text"))) + { + return Some(ContentBlock { + r#type: "text".to_string(), + content: text, + language: None, + filename: None, + mime_type: None, + }); + } + } + + if part_type == Some("code") { + if let Some(code) = json_value_as_str(object.get("content")) + .or_else(|| json_value_as_str(object.get("text"))) + { + return Some(ContentBlock { + r#type: "code".to_string(), + content: code, + language: json_value_as_str(object.get("language")), + filename: json_value_as_str(object.get("filename")), + mime_type: None, + }); + } + } + + if part_type == Some("file") { + if let Some(path) = json_value_as_str(object.get("path")) + .or_else(|| json_value_as_str(object.get("file_path"))) + .or_else(|| json_value_as_str(object.get("filePath"))) + .or_else(|| json_value_as_str(object.get("content"))) + { + return Some(ContentBlock { + r#type: "file".to_string(), + content: path, + language: None, + filename: json_value_as_str(object.get("name")) + .or_else(|| json_value_as_str(object.get("filename"))), + mime_type: json_value_as_str(object.get("mime_type")) + .or_else(|| json_value_as_str(object.get("media_type"))), + }); + } + } + + if matches!(part_type, Some("image_url" | "input_image")) { + let image_url = object.get("image_url").or_else(|| object.get("url")); + let url = image_url + .and_then(|value| value.as_str().map(ToString::to_string)) + .or_else(|| { + image_url + .and_then(|value| value.as_object()) + .and_then(|value| json_value_as_str(value.get("url"))) + }); + + if let Some(url) = url { + return Some(ContentBlock { + r#type: "image".to_string(), + content: url, + language: None, + filename: None, + mime_type: None, + }); + } + } + + if part_type == Some("image") { + if let Some(url) = json_value_as_str(object.get("url")) + .or_else(|| json_value_as_str(object.get("image_url"))) + { + return Some(ContentBlock { + r#type: "image".to_string(), + content: url, + language: None, + filename: None, + mime_type: None, + }); + } + + let source = object.get("source").and_then(|value| value.as_object()); + let mime_type = json_value_as_str(object.get("mime_type")) + .or_else(|| json_value_as_str(object.get("media_type"))) + .or_else(|| { + source + .and_then(|value| json_value_as_str(value.get("mime_type"))) + .or_else(|| source.and_then(|value| json_value_as_str(value.get("media_type")))) + }); + let data = json_value_as_str(object.get("data")) + .or_else(|| json_value_as_str(object.get("image_base64"))) + .or_else(|| source.and_then(|value| json_value_as_str(value.get("data")))); + + if let (Some(mime_type), Some(data)) = (mime_type, data) { + return Some(ContentBlock { + r#type: "image".to_string(), + content: format!("data:{mime_type};base64,{data}"), + language: None, + filename: None, + mime_type: Some(mime_type), + }); + } + } + + if let Some(image_url_obj) = object.get("image_url").and_then(|value| value.as_object()) { + if let Some(url) = json_value_as_str(image_url_obj.get("url")) { + return Some(ContentBlock { + r#type: "image".to_string(), + content: url, + language: None, + filename: None, + mime_type: None, + }); + } + } + + if let Some(url) = json_value_as_str(object.get("image_url")) { + return Some(ContentBlock { + r#type: "image".to_string(), + content: url, + language: None, + filename: None, + mime_type: None, + }); + } + + if let Some(text) = json_value_as_str(object.get("content")) { + return Some(ContentBlock { + r#type: "text".to_string(), + content: text, + language: None, + filename: None, + mime_type: None, + }); + } + + None +} + +fn convert_unified_content_to_general_parts( + content: &serde_json::Value, +) -> (String, Option>) { + match content { + serde_json::Value::String(text) => (text.clone(), None), + serde_json::Value::Array(items) => { + let blocks: Vec = items + .iter() + .filter_map(|item| item.as_object()) + .filter_map(convert_unified_content_part_to_general_block) + .collect(); + + if blocks.is_empty() { + return (serde_json::to_string(content).unwrap_or_default(), None); + } + + let text_content = blocks + .iter() + .filter(|block| matches!(block.r#type.as_str(), "text" | "code" | "file")) + .map(|block| block.content.clone()) + .collect::>() + .join("\n") + .trim() + .to_string(); + + let content = if !text_content.is_empty() { + text_content + } else if blocks.iter().any(|block| block.r#type == "image") { + "[图片]".to_string() + } else { + serde_json::to_string(content).unwrap_or_default() + }; + + (content, Some(blocks)) + } + serde_json::Value::Object(object) => { + let block = convert_unified_content_part_to_general_block(object); + if let Some(block) = block { + let content = if matches!(block.r#type.as_str(), "text" | "code" | "file") { + block.content.clone() + } else if block.r#type == "image" { + "[图片]".to_string() + } else { + serde_json::to_string(content).unwrap_or_default() + }; + (content, Some(vec![block])) + } else { + (serde_json::to_string(content).unwrap_or_default(), None) + } + } + _ => (serde_json::to_string(content).unwrap_or_default(), None), + } +} + +fn convert_unified_message_to_general(message: UnifiedChatMessage) -> Option { + let role = match message.role.as_str() { + "user" => MessageRole::User, + "assistant" => MessageRole::Assistant, + "system" => MessageRole::System, + _ => return None, + }; + + let (content, blocks) = convert_unified_content_to_general_parts(&message.content); + if content.trim().is_empty() && blocks.as_ref().is_none_or(Vec::is_empty) { + return None; + } + + Some(ChatMessage { + id: message.id.to_string(), + session_id: message.session_id, + role, + content, + blocks, + status: "complete".to_string(), + created_at: rfc3339_to_timestamp_ms(&message.created_at), + metadata: message.metadata, + }) +} + +fn general_message_identity_key(message: &ChatMessage) -> String { + let blocks_signature = + serde_json::to_string(&message.blocks).unwrap_or_else(|_| "[]".to_string()); + format!( + "{}|{}|{}|{}", + general_message_role_name(&message.role), + message.created_at, + message.content, + blocks_signature + ) +} + +fn merge_general_message_sources( + legacy_messages: Vec, + unified_messages: Vec, +) -> Vec { + let mut seen = HashSet::new(); + let mut merged = Vec::new(); + + for message in legacy_messages + .into_iter() + .chain(unified_messages.into_iter()) + { + if seen.insert(general_message_identity_key(&message)) { + merged.push(message); + } + } + + merged.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + + merged +} + +fn paginate_general_messages( + messages: Vec, + limit: Option, + before_id: Option<&str>, +) -> Vec { + let filtered = if let Some(before_id) = before_id { + if let Some(index) = messages.iter().position(|message| message.id == before_id) { + messages.into_iter().take(index).collect::>() + } else { + messages + } + } else { + messages + }; + + let Some(limit) = limit else { + return filtered; + }; + + let limit = limit.max(0) as usize; + if limit == 0 || filtered.len() <= limit { + return filtered; + } + + filtered[filtered.len() - limit..].to_vec() +} + +fn load_merged_general_messages( + conn: &rusqlite::Connection, + session_id: &str, +) -> Result, String> { + let legacy_messages = if GeneralChatDao::session_exists(conn, session_id) + .map_err(|e| format!("检查 general_chat 会话失败: {e}"))? + { + GeneralChatDao::get_messages(conn, session_id, None, None) + .map_err(|e| format!("读取 general_chat 消息失败: {e}"))? + } else { + Vec::new() + }; + + let unified_messages = match ChatDao::get_session(conn, session_id) + .map_err(|e| format!("读取 unified 会话失败: {e}"))? + { + Some(session) if session.mode == ChatMode::General => { + ChatDao::get_messages(conn, session_id, None) + .map_err(|e| format!("读取 unified 消息失败: {e}"))? + .into_iter() + .filter_map(convert_unified_message_to_general) + .collect() + } + _ => Vec::new(), + }; + + Ok(merge_general_message_sources( + legacy_messages, + unified_messages, + )) +} + +fn ensure_general_session_shadow( + conn: &rusqlite::Connection, + session: &ChatSession, +) -> Result<(), String> { + if ChatDao::session_exists(conn, &session.id) + .map_err(|e| format!("检查 unified 会话失败: {e}"))? + { + ChatDao::update_title(conn, &session.id, &session.name) + .map_err(|e| format!("更新 unified 会话标题失败: {e}"))?; + return Ok(()); + } + + let unified_session = UnifiedChatSession { + id: session.id.clone(), + mode: ChatMode::General, + title: Some(session.name.clone()), + system_prompt: None, + model: None, + provider_type: None, + credential_uuid: None, + metadata: session.metadata.clone(), + created_at: timestamp_ms_to_rfc3339(session.created_at), + updated_at: timestamp_ms_to_rfc3339(session.updated_at), + }; + + ChatDao::create_session(conn, &unified_session) + .map_err(|e| format!("创建 unified 会话影子失败: {e}")) +} + +fn ensure_general_session_shadow_by_id( + conn: &rusqlite::Connection, + session_id: &str, +) -> Result<(), String> { + let session = GeneralChatDao::get_session(conn, session_id) + .map_err(|e| format!("读取 general_chat 会话失败: {e}"))? + .ok_or_else(|| format!("general_chat 会话不存在: {session_id}"))?; + + ensure_general_session_shadow(conn, &session) +} + +fn convert_general_blocks_to_unified_content( + content: &str, + blocks: Option<&[ContentBlock]>, +) -> serde_json::Value { + if let Some(blocks) = blocks { + let converted: Vec = blocks + .iter() + .map(|block| match block.r#type.as_str() { + "text" => serde_json::json!({ + "type": "text", + "text": block.content, + }), + "image" => serde_json::json!({ + "type": "image", + "url": block.content, + "alt": block.filename, + }), + "file" => serde_json::json!({ + "type": "file", + "path": block.content, + "name": block.filename.clone().unwrap_or_default(), + }), + _ => serde_json::json!({ + "type": "text", + "text": block.content, + }), + }) + .collect(); + + if !converted.is_empty() { + return serde_json::Value::Array(converted); + } + } + + serde_json::json!([{ "type": "text", "text": content }]) +} + +fn convert_general_message_to_unified(message: &ChatMessage) -> UnifiedChatMessage { + let role = match message.role { + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::System => "system", + }; + + UnifiedChatMessage { + id: 0, + session_id: message.session_id.clone(), + role: role.to_string(), + content: convert_general_blocks_to_unified_content( + &message.content, + message.blocks.as_deref(), + ), + tool_calls: None, + tool_call_id: None, + metadata: message.metadata.clone(), + created_at: timestamp_ms_to_rfc3339(message.created_at), + } +} + +fn mirror_general_message_to_unified( + conn: &rusqlite::Connection, + message: &ChatMessage, +) -> Result { + ensure_general_session_shadow_by_id(conn, &message.session_id)?; + let unified_message = convert_general_message_to_unified(message); + ChatDao::add_message(conn, &unified_message).map_err(|e| format!("写入 unified 消息失败: {e}")) +} + +fn log_general_session_shadow_result( + action: &'static str, + session_id: &str, + result: Result<(), String>, +) { + match result { + Ok(()) => { + tracing::info!( + "[GeneralChat][Compat] {} 已同步 unified 会话影子: session={}", + action, + session_id + ); + } + Err(error) => { + tracing::warn!( + "[GeneralChat][Compat] {} 未能同步 unified 会话影子: session={}, error={}", + action, + session_id, + error + ); + } + } +} + +fn log_general_message_mirror_result( + action: &'static str, + message: &ChatMessage, + result: Result, +) { + match result { + Ok(unified_id) => tracing::info!( + "[GeneralChat][Compat] {} 已同步 unified 消息: session={}, role={:?}, unified_message_id={}", + action, + message.session_id, + message.role, + unified_id + ), + Err(error) => tracing::warn!( + "[GeneralChat][Compat] {} 未能同步 unified 消息: session={}, role={:?}, error={}", + action, + message.session_id, + message.role, + error + ), + } +} + // ==================== 会话管理命令 ==================== -/// 创建新会话 +/// 兼容层:创建新会话。 /// /// # Arguments /// * `name` - 会话名称(可选,默认为"新对话") @@ -34,6 +659,11 @@ pub async fn general_chat_create_session( name: Option, metadata: Option, ) -> Result { + warn_general_chat_legacy( + "general_chat_create_session", + "chat_create_session(mode = ChatMode::General)", + ); + let now = chrono::Utc::now().timestamp_millis(); let session = ChatSession { id: Uuid::new_v4().to_string(), @@ -45,6 +675,11 @@ pub async fn general_chat_create_session( let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; GeneralChatDao::create_session(&conn, &session).map_err(|e| format!("创建会话失败: {e}"))?; + log_general_session_shadow_result( + "创建会话", + &session.id, + ensure_general_session_shadow(&conn, &session), + ); tracing::info!( "[GeneralChat] 创建会话: id={}, name={}", @@ -54,19 +689,39 @@ pub async fn general_chat_create_session( Ok(session) } -/// 获取会话列表 +/// 兼容层:获取会话列表。 #[tauri::command] pub async fn general_chat_list_sessions( db: State<'_, DbConnection>, ) -> Result, String> { + warn_general_chat_legacy( + "general_chat_list_sessions", + "chat_list_sessions(mode = Some(ChatMode::General))", + ); + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let sessions = + let legacy_sessions = GeneralChatDao::list_sessions(&conn).map_err(|e| format!("获取会话列表失败: {e}"))?; + let unified_sessions = ChatDao::list_sessions(&conn, Some(ChatMode::General)) + .map_err(|e| format!("获取 unified 会话列表失败: {e}"))?; + let unified_session_map: HashMap = unified_sessions + .into_iter() + .map(|session| (session.id.clone(), session)) + .collect(); + + let mut sessions: Vec = legacy_sessions + .into_iter() + .map(|session| { + overlay_general_session(Some(session.clone()), unified_session_map.get(&session.id)) + .unwrap_or(session) + }) + .collect(); + sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); Ok(sessions) } -/// 获取会话详情(包含消息列表) +/// 兼容层:获取会话详情(包含消息列表)。 /// /// # Arguments /// * `session_id` - 会话 ID @@ -77,17 +732,20 @@ pub async fn general_chat_get_session( session_id: String, message_limit: Option, ) -> Result { + warn_general_chat_legacy("general_chat_get_session", "chat_get_session"); + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let session = GeneralChatDao::get_session(&conn, &session_id) - .map_err(|e| format!("获取会话失败: {e}"))? + let legacy_session = GeneralChatDao::get_session(&conn, &session_id) + .map_err(|e| format!("获取 general_chat 会话失败: {e}"))?; + let unified_session = ChatDao::get_session(&conn, &session_id) + .map_err(|e| format!("获取 unified 会话失败: {e}"))?; + + let session = overlay_general_session(legacy_session, unified_session.as_ref()) .ok_or_else(|| "会话不存在".to_string())?; - - let messages = GeneralChatDao::get_messages(&conn, &session_id, message_limit, None) - .map_err(|e| format!("获取消息失败: {e}"))?; - - let message_count = GeneralChatDao::get_message_count(&conn, &session_id) - .map_err(|e| format!("获取消息数量失败: {e}"))?; + let all_messages = load_merged_general_messages(&conn, &session_id)?; + let message_count = all_messages.len() as i64; + let messages = paginate_general_messages(all_messages, message_limit, None); Ok(SessionDetail { session, @@ -96,7 +754,7 @@ pub async fn general_chat_get_session( }) } -/// 删除会话 +/// 兼容层:删除会话。 /// /// # Arguments /// * `session_id` - 会话 ID @@ -105,19 +763,36 @@ pub async fn general_chat_delete_session( db: State<'_, DbConnection>, session_id: String, ) -> Result { + warn_general_chat_legacy("general_chat_delete_session", "chat_delete_session"); + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; let deleted = GeneralChatDao::delete_session(&conn, &session_id) .map_err(|e| format!("删除会话失败: {e}"))?; if deleted { + match ChatDao::delete_session(&conn, &session_id) { + Ok(true) => tracing::info!( + "[GeneralChat][Compat] 已删除 unified 会话影子: session={}", + session_id + ), + Ok(false) => tracing::debug!( + "[GeneralChat][Compat] 未找到 unified 会话影子,无需删除: session={}", + session_id + ), + Err(error) => tracing::warn!( + "[GeneralChat][Compat] 删除 unified 会话影子失败: session={}, error={}", + session_id, + error + ), + } tracing::info!("[GeneralChat] 删除会话: id={}", session_id); } Ok(deleted) } -/// 重命名会话 +/// 兼容层:重命名会话。 /// /// # Arguments /// * `session_id` - 会话 ID @@ -128,12 +803,19 @@ pub async fn general_chat_rename_session( session_id: String, name: String, ) -> Result { + warn_general_chat_legacy("general_chat_rename_session", "chat_rename_session"); + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; let renamed = GeneralChatDao::rename_session(&conn, &session_id, &name) .map_err(|e| format!("重命名会话失败: {e}"))?; if renamed { + log_general_session_shadow_result( + "重命名会话", + &session_id, + ensure_general_session_shadow_by_id(&conn, &session_id), + ); tracing::info!("[GeneralChat] 重命名会话: id={}, name={}", session_id, name); } @@ -142,7 +824,7 @@ pub async fn general_chat_rename_session( // ==================== 消息管理命令 ==================== -/// 获取会话消息列表 +/// 兼容层:获取会话消息列表。 /// /// # Arguments /// * `session_id` - 会话 ID @@ -155,15 +837,19 @@ pub async fn general_chat_get_messages( limit: Option, before_id: Option, ) -> Result, String> { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + warn_general_chat_legacy("general_chat_get_messages", "chat_get_messages"); - let messages = GeneralChatDao::get_messages(&conn, &session_id, limit, before_id.as_deref()) - .map_err(|e| format!("获取消息失败: {e}"))?; + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let messages = paginate_general_messages( + load_merged_general_messages(&conn, &session_id)?, + limit, + before_id.as_deref(), + ); Ok(messages) } -/// 添加消息到会话 +/// 兼容层:添加消息到会话。 /// /// # Arguments /// * `session_id` - 会话 ID @@ -173,74 +859,25 @@ pub async fn general_chat_get_messages( /// * `metadata` - 额外元数据(可选) #[tauri::command] pub async fn general_chat_add_message( - db: State<'_, DbConnection>, - session_id: String, - role: String, - content: String, - blocks: Option>, - metadata: Option, + _db: State<'_, DbConnection>, + _session_id: String, + _role: String, + _content: String, + _blocks: Option>, + _metadata: Option, ) -> Result { - let now = chrono::Utc::now().timestamp_millis(); - - let message_role = match role.as_str() { - "user" => MessageRole::User, - "assistant" => MessageRole::Assistant, - "system" => MessageRole::System, - _ => return Err(format!("无效的消息角色: {role}")), - }; - - let message = ChatMessage { - id: Uuid::new_v4().to_string(), - session_id: session_id.clone(), - role: message_role, - content, - blocks, - status: "complete".to_string(), - created_at: now, - metadata, - }; - - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - - // 检查会话是否存在 - if !GeneralChatDao::session_exists(&conn, &session_id) - .map_err(|e| format!("检查会话失败: {e}"))? - { - return Err("会话不存在".to_string()); - } - - GeneralChatDao::add_message(&conn, &message).map_err(|e| format!("添加消息失败: {e}"))?; - - tracing::debug!( - "[GeneralChat] 添加消息: session={}, role={:?}, len={}", - session_id, - message.role, - message.content.len() + warn_general_chat_legacy( + "general_chat_add_message", + "统一对话消息流程(暂无一对一 Tauri 替代命令)", ); - Ok(message) + Err(general_chat_deprecated_error( + "general_chat_add_message", + "统一对话消息流程", + )) } -// ==================== 流式消息命令 ==================== - -use std::sync::Arc; -use tauri::{AppHandle, Emitter}; -use tokio::sync::RwLock; - -/// 流式消息事件 -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -#[allow(dead_code)] -pub enum StreamEvent { - /// 开始生成 - Start { message_id: String }, - /// 文本增量 - Delta { content: String }, - /// 生成完成 - Done { message_id: String, content: String }, - /// 发生错误 - Error { message: String }, -} +// ==================== 旧流式消息兼容命令 ==================== /// 流式消息请求 #[derive(Debug, Deserialize)] @@ -261,146 +898,42 @@ pub struct SendMessageRequest { pub model: Option, } -/// 全局停止标志存储 -static STOP_FLAGS: once_cell::sync::Lazy>>> = - once_cell::sync::Lazy::new(|| Arc::new(RwLock::new(std::collections::HashMap::new()))); - -/// 发送消息并获取流式响应 +/// 兼容层:发送消息并获取流式响应。 /// -/// 此命令会: -/// 1. 保存用户消息到数据库 -/// 2. 调用 AI Provider 获取响应 -/// 3. 通过事件流式返回响应 -/// 4. 保存 AI 响应到数据库 +/// 该命令历史上维护了一套独立于现役链路之外的模拟流式实现, +/// 会造成“命令还在、行为却已失真”的治理问题。 +/// 现在仅保留命令名用于兼容探测,并显式返回迁移错误。 #[tauri::command] -pub async fn general_chat_send_message( - app: AppHandle, - db: State<'_, DbConnection>, - request: SendMessageRequest, -) -> Result { - let now = chrono::Utc::now().timestamp_millis(); - let user_message_id = Uuid::new_v4().to_string(); - let assistant_message_id = Uuid::new_v4().to_string(); - - // 保存用户消息 - let user_message = ChatMessage { - id: user_message_id.clone(), - session_id: request.session_id.clone(), - role: MessageRole::User, - content: request.content.clone(), - blocks: None, - status: "complete".to_string(), - created_at: now, - metadata: None, - }; - - { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - - // 检查会话是否存在 - if !GeneralChatDao::session_exists(&conn, &request.session_id) - .map_err(|e| format!("检查会话失败: {e}"))? - { - return Err("会话不存在".to_string()); - } - - GeneralChatDao::add_message(&conn, &user_message) - .map_err(|e| format!("保存用户消息失败: {e}"))?; - } - - // 设置停止标志 - { - let mut flags = STOP_FLAGS.write().await; - flags.insert(request.session_id.clone(), false); - } - - // 发送开始事件 - let start_event = StreamEvent::Start { - message_id: assistant_message_id.clone(), - }; - if let Err(e) = app.emit(&request.event_name, &start_event) { - tracing::error!("[GeneralChat] 发送开始事件失败: {}", e); - } - - // TODO: 实际调用 AI Provider 获取响应 - // 这里先返回一个模拟响应,后续集成 Provider 系统 - let mock_response = format!( - "这是对「{}」的模拟响应。实际实现需要集成 Provider 系统。", - request.content +pub async fn general_chat_send_message(_request: SendMessageRequest) -> Result { + warn_general_chat_legacy( + "general_chat_send_message", + "chat_send_message / aster_agent_chat_stream", ); - // 模拟流式输出 - for chunk in mock_response.chars().collect::>().chunks(5) { - // 检查是否需要停止 - { - let flags = STOP_FLAGS.read().await; - if flags.get(&request.session_id).copied().unwrap_or(false) { - tracing::info!("[GeneralChat] 生成被用户停止"); - break; - } - } - - let content: String = chunk.iter().collect(); - let delta_event = StreamEvent::Delta { content }; - if let Err(e) = app.emit(&request.event_name, &delta_event) { - tracing::error!("[GeneralChat] 发送增量事件失败: {}", e); - } - - // 模拟延迟 - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - } - - // 保存 AI 响应消息 - let assistant_message = ChatMessage { - id: assistant_message_id.clone(), - session_id: request.session_id.clone(), - role: MessageRole::Assistant, - content: mock_response.clone(), - blocks: None, - status: "complete".to_string(), - created_at: chrono::Utc::now().timestamp_millis(), - metadata: None, - }; - - { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - GeneralChatDao::add_message(&conn, &assistant_message) - .map_err(|e| format!("保存 AI 响应失败: {e}"))?; - } - - // 发送完成事件 - let done_event = StreamEvent::Done { - message_id: assistant_message_id.clone(), - content: mock_response, - }; - if let Err(e) = app.emit(&request.event_name, &done_event) { - tracing::error!("[GeneralChat] 发送完成事件失败: {}", e); - } - - // 清理停止标志 - { - let mut flags = STOP_FLAGS.write().await; - flags.remove(&request.session_id); - } - - Ok(assistant_message_id) + Err(general_chat_deprecated_error( + "general_chat_send_message", + "chat_send_message / aster_agent_chat_stream", + )) } -/// 停止生成 +/// 兼容层:停止生成。 +/// +/// 旧实现依赖 compat 层自建的停止标志,已与现役 Aster 会话停止链路脱节。 +/// 现在仅保留命令名用于兼容探测,并显式返回迁移错误。 /// /// # Arguments /// * `session_id` - 会话 ID #[tauri::command] -pub async fn general_chat_stop_generation(session_id: String) -> Result { - tracing::info!("[GeneralChat] 停止生成: session={}", session_id); +pub async fn general_chat_stop_generation(_session_id: String) -> Result { + warn_general_chat_legacy( + "general_chat_stop_generation", + "chat_stop_generation / aster_agent_stop", + ); - let mut flags = STOP_FLAGS.write().await; - if let std::collections::hash_map::Entry::Occupied(mut e) = flags.entry(session_id) { - e.insert(true); - Ok(true) - } else { - Ok(false) - } + Err(general_chat_deprecated_error( + "general_chat_stop_generation", + "chat_stop_generation / aster_agent_stop", + )) } /// 自动生成会话标题请求 @@ -418,7 +951,7 @@ pub struct GenerateTitleRequest { pub model: Option, } -/// 自动生成会话标题 +/// 兼容层:自动生成会话标题。 /// /// 基于用户第一条消息,调用 AI 生成简短的会话标题 /// @@ -426,142 +959,137 @@ pub struct GenerateTitleRequest { /// * `request` - 生成标题请求 #[tauri::command] pub async fn general_chat_generate_title( - db: State<'_, DbConnection>, - request: GenerateTitleRequest, + _db: State<'_, DbConnection>, + _request: GenerateTitleRequest, ) -> Result { - tracing::info!( - "[GeneralChat] 生成标题: session={}, message_len={}, provider={:?}, model={:?}", - request.session_id, - request.first_message.len(), - request.provider, - request.model + warn_general_chat_legacy( + "general_chat_generate_title", + "统一对话标题流程(暂无一对一 Tauri 替代命令)", ); + Err(general_chat_deprecated_error( + "general_chat_generate_title", + "前端本地标题规则 + general_chat_rename_session / chat_rename_session", + )) +} - // 生成标题的 prompt - let prompt = format!( - "请根据以下用户消息,生成一个简短的对话标题(不超过15个字符,不要使用引号,直接输出标题):\n\n{}", - request.first_message.chars().take(500).collect::() - ); +#[cfg(test)] +mod tests { + use super::*; - // 尝试调用 AI 生成标题,使用指定的模型或默认模型 - let model = request.model.as_deref(); - let title = match generate_title_with_ai(&prompt, model).await { - Ok(ai_title) => { - tracing::info!("[GeneralChat] AI 生成标题成功: {}", ai_title); - // 清理 AI 返回的标题(去除引号、换行等) - clean_title(&ai_title) + fn build_general_session( + id: &str, + name: &str, + created_at: i64, + updated_at: i64, + ) -> ChatSession { + ChatSession { + id: id.to_string(), + name: name.to_string(), + created_at, + updated_at, + metadata: None, } - Err(e) => { - tracing::warn!("[GeneralChat] AI 生成标题失败,使用 fallback: {}", e); - // Fallback: 使用简单的截取逻辑 - generate_fallback_title(&request.first_message) + } + + fn build_unified_session( + id: &str, + title: Option<&str>, + created_at: &str, + updated_at: &str, + ) -> UnifiedChatSession { + UnifiedChatSession { + id: id.to_string(), + mode: ChatMode::General, + title: title.map(ToString::to_string), + system_prompt: None, + model: None, + provider_type: None, + credential_uuid: None, + metadata: None, + created_at: created_at.to_string(), + updated_at: updated_at.to_string(), } - }; - - // 更新数据库中的会话标题 - { - let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - GeneralChatDao::rename_session(&conn, &request.session_id, &title) - .map_err(|e| format!("更新标题失败: {e}"))?; } - tracing::info!( - "[GeneralChat] 标题生成完成: session={}, title={}", - request.session_id, - title - ); - - Ok(title) -} - -/// 使用 AI 生成标题 -async fn generate_title_with_ai(prompt: &str, model: Option<&str>) -> Result { - use crate::models::openai::{ChatCompletionRequest, ChatMessage, MessageContent}; - use crate::providers::openai_custom::OpenAICustomProvider; - - // 使用本地代理服务器调用 AI - // 这样可以利用已配置的凭证池 - let provider = OpenAICustomProvider::with_config( - "local".to_string(), - Some("http://127.0.0.1:5678".to_string()), - ); - - let request = ChatCompletionRequest { - model: model.unwrap_or("default").to_string(), - messages: vec![ChatMessage { - role: "user".to_string(), - content: Some(MessageContent::Text(prompt.to_string())), - tool_calls: None, - tool_call_id: None, - reasoning_content: None, - }], - temperature: Some(0.3), - max_tokens: Some(32), - top_p: None, - stream: false, - tools: None, - tool_choice: None, - reasoning_effort: None, - }; - - let resp = provider - .call_api(&request) - .await - .map_err(|e| format!("API 调用失败: {e}"))?; - - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - - if !status.is_success() { - return Err(format!("API 返回错误: {status} - {body}")); + fn build_general_message( + id: &str, + role: MessageRole, + content: &str, + created_at: i64, + ) -> ChatMessage { + ChatMessage { + id: id.to_string(), + session_id: "session-1".to_string(), + role, + content: content.to_string(), + blocks: None, + status: "complete".to_string(), + created_at, + metadata: None, + } } - let parsed: serde_json::Value = - serde_json::from_str(&body).map_err(|e| format!("解析响应失败: {e}"))?; + #[test] + fn overlay_general_session_prefers_unified_title_and_latest_timestamp() { + let legacy_session = build_general_session("session-1", "旧标题", 1000, 2000); + let unified_session = build_unified_session( + "session-1", + Some("新标题"), + "1970-01-01T00:00:01Z", + "1970-01-01T00:00:05Z", + ); - let content = parsed["choices"] - .as_array() - .and_then(|arr| arr.first()) - .and_then(|c| c["message"]["content"].as_str()) - .unwrap_or("") - .to_string(); + let merged = overlay_general_session(Some(legacy_session), Some(&unified_session)) + .expect("会话应存在"); - if content.is_empty() { - return Err("AI 返回空内容".to_string()); + assert_eq!(merged.name, "新标题"); + assert_eq!(merged.created_at, 1000); + assert_eq!(merged.updated_at, 5000); } - Ok(content) -} + #[test] + fn merge_general_message_sources_deduplicates_mirrored_messages() { + let legacy_message = build_general_message("legacy-1", MessageRole::User, "你好", 1000); + let unified_duplicate = + build_general_message("unified-101", MessageRole::User, "你好", 1000); + let unified_new = + build_general_message("unified-102", MessageRole::Assistant, "收到", 2000); -/// 清理 AI 生成的标题 -fn clean_title(title: &str) -> String { - let cleaned = title - .trim() - .trim_matches('"') - .trim_matches('\'') - .trim_matches('「') - .trim_matches('」') - .trim_matches('《') - .trim_matches('》') - .lines() - .next() - .unwrap_or(title) - .trim(); + let merged = merge_general_message_sources( + vec![legacy_message.clone()], + vec![unified_duplicate, unified_new.clone()], + ); - // 限制长度 - if cleaned.chars().count() > 20 { - format!("{}...", cleaned.chars().take(17).collect::()) - } else { - cleaned.to_string() + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].id, legacy_message.id); + assert_eq!(merged[1].id, unified_new.id); } -} - -/// 生成 fallback 标题 -fn generate_fallback_title(message: &str) -> String { - let trimmed = message.trim(); - if trimmed.chars().count() > 20 { - format!("{}...", trimmed.chars().take(17).collect::()) - } else { - trimmed.to_string() + + #[test] + fn paginate_general_messages_respects_before_id_and_limit() { + let messages = vec![ + build_general_message("msg-1", MessageRole::User, "1", 1000), + build_general_message("msg-2", MessageRole::Assistant, "2", 2000), + build_general_message("msg-3", MessageRole::User, "3", 3000), + build_general_message("msg-4", MessageRole::Assistant, "4", 4000), + ]; + + let paged = paginate_general_messages(messages, Some(2), Some("msg-4")); + + assert_eq!(paged.len(), 2); + assert_eq!(paged[0].id, "msg-2"); + assert_eq!(paged[1].id, "msg-3"); + } + + #[test] + fn deprecated_error_mentions_command_and_replacement() { + let error = general_chat_deprecated_error( + "general_chat_send_message", + "chat_send_message / aster_agent_chat_stream", + ); + + assert!(error.contains("general_chat_send_message")); + assert!(error.contains("chat_send_message / aster_agent_chat_stream")); + assert!(error.contains("已废弃")); } } diff --git a/src-tauri/src/commands/skill_cmd.rs b/src-tauri/src/commands/skill_cmd.rs index 7bb5e16c3..2831a9e3e 100644 --- a/src-tauri/src/commands/skill_cmd.rs +++ b/src-tauri/src/commands/skill_cmd.rs @@ -150,6 +150,28 @@ fn get_skill_key(app_type: &AppType, directory: &str) -> String { format!("{}:{}", app_type.to_string().to_lowercase(), directory) } +/// 解析指定应用的技能列表(供 dispatcher 等非 Tauri command 场景调用) +pub async fn resolve_skills_for_app( + db: &DbConnection, + skill_service: &Arc, + app_type: &AppType, + _refresh_remote: bool, +) -> Result, String> { + let (repos, installed_states) = { + let conn = db.lock().map_err(|e| e.to_string())?; + let repos = SkillDao::get_skill_repos(&conn).map_err(|e| e.to_string())?; + let installed_states = SkillDao::get_skills(&conn).map_err(|e| e.to_string())?; + (repos, installed_states) + }; + + let skills = skill_service + .list_skills(app_type, &repos, &installed_states) + .await + .map_err(|e| e.to_string())?; + + Ok(skills) +} + #[tauri::command] pub async fn get_skills( db: State<'_, DbConnection>, @@ -203,6 +225,25 @@ pub async fn get_skills_for_app( Ok(skills) } +#[tauri::command] +pub fn get_local_skills_for_app( + db: State<'_, DbConnection>, + skill_service: State<'_, SkillServiceState>, + app: String, +) -> Result, String> { + let app_type: AppType = app.parse().map_err(|e: String| e)?; + + let installed_states = { + let conn = db.lock().map_err(|e| e.to_string())?; + SkillDao::get_skills(&conn).map_err(|e| e.to_string())? + }; + + skill_service + .0 + .list_local_skills(&app_type, &installed_states) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn install_skill( db: State<'_, DbConnection>, @@ -337,6 +378,12 @@ pub fn remove_skill_repo( Ok(true) } +#[tauri::command] +pub fn refresh_skill_cache(skill_service: State<'_, SkillServiceState>) -> Result { + skill_service.0.refresh_cache(); + Ok(true) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/skill_exec_cmd.rs b/src-tauri/src/commands/skill_exec_cmd.rs index 1528cf2cb..9f799e9b1 100644 --- a/src-tauri/src/commands/skill_exec_cmd.rs +++ b/src-tauri/src/commands/skill_exec_cmd.rs @@ -506,6 +506,7 @@ fn emit_social_write_file_events( output: format!("写入社媒文稿: {file_path}"), error: None, images: None, + metadata: None, }, }; if let Err(err) = app_handle.emit(&event_name, &tool_end) { diff --git a/src-tauri/src/commands/subagent_cmd.rs b/src-tauri/src/commands/subagent_cmd.rs index 4bcae19eb..d2545a564 100644 --- a/src-tauri/src/commands/subagent_cmd.rs +++ b/src-tauri/src/commands/subagent_cmd.rs @@ -40,8 +40,12 @@ pub async fn init_subagent_scheduler( db: State<'_, DbConnection>, state: State<'_, SubAgentSchedulerState>, config: Option, + session_id: Option, ) -> Result<(), String> { - let scheduler = ProxyCastScheduler::new(db.inner().clone()).with_app_handle(app); + let mut scheduler = ProxyCastScheduler::new(db.inner().clone()).with_app_handle(app); + if let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) { + scheduler = scheduler.with_event_session_id(session_id); + } scheduler.init(config).await; @@ -60,17 +64,14 @@ pub async fn execute_subagent_tasks( tasks: Vec, config: Option, role: Option, + session_id: Option, ) -> Result { - // 确保调度器已初始化 - let scheduler_guard = state.scheduler.read().await; - - if scheduler_guard.is_none() { - drop(scheduler_guard); - // 自动初始化 - let scheduler = ProxyCastScheduler::new(db.inner().clone()).with_app_handle(app); - scheduler.init(config.clone()).await; - *state.scheduler.write().await = Some(scheduler); + let mut scheduler = ProxyCastScheduler::new(db.inner().clone()).with_app_handle(app); + if let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) { + scheduler = scheduler.with_event_session_id(session_id); } + scheduler.init(config.clone()).await; + *state.scheduler.write().await = Some(scheduler); let scheduler_guard = state.scheduler.read().await; let scheduler = scheduler_guard diff --git a/src-tauri/src/commands/theme_context_cmd.rs b/src-tauri/src/commands/theme_context_cmd.rs index 7e9631be4..70a1ca377 100644 --- a/src-tauri/src/commands/theme_context_cmd.rs +++ b/src-tauri/src/commands/theme_context_cmd.rs @@ -6,7 +6,9 @@ use crate::agent::{AsterAgentState, AsterAgentWrapper}; use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; -use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile; +use crate::services::memory_profile_prompt_service::{ + merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources, +}; use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; use crate::services::web_search_runtime_service::apply_web_search_runtime_env; use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; @@ -379,9 +381,15 @@ pub async fn aster_agent_theme_context_search( }); let request_tool_policy = resolve_request_tool_policy(Some(true), false); + let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let system_prompt = proxycast_agent::merge_system_prompt_with_request_tool_policy( merge_system_prompt_with_web_search( - merge_system_prompt_with_memory_profile(project_prompt, &runtime_config), + merge_system_prompt_with_memory_sources( + merge_system_prompt_with_memory_profile(project_prompt, &runtime_config), + &runtime_config, + &working_dir, + None, + ), &runtime_config, ), &request_tool_policy, diff --git a/src-tauri/src/commands/unified_chat_cmd.rs b/src-tauri/src/commands/unified_chat_cmd.rs index 115c830c0..652baf395 100644 --- a/src-tauri/src/commands/unified_chat_cmd.rs +++ b/src-tauri/src/commands/unified_chat_cmd.rs @@ -19,7 +19,9 @@ use crate::commands::aster_agent_cmd::ensure_browser_mcp_tools_registered; use crate::config::GlobalConfigManagerState; use crate::database::dao::chat::{ChatDao, ChatMessage, ChatMode, ChatSession}; use crate::database::DbConnection; -use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile; +use crate::services::memory_profile_prompt_service::{ + merge_system_prompt_with_memory_profile, merge_system_prompt_with_memory_sources, +}; use crate::services::request_tool_policy_prompt_service::{ execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy, RequestToolPolicy, WebSearchExecutionTracker, @@ -128,8 +130,14 @@ pub async fn chat_create_session( let session_id = uuid::Uuid::new_v4().to_string(); let config = config_manager.config(); + let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let merged_system_prompt = merge_system_prompt_with_web_search( - merge_system_prompt_with_memory_profile(request.system_prompt.clone(), &config), + merge_system_prompt_with_memory_sources( + merge_system_prompt_with_memory_profile(request.system_prompt.clone(), &config), + &config, + &working_dir, + None, + ), &config, ); @@ -365,8 +373,14 @@ pub async fn chat_send_message( // 根据模式处理 let config = config_manager.config(); apply_web_search_runtime_env(&config); + let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let merged_system_prompt = merge_system_prompt_with_web_search( - merge_system_prompt_with_memory_profile(session.system_prompt.clone(), &config), + merge_system_prompt_with_memory_sources( + merge_system_prompt_with_memory_profile(session.system_prompt.clone(), &config), + &config, + &working_dir, + None, + ), &config, ); diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index c11a1ac75..386cf976f 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -195,6 +195,7 @@ fn arb_config() -> impl Strategy { content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), + environment: proxycast_core::config::EnvironmentConfig::default(), web_search: proxycast_core::config::WebSearchConfig::default(), memory: proxycast_core::config::MemoryConfig::default(), voice: proxycast_core::config::VoiceConfig::default(), @@ -452,6 +453,7 @@ fn arb_valid_config() -> impl Strategy { content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), + environment: proxycast_core::config::EnvironmentConfig::default(), web_search: proxycast_core::config::WebSearchConfig::default(), memory: proxycast_core::config::MemoryConfig::default(), voice: proxycast_core::config::VoiceConfig::default(), @@ -519,6 +521,7 @@ fn arb_invalid_config() -> impl Strategy { content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), + environment: proxycast_core::config::EnvironmentConfig::default(), web_search: proxycast_core::config::WebSearchConfig::default(), memory: proxycast_core::config::MemoryConfig::default(), voice: proxycast_core::config::VoiceConfig::default(), diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 3c8d64344..978fdd1c6 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -306,9 +306,18 @@ pub async fn handle_command( // 保存配置到文件 let config: proxycast_core::config::Config = serde_json::from_value(args.unwrap_or_default())?; proxycast_core::config::save_config(&config)?; + crate::services::environment_service::apply_configured_environment(&config).await; Ok(serde_json::json!({ "success": true })) } + "get_environment_preview" => { + let config_path = proxycast_core::config::ConfigManager::default_config_path(); + let manager = proxycast_core::config::ConfigManager::load(&config_path)?; + let config = manager.config(); + let preview = crate::services::environment_service::build_environment_preview(&config).await; + Ok(serde_json::to_value(preview)?) + } + "get_default_provider" => { let default_provider_ref = { state.server.read().await.default_provider_ref.clone() }; let provider = default_provider_ref.read().await.clone(); @@ -517,21 +526,20 @@ pub async fn handle_command( .and_then(|value| value.as_str()) .unwrap_or("proxycast") .to_string(); + let refresh_remote = args + .get("refresh_remote") + .or_else(|| args.get("refreshRemote")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); let app_type: crate::models::app_type::AppType = app.parse().map_err(|e: String| e)?; if let Some(db) = &state.db { - let (repos, installed_states) = { - let conn = db.lock().map_err(|e| e.to_string())?; - let repos = crate::database::dao::skills::SkillDao::get_skill_repos(&conn) - .map_err(|e| e.to_string())?; - let installed_states = crate::database::dao::skills::SkillDao::get_skills(&conn) - .map_err(|e| e.to_string())?; - (repos, installed_states) - }; - - let skills = state - .skill_service - .list_skills(&app_type, &repos, &installed_states) + let skills = crate::commands::skill_cmd::resolve_skills_for_app( + db, + &state.skill_service, + &app_type, + refresh_remote, + ) .await .map_err(|e| e.to_string())?; @@ -541,6 +549,31 @@ pub async fn handle_command( } } + "get_local_skills_for_app" => { + let args = args.unwrap_or_default(); + let app = args + .get("app") + .and_then(|value| value.as_str()) + .unwrap_or("proxycast") + .to_string(); + + if let Some(db) = &state.db { + let app_type: crate::models::app_type::AppType = app.parse().map_err(|e: String| e)?; + let installed_states = { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + crate::database::dao::skills::SkillDao::get_skills(&conn) + .map_err(|e| format!("{e}"))? + }; + let skills = state + .skill_service + .list_local_skills(&app_type, &installed_states) + .map_err(|e| format!("{e}"))?; + Ok(serde_json::to_value(skills)?) + } else { + Ok(serde_json::json!([])) + } + } + "test_api" => { // 测试 API 连接 // 从 args 获取 provider diff --git a/src-tauri/src/services/environment_service.rs b/src-tauri/src/services/environment_service.rs new file mode 100644 index 000000000..50d9fd0a0 --- /dev/null +++ b/src-tauri/src/services/environment_service.rs @@ -0,0 +1,646 @@ +use proxycast_core::config::{Config, EnvironmentVariableOverride, WebSearchProvider}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::{Mutex, OnceLock}; +use std::time::Instant; +use tokio::process::Command; +use tokio::time::{timeout, Duration}; + +const CONFIGURED_NAMESPACE: &str = "configured_environment"; +const WEB_SEARCH_NAMESPACE: &str = "web_search_runtime"; +const MAX_SHELL_IMPORT_TIMEOUT_MS: u64 = 30_000; +const DEFAULT_PREVIEW_KEYS: &[&str] = &[ + "PATH", + "HOME", + "USER", + "SHELL", + "COMSPEC", + "TMPDIR", + "TMP", + "TEMP", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", +]; +const DERIVED_PREVIEW_KEYS: &[&str] = &[ + "WEB_SEARCH_PROVIDER", + "WEB_SEARCH_PROVIDER_PRIORITY", + "TAVILY_API_KEY", + "BING_SEARCH_API_KEY", + "GOOGLE_SEARCH_API_KEY", + "GOOGLE_SEARCH_ENGINE_ID", +]; + +static APPLIED_ENV_REGISTRY: OnceLock>>> = OnceLock::new(); +static BASELINE_ENV_REGISTRY: OnceLock>>> = OnceLock::new(); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShellImportPreview { + pub enabled: bool, + pub status: String, + pub message: String, + pub imported_count: usize, + pub duration_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentPreviewEntry { + pub key: String, + pub value: String, + pub masked_value: String, + pub source: String, + pub source_label: String, + pub sensitive: bool, + #[serde(default)] + pub overridden_sources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentPreview { + pub shell_import: ShellImportPreview, + pub entries: Vec, +} + +#[derive(Debug, Clone)] +struct ShellImportResult { + env: BTreeMap, + preview: ShellImportPreview, +} + +#[derive(Debug, Clone)] +struct EffectiveEnvironmentResolution { + env: BTreeMap, + shell_import: ShellImportPreview, + sources: HashMap, + overridden_sources: HashMap>, +} + +fn managed_registry() -> &'static Mutex>> { + APPLIED_ENV_REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn baseline_registry() -> &'static Mutex>> { + BASELINE_ENV_REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn is_valid_env_key(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn is_sensitive_key(key: &str) -> bool { + let upper = key.to_ascii_uppercase(); + upper.contains("KEY") + || upper.contains("TOKEN") + || upper.contains("SECRET") + || upper.contains("PASSWORD") + || upper.contains("AUTH") +} + +fn mask_value(value: &str) -> String { + if value.is_empty() { + return String::new(); + } + + let chars: Vec = value.chars().collect(); + if chars.len() <= 8 { + return "••••••".to_string(); + } + + let prefix: String = chars.iter().take(3).collect(); + let suffix: String = chars + .iter() + .skip(chars.len().saturating_sub(2)) + .copied() + .collect(); + format!("{prefix}••••••{suffix}") +} + +fn normalize_override_entry(entry: &EnvironmentVariableOverride) -> Option<(String, String)> { + if !entry.enabled { + return None; + } + + let key = entry.key.trim(); + if !is_valid_env_key(key) { + return None; + } + + Some((key.to_string(), entry.value.clone())) +} + +pub fn collect_configured_override_env(config: &Config) -> BTreeMap { + let mut env = BTreeMap::new(); + for entry in &config.environment.variables { + if let Some((key, value)) = normalize_override_entry(entry) { + env.insert(key, value); + } + } + env +} + +pub fn build_web_search_runtime_env(config: &Config) -> BTreeMap { + let web_search = &config.web_search; + let mut env = BTreeMap::new(); + + env.insert( + "WEB_SEARCH_PROVIDER".to_string(), + match web_search.provider { + WebSearchProvider::Tavily => "tavily", + WebSearchProvider::MultiSearchEngine => "multi_search_engine", + WebSearchProvider::DuckduckgoInstant => "duckduckgo_instant", + WebSearchProvider::BingSearchApi => "bing_search_api", + WebSearchProvider::GoogleCustomSearch => "google_custom_search", + } + .to_string(), + ); + + let mut provider_priority = Vec::new(); + let mut push_unique = |value: &str| { + if !provider_priority.iter().any(|current| current == value) { + provider_priority.push(value.to_string()); + } + }; + + push_unique(env["WEB_SEARCH_PROVIDER"].as_str()); + for provider in &web_search.provider_priority { + push_unique(match provider { + WebSearchProvider::Tavily => "tavily", + WebSearchProvider::MultiSearchEngine => "multi_search_engine", + WebSearchProvider::DuckduckgoInstant => "duckduckgo_instant", + WebSearchProvider::BingSearchApi => "bing_search_api", + WebSearchProvider::GoogleCustomSearch => "google_custom_search", + }); + } + for provider in [ + "tavily", + "multi_search_engine", + "bing_search_api", + "google_custom_search", + "duckduckgo_instant", + ] { + push_unique(provider); + } + env.insert( + "WEB_SEARCH_PROVIDER_PRIORITY".to_string(), + provider_priority.join(","), + ); + + let insert_trimmed = + |target: &mut BTreeMap, key: &str, value: &Option| { + if let Some(trimmed) = value + .as_ref() + .map(|item| item.trim()) + .filter(|item| !item.is_empty()) + { + target.insert(key.to_string(), trimmed.to_string()); + } + }; + + insert_trimmed(&mut env, "TAVILY_API_KEY", &web_search.tavily_api_key); + insert_trimmed( + &mut env, + "BING_SEARCH_API_KEY", + &web_search.bing_search_api_key, + ); + insert_trimmed( + &mut env, + "GOOGLE_SEARCH_API_KEY", + &web_search.google_search_api_key, + ); + insert_trimmed( + &mut env, + "GOOGLE_SEARCH_ENGINE_ID", + &web_search.google_search_engine_id, + ); + + let engines = web_search + .multi_search + .engines + .iter() + .filter_map(|entry| { + let name = entry.name.trim(); + let template = entry.url_template.trim(); + if name.is_empty() || template.is_empty() || !template.contains("{query}") { + return None; + } + Some(serde_json::json!({ + "name": name, + "url_template": template, + "enabled": entry.enabled, + })) + }) + .collect::>(); + + let valid_engine_names: std::collections::HashSet = engines + .iter() + .filter_map(|engine| engine.get("name").and_then(|v| v.as_str())) + .map(|s| s.to_string()) + .collect(); + + let multi_search_priority = if web_search.multi_search.priority.is_empty() { + valid_engine_names.iter().cloned().collect::>() + } else { + web_search + .multi_search + .priority + .iter() + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty() && valid_engine_names.contains(name)) + .collect::>() + }; + + let multi_search_config = serde_json::json!({ + "priority": multi_search_priority, + "engines": engines, + "max_results_per_engine": web_search.multi_search.max_results_per_engine, + "max_total_results": web_search.multi_search.max_total_results, + "timeout_ms": web_search.multi_search.timeout_ms, + }); + + if let Ok(raw) = serde_json::to_string(&multi_search_config) { + env.insert("MULTI_SEARCH_ENGINE_CONFIG_JSON".to_string(), raw); + } + + env +} + +fn upsert_source( + sources: &mut HashMap, + overridden_sources: &mut HashMap>, + key: &str, + source: &str, +) { + if let Some(previous) = sources.insert(key.to_string(), source.to_string()) { + overridden_sources + .entry(key.to_string()) + .or_default() + .push(previous); + } +} + +async fn import_shell_environment(config: &Config) -> ShellImportResult { + if !config.environment.shell_import.enabled { + return ShellImportResult { + env: BTreeMap::new(), + preview: ShellImportPreview { + enabled: false, + status: "disabled".to_string(), + message: "已关闭 Shell 环境导入,仅使用当前进程环境与显式覆盖。".to_string(), + imported_count: 0, + duration_ms: None, + }, + }; + } + + let timeout_ms = config + .environment + .shell_import + .timeout_ms + .clamp(100, MAX_SHELL_IMPORT_TIMEOUT_MS); + let started_at = Instant::now(); + + let output = timeout( + Duration::from_millis(timeout_ms), + read_shell_environment_output(), + ) + .await; + match output { + Ok(Ok(raw)) => { + let env = parse_environment_output(&raw); + let duration_ms = started_at.elapsed().as_millis() as u64; + ShellImportResult { + preview: ShellImportPreview { + enabled: true, + status: "ok".to_string(), + message: format!("已导入 Shell 环境,共 {} 个变量。", env.len()), + imported_count: env.len(), + duration_ms: Some(duration_ms), + }, + env, + } + } + Ok(Err(error)) => ShellImportResult { + env: BTreeMap::new(), + preview: ShellImportPreview { + enabled: true, + status: "error".to_string(), + message: format!("Shell 环境导入失败:{error}"), + imported_count: 0, + duration_ms: Some(started_at.elapsed().as_millis() as u64), + }, + }, + Err(_) => ShellImportResult { + env: BTreeMap::new(), + preview: ShellImportPreview { + enabled: true, + status: "timeout".to_string(), + message: format!( + "Shell 环境导入超时({} ms),已回退为仅使用显式覆盖。", + timeout_ms + ), + imported_count: 0, + duration_ms: Some(timeout_ms), + }, + }, + } +} + +async fn read_shell_environment_output() -> Result, String> { + #[cfg(target_os = "windows")] + { + let script = r#"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-ChildItem Env: | ForEach-Object { "{0}={1}" -f $_.Name, $_.Value }"#; + for shell in ["pwsh", "powershell"] { + let mut command = Command::new(shell); + let output = command + .arg("-NoLogo") + .arg("-Command") + .arg(script) + .output() + .await; + + match output { + Ok(result) if result.status.success() => return Ok(result.stdout), + Ok(_) => continue, + Err(_) => continue, + } + } + Err("未找到可用的 PowerShell 解释器。".to_string()) + } + + #[cfg(not(target_os = "windows"))] + { + let shell = std::env::var("SHELL") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "/bin/zsh".to_string()); + + for args in [vec!["-lic", "env -0"], vec!["-lc", "env -0"]] { + let mut command = Command::new(&shell); + let output = command.args(&args).output().await; + match output { + Ok(result) if result.status.success() => return Ok(result.stdout), + Ok(_) => continue, + Err(_) => continue, + } + } + + Err(format!("无法使用 Shell `{shell}` 读取环境变量。")) + } +} + +fn parse_environment_output(raw: &[u8]) -> BTreeMap { + let mut env = BTreeMap::new(); + let text = String::from_utf8_lossy(raw); + let segments = if text.contains('\0') { + text.split('\0').map(str::to_string).collect::>() + } else { + text.lines().map(str::to_string).collect::>() + }; + + for line in segments { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Some((key, value)) = trimmed.split_once('=') else { + continue; + }; + if !is_valid_env_key(key.trim()) { + continue; + } + env.insert(key.trim().to_string(), value.to_string()); + } + + env +} + +async fn resolve_effective_environment(config: &Config) -> EffectiveEnvironmentResolution { + let shell_import = import_shell_environment(config).await; + let override_env = collect_configured_override_env(config); + let derived_web_search_env = build_web_search_runtime_env(config); + let mut env = BTreeMap::new(); + let mut sources = HashMap::new(); + let mut overridden_sources = HashMap::new(); + + for (key, value) in &shell_import.env { + env.insert(key.clone(), value.clone()); + upsert_source(&mut sources, &mut overridden_sources, key, "shell_import"); + } + + for (key, value) in &derived_web_search_env { + if override_env.contains_key(key) { + overridden_sources + .entry(key.clone()) + .or_default() + .push("web_search".to_string()); + continue; + } + env.insert(key.clone(), value.clone()); + upsert_source(&mut sources, &mut overridden_sources, key, "web_search"); + } + + for (key, value) in &override_env { + env.insert(key.clone(), value.clone()); + upsert_source(&mut sources, &mut overridden_sources, key, "override"); + } + + EffectiveEnvironmentResolution { + env, + shell_import: shell_import.preview, + sources, + overridden_sources, + } +} + +pub async fn build_environment_preview(config: &Config) -> EnvironmentPreview { + let resolution = resolve_effective_environment(config).await; + let configured_keys = collect_configured_override_env(config) + .into_keys() + .collect::>(); + let derived_keys = build_web_search_runtime_env(config) + .into_keys() + .filter(|key| { + DERIVED_PREVIEW_KEYS + .iter() + .any(|candidate| candidate == key) + }) + .collect::>(); + let mut preview_keys = BTreeSet::new(); + + preview_keys.extend(configured_keys); + preview_keys.extend(derived_keys); + preview_keys.extend( + DEFAULT_PREVIEW_KEYS + .iter() + .filter(|key| resolution.env.contains_key(**key)) + .map(|key| key.to_string()), + ); + + let entries = preview_keys + .into_iter() + .filter_map(|key| { + let value = resolution.env.get(&key)?.to_string(); + let source = resolution + .sources + .get(&key) + .cloned() + .unwrap_or_else(|| "process".to_string()); + let source_label = match source.as_str() { + "override" => "环境变量覆盖", + "shell_import" => "Shell 环境导入", + "web_search" => "网络搜索配置", + _ => "当前进程环境", + } + .to_string(); + let sensitive = is_sensitive_key(&key); + Some(EnvironmentPreviewEntry { + key: key.clone(), + masked_value: if sensitive { + mask_value(&value) + } else { + value.clone() + }, + value, + source, + source_label, + sensitive, + overridden_sources: resolution + .overridden_sources + .get(&key) + .cloned() + .unwrap_or_default(), + }) + }) + .collect(); + + EnvironmentPreview { + shell_import: resolution.shell_import, + entries, + } +} + +pub async fn apply_configured_environment(config: &Config) { + let shell_import = import_shell_environment(config).await; + let mut env = shell_import.env; + for (key, value) in collect_configured_override_env(config) { + env.insert(key, value); + } + apply_environment_namespace(CONFIGURED_NAMESPACE, &env); +} + +pub fn apply_web_search_environment(config: &Config) { + let override_keys = collect_configured_override_env(config) + .into_keys() + .collect::>(); + let mut env = build_web_search_runtime_env(config); + env.retain(|key, _| !override_keys.contains(key)); + apply_environment_namespace(WEB_SEARCH_NAMESPACE, &env); +} + +pub fn apply_environment_namespace(namespace: &str, env: &BTreeMap) { + let registry = managed_registry(); + let mut registry = match registry.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let baseline_registry = baseline_registry(); + let mut baseline_registry = match baseline_registry.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + + let next_keys = env.keys().cloned().collect::>(); + let previous_keys = registry + .insert(namespace.to_string(), next_keys.clone()) + .unwrap_or_default(); + + for key in previous_keys.difference(&next_keys) { + if let Some(Some(original)) = baseline_registry.get(key) { + std::env::set_var(key, original); + } else { + std::env::remove_var(key); + } + } + + for (key, value) in env { + baseline_registry + .entry(key.clone()) + .or_insert_with(|| std::env::var(key).ok()); + std::env::set_var(key, value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proxycast_core::config::{ + Config, MultiSearchEngineEntryConfig, SearchEngine, WebSearchConfig, + }; + + #[tokio::test] + async fn environment_preview_prefers_explicit_override_over_web_search() { + let mut config = Config::default(); + config.environment.variables = vec![EnvironmentVariableOverride { + key: "TAVILY_API_KEY".to_string(), + value: "override-key".to_string(), + enabled: true, + }]; + config.web_search = WebSearchConfig { + engine: SearchEngine::Google, + provider: WebSearchProvider::Tavily, + provider_priority: vec![], + tavily_api_key: Some("search-key".to_string()), + bing_search_api_key: None, + google_search_api_key: None, + google_search_engine_id: None, + multi_search: Default::default(), + }; + + let preview = build_environment_preview(&config).await; + let entry = preview + .entries + .iter() + .find(|item| item.key == "TAVILY_API_KEY") + .expect("should contain TAVILY_API_KEY"); + + assert_eq!(entry.value, "override-key"); + assert_eq!(entry.source, "override"); + assert!(entry + .overridden_sources + .iter() + .any(|item| item == "web_search")); + } + + #[test] + fn build_web_search_runtime_env_contains_serialized_multi_search_config() { + let mut config = Config::default(); + config.web_search.provider = WebSearchProvider::MultiSearchEngine; + config.web_search.multi_search.engines = vec![MultiSearchEngineEntryConfig { + name: "google".to_string(), + url_template: "https://www.google.com/search?q={query}".to_string(), + enabled: true, + }]; + + let env = build_web_search_runtime_env(&config); + assert_eq!( + env.get("WEB_SEARCH_PROVIDER").map(String::as_str), + Some("multi_search_engine") + ); + assert!(env.contains_key("MULTI_SEARCH_ENGINE_CONFIG_JSON")); + } +} diff --git a/src-tauri/src/services/memory_profile_prompt_service.rs b/src-tauri/src/services/memory_profile_prompt_service.rs index 8837d459a..21d563a41 100644 --- a/src-tauri/src/services/memory_profile_prompt_service.rs +++ b/src-tauri/src/services/memory_profile_prompt_service.rs @@ -4,11 +4,12 @@ //! 转换为可注入到系统提示词中的统一指令片段。 use proxycast_core::config::Config; -use std::path::PathBuf; +use std::path::Path; use crate::services::memory_source_resolver_service::build_memory_sources_prompt; const MEMORY_PROFILE_PROMPT_MARKER: &str = "【用户记忆画像偏好】"; +const MEMORY_SOURCE_PROMPT_MARKER: &str = "【记忆来源补充指令】"; fn normalize_text(input: &str) -> Option { let trimmed = input.trim(); @@ -79,13 +80,6 @@ pub fn build_memory_profile_prompt(config: &Config) -> Option { lines.push("2. 在保证正确性的前提下,控制解释粒度并匹配用户理解路径。".to_string()); lines.push("3. 不要显式提及你看到了该画像配置。".to_string()); - // 记忆来源补充(AGENTS、规则、自动记忆等) - let working_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - if let Some(source_prompt) = build_memory_sources_prompt(config, &working_dir, None, 4000) { - lines.push(String::new()); - lines.push(source_prompt); - } - Some(lines.join("\n")) } @@ -115,10 +109,41 @@ pub fn merge_system_prompt_with_memory_profile( } } +pub fn merge_system_prompt_with_memory_sources( + base_prompt: Option, + config: &Config, + working_dir: &Path, + active_relative_path: Option<&str>, +) -> Option { + if !config.memory.enabled { + return base_prompt; + } + + let memory_sources_prompt = + build_memory_sources_prompt(config, working_dir, active_relative_path, 4000); + + match (base_prompt, memory_sources_prompt) { + (Some(base), Some(source_prompt)) => { + if base.contains(MEMORY_SOURCE_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(source_prompt) + } else { + Some(format!("{base}\n\n{source_prompt}")) + } + } + (Some(base), None) => Some(base), + (None, Some(source_prompt)) => Some(source_prompt), + (None, None) => None, + } +} + #[cfg(test)] mod tests { use super::*; use proxycast_core::config::Config; + use std::fs; + use tempfile::TempDir; #[test] fn memory_disabled_should_not_build_prompt() { @@ -170,4 +195,25 @@ mod tests { let merged = merge_system_prompt_with_memory_profile(base.clone(), &config); assert_eq!(merged, base); } + + #[test] + fn should_merge_memory_sources_without_profile_data() { + let tmp = TempDir::new().expect("create temp dir"); + fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 偏好简洁输出") + .expect("write memory file"); + + let mut config = Config::default(); + config.memory.enabled = true; + config.memory.profile = Some(Default::default()); + config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string()); + config.memory.sources.user_memory_path = Some("missing-user.md".to_string()); + config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()]; + config.memory.sources.project_rule_dirs = Vec::new(); + + let merged = merge_system_prompt_with_memory_sources(None, &config, tmp.path(), None) + .expect("should build sources prompt"); + + assert!(merged.contains("【记忆来源补充指令】")); + assert!(merged.contains("偏好简洁输出")); + } } diff --git a/src-tauri/src/services/memory_source_resolver_service.rs b/src-tauri/src/services/memory_source_resolver_service.rs index a7a0c5203..9c74f8010 100644 --- a/src-tauri/src/services/memory_source_resolver_service.rs +++ b/src-tauri/src/services/memory_source_resolver_service.rs @@ -5,11 +5,18 @@ use crate::services::auto_memory_service::{get_auto_memory_index, resolve_auto_memory_root}; use crate::services::memory_import_parser_service::{parse_memory_file, MemoryImportParseOptions}; use crate::services::memory_rules_loader_service::load_rules; +use proxycast_agent::{ + resolve_durable_memory_root, to_virtual_memory_path, DURABLE_MEMORY_VIRTUAL_ROOT, +}; use proxycast_core::config::{Config, MemoryConfig}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; +use std::fs; use std::path::{Path, PathBuf}; +const DURABLE_MEMORY_MAX_DEPTH: usize = 4; +const DURABLE_MEMORY_MAX_FILES: usize = 64; + /// 单个来源解析结果 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct EffectiveMemorySource { @@ -99,7 +106,16 @@ pub fn resolve_effective_sources( &mut prompt_segments, ); - // 3. project hierarchy memory + rules + // 3. cross-thread durable memory (`/memories/...`) + resolve_durable_memory_sources( + memory, + &options, + &mut seen, + &mut sources, + &mut prompt_segments, + ); + + // 4. project hierarchy memory + rules let ancestors = collect_ancestor_dirs(working_dir); for ancestor in &ancestors { for rel in &memory.sources.project_memory_paths { @@ -153,7 +169,7 @@ pub fn resolve_effective_sources( } } - // 4. additional directories + // 5. additional directories if memory.resolve.load_additional_dirs_memory { for additional in &memory.resolve.additional_dirs { let additional_dir = expand_path(additional, Some(working_dir)); @@ -189,7 +205,7 @@ pub fn resolve_effective_sources( } } - // 5. auto memory + // 6. auto memory resolve_auto_memory_source( memory, working_dir, @@ -263,11 +279,36 @@ fn resolve_file_source( seen: &mut HashSet, output: &mut Vec, prompt_segments: &mut Vec, +) { + resolve_file_source_with_display_path( + kind, + file_path, + None, + include_missing, + options, + seen, + output, + prompt_segments, + ); +} + +fn resolve_file_source_with_display_path( + kind: &str, + file_path: &Path, + display_path: Option<&str>, + include_missing: bool, + options: &MemoryImportParseOptions, + seen: &mut HashSet, + output: &mut Vec, + prompt_segments: &mut Vec, ) { let normalized = normalize_path(file_path); if !seen.insert(normalized.clone()) { return; } + let display_path = display_path + .map(str::to_string) + .unwrap_or_else(|| normalized.to_string_lossy().to_string()); if !normalized.exists() || !normalized.is_file() { if !include_missing { @@ -275,7 +316,7 @@ fn resolve_file_source( } output.push(EffectiveMemorySource { kind: kind.to_string(), - path: normalized.to_string_lossy().to_string(), + path: display_path, exists: false, loaded: false, line_count: 0, @@ -304,7 +345,7 @@ fn resolve_file_source( output.push(EffectiveMemorySource { kind: kind.to_string(), - path: normalized.to_string_lossy().to_string(), + path: display_path.clone(), exists: true, loaded, line_count, @@ -314,18 +355,13 @@ fn resolve_file_source( }); if loaded { - prompt_segments.push(format!( - "### {} ({})\n{}", - kind, - normalized.display(), - content - )); + prompt_segments.push(format!("### {} ({})\n{}", kind, display_path, content)); } } Err(err) => { output.push(EffectiveMemorySource { kind: kind.to_string(), - path: normalized.to_string_lossy().to_string(), + path: display_path, exists: true, loaded: false, line_count: 0, @@ -337,6 +373,88 @@ fn resolve_file_source( } } +fn resolve_durable_memory_sources( + memory_config: &MemoryConfig, + options: &MemoryImportParseOptions, + seen: &mut HashSet, + output: &mut Vec, + prompt_segments: &mut Vec, +) { + let root = match resolve_durable_memory_root() { + Ok(path) => path, + Err(err) => { + output.push(EffectiveMemorySource { + kind: "durable_memory".to_string(), + path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(), + exists: false, + loaded: false, + line_count: 0, + import_count: 0, + warnings: vec![format!("解析 durable memory 根目录失败: {err}")], + preview: None, + }); + return; + } + }; + + let files = match collect_durable_memory_files( + &root, + DURABLE_MEMORY_MAX_DEPTH, + DURABLE_MEMORY_MAX_FILES, + ) { + Ok(files) => files, + Err(err) => { + output.push(EffectiveMemorySource { + kind: "durable_memory".to_string(), + path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(), + exists: root.exists(), + loaded: false, + line_count: 0, + import_count: 0, + warnings: vec![format!("扫描 durable memory 文件失败: {err}")], + preview: None, + }); + return; + } + }; + + if files.is_empty() { + let warnings = if memory_config.enabled { + vec!["尚未创建 durable memory 文件,可通过 `/memories/...` 路径写入".to_string()] + } else { + vec!["记忆功能已关闭".to_string()] + }; + output.push(EffectiveMemorySource { + kind: "durable_memory".to_string(), + path: DURABLE_MEMORY_VIRTUAL_ROOT.to_string(), + exists: root.exists(), + loaded: false, + line_count: 0, + import_count: 0, + warnings, + preview: None, + }); + return; + } + + for file_path in files { + let display_path = to_virtual_memory_path(&file_path) + .ok() + .flatten() + .unwrap_or_else(|| file_path.to_string_lossy().to_string()); + resolve_file_source_with_display_path( + "durable_memory", + &file_path, + Some(&display_path), + false, + options, + seen, + output, + prompt_segments, + ); + } +} + fn resolve_rule_sources( rule_dir: &Path, active_relative_path: Option<&str>, @@ -495,6 +613,92 @@ fn resolve_auto_memory_source( } } +fn collect_durable_memory_files( + root: &Path, + max_depth: usize, + max_files: usize, +) -> Result, String> { + let mut files = Vec::new(); + collect_durable_memory_files_recursive(root, 0, max_depth, max_files, &mut files)?; + files.sort_by(|left, right| durable_memory_sort_key(left).cmp(&durable_memory_sort_key(right))); + Ok(files) +} + +fn collect_durable_memory_files_recursive( + dir: &Path, + depth: usize, + max_depth: usize, + max_files: usize, + output: &mut Vec, +) -> Result<(), String> { + if depth > max_depth || output.len() >= max_files || !dir.exists() { + return Ok(()); + } + + let mut entries = fs::read_dir(dir) + .map_err(|e| format!("读取目录失败 {}: {e}", dir.display()))? + .filter_map(Result::ok) + .collect::>(); + entries.sort_by(|left, right| left.path().cmp(&right.path())); + + for entry in entries { + if output.len() >= max_files { + break; + } + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + + if file_type.is_dir() { + collect_durable_memory_files_recursive(&path, depth + 1, max_depth, max_files, output)?; + continue; + } + + if file_type.is_file() && is_durable_memory_candidate_file(&path) { + output.push(path); + } + } + + Ok(()) +} + +fn is_durable_memory_candidate_file(path: &Path) -> bool { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.trim().to_ascii_lowercase()); + + matches!( + extension.as_deref(), + Some("md") + | Some("markdown") + | Some("mdx") + | Some("txt") + | Some("json") + | Some("yaml") + | Some("yml") + | Some("toml") + ) +} + +fn durable_memory_sort_key(path: &Path) -> (u8, String) { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default(); + + let priority = match file_name.as_str() { + "memory.md" | "memory.mdx" | "memory.txt" => 0, + "preferences.md" | "preferences.json" | "preferences.toml" => 1, + "project.md" | "project.json" | "project.toml" => 2, + _ => 10, + }; + + (priority, path.to_string_lossy().to_string()) +} + fn collect_ancestor_dirs(start: &Path) -> Vec { let mut dirs = Vec::new(); let mut current = if start.is_file() { @@ -614,9 +818,38 @@ fn clip_text(text: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; use std::fs; + use std::sync::{Mutex, OnceLock}; use tempfile::TempDir; + fn durable_memory_env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct DurableMemoryEnvGuard { + previous: Option, + } + + impl DurableMemoryEnvGuard { + fn set(path: &Path) -> Self { + let previous = std::env::var_os("PROXYCAST_DURABLE_MEMORY_DIR"); + std::env::set_var("PROXYCAST_DURABLE_MEMORY_DIR", path.as_os_str()); + Self { previous } + } + } + + impl Drop for DurableMemoryEnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous { + std::env::set_var("PROXYCAST_DURABLE_MEMORY_DIR", value); + } else { + std::env::remove_var("PROXYCAST_DURABLE_MEMORY_DIR"); + } + } + } + #[test] fn should_resolve_project_memory_and_rules() { let tmp = TempDir::new().expect("create temp dir"); @@ -661,4 +894,46 @@ mod tests { .any(|s| s.kind == "additional_memory" && s.loaded); assert!(has_additional_loaded); } + + #[test] + fn should_resolve_durable_memory_sources_with_virtual_paths() { + let _env_lock = durable_memory_env_lock().lock().expect("lock env"); + let tmp = TempDir::new().expect("create temp dir"); + fs::create_dir_all(tmp.path().join("team")).expect("create subdir"); + fs::write(tmp.path().join("MEMORY.md"), "# 长期记忆\n- 始终先给结论") + .expect("write durable memory"); + fs::write( + tmp.path().join("team/preferences.md"), + "# 团队偏好\n- 保持 KISS", + ) + .expect("write nested durable memory"); + let _env = DurableMemoryEnvGuard::set(tmp.path()); + + let mut cfg = Config::default(); + cfg.memory.enabled = true; + cfg.memory.sources.managed_policy_path = Some("missing-managed.md".to_string()); + cfg.memory.sources.user_memory_path = Some("missing-user.md".to_string()); + cfg.memory.sources.project_memory_paths = Vec::new(); + cfg.memory.sources.project_rule_dirs = Vec::new(); + + let resolved = resolve_effective_sources(&cfg, Path::new("."), None); + assert!(resolved + .response + .sources + .iter() + .any(|source| source.kind == "durable_memory" + && source.path == "/memories/MEMORY.md" + && source.loaded)); + assert!(resolved + .response + .sources + .iter() + .any(|source| source.kind == "durable_memory" + && source.path == "/memories/team/preferences.md" + && source.loaded)); + assert!(resolved + .prompt_segments + .iter() + .any(|segment| segment.contains("/memories/MEMORY.md"))); + } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 7d9f807c6..688411489 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -6,6 +6,7 @@ // 保留在主 crate 的 Tauri 相关服务 pub mod auto_memory_service; pub mod conversation_statistics_service; +pub mod environment_service; pub mod execution_tracker_service; pub mod file_browser_service; pub mod heartbeat_service; diff --git a/src-tauri/src/services/openclaw_service.rs b/src-tauri/src/services/openclaw_service.rs index 9cf1642ac..964b3e6c3 100644 --- a/src-tauri/src/services/openclaw_service.rs +++ b/src-tauri/src/services/openclaw_service.rs @@ -1,6 +1,20 @@ use crate::app::AppState; use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType}; use dirs::{data_dir, home_dir}; +use proxycast_core::openclaw_install::{ + build_openclaw_cleanup_command as core_build_openclaw_cleanup_command, + build_openclaw_install_command as core_build_openclaw_install_command, + build_winget_install_command as core_build_winget_install_command, + command_bin_dir_for as core_command_bin_dir_for, + resolve_windows_dependency_install_plan as core_resolve_windows_dependency_install_plan, + select_best_semver_candidate as core_select_best_semver_candidate, + select_preferred_path_candidate as core_select_preferred_path_candidate, + shell_command_escape_for as core_shell_command_escape_for, + shell_npm_prefix_assignment_for as core_shell_npm_prefix_assignment_for, + shell_path_assignment_for as core_shell_path_assignment_for, + windows_manual_install_message as core_windows_manual_install_message, + OpenClawInstallDependencyKind, ShellPlatform, WindowsDependencyInstallPlan, +}; use rand::{distributions::Alphanumeric, Rng}; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -11,7 +25,7 @@ use std::ffi::OsString; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::OnceLock; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use std::time::SystemTime; use tauri::{AppHandle, Emitter, Manager}; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader}; @@ -19,10 +33,20 @@ use tokio::net::TcpStream; use tokio::process::{Child, Command}; use tokio::sync::Mutex; use tokio::time::{sleep, timeout, Duration}; +#[cfg(target_os = "windows")] +use winapi::shared::minwindef::{DWORD, HKEY}; +#[cfg(target_os = "windows")] +use winapi::shared::winerror::ERROR_SUCCESS; +#[cfg(target_os = "windows")] +use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; const DEFAULT_GATEWAY_PORT: u16 = 18790; const OPENCLAW_INSTALL_EVENT: &str = "openclaw:install-progress"; const OPENCLAW_CONFIG_ENV: &str = "OPENCLAW_CONFIG_PATH"; + +fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} const OPENCLAW_CN_PACKAGE: &str = "@qingchencloud/openclaw-zh@latest"; const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest"; const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com"; @@ -304,16 +328,26 @@ impl OpenClawService { let (_, npm_path, npm_prefix, cleanup_command, install_command) = self.resolve_install_commands(app).await?; - let command = format!("{cleanup_command}\n{install_command}"); emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); if let Some(prefix) = npm_prefix { emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info"); } emit_install_progress(app, "安装前先清理已有 OpenClaw 全局包。", "info"); + let cleanup_result = run_shell_command_with_progress(app, &cleanup_command).await?; + if !cleanup_result.success { + emit_install_progress( + app, + &format!( + "清理旧版 OpenClaw 失败,继续尝试安装:{}", + cleanup_result.message + ), + "warn", + ); + } emit_install_progress(app, &format!("执行安装命令: {install_command}"), "info"); - let result = run_shell_command_with_progress(app, &command).await?; + let result = run_shell_command_with_progress(app, &install_command).await?; if !result.success { return Ok(result); } @@ -477,33 +511,44 @@ impl OpenClawService { async fn install_node_runtime(&mut self, app: &AppHandle) -> Result { #[cfg(target_os = "windows")] { - if let Some(winget_path) = find_command_in_shell("winget").await? { - emit_install_progress(app, "检测到 winget,准备通过 winget 安装 Node.js。", "info"); - let command = format!( - "{}{} install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements", - shell_path_assignment(&winget_path), - shell_command_escape(&winget_path) - ); - let result = run_shell_command_with_progress(app, &command).await?; - if !result.success { - return Ok(result); + let winget_path = find_command_in_shell("winget").await?; + match resolve_windows_dependency_install_plan( + DependencyKind::Node, + winget_path.is_some(), + ) { + WindowsDependencyInstallPlan::Winget { package_id } => { + let winget_path = winget_path.expect("winget path should exist"); + emit_install_progress( + app, + "检测到 winget,准备通过 winget 安装 Node.js。", + "info", + ); + let command = build_winget_install_command(&winget_path, package_id); + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + return self + .verify_dependency_after_install(app, DependencyKind::Node) + .await; + } + WindowsDependencyInstallPlan::OfficialInstaller => { + emit_install_progress( + app, + "未检测到 winget,准备下载官方 Node.js 安装器。", + "warn", + ); + let asset = resolve_node_installer_asset().await?; + let installer_path = download_installer_asset(app, &asset).await?; + launch_installer(&installer_path)?; + return self + .wait_for_dependency_ready(app, DependencyKind::Node, 900) + .await; + } + WindowsDependencyInstallPlan::ManualDownload => { + unreachable!("Node.js 在 Windows 上不应返回手动下载计划") } - return self - .verify_dependency_after_install(app, DependencyKind::Node) - .await; } - - emit_install_progress( - app, - "未检测到 winget,准备下载官方 Node.js 安装器。", - "warn", - ); - let asset = resolve_node_installer_asset().await?; - let installer_path = download_installer_asset(app, &asset).await?; - launch_installer(&installer_path)?; - return self - .wait_for_dependency_ready(app, DependencyKind::Node, 900) - .await; } #[cfg(target_os = "macos")] @@ -556,30 +601,35 @@ impl OpenClawService { async fn install_git_runtime(&mut self, app: &AppHandle) -> Result { #[cfg(target_os = "windows")] { - if let Some(winget_path) = find_command_in_shell("winget").await? { - emit_install_progress(app, "检测到 winget,准备通过 winget 安装 Git。", "info"); - let command = format!( - "{}{} install --id Git.Git -e --accept-source-agreements --accept-package-agreements", - shell_path_assignment(&winget_path), - shell_command_escape(&winget_path) - ); - let result = run_shell_command_with_progress(app, &command).await?; - if !result.success { - return Ok(result); + let winget_path = find_command_in_shell("winget").await?; + match resolve_windows_dependency_install_plan( + DependencyKind::Git, + winget_path.is_some(), + ) { + WindowsDependencyInstallPlan::Winget { package_id } => { + let winget_path = winget_path.expect("winget path should exist"); + emit_install_progress(app, "检测到 winget,准备通过 winget 安装 Git。", "info"); + let command = build_winget_install_command(&winget_path, package_id); + let result = run_shell_command_with_progress(app, &command).await?; + if !result.success { + return Ok(result); + } + return self + .verify_dependency_after_install(app, DependencyKind::Git) + .await; + } + WindowsDependencyInstallPlan::OfficialInstaller => { + unreachable!("Git 在 Windows 上不应返回官方安装器计划") + } + WindowsDependencyInstallPlan::ManualDownload => { + let message = windows_manual_install_message(DependencyKind::Git).to_string(); + emit_install_progress(app, &message, "warn"); + return Ok(ActionResult { + success: false, + message, + }); } - return self - .verify_dependency_after_install(app, DependencyKind::Git) - .await; } - - let message = - "当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。" - .to_string(); - emit_install_progress(app, &message, "warn"); - return Ok(ActionResult { - success: false, - message, - }); } #[cfg(target_os = "macos")] @@ -628,6 +678,16 @@ impl OpenClawService { app: &AppHandle, dependency: DependencyKind, ) -> Result { + // 在 Windows 上刷新 PATH 环境变量 + #[cfg(target_os = "windows")] + { + if let Err(e) = refresh_windows_path_from_registry() { + emit_install_progress(app, &format!("刷新环境变量失败: {}", e), "warn"); + } else { + emit_install_progress(app, "已刷新系统环境变量。", "info"); + } + } + let status = self.inspect_dependency_status(dependency).await?; if status.status == "ok" { emit_install_progress( @@ -677,8 +737,19 @@ impl OpenClawService { let start = tokio::time::Instant::now(); let mut last_notice_at = 0_u64; + #[cfg(target_os = "windows")] + let mut last_refresh_at = 0_u64; + while start.elapsed() < Duration::from_secs(timeout_secs) { let elapsed = start.elapsed().as_secs(); + + // 每 10 秒刷新一次 Windows PATH(因为用户可能在安装过程中) + #[cfg(target_os = "windows")] + if elapsed >= last_refresh_at + 10 { + last_refresh_at = elapsed; + let _ = refresh_windows_path_from_registry(); + } + if elapsed >= last_notice_at + 15 { last_notice_at = elapsed; emit_install_progress( @@ -776,11 +847,10 @@ impl OpenClawService { ); } let mut command = Command::new(&binary); + let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token); apply_binary_runtime_path(&mut command, &binary); command - .arg("gateway") - .arg("--port") - .arg(self.gateway_port.to_string()) + .args(&start_args) .env(OPENCLAW_CONFIG_ENV, &config_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -789,20 +859,34 @@ impl OpenClawService { .spawn() .map_err(|e| format!("启动 Gateway 失败: {e}"))?; + let latest_gateway_error = Arc::new(StdMutex::new(None::)); + if let Some(stdout) = child.stdout.take() { + let app = app.cloned(); tokio::spawn(async move { let mut lines = BufReader::new(stdout).lines(); while let Ok(Some(line)) = lines.next_line().await { tracing::info!(target: "openclaw", "Gateway stdout: {}", line); + if let Some(app) = app.as_ref() { + emit_install_progress(app, &line, classify_progress_level(&line, "info")); + } } }); } if let Some(stderr) = child.stderr.take() { + let app = app.cloned(); + let latest_gateway_error = latest_gateway_error.clone(); tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = lines.next_line().await { tracing::warn!(target: "openclaw", "Gateway stderr: {}", line); + if let Ok(mut slot) = latest_gateway_error.lock() { + *slot = Some(line.clone()); + } + if let Some(app) = app.as_ref() { + emit_install_progress(app, &line, classify_progress_level(&line, "warn")); + } } }); } @@ -818,6 +902,22 @@ impl OpenClawService { while start_at.elapsed() < Duration::from_secs(30) { sleep(Duration::from_millis(300)).await; self.refresh_process_state().await?; + let latest_gateway_error = latest_gateway_error + .lock() + .ok() + .and_then(|slot| slot.clone()); + + if self.gateway_process.is_none() && self.gateway_status == GatewayStatus::Error { + let message = format_gateway_start_failure_message(latest_gateway_error.as_deref()); + if let Some(app) = app { + emit_install_progress(app, &message, "error"); + } + return Ok(ActionResult { + success: false, + message, + }); + } + if self.gateway_status == GatewayStatus::Running { if let Some(app) = app { emit_install_progress( @@ -849,12 +949,17 @@ impl OpenClawService { } self.gateway_status = GatewayStatus::Error; + let latest_gateway_error = latest_gateway_error + .lock() + .ok() + .and_then(|slot| slot.clone()); + let message = format_gateway_start_failure_message(latest_gateway_error.as_deref()); if let Some(app) = app { - emit_install_progress(app, "Gateway 启动超时,请检查配置或端口占用。", "error"); + emit_install_progress(app, &message, "error"); } Ok(ActionResult { success: false, - message: "Gateway 启动超时,请检查配置或端口占用。".to_string(), + message, }) } @@ -1305,27 +1410,7 @@ impl OpenClawService { self.gateway_auth_token = generate_auth_token(); } - ensure_path_object(&mut config, &["gateway"]); - set_json_path( - &mut config, - &["gateway", "mode"], - Value::String("local".to_string()), - ); - set_json_path( - &mut config, - &["gateway", "port"], - Value::Number(self.gateway_port.into()), - ); - set_json_path( - &mut config, - &["gateway", "auth", "token"], - Value::String(self.gateway_auth_token.clone()), - ); - set_json_path( - &mut config, - &["gateway", "remote", "token"], - Value::String(self.gateway_auth_token.clone()), - ); + apply_gateway_runtime_defaults(&mut config, self.gateway_port, &self.gateway_auth_token); if let Some((provider_key, provider_value)) = provider_entry { set_json_path( @@ -1362,27 +1447,22 @@ impl OpenClawService { .await? .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; let npm_prefix = detect_npm_global_prefix(&npm_path).await; - let package = if should_use_china_package(app).await { + let use_china_package = should_use_china_package(app).await; + let package = if use_china_package { OPENCLAW_CN_PACKAGE } else { OPENCLAW_DEFAULT_PACKAGE }; - let path_env = shell_path_assignment(&npm_path); - let prefix_env = npm_prefix - .as_deref() - .map(shell_npm_prefix_assignment) - .unwrap_or_default(); - let npm_cmd = shell_command_escape(&npm_path); - let cleanup_command = format!( - "{path_env}{prefix_env}{npm_cmd} uninstall -g openclaw @qingchencloud/openclaw-zh || true" + let shell_platform = current_shell_platform(); + let cleanup_command = + build_openclaw_cleanup_command(shell_platform, &npm_path, npm_prefix.as_deref()); + let install_command = build_openclaw_install_command( + shell_platform, + &npm_path, + npm_prefix.as_deref(), + package, + use_china_package.then_some(NPM_MIRROR_CN), ); - let install_command = if should_use_china_package(app).await { - format!( - "{path_env}{prefix_env}{npm_cmd} install -g {package} --registry={NPM_MIRROR_CN}" - ) - } else { - format!("{path_env}{prefix_env}{npm_cmd} install -g {package}") - }; Ok(( package.to_string(), npm_path, @@ -1397,16 +1477,10 @@ impl OpenClawService { .await? .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; let npm_prefix = detect_npm_global_prefix(&npm_path).await; - let path_env = shell_path_assignment(&npm_path); - let prefix_env = npm_prefix - .as_deref() - .map(shell_npm_prefix_assignment) - .unwrap_or_default(); - let command = format!( - "{}{}{} uninstall -g openclaw @qingchencloud/openclaw-zh", - path_env, - prefix_env, - shell_command_escape(&npm_path) + let command = build_openclaw_cleanup_command( + current_shell_platform(), + &npm_path, + npm_prefix.as_deref(), ); Ok((npm_path, npm_prefix, command)) } @@ -1445,14 +1519,22 @@ impl OpenClawService { self.gateway_port = next_port.max(1); } self.restore_auth_token_from_config(); + if self.gateway_auth_token.is_empty() { + self.gateway_auth_token = generate_auth_token(); + } let binary = find_command_in_shell("openclaw") .await? .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; let config_path = openclaw_proxycast_config_path(); + let command = gateway_start_args(self.gateway_port, &self.gateway_auth_token) + .into_iter() + .map(|arg| shell_escape(&arg)) + .collect::>() + .join(" "); Ok(CommandPreview { title: "启动 Gateway".to_string(), command: format!( - "{}OPENCLAW_CONFIG_PATH={} {} gateway --port {}", + "{}OPENCLAW_CONFIG_PATH={} {} {}", if cfg!(target_os = "windows") { "set " } else { @@ -1460,7 +1542,7 @@ impl OpenClawService { }, shell_escape(config_path.to_string_lossy().as_ref()), shell_escape(&binary), - self.gateway_port + command ), }) } @@ -1986,6 +2068,85 @@ fn set_json_path(root: &mut Value, path: &[&str], value: Value) { parent.insert(path[path.len() - 1].to_string(), value); } +fn apply_gateway_runtime_defaults(config: &mut Value, gateway_port: u16, gateway_auth_token: &str) { + ensure_path_object(config, &["gateway"]); + set_json_path( + config, + &["gateway", "mode"], + Value::String("local".to_string()), + ); + set_json_path( + config, + &["gateway", "bind"], + Value::String("loopback".to_string()), + ); + set_json_path( + config, + &["gateway", "port"], + Value::Number(gateway_port.into()), + ); + set_json_path( + config, + &["gateway", "auth", "mode"], + Value::String("token".to_string()), + ); + set_json_path( + config, + &["gateway", "auth", "token"], + Value::String(gateway_auth_token.to_string()), + ); + set_json_path( + config, + &["gateway", "remote", "token"], + Value::String(gateway_auth_token.to_string()), + ); +} + +fn gateway_start_args(gateway_port: u16, gateway_auth_token: &str) -> Vec { + vec![ + "gateway".to_string(), + "--allow-unconfigured".to_string(), + "--bind".to_string(), + "loopback".to_string(), + "--auth".to_string(), + "token".to_string(), + "--token".to_string(), + gateway_auth_token.to_string(), + "--port".to_string(), + gateway_port.to_string(), + ] +} + +fn format_gateway_start_failure_message(detail: Option<&str>) -> String { + let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else { + return "Gateway 启动超时,请检查配置或端口占用。".to_string(); + }; + + let normalized = detail.to_ascii_lowercase(); + if normalized.contains("missing config") || normalized.contains("gateway.mode=local") { + return "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。" + .to_string(); + } + + if normalized.contains("gateway.auth.mode") { + return "Gateway 启动失败:缺少网关认证模式,已自动切换为 token 模式,请重试。".to_string(); + } + + if normalized.contains("address already in use") || normalized.contains("eaddrinuse") { + return "Gateway 启动失败:目标端口已被占用,请更换端口或停止占用进程。".to_string(); + } + + if normalized.contains("resolved to non-loopback host") { + return "Gateway 启动失败:当前环境无法绑定到本地回环地址 127.0.0.1,请检查本机网络或代理配置。".to_string(); + } + + if normalized.contains("allowedorigins") || normalized.contains("host-header origin fallback") { + return "Gateway 启动失败:当前绑定方式需要配置 Control UI 允许来源,请检查 gateway.controlUi.allowedOrigins。".to_string(); + } + + format!("Gateway 启动失败:{detail}") +} + fn ensure_value_object(value: &mut Value) -> &mut Map { if !value.is_object() { *value = Value::Object(Map::new()); @@ -2159,39 +2320,83 @@ async fn detect_npm_global_prefix(npm_path: &str) -> Option { } } -fn shell_escape(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\"'\"'")) +fn current_shell_platform() -> ShellPlatform { + if cfg!(target_os = "windows") { + ShellPlatform::Windows + } else { + ShellPlatform::Unix + } +} + +#[allow(dead_code)] +fn command_bin_dir_for(platform: ShellPlatform, binary_path: &str) -> Option { + core_command_bin_dir_for(platform, binary_path) +} + +fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String { + core_shell_command_escape_for(platform, value) } fn shell_command_escape(value: &str) -> String { - if cfg!(target_os = "windows") { - format!("\"{}\"", value.replace('"', "\"\"")) - } else { - shell_escape(value) - } + shell_command_escape_for(current_shell_platform(), value) } -fn shell_npm_prefix_assignment(value: &str) -> String { - if cfg!(target_os = "windows") { - format!( - "set \"NPM_CONFIG_PREFIX={}\" && ", - value.replace('"', "\"\"") - ) - } else { - format!("NPM_CONFIG_PREFIX={} ", shell_escape(value)) - } +#[allow(dead_code)] +fn shell_npm_prefix_assignment_for(platform: ShellPlatform, value: &str) -> String { + core_shell_npm_prefix_assignment_for(platform, value) +} + +fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) -> String { + core_shell_path_assignment_for(platform, binary_path) } fn shell_path_assignment(binary_path: &str) -> String { - let Some(bin_dir) = Path::new(binary_path).parent() else { - return String::new(); - }; - let bin_dir = bin_dir.to_string_lossy(); - if cfg!(target_os = "windows") { - format!("set \"PATH={};%PATH%\" && ", bin_dir.replace('"', "\"\"")) - } else { - format!("PATH={}:$PATH ", shell_escape(bin_dir.as_ref())) - } + shell_path_assignment_for(current_shell_platform(), binary_path) +} + +fn build_openclaw_cleanup_command( + platform: ShellPlatform, + npm_path: &str, + npm_prefix: Option<&str>, +) -> String { + core_build_openclaw_cleanup_command(platform, npm_path, npm_prefix) +} + +fn build_openclaw_install_command( + platform: ShellPlatform, + npm_path: &str, + npm_prefix: Option<&str>, + package: &str, + registry: Option<&str>, +) -> String { + core_build_openclaw_install_command(platform, npm_path, npm_prefix, package, registry) +} + +#[allow(dead_code)] +fn resolve_windows_dependency_install_plan( + dependency: DependencyKind, + has_winget: bool, +) -> WindowsDependencyInstallPlan { + core_resolve_windows_dependency_install_plan( + match dependency { + DependencyKind::Node => OpenClawInstallDependencyKind::Node, + DependencyKind::Git => OpenClawInstallDependencyKind::Git, + }, + has_winget, + ) +} + +#[allow(dead_code)] +fn build_winget_install_command(winget_path: &str, package_id: &str) -> String { + core_build_winget_install_command(winget_path, package_id) +} + +#[allow(dead_code)] +fn windows_manual_install_message(dependency: DependencyKind) -> &'static str { + core_windows_manual_install_message(match dependency { + DependencyKind::Node => OpenClawInstallDependencyKind::Node, + DependencyKind::Git => OpenClawInstallDependencyKind::Git, + }) } fn prepend_path(dir: &Path) -> Option { @@ -2221,42 +2426,58 @@ fn apply_windows_no_window(_command: &mut Command) { } async fn find_command_in_shell(command_name: &str) -> Result, String> { - if cfg!(target_os = "windows") { - let mut command = Command::new("cmd"); - apply_windows_no_window(&mut command); - let output = command - .arg("/C") - .arg("where") - .arg(command_name) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await - .map_err(|e| format!("查找命令失败: {e}"))?; + let mut candidates = Vec::new(); - if output.status.success() { - let result = String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .map(str::to_string); - if result.is_some() { - return Ok(result); - } - } - - return Ok(find_command_in_known_locations(command_name) - .await? - .map(|path| path.to_string_lossy().to_string())); + #[cfg(target_os = "windows")] + { + candidates.extend(find_commands_via_where(command_name).await?); } - Ok(find_command_in_known_locations(command_name) + candidates.extend(find_all_commands_in_known_locations(command_name)); + + let mut deduped = Vec::with_capacity(candidates.len()); + let mut seen = HashSet::new(); + for candidate in candidates { + if seen.insert(candidate.clone()) { + deduped.push(candidate); + } + } + + Ok(select_command_candidate(command_name, deduped) .await? .map(|path| path.to_string_lossy().to_string())) } -async fn find_command_in_known_locations(command_name: &str) -> Result, String> { - let candidates = find_all_commands_in_known_locations(command_name); +#[cfg(target_os = "windows")] +async fn find_commands_via_where(command_name: &str) -> Result, String> { + let mut command = Command::new("cmd"); + apply_windows_no_window(&mut command); + let output = command + .arg("/C") + .arg("where") + .arg(command_name) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await + .map_err(|e| format!("查找命令失败: {e}"))?; + + if !output.status.success() { + return Ok(Vec::new()); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect()) +} + +async fn select_command_candidate( + command_name: &str, + candidates: Vec, +) -> Result, String> { if candidates.is_empty() { return Ok(None); } @@ -2366,11 +2587,14 @@ async fn select_node_runtime_candidate( let preferred_node = select_best_node_candidate(find_all_commands_in_known_locations("node")).await?; if let Some(preferred_bin_dir) = preferred_node.as_deref().and_then(Path::parent) { - if let Some(candidate) = candidates - .iter() - .find(|candidate| candidate.parent() == Some(preferred_bin_dir)) - { - return Ok(Some(candidate.clone())); + if let Some(candidate) = select_preferred_path_candidate( + candidates + .iter() + .filter(|candidate| candidate.parent() == Some(preferred_bin_dir)) + .cloned() + .collect(), + ) { + return Ok(Some(candidate)); } } @@ -2419,23 +2643,11 @@ async fn read_binary_semver(path: &Path) -> Option<(u64, u64, u64)> { fn select_best_semver_candidate( candidates: Vec<(PathBuf, Option<(u64, u64, u64)>)>, ) -> Option { - candidates - .iter() - .filter_map(|(path, version)| { - version - .filter(|version| *version >= NODE_MIN_VERSION) - .map(|version| (path.clone(), version)) - }) - .max_by_key(|(_, version)| *version) - .map(|(path, _)| path) - .or_else(|| { - candidates - .iter() - .filter_map(|(path, version)| version.map(|version| (path.clone(), version))) - .max_by_key(|(_, version)| *version) - .map(|(path, _)| path) - }) - .or_else(|| candidates.into_iter().next().map(|(path, _)| path)) + core_select_best_semver_candidate(candidates, NODE_MIN_VERSION) +} + +fn select_preferred_path_candidate(candidates: Vec) -> Option { + core_select_preferred_path_candidate(candidates) } async fn run_shell_command_with_progress( @@ -2647,13 +2859,20 @@ fn format_semver(version: (u64, u64, u64)) -> String { #[cfg(test)] mod tests { use super::{ - build_environment_status, determine_api_type, extract_gateway_auth_token, - format_provider_base_url, has_api_version, parse_semver_from_text, trim_trailing_slash, - DependencyStatus, + apply_gateway_runtime_defaults, build_environment_status, build_openclaw_cleanup_command, + build_openclaw_install_command, build_winget_install_command, command_bin_dir_for, + determine_api_type, extract_gateway_auth_token, format_gateway_start_failure_message, + format_provider_base_url, gateway_start_args, has_api_version, parse_semver_from_text, + resolve_windows_dependency_install_plan, select_best_semver_candidate, + select_preferred_path_candidate, shell_command_escape_for, shell_npm_prefix_assignment_for, + shell_path_assignment_for, trim_trailing_slash, windows_manual_install_message, + DependencyKind, DependencyStatus, ShellPlatform, WindowsDependencyInstallPlan, + NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE, }; use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup}; use chrono::Utc; - use serde_json::json; + use serde_json::{json, Value}; + use std::path::PathBuf; fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvider { ApiKeyProvider { @@ -2782,6 +3001,81 @@ mod tests { assert_eq!(extract_gateway_auth_token(&config), None); } + #[test] + fn applies_gateway_runtime_defaults_for_current_openclaw() { + let mut config = json!({}); + + apply_gateway_runtime_defaults(&mut config, 18790, "proxycast-token"); + + assert_eq!( + config.pointer("/gateway/mode").and_then(Value::as_str), + Some("local") + ); + assert_eq!( + config.pointer("/gateway/bind").and_then(Value::as_str), + Some("loopback") + ); + assert_eq!( + config.pointer("/gateway/auth/mode").and_then(Value::as_str), + Some("token") + ); + assert_eq!( + config + .pointer("/gateway/auth/token") + .and_then(Value::as_str), + Some("proxycast-token") + ); + assert_eq!( + config + .pointer("/gateway/remote/token") + .and_then(Value::as_str), + Some("proxycast-token") + ); + assert_eq!( + config.pointer("/gateway/port").and_then(Value::as_u64), + Some(18_790) + ); + } + + #[test] + fn gateway_start_args_include_new_runtime_guards() { + assert_eq!( + gateway_start_args(18790, "proxycast-token"), + vec![ + "gateway", + "--allow-unconfigured", + "--bind", + "loopback", + "--auth", + "token", + "--token", + "proxycast-token", + "--port", + "18790", + ] + ); + } + + #[test] + fn formats_gateway_start_failure_for_missing_config() { + assert_eq!( + format_gateway_start_failure_message(Some( + "Missing config. Run `openclaw setup` or set gateway.mode=local." + )), + "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。" + ); + } + + #[test] + fn formats_gateway_start_failure_for_loopback_bind_error() { + assert_eq!( + format_gateway_start_failure_message(Some( + "gateway bind=loopback resolved to non-loopback host 0.0.0.0" + )), + "Gateway 启动失败:当前环境无法绑定到本地回环地址 127.0.0.1,请检查本机网络或代理配置。" + ); + } + #[test] fn parses_semver_from_git_version_text() { assert_eq!( @@ -2819,4 +3113,294 @@ mod tests { assert_eq!(env.recommended_action, "install_node"); assert_eq!(env.openclaw.auto_install_supported, false); } + + #[test] + fn semver_selection_prefers_windows_launcher_over_bare_file_when_versions_equal() { + let preferred = select_best_semver_candidate(vec![ + (PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), Some((23, 1, 0))), + ( + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), + Some((23, 1, 0)), + ), + ]); + + assert_eq!( + preferred, + Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd")) + ); + } + + #[test] + fn windows_command_bin_dir_supports_backslash_paths() { + assert_eq!( + command_bin_dir_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), + Some(r"C:\Program Files\nodejs".to_string()) + ); + } + + #[test] + fn windows_shell_command_escape_keeps_cmd_compatible_quotes() { + assert_eq!( + shell_command_escape_for(ShellPlatform::Windows, r#"C:\Program Files\nodejs\npm.cmd"#), + r#""C:\Program Files\nodejs\npm.cmd""# + ); + assert_eq!( + shell_command_escape_for(ShellPlatform::Windows, "C:\\demo\\na\"me\\npm.cmd"), + r#""C:\demo\na""me\npm.cmd""# + ); + } + + #[test] + fn windows_shell_npm_prefix_assignment_uses_set_syntax() { + assert_eq!( + shell_npm_prefix_assignment_for( + ShellPlatform::Windows, + r"C:\Users\demo\AppData\Roaming\npm" + ), + r#"set "NPM_CONFIG_PREFIX=C:\Users\demo\AppData\Roaming\npm" && "# + ); + } + + #[test] + fn windows_shell_path_assignment_prepends_binary_directory() { + assert_eq!( + shell_path_assignment_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), + r#"set "PATH=C:\Program Files\nodejs;%PATH%" && "# + ); + } + + #[test] + fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() { + let command = build_openclaw_cleanup_command( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd", + Some(r"C:\Users\demo\AppData\Roaming\npm"), + ); + + assert_eq!( + command, + concat!( + "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", + "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", + "\"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh" + ) + ); + assert!(!command.contains("|| true")); + } + + #[test] + fn windows_install_command_adds_registry_when_using_china_package() { + let command = build_openclaw_install_command( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd", + Some(r"C:\Users\demo\AppData\Roaming\npm"), + OPENCLAW_CN_PACKAGE, + Some(NPM_MIRROR_CN), + ); + + assert_eq!( + command, + concat!( + "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", + "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", + "\"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ", + "--registry=https://registry.npmmirror.com" + ) + ); + } + + #[test] + fn windows_install_command_omits_registry_for_default_package() { + let command = build_openclaw_install_command( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd", + None, + OPENCLAW_DEFAULT_PACKAGE, + None, + ); + + assert_eq!( + command, + concat!( + "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", + "\"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest" + ) + ); + assert!(!command.contains("--registry=")); + } + + #[test] + fn preferred_path_candidate_prioritizes_windows_executable_extensions() { + let preferred = select_preferred_path_candidate(vec![ + PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.bat"), + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), + PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe"), + ]); + + assert_eq!( + preferred, + Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe")) + ); + } + + #[test] + fn windows_node_prefers_winget_when_available() { + assert_eq!( + resolve_windows_dependency_install_plan(DependencyKind::Node, true), + WindowsDependencyInstallPlan::Winget { + package_id: "OpenJS.NodeJS.LTS" + } + ); + } + + #[test] + fn windows_node_falls_back_to_official_installer_without_winget() { + assert_eq!( + resolve_windows_dependency_install_plan(DependencyKind::Node, false), + WindowsDependencyInstallPlan::OfficialInstaller + ); + } + + #[test] + fn windows_git_prefers_winget_when_available() { + assert_eq!( + resolve_windows_dependency_install_plan(DependencyKind::Git, true), + WindowsDependencyInstallPlan::Winget { + package_id: "Git.Git" + } + ); + } + + #[test] + fn windows_git_requires_manual_download_without_winget() { + assert_eq!( + resolve_windows_dependency_install_plan(DependencyKind::Git, false), + WindowsDependencyInstallPlan::ManualDownload + ); + assert_eq!( + windows_manual_install_message(DependencyKind::Git), + "当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。" + ); + } + + #[test] + fn winget_install_command_uses_expected_windows_flags() { + assert_eq!( + build_winget_install_command( + r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe", + "OpenJS.NodeJS.LTS" + ), + concat!( + "set \"PATH=C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps;%PATH%\" && ", + "\"C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps\\winget.exe\" install ", + "--id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements" + ) + ); + } +} + +/// 从 Windows 注册表读取最新的 PATH 环境变量并刷新当前进程 +#[cfg(target_os = "windows")] +fn refresh_windows_path_from_registry() -> Result<(), String> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::ptr; + + unsafe { + let mut combined_path = String::new(); + + // 读取系统 PATH (HKEY_LOCAL_MACHINE) + if let Ok(system_path) = read_registry_path(HKEY_LOCAL_MACHINE) { + combined_path.push_str(&system_path); + } + + // 读取用户 PATH (HKEY_CURRENT_USER) + if let Ok(user_path) = read_registry_path(HKEY_CURRENT_USER) { + if !combined_path.is_empty() { + combined_path.push(';'); + } + combined_path.push_str(&user_path); + } + + if !combined_path.is_empty() { + std::env::set_var("PATH", combined_path); + } + } + + Ok(()) +} + +#[cfg(target_os = "windows")] +unsafe fn read_registry_path(root_key: HKEY) -> Result { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::ptr; + + let subkey: Vec = OsStr::new("Environment") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let value_name: Vec = OsStr::new("Path") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut key: HKEY = ptr::null_mut(); + let result = RegOpenKeyExW( + root_key, + subkey.as_ptr(), + 0, + winapi::um::winnt::KEY_READ, + &mut key, + ); + + if result != ERROR_SUCCESS as i32 { + return Err(format!("无法打开注册表键: {}", result)); + } + + let mut buffer_size: DWORD = 0; + let result = RegQueryValueExW( + key, + value_name.as_ptr(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + &mut buffer_size, + ); + + if result != ERROR_SUCCESS as i32 { + winapi::um::winreg::RegCloseKey(key); + return Err(format!("无法查询注册表值大小: {}", result)); + } + + let mut buffer: Vec = vec![0; (buffer_size / 2) as usize + 1]; + let result = RegQueryValueExW( + key, + value_name.as_ptr(), + ptr::null_mut(), + ptr::null_mut(), + buffer.as_mut_ptr() as *mut u8, + &mut buffer_size, + ); + + winapi::um::winreg::RegCloseKey(key); + + if result != ERROR_SUCCESS as i32 { + return Err(format!("无法读取注册表值: {}", result)); + } + + // 移除尾部的 null 字符 + if let Some(null_pos) = buffer.iter().position(|&c| c == 0) { + buffer.truncate(null_pos); + } + + Ok(String::from_utf16_lossy(&buffer)) +} + +#[cfg(not(target_os = "windows"))] +#[allow(dead_code)] +fn refresh_windows_path_from_registry() -> Result<(), String> { + Ok(()) } diff --git a/src-tauri/src/services/web_search_runtime_service.rs b/src-tauri/src/services/web_search_runtime_service.rs index b7c19797c..7a7d4c58c 100644 --- a/src-tauri/src/services/web_search_runtime_service.rs +++ b/src-tauri/src/services/web_search_runtime_service.rs @@ -2,148 +2,17 @@ //! //! 将设置页中的网络搜索配置同步为 aster-rust 可读取的环境变量。 -use proxycast_core::config::{ - Config, MultiSearchEngineEntryConfig, WebSearchConfig, WebSearchProvider, -}; - -fn provider_to_env_value(provider: &WebSearchProvider) -> &'static str { - match provider { - WebSearchProvider::Tavily => "tavily", - WebSearchProvider::MultiSearchEngine => "multi_search_engine", - WebSearchProvider::DuckduckgoInstant => "duckduckgo_instant", - WebSearchProvider::BingSearchApi => "bing_search_api", - WebSearchProvider::GoogleCustomSearch => "google_custom_search", - } -} - -fn default_provider_chain() -> Vec { - vec![ - WebSearchProvider::Tavily, - WebSearchProvider::MultiSearchEngine, - WebSearchProvider::BingSearchApi, - WebSearchProvider::GoogleCustomSearch, - WebSearchProvider::DuckduckgoInstant, - ] -} - -fn normalize_text(value: &Option) -> Option { - value - .as_ref() - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) -} - -fn push_provider_unique(target: &mut Vec, provider: WebSearchProvider) { - if !target.contains(&provider) { - target.push(provider); - } -} - -fn resolve_provider_priority(web_search: &WebSearchConfig) -> Vec { - let mut resolved = Vec::new(); - push_provider_unique(&mut resolved, web_search.provider.clone()); - for provider in &web_search.provider_priority { - push_provider_unique(&mut resolved, provider.clone()); - } - for provider in default_provider_chain() { - push_provider_unique(&mut resolved, provider); - } - resolved -} - -fn normalize_engine_entry(entry: &MultiSearchEngineEntryConfig) -> Option { - let name = entry.name.trim(); - let template = entry.url_template.trim(); - if name.is_empty() || template.is_empty() || !template.contains("{query}") { - return None; - } - Some(serde_json::json!({ - "name": name, - "url_template": template, - "enabled": entry.enabled, - })) -} - -fn set_or_clear_env(key: &str, value: Option) { - if let Some(value) = value { - std::env::set_var(key, value); - } else { - std::env::remove_var(key); - } -} +use crate::services::environment_service::apply_web_search_environment; +use proxycast_core::config::Config; pub fn apply_web_search_runtime_env(config: &Config) { - let web_search = &config.web_search; - let provider_priority = resolve_provider_priority(web_search); - - std::env::set_var( - "WEB_SEARCH_PROVIDER", - provider_to_env_value(&web_search.provider), - ); - std::env::set_var( - "WEB_SEARCH_PROVIDER_PRIORITY", - provider_priority - .iter() - .map(provider_to_env_value) - .collect::>() - .join(","), - ); - - set_or_clear_env("TAVILY_API_KEY", normalize_text(&web_search.tavily_api_key)); - set_or_clear_env( - "BING_SEARCH_API_KEY", - normalize_text(&web_search.bing_search_api_key), - ); - set_or_clear_env( - "GOOGLE_SEARCH_API_KEY", - normalize_text(&web_search.google_search_api_key), - ); - set_or_clear_env( - "GOOGLE_SEARCH_ENGINE_ID", - normalize_text(&web_search.google_search_engine_id), - ); - - let multi_search_priority = if web_search.multi_search.priority.is_empty() { - web_search - .multi_search - .engines - .iter() - .map(|entry| entry.name.trim().to_string()) - .filter(|name| !name.is_empty()) - .collect::>() - } else { - web_search - .multi_search - .priority - .iter() - .map(|name| name.trim().to_string()) - .filter(|name| !name.is_empty()) - .collect::>() - }; - - let engines = web_search - .multi_search - .engines - .iter() - .filter_map(normalize_engine_entry) - .collect::>(); - - let mse_config = serde_json::json!({ - "priority": multi_search_priority, - "engines": engines, - "max_results_per_engine": web_search.multi_search.max_results_per_engine, - "max_total_results": web_search.multi_search.max_total_results, - "timeout_ms": web_search.multi_search.timeout_ms, - }); - set_or_clear_env( - "MULTI_SEARCH_ENGINE_CONFIG_JSON", - serde_json::to_string(&mse_config).ok(), - ); + apply_web_search_environment(config); } #[cfg(test)] mod tests { - use super::*; + use crate::services::environment_service::build_web_search_runtime_env; + use proxycast_core::config::{Config, WebSearchConfig, WebSearchProvider}; use proxycast_core::config::{MultiSearchConfig, SearchEngine}; #[test] @@ -155,30 +24,46 @@ mod tests { WebSearchProvider::Tavily, ]; - let priority = resolve_provider_priority(&web_search); + let config = Config { + web_search, + ..Config::default() + }; + let raw = build_web_search_runtime_env(&config) + .get("WEB_SEARCH_PROVIDER_PRIORITY") + .cloned() + .unwrap_or_default(); + let priority = raw.split(',').map(str::to_string).collect::>(); assert_eq!( - priority.first(), - Some(&WebSearchProvider::GoogleCustomSearch) + priority.first().map(String::as_str), + Some("google_custom_search") ); - assert!(priority.contains(&WebSearchProvider::DuckduckgoInstant)); - assert!(priority.contains(&WebSearchProvider::Tavily)); + assert!(priority.iter().any(|item| item == "duckduckgo_instant")); + assert!(priority.iter().any(|item| item == "tavily")); } #[test] fn should_filter_invalid_multi_search_engine_entries() { - let valid = MultiSearchEngineEntryConfig { - name: "valid".to_string(), - url_template: "https://example.com/search?q={query}".to_string(), - enabled: true, - }; - let invalid = MultiSearchEngineEntryConfig { - name: "invalid".to_string(), - url_template: "https://example.com/search".to_string(), - enabled: true, - }; + let mut config = Config::default(); + config.web_search.provider = WebSearchProvider::MultiSearchEngine; + config.web_search.multi_search.engines = vec![ + proxycast_core::config::MultiSearchEngineEntryConfig { + name: "valid".to_string(), + url_template: "https://example.com/search?q={query}".to_string(), + enabled: true, + }, + proxycast_core::config::MultiSearchEngineEntryConfig { + name: "invalid".to_string(), + url_template: "https://example.com/search".to_string(), + enabled: true, + }, + ]; - assert!(normalize_engine_entry(&valid).is_some()); - assert!(normalize_engine_entry(&invalid).is_none()); + let raw = build_web_search_runtime_env(&config) + .get("MULTI_SEARCH_ENGINE_CONFIG_JSON") + .cloned() + .unwrap_or_default(); + assert!(raw.contains("\"valid\"")); + assert!(!raw.contains("\"invalid\"")); } #[test] @@ -195,8 +80,10 @@ mod tests { multi_search: MultiSearchConfig::default(), }; - apply_web_search_runtime_env(&config); - let raw = std::env::var("MULTI_SEARCH_ENGINE_CONFIG_JSON").unwrap_or_default(); + let raw = build_web_search_runtime_env(&config) + .get("MULTI_SEARCH_ENGINE_CONFIG_JSON") + .cloned() + .unwrap_or_default(); assert!(!raw.is_empty()); } } diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index 22241c76f..69d2db923 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -1,64 +1,61 @@ use std::fs; use std::path::{Path, PathBuf}; -const VIDEO_GENERATE_SKILL_NAME: &str = "video_generate"; +use proxycast_core::models::{ + BROADCAST_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY, + IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, + RESEARCH_SKILL_DIRECTORY, SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, + URL_PARSE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_DIRECTORY, +}; + const VIDEO_GENERATE_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/video_generate/SKILL.md"); -const BROADCAST_GENERATE_SKILL_NAME: &str = "broadcast_generate"; const BROADCAST_GENERATE_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/broadcast_generate/SKILL.md"); -const COVER_GENERATE_SKILL_NAME: &str = "cover_generate"; const COVER_GENERATE_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/cover_generate/SKILL.md"); -const MODAL_RESOURCE_SEARCH_SKILL_NAME: &str = "modal_resource_search"; const MODAL_RESOURCE_SEARCH_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/modal_resource_search/SKILL.md"); -const IMAGE_GENERATE_SKILL_NAME: &str = "image_generate"; const IMAGE_GENERATE_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/image_generate/SKILL.md"); -const LIBRARY_SKILL_NAME: &str = "library"; const LIBRARY_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/library/SKILL.md"); -const URL_PARSE_SKILL_NAME: &str = "url_parse"; const URL_PARSE_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/url_parse/SKILL.md"); -const RESEARCH_SKILL_NAME: &str = "research"; const RESEARCH_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/research/SKILL.md"); -const TYPESETTING_SKILL_NAME: &str = "typesetting"; const TYPESETTING_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/typesetting/SKILL.md"); -const SOCIAL_POST_WITH_COVER_SKILL_NAME: &str = "social_post_with_cover"; const SOCIAL_POST_WITH_COVER_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/social_post_with_cover/SKILL.md"); fn default_skills() -> [(&'static str, &'static str); 10] { [ - (VIDEO_GENERATE_SKILL_NAME, VIDEO_GENERATE_SKILL_CONTENT), + (VIDEO_GENERATE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_CONTENT), ( - BROADCAST_GENERATE_SKILL_NAME, + BROADCAST_GENERATE_SKILL_DIRECTORY, BROADCAST_GENERATE_SKILL_CONTENT, ), - (COVER_GENERATE_SKILL_NAME, COVER_GENERATE_SKILL_CONTENT), + (COVER_GENERATE_SKILL_DIRECTORY, COVER_GENERATE_SKILL_CONTENT), ( - MODAL_RESOURCE_SEARCH_SKILL_NAME, + MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_CONTENT, ), - (IMAGE_GENERATE_SKILL_NAME, IMAGE_GENERATE_SKILL_CONTENT), - (LIBRARY_SKILL_NAME, LIBRARY_SKILL_CONTENT), - (URL_PARSE_SKILL_NAME, URL_PARSE_SKILL_CONTENT), - (RESEARCH_SKILL_NAME, RESEARCH_SKILL_CONTENT), - (TYPESETTING_SKILL_NAME, TYPESETTING_SKILL_CONTENT), + (IMAGE_GENERATE_SKILL_DIRECTORY, IMAGE_GENERATE_SKILL_CONTENT), + (LIBRARY_SKILL_DIRECTORY, LIBRARY_SKILL_CONTENT), + (URL_PARSE_SKILL_DIRECTORY, URL_PARSE_SKILL_CONTENT), + (RESEARCH_SKILL_DIRECTORY, RESEARCH_SKILL_CONTENT), + (TYPESETTING_SKILL_DIRECTORY, TYPESETTING_SKILL_CONTENT), ( - SOCIAL_POST_WITH_COVER_SKILL_NAME, + SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, SOCIAL_POST_WITH_COVER_SKILL_CONTENT, ), ] @@ -135,13 +132,13 @@ mod tests { fn should_install_default_skill_when_missing() { let temp = tempfile::tempdir().expect("create temp dir"); let installed = ensure_default_local_skills_in_home(temp.path()).expect("install"); - assert!(installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string())); + assert!(installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY.to_string())); let skill_md_path = temp .path() .join(".proxycast") .join("skills") - .join(SOCIAL_POST_WITH_COVER_SKILL_NAME) + .join(SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY) .join("SKILL.md"); assert!(skill_md_path.exists()); } @@ -153,7 +150,7 @@ mod tests { .path() .join(".proxycast") .join("skills") - .join(SOCIAL_POST_WITH_COVER_SKILL_NAME); + .join(SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY); fs::create_dir_all(&skill_dir).expect("create skill dir"); let skill_md_path = skill_dir.join("SKILL.md"); // 无版本号的自定义内容不应被覆盖 @@ -162,7 +159,7 @@ mod tests { let installed = ensure_default_local_skills_in_home(temp.path()).expect("install"); assert!( - !installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string()), + !installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY.to_string()), "无版本信息的已存在 skill 不应被重新安装" ); @@ -177,7 +174,7 @@ mod tests { .path() .join(".proxycast") .join("skills") - .join(SOCIAL_POST_WITH_COVER_SKILL_NAME); + .join(SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY); fs::create_dir_all(&skill_dir).expect("create skill dir"); let skill_md_path = skill_dir.join("SKILL.md"); // 旧版本内容 @@ -186,7 +183,7 @@ mod tests { let installed = ensure_default_local_skills_in_home(temp.path()).expect("install"); assert!( - installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_NAME.to_string()), + installed.contains(&SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY.to_string()), "内置版本更新时应自动升级" ); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4aacab948..0e22a9462 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.83.2", + "version": "0.84.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 5d1276e2b..4929bb5c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import React, { useState, useEffect, useCallback } from "react"; import styled from "styled-components"; -import { safeInvoke } from "@/lib/dev-bridge"; +import { getWindowsStartupDiagnostics } from "@/lib/api/serverRuntime"; import { withI18nPatch } from "./i18n/withI18nPatch"; import { SplashScreen } from "./components/SplashScreen"; import { AppSidebar } from "./components/AppSidebar"; @@ -30,6 +30,7 @@ import { WorkbenchPage } from "./components/workspace"; import { ProjectType, createProject, + ensureDefaultWorkspaceReady, isUserProjectType, resolveProjectRootPath, } from "./lib/api/project"; @@ -112,22 +113,6 @@ const THEME_WORKSPACE_PAGES: ThemeWorkspacePage[] = [ "workspace-novel", ]; -interface WindowsStartupDiagnostics { - platform: string; - app_data_dir?: string | null; - legacy_proxycast_dir?: string | null; - db_path?: string | null; - webview2_version?: string | null; - checks: Array<{ - key: string; - status: string; - message: string; - detail?: string | null; - }>; - has_blocking_issues: boolean; - has_warnings: boolean; - summary_message?: string | null; -} function isTauriDesktopEnvironment(): boolean { if (typeof window === "undefined") { @@ -386,9 +371,7 @@ function AppContent() { return; } - void safeInvoke( - "get_windows_startup_diagnostics", - ) + void getWindowsStartupDiagnostics() .then((diagnostics) => { if (!diagnostics.summary_message) { return; @@ -415,13 +398,7 @@ function AppContent() { }, []); useEffect(() => { - void safeInvoke<{ - workspaceId: string; - rootPath: string; - created: boolean; - repaired: boolean; - relocated?: boolean; - } | null>("workspace_ensure_default_ready") + void ensureDefaultWorkspaceReady() .then((result) => { if (result?.repaired) { recordWorkspaceRepair({ @@ -633,6 +610,7 @@ function AppContent() { style={{ flex: 1, minHeight: 0, + overflowY: "auto", display: currentPage === "openclaw" ? "flex" : "none", flexDirection: "column", }} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index bc86fd47e..533164911 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -43,7 +43,7 @@ import { PageParams, ThemeWorkspacePage, } from "@/types/page"; -import { getConfig } from "@/hooks/useTauri"; +import { getConfig } from "@/lib/api/appConfig"; import { buildHomeAgentParams, buildWorkspaceResetParams, @@ -306,6 +306,7 @@ const THEME_MENU_ITEMS: SidebarNavItem[] = [ label: "短视频", icon: Video, page: getThemeWorkspacePage("video"), + params: { workspaceViewMode: "workspace" }, isActive: (currentPage) => currentPage === getThemeWorkspacePage("video"), }, { diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index c52191776..0688ab6de 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -18,8 +18,6 @@ import { getEnvVariables, getTokenFileHash, checkAndReloadCredentials, - KiroCredentialStatus, - EnvVariable, // Gemini getGeminiCredentials, reloadGeminiCredentials, @@ -27,7 +25,6 @@ import { getGeminiEnvVariables, getGeminiTokenFileHash, checkAndReloadGeminiCredentials, - GeminiCredentialStatus, // Qwen getQwenCredentials, reloadQwenCredentials, @@ -35,18 +32,19 @@ import { getQwenEnvVariables, getQwenTokenFileHash, checkAndReloadQwenCredentials, - QwenCredentialStatus, // OpenAI/Claude Custom getOpenAICustomStatus, setOpenAICustomConfig, getClaudeCustomStatus, setClaudeCustomConfig, - OpenAICustomStatus, - ClaudeCustomStatus, - // Default Provider - getDefaultProvider, - setDefaultProvider, -} from "@/hooks/useTauri"; + type ClaudeCustomStatus, + type EnvVariable, + type GeminiCredentialStatus, + type KiroCredentialStatus, + type OpenAICustomStatus, + type QwenCredentialStatus, +} from "@/lib/api/providerRuntime"; +import { getDefaultProvider, setDefaultProvider } from "@/lib/api/appConfig"; import { useProviderState } from "@/hooks/useProviderState"; import { useFileMonitoring } from "@/hooks/useFileMonitoring"; diff --git a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx index 2263bc9f6..932abea08 100644 --- a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx +++ b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx @@ -48,7 +48,7 @@ const { mockEmitProviderDataChanged: vi.fn(), })); -vi.mock("@/lib/api/agent", () => ({ +vi.mock("@/lib/api/agentRuntime", () => ({ initAsterAgent: mockInitAsterAgent, sendAsterMessageStream: mockSendAsterMessageStream, createAsterSession: mockCreateAsterSession, @@ -59,6 +59,9 @@ vi.mock("@/lib/api/agent", () => ({ stopAsterSession: mockStopAsterSession, confirmAsterAction: mockConfirmAsterAction, submitAsterElicitationResponse: mockSubmitAsterElicitationResponse, +})); + +vi.mock("@/lib/api/agentStream", () => ({ parseStreamEvent: mockParseStreamEvent, })); @@ -141,7 +144,10 @@ function createModel(id: string, providerId: string) { }; } -function mount(workspaceId: string, options: MountOptions = {}): HTMLDivElement { +function mount( + workspaceId: string, + options: MountOptions = {}, +): HTMLDivElement { const { onManageProviders } = options; const container = document.createElement("div"); document.body.appendChild(container); @@ -218,12 +224,14 @@ function findButtonByText( options: { excludeCombobox?: boolean } = {}, ): HTMLButtonElement { const { excludeCombobox = false } = options; - const target = Array.from(document.querySelectorAll("button")).find((node) => { - if (excludeCombobox && node.getAttribute("role") === "combobox") { - return false; - } - return node.textContent?.includes(text); - }); + const target = Array.from(document.querySelectorAll("button")).find( + (node) => { + if (excludeCombobox && node.getAttribute("role") === "combobox") { + return false; + } + return node.textContent?.includes(text); + }, + ); if (!target) { throw new Error(`未找到按钮文本: ${text}`); } @@ -294,22 +302,30 @@ beforeEach(() => { loading: false, }); - mockUseProviderModels.mockImplementation((selectedProvider: { key: string } | null) => { - const key = selectedProvider?.key; - const models = - key === "gemini" - ? [createModel("gemini-2.5-pro", "gemini"), createModel("gemini-2.5-flash", "gemini")] - : key === "deepseek" - ? [createModel("deepseek-chat", "deepseek"), createModel("deepseek-reasoner", "deepseek")] - : []; + mockUseProviderModels.mockImplementation( + (selectedProvider: { key: string } | null) => { + const key = selectedProvider?.key; + const models = + key === "gemini" + ? [ + createModel("gemini-2.5-pro", "gemini"), + createModel("gemini-2.5-flash", "gemini"), + ] + : key === "deepseek" + ? [ + createModel("deepseek-chat", "deepseek"), + createModel("deepseek-reasoner", "deepseek"), + ] + : []; - return { - modelIds: models.map((item) => item.id), - models, - loading: false, - error: null, - }; - }); + return { + modelIds: models.map((item) => item.id), + models, + loading: false, + error: null, + }; + }, + ); }); afterEach(() => { @@ -385,9 +401,8 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => { expect( JSON.parse( - localStorage.getItem( - `agent_topic_model_pref_${workspaceId}_topic-a`, - ) || "null", + localStorage.getItem(`agent_topic_model_pref_${workspaceId}_topic-a`) || + "null", ), ).toEqual({ providerType: "gemini", @@ -395,9 +410,8 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => { }); expect( JSON.parse( - localStorage.getItem( - `agent_topic_model_pref_${workspaceId}_topic-b`, - ) || "null", + localStorage.getItem(`agent_topic_model_pref_${workspaceId}_topic-b`) || + "null", ), ).toEqual({ providerType: "deepseek", diff --git a/src/components/agent/chat/components/ChatNavbar.test.tsx b/src/components/agent/chat/components/ChatNavbar.test.tsx new file mode 100644 index 000000000..3ea4924b6 --- /dev/null +++ b/src/components/agent/chat/components/ChatNavbar.test.tsx @@ -0,0 +1,152 @@ +import React, { useState } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ChatNavbar } from "./ChatNavbar"; + +vi.mock("@/components/projects/ProjectSelector", () => ({ + ProjectSelector: () =>
, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onClick, + disabled, + ...rest + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + [key: string]: unknown; + }) => ( + + ), +})); + +interface MountedHarness { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedHarness[] = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function mount(node: React.ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + mountedRoots.push({ container, root }); + return container; +} + +function renderChatNavbar( + props?: Partial>, +) { + const defaultProps: React.ComponentProps = { + isRunning: false, + onToggleHistory: vi.fn(), + onToggleFullscreen: vi.fn(), + }; + + return mount(); +} + +describe("ChatNavbar", () => { + it("有 Harness 信号时应渲染顶栏切换按钮", () => { + const onToggleHarnessPanel = vi.fn(); + const container = renderChatNavbar({ + showHarnessToggle: true, + harnessPanelVisible: false, + harnessPendingCount: 2, + onToggleHarnessPanel, + }); + + const button = container.querySelector( + 'button[aria-label="展开 Harness 面板"]', + ) as HTMLButtonElement | null; + + expect(button).not.toBeNull(); + expect(button?.textContent).toContain("Harness"); + expect(button?.textContent).toContain("2"); + + act(() => { + button?.click(); + }); + + expect(onToggleHarnessPanel).toHaveBeenCalledTimes(1); + }); + + it("点击顶栏按钮后应切换 Harness 面板显隐", () => { + function HarnessToggleHarness() { + const [visible, setVisible] = useState(false); + + return ( + <> + {}} + onToggleFullscreen={() => {}} + showHarnessToggle + harnessPanelVisible={visible} + onToggleHarnessPanel={() => setVisible((current) => !current)} + /> + {visible ? ( +
Harness Panel
+ ) : null} + + ); + } + + const container = mount(); + const expandButton = container.querySelector( + 'button[aria-label="展开 Harness 面板"]', + ) as HTMLButtonElement | null; + + expect(container.querySelector('[data-testid="harness-panel"]')).toBeNull(); + + act(() => { + expandButton?.click(); + }); + + expect( + container.querySelector('[data-testid="harness-panel"]'), + ).not.toBeNull(); + + const collapseButton = container.querySelector( + 'button[aria-label="收起 Harness 面板"]', + ) as HTMLButtonElement | null; + + act(() => { + collapseButton?.click(); + }); + + expect(container.querySelector('[data-testid="harness-panel"]')).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index 97877679c..885a62ca2 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -1,16 +1,19 @@ import React from "react"; import { Box, + ChevronDown, FolderOpen, Home, PanelLeftClose, PanelLeftOpen, Plus, Settings2, + Sparkles, X, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ProjectSelector } from "@/components/projects/ProjectSelector"; +import { cn } from "@/lib/utils"; import { Navbar } from "../styles"; interface ChatNavbarProps { @@ -25,6 +28,11 @@ interface ChatNavbarProps { projectId?: string | null; onProjectChange?: (projectId: string) => void; workspaceType?: string; + showHarnessToggle?: boolean; + harnessPanelVisible?: boolean; + onToggleHarnessPanel?: () => void; + harnessPendingCount?: number; + harnessAttentionLevel?: "idle" | "active" | "warning"; novelCanvasControls?: { chapterListCollapsed: boolean; onToggleChapterList: () => void; @@ -45,6 +53,11 @@ export const ChatNavbar: React.FC = ({ projectId = null, onProjectChange, workspaceType, + showHarnessToggle = false, + harnessPanelVisible = false, + onToggleHarnessPanel, + harnessPendingCount = 0, + harnessAttentionLevel = "idle", novelCanvasControls = null, }) => { return ( @@ -147,6 +160,42 @@ export const ChatNavbar: React.FC = ({ className="h-8 text-xs min-w-[160px] max-w-[220px]" /> + {showHarnessToggle ? ( + + ) : null} + + ); + } + + return ( +
+ {cardContent} +
+ ); +} + +function Section({ + sectionKey, + title, + badge, + children, + registerRef, +}: { + sectionKey?: HarnessSectionKey; + title: string; + badge?: string; + children: ReactNode; + registerRef?: (key: HarnessSectionKey, node: HTMLElement | null) => void; +}) { + return ( +
+ sectionKey && registerRef ? registerRef(sectionKey, node) : undefined + } + data-harness-section={sectionKey} + className="rounded-xl border border-border bg-background/80 p-4" + > +
+

{title}

+ {badge ? {badge} : null} +
+ {children} +
+ ); +} + +export function HarnessStatusPanel({ + harnessState, + subAgentRuntime, + environment, + onLoadFilePreview, + onOpenFile, + onRevealPath, + onOpenPath, +}: HarnessStatusPanelProps) { + const [expanded, setExpanded] = useState(true); + const [fileFilter, setFileFilter] = useState("all"); + const [outputFilter, setOutputFilter] = useState("all"); + const [fileDisplayMode, setFileDisplayMode] = + useState("timeline"); + const [previewDialog, setPreviewDialog] = useState({ + open: false, + title: "", + displayName: "", + isBinary: false, + loading: false, + }); + const previewRequestIdRef = useRef(0); + const sectionRefs = useRef>>( + {}, + ); + + const registerSectionRef = useCallback( + (key: HarnessSectionKey, node: HTMLElement | null) => { + sectionRefs.current[key] = node; + }, + [], + ); + + const scrollToSection = useCallback( + (key: HarnessSectionKey) => { + const target = sectionRefs.current[key]; + if (!target) { + return; + } + target.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + [], + ); + + const recentSchedulerEvents = useMemo( + () => subAgentRuntime.events.slice(-4).reverse(), + [subAgentRuntime.events], + ); + + const fileFilterOptions = useMemo( + () => + [ + { value: "all" as const, label: "全部" }, + { value: "document" as const, label: "文档" }, + { value: "code" as const, label: "代码" }, + { value: "log" as const, label: "日志" }, + { value: "artifact" as const, label: "产物" }, + { value: "offload" as const, label: "转存" }, + { value: "other" as const, label: "其他" }, + ].filter( + (option) => + option.value === "all" || + harnessState.recentFileEvents.some((event) => event.kind === option.value), + ), + [harnessState.recentFileEvents], + ); + + const outputFilterOptions = useMemo( + () => + [ + { value: "all" as const, label: "全部" }, + { value: "path" as const, label: "有路径" }, + { value: "offload" as const, label: "转存" }, + { value: "truncated" as const, label: "截断" }, + { value: "summary" as const, label: "仅摘要" }, + ].filter( + (option) => + option.value === "all" || + harnessState.outputSignals.some((signal) => + matchesOutputFilter(signal, option.value), + ), + ), + [harnessState.outputSignals], + ); + + const filteredFileEvents = useMemo( + () => + harnessState.recentFileEvents.filter( + (event) => fileFilter === "all" || event.kind === fileFilter, + ), + [fileFilter, harnessState.recentFileEvents], + ); + + const filteredOutputSignals = useMemo( + () => + harnessState.outputSignals.filter((signal) => + matchesOutputFilter(signal, outputFilter), + ), + [harnessState.outputSignals, outputFilter], + ); + + const groupedFileEvents = useMemo(() => { + const groups = new Map< + string, + { + key: string; + path: string; + displayName: string; + kind: HarnessFileKind; + latestEvent: HarnessSessionState["recentFileEvents"][number]; + count: number; + events: HarnessSessionState["recentFileEvents"]; + } + >(); + + for (const event of filteredFileEvents) { + const key = event.path.trim() || event.id; + const existing = groups.get(key); + if (!existing) { + groups.set(key, { + key, + path: event.path, + displayName: event.displayName, + kind: event.kind, + latestEvent: event, + count: 1, + events: [event], + }); + continue; + } + + existing.events.push(event); + existing.count += 1; + + const currentTime = existing.latestEvent.timestamp?.getTime() ?? 0; + const nextTime = event.timestamp?.getTime() ?? 0; + if (nextTime >= currentTime) { + existing.latestEvent = event; + existing.displayName = event.displayName; + existing.kind = event.kind; + } + } + + return Array.from(groups.values()) + .map((group) => ({ + ...group, + actionSummary: summarizeFileActions(group.events), + })) + .sort((left, right) => { + const leftTime = left.latestEvent.timestamp?.getTime() ?? 0; + const rightTime = right.latestEvent.timestamp?.getTime() ?? 0; + return rightTime - leftTime; + }); + }, [filteredFileEvents]); + + const availableSections = useMemo( + () => [ + harnessState.pendingApprovals.length > 0 + ? { key: "approvals" as const, label: "待审批" } + : null, + harnessState.recentFileEvents.length > 0 + ? { key: "files" as const, label: "文件活动" } + : null, + harnessState.outputSignals.length > 0 + ? { key: "outputs" as const, label: "工具输出" } + : null, + harnessState.plan.phase !== "idle" || harnessState.plan.items.length > 0 + ? { key: "plan" as const, label: "规划状态" } + : null, + subAgentRuntime.isRunning || + harnessState.delegatedTasks.length > 0 || + recentSchedulerEvents.length > 0 || + subAgentRuntime.error || + subAgentRuntime.result + ? { key: "delegation" as const, label: "子任务委派" } + : null, + harnessState.latestContextTrace.length > 0 + ? { key: "context" as const, label: "上下文轨迹" } + : null, + { key: "capabilities" as const, label: "已装载能力" }, + ].filter((item): item is { key: HarnessSectionKey; label: string } => item !== null), + [ + harnessState.delegatedTasks.length, + harnessState.latestContextTrace.length, + harnessState.outputSignals.length, + harnessState.pendingApprovals.length, + harnessState.plan.items.length, + harnessState.plan.phase, + harnessState.recentFileEvents.length, + recentSchedulerEvents.length, + subAgentRuntime.error, + subAgentRuntime.isRunning, + subAgentRuntime.result, + ], + ); + + const summaryCards = useMemo( + () => [ + { + sectionKey: "approvals" as const, + title: "待审批", + value: `${harnessState.pendingApprovals.length}`, + hint: + harnessState.pendingApprovals.length > 0 + ? "需要你确认的操作" + : "当前无阻塞审批", + icon: ShieldAlert, + }, + { + sectionKey: "files" as const, + title: "文件活动", + value: `${harnessState.recentFileEvents.length}`, + hint: + harnessState.recentFileEvents[0]?.displayName || "暂无可展示文件活动", + icon: FolderOpen, + }, + { + sectionKey: "plan" as const, + title: "计划状态", + value: + harnessState.plan.phase === "planning" + ? "进行中" + : harnessState.plan.phase === "ready" + ? "已就绪" + : "空闲", + hint: + harnessState.plan.items[0]?.content || "未检测到显式计划快照", + icon: ListChecks, + }, + { + sectionKey: "context" as const, + title: "上下文", + value: `${environment.activeContextCount}/${environment.contextItemsCount}`, + hint: environment.contextEnabled ? "上下文工作台已启用" : "普通聊天模式", + icon: Sparkles, + }, + ], + [ + environment.activeContextCount, + environment.contextEnabled, + environment.contextItemsCount, + harnessState.pendingApprovals.length, + harnessState.plan.items, + harnessState.plan.phase, + harnessState.recentFileEvents, + ], + ); + + const openPreview = useCallback( + async ({ + title, + description, + path, + content, + preview, + }: { + title: string; + description?: string; + path?: string; + content?: string; + preview?: string; + }) => { + const requestId = previewRequestIdRef.current + 1; + previewRequestIdRef.current = requestId; + + const shouldLoad = + !content?.trim() && !!path && typeof onLoadFilePreview === "function"; + + setPreviewDialog({ + open: true, + title, + description, + path, + displayName: path ? getFileName(path) : title, + content: content?.trim() || preview?.trim(), + preview, + error: + content?.trim() || preview?.trim() + ? undefined + : shouldLoad + ? undefined + : "暂无可预览内容", + isBinary: false, + loading: shouldLoad, + }); + + if (!shouldLoad || !path) { + return; + } + + try { + const result = await onLoadFilePreview(path); + if (previewRequestIdRef.current !== requestId) { + return; + } + + const nextPath = result.path || path; + const normalizedContent = result.content ?? undefined; + + setPreviewDialog((current) => ({ + ...current, + path: nextPath, + displayName: getFileName(nextPath), + content: normalizedContent?.trim() ? normalizedContent : current.content, + isBinary: result.isBinary === true, + size: result.size, + error: + result.isBinary === true + ? undefined + : result.error || (normalizedContent ? undefined : current.error), + loading: false, + })); + } catch (error) { + if (previewRequestIdRef.current !== requestId) { + return; + } + + setPreviewDialog((current) => ({ + ...current, + loading: false, + error: error instanceof Error ? error.message : String(error), + })); + } + }, + [onLoadFilePreview], + ); + + const handleOpenFile = useCallback(() => { + if (!onOpenFile || !previewDialog.content?.trim()) { + return; + } + + onOpenFile( + previewDialog.path || previewDialog.displayName, + previewDialog.content, + ); + }, [onOpenFile, previewDialog.content, previewDialog.displayName, previewDialog.path]); + + const handleCopyPath = useCallback(async () => { + const path = previewDialog.path?.trim(); + if (!path) { + toast.error("当前没有可复制的文件路径"); + return; + } + + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + toast.error("当前环境不支持剪贴板复制"); + return; + } + + try { + await navigator.clipboard.writeText(path); + toast.success("文件路径已复制"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "复制路径失败"); + } + }, [previewDialog.path]); + + const handleCopyContent = useCallback(async () => { + const content = previewDialog.content?.trim(); + if (!content) { + toast.error("当前没有可复制的内容"); + return; + } + + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + toast.error("当前环境不支持剪贴板复制"); + return; + } + + try { + await navigator.clipboard.writeText(previewDialog.content || ""); + toast.success("内容已复制"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "复制内容失败"); + } + }, [previewDialog.content]); + + const handleRevealPath = useCallback(async () => { + const path = previewDialog.path?.trim(); + if (!path) { + toast.error("当前没有可定位的文件路径"); + return; + } + + try { + await (onRevealPath ?? revealPathInFinder)(path); + } catch (error) { + toast.error(error instanceof Error ? error.message : "定位文件失败"); + } + }, [onRevealPath, previewDialog.path]); + + const handleOpenPath = useCallback(async () => { + const path = previewDialog.path?.trim(); + if (!path) { + toast.error("当前没有可打开的文件路径"); + return; + } + + try { + await (onOpenPath ?? openPathWithDefaultApp)(path); + } catch (error) { + toast.error(error instanceof Error ? error.message : "打开文件失败"); + } + }, [onOpenPath, previewDialog.path]); + + return ( + <> +
+
+
+
+ +

+ Harness 运行面板 +

+ {subAgentRuntime.isRunning ? ( + + + 子任务运行中 + + ) : null} +
+

+ 展示最近文件活动、工具输出、审批与上下文装载情况。 +

+
+ +
+ +
+ {summaryCards.map((card) => ( + scrollToSection(card.sectionKey)} + /> + ))} +
+ + {expanded ? ( + +
+ {availableSections.length > 0 ? ( +
+ {availableSections.map((item) => ( + + ))} +
+ ) : null} + {harnessState.recentFileEvents.length > 0 ? ( +
+
+
+
+ {fileFilterOptions.map((option) => { + const count = + option.value === "all" + ? harnessState.recentFileEvents.length + : harnessState.recentFileEvents.filter( + (event) => event.kind === option.value, + ).length; + const active = option.value === fileFilter; + + return ( + + ); + })} +
+
+ {[ + { value: "timeline" as const, label: "时间流" }, + { value: "grouped" as const, label: "按文件" }, + ].map((option) => { + const active = option.value === fileDisplayMode; + return ( + + ); + })} +
+
+ {filteredFileEvents.length > 0 ? ( + fileDisplayMode === "grouped" ? ( + groupedFileEvents.map((group) => { + const latestEvent = group.latestEvent; + const Icon = resolveKindIcon(group.kind); + return ( + + ); + }) + ) : ( + filteredFileEvents.map((event) => { + const Icon = resolveKindIcon(event.kind); + return ( + + ); + }) + ) + ) : ( +
+ 当前筛选条件下暂无记录。 +
+ )} +
+
+ ) : null} + + {harnessState.outputSignals.length > 0 ? ( +
+
+
+ {outputFilterOptions.map((option) => { + const count = + option.value === "all" + ? harnessState.outputSignals.length + : harnessState.outputSignals.filter((signal) => + matchesOutputFilter(signal, option.value), + ).length; + const active = option.value === outputFilter; + + return ( + + ); + })} +
+ {filteredOutputSignals.length > 0 ? ( + filteredOutputSignals.map((signal) => { + const signalPath = getSignalPath(signal); + return ( + + ); + }) + ) : ( +
+ 当前筛选条件下暂无记录。 +
+ )} +
+
+ ) : null} + + {harnessState.pendingApprovals.length > 0 ? ( +
+
+ {harnessState.pendingApprovals.map((item) => ( +
+
+ + {item.prompt || "等待用户确认"} +
+ {describeApproval(item) ? ( +
+ {describeApproval(item)} +
+ ) : null} +
+ 请求 ID:{item.requestId} +
+
+ ))} +
+
+ ) : null} + + {harnessState.plan.phase !== "idle" || + harnessState.plan.items.length > 0 ? ( +
+
+ {harnessState.plan.items.length > 0 ? ( + harnessState.plan.items.map((item) => ( +
+
+ {item.content} +
+ + {item.status === "completed" + ? "已完成" + : item.status === "in_progress" + ? "进行中" + : "待开始"} + +
+ )) + ) : ( +
+ 已进入规划流程,但暂无可展示的 Todo 快照。 +
+ )} +
+
+ ) : null} + + {subAgentRuntime.isRunning || + harnessState.delegatedTasks.length > 0 || + recentSchedulerEvents.length > 0 || + subAgentRuntime.error || + subAgentRuntime.result ? ( +
+
+ {subAgentRuntime.progress ? ( +
+
+
+ 调度进度 +
+ + {subAgentRuntime.progress.completed}/ + {subAgentRuntime.progress.total} + +
+
+
+
+ {subAgentRuntime.progress.currentTasks.length > 0 ? ( +
+ 当前任务: + {subAgentRuntime.progress.currentTasks.join("、")} +
+ ) : null} +
+ ) : null} + + {harnessState.delegatedTasks.map((task) => ( +
+
+
+
+ + + {task.title} + +
+
+ {task.role ? 角色:{task.role} : null} + {task.taskType ? ( + 类型:{task.taskType} + ) : null} + {task.model ? 模型:{task.model} : null} +
+ {task.summary ? ( +
+ {task.summary} +
+ ) : null} +
+ + {task.status === "completed" + ? "已完成" + : task.status === "running" + ? "运行中" + : "失败"} + +
+
+ ))} + + {recentSchedulerEvents.length > 0 ? ( +
+
+ 最近调度事件 +
+
+ {recentSchedulerEvents.map((event, index) => ( +
+ {summarizeSchedulerEvent(event)} +
+ ))} +
+
+ ) : null} + + {subAgentRuntime.error ? ( +
+ {subAgentRuntime.error} +
+ ) : null} + + {subAgentRuntime.result?.mergedSummary ? ( +
+ {subAgentRuntime.result.mergedSummary} +
+ ) : null} +
+
+ ) : null} + + {harnessState.latestContextTrace.length > 0 ? ( +
+
+ {harnessState.latestContextTrace.map((step, index) => ( +
+
+ + {step.stage} +
+
+ {step.detail} +
+
+ ))} +
+
+ ) : null} + +
+
+
+ {environment.skillNames.length > 0 ? ( + environment.skillNames.map((name) => ( + + {name} + + )) + ) : ( + + 当前未检测到已装载技能名称 + + )} +
+ +
+ {environment.memorySignals.length > 0 ? ( + environment.memorySignals.map((signal) => ( + + {signal} + + )) + ) : ( + + 当前未识别到持久记忆信号 + + )} +
+ +
+
+ 上下文条目:{environment.activeContextCount}/ + {environment.contextItemsCount} +
+ {environment.contextItemNames.length > 0 ? ( +
活跃上下文:{environment.contextItemNames.join("、")}
+ ) : null} +
+ +
+ + 规划 {harnessState.activity.planning} + + + 文件 {harnessState.activity.filesystem} + + + 执行 {harnessState.activity.execution} + + 网页 {harnessState.activity.web} + + 技能 {harnessState.activity.skills} + + + 委派 {harnessState.activity.delegation} + +
+
+
+
+
+ ) : null} +
+ + + setPreviewDialog((current) => ({ + ...current, + open, + loading: open ? current.loading : false, + })) + } + > + + + {previewDialog.title} + + {previewDialog.description ? ( + {previewDialog.description} + ) : null} + {previewDialog.path ? ( + + {previewDialog.path} + + ) : null} + + + +
+
+ {previewDialog.displayName} + {formatSize(previewDialog.size) ? ( + {formatSize(previewDialog.size)} + ) : null} + {previewDialog.loading ? ( + + + 正在加载完整内容 + + ) : null} + {previewDialog.preview && + previewDialog.content === previewDialog.preview && + !previewDialog.loading ? ( + 当前展示为摘要预览 + ) : null} +
+ + + {previewDialog.isBinary ? ( +
+ + 该文件为二进制内容,暂不支持文本预览。 +
+ ) : previewDialog.error ? ( +
+ + {previewDialog.error} +
+ ) : previewDialog.content ? ( +
+                  {previewDialog.content}
+                
+ ) : ( +
+ + 暂无可展示内容 +
+ )} +
+
+ + + {previewDialog.path ? ( + + ) : null} + {previewDialog.content?.trim() ? ( + + ) : null} + {previewDialog.path ? ( + + ) : null} + {previewDialog.path ? ( + + ) : null} + + {onOpenFile && + !previewDialog.isBinary && + previewDialog.content?.trim() ? ( + + ) : null} + +
+
+ + ); +} + +export default HarnessStatusPanel; diff --git a/src/components/agent/chat/components/StreamingRenderer.tsx b/src/components/agent/chat/components/StreamingRenderer.tsx index 1d9d41f13..482676524 100644 --- a/src/components/agent/chat/components/StreamingRenderer.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.tsx @@ -20,7 +20,7 @@ import type { ParsedMessageContent, } from "@/components/content-creator/a2ui/types"; import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets"; -import type { ToolCallState } from "@/lib/api/agent"; +import type { ToolCallState } from "@/lib/api/agentStream"; import type { ContentPart, ActionRequired, ConfirmResponse } from "../types"; const STRUCTURED_CONTENT_HINT_RE = / { - root.render(); + root.render( + , + ); }); mountedRoots.push({ container, root }); @@ -55,7 +61,9 @@ describe("ToolCallDisplay", () => { result: { success: true, output: "图片已生成", - images: [{ src: "data:image/png;base64,aGVsbG8=", mimeType: "image/png" }], + images: [ + { src: "data:image/png;base64,aGVsbG8=", mimeType: "image/png" }, + ], }, }; @@ -77,7 +85,9 @@ describe("ToolCallDisplay", () => { result: { success: true, output: "图片已生成", - images: [{ src: "data:image/png;base64,aGVsbG8=", mimeType: "image/png" }], + images: [ + { src: "data:image/png;base64,aGVsbG8=", mimeType: "image/png" }, + ], }, }; @@ -96,4 +106,65 @@ describe("ToolCallDisplay", () => { ) as HTMLImageElement | null; expect(enlargedImage).not.toBeNull(); }); + + it("工具结果包含 metadata 时应渲染执行摘要", () => { + const toolCall: ToolCallState = { + id: "tool-meta-1", + name: "Bash", + status: "failed", + startTime: new Date(), + endTime: new Date(), + result: { + success: false, + output: "命令执行失败", + metadata: { + exit_code: 1, + stdout_length: 120, + stderr_length: 32, + sandboxed: true, + output_file: "/tmp/aster_tasks/task-1.log", + }, + }, + }; + + const container = render(toolCall); + expect(container.textContent).toContain("退出码 1"); + expect(container.textContent).toContain("stdout 120"); + expect(container.textContent).toContain("已隔离执行"); + expect(container.textContent).toContain( + "输出文件: /tmp/aster_tasks/task-1.log", + ); + }); + + it("工具结果完成 offload 转存时应显示转存摘要与文件路径", () => { + const toolCall: ToolCallState = { + id: "tool-offload-1", + name: "Write", + status: "completed", + startTime: new Date(), + endTime: new Date(), + result: { + success: true, + output: + "preview line\n\n[ProxyCast Offload] 完整输出已转存到文件:/tmp/proxycast/harness/tool-io/results/tool-offload-1.json", + metadata: { + proxycast_offloaded: true, + offload_file: + "/tmp/proxycast/harness/tool-io/results/tool-offload-1.json", + offload_original_chars: 18234, + offload_original_tokens: 4521, + offload_trigger: "token_limit_before_evict", + }, + }, + }; + + const container = render(toolCall); + expect(container.textContent).toContain("完整输出已转存"); + expect(container.textContent).toContain("原始 18234 字符"); + expect(container.textContent).toContain("约 4521 tokens"); + expect(container.textContent).toContain("token 阈值触发"); + expect(container.textContent).toContain( + "转存文件: /tmp/proxycast/harness/tool-io/results/tool-offload-1.json", + ); + }); }); diff --git a/src/components/agent/chat/components/ToolCallDisplay.tsx b/src/components/agent/chat/components/ToolCallDisplay.tsx index dd8daa528..b7527acff 100644 --- a/src/components/agent/chat/components/ToolCallDisplay.tsx +++ b/src/components/agent/chat/components/ToolCallDisplay.tsx @@ -29,7 +29,7 @@ import { ExternalLink, } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { ToolCallState, ToolResultImage } from "@/lib/api/agent"; +import type { ToolCallState, ToolResultImage } from "@/lib/api/agentStream"; import { MarkdownRenderer } from "./MarkdownRenderer"; // ============ 类型定义 ============ @@ -283,9 +283,7 @@ const snakeToTitleCase = (str: string): string => { .join(" "); }; -const normalizeToolResultImages = ( - rawImages: unknown, -): ToolResultImage[] => { +const normalizeToolResultImages = (rawImages: unknown): ToolResultImage[] => { if (!Array.isArray(rawImages)) return []; const normalized: ToolResultImage[] = []; for (const item of rawImages) { @@ -308,6 +306,19 @@ const normalizeToolResultImages = ( return normalized; }; +const normalizeToolResultMetadata = ( + rawMetadata: unknown, +): Record | undefined => { + if ( + !rawMetadata || + typeof rawMetadata !== "object" || + Array.isArray(rawMetadata) + ) { + return undefined; + } + return Object.fromEntries(Object.entries(rawMetadata)); +}; + // ============ 可展开面板组件 ============ interface ExpandablePanelProps { @@ -610,6 +621,82 @@ export const ToolCallDisplay: React.FC = ({ () => normalizeToolResultImages(toolCall.result?.images), [toolCall.result?.images], ); + const resultMetadata = useMemo( + () => normalizeToolResultMetadata(toolCall.result?.metadata), + [toolCall.result?.metadata], + ); + const resultMetaItems = useMemo(() => { + if (!resultMetadata) return []; + + const items: string[] = []; + if (resultMetadata.proxycast_offloaded === true) { + items.push("完整输出已转存"); + } + if (typeof resultMetadata.exit_code === "number") { + items.push(`退出码 ${resultMetadata.exit_code}`); + } + if (typeof resultMetadata.stdout_length === "number") { + items.push(`stdout ${resultMetadata.stdout_length}`); + } + if (typeof resultMetadata.stderr_length === "number") { + items.push(`stderr ${resultMetadata.stderr_length}`); + } + if (typeof resultMetadata.sandboxed === "boolean") { + items.push(resultMetadata.sandboxed ? "已隔离执行" : "普通执行"); + } + if (resultMetadata.output_truncated === true) { + items.push("输出已截断"); + } + if (typeof resultMetadata.offload_original_chars === "number") { + items.push(`原始 ${resultMetadata.offload_original_chars} 字符`); + } + if (typeof resultMetadata.offload_original_tokens === "number") { + items.push(`约 ${resultMetadata.offload_original_tokens} tokens`); + } + if (typeof resultMetadata.offload_trigger === "string") { + const triggerLabel = + resultMetadata.offload_trigger === "history_context_pressure" + ? "上下文压力触发" + : resultMetadata.offload_trigger === "token_limit_before_evict" + ? "token 阈值触发" + : resultMetadata.offload_trigger === "payload_bytes" + ? "字节阈值触发" + : resultMetadata.offload_trigger === "payload_chars" + ? "字符阈值触发" + : resultMetadata.offload_trigger; + items.push(triggerLabel); + } + + return items; + }, [resultMetadata]); + const resultPath = useMemo(() => { + if (!resultMetadata) return undefined; + if ( + typeof resultMetadata.offload_file === "string" && + resultMetadata.offload_file.trim() + ) { + return { + label: "转存文件", + value: resultMetadata.offload_file.trim(), + }; + } + if ( + typeof resultMetadata.output_file === "string" && + resultMetadata.output_file.trim() + ) { + return { + label: "输出文件", + value: resultMetadata.output_file.trim(), + }; + } + if (typeof resultMetadata.path === "string" && resultMetadata.path.trim()) { + return { + label: "产物路径", + value: resultMetadata.path.trim(), + }; + } + return undefined; + }, [resultMetadata]); const hasResultImages = resultImages.length > 0; useEffect(() => { @@ -735,6 +822,23 @@ export const ToolCallDisplay: React.FC = ({ > Output
+ {resultMetaItems.length > 0 ? ( +
+ {resultMetaItems.map((item) => ( + + {item} + + ))} +
+ ) : null} + {resultPath ? ( +
+ {resultPath.label}: {resultPath.value} +
+ ) : null}
 ({
-    mockSafeListen: vi.fn(),
-    mockParseStreamEvent: vi.fn((payload: unknown) => payload),
-    mockListExecutableSkills: vi.fn(),
-    mockExecuteSkill: vi.fn(),
-  }));
+const {
+  mockSafeListen,
+  mockParseStreamEvent,
+  mockListExecutableSkills,
+  mockExecuteSkill,
+} = vi.hoisted(() => ({
+  mockSafeListen: vi.fn(),
+  mockParseStreamEvent: vi.fn((payload: unknown) => payload),
+  mockListExecutableSkills: vi.fn(),
+  mockExecuteSkill: vi.fn(),
+}));
 
 vi.mock("@/lib/dev-bridge", () => ({
   safeListen: mockSafeListen,
 }));
 
-vi.mock("@/lib/api/agent", () => ({
+vi.mock("@/lib/api/agentStream", () => ({
   parseStreamEvent: mockParseStreamEvent,
 }));
 
@@ -84,7 +88,11 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => {
     });
 
     mockExecuteSkill.mockImplementation(async () => {
-      const emitWriteToolStart = (toolId: string, path: string, content: string) => {
+      const emitWriteToolStart = (
+        toolId: string,
+        path: string,
+        content: string,
+      ) => {
         streamHandler?.({
           payload: {
             type: "tool_start",
@@ -106,12 +114,12 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => {
       emitWriteToolStart(
         "tool-cover",
         "social-posts/demo.cover.json",
-        "{\"cover_url\":\"https://example.com/cover.png\",\"status\":\"成功\"}",
+        '{"cover_url":"https://example.com/cover.png","status":"成功"}',
       );
       emitWriteToolStart(
         "tool-pack",
         "social-posts/demo.publish-pack.json",
-        "{\"article_path\":\"social-posts/demo.md\",\"cover_meta_path\":\"social-posts/demo.cover.json\"}",
+        '{"article_path":"social-posts/demo.md","cover_meta_path":"social-posts/demo.cover.json"}',
       );
       streamHandler?.({ payload: { type: "final_done" } });
 
@@ -241,7 +249,9 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => {
     expect(onWriteFile).toHaveBeenCalledTimes(1);
     const [contentArg, filePathArg] = onWriteFile.mock.calls[0];
     expect(contentArg).toBe("# 标题\n\n正文内容");
-    expect(filePathArg).toMatch(/^social-posts\/\d{8}-\d{6}-[a-z0-9-]+-[a-z0-9]{3,6}\.md$/);
+    expect(filePathArg).toMatch(
+      /^social-posts\/\d{8}-\d{6}-[a-z0-9-]+-[a-z0-9]{3,6}\.md$/,
+    );
   });
 
   it("非社媒技能在无 write_file 时不应触发兜底写入", async () => {
diff --git a/src/components/agent/chat/hooks/skillCommand.ts b/src/components/agent/chat/hooks/skillCommand.ts
index 6b834f9b7..9a1c4d933 100644
--- a/src/components/agent/chat/hooks/skillCommand.ts
+++ b/src/components/agent/chat/hooks/skillCommand.ts
@@ -1,7 +1,7 @@
 import type { Dispatch, SetStateAction } from "react";
 import type { UnlistenFn } from "@tauri-apps/api/event";
 import { safeListen } from "@/lib/dev-bridge";
-import { parseStreamEvent, type StreamEvent } from "@/lib/api/agent";
+import { parseStreamEvent, type StreamEvent } from "@/lib/api/agentStream";
 import {
   skillExecutionApi,
   type ExecutableSkillInfo,
@@ -62,12 +62,16 @@ function buildSocialPostSlug(seed: string): string {
   return normalized || "post";
 }
 
-function buildSocialPostFallbackPath(seed: string, assistantMsgId: string): string {
+function buildSocialPostFallbackPath(
+  seed: string,
+  assistantMsgId: string,
+): string {
   const now = new Date();
   const format2 = (value: number) => String(value).padStart(2, "0");
   const timestamp = `${now.getFullYear()}${format2(now.getMonth() + 1)}${format2(now.getDate())}-${format2(now.getHours())}${format2(now.getMinutes())}${format2(now.getSeconds())}`;
   const slug = buildSocialPostSlug(seed);
-  const suffix = assistantMsgId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 6) || "run";
+  const suffix =
+    assistantMsgId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 6) || "run";
   return `social-posts/${timestamp}-${slug}-${suffix.toLowerCase()}.md`;
 }
 
@@ -610,8 +614,8 @@ ${failureText}`
       : shouldForceResultOutput
         ? result.output || "Skill 执行完成"
         : hasStreamedContent
-        ? accumulatedContent
-        : result.output || "Skill 执行完成";
+          ? accumulatedContent
+          : result.output || "Skill 执行完成";
 
     if (failure) {
       console.warn(
diff --git a/src/components/agent/chat/hooks/useAgentChat.test.tsx b/src/components/agent/chat/hooks/useAgentChat.test.tsx
index 3c42af158..82fccba24 100644
--- a/src/components/agent/chat/hooks/useAgentChat.test.tsx
+++ b/src/components/agent/chat/hooks/useAgentChat.test.tsx
@@ -7,7 +7,7 @@ const {
   mockStopAgentProcess,
   mockGetAgentProcessStatus,
   mockCreateAgentSession,
-  mockSendAgentMessageStream,
+  mockSendAsterMessageStream,
   mockListAgentSessions,
   mockDeleteAgentSession,
   mockGetAgentSessionMessages,
@@ -24,7 +24,7 @@ const {
   mockStopAgentProcess: vi.fn(),
   mockGetAgentProcessStatus: vi.fn(),
   mockCreateAgentSession: vi.fn(),
-  mockSendAgentMessageStream: vi.fn(),
+  mockSendAsterMessageStream: vi.fn(),
   mockListAgentSessions: vi.fn(),
   mockDeleteAgentSession: vi.fn(),
   mockGetAgentSessionMessages: vi.fn(),
@@ -38,23 +38,26 @@ const {
   mockGetProviderConfig: vi.fn(),
 }));
 
-vi.mock("@/lib/api/agent", () => ({
+vi.mock("@/lib/api/agentRuntime", () => ({
   startAgentProcess: mockStartAgentProcess,
   stopAgentProcess: mockStopAgentProcess,
   getAgentProcessStatus: mockGetAgentProcessStatus,
   createAgentSession: mockCreateAgentSession,
-  sendAgentMessageStream: mockSendAgentMessageStream,
+  sendAsterMessageStream: mockSendAsterMessageStream,
   listAgentSessions: mockListAgentSessions,
   deleteAgentSession: mockDeleteAgentSession,
   getAgentSessionMessages: mockGetAgentSessionMessages,
   renameAgentSession: mockRenameAgentSession,
   generateAgentTitle: mockGenerateAgentTitle,
-  parseStreamEvent: mockParseStreamEvent,
   confirmAsterAction: mockConfirmAsterAction,
   submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
   stopAsterSession: mockStopAsterSession,
 }));
 
+vi.mock("@/lib/api/agentStream", () => ({
+  parseStreamEvent: mockParseStreamEvent,
+}));
+
 vi.mock("@/lib/dev-bridge", () => ({
   safeListen: mockSafeListen,
 }));
@@ -151,7 +154,7 @@ beforeEach(() => {
   mockStopAgentProcess.mockResolvedValue(undefined);
   mockGetAgentProcessStatus.mockResolvedValue({ running: false });
   mockCreateAgentSession.mockResolvedValue({ session_id: "session-created" });
-  mockSendAgentMessageStream.mockResolvedValue(undefined);
+  mockSendAsterMessageStream.mockResolvedValue(undefined);
   mockListAgentSessions.mockResolvedValue([]);
   mockDeleteAgentSession.mockResolvedValue(undefined);
   mockGetAgentSessionMessages.mockResolvedValue([]);
@@ -201,8 +204,7 @@ describe("useAgentChat 偏好持久化", () => {
       ).toBe("gemini-2.5-pro");
       expect(
         JSON.parse(
-          localStorage.getItem(`agent_pref_migrated_${workspaceId}`) ||
-            "false",
+          localStorage.getItem(`agent_pref_migrated_${workspaceId}`) || "false",
         ),
       ).toBe(true);
     } finally {
@@ -255,12 +257,14 @@ describe("useAgentChat 偏好持久化", () => {
       const value = secondMount.getValue();
       expect(value.providerType).toBe("gemini");
       expect(value.model).toBe("gemini-2.5-pro");
-      expect(JSON.parse(localStorage.getItem("agent_pref_provider_global") || "null")).toBe(
-        "gemini",
-      );
-      expect(JSON.parse(localStorage.getItem("agent_pref_model_global") || "null")).toBe(
-        "gemini-2.5-pro",
-      );
+      expect(
+        JSON.parse(
+          localStorage.getItem("agent_pref_provider_global") || "null",
+        ),
+      ).toBe("gemini");
+      expect(
+        JSON.parse(localStorage.getItem("agent_pref_model_global") || "null"),
+      ).toBe("gemini-2.5-pro");
     } finally {
       secondMount.unmount();
     }
diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts
index 4c6cdc5b9..8c88c88b1 100644
--- a/src/components/agent/chat/hooks/useAgentChat.ts
+++ b/src/components/agent/chat/hooks/useAgentChat.ts
@@ -7,21 +7,20 @@ import {
   stopAgentProcess,
   getAgentProcessStatus,
   createAgentSession,
-  sendAgentMessageStream,
+  sendAsterMessageStream,
   listAgentSessions,
   deleteAgentSession,
   getAgentSessionMessages,
   renameAgentSession,
   generateAgentTitle,
-  parseStreamEvent,
   confirmAsterAction,
   submitAsterElicitationResponse,
   stopAsterSession,
   type AgentProcessStatus,
   type SessionInfo,
   type SkillInfo,
-  type StreamEvent,
-} from "@/lib/api/agent";
+} from "@/lib/api/agentRuntime";
+import { parseStreamEvent, type StreamEvent } from "@/lib/api/agentStream";
 import { skillsApi } from "@/lib/api/skills";
 import { A2UIFormAPI } from "@/lib/api/a2uiForm";
 import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
@@ -788,14 +787,22 @@ export function useAgentChat(options: UseAgentChatOptions) {
     currentStreamingSessionIdRef.current = activeSessionId;
 
     try {
-      await sendAgentMessageStream(
+      await sendAsterMessageStream(
         message,
+        activeSessionId,
         eventName,
         resolvedWorkspaceId,
-        activeSessionId,
-        modelName,
         images,
-        providerType,
+        providerType
+          ? {
+              provider_id: providerType,
+              provider_name: providerType,
+              model_name: modelName || "claude-sonnet-4-20250514",
+            }
+          : undefined,
+        undefined,
+        undefined,
+        undefined,
         undefined,
         projectId,
       );
@@ -823,14 +830,22 @@ export function useAgentChat(options: UseAgentChatOptions) {
         freshSessionId,
         workspaceId: resolvedWorkspaceId,
       });
-      await sendAgentMessageStream(
+      await sendAsterMessageStream(
         message,
+        freshSessionId,
         eventName,
         resolvedWorkspaceId,
-        freshSessionId,
-        modelName,
         images,
-        providerType,
+        providerType
+          ? {
+              provider_id: providerType,
+              provider_name: providerType,
+              model_name: modelName || "claude-sonnet-4-20250514",
+            }
+          : undefined,
+        undefined,
+        undefined,
+        undefined,
         undefined,
         projectId,
       );
diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx
index 1b987f278..9b411f29e 100644
--- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx
+++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx
@@ -37,11 +37,13 @@ const {
     info: vi.fn(),
     warning: vi.fn(),
   },
-  mockParseSkillSlashCommand: vi.fn((): { skillName: string; userInput: string } | null => null),
+  mockParseSkillSlashCommand: vi.fn(
+    (): { skillName: string; userInput: string } | null => null,
+  ),
   mockTryExecuteSlashSkillCommand: vi.fn(async () => false),
 }));
 
-vi.mock("@/lib/api/agent", () => ({
+vi.mock("@/lib/api/agentRuntime", () => ({
   initAsterAgent: mockInitAsterAgent,
   sendAsterMessageStream: mockSendAsterMessageStream,
   createAsterSession: mockCreateAsterSession,
@@ -52,6 +54,9 @@ vi.mock("@/lib/api/agent", () => ({
   stopAsterSession: mockStopAsterSession,
   confirmAsterAction: mockConfirmAsterAction,
   submitAsterElicitationResponse: mockSubmitAsterElicitationResponse,
+}));
+
+vi.mock("@/lib/api/agentStream", () => ({
   parseStreamEvent: mockParseStreamEvent,
 }));
 
@@ -182,9 +187,15 @@ describe("useAsterAgentChat 首页新会话", () => {
 
       expect(harness.getValue().sessionId).toBeNull();
       expect(harness.getValue().messages).toEqual([]);
-      expect(sessionStorage.getItem(`aster_curr_sessionId_${workspaceId}`)).toBe("null");
-      expect(sessionStorage.getItem(`aster_messages_${workspaceId}`)).toBe("[]");
-      expect(localStorage.getItem(`aster_last_sessionId_${workspaceId}`)).toBe("null");
+      expect(
+        sessionStorage.getItem(`aster_curr_sessionId_${workspaceId}`),
+      ).toBe("null");
+      expect(sessionStorage.getItem(`aster_messages_${workspaceId}`)).toBe(
+        "[]",
+      );
+      expect(localStorage.getItem(`aster_last_sessionId_${workspaceId}`)).toBe(
+        "null",
+      );
     } finally {
       harness.unmount();
     }
@@ -300,14 +311,16 @@ describe("useAsterAgentChat slash skill 执行链路", () => {
     try {
       await flushEffects();
       await act(async () => {
-        await harness.getValue().sendMessage(
-          "/social_post_with_cover 写一篇春季新品文案",
-          [],
-          false,
-          false,
-          false,
-          "react",
-        );
+        await harness
+          .getValue()
+          .sendMessage(
+            "/social_post_with_cover 写一篇春季新品文案",
+            [],
+            false,
+            false,
+            false,
+            "react",
+          );
       });
 
       expect(mockParseSkillSlashCommand).toHaveBeenCalledWith(
@@ -333,14 +346,16 @@ describe("useAsterAgentChat slash skill 执行链路", () => {
     try {
       await flushEffects();
       await act(async () => {
-        await harness.getValue().sendMessage(
-          "/social_post_with_cover 写一篇春季新品文案",
-          [],
-          false,
-          false,
-          false,
-          "react",
-        );
+        await harness
+          .getValue()
+          .sendMessage(
+            "/social_post_with_cover 写一篇春季新品文案",
+            [],
+            false,
+            false,
+            false,
+            "react",
+          );
       });
 
       expect(mockTryExecuteSlashSkillCommand).toHaveBeenCalledTimes(1);
@@ -801,6 +816,84 @@ describe("useAsterAgentChat action_required 渲染链路", () => {
       harness.unmount();
     }
   });
+
+  it("收到带 ProxyCast 元数据块的 tool_end 后应清洗输出并恢复失败态 metadata", async () => {
+    const workspaceId = "ws-tool-metadata-block";
+    seedSession(workspaceId, "session-tool-metadata-block");
+    const harness = mountHook(workspaceId);
+
+    let streamHandler: ((event: { payload: unknown }) => void) | null = null;
+    mockSafeListen.mockImplementationOnce(async (_eventName, handler) => {
+      streamHandler = handler as (event: { payload: unknown }) => void;
+      return () => {
+        streamHandler = null;
+      };
+    });
+
+    try {
+      await flushEffects();
+
+      await act(async () => {
+        await harness
+          .getValue()
+          .sendMessage("执行任务", [], false, false, false, "react");
+      });
+
+      act(() => {
+        streamHandler?.({
+          payload: {
+            type: "tool_start",
+            tool_id: "tool-meta-1",
+            tool_name: "SubAgentTask",
+            arguments: JSON.stringify({
+              prompt: "检查 harness 缺口",
+            }),
+          },
+        });
+      });
+
+      act(() => {
+        streamHandler?.({
+          payload: {
+            type: "tool_end",
+            tool_id: "tool-meta-1",
+            result: {
+              success: true,
+              output: [
+                "子任务执行失败,需要人工接管",
+                "",
+                "[ProxyCast 工具元数据开始]",
+                JSON.stringify({
+                  reported_success: false,
+                  role: "planner",
+                  failed_count: 1,
+                }),
+                "[ProxyCast 工具元数据结束]",
+              ].join("\n"),
+            },
+          },
+        });
+      });
+
+      const assistantMessage = [...harness.getValue().messages]
+        .reverse()
+        .find((msg) => msg.role === "assistant");
+      const toolCall = assistantMessage?.toolCalls?.find(
+        (item) => item.id === "tool-meta-1",
+      );
+
+      expect(toolCall?.status).toBe("failed");
+      expect(toolCall?.result?.output).toBe("子任务执行失败,需要人工接管");
+      expect(toolCall?.result?.output).not.toContain("ProxyCast 工具元数据");
+      expect(toolCall?.result?.metadata).toMatchObject({
+        reported_success: false,
+        role: "planner",
+        failed_count: 1,
+      });
+    } finally {
+      harness.unmount();
+    }
+  });
 });
 
 describe("useAsterAgentChat 偏好持久化", () => {
diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts
index a8b32c94f..5b94eb3eb 100644
--- a/src/components/agent/chat/hooks/useAsterAgentChat.ts
+++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts
@@ -7,7 +7,6 @@
 
 import { useState, useEffect, useRef, useCallback } from "react";
 import { toast } from "sonner";
-import { invoke } from "@tauri-apps/api/core";
 import { safeListen } from "@/lib/dev-bridge";
 import type { UnlistenFn } from "@tauri-apps/api/event";
 import {
@@ -22,14 +21,17 @@ import {
   stopAsterSession,
   confirmAsterAction,
   submitAsterElicitationResponse,
-  parseStreamEvent,
-  type StreamEvent,
-  type ContextTraceStep,
   type AsterSessionInfo,
   type AsterExecutionStrategy,
   type AutoContinueRequestPayload,
+} from "@/lib/api/agentRuntime";
+import { updateProject } from "@/lib/api/project";
+import {
+  parseStreamEvent,
+  type StreamEvent,
+  type ContextTraceStep,
   type ToolResultImage,
-} from "@/lib/api/agent";
+} from "@/lib/api/agentStream";
 import {
   isAsterSessionNotFoundError,
   resolveRestorableSessionId,
@@ -98,6 +100,8 @@ const normalizeActionType = (
 };
 
 const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE = "workspace_path_auto_created";
+const PROXYCAST_TOOL_METADATA_BEGIN = "[ProxyCast 工具元数据开始]";
+const PROXYCAST_TOOL_METADATA_END = "[ProxyCast 工具元数据结束]";
 
 const isWorkspacePathErrorMessage = (message: string): boolean => {
   return (
@@ -488,6 +492,126 @@ const normalizeToolResultImages = (
   return normalized.length > 0 ? normalized : undefined;
 };
 
+const parseToolResultMetadataRecord = (
+  value: unknown,
+): Record | null => {
+  if (!value || typeof value !== "object" || Array.isArray(value)) {
+    return null;
+  }
+  return Object.fromEntries(Object.entries(value));
+};
+
+const extractProxycastToolMetadataBlock = (
+  text?: string,
+): { text: string; metadata?: Record } => {
+  if (!text) {
+    return { text: "" };
+  }
+
+  const beginIndex = text.lastIndexOf(PROXYCAST_TOOL_METADATA_BEGIN);
+  const endIndex = text.lastIndexOf(PROXYCAST_TOOL_METADATA_END);
+  if (beginIndex < 0 || endIndex < beginIndex) {
+    return { text };
+  }
+
+  const metadataRaw = text
+    .slice(beginIndex + PROXYCAST_TOOL_METADATA_BEGIN.length, endIndex)
+    .trim();
+  const parsedMetadata = (() => {
+    if (!metadataRaw) return undefined;
+    try {
+      const parsed = JSON.parse(metadataRaw);
+      return parseToolResultMetadataRecord(parsed) || undefined;
+    } catch {
+      return undefined;
+    }
+  })();
+
+  const cleaned = text.slice(0, beginIndex).replace(/\s+$/, "");
+  return {
+    text: cleaned,
+    metadata: parsedMetadata,
+  };
+};
+
+const parseProxycastExecutionSummary = (
+  text?: string,
+): Record | undefined => {
+  if (!text) return undefined;
+  const marker = "[ProxyCast 执行摘要]";
+  const markerIndex = text.lastIndexOf(marker);
+  if (markerIndex < 0) return undefined;
+
+  const raw = text.slice(markerIndex + marker.length).trim();
+  if (!raw) return undefined;
+
+  const metadata: Record = {};
+  for (const line of raw.split("\n")) {
+    const separatorIndex = line.indexOf(":");
+    if (separatorIndex <= 0) continue;
+    const key = line.slice(0, separatorIndex).trim();
+    const rawValue = line.slice(separatorIndex + 1).trim();
+    if (!key || !rawValue) continue;
+
+    if (rawValue === "true" || rawValue === "false") {
+      metadata[key] = rawValue === "true";
+      continue;
+    }
+
+    const numericValue = Number(rawValue);
+    if (!Number.isNaN(numericValue) && rawValue === String(numericValue)) {
+      metadata[key] = numericValue;
+      continue;
+    }
+
+    metadata[key] = rawValue;
+  }
+
+  return Object.keys(metadata).length > 0 ? metadata : undefined;
+};
+
+const normalizeToolResultMetadata = (
+  value: unknown,
+  fallbackText?: string,
+): Record | undefined => {
+  const direct = parseToolResultMetadataRecord(value);
+  const fromBlock = extractProxycastToolMetadataBlock(fallbackText).metadata;
+  const fromSummary = parseProxycastExecutionSummary(fallbackText);
+
+  if (!direct && !fromBlock && !fromSummary) return undefined;
+  return {
+    ...(direct || {}),
+    ...(fromBlock || {}),
+    ...(fromSummary || {}),
+  };
+};
+
+const isToolResultSuccessful = (
+  result:
+    | {
+        success?: boolean;
+        metadata?: Record;
+      }
+    | null
+    | undefined,
+): boolean => {
+  if (!result) return false;
+
+  const metadata = result.metadata;
+  if (metadata?.reported_success === false) {
+    return false;
+  }
+  if (
+    typeof metadata?.exit_code === "number" &&
+    Number.isFinite(metadata.exit_code) &&
+    metadata.exit_code !== 0
+  ) {
+    return false;
+  }
+
+  return result.success !== false;
+};
+
 const normalizeHistoryPartType = (value: unknown): string => {
   if (typeof value !== "string") return "";
   return value
@@ -1496,8 +1620,6 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
         );
 
         const now = new Date();
-        setMessages([]);
-        setPendingActions([]);
         setSessionId(newSessionId);
         setTopics((prev) => [
           {
@@ -1889,7 +2011,22 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
             }
 
             case "tool_end": {
-              const isSuccess = data.result.success;
+              const normalizedOutput = extractProxycastToolMetadataBlock(
+                data.result?.output,
+              );
+              const normalizedResult = {
+                ...data.result,
+                output: normalizedOutput.text,
+                images: normalizeToolResultImages(
+                  data.result?.images,
+                  normalizedOutput.text,
+                ),
+                metadata: normalizeToolResultMetadata(
+                  data.result?.metadata,
+                  data.result?.output,
+                ),
+              };
+              const isSuccess = isToolResultSuccessful(normalizedResult);
               const eventType = isSuccess ? "tool_complete" : "tool_error";
               const startedAt = toolStartedAtByToolId.get(data.tool_id);
               const toolName = toolNameByToolId.get(data.tool_id) || "未知工具";
@@ -1898,10 +2035,9 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
                   ? Date.now() - startedAt
                   : undefined;
               const toolLogId = toolLogIdByToolId.get(data.tool_id);
-              const outputText =
-                typeof data.result?.output === "string"
-                  ? truncateForLog(data.result.output, 120)
-                  : "";
+              const outputText = normalizedResult.output
+                ? truncateForLog(normalizedResult.output, 120)
+                : "";
 
               if (toolLogId) {
                 activityLogger.updateLog(toolLogId, {
@@ -1932,18 +2068,11 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
               setMessages((prev) =>
                 prev.map((msg) => {
                   if (msg.id !== assistantMsgId) return msg;
-                  const normalizedResult = {
-                    ...data.result,
-                    images: normalizeToolResultImages(
-                      data.result?.images,
-                      data.result?.output,
-                    ),
-                  };
                   const updatedToolCalls = (msg.toolCalls || []).map((tc) =>
                     tc.id === data.tool_id
                       ? {
                           ...tc,
-                          status: data.result.success
+                          status: isSuccess
                             ? ("completed" as const)
                             : ("failed" as const),
                           result: normalizedResult,
@@ -1961,7 +2090,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
                           ...part,
                           toolCall: {
                             ...part.toolCall,
-                            status: data.result.success
+                            status: isSuccess
                               ? ("completed" as const)
                               : ("failed" as const),
                             result: normalizedResult,
@@ -2280,10 +2409,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
         workspacePathMissing;
       setWorkspacePathMissing(null);
       try {
-        await invoke("workspace_update", {
-          id: workspaceId,
-          request: { rootPath: newPath },
-        });
+        await updateProject(workspaceId, { rootPath: newPath });
         await sendMessage(retryContent, retryImages, false, false, true);
       } catch (err) {
         toast.error(
@@ -2624,13 +2750,29 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
 
               if (partType === "tool_response") {
                 if (!part.id || typeof part.id !== "string") continue;
-                const success = part.success !== false;
                 const toolName = resolveHistoryToolName(
                   part.id,
                   historyToolNameById,
                 );
-                const outputText =
+                const rawOutputText =
                   typeof part.output === "string" ? part.output : "";
+                const normalizedOutput =
+                  extractProxycastToolMetadataBlock(rawOutputText);
+                const normalizedResult = {
+                  success: part.success !== false,
+                  output: normalizedOutput.text,
+                  error:
+                    typeof part.error === "string" ? part.error : undefined,
+                  images: normalizeToolResultImages(
+                    part.images,
+                    normalizedOutput.text,
+                  ),
+                  metadata: normalizeToolResultMetadata(
+                    part.metadata,
+                    rawOutputText,
+                  ),
+                };
+                const success = isToolResultSuccessful(normalizedResult);
                 const toolCall = {
                   id: part.id,
                   name: toolName,
@@ -2640,11 +2782,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
                   startTime: messageTimestamp,
                   endTime: messageTimestamp,
                   result: {
+                    ...normalizedResult,
                     success,
-                    output: outputText,
-                    error:
-                      typeof part.error === "string" ? part.error : undefined,
-                    images: normalizeToolResultImages(part.images, outputText),
                   },
                 };
                 toolCalls.push(toolCall);
diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx
index eec593ed6..6315c6f99 100644
--- a/src/components/agent/chat/index.test.tsx
+++ b/src/components/agent/chat/index.test.tsx
@@ -11,6 +11,7 @@ const {
   mockGetOrCreateDefaultProject,
   mockGetContent,
   mockGetThemeWorkbenchDocumentState,
+  mockEnsureWorkspaceReady,
   mockUpdateContent,
   mockGetProjectMemory,
   mockToast,
@@ -34,6 +35,7 @@ const {
   mockGetOrCreateDefaultProject: vi.fn(),
   mockGetContent: vi.fn(),
   mockGetThemeWorkbenchDocumentState: vi.fn(),
+  mockEnsureWorkspaceReady: vi.fn(),
   mockUpdateContent: vi.fn(),
   mockGetProjectMemory: vi.fn(),
   mockToast: {
@@ -240,8 +242,14 @@ vi.mock("@/components/content-creator/canvas/CanvasFactory", () => ({
   CanvasFactory: () => 
, })); -vi.mock("@/components/general-chat/canvas", () => ({ +vi.mock("@/components/general-chat/bridge", () => ({ CanvasPanel: () =>
, + DEFAULT_CANVAS_STATE: { + isOpen: false, + contentType: null, + content: "", + isEditing: false, + }, })); vi.mock("@/components/artifact", () => ({ @@ -304,6 +312,7 @@ vi.mock("@/lib/api/project", () => ({ getOrCreateDefaultProject: mockGetOrCreateDefaultProject, getContent: mockGetContent, getThemeWorkbenchDocumentState: mockGetThemeWorkbenchDocumentState, + ensureWorkspaceReady: mockEnsureWorkspaceReady, updateContent: mockUpdateContent, })); @@ -487,6 +496,16 @@ beforeEach(() => { mockGetOrCreateDefaultProject.mockResolvedValue(null); mockGetContent.mockResolvedValue(null); mockGetThemeWorkbenchDocumentState.mockResolvedValue(null); + mockEnsureWorkspaceReady.mockResolvedValue({ + workspaceId: "workspace-test", + rootPath: "/tmp/workspace-test", + existed: true, + created: false, + repaired: false, + relocated: false, + previousRootPath: null, + warning: null, + }); mockUpdateContent.mockResolvedValue(undefined); mockGetProjectMemory.mockResolvedValue(null); mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({ @@ -2029,8 +2048,96 @@ describe("AgentChatPage 视频主题工作台", () => { await flushEffects(10); expect(container.querySelector('[data-testid="inputbar"]')).toBeNull(); - expect(container.querySelector('[data-testid="theme-workbench-sidebar"]')).toBeNull(); + expect( + container.querySelector('[data-testid="theme-workbench-sidebar"]'), + ).toBeNull(); expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled(); expect(sharedSendMessageMock).not.toHaveBeenCalled(); }); }); + +describe("AgentChatPage 海报主题工作台", () => { + it("海报主题工作台不应渲染底部通用输入条、左侧上下文栏,也不应自动发起请求", async () => { + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + + const container = renderPage({ + projectId: "project-poster", + contentId: "content-poster", + theme: "poster", + lockTheme: true, + }); + await flushEffects(10); + + expect(container.querySelector('[data-testid="inputbar"]')).toBeNull(); + expect( + container.querySelector('[data-testid="theme-workbench-sidebar"]'), + ).toBeNull(); + expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled(); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + }); +}); + +describe("AgentChatPage 小说主题工作台", () => { + it("小说主题工作台普通进入时不应自动发起请求", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + + const container = renderPage({ + projectId: "project-novel", + contentId: "content-novel", + theme: "novel", + lockTheme: true, + }); + await flushEffects(10); + + expect( + container.querySelector('[data-testid="theme-workbench-sidebar"]'), + ).not.toBeNull(); + expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled(); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + }); + + it("小说主题工作台带初始意图时仍应自动发送首条请求", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + + const initialUserPrompt = "请基于当前设定生成第一章开篇。"; + const onInitialUserPromptConsumed = vi.fn(); + + renderPage({ + projectId: "project-novel-intent", + contentId: "content-novel-intent", + theme: "novel", + lockTheme: true, + initialUserPrompt, + onInitialUserPromptConsumed, + }); + await flushEffects(12); + + expect(sharedSendMessageMock).toHaveBeenCalledWith( + initialUserPrompt, + [], + false, + false, + false, + undefined, + "mock-model", + undefined, + undefined, + ); + expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1); + expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 77c2e5256..6233a3196 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -20,8 +20,8 @@ import { toast } from "sonner"; import styled from "styled-components"; import { Info, PanelLeftOpen } from "lucide-react"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; -import { invoke } from "@tauri-apps/api/core"; import { safeListen } from "@/lib/dev-bridge"; +import { readFilePreview } from "@/lib/api/fileBrowser"; import { uploadImageToSession, importDocument } from "@/lib/api/session-files"; import { useAgentChatUnified, @@ -39,6 +39,7 @@ import { ThemeWorkbenchSidebar, type ThemeWorkbenchCreationTaskEvent, } from "./components/ThemeWorkbenchSidebar"; +import { HarnessStatusPanel } from "./components/HarnessStatusPanel"; import { MessageList } from "./components/MessageList"; import { Inputbar } from "./components/Inputbar"; import { RuntimeStyleControlBar } from "./components/RuntimeStyleControlBar"; @@ -65,11 +66,11 @@ import type { TextStylizeRunPayload, } from "@/components/content-creator/canvas/document/types"; import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; -import { CanvasPanel as GeneralCanvasPanel } from "@/components/general-chat/canvas"; +import { CanvasPanel as GeneralCanvasPanel } from "@/components/general-chat/bridge"; import { type CanvasState as GeneralCanvasState, DEFAULT_CANVAS_STATE, -} from "@/components/general-chat/types"; +} from "@/components/general-chat/bridge"; import { artifactsAtom, selectedArtifactAtom, @@ -94,6 +95,8 @@ import { getOrCreateDefaultProject, getContent, getThemeWorkbenchDocumentState, + ensureWorkspaceReady, + updateProject as updateProjectById, updateContent, type Project, type ProjectType, @@ -109,6 +112,7 @@ import { SettingsTabs } from "@/types/settings"; import { skillsApi, type Skill } from "@/lib/api/skills"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { useSubAgentScheduler } from "@/hooks/useSubAgentScheduler"; import { LatestRunStatusBadge } from "@/components/execution/LatestRunStatusBadge"; import { executionRunGet, @@ -131,10 +135,8 @@ import { loadRememberedBaseModel, saveRememberedBaseModel, } from "@/lib/model/thinkingBaseModelMemory"; -import type { - AutoContinueRequestPayload, - ToolCallState, -} from "@/lib/api/agent"; +import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; +import type { ToolCallState } from "@/lib/api/agentStream"; import { skillExecutionApi, type SkillDetailInfo, @@ -159,6 +161,7 @@ import { saveChatToolPreferences, type ChatToolPreferences, } from "./utils/chatToolPreferences"; +import { deriveHarnessSessionState } from "./utils/harnessState"; import { resolveCanvasTaskFileTarget, shouldDeferCanvasSyncWhileEditing, @@ -187,6 +190,20 @@ const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ "novel", ]; +interface HarnessFilePreviewResult { + path: string; + content: string | null; + isBinary: boolean; + size: number; + error: string | null; +} + +function extractFileNameFromPath(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const segments = normalized.split("/"); + return segments[segments.length - 1] || path; +} + function normalizeInitialTheme(value?: string): ThemeType { if (!value) return "general"; if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) { @@ -1455,6 +1472,32 @@ function savePersistedProjectId(key: string, projectId: string) { } } +function loadPersistedBoolean(key: string, fallback = false): boolean { + try { + const stored = localStorage.getItem(key); + if (stored == null) { + return fallback; + } + + try { + const parsed = JSON.parse(stored); + return typeof parsed === "boolean" ? parsed : fallback; + } catch { + return stored === "true"; + } + } catch { + return fallback; + } +} + +function savePersistedBoolean(key: string, value: boolean) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // ignore write errors + } +} + export interface WorkflowProgressSnapshot { steps: Array<{ id: string; @@ -1468,6 +1511,8 @@ export interface WorkflowProgressSnapshot { * 判断画布状态是否为空 * 用于决定是否自动触发 AI 引导 */ +const HARNESS_PANEL_VISIBILITY_KEY = "proxycast.chat.harness-panel.visible.v1"; + function isCanvasStateEmpty(state: CanvasStateUnion | null): boolean { if (!state) return true; @@ -1859,14 +1904,6 @@ export function AgentChatPage({ setLayoutMode("chat-canvas"); }, [artifacts.length, activeTheme]); - // 加载技能列表 - useEffect(() => { - skillsApi - .getAll("proxycast") - .then(setSkills) - .catch((err) => console.error("加载技能列表失败:", err)); - }, []); - // 跳转到设置页安装技能 const handleNavigateToSkillSettings = useCallback(() => { _onNavigate?.("settings", { tab: SettingsTabs.Skills }); @@ -2062,12 +2099,7 @@ export function AgentChatPage({ const normalizedId = normalizeProjectId(projectId); if (!normalizedId) return; - invoke<{ created: boolean; repaired: boolean; rootPath: string }>( - "workspace_ensure_ready", - { - id: normalizedId, - }, - ) + ensureWorkspaceReady(normalizedId) .then(({ repaired, rootPath }) => { if (repaired) { recordWorkspaceRepair({ @@ -2169,6 +2201,7 @@ export function AgentChatPage({ deleteMessage, editMessage, handlePermissionResponse, + pendingActions, triggerAIGuide, topics, sessionId, @@ -2188,6 +2221,14 @@ export function AgentChatPage({ workspaceId: projectId ?? "", }); const { providers: configuredProviders } = useConfiguredProviders(); + const subAgentRuntime = useSubAgentScheduler(sessionId); + const harnessState = useMemo( + () => deriveHarnessSessionState(messages, pendingActions), + [messages, pendingActions], + ); + const [harnessPanelVisible, setHarnessPanelVisible] = useState(() => + loadPersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, false), + ); const selectedProvider = useMemo( () => configuredProviders.find((provider) => provider.key === providerType), [configuredProviders, providerType], @@ -2201,6 +2242,10 @@ export function AgentChatPage({ onSessionChange?.(sessionId ?? null); }, [onSessionChange, sessionId]); + useEffect(() => { + savePersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, harnessPanelVisible); + }, [harnessPanelVisible]); + const contextWorkspace = useThemeContextWorkspace({ projectId, activeTheme, @@ -2208,7 +2253,63 @@ export function AgentChatPage({ providerType, model, }); + const installedSkills = useMemo( + () => skills.filter((skill) => skill.installed), + [skills], + ); + const harnessPendingCount = harnessState.pendingApprovals.length; + const showHarnessToggle = + harnessPanelVisible || harnessState.hasSignals || subAgentRuntime.isRunning; + const harnessAttentionLevel = + harnessPendingCount > 0 ? "warning" : showHarnessToggle ? "active" : "idle"; + const visibleContextItems = useMemo(() => { + const activeItems = contextWorkspace.sidebarContextItems.filter( + (item) => item.active, + ); + return activeItems.length > 0 + ? activeItems + : contextWorkspace.sidebarContextItems; + }, [contextWorkspace.sidebarContextItems]); + const harnessEnvironment = useMemo( + () => ({ + skillsCount: installedSkills.length, + skillNames: installedSkills + .map((skill) => skill.name || skill.key) + .filter((name) => !!name.trim()) + .slice(0, 4), + memorySignals: [ + projectMemory?.characters.length ? "角色" : null, + projectMemory?.world_building ? "世界观" : null, + projectMemory?.style_guide ? "风格" : null, + projectMemory?.outline.length ? "大纲" : null, + ].filter((item): item is string => item !== null), + contextItemsCount: contextWorkspace.sidebarContextItems.length, + activeContextCount: contextWorkspace.sidebarContextItems.filter( + (item) => item.active, + ).length, + contextItemNames: visibleContextItems + .map((item) => item.name) + .filter((name) => !!name.trim()) + .slice(0, 4), + contextEnabled: contextWorkspace.enabled, + }), + [ + contextWorkspace.enabled, + contextWorkspace.sidebarContextItems, + installedSkills, + projectMemory?.characters.length, + projectMemory?.outline.length, + projectMemory?.style_guide, + projectMemory?.world_building, + visibleContextItems, + ], + ); const isThemeWorkbench = contextWorkspace.enabled; + const shouldUseCompactThemeWorkbench = + isThemeWorkbench && (mappedTheme === "video" || mappedTheme === "poster"); + const shouldSkipThemeWorkbenchAutoGuideWithoutPrompt = + isThemeWorkbench && + (shouldUseCompactThemeWorkbench || mappedTheme === "novel"); const enableThemeWorkbenchPanelCollapse = isThemeWorkbench && mappedTheme === "social-media"; @@ -4796,6 +4897,72 @@ export function AgentChatPage({ [activeTheme, isThemeWorkbench, mappedTheme, upsertNovelCanvasState], ); + const handleHarnessLoadFilePreview = useCallback( + async (path: string): Promise => { + const normalizedPath = path.trim(); + const createFallbackResult = ( + overrides: Partial = {}, + ): HarnessFilePreviewResult => ({ + path: normalizedPath, + content: null, + isBinary: false, + size: 0, + error: null, + ...overrides, + }); + + if (!normalizedPath) { + return createFallbackResult({ error: "文件路径为空" }); + } + + const fileName = extractFileNameFromPath(normalizedPath); + const candidateNames = [...new Set([normalizedPath, fileName])]; + + const matchedTaskFile = taskFiles.find((file) => + candidateNames.includes(file.name), + ); + if (matchedTaskFile) { + const content = matchedTaskFile.content ?? ""; + return createFallbackResult({ + path: matchedTaskFile.name, + content, + size: content.length, + }); + } + + const matchedSessionFile = sessionFiles.find((file) => + candidateNames.includes(file.name), + ); + if (matchedSessionFile) { + const content = await readSessionFile(matchedSessionFile.name); + if (content !== null) { + return createFallbackResult({ + path: matchedSessionFile.name, + content, + size: content.length, + }); + } + } + + try { + const result = await readFilePreview(normalizedPath, 64 * 1024); + + return createFallbackResult({ + path: result.path || normalizedPath, + content: result.content ?? null, + isBinary: result.isBinary ?? false, + size: result.size ?? 0, + error: result.error ?? null, + }); + } catch (error) { + return createFallbackResult({ + error: error instanceof Error ? error.message : String(error), + }); + } + }, + [readSessionFile, sessionFiles, taskFiles], + ); + // 处理代码块点击 - 在画布中显示代码(General 主题专用) const handleCodeBlockClick = useCallback( (language: string, code: string) => { @@ -4924,7 +5091,7 @@ export function AgentChatPage({ // 当从项目进入且有 contentId 时,自动启动创作引导 useEffect(() => { - if (mappedTheme === "video") { + if (shouldUseCompactThemeWorkbench) { return; } @@ -4981,6 +5148,9 @@ export function AgentChatPage({ } if (isThemeWorkbench) { + if (shouldSkipThemeWorkbenchAutoGuideWithoutPrompt) { + return; + } hasTriggeredGuide.current = true; console.log("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流"); // 同步创建后端工作流(不阻塞触发) @@ -5031,13 +5201,15 @@ export function AgentChatPage({ handleSend, chatToolPreferences, onInitialUserPromptConsumed, + shouldUseCompactThemeWorkbench, + shouldSkipThemeWorkbenchAutoGuideWithoutPrompt, ]); // 通用聊天场景:若带有 initialUserPrompt,则自动新建并发送首条消息 useEffect(() => { const pendingInitialPrompt = (initialUserPrompt || "").trim(); if ( - mappedTheme === "video" || + shouldUseCompactThemeWorkbench || !pendingInitialPrompt || contentId || !sessionId || @@ -5067,10 +5239,10 @@ export function AgentChatPage({ handleSend, initialUserPrompt, isSending, - mappedTheme, messages.length, onInitialUserPromptConsumed, sessionId, + shouldUseCompactThemeWorkbench, ]); // 当 contentId 变化时重置引导状态 @@ -5125,9 +5297,9 @@ export function AgentChatPage({ // 主题工作台始终使用聊天布局与浮层输入,不走旧 EmptyState 输入流程 const showChatLayout = hasMessages || isThemeWorkbench; - const shouldHideThemeWorkbenchInputForTheme = - isThemeWorkbench && mappedTheme === "video"; - const shouldShowThemeWorkbenchSidebarForTheme = mappedTheme !== "video"; + const shouldHideThemeWorkbenchInputForTheme = shouldUseCompactThemeWorkbench; + const shouldShowThemeWorkbenchSidebarForTheme = + !shouldUseCompactThemeWorkbench; const showThemeWorkbenchSidebar = showChatPanel && showSidebar && @@ -5307,10 +5479,7 @@ export function AgentChatPage({ } else if (projectId) { // 主动健康检查发现问题:只更新路径,不需要重试 try { - await invoke("workspace_update", { - id: projectId, - request: { rootPath: newPath }, - }); + await updateProjectById(projectId, { rootPath: newPath }); setWorkspaceHealthError(false); toast.success("工作区目录已更新"); } catch (err) { @@ -5579,6 +5748,16 @@ export function AgentChatPage({ /> ) : null} + {harnessPanelVisible ? ( + + ) : null} + {showChatLayout ? ( {contextWorkspace.enabled ? ( @@ -5728,13 +5907,17 @@ export function AgentChatPage({ handleA2UISubmit, handleCodeBlockClick, handleFileClick, + handleHarnessLoadFilePreview, handleManageProviders, handleNavigateToSkillSettings, handlePermissionResponse, handleSelectWorkspaceDirectory, handleSend, handleWriteFile, + harnessEnvironment, + harnessPanelVisible, hasMessages, + harnessState, hideInlineStepProgress, input, inputbarNode, @@ -5762,6 +5945,7 @@ export function AgentChatPage({ mappedTheme, runtimeStyleSelection, styleActionsDisabled, + subAgentRuntime, skills, steps, workspaceHealthError, @@ -5942,6 +6126,13 @@ export function AgentChatPage({ onProjectChange={handleProjectChange} workspaceType={activeTheme} onBackHome={handleBackHome} + showHarnessToggle={showHarnessToggle} + harnessPanelVisible={harnessPanelVisible} + onToggleHarnessPanel={() => + setHarnessPanelVisible((current) => !current) + } + harnessPendingCount={harnessPendingCount} + harnessAttentionLevel={harnessAttentionLevel} onToggleSettings={() => { _onNavigate?.("settings", { tab: SettingsTabs.ChatAppearance, @@ -6054,6 +6245,9 @@ export function AgentChatPage({ inputbarNode, isSending, isThemeWorkbench, + harnessAttentionLevel, + harnessPanelVisible, + harnessPendingCount, layoutMode, novelChapterListCollapsed, onBackToProjectManagement, @@ -6062,6 +6256,7 @@ export function AgentChatPage({ shouldHideThemeWorkbenchInputForTheme, showChatLayout, showChatPanel, + showHarnessToggle, showNovelNavbarControls, syncStatus, themeWorkbenchRunState, diff --git a/src/components/agent/chat/types.ts b/src/components/agent/chat/types.ts index 3e5bf60f4..b8bdfa323 100644 --- a/src/components/agent/chat/types.ts +++ b/src/components/agent/chat/types.ts @@ -1,5 +1,5 @@ -import type { ToolCallState, TokenUsage } from "@/lib/api/agent"; -import type { ContextTraceStep } from "@/lib/api/agent"; +import type { ToolCallState, TokenUsage } from "@/lib/api/agentStream"; +import type { ContextTraceStep } from "@/lib/api/agentStream"; import { safeInvoke } from "@/lib/dev-bridge"; export interface MessageImage { diff --git a/src/components/agent/chat/utils/harnessState.test.ts b/src/components/agent/chat/utils/harnessState.test.ts new file mode 100644 index 000000000..e66a07a8a --- /dev/null +++ b/src/components/agent/chat/utils/harnessState.test.ts @@ -0,0 +1,525 @@ +import { describe, expect, it } from "vitest"; +import type { ActionRequired, Message } from "../types"; +import { deriveHarnessSessionState } from "./harnessState"; + +const BASE_TIME = new Date("2026-03-11T12:00:00.000Z"); + +function asLegacyDate(value: string): Date { + return value as unknown as Date; +} + +function createAssistantMessage(overrides: Partial = {}): Message { + return { + id: "assistant-1", + role: "assistant", + content: "", + timestamp: BASE_TIME, + ...overrides, + }; +} + +describe("deriveHarnessSessionState", () => { + it("应从 TodoWrite 参数提取结构化 Todo", () => { + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + toolCalls: [ + { + id: "todo-1", + name: "TodoWrite", + arguments: JSON.stringify({ + todos: [ + { id: "a", content: "梳理主链", status: "in_progress" }, + { id: "b", content: "实现面板", status: "pending" }, + ], + }), + status: "completed", + startTime: BASE_TIME, + endTime: new Date(BASE_TIME.getTime() + 1000), + }, + ], + }), + ], + [], + ); + + expect(state.plan.phase).toBe("planning"); + expect(state.plan.items).toEqual([ + { id: "a", content: "梳理主链", status: "in_progress" }, + { id: "b", content: "实现面板", status: "pending" }, + ]); + expect(state.activity.planning).toBe(1); + }); + + it("应在 ExitPlanMode 完成后标记规划完成", () => { + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + toolCalls: [ + { + id: "todo-1", + name: "TodoWrite", + arguments: JSON.stringify({ + todos: [{ id: "a", content: "完成实现", status: "completed" }], + }), + status: "completed", + startTime: BASE_TIME, + endTime: new Date(BASE_TIME.getTime() + 1000), + }, + { + id: "plan-exit", + name: "ExitPlanMode", + status: "completed", + startTime: new Date(BASE_TIME.getTime() + 2000), + endTime: new Date(BASE_TIME.getTime() + 3000), + }, + ], + }), + ], + [], + ); + + expect(state.plan.phase).toBe("ready"); + expect(state.plan.items).toHaveLength(1); + }); + + it("应提取待审批数和最新 context trace", () => { + const pendingApprovals: ActionRequired[] = [ + { + requestId: "req-1", + actionType: "tool_confirmation", + prompt: "是否允许写文件?", + }, + ]; + + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + id: "assistant-2", + contextTrace: [ + { stage: "workspace", detail: "加载 AGENTS.md" }, + { stage: "memory", detail: "注入项目记忆" }, + ], + }), + ], + pendingApprovals, + ); + + expect(state.pendingApprovals).toHaveLength(1); + expect(state.latestContextTrace).toHaveLength(2); + expect(state.latestContextTrace[0]?.stage).toBe("workspace"); + }); + + it("应兼容未提供待审批数组的旧调用方", () => { + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + contextTrace: [{ stage: "workspace", detail: "恢复旧会话" }], + }), + ], + undefined as unknown as ActionRequired[], + ); + + expect(state.pendingApprovals).toEqual([]); + expect(state.latestContextTrace).toHaveLength(1); + expect(state.hasSignals).toBe(true); + }); + + it("应识别 SubAgentTask 和关键工具活动", () => { + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + toolCalls: [ + { + id: "read-1", + name: "Read", + status: "completed", + startTime: BASE_TIME, + endTime: new Date(BASE_TIME.getTime() + 1000), + }, + { + id: "sub-1", + name: "SubAgentTask", + arguments: JSON.stringify({ + description: "调研 legacy chat", + role: "explorer", + taskType: "explore", + }), + status: "running", + result: { + success: true, + output: "正在执行", + }, + startTime: new Date(BASE_TIME.getTime() + 2000), + }, + { + id: "web-1", + name: "WebSearch", + status: "completed", + startTime: new Date(BASE_TIME.getTime() + 3000), + endTime: new Date(BASE_TIME.getTime() + 4000), + }, + ], + }), + ], + [], + ); + + expect(state.activity.filesystem).toBe(1); + expect(state.activity.delegation).toBe(1); + expect(state.activity.web).toBe(1); + expect(state.delegatedTasks).toHaveLength(1); + expect(state.delegatedTasks[0]?.title).toBe("调研 legacy chat"); + expect(state.delegatedTasks[0]?.role).toBe("explorer"); + }); + + it("应兼容历史缓存中的字符串时间戳", () => { + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + toolCalls: [ + { + id: "plan-enter", + name: "EnterPlanMode", + status: "completed", + startTime: asLegacyDate("2026-03-11T12:00:00.000Z"), + endTime: asLegacyDate("2026-03-11T12:00:01.000Z"), + }, + { + id: "todo-legacy", + name: "TodoWrite", + arguments: JSON.stringify({ + todos: [ + { id: "legacy-1", content: "修复短视频崩溃", status: "done" }, + ], + }), + status: "completed", + startTime: asLegacyDate("2026-03-11T12:00:02.000Z"), + endTime: asLegacyDate("2026-03-11T12:00:03.000Z"), + }, + { + id: "sub-legacy", + name: "SubAgentTask", + arguments: JSON.stringify({ + description: "检查历史会话", + role: "diagnose", + }), + status: "completed", + startTime: asLegacyDate("2026-03-11T12:00:04.000Z"), + endTime: asLegacyDate("2026-03-11T12:00:05.000Z"), + result: { + success: true, + output: "已完成", + }, + }, + ], + }), + ], + [], + ); + + expect(state.plan.phase).toBe("planning"); + expect(state.plan.items).toEqual([ + { id: "legacy-1", content: "修复短视频崩溃", status: "completed" }, + ]); + expect(state.delegatedTasks[0]?.startedAt).toBeInstanceOf(Date); + expect(state.activity.planning).toBe(2); + expect(state.activity.delegation).toBe(1); + }); + + it("应识别归一化后的 Harness 工具别名", () => { + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + toolCalls: [ + { + id: "todo-2", + name: "Write_Todos", + arguments: JSON.stringify({ + items: [{ content: "补充事件隔离", status: "running" }], + }), + status: "completed", + startTime: BASE_TIME, + endTime: new Date(BASE_TIME.getTime() + 1000), + }, + { + id: "fs-2", + name: "list_directory", + status: "completed", + startTime: new Date(BASE_TIME.getTime() + 2000), + endTime: new Date(BASE_TIME.getTime() + 3000), + }, + { + id: "skill-2", + name: "three_stage_workflow", + status: "completed", + startTime: new Date(BASE_TIME.getTime() + 4000), + endTime: new Date(BASE_TIME.getTime() + 5000), + }, + ], + }), + ], + [], + ); + + expect(state.plan.items).toEqual([ + { id: "todo-1", content: "补充事件隔离", status: "in_progress" }, + ]); + expect(state.activity.planning).toBe(1); + expect(state.activity.filesystem).toBe(1); + expect(state.activity.skills).toBe(1); + }); + + it("应提取任务输出文件与命令执行摘要信号", () => { + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + toolCalls: [ + { + id: "task-output-1", + name: "TaskOutput", + status: "completed", + startTime: BASE_TIME, + endTime: new Date(BASE_TIME.getTime() + 1000), + result: { + success: true, + output: [ + "=== 任务 task-1 ===", + "状态: completed", + "输出文件: /tmp/aster_tasks/task-1.log", + ].join("\n"), + }, + }, + { + id: "bash-1", + name: "Bash", + status: "completed", + startTime: new Date(BASE_TIME.getTime() + 2000), + endTime: new Date(BASE_TIME.getTime() + 3000), + result: { + success: true, + output: [ + "done", + "[ProxyCast 执行摘要]", + "exit_code: 1", + "stdout_length: 120", + "stderr_length: 32", + "sandboxed: true", + "output_truncated: true", + ].join("\n"), + }, + }, + ], + }), + ], + [], + ); + + const taskSignal = state.outputSignals.find( + (signal) => signal.toolName === "TaskOutput", + ); + const bashSignal = state.outputSignals.find( + (signal) => signal.toolName === "Bash", + ); + + expect(taskSignal?.outputFile).toBe("/tmp/aster_tasks/task-1.log"); + expect(taskSignal?.title).toBe("任务输出已落盘"); + expect(bashSignal?.exitCode).toBe(1); + expect(bashSignal?.stdoutLength).toBe(120); + expect(bashSignal?.stderrLength).toBe(32); + expect(bashSignal?.sandboxed).toBe(true); + expect(bashSignal?.truncated).toBe(true); + }); + + it("应识别工具输出 offload 转存信号", () => { + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + toolCalls: [ + { + id: "tool-offload-1", + name: "Write", + status: "completed", + startTime: BASE_TIME, + endTime: new Date(BASE_TIME.getTime() + 1000), + result: { + success: true, + output: + "preview line 1\n\n[ProxyCast Offload] 完整输出已转存到文件:/tmp/proxycast/harness/tool-io/results/tool-offload-1.json", + metadata: { + proxycast_offloaded: true, + offload_file: + "/tmp/proxycast/harness/tool-io/results/tool-offload-1.json", + offload_original_chars: 18234, + offload_original_tokens: 4521, + offload_trigger: "history_context_pressure", + }, + }, + }, + ], + }), + ], + [], + ); + + const signal = state.outputSignals.find( + (item) => item.toolCallId === "tool-offload-1", + ); + + expect(signal?.title).toBe("工具输出已转存"); + expect(signal?.offloadFile).toBe( + "/tmp/proxycast/harness/tool-io/results/tool-offload-1.json", + ); + expect(signal?.offloaded).toBe(true); + expect(signal?.offloadOriginalChars).toBe(18234); + expect(signal?.offloadOriginalTokens).toBe(4521); + expect(signal?.offloadTrigger).toBe("history_context_pressure"); + expect(signal?.summary).toContain("完整输出已转存"); + expect(signal?.summary).toContain("约 4521 tokens"); + expect(signal?.summary).toContain("上下文压力触发"); + }); + + it("应提取最近文件活动并保留文本预览", () => { + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + toolCalls: [ + { + id: "write-1", + name: "Write", + arguments: JSON.stringify({ + path: "/tmp/workspace/plan.md", + content: "# 规划\n- 第一步", + }), + status: "completed", + startTime: BASE_TIME, + endTime: new Date(BASE_TIME.getTime() + 1000), + result: { + success: true, + output: "已写入 /tmp/workspace/plan.md", + }, + }, + { + id: "read-1", + name: "Read", + arguments: JSON.stringify({ + path: "/tmp/workspace/plan.md", + }), + status: "completed", + startTime: new Date(BASE_TIME.getTime() + 2000), + endTime: new Date(BASE_TIME.getTime() + 3000), + result: { + success: true, + output: "# 规划\n- 第一步\n- 第二步", + }, + }, + ], + }), + ], + [], + ); + + expect(state.recentFileEvents).toHaveLength(2); + expect(state.recentFileEvents[0]).toMatchObject({ + action: "read", + path: "/tmp/workspace/plan.md", + displayName: "plan.md", + kind: "document", + }); + expect(state.recentFileEvents[0]?.preview).toContain("# 规划"); + expect(state.recentFileEvents[0]?.content).toContain("- 第二步"); + expect(state.recentFileEvents[1]).toMatchObject({ + action: "write", + path: "/tmp/workspace/plan.md", + }); + expect(state.recentFileEvents[1]?.content).toContain("# 规划"); + }); + + it("应从输出信号提取可点击文件事件", () => { + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + toolCalls: [ + { + id: "bash-offload-1", + name: "Bash", + status: "completed", + startTime: BASE_TIME, + endTime: new Date(BASE_TIME.getTime() + 1000), + result: { + success: true, + output: [ + "stdout preview line", + "输出文件: /tmp/proxycast/tasks/run-1.log", + "[ProxyCast Offload] 完整输出已转存到文件:/tmp/proxycast/harness/results/run-1.json", + ].join("\n"), + metadata: { + proxycast_offloaded: true, + output_file: "/tmp/proxycast/tasks/run-1.log", + offload_file: "/tmp/proxycast/harness/results/run-1.json", + }, + }, + }, + ], + }), + ], + [], + ); + + expect(state.recentFileEvents).toHaveLength(2); + const offloadEvent = state.recentFileEvents.find( + (event) => event.action === "offload", + ); + const outputEvent = state.recentFileEvents.find( + (event) => event.path === "/tmp/proxycast/tasks/run-1.log", + ); + + expect(offloadEvent).toMatchObject({ + action: "offload", + path: "/tmp/proxycast/harness/results/run-1.json", + kind: "offload", + clickable: true, + }); + expect(offloadEvent?.preview).toContain("stdout preview line"); + expect(outputEvent).toMatchObject({ + action: "persist", + path: "/tmp/proxycast/tasks/run-1.log", + kind: "log", + }); + }); + + it("最近文件活动应只保留最新 5 条", () => { + const toolCalls = Array.from({ length: 6 }, (_, index) => ({ + id: `read-${index + 1}`, + name: "Read", + arguments: JSON.stringify({ + path: `/tmp/workspace/file-${index + 1}.txt`, + }), + status: "completed" as const, + startTime: new Date(BASE_TIME.getTime() + index * 1000), + endTime: new Date(BASE_TIME.getTime() + index * 1000 + 500), + result: { + success: true, + output: `file-${index + 1}`, + }, + })); + + const state = deriveHarnessSessionState( + [ + createAssistantMessage({ + toolCalls, + }), + ], + [], + ); + + expect(state.recentFileEvents).toHaveLength(5); + expect(state.recentFileEvents.map((item) => item.displayName)).toEqual([ + "file-6.txt", + "file-5.txt", + "file-4.txt", + "file-3.txt", + "file-2.txt", + ]); + }); +}); diff --git a/src/components/agent/chat/utils/harnessState.ts b/src/components/agent/chat/utils/harnessState.ts new file mode 100644 index 000000000..f9efbcd68 --- /dev/null +++ b/src/components/agent/chat/utils/harnessState.ts @@ -0,0 +1,1077 @@ +import type { ContextTraceStep, ToolCallState } from "@/lib/api/agentStream"; +import type { ActionRequired, Message } from "../types"; + +export type HarnessTodoStatus = "pending" | "in_progress" | "completed"; +export type HarnessPlanPhase = "idle" | "planning" | "ready"; + +export interface HarnessTodoItem { + id: string; + content: string; + status: HarnessTodoStatus; +} + +export interface HarnessPlanState { + phase: HarnessPlanPhase; + items: HarnessTodoItem[]; + sourceToolCallId?: string; +} + +export interface HarnessToolActivity { + planning: number; + filesystem: number; + execution: number; + web: number; + skills: number; + delegation: number; +} + +export interface HarnessDelegatedTask { + id: string; + title: string; + status: ToolCallState["status"]; + taskType?: string; + role?: string; + model?: string; + summary?: string; + startedAt?: Date; +} + +export interface HarnessOutputSignal { + id: string; + toolCallId: string; + toolName: string; + title: string; + summary: string; + preview?: string; + outputFile?: string; + offloadFile?: string; + artifactPath?: string; + exitCode?: number; + stdoutLength?: number; + stderrLength?: number; + sandboxed?: boolean; + truncated?: boolean; + offloaded?: boolean; + offloadOriginalChars?: number; + offloadOriginalTokens?: number; + offloadTrigger?: string; +} + +export type HarnessFileKind = + | "document" + | "code" + | "log" + | "artifact" + | "offload" + | "other"; + +export type HarnessFileAction = + | "read" + | "write" + | "edit" + | "offload" + | "persist"; + +export interface HarnessFileEvent { + id: string; + toolCallId: string; + path: string; + displayName: string; + kind: HarnessFileKind; + action: HarnessFileAction; + sourceToolName: string; + timestamp?: Date; + preview?: string; + content?: string; + clickable: boolean; +} + +export interface HarnessSessionState { + pendingApprovals: ActionRequired[]; + latestContextTrace: ContextTraceStep[]; + plan: HarnessPlanState; + activity: HarnessToolActivity; + delegatedTasks: HarnessDelegatedTask[]; + outputSignals: HarnessOutputSignal[]; + recentFileEvents: HarnessFileEvent[]; + hasSignals: boolean; +} + +interface ToolCallEntry { + toolCall: ToolCallState; + messageTimestamp: Date; +} + +const PLANNING_TOOL_NAMES = new Set([ + "todowrite", + "writetodos", + "enterplanmode", + "exitplanmode", +]); +const TODO_SNAPSHOT_TOOL_NAMES = new Set(["todowrite", "writetodos"]); + +const FILESYSTEM_TOOL_NAMES = new Set([ + "read", + "readfile", + "write", + "writefile", + "edit", + "editfile", + "multiedit", + "glob", + "grep", + "ls", + "list", + "listdirectory", + "createfile", +]); + +const WEB_TOOL_RE = /^(websearch|webfetch)|browser|playwright/i; +const SKILL_TOOL_NAMES = new Set(["skill", "threestageworkflow"]); +const PROXYCAST_TOOL_METADATA_BEGIN = "[ProxyCast 工具元数据开始]"; +const PROXYCAST_TOOL_METADATA_END = "[ProxyCast 工具元数据结束]"; + +function normalizeToolName(value: string): string { + return value + .replace(/[\s_-]+/g, "") + .trim() + .toLowerCase(); +} + +function parseJsonValue(raw?: string): unknown { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + + const normalized = trimmed.startsWith("```") + ? trimmed.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "") + : trimmed; + + try { + return JSON.parse(normalized); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function normalizeDate(value: unknown): Date | null { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + + if (typeof value === "string" || typeof value === "number") { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + return null; +} + +function normalizeString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizeBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function normalizeNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function resolveTimestamp(...values: unknown[]): number { + for (const value of values) { + const normalized = normalizeDate(value); + if (normalized) { + return normalized.getTime(); + } + } + return 0; +} + +function normalizeTodoStatus(value: unknown): HarnessTodoStatus { + if (value === true) return "completed"; + + const normalized = + typeof value === "string" ? value.trim().toLowerCase() : undefined; + if (!normalized) return "pending"; + + if (normalized === "completed" || normalized === "done") { + return "completed"; + } + if ( + normalized === "in_progress" || + normalized === "inprogress" || + normalized === "active" || + normalized === "running" + ) { + return "in_progress"; + } + return "pending"; +} + +function extractTodoCandidates(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + + const record = asRecord(value); + if (!record) return []; + + for (const key of [ + "todos", + "items", + "tasks", + "todo_list", + "todoList", + "task_list", + "taskList", + ]) { + const candidate = record[key]; + if (Array.isArray(candidate)) { + return candidate; + } + } + + return []; +} + +function normalizeTodoItem( + value: unknown, + index: number, +): HarnessTodoItem | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const content = + (typeof record.content === "string" && record.content.trim()) || + (typeof record.text === "string" && record.text.trim()) || + (typeof record.title === "string" && record.title.trim()) || + (typeof record.task === "string" && record.task.trim()) || + (typeof record.label === "string" && record.label.trim()) || + ""; + + if (!content) return null; + + return { + id: + (typeof record.id === "string" && record.id.trim()) || + `todo-${index + 1}`, + content, + status: normalizeTodoStatus( + record.status ?? record.done ?? record.completed ?? record.state, + ), + }; +} + +function extractTodoSnapshot(toolCall: ToolCallState): HarnessTodoItem[] { + const fromArguments = extractTodoCandidates( + parseJsonValue(toolCall.arguments), + ) + .map(normalizeTodoItem) + .filter((item): item is HarnessTodoItem => item !== null); + if (fromArguments.length > 0) { + return fromArguments; + } + + return extractTodoCandidates(parseJsonValue(toolCall.result?.output)) + .map(normalizeTodoItem) + .filter((item): item is HarnessTodoItem => item !== null); +} + +function collectToolCalls(messages: Message[]): ToolCallEntry[] { + return messages + .flatMap((message) => + (message.toolCalls || []).map((toolCall) => ({ + toolCall, + messageTimestamp: message.timestamp, + })), + ) + .sort((left, right) => { + const leftTime = resolveTimestamp( + left.toolCall.startTime, + left.messageTimestamp, + ); + const rightTime = resolveTimestamp( + right.toolCall.startTime, + right.messageTimestamp, + ); + return leftTime - rightTime; + }); +} + +function summarizeToolOutput(toolCall: ToolCallState): string | undefined { + const value = + toolCall.result?.output?.trim() || toolCall.result?.error?.trim(); + if (!value) return undefined; + return value.length > 160 ? `${value.slice(0, 160)}...` : value; +} + +function stripAuxiliaryOutput(raw?: string): string { + if (!raw) return ""; + + let normalized = raw; + const beginIndex = normalized.lastIndexOf(PROXYCAST_TOOL_METADATA_BEGIN); + const endIndex = normalized.lastIndexOf(PROXYCAST_TOOL_METADATA_END); + + if (beginIndex >= 0 && endIndex >= beginIndex) { + normalized = + normalized.slice(0, beginIndex) + + normalized.slice(endIndex + PROXYCAST_TOOL_METADATA_END.length); + } + + normalized = normalized.replace( + /^\[ProxyCast Offload\]\s*完整输出已转存到文件:.+$/gm, + "", + ); + + return normalized.trim(); +} + +function buildTextPreview( + raw?: string, + options?: { + maxLines?: number; + maxChars?: number; + }, +): string | undefined { + const normalized = stripAuxiliaryOutput(raw); + if (!normalized) { + return undefined; + } + + const maxLines = options?.maxLines ?? 8; + const maxChars = options?.maxChars ?? 480; + const lines = normalized + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line, index, all) => line.length > 0 || all.length === 1) + .slice(0, maxLines); + + const preview = lines.join("\n").trim(); + if (!preview) { + return undefined; + } + + return preview.length > maxChars + ? `${preview.slice(0, maxChars).trimEnd()}…` + : preview; +} + +function maybeKeepTextContent(raw?: string): string | undefined { + if (!raw) { + return undefined; + } + + const normalized = stripAuxiliaryOutput(raw); + if (!normalized || normalized.length > 64 * 1024) { + return undefined; + } + + return normalized; +} + +function extractMetadata( + toolCall: ToolCallState, +): Record | null { + const direct = asRecord(toolCall.result?.metadata); + if (direct) return direct; + + const output = toolCall.result?.output; + if (!output) return null; + const beginIndex = output.lastIndexOf(PROXYCAST_TOOL_METADATA_BEGIN); + const endIndex = output.lastIndexOf(PROXYCAST_TOOL_METADATA_END); + if (beginIndex < 0 || endIndex < beginIndex) { + return null; + } + + const raw = output + .slice(beginIndex + PROXYCAST_TOOL_METADATA_BEGIN.length, endIndex) + .trim(); + if (!raw) return null; + + try { + return asRecord(JSON.parse(raw)); + } catch { + return null; + } +} + +function extractRegexValue(pattern: RegExp, text?: string): string | undefined { + if (!text) return undefined; + const match = text.match(pattern); + const value = match?.[1]?.trim(); + return value || undefined; +} + +function parseNumberFromText( + pattern: RegExp, + text?: string, +): number | undefined { + const value = extractRegexValue(pattern, text); + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function parseBooleanFromText( + pattern: RegExp, + text?: string, +): boolean | undefined { + const value = extractRegexValue(pattern, text); + if (!value) return undefined; + if (value === "true") return true; + if (value === "false") return false; + return undefined; +} + +function fileNameFromPath(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const segments = normalized.split("/"); + return segments[segments.length - 1] || path; +} + +function pickFirstPath(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === "string" && item.trim()) { + return item.trim(); + } + } + } + + return undefined; +} + +function extractPathFromRecord(record: Record | null): string | undefined { + if (!record) { + return undefined; + } + + for (const key of [ + "path", + "file_path", + "filePath", + "file_name", + "fileName", + "filename", + "target_path", + "targetPath", + "output_path", + "outputPath", + "absolute_path", + "absolutePath", + "new_path", + "newPath", + "paths", + "files", + ]) { + const value = pickFirstPath(record[key]); + if (value) { + return value; + } + } + + return undefined; +} + +function extractContentFromRecord( + record: Record | null, +): string | undefined { + if (!record) { + return undefined; + } + + for (const key of [ + "content", + "new_str", + "newText", + "text", + "body", + "value", + ]) { + const value = record[key]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + + return undefined; +} + +function resolveFileKind( + path: string, + preferred?: HarnessFileKind, +): HarnessFileKind { + if (preferred) { + return preferred; + } + + const extension = fileNameFromPath(path).split(".").pop()?.toLowerCase(); + if (!extension) { + return "other"; + } + + if (["log", "out", "err"].includes(extension)) { + return "log"; + } + + if ( + [ + "rs", + "ts", + "tsx", + "js", + "jsx", + "py", + "go", + "java", + "c", + "cpp", + "h", + "json", + "yaml", + "yml", + "toml", + "sql", + "sh", + "bash", + "zsh", + "html", + "css", + "scss", + "xml", + ].includes(extension) + ) { + return "code"; + } + + if ( + [ + "md", + "markdown", + "txt", + "pdf", + "doc", + "docx", + "csv", + "rtf", + ].includes(extension) + ) { + return "document"; + } + + return "other"; +} + +function extractArtifactPath(toolCall: ToolCallState): string | undefined { + const output = toolCall.result?.output; + const parsed = asRecord(parseJsonValue(output)); + return ( + normalizeString(parsed?.absolute_path) || + normalizeString(parsed?.path) || + normalizeString(parsed?.output_path) + ); +} + +function extractOutputSignal( + toolCall: ToolCallState, +): HarnessOutputSignal | null { + if (!toolCall.result) return null; + + const metadata = extractMetadata(toolCall); + const output = toolCall.result.output; + const outputFile = + normalizeString(metadata?.output_file) || + extractRegexValue(/^输出文件:\s*(.+)$/m, output); + const offloadFile = + normalizeString(metadata?.offload_file) || + extractRegexValue( + /^\[ProxyCast Offload\]\s*完整输出已转存到文件:(.+)$/m, + output, + ); + const artifactPath = + normalizeString(metadata?.path) || extractArtifactPath(toolCall); + const exitCode = + normalizeNumber(metadata?.exit_code) || + parseNumberFromText(/^退出码:\s*(-?\d+)$/m, output) || + parseNumberFromText(/^exit_code:\s*(-?\d+)$/m, output) || + parseNumberFromText(/Command exited with code (-?\d+)/, output); + const stdoutLength = + normalizeNumber(metadata?.stdout_length) || + parseNumberFromText(/^stdout_length:\s*(\d+)$/m, output); + const stderrLength = + normalizeNumber(metadata?.stderr_length) || + parseNumberFromText(/^stderr_length:\s*(\d+)$/m, output); + const sandboxed = + normalizeBoolean(metadata?.sandboxed) || + parseBooleanFromText(/^sandboxed:\s*(true|false)$/m, output); + const outputTruncatedFromSummary = parseBooleanFromText( + /^output_truncated:\s*(true|false)$/m, + output, + ); + const truncated = + output.includes("[event_converter] 工具输出已截断") || + output.includes("[output truncated:") || + outputTruncatedFromSummary === true; + const offloaded = + normalizeBoolean(metadata?.proxycast_offloaded) === true || + !!offloadFile || + output.includes("[ProxyCast Offload]"); + const offloadOriginalChars = normalizeNumber( + metadata?.offload_original_chars, + ); + const offloadOriginalTokens = normalizeNumber( + metadata?.offload_original_tokens, + ); + const offloadTrigger = normalizeString(metadata?.offload_trigger); + const preview = buildTextPreview(output); + + if ( + !outputFile && + !offloadFile && + !artifactPath && + exitCode === undefined && + stdoutLength === undefined && + stderrLength === undefined && + sandboxed === undefined && + !truncated && + !offloaded + ) { + return null; + } + + const summaryParts: string[] = []; + let title = "工具输出信号"; + + if (outputFile) { + title = "任务输出已落盘"; + summaryParts.push(fileNameFromPath(outputFile)); + } + + if (offloadFile) { + title = outputFile ? title : "工具输出已转存"; + summaryParts.push(fileNameFromPath(offloadFile)); + } + + if (artifactPath) { + title = outputFile || offloadFile ? title : "产物已写入"; + summaryParts.push(fileNameFromPath(artifactPath)); + } + + if (exitCode !== undefined) { + title = outputFile || offloadFile || artifactPath ? title : "命令执行摘要"; + summaryParts.push(`退出码 ${exitCode}`); + } + + if (stdoutLength !== undefined) { + summaryParts.push(`stdout ${stdoutLength}`); + } + + if (stderrLength !== undefined) { + summaryParts.push(`stderr ${stderrLength}`); + } + + if (sandboxed !== undefined) { + summaryParts.push(sandboxed ? "已隔离执行" : "普通执行"); + } + + if (truncated) { + title = + outputFile || offloadFile || artifactPath ? title : "工具输出已截断"; + summaryParts.push("输出已截断"); + } + + if (offloaded) { + title = + outputFile || offloadFile || artifactPath ? title : "工具输出已转存"; + summaryParts.push("完整输出已转存"); + } + + if (offloadOriginalChars !== undefined) { + summaryParts.push(`原始 ${offloadOriginalChars} 字符`); + } + if (offloadOriginalTokens !== undefined) { + summaryParts.push(`约 ${offloadOriginalTokens} tokens`); + } + if (offloadTrigger) { + summaryParts.push( + offloadTrigger === "history_context_pressure" + ? "上下文压力触发" + : offloadTrigger === "token_limit_before_evict" + ? "token 阈值触发" + : offloadTrigger === "payload_bytes" + ? "字节阈值触发" + : offloadTrigger === "payload_chars" + ? "字符阈值触发" + : offloadTrigger, + ); + } + + return { + id: `${toolCall.id}:output-signal`, + toolCallId: toolCall.id, + toolName: toolCall.name, + title, + summary: summaryParts.join(" / ") || "存在可观测输出信号", + preview, + outputFile, + offloadFile, + artifactPath, + exitCode, + stdoutLength, + stderrLength, + sandboxed, + truncated, + offloaded, + offloadOriginalChars, + offloadOriginalTokens, + offloadTrigger, + }; +} + +function extractFileEventFromToolCall( + toolCall: ToolCallState, + normalizedName: string, +): HarnessFileEvent | null { + if ( + !FILESYSTEM_TOOL_NAMES.has(normalizedName) && + normalizedName !== "read_file" && + normalizedName !== "write_file" && + normalizedName !== "edit_file" + ) { + return null; + } + + const args = asRecord(parseJsonValue(toolCall.arguments)); + const metadata = extractMetadata(toolCall); + const path = extractPathFromRecord(args) || extractPathFromRecord(metadata); + if (!path) { + return null; + } + + const timestamp = + normalizeDate(toolCall.endTime) ?? normalizeDate(toolCall.startTime) ?? undefined; + const action: HarnessFileAction = + normalizedName.startsWith("read") + ? "read" + : normalizedName.includes("edit") + ? "edit" + : "write"; + const sourceContent = + action === "read" + ? toolCall.result?.output + : extractContentFromRecord(args) || + normalizeString(metadata?.content) || + toolCall.result?.output; + const content = maybeKeepTextContent(sourceContent); + const preview = buildTextPreview(sourceContent); + + return { + id: `${toolCall.id}:file:${action}:${path}`, + toolCallId: toolCall.id, + path, + displayName: fileNameFromPath(path), + kind: resolveFileKind(path), + action, + sourceToolName: toolCall.name, + timestamp, + preview, + content, + clickable: true, + }; +} + +function extractFileEventsFromOutputSignal( + signal: HarnessOutputSignal, + toolCall: ToolCallState, +): HarnessFileEvent[] { + const timestamp = + normalizeDate(toolCall.endTime) ?? normalizeDate(toolCall.startTime) ?? undefined; + const events: HarnessFileEvent[] = []; + + if (signal.outputFile) { + events.push({ + id: `${signal.id}:output-file`, + toolCallId: toolCall.id, + path: signal.outputFile, + displayName: fileNameFromPath(signal.outputFile), + kind: resolveFileKind(signal.outputFile, "log"), + action: "persist", + sourceToolName: signal.toolName, + timestamp, + preview: signal.preview, + clickable: true, + }); + } + + if (signal.offloadFile) { + events.push({ + id: `${signal.id}:offload-file`, + toolCallId: toolCall.id, + path: signal.offloadFile, + displayName: fileNameFromPath(signal.offloadFile), + kind: "offload", + action: "offload", + sourceToolName: signal.toolName, + timestamp, + preview: signal.preview, + clickable: true, + }); + } + + if (signal.artifactPath) { + events.push({ + id: `${signal.id}:artifact-file`, + toolCallId: toolCall.id, + path: signal.artifactPath, + displayName: fileNameFromPath(signal.artifactPath), + kind: resolveFileKind(signal.artifactPath, "artifact"), + action: "persist", + sourceToolName: signal.toolName, + timestamp, + preview: signal.preview, + clickable: true, + }); + } + + return events; +} + +function mergeFileEvent( + previous: HarnessFileEvent | undefined, + next: HarnessFileEvent, +): HarnessFileEvent { + if (!previous) { + return next; + } + + return { + ...previous, + ...next, + preview: next.preview || previous.preview, + content: next.content || previous.content, + timestamp: next.timestamp || previous.timestamp, + clickable: previous.clickable || next.clickable, + }; +} + +function isPlanningTool(name: string): boolean { + return PLANNING_TOOL_NAMES.has(name); +} + +function classifyToolActivity( + activity: HarnessToolActivity, + name: string, +): void { + if (isPlanningTool(name)) { + activity.planning += 1; + return; + } + + if (name === "subagenttask") { + activity.delegation += 1; + return; + } + + if ( + name === "task" || + name === "taskoutput" || + name === "killshell" || + name === "bash" + ) { + activity.execution += 1; + return; + } + + if (FILESYSTEM_TOOL_NAMES.has(name)) { + activity.filesystem += 1; + return; + } + + if (WEB_TOOL_RE.test(name)) { + activity.web += 1; + return; + } + + if (SKILL_TOOL_NAMES.has(name)) { + activity.skills += 1; + } +} + +function extractDelegatedTask(toolCall: ToolCallState): HarnessDelegatedTask { + const args = asRecord(parseJsonValue(toolCall.arguments)); + const title = + (typeof args?.description === "string" && args.description.trim()) || + (typeof args?.prompt === "string" && args.prompt.trim()) || + "子任务委派"; + + return { + id: toolCall.id, + title, + status: toolCall.status, + taskType: + typeof args?.taskType === "string" + ? args.taskType + : typeof args?.task_type === "string" + ? args.task_type + : undefined, + role: typeof args?.role === "string" ? args.role : undefined, + model: typeof args?.model === "string" ? args.model : undefined, + summary: summarizeToolOutput(toolCall), + startedAt: normalizeDate(toolCall.startTime) ?? undefined, + }; +} + +export function deriveHarnessSessionState( + messages: Message[], + pendingApprovals: ActionRequired[], +): HarnessSessionState { + const safePendingApprovals = Array.isArray(pendingApprovals) + ? pendingApprovals + : []; + const toolCalls = collectToolCalls(messages); + const activity: HarnessToolActivity = { + planning: 0, + filesystem: 0, + execution: 0, + web: 0, + skills: 0, + delegation: 0, + }; + + let latestTodoItems: HarnessTodoItem[] = []; + let latestTodoSourceToolCallId: string | undefined; + let latestPlanningTimestamp = 0; + let latestExitPlanTimestamp = 0; + const delegatedTasks: HarnessDelegatedTask[] = []; + const outputSignals: HarnessOutputSignal[] = []; + const recentFileEventMap = new Map(); + + for (const entry of toolCalls) { + const normalizedName = normalizeToolName(entry.toolCall.name); + const timestamp = resolveTimestamp( + entry.toolCall.endTime, + entry.toolCall.startTime, + entry.messageTimestamp, + ); + + classifyToolActivity(activity, normalizedName); + + if (TODO_SNAPSHOT_TOOL_NAMES.has(normalizedName)) { + latestPlanningTimestamp = Math.max(latestPlanningTimestamp, timestamp); + const snapshot = extractTodoSnapshot(entry.toolCall); + if (snapshot.length > 0) { + latestTodoItems = snapshot; + latestTodoSourceToolCallId = entry.toolCall.id; + } + continue; + } + + if (normalizedName === "enterplanmode") { + latestPlanningTimestamp = Math.max(latestPlanningTimestamp, timestamp); + continue; + } + + if ( + normalizedName === "exitplanmode" && + entry.toolCall.status === "completed" + ) { + latestExitPlanTimestamp = Math.max(latestExitPlanTimestamp, timestamp); + continue; + } + + if (normalizedName === "subagenttask") { + delegatedTasks.push(extractDelegatedTask(entry.toolCall)); + } + + const fileEvent = extractFileEventFromToolCall( + entry.toolCall, + normalizedName, + ); + if (fileEvent) { + recentFileEventMap.set( + fileEvent.id, + mergeFileEvent(recentFileEventMap.get(fileEvent.id), fileEvent), + ); + } + + const outputSignal = extractOutputSignal(entry.toolCall); + if (outputSignal) { + outputSignals.push(outputSignal); + const outputFileEvents = extractFileEventsFromOutputSignal( + outputSignal, + entry.toolCall, + ); + outputFileEvents.forEach((event) => { + recentFileEventMap.set( + event.id, + mergeFileEvent(recentFileEventMap.get(event.id), event), + ); + }); + } + } + + const recentFileEvents = [...recentFileEventMap.values()] + .sort((left, right) => { + const leftTime = left.timestamp?.getTime() ?? 0; + const rightTime = right.timestamp?.getTime() ?? 0; + return rightTime - leftTime; + }) + .slice(0, 5); + + const latestContextTrace = + [...messages] + .reverse() + .find( + (message) => + Array.isArray(message.contextTrace) && + message.contextTrace.length > 0, + )?.contextTrace || []; + + const planPhase: HarnessPlanPhase = + latestPlanningTimestamp === 0 && + latestExitPlanTimestamp === 0 && + latestTodoItems.length === 0 + ? "idle" + : latestExitPlanTimestamp > 0 && + latestExitPlanTimestamp >= latestPlanningTimestamp + ? "ready" + : "planning"; + + const hasSignals = + safePendingApprovals.length > 0 || + latestContextTrace.length > 0 || + latestTodoItems.length > 0 || + delegatedTasks.length > 0 || + outputSignals.length > 0 || + recentFileEvents.length > 0 || + Object.values(activity).some((count) => count > 0); + + return { + pendingApprovals: safePendingApprovals, + latestContextTrace, + plan: { + phase: planPhase, + items: latestTodoItems, + sourceToolCallId: latestTodoSourceToolCallId, + }, + activity, + delegatedTasks: delegatedTasks.slice(-5).reverse(), + outputSignals: outputSignals.slice(-5).reverse(), + recentFileEvents, + hasSignals, + }; +} diff --git a/src/components/agent/chat/utils/streamDiagnostics.ts b/src/components/agent/chat/utils/streamDiagnostics.ts index 50359377d..6eec7aee2 100644 --- a/src/components/agent/chat/utils/streamDiagnostics.ts +++ b/src/components/agent/chat/utils/streamDiagnostics.ts @@ -1,4 +1,4 @@ -import type { StreamEvent } from "@/lib/api/agent"; +import type { StreamEvent } from "@/lib/api/agentStream"; import { updateCrashContext } from "@/lib/crashReporting"; const EVENT_PUBLISH_INTERVAL = 20; diff --git a/src/components/agent/chat/utils/styleRuntime.ts b/src/components/agent/chat/utils/styleRuntime.ts index ae5755f13..f75db3ff4 100644 --- a/src/components/agent/chat/utils/styleRuntime.ts +++ b/src/components/agent/chat/utils/styleRuntime.ts @@ -1,7 +1,7 @@ import type { ThemeType } from "@/components/content-creator/types"; import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; import { scriptStateToText } from "@/components/content-creator/canvas/script"; -import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/types"; +import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge"; import type { TaskFile } from "../components/TaskFiles"; import { getSupportedFilenames } from "./workflowMapping"; @@ -67,7 +67,9 @@ export function extractStyleActionContent(context: StyleActionContext): string { } } -export function resolveStyleActionFileName(context: StyleActionContext): string { +export function resolveStyleActionFileName( + context: StyleActionContext, +): string { const selectedFile = context.taskFiles.find( (file) => file.id === context.selectedFileId, ); diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index eca0ea8dd..f9d21008f 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -11,28 +11,32 @@ import * as Select from "@radix-ui/react-select"; import { invoke } from "@tauri-apps/api/core"; import { LogsTab } from "./LogsTab"; import { ProviderIcon } from "@/icons/providers"; +import { reloadCredentials } from "@/lib/api/providerRuntime"; +import { revealPathInFinder } from "@/lib/api/fileSystem"; import { - startServer, - stopServer, - getServerStatus, - getServerDiagnostics, - getConfig, - saveConfig, - reloadCredentials, + getNetworkInfo, testApi, - exportSupportBundle, - revealInFinder, - ServerStatus, - ServerDiagnostics, - SupportBundleExportResult, - Config, - TestResult, + type NetworkInfo, + type TestResult, +} from "@/lib/api/serverTools"; +import { + getConfig, getDefaultProvider, + saveConfig, setDefaultProvider, updateProviderEnvVars, - getNetworkInfo, - NetworkInfo, -} from "@/hooks/useTauri"; + type Config, +} from "@/lib/api/appConfig"; +import { + exportSupportBundle, + getServerDiagnostics, + getServerStatus, + startServer, + stopServer, + type ServerDiagnostics, + type ServerStatus, + type SupportBundleExportResult, +} from "@/lib/api/serverRuntime"; import { providerPoolApi, ProviderPoolOverview } from "@/lib/api/providerPool"; import { apiKeyProviderApi, @@ -275,7 +279,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { const handleRevealSupportBundle = async () => { if (!supportBundleResult?.bundle_path) return; try { - await revealInFinder(supportBundleResult.bundle_path); + await revealPathInFinder(supportBundleResult.bundle_path); } catch (e: unknown) { const errMsg = e instanceof Error ? e.message : String(e); setMessage({ type: "error", text: `打开支持包目录失败: ${errMsg}` }); diff --git a/src/components/api-server/LogsTab.tsx b/src/components/api-server/LogsTab.tsx index b5d48f590..036349014 100644 --- a/src/components/api-server/LogsTab.tsx +++ b/src/components/api-server/LogsTab.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { Trash2, Download } from "lucide-react"; -import { getLogs, clearLogs, LogEntry } from "@/hooks/useTauri"; +import { clearLogs, getLogs, type LogEntry } from "@/lib/api/logs"; export function LogsTab() { const [logs, setLogs] = useState([]); diff --git a/src/components/api-server/ModelsTab.tsx b/src/components/api-server/ModelsTab.tsx index d1f0cbddc..063d37edc 100644 --- a/src/components/api-server/ModelsTab.tsx +++ b/src/components/api-server/ModelsTab.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from "react"; import { Cpu, RefreshCw, Copy, Check, Search } from "lucide-react"; -import { getAvailableModels, ModelInfo } from "@/hooks/useTauri"; +import { getAvailableModels, type ModelInfo } from "@/lib/api/modelCatalog"; // 根据 provider_id 获取分组配置 const PROVIDER_GROUPS: Record = { diff --git a/src/components/chat/ChatPage.tsx b/src/components/chat/ChatPage.tsx index fabecb64c..474847d6e 100644 --- a/src/components/chat/ChatPage.tsx +++ b/src/components/chat/ChatPage.tsx @@ -1,152 +1,20 @@ /** * @file 通用对话页面 - * @description ProxyCast 核心功能 - 通用对话页面 + * @description 旧版通用对话页面兼容包装层 * @module components/chat/ChatPage */ -import React, { useState, useCallback, useEffect, memo } from "react"; -import styled from "styled-components"; -import { MessageList, InputBar, ThemeSelector, EmptyState } from "./components"; -import { useChat } from "./hooks"; -import { ThemeType } from "./types"; - -const PageContainer = styled.div` - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - background-color: hsl(var(--background)); -`; - -const ChatArea = styled.div` - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -`; - -const ErrorBanner = styled.div` - padding: 12px 16px; - margin: 0 16px; - border-radius: 8px; - background: hsl(var(--destructive) / 0.1); - color: hsl(var(--destructive)); - font-size: 14px; - text-align: center; -`; +import React, { memo } from "react"; +import { GeneralChatPage } from "@/components/general-chat"; /** * 通用对话页面 * - * ProxyCast 的核心功能,提供: - * - 即时对话,打开即用 - * - Markdown 渲染和代码高亮 - * - 流式响应 - * - 主题选择入口 + * 该组件仅保留兼容入口职责,实际实现统一委托给 + * `components/general-chat/GeneralChatPage`,避免旧页面继续维护独立状态机。 + * + * @deprecated 遗留通用聊天页面。禁止新增依赖,请优先使用现役聊天入口。 */ -export const ChatPage: React.FC = memo(() => { - const { - messages, - isGenerating, - error, - sendMessage, - clearMessages: _clearMessages, - retryLastMessage, - stopGeneration, - } = useChat(); - - const [currentTheme, setCurrentTheme] = useState("general"); - const [selectedText, setSelectedText] = useState(""); - - useEffect(() => { - const handleSelectionChange = () => { - const rawSelection = window.getSelection()?.toString() || ""; - const normalized = rawSelection.trim().replace(/\s+/g, " "); - const clipped = - normalized.length > 500 - ? `${normalized.slice(0, 500).trim()}…` - : normalized; - - setSelectedText((prev) => (prev === clipped ? prev : clipped)); - }; - - document.addEventListener("selectionchange", handleSelectionChange); - return () => { - document.removeEventListener("selectionchange", handleSelectionChange); - }; - }, []); - - const hasMessages = messages.length > 0; - - // 处理建议点击 - const handleSuggestionClick = useCallback( - (prompt: string) => { - sendMessage(prompt); - }, - [sendMessage], - ); - - // 处理删除消息 - const handleDeleteMessage = useCallback((id: string) => { - // TODO: 实现单条消息删除 - console.log("删除消息:", id); - }, []); - - // 处理重试消息 - const handleRetryMessage = useCallback( - (_id: string) => { - retryLastMessage(); - }, - [retryLastMessage], - ); - - // 处理主题变更 - const handleThemeChange = useCallback((theme: ThemeType) => { - setCurrentTheme(theme); - // TODO: 切换到创作模式 - console.log("切换主题:", theme); - }, []); - - return ( - - - {error && ⚠️ {error}} - - {hasMessages ? ( - - ) : ( - - )} - - - - - {!hasMessages && ( - - )} - - ); -}); +export const ChatPage: React.FC = memo(() => ); ChatPage.displayName = "ChatPage"; diff --git a/src/components/chat/README.md b/src/components/chat/README.md index 182095402..7d3d57ecc 100644 --- a/src/components/chat/README.md +++ b/src/components/chat/README.md @@ -4,50 +4,50 @@ ## 架构说明 -通用对话模块 - ProxyCast 的核心功能。提供即时对话能力,支持 Markdown 渲染、代码高亮、流式响应。 +该目录现阶段仅保留 **兼容入口**,用于承接历史 `components/chat` 依赖。 +现役通用对话实现已经迁移到 `src/components/general-chat/`,不要再在这里新增业务逻辑。 -## 功能特性 +## 当前定位 -- **即时对话**:打开即用,无需选择主题 -- **Markdown 渲染**:支持标题、列表、粗体、斜体、链接等 -- **代码高亮**:支持 12+ 种编程语言语法高亮 -- **一键复制**:代码块支持一键复制 -- **流式响应**:打字机效果,实时显示 AI 回复 -- **主题选择**:底部提供创作主题入口 +- **兼容包装**:保留旧导入路径,避免一次性打爆历史调用方 +- **单一事实源**:真实会话、消息、流式状态统一以 `general-chat` Store 和后端 compat 命令为准 +- **禁止扩散**:该目录下文件只能做委托、适配、废弃标记,不再维护独立状态机 ## 文件索引 - `index.ts` - 模块导出入口 -- `ChatPage.tsx` - 通用对话主页面 +- `ChatPage.tsx` - `GeneralChatPage` 的兼容包装层 - `types.ts` - 类型定义 ### components/ -- `CodeBlock.tsx` - 代码块组件(语法高亮 + 复制) -- `MessageItem.tsx` - 单条消息组件 -- `MessageList.tsx` - 消息列表组件 -- `InputBar.tsx` - 输入栏组件 -- `ThemeSelector.tsx` - 主题选择器 -- `ModeSelector.tsx` - 创作模式选择器 -- `EmptyState.tsx` - 空状态欢迎界面 -- `index.ts` - 组件导出入口 +- `*.tsx` - 历史 UI 资产源码,仅保留参考和兼容排障价值 +- `index.ts` - 空壳兼容入口,不再导出旧组件 -### hooks/ +### hooks/(兼容层) -- `useChat.ts` - 对话状态管理 Hook -- `useStreaming.ts` - 流式响应处理 Hook +- `useChat.ts` - 委托到 `general-chat` Store 的兼容 Hook +- `useStreaming.ts` - 历史遗留流式 Hook,已停止维护,不再从模块根入口导出 - `index.ts` - Hooks 导出入口 -## 使用示例 +## 推荐用法 ```tsx -import { ChatPage } from '@/components/chat' +import { useUnifiedChat } from "@/hooks/useUnifiedChat"; -function App() { - return +function Example() { + const chat = useUnifiedChat({ mode: "general" }); + + return ; } ``` +- 页面层不要新增 `ChatPage` / `GeneralChatPage` 依赖,请走现有工作台或路由入口。 +- 新的对话逻辑请优先基于 `@/hooks/useUnifiedChat`。 +- `@/components/chat` 模块根入口现仅保留 `ChatPage`、基础消息类型和 `useChat` 兼容导出。 +- `@/components/chat/components` 已不再导出任何组件,避免旧 UI 资产继续扩散。 +- 如必须兼容旧代码,`@/components/chat` 仍可继续导入,但应尽快迁移到统一对话链路。 + ## 更新提醒 任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src/components/chat/components/EmptyState.tsx b/src/components/chat/components/EmptyState.tsx index abac2070b..af997e5a0 100644 --- a/src/components/chat/components/EmptyState.tsx +++ b/src/components/chat/components/EmptyState.tsx @@ -15,7 +15,7 @@ import { Lightbulb, } from "lucide-react"; import { ProjectSelector } from "@/components/projects/ProjectSelector"; -import { getConfig } from "@/hooks/useTauri"; +import { getConfig } from "@/lib/api/appConfig"; import type { ThemeType } from "../types"; import { buildRecommendationPrompt, @@ -180,8 +180,10 @@ export const EmptyState: React.FC = memo( const [localProjectId, setLocalProjectId] = useState( selectedProjectId || null, ); - const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] = - useState(true); + const [ + appendSelectedTextToRecommendation, + setAppendSelectedTextToRecommendation, + ] = useState(true); useEffect(() => { const loadConfigPreferences = async () => { @@ -227,18 +229,16 @@ export const EmptyState: React.FC = memo( selectedText: recommendationSelectedText, }); - return recommendationTuples - .slice(0, 4) - .map(([title, prompt], index) => ({ - icon: SUGGESTION_ICONS[index % SUGGESTION_ICONS.length], - title, - desc: prompt, - prompt: buildRecommendationPrompt( - prompt, - selectedText, - appendSelectedTextToRecommendation, - ), - })); + return recommendationTuples.slice(0, 4).map(([title, prompt], index) => ({ + icon: SUGGESTION_ICONS[index % SUGGESTION_ICONS.length], + title, + desc: prompt, + prompt: buildRecommendationPrompt( + prompt, + selectedText, + appendSelectedTextToRecommendation, + ), + })); }, [ recommendationTheme, recommendationSelectedText, diff --git a/src/components/chat/components/index.ts b/src/components/chat/components/index.ts index c1e7cf340..39db6a450 100644 --- a/src/components/chat/components/index.ts +++ b/src/components/chat/components/index.ts @@ -1,14 +1,11 @@ /** * @file 组件导出入口 - * @description 导出通用对话相关的组件 + * @description 遗留通用对话组件兼容占位入口 * @module components/chat/components */ -export { CodeBlock } from "./CodeBlock"; -export { MessageItem } from "./MessageItem"; -export { MessageList } from "./MessageList"; -export { InputBar } from "./InputBar"; -export { ThemeSelector } from "./ThemeSelector"; -export { ModeSelector } from "./ModeSelector"; -export type { CreationMode } from "./ModeSelector"; -export { EmptyState } from "./EmptyState"; +// 该目录下组件已降级为历史 UI 资产: +// - 不再从 barrel 导出 +// - 不作为正式复用入口 +// - 仅保留源码,供历史兼容排障参考 +export {}; diff --git a/src/components/chat/hooks/index.ts b/src/components/chat/hooks/index.ts index 1b5918092..1e99473b0 100644 --- a/src/components/chat/hooks/index.ts +++ b/src/components/chat/hooks/index.ts @@ -1,8 +1,7 @@ /** * @file Hooks 导出入口 - * @description 导出通用对话相关的 Hooks + * @description 遗留通用对话 Hooks 兼容导出 * @module components/chat/hooks */ export { useChat } from "./useChat"; -export { useStreaming } from "./useStreaming"; diff --git a/src/components/chat/hooks/useChat.ts b/src/components/chat/hooks/useChat.ts index 991fed9c6..c9ff05709 100644 --- a/src/components/chat/hooks/useChat.ts +++ b/src/components/chat/hooks/useChat.ts @@ -1,145 +1,139 @@ /** * @file useChat Hook - * @description 通用对话状态管理 Hook + * @description 遗留通用对话兼容 Hook * @module components/chat/hooks/useChat */ -import { useState, useCallback, useRef } from "react"; +import { useCallback, useMemo } from "react"; +import { + useGeneralChatStore, + type GeneralChatState, +} from "@/components/general-chat/store/useGeneralChatStore"; +import type { Message as GeneralChatMessage } from "@/components/general-chat/bridge"; import { Message, ChatState, ChatActions } from "../types"; -import { useStreaming } from "./useStreaming"; + +const EMPTY_MESSAGES: GeneralChatMessage[] = []; + +const getMessageContent = (message: GeneralChatMessage): string => { + if (message.content.trim()) { + return message.content; + } + + const textContent = message.blocks + .filter((block) => block.type === "text") + .map((block) => block.content) + .join("\n") + .trim(); + + return textContent; +}; + +const toLegacyMessage = (message: GeneralChatMessage): Message => ({ + id: message.id, + role: message.role, + content: getMessageContent(message), + timestamp: message.createdAt, + metadata: message.metadata + ? { + model: message.metadata.model, + tokens: message.metadata.tokens, + duration: message.metadata.duration, + } + : undefined, +}); + +const selectCurrentMessages = ( + state: GeneralChatState, +): GeneralChatMessage[] => { + if (!state.currentSessionId) { + return EMPTY_MESSAGES; + } + + return state.messages[state.currentSessionId] || EMPTY_MESSAGES; +}; /** - * 生成唯一 ID - */ -function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; -} - -/** - * 通用对话状态管理 Hook + * 遗留通用对话兼容 Hook * - * 提供消息管理、发送、重试、停止等功能 + * 兼容旧 `components/chat` 调用方,但内部已完全委托给 + * `general-chat` Store,避免继续维护第二套聊天状态机。 * * @returns 对话状态和操作方法 + * @deprecated 遗留聊天 Hook。禁止新增依赖,请优先使用 @/hooks/useUnifiedChat 或现役聊天入口。 */ export function useChat(): ChatState & ChatActions { - const [messages, setMessages] = useState([]); - const [isGenerating, setIsGenerating] = useState(false); - const [error, setError] = useState(null); - const abortControllerRef = useRef(null); - const currentAiMessageIdRef = useRef(null); + const currentMessages = useGeneralChatStore(selectCurrentMessages); + const isGenerating = useGeneralChatStore( + (state) => state.streaming.isStreaming, + ); + const sendMessageInStore = useGeneralChatStore((state) => state.sendMessage); + const stopGenerationInStore = useGeneralChatStore( + (state) => state.stopGeneration, + ); + const retryMessageInStore = useGeneralChatStore( + (state) => state.retryMessage, + ); + const createSessionInStore = useGeneralChatStore( + (state) => state.createSession, + ); - const { streamChat } = useStreaming(); + const messages = useMemo( + () => currentMessages.map(toLegacyMessage), + [currentMessages], + ); + + const error = useMemo(() => { + const latestMessage = currentMessages[currentMessages.length - 1]; + if (latestMessage?.status !== "error") { + return null; + } + + return latestMessage.error?.message || null; + }, [currentMessages]); /** * 发送消息 */ const sendMessage = useCallback( async (content: string) => { - if (!content.trim() || isGenerating) return; - - setError(null); - - // 添加用户消息 - const userMessage: Message = { - id: generateId(), - role: "user", - content: content.trim(), - timestamp: Date.now(), - }; - - // 创建 AI 消息占位 - const aiMessage: Message = { - id: generateId(), - role: "assistant", - content: "", - timestamp: Date.now(), - }; - - currentAiMessageIdRef.current = aiMessage.id; - setMessages((prev) => [...prev, userMessage, aiMessage]); - setIsGenerating(true); - - // 创建 AbortController - abortControllerRef.current = new AbortController(); - - const startTime = Date.now(); - - try { - await streamChat( - [...messages, userMessage], - (chunk) => { - setMessages((prev) => - prev.map((msg) => - msg.id === aiMessage.id - ? { ...msg, content: msg.content + chunk } - : msg, - ), - ); - }, - abortControllerRef.current.signal, - ); - - // 更新元数据 - const duration = Date.now() - startTime; - setMessages((prev) => - prev.map((msg) => - msg.id === aiMessage.id - ? { ...msg, metadata: { ...msg.metadata, duration } } - : msg, - ), - ); - } catch (err) { - if (err instanceof Error && err.name !== "AbortError") { - setError(err.message); - // 移除空的 AI 消息 - setMessages((prev) => prev.filter((msg) => msg.id !== aiMessage.id)); - } - } finally { - setIsGenerating(false); - abortControllerRef.current = null; - currentAiMessageIdRef.current = null; - } + await sendMessageInStore(content); }, - [messages, isGenerating, streamChat], + [sendMessageInStore], ); /** - * 清空消息 + * 清空消息(兼容语义:新建空白会话,而非删除已有历史) */ const clearMessages = useCallback(() => { - setMessages([]); - setError(null); - }, []); + stopGenerationInStore(); + void createSessionInStore().catch((createSessionError) => { + console.error("兼容 clearMessages 创建新会话失败:", createSessionError); + }); + }, [createSessionInStore, stopGenerationInStore]); /** - * 重试最后一条消息 + * 重试最后一条错误消息 */ const retryLastMessage = useCallback(async () => { - const lastUserMessage = [...messages] + const lastErrorAssistantMessage = [...currentMessages] .reverse() - .find((m) => m.role === "user"); - if (lastUserMessage) { - // 移除最后一条 AI 消息 - setMessages((prev) => { - const lastAiIndex = [...prev] - .reverse() - .findIndex((m: Message) => m.role === "assistant"); - if (lastAiIndex > -1) { - return prev.slice(0, prev.length - 1 - lastAiIndex); - } - return prev; - }); - await sendMessage(lastUserMessage.content); + .find( + (message) => message.role === "assistant" && message.status === "error", + ); + + if (!lastErrorAssistantMessage) { + return; } - }, [messages, sendMessage]); + + await retryMessageInStore(lastErrorAssistantMessage.id); + }, [currentMessages, retryMessageInStore]); /** * 停止生成 */ const stopGeneration = useCallback(() => { - abortControllerRef.current?.abort(); - }, []); + stopGenerationInStore(); + }, [stopGenerationInStore]); return { messages, diff --git a/src/components/chat/hooks/useStreaming.ts b/src/components/chat/hooks/useStreaming.ts index d4591f078..bf3c6fc8d 100644 --- a/src/components/chat/hooks/useStreaming.ts +++ b/src/components/chat/hooks/useStreaming.ts @@ -1,22 +1,12 @@ /** * @file useStreaming Hook - * @description 流式响应处理 Hook + * @description 遗留流式兼容 Hook * @module components/chat/hooks/useStreaming */ import { useCallback } from "react"; -import { safeInvoke, safeListen } from "@/lib/dev-bridge"; -import type { UnlistenFn } from "@tauri-apps/api/event"; import { Message } from "../types"; -/** - * 流式响应事件数据 - */ -interface StreamChunkEvent { - content: string; - done: boolean; -} - /** * 流式对话选项 */ @@ -26,11 +16,13 @@ interface StreamChatOptions { } /** - * 流式响应处理 Hook + * 遗留流式兼容 Hook * - * 提供与后端的流式通信能力 + * 该 Hook 曾依赖已废弃的 `agent_chat_stream` 命令。 + * 为避免继续扩散旧链路,这里只保留兼容 API 形状,并显式提示调用方迁移。 * * @returns 流式对话方法 + * @deprecated 禁止新增依赖,请迁移到 `@/components/general-chat` 或统一对话链路。 */ export function useStreaming() { /** @@ -43,51 +35,14 @@ export function useStreaming() { */ const streamChat = useCallback( async ( - messages: Message[], - onChunk: (chunk: string) => void, - signal?: AbortSignal, - options?: StreamChatOptions, + _messages: Message[], + _onChunk: (chunk: string) => void, + _signal?: AbortSignal, + _options?: StreamChatOptions, ): Promise => { - let unlisten: UnlistenFn | null = null; - let isAborted = false; - - // 监听中断信号 - if (signal) { - signal.addEventListener("abort", () => { - isAborted = true; - unlisten?.(); - }); - } - - try { - // 监听流式响应事件 - unlisten = await safeListen( - "chat-stream-chunk", - (event) => { - if (isAborted) return; - if (event.payload.content) { - onChunk(event.payload.content); - } - }, - ); - - // 调用 Tauri 命令开始流式对话 - // 注意:这里使用现有的 agent_chat_stream 命令 - // 如果需要独立的通用对话命令,可以后续添加 - await safeInvoke("agent_chat_stream", { - messages: messages.map((m) => ({ - role: m.role, - content: m.content, - })), - projectId: options?.projectId, - }); - } catch (err) { - if (!isAborted) { - throw err; - } - } finally { - unlisten?.(); - } + throw new Error( + "components/chat/hooks/useStreaming 已停止维护,请迁移到 general-chat 或统一对话链路。", + ); }, [], ); diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts index f7b13458a..475dc14b1 100644 --- a/src/components/chat/index.ts +++ b/src/components/chat/index.ts @@ -1,10 +1,9 @@ /** * @file 通用对话模块入口 - * @description 导出通用对话相关的组件和类型 + * @description 遗留通用对话兼容入口 * @module components/chat */ export { ChatPage } from "./ChatPage"; -export * from "./types"; -export * from "./hooks"; -export * from "./components"; +export { useChat } from "./hooks/useChat"; +export type { MessageRole, Message, ChatState, ChatActions } from "./types"; diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index 28b118a8d..905f57b3a 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -60,6 +60,8 @@ export interface ChatActions { /** * 主题类型 + * + * @deprecated 仅供遗留 `components/chat` UI 资产使用,请改用现役内容创作或统一对话主题类型。 */ export type ThemeType = | "general" // 通用对话(默认) @@ -76,6 +78,8 @@ export type ThemeType = /** * 主题配置 + * + * @deprecated 仅供遗留 `components/chat` UI 资产使用。 */ export interface ThemeConfig { id: ThemeType; @@ -86,6 +90,8 @@ export interface ThemeConfig { /** * 主题配置列表 + * + * @deprecated 仅供遗留 `components/chat` UI 资产使用。 */ export const THEME_CONFIGS: ThemeConfig[] = [ { diff --git a/src/components/clients/LiveConfigModal.tsx b/src/components/clients/LiveConfigModal.tsx index a62438b38..f60995dde 100644 --- a/src/components/clients/LiveConfigModal.tsx +++ b/src/components/clients/LiveConfigModal.tsx @@ -121,8 +121,7 @@ export function LiveConfigModal({ appType, onClose }: LiveConfigModalProps) {

暂无环境变量配置

- 💡 提示:切换 Claude 供应商后,ProxyCast 会自动将配置写入 - shell 配置文件 + 💡 提示:这里展示的是兼容外部客户端的 Shell 写入结果;运行时统一环境请以系统设置中的“环境变量”页为准。

)} @@ -140,7 +139,7 @@ export function LiveConfigModal({ appType, onClose }: LiveConfigModalProps) {

{appType === "claude" && claudeConfig ? ( - <>配置方式:配置文件 + Shell 环境变量(需重启终端生效) + <>兼容输出:配置文件 + Shell 环境变量;运行时主入口已统一到系统设置的“环境变量”页 ) : ( <> 配置文件路径:{" "} diff --git a/src/components/clients/ProviderForm.tsx b/src/components/clients/ProviderForm.tsx index 13a147dc9..0f9d9fff6 100644 --- a/src/components/clients/ProviderForm.tsx +++ b/src/components/clients/ProviderForm.tsx @@ -1,7 +1,7 @@ import React, { useState, useMemo, useCallback, useEffect } from "react"; import { X, ExternalLink, Wand2, Eye, EyeOff, Database } from "lucide-react"; import { Provider, AppType } from "@/lib/api/switch"; -import { getConfig } from "@/hooks/useTauri"; +import { getConfig } from "@/lib/api/appConfig"; import { cn } from "@/lib/utils"; import { ProviderIcon } from "@/icons/providers"; import { diff --git a/src/components/content-creator/canvas/video/VideoWorkspace.tsx b/src/components/content-creator/canvas/video/VideoWorkspace.tsx index ec1052f15..46fc28e7e 100644 --- a/src/components/content-creator/canvas/video/VideoWorkspace.tsx +++ b/src/components/content-creator/canvas/video/VideoWorkspace.tsx @@ -8,10 +8,13 @@ import React, { } from "react"; import styled from "styled-components"; import { Video } from "lucide-react"; -import { invoke } from "@tauri-apps/api/core"; import { toast } from "sonner"; import { VideoCanvasState } from "./types"; import { PromptInput } from "./PromptInput"; +import { + importMaterialFromUrl, + type ImportMaterialFromUrlRequest, +} from "@/lib/api/materials"; import { videoGenerationApi, type VideoGenerationTask, @@ -151,15 +154,6 @@ const TaskPrompt = styled.div` -webkit-line-clamp: 2; `; -interface ImportMaterialFromUrlRequest { - projectId: string; - name: string; - type: "video" | "image"; - url: string; - tags?: string[]; - description?: string; -} - interface WorkspaceTask extends VideoGenerationTask { resourceMaterialId?: string; resourceSavedAt?: number; @@ -315,12 +309,7 @@ export const VideoWorkspace: React.FC = memo( tags: [VIDEO_TASK_TAG], description: `视频生成自动入库(服务:${task.providerId},模型:${task.model})`, }; - const savedMaterial = await invoke<{ id: string }>( - "import_material_from_url", - { - req: request, - }, - ); + const savedMaterial = await importMaterialFromUrl(request); setTasks((previous) => previous.map((item) => @@ -485,9 +474,7 @@ export const VideoWorkspace: React.FC = memo( ? "视频生成首帧参考图(自动上传)" : "视频生成尾帧参考图(自动上传)", }; - const material = await invoke<{ id: string }>("import_material_from_url", { - req: request, - }); + const material = await importMaterialFromUrl(request); const materialUrl = `material://${material.id}`; materialRefCache.current.set(normalizedUrl, materialUrl); @@ -496,77 +483,82 @@ export const VideoWorkspace: React.FC = memo( [projectId], ); - const handleGenerate = useCallback(async (textOverride?: string) => { - if (!projectId) { - toast.error("请先选择项目后再生成视频"); - return; - } - if (!state.providerId) { - toast.error("请选择视频服务"); - return; - } - if (!state.model) { - toast.error("请选择视频模型"); - return; - } - const promptText = textOverride || state.prompt.trim(); - if (!promptText) { - toast.error("请输入视频描述"); - return; - } - const providerNormalized = state.providerId.trim().toLowerCase(); - const supportedProvider = - providerNormalized.includes("doubao") || - providerNormalized.includes("volc") || - providerNormalized.includes("dashscope") || - providerNormalized.includes("alibaba") || - providerNormalized.includes("qwen"); - if (!supportedProvider) { - toast.error("当前仅支持火山或阿里兼容视频服务"); - return; - } + const handleGenerate = useCallback( + async (textOverride?: string) => { + if (!projectId) { + toast.error("请先选择项目后再生成视频"); + return; + } + if (!state.providerId) { + toast.error("请选择视频服务"); + return; + } + if (!state.model) { + toast.error("请选择视频模型"); + return; + } + const promptText = textOverride || state.prompt.trim(); + if (!promptText) { + toast.error("请输入视频描述"); + return; + } + const providerNormalized = state.providerId.trim().toLowerCase(); + const supportedProvider = + providerNormalized.includes("doubao") || + providerNormalized.includes("volc") || + providerNormalized.includes("dashscope") || + providerNormalized.includes("alibaba") || + providerNormalized.includes("qwen"); + if (!supportedProvider) { + toast.error("当前仅支持火山或阿里兼容视频服务"); + return; + } - onStateChange({ - ...state, - status: "generating", - errorMessage: undefined, - }); - try { - const [resolvedStartImageUrl, resolvedEndImageUrl] = await Promise.all([ - ensureReferenceImageUrl(state.startImage, "start"), - ensureReferenceImageUrl(state.endImage, "end"), - ]); - - const created = await videoGenerationApi.createTask({ - projectId, - providerId: state.providerId, - model: state.model, - prompt: promptText, - aspectRatio: state.aspectRatio, - resolution: state.resolution, - duration: state.duration, - imageUrl: resolvedStartImageUrl, - endImageUrl: resolvedEndImageUrl, - seed: state.seed, - generateAudio: state.generateAudio, - cameraFixed: state.cameraFixed, - }); - - setTasks((previous) => { - const merged = mergeTaskList(previous, [created]); - return merged; - }); - toast.success("视频任务已提交,正在生成"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); onStateChange({ ...state, - status: "error", - errorMessage: message, + status: "generating", + errorMessage: undefined, }); - toast.error(message); - } - }, [ensureReferenceImageUrl, onStateChange, projectId, state]); + try { + const [resolvedStartImageUrl, resolvedEndImageUrl] = + await Promise.all([ + ensureReferenceImageUrl(state.startImage, "start"), + ensureReferenceImageUrl(state.endImage, "end"), + ]); + + const created = await videoGenerationApi.createTask({ + projectId, + providerId: state.providerId, + model: state.model, + prompt: promptText, + aspectRatio: state.aspectRatio, + resolution: state.resolution, + duration: state.duration, + imageUrl: resolvedStartImageUrl, + endImageUrl: resolvedEndImageUrl, + seed: state.seed, + generateAudio: state.generateAudio, + cameraFixed: state.cameraFixed, + }); + + setTasks((previous) => { + const merged = mergeTaskList(previous, [created]); + return merged; + }); + toast.success("视频任务已提交,正在生成"); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + onStateChange({ + ...state, + status: "error", + errorMessage: message, + }); + toast.error(message); + } + }, + [ensureReferenceImageUrl, onStateChange, projectId, state], + ); const isGenerated = tasks.length > 0 || state.status !== "idle"; diff --git a/src/components/general-chat/GeneralChatPage.tsx b/src/components/general-chat/GeneralChatPage.tsx index 9e085d813..d230138a4 100644 --- a/src/components/general-chat/GeneralChatPage.tsx +++ b/src/components/general-chat/GeneralChatPage.tsx @@ -1,6 +1,6 @@ /** * @file GeneralChatPage.tsx - * @description 通用对话主页面 - 三栏布局 + * @description 通用对话主页面 - 三栏布局(旧 general-chat 兼容入口) * @module components/general-chat/GeneralChatPage * * @requirements 3.1, 3.5, 9.4 @@ -11,6 +11,7 @@ import { ChatPanel } from "./chat/ChatPanel"; import { CanvasPanel } from "./canvas/CanvasPanel"; import { ErrorBoundary } from "./chat/ErrorBoundary"; import { useGeneralChatStore } from "./store/useGeneralChatStore"; +import { useStreaming } from "./hooks/useStreaming"; import type { CanvasState, GeneralChatPageProps } from "./types"; import { DEFAULT_CANVAS_STATE } from "./types"; @@ -21,41 +22,59 @@ import { DEFAULT_CANVAS_STATE } from "./types"; * - 左侧:会话列表(复用 ChatSidebar) * - 中间:聊天区域 * - 右侧:画布面板(可折叠) + * + * @deprecated 该页面仅用于兼容旧版 general-chat 链路,新功能请优先接入统一对话入口。 */ export const GeneralChatPage: React.FC = ({ initialSessionId, onNavigate, }) => { - const { currentSessionId, selectSession, sessions, createSession } = + const { currentSessionId, selectSession, createSession, hydrateSessions } = useGeneralChatStore(); // 画布状态 const [canvasState, setCanvasState] = useState(DEFAULT_CANVAS_STATE); - // 使用 ref 防止重复创建会话 + // 使用 ref 防止 StrictMode 下重复初始化 + const hydratedRef = useRef(false); const sessionCreatedRef = useRef(false); - // 初始化:如果有初始会话 ID,选择它;否则如果没有会话,创建一个 + // 接入现役 Aster 流式事件,避免发送后只停留在占位消息。 + useStreaming({ sessionId: currentSessionId }); + + // 初始化:先从后端 hydrate 会话,再决定是否创建默认会话。 useEffect(() => { - if (initialSessionId) { - selectSession(initialSessionId); - } else if ( - sessions.length === 0 && - !currentSessionId && - !sessionCreatedRef.current - ) { - // 如果没有会话,创建一个新会话(只创建一次) - sessionCreatedRef.current = true; - createSession(); + if (hydratedRef.current) { + return; } - }, [ - initialSessionId, - selectSession, - sessions.length, - currentSessionId, - createSession, - ]); + + hydratedRef.current = true; + let cancelled = false; + + void (async () => { + try { + const hydratedSessionId = await hydrateSessions(initialSessionId); + + if (!cancelled && !hydratedSessionId && !sessionCreatedRef.current) { + sessionCreatedRef.current = true; + await createSession(); + } + } catch (error) { + console.error("[GeneralChatPage] 初始化会话失败:", error); + } + })(); + + return () => { + cancelled = true; + }; + }, [initialSessionId, hydrateSessions, createSession]); + + useEffect(() => { + if (initialSessionId && initialSessionId !== currentSessionId) { + selectSession(initialSessionId); + } + }, [initialSessionId, currentSessionId, selectSession]); // 打开画布 const handleOpenCanvas = useCallback((state: CanvasState) => { diff --git a/src/components/general-chat/README.md b/src/components/general-chat/README.md index 17f8d74fe..2bd6d7bd2 100644 --- a/src/components/general-chat/README.md +++ b/src/components/general-chat/README.md @@ -7,7 +7,12 @@ 通用对话功能模块,提供简洁高效的 AI 对话体验。 采用三栏布局架构:左侧会话列表 + 中间聊天区域 + 右侧画布面板。 +> 治理说明:`GeneralChatPage` 仍是现役页面容器,但已不鼓励业务代码继续直接 import 页面入口; +> 新逻辑优先走统一对话链路(如 `@/hooks/useUnifiedChat`)或现有工作台/路由接入。 +> 如必须跨模块复用 `general-chat` 的少量能力,请优先走 `bridge.ts`,不要直接深导入内部目录。 + **多模态支持**: 完整支持图片上传、显示和处理,包括: + - 图片上传(拖拽、点击上传) - 图片预览和下载 - 支持 JPEG、PNG、GIF、WebP 格式 @@ -26,7 +31,8 @@ ## 文件索引 -- `index.tsx` - 主入口导出 +- `index.tsx` - 兼容根入口,仅导出 `GeneralChatPage` +- `bridge.ts` - 对外桥接层,仅暴露少量跨模块允许复用的稳定能力 - `GeneralChatPage.tsx` - 页面容器(三栏布局) - `types.ts` - 核心类型定义 - Session、Message、ContentBlock 等数据类型 @@ -71,15 +77,18 @@ - `CanvasToolbar.tsx` - 画布工具栏 - `store/` - 状态管理 - - `useGeneralChatStore.ts` - 主 Store + - `useGeneralChatStore.ts` - 主 Store(只消费 compat API 网关与现役 `agentRuntime`,不再直连 `general_chat_*` / `aster_*` 命令) - `sessionSlice.ts` - 会话状态切片 - `messageSlice.ts` - 消息状态切片 - `uiSlice.ts` - UI 状态切片 +- `src/lib/api/generalChatCompat.ts` - general-chat compat API 网关(前端唯一允许直连 `general_chat_*` 命令的地方) +- `src/lib/api/agentRuntime.ts` - Aster 运行时 API(general-chat 发送/停止流式响应统一走这里) + - `hooks/` - 自定义 Hooks - - `useChat.ts` - 对话逻辑 Hook - - `useStreaming.ts` - 流式响应 Hook - - `useSession.ts` - 会话管理 Hook + - `useChat.ts` - 旧对话逻辑 Hook(兼容层,不再从 barrel/root 导出) + - `useStreaming.ts` - 旧流式响应 Hook(兼容层,不再从 barrel/root 导出) + - `useSession.ts` - 旧会话管理 Hook(兼容层,不再从 barrel/root 导出) - `useCanvas.ts` - 画布控制 Hook - `useProvider.ts` - Provider 选择逻辑 Hook(复用 ProviderPool 系统) - 自动选择可用 Provider diff --git a/src/components/general-chat/bridge.ts b/src/components/general-chat/bridge.ts new file mode 100644 index 000000000..01649f4c4 --- /dev/null +++ b/src/components/general-chat/bridge.ts @@ -0,0 +1,12 @@ +/** + * @file bridge.ts + * @description general-chat 对外桥接层 + * @module components/general-chat/bridge + * + * 仅用于其他模块在治理过渡期按需复用少量稳定能力, + * 避免继续直接深挖 `general-chat` 内部实现目录。 + */ + +export { CanvasPanel } from "./canvas"; +export { DEFAULT_CANVAS_STATE } from "./types"; +export type { CanvasState, Message } from "./types"; diff --git a/src/components/general-chat/hooks/index.ts b/src/components/general-chat/hooks/index.ts index 4f4c5e3aa..ba5ae0470 100644 --- a/src/components/general-chat/hooks/index.ts +++ b/src/components/general-chat/hooks/index.ts @@ -1,11 +1,8 @@ /** * @file index.ts - * @description Hooks 导出 + * @description Hooks 导出(仅保留现役稳定入口) * @module components/general-chat/hooks */ -export { useStreaming } from "./useStreaming"; -export { useChat } from "./useChat"; -export { useSession } from "./useSession"; export { useProvider } from "./useProvider"; export type { UseProviderResult } from "./useProvider"; diff --git a/src/components/general-chat/hooks/useChat.ts b/src/components/general-chat/hooks/useChat.ts index 12bfc6454..106250cf5 100644 --- a/src/components/general-chat/hooks/useChat.ts +++ b/src/components/general-chat/hooks/useChat.ts @@ -9,21 +9,9 @@ */ import { useCallback } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { useGeneralChatStore } from "../store/useGeneralChatStore"; import type { Message, ProviderConfig } from "../types"; -/** - * 发送消息请求参数 - */ -interface SendMessageRequest { - session_id: string; - content: string; - event_name: string; - provider?: string; - model?: string; -} - /** * useChat Hook 配置 */ @@ -40,11 +28,16 @@ interface UseChatOptions { /** * 聊天逻辑 Hook + * + * @deprecated general-chat 的旧聊天 Hook。禁止新增依赖,请优先使用 @/hooks/useUnifiedChat 或现役聊天入口。 */ export const useChat = (options: UseChatOptions) => { - const { sessionId, providerConfig, onMessageSent, onError } = options; + const { sessionId, onMessageSent, onError } = options; - const { startStreaming } = useGeneralChatStore(); + const sendMessageInStore = useGeneralChatStore((state) => state.sendMessage); + const stopGenerationInStore = useGeneralChatStore( + (state) => state.stopGeneration, + ); /** * 发送消息 @@ -56,65 +49,45 @@ export const useChat = (options: UseChatOptions) => { } try { - // 构建事件名称 - const eventName = `general-chat-stream-${sessionId}`; - - // 调用 Tauri 命令发送消息 - const request: SendMessageRequest = { - session_id: sessionId, - content: content.trim(), - event_name: eventName, - provider: providerConfig?.providerName, - model: providerConfig?.modelName, - }; - - const messageId = await invoke("general_chat_send_message", { - request, - }); - - startStreaming(messageId); + await sendMessageInStore(content.trim()); // 消息发送成功 if (onMessageSent) { - const message: Message = { - id: messageId, - sessionId, - role: "assistant", - content: "", - blocks: [], - status: "streaming", - createdAt: Date.now(), - }; - onMessageSent(message); + const { messages } = useGeneralChatStore.getState(); + const latestAssistantMessage = [...(messages[sessionId] || [])] + .reverse() + .find((message) => message.role === "assistant"); + + if (latestAssistantMessage) { + onMessageSent(latestAssistantMessage); + } } } catch (error) { - // 停止流式状态 - const { stopGeneration } = useGeneralChatStore.getState(); - stopGeneration(); + stopGenerationInStore(); const errorMessage = error instanceof Error ? error.message : String(error); onError?.(errorMessage); } }, - [sessionId, providerConfig, startStreaming, onMessageSent, onError], + [ + sessionId, + sendMessageInStore, + stopGenerationInStore, + onMessageSent, + onError, + ], ); /** * 停止生成 */ const stopGeneration = useCallback(async () => { - if (!sessionId) return; - try { - await invoke("general_chat_stop_generation", { - sessionId, - }); - const { stopGeneration: stopGen } = useGeneralChatStore.getState(); - stopGen(); + stopGenerationInStore(); } catch (error) { console.error("停止生成失败:", error); } - }, [sessionId]); + }, [stopGenerationInStore]); /** * 重新生成消息 diff --git a/src/components/general-chat/hooks/useSession.ts b/src/components/general-chat/hooks/useSession.ts index cccd40727..96a48f14b 100644 --- a/src/components/general-chat/hooks/useSession.ts +++ b/src/components/general-chat/hooks/useSession.ts @@ -1,6 +1,6 @@ /** * @file useSession.ts - * @description 会话管理 Hook + * @description 会话管理 Hook(旧 general-chat 兼容实现) * @module components/general-chat/hooks/useSession * * 封装会话加载、切换、自动标题生成等逻辑 @@ -9,40 +9,7 @@ */ import { useCallback, useEffect } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { useGeneralChatStore } from "../store/useGeneralChatStore"; -import type { Session } from "../types"; - -/** - * 后端会话数据结构 - */ -interface BackendSession { - id: string; - name: string; - created_at: number; - updated_at: number; - metadata?: Record; -} - -/** - * 后端会话详情数据结构 - */ -interface BackendSessionDetail { - session: BackendSession; - messages: unknown[]; - message_count: number; -} - -/** - * 转换后端会话为前端格式 - */ -const convertSession = (backend: BackendSession): Session => ({ - id: backend.id, - name: backend.name, - createdAt: backend.created_at, - updatedAt: backend.updated_at, - messageCount: 0, -}); /** * useSession Hook 配置 @@ -56,6 +23,8 @@ interface UseSessionOptions { /** * 会话管理 Hook + * + * @deprecated 该 Hook 仍停留在 general-chat compat 会话链路,仅用于兼容旧版 general-chat 页面。 */ export const useSession = (options: UseSessionOptions = {}) => { const { autoLoad = true, onSessionChange } = options; @@ -63,11 +32,11 @@ export const useSession = (options: UseSessionOptions = {}) => { const { sessions, currentSessionId, - setSessions, + hydrateSessions, selectSession, createSession: createNewSession, - deleteSession: _removeSession, - updateSession, + deleteSession: removeSession, + renameSession: renameSessionInStore, } = useGeneralChatStore(); /** @@ -75,15 +44,11 @@ export const useSession = (options: UseSessionOptions = {}) => { */ const loadSessions = useCallback(async () => { try { - const backendSessions = await invoke( - "general_chat_list_sessions", - ); - const frontendSessions = backendSessions.map(convertSession); - setSessions(frontendSessions); + await hydrateSessions(); } catch (error) { console.error("加载会话列表失败:", error); } - }, [setSessions]); + }, [hydrateSessions]); /** * 创建新会话 @@ -91,15 +56,10 @@ export const useSession = (options: UseSessionOptions = {}) => { const createSession = useCallback( async (name?: string): Promise => { try { - const _session = await invoke( - "general_chat_create_session", - { - name: name || undefined, - metadata: undefined, - }, - ); - // 使用 store 的 createSession 方法,它会自动添加会话并设置为当前会话 const sessionId = await createNewSession(); + if (name?.trim()) { + await renameSessionInStore(sessionId, name.trim()); + } onSessionChange?.(sessionId); return sessionId; } catch (error) { @@ -107,7 +67,7 @@ export const useSession = (options: UseSessionOptions = {}) => { return null; } }, - [createNewSession, onSessionChange], + [createNewSession, renameSessionInStore, onSessionChange], ); /** @@ -116,26 +76,13 @@ export const useSession = (options: UseSessionOptions = {}) => { const switchSession = useCallback( async (sessionId: string) => { try { - // 加载会话详情 - const detail = await invoke( - "general_chat_get_session", - { - sessionId, - messageLimit: 50, - }, - ); - - // 更新会话消息数量 - updateSession(sessionId, { messageCount: detail.message_count }); - - // 切换当前会话 selectSession(sessionId); onSessionChange?.(sessionId); } catch (error) { console.error("切换会话失败:", error); } }, - [selectSession, updateSession, onSessionChange], + [selectSession, onSessionChange], ); /** @@ -144,9 +91,7 @@ export const useSession = (options: UseSessionOptions = {}) => { const deleteSession = useCallback( async (sessionId: string) => { try { - await invoke("general_chat_delete_session", { sessionId }); - // 使用 store 的 deleteSession 方法,它会自动处理会话切换逻辑 - await useGeneralChatStore.getState().deleteSession(sessionId); + await removeSession(sessionId); // 获取新的当前会话 ID 并触发回调 const newCurrentId = useGeneralChatStore.getState().currentSessionId; @@ -155,7 +100,7 @@ export const useSession = (options: UseSessionOptions = {}) => { console.error("删除会话失败:", error); } }, - [onSessionChange], + [removeSession, onSessionChange], ); /** @@ -164,13 +109,12 @@ export const useSession = (options: UseSessionOptions = {}) => { const renameSession = useCallback( async (sessionId: string, name: string) => { try { - await invoke("general_chat_rename_session", { sessionId, name }); - updateSession(sessionId, { name }); + await renameSessionInStore(sessionId, name); } catch (error) { console.error("重命名会话失败:", error); } }, - [updateSession], + [renameSessionInStore], ); /** @@ -180,15 +124,10 @@ export const useSession = (options: UseSessionOptions = {}) => { const generateTitle = useCallback( async (sessionId: string, firstMessage: string) => { try { - // 调用后端命令生成标题 - const title = await invoke("general_chat_generate_title", { - request: { - session_id: sessionId, - first_message: firstMessage, - }, - }); - // 更新本地状态 - updateSession(sessionId, { name: title }); + await renameSession( + sessionId, + firstMessage.slice(0, 20).trim() || "新话题", + ); } catch (error) { console.error("生成标题失败:", error); // 失败时使用简单截取作为 fallback @@ -197,7 +136,7 @@ export const useSession = (options: UseSessionOptions = {}) => { await renameSession(sessionId, fallbackTitle); } }, - [renameSession, updateSession], + [renameSession], ); // 自动加载会话列表 diff --git a/src/components/general-chat/hooks/useStreaming.ts b/src/components/general-chat/hooks/useStreaming.ts index 66f157605..bbc2a95e2 100644 --- a/src/components/general-chat/hooks/useStreaming.ts +++ b/src/components/general-chat/hooks/useStreaming.ts @@ -1,6 +1,6 @@ /** * @file useStreaming.ts - * @description 流式响应处理 Hook + * @description 流式响应处理 Hook(旧 general-chat 兼容实现) * @module components/general-chat/hooks/useStreaming * * 处理 Tauri 事件监听和流式内容累积 @@ -9,13 +9,15 @@ */ import { useEffect, useCallback, useRef } from "react"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { parseStreamEvent } from "@/lib/api/agentStream"; +import { safeListen } from "@/lib/dev-bridge"; import { useGeneralChatStore } from "../store/useGeneralChatStore"; /** - * 流式事件类型 + * 旧版流式事件类型 */ -interface StreamEvent { +interface LegacyStreamEvent { type: "start" | "delta" | "done" | "error"; message_id?: string; content?: string; @@ -44,6 +46,8 @@ interface UseStreamingOptions { * 流式响应处理 Hook * * 监听 Tauri 事件,处理流式响应 + * + * @deprecated 该 Hook 依赖 `start/delta/done` 旧事件协议,仅用于兼容旧版 general-chat 页面。 */ export const useStreaming = (options: UseStreamingOptions) => { const { @@ -62,39 +66,103 @@ export const useStreaming = (options: UseStreamingOptions) => { // 处理流式事件 const handleStreamEvent = useCallback( - (event: { payload: StreamEvent }) => { - const { type, message_id, content, message } = event.payload; + (event: { payload: unknown }) => { + const payload = event.payload; + const legacyEvent = + payload && typeof payload === "object" + ? (payload as LegacyStreamEvent) + : null; - switch (type) { - case "start": - contentRef.current = ""; - startStreaming(message_id || ""); - onStart?.(message_id || ""); + if (legacyEvent?.type === "start") { + contentRef.current = ""; + startStreaming(legacyEvent.message_id || ""); + onStart?.(legacyEvent.message_id || ""); + return; + } + + if (legacyEvent?.type === "delta") { + if (legacyEvent.content) { + contentRef.current += legacyEvent.content; + appendStreamingContent(legacyEvent.content); + onDelta?.(legacyEvent.content); + } + return; + } + + if (legacyEvent?.type === "done" && !("usage" in legacyEvent)) { + const { finalizeMessage } = useGeneralChatStore.getState(); + void finalizeMessage(); + onDone?.(legacyEvent.message_id || "", contentRef.current); + contentRef.current = ""; + return; + } + + if (legacyEvent?.type === "error") { + const { + streaming, + setMessageError, + stopGeneration: stopGen, + } = useGeneralChatStore.getState(); + if (streaming.currentMessageId) { + setMessageError( + streaming.currentMessageId, + legacyEvent.message || "未知错误", + ); + } else { + stopGen(); + } + onError?.(legacyEvent.message || "未知错误"); + contentRef.current = ""; + return; + } + + const streamEvent = parseStreamEvent(payload); + if (!streamEvent) { + return; + } + + switch (streamEvent.type) { + case "text_delta": + contentRef.current += streamEvent.text; + appendStreamingContent(streamEvent.text); + onDelta?.(streamEvent.text); break; - case "delta": - if (content) { - contentRef.current += content; - appendStreamingContent(content); - onDelta?.(content); - } + case "done": + // Aster 的 done 只代表一轮响应结束,工具循环可能继续。 break; - case "done": { - const { finalizeMessage } = useGeneralChatStore.getState(); - finalizeMessage(); - onDone?.(message_id || "", contentRef.current); + case "final_done": { + const { finalizeMessage, streaming } = useGeneralChatStore.getState(); + const messageId = streaming.currentMessageId || ""; + void finalizeMessage(); + onDone?.(messageId, contentRef.current); contentRef.current = ""; break; } case "error": { - const { stopGeneration: stopGen } = useGeneralChatStore.getState(); - stopGen(); - onError?.(message || "未知错误"); + const { + streaming, + setMessageError, + stopGeneration: stopGen, + } = useGeneralChatStore.getState(); + if (streaming.currentMessageId) { + setMessageError(streaming.currentMessageId, streamEvent.message); + } else { + stopGen(); + } + onError?.(streamEvent.message); contentRef.current = ""; break; } + + case "warning": + console.warn("[GeneralChat] 流式告警:", streamEvent.message); + break; + + default: + break; } }, [startStreaming, appendStreamingContent, onStart, onDelta, onDone, onError], @@ -112,10 +180,7 @@ export const useStreaming = (options: UseStreamingOptions) => { // 设置新的监听器 const eventKey = `${eventName}-${sessionId}`; - unlistenRef.current = await listen( - eventKey, - handleStreamEvent, - ); + unlistenRef.current = await safeListen(eventKey, handleStreamEvent); }; setupListener(); diff --git a/src/components/general-chat/index.ts b/src/components/general-chat/index.ts index c17fc9840..a7526c00e 100644 --- a/src/components/general-chat/index.ts +++ b/src/components/general-chat/index.ts @@ -1,6 +1,6 @@ /** * @file index.tsx - * @description 通用对话功能主入口导出 + * @description 通用对话兼容根入口导出 * @module components/general-chat * @requires ./GeneralChatPage * @exports GeneralChatPage - 通用对话页面组件 @@ -8,38 +8,3 @@ // 主页面组件导出 export { default as GeneralChatPage } from "./GeneralChatPage"; - -// 类型导出(不触发 react-refresh 警告) -export type { - Session, - Message, - MessageRole, - MessageStatus, - ContentBlock, - ContentBlockType, - CanvasState, - CanvasContentType, - UIState, - StreamingState, - ProviderConfig, - MessageMetadata, - GeneralChatPageProps, - ChatPanelProps, - MessageItemProps, - InputBarProps, - CanvasPanelProps, -} from "./types"; - -// 子模块组件导出 -export { - ChatPanel, - MessageList, - MessageItem, - UserMessage, - AssistantMessage, - CodeBlock, - ErrorBoundary, -} from "./chat"; -export { CanvasPanel, CodePreview, MarkdownPreview } from "./canvas"; -export { useGeneralChatStore } from "./store"; -export { useStreaming, useChat, useSession } from "./hooks"; diff --git a/src/components/general-chat/store/useGeneralChatStore.ts b/src/components/general-chat/store/useGeneralChatStore.ts index c8b838e52..e75931cac 100644 --- a/src/components/general-chat/store/useGeneralChatStore.ts +++ b/src/components/general-chat/store/useGeneralChatStore.ts @@ -11,9 +11,25 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; +import { + createGeneralChatCompatSession, + deleteGeneralChatCompatSession, + getGeneralChatCompatMessages, + getGeneralChatCompatSession, + listGeneralChatCompatSessions, + renameGeneralChatCompatSession, + type GeneralChatCompatMessageRecord, + type GeneralChatCompatSessionRecord, +} from "@/lib/api/generalChatCompat"; +import { + sendAsterMessageStream, + stopAsterSession, +} from "@/lib/api/agentRuntime"; +import { requireDefaultProjectId } from "@/lib/api/project"; import type { Session, Message, + ContentBlock, UIState, StreamingState, CanvasState, @@ -143,6 +159,10 @@ export interface GeneralChatState { deleteSession: (id: string) => Promise; /** 重命名会话 */ renameSession: (id: string, name: string) => Promise; + /** 从后端同步会话列表 */ + hydrateSessions: ( + preferredSessionId?: string | null, + ) => Promise; /** 设置会话列表 */ setSessions: (sessions: Session[]) => void; /** 更新单个会话 */ @@ -322,6 +342,197 @@ const generateId = (): string => { */ const now = (): number => Date.now(); +interface SessionSnapshot { + session: Session; + messages: Message[]; + pagination: PaginationState; +} + +interface AsterImagePayload { + data: string; + media_type: string; +} + +const sortSessions = (sessions: Session[]): Session[] => + [...sessions].sort((a, b) => b.updatedAt - a.updatedAt); + +const upsertSession = (sessions: Session[], session: Session): Session[] => + sortSessions([session, ...sessions.filter((item) => item.id !== session.id)]); + +const toTextBlocks = (content: string): ContentBlock[] => [ + { + type: "text", + content, + }, +]; + +const normalizeMetadata = ( + metadata: Record | null | undefined, +): MessageMetadata | undefined => { + if (!metadata) { + return undefined; + } + + return { + model: typeof metadata.model === "string" ? metadata.model : undefined, + tokens: typeof metadata.tokens === "number" ? metadata.tokens : undefined, + duration: + typeof metadata.duration === "number" ? metadata.duration : undefined, + }; +}; + +const convertCompatSession = ( + backend: GeneralChatCompatSessionRecord, + messageCount = 0, +): Session => ({ + id: backend.id, + name: backend.name, + createdAt: backend.created_at, + updatedAt: backend.updated_at, + messageCount, +}); + +const convertCompatMessage = ( + message: GeneralChatCompatMessageRecord, +): Message => { + const blocks = + message.blocks?.map((block) => ({ + type: block.type as ContentBlock["type"], + content: block.content, + language: block.language, + filename: block.filename, + mimeType: block.mime_type, + })) || toTextBlocks(message.content); + + return { + id: message.id, + sessionId: message.session_id, + role: message.role as Message["role"], + content: message.content, + blocks, + status: message.status as Message["status"], + createdAt: message.created_at, + metadata: normalizeMetadata(message.metadata), + }; +}; + +const buildPaginationState = ( + messages: Message[], + messageCount: number, + overrides?: Partial, +): PaginationState => ({ + ...DEFAULT_PAGINATION_STATE, + hasMoreMessages: messageCount > messages.length, + oldestMessageId: messages[0]?.id || null, + ...overrides, +}); + +const generateFallbackSessionTitle = (content: string): string => { + const trimmed = content.trim(); + if (!trimmed) { + return "新对话"; + } + + const chars = Array.from(trimmed); + return chars.length > 20 ? `${chars.slice(0, 17).join("")}...` : trimmed; +}; + +const loadGeneralChatSessionSnapshot = async ( + sessionId: string, +): Promise => { + const detail = await getGeneralChatCompatSession( + sessionId, + DEFAULT_PAGINATION_STATE.pageSize, + ); + const messages = detail.messages.map(convertCompatMessage); + + return { + session: convertCompatSession(detail.session, detail.message_count), + messages, + pagination: buildPaginationState(messages, detail.message_count), + }; +}; + +const buildAsterMessageToSend = ( + content: string, + theme: GeneralChatState["contentTheme"], + mode: GeneralChatState["contentCreationMode"], +): string => { + const normalizedContent = content.trim() || "请分析这张图片"; + + if (theme === "general") { + return normalizedContent; + } + + const systemInstruction = getContentCreationInstruction(theme, mode); + return `${systemInstruction}\n\n---\n\n用户请求:${normalizedContent}`; +}; + +const resolveDefaultWorkspaceId = async (): Promise => { + return requireDefaultProjectId("未找到默认工作区,请先创建并设为默认工作区"); +}; + +const invokeGeneralChatAsterStream = async ({ + sessionId, + message, + images, + webSearch, +}: { + sessionId: string; + message: string; + images?: AsterImagePayload[]; + webSearch?: boolean; +}) => { + const workspaceId = await resolveDefaultWorkspaceId(); + + await sendAsterMessageStream( + message, + sessionId, + `general-chat-stream-${sessionId}`, + workspaceId, + images, + undefined, + undefined, + webSearch, + ); +}; + +const extractImagePayloadFromDataUrl = ( + value: string, +): AsterImagePayload | null => { + const matched = value.match(/^data:([^;]+);base64,(.+)$/); + if (!matched) { + return null; + } + + const [, mediaType, data] = matched; + return { + data, + media_type: mediaType, + }; +}; + +const extractRetryPayloadFromMessage = ( + message: Message, +): { content: string; images?: AsterImagePayload[] } => { + const textContent = message.blocks + .filter((block) => block.type === "text") + .map((block) => block.content) + .join("\n") + .trim(); + + const imagePayload = message.blocks + .filter((block) => block.type === "image") + .map((block) => extractImagePayloadFromDataUrl(block.content)) + .filter((block): block is AsterImagePayload => block !== null); + + return { + content: + textContent || (message.content === "[图片]" ? "" : message.content), + images: imagePayload.length > 0 ? imagePayload : undefined, + }; +}; + // ============================================================================ // Store 实现 // ============================================================================ @@ -337,46 +548,90 @@ export const useGeneralChatStore = create()( (set, get) => ({ ...initialState, + hydrateSessions: async (preferredSessionId?: string | null) => { + const backendSessions = await listGeneralChatCompatSessions(); + const sessions = sortSessions( + backendSessions.map((session) => convertCompatSession(session)), + ); + const currentSessionId = get().currentSessionId; + const nextSessionId = + [preferredSessionId, currentSessionId] + .filter((value): value is string => Boolean(value)) + .find((value) => + sessions.some((session) => session.id === value), + ) || + sessions[0]?.id || + null; + + set((state) => ({ + sessions, + currentSessionId: nextSessionId, + messages: Object.fromEntries( + Object.entries(state.messages).filter(([sessionId]) => + sessions.some((session) => session.id === sessionId), + ), + ), + })); + + if (nextSessionId) { + get().selectSession(nextSessionId); + } + + return nextSessionId; + }, + // ========== 会话操作实现 ========== createSession: async () => { - const id = generateId(); - const timestamp = now(); - const newSession: Session = { - id, - name: "新对话", - createdAt: timestamp, - updatedAt: timestamp, - messageCount: 0, - }; + const backendSession = await createGeneralChatCompatSession(); + const newSession = convertCompatSession(backendSession); + const paginationState = buildPaginationState([], 0, { + hasMoreMessages: false, + }); set((state) => ({ - sessions: [newSession, ...state.sessions], - currentSessionId: id, + sessions: upsertSession(state.sessions, newSession), + currentSessionId: newSession.id, messages: { ...state.messages, - [id]: [], + [newSession.id]: [], + }, + pagination: { + ...state.pagination, + [newSession.id]: paginationState, }, })); - // TODO: 调用 Tauri 命令持久化到数据库 - // await invoke('general_chat_create_session', { name: newSession.name }); - - return id; + return newSession.id; }, selectSession: (id: string) => { - const { sessions } = get(); - const sessionExists = sessions.some((s) => s.id === id); + set({ currentSessionId: id }); - if (sessionExists) { - set({ currentSessionId: id }); - // TODO: 如果消息未加载,调用 Tauri 命令加载消息 - } + void (async () => { + try { + const snapshot = await loadGeneralChatSessionSnapshot(id); + + set((state) => ({ + sessions: upsertSession(state.sessions, snapshot.session), + messages: { + ...state.messages, + [id]: snapshot.messages, + }, + pagination: { + ...state.pagination, + [id]: snapshot.pagination, + }, + })); + } catch (error) { + console.error("加载会话详情失败:", error); + } + })(); }, deleteSession: async (id: string) => { const { sessions, currentSessionId, messages } = get(); + await deleteGeneralChatCompatSession(id); // 从列表中移除会话 const newSessions = sessions.filter((s) => s.id !== id); @@ -399,29 +654,37 @@ export const useGeneralChatStore = create()( messages: newMessages, }); - // TODO: 调用 Tauri 命令从数据库删除 - // await invoke('general_chat_delete_session', { sessionId: id }); + if (newCurrentId && !(newCurrentId in newMessages)) { + get().selectSession(newCurrentId); + } }, renameSession: async (id: string, name: string) => { + await renameGeneralChatCompatSession(id, name); + set((state) => ({ - sessions: state.sessions.map((s) => - s.id === id ? { ...s, name, updatedAt: now() } : s, + sessions: sortSessions( + state.sessions.map((session) => + session.id === id + ? { ...session, name, updatedAt: now() } + : session, + ), ), })); - - // TODO: 调用 Tauri 命令持久化到数据库 - // await invoke('general_chat_rename_session', { sessionId: id, name }); }, setSessions: (sessions: Session[]) => { - set({ sessions }); + set({ sessions: sortSessions(sessions) }); }, updateSession: (id: string, updates: Partial) => { set((state) => ({ - sessions: state.sessions.map((s) => - s.id === id ? { ...s, ...updates, updatedAt: now() } : s, + sessions: sortSessions( + state.sessions.map((session) => + session.id === id + ? { ...session, ...updates, updatedAt: now() } + : session, + ), ), })); }, @@ -433,17 +696,16 @@ export const useGeneralChatStore = create()( images?: File[], webSearch?: boolean, ) => { - const { currentSessionId, messages } = get(); + const { messages } = get(); // 验证:空白消息且无图片不发送 if (!content.trim() && (!images || images.length === 0)) { return; } - // 验证:必须有当前会话 + let currentSessionId = get().currentSessionId; if (!currentSessionId) { - console.warn("No current session selected"); - return; + currentSessionId = await get().createSession(); } const messageId = generateId(); @@ -515,7 +777,7 @@ export const useGeneralChatStore = create()( // 更新会话的消息数量和更新时间 get().updateSession(currentSessionId, { - messageCount: currentMessages.length + 1, + messageCount: currentMessages.length + 2, }); // 创建 AI 响应消息占位符 @@ -591,30 +853,19 @@ export const useGeneralChatStore = create()( } try { - // 调用 Tauri 命令发送消息并开始流式响应 - const { invoke } = await import("@tauri-apps/api/core"); - // 获取内容创作状态 const { contentTheme, contentCreationMode } = get(); + const messageToSend = buildAsterMessageToSend( + content, + contentTheme, + contentCreationMode, + ); - // 根据主题生成系统指令前缀 - let messageToSend = content.trim() || "请分析这张图片"; - - // 如果不是通用主题,注入系统指令 - if (contentTheme !== "general") { - const systemInstruction = getContentCreationInstruction( - contentTheme, - contentCreationMode, - ); - messageToSend = `${systemInstruction}\n\n---\n\n用户请求:${messageToSend}`; - } - - await invoke("aster_agent_chat_stream", { + await invokeGeneralChatAsterStream({ sessionId: currentSessionId, message: messageToSend, - eventName: `general-chat-stream-${currentSessionId}`, images: imageData, - web_search: webSearch, + webSearch, }); // 如果启用了工作流,执行 Action 阶段 @@ -699,8 +950,11 @@ export const useGeneralChatStore = create()( set({ streaming: { ...DEFAULT_STREAMING_STATE } }); } - // TODO: 调用 Tauri 命令停止生成 - // await invoke('general_chat_stop_generation', { sessionId: currentSessionId }); + if (currentSessionId) { + void stopAsterSession(currentSessionId).catch((error) => { + console.error("停止生成失败:", error); + }); + } }, appendStreamingContent: (content: string) => { @@ -763,6 +1017,9 @@ export const useGeneralChatStore = create()( }, streaming: { ...DEFAULT_STREAMING_STATE }, })); + get().updateSession(currentSessionId, { + messageCount: updatedMessages.length, + }); // 自动生成会话标题:当这是第一轮对话完成时(2条消息:用户+助手) const currentSession = sessions.find((s) => s.id === currentSessionId); @@ -772,26 +1029,15 @@ export const useGeneralChatStore = create()( (m) => m.role === "user", ); if (firstUserMessage) { + const nextTitle = generateFallbackSessionTitle( + firstUserMessage.content, + ); + try { - const { invoke } = await import("@tauri-apps/api/core"); - const title = await invoke( - "general_chat_generate_title", - { - request: { - session_id: currentSessionId, - first_message: firstUserMessage.content, - }, - }, - ); - // 更新本地会话标题 - get().updateSession(currentSessionId, { name: title }); + await get().renameSession(currentSessionId, nextTitle); } catch (error) { - console.warn("自动生成标题失败:", error); - // 失败时使用简单截取 - const fallbackTitle = - firstUserMessage.content.slice(0, 20) + - (firstUserMessage.content.length > 20 ? "..." : ""); - get().updateSession(currentSessionId, { name: fallbackTitle }); + console.warn("自动更新标题失败:", error); + get().updateSession(currentSessionId, { name: nextTitle }); } } } @@ -855,13 +1101,29 @@ export const useGeneralChatStore = create()( }, startStreaming: (messageId: string) => { - set({ + const { currentSessionId, messages } = get(); + const updatedMessages = currentSessionId + ? (messages[currentSessionId] || []).map((message) => + message.id === messageId + ? { ...message, status: "streaming" as const } + : message, + ) + : null; + + set((state) => ({ + messages: + currentSessionId && updatedMessages + ? { + ...state.messages, + [currentSessionId]: updatedMessages, + } + : state.messages, streaming: { isStreaming: true, currentMessageId: messageId, partialContent: "", }, - }); + })); }, setMessageError: (messageId: string, error: ErrorInfo | string) => { @@ -916,6 +1178,8 @@ export const useGeneralChatStore = create()( if (!userMessage) return; + const retryPayload = extractRetryPayloadFromMessage(userMessage); + // 清除错误状态,将消息状态改为 pending const updatedMessages = currentMessages.map((m) => m.id === messageId @@ -924,6 +1188,7 @@ export const useGeneralChatStore = create()( status: "pending" as const, error: undefined, content: "", + blocks: [], } : m, ); @@ -935,12 +1200,24 @@ export const useGeneralChatStore = create()( }, })); - // TODO: 调用 Tauri 命令重新发送消息 - // await invoke('general_chat_send_message', { - // sessionId: currentSessionId, - // content: userMessage.content, - // eventName: `chat-stream-${currentSessionId}`, - // }); + get().startStreaming(messageId); + + try { + const { contentTheme, contentCreationMode } = get(); + const messageToSend = buildAsterMessageToSend( + retryPayload.content, + contentTheme, + contentCreationMode, + ); + + await invokeGeneralChatAsterStream({ + sessionId: currentSessionId, + message: messageToSend, + images: retryPayload.images, + }); + } catch (error) { + get().setMessageError(messageId, error as string); + } }, clearMessageError: (messageId: string) => { @@ -996,54 +1273,14 @@ export const useGeneralChatStore = create()( currentMessages.length > 0 ? currentMessages[0] : null; const beforeId = oldestMessage?.id || null; - // 调用 Tauri 命令获取更多消息 - const { invoke } = await import("@tauri-apps/api/core"); - const olderMessages = await invoke< - Array<{ - id: string; - session_id: string; - role: string; - content: string; - blocks: Array<{ - type: string; - content: string; - language?: string; - filename?: string; - mime_type?: string; - }> | null; - status: string; - created_at: number; - metadata: Record | null; - }> - >("general_chat_get_messages", { + const olderMessages = await getGeneralChatCompatMessages( sessionId, - limit: currentPagination.pageSize, + currentPagination.pageSize, beforeId, - }); + ); // 转换后端消息格式为前端格式 - const convertedMessages: Message[] = olderMessages.map((msg) => ({ - id: msg.id, - sessionId: msg.session_id, - role: msg.role as Message["role"], - content: msg.content, - blocks: msg.blocks?.map((b) => ({ - type: b.type as Message["blocks"][0]["type"], - content: b.content, - language: b.language, - filename: b.filename, - mimeType: b.mime_type, - })) || [{ type: "text" as const, content: msg.content }], - status: msg.status as Message["status"], - createdAt: msg.created_at, - metadata: msg.metadata - ? { - model: msg.metadata.model as string | undefined, - tokens: msg.metadata.tokens as number | undefined, - duration: msg.metadata.duration as number | undefined, - } - : undefined, - })); + const convertedMessages = olderMessages.map(convertCompatMessage); // 判断是否还有更多消息 const hasMore = diff --git a/src/components/image-gen/tabs/ImageSearchTab.tsx b/src/components/image-gen/tabs/ImageSearchTab.tsx index b98a9d8d1..0f5702350 100644 --- a/src/components/image-gen/tabs/ImageSearchTab.tsx +++ b/src/components/image-gen/tabs/ImageSearchTab.tsx @@ -5,7 +5,6 @@ */ import { useEffect, useRef, useState, type KeyboardEvent } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { open as openExternal } from "@tauri-apps/plugin-shell"; import { Search, @@ -38,6 +37,7 @@ import { getCanvasImageInsertHistory, type CanvasImageInsertHistoryEntry, } from "@/lib/canvasImageInsertHistory"; +import { importMaterialFromUrl } from "@/lib/api/materials"; import type { Page, PageParams } from "@/types/page"; export interface ImageSearchTabProps { @@ -716,7 +716,9 @@ export function ImageSearchTab({ projectId, onNavigate }: ImageSearchTabProps) { useEffect(() => { const unsubscribe = onCanvasImageInsertAck((ack: CanvasImageInsertAck) => { - const pendingMeta = pendingInsertRequestMetaRef.current.get(ack.requestId); + const pendingMeta = pendingInsertRequestMetaRef.current.get( + ack.requestId, + ); if (!pendingMeta) { return; } @@ -724,7 +726,9 @@ export function ImageSearchTab({ projectId, onNavigate }: ImageSearchTabProps) { if (ack.success) { const canvasLabel = CANVAS_DISPLAY_NAME[ack.canvasType] || "目标画布"; - const locationLabel = ack.locationLabel ? ` · ${ack.locationLabel}` : ""; + const locationLabel = ack.locationLabel + ? ` · ${ack.locationLabel}` + : ""; toast.success(`已插入到${canvasLabel}${locationLabel}`); const nextHistory = addCanvasImageInsertHistory({ @@ -777,14 +781,12 @@ export function ImageSearchTab({ projectId, onNavigate }: ImageSearchTabProps) { setSavingId(imageUrl); try { - await invoke("import_material_from_url", { - req: { - projectId, - name: imageName, - type: "image", - url: imageUrl, - tags: [provider], - }, + await importMaterialFromUrl({ + projectId, + name: imageName, + type: "image", + url: imageUrl, + tags: [provider], }); toast.success("已保存到图片库"); } catch (error) { @@ -960,7 +962,8 @@ export function ImageSearchTab({ projectId, onNavigate }: ImageSearchTabProps) { 最近插入记录(可一键定位) {recentInsertHistory.map((entry) => { - const canvasLabel = CANVAS_DISPLAY_NAME[entry.canvasType] || "画布"; + const canvasLabel = + CANVAS_DISPLAY_NAME[entry.canvasType] || "画布"; const locationLabel = entry.locationLabel || "已插入"; return ( diff --git a/src/components/image-gen/tabs/LocalImageTab.tsx b/src/components/image-gen/tabs/LocalImageTab.tsx index 89a9d3cad..007df463c 100644 --- a/src/components/image-gen/tabs/LocalImageTab.tsx +++ b/src/components/image-gen/tabs/LocalImageTab.tsx @@ -6,11 +6,11 @@ import { useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; -import { invoke } from "@tauri-apps/api/core"; import { ImagePlus, Loader2, Upload, Trash2 } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { toast } from "sonner"; import styled, { keyframes } from "styled-components"; +import { uploadMaterial } from "@/lib/api/materials"; export interface LocalImageTabProps { /** 目标项目 ID */ @@ -54,7 +54,7 @@ const DropZone = styled.div<{ $dragging?: boolean }>` min-height: 280px; border: 2px dashed ${({ $dragging }) => - $dragging ? "hsl(var(--primary))" : "hsl(var(--border))"}; + $dragging ? "hsl(var(--primary))" : "hsl(var(--border))"}; border-radius: 20px; padding: 48px; text-align: center; @@ -66,9 +66,7 @@ const DropZone = styled.div<{ $dragging?: boolean }>` justify-content: center; gap: 20px; background: ${({ $dragging }) => - $dragging - ? "hsl(var(--primary) / 0.06)" - : "hsl(var(--card) / 0.3)"}; + $dragging ? "hsl(var(--primary) / 0.06)" : "hsl(var(--card) / 0.3)"}; &:hover { border-color: hsl(var(--primary) / 0.5); @@ -168,9 +166,9 @@ const ActionButton = styled.button<{ $primary?: boolean; $danger?: boolean }>` &:hover:not(:disabled) { transform: translateY(-1px); box-shadow: ${({ $primary }) => - $primary - ? "0 6px 20px hsl(var(--primary) / 0.3)" - : "0 4px 12px hsl(var(--background) / 0.3)"}; + $primary + ? "0 6px 20px hsl(var(--primary) / 0.3)" + : "0 4px 12px hsl(var(--background) / 0.3)"}; } &:disabled { @@ -243,14 +241,12 @@ export function LocalImageTab({ projectId }: LocalImageTabProps) { setSaving(true); try { - await invoke("upload_material", { - req: { - project_id: projectId, - name: selectedPath.split("/").pop() || "本地图片", - type: "image", - file_path: selectedPath, - tags: ["local"], - }, + await uploadMaterial({ + projectId, + name: selectedPath.split("/").pop() || "本地图片", + type: "image", + filePath: selectedPath, + tags: ["local"], }); toast.success("已保存到图片库"); @@ -277,7 +273,13 @@ export function LocalImageTab({ projectId }: LocalImageTabProps) { -

+
请先选择项目
diff --git a/src/components/image-gen/useImageGen.ts b/src/components/image-gen/useImageGen.ts index 5e133e73b..c92edab72 100644 --- a/src/components/image-gen/useImageGen.ts +++ b/src/components/image-gen/useImageGen.ts @@ -5,9 +5,12 @@ */ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { useApiKeyProvider } from "@/hooks/useApiKeyProvider"; import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider"; +import { + importMaterialFromUrl, + type ImportMaterialFromUrlRequest, +} from "@/lib/api/materials"; import { getImageModelsForProvider } from "@/lib/imageGeneration"; import { isDebugFlagEnabled } from "@/lib/perfDebug"; import { setStoredResourceProjectId } from "@/lib/resourceProjectSelection"; @@ -38,15 +41,6 @@ interface EndpointRequestOptions { timeoutMs?: number; } -interface ImportMaterialFromUrlRequest { - projectId: string; - name: string; - type: "image"; - url: string; - tags?: string[]; - description?: string; -} - interface BackfillImagesResult { total: number; saved: number; @@ -1284,7 +1278,10 @@ async function requestImageFromFalQueue( }); const submitRaw = await submitResponse.text(); - const submitPayload = tryParseJson(submitRaw) as Record | null; + const submitPayload = tryParseJson(submitRaw) as Record< + string, + unknown + > | null; if (!submitResponse.ok) { throw new Error( @@ -1330,7 +1327,10 @@ async function requestImageFromFalQueue( }, }); const statusRaw = await statusResponse.text(); - const statusPayload = tryParseJson(statusRaw) as Record | null; + const statusPayload = tryParseJson(statusRaw) as Record< + string, + unknown + > | null; if (!statusResponse.ok) { throw new Error( @@ -1369,9 +1369,10 @@ async function requestImageFromFalQueue( }, }); const pollingRaw = await pollingResponse.text(); - const pollingPayload = tryParseJson(pollingRaw) as - | Record - | null; + const pollingPayload = tryParseJson(pollingRaw) as Record< + string, + unknown + > | null; if (pollingResponse.ok) { const imageUrl = pollingPayload @@ -1601,7 +1602,9 @@ export function useImageGen(options: UseImageGenOptions = {}) { } const preferredProvider = preferredProviderId - ? availableProviders.find((provider) => provider.id === preferredProviderId) + ? availableProviders.find( + (provider) => provider.id === preferredProviderId, + ) : null; const nextProvider = preferredProvider ?? availableProviders[0]; @@ -1650,10 +1653,7 @@ export function useImageGen(options: UseImageGenOptions = {}) { setResourceSavingCount((count) => count + 1); try { - const savedMaterial = await invoke<{ id: string }>( - "import_material_from_url", - { req: request }, - ); + const savedMaterial = await importMaterialFromUrl(request); const savedAt = Date.now(); setImages((prev) => { diff --git a/src/components/layout/CrashRecoveryPanel.tsx b/src/components/layout/CrashRecoveryPanel.tsx index 3f06d66d6..6f1965b2f 100644 --- a/src/components/layout/CrashRecoveryPanel.tsx +++ b/src/components/layout/CrashRecoveryPanel.tsx @@ -6,7 +6,8 @@ import { RefreshCw, Trash2, } from "lucide-react"; -import { getConfig, getLogs, getPersistedLogsTail } from "@/hooks/useTauri"; +import { getConfig } from "@/lib/api/appConfig"; +import { getLogs, getPersistedLogsTail } from "@/lib/api/logs"; import { buildCrashDiagnosticPayload, clearCrashDiagnosticHistory, @@ -19,9 +20,9 @@ import { normalizeCrashReportingConfig, openCrashDiagnosticDownloadDirectory, } from "@/lib/crashDiagnostic"; +import { getProjectByRootPath, updateProject } from "@/lib/api/project"; import { cn } from "@/lib/utils"; import { ClipboardPermissionGuideCard } from "@/components/settings-v2/system/shared/ClipboardPermissionGuideCard"; -import { invoke } from "@tauri-apps/api/core"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; interface CrashRecoveryPanelProps { @@ -197,10 +198,7 @@ export function CrashRecoveryPanel({ if (!newPath) { return; } - const workspace = await invoke<{ id: string } | null>( - "workspace_get_by_path", - { rootPath: oldWorkspacePath }, - ); + const workspace = await getProjectByRootPath(oldWorkspacePath); if (!workspace) { setMessage({ type: "error", @@ -208,10 +206,7 @@ export function CrashRecoveryPanel({ }); return; } - await invoke("workspace_update", { - id: workspace.id, - request: { rootPath: newPath }, - }); + await updateProject(workspace.id, { rootPath: newPath }); setMessage({ type: "success", text: `Workspace 路径已更新为:${newPath}`, diff --git a/src/components/memory/MemoryPage.tsx b/src/components/memory/MemoryPage.tsx index 0b2d5cb1d..04aa02c9f 100644 --- a/src/components/memory/MemoryPage.tsx +++ b/src/components/memory/MemoryPage.tsx @@ -33,13 +33,11 @@ import { import { cn } from "@/lib/utils"; import type { MemoryPageParams, Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; +import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig"; import { - getConfig, getMemoryOverview as getContextMemoryOverview, - saveConfig, - type Config, type MemoryConfig as TauriMemoryConfig, -} from "@/hooks/useTauri"; +} from "@/lib/api/memoryRuntime"; import { createCharacter, createOutlineNode, diff --git a/src/components/openclaw/OpenClawConfigurePage.tsx b/src/components/openclaw/OpenClawConfigurePage.tsx index eb10e102d..2a60a6370 100644 --- a/src/components/openclaw/OpenClawConfigurePage.tsx +++ b/src/components/openclaw/OpenClawConfigurePage.tsx @@ -87,7 +87,7 @@ export function OpenClawConfigurePage({ : "尚未执行健康检查"; return ( -
+
diff --git a/src/components/openclaw/OpenClawInstallPage.tsx b/src/components/openclaw/OpenClawInstallPage.tsx index cc5a92ee2..4e7fbd9c8 100644 --- a/src/components/openclaw/OpenClawInstallPage.tsx +++ b/src/components/openclaw/OpenClawInstallPage.tsx @@ -170,7 +170,7 @@ export function OpenClawInstallPage({ : "一键修复环境并安装 OpenClaw"; return ( -
+
diff --git a/src/components/openclaw/OpenClawPage.tsx b/src/components/openclaw/OpenClawPage.tsx index 5deee52ae..506b43906 100644 --- a/src/components/openclaw/OpenClawPage.tsx +++ b/src/components/openclaw/OpenClawPage.tsx @@ -215,7 +215,7 @@ function renderBlockedPage( onAction: () => void, ) { return ( -
+

{title}

@@ -1199,7 +1199,7 @@ export function OpenClawPage({ if (!statusResolved && !operationState.running) { return ( -

+

diff --git a/src/components/openclaw/OpenClawProgressPage.tsx b/src/components/openclaw/OpenClawProgressPage.tsx index 5887effa6..878bd5bd8 100644 --- a/src/components/openclaw/OpenClawProgressPage.tsx +++ b/src/components/openclaw/OpenClawProgressPage.tsx @@ -49,7 +49,7 @@ export function OpenClawProgressPage({ onAskAgentFix, }: OpenClawProgressPageProps) { return ( -
+
diff --git a/src/components/openclaw/OpenClawRuntimePage.tsx b/src/components/openclaw/OpenClawRuntimePage.tsx index be9de09d4..9d96bc069 100644 --- a/src/components/openclaw/OpenClawRuntimePage.tsx +++ b/src/components/openclaw/OpenClawRuntimePage.tsx @@ -92,7 +92,7 @@ export function OpenClawRuntimePage({ : "尚未执行健康检查"; return ( -
+
diff --git a/src/components/plugins/PluginManager.tsx b/src/components/plugins/PluginManager.tsx index 3e8e889b5..ef80f355c 100644 --- a/src/components/plugins/PluginManager.tsx +++ b/src/components/plugins/PluginManager.tsx @@ -1,5 +1,18 @@ import React, { useEffect, useState, useCallback, useMemo } from "react"; -import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { safeListen } from "@/lib/dev-bridge"; +import { + cancelPluginTask, + disablePlugin, + enablePlugin, + getPluginQueueStats, + getPluginStatus, + getPlugins, + getPluginTask, + listInstalledPlugins, + listPluginTasks, + reloadPlugins, + unloadPlugin, +} from "@/lib/api/plugins"; import { Puzzle, RefreshCw, @@ -577,13 +590,11 @@ export function PluginManager() { const fetchRuntimeData = useCallback(async () => { try { const [taskList, queueStatsList] = await Promise.all([ - safeInvoke("list_plugin_tasks", { + listPluginTasks({ taskState: taskFilter === "all" ? null : taskFilter, limit: 300, }).catch(() => []), - safeInvoke("get_plugin_queue_stats").catch( - () => [], - ), + getPluginQueueStats().catch(() => []), ]); const groupedTasks = taskList.reduce>( @@ -615,9 +626,9 @@ export function PluginManager() { try { setLoading(true); const [serviceStatus, pluginList, installedList] = await Promise.all([ - safeInvoke("get_plugin_status"), - safeInvoke("get_plugins"), - safeInvoke("list_installed_plugins").catch(() => []), + getPluginStatus(), + getPlugins(), + listInstalledPlugins().catch(() => []), ]); setStatus(serviceStatus); setPlugins(pluginList); @@ -742,9 +753,9 @@ export function PluginManager() { const handleTogglePlugin = async (name: string, currentEnabled: boolean) => { try { if (currentEnabled) { - await safeInvoke("disable_plugin", { name }); + await disablePlugin(name); } else { - await safeInvoke("enable_plugin", { name }); + await enablePlugin(name); } await fetchData(); } catch (err) { @@ -754,7 +765,7 @@ export function PluginManager() { const handleReloadPlugins = async () => { try { - await safeInvoke("reload_plugins"); + await reloadPlugins(); await fetchData(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -764,9 +775,7 @@ export function PluginManager() { const handleCancelTask = async (taskId: string) => { try { setCancellingTaskId(taskId); - const cancelled = await safeInvoke("cancel_plugin_task", { - taskId, - }); + const cancelled = await cancelPluginTask(taskId); if (cancelled) { toast.success("任务取消请求已发送"); } else { @@ -784,12 +793,7 @@ export function PluginManager() { const handleLoadTaskDetail = async (taskId: string) => { try { setDetailLoadingTaskId(taskId); - const detail = await safeInvoke( - "get_plugin_task", - { - taskId, - }, - ); + const detail = await getPluginTask(taskId); if (!detail) { toast.warning("任务详情不存在,可能已被清理"); return; @@ -855,7 +859,7 @@ export function PluginManager() { const handleUnloadPlugin = async (name: string) => { try { - await safeInvoke("unload_plugin", { name }); + await unloadPlugin(name); await fetchData(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -1487,10 +1491,10 @@ export function PluginManager() { onToggleEnabled={async () => { try { if (plugin.enabled) { - await safeInvoke("disable_plugin", { name: plugin.id }); + await disablePlugin(plugin.id); toast.success("插件已禁用"); } else { - await safeInvoke("enable_plugin", { name: plugin.id }); + await enablePlugin(plugin.id); toast.success("插件已启用"); } fetchData(); diff --git a/src/components/plugins/PluginUninstallDialog.tsx b/src/components/plugins/PluginUninstallDialog.tsx index cbf454253..eb47d1a1b 100644 --- a/src/components/plugins/PluginUninstallDialog.tsx +++ b/src/components/plugins/PluginUninstallDialog.tsx @@ -6,10 +6,10 @@ */ import { useState } from "react"; -import { safeInvoke } from "@/lib/dev-bridge"; import { AlertTriangle, Loader2, Trash2 } from "lucide-react"; import { Modal, ModalBody, ModalFooter } from "@/components/Modal"; import { Button } from "@/components/ui/button"; +import { uninstallPlugin } from "@/lib/api/plugins"; /** 已安装插件信息 */ interface InstalledPlugin { @@ -54,7 +54,7 @@ export function PluginUninstallDialog({ setError(null); try { - await safeInvoke("uninstall_plugin", { pluginId: plugin.id }); + await uninstallPlugin(plugin.id); onSuccess(); // 触发侧边栏刷新事件 window.dispatchEvent(new CustomEvent("plugin-changed")); diff --git a/src/components/projects/ProjectCategories.tsx b/src/components/projects/ProjectCategories.tsx index 91a3727ff..4ff2044fd 100644 --- a/src/components/projects/ProjectCategories.tsx +++ b/src/components/projects/ProjectCategories.tsx @@ -7,7 +7,7 @@ import { useState, useEffect, useMemo } from "react"; import { cn } from "@/lib/utils"; import { TYPE_CONFIGS, type UserType } from "@/lib/api/project"; -import { getConfig } from "@/hooks/useTauri"; +import { getConfig } from "@/lib/api/appConfig"; export type ProjectFilter = "all" | UserType | "favorites" | "archived"; diff --git a/src/components/provider-pool/AmpConfigSection.tsx b/src/components/provider-pool/AmpConfigSection.tsx index 26e0a8738..78110d315 100644 --- a/src/components/provider-pool/AmpConfigSection.tsx +++ b/src/components/provider-pool/AmpConfigSection.tsx @@ -8,7 +8,7 @@ import { CheckCircle2, AlertTriangle, } from "lucide-react"; -import type { AmpConfig, AmpModelMapping } from "@/hooks/useTauri"; +import type { AmpConfig, AmpModelMapping } from "@/lib/api/providerRuntime"; interface AmpConfigSectionProps { config: AmpConfig; diff --git a/src/components/provider-pool/CodexSection.tsx b/src/components/provider-pool/CodexSection.tsx index d71488d64..103bfb958 100644 --- a/src/components/provider-pool/CodexSection.tsx +++ b/src/components/provider-pool/CodexSection.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Plus, Trash2, FolderOpen, LogIn, RefreshCw } from "lucide-react"; import { open } from "@tauri-apps/plugin-dialog"; -import type { CredentialEntry } from "@/hooks/useTauri"; +import type { CredentialEntry } from "@/lib/api/providerRuntime"; interface CodexSectionProps { entries: CredentialEntry[]; diff --git a/src/components/provider-pool/GeminiApiKeySection.tsx b/src/components/provider-pool/GeminiApiKeySection.tsx index a5ddb4604..3742e9a85 100644 --- a/src/components/provider-pool/GeminiApiKeySection.tsx +++ b/src/components/provider-pool/GeminiApiKeySection.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Plus, Trash2, Key, Globe, Ban, Eye, EyeOff } from "lucide-react"; -import type { GeminiApiKeyEntry } from "@/hooks/useTauri"; +import type { GeminiApiKeyEntry } from "@/lib/api/providerRuntime"; interface GeminiApiKeySectionProps { entries: GeminiApiKeyEntry[] | undefined; diff --git a/src/components/provider-pool/VertexAISection.tsx b/src/components/provider-pool/VertexAISection.tsx index 3b69faacb..5c2133348 100644 --- a/src/components/provider-pool/VertexAISection.tsx +++ b/src/components/provider-pool/VertexAISection.tsx @@ -8,7 +8,10 @@ import { EyeOff, ArrowRight, } from "lucide-react"; -import type { VertexApiKeyEntry, VertexModelAlias } from "@/hooks/useTauri"; +import type { + VertexApiKeyEntry, + VertexModelAlias, +} from "@/lib/api/providerRuntime"; interface VertexAISectionProps { entries: VertexApiKeyEntry[] | undefined; diff --git a/src/components/resources/ResourcesPage.tsx b/src/components/resources/ResourcesPage.tsx index 473028d93..4edf05bc7 100644 --- a/src/components/resources/ResourcesPage.tsx +++ b/src/components/resources/ResourcesPage.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { ArrowUp, @@ -53,6 +52,8 @@ import { TableRow, } from "@/components/ui/table"; import { useProjects } from "@/hooks/useProjects"; +import { openPathWithDefaultApp } from "@/lib/api/fileSystem"; +import { listMaterials } from "@/lib/api/materials"; import { getStoredResourceProjectId, onResourceProjectChange, @@ -194,7 +195,9 @@ const matchResourceCategory = ( if (category === "document") { if (item.kind === "document") return true; if (item.kind !== "file") return false; - return !isImageResource(item) && !isAudioResource(item) && !isVideoResource(item); + return ( + !isImageResource(item) && !isAudioResource(item) && !isVideoResource(item) + ); } if (category === "image") return isImageResource(item); if (category === "audio") return isAudioResource(item); @@ -244,7 +247,9 @@ const sortResources = ( field: "updatedAt" | "createdAt" | "name", direction: "asc" | "desc", ): ResourceItem[] => { - return [...resources].sort((a, b) => compareBySortField(a, b, field, direction)); + return [...resources].sort((a, b) => + compareBySortField(a, b, field, direction), + ); }; export function ResourcesPage({ onNavigate }: ResourcesPageProps) { @@ -310,10 +315,14 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { const categoryCounts = useMemo( () => ({ all: items.length, - document: items.filter((item) => matchResourceCategory(item, "document")).length, - image: items.filter((item) => matchResourceCategory(item, "image")).length, - audio: items.filter((item) => matchResourceCategory(item, "audio")).length, - video: items.filter((item) => matchResourceCategory(item, "video")).length, + document: items.filter((item) => matchResourceCategory(item, "document")) + .length, + image: items.filter((item) => matchResourceCategory(item, "image")) + .length, + audio: items.filter((item) => matchResourceCategory(item, "audio")) + .length, + video: items.filter((item) => matchResourceCategory(item, "video")) + .length, }), [items], ); @@ -389,7 +398,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { return; } - if (!availableProjects.some((project) => project.id === detail.projectId)) { + if ( + !availableProjects.some((project) => project.id === detail.projectId) + ) { return; } @@ -432,10 +443,8 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { const results = await Promise.all( candidateProjects.map(async (project) => { try { - const materials = await invoke("list_materials", { - projectId: project.id, - project_id: project.id, - filter: { type: viewCategory }, + const materials = await listMaterials(project.id, { + type: viewCategory, }); return { projectId: project.id, @@ -525,48 +534,53 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { } try { - await invoke("open_with_default_app", { path: item.filePath }); + await openPathWithDefaultApp(item.filePath); } catch (invokeError) { toast.error( - invokeError instanceof Error ? invokeError.message : String(invokeError), + invokeError instanceof Error + ? invokeError.message + : String(invokeError), ); } }, []); - const handleOpenDocument = useCallback(async (item: ResourceItem) => { - if (onNavigate) { - onNavigate("agent", { - projectId: item.projectId, - contentId: item.id, - lockTheme: true, - fromResources: true, - }); - return; - } - - setPreviewOpen(true); - setPreviewLoading(true); - setPreviewTitle(item.name); - setPreviewContent(""); - - try { - const detail = await fetchDocumentDetail(item.id); - if (!detail) { - setPreviewContent("文档不存在或已被删除。"); + const handleOpenDocument = useCallback( + async (item: ResourceItem) => { + if (onNavigate) { + onNavigate("agent", { + projectId: item.projectId, + contentId: item.id, + lockTheme: true, + fromResources: true, + }); return; } - setPreviewTitle(detail.title); - setPreviewContent(detail.body || ""); - } catch (detailError) { - setPreviewContent( - detailError instanceof Error - ? `读取失败:${detailError.message}` - : `读取失败:${String(detailError)}`, - ); - } finally { - setPreviewLoading(false); - } - }, [onNavigate]); + + setPreviewOpen(true); + setPreviewLoading(true); + setPreviewTitle(item.name); + setPreviewContent(""); + + try { + const detail = await fetchDocumentDetail(item.id); + if (!detail) { + setPreviewContent("文档不存在或已被删除。"); + return; + } + setPreviewTitle(detail.title); + setPreviewContent(detail.body || ""); + } catch (detailError) { + setPreviewContent( + detailError instanceof Error + ? `读取失败:${detailError.message}` + : `读取失败:${String(detailError)}`, + ); + } finally { + setPreviewLoading(false); + } + }, + [onNavigate], + ); const handleOpenResource = useCallback( async (item: ResourceItem) => { @@ -618,7 +632,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { key: "upload-folder", label: "上传文件夹", action: () => { - toast.info("当前版本暂不支持文件夹上传,可先创建文件夹后逐个上传文件"); + toast.info( + "当前版本暂不支持文件夹上传,可先创建文件夹后逐个上传文件", + ); }, }, ], @@ -642,7 +658,9 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {

); diff --git a/src/components/settings-v2/general/hotkeys/index.tsx b/src/components/settings-v2/general/hotkeys/index.tsx index 20235eacc..87a6f7230 100644 --- a/src/components/settings-v2/general/hotkeys/index.tsx +++ b/src/components/settings-v2/general/hotkeys/index.tsx @@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react"; import styled from "styled-components"; import { Loader2 } from "lucide-react"; -import { getExperimentalConfig } from "@/hooks/useTauri"; +import { getExperimentalConfig } from "@/lib/api/experimentalFeatures"; import { getVoiceInputConfig, type VoiceInputConfig, diff --git a/src/components/settings-v2/general/memory/index.tsx b/src/components/settings-v2/general/memory/index.tsx index 806e1a745..5802bdd61 100644 --- a/src/components/settings-v2/general/memory/index.tsx +++ b/src/components/settings-v2/general/memory/index.tsx @@ -2,22 +2,20 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Brain, Loader2, RefreshCw } from "lucide-react"; import { cn } from "@/lib/utils"; import { - getConfig, getMemoryAutoIndex, getMemoryEffectiveSources, - getMemoryOverview as getContextMemoryOverview, - saveConfig, toggleMemoryAuto, updateMemoryAutoNote, type AutoMemoryIndexResponse, - type Config, type EffectiveMemorySourcesResponse, type MemoryAutoConfig, type MemoryConfig, type MemoryProfileConfig, type MemoryResolveConfig, type MemorySourcesConfig, -} from "@/hooks/useTauri"; + getMemoryOverview as getContextMemoryOverview, +} from "@/lib/api/memoryRuntime"; +import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig"; import { getUnifiedMemoryStats } from "@/lib/api/unifiedMemory"; import { getProjectMemory } from "@/lib/api/memory"; import { @@ -148,7 +146,9 @@ function MultiSelectSection({

{title}

- {subtitle &&

{subtitle}

} + {subtitle && ( +

{subtitle}

+ )}
@@ -208,13 +208,14 @@ export function MemorySettings() { const currentProjectId = targetProjectId ?? projectId; setLoadingLayerMetrics(true); try { - const [unifiedStats, contextOverview, projectMemory] = await Promise.all([ - getUnifiedMemoryStats(), - getContextMemoryOverview(200).catch(() => null), - currentProjectId - ? getProjectMemory(currentProjectId).catch(() => null) - : Promise.resolve(null), - ]); + const [unifiedStats, contextOverview, projectMemory] = + await Promise.all([ + getUnifiedMemoryStats(), + getContextMemoryOverview(200).catch(() => null), + currentProjectId + ? getProjectMemory(currentProjectId).catch(() => null) + : Promise.resolve(null), + ]); setLayerMetrics( buildLayerMetrics({ @@ -381,7 +382,10 @@ export function MemorySettings() { setSavingAutoNote(true); try { - const index = await updateMemoryAutoNote(note, autoTopic.trim() || undefined); + const index = await updateMemoryAutoNote( + note, + autoTopic.trim() || undefined, + ); setAutoIndex(index); setAutoNote(""); setMessage("已写入自动记忆"); @@ -457,7 +461,9 @@ export function MemorySettings() {
-

以下哪个选项最能形容你现在的状态?

+

+ 以下哪个选项最能形容你现在的状态? +

{STATUS_OPTIONS.map((option) => { const selected = profile.current_status === option; @@ -513,7 +519,9 @@ export function MemorySettings() { disabled={loadingLayerMetrics} className="inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-60" > - + 刷新
@@ -566,7 +574,9 @@ export function MemorySettings() { disabled={loadingSourceState} className="inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-60" > - + 刷新来源
@@ -611,7 +621,9 @@ export function MemorySettings() {
@@ -877,7 +893,10 @@ export function MemorySettings() { {effectiveSources ? (
{effectiveSources.sources.map((source) => ( -
+
{source.kind} - {source.loaded ? "已加载" : source.exists ? "存在未命中" : "未发现"} + {source.loaded + ? "已加载" + : source.exists + ? "存在未命中" + : "未发现"}
-

{source.path}

+

+ {source.path} +

{source.preview && (

{source.preview} @@ -906,7 +931,9 @@ export function MemorySettings() { ))}

) : ( -

正在加载来源命中结果...

+

+ 正在加载来源命中结果... +

)}
diff --git a/src/components/settings-v2/hooks/useSettingsCategory.ts b/src/components/settings-v2/hooks/useSettingsCategory.ts index 6c1dfacfc..622f1725b 100644 --- a/src/components/settings-v2/hooks/useSettingsCategory.ts +++ b/src/components/settings-v2/hooks/useSettingsCategory.ts @@ -23,6 +23,7 @@ import { Plug, Route, Search, + Variable, Monitor, ShieldCheck, HeartPulse, @@ -173,6 +174,11 @@ export function useSettingsCategory(): CategoryGroup[] { label: t("settings.tab.webSearch", "网络搜索"), icon: Search, }, + { + key: SettingsTabs.Environment, + label: t("settings.tab.environment", "环境变量"), + icon: Variable, + }, { key: SettingsTabs.ChromeRelay, label: t("settings.tab.chromeRelay", "Chrome Relay"), diff --git a/src/components/settings-v2/system/about/index.tsx b/src/components/settings-v2/system/about/index.tsx index 5fae40a28..1439ea277 100644 --- a/src/components/settings-v2/system/about/index.tsx +++ b/src/components/settings-v2/system/about/index.tsx @@ -6,23 +6,14 @@ import { AlertCircle, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { safeInvoke } from "@/lib/dev-bridge"; +import { + checkForUpdates, + downloadUpdate, + type DownloadUpdateResult, + type VersionInfo, +} from "@/lib/api/appUpdate"; import { ProviderIcon } from "@/icons/providers"; -interface VersionInfo { - current: string; - latest?: string; - hasUpdate: boolean; - downloadUrl?: string; - error?: string; -} - -interface DownloadResult { - success: boolean; - message: string; - filePath?: string; -} - const FALLBACK_TAGS_URL = "https://github.com/aiclientproxy/proxycast/tags"; const CREATIVE_THEMES = [ @@ -62,9 +53,8 @@ export function AboutSection() { }); const [checking, setChecking] = useState(false); const [downloading, setDownloading] = useState(false); - const [downloadResult, setDownloadResult] = useState( - null, - ); + const [downloadResult, setDownloadResult] = + useState(null); const manualDownloadUrl = versionInfo.downloadUrl || FALLBACK_TAGS_URL; // 加载当前版本号(从后端获取,确保与 Cargo.toml 同步) @@ -72,7 +62,7 @@ export function AboutSection() { const loadCurrentVersion = async () => { try { // check_for_updates 会返回当前版本号 - const result = await safeInvoke("check_for_updates"); + const result = await checkForUpdates(); setVersionInfo((prev) => ({ ...prev, current: result.current, @@ -93,7 +83,7 @@ export function AboutSection() { setChecking(true); setDownloadResult(null); try { - const result = await safeInvoke("check_for_updates"); + const result = await checkForUpdates(); setVersionInfo({ ...result, downloadUrl: result.downloadUrl || FALLBACK_TAGS_URL, @@ -114,7 +104,7 @@ export function AboutSection() { setDownloading(true); setDownloadResult(null); try { - const result = await safeInvoke("download_update"); + const result = await downloadUpdate(); setDownloadResult(result); if (result.success) { diff --git a/src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx b/src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx index cd8f17073..5baf738c3 100644 --- a/src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx +++ b/src/components/settings-v2/system/channels/ChannelLogTailPanel.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Copy, Pause, Play, Trash2 } from "lucide-react"; -import { clearLogs, getPersistedLogsTail, type LogEntry } from "@/hooks/useTauri"; +import { clearLogs, getPersistedLogsTail, type LogEntry } from "@/lib/api/logs"; import { type ChannelLogPreset, buildChannelLogRegex, @@ -152,7 +152,11 @@ export function ChannelLogTailPanel() { onClick={() => setPaused((v) => !v)} className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs transition-colors hover:bg-muted" > - {paused ? : } + {paused ? ( + + ) : ( + + )} {paused ? "继续" : "暂停"}
{busyAction && ( -
正在执行:{busyAction}
+
+ 正在执行:{busyAction} +
)}
@@ -657,7 +714,9 @@ function TelegramForm({

启用 Telegram Bot

-

开启后可通过 Telegram Bot 与 AI 对话

+

+ 开启后可通过 Telegram Bot 与 AI 对话 +

{busyAction && ( -
正在执行:{busyAction}
+
+ 正在执行:{busyAction} +
)}
@@ -1079,7 +1146,9 @@ function FeishuGatewayDebugPanel() { channel: "feishu", accountId: resolveAccountId(), }); - const statusResult = await gatewayChannelStatus({ channel: "feishu" }); + const statusResult = await gatewayChannelStatus({ + channel: "feishu", + }); return { stop: stopResult, start: startResult, @@ -1097,7 +1166,9 @@ function FeishuGatewayDebugPanel() {
{busyAction && ( -
正在执行:{busyAction}
+
+ 正在执行:{busyAction} +
)}
@@ -1200,7 +1271,8 @@ function DiscordGatewayDebugPanel() { onClick={() => void runAction( "probe", - async () => discordChannelProbe({ accountId: resolveAccountId() }), + async () => + discordChannelProbe({ accountId: resolveAccountId() }), "Discord 探测完成", ) } @@ -1269,7 +1341,9 @@ function DiscordGatewayDebugPanel() { channel: "discord", accountId: resolveAccountId(), }); - const statusResult = await gatewayChannelStatus({ channel: "discord" }); + const statusResult = await gatewayChannelStatus({ + channel: "discord", + }); return { stop: stopResult, start: startResult, @@ -1287,7 +1361,9 @@ function DiscordGatewayDebugPanel() {
{busyAction && ( -
正在执行:{busyAction}
+
+ 正在执行:{busyAction} +
)}
@@ -1311,7 +1387,10 @@ function DiscordForm({ config: DiscordBotConfig; onChange: (c: DiscordBotConfig) => void; }) { - const accountIds = useMemo(() => Object.keys(config.accounts ?? {}), [config.accounts]); + const accountIds = useMemo( + () => Object.keys(config.accounts ?? {}), + [config.accounts], + ); const [activeAccountId, setActiveAccountId] = useState( config.default_account || accountIds[0] || "default", ); @@ -1332,9 +1411,9 @@ function DiscordForm({ const patchAccount = ( accountId: string, - updater: (current: NonNullable[string]) => NonNullable< - DiscordBotConfig["accounts"] - >[string], + updater: ( + current: NonNullable[string], + ) => NonNullable[string], ) => { const baseAccounts = config.accounts ?? {}; const current = baseAccounts[accountId] ?? { enabled: true }; @@ -1375,7 +1454,9 @@ function DiscordForm({ patch({ accounts: baseAccounts, default_account: - config.default_account === accountId ? (nextIds[0] || "default") : config.default_account, + config.default_account === accountId + ? nextIds[0] || "default" + : config.default_account, }); setActiveAccountId(nextIds[0] || "default"); }; @@ -1395,7 +1476,9 @@ function DiscordForm({

启用 Discord Bot

-

开启后可通过 Discord Bot 与 AI 对话

+

+ 开启后可通过 Discord Bot 与 AI 对话 +

+
+
+
+ {environment.variables.length === 0 ? ( +
+ 暂无显式环境变量覆盖,当前仅使用 Shell + 导入与业务模块推导的运行时变量。 +
+ ) : ( + environment.variables.map((entry, index) => ( +
+
+ + + updateVariable(index, { key: event.target.value }) + } + placeholder="例如 OPENAI_BASE_URL" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+
+ + + updateVariable(index, { value: event.target.value }) + } + placeholder="变量值" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+
+
+ + + updateVariable(index, { enabled: checked }) + } + /> +
+
+
+ +
+
+ )) + )} +
+ + +
+
+
+
+
+ +

生效预览

+
+

+ 展示当前统一环境层最终提供给运行时的关键变量,以及它们的来源。 +

+
+ +
+
+
+ {!preview || preview.entries.length === 0 ? ( +
+ 暂无可预览的环境变量。 +
+ ) : ( +
+
+ 变量 + 当前值 + 来源 +
+
+ {preview.entries.map((entry) => ( +
+
{entry.key}
+
+
+ {showValues || !entry.sensitive + ? entry.value + : entry.maskedValue} +
+ {entry.overriddenSources.length > 0 ? ( +
+ 已覆盖来源: + {entry.overriddenSources + .map(formatSourceLabel) + .join("、")} +
+ ) : null} +
+
+ + {entry.sourceLabel} + +
+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx b/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx index 482426201..ff5f472b1 100644 --- a/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx +++ b/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx @@ -11,29 +11,15 @@ */ import { useState, useEffect } from "react"; -import { safeInvoke } from "@/lib/dev-bridge"; import { Bug } from "lucide-react"; - -interface UpdateCheckConfig { - enabled: boolean; - check_interval_hours: number; - show_notification: boolean; - last_check_timestamp: number; - skipped_version: string | null; - remind_later_until: number | null; -} - -interface UpdateNotificationMetrics { - shown_count: number; - update_now_count: number; - remind_later_count: number; - skip_version_count: number; - dismiss_count: number; - update_now_rate: number; - remind_later_rate: number; - skip_version_rate: number; - dismiss_rate: number; -} +import { + getUpdateCheckSettings, + getUpdateNotificationMetrics, + setUpdateCheckSettings, + testUpdateWindow, + type UpdateCheckConfig, + type UpdateNotificationMetrics, +} from "@/lib/api/appUpdate"; /** * 更新检查设置组件 @@ -67,15 +53,11 @@ export function UpdateCheckSettings() { const loadSettings = async () => { try { - const configResult = await safeInvoke( - "get_update_check_settings", - ); + const configResult = await getUpdateCheckSettings(); setSettings(configResult); try { - const metricsResult = await safeInvoke( - "get_update_notification_metrics", - ); + const metricsResult = await getUpdateNotificationMetrics(); setMetrics(metricsResult); } catch (metricsError) { console.error("加载更新提醒指标失败:", metricsError); @@ -89,7 +71,7 @@ export function UpdateCheckSettings() { const saveSettings = async (newSettings: UpdateCheckConfig) => { try { - await safeInvoke("set_update_check_settings", { settings: newSettings }); + await setUpdateCheckSettings(newSettings); setSettings(newSettings); } catch (error) { console.error("保存更新检查设置失败:", error); @@ -234,12 +216,14 @@ export function UpdateCheckSettings() {
提醒转化指标
- 展示 {metrics.shown_count} 次,立即更新 {metrics.update_now_count} 次( + 展示 {metrics.shown_count} 次,立即更新 {metrics.update_now_count}{" "} + 次( {metrics.update_now_rate}%)
- 稍后 {metrics.remind_later_count} 次({metrics.remind_later_rate}%),跳过{" "} - {metrics.skip_version_count} 次({metrics.skip_version_rate}%) + 稍后 {metrics.remind_later_count} 次({metrics.remind_later_rate} + %),跳过 {metrics.skip_version_count} 次( + {metrics.skip_version_rate}%)
关闭 {metrics.dismiss_count} 次({metrics.dismiss_rate}%) @@ -251,7 +235,7 @@ export function UpdateCheckSettings() {
- + = {}): Skill { description: "A test skill", directory: "test-skill", installed: false, + sourceKind: "other", ...overrides, }; } @@ -36,6 +38,7 @@ describe("getSkillSource", () => { * Property 4: Source Classification Logic * * *For any* Skill object, the source classification SHALL return: + * - "builtin" if sourceKind="builtin" * - "official" if repoOwner="proxycast" AND repoName="skills" * - "community" if repoOwner and repoName are present but not proxycast/skills * - "local" if repoOwner or repoName is missing @@ -129,16 +132,27 @@ describe("getSkillSource", () => { repoName: repoName ?? undefined, }); const source = getSkillSource(skill); - expect(["official", "community", "local"]).toContain(source); + expect(["builtin", "official", "community", "local"]).toContain(source); }, ); }); }); describe("canViewLocalSkillContent", () => { + it("内置且已安装 skill 应可查看内容", () => { + const skill = createSkill({ + installed: true, + sourceKind: "builtin", + repoOwner: undefined, + repoName: undefined, + }); + expect(canViewLocalSkillContent(skill)).toBe(true); + }); + it("本地且已安装 skill 应可查看内容", () => { const skill = createSkill({ installed: true, + sourceKind: "other", repoOwner: undefined, repoName: undefined, }); @@ -148,6 +162,7 @@ describe("canViewLocalSkillContent", () => { it("本地但未安装 skill 不可查看内容", () => { const skill = createSkill({ installed: false, + sourceKind: "other", repoOwner: undefined, repoName: undefined, }); @@ -157,9 +172,30 @@ describe("canViewLocalSkillContent", () => { it("非本地已安装 skill 不可查看内容", () => { const skill = createSkill({ installed: true, + sourceKind: "other", repoOwner: "proxycast", repoName: "skills", }); expect(canViewLocalSkillContent(skill)).toBe(false); }); }); + +describe("canManageSkillInstallation", () => { + it("内置 skill 不应显示安装或卸载入口", () => { + const skill = createSkill({ sourceKind: "builtin" }); + expect(canManageSkillInstallation(skill)).toBe(false); + }); + + it("其他 skill 应保留安装或卸载入口", () => { + const skill = createSkill({ sourceKind: "other" }); + expect(canManageSkillInstallation(skill)).toBe(true); + }); +}); +it("内置技能应优先返回 'builtin'", () => { + const skill = createSkill({ + sourceKind: "builtin", + repoOwner: "proxycast", + repoName: "skills", + }); + expect(getSkillSource(skill)).toBe("builtin" as SkillSource); +}); diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx index 86e641c2f..d5297b846 100644 --- a/src/components/skills/SkillCard.tsx +++ b/src/components/skills/SkillCard.tsx @@ -4,9 +4,9 @@ * * 功能: * - 显示 Skill 基本信息(名称、描述、来源) - * - 安装/卸载操作按钮 + * - 安装/卸载操作按钮(非内置) * - 执行按钮(仅已安装的 Skill 显示) - * - 查看内容按钮(仅本地且已安装的 Skill 显示) + * - 查看内容按钮(内置或本地且已安装的 Skill 显示) * - GitHub 链接按钮 * * @module components/skills @@ -25,11 +25,12 @@ import type { Skill } from "@/lib/api/skills"; /** * Skill 来源类型 + * - builtin: ProxyCast 内置技能 * - official: 来自 proxycast/skills 官方仓库 * - community: 来自其他 GitHub 仓库 * - local: 本地安装,无仓库信息 */ -export type SkillSource = "official" | "community" | "local"; +export type SkillSource = "builtin" | "official" | "community" | "local"; /** * 判断 Skill 的来源类型 @@ -38,12 +39,16 @@ export type SkillSource = "official" | "community" | "local"; * @returns SkillSource - 来源类型 * * 分类规则: + * - "builtin": sourceKind="builtin" * - "official": repoOwner="proxycast" AND repoName="skills" * - "community": repoOwner 和 repoName 存在但不是 proxycast/skills * - "local": repoOwner 或 repoName 缺失 */ // eslint-disable-next-line react-refresh/only-export-components export function getSkillSource(skill: Skill): SkillSource { + if (skill.sourceKind === "builtin") { + return "builtin"; + } if (!skill.repoOwner || !skill.repoName) { return "local"; } @@ -56,14 +61,28 @@ export function getSkillSource(skill: Skill): SkillSource { /** * 是否可查看本地 Skill 内容 * - * 仅本地且已安装的 Skill 支持查看 SKILL.md。 + * 仅内置或本地且已安装的 Skill 支持查看 SKILL.md。 * * @param skill - Skill 对象 * @returns 是否显示查看内容入口 */ // eslint-disable-next-line react-refresh/only-export-components export function canViewLocalSkillContent(skill: Skill): boolean { - return skill.installed && getSkillSource(skill) === "local"; + const source = getSkillSource(skill); + return skill.installed && (source === "builtin" || source === "local"); +} + +/** + * 是否允许用户安装或卸载 Skill + * + * 内置 Skill 默认可用,不提供安装/卸载入口。 + * + * @param skill - Skill 对象 + * @returns 是否显示安装/卸载操作 + */ +// eslint-disable-next-line react-refresh/only-export-components +export function canManageSkillInstallation(skill: Skill): boolean { + return skill.sourceKind !== "builtin"; } /** @@ -71,6 +90,11 @@ export function canViewLocalSkillContent(skill: Skill): boolean { */ const sourceConfig: Record = { + builtin: { + label: "内置", + className: + "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400", + }, official: { label: "官方", className: @@ -119,9 +143,9 @@ interface SkillCardProps { * Skill 卡片组件 * * 展示单个 Skill 的信息和操作按钮,包括: - * - 安装/卸载按钮 + * - 安装/卸载按钮(非内置) * - 执行按钮(仅已安装的 Skill 显示) - * - 查看内容按钮(仅本地且已安装的 Skill 显示) + * - 查看内容按钮(仅内置或本地且已安装的 Skill 显示) * - GitHub 链接按钮 * * @param props - 组件属性 @@ -137,8 +161,10 @@ export function SkillCard({ onViewContent, installing, }: SkillCardProps) { + const canManageInstallation = canManageSkillInstallation(skill); + const handleAction = () => { - if (installing) return; + if (installing || !canManageInstallation) return; if (skill.installed) { onUninstall(skill.directory); } else { @@ -169,7 +195,9 @@ export function SkillCard({ }; const source = getSkillSource(skill); - const showViewContent = Boolean(onViewContent && canViewLocalSkillContent(skill)); + const showViewContent = Boolean( + onViewContent && canViewLocalSkillContent(skill), + ); return (
@@ -197,36 +225,38 @@ export function SkillCard({

- + {canManageInstallation && ( + + )} {/* 执行按钮 - 仅已安装的 Skill 显示 */} {skill.installed && onExecute && ( @@ -241,7 +271,7 @@ export function SkillCard({ )} - {/* 查看内容按钮 - 仅本地且已安装的 Skill 显示 */} + {/* 查看内容按钮 - 仅内置或本地且已安装的 Skill 显示 */} {showViewContent && (