feat: release v0.84.0 with full pending changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
coso
2026-03-12 00:34:23 +08:00
co-authored by Claude Opus 4.6
parent bfacf764b7
commit 2aa83ee7fc
268 changed files with 21727 additions and 6006 deletions
+50
View File
@@ -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"
+2 -1
View File
@@ -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/
+31 -26
View File
@@ -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
+4
View File
@@ -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
+33 -25
View File
@@ -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",
);
```
## 相关文档
+41 -35
View File
@@ -82,6 +82,7 @@ export function generateContentCreationPrompt(
</write_file>
**重要规则**:
- 标签前:先写一句引导语
- 标签后:写完成总结
- 标签内的内容会实时流式显示在右侧画布
@@ -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
- 后续消息不再注入(避免重复)
+190
View File
@@ -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. 如果无法在本次改动中完成收口,至少要建立守卫,阻止问题继续扩散。
## 一句话总结
**治理不是继续写一个“更新的版本”,而是让系统以后只能向一个版本收敛。**
+36 -36
View File
@@ -91,13 +91,13 @@ pub struct AgentConstraints {
pub trait ToolExecutor: Send + Sync {
/// 执行工具
async fn execute(&self, input: ToolInput) -> Result<ToolOutput, ToolError>;
/// 工具名称
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<AgentResponse, AgentError> {
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<ProviderTool>;
}
@@ -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<StreamEvent> {
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<dyn ToolExecutor>) {
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<AgentState>((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 功能的架构设计,随着开发进展会持续更新。_
+839 -1
View File
@@ -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",
},
},
];
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.83.2",
"version": "0.84.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
+434 -278
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -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"
@@ -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<PathBuf, String> {
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<String> {
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<Option<PathBuf>, 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<Option<String>, 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::<Vec<_>>()
.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<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvOverrideGuard {
previous: Option<OsString>,
}
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");
}
}
+148 -22
View File
@@ -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<T: serde::Serialize>(result: &T) -> ExtractedToolRes
}
}
fn extract_tool_result_metadata<T: serde::Serialize>(
result: &T,
) -> Option<std::collections::HashMap<String, serde_json::Value>> {
fn find_metadata(
value: &serde_json::Value,
depth: usize,
) -> Option<std::collections::HashMap<String, serde_json::Value>> {
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub images: Option<Vec<TauriToolImage>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
}
/// Token 使用量
@@ -610,6 +667,8 @@ pub enum TauriMessageContent {
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
images: Option<Vec<TauriToolImage>>,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
},
#[serde(rename = "action_required")]
@@ -637,11 +696,12 @@ pub fn convert_agent_event(event: AgentEvent) -> Vec<TauriAgentEvent> {
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<TauriAgentEvent> {
}
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<TauriAgentEvent> {
}
},
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<TauriAgentEvent> {
output,
error,
images,
metadata,
},
});
}
@@ -825,32 +902,41 @@ fn convert_message_content(content: &MessageContent) -> Option<TauriMessageConte
MessageContent::Thinking(thinking) => 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<TauriMessageConte
output,
error,
images,
metadata,
})
}
MessageContent::ActionRequired(action) => {
@@ -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"))
);
}
}
+7
View File
@@ -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;
+16 -4
View File
@@ -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 操作
@@ -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)
+166 -11
View File
@@ -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<SessionDe
let messages =
AgentDao::get_messages(&conn, session_id).map_err(|e| format!("获取消息失败: {e}"))?;
let tauri_messages: Vec<TauriMessage> = 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<TauriMessageContent> {
}
/// 将 AgentMessage 转换为 TauriMessage
fn convert_agent_message(message: &AgentMessage) -> TauriMessage {
fn convert_agent_messages(
messages: &[AgentMessage],
model_name: Option<&str>,
) -> Vec<TauriMessage> {
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<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvGuard {
values: Vec<(&'static str, Option<OsString>)>,
}
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));
}
}
@@ -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<String, Value>,
}
#[derive(Debug, Clone, Default)]
pub struct HistoryToolIoEvictionPlan {
pub request_ids: HashSet<String>,
pub response_ids: HashSet<String>,
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::<String>();
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<usize> {
names
.iter()
.find_map(|name| std::env::var(name).ok())
.and_then(|value| value.trim().parse::<usize>().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::<f64>().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<PathBuf, String> {
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<OffloadInfo, String> {
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<HashMap<String, Value>>,
extra: HashMap<String, Value>,
) -> HashMap<String, Value> {
let mut merged = base.unwrap_or_default();
merged.extend(extra);
merged
}
fn scalar_or_short_value(value: &Value) -> Option<Value> {
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<ToolIoOffloadTrigger> {
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<ToolIoOffloadTrigger> {
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<String, Value> {
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<HashMap<String, Value>>,
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<T: Serialize>(
key: &str,
preview_source: &str,
payload: &T,
metadata: Option<HashMap<String, Value>>,
) -> 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<HashMap<String, Value>>,
) -> 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<HashMap<String, Value>>,
) -> 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::<Value>(trimmed).unwrap_or_else(|_| json!({ "raw": arguments }))
}
fn estimate_message_tokens(
message: &AgentMessage,
policy: ToolIoEvictionPolicy,
) -> (usize, Vec<HistoryEvictionCandidate>) {
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<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvGuard {
values: Vec<(&'static str, Option<OsString>)>,
}
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);
}
}
+11 -10
View File
@@ -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,
+70
View File
@@ -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<EnvironmentVariableOverride>,
}
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")]
@@ -218,13 +218,40 @@ impl GeneralChatDao {
limit: Option<i32>,
before_id: Option<&str>,
) -> Result<Vec<ChatMessage>, 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");
}
}
+1
View File
@@ -60,6 +60,7 @@ pub mod event_emit;
// 网络工具
pub mod network;
pub mod openclaw_install;
// 凭证清理(敏感信息过滤)
pub mod sanitizer;
+8 -1
View File
@@ -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};
@@ -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<String>,
pub installed: bool,
#[serde(rename = "sourceKind")]
pub source_kind: SkillSourceKind,
#[serde(rename = "repoOwner", skip_serializing_if = "Option::is_none")]
pub repo_owner: Option<String>,
#[serde(rename = "repoName", skip_serializing_if = "Option::is_none")]
@@ -103,6 +137,18 @@ pub fn get_default_skill_repos() -> Vec<SkillRepo> {
]
}
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<String, SkillState>;
#[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
);
}
}
@@ -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<String> {
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<PathBuf>) -> Option<PathBuf> {
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<SemVer>)>,
min_version: SemVer,
) -> Option<PathBuf> {
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"
)
);
}
}
+1
View File
@@ -23,6 +23,7 @@ serde_yaml.workspace = true
# 异步运行时
tokio.workspace = true
async-trait.workspace = true
futures.workspace = true
# 错误处理
thiserror.workspace = true
@@ -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<String>,
_model_config: Option<ModelConfig>,
model_config: Option<ModelConfig>,
) -> 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");
}
}
+65 -1
View File
@@ -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<String, SkillState>,
) -> Result<Vec<Skill>> {
let mut all_skills: HashMap<String, Skill> = 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<Skill> = 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)]
+68 -1
View File
@@ -23,6 +23,8 @@ pub struct ProxyCastScheduler {
inner: proxycast_agent::subagent_scheduler::ProxyCastScheduler,
/// Tauri AppHandle
app_handle: Option<AppHandle>,
/// 调度事件归属的会话 ID
event_session_id: Option<String>,
}
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<String>) -> 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<SchedulerConfig>) {
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);
}
}
+82 -2
View File
@@ -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<crate::services::environment_service::EnvironmentPreview, String> {
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<String, String> {
@@ -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);
}
}
+9 -2
View File
@@ -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,
+9 -2
View File
@@ -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);
// 保存会话到数据库
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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<SkillService>,
app_type: &AppType,
_refresh_remote: bool,
) -> Result<Vec<Skill>, 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<Vec<Skill>, 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<bool, String> {
skill_service.0.refresh_cache();
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -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) {
+11 -10
View File
@@ -40,8 +40,12 @@ pub async fn init_subagent_scheduler(
db: State<'_, DbConnection>,
state: State<'_, SubAgentSchedulerState>,
config: Option<SchedulerConfig>,
session_id: Option<String>,
) -> 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<SubAgentTask>,
config: Option<SchedulerConfig>,
role: Option<SubAgentRole>,
session_id: Option<String>,
) -> Result<SchedulerExecutionResult, String> {
// 确保调度器已初始化
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
+10 -2
View File
@@ -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,
+17 -3
View File
@@ -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,
);
+3
View File
@@ -195,6 +195,7 @@ fn arb_config() -> impl Strategy<Value = Config> {
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<Value = Config> {
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<Value = Config> {
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(),
+45 -12
View File
@@ -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
@@ -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<Mutex<HashMap<String, BTreeSet<String>>>> = OnceLock::new();
static BASELINE_ENV_REGISTRY: OnceLock<Mutex<HashMap<String, Option<String>>>> = 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<u64>,
}
#[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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvironmentPreview {
pub shell_import: ShellImportPreview,
pub entries: Vec<EnvironmentPreviewEntry>,
}
#[derive(Debug, Clone)]
struct ShellImportResult {
env: BTreeMap<String, String>,
preview: ShellImportPreview,
}
#[derive(Debug, Clone)]
struct EffectiveEnvironmentResolution {
env: BTreeMap<String, String>,
shell_import: ShellImportPreview,
sources: HashMap<String, String>,
overridden_sources: HashMap<String, Vec<String>>,
}
fn managed_registry() -> &'static Mutex<HashMap<String, BTreeSet<String>>> {
APPLIED_ENV_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
fn baseline_registry() -> &'static Mutex<HashMap<String, Option<String>>> {
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<char> = 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<String, String> {
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<String, String> {
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<String, String>, key: &str, value: &Option<String>| {
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::<Vec<_>>();
let valid_engine_names: std::collections::HashSet<String> = 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::<Vec<_>>()
} else {
web_search
.multi_search
.priority
.iter()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty() && valid_engine_names.contains(name))
.collect::<Vec<_>>()
};
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<String, String>,
overridden_sources: &mut HashMap<String, Vec<String>>,
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<Vec<u8>, 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<String, String> {
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::<Vec<_>>()
} else {
text.lines().map(str::to_string).collect::<Vec<_>>()
};
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::<BTreeSet<_>>();
let derived_keys = build_web_search_runtime_env(config)
.into_keys()
.filter(|key| {
DERIVED_PREVIEW_KEYS
.iter()
.any(|candidate| candidate == key)
})
.collect::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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<String, String>) {
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::<BTreeSet<_>>();
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"));
}
}
@@ -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<String> {
let trimmed = input.trim();
@@ -79,13 +80,6 @@ pub fn build_memory_profile_prompt(config: &Config) -> Option<String> {
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<String>,
config: &Config,
working_dir: &Path,
active_relative_path: Option<&str>,
) -> Option<String> {
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("偏好简洁输出"));
}
}
@@ -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<PathBuf>,
output: &mut Vec<EffectiveMemorySource>,
prompt_segments: &mut Vec<String>,
) {
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<PathBuf>,
output: &mut Vec<EffectiveMemorySource>,
prompt_segments: &mut Vec<String>,
) {
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<PathBuf>,
output: &mut Vec<EffectiveMemorySource>,
prompt_segments: &mut Vec<String>,
) {
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<Vec<PathBuf>, 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<PathBuf>,
) -> 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::<Vec<_>>();
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<PathBuf> {
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<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct DurableMemoryEnvGuard {
previous: Option<OsString>,
}
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")));
}
}
+1
View File
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -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<WebSearchProvider> {
vec![
WebSearchProvider::Tavily,
WebSearchProvider::MultiSearchEngine,
WebSearchProvider::BingSearchApi,
WebSearchProvider::GoogleCustomSearch,
WebSearchProvider::DuckduckgoInstant,
]
}
fn normalize_text(value: &Option<String>) -> Option<String> {
value
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
fn push_provider_unique(target: &mut Vec<WebSearchProvider>, provider: WebSearchProvider) {
if !target.contains(&provider) {
target.push(provider);
}
}
fn resolve_provider_priority(web_search: &WebSearchConfig) -> Vec<WebSearchProvider> {
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<serde_json::Value> {
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<String>) {
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::<Vec<_>>()
.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::<Vec<_>>()
} else {
web_search
.multi_search
.priority
.iter()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.collect::<Vec<_>>()
};
let engines = web_search
.multi_search
.engines
.iter()
.filter_map(normalize_engine_entry)
.collect::<Vec<_>>();
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::<Vec<_>>();
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());
}
}
+23 -26
View File
@@ -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()),
"内置版本更新时应自动升级"
);
+1 -1
View File
@@ -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",
+5 -27
View File
@@ -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<WindowsStartupDiagnostics>(
"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",
}}
+2 -1
View File
@@ -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"),
},
{
+8 -10
View File
@@ -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";
@@ -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",
@@ -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: () => <div data-testid="project-selector" />,
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
children,
onClick,
disabled,
...rest
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
[key: string]: unknown;
}) => (
<button type="button" onClick={onClick} disabled={disabled} {...rest}>
{children}
</button>
),
}));
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<React.ComponentProps<typeof ChatNavbar>>,
) {
const defaultProps: React.ComponentProps<typeof ChatNavbar> = {
isRunning: false,
onToggleHistory: vi.fn(),
onToggleFullscreen: vi.fn(),
};
return mount(<ChatNavbar {...defaultProps} {...props} />);
}
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 (
<>
<ChatNavbar
isRunning={false}
onToggleHistory={() => {}}
onToggleFullscreen={() => {}}
showHarnessToggle
harnessPanelVisible={visible}
onToggleHarnessPanel={() => setVisible((current) => !current)}
/>
{visible ? (
<div data-testid="harness-panel">Harness Panel</div>
) : null}
</>
);
}
const container = mount(<HarnessToggleHarness />);
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();
});
});
@@ -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<ChatNavbarProps> = ({
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<ChatNavbarProps> = ({
className="h-8 text-xs min-w-[160px] max-w-[220px]"
/>
{showHarnessToggle ? (
<Button
type="button"
variant={harnessPanelVisible ? "secondary" : "outline"}
size="sm"
className={cn(
"h-8 gap-1.5 px-3 text-xs",
harnessAttentionLevel === "warning" &&
!harnessPanelVisible &&
"border-amber-300 text-amber-700 hover:text-amber-800",
)}
onClick={onToggleHarnessPanel}
aria-label={
harnessPanelVisible ? "收起 Harness 面板" : "展开 Harness 面板"
}
aria-expanded={harnessPanelVisible}
title={
harnessPanelVisible ? "收起 Harness 面板" : "展开 Harness 面板"
}
>
<Sparkles size={14} />
<span>Harness</span>
{harnessPendingCount > 0 ? (
<span className="rounded-full bg-destructive px-1.5 py-0.5 text-[10px] font-medium leading-none text-destructive-foreground">
{harnessPendingCount > 99 ? "99+" : harnessPendingCount}
</span>
) : null}
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform",
harnessPanelVisible && "rotate-180",
)}
/>
</Button>
) : null}
<Button
variant="ghost"
size="icon"
@@ -16,7 +16,7 @@ import {
Music,
Code2,
} from "lucide-react";
import { getConfig } from "@/hooks/useTauri";
import { getConfig } from "@/lib/api/appConfig";
import type { CreationMode, EntryTaskSlotValues, EntryTaskType } from "./types";
import { CREATION_MODE_CONFIG } from "./constants";
import { Button } from "@/components/ui/button";
@@ -543,8 +543,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const [enabledThemes, setEnabledThemes] = useState<string[]>(
DEFAULT_ENABLED_THEMES,
);
const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] =
useState(true);
const [
appendSelectedTextToRecommendation,
setAppendSelectedTextToRecommendation,
] = useState(true);
// 加载配置
useEffect(() => {
@@ -555,8 +557,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
setEnabledThemes(loadedConfig.content_creator.enabled_themes);
}
setAppendSelectedTextToRecommendation(
loadedConfig.chat_appearance?.append_selected_text_to_recommendation ??
true,
loadedConfig.chat_appearance
?.append_selected_text_to_recommendation ?? true,
);
} catch (e) {
console.error("加载主题配置失败:", e);
@@ -618,10 +620,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
if (wrappedByActiveSkill !== text) {
return wrappedByActiveSkill;
}
if (
activeTheme === "social-media" &&
!text.trimStart().startsWith("/")
) {
if (activeTheme === "social-media" && !text.trimStart().startsWith("/")) {
return `/${SOCIAL_ARTICLE_SKILL_KEY} ${text}`.trim();
}
return text;
@@ -854,7 +853,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
<ContentWrapper>
<Header>
<MainTitle>
{themeHeadline.lead}<span>{themeHeadline.focus}</span>
{themeHeadline.lead}
<span>{themeHeadline.focus}</span>
</MainTitle>
</Header>
@@ -1260,9 +1260,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
? "border-yellow-500 text-yellow-600 bg-yellow-50 dark:bg-yellow-950/30"
: ""
}`}
onClick={() =>
onThinkingEnabledChange?.(!thinkingEnabled)
}
onClick={() => onThinkingEnabledChange?.(!thinkingEnabled)}
aria-pressed={thinkingEnabled}
title={thinkingEnabled ? "关闭深度思考" : "开启深度思考"}
>
@@ -1279,9 +1277,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
? "border-blue-500 text-blue-600 bg-blue-50 dark:bg-blue-950/30"
: ""
}`}
onClick={() =>
onWebSearchEnabledChange?.(!webSearchEnabled)
}
onClick={() => onWebSearchEnabledChange?.(!webSearchEnabled)}
aria-pressed={webSearchEnabled}
title={webSearchEnabled ? "关闭联网搜索" : "开启联网搜索"}
>
@@ -1347,7 +1343,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
{selectedTextPreview && (
<div className="w-full max-w-[800px] text-xs text-muted-foreground bg-muted/30 border border-border/70 rounded-lg px-3 py-2">
已检测到选中内容,点击推荐会自动附带上下文:
<span className="ml-1 text-foreground">“{selectedTextPreview}”</span>
<span className="ml-1 text-foreground">
“{selectedTextPreview}”
</span>
</div>
)}
<div className="w-full max-w-[800px] flex flex-wrap gap-3 justify-center">
@@ -0,0 +1,514 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HarnessStatusPanel } from "./HarnessStatusPanel";
import type { HarnessSessionState } from "../utils/harnessState";
const { mockToast } = vi.hoisted(() => ({
mockToast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}));
vi.mock("sonner", () => ({
toast: mockToast,
}));
interface RenderResult {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: RenderResult[] = [];
let originalClipboard: Clipboard | undefined;
function createHarnessState(
overrides: Partial<HarnessSessionState> = {},
): HarnessSessionState {
return {
pendingApprovals: [],
latestContextTrace: [],
plan: {
phase: "idle",
items: [],
},
activity: {
planning: 0,
filesystem: 1,
execution: 0,
web: 0,
skills: 0,
delegation: 0,
},
delegatedTasks: [],
outputSignals: [],
recentFileEvents: [],
hasSignals: true,
...overrides,
};
}
function renderPanel(
overrides: Partial<ComponentProps<typeof HarnessStatusPanel>> = {},
): RenderResult {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<HarnessStatusPanel
harnessState={createHarnessState()}
subAgentRuntime={{
isRunning: false,
progress: null,
events: [],
result: null,
error: null,
}}
environment={{
skillsCount: 2,
skillNames: ["read_file", "write_todos"],
memorySignals: ["风格"],
contextItemsCount: 2,
activeContextCount: 1,
contextItemNames: ["需求.md"],
contextEnabled: true,
}}
{...overrides}
/>,
);
});
const rendered = { container, root };
mountedRoots.push(rendered);
return rendered;
}
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
originalClipboard = navigator.clipboard;
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: originalClipboard,
});
vi.clearAllMocks();
});
describe("HarnessStatusPanel", () => {
it("摘要卡和快速导航应支持跳转到对应区块", () => {
const scrollIntoViewMock = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
renderPanel({
harnessState: createHarnessState({
pendingApprovals: [
{
requestId: "approval-1",
actionType: "tool_confirmation",
prompt: "确认写入",
},
],
recentFileEvents: [
{
id: "event-nav-1",
toolCallId: "tool-nav-1",
path: "/tmp/workspace/nav.md",
displayName: "nav.md",
kind: "document",
action: "write",
sourceToolName: "Write",
timestamp: new Date("2026-03-11T12:00:00.000Z"),
preview: "导航预览",
clickable: true,
},
],
}),
});
const summaryJumpButton = document.body.querySelector(
'button[aria-label="跳转到待审批"]',
) as HTMLButtonElement | null;
act(() => {
summaryJumpButton?.click();
});
expect(scrollIntoViewMock).toHaveBeenCalled();
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
});
it("应渲染最近文件活动区块", () => {
renderPanel({
harnessState: createHarnessState({
recentFileEvents: [
{
id: "event-1",
toolCallId: "tool-1",
path: "/tmp/workspace/draft.md",
displayName: "draft.md",
kind: "document",
action: "write",
sourceToolName: "Write",
timestamp: new Date("2026-03-11T12:00:00.000Z"),
preview: "# 草稿\n这是预览",
clickable: true,
},
],
}),
});
expect(document.body.textContent).toContain("最近文件活动");
expect(document.body.textContent).toContain("draft.md");
expect(document.body.textContent).toContain("写入");
expect(document.body.textContent).toContain("这是预览");
});
it("点击文件活动后应加载并展示预览内容", async () => {
const onLoadFilePreview = vi.fn().mockResolvedValue({
path: "/tmp/workspace/draft.md",
content: "# 标题\n正文内容",
isBinary: false,
size: 18,
error: null,
});
const onOpenFile = vi.fn();
renderPanel({
harnessState: createHarnessState({
recentFileEvents: [
{
id: "event-2",
toolCallId: "tool-2",
path: "/tmp/workspace/draft.md",
displayName: "draft.md",
kind: "document",
action: "read",
sourceToolName: "Read",
timestamp: new Date("2026-03-11T12:01:00.000Z"),
preview: "摘要预览",
clickable: true,
},
],
}),
onLoadFilePreview,
onOpenFile,
});
const trigger = document.body.querySelector(
'button[aria-label="查看文件活动:draft.md"]',
) as HTMLButtonElement | null;
await act(async () => {
trigger?.click();
await Promise.resolve();
});
expect(onLoadFilePreview).toHaveBeenCalledWith("/tmp/workspace/draft.md");
expect(document.body.textContent).toContain("# 标题");
expect(document.body.textContent).toContain("正文内容");
const openInChatButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("在会话中打开"));
act(() => {
openInChatButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(onOpenFile).toHaveBeenCalledWith(
"/tmp/workspace/draft.md",
"# 标题\n正文内容",
);
});
it("应支持按类型筛选最近文件活动", () => {
renderPanel({
harnessState: createHarnessState({
recentFileEvents: [
{
id: "event-filter-doc",
toolCallId: "tool-filter-doc",
path: "/tmp/workspace/spec.md",
displayName: "spec.md",
kind: "document",
action: "write",
sourceToolName: "Write",
timestamp: new Date("2026-03-11T12:10:00.000Z"),
preview: "需求说明",
clickable: true,
},
{
id: "event-filter-code",
toolCallId: "tool-filter-code",
path: "/tmp/workspace/app.ts",
displayName: "app.ts",
kind: "code",
action: "edit",
sourceToolName: "Edit",
timestamp: new Date("2026-03-11T12:11:00.000Z"),
preview: "const app = true;",
clickable: true,
},
{
id: "event-filter-log",
toolCallId: "tool-filter-log",
path: "/tmp/workspace/run.log",
displayName: "run.log",
kind: "log",
action: "persist",
sourceToolName: "Execute",
timestamp: new Date("2026-03-11T12:12:00.000Z"),
preview: "执行完成",
clickable: true,
},
],
}),
});
const codeFilterButton = document.body.querySelector(
'button[aria-label="文件活动筛选:代码"]',
) as HTMLButtonElement | null;
act(() => {
codeFilterButton?.click();
});
const fileSection = document.body.querySelector(
'[data-harness-section="files"]',
) as HTMLElement | null;
expect(fileSection?.textContent).toContain("app.ts");
expect(fileSection?.textContent).not.toContain("spec.md");
expect(fileSection?.textContent).not.toContain("run.log");
expect(fileSection?.textContent).toContain("1 / 3 条");
});
it("应支持按文件聚合最近文件活动", () => {
renderPanel({
harnessState: createHarnessState({
recentFileEvents: [
{
id: "event-group-1",
toolCallId: "tool-group-1",
path: "/tmp/workspace/draft.md",
displayName: "draft.md",
kind: "document",
action: "write",
sourceToolName: "Write",
timestamp: new Date("2026-03-11T12:20:00.000Z"),
preview: "第一版",
clickable: true,
},
{
id: "event-group-2",
toolCallId: "tool-group-2",
path: "/tmp/workspace/draft.md",
displayName: "draft.md",
kind: "document",
action: "edit",
sourceToolName: "Edit",
timestamp: new Date("2026-03-11T12:21:00.000Z"),
preview: "第二版",
clickable: true,
},
{
id: "event-group-3",
toolCallId: "tool-group-3",
path: "/tmp/workspace/notes.md",
displayName: "notes.md",
kind: "document",
action: "read",
sourceToolName: "Read",
timestamp: new Date("2026-03-11T12:22:00.000Z"),
preview: "笔记",
clickable: true,
},
],
}),
});
const groupedViewButton = document.body.querySelector(
'button[aria-label="文件视图:按文件"]',
) as HTMLButtonElement | null;
act(() => {
groupedViewButton?.click();
});
const fileSection = document.body.querySelector(
'[data-harness-section="files"]',
) as HTMLElement | null;
const groupedCards = document.body.querySelectorAll(
'button[aria-label^="查看聚合文件活动:"]',
);
expect(groupedCards).toHaveLength(2);
expect(fileSection?.textContent).toContain("2 个文件 / 3 条");
expect(fileSection?.textContent).toContain("draft.md");
expect(fileSection?.textContent).toContain("2 次活动");
expect(fileSection?.textContent).toContain("写入 1");
expect(fileSection?.textContent).toContain("编辑 1");
});
it("应支持按类型筛选工具输出", () => {
renderPanel({
harnessState: createHarnessState({
outputSignals: [
{
id: "signal-path",
toolCallId: "tool-path",
toolName: "read_file",
title: "读取结果",
summary: "返回了输出文件",
outputFile: "/tmp/workspace/output.txt",
},
{
id: "signal-offload",
toolCallId: "tool-offload",
toolName: "write_file",
title: "大结果转存",
summary: "内容已转存",
offloadFile: "/tmp/workspace/offload/result.md",
offloaded: true,
},
{
id: "signal-summary",
toolCallId: "tool-summary",
toolName: "execute",
title: "执行摘要",
summary: "仅保留摘要",
preview: "最后 10 行输出",
},
{
id: "signal-truncated",
toolCallId: "tool-truncated",
toolName: "execute",
title: "截断输出",
summary: "输出过长已截断",
truncated: true,
},
],
}),
});
const summaryFilterButton = document.body.querySelector(
'button[aria-label="工具输出筛选:仅摘要"]',
) as HTMLButtonElement | null;
act(() => {
summaryFilterButton?.click();
});
const outputSection = document.body.querySelector(
'[data-harness-section="outputs"]',
) as HTMLElement | null;
expect(outputSection?.textContent).toContain("执行摘要");
expect(outputSection?.textContent).not.toContain("读取结果");
expect(outputSection?.textContent).not.toContain("大结果转存");
expect(outputSection?.textContent).not.toContain("截断输出");
expect(outputSection?.textContent).toContain("1 / 4 条");
});
it("预览弹窗应支持复制路径和系统文件操作", async () => {
const onLoadFilePreview = vi.fn().mockResolvedValue({
path: "/tmp/workspace/draft.md",
content: "# 标题\n正文内容",
isBinary: false,
size: 18,
error: null,
});
const onRevealPath = vi.fn().mockResolvedValue(undefined);
const onOpenPath = vi.fn().mockResolvedValue(undefined);
renderPanel({
harnessState: createHarnessState({
recentFileEvents: [
{
id: "event-3",
toolCallId: "tool-3",
path: "/tmp/workspace/draft.md",
displayName: "draft.md",
kind: "document",
action: "read",
sourceToolName: "Read",
timestamp: new Date("2026-03-11T12:02:00.000Z"),
preview: "摘要预览",
clickable: true,
},
],
}),
onLoadFilePreview,
onRevealPath,
onOpenPath,
});
const trigger = document.body.querySelector(
'button[aria-label="查看文件活动:draft.md"]',
) as HTMLButtonElement | null;
await act(async () => {
trigger?.click();
await Promise.resolve();
});
const copyPathButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("复制路径"));
const revealButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("定位文件"),
);
const openPathButton = Array.from(
document.body.querySelectorAll("button"),
).find((button) => button.textContent?.includes("系统打开"));
await act(async () => {
copyPathButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
revealButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
openPathButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
await Promise.resolve();
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
"/tmp/workspace/draft.md",
);
expect(onRevealPath).toHaveBeenCalledWith("/tmp/workspace/draft.md");
expect(onOpenPath).toHaveBeenCalledWith("/tmp/workspace/draft.md");
});
});
File diff suppressed because it is too large Load Diff
@@ -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 = /<a2ui|```\s*a2ui|<write_file|<document/i;
@@ -8,7 +8,7 @@
import React from "react";
import styled from "styled-components";
import { Coins } from "lucide-react";
import type { TokenUsage } from "@/lib/api/agent";
import type { TokenUsage } from "@/lib/api/agentStream";
const UsageContainer = styled.div`
display: inline-flex;
@@ -2,7 +2,7 @@ import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ToolCallState } from "@/lib/api/agent";
import type { ToolCallState } from "@/lib/api/agentStream";
import { ToolCallDisplay } from "./ToolCallDisplay";
interface MountedHarness {
@@ -37,7 +37,13 @@ function render(toolCall: ToolCallState): HTMLDivElement {
const root = createRoot(container);
act(() => {
root.render(<ToolCallDisplay toolCall={toolCall} />);
root.render(
<ToolCallDisplay
toolCall={toolCall}
defaultExpanded
isMessageStreaming
/>,
);
});
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",
);
});
});
@@ -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<string, unknown> | 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<ToolCallDisplayProps> = ({
() => 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<ToolCallDisplayProps> = ({
>
Output
</div>
{resultMetaItems.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-2">
{resultMetaItems.map((item) => (
<span
key={item}
className="rounded-full bg-[var(--surface-secondary)] px-2 py-1 text-[11px] text-[var(--ink-600)]"
>
{item}
</span>
))}
</div>
) : null}
{resultPath ? (
<div className="mb-2 break-all text-[11px] text-[var(--ink-600)]">
{resultPath.label}: {resultPath.value}
</div>
) : null}
<pre
className={cn(
"whitespace-pre-wrap font-mono text-xs break-all max-h-40 overflow-y-auto",
@@ -4,19 +4,23 @@ import type { UnlistenFn } from "@tauri-apps/api/event";
import type { Message } from "../types";
import { tryExecuteSlashSkillCommand } from "./skillCommand";
const { mockSafeListen, mockParseStreamEvent, mockListExecutableSkills, mockExecuteSkill } =
vi.hoisted(() => ({
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 () => {
@@ -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(
@@ -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();
}
+27 -12
View File
@@ -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,
);
@@ -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 偏好持久化", () => {
@@ -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<string, unknown> | 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<string, unknown> } => {
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<string, unknown> | 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<string, unknown> = {};
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<string, unknown> | 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<string, unknown>;
}
| 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);
+109 -2
View File
@@ -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: () => <div data-testid="canvas-factory" />,
}));
vi.mock("@/components/general-chat/canvas", () => ({
vi.mock("@/components/general-chat/bridge", () => ({
CanvasPanel: () => <div data-testid="general-canvas" />,
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();
});
});
+226 -31
View File
@@ -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<HarnessFilePreviewResult> => {
const normalizedPath = path.trim();
const createFallbackResult = (
overrides: Partial<HarnessFilePreviewResult> = {},
): 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 ? (
<HarnessStatusPanel
harnessState={harnessState}
subAgentRuntime={subAgentRuntime}
environment={harnessEnvironment}
onLoadFilePreview={handleHarnessLoadFilePreview}
onOpenFile={handleFileClick}
/>
) : null}
{showChatLayout ? (
<ChatContent>
{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,
+2 -2
View File
@@ -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 {
@@ -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> = {}): 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",
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -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;
@@ -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,
);
+22 -18
View File
@@ -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}` });
+1 -1
View File
@@ -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<LogEntry[]>([]);
+1 -1
View File
@@ -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<string, { name: string; color: string }> = {
+8 -140
View File
@@ -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<ThemeType>("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 (
<PageContainer>
<ChatArea>
{error && <ErrorBanner>⚠️ {error}</ErrorBanner>}
{hasMessages ? (
<MessageList
messages={messages}
isGenerating={isGenerating}
onDeleteMessage={handleDeleteMessage}
onRetryMessage={handleRetryMessage}
/>
) : (
<EmptyState
onSuggestionClick={handleSuggestionClick}
activeTheme={currentTheme}
selectedText={selectedText}
/>
)}
</ChatArea>
<InputBar
onSend={sendMessage}
isGenerating={isGenerating}
onStop={stopGeneration}
placeholder={
currentTheme === "general"
? "输入消息,按 Enter 发送..."
: `开始${currentTheme}创作...`
}
/>
{!hasMessages && (
<ThemeSelector
currentTheme={currentTheme}
onThemeChange={handleThemeChange}
/>
)}
</PageContainer>
);
});
export const ChatPage: React.FC = memo(() => <GeneralChatPage />);
ChatPage.displayName = "ChatPage";
+24 -24
View File
@@ -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 <ChatPage />
function Example() {
const chat = useUnifiedChat({ mode: "general" });
return <button onClick={() => void chat.sendMessage("你好")}>发送</button>;
}
```
- 页面层不要新增 `ChatPage` / `GeneralChatPage` 依赖,请走现有工作台或路由入口。
- 新的对话逻辑请优先基于 `@/hooks/useUnifiedChat`。
- `@/components/chat` 模块根入口现仅保留 `ChatPage`、基础消息类型和 `useChat` 兼容导出。
- `@/components/chat/components` 已不再导出任何组件,避免旧 UI 资产继续扩散。
- 如必须兼容旧代码,`@/components/chat` 仍可继续导入,但应尽快迁移到统一对话链路。
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
+15 -15
View File
@@ -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<EmptyStateProps> = memo(
const [localProjectId, setLocalProjectId] = useState<string | null>(
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<EmptyStateProps> = 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,
+6 -9
View File
@@ -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 {};
+1 -2
View File
@@ -1,8 +1,7 @@
/**
* @file Hooks 导出入口
* @description 导出通用对话相关的 Hooks
* @description 遗留通用对话 Hooks 兼容导出
* @module components/chat/hooks
*/
export { useChat } from "./useChat";
export { useStreaming } from "./useStreaming";
+99 -105
View File
@@ -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<Message[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const currentAiMessageIdRef = useRef<string | null>(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,
+12 -57
View File
@@ -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<void> => {
let unlisten: UnlistenFn | null = null;
let isAborted = false;
// 监听中断信号
if (signal) {
signal.addEventListener("abort", () => {
isAborted = true;
unlisten?.();
});
}
try {
// 监听流式响应事件
unlisten = await safeListen<StreamChunkEvent>(
"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 或统一对话链路。",
);
},
[],
);
+3 -4
View File
@@ -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";
+6
View File
@@ -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[] = [
{
+2 -3
View File
@@ -121,8 +121,7 @@ export function LiveConfigModal({ appType, onClose }: LiveConfigModalProps) {
<div className="p-4 rounded-lg bg-muted/30 text-sm text-muted-foreground border border-dashed">
<p className="mb-2">暂无环境变量配置</p>
<p className="text-xs">
💡 提示:切换 Claude 供应商后,ProxyCast 会自动将配置写入
shell 配置文件
💡 提示:这里展示的是兼容外部客户端的 Shell 写入结果;运行时统一环境请以系统设置中的“环境变量”页为准。
</p>
</div>
)}
@@ -140,7 +139,7 @@ export function LiveConfigModal({ appType, onClose }: LiveConfigModalProps) {
<div className="p-4 border-t bg-muted/30">
<p className="text-xs text-muted-foreground">
{appType === "claude" && claudeConfig ? (
<>配置方式:配置文件 + Shell 环境变量(需重启终端生效)</>
<>兼容输出:配置文件 + Shell 环境变量;运行时主入口已统一到系统设置的“环境变量”页</>
) : (
<>
配置文件路径:{" "}
+1 -1
View File
@@ -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 {
@@ -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<VideoWorkspaceProps> = 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<VideoWorkspaceProps> = 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<VideoWorkspaceProps> = 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";
+40 -21
View File
@@ -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<GeneralChatPageProps> = ({
initialSessionId,
onNavigate,
}) => {
const { currentSessionId, selectSession, sessions, createSession } =
const { currentSessionId, selectSession, createSession, hydrateSessions } =
useGeneralChatStore();
// 画布状态
const [canvasState, setCanvasState] =
useState<CanvasState>(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) => {
+14 -5
View File
@@ -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
+12
View File
@@ -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";
+1 -4
View File
@@ -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";
+26 -53
View File
@@ -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<string>("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]);
/**
* 重新生成消息
+22 -83
View File
@@ -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<string, unknown>;
}
/**
* 后端会话详情数据结构
*/
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<BackendSession[]>(
"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<string | null> => {
try {
const _session = await invoke<BackendSession>(
"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<BackendSessionDetail>(
"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<string>("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],
);
// 自动加载会话列表
@@ -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<StreamEvent>(
eventKey,
handleStreamEvent,
);
unlistenRef.current = await safeListen(eventKey, handleStreamEvent);
};
setupListener();
+1 -36
View File
@@ -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";
@@ -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<void>;
/** 重命名会话 */
renameSession: (id: string, name: string) => Promise<void>;
/** 从后端同步会话列表 */
hydrateSessions: (
preferredSessionId?: string | null,
) => Promise<string | null>;
/** 设置会话列表 */
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<string, unknown> | 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>,
): 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<SessionSnapshot> => {
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<string> => {
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<GeneralChatState>()(
(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<GeneralChatState>()(
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<Session>) => {
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<GeneralChatState>()(
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<GeneralChatState>()(
// 更新会话的消息数量和更新时间
get().updateSession(currentSessionId, {
messageCount: currentMessages.length + 1,
messageCount: currentMessages.length + 2,
});
// 创建 AI 响应消息占位符
@@ -591,30 +853,19 @@ export const useGeneralChatStore = create<GeneralChatState>()(
}
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<GeneralChatState>()(
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<GeneralChatState>()(
},
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<GeneralChatState>()(
(m) => m.role === "user",
);
if (firstUserMessage) {
const nextTitle = generateFallbackSessionTitle(
firstUserMessage.content,
);
try {
const { invoke } = await import("@tauri-apps/api/core");
const title = await invoke<string>(
"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<GeneralChatState>()(
},
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<GeneralChatState>()(
if (!userMessage) return;
const retryPayload = extractRetryPayloadFromMessage(userMessage);
// 清除错误状态,将消息状态改为 pending
const updatedMessages = currentMessages.map((m) =>
m.id === messageId
@@ -924,6 +1188,7 @@ export const useGeneralChatStore = create<GeneralChatState>()(
status: "pending" as const,
error: undefined,
content: "",
blocks: [],
}
: m,
);
@@ -935,12 +1200,24 @@ export const useGeneralChatStore = create<GeneralChatState>()(
},
}));
// 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<GeneralChatState>()(
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<string, unknown> | 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 =

Some files were not shown because too many files have changed in this diff Show More