mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
@@ -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
|
||||
- 后续消息不再注入(避免重复)
|
||||
|
||||
|
||||
@@ -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. 如果无法在本次改动中完成收口,至少要建立守卫,阻止问题继续扩散。
|
||||
|
||||
## 一句话总结
|
||||
|
||||
**治理不是继续写一个“更新的版本”,而是让系统以后只能向一个版本收敛。**
|
||||
@@ -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
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.83.2",
|
||||
"version": "0.84.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
Generated
+434
-278
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ pub mod event_emit;
|
||||
|
||||
// 网络工具
|
||||
pub mod network;
|
||||
pub mod openclaw_install;
|
||||
|
||||
// 凭证清理(敏感信息过滤)
|
||||
pub mod sanitizer;
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,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,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 }> = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,8 +1,7 @@
|
||||
/**
|
||||
* @file Hooks 导出入口
|
||||
* @description 导出通用对话相关的 Hooks
|
||||
* @description 遗留通用对话 Hooks 兼容导出
|
||||
* @module components/chat/hooks
|
||||
*/
|
||||
|
||||
export { useChat } from "./useChat";
|
||||
export { useStreaming } from "./useStreaming";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 或统一对话链路。",
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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]);
|
||||
|
||||
/**
|
||||
* 重新生成消息
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user