Compare commits
248 Commits
feature/fr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 121793367a | |||
| cfdec831d8 | |||
| 1d0a7e95fa | |||
| d4c3e73134 | |||
| 2b131de702 | |||
| faef998a9a | |||
| 248f5a4b1a | |||
| 310d82f4b8 | |||
| 3138d23687 | |||
| 11ffdc2bb5 | |||
| 089291f77e | |||
| 4f3ba722a1 | |||
| 69e3ef51b6 | |||
| b2b8013f3a | |||
| f6cbe78d68 | |||
| 57aaf76809 | |||
| a60e164aa2 | |||
| f8f5d26ee3 | |||
| 4e1d08f106 | |||
| 297a6cc5ec | |||
| 7ea4638c8c | |||
| 351cd311c2 | |||
| ce92697181 | |||
| 571ed6c81f | |||
| 0aa37b83ee | |||
| ae70e4902e | |||
| 30bfb79cbd | |||
| 4fcd0ccc4d | |||
| 3c4ae917b2 | |||
| a95e969ba2 | |||
| 58ec936a8c | |||
| e29ccb318e | |||
| fe6fba3958 | |||
| de5160ead0 | |||
| 692748d567 | |||
| 7f2ad9144d | |||
| f652118527 | |||
| 8e5d4f05d9 | |||
| 811974d6e7 | |||
| e8b857f3ae | |||
| c08c4ab141 | |||
| 3585967b40 | |||
| cc8d5817aa | |||
| af56de6812 | |||
| 6618ab03dc | |||
| 5244768494 | |||
| 6b431ed51a | |||
| 3f3662d3a5 | |||
| 8594b43d8a | |||
| 19746e67d3 | |||
| b2cbe0ab5d | |||
| 67292f9fd3 | |||
| 576fbb39d8 | |||
| d2170567e6 | |||
| cb70bc2929 | |||
| f8bba77a60 | |||
| b7a535fc8f | |||
| e3c897947f | |||
| 6a2cb820bc | |||
| 765196ea3c | |||
| 3db4e0beb4 | |||
| 15106e7d00 | |||
| 2264a86851 | |||
| 478d24c4dd | |||
| 7863e497d8 | |||
| 01dfec4c72 | |||
| 4a54d6fbb7 | |||
| 359caca672 | |||
| ae5676cd30 | |||
| dd5e94b294 | |||
| 2b2ff44fcd | |||
| 0a4c75ce45 | |||
| 5ac17cd279 | |||
| 6f47221ed8 | |||
| 737c4d61a2 | |||
| f7a4708a84 | |||
| ed9bbeecb7 | |||
| 38ff51416f | |||
| 72a49a7c8d | |||
| f3e166e258 | |||
| d1a9a51bb8 | |||
| fd29b3b89c | |||
| 86eef988dc | |||
| 6a5ba2d95e | |||
| 626b7eaa52 | |||
| 09a67f20dd | |||
| bb2b98fc82 | |||
| bd9af317e4 | |||
| 4fcc6a1b25 | |||
| 15dea9c2e5 | |||
| 426825d475 | |||
| cc22541560 | |||
| f7b79418f9 | |||
| e4a663594d | |||
| 53b3669f1d | |||
| e90610ed05 | |||
| 2eed4d5b86 | |||
| f03446a3bb | |||
| f44c09a7ce | |||
| b4080f3880 | |||
| b76708de13 | |||
| 2bfbddb0dd | |||
| 8e1a339102 | |||
| 6c368d2aa0 | |||
| f661dc6456 | |||
| 8235c4ce70 | |||
| 7b683ab390 | |||
| 9ed956518c | |||
| a212a0076c | |||
| 2c6d208be7 | |||
| 2766a8ab57 | |||
| 09af37055a | |||
| 56882f3169 | |||
| a6775598dc | |||
| 9e4b0bdb97 | |||
| fd8decc3c3 | |||
| c2d460a706 | |||
| f75e2f07a3 | |||
| c5dde27faa | |||
| f43b5ea73b | |||
| edb557bdd3 | |||
| 55b7b7deea | |||
| f31f8d6fd7 | |||
| ad1e9e1475 | |||
| 1da269e2aa | |||
| ea4e5dc14b | |||
| f43025047e | |||
| 50e49d60ec | |||
| 095e0631c1 | |||
| 55097cb3fc | |||
| 7d716b8b90 | |||
| 2e54f2c343 | |||
| 788753f6fd | |||
| af4ec5c1a8 | |||
| f9d194f100 | |||
| 04128fac14 | |||
| 76e4c5144e | |||
| 0ed262c87b | |||
| ee9ac28f26 | |||
| dcb25dfe89 | |||
| 3abbf30422 | |||
| 4004bceb0c | |||
| 820ec69354 | |||
| 5d2da692b0 | |||
| 960d087db0 | |||
| 6255efc594 | |||
| d24e00b9b3 | |||
| 62e2c08f5a | |||
| 644b609d85 | |||
| ca4bf17639 | |||
| ff7108738e | |||
| d5bf110b88 | |||
| 033bea236c | |||
| 59b81c4551 | |||
| a2a04b9529 | |||
| bd338f7ad4 | |||
| 8d87068ba7 | |||
| 2fcd241d94 | |||
| 4807c0b9f1 | |||
| 0d4f6c9e56 | |||
| 66261e91c6 | |||
| b6818bc0d0 | |||
| 84336b1802 | |||
| d639278182 | |||
| 7c08031413 | |||
| 5723da8eb1 | |||
| 180fb9bfbd | |||
| 78544cb205 | |||
| 4bf0733907 | |||
| a1dd97d5f1 | |||
| f50d87fc79 | |||
| f383b0ec71 | |||
| 8617304fd4 | |||
| d095531bb8 | |||
| 05e8ea5e40 | |||
| b4030c386e | |||
| e34e2c965c | |||
| 9be775d346 | |||
| 9399d3f41e | |||
| 779140ef59 | |||
| 469e804e38 | |||
| 548455f127 | |||
| 04cdf964af | |||
| 582f292114 | |||
| 1d3703b0e5 | |||
| b8a51c1c63 | |||
| eaa5e3bee9 | |||
| be08026fca | |||
| ad20853965 | |||
| f2d7d8f3b1 | |||
| 2f0e269788 | |||
| 0815625cfd | |||
| 75594aa2aa | |||
| a224537618 | |||
| 6f78277a03 | |||
| 6d5630c820 | |||
| 5bdc60006d | |||
| fc70179b02 | |||
| 3154cd8bc6 | |||
| 59a79b0063 | |||
| 50310a1bf8 | |||
| 39328cf6a9 | |||
| 72c2b2af2a | |||
| 8fddf0d1ed | |||
| bb44120087 | |||
| dc0149f134 | |||
| 9f37479272 | |||
| 1702ccacd2 | |||
| 5693c50d3a | |||
| 3fd2214da0 | |||
| f5e4541d83 | |||
| 32865f6d6e | |||
| e220b3703a | |||
| 8a1e17b9a0 | |||
| 77752a22d4 | |||
| 3d4c483807 | |||
| b1cf4b9ff2 | |||
| 08d9812f45 | |||
| 03cb39924a | |||
| 672110c213 | |||
| 1c1aee090c | |||
| 69ae036f08 | |||
| 1c67468ba9 | |||
| eb00a3522a | |||
| 8da51b3b68 | |||
| bed194386a | |||
| f61a8df6fa | |||
| 92ca6e460e | |||
| 0b6b39f262 | |||
| 611fcdd6d3 | |||
| ecba45da65 | |||
| 3511f0e6ee | |||
| 3bc6f718eb | |||
| 348cad2b1a | |||
| 1812ee7c27 | |||
| cd82c6d658 | |||
| e955ef73e0 | |||
| 398ee6e760 | |||
| ae9157c161 | |||
| aff972b218 | |||
| 41305ae2a8 | |||
| b03d158ab6 | |||
| 1e83bc9f68 | |||
| eaf270766f | |||
| 16473c824d | |||
| 67fdb715f5 | |||
| 30e7709df1 | |||
| cc9df7959a |
153
.astrion/memory/agent_hooks_design.md
Normal file
153
.astrion/memory/agent_hooks_design.md
Normal file
@ -0,0 +1,153 @@
|
||||
---
|
||||
name: agent_hooks_design
|
||||
description: 当需要继续讨论或实现本项目的 hooks / 生命周期钩子功能时,应该索引本记忆
|
||||
---
|
||||
|
||||
# Hooks / 生命周期钩子功能设计讨论(进行中)
|
||||
|
||||
## 调研结论
|
||||
|
||||
### 主流实现
|
||||
- **Claude Code**:最完整,20+ 事件、5 种 handler(command/http/mcp_tool/prompt/agent)、多层配置(全局/项目/本地/组织/插件/SDK)。
|
||||
- **OpenAI Codex**:11 个事件、command 回调为主、有信任审查机制和企业托管 hooks。
|
||||
- **Cursor**:`.cursor/hooks.json`,Beta,command 回调,项目/用户/企业三级。
|
||||
- **GitHub Copilot**:`.github/hooks/*.json`,command/http/prompt 三种回调,Cloud Agent 也支持。
|
||||
- **Windsurf/Cascade**:`.windsurf/hooks.json`,exit 2 阻断,12 个事件。
|
||||
- **Aider / Continue.dev / Kimi Code / Devin**:无原生 hooks 或资料有限。
|
||||
|
||||
### 共性模式
|
||||
```
|
||||
Agent 事件发生
|
||||
↓
|
||||
stdin 发送 JSON 事件载荷 → Hook 脚本 / HTTP 端点
|
||||
↓
|
||||
返回决策(allow/deny/ask/modify/injectMessage)
|
||||
↓
|
||||
Agent 根据决策继续执行
|
||||
```
|
||||
|
||||
## 用户(项目 owner)对 hooks 的理解
|
||||
|
||||
> 钩子 = 发生一件事件之前/中/后 → 自动触发另一个事件。用 JSON 格式写条件和行为,分为全局/项目级,放在数据存储目录下/工作区内。
|
||||
|
||||
用户认可的核心:**事件驱动 + JSON 配置 + 全局/项目两级 + before/after 拦截**。
|
||||
|
||||
## 已达成的设计共识
|
||||
|
||||
### 1. 配置位置
|
||||
- 全局:`~/.astrion/astrion/config/hooks.json`
|
||||
- 项目级:`<project>/.astrion/hooks.json`
|
||||
- 未来可扩展:`.astrion/hooks.local.json`、企业策略级。
|
||||
- 多层配置**合并执行**,不是覆盖(更安全)。
|
||||
|
||||
### 2. 优先事件(MVP 范围建议)
|
||||
- `PreToolUse`:工具执行前拦截/修改
|
||||
- `PostToolUse`:工具执行后触发后续动作
|
||||
- `Stop`:模型/turn 结束时注入反馈
|
||||
- `PreCompact`:上下文压缩前备份
|
||||
|
||||
### 3. 优先回调类型
|
||||
- MVP 只做 `command`(shell/python/任意可执行文件)。
|
||||
- 后续可扩展 `http`、`prompt`、`agent`、`mcp_tool`。
|
||||
|
||||
### 4. 通信协议(对齐 Claude Code/Codex)
|
||||
- stdin 输入 JSON 事件载荷
|
||||
- stdout 返回 JSON 决策
|
||||
- exit code:0=通过,1=非阻断错误,2=阻断
|
||||
|
||||
### 5. JSON 结构草案
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"disabled": false,
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"name": "block-dangerous-rm",
|
||||
"matcher": "run_command",
|
||||
"if": "command contains 'rm -rf /'",
|
||||
"enabled": true,
|
||||
"max_repeats": null,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.astrion/astrion/hooks/block_dangerous.py",
|
||||
"timeout": 5,
|
||||
"statusMessage": "检查危险命令...",
|
||||
"fail_policy": "continue",
|
||||
"on_error": "log"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"name": "remind-check-completion",
|
||||
"max_repeats": 3,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.astrion/astrion/hooks/remind_check.py",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 已讨论的具体例子
|
||||
|
||||
### 例子 1:拦截危险命令
|
||||
- 事件:`PreToolUse`
|
||||
- 条件:`matcher=run_command` + `if=command contains 'rm -rf /'`
|
||||
- 行为:调用脚本,返回 `permissionDecision: deny`
|
||||
|
||||
### 例子 2:edit_file 单行全替换触发审核
|
||||
- 事件:`PreToolUse`
|
||||
- 条件:`matcher=edit_file` + `old_string` 只有一行 + `replace_all=true`
|
||||
- 行为:返回 `permissionDecision: ask`
|
||||
|
||||
### 例子 3:Stop 时自动插入 user 消息,最多 3 次
|
||||
- 事件:`Stop`
|
||||
- 行为:返回 `injectMessage: {role: "user", content: "请仔细检查是否真的完成了所有工作"}`
|
||||
- "最多 3 次"可用 `max_repeats` 或由脚本自己维护状态文件。
|
||||
|
||||
## 尚未决定的决策点
|
||||
|
||||
1. **`if` 条件表达式语法**:
|
||||
- 选项 A:简单规则 DSL(如 `command contains '...'`)
|
||||
- 选项 B:结构化 JSON(如 `{"field": "...", "op": "contains", "value": "..."}`)
|
||||
- 选项 C:交给 hook 脚本自己判断,`if` 只保留工具名匹配
|
||||
|
||||
2. **"最多 N 次"由谁负责**:
|
||||
- 框架负责 `max_repeats`(简单,但状态语义固定)
|
||||
- 脚本自己维护状态文件(灵活,框架更轻量)
|
||||
|
||||
3. **注入消息语义**:
|
||||
- `injectMessage.user`:会触发模型再回复一轮
|
||||
- `addContext.system`:只是给下一轮加提示,不立刻触发回复
|
||||
|
||||
4. **hook 失败时的主流程策略**:
|
||||
- 默认 `continue`(用户配置错误不瘫痪智能体)
|
||||
- 或提供 `fail_policy: block` 让管理员强制策略
|
||||
|
||||
5. **工具名匹配**:
|
||||
- 是否同时支持内部工具名(`run_command`/`edit_file`)和 Claude Code 风格别名(`Bash`/`Edit|Write`)
|
||||
|
||||
## 推荐后续路线
|
||||
|
||||
1. 先定 JSON schema 和 `if` 语法。
|
||||
2. MVP 实现 `command` 回调 + `PreToolUse`/`PostToolUse`/`Stop`。
|
||||
3. 新增 `modules/hook_manager.py`。
|
||||
4. 在 `core/main_terminal_parts/tools_execution.py` 和 `core/web_terminal.py` 插入 hook 调用。
|
||||
5. 确保 API task 模式也走 hooks。
|
||||
6. 补文档 `docs/hooks.md`。
|
||||
|
||||
## 参考资料位置
|
||||
|
||||
- 调研报告:`research/claudecode_hooks/claudecode_hooks.md`
|
||||
- 调研报告:`research/codex_hooks/codex_hooks.md`
|
||||
- 调研报告:`research/other_agents_hooks/other_agents_hooks.md`
|
||||
68
.astrion/memory/ai_avatar_demo.md
Normal file
68
.astrion/memory/ai_avatar_demo.md
Normal file
@ -0,0 +1,68 @@
|
||||
---
|
||||
name: ai_avatar_demo
|
||||
description: 当需要了解或继续开发/修改 agents 项目的 AI 形象头像 demo 时,应该索引本记忆
|
||||
---
|
||||
|
||||
---
|
||||
name: ai_avatar_demo
|
||||
description: 当需要了解或继续开发/修改 agents 项目的 AI 形象头像 demo 时,应该索引本记忆
|
||||
---
|
||||
|
||||
# AI 形象头像 Demo
|
||||
|
||||
## 文件位置
|
||||
`/Users/jojo/Desktop/agents/正在修复中/agents/demo/ai-avatar/index.html`
|
||||
|
||||
## 设计目标
|
||||
为项目设计一个独特、动态的 AI 形象,替代之前从网上找的通用小机器人 SVG。
|
||||
|
||||
## 当前设计
|
||||
|
||||
### 视觉构成
|
||||
- **外框**:flat-top 圆角正六边形(上下边水平,底部是一条横线),中心 `(100,100)`,外接圆半径 80,圆角半径 14。
|
||||
- **内部符号**:由两条等粗白色线段组成:
|
||||
- 空闲态:两条竖线作为眼睛,长度 22,间距 32,位于 `y ≈ 84`(面部上半区域略偏上)。
|
||||
- 工作态:`>` 终端提示符(无下横线),尖端朝右,开角 60°,单条线长度 34。
|
||||
|
||||
### 状态与动画
|
||||
- **空闲**:六边形停到最近的 60° 正位,显示眼睛,眼睛跟随鼠标移动。
|
||||
- **工作**:六边形开始持续旋转,显示 `>` 符号。
|
||||
- 状态切换时内部符号立即变形,不等待外框停稳;外框则从当前角度平滑过渡到目标角度或开始旋转。
|
||||
- 内部符号在空闲/工作之间通过**同两条 `<line>`** 进行 morph:
|
||||
- `transform`(位置 + 旋转)使用 CSS `transition: transform 0.55s cubic-bezier(0.22, 1, 0.36, 1)`。
|
||||
- `x2`(线段长度)使用同节奏(0.55s,相同 cubic-bezier)的 `requestAnimationFrame` 插值,避免长度跳变。
|
||||
- 提供一个独立的"停止追踪 / 开始追踪"按钮:停止追踪时眼睛平滑回到默认居中位置。
|
||||
|
||||
### 技术实现
|
||||
- **纯 SVG**:外框用 `<path>`,眼睛 / `>` 用 `<line>`,整体用 `<svg>` + `<g>`。
|
||||
- **动画方式**:
|
||||
- 六边形旋转:CSS `@keyframes spin` + CSS `transition`。
|
||||
- 符号变形:CSS `transition` 改变 `transform`;JS `requestAnimationFrame` 插值改变 `x2`。
|
||||
- 眼睛追踪:JS `requestAnimationFrame` 实时更新 SVG `<g>` 的 `transform`。
|
||||
- **无 Canvas / WebGL / GIF / Lottie / 图片序列帧**。
|
||||
- 整体缩放不会导致比例或长度失调(矢量 SVG)。
|
||||
|
||||
## 设计约束
|
||||
|
||||
### 不可改动的核心动画
|
||||
`demo/ai-avatar/index.html` 中 **空闲(idle)** 与 **工作(work)** 两个核心状态的切换动画不可改动:
|
||||
|
||||
- 六边形外框:空闲时从当前角度滑到最近的 60° 正位后停住;工作时从当前角度开始持续旋转。
|
||||
- 内部图标:空闲为眼睛,工作为 `>` 符号,二者通过同两条 `<line>` 元素进行 morphing 变形。
|
||||
- 变形节奏统一为 0.55s cubic-bezier(0.22, 1, 0.36, 1):
|
||||
- `transform` 由 CSS transition 处理。
|
||||
- `x2` 长度由 JS `requestAnimationFrame` 插值处理(CSS transition 不直接支持 SVG `x2` 属性)。
|
||||
|
||||
### 设计原则
|
||||
- 只允许 SVG 的**微动 / 线条 / 点**之间的**优雅细微动化**。
|
||||
- 禁止花哨特效:脉冲波纹、光标闪烁、发光、强烈缩放、大幅度弹跳等。
|
||||
- 新增工作状态时,应保持通用切换容器,避免为每个 SVG 单独适配位置。
|
||||
- 内部图标线条应比外框稍细,保持视觉层次感。
|
||||
|
||||
## 关键调整历史
|
||||
- 外框从 pointy-top 改为 flat-top(横线在底部)。
|
||||
- 边角从锐利直线改为 Q 贝塞尔圆角。
|
||||
- 状态含义反转:空闲 = 静止 + 眼睛 + 追踪;工作 = 旋转 + `>`。
|
||||
- 眼睛长度缩短,位置上移至 `y=84`。
|
||||
- 鼠标追踪范围水平/垂直均为 ±10px。
|
||||
- 2026-07-01:为 `x2` 长度变化增加与 `transform` 同节奏的 JS 插值动画,解决工作/休息切换时长度跳变问题。
|
||||
30
.astrion/memory/app_initialization_flow.md
Normal file
30
.astrion/memory/app_initialization_flow.md
Normal file
@ -0,0 +1,30 @@
|
||||
---
|
||||
name: app_initialization_flow
|
||||
description: 当需要适配桌面 App 初始化流程或理解项目首次启动配置时,应该索引本记忆
|
||||
---
|
||||
|
||||
## 项目初始化流程(首次启动 / 桌面 App 适配必读)
|
||||
|
||||
### 关键文件
|
||||
- `_bootstrap.sh` — 共享引导(定位项目根、准备 Python venv + Node 依赖)
|
||||
- `setup.sh` — 首次初始化入口:调 `_bootstrap.sh` 后运行 `python -m scripts.setup`
|
||||
- `scripts/setup.py` — **交互式配置向导**(终端),5 步生成 `.env` + `custom_models.json`
|
||||
|
||||
### setup.py 向导步骤
|
||||
1. **运行模式** — `host`(单机本地沙箱)/ `web`(多用户 Docker),决定 `TERMINAL_SANDBOX_MODE`
|
||||
2. **Web 监听** — 端口 (`WEB_SERVER_PORT`) + 地址 (`WEB_SERVER_HOST`),host 模式默认 127.0.0.1
|
||||
3. **数据目录** — `~/.astrion/astrion/<mode>/`,可自定义(设 `ASTRION_DATA_ROOT`)
|
||||
4. **管理员账户** — `AGENT_ADMIN_USERNAME` + `AGENT_ADMIN_PASSWORD_HASH`(werkzeug hash)
|
||||
5. **模型配置** — 写入部署级 `custom_models.json`(~/.astrion/astrion/config/):model_name / url / apikey / model_id / thinking 支持
|
||||
6. **自动生成** — `WEB_SECRET_KEY` + `API_TOKEN_SECRET`(secrets.token_hex(32))
|
||||
|
||||
### 设计要点
|
||||
- 向导**刻意不 import config**,以免在 `.env` 写之前过早锁定路径
|
||||
- 以 `.env.example` 为模板,仅替换被接管键,其余原样保留(含注释)
|
||||
- `.env` 写完后 chmod 600
|
||||
|
||||
### 桌面 App 适配要点
|
||||
- 桌面 App 应固定 `TERMINAL_SANDBOX_MODE=host`
|
||||
- 端口应自动找可用端口
|
||||
- 模型配置可在 Web UI 中后续修改,但首次启动至少需要一个模型
|
||||
- `_deploy_config_dir()` 推导逻辑必须与 `config/paths.py` 保持一致
|
||||
48
.astrion/memory/astrion_naming.md
Normal file
48
.astrion/memory/astrion_naming.md
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
name: astrion_naming
|
||||
description: 当需要了解或引用本项目的品牌名称、产品名 Astrion 的含义与来源时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 项目名称 / 产品名:Astrion
|
||||
|
||||
## 决策记录
|
||||
- **暂定名称**:Astrion
|
||||
- **决策时间**:2026-07-07
|
||||
- **命名方向**:人格化 + 宇宙现象/天体 + 科技意象
|
||||
- **当前状态**:暂定,后续如需变更请同步更新本记忆、AGENTS.md 与 claude.md
|
||||
|
||||
## 名称含义
|
||||
|
||||
**构词**
|
||||
- **Astr-**:来自希腊语 *astron*,意为“星星 / 天体 / 宇宙”
|
||||
- **-ion**:科技命名中常见的粒子/状态后缀(如 photon、electron、neutron、ion)
|
||||
|
||||
**字面意象**
|
||||
> 星之子、星际粒子、来自宇宙尘埃的微小发光体。
|
||||
|
||||
## 表达特点
|
||||
|
||||
| 特点 | 解读 |
|
||||
|------|------|
|
||||
| 科技感 | `-ion` 后缀自带物理/科技联想 |
|
||||
| 宇宙感 | `Astr-` 词根直接指向星空 |
|
||||
| 轻盈感 | 粒子暗示微小、快速、自由穿行 |
|
||||
| 能量感 | ion(离子)带电,暗示活跃、驱动、连接 |
|
||||
| 可人格化 | 三音节 `/ˈæstriən/`,发音接近 Orion、Damion,适合作为“他/她” |
|
||||
| 中性 | 不偏男性或女性,适用面广 |
|
||||
|
||||
## 品牌叙事参考
|
||||
|
||||
1. **连接者**:像星际间传递信号的粒子,把人类意图传递到工具、文件、终端、子智能体之间。
|
||||
2. **从混沌中组织秩序**:宇宙尘埃在引力下聚集成恒星;Astrion 把零散任务聚合成可执行结果。
|
||||
3. **渺小但明亮**:单个粒子微不足道,但携带能量、能引发连锁反应——对应 Agent 虽小,却能撬动复杂系统。
|
||||
|
||||
## 中文参考
|
||||
|
||||
- 全称音译:**阿斯特里恩**
|
||||
- 简称候选:**星子**(对应 astron + particle,更顺口)
|
||||
|
||||
## 同步位置
|
||||
|
||||
- 项目规范:`AGENTS.md`
|
||||
- Claude Code 指南:`claude.md`
|
||||
109
.astrion/memory/background_notification_pool.md
Normal file
109
.astrion/memory/background_notification_pool.md
Normal file
@ -0,0 +1,109 @@
|
||||
---
|
||||
name: background_notification_pool
|
||||
description: 当需要讨论或修复后台子智能体/后台 run_command 完成通知机制时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 后台子智能体 / 后台 run_command 通知机制问题(待修复)
|
||||
|
||||
## 问题描述
|
||||
|
||||
后台子智能体和后台 `run_command` 在“主智能体停止输出后完成”的场景下,通知机制存在串行化问题:
|
||||
|
||||
- 若同时有多个后台任务在运行,它们不会随完成逐个通知。
|
||||
- 而是在所有后台任务全部完成后,按时间顺序一条一条地发送 user 消息。
|
||||
- 且每发送一条 user 消息就要触发一次“工作 → 停止 → 再工作”的循环,导致通知被拆成多次停止输出。
|
||||
|
||||
## 当前机制
|
||||
|
||||
运行期间(inline)和停止输出后(polling)是两条链路:
|
||||
|
||||
### 1. 运行期间(inline,当前正常)
|
||||
|
||||
- 位置:`server/chat_flow_tool_loop.py` 中 `execute_tool_calls` 末尾。
|
||||
- 调用:
|
||||
- `process_sub_agent_updates(..., inline=True)`
|
||||
- `process_background_command_updates(..., inline=True)`
|
||||
- 行为:每次工具循环结束时,把**当前所有已完成且未通知**的任务一次性插入 `messages`,不触发新任务。
|
||||
- 结论:运行期间的批量通知已经是“池化”的,暂无问题。
|
||||
|
||||
### 2. 停止输出后(polling,问题所在)
|
||||
|
||||
- 位置:`server/chat_flow_task_main.py` 中 `handle_task_with_sender` 结尾。
|
||||
- 调用:
|
||||
- `poll_sub_agent_completion(...)`
|
||||
- `poll_background_command_completion(...)`
|
||||
- 行为:
|
||||
- 两个轮询器各自由 `socketio.start_background_task(...)` 启动。
|
||||
- 每轮循环只处理**一个**完成的通知。
|
||||
- 处理完一个通知后,创建一个新的 chat task,然后 `return`。
|
||||
- 下一个通知必须等待这个新 chat task 跑完,并再次走到 `handle_task_with_sender` 结尾重新 spawn 轮询器时才会发出。
|
||||
|
||||
## 根因
|
||||
|
||||
后台任务管理器本身已经扮演了“后台池 + 通知池”的角色(任务完成但 `notified=False` 就是待通知状态)。
|
||||
|
||||
但轮询器是 **one-shot** 的:每次只消费一条通知就退出,导致通知被串行化到多次“工作 → 停止 → 再工作”循环中。
|
||||
|
||||
## 讨论中的设计方案
|
||||
|
||||
统一为“通知池轮询器”:
|
||||
|
||||
1. 合并 `poll_sub_agent_completion` 和 `poll_background_command_completion` 为一个统一的 `poll_completion_notifications`。
|
||||
2. 每次轮询时,同时从以下两处取出**所有**待通知项:
|
||||
- `sub_agent_manager.poll_updates()`
|
||||
- `background_command_manager.poll_updates(conversation_id=...)`
|
||||
3. 把所有待通知项作为 user 消息插入对话历史(倾向保留为多条消息,而非合并成一条)。
|
||||
4. 只创建 **一个**后续 chat task 来触发继续工作。
|
||||
5. 轮询器返回,由这个新任务在结束时继续下一轮轮询。
|
||||
|
||||
### 期望行为示例
|
||||
|
||||
- 3 个后台子智能体运行中,主智能体停止输出。
|
||||
- 第 1 个完成 → 通知池有 1 条 → 插入 1 条 user 消息 → 触发 1 个新任务。
|
||||
- 新任务处理期间,第 2、3 个完成 → 进入通知池。
|
||||
- 新任务停止输出 → 轮询器发现通知池还有 2 条 → 一次性插入 2 条 user 消息 → 再触发 1 个新任务。
|
||||
- 不再有后台任务时,轮询器直接退出。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 角色 |
|
||||
|---|---|
|
||||
| `server/chat_flow_task_support.py` | 新增统一轮询器 `poll_completion_notifications`;可废弃旧的两个轮询函数。 |
|
||||
| `server/chat_flow_task_main.py` | 在 `handle_task_with_sender` 结尾,把分别 spawn 两个轮询器的逻辑替换为 spawn 一个统一轮询器。 |
|
||||
| `modules/sub_agent/manager.py` | 提供 `poll_updates()`,基于 `notified` 标志表示待通知状态。 |
|
||||
| `modules/background_command_manager.py` | 提供 `poll_updates()` 和 `has_pending_for_conversation()`。 |
|
||||
|
||||
## 待决策点
|
||||
|
||||
- 是否坚持把通知拆成多条 user 消息插入历史(推荐),还是合并为一条。
|
||||
- 统一轮询器是否做成“持久单例”还是保持 one-shot batch(当前讨论倾向 one-shot batch,由新任务继续轮询)。
|
||||
|
||||
## 状态
|
||||
|
||||
已实施(2026-06-24,方案 A + one-shot batch)。
|
||||
|
||||
### 落地实现
|
||||
|
||||
- `server/chat_flow_task_support.py` 未改;新增逻辑集中在 `server/chat_flow_task_main.py`。
|
||||
- 新增统一轮询器 `poll_completion_notifications`,替换并删除旧的 `poll_sub_agent_completion` / `poll_background_command_completion`。
|
||||
- 新增 `_collect_pending_completion_notices`:每轮一次性从子智能体 + 后台命令两路取出所有待通知项(取出即就地标记 `notified` / `_announced_sub_agent_tasks`),按 `updated_at` 跨两路排序。
|
||||
- 新增 `_has_pending_completion_work`:判断是否还有运行中/待通知的后台工作,决定轮询是否继续。
|
||||
- 新增 `_build_sub_agent_notice_text`:抽出子智能体通知文本构建。
|
||||
- `handle_task_with_sender` 结尾:保留 `sub_agent_waiting` 事件探测,但把「分别 spawn 两个轮询器」改为只 spawn **一个** `run_completion_poll`。**这也顺带消除了两个轮询器同时 `create_chat_task` 撞 `create_chat_task` 单工作区互斥(`models.py` RuntimeError)的隐患。**
|
||||
|
||||
### 方案 A 的「多条 + 单 task」如何落地
|
||||
|
||||
`create_chat_task` 只收单条 message,`handle_task_with_sender` 开头无条件 `add_conversation`。因此:
|
||||
|
||||
- 一批取出 N 条:前 N-1 条为「前置通知」,最后 1 条作为后续 chat task 的触发消息。
|
||||
- `_dispatch_completion_user_notice` 新增 `preceding_notices` 参数:
|
||||
- 通过 `_persist_and_echo_preceding_notice` 把前 N-1 条**写入对话历史**(`add_conversation`,落盘)+ **socketio 回显**(在线客户端即时可见),`starts_work=False`(渲染为完成态气泡,不起独立计时器)。
|
||||
- 把 `preceding_user_notices` 塞进 `session_data`,传给后续任务。
|
||||
- `server/tasks/models.py` 的 `_run_chat_task`:在补发触发 user_message 事件**之前**,先按顺序把 `preceding_user_notices` 逐条 `_append_event("user_message", ...)` 注入任务事件流——保证 REST 轮询客户端按时间顺序看到所有完成通知。
|
||||
- 触发消息(最后 1 条)走原有 `auto_user_message_event` 链路,正常 `starts_work=True` 启动一轮工作。
|
||||
|
||||
### 验证
|
||||
|
||||
- `python3 -m py_compile server/chat_flow_task_main.py server/tasks/models.py` 通过(注意本机 pycache 目录无写权限,需 `PYTHONPYCACHEPREFIX=$TMPDIR/pycache`)。
|
||||
- `python3 -m unittest test.test_server_refactor_smoke` 6 用例通过。
|
||||
- 前端未改:复用既有 `user_message` / `sub_agent_notice` 渲染链路(`messaging.ts` handleUserMessage + socketio `user_message`),dedup 与单通知场景一致。
|
||||
49
.astrion/memory/chat_block_scroll_anchor.md
Normal file
49
.astrion/memory/chat_block_scroll_anchor.md
Normal file
@ -0,0 +1,49 @@
|
||||
---
|
||||
name: chat_block_scroll_anchor
|
||||
description: 当需要优化聊天区域块展开/折叠滚动行为,或排查向上滚动后瞬间跳回问题时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 聊天区域块展开/折叠滚动行为优化记录
|
||||
|
||||
## 背景
|
||||
聊天区域默认使用 `vue-stick-to-bottom` 保持底部锁定。块(thinking/tool)展开/折叠时,`stick-to-bottom` 的弹簧追底动画和 CSS 高度过渡不同步,会产生「向下展开一点、向上滚动一点」的顿挫感。
|
||||
|
||||
## 已尝试方案
|
||||
|
||||
### 1. 块展开锚定(useBlockExpansionAnchor)
|
||||
- 在 `static/src/composables/useBlockExpansionAnchor.ts` 中实现
|
||||
- 块展开/折叠的 CSS 过渡期间(约 260–300ms),用 `requestAnimationFrame` 持续覆盖 `scrollTop`
|
||||
- 目标:让被操作块的底边或顶边保持在原来视口位置
|
||||
- 方向判断:
|
||||
- 用**底边**判断比顶边更准确。高块即使顶边位于上半,底边仍可能在下半;用顶边判断会把高块误判为 `down` 模式
|
||||
- `up` 模式:底边不动,头部向上展开
|
||||
- `down` 模式:顶边不动,内容向下展开
|
||||
- 方向在单次展开-收起周期内记忆(`preferredModes`),保证收起是展开的倒放;收起动画结束后清除记录
|
||||
|
||||
### 2. 运行期间策略
|
||||
- 运行中的 thinking/tool 块(带 `.processing`/`.running`/`.thinking-animation`)保持旧逻辑,由 `stick-to-bottom` 追底,避免被输入栏遮挡
|
||||
- 非运行中的块(包括运行期间的历史块)走锚定逻辑
|
||||
- 已实现 commit:`f43b5ea7 fix(chat): 运行期间恢复旧滚动逻辑,堆叠块更多按钮也接入锚定`
|
||||
|
||||
### 3. 堆叠块「更多」按钮
|
||||
- `StackedBlocks.vue` 在 toggleMore 时 emit `more-toggle`,`ChatArea.vue` 监听后对 more-block 元素启动锚定
|
||||
- 注意:事件 payload 里的展开状态应使用 `payload.expanded`,避免 `ReferenceError`
|
||||
|
||||
## 未解决的向上滚动跳回问题
|
||||
|
||||
### 现象
|
||||
- 在块较多的对话中,**向上滚动**后页面会瞬间跳回滚动前的位置
|
||||
- 向下滚动不会出现
|
||||
- 禁用 `anchorBlockElement` 调用、禁用 wheel-up 时的 `stopScroll()` 后问题依然存在
|
||||
|
||||
### 调试结论
|
||||
- 不是 `anchorBlockElement` 直接导致
|
||||
- 不是 `stopScroll()` 直接导致
|
||||
- `heightDelta` 可能为负(内容高度减小),但 large-growth 检测未触发
|
||||
- `escapedFromLock` 已为 `true`,`stick-state-change` 未出现异常变化
|
||||
- 原因未最终定位,可能与 `vue-stick-to-bottom` 内部状态、浏览器原生滚动行为或内容高度变化后的布局抖动有关
|
||||
|
||||
## 当前代码状态
|
||||
- 当前 HEAD:`f43b5ea7 fix(chat): 运行期间恢复旧滚动逻辑,堆叠块更多按钮也接入锚定`
|
||||
- 工作区干净,所有调试和临时修改已撤回,stash 已清空
|
||||
- 后续若继续优化,建议从 `f43b5ea7` 开始,并优先定位向上滚动跳回的根本原因
|
||||
148
.astrion/memory/cloud_server_disk_usage.md
Normal file
148
.astrion/memory/cloud_server_disk_usage.md
Normal file
@ -0,0 +1,148 @@
|
||||
---
|
||||
name: cloud_server_disk_usage
|
||||
description: 当需要了解云服务器磁盘使用现状、规划Docker镜像构建空间、或决定清理哪些文件释放空间时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 云服务器磁盘使用现状
|
||||
|
||||
> 记录时间:2026-06-25
|
||||
> 服务器:59.110.19.30(Ubuntu 24.04.2 LTS, x86_64)
|
||||
|
||||
## 总体情况
|
||||
|
||||
```text
|
||||
Filesystem Size Used Avail Use%
|
||||
/dev/vda3 49G 43G 4.0G 92%
|
||||
```
|
||||
|
||||
- 总磁盘:约 49 GB
|
||||
- 已用:约 43 GB(92%)
|
||||
- 可用:约 4 GB
|
||||
|
||||
**结论:磁盘非常紧张,不足以直接本地构建方案 B 的 2.9GB Docker 镜像。**
|
||||
|
||||
## 占用大户(按大小排序)
|
||||
|
||||
| 路径 | 大小 | 说明 |
|
||||
|------|------|------|
|
||||
| `/var/lib/docker` | **18 GB** | Docker 镜像、容器、volume |
|
||||
| `/var/log/journal` | **4.1 GB** | systemd journal 日志 |
|
||||
| `/opt/agent` | **5.0 GB** | agents 项目源码、运行时、备份、镜像 |
|
||||
| `/opt/openwebui` | **3.6 GB** | OpenWebUI 部署目录 |
|
||||
| `/var/lib/filebrowser` | **3.0 GB** | filebrowser 数据 |
|
||||
| `/swapfile` | **2.1 GB** | swap 文件 |
|
||||
| `/home/git/data` | **856 MB** | gitea 数据 |
|
||||
| `/home/admin/go` | **770 MB** | Go 相关文件/缓存 |
|
||||
| `/var/www` | **1.4 GB** | nginx web 目录 |
|
||||
| `/var/lib/clamav` | **1.2 GB** | 病毒库 |
|
||||
| `/usr` | **4.2 GB** | 系统软件 |
|
||||
|
||||
## Docker 详细占用
|
||||
|
||||
```text
|
||||
Images: 4 5.284 GB
|
||||
- ghcr.io/open-webui/open-webui <none> 4.32 GB
|
||||
- my-agent-shell amd64 913 MB
|
||||
- my-agent-shell latest 913 MB(与 amd64 同 image id)
|
||||
- python 3.11-slim 124 MB
|
||||
- ghcr.io/corentinth/it-tools latest 56.2 MB
|
||||
|
||||
Volumes: 1 7.966 GB
|
||||
- open-webui: 7.966 GB
|
||||
|
||||
Containers: 3
|
||||
- agent-term-jojo-default my-agent-shell:latest Up
|
||||
- openwebui-new open-webui image Up (healthy)
|
||||
- it-tools it-tools image Exited (7 months ago)
|
||||
|
||||
Build Cache: 10 / 2.182 kB(可忽略)
|
||||
```
|
||||
|
||||
## `/opt/agent` 详细占用
|
||||
|
||||
| 路径 | 大小 | 说明 |
|
||||
|------|------|------|
|
||||
| `/opt/agent/backup` | **1.5 GB** | 6 个压缩备份包(runtime_backup_* ×3, users_data_backup_* ×3) |
|
||||
| `/opt/agent/dockerimage` | **894 MB** | `my-agent-shell_amd64.tar` 旧镜像 tar |
|
||||
| `/opt/agent/agents` | **883 MB** | 当前源码目录 |
|
||||
| `/opt/agent/agents_old_20260614` | **540 MB** | 旧版本源码备份 |
|
||||
| `/opt/agent/runtime` | **520 MB** | 运行时数据(对话、用户工作区等) |
|
||||
| `/opt/agent/voice_models` | **230 MB** | 语音模型文件 |
|
||||
| `/opt/agent/agents_old_20260612_012026` | **190 MB** | 旧版本源码备份 |
|
||||
| `/opt/agent/agents_old_20260614_190527` | **190 MB** | 旧版本源码备份 |
|
||||
| `/opt/agent/venv` | **96 MB** | Python 虚拟环境 |
|
||||
| `/opt/agent/agents.zip` | **42 MB** | 源码 zip 包 |
|
||||
|
||||
## 建议清理清单
|
||||
|
||||
### 低风险(约可释放 6.5 GB)
|
||||
|
||||
| 操作 | 可释放 | 命令/路径 |
|
||||
|------|--------|-----------|
|
||||
| 清理 systemd journal 旧日志 | ~4 GB | `journalctl --vacuum-time=7d` 或 `--vacuum-size=500M` |
|
||||
| 删除 `/opt/agent/backup` 旧备份,只留最新各 1 个 | ~1 GB | 手动删除旧的 `runtime_backup_*` 和 `users_data_backup_*` |
|
||||
| 删除 `/opt/agent/dockerimage/my-agent-shell_amd64.tar` | 894 MB | `rm /opt/agent/dockerimage/my-agent-shell_amd64.tar` |
|
||||
| 删除 `/opt/agent/agents_old_*` 旧版本目录 | ~920 MB | `rm -rf /opt/agent/agents_old_*` |
|
||||
| 删除 `/opt/agent/agents.zip` | 42 MB | `rm /opt/agent/agents.zip` |
|
||||
| 删除已退出容器 `it-tools` | ~514 kB | `docker rm it-tools` |
|
||||
|
||||
### 中风险(需先确认是否仍在使用)
|
||||
|
||||
| 操作 | 可释放 | 说明 |
|
||||
|------|--------|------|
|
||||
| 清理 `/var/lib/filebrowser` | 3 GB | 需确认 filebrowser 中是否还有需要保留的文件 |
|
||||
| 清理 `/home/admin/go` 缓存 | ~770 MB | `go clean -cache` |
|
||||
| 删除 `/var/lib/clamav` | 1.2 GB | 如果系统不使用 clamav 扫描 |
|
||||
|
||||
### 不建议动
|
||||
|
||||
- `/opt/agent/runtime`:用户运行时数据(对话、项目文件等)
|
||||
- `/opt/agent/agents`:当前项目源码
|
||||
- `/opt/openwebui`:如果 OpenWebUI 服务仍在使用
|
||||
- `/home/git/data`:gitea 数据
|
||||
- Docker volume `open-webui`:OpenWebUI 用户数据
|
||||
|
||||
## 关键命令(下次快速查看)
|
||||
|
||||
```bash
|
||||
# 总体磁盘
|
||||
ssh 59.110.19.30
|
||||
df -h
|
||||
|
||||
# 根目录一级占用
|
||||
sudo du -sh /* 2>/dev/null | sort -h | tail -20
|
||||
|
||||
# /var 明细
|
||||
sudo du -sh /var/lib/* /var/log/* 2>/dev/null | sort -h | tail -20
|
||||
|
||||
# /opt/agent 明细
|
||||
sudo du -sh /opt/agent/* 2>/dev/null | sort -h | tail -20
|
||||
|
||||
# Docker 占用
|
||||
docker system df
|
||||
docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}'
|
||||
docker volume ls
|
||||
|
||||
# 清理 journal
|
||||
sudo journalctl --vacuum-time=7d
|
||||
|
||||
# 清理 Docker
|
||||
# docker image prune -a # 谨慎:会删除所有未使用镜像
|
||||
# docker volume prune # 谨慎:会删除未使用 volume
|
||||
```
|
||||
|
||||
## 与本次 Docker 改造的关系
|
||||
|
||||
- 当前 `my-agent-shell` 镜像:913 MB
|
||||
- 方案 B 目标镜像:约 2.9 GB(本地 arm64 实测)
|
||||
- 构建过程需下载 586 MB apt 包,解压后约 2.2 GB
|
||||
- 当前可用空间仅 4 GB,**不足以安全构建方案 B**
|
||||
- 建议先执行低风险清理释放 6-8 GB,再考虑构建
|
||||
- 云端构建建议分阶段:先只加 LibreOffice + 字体 + Node + Python 必要包(约 1.5 GB 镜像),验证稳定后再决定是否加 chromium/ffmpeg
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 服务器架构为 x86_64,本地为 arm64,**禁止将本地构建的镜像上传到云端**
|
||||
- 云端网络环境较差,大文件下载可能超时
|
||||
- 云端内存 2 GB,构建/运行大镜像需警惕 OOM
|
||||
- 清理前务必确认文件是否仍在使用,尤其是 `/var/lib/filebrowser` 和 `/opt/openwebui`
|
||||
75
.astrion/memory/cloud_server_ssh_and_directories.md
Normal file
75
.astrion/memory/cloud_server_ssh_and_directories.md
Normal file
@ -0,0 +1,75 @@
|
||||
---
|
||||
name: cloud_server_ssh_and_directories
|
||||
description: 当需要SSH登录云服务器排查部署问题、查看日志、或操作服务器文件时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 云服务器 SSH 与目录指南
|
||||
|
||||
## SSH 登录
|
||||
|
||||
```bash
|
||||
ssh 59.110.19.30
|
||||
# 使用与本地相同的 id_ed25519 密钥
|
||||
```
|
||||
|
||||
## 关键目录
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| **源码根目录** | `/opt/agent/agents/` |
|
||||
| **运行时数据根** | `/opt/agent/runtime/`(`ASTRION_DATA_ROOT`) |
|
||||
| **部署级配置** | `/opt/agent/runtime/config/` |
|
||||
| **子智能体模型配置** | `/opt/agent/runtime/config/sub_agent_models.json` |
|
||||
| **子智能体代码** | `/opt/agent/agents/modules/sub_agent/`(Python 主进程内协程) |
|
||||
| **虚拟环境** | `/opt/agent/venv/` |
|
||||
|
||||
## 用户数据
|
||||
|
||||
```
|
||||
/opt/agent/runtime/web/users/<用户名>/projects/<项目名>/
|
||||
├── project/ # 用户工作区文件
|
||||
└── data/
|
||||
└── conversations/
|
||||
└── default/
|
||||
└── conv_xxx.json # 对话记录
|
||||
```
|
||||
|
||||
## 子智能体相关
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| **子智能体任务目录** | `/opt/agent/runtime/web/data/sub_agent_tasks/<task_id>/` |
|
||||
| **任务文件** | `task.txt`、`system_prompt.txt` |
|
||||
| **子智能体交付目录** | 在用户 project 下的 `sub_agent_results/` |
|
||||
|
||||
## 服务管理
|
||||
|
||||
| 操作 | 命令/路径 |
|
||||
|------|----------|
|
||||
| **启动服务** | `bash /opt/agent/start_webapp_new.sh`(端口 8091) |
|
||||
| **更新服务** | `bash /opt/agent/refresh_agents.sh`(git pull + 重启 + nginx reload) |
|
||||
| **数据迁移脚本** | `python3 /opt/agent/agents/scripts/migrate_runtime_data.py`(执行前务必 `--dry-run`) |
|
||||
| **Nginx 配置** | `/etc/nginx/sites-enabled/agent.cyjai.com` |
|
||||
| **本地部署脚本** | `deploy_agents.sh`(git push → npm build → scp dist/ → 触发远程 refresh) |
|
||||
|
||||
## 日志
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| **Web 日志** | `/opt/agent/runtime/web/logs/` |
|
||||
| **主机调试日志** | `/opt/agent/runtime/web/logs/host_workspace_debug.log` |
|
||||
|
||||
## 常见排查流程
|
||||
|
||||
1. SSH 登录服务器
|
||||
2. 查看对话 JSON:`cat /opt/agent/runtime/web/users/<user>/projects/<project>/data/conversations/default/conv_xxx.json`
|
||||
3. 查看子智能体任务:`ls /opt/agent/runtime/web/data/sub_agent_tasks/`
|
||||
4. 查看日志:`tail -100 /opt/agent/runtime/web/logs/host_workspace_debug.log`
|
||||
5. 查看子智能体任务文件:`ls /opt/agent/runtime/web/data/sub_agent_tasks/<task_id>/`
|
||||
|
||||
## 已知坑
|
||||
|
||||
- **前端构建不能服务器执行**:2G 内存会 OOM,必须本地 build 后 scp 上传 `static/dist/`
|
||||
- **venv 符号链接问题**:从 macOS 复制来的 venv,启动脚本 `fix_venv()` 会自动检测重建
|
||||
- **子智能体现为 Python 协程**:`easyagent/` 目录已废弃,不再作为子智能体入口
|
||||
- **数据迁移后路径变更**:运行态数据根的环境变量由 `AGENTS_DATA_ROOT` 改为 `ASTRION_DATA_ROOT`,工作区内部目录由 `.agents/` 改为 `.astrion/`
|
||||
13
.astrion/memory/config_system_and_deployment.md
Normal file
13
.astrion/memory/config_system_and_deployment.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
name: config_system_and_deployment
|
||||
description: 当修改配置加载逻辑、服务器部署流程、skill 归档路径或理解 settings.json 结构时,应该索引本记忆
|
||||
---
|
||||
|
||||
## Skill 归档路径约定(2026-07-09 更新)
|
||||
|
||||
- **内置 skill 库**:`AGENT_SKILLS_DIR`(源码树 `agentskills/`),只放通用、自带、适用于项目的 skill。
|
||||
- **用户自定义 skill 归档目录**:`CUSTOM_SKILLS_DIR = <RUNTIME_ROOT>/<mode>/agentskills`
|
||||
- host 模式:`~/.astrion/astrion/host/agentskills/`(全局,不按用户拆分)
|
||||
- web/docker 模式:`~/.astrion/astrion/web/agentskills/`(可与 per-user `personal/agentskills` 并存)
|
||||
- **create_skill 行为**:统一通过 `infer_private_skills_dir(data_dir)` 解析目标目录,host 模式不再特殊处理为源码树 `agentskills/`。
|
||||
- **实验/测试 skill**:如 `test` 应从源码树 `agentskills/` 移出,归档到 `CUSTOM_SKILLS_DIR`,不作为内置 skill 发布。
|
||||
66
.astrion/memory/conversation_model_persistence.md
Normal file
66
.astrion/memory/conversation_model_persistence.md
Normal file
@ -0,0 +1,66 @@
|
||||
---
|
||||
name: conversation_model_persistence
|
||||
description: 当加载/恢复对话时发现模型未恢复,或新建对话后旧对话模型被覆盖时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 对话模型持久化约定
|
||||
|
||||
## 核心规则
|
||||
|
||||
- 每个对话的 `model_key` 保存在对话文件的 `metadata.model_key` 中。
|
||||
- 切换模型时(`/api/model`)会立即调用 `utils/conversation_manager/ConversationManager.save_conversation(..., model_key=terminal.model_key)` 写入对话文件。
|
||||
- **加载对话时必须恢复该对话保存的模型**,不能沿用终端当前的默认模型或用户个性化默认模型。
|
||||
- **创建新对话时,必须先保存旧对话、再应用默认模型到新对话**,否则旧对话的 `model_key` 会被默认模型覆盖。
|
||||
|
||||
## 关键实现点
|
||||
|
||||
### 1. 手动加载对话
|
||||
|
||||
路径:`/api/conversations/<conversation_id>/load` → `core/web_terminal.py::WebTerminal.load_conversation()`
|
||||
|
||||
- 该方法会从对话元数据中恢复 `thinking_mode`、`permission_mode`、`execution_mode`、`network_permission` 等状态。
|
||||
- **必须同时恢复 `model_key`**:
|
||||
```python
|
||||
saved_model_key = meta.get("model_key")
|
||||
if saved_model_key:
|
||||
try:
|
||||
self.set_model(saved_model_key)
|
||||
except Exception as exc:
|
||||
logger.warning("加载对话模型 %s 失败: %s", saved_model_key, exc)
|
||||
```
|
||||
- 恢复后,`terminal.model_key` 即对话保存的模型,`get_status()` 和 API 返回的 `model_key` 都会正确。
|
||||
|
||||
### 2. 程序重启后自动恢复最近对话
|
||||
|
||||
路径:`core/web_terminal.py::WebTerminal._ensure_conversation()`
|
||||
|
||||
- Web 终端初始化时会自动加载最近的对话。
|
||||
- 原来的实现只调用 `utils/context_manager/ContextManager.load_conversation_by_id()`,不会恢复 `model_key` 等运行时状态。
|
||||
- 应改为调用 `self.load_conversation(conv_id)`,以复用完整的状态恢复逻辑(含模型恢复)。
|
||||
|
||||
### 3. 创建新对话时保护旧对话的模型
|
||||
|
||||
路径:`core/web_terminal.py::WebTerminal.create_new_conversation()`
|
||||
|
||||
- **错误顺序**:先 `set_model(默认模型)` → 再 `start_new_conversation()` → `start_new_conversation()` 内部先 `save_current_conversation()` → 旧对话被按默认模型保存,覆盖了之前的模型。
|
||||
- **正确顺序**:
|
||||
1. 计算默认 `run_mode`、`permission_mode` 并应用(不改 `model_key`)。
|
||||
2. 调用 `start_new_conversation()`,此时 `terminal.model_key` 仍是旧对话的模型,旧对话被正确保存。
|
||||
3. 新对话创建完成后,再 `set_model(默认模型)`,并 `save_conversation` 更新新对话的 `model_key`。
|
||||
|
||||
### 4. 前端
|
||||
|
||||
路径:`static/src/app/methods/conversation/load.ts::loadConversation()`
|
||||
|
||||
- 前端在调用 `/api/conversations/<id>/load` 后,会根据返回的 `result.model_key` 更新当前模型:
|
||||
```typescript
|
||||
if (typeof result.model_key === 'string' && result.model_key) {
|
||||
this.modelSet(result.model_key);
|
||||
}
|
||||
```
|
||||
- 后端修复后,返回的 `model_key` 即为对话保存的模型,前端会自动应用。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `set_model()` 可能因图片/视频不兼容或模型被禁用而抛异常,加载场景下应捕获并记录警告,不要让加载失败。
|
||||
- 如果对话元数据中没有 `model_key`(旧数据或新建对话),保持当前模型即可。
|
||||
210
.astrion/memory/docker_toolbox_enhancement_plan.md
Normal file
210
.astrion/memory/docker_toolbox_enhancement_plan.md
Normal file
@ -0,0 +1,210 @@
|
||||
---
|
||||
name: docker_toolbox_enhancement_plan
|
||||
description: 当需要继续推进 my-agent-shell 容器镜像增强改造、查看本地实验结果、或在云端构建新版镜像时,应该索引本记忆
|
||||
---
|
||||
|
||||
# my-agent-shell 容器镜像增强改造计划与本地实验进度
|
||||
|
||||
> 记录时间:2026-06-25
|
||||
> 相关记忆:`cloud_server_disk_usage`(服务器磁盘现状)
|
||||
|
||||
## 背景
|
||||
|
||||
当前运行在云端的 `my-agent-shell` 容器(镜像 `my-agent-shell:latest`,基于 `python:3.11-slim`)依赖不足:
|
||||
|
||||
- docx/pptx skill 缺少 `defusedxml`
|
||||
- pptx skill 推荐的 `markitdown` 未安装
|
||||
- 完全缺少 LibreOffice / soffice
|
||||
- 完全缺少 pandoc、pdftoppm(poppler-utils)
|
||||
- 完全缺少 Node.js / npm
|
||||
- 完全缺少中文字体(`fc-list` 为 0)
|
||||
|
||||
导致用户常见的 Office 转换、图片处理、OCR、网页截图等需求无法跑通。
|
||||
|
||||
## 改造方案
|
||||
|
||||
### 方案 B(已选定)
|
||||
|
||||
在最小方案基础上扩展常见业务能力:
|
||||
|
||||
**系统 apt 包:**
|
||||
- Office:libreoffice、pandoc、poppler-utils
|
||||
- 图片/视频:imagemagick、ffmpeg
|
||||
- OCR:tesseract-ocr、tesseract-ocr-chi-sim、tesseract-ocr-chi-tra
|
||||
- 网页:chromium
|
||||
- 字体:fonts-noto-cjk、fonts-noto-color-emoji、fonts-liberation
|
||||
- 开发工具:jq、ripgrep、fd-find、tree
|
||||
|
||||
**Python pip 包(加入 `docker/toolbox-requirements.txt`):**
|
||||
- defusedxml
|
||||
- markitdown[docx]
|
||||
- pytesseract
|
||||
- pdf2image
|
||||
|
||||
**Node 全局包:**
|
||||
- docx
|
||||
- pptxgenjs
|
||||
|
||||
**特殊处理:**
|
||||
- 用 NodeSource 安装 Node 20 LTS,避免 Debian 源版本过老
|
||||
- 修改 ImageMagick policy.xml 允许读写 PDF
|
||||
- 设置 `NODE_PATH=/usr/lib/node_modules`
|
||||
|
||||
## 已修改文件
|
||||
|
||||
### 1. `docker/terminal.Dockerfile`
|
||||
|
||||
主要变更:
|
||||
- apt 安装段加入 libreoffice、pandoc、poppler-utils、imagemagick、ffmpeg、tesseract、chromium、中文字体、开发工具
|
||||
- 增加 ImageMagick PDF 策略调整 RUN 层
|
||||
- 增加 NodeSource Node 20 安装
|
||||
- 增加 `npm install -g docx pptxgenjs`
|
||||
- 末尾添加 `ENV NODE_PATH="/usr/lib/node_modules"`
|
||||
|
||||
### 2. `docker/toolbox-requirements.txt`
|
||||
|
||||
新增:
|
||||
- `defusedxml`
|
||||
- `markitdown[docx]`
|
||||
- `pytesseract`
|
||||
- `pdf2image`
|
||||
|
||||
## 本地实验进度
|
||||
|
||||
### 实验环境
|
||||
|
||||
- 本地 macOS 15.6.1, arm64
|
||||
- Docker Desktop
|
||||
- 构建命令:
|
||||
```bash
|
||||
cd /Users/jojo/Desktop/agents/正在修复中/agents
|
||||
docker build -f docker/terminal.Dockerfile -t my-agent-shell:local .
|
||||
```
|
||||
|
||||
### 实验结果
|
||||
|
||||
| 指标 | 结果 |
|
||||
|------|------|
|
||||
| 构建成功 | ✅ 是 |
|
||||
| 新镜像大小 | **2.9 GB**(原镜像 913 MB) |
|
||||
| 镜像增加 | 约 **2 GB** |
|
||||
| 空闲内存 | ~10 MB |
|
||||
| 单任务峰值内存 | ~40 MB |
|
||||
| 并发 5×soffice + 1×chromium 峰值内存 | **~237 MB** |
|
||||
|
||||
### 功能验证(全部通过)
|
||||
|
||||
| 测试项 | 结果 |
|
||||
|--------|------|
|
||||
| `soffice --headless --convert-to pdf` docx→pdf | ✅ |
|
||||
| `pandoc docx -o md` | ✅ |
|
||||
| `pdftoppm` pdf→png | ✅ |
|
||||
| `markitdown` 读取 docx/pptx | ✅ |
|
||||
| `python-docx` / `python-pptx` | ✅ |
|
||||
| Node `docx-js` 生成 docx | ✅ |
|
||||
| Node `pptxgenjs` 生成 pptx | ✅ |
|
||||
| ImageMagick 图片裁剪 | ✅ |
|
||||
| matplotlib 生成中文字体表格图片 | ✅ |
|
||||
| tesseract OCR 中英文 | ✅(可运行,中文识别准确度一般) |
|
||||
| chromium 网页截图 | ✅ |
|
||||
| `pdf2image` PDF 转图片 | ✅ |
|
||||
| docx/pptx skill 脚本 import | ✅ |
|
||||
|
||||
### 遇到的问题与解决
|
||||
|
||||
1. **markitdown 读 docx 报 MissingDependencyException**
|
||||
- 原因:默认 `markitdown` 不包含 docx converter 的 `mammoth` 依赖
|
||||
- 解决:改成 `markitdown[docx]`
|
||||
|
||||
2. **Node 全局安装的 `docx`/`pptxgenjs` require 不到**
|
||||
- 原因:node 默认不搜索 `/usr/lib/node_modules`
|
||||
- 解决:Dockerfile 添加 `ENV NODE_PATH="/usr/lib/node_modules"`
|
||||
|
||||
3. **chromium 运行时有 dbus 报错**
|
||||
- 不影响截图结果,属于 headless 环境下的常见警告
|
||||
|
||||
4. **ImageMagick 默认禁止 PDF 处理**
|
||||
- 解决:修改 `/etc/ImageMagick-6/policy.xml` 中 PDF 的 rights 为 `read|write`
|
||||
|
||||
5. **onnxruntime 启动警告**
|
||||
- 警告:`Unknown CPU vendor. cpuinfo_vendor value: 0`
|
||||
- 不影响功能,arm64 容器中的常见提示
|
||||
|
||||
## 尚未完成的工作
|
||||
|
||||
1. **云端构建验证**
|
||||
- 当前只在本地 arm64 构建验证
|
||||
- 需要在云端 x86_64 上实际构建
|
||||
- 云端磁盘紧张(只剩 4GB),需先清理或分阶段构建
|
||||
|
||||
2. **精简镜像体积(可选)**
|
||||
- 2.9GB 较大,可考虑去掉 chromium/ffmpeg 做成"最小方案 B"(约 1.5GB)
|
||||
- 或保留完整方案 B,但需确保云端有足够空间
|
||||
|
||||
3. **react-icons / react / react-dom / sharp**
|
||||
- pptxgenjs skill 文档中提到图标生成需要这几个包
|
||||
- 当前未预装,需要时可在运行时安装或后续加入镜像
|
||||
- `sharp` 涉及原生编译,构建时间和失败风险较高
|
||||
|
||||
4. **更详细的内存压力测试**
|
||||
- 当前只做了简单并发测试
|
||||
- 未测试大文件转换、长时间运行场景
|
||||
|
||||
5. **ImageMagick 安全策略复核**
|
||||
- 当前为支持 PDF 放宽了限制,需确认是否符合安全要求
|
||||
|
||||
## 下一步建议
|
||||
|
||||
### 推荐路径:分阶段上云端
|
||||
|
||||
1. **阶段一:精简必要包**
|
||||
- 去掉 chromium、ffmpeg、imagemagick、tesseract
|
||||
- 保留:libreoffice、pandoc、poppler-utils、node、npm、docx、pptxgenjs、字体、defusedxml、markitdown[docx]
|
||||
- 目标镜像约 1.3-1.5GB
|
||||
- 先验证 Office skill 能跑通
|
||||
|
||||
2. **阶段二:按需加回媒体能力**
|
||||
- 根据实际使用频率,决定是否加回 imagemagick、ffmpeg、chromium、tesseract
|
||||
|
||||
3. **阶段三:优化构建缓存**
|
||||
- 考虑把 apt 层拆成多个 RUN,便于失败后重试
|
||||
- 云端构建时加 `--progress=plain` 便于排查
|
||||
|
||||
## 关键命令
|
||||
|
||||
```bash
|
||||
# 本地构建
|
||||
cd /Users/jojo/Desktop/agents/正在修复中/agents
|
||||
docker build -f docker/terminal.Dockerfile -t my-agent-shell:local .
|
||||
|
||||
# 本地测试容器
|
||||
docker run -d --name agent-test-local my-agent-shell:local tail -f /dev/null
|
||||
|
||||
# 验证基础依赖
|
||||
docker exec agent-test-local bash -c "which soffice pandoc pdftoppm node npm convert ffmpeg chromium tesseract jq rg fdfind tree"
|
||||
|
||||
# 验证 Python 包
|
||||
docker exec agent-test-local bash -c "/opt/agent-venv/bin/python -c 'import defusedxml, markitdown, pytesseract, pdf2image'"
|
||||
|
||||
# 验证 Node 包
|
||||
docker exec agent-test-local bash -c "node -e 'require(\"docx\"); require(\"pptxgenjs\"); console.log(\"OK\")'"
|
||||
|
||||
# 查看镜像大小
|
||||
docker images my-agent-shell:local
|
||||
|
||||
# 监控运行时内存
|
||||
docker stats agent-test-local --no-stream
|
||||
```
|
||||
|
||||
## 重要约束
|
||||
|
||||
- **本地 arm64,云端 x86_64,禁止把本地构建的镜像上传到云端**
|
||||
- 云端磁盘只剩 4GB,必须清理或分阶段构建
|
||||
- 云端内存 2GB,构建/运行需警惕 OOM
|
||||
- 云端网络差,大文件下载可能超时
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `docker/terminal.Dockerfile`
|
||||
- `docker/toolbox-requirements.txt`
|
||||
- 项目记忆:`cloud_server_disk_usage`(服务器磁盘现状)
|
||||
85
.astrion/memory/frontend_debug_log_to_file.md
Normal file
85
.astrion/memory/frontend_debug_log_to_file.md
Normal file
@ -0,0 +1,85 @@
|
||||
---
|
||||
name: frontend_debug_log_to_file
|
||||
description: 当需要把前端日志输出到后端文件(如调试目标模式、轮询状态等)时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 前端日志输出到文件
|
||||
|
||||
## 背景
|
||||
|
||||
项目已内置一套轻量级的前端 → 后端日志上报机制,用于把浏览器端的状态变化/调试信息持久化到服务器日志文件,便于复现和排查问题。
|
||||
|
||||
## 基础设施
|
||||
|
||||
### 后端端点
|
||||
|
||||
- 文件:`server/app_legacy.py`
|
||||
- 端点:`POST /api/client_debug_log`
|
||||
- 写入函数:`log_goal_mode_debug_entry(data: Dict[str, Any])`
|
||||
- 日志文件:`{LOGS_DIR}/goal_mode_debug.log`
|
||||
- 宿主机模式示例:`/Users/jojo/.astrion/astrion/host/logs/goal_mode_debug.log`
|
||||
|
||||
### 前端函数
|
||||
|
||||
- 文件:`static/src/app/methods/common.ts`
|
||||
- 函数:`goalModeDebugLog(event: string, payload: Record<string, any> = {})`
|
||||
- 默认关闭,避免日常刷屏
|
||||
|
||||
## 启用方式
|
||||
|
||||
### 方式一:浏览器控制台临时开启(无需改代码)
|
||||
|
||||
```js
|
||||
localStorage.setItem('goalModeDebug', '1')
|
||||
// 刷新页面后生效
|
||||
```
|
||||
|
||||
### 方式二:代码里强制开启
|
||||
|
||||
在 `common.ts` 中修改 `isGoalModeDebugEnabled()`,让它默认返回 `true`:
|
||||
|
||||
```ts
|
||||
function isGoalModeDebugEnabled() {
|
||||
// ...
|
||||
return true; // 强制开启
|
||||
}
|
||||
```
|
||||
|
||||
### 方式三:运行时开关
|
||||
|
||||
```js
|
||||
window.__GOAL_MODE_DEBUG__ = true
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
在前端需要记录状态的位置调用:
|
||||
|
||||
```ts
|
||||
import { goalModeDebugLog } from './common';
|
||||
|
||||
goalModeDebugLog('event_name', {
|
||||
conversationId: this.currentConversationId,
|
||||
someState: this.someState,
|
||||
// 其他需要记录的字段
|
||||
});
|
||||
```
|
||||
|
||||
调用后会:
|
||||
1. 在浏览器控制台输出 `[GOAL_MODE_DEBUG]` 前缀日志
|
||||
2. 通过 `fetch('/api/client_debug_log', ...)` 上报到后端
|
||||
3. 后端写入 `goal_mode_debug.log`
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 日志默认关闭,只有显式开启后才会输出
|
||||
- `fetch` 调用使用 `void` 前缀,不会阻塞业务流程
|
||||
- 上报失败会被静默捕获,不影响主流程
|
||||
- 如需长期保留某类日志,应把调用代码保留在目标文件中;临时调试后建议清理调用点,只保留 `common.ts` 和后端端点基础设施
|
||||
|
||||
## 历史应用
|
||||
|
||||
- 曾用于排查"第一个对话开启目标模式后停止,目标状态被错误继承到下一个对话"的问题
|
||||
- 修复代码位于 `server/tasks/api.py`:
|
||||
- `/api/tasks/<task_id>/cancel` 取消任务时同步停止工作区目标状态
|
||||
- `POST /api/tasks` 创建非目标模式任务时清理残留的活动目标状态
|
||||
41
.astrion/memory/ocr_client_initialization.md
Normal file
41
.astrion/memory/ocr_client_initialization.md
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
name: ocr_client_initialization
|
||||
description: 当 OCRClient/OpenAI 初始化报错、WebTerminal 创建失败、或升级 openai 包时,应该索引本记忆
|
||||
---
|
||||
|
||||
# OCRClient 初始化与 OpenAI 包兼容性
|
||||
|
||||
## 问题
|
||||
|
||||
`modules/ocr_client.py` 在 `__init__` 中直接创建 `OpenAI(api_key=OCR_API_KEY)`。
|
||||
|
||||
- `openai` 包较新版本(>= 2.40)在 `api_key` 为空时会抛出 `openai.OpenAIError: Missing credentials`
|
||||
- 服务器 settings.json 通常未配置 `OCR_API_KEY`
|
||||
- 这会导致 `WebTerminal`/`MainTerminal` 初始化失败,所有依赖 terminal 的 API 都返回 500
|
||||
|
||||
## 修复
|
||||
|
||||
在 `OCRClient.__init__` 中,仅在 `OCR_API_KEY` 存在时才创建 `OpenAI` 客户端:
|
||||
|
||||
```python
|
||||
self.client = None
|
||||
if OCR_API_KEY:
|
||||
self.client = OpenAI(
|
||||
api_key=OCR_API_KEY,
|
||||
base_url=base_url or None,
|
||||
http_client=self.http_client,
|
||||
)
|
||||
```
|
||||
|
||||
在 `vlm_analyze` 中同时检查 `self.client`:
|
||||
|
||||
```python
|
||||
if not self.client or not OCR_API_KEY or not OCR_API_BASE_URL or not self.model:
|
||||
return {"success": False, "error": "VLM 配置缺失,请设置 OCR_API_BASE_URL / OCR_API_KEY / OCR_MODEL_ID", "warnings": warnings}
|
||||
```
|
||||
|
||||
## 教训
|
||||
|
||||
- 升级依赖包后要检查破坏性变更
|
||||
- 可选功能(OCR/VLM)的客户端初始化不应阻塞主终端启动
|
||||
- 服务器环境缺少某个 API key 时,应优雅降级而不是直接崩溃
|
||||
100
.astrion/memory/opencode_key_replacement_script.md
Normal file
100
.astrion/memory/opencode_key_replacement_script.md
Normal file
@ -0,0 +1,100 @@
|
||||
---
|
||||
name: opencode_key_replacement_script
|
||||
description: 当需要批量替换 ~/.astrion/astrion、~/.astrion/astrion-clone 和服务器 /opt/agent/runtime 中的 OpenCode API key 时,应该索引本记忆
|
||||
---
|
||||
|
||||
---
|
||||
name: opencode_key_replacement_script
|
||||
description: 当需要批量替换 ~/.astrion/astrion、~/.astrion/astrion-clone 和服务器 /opt/agent/runtime 中的 OpenCode API key 时,应该索引本记忆
|
||||
---
|
||||
|
||||
# OpenCode API key 批量替换脚本
|
||||
|
||||
## 脚本位置
|
||||
|
||||
项目根目录下的 `replace_opencode_keys.py`。
|
||||
|
||||
## 作用
|
||||
|
||||
一次性批量替换以下目录中所有 OpenCode 相关的 API key:
|
||||
|
||||
- 本地:`/Users/jojo/.astrion/astrion`
|
||||
- 本地:`/Users/jojo/.astrion/astrion-clone`
|
||||
- 服务器:`/opt/agent/runtime/`(或服务器上 `ASTRION_DATA_ROOT` 指向的目录)
|
||||
|
||||
## 扫描范围
|
||||
|
||||
仅扫描配置文件,自动跳过运行时数据:
|
||||
|
||||
- `settings.json`
|
||||
- `config/*.json`
|
||||
- 跳过 `data/`、`logs/`、对话历史等目录
|
||||
- 跳过已有的 `.bak_` / `.bak` 备份文件
|
||||
|
||||
## 替换规则
|
||||
|
||||
脚本会查找已知的旧 OpenCode key 值(被多个 OpenCode Go 相关环境变量共用的同一串 key),并将其替换为新的 OpenCode key。
|
||||
|
||||
实际会改到的键名通常包括:
|
||||
|
||||
- `settings.json` 中 `env_vars` 下的:
|
||||
- `API_KEY_OPENCODE`
|
||||
- `AGENT_API_KEY`
|
||||
- `AGENT_THINKING_API_KEY`
|
||||
- `AGENT_TITLE_API_KEY`
|
||||
- 指向 `opencode.ai` 的 `API_KEY_KIMI`、`API_KEY_DEEPSEEK`、`API_KEY_QWEN`、`API_KEY_MINIMAX` 等
|
||||
- `config/sub_agent_models.json` 中 `url` 为 `https://opencode.ai/zen/go/v1` 的模型条目的 `apikey`
|
||||
|
||||
`config/custom_models.json` 中一般使用 `${API_KEY_OPENCODE}` 变量引用,key 本身存在 `settings.json`,脚本不会直接修改它。
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
# 只预览,不写入(循环轮换模式)
|
||||
python3 replace_opencode_keys.py --dry-run
|
||||
|
||||
# 正式执行循环轮换(默认:key1→key2, key2→key3, key3→key1)
|
||||
python3 replace_opencode_keys.py
|
||||
|
||||
# 单次指定替换(覆盖默认循环模式)
|
||||
python3 replace_opencode_keys.py --old-key <旧key> --new-key <新key>
|
||||
```
|
||||
|
||||
## 服务器替换流程
|
||||
|
||||
1. 确保脚本已上传至 `/opt/agent/agents/replace_opencode_keys.py`
|
||||
2. 在服务器上修改脚本,把 `Path("/opt/agent/runtime")` 加入 `TARGET_DIRS`
|
||||
3. 先执行 `--dry-run` 预览影响范围
|
||||
4. 去掉 `--dry-run` 正式执行
|
||||
5. 执行 `bash /opt/agent/refresh_agents.sh` 重启 webapp 服务使配置生效
|
||||
6. 删除生成的 `.bak_时间戳` 备份文件
|
||||
|
||||
## 服务器执行方式建议
|
||||
|
||||
- **推荐**:通过 `terminal_session` 打开一个 SSH 会话,在会话里直接运行 `python3 replace_opencode_keys.py ...`
|
||||
- **次选**:使用服务器虚拟环境的完整路径,例如 `/opt/agent/venv/bin/python3 replace_opencode_keys.py ...`
|
||||
- **注意**:在旧版本或未重启前,通过 `run_command` 直接执行 `ssh host "python3 ..."` 时,`run_command` 可能把命令中的 `python3` 重写为本地 macOS 的 Python 绝对路径,导致远程服务器找不到解释器。该问题已在 `modules/terminal_ops/run.py` 中修复:当命令通过 `ssh` 在远程执行时,跳过 `python3`/`pip` 重写。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 执行前会自动在同级目录生成 `.bak_时间戳` 备份。
|
||||
- 如果在沙箱/受限执行环境下运行,原目录不可写时脚本会把备份和修改后的文件写到项目工作区的 `opencode_key_replacement_backup/` 目录下,需要手动复制覆盖。
|
||||
- 建议执行后删除生成的 `.bak_时间戳` 备份文件。
|
||||
- 服务器上配置文件修改后,需要重启 webapp 服务才能生效。
|
||||
|
||||
## 历史记录
|
||||
|
||||
- 2026-06-19:使用脚本将本地两个目录的配置从主 key 替换为备用 key,并删除备份。
|
||||
- 2026-06-19:将服务器 `/opt/agent/runtime/` 下的 `settings.json` 和 `config/sub_agent_models.json` 也从主 key 替换为备用 key,并删除备份。
|
||||
- 2026-06-24:将服务器 `/opt/agent/runtime/` 下的 `settings.json`(9 处)和 `config/sub_agent_models.json`(1 处)从备用 key 替换回主 key,随后执行 `bash /opt/agent/refresh_agents.sh` 重启 webapp 服务,并删除备份。期间发现 `run_command` 执行 `ssh host "python3 ..."` 会错误重写 `python3` 为本地 macOS 路径,已修复 `modules/terminal_ops/run.py`。
|
||||
- 2026-06-25:重构脚本为三 key 循环轮换模式(key1/key2/key3,去掉主/备用命名)。将本地 `~/.astrion/astrion` 和 `~/.astrion/astrion-clone` 的配置从 key2 替换为 key3(4 文件 20 处)。服务器尚未操作,当前仍为 key1。
|
||||
- 2026-06-25:将本地 `~/.astrion/astrion` 和 `~/.astrion/astrion-clone` 的配置从 key2 替换为 key3(4 文件 20 处),删除备份。服务器仍为 key1。
|
||||
|
||||
## 相关 key 记录
|
||||
|
||||
| 说明 | Key |
|
||||
|---|---|
|
||||
| key1 | `sk-zNU867TGGXNKxoM0H6Pg6Jq4PYUPUOPDgMlzrOl3SS6jECRMWK5nNi6YMSsHzoZ` |
|
||||
| key2 | `sk-sgt4ERgERPx2axU18PSfmenHv9zNDD3y8M5yr0p4ryhcNSm04CZsCxDHftJ9AkFD` |
|
||||
| key3 | `sk-Jhvi76vJdCEQzvEdl8cAS5MIwBxBS4iTrJYSru2zCAXVnDVQpiZIvrcn9hBwkdZ4` |
|
||||
| legacy(已停用,等同 key1 轮换到 key2) | `sk-j0NdTvEDoqqkKCHEAFXXYneEsuXEiOXNCgLeZARJmfYUBf2Riym7KPFWq1amkB6z` |
|
||||
94
.astrion/memory/pywebview_macos_packaging.md
Normal file
94
.astrion/memory/pywebview_macos_packaging.md
Normal file
@ -0,0 +1,94 @@
|
||||
---
|
||||
name: pywebview_macos_packaging
|
||||
description: 当打包 PyWebview macOS App、调试 "The string did not match" 错误、修改 _packaging/ 目录时,应该索引本记忆
|
||||
---
|
||||
|
||||
# PyWebview macOS 打包项目记忆
|
||||
|
||||
## 目标
|
||||
将 Vue 3 + Python Flask 项目打包为 macOS 桌面 App(PyWebview),不使用 Electron/Tauri 或原生 macOS 对话框。
|
||||
|
||||
## 核心文件
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `_packaging/app.py` | PyWebview 主入口:检测 `.env` → 直接启动 / setup页面 |
|
||||
| `_packaging/setup.html` | 首次设置页面(4步:工作区→模型→管理员→确认),classic暖奶油主题 |
|
||||
| `_packaging/app.spec` | PyInstaller 打包配置 |
|
||||
| `_packaging/dist/AI-Agent-System.app` | 打包产物 |
|
||||
| `_packaging/AI-Agent-System-v8.dmg` | 最后一个可用DMG(527MB) |
|
||||
|
||||
## 关键修复历史
|
||||
|
||||
### 1. DMG 禁止标志
|
||||
- **原因**:DMG 内同时存在 `AI-Agent-System/`(onedir目录)和 `AI-Agent-System.app`
|
||||
- **修复**:DMG 只放 `.app` + `Applications` 符号链接
|
||||
- **hdiutil 创建需完全访问权限**:当前受限制,需切换到完全访问权限模式
|
||||
|
||||
### 2. OpenSSL 符号冲突
|
||||
- **原因**:`_ssl.cpython-311-darwin.so` 加载 psycopg2 自带的旧版 `libcrypto.3.dylib`
|
||||
- **修复**:用 Homebrew OpenSSL 3.6.2 的 dylib 替换 bundle 中 `__dot__dylibs` 目录
|
||||
|
||||
### 3. `.env` 加载路径
|
||||
- **修复**:`config/__init__.py` 的 `_load_dotenv()` 改为 frozen 时读 `~/.astrion/astrion/host/.env`
|
||||
|
||||
### 4. engineio Invalid async_mode
|
||||
- **原因**:PyInstaller frozen 环境缺少 `engineio.async_drivers.threading`
|
||||
- **修复**:`app.spec` hiddenimports 添加 `'engineio.async_drivers.threading'`
|
||||
|
||||
### 5. PyWebview 6.x private_mode 🚨 **关键问题**
|
||||
- **现象**:Playwright 浏览器中正常(0 errors),PyWebview App 中所有 API 返回 HTML 导致 "The string did not match the expected pattern"
|
||||
- **根因**:`webview.start()` 默认 `private_mode=True`,WKWebView 隐私模式下 cookie 不工作,session 丢失
|
||||
- **修复**:`webview.start(private_mode=False)`
|
||||
|
||||
### 6. App Transport Security
|
||||
- **修复**:`app.spec` Info.plist 添加 `NSAppTransportSecurity: { NSAllowsLocalNetworking: True }`
|
||||
|
||||
### 7. 环境变量污染
|
||||
- **问题**:旧环境变量 `AGENTS_HOST_HOME=~/.agents/host-clone` 导致数据目录错误(现已被 `ASTRION_DATA_ROOT` 替代)
|
||||
- **注意**:`~/.astrion/astrion-clone` 是本对话副本数据,不要动
|
||||
- **修复**:`_start_flask` 导入前清除 `ASTRION_DATA_ROOT`/`DATA_DIR`/`LOGS_DIR` 等旧环境变量污染
|
||||
|
||||
### 8. JSON 错误处理器
|
||||
- **修复**:`_start_flask` 中注册全局 error handler,确保任何异常都返回 JSON 而非 HTML
|
||||
|
||||
## 当前状态(2026-06-12)
|
||||
|
||||
### ✅ 已确认正常
|
||||
- Flask 后端在浏览器中完全正常(curl/Playwright 测试 0 errors)
|
||||
- 登录、对话列表、数据加载均正常
|
||||
- API 返回纯 JSON
|
||||
|
||||
### ❌ 仍存在问题
|
||||
- **PyWebview App 中仍然无数据**,前端报 "The string did not match the expected pattern"
|
||||
- 已排除:backend 崩溃、engineio 错误、private_mode、ATS、环境变量污染
|
||||
- **问题定位**:在 PyWebview 的 WKWebView 内部,fetch API 请求被某种机制拦截/修改,导致返回非 JSON 响应
|
||||
|
||||
### 🔍 下一步排查方向
|
||||
1. 在 PyWebview 中启用远程调试(`webview.start(debug=True)`)查看 WKWebView console
|
||||
2. 检查 PyWebview 6.x 的 `request_sent`/`response_received` 事件是否拦截了请求
|
||||
3. 检查 WKWebView 对 `127.0.0.1` 的 same-origin 策略是否有特殊处理
|
||||
4. 尝试用 `localhost` 替代 `127.0.0.1`
|
||||
5. 考虑降级 PyWebview 到 5.x 或尝试其他打包方案(Tauri/Electron)
|
||||
|
||||
## 打包命令
|
||||
```bash
|
||||
# Python 必须是 Homebrew Python 3.11(Xcode Python 3.9 会 SIGKILL)
|
||||
cd _packaging
|
||||
pyinstaller --noconfirm --distpath ./dist --workpath ./build app.spec
|
||||
|
||||
# 安装到 /Applications
|
||||
cp -R dist/AI-Agent-System.app /Applications/
|
||||
codesign --force --deep -s - /Applications/AI-Agent-System.app
|
||||
xattr -cr /Applications/AI-Agent-System.app
|
||||
|
||||
# 创建 DMG(需完全访问权限)
|
||||
hdiutil create -srcfolder dist/AI-Agent-System.app -volname "AI_Agent_System" -ov -format UDZO AI-Agent-System.dmg
|
||||
```
|
||||
|
||||
## 用户要求
|
||||
- 禁止原生 macOS/浏览器组件
|
||||
- setup 页面需与项目设计风格统一
|
||||
- 只读一个 `.env`(数据存储目录 ~/.astrion/astrion/host/.env)
|
||||
- 等待时间不要设置太长
|
||||
- `~/.astrion/astrion-clone` 是本对话副本数据,不要乱动
|
||||
- `~/.astrion/astrion/host` 是正常运行时数据
|
||||
81
.astrion/memory/remaining_long_files_to_split.md
Normal file
81
.astrion/memory/remaining_long_files_to_split.md
Normal file
@ -0,0 +1,81 @@
|
||||
---
|
||||
name: remaining_long_files_to_split
|
||||
description: 当需要继续拆分项目中的过长单个文件时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 仍需拆分的长文件清单
|
||||
|
||||
> 更新于 2026-06-21。
|
||||
> 已完成的拆分:`server/chat/`、`server/status/`、`server/tasks/`、`utils/context_manager/`、`utils/conversation_manager/`、`utils/api_client/`、`utils/tool_result_formatter/`、`modules/file_manager/`、`modules/persistent_terminal/`、`modules/terminal_ops/`、`modules/mcp_client_manager/`、`core/main_terminal_parts/context/`、`core/main_terminal_parts/tools_definition/`、前端 `static/src/app/methods/{ui,message,upload,taskPolling,conversation}/`。
|
||||
> `static/src/components/chat/monitor/MonitorDirector.ts` 按用户要求不参与拆分。
|
||||
|
||||
下列文件仍是单个文件且超过约 500 行,**但 500 行只是参考线,不是硬性指标**。是否拆分取决于可维护性——优先拆**业务逻辑重、复用价值高、边界清晰**的文件;边界模糊、耦合紧密或改动频率低的文件,保持现状更划算。Vue SFC 文件因模板-样式-脚本耦合较深,拆分风险较高,建议先抽离 composable / 子组件,而非直接切分 `.vue`。
|
||||
|
||||
## 后端 Python(高优先级)
|
||||
|
||||
| 文件 | 行数 | 建议拆分方向 |
|
||||
|------|------|--------------|
|
||||
| `core/main_terminal_parts/tools_execution.py` | ~1911 | 按执行阶段拆:准备 / 工具调度 / 结果处理 / 错误恢复 / 流式输出 |
|
||||
| `server/chat_flow_task_main.py` | ~1739 | 拆为消息处理、guidance 队列、流式响应、任务生命周期、历史保存等 mixin |
|
||||
| `server/conversation.py` | ~1981 | 拆为对话 CRUD、回顾(review)、压缩(compression)、待办联动等模块 |
|
||||
| `server/chat_flow_tool_loop.py` | ~1391 | 按循环阶段拆:参数检查、工具调用、结果合并、循环终止 |
|
||||
| `server/app_legacy.py` | ~1431 | 它是 legacy 入口,改动频繁则优先拆为注册/中间件/路由聚合 |
|
||||
|
||||
## 后端 Python(中优先级)
|
||||
|
||||
| 文件 | 行数 | 建议拆分方向 |
|
||||
|------|------|--------------|
|
||||
| `core/main_terminal.py` | ~661 | 拆 init / 状态 / 主循环 / 事件处理 mixin |
|
||||
| `core/web_terminal.py` | ~710 | 拆轮询任务 / Socket.IO / REST 接口 / 前端状态同步 |
|
||||
| `core/main_terminal_parts/commands.py` | ~877 | 拆命令解析、分发、内置命令处理 |
|
||||
| `server/context.py` | ~725 | 拆 terminal/workspace 资源获取、会话装饰器、错误处理 |
|
||||
| `server/admin.py` | ~728 | 按管理领域拆:用户/模型/策略/日志/系统配置 |
|
||||
| `server/api_v1.py` | ~704 | 按资源拆子 blueprint |
|
||||
| `modules/background_command_manager.py` | ~727 | 拆生命周期、输出流、持久化 |
|
||||
| `modules/personalization_manager.py` | ~805 | 拆读取/合并/保存/校验 |
|
||||
| `modules/terminal_manager.py` | ~756 | 拆会话池、命令执行、输出缓存 |
|
||||
| `modules/user_manager.py` | ~779 | 拆认证、工作区管理、host/web 双路径 |
|
||||
| `modules/versioning_manager.py` | ~842 | 拆版本记录、快照、回滚、清理 |
|
||||
|
||||
## 前端 TypeScript / Vue(高优先级)
|
||||
|
||||
| 文件 | 行数 | 建议拆分方向 |
|
||||
|------|------|--------------|
|
||||
| `static/src/components/input/InputComposer.vue` | ~2824 | **高风险**:先抽离 composable(输入状态、快捷键、粘贴处理)、子组件(附件列表、工具按钮),保留 `.vue` 外壳 |
|
||||
| `static/src/components/personalization/PersonalizationDrawer.vue` | ~3547 | **高风险**:按设置分组拆成子组件 / tab panels,抽离表单校验 composable |
|
||||
| `static/src/components/chat/ChatArea.vue` | ~1728 | 抽离消息列表、滚动行为、空状态、错误状态为子组件或 composable |
|
||||
| `static/src/composables/useLegacySocket.ts` | ~1885 | 按事件类型拆为多个 handler 模块 |
|
||||
| `static/src/admin/AdminDashboardApp.vue` | ~1959 | 按 admin 子页面拆为独立组件 |
|
||||
| `static/src/admin/PolicyApp.vue` | ~1601 | 按策略维度拆为子表单 / 子组件 |
|
||||
| `static/src/app/bootstrap.ts` | ~1339 | 按初始化阶段拆:配置加载、store 初始化、路由恢复、终端重建 |
|
||||
|
||||
## 前端 TypeScript / Vue(中优先级)
|
||||
|
||||
| 文件 | 行数 | 建议拆分方向 |
|
||||
|------|------|--------------|
|
||||
| `static/src/components/chat/actions/ToolAction.vue` | ~1532 | 拆工具状态展示、参数/结果渲染、审批交互 |
|
||||
| `static/src/components/chat/actions/toolRenderers.ts` | ~1044 | 按工具类型拆 renderer |
|
||||
| `static/src/App.vue` | ~882 | 抽离布局壳、侧边栏、全局弹窗 |
|
||||
| `static/src/stores/personalization.ts` | ~924 | 按功能域拆 actions |
|
||||
| `static/src/stores/task.ts` | ~632 | 若继续增长可拆任务/审批/队列 |
|
||||
| `static/src/stores/tutorial.ts` | ~749 | 拆 tutorial 步骤、状态、触发逻辑 |
|
||||
| `static/src/stores/monitor.ts` | ~677 | 目前可接受,继续增长再拆 |
|
||||
|
||||
## CLI
|
||||
|
||||
| 文件 | 行数 | 建议拆分方向 |
|
||||
|------|------|--------------|
|
||||
| `cli/src/App.tsx` | ~704 | 拆 views / screens / hooks |
|
||||
|
||||
## 拆分原则(与 AGENTS.md 一致)
|
||||
|
||||
- 优先用 **mixin / 子包** 模式,保持 `from xxx import Xxx` 兼容入口。
|
||||
- 拆 Flask blueprint 时,`__init__.py` 创建唯一 blueprint,子模块从包导入并保留装饰器。
|
||||
- 拆分后检查**跨模块共享函数/常量/类属性**,必要时抽到 `common.py` 或显式导入。
|
||||
- 前端 methods 拆完后,入口 `index.ts` 必须用 `...methodGroup` 平铺,避免嵌套对象导致 `this.xxx` 不存在。
|
||||
- 每次拆分后必须:
|
||||
```bash
|
||||
python3 -m py_compile <对应包>/*.py
|
||||
python -m unittest test.test_server_refactor_smoke
|
||||
npm run build --silent 2>&1 | tail -n 5
|
||||
```
|
||||
68
.astrion/memory/server_deployment_venv.md
Normal file
68
.astrion/memory/server_deployment_venv.md
Normal file
@ -0,0 +1,68 @@
|
||||
---
|
||||
name: server_deployment_venv
|
||||
description: 当服务器部署时遇到 venv/pip 损坏、Python 路径错误,或需要更新部署脚本时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 服务器 venv 部署注意事项
|
||||
|
||||
## 背景
|
||||
|
||||
服务器 `/opt/agent/venv` 最初是从 macOS 复制到 Linux(Ubuntu 24.04)的,导致:
|
||||
|
||||
- `python3` 等符号链接指向 macOS 路径(`/Library/Developer/...`)
|
||||
- `pip`、`flask` 等脚本的 shebang 指向 macOS 路径或错误路径
|
||||
- 结果:`python3` 可能能用,但 `pip` 无法执行
|
||||
|
||||
## 修复方法
|
||||
|
||||
### 1. 重建 venv(推荐)
|
||||
|
||||
在服务器上用 Linux 系统 Python 重建:
|
||||
|
||||
```bash
|
||||
/usr/bin/python3.12 -m venv --clear /opt/agent/venv
|
||||
/opt/agent/venv/bin/pip install -r /opt/agent/agents/requirements.txt
|
||||
```
|
||||
|
||||
### 2. 修改后的部署脚本
|
||||
|
||||
#### `/opt/agent/start_webapp_new.sh`
|
||||
|
||||
`fix_venv()` 同时检查 `python3` 和 `pip` 是否可用:
|
||||
|
||||
```bash
|
||||
fix_venv() {
|
||||
if [ -x "$VENV_DIR/bin/python3" ] && "$VENV_DIR/bin/python3" --version &>/dev/null && \
|
||||
[ -x "$VENV_DIR/bin/pip" ] && "$VENV_DIR/bin/pip" --version &>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
# ... 重建 venv ...
|
||||
}
|
||||
```
|
||||
|
||||
#### `/opt/agent/refresh_agents.sh`
|
||||
|
||||
安装依赖前验证 `pip` 是否可执行,不可执行则自动重建 venv:
|
||||
|
||||
```bash
|
||||
VENV_DIR="/opt/agent/venv"
|
||||
if [ -x "$VENV_DIR/bin/pip" ] && "$VENV_DIR/bin/pip" --version &>/dev/null; then
|
||||
"$VENV_DIR/bin/pip" install -r "$AGENTS_DIR/requirements.txt" -q 2>&1 | tail -3
|
||||
else
|
||||
log "venv pip 不可用,重建虚拟环境..."
|
||||
sys_python=$(command -v python3.12 2>/dev/null || command -v python3 2>/dev/null || command -v python 2>/dev/null || true)
|
||||
if [ -z "$sys_python" ]; then
|
||||
log "错误: 找不到系统 Python"
|
||||
exit 1
|
||||
fi
|
||||
"$sys_python" -m venv --clear "$VENV_DIR"
|
||||
"$VENV_DIR/bin/pip" install -r "$AGENTS_DIR/requirements.txt" -q 2>&1 | tail -3
|
||||
log "虚拟环境重建完成"
|
||||
fi
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 服务器内存只有 2G,前端构建不在服务器执行,由本地 `deploy_agents.sh` scp 上传 `static/dist/`
|
||||
- 以后部署不要再从 macOS 复制 venv 到服务器
|
||||
- 如果 pip 包版本升级引入破坏性变更(如 `openai>=2.40` 强制要求 `api_key`),需要同步修复代码
|
||||
27
.astrion/memory/server_frontend_build_oom.md
Normal file
27
.astrion/memory/server_frontend_build_oom.md
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
name: server_frontend_build_oom
|
||||
description: 当服务器执行前端构建失败、OOM、或需要部署前端时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 服务器前端构建 OOM
|
||||
|
||||
## 问题
|
||||
服务器(2G 内存 Linux)执行 `npm run build` 时因内存不足导致 OOM,进程被 kill,服务器可能 SSH 超时无响应。
|
||||
|
||||
## 解决方案
|
||||
**不在服务器上构建前端。** 在本地(macOS,充足内存)构建,然后 scp 上传 `static/dist/`。
|
||||
|
||||
## 操作步骤
|
||||
```bash
|
||||
# 本地构建
|
||||
cd agents && npm run build
|
||||
|
||||
# 打包上传
|
||||
tar czf /tmp/static_dist.tar.gz static/dist/
|
||||
scp /tmp/static_dist.tar.gz root@59.110.19.30:/tmp/
|
||||
ssh root@59.110.19.30 "cd /opt/agent/agents && tar xzf /tmp/static_dist.tar.gz"
|
||||
```
|
||||
|
||||
## 已落实到脚本
|
||||
- `deploy_agents.sh`(本地):自动本地 build + 上传
|
||||
- `refresh_agents.sh`(服务器):已移除 npm build 步骤,注释说明原因
|
||||
23
.astrion/memory/slash_menu_highlight_animation.md
Normal file
23
.astrion/memory/slash_menu_highlight_animation.md
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
name: slash_menu_highlight_animation
|
||||
description: 当修改/菜单选中高亮动画时,应该索引本记忆
|
||||
---
|
||||
|
||||
## / 菜单选中高亮动画 — 设计教训
|
||||
|
||||
### 最终方案
|
||||
- 提取 `skill-slash-menu-wrapper` 外壳层,高亮条(`.skill-slash-menu__highlight`)放在 wrapper 内但**滚动容器外**,用 `position: absolute` 相对于 wrapper 定位
|
||||
- 用一个 `requestAnimationFrame` 循环**同时驱动 scrollTop 和 slashHighlightTop**,相同 easing(easeOutCubic)、相同时间(180ms),帧帧同步
|
||||
- 不使用 CSS transition、scroll-behavior: smooth、scroll 事件
|
||||
|
||||
### 踩过的坑
|
||||
1. **高亮条必须在滚动容器外**:放在内部会随内容滚动,钉死在中间的效果无法实现
|
||||
2. **CSS transition + scroll-behavior: smooth 无法精确同步**:easing 曲线和时间窗口不完全匹配,导致运动快慢不一致
|
||||
3. **scroll 事件有盲区**:当 scrollTop 不变(边界、所有项可见)时不触发,导致高亮条卡死
|
||||
4. **底部 5px 偏差**:`maxScroll` 不是 step(43px) 的整数倍,用 `visibleRows` 推算永远无法精确对齐。正确做法:`highlightTop = gap + index * step - targetScroll`,公式本身就保证了精确对齐
|
||||
|
||||
### 中间区域"固定"原理
|
||||
高亮条和列表以相同距离(step)、相同时间(180ms)、相同 easing 反向运动,视觉上高亮条钉在屏幕中间不动,只有列表在滚。到达滚动极限时,高亮条才开始实际移动。
|
||||
|
||||
### 已选中项 hover 不叠加
|
||||
`.skill-slash-item--active:hover` 设为 `background: transparent`,避免高亮条 + hover 两层背景叠加。
|
||||
24
.astrion/memory/sub_agent_network_permission.md
Normal file
24
.astrion/memory/sub_agent_network_permission.md
Normal file
@ -0,0 +1,24 @@
|
||||
---
|
||||
name: sub_agent_network_permission
|
||||
description: 当需要理解或调整子智能体在受限网络下的行为时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 子智能体进程网络权限设计
|
||||
|
||||
## 问题
|
||||
当用户把「网络权限」设置为受限(`HOST_SANDBOX_NETWORK_PERMISSION=restricted`)时,创建子智能体会失败,子智能体进程内报 `fetch failed (ENOTFOUND)`。原因是子智能体进程本身也被加上了与工具命令相同的宿主机沙箱,导致它无法解析模型 API 域名、无法调用模型接口。
|
||||
|
||||
## 解决方案
|
||||
子智能体进程使用独立的网络权限配置 `SUB_AGENT_HOST_NETWORK_PERMISSION`(默认 `full`),与工具级 `HOST_SANDBOX_NETWORK_PERMISSION` 解耦。
|
||||
|
||||
- 子智能体本身需要调用模型 API,因此默认拥有完整网络,避免在受限网络下直接无法启动。
|
||||
- 主进程对普通 `run_command` / `terminal_input` 等命令单独施加 `HOST_SANDBOX_NETWORK_PERMISSION` 沙箱,这部分限制不受影响。
|
||||
- 子智能体内部执行的 `run_command` 目前不额外加沙箱(macOS 的 `sandbox-exec` 不支持嵌套沙箱,无法在同一个子智能体进程内再套一层)。
|
||||
|
||||
## 配置项
|
||||
- `config/sub_agent.py` 新增 `SUB_AGENT_HOST_NETWORK_PERMISSION`,可通过环境变量覆盖,默认 `full`。
|
||||
- `modules/sub_agent_manager.py` 在构建宿主机沙箱启动计划时使用该配置,而不是直接使用 `HOST_SANDBOX_NETWORK_PERMISSION`。
|
||||
|
||||
## 注意事项
|
||||
- 若未来需要在子智能体内部也对命令做网络限制,必须采用非嵌套方案(例如由主进程提供本地代理/执行服务,或让子智能体通过 localhost 调用主进程代理后再外出)。不能简单在子智能体里再次调用 `sandbox-exec`。
|
||||
- 在 Docker/容器模式下,网络由容器自身策略控制,上述配置不影响容器行为。
|
||||
73
.astrion/memory/sub_agent_python_rewrite.md
Normal file
73
.astrion/memory/sub_agent_python_rewrite.md
Normal file
@ -0,0 +1,73 @@
|
||||
---
|
||||
name: sub_agent_python_rewrite
|
||||
description: 当需要了解子智能体当前实现、修改子智能体代码、或排查子智能体相关问题时,应该索引本记忆
|
||||
---
|
||||
|
||||
# 子智能体现状(Python 主进程内协程)
|
||||
|
||||
## 核心变更
|
||||
|
||||
子智能体已经从 **Node.js 子进程**(`easyagent/src/batch/index.js`)彻底改为 **Python 主进程内协程**。
|
||||
|
||||
## 代码位置
|
||||
|
||||
所有子智能体专用代码集中在 `modules/sub_agent/` 包内:
|
||||
|
||||
```
|
||||
modules/sub_agent/
|
||||
├── __init__.py # 导出 SubAgentManager
|
||||
├── manager.py # 任务调度、创建、终止、状态查询
|
||||
├── task.py # LLM 循环、模型调用、工具执行驱动
|
||||
├── toolkit.py # 8 个工具定义、工具结果格式化、模型配置解析
|
||||
├── prompts.py # 用户消息和系统提示词构建
|
||||
├── tools.py # search_workspace、read_mediafile 本地实现
|
||||
├── state.py # 状态加载、保存、刷新、超时、僵尸清理 Mixin
|
||||
├── stats.py # 统计摘要与结果消息组装 Mixin
|
||||
└── creation.py # 创建参数校验、任务 ID 生成、交付目录解析 Mixin
|
||||
```
|
||||
|
||||
## 执行机制
|
||||
|
||||
- 子智能体作为 `asyncio.Task` 在独立的后台事件循环线程中运行(避免与 Flask-SocketIO threading 模式冲突)。
|
||||
- 所有实际工具调用都通过 `WebTerminal.handle_tool_call()` 执行,因此**自然复用主进程的宿主机沙箱 / Docker 容器链路**。
|
||||
- 子智能体自身不再被整体沙箱限制网络(因为它需要调用模型 API),网络调用走 `utils.api_client.DeepSeekClient`。
|
||||
- 权限固定为 `unrestricted`,但工具执行本身受主进程沙箱/容器/审批链路约束。
|
||||
|
||||
## 模型配置
|
||||
|
||||
- 子智能体模型独立配置,不跟主模型合并。
|
||||
- 配置文件:`~/.astrion/astrion/config/sub_agent_models.json`(由 `SUB_AGENT_MODELS_CONFIG_FILE` 指定)。
|
||||
- 默认读取该文件中的 `default_model` 和 `models` 列表。
|
||||
|
||||
## 可用工具(8 个)
|
||||
|
||||
1. `read_file` - 读取/搜索/抽取文件
|
||||
2. `write_file` - 创建或覆盖文件(支持 append)
|
||||
3. `edit_file` - 精确字符串替换,支持 `replacements` 数组和 `replace_all`
|
||||
4. `run_command` - 执行命令(走主进程沙箱/容器)
|
||||
5. `web_search` - 网络搜索
|
||||
6. `extract_webpage` - 网页内容提取
|
||||
7. `search_workspace` - 工作区搜索
|
||||
8. `read_mediafile` - 读取图片/视频文件(base64 返回)
|
||||
|
||||
## easyagent/ 目录状态
|
||||
|
||||
- `easyagent/` 是旧的 Node.js 子智能体实现,目前**暂时保留但已不再使用**。
|
||||
- 主项目 Python 代码没有任何地方引用 `easyagent/`。
|
||||
- 是否删除待后续决策。
|
||||
|
||||
## 入口点
|
||||
|
||||
- 主项目创建子智能体:`modules.sub_agent.manager.SubAgentManager.create_sub_agent()`
|
||||
- `core/main_terminal.py` 中通过 `SubAgentManager.set_terminal(self)` 注入 WebTerminal 引用。
|
||||
|
||||
## 回归测试
|
||||
|
||||
- 测试脚本:`test/test_sub_agent_regression.py`
|
||||
- 覆盖:write_file、edit_file(多处替换 + replace_all)、search_workspace、read_mediafile、terminate
|
||||
|
||||
## 已删除/清理的内容
|
||||
|
||||
- `config/sub_agent.py` 中已删除 `SUB_AGENT_HOST_NETWORK_PERMISSION` 临时配置。
|
||||
- `easyagent/src/batch/index.js` 已删除(旧子进程入口)。
|
||||
- `AGENTS.md` 和 `CLAUDE.md` 中关于子智能体的描述已更新为当前实现。
|
||||
22
.env.example
22
.env.example
@ -9,8 +9,16 @@ AGENT_THINKING_API_BASE_URL=
|
||||
AGENT_THINKING_API_KEY=
|
||||
AGENT_THINKING_MODEL_ID=
|
||||
|
||||
# 对话标题生成模型(可选,留空则回退到基础推理模型)
|
||||
AGENT_TITLE_API_BASE_URL=
|
||||
AGENT_TITLE_API_KEY=
|
||||
AGENT_TITLE_MODEL_ID=
|
||||
|
||||
# 第三方搜索(可选)
|
||||
AGENT_TAVILY_API_KEY=
|
||||
AGENT_TAVILY_API_KEY_2=
|
||||
AGENT_TAVILY_API_KEY_BACKUP=
|
||||
# 使用哪个 Tavily 环境变量请在 config/search.py 里改 TAVILY_API_KEY_ENV_NAME
|
||||
|
||||
# 每轮最大响应 token(可选)
|
||||
AGENT_DEFAULT_RESPONSE_MAX_TOKENS=32768
|
||||
@ -23,6 +31,20 @@ AGENT_ADMIN_PASSWORD_HASH=
|
||||
# === Web Session =============================================================
|
||||
WEB_SECRET_KEY=replace-with-random-hex
|
||||
|
||||
# === Web 服务 ================================================================
|
||||
# 监听端口(统一来源,main.py / server.app / state 均读取此项)
|
||||
WEB_SERVER_PORT=8091
|
||||
# 监听地址:多用户/服务器用 0.0.0.0;单机便携包建议 127.0.0.1 只听本机
|
||||
WEB_SERVER_HOST=0.0.0.0
|
||||
# 调试模式(同时控制 Flask reloader),生产保持 0
|
||||
WEB_SERVER_DEBUG=0
|
||||
|
||||
# === 运行环境二进制(便携 release 包用,开发环境留默认即可)==================
|
||||
# 子智能体使用的 node 可执行文件;留 "node" 表示走系统 PATH
|
||||
AGENT_NODE_BIN=node
|
||||
# Python 解释器路径;留空表示使用当前进程的 sys.executable
|
||||
AGENT_PYTHON_BIN=
|
||||
|
||||
# === 终端容器沙箱 =============================================================
|
||||
# 模式:docker / host
|
||||
TERMINAL_SANDBOX_MODE=docker
|
||||
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@ -12,17 +12,19 @@ __pycache__/
|
||||
venv/
|
||||
|
||||
# Runtime data (main agent)
|
||||
# 注意:运行态数据默认已迁出源码树到 ~/.astrion/astrion/<mode>/。
|
||||
# 以下条目用于忽略「通过具体目录变量指回源码树」或历史遗留的运行态目录。
|
||||
.astrion/
|
||||
.agents/
|
||||
logs/
|
||||
data/
|
||||
project/
|
||||
users/
|
||||
api/
|
||||
webapp.pid
|
||||
|
||||
# Runtime data (sub agent)
|
||||
sub_agent/tasks/
|
||||
sub_agent/data/
|
||||
sub_agent/logs/
|
||||
sub_agent/project/
|
||||
# 旧版 sub_agent/ 子项目已移除,子智能体逻辑现统一在 easyagent/。
|
||||
sub_agent_results/
|
||||
|
||||
# Misc
|
||||
@ -33,12 +35,20 @@ static/dist/
|
||||
*.tsbuildinfo
|
||||
user_upload/
|
||||
skills/
|
||||
ax_probe_chrome.py
|
||||
|
||||
# Ignore docs and runtime pid
|
||||
webapp.pid
|
||||
# 本地实验残留与历史文档归档(不进仓库)
|
||||
_experiments/
|
||||
|
||||
# Ignore docs
|
||||
doc/
|
||||
|
||||
# Host workspace config (local, use .example)
|
||||
# 部署级配置(含密钥引用 / 机器特定,本地保留,仓库仅留 .example 种子)
|
||||
config/host_workspaces.json
|
||||
config/custom_models.json
|
||||
config/auto_approval.json
|
||||
config/goal_review.json
|
||||
config/host_sandbox_policy.json
|
||||
|
||||
# Claude Code local artifacts (settings, worktrees)
|
||||
.claude/
|
||||
easyagent/models.json
|
||||
|
||||
84
.stylelintrc.cjs
Normal file
84
.stylelintrc.cjs
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Stylelint 配置 —— 颜色 Token 栏杆(规范见 doc/frontend/color_token_spec.md)
|
||||
*
|
||||
* 作用:拦住三类"绕过设计 Token"的写法,让规范从文字变成机制。
|
||||
* 规则1 color-no-hex → 禁止裸十六进制色号(如 #da7756)
|
||||
* 规则2 declaration-property-value-... → 禁止 rgb()/hsl() 字面色、禁止 var(--x, #hex) 兜底
|
||||
* 规则3 media-feature-name-disallowed-list → 禁止组件内新增 prefers-color-scheme 主题分支
|
||||
*
|
||||
* 基线策略(baseline):
|
||||
* - 颜色属性必须走 var(--token)。
|
||||
* - 下方 BASELINE_EXEMPT 列出的是"立规矩之前就存在违规"的存量文件,暂时豁免。
|
||||
* - 新文件不在名单里 → 从第一行起就受三条规则约束。
|
||||
* - 每当治理(清理)掉一个存量文件,就把它从 BASELINE_EXEMPT 删除,它从此受约束、不再回退。
|
||||
* - 设计上永久豁免:_tokens.scss(Token 定义源)、_virtual-monitor.scss(canvas 动画资产)。
|
||||
*/
|
||||
|
||||
// 颜色相关属性:这些属性的值必须用 token,不能写字面色
|
||||
const COLOR_PROPS =
|
||||
'/^(color|fill|stroke|background|background-color|border|border-color|border-top-color|border-right-color|border-bottom-color|border-left-color|box-shadow|outline|outline-color|text-decoration-color|caret-color|column-rule-color|stop-color)$/';
|
||||
|
||||
// 立规矩之前就存在硬编码颜色的存量文件(baseline 豁免,治理后逐个移除)
|
||||
const BASELINE_EXEMPT = [
|
||||
'static/src/admin/AdminDashboardApp.vue',
|
||||
'static/src/admin/ApiAdminApp.vue',
|
||||
'static/src/admin/CustomToolsApp.vue',
|
||||
'static/src/admin/CustomToolsGuideApp.vue',
|
||||
'static/src/admin/PolicyApp.vue',
|
||||
];
|
||||
|
||||
// 三条颜色规则(供默认与 override 复用)
|
||||
const COLOR_RULES = {
|
||||
// 规则1:禁止裸 hex
|
||||
'color-no-hex': [true, { message: '禁止裸 hex 色号,请使用设计 Token:var(--accent) 等(见 color_token_spec.md)' }],
|
||||
// 规则2:禁止 rgb()/hsl() 字面色 + 禁止 var() 兜底色
|
||||
'declaration-property-value-disallowed-list': [
|
||||
{
|
||||
[COLOR_PROPS]: ['/rgba?\\(/', '/hsla?\\(/', '/var\\(\\s*--[^,)]+,/'],
|
||||
},
|
||||
{
|
||||
message:
|
||||
'颜色属性禁止 rgb()/hsl() 字面色或 var(--x, #hex) 兜底,请直接用 var(--token)(见 color_token_spec.md)',
|
||||
},
|
||||
],
|
||||
// 规则3:禁止组件内手写 prefers-color-scheme 主题分支(主题切换只在 _tokens.scss 的 [data-theme] 完成)
|
||||
'media-feature-name-disallowed-list': [
|
||||
['prefers-color-scheme'],
|
||||
{ message: '禁止在组件内用 prefers-color-scheme 写主题分支,颜色随主题变化应通过 Token 自动完成' },
|
||||
],
|
||||
};
|
||||
|
||||
// 关闭三条规则(给存量豁免文件用)
|
||||
const COLOR_RULES_OFF = {
|
||||
'color-no-hex': null,
|
||||
'declaration-property-value-disallowed-list': null,
|
||||
'media-feature-name-disallowed-list': null,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// 设计上永久豁免:Token 定义源 + canvas 动画资产
|
||||
ignoreFiles: [
|
||||
'static/dist/**',
|
||||
'static/src/styles/base/_tokens.scss',
|
||||
'static/src/styles/components/chat/_virtual-monitor.scss',
|
||||
],
|
||||
// 顶层 rules 必填(stylelint 16 要求)。具体规则按文件类型在 overrides 中施加。
|
||||
rules: {},
|
||||
overrides: [
|
||||
{
|
||||
files: ['static/src/**/*.scss'],
|
||||
customSyntax: 'postcss-scss',
|
||||
rules: COLOR_RULES,
|
||||
},
|
||||
{
|
||||
files: ['static/src/**/*.vue'],
|
||||
customSyntax: 'postcss-html',
|
||||
rules: COLOR_RULES,
|
||||
},
|
||||
// baseline:存量文件暂时关闭三条规则(治理后从此数组移除该文件即可恢复约束)
|
||||
{
|
||||
files: BASELINE_EXEMPT,
|
||||
rules: COLOR_RULES_OFF,
|
||||
},
|
||||
],
|
||||
};
|
||||
331
AGENTS.md
331
AGENTS.md
@ -1,10 +1,22 @@
|
||||
# Repository Guidelines (Code-Verified)
|
||||
|
||||
> Last verified against current codebase: 2026-05-11
|
||||
> Last verified against current codebase: 2026-06-25
|
||||
> Scope: `/Users/jojo/Desktop/agents/正在修复中/agents`
|
||||
|
||||
这份文档基于当前仓库实际代码重写。若与未来代码冲突,以代码为准并及时更新本文档。
|
||||
|
||||
> 和用户交流时默认使用中文。
|
||||
|
||||
## 0) 项目名称
|
||||
|
||||
本项目(仓库 `agents`)的暂定产品名为 **Astrion**。
|
||||
|
||||
- **构词**:`Astr-`(希腊语 *astron*,星星/天体)+ `-ion`(粒子/状态后缀,如 photon、electron)。
|
||||
- **意象**:星之子、星际粒子、来自宇宙尘埃的微小发光体。
|
||||
- **寓意**:Agent 像星际间传递信号的粒子,把人类意图传递到工具、文件、终端与子智能体之间;同时把零散任务像星尘聚合成恒星一样,聚合成可执行结果。
|
||||
- **状态**:暂定名,后续若变更需同步更新 `AGENTS.md`、`claude.md` 与项目记忆 `astrion_naming`。
|
||||
- **中文参考**:阿斯特里恩;简称可考虑 **星子**。
|
||||
|
||||
## 1) 当前项目结构(按代码现状)
|
||||
|
||||
- **后端入口**
|
||||
@ -12,20 +24,83 @@
|
||||
- `server/app.py`: 推荐的 Web 服务入口(封装并转发到 `server/app_legacy.py`)
|
||||
- `web_server.py`: 兼容入口,已标记 deprecated,但仍可启动
|
||||
- **后端核心目录**
|
||||
- `server/`: Flask 业务主线(chat/task 以 REST 任务轮询为主,Socket.IO 主要用于兼容与实时辅助通道)
|
||||
- `core/`: 终端与工具编排(`main_terminal.py`、`web_terminal.py`、`main_terminal_parts/*`)
|
||||
- `modules/`: 可复用能力模块(terminal/file/memory/sub_agent/upload_security/user 等)
|
||||
- `server/`: Flask 业务主线(chat/task/status 已拆分为子包:`server/chat/`、`server/status/`、`server/tasks/`;REST 任务轮询为主,Socket.IO 主要用于兼容与实时辅助通道)
|
||||
- `core/`: 终端与工具编排(`main_terminal.py`、`web_terminal.py`、`main_terminal_parts/*`;其中 `main_terminal_parts/context/` 和 `main_terminal_parts/tools_definition` 已拆分为 base + mixin 子包)
|
||||
- `modules/`: 可复用能力模块(terminal/file/memory/sub_agent/upload_security/user 等;`file_manager`、`persistent_terminal`、`terminal_ops`、`mcp_client_manager` 已拆分为子包)
|
||||
- `config/`: 配置拆分(`api.py`, `limits.py`, `terminal.py`, `paths.py` ...),由 `config/__init__.py` 聚合并加载 `.env`
|
||||
- `utils/`: API client、日志、上下文与对话工具等公共函数
|
||||
- `utils/`: API client、日志、上下文与对话工具等公共函数(`api_client`、`tool_result_formatter`、`context_manager`、`conversation_manager` 已拆分为子包;原入口文件保留为兼容入口)
|
||||
- **前端目录**
|
||||
- `static/src/`: Vue 3 + TS 前端
|
||||
- `cli/src/`: React 19 + Ink 6 + TypeScript CLI 前端(正在重写中)
|
||||
- 监控动画相关核心文件:
|
||||
- `static/src/components/chat/monitor/MonitorDirector.ts`
|
||||
- `static/src/stores/monitor.ts`
|
||||
- `static/src/components/chat/monitor/*`
|
||||
- **CLI 相关文档**
|
||||
- `docs/cli_ui_display_spec.md`: CLI 显示层设计说明
|
||||
- `docs/cli_slash_commands_spec.md`: CLI `/` 指令设计说明
|
||||
- **其他子项目/资源**
|
||||
- `android-webview-app/`: Android WebView 客户端工程
|
||||
- `easyagent/`: 独立前端子目录(与根目录前端并存)
|
||||
- `modules/sub_agent/`: 子智能体执行逻辑(主进程内 `asyncio.Task`,工具调用复用主进程链路)
|
||||
- `easyagent/`: 旧版 Node.js 子智能体实现,暂时保留但已不再使用
|
||||
- `_experiments/`: 本地实验残留与历史文档归档目录(**不纳入 git**,见 §7)
|
||||
|
||||
## 1.5) 运行态数据目录与路径变量(2026-06 更新)
|
||||
|
||||
> 设计目标:运行态数据(对话、用户工作区、日志等)默认存放在用户主目录的 `~/.astrion/astrion/` 下,对齐 `~/.claude`、`~/.codex` 惯例,**不落在源码树内**。配置实现见 `config/paths.py`。
|
||||
|
||||
### 1.5.1 默认位置与模式分流
|
||||
|
||||
运行态数据根目录(`data_root`)默认为 `~/.astrion/astrion`,可通过 `ASTRION_DATA_ROOT` 整体搬迁。在该根目录下,按运行模式自动分流(由 `TERMINAL_SANDBOX_MODE` 决定):
|
||||
|
||||
- 宿主机模式(`TERMINAL_SANDBOX_MODE=host`)→ `~/.astrion/astrion/host`
|
||||
- 其它模式(默认 docker / web)→ `~/.astrion/astrion/web`
|
||||
|
||||
数据根目录下还包含:`settings.json`(唯一配置文件)、`config/`(部署级配置,host/web 共享)。每个模式目录下包含:`data/`(对话、用户库、记忆、sub_agents.json、sub_agent_tasks 等)、`users/`(web 多用户工作区)、`api/users/`(API 用户工作区)、`logs/`(日志)。
|
||||
|
||||
### 1.5.2 路径解析优先级(从高到低)
|
||||
|
||||
1. **具体目录环境变量**:`DATA_DIR` / `LOGS_DIR` / `USER_SPACE_DIR` / `API_USER_SPACE_DIR`(单独覆盖某个目录,最高优先级)
|
||||
2. **数据根目录环境变量**:`ASTRION_DATA_ROOT`(整体搬迁运行态数据根目录,默认 `~/.astrion/astrion`)
|
||||
3. **兜底默认**:`~/.astrion/astrion/<mode>`
|
||||
|
||||
具体目录变量支持相对路径(相对仓库根目录展开)、绝对路径与 `~`。
|
||||
|
||||
> 注意:`config/*.json` 分两类:
|
||||
> - **程序能力**(`docker_risk_markers.json`、`skill_hints.json`):是程序行为的一部分,随版本演进,仍锚定源码树。`prompts/`、`agentskills/` 同理。
|
||||
> - **部署级配置**(`custom_models`、`host_workspaces`、`auto_approval`、`goal_review`、`forbidden_commands`、`host_sandbox_policy`):因部署/机器而异或含密钥,外置到 `~/.astrion/astrion/config/`(即 `DEPLOY_CONFIG_DIR`,可单独用该环境变量覆盖,host/web 共享)。读取走 `config.resolve_deploy_config(name)`,回退链:部署目录 → 源码树 `.json` → 源码树 `.json.example`,因此开发环境不必先跑 setup 也能用源码树种子。含密钥/机器特定的 5 个(除 `forbidden_commands`)不纳入 git,仓库仅留 `.example`。
|
||||
|
||||
### 1.5.3 Host / Web 双路径机制(2026-06 新增)
|
||||
|
||||
`paths.py` 新增三个固定变量,支持 host 模式同时读取 web/ 数据:
|
||||
|
||||
- **`IS_HOST_MODE`**:当前是否为 host 模式(布尔值)
|
||||
- **`WEB_DATA_DIR`**:固定指向 `<data_root>/web/data`(不受当前模式影响)
|
||||
- **`WEB_USER_SPACE_DIR`**:固定指向 `<data_root>/web/users`(不受当前模式影响)
|
||||
|
||||
**host 模式**(`modules/user_manager.py`):
|
||||
- 用户列表:同时加载 `host/data/users.json` + `web/data/users.json`(web 用户不覆盖已有)
|
||||
- 工作区列表:合并 host/ 和 web/ 两处
|
||||
- 工作区定位:已有 web 工作区直接复用;新工作区写入 host/
|
||||
- 写操作(创建/删除/重命名):始终走 host 路径
|
||||
|
||||
**web 模式**:只读取 web/ 数据,无变化。
|
||||
|
||||
> 设计意图:host 模式 = host + web 数据都可用;web 模式 = 仅 web 数据可用。
|
||||
|
||||
### 1.5.4 日志策略(2026-06)
|
||||
|
||||
- API 请求体落盘(dump)默认**关闭**,由 `AGENT_API_DUMP_ENABLED` 控制(`1/true/yes/on` 开启)。
|
||||
- 日志混合轮转(实现见 `utils/log_rotation.py`):
|
||||
- 追加型单文件(`host_workspace_debug.log`、`chunk_*.log`、`conn_diag.log`、`api_debug.log`、TaskLogger/ErrorLogger)按大小轮转,默认单文件 20MB、保留 3 份。
|
||||
- 按份计量目录(`api_requests/` 一请求一文件)只保留最近 N 个,默认 30。
|
||||
- 阈值可覆盖:`AGENT_LOG_ROTATE_MAX_BYTES` / `AGENT_LOG_ROTATE_BACKUPS` / `AGENT_DUMP_KEEP`。
|
||||
|
||||
### 1.5.5 数据迁移
|
||||
|
||||
- 迁移脚本:`scripts/migrate_runtime_data.py`(源码树 → 运行态根;复制+备份+可回滚+幂等,`logs/` 丢弃不迁)。
|
||||
- 脚本 `import config` 复用程序同一套路径解析(含 `.env`),**模式由 `.env` 决定**;执行前务必先 `--dry-run` 确认目标。
|
||||
- 清理误迁副本:`scripts/cleanup_misplaced_web.sh`。
|
||||
|
||||
## 2) 当前可用启动/构建命令(已按代码核对)
|
||||
|
||||
@ -38,41 +113,123 @@
|
||||
|
||||
### Frontend(根目录)
|
||||
- 安装依赖:`npm install`
|
||||
- 构建:`npm run build`
|
||||
- 构建:`npm run build --silent 2>&1 | tail -n 5`(默认只看最后 5 行,减少 Vite 构建资源列表等无关输出)
|
||||
- 开发监听(当前脚本是 build watch):`npm run dev`
|
||||
- Lint:`npm run lint`
|
||||
|
||||
### CLI(React / Ink)
|
||||
- 安装依赖:`npm --prefix cli install`
|
||||
- 开发启动:`npm run cli` 或 `npm --prefix cli run dev`
|
||||
- 构建:`npm run cli:build`
|
||||
- 类型检查:`npm run cli:typecheck`
|
||||
- 可执行命令名(构建后):`agents` / `agents-cli`
|
||||
|
||||
## 3) 测试现状(不要再写过时命令)
|
||||
|
||||
- 当前仓库内可见自动化冒烟:`test/test_server_refactor_smoke.py`(`unittest`)
|
||||
- 运行方式:`python -m unittest test.test_server_refactor_smoke`
|
||||
- `test_system_message.py` 依赖外部 `MOONSHOT_API_KEY` 与网络,不属于离线稳定 CI 用例。
|
||||
- 当前仓库未发现 `pytest.ini`/`pyproject.toml`/`tox.ini`;不要默认要求 `pytest` 作为唯一入口。
|
||||
- CLI 当前最小可复现验证:
|
||||
- `npm --prefix cli run typecheck`
|
||||
- `npm --prefix cli run build`
|
||||
- 若改动 `server/chat/`、`server/status/`、`server/tasks/` 等后端接口适配,补充:
|
||||
- `python3 -m py_compile server/chat/*.py server/status/*.py server/tasks/*.py`
|
||||
- `python -m unittest test.test_server_refactor_smoke`
|
||||
|
||||
## 4) 代码修改约定(实用版)
|
||||
|
||||
- **最小改动原则**:只改与需求直接相关文件,避免顺手重构。
|
||||
- **精确读取原则**:禁止整文件通读。阅读/修改前先用搜索工具(grep / 符号查找 / 关键字定位)锁定需要的行段,只精确提取并阅读相关片段,不要把整个文件一次性读进来。
|
||||
- **文件编辑方式**:修改文件时优先使用 `apply_patch` 或其他原生文件编辑工具;尽量不要用 `bash`/`python` 脚本批量改文件,除非原生工具明显不适合。
|
||||
- **后端改动优先级**:先改 `modules/`、`server/` 内对应模块,最后才动入口。
|
||||
- **前端改动优先级**:按 `static/src` 现有分层改(`app/`、`stores/`、`components/`、`composables/`)。
|
||||
- **CLI 改动优先级**:优先在 `cli/src/App.tsx`、`cli/src/components.tsx`、`cli/src/eventMapper.ts`、`cli/src/api.ts` 内做最小闭环修改。
|
||||
- 涉及 monitor 动画/事件联动时,至少同步检查:
|
||||
- `MonitorDirector.ts`(动画与场景执行)
|
||||
- `stores/monitor.ts`(事件队列与状态机)
|
||||
|
||||
## 5) 风格与质量
|
||||
|
||||
### 文件长度与拆分
|
||||
|
||||
- **单文件最好不超过 1000 行**;超过时应优先考虑合理拆分,避免维护成本快速上升。
|
||||
- **行数不是硬性指标**:文件是否拆分取决于**可维护性**,而不是必须小于某个固定行数。
|
||||
- 当单一文件超过约 500 行时,应评估是否具备以下拆分价值:
|
||||
- 业务逻辑过重、职责不单一;
|
||||
- 存在清晰的边界(如按工具类型、按资源、按生命周期阶段);
|
||||
- 拆分后能提高复用性、可读性或测试便利性。
|
||||
- **不要为了拆分而拆分**。边界模糊、耦合紧密或改动频率低的文件,保持现状可能更划算。
|
||||
- Vue SFC 因模板、样式、脚本耦合较深,拆分风险较高;优先抽离 composable 和子组件,而非直接切分 `.vue` 文件。
|
||||
|
||||
- Python:4 空格、`snake_case` 函数、`PascalCase` 类,新增公共函数尽量补 type hints。
|
||||
- Vue/TS:保持现有代码风格,不做无关风格清洗。
|
||||
- CLI React/TS:保留现有 Ink 渲染方式与光标修正逻辑,不要轻易重写输入框定位策略。
|
||||
- 日志:优先复用现有 logger/日志路径,不引入大量临时 `print`。
|
||||
- 提交前至少做与改动相关的最小验证(命令输出或手工步骤要可复现)。
|
||||
- 运行根目录前端构建时,默认使用 `npm run build --silent 2>&1 | tail -n 5`。
|
||||
|
||||
### CLI 当前交互约束(2026-05-15)
|
||||
|
||||
- CLI 连接的是现有本地 Web API(默认 `127.0.0.1:8091`),不是独立 agent runtime。
|
||||
- 启动 CLI 时应清屏、连接本地服务、创建新会话,并将输入区固定在底部。
|
||||
- 当前目录若不在工作区中,应先弹出“是否添加到工作区”的选择。
|
||||
- 思考内容当前默认隐藏,只显示“思考中 / 思考完成”标题;相关折叠代码保留,后续可继续修。
|
||||
- 不要在未获得用户要求的情况下运行交互式 TUI 压测或长时间模拟输入,以免刷屏占满上下文。
|
||||
|
||||
## 5.5) 前端设计风格统一规范(强制)
|
||||
|
||||
> 适用范围:所有前端 UI(以 `static/src` Web 为主;`cli/src` 在视觉可类比处同样适用;以及未来新增的任何界面)。
|
||||
> 约束级别:写新 UI 或改动 UI 时**必须遵守**;遇到存量违规应在最小改动允许范围内顺手修正。
|
||||
> 配色基础设施:两层 token(原始层 + 语义层)定义在 `static/src/styles/base/_tokens.scss`(`:root[data-theme='classic'|'light'|'dark']` + 首屏回退 `:root:not([data-theme])`),切换逻辑见 `static/src/utils/theme.ts`;`.stylelintrc.cjs` 三条规则做防回退栏杆,`build` 含 `stylelint` 步骤。
|
||||
|
||||
1. **禁止边缘光晕**:不使用任何 glow / 外发光 / 彩色光晕效果(大范围彩色 `box-shadow` 扩散、`filter: drop-shadow` 光圈、`::before/::after` 模糊光晕等)。已有的要移除。允许的只是中性、克制的投影——沿用 `--shadow-*` 既有 token,不自造发光阴影。**仅在用户明确允许或要求时才可使用光晕效果。**
|
||||
2. **禁止原生浏览器组件**:不直接使用浏览器默认外观的 `<select>`、`<input type=checkbox/radio/range/date/color>`、`alert/confirm/prompt`、原生右键菜单、原生 tooltip 等,一律换成与项目风格统一的自定义组件(下拉 / 开关 / 单选 / 滑块 / 模态 / Toast)。
|
||||
3. **禁止圆角套娃**:不允许「圆角矩形套圆角矩形套圆角矩形」的多层嵌套卡片——典型垃圾审美,且极大占据有效显示面积。容器层级要扁平,优先用分隔线 / 留白 / 底色区分,不要多包一层带边框圆角的盒子。
|
||||
4. **图标外框对齐**:同一组图标按钮的外框(点击热区 / 容器)必须统一对齐,不能一会居中、一会左对齐、一会右对齐;同组固定相同尺寸与对齐方式。
|
||||
5. **图标视觉对齐而非理论对齐**:按图标视觉重心对齐而非几何边界盒;对重心偏移的图标(三角形 / 播放键 / 放大镜等)做微调,使其「看起来居中」。
|
||||
6. **颜色遵循三模式切换 + 两层 Token**:所有颜色走原生三模式,禁止写死颜色字面量(裸 hex / `rgb()`/`hsl()` 字面色 / `var(--x, #hex)` 兜底),一律用**中性语义** CSS 变量(`--surface-*` / `--text-*` / `--border-*` / `--accent*` / `--state-*`)。三主题定位:**经典抄 Claude 亮色盘**(暖奶油 + 暖橙 primary `#cc785c`)、**浅色抄 ChatGPT 亮色盘**(冷白 + 灰 + 近黑 primary `#181818`)、**深色自研中性灰阶**。亮色表面靠灰阶拉层次(经典 `--surface-base #faf9f5` < `soft #f5f0e8` < `card #efe9de` < `muted #e8e0d2`),不许全塌成白。primary 克制(CTA-only),hover/选中/运行态走中性灰。半透明 tint 用 `color-mix(in srgb, var(--token) N%, transparent)` 派生。`--claude-*`/`--theme-*` 是过时别名,新代码勿用。新增颜色必须三主题(含首屏回退)补齐。
|
||||
7. **带文字容器固定高度**:所有含内部文字的选项 / 按钮 / 标签 / 容器固定高度,禁止因文字多少被撑大撑高;过长文字用省略号或内部滚动处理。
|
||||
8. **窗口设最大尺寸 + 内部滚动**:所有弹窗 / 面板 / 列表设置 `max-height` / `max-width`,超出由内部容器滚动,不顶大整个窗口;滚动条要么隐藏,要么做样式适配,不暴露原生粗滚动条。
|
||||
9. **实体面板禁止半透明**:实体 UI 容器(对话区 / 侧栏 / 下拉/二级菜单 / 抽屉 / 对话框本体 / 状态条 / tooltip / 输入栏)背景必须不透明,且不加 `backdrop-filter` 磨砂。唯一例外:遮罩层 scrim(`--overlay-scrim`,`position:fixed;inset:0`)和刻意玻璃质感装饰保留半透明。
|
||||
|
||||
## 5.6) 工具结果显示规范(强制)
|
||||
|
||||
> 适用范围:所有使用 `tool-result-meta` / `tool-result-content` 结构渲染的工具结果(如 `static/src/components/chat/actions/ToolAction.vue`、`toolRenderers.ts` 等)。
|
||||
> 约束级别:新增或修改工具结果渲染时**必须遵守**。
|
||||
|
||||
1. **meta 部分只放参数**:`tool-result-meta` 仅用于展示工具调用的参数、配置、状态标识等元信息。例如:状态、ID、路径、任务描述、超时时间等。禁止在 meta 区域放置执行结果、统计摘要、输出内容等。
|
||||
2. **content 部分只放结果**:`tool-result-content` 仅用于展示工具执行后的结果、输出、统计、总结等。例如:文件内容、命令输出、搜索命中、执行统计(工作时间 / 调用次数 / 工具次数)、最终回复摘要等。禁止在 content 区域重复展示工具参数。
|
||||
|
||||
## 5.7) 调试输出规范(强制)
|
||||
|
||||
> 约束级别:需要临时加调试日志排查问题时**必须遵守**。
|
||||
|
||||
### 前端调试日志
|
||||
|
||||
- **统一筛选词(强制)**:所有临时 `console.log` 必须使用**同一个**筛选前缀(例如 `[route-debug]`),方便用户在浏览器控制台用该前缀一次性筛选。
|
||||
- **同一次调试任务中严禁使用多个前缀**。如果涉及多个模块(如状态栏、Git 摘要、用户问题),应共用同一个前缀(如 `[status-bar-debug]`),通过日志对象里的字段区分模块,而不是发明多个前缀增加用户筛选成本。
|
||||
- 选择前缀时优先与本次排查的「用户可见现象」对齐,而不是与内部模块名对齐。
|
||||
- **控制数量与时机**:
|
||||
- 禁止一次性输出上百上千条日志刷屏。
|
||||
- 只在关键路径(入口、分支判断、状态变化、导航动作)打印,避免在循环、高频事件、每帧渲染中输出。
|
||||
- 需要大量结构化数据时,优先用 `console.group` / `console.table` 或对象快照,而不是逐条打印。
|
||||
- **用完即清**:问题定位后应及时删除或注释掉临时调试日志,不要长期留在代码里。
|
||||
|
||||
### 后端调试日志
|
||||
|
||||
- **必须写入文件**:后端调试信息禁止直接 `print` 到终端刷屏,必须写入日志文件。
|
||||
- **复用现有 logger**:优先复用项目已有 logger(如 `utils/logger.py`、`modules/multi_agent/debug_logger.py` 等),按模块落到 `~/.astrion/astrion/<mode>/logs/` 下。
|
||||
- **关键状态转换必打**:多智能体、子智能体、任务轮询等复杂链路应在关键状态转换点写结构化日志,便于复现问题后按时间线追溯。
|
||||
|
||||
## 6) Git 工作流(开发 + Review)
|
||||
|
||||
### 6.1 核心原则
|
||||
|
||||
- **永远不直接在 `main` 上开发**。所有改动走功能分支,合入时 Squash Merge。
|
||||
- 每个功能分支合入 main 后,main 上对应一个 commit(一个功能 = 一个 commit),历史干净可回滚。
|
||||
- 默认直接在 `main` 上开发,不强制新建功能分支。
|
||||
- 每个功能建议在 `main` 上保持一个清晰 commit,历史干净可回滚。
|
||||
- 使用 **Conventional Commits**:`feat:` `fix:` `refactor:` `chore:` `docs:` `test:` `perf:`。
|
||||
- 个人项目,不设 PR 和 Review 流程。功能分支 = 隔离容器,写完即合。
|
||||
- 个人项目,不设 PR 和 Review 流程;如用户临时要求隔离开发,再按需新建分支。
|
||||
|
||||
### 6.2 工作流(唯一步骤)
|
||||
|
||||
@ -81,13 +238,12 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
|
||||
```
|
||||
0. 检查工作区状态
|
||||
git status
|
||||
如有未提交的改动(切换分支可能丢失):
|
||||
如有未提交的改动:
|
||||
- 若无关:提醒用户先 commit 或 discard
|
||||
- 若有关:git stash push -m "WIP: <简短描述>" 暂存
|
||||
|
||||
1. 从 main 切出功能分支
|
||||
1. 确认在 main 并同步最新代码
|
||||
git checkout main && git pull origin main
|
||||
git checkout -b feature/<简短描述> main
|
||||
|
||||
2. 开发并提交
|
||||
git add <修改的文件>
|
||||
@ -95,42 +251,30 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
|
||||
(多轮迭代可用 git commit --amend 保持一个 commit)
|
||||
|
||||
3. 推送到远程(备份 + 方便切设备)
|
||||
git push origin feature/<分支名>
|
||||
|
||||
4. Squash Merge 到 main
|
||||
git checkout main && git pull origin main
|
||||
git merge --squash feature/<分支名>
|
||||
git commit -m "feat(scope): 简短描述"
|
||||
git push origin main
|
||||
|
||||
5. 清理
|
||||
git branch -D feature/<分支名>
|
||||
git push origin --delete feature/<分支名>
|
||||
```
|
||||
|
||||
**重要约束**:
|
||||
- AI 必须在本地验证修改能正常运行,再建议用户提交。
|
||||
- 修改完成后主动告知:修改了哪些文件、核心变更点、验证结果。
|
||||
- 修改完成后按改动规模汇报:大修改说明核心变更点与验证结果;小修改只说明核心变更点。
|
||||
- 禁止 AI 在未经用户确认的情况下执行 `git push`。
|
||||
- 如需 Review,在同一对话内直接 `git diff main...feature/<分支>` 即可,不必跨对话。
|
||||
- 不需要每次都执行或输出 diff;如用户要求 Review,再在同一对话内使用 `git diff` 或 `git diff HEAD~1..HEAD`。
|
||||
|
||||
### 6.3 常用命令速查
|
||||
|
||||
| 操作 | 命令 |
|
||||
|------|------|
|
||||
| 切功能分支 | `git checkout -b feature/<名称> main` |
|
||||
| 暂存未完成工作 | `git stash push -m "WIP: xxx"` |
|
||||
| 修改最近 commit | `git commit --amend` |
|
||||
| Squash Merge | `git merge --squash feature/<名称>` |
|
||||
| 强制删除本地分支 | `git branch -D feature/<名称>` |
|
||||
| 删除远程分支 | `git push origin --delete feature/<名称>` |
|
||||
| 同对话内 Review diff | `git diff main...feature/<名称>` |
|
||||
| 同步 main | `git checkout main && git pull origin main` |
|
||||
| 同对话内 Review diff | `git diff` 或 `git diff HEAD~1..HEAD` |
|
||||
| 查看提交历史 | `git log --oneline -10` |
|
||||
|
||||
## 7) 安全与仓库卫生
|
||||
|
||||
- 严禁提交真实密钥(`.env`、token、cookie、用户隐私)。
|
||||
- `logs/`, `data/`, `users/`, `project/` 为运行态目录(见 `.gitignore`),分享前需脱敏。
|
||||
- 运行态数据默认在 `~/.astrion/astrion/<mode>/`(`data/`、`users/`、`logs/`、`api/`),不在源码树内;详见 §1.5。`.gitignore` 仍忽略源码树内的 `logs/`、`data/`、`users/`、`api/`、`project/` 等,以防通过具体变量指回源码树或历史遗留产生污染。分享前需脱敏。
|
||||
- **`_experiments/` 用途**:归档本地实验残留与历史文档(调试记录、旧变更日志、模型测试脚本、翻译资料、旧子智能体文档等)。该目录**不纳入 git**(已在 `.gitignore`)。需要保留但不属于当前主线、又不想直接删的零散文件,统一放这里,不要散落在根目录。
|
||||
- 不要把本地构建产物(如 `static/dist/`、`node_modules/`)纳入提交。
|
||||
|
||||
## 8) Android App 发布联动要求(重要)
|
||||
@ -149,10 +293,11 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
|
||||
## 9) 给 Agent 的硬性要求
|
||||
|
||||
- 先读当前代码再执行,不依赖历史文档记忆。
|
||||
- 输出结果必须带:
|
||||
1. 修改文件列表
|
||||
2. 核心变更点
|
||||
3. 验证结果(已执行命令/未执行原因)
|
||||
- 输出结果按改动规模决定:
|
||||
- 大修改:必须包含核心变更点、验证结果(已执行命令/未执行原因)。
|
||||
- 小修改:只需包含核心变更点。
|
||||
- 不要求每次列出所有修改文件;仅在用户要求、改动复杂或有助于说明时列出。
|
||||
- 不要求每次执行或展示 diff;仅在用户要求 Review 或排查差异时使用。
|
||||
- 如果发现本文档过时,直接更新 `AGENTS.md` 并在结果中说明依据。
|
||||
|
||||
## 10) 宿主机权限模式与沙箱执行机制(2026-05 更新)
|
||||
@ -208,3 +353,119 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
|
||||
- 调试开关(代码变量):`modules/approval_agent.py` 中 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`
|
||||
- 开启后写入:`logs/approval_agent/`
|
||||
- 记录以累积 `messages` 为主,便于对齐主/子智能体会话格式
|
||||
|
||||
## 11) 多智能体模式(multi-agent mode)
|
||||
|
||||
> 对话级开关 `metadata.multi_agent_mode = true` 启用,URL 入口 `/multiagent/<conv_id>`,数据目录 `~/.astrion/astrion/host/mutiagents/`(保留原拼写)。完整设计与踩坑记录见项目记忆 `multi_agent_mode_design`,本节只列 Agent 改代码时必须知道的硬约束。
|
||||
|
||||
### 11.1 角色与实例
|
||||
|
||||
- 主智能体显示名固定为 `Team Leader`,不需要专门的预置角色文件。
|
||||
- 子智能体 = `role_id`(如 `ui-operator` / `full-stack-engineer` / `code-reviewer` / `researcher`)+ `agent_id`(同一 role_id 下从 1 递增)。显示名格式 `{Role Name}_{agent_id}`,例如 `UI Operator_1`,后缀永远带数字。
|
||||
- 主→子 / 子→主 / 子→子三种通信通过工具完成,工具签名见 `modules/multi_agent/tools.py`。
|
||||
- **`send_message_to_sub_agent` 与 `ask_sub_agent` 语义不同,必须保留两者**:前者插入引导消息不阻塞,后者阻塞等待一轮回答。
|
||||
- 子智能体间通信要求同时向主智能体输出汇报,不允许「偷偷沟通」。
|
||||
|
||||
### 11.2 子智能体执行机制
|
||||
|
||||
- 子智能体在主进程内 `asyncio.Task`,跑在独立后台事件循环线程里(避开 Flask-SocketIO threading 冲突)。工具调用复用主进程沙箱/容器链路,网络调用走 `utils.api_client.DeepSeekClient`。
|
||||
- 子智能体在多智能体模式下:
|
||||
- `create_sub_agent` 强制 `run_in_background=False`,不触发 `sub_agent_waiting` 事件,不阻塞前端输入区。
|
||||
- 子智能体自然的 assistant 输出结束(无 tool_calls)即本轮任务结束,进入 `idle`,上下文保留,不算 failed。
|
||||
- 不能把 `output.success == null` 直接判为 `failed`—`_check_task_status()` 对多智能体任务 `status=running/idle` 视为正常态(见 `modules/sub_agent/state.py`)。
|
||||
- idle 等待必须用 `asyncio.Event`,不能用 `threading.Event`,跨线程唤醒用 `call_soon_threadsafe`。
|
||||
- 任务记录里 `multi_agent_mode` 字段必须显式写入,缺失会导致传统后台通知池把多智能体任务当后台任务处理。`reconcile_task_states()` 已对旧任务补回。
|
||||
|
||||
### 11.3 消息池与派发链路(重点)
|
||||
|
||||
**输出端 ↔ 接收端分离**:
|
||||
- 输出端(`SubAgentTask._forward_output_to_master`):子智能体每次 assistant 文本输出都封为标准格式消息并 `push_master_message` 到会话的 `MultiAgentState.pending_master_messages`,不再做事。
|
||||
- 接收端(`MultiAgentState` + dispatch):根据主智能体当前状态选择插入方式:
|
||||
- **情况1(主运行中)**:在 `execute_tool_calls` 末尾 `process_multi_agent_master_messages(inline=True, after_tool_call_id=...)` 插入到下一轮模型 messages 列表里(详见 `server/chat_flow_tool_loop.py:1326`)。
|
||||
- **情况2(主空闲)**:`poll_multi_agent_notifications` spawn 出后台 poll,主对话空闲且 pool 有消息就 drain,调 `_dispatch_multi_agent_idle_messages` 创建 `task_type="notice"` 的后续 task,触发新一轮工作。完整顺序见项目记忆 multi_agent_mode_design 中的「情况2完整链路」图。
|
||||
- **情况3(主最后一轮无工具调用)**:主循环 `if not tool_calls:` 分支调 `process_multi_agent_master_messages(inline=False)` 后 `continue` 继续迭代,Team Leader 在同 task 续跑处理子智能体输出。(实现上与情况2同路径,即情况3通过后续 task 完成情况2)。
|
||||
|
||||
**情况2 硬约束(调试踩过坑)**:
|
||||
1. **Pool 优先,不能等所有 running 退出再 drain**:`ask_master` await 期间 status=running,但子智能体本身不会产生新输出,只有等主对话回答才能解套。pool 有消息就立即 drain,不管 running 状态。「对话处于运行状态」只决定前端显示态,不阻塞 pool 消费。
|
||||
2. **`_dispatch_multi_agent_idle_messages` 持久化不要重复**:前置 N-1 条调 `inject_multi_agent_master_message` 绑定持久化。最后一条只 emit 给在线客户端、不持久化、在后续 `task_manager.create_chat_task` 走的 `handle_task_with_sender` 里才 `add_conversation`。否则历史里会有两条相同 user 消息,前端刷新会被渲染两遍(一条多智能体渲染、一条通知渲染)。
|
||||
3. **Metadata `visibility` 必须显式为 `"chat"`**:`_user_message_ui_defaults("sub_agent")` 默认给 `{visibility: "compact"}`;在 `_dispatch_multi_agent_idle_messages` 构造 `auto_user_message_payload`时,如果有`**ui_defaults`,必须在它之**后**写 `"visibility": "chat"`(dict 字面量中后出现的key 胜出)。同样,在`handle_task_with_sender`处理多智能体消息时,`user_message_metadata.update(multi_agent_meta)`之后要显式`user_message_metadata["visibility"] = "chat"`。
|
||||
4. **必须传 `auto_message_type`**:前端 `isMultiAgentMessage()` 只看 `auto_message_type.startsWith('multi_agent_')`。`auto_user_message_payload` 和 `preceding_user_notices[i].payload` 都必须显式写 `auto_message_type`,字段不能用空值,否则前端 fallback 起通知渲染。
|
||||
|
||||
### 11.4 通知池轮询器完全独立
|
||||
|
||||
- 传统后台子智能体 / 后台 `run_command` 通知:`poll_completion_notifications`,在 `handle_task_with_sender` 结尾的 `needs_completion_poll` 。
|
||||
- 多智能体通知:`poll_multi_agent_notifications`,在 `needs_ma_poll`。两者完全独立,避免 task_manager 单工作区互斥竞争。
|
||||
- `task_complete` 事件中:
|
||||
- `has_running_sub_agents` 只算传统后台任务,不算多智能体。
|
||||
- `has_running_multi_agent` 多智能体专用字段,同时包含 _running_ 实例和 pending master 消息(详见 `server/chat_flow_task_main.py:2324`)。前端通过该字段走独立的 `startMultiAgentTaskProbe`(不启动 `sub_agent_waiting`)。
|
||||
|
||||
### 11.5 渲染与前端约定
|
||||
|
||||
- 多智能体消息渲染条件是**两个独立判断**:
|
||||
- `isMultiAgentMessage()`:看 `auto_message_type.startsWith('multi_agent_')`。
|
||||
- `getMessageVisibility()`:看 `metadata.visibility`。两者都必须正确,消息才能走多智能体渲染分支。任一错都会 fallback 到通知渲染。
|
||||
- 多智能体消息不显示新的 assistant 回复头部(Astrion/工作时间),即 `metadata.starts_work=false`。但前端通过 `has_running_multi_agent` 在 `task_complete` 中单独处理恢复轮询。
|
||||
- 子智能体进度弹窗:输出与工具按真实时间线混排,默认 3 行,过长用省略号或内部滚动;颜色走 `--text-primary` 等语义 token。
|
||||
- 全局工具规范统一适用于多智能体权限:UI 不能引入 `compact` 以外的 fallback 样式习惯复制到多智能体渲染。
|
||||
|
||||
### 11.6 工具结果格式化与前端渲染位置
|
||||
|
||||
新增多智能体工具时,必须同步补齐「后端结果格式化」和「前端结构化渲染」,否则前端会 fallback 到原始 JSON 或空白。
|
||||
|
||||
**后端 formatter(主智能体工具)**
|
||||
- 实现位置:`utils/tool_result_formatter/agent_context.py`
|
||||
- 注册位置:`utils/tool_result_formatter/dispatch.py` 的 `TOOL_FORMATTERS`
|
||||
- 处理入口:所有主智能体工具执行结果,最终由 `format_tool_result_for_context()` 转换为自然语言摘要,写入对话历史。
|
||||
|
||||
**后端 formatter(子智能体通信工具)**
|
||||
- 实现位置:`modules/sub_agent/toolkit.py` 的 `_format_tool_result()`
|
||||
- 覆盖工具:`ask_master` / `ask_other_agent` / `answer_other_agent` / `list_active_sub_agents`
|
||||
- 处理入口:子智能体 tool call 结果回填到子对话上下文前。
|
||||
|
||||
**前端 renderer(主路径)**
|
||||
- 实现位置:`static/src/components/chat/actions/toolRenderers.ts` 的 `renderEnhancedToolResult()`
|
||||
- 调用方:
|
||||
- `static/src/components/chat/MinimalBlocks.vue`
|
||||
- `static/src/components/chat/StackedBlocks.vue`
|
||||
- 注意:这两个视图已移除 `enhanced_tool_display` 开关判断,**所有工具块都强制走结构化渲染**,不再显示原始 JSON。
|
||||
|
||||
**前端 renderer(备用路径)**
|
||||
- 实现位置:`static/src/components/chat/actions/ToolAction.vue` 的 `renderToolResult()`
|
||||
- 调用方:`static/src/components/chat/ChatArea.vue`
|
||||
- 同样已移除原始 JSON fallback,仅作为 ChatArea 独立工具块渲染的备用。
|
||||
|
||||
**新增工具 checklist**
|
||||
1. `modules/multi_agent/tools.py` 定义工具签名。
|
||||
2. `core/main_terminal_parts/tools_execution.py` 实现工具 handler 并返回标准 dict(`success` + 业务字段 + 可选 `error`)。
|
||||
3. `utils/tool_result_formatter/agent_context.py` 新增 `_format_<tool_name>()`,`dispatch.py` 注册。
|
||||
4. `static/src/components/chat/actions/toolRenderers.ts` 新增对应 `render<PascalCaseToolName>()` 并在 `renderEnhancedToolResult()` 中分发。
|
||||
5. 如果是子智能体通信工具,同步在 `modules/sub_agent/toolkit.py` 的 `_format_tool_result()` 中补 formatter。
|
||||
|
||||
### 11.7 调试机制
|
||||
|
||||
- 调试日志统一走 `modules/multi_agent/debug_logger.py` 的 `ma_debug()` 函数,写入 `~/.astrion/astrion/host/logs/multi_agent_loop.log`。
|
||||
- 关键状态转换点必须打 `ma_debug`,便于复现 bug:
|
||||
- `handle_task_with_sender_start`(含 `pending_master_messages_count`)
|
||||
- `state_push_master_message` (交出 `queue_len_before`)
|
||||
- `poll_ma_tick` (每 0.5s 一次,包含 `instance_count` / `statuses` / `pending_count` / `main_active` 的完整状态快照)
|
||||
- `dispatch_ma_idle_enter` / `dispatch_ma_idle_before_create_task` / `dispatch_ma_idle_task_created` / `dispatch_ma_idle_sender_user_message` / `dispatch_ma_idle_exit_ok`
|
||||
- `create_chat_task` 异常 / `provide_answer` 跨循环回写
|
||||
- `runtime_injected` 字段标记(metadata 里能区分多智能体 inline / idle / ask插入路径)
|
||||
|
||||
### 11.8 已知坑
|
||||
|
||||
- 多智能体模式下根本不 `emit` `sub_agent_waiting` 事件,避免前端进入「等待后台子智能体」的输入区阻塞态。
|
||||
- `_announced_sub_agent_tasks` / `notified` 等标记仅适用于传统后台子智能体任务,多智能体任务不走这条通知路径。
|
||||
- 多智能体任务的 `output.json` 中 `status` 在子智能体自然进入 idle 时会被写为 `"idle"`;在 `_check_task_status` 中由 `_check_task_status_keep_alive` 跳过防止错误判定为 `failed`。
|
||||
- 传统后台通知池 `_collect_pending_completion_notices` 在 `task.get("multi_agent_mode")` 为真时跳过该 task;多智能体派出走独立的 `poll_multi_agent_notifications` 路径,不当混用。
|
||||
- `_has_pending_completion_work` 主动排除 `multi_agent_mode=True` 的任务;两者永远独立。
|
||||
|
||||
### 11.9 对话切换与状态保留
|
||||
|
||||
- 切换会话不清理 `_running_tasks` 和 `_sub_agent_instances`。`SubAgentManager` 的全局 tasks 字典按 `task_id` 保持,多智能体状态由 `conversation_id` 在 `get_multi_agent_state` 中查。
|
||||
- 子智能体对话存在 `~/.astrion/astrion/host/host/data/sub_agents/`。重启后走 `manager.restore_sub_agent` 恢复实例引用。
|
||||
- `MultiAgentState` 实例在 Manager 上常驻(dict by `conversation_id`);进程重启走 `from_snapshot` 恢复。
|
||||
|
||||
---
|
||||
|
||||
注:本节按「现有架构 + 多智能体分支」方案描述,与项目记忆 `multi_agent_mode_design` 同步;如两者冲突以代码为准并同步修订本节。
|
||||
@ -1,107 +0,0 @@
|
||||
# Bug 修复说明
|
||||
|
||||
## 修复的问题
|
||||
|
||||
### 1. 内容显示两遍
|
||||
**原因**: WebSocket 和轮询同时工作,导致事件被处理了两次
|
||||
|
||||
**解决方案**:
|
||||
- 在 `app/state.ts` 中添加 `usePollingMode: true` 标志
|
||||
- 在所有关键的 WebSocket 事件处理器开头添加检查:
|
||||
```javascript
|
||||
if (ctx.usePollingMode) {
|
||||
return; // 跳过 WebSocket 事件
|
||||
}
|
||||
```
|
||||
- 修改的事件包括:
|
||||
- `ai_message_start`
|
||||
- `thinking_start/chunk/end`
|
||||
- `text_start/chunk/end`
|
||||
- `tool_preparing/intent/status/start`
|
||||
|
||||
### 2. 刷新后内容消失
|
||||
**原因**: `restoreTaskState()` 清空了 `messages` 数组,导致已加载的历史记录丢失
|
||||
|
||||
**解决方案**:
|
||||
- 修改 `app/methods/taskPolling.ts` 中的 `restoreTaskState()` 方法
|
||||
- 不再清空 `messages` 数组
|
||||
- 只标记状态为进行中(`streamingMessage = true`, `taskInProgress = true`)
|
||||
- 轮询只处理新事件,不重建历史记录
|
||||
|
||||
## 修改的文件
|
||||
|
||||
1. **static/src/app/state.ts**
|
||||
- 添加 `usePollingMode: true` 标志
|
||||
|
||||
2. **static/src/app/methods/taskPolling.ts**
|
||||
- 修改 `restoreTaskState()` 方法,不清空消息
|
||||
|
||||
3. **static/src/composables/useLegacySocket.ts**
|
||||
- 在所有关键事件处理器开头添加轮询模式检查
|
||||
- 修改的事件:
|
||||
- `ai_message_start`
|
||||
- `thinking_start`
|
||||
- `thinking_chunk`
|
||||
- `thinking_end`
|
||||
- `text_start`
|
||||
- `text_chunk`
|
||||
- `text_end`
|
||||
- `tool_preparing`
|
||||
- `tool_intent`
|
||||
- `tool_status`
|
||||
- `tool_start`
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 正常发送消息
|
||||
1. 用户输入消息并发送
|
||||
2. 调用 REST API 创建任务
|
||||
3. 后端在独立线程中运行任务
|
||||
4. 前端每秒轮询一次,获取新事件
|
||||
5. 通过 `handleTaskEvent()` 处理事件,更新界面
|
||||
6. WebSocket 事件被 `usePollingMode` 标志跳过
|
||||
|
||||
### 页面刷新恢复
|
||||
1. 页面加载完成
|
||||
2. 延迟 1 秒后调用 `restoreTaskState()`
|
||||
3. 查找运行中的任务
|
||||
4. 如果找到,标记状态为进行中
|
||||
5. 启动轮询,只处理新事件
|
||||
6. 已加载的历史记录保持不变
|
||||
7. 新事件追加到现有消息后面
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **基本功能**
|
||||
- 发送消息,观察是否只显示一次
|
||||
- 刷新页面,观察历史记录是否保留
|
||||
- 观察新内容是否正常追加
|
||||
|
||||
2. **边界情况**
|
||||
- 任务执行中多次刷新
|
||||
- 任务完成后刷新
|
||||
- 快速连续发送多条消息
|
||||
|
||||
3. **性能**
|
||||
- 长时间运行任务
|
||||
- 大量事件(观察是否有重复)
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
cd static
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建成功!
|
||||
- `static/dist/assets/main.js` (687.87 kB)
|
||||
- `static/dist/assets/task.js` (3.68 kB)
|
||||
|
||||
## 下一步
|
||||
|
||||
现在可以启动服务器测试:
|
||||
```bash
|
||||
python web_server.py
|
||||
```
|
||||
|
||||
访问 http://localhost:8091 测试功能。
|
||||
@ -1,191 +0,0 @@
|
||||
# Bug 修复 v2 - 工具块和轮询优化
|
||||
|
||||
## 修复的问题
|
||||
|
||||
### 1. 工具块无法显示
|
||||
**原因**: 工具事件处理逻辑不完整,缺少关键的工具创建和更新逻辑
|
||||
|
||||
**解决方案**:
|
||||
- 完整实现 `handleToolPreparing()` - 创建工具准备块
|
||||
- 完整实现 `handleToolStart()` - 工具开始执行
|
||||
- 完整实现 `handleToolUpdateAction()` - 工具状态更新和完成
|
||||
- 添加 `update_action` 事件处理(工具完成的关键事件)
|
||||
- 在 WebSocket 中跳过 `update_action` 事件
|
||||
|
||||
### 2. 轮询频率太慢(1秒)
|
||||
**原因**: 1秒的轮询间隔无法提供流式输出的体验
|
||||
|
||||
**解决方案**:
|
||||
- 将轮询间隔从 1000ms 改为 **150ms**
|
||||
- 接近流式输出的效果(每秒约 6-7 次更新)
|
||||
- 在 `stores/task.ts` 中修改 `startPolling()` 方法
|
||||
|
||||
### 3. 刷新后加载两遍内容
|
||||
**原因**:
|
||||
- `restoreTaskState()` 没有检查是否已在流式输出中
|
||||
- `handleTaskComplete()` 会重新加载历史记录
|
||||
|
||||
**解决方案**:
|
||||
- 在 `restoreTaskState()` 中添加状态检查,避免重复恢复
|
||||
- 检查最后一条消息是否是 assistant 消息
|
||||
- 在 `handleTaskComplete()` 中移除 `fetchAndDisplayHistory()`,只更新统计
|
||||
|
||||
## 修改的文件
|
||||
|
||||
### 1. static/src/app/methods/taskPolling.ts
|
||||
**工具处理逻辑**:
|
||||
```javascript
|
||||
handleToolPreparing(data) {
|
||||
// 创建工具准备块
|
||||
const action = {
|
||||
id: data.id,
|
||||
type: 'tool',
|
||||
tool: {
|
||||
status: 'preparing',
|
||||
name: data.name,
|
||||
// ... 完整的工具属性
|
||||
}
|
||||
};
|
||||
msg.actions.push(action);
|
||||
this.preparingTools.set(data.id, action);
|
||||
}
|
||||
|
||||
handleToolStart(data) {
|
||||
// 从 preparing 转为 running
|
||||
let action = this.preparingTools.get(data.preparing_id);
|
||||
action.tool.status = 'running';
|
||||
action.tool.arguments = data.arguments;
|
||||
// ... 更新工具状态
|
||||
}
|
||||
|
||||
handleToolUpdateAction(data) {
|
||||
// 更新工具状态(包括完成)
|
||||
let targetAction = this.toolFindAction(data.id, ...);
|
||||
targetAction.tool.status = data.status;
|
||||
targetAction.tool.result = data.result;
|
||||
// ... 更新工具结果
|
||||
}
|
||||
```
|
||||
|
||||
**恢复逻辑优化**:
|
||||
```javascript
|
||||
async restoreTaskState() {
|
||||
// 检查是否已在流式输出中
|
||||
if (this.streamingMessage || this.taskInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已有 assistant 消息
|
||||
const lastMessage = this.messages[this.messages.length - 1];
|
||||
const hasAssistantMessage = lastMessage && lastMessage.role === 'assistant';
|
||||
|
||||
// 只标记状态,不清空消息
|
||||
this.streamingMessage = true;
|
||||
this.taskInProgress = true;
|
||||
}
|
||||
```
|
||||
|
||||
**任务完成优化**:
|
||||
```javascript
|
||||
handleTaskComplete(data) {
|
||||
// 只更新统计,不重新加载历史
|
||||
this.fetchConversationTokenStatistics();
|
||||
this.updateCurrentContextTokens();
|
||||
// 移除了 fetchAndDisplayHistory()
|
||||
}
|
||||
```
|
||||
|
||||
### 2. static/src/stores/task.ts
|
||||
**轮询频率优化**:
|
||||
```javascript
|
||||
startPolling(eventHandler) {
|
||||
// 150ms 间隔,接近流式输出效果
|
||||
this.pollingInterval = window.setInterval(() => {
|
||||
this.pollTaskEvents(handler);
|
||||
}, 150);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. static/src/composables/useLegacySocket.ts
|
||||
**跳过 update_action 事件**:
|
||||
```javascript
|
||||
ctx.socket.on('update_action', (data) => {
|
||||
if (ctx.usePollingMode) {
|
||||
return; // 跳过 WebSocket 事件
|
||||
}
|
||||
// ... 原有逻辑
|
||||
});
|
||||
```
|
||||
|
||||
## 事件处理流程
|
||||
|
||||
### 工具执行流程
|
||||
1. `tool_preparing` → 创建工具准备块
|
||||
2. `tool_start` → 工具开始执行,更新状态为 running
|
||||
3. `update_action` → 工具完成,更新状态为 completed,显示结果
|
||||
|
||||
### 轮询流程
|
||||
1. 每 150ms 轮询一次
|
||||
2. 获取新事件(通过 `from` 参数)
|
||||
3. 处理事件,更新界面
|
||||
4. 任务完成后停止轮询
|
||||
|
||||
### 刷新恢复流程
|
||||
1. 页面加载完成
|
||||
2. 延迟 1 秒后调用 `restoreTaskState()`
|
||||
3. 检查是否已在流式输出中(避免重复)
|
||||
4. 检查是否已有 assistant 消息(历史已加载)
|
||||
5. 只标记状态,启动轮询
|
||||
6. 轮询只处理新事件,不重建历史
|
||||
|
||||
## 性能优化
|
||||
|
||||
- **轮询频率**: 150ms(每秒约 6-7 次)
|
||||
- **事件增量获取**: 通过 `from` 参数只获取新事件
|
||||
- **避免重复加载**: 检查状态和消息,避免重复恢复
|
||||
- **避免重复显示**: 任务完成后不重新加载历史
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **工具块显示**
|
||||
- 发送需要调用工具的消息
|
||||
- 观察工具块是否正常显示
|
||||
- 观察工具状态变化(preparing → running → completed)
|
||||
- 观察工具结果是否正确显示
|
||||
|
||||
2. **流式输出效果**
|
||||
- 发送消息,观察输出是否流畅
|
||||
- 观察思考块、文本块的更新频率
|
||||
- 对比之前 1 秒轮询的卡顿感
|
||||
|
||||
3. **刷新恢复**
|
||||
- 任务执行中刷新页面
|
||||
- 观察是否只显示一遍内容
|
||||
- 观察新内容是否正常追加
|
||||
- 观察工具块是否保留
|
||||
|
||||
4. **任务完成**
|
||||
- 等待任务完成
|
||||
- 观察是否有重复内容
|
||||
- 观察统计是否正确更新
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
cd static
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建成功!
|
||||
- `static/dist/assets/main.js` (689.89 kB)
|
||||
- `static/dist/assets/task.js` (3.68 kB)
|
||||
|
||||
## 已知改进
|
||||
|
||||
- ✅ 工具块正常显示
|
||||
- ✅ 轮询频率提升到 150ms(接近流式效果)
|
||||
- ✅ 刷新后不重复显示内容
|
||||
- ✅ 任务完成后不重复加载历史
|
||||
- ✅ 状态检查避免重复恢复
|
||||
|
||||
现在可以启动服务器测试了!
|
||||
@ -1,187 +0,0 @@
|
||||
# Bug 修复 v3 - 刷新后重复加载问题
|
||||
|
||||
## 修复的问题
|
||||
|
||||
### 刷新后加载两遍内容
|
||||
**原因**:
|
||||
1. `loadInitialData()` 会调用 `fetchAndDisplayHistory()` 加载历史记录
|
||||
2. 1 秒后 `restoreTaskState()` 启动轮询
|
||||
3. `loadRunningTask()` 设置 `lastEventIndex = 0`,从头开始获取所有事件
|
||||
4. 导致已经在历史中的事件被重复处理
|
||||
|
||||
**解决方案**:
|
||||
|
||||
#### 1. 等待历史加载完成
|
||||
在 `restoreTaskState()` 中检查历史是否已加载:
|
||||
```javascript
|
||||
async restoreTaskState() {
|
||||
// 检查历史是否已加载
|
||||
const hasMessages = Array.isArray(this.messages) && this.messages.length > 0;
|
||||
|
||||
if (!hasMessages) {
|
||||
// 等待历史加载完成后再恢复
|
||||
setTimeout(() => {
|
||||
this.restoreTaskState();
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// 历史已加载,启动轮询
|
||||
taskStore.startPolling(...);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 计算正确的事件偏移量
|
||||
在 `loadRunningTask()` 中获取任务详情,计算已处理的事件数量:
|
||||
```javascript
|
||||
async loadRunningTask(conversationId) {
|
||||
// 查找运行中的任务
|
||||
const runningTask = result.data.find(...);
|
||||
|
||||
if (runningTask) {
|
||||
// 获取任务详情,计算已处理的事件数量
|
||||
const detailResponse = await fetch(`/api/tasks/${runningTask.task_id}`);
|
||||
const detailResult = await detailResponse.json();
|
||||
|
||||
// 设置为当前事件数量,只获取新事件
|
||||
this.lastEventIndex = detailResult.data.next_offset || detailResult.data.events.length;
|
||||
|
||||
debugLog('[Task] 设置起始偏移量:', this.lastEventIndex);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 页面刷新恢复流程(修复后)
|
||||
|
||||
1. **页面加载** (`mounted()`)
|
||||
- 调用 `loadInitialData()`
|
||||
- 加载历史记录(`fetchAndDisplayHistory()`)
|
||||
- 1 秒后调用 `restoreTaskState()`
|
||||
|
||||
2. **任务恢复** (`restoreTaskState()`)
|
||||
- 检查是否已在流式输出中 → 跳过
|
||||
- 查找运行中的任务
|
||||
- **检查历史是否已加载** → 如果未加载,等待 500ms 后重试
|
||||
- 历史已加载 → 继续
|
||||
|
||||
3. **加载任务详情** (`loadRunningTask()`)
|
||||
- 查找运行中的任务
|
||||
- **获取任务详情**
|
||||
- **计算已处理的事件数量**
|
||||
- 设置 `lastEventIndex` 为当前事件数量
|
||||
|
||||
4. **启动轮询** (`startPolling()`)
|
||||
- 从 `lastEventIndex` 开始轮询
|
||||
- **只获取新事件**
|
||||
- 每 150ms 轮询一次
|
||||
|
||||
5. **处理新事件**
|
||||
- 只处理新产生的事件
|
||||
- 追加到已有的历史记录后面
|
||||
- 不重复显示已有内容
|
||||
|
||||
## 关键改进
|
||||
|
||||
### 1. 等待历史加载
|
||||
- 在 `restoreTaskState()` 中检查 `messages` 是否已加载
|
||||
- 如果未加载,延迟 500ms 后重试
|
||||
- 确保历史加载完成后再启动轮询
|
||||
|
||||
### 2. 正确的事件偏移量
|
||||
- 在 `loadRunningTask()` 中获取任务详情
|
||||
- 计算已处理的事件数量(`next_offset` 或 `events.length`)
|
||||
- 设置 `lastEventIndex` 为当前事件数量
|
||||
- 轮询只获取新事件(`from=lastEventIndex`)
|
||||
|
||||
### 3. 避免重复处理
|
||||
- 历史记录由 `fetchAndDisplayHistory()` 加载
|
||||
- 轮询只处理新事件
|
||||
- 不会重复显示已有内容
|
||||
|
||||
## 修改的文件
|
||||
|
||||
### 1. static/src/app/methods/taskPolling.ts
|
||||
**等待历史加载**:
|
||||
```javascript
|
||||
async restoreTaskState() {
|
||||
// 检查历史是否已加载
|
||||
const hasMessages = Array.isArray(this.messages) && this.messages.length > 0;
|
||||
|
||||
if (!hasMessages) {
|
||||
debugLog('[TaskPolling] 历史未加载,等待历史加载完成');
|
||||
setTimeout(() => {
|
||||
this.restoreTaskState();
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('[TaskPolling] 历史已加载,启动轮询');
|
||||
// ... 启动轮询
|
||||
}
|
||||
```
|
||||
|
||||
### 2. static/src/stores/task.ts
|
||||
**计算事件偏移量**:
|
||||
```javascript
|
||||
async loadRunningTask(conversationId) {
|
||||
const runningTask = result.data.find(...);
|
||||
|
||||
if (runningTask) {
|
||||
// 获取任务详情
|
||||
const detailResponse = await fetch(`/api/tasks/${runningTask.task_id}`);
|
||||
const detailResult = await detailResponse.json();
|
||||
|
||||
// 设置为当前事件数量
|
||||
this.lastEventIndex = detailResult.data.next_offset || detailResult.data.events.length;
|
||||
|
||||
debugLog('[Task] 设置起始偏移量:', this.lastEventIndex);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 1. 正常发送消息
|
||||
- 发送消息
|
||||
- 观察输出是否正常
|
||||
- 观察是否只显示一次
|
||||
|
||||
### 2. 刷新页面(任务进行中)
|
||||
- 任务执行中刷新页面
|
||||
- 观察历史记录是否正确显示
|
||||
- 观察是否只显示一次
|
||||
- 观察新内容是否正常追加
|
||||
- 观察工具块是否保留
|
||||
|
||||
### 3. 刷新页面(任务完成后)
|
||||
- 任务完成后刷新页面
|
||||
- 观察历史记录是否完整
|
||||
- 观察是否没有重复内容
|
||||
|
||||
### 4. 多次刷新
|
||||
- 任务执行中多次刷新
|
||||
- 观察每次刷新是否正常
|
||||
- 观察是否有累积的重复内容
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
cd static
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建成功!
|
||||
- `static/dist/assets/main.js` (690.04 kB)
|
||||
- `static/dist/assets/task.js` (4.01 kB)
|
||||
|
||||
## 最终效果
|
||||
|
||||
- ✅ 刷新后不重复显示内容
|
||||
- ✅ 历史记录正确加载
|
||||
- ✅ 新事件正常追加
|
||||
- ✅ 工具块正确显示
|
||||
- ✅ 流畅的输出效果(150ms 轮询)
|
||||
|
||||
现在可以测试了!刷新页面应该只显示一次内容,新内容会正常追加。
|
||||
@ -1,186 +0,0 @@
|
||||
# Bug 修复 v4 - 刷新后后端停止问题
|
||||
|
||||
## 修复的问题
|
||||
|
||||
### 刷新后后端直接停止,不继续执行
|
||||
**原因**:
|
||||
1. 页面刷新时 WebSocket 断开
|
||||
2. `handle_disconnect` 检测到断开且没有其他连接
|
||||
3. 设置 `stop_flags[f"user:{username}"]` 为 `True`
|
||||
4. 任务检查停止标志时,会查找 `stop_flags[task_id]` 和 `stop_flags[f"user:{username}"]`
|
||||
5. 发现用户级别的停止标志,任务停止执行
|
||||
|
||||
**解决方案**:
|
||||
在 `handle_disconnect` 中检查是否有通过 REST API 创建的运行中任务。如果有,说明使用轮询模式,不应该停止任务。
|
||||
|
||||
## 修改的文件
|
||||
|
||||
### server/socket_handlers.py
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
username = connection_users.pop(request.sid, None)
|
||||
has_other_connection = False
|
||||
if username:
|
||||
for sid, user in connection_users.items():
|
||||
if user == username:
|
||||
has_other_connection = True
|
||||
break
|
||||
|
||||
task_info = get_stop_flag(request.sid, username)
|
||||
if isinstance(task_info, dict) and not has_other_connection:
|
||||
task_info['stop'] = True # 设置停止标志
|
||||
# ... 取消任务
|
||||
|
||||
clear_stop_flag(request.sid, None) # 清理所有停止标志
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
username = connection_users.pop(request.sid, None)
|
||||
has_other_connection = False
|
||||
if username:
|
||||
for sid, user in connection_users.items():
|
||||
if user == username:
|
||||
has_other_connection = True
|
||||
break
|
||||
|
||||
# 检查是否有通过 REST API 创建的运行中任务
|
||||
has_rest_api_task = False
|
||||
if username and not has_other_connection:
|
||||
try:
|
||||
from .tasks import task_manager
|
||||
running_tasks = [t for t in task_manager.list_tasks(username) if t.status == "running"]
|
||||
if running_tasks:
|
||||
has_rest_api_task = True
|
||||
debug_log(f"[WebSocket] 用户 {username} 有运行中的 REST API 任务,不停止")
|
||||
except Exception as e:
|
||||
debug_log(f"[WebSocket] 检查 REST API 任务失败: {e}")
|
||||
|
||||
task_info = get_stop_flag(request.sid, username)
|
||||
# 只有在没有其他连接且没有 REST API 任务时才停止
|
||||
if isinstance(task_info, dict) and not has_other_connection and not has_rest_api_task:
|
||||
task_info['stop'] = True
|
||||
# ... 取消任务
|
||||
|
||||
# 清理停止标志(只清理 sid 级别的,不清理 user 级别的)
|
||||
if request.sid in stop_flags:
|
||||
stop_flags.pop(request.sid, None)
|
||||
```
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 停止标志机制
|
||||
|
||||
**stop_flags 结构**:
|
||||
```python
|
||||
stop_flags = {
|
||||
"client_sid": {"stop": bool, "task": asyncio.Task, "terminal": WebTerminal},
|
||||
"user:{username}": {"stop": bool, "task": asyncio.Task, "terminal": WebTerminal},
|
||||
"task_id": {"stop": bool, "task": None, "terminal": None}
|
||||
}
|
||||
```
|
||||
|
||||
**查找顺序**:
|
||||
1. 先查找 `stop_flags[client_sid]`(WebSocket sid 或 task_id)
|
||||
2. 再查找 `stop_flags[f"user:{username}"]`(用户级别)
|
||||
|
||||
### WebSocket 断开流程(修复后)
|
||||
|
||||
1. **检测断开**
|
||||
- 移除 `connection_users[sid]`
|
||||
- 检查是否有其他连接
|
||||
|
||||
2. **检查 REST API 任务**
|
||||
- 查询 `task_manager.list_tasks(username)`
|
||||
- 检查是否有 `status == "running"` 的任务
|
||||
- 如果有,设置 `has_rest_api_task = True`
|
||||
|
||||
3. **决定是否停止**
|
||||
- 如果有其他连接 → 不停止
|
||||
- 如果有 REST API 任务 → 不停止
|
||||
- 否则 → 停止任务
|
||||
|
||||
4. **清理停止标志**
|
||||
- 只清理 `stop_flags[sid]`(sid 级别)
|
||||
- 不清理 `stop_flags[f"user:{username}"]`(用户级别)
|
||||
- 避免影响 REST API 任务
|
||||
|
||||
### 页面刷新流程(修复后)
|
||||
|
||||
1. **WebSocket 断开**
|
||||
- 检测到有运行中的 REST API 任务
|
||||
- 不设置停止标志
|
||||
- 任务继续执行
|
||||
|
||||
2. **页面重新加载**
|
||||
- 加载历史记录
|
||||
- 恢复任务状态
|
||||
- 启动轮询
|
||||
|
||||
3. **任务继续执行**
|
||||
- 后端任务不受影响
|
||||
- 继续生成事件
|
||||
- 前端轮询获取新事件
|
||||
|
||||
## 关键改进
|
||||
|
||||
### 1. 检测 REST API 任务
|
||||
- 在 WebSocket 断开时检查是否有运行中的 REST API 任务
|
||||
- 如果有,说明使用轮询模式,不应该停止
|
||||
|
||||
### 2. 保护 REST API 任务
|
||||
- 只有在没有其他连接且没有 REST API 任务时才停止
|
||||
- 避免 WebSocket 断开影响 REST API 任务
|
||||
|
||||
### 3. 精确清理停止标志
|
||||
- 只清理 sid 级别的停止标志
|
||||
- 不清理用户级别的停止标志
|
||||
- 避免误清理 REST API 任务的停止标志
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 1. 正常发送消息
|
||||
- 发送消息
|
||||
- 观察任务是否正常执行
|
||||
- 观察输出是否正常
|
||||
|
||||
### 2. 刷新页面(任务进行中)
|
||||
- 任务执行中刷新页面
|
||||
- **观察后端是否继续执行**
|
||||
- 观察前端是否正常恢复
|
||||
- 观察新内容是否正常追加
|
||||
|
||||
### 3. 多次刷新
|
||||
- 任务执行中多次刷新
|
||||
- 观察每次刷新后任务是否继续
|
||||
- 观察是否有累积的问题
|
||||
|
||||
### 4. 停止任务
|
||||
- 点击停止按钮
|
||||
- 观察任务是否立即停止
|
||||
- 观察前端状态是否正确
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
cd static
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建成功!
|
||||
|
||||
## 最终效果
|
||||
|
||||
- ✅ 刷新后后端继续执行
|
||||
- ✅ 前端正常恢复状态
|
||||
- ✅ 新内容正常追加
|
||||
- ✅ 工具块正确显示
|
||||
- ✅ 流畅的输出效果(150ms 轮询)
|
||||
- ✅ 不重复显示内容
|
||||
|
||||
现在可以测试了!刷新页面后,后端应该继续执行,前端会正常恢复并显示新内容。
|
||||
244
CLAUDE.md
244
CLAUDE.md
@ -2,42 +2,63 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> 和用户交流时默认使用中文。
|
||||
|
||||
## Project Overview
|
||||
|
||||
多用户 AI Agent 系统,为每个登录用户提供独立的 Docker 容器环境。支持终端交互、文件操作、对话管理和实时监控。主要用于学习和实验"智能体 + 真实 Dev Workflow",代码大量由 AI 生成。
|
||||
多用户 AI Agent 系统,产品暂定名为 **Astrion**(`Astr-` 星星/天体 + `-ion` 粒子后缀,意象为“星之子、星际粒子”)。为每个登录用户提供独立的 Docker 容器环境,支持终端交互、文件操作、对话管理和实时监控。主要用于学习和实验"智能体 + 真实 Dev Workflow",代码大量由 AI 生成。
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Entry Points
|
||||
- **CLI模式**: `python main.py` - 启动命令行交互式 Agent
|
||||
- **Web模式**: `python web_server.py` - 启动 Flask Web 服务器(主链路为 REST 任务轮询,默认端口 8091)
|
||||
- **统一入口**: `python main.py` - 当前实现默认进入 Web 启动流程(thinking_mode=True)
|
||||
- **推荐 Web 入口**: `python -m server.app` - 封装并转发到 `server/app_legacy.py`,主链路为 REST 任务轮询,默认端口 8091
|
||||
- **兼容入口**: `python web_server.py` - 已标记 deprecated,但仍可启动
|
||||
|
||||
### Key Components
|
||||
|
||||
**server/** (Flask 业务主线,chat/task/status 已拆分子包,以 REST 任务轮询为主,Socket.IO 用于兼容与实时辅助通道)
|
||||
- `app.py` / `app_legacy.py` - Web 服务入口与遗留实现
|
||||
- `chat/` / `chat_flow*.py` - 对话接口(已拆分为子包)与任务流编排(runner / tool_loop / stream_loop / task_main 等拆分)
|
||||
- `status/` - 状态/项目/容器/工作区相关接口(已拆分为子包)
|
||||
- `tasks/` - 任务管理接口(已拆分为子包)
|
||||
- `goal_flow.py` - 自主目标循环
|
||||
- `deep_compression.py` - 上下文深度压缩
|
||||
- `conversation.py` / `files.py` / `auth.py` / `admin.py` / `api_v1.py` / `context.py` - 对话、文件、认证、管理、API v1、请求上下文接口
|
||||
|
||||
**core/**
|
||||
- `MainTerminal` - CLI 主终端,处理用户输入和 AI 对话循环
|
||||
- `WebTerminal` - Web 终端,继承 MainTerminal,支持轮询任务与实时通道兼容能力
|
||||
- `MainTerminal` (`main_terminal.py`) - CLI 主终端,处理用户输入和 AI 对话循环;逻辑拆分到 `core/main_terminal_parts/*`(commands / context / tools_definition / tools_execution / tools_policy / tools_read 等)
|
||||
- `WebTerminal` (`web_terminal.py`) - Web 终端,继承 MainTerminal,支持轮询任务与实时通道兼容能力
|
||||
- `tool_config.py` - 定义所有工具的配置和分类
|
||||
|
||||
**modules/**
|
||||
- `user_container_manager.py` - 单用户-单容器管理,负责容器生命周期
|
||||
- `container_file_proxy.py` - 容器内文件代理,通过 `docker exec` 沙箱化文件操作
|
||||
- `terminal_manager.py` - 管理多个持久化终端会话
|
||||
- `file_manager.py` - 文件 CRUD,包含搜索、替换和 append/modify streaming 功能
|
||||
- `conversation_manager.py` - 对话历史持久化
|
||||
- `context_manager.py` - 上下文构建、token统计、对话管理
|
||||
- `persistent_terminal/` - 持久化终端核心(已拆分为 base + mixin 子包)
|
||||
- `terminal_ops/` - 终端命令执行相关能力(已拆分为 base + mixin 子包)
|
||||
- `mcp_client_manager/` - MCP 客户端管理(已拆分为子包)
|
||||
- `file_manager/` - 文件 CRUD,包含搜索、替换和 append/modify streaming 功能(已拆分为子包)
|
||||
- `conversation_manager/` - 对话历史持久化(已拆分为 base + mixin 子包)
|
||||
- `context_manager/` - 上下文构建、token统计、对话管理(已拆分为 base + mixin 子包)
|
||||
- `memory_manager.py` - 长期记忆管理
|
||||
- `todo_manager.py` - 待办事项管理
|
||||
- `sub_agent_manager.py` - 子智能体任务调度
|
||||
- `modules/sub_agent/` - 子智能体任务调度与执行(主进程内协程,工具调用复用主 WebTerminal)
|
||||
- `user_manager.py` - 用户认证和工作区管理
|
||||
- `upload_security.py` - 上传文件隔离扫描
|
||||
|
||||
**utils/**
|
||||
- `api_client.py` - 与 OpenAI-compatible API 交互,支持 thinking mode(首次思考,后续快速)
|
||||
- `api_client.py` - 兼容入口,实际实现已迁移到 `utils/api_client/` 子包(mixin 组织)
|
||||
- `tool_result_formatter.py` - 兼容入口,实际实现已迁移到 `utils/tool_result_formatter/` 子包
|
||||
- `terminal_factory.py` - 终端类型工厂
|
||||
|
||||
**static/**
|
||||
- Vue 前端(聊天主链路使用 REST 任务轮询,保留 Socket.IO 兼容能力),包含对话流、终端输出、资源监控面板
|
||||
- Vue 3 + TS 前端(`static/src/`,分层为 `app/` `stores/` `components/` `composables/`),聊天主链路使用 REST 任务轮询,保留 Socket.IO 兼容能力,包含对话流、终端输出、资源监控面板
|
||||
- 监控动画核心文件:`static/src/components/chat/monitor/MonitorDirector.ts`(动画与场景执行)、`static/src/stores/monitor.ts`(事件队列与状态机)
|
||||
|
||||
**cli/**
|
||||
- React 19 + Ink 6 + TypeScript CLI 前端(正在重写中),连接现有本地 Web API(默认 `127.0.0.1:8091`),不是独立 agent runtime
|
||||
- 关键文件:`cli/src/App.tsx`、`cli/src/components.tsx`、`cli/src/eventMapper.ts`、`cli/src/api.ts`
|
||||
|
||||
**docker/**
|
||||
- `terminal.Dockerfile` - 终端容器镜像构建配置
|
||||
@ -46,31 +67,82 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
### Build & Run
|
||||
```bash
|
||||
# 安装依赖(需要在项目根目录创建 requirements.txt)
|
||||
pip install flask flask-socketio flask-cors openai python-dotenv tiktoken lxml
|
||||
# === 开箱即用(release / 首次部署,推荐)===
|
||||
# 首次初始化:建 venv、装依赖、跑配置向导(模式/端口/管理员/密钥/模型)
|
||||
./setup.sh
|
||||
# 启动(首次无 .env 会自动跑向导,然后起服务)
|
||||
./start.sh
|
||||
# start.sh 透传参数给 server.app,例如:
|
||||
./start.sh --thinking-mode
|
||||
|
||||
# === 手动方式(开发) ===
|
||||
# 安装 Python 依赖
|
||||
pip install -r requirements.txt # 开发用,宽松
|
||||
pip install -r requirements.lock.txt # 可复现/打包用,精确版本
|
||||
|
||||
# 构建终端镜像
|
||||
docker build -f docker/terminal.Dockerfile -t my-agent-shell:latest .
|
||||
|
||||
# CLI 模式
|
||||
# 统一入口(默认进入 Web 启动流程)
|
||||
python main.py
|
||||
|
||||
# Web 模式
|
||||
# 推荐 Web 启动
|
||||
python -m server.app
|
||||
# 可选参数
|
||||
python -m server.app --path ./project --port 8091 --debug --thinking-mode
|
||||
|
||||
# 兼容启动方式(deprecated)
|
||||
python web_server.py
|
||||
# 或指定端口和思考模式
|
||||
python web_server.py --port 8091 --thinking-mode
|
||||
```
|
||||
|
||||
### Testing & Debugging
|
||||
### Frontend (根目录 Vue)
|
||||
```bash
|
||||
npm install
|
||||
# 构建(默认只看最后 5 行,减少 Vite 构建资源列表等无关输出)
|
||||
npm run build --silent 2>&1 | tail -n 5
|
||||
# 开发监听(当前脚本是 build watch)
|
||||
npm run dev
|
||||
# Lint
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### CLI (React / Ink)
|
||||
```bash
|
||||
npm --prefix cli install
|
||||
# 开发启动
|
||||
npm run cli # 或 npm --prefix cli run dev
|
||||
# 构建
|
||||
npm run cli:build
|
||||
# 类型检查
|
||||
npm run cli:typecheck
|
||||
# 可执行命令名(构建后):agents / agents-cli
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# 主要离线冒烟用例(unittest)
|
||||
python -m unittest test.test_server_refactor_smoke
|
||||
|
||||
# 改动后端接口适配(如 server/chat/、server/status/、server/tasks/)时补充
|
||||
python3 -m py_compile server/chat/*.py server/status/*.py server/tasks/*.py
|
||||
|
||||
# CLI 最小可复现验证
|
||||
npm --prefix cli run typecheck
|
||||
npm --prefix cli run build
|
||||
```
|
||||
> 仓库未发现 `pytest.ini`/`pyproject.toml`/`tox.ini`,不要默认要求 `pytest` 作为唯一入口。`test/test_system_message.py` 依赖外部 `MOONSHOT_API_KEY` 与网络,不属于离线稳定用例。
|
||||
|
||||
### Debugging
|
||||
```bash
|
||||
# 查看容器状态
|
||||
docker ps -a | grep agent-term
|
||||
|
||||
# 查看调试日志
|
||||
tail -f logs/debug_stream.log
|
||||
# 查看调试日志(运行态日志默认在 ~/.astrion/astrion/<mode>/logs/)
|
||||
tail -f ~/.astrion/astrion/host/logs/debug_stream.log # host 模式
|
||||
tail -f ~/.astrion/astrion/web/logs/debug_stream.log # web/docker 模式
|
||||
|
||||
# 查看容器统计
|
||||
tail -f logs/container_stats.log
|
||||
tail -f ~/.astrion/astrion/web/logs/container_stats.log
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@ -94,6 +166,30 @@ tail -f logs/container_stats.log
|
||||
- `PROJECT_MAX_STORAGE_MB` - 单用户磁盘配额 (默认 2048MB)
|
||||
- `MAX_ACTIVE_USER_CONTAINERS` - 并发容器数量 (默认 8)
|
||||
|
||||
**数据目录与路径变量** (`config/paths.py`,2026-06 更新):
|
||||
|
||||
运行态数据默认存放在 `~/.astrion/astrion/<mode>`(对齐 `~/.claude`、`~/.codex`),**不落在源码树**。数据根目录(`data_root`)默认为 `~/.astrion/astrion`,可通过 `ASTRION_DATA_ROOT` 整体搬迁;模式由 `TERMINAL_SANDBOX_MODE` 决定:`host` → `~/.astrion/astrion/host`,其它(默认 docker/web)→ `~/.astrion/astrion/web`。数据根目录下还包含 `settings.json`(唯一配置文件)和 `config/`(部署级配置,host/web 共享)。每个模式目录下含 `data/`(对话、用户库、记忆、sub_agents.json、sub_agent_tasks)、`users/`、`api/users/`、`logs/`。
|
||||
|
||||
路径解析优先级(高→低):
|
||||
1. 具体目录变量 `DATA_DIR` / `LOGS_DIR` / `USER_SPACE_DIR` / `API_USER_SPACE_DIR`(单独覆盖某目录)
|
||||
2. 数据根目录变量 `ASTRION_DATA_ROOT`(整体搬迁运行态数据根目录,默认 `~/.astrion/astrion`)
|
||||
3. 兜底 `~/.astrion/astrion/<mode>`
|
||||
|
||||
**Host / Web 双路径机制(2026-06)**:
|
||||
- `paths.py` 新增 `IS_HOST_MODE`、`WEB_DATA_DIR`、`WEB_USER_SPACE_DIR` 三个固定变量
|
||||
- host 模式下 `UserManager` 同时加载 `host/` 和 `web/` 的用户与工作区(web 用户不覆盖已有)
|
||||
- 已有 web 工作区直接复用(保持旧数据可访问),新工作区写入 host/
|
||||
- web 模式:仅读取 web/ 数据,无变化
|
||||
|
||||
> `config/*.json` 分两类:**程序能力**(`docker_risk_markers.json`、`skill_hints.json`)与 `prompts/`、`agentskills/` 一样锚定源码树;**部署级配置**(`custom_models`、`host_workspaces`、`auto_approval`、`goal_review`、`forbidden_commands`、`host_sandbox_policy`)外置到 `~/.astrion/astrion/config/`(`DEPLOY_CONFIG_DIR`,host/web 共享),读取走 `config.resolve_deploy_config(name)`,回退链:部署目录 → 源码树 `.json` → 源码树 `.json.example`。含密钥/机器特定的 5 个不进 git,仓库仅留 `.example`。
|
||||
|
||||
**日志策略**:
|
||||
- API 请求体 dump 默认**关闭**,`AGENT_API_DUMP_ENABLED=1` 开启。
|
||||
- 混合轮转(`utils/log_rotation.py`):追加型单文件按大小轮转(默认 20MB×3 份),dump 目录按份保留(默认 30 个)。阈值变量:`AGENT_LOG_ROTATE_MAX_BYTES` / `AGENT_LOG_ROTATE_BACKUPS` / `AGENT_DUMP_KEEP`。
|
||||
|
||||
**数据迁移**:
|
||||
- `scripts/migrate_runtime_data.py`:源码树 → `~/.astrion/astrion/<mode>`,复制+备份+可回滚+幂等,`logs/` 丢弃不迁。脚本复用 config 路径解析(模式由 `.env` 决定),执行前先 `--dry-run` 确认目标。
|
||||
|
||||
## Important Implementation Details
|
||||
|
||||
### File Operations
|
||||
@ -139,9 +235,19 @@ tail -f logs/container_stats.log
|
||||
|
||||
### Adding New Tools
|
||||
|
||||
1. 在 `MainTerminal` 添加 `handle_<tool_name>` 方法
|
||||
2. 在 `core/tool_config.py` 的 `define_tools()` 中注册工具
|
||||
3. 如果需要在流式输出中处理,在 `web_server.py` 添加特殊逻辑
|
||||
1. 在 `core/main_terminal_parts/tools_execution.py`(及相关 `tools_*.py`)实现执行逻辑
|
||||
2. 在 `core/tool_config.py` / `core/main_terminal_parts/tools_definition.py` 中注册工具定义
|
||||
3. 如需在任务流/流式输出中特殊处理,在 `server/chat_flow*.py` 对应环节添加逻辑
|
||||
|
||||
### 代码修改约定
|
||||
|
||||
- **最小改动原则**:只改与需求直接相关文件,避免顺手重构。
|
||||
- **文件编辑方式**:优先使用原生文件编辑工具,尽量不要用 `bash`/`python` 脚本批量改文件。
|
||||
- **后端改动优先级**:先改 `modules/`、`server/` 内对应模块,最后才动入口。
|
||||
- **前端改动优先级**:按 `static/src` 现有分层改(`app/` `stores/` `components/` `composables/`)。
|
||||
- **CLI 改动优先级**:优先在 `cli/src/App.tsx`、`cli/src/components.tsx`、`cli/src/eventMapper.ts`、`cli/src/api.ts` 内做最小闭环修改。
|
||||
- 涉及 monitor 动画/事件联动时,至少同步检查 `MonitorDirector.ts` 与 `stores/monitor.ts`。
|
||||
- **前端不强制本地构建**:沙箱环境无法成功执行 `npm run build` / `npm run cli:build`,因此改完前端不必每次都跑构建验证。改动后用类型检查(`npm run lint` / `npm --prefix cli run typecheck`)或人工 review 自查即可,构建交由用户在本机或 CI 完成。
|
||||
|
||||
### Working with Containers
|
||||
|
||||
@ -173,6 +279,36 @@ terminal.search_conversations(query="bug fix")
|
||||
terminal.delete_conversation(conversation_id)
|
||||
```
|
||||
|
||||
## 前端设计风格统一规范(强制)
|
||||
|
||||
> 适用范围:所有前端 UI(以 `static/src` Web 为主;`cli/src` 在视觉可类比处同样适用;以及未来新增的任何界面)。
|
||||
> 约束级别:写新 UI 或改动 UI 时**必须遵守**;遇到存量违规应在最小改动允许范围内顺手修正。
|
||||
|
||||
1. **禁止边缘光晕**:不使用任何 glow / 外发光 / 彩色光晕效果(大范围彩色 `box-shadow` 扩散、用 `filter: drop-shadow` 做光圈、`::before/::after` 模糊光晕等)。已有的要移除。允许的只是中性、克制的投影——沿用 `--claude-shadow` / `--theme-shadow-*` 这类已有 token,不要自造发光阴影。**仅在用户明确允许或要求时才可使用光晕效果。**
|
||||
|
||||
2. **禁止原生浏览器组件**:不直接使用浏览器默认外观的 `<select>`、`<input type=checkbox/radio/range/date/color>`、`alert/confirm/prompt`、原生右键菜单、原生 tooltip 等。一律替换为与项目风格统一的自定义组件(下拉、开关、单选、滑块、模态、Toast 等)。
|
||||
|
||||
3. **禁止圆角套娃**:不允许「圆角矩形里再套圆角矩形再套圆角矩形」的多层嵌套卡片——这是典型的垃圾审美,且会极大占据有效显示面积。容器层级要扁平:能用分隔线 / 留白 / 底色区分的,就不要再多包一层带边框圆角的盒子。
|
||||
|
||||
4. **图标外框对齐**:同一组图标按钮的外框(点击热区 / 容器)必须对齐统一,不能一个居中、一个左对齐、一个右对齐。同组按钮固定相同尺寸与对齐方式。
|
||||
|
||||
5. **图标视觉对齐而非理论对齐**:按图标的视觉重心对齐,而不是几何边界盒。对视觉重心偏移的图标(三角形 / 播放键 / 放大镜等)做微调(micro-nudge),让它「看起来居中」而非「数值上居中」。
|
||||
|
||||
6. **颜色遵循三模式切换 + 两层 Token**:所有颜色必须走原生三模式(经典 `classic` / 明亮 `light` / 夜间 `dark`),禁止写死颜色字面量(含裸 hex、`rgb()`/`hsl()` 字面色、`var(--x, #hex)` 兜底),一律使用语义 CSS 变量。
|
||||
- **三主题定位**(填色基准):**经典 = 抄 Claude 官网亮色盘**(暖奶油色阶 + 暖橙 primary `#cc785c`);**浅色 = 抄 ChatGPT 亮色盘**(冷白 + 中性灰 + 近黑 primary `#181818`);**深色 = 自研中性灰阶**(浅灰/深灰/黑,不硬凑暖调)。
|
||||
- **两层结构**:token 定义在 `static/src/styles/base/_tokens.scss`,分原始层 + 语义层;组件只引用**中性语义名**(`--surface-*` / `--text-*` / `--border-*` / `--accent*` / `--state-*` 等)。`--claude-*` / `--theme-*` 是**过时兼容别名**,迁移完成后会删,新代码不要用。
|
||||
- **表面层次**(经典):画布 `--surface-base` `#faf9f5` < 侧栏 `--surface-soft` `#f5f0e8` < 卡片 `--surface-card` `#efe9de` < 嵌套 `--surface-muted` `#e8e0d2` < 纯白浮起 `--surface-raised`。亮色主题不许把所有表面塌成同一个白,必须靠暖/冷灰阶拉开层次。
|
||||
- **强调色克制**:primary(`--accent`)是 "CTA-only voltage",只点在主按钮/logo 等极少数处;hover / 选中 / 运行态一律走中性灰(跟各自主题表面色),不抢镜。
|
||||
- **派生色**:需要半透明 tint 时用 `color-mix(in srgb, var(--token) N%, transparent)` 从语义 token 派生,不写死 rgba。
|
||||
- **新增颜色**必须在 classic / light / dark 三套主题(及首屏回退 `:root:not([data-theme])`,与经典同值)都补齐。
|
||||
- **防回退栏杆**:`.stylelintrc.cjs` 用 `color-no-hex` / `declaration-property-value-disallowed-list` / `media-feature-name-disallowed-list` 三条规则拦截违规,`build` 脚本含 `stylelint` 步骤会拦下裸色。存量未清理文件在 `BASELINE_EXEMPT` 临时豁免,清理一个移除一个,不再回退。切换逻辑见 `static/src/utils/theme.ts`。
|
||||
|
||||
7. **带文字容器固定高度**:所有含内部文字的选项 / 按钮 / 标签 / 容器必须固定高度,禁止因文字多少被撑大撑高。文字过长用省略号或内部滚动处理,保持行高与块高稳定。
|
||||
|
||||
8. **窗口设最大尺寸 + 内部滚动**:所有弹窗 / 面板 / 列表必须设置 `max-height` / `max-width`,超出时由内部容器滚动,而不是把整个窗口顶大。滚动条要么隐藏,要么做样式适配(贴合整体设计,不暴露原生粗滚动条)。
|
||||
|
||||
9. **实体面板禁止半透明**:所有实体 UI 容器(对话区、侧栏、下拉/二级菜单、抽屉、对话框本体、状态条、tooltip 气泡、输入栏等)背景必须**不透明**,且不加 `backdrop-filter` 磨砂。背景一旦不透明,磨砂就失去意义,必须移除。**唯一例外**是遮罩层(弹窗背后压暗的 scrim,用 `--overlay-scrim`,`position:fixed;inset:0`)和刻意的玻璃质感装饰,这类保留半透明。
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- 所有用户操作在独立容器中执行,与宿主机隔离
|
||||
@ -192,26 +328,70 @@ terminal.delete_conversation(conversation_id)
|
||||
|
||||
```
|
||||
agents/
|
||||
├── core/ # 核心终端和工具配置
|
||||
├── server/ # Flask 业务主线(chat/task/goal/monitor/auth ...)
|
||||
├── core/ # 核心终端与工具编排(main_terminal_parts/*)
|
||||
├── modules/ # 独立功能模块
|
||||
├── utils/ # 辅助工具
|
||||
├── config/ # 配置文件
|
||||
├── static/ # Web 前端
|
||||
├── utils/ # 辅助工具(含 log_rotation 日志轮转)
|
||||
├── config/ # 配置拆分(api/limits/terminal/paths ...,由 __init__ 聚合)
|
||||
├── static/ # Vue 3 + TS Web 前端
|
||||
├── cli/ # React + Ink CLI 前端(重写中)
|
||||
├── modules/sub_agent/ # 子智能体执行逻辑(主进程内协程)
|
||||
├── easyagent/ # 旧版 Node.js 子智能体实现,暂时保留但已不再使用
|
||||
├── docker/ # 容器镜像
|
||||
├── prompts/ # 系统提示词
|
||||
├── users/ # 用户工作区
|
||||
├── data/ # 全局数据
|
||||
└── logs/ # 日志文件
|
||||
├── scripts/ # 运维脚本(数据迁移 migrate_runtime_data.py 等)
|
||||
├── android-webview-app/ # Android WebView 客户端工程
|
||||
├── test/ # 测试用例
|
||||
├── docs/ / doc/ # 设计与发布文档
|
||||
└── _experiments/ # 本地实验残留与历史文档归档(gitignored,不进仓库)
|
||||
|
||||
运行态数据(不在源码树):
|
||||
~/.astrion/astrion/ # 数据根目录(可通过 ASTRION_DATA_ROOT 覆盖)
|
||||
├── settings.json # 唯一配置文件
|
||||
├── config/ # 部署级配置(host/web 共享)
|
||||
├── host/ # mode = host,由 TERMINAL_SANDBOX_MODE 决定
|
||||
│ ├── data/ # 对话、用户库、记忆、sub_agent_tasks(运行态)
|
||||
│ └── logs/ # 日志文件
|
||||
└── web/ # mode = web/docker
|
||||
├── data/ # 对话、用户库、记忆、sub_agent_tasks(运行态)
|
||||
├── users/ # web 多用户工作区
|
||||
├── api/users/ # API 用户工作区
|
||||
└── logs/ # 日志文件
|
||||
```
|
||||
|
||||
> 历史说明:`data/`、`users/`、`logs/`、`project/` 等过去曾在源码树内,现已默认迁至 `~/.astrion/astrion/<mode>/`。详见 Configuration 节「数据目录与路径变量」。
|
||||
|
||||
## Development Tips
|
||||
|
||||
- 修改系统提示词:编辑 `prompts/main_system.txt`
|
||||
- 调整工具限制:修改 `config/limits.py`
|
||||
- 容器镜像定制:编辑 `docker/terminal.Dockerfile` 并重新构建
|
||||
- 前端开发:`npm install && npm run dev` (在 `static/` 目录)
|
||||
- 前端开发:在项目根目录运行 `npm install && npm run dev`(build watch)
|
||||
- 查看详细执行流程:监控 `logs/debug_stream.log`
|
||||
|
||||
## Permission Mode & Sandbox Execution (2026-05)
|
||||
|
||||
完整说明文档:`docs/host_sandbox_and_permission_model.md`
|
||||
|
||||
系统有两套独立但叠加的控制:
|
||||
|
||||
### 权限模式 (Permission Mode)
|
||||
- `readonly` - `run_command` 在只读沙箱执行;`write_file`/`edit_file` 等写入类工具直接拒绝
|
||||
- `approval` - `run_command` 先走只读沙箱,权限拒绝(如 `Operation not permitted`/`Permission denied`)时触发前端审批,通过后仅该次命令以可写沙箱重试
|
||||
- `auto_approval` - 工作区内写入直接执行,工作区外写入进入审批;`run_command` 权限拒绝由后台审批智能体自动审核
|
||||
- `unrestricted` - 不做权限模式拦截,是否沙箱由执行环境决定
|
||||
|
||||
### 执行环境 (Execution Mode)
|
||||
- `sandbox`(默认)- OS 沙箱执行(macOS: `sandbox-exec`,Linux: `bwrap + seccomp`,Windows: `WSL2`),支持会话级 TTL 自动回退 `direct -> sandbox`
|
||||
- `direct` - 宿主机直接执行(高风险,仅建议短时使用)
|
||||
|
||||
### 路径授权语义
|
||||
- 前端路径授权分「可读可写」与「仅可读」:可读集合 = 可读可写 + 仅可读,可写集合 = 可读可写
|
||||
|
||||
### 自动审批智能体
|
||||
- 配置文件:`config/auto_approval.json`(`name`/`url`/`key`/`model`/`extra_params`,及 `timeout_seconds`/`max_rounds`/`max_command_timeout`)
|
||||
- 调试开关:`modules/approval_agent.py` 中 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`,开启后写入 `logs/approval_agent/`
|
||||
|
||||
## Android Release Notes (Required for frontend update)
|
||||
|
||||
当改动前端并准备发 Android WebView 壳版本时,必须同步完成:
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
# 轮询模式改造完成
|
||||
|
||||
## 改造内容
|
||||
|
||||
已成功将前端从 WebSocket 实时推送模式改造为 REST API + 轮询模式。
|
||||
|
||||
### 主要变更
|
||||
|
||||
#### 1. 后端改造
|
||||
- **server/tasks.py**: 已有完整的任务管理系统
|
||||
- `POST /api/tasks` - 创建任务
|
||||
- `GET /api/tasks/<task_id>?from=<offset>` - 轮询任务事件
|
||||
- `POST /api/tasks/<task_id>/cancel` - 取消任务
|
||||
- 支持 videos 和 run_mode 参数
|
||||
|
||||
#### 2. 前端改造
|
||||
- **stores/task.ts** (新建): 任务轮询状态管理
|
||||
- `createTask()` - 创建任务并启动轮询
|
||||
- `pollTaskEvents()` - 轮询任务事件
|
||||
- `startPolling()` / `stopPolling()` - 控制轮询
|
||||
- `loadRunningTask()` - 加载运行中的任务(用于页面刷新恢复)
|
||||
|
||||
- **app/methods/taskPolling.ts** (新建): 事件处理器
|
||||
- `handleTaskEvent()` - 统一事件分发
|
||||
- `handleAiMessageStart()` - AI 消息开始
|
||||
- `handleThinkingStart/Chunk/End()` - 思考过程
|
||||
- `handleTextStart/Chunk/End()` - 文本输出
|
||||
- `handleToolPreparing/Start/UpdateAction()` - 工具执行
|
||||
- `handleAppendPayload/ModifyPayload()` - 文件操作
|
||||
- `handleTaskComplete()` - 任务完成
|
||||
- `restoreTaskState()` - 恢复任务状态(页面刷新后)
|
||||
|
||||
- **app/methods/message.ts**: 修改消息发送逻辑
|
||||
- 改为调用 REST API 创建任务
|
||||
- 停止任务改为调用 REST API 取消任务
|
||||
- 保留 WebSocket 兼容性(命令模式)
|
||||
|
||||
- **app/lifecycle.ts**: 修改生命周期
|
||||
- `mounted()` 中注册全局事件处理器
|
||||
- `mounted()` 中延迟调用 `restoreTaskState()` 恢复任务
|
||||
- `beforeUnmount()` 中停止轮询
|
||||
|
||||
- **app.ts**: 集成任务轮询方法
|
||||
|
||||
## 功能特性
|
||||
|
||||
### ✅ 已实现
|
||||
|
||||
1. **后端独立运行**
|
||||
- 任务创建后在后台线程运行
|
||||
- 不依赖 WebSocket 连接
|
||||
- 事件完整记录到内存队列(最多 1000 条)
|
||||
|
||||
2. **前端轮询更新**
|
||||
- 1 秒间隔轮询任务事件
|
||||
- 增量获取新事件(通过 `from` 参数)
|
||||
- 任务完成后自动停止轮询
|
||||
|
||||
3. **页面刷新恢复**
|
||||
- 页面加载时自动查找运行中的任务
|
||||
- 从事件流重建前端状态
|
||||
- 恢复思考块、文本块、工具块的展开状态
|
||||
- 继续轮询更新新事件
|
||||
|
||||
4. **状态完整恢复**
|
||||
- 正在输出的思考内容 → 展开的思考块 + 已输出内容
|
||||
- 正在输出的文本内容 → 文本块 + 已输出内容
|
||||
- 正在执行的工具 → 工具块 + 执行状态
|
||||
- 文件操作 → append/modify payload 块
|
||||
|
||||
5. **任务取消**
|
||||
- 支持通过 REST API 取消任务
|
||||
- 前端立即清理状态
|
||||
- 后端标记停止标志
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 正常使用
|
||||
1. 输入消息并发送
|
||||
2. 后端自动创建任务并开始执行
|
||||
3. 前端每秒轮询一次,实时更新界面
|
||||
4. 任务完成后自动停止轮询
|
||||
|
||||
### 页面刷新
|
||||
1. 刷新页面
|
||||
2. 前端自动检测运行中的任务
|
||||
3. 从事件流重建状态(思考块、文本块、工具块)
|
||||
4. 继续轮询更新新事件
|
||||
5. 显示提示:"检测到进行中的任务,已恢复连接"
|
||||
|
||||
### 停止任务
|
||||
1. 点击停止按钮
|
||||
2. 调用 REST API 取消任务
|
||||
3. 前端立即清理状态
|
||||
4. 后端收到停止信号后终止执行
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 轮询机制
|
||||
- 间隔:1 秒
|
||||
- 增量获取:通过 `from` 参数指定起始事件索引
|
||||
- 自动停止:任务状态为 `succeeded/failed/canceled` 时停止
|
||||
|
||||
### 事件流格式
|
||||
```json
|
||||
{
|
||||
"idx": 0,
|
||||
"type": "ai_message_start",
|
||||
"data": {},
|
||||
"ts": 1234567890.123
|
||||
}
|
||||
```
|
||||
|
||||
### 任务状态
|
||||
- `pending` - 待执行
|
||||
- `running` - 执行中
|
||||
- `succeeded` - 成功完成
|
||||
- `failed` - 执行失败
|
||||
- `canceled` - 已取消
|
||||
|
||||
### 事件类型
|
||||
- `ai_message_start` - AI 消息开始
|
||||
- `thinking_start/chunk/end` - 思考过程
|
||||
- `text_start/chunk/end` - 文本输出
|
||||
- `tool_preparing/start/update_action` - 工具执行
|
||||
- `append_payload/modify_payload` - 文件操作
|
||||
- `task_complete` - 任务完成
|
||||
- `error` - 错误
|
||||
- `token_update` - Token 统计更新
|
||||
- `conversation_resolved` - 对话已解析
|
||||
|
||||
## 兼容性
|
||||
|
||||
- ✅ 保留 WebSocket 连接(用于命令模式和实时推送)
|
||||
- ✅ 命令模式(`/clear` 等)仍使用 WebSocket
|
||||
- ✅ 在线用户仍可收到实时推送(通过 `socketio.emit` 到房间)
|
||||
- ✅ 支持多标签页同时查看进度
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **基本功能测试**
|
||||
- 发送消息,观察是否正常输出
|
||||
- 刷新页面,观察是否恢复状态
|
||||
- 停止任务,观察是否立即停止
|
||||
|
||||
2. **边界情况测试**
|
||||
- 任务执行中刷新多次
|
||||
- 任务完成后刷新
|
||||
- 多个标签页同时打开
|
||||
- 网络断开后重连
|
||||
|
||||
3. **性能测试**
|
||||
- 长时间运行任务(观察轮询是否稳定)
|
||||
- 大量事件(1000+ 条)
|
||||
- 多用户并发
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. **事件队列大小**: 最多保留 1000 条事件(deque maxlen=1000)
|
||||
2. **任务持久化**: 当前仅内存存储,服务器重启后丢失
|
||||
3. **并发限制**: 单用户单工作区同时只能有一个任务
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. **持久化**: 将任务和事件存储到 Redis/数据库
|
||||
2. **事件压缩**: 对历史事件进行压缩存储
|
||||
3. **断点续传**: 支持从任意事件索引恢复
|
||||
4. **WebSocket 降级**: 完全移除 WebSocket 依赖
|
||||
5. **长轮询**: 使用 long-polling 减少请求次数
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
cd static
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建成功!输出文件:
|
||||
- `static/dist/assets/task.js` (3.68 kB)
|
||||
- `static/dist/assets/main.js` (687.51 kB)
|
||||
425
README.md
425
README.md
@ -1,7 +1,74 @@
|
||||
# AI Agent 系统
|
||||
# Astrion
|
||||
|
||||
一个功能完整的 AI 智能体系统,支持多模态交互、子智能体协作、实时终端操作和丰富的工具集成。
|
||||
|
||||
## 当前状态(2026-06)
|
||||
|
||||
- Web 端仍然是主线入口,基于 Flask + Vue 3;聊天/状态/任务接口已拆分为 `server/chat/`、`server/status/`、`server/tasks/` 子包。
|
||||
- 新 CLI 端已开始重写,位于 `cli/`,技术栈为 React 19 + Ink 6 + TypeScript。
|
||||
- 当前 CLI 不是独立后端;它会直接连接本地 `8091` Web API,并复用现有会话、任务、权限、工作区等接口。
|
||||
- CLI 已实现“启动即连接后端并新建会话”的流程,但仍处于持续打磨阶段,输入法、光标、时间线裁剪等终端细节仍在迭代。
|
||||
- 运行态数据(对话、用户库、日志等)默认存放在用户主目录的 `~/.astrion/astrion/<mode>/` 下,不再落在源码树内;源码树内的 `data/`、`logs/`、`users/` 等仅作为 `.gitignore` 防污染保留。
|
||||
|
||||
## CLI 快速开始
|
||||
|
||||
### 安装与构建
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm --prefix cli install
|
||||
npm run cli:build
|
||||
```
|
||||
|
||||
### 开发启动
|
||||
|
||||
```bash
|
||||
npm run cli
|
||||
```
|
||||
|
||||
或直接:
|
||||
|
||||
```bash
|
||||
npm --prefix cli run dev
|
||||
```
|
||||
|
||||
### 类型检查
|
||||
|
||||
```bash
|
||||
npm --prefix cli run typecheck
|
||||
```
|
||||
|
||||
### 在目标目录启动 CLI
|
||||
|
||||
```bash
|
||||
cd /path/to/your/project
|
||||
npm --prefix /Users/jojo/Desktop/agents/正在修复中/agents/cli run dev
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- CLI 会默认把“当前终端目录”视为目标工作目录。
|
||||
- 若当前目录尚未加入工作区,启动后会先提示是否将该目录加入工作区。
|
||||
- CLI 会优先连接 `http://127.0.0.1:8091`;若本地服务未启动,会尝试拉起:
|
||||
|
||||
```bash
|
||||
python3 -m server.app --path <当前目录> --port 8091 --thinking-mode
|
||||
```
|
||||
|
||||
### 当前 CLI 已支持的能力
|
||||
|
||||
- 启动即创建会话,不必等首条消息发送后再建会话
|
||||
- `/new` `/resume` `/model` `/versioning` `/permission` `/execution`
|
||||
- `/workspace` `/compact` `/tools` `/path` `/skill` `/status` `/help` `/clear` `/exit`
|
||||
- 基于任务轮询的时间线展示:用户消息、工具调用、模型输出、审批、后台状态
|
||||
- 宿主机工作区接入、版本控制开关、权限模式与执行环境切换
|
||||
|
||||
### 当前 CLI 已知限制
|
||||
|
||||
- 默认隐藏思考内容,只显示“思考中 / 思考完成”标题
|
||||
- 时间线布局、终端输入法光标、长内容换行等细节仍在持续修正
|
||||
- 目前更适合本地开发与人工交互,不建议当作完全稳定的自动化终端入口
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 🎯 多模式运行
|
||||
@ -25,18 +92,18 @@
|
||||
|
||||
### 🤖 子智能体系统
|
||||
|
||||
支持创建独立的子智能体处理复杂任务:
|
||||
支持创建子智能体处理复杂任务:
|
||||
|
||||
- **并行执行**: 最多同时运行多个子智能体
|
||||
- **独立上下文**: 每个子智能体拥有独立的工作空间和对话历史
|
||||
- **任务隔离**: 子智能体在独立进程中运行,互不干扰
|
||||
- **状态追踪**: 实时监控子智能体的执行状态和进度
|
||||
- **后台运行**: 支持后台模式,主智能体可继续处理其他任务
|
||||
- **实现方式**: 当前子智能体在主进程内以 `asyncio.Task` 运行,工具调用复用主进程链路;旧版 Node.js 子进程实现保留在 `easyagent/` 但已不再使用
|
||||
|
||||
工具接口:
|
||||
- `create_sub_agent`: 创建并启动子智能体
|
||||
- `wait_sub_agent`: 等待子智能体完成并获取结果
|
||||
- `close_sub_agent`: 终止运行中的子智能体
|
||||
- `terminate_sub_agent`: 强制终止运行中的子智能体
|
||||
|
||||
### 🛠️ 丰富的工具集
|
||||
|
||||
@ -49,7 +116,7 @@
|
||||
- `list_files`: 列出目录内容
|
||||
- `search_files`: 搜索文件
|
||||
|
||||
> 说明:目录创建/重命名/删除等通用文件管理建议统一使用 `run_command` 或 `run_python`。
|
||||
> 说明:目录创建/重命名/删除等通用文件管理建议统一使用 `run_command`;需要 Python 时先探测并选择合适解释器。
|
||||
|
||||
#### 终端操作
|
||||
|
||||
@ -60,8 +127,7 @@
|
||||
- `sleep`: 等待命令执行完成
|
||||
|
||||
**快速命令执行**:
|
||||
- `run_command`: 执行 Shell 命令
|
||||
- `run_python`: 执行 Python 代码
|
||||
- `run_command`: 执行 Shell 命令;需要 Python 时通过命令探测并调用合适解释器
|
||||
|
||||
#### 网络检索
|
||||
- `web_search`: 网络搜索
|
||||
@ -247,35 +313,45 @@ TERMINAL_SANDBOX_MOUNT_PATH=/workspace
|
||||
|
||||
```
|
||||
agents/
|
||||
├── main.py # 程序入口
|
||||
├── core/ # 核心模块
|
||||
│ ├── main_terminal.py # 主终端(CLI模式)
|
||||
│ ├── web_terminal.py # Web终端
|
||||
│ ├── tool_config.py # 工具配置
|
||||
│ └── ...
|
||||
├── server/ # Web服务器
|
||||
│ ├── app.py # Flask应用
|
||||
│ ├── chat_flow_*.py # 对话流程处理
|
||||
├── main.py # 统一启动入口(当前默认走 Web 模式 + thinking_mode=True)
|
||||
├── server/ # Flask 业务主线
|
||||
│ ├── app.py # 推荐的 Web 服务入口(封装并转发到 app_legacy.py)
|
||||
│ ├── app_legacy.py # 原有 Flask 应用主体
|
||||
│ ├── chat/ # 聊天相关接口子包(approval/files/misc/permission/settings/terminal)
|
||||
│ ├── status/ # 状态相关接口子包(app/base/docker/file_open/git/host_workspace)
|
||||
│ ├── tasks/ # 任务相关接口子包(api/helpers/media/models/skills)
|
||||
│ ├── conversation.py # 对话管理
|
||||
│ ├── socket_handlers.py # Socket.IO 连接与兼容事件处理(聊天主链路已迁移到 REST 任务轮询)
|
||||
│ └── ...
|
||||
├── modules/ # 功能模块
|
||||
│ ├── file_manager.py # 文件管理
|
||||
│ ├── terminal_manager.py # 终端管理
|
||||
│ ├── sub_agent_manager.py# 子智能体管理
|
||||
│ └── socket_handlers.py # Socket.IO 兼容通道(聊天主链路已迁移到 REST 任务轮询)
|
||||
├── core/ # 终端与工具编排
|
||||
│ ├── main_terminal.py # 主终端(CLI模式)
|
||||
│ ├── web_terminal.py # Web 终端
|
||||
│ ├── main_terminal_parts/# 终端功能拆分(context/、tools_definition/ 等)
|
||||
│ └── tool_config.py # 工具配置
|
||||
├── modules/ # 可复用能力模块
|
||||
│ ├── file_manager/ # 文件管理(已拆分为子包)
|
||||
│ ├── persistent_terminal/# 持久化终端(已拆分为子包)
|
||||
│ ├── terminal_ops/ # 终端操作(已拆分为子包)
|
||||
│ ├── sub_agent/ # 子智能体执行逻辑(creation/manager/prompts/state/stats/task/toolkit/tools)
|
||||
│ ├── mcp_client_manager/ # MCP 客户端管理(已拆分为子包)
|
||||
│ ├── memory_manager.py # 记忆管理
|
||||
│ ├── search_engine.py # 搜索引擎
|
||||
│ └── ...
|
||||
├── utils/ # 工具函数
|
||||
│ ├── api_client.py # API客户端
|
||||
│ ├── context_manager.py # 上下文管理
|
||||
│ ├── conversation_manager.py # 对话持久化
|
||||
│ ├── api_client/ # API 客户端(已拆分为子包)
|
||||
│ ├── tool_result_formatter/# 工具结果格式化(已拆分为子包)
|
||||
│ ├── context_manager/ # 上下文管理(已拆分为子包)
|
||||
│ ├── conversation_manager/# 对话持久化(已拆分为子包)
|
||||
│ ├── log_rotation.py # 日志轮转
|
||||
│ └── ...
|
||||
└── config/ # 配置文件
|
||||
├── __init__.py # 主配置
|
||||
├── model_profiles.py # 模型配置
|
||||
├── limits.py # 限制配置
|
||||
└── ...
|
||||
├── config/ # 配置拆分
|
||||
│ ├── __init__.py # 主配置(聚合并加载 .env)
|
||||
│ ├── paths.py # 路径解析与运行态目录
|
||||
│ ├── api.py # API 配置
|
||||
│ ├── limits.py # 限制配置
|
||||
│ ├── terminal.py # 终端配置
|
||||
│ └── ...
|
||||
├── easyagent/ # 旧版 Node.js 子智能体实现,暂时保留但已不再使用
|
||||
└── _experiments/ # 本地实验残留与历史文档归档(不纳入 git)
|
||||
```
|
||||
|
||||
### 前端架构
|
||||
@ -319,7 +395,25 @@ static/src/
|
||||
|
||||
### Web 界面
|
||||
|
||||
启动后访问 `http://localhost:8091`(默认端口)
|
||||
安装后端依赖:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
启动 Web 服务:
|
||||
|
||||
```bash
|
||||
python -m server.app
|
||||
```
|
||||
|
||||
或带参数启动:
|
||||
|
||||
```bash
|
||||
python -m server.app --path ./project --port 8091 --debug --thinking-mode
|
||||
```
|
||||
|
||||
启动后访问 `http://localhost:8091`(默认端口 8091)
|
||||
|
||||
#### 基本操作
|
||||
|
||||
@ -384,146 +478,44 @@ static/src/
|
||||
|
||||
## 模型支持
|
||||
|
||||
系统支持多个 AI 模型提供商:
|
||||
系统不再内置任何固定供应商模型。可用模型由 `config/custom_models.json`(或后续 API 扩展配置)动态注册,前后端均通过 `/api/v1/models` 获取当前模型列表。
|
||||
|
||||
### Kimi (月之暗面)
|
||||
- `kimi-k2`: K2 系列模型
|
||||
- `kimi-k2.5`: K2.5 最新模型
|
||||
- 支持 256K 上下文窗口
|
||||
每个模型配置应提供 API 地址、密钥、模型 ID、上下文窗口、输出 token 上限、多模态能力和思考模式参数等信息;需要额外请求参数时,通过配置中的 `extra_parameter` / `thinkmode_status.*_extra_parameter` 注入。
|
||||
|
||||
### DeepSeek
|
||||
- `deepseek-chat`: 快速模式
|
||||
- `deepseek-reasoner`: 推理模式
|
||||
|
||||
### Qwen (通义千问)
|
||||
- `qwen3-max`: 最大模型
|
||||
- `qwen3.5-plus`: 增强版本
|
||||
- `qwen3-vl-plus`: 视觉语言模型
|
||||
|
||||
### MiniMax
|
||||
- `MiniMax-M2.5`: M2.5 模型
|
||||
- 支持 204K 上下文窗口
|
||||
|
||||
## 技能系统 (AgentSkills)
|
||||
|
||||
可扩展的技能包系统,提供预定义的工作流:
|
||||
|
||||
- **agent-build-standard**: 标准构建流程
|
||||
- **docx**: 文档处理
|
||||
- **frontend-design**: 前端设计辅助
|
||||
- **skill-creator**: 技能创建工具
|
||||
- **sub-agent-guide**: 子智能体使用指南
|
||||
- **terminal-guide**: 终端操作指南
|
||||
|
||||
### 安全特性
|
||||
|
||||
#### 宿主机模式安全
|
||||
|
||||
- **路径白名单**: 禁止访问系统敏感目录
|
||||
- **路径遍历防护**: 防止 `..` 等路径攻击
|
||||
- **根路径保护**: 禁止操作根目录和系统目录
|
||||
- **符号链接检查**: 防止符号链接逃逸
|
||||
- **Linux 安全模式**: 可选的额外安全限制
|
||||
|
||||
#### Docker 模式安全
|
||||
|
||||
- **完全隔离**: 容器级别的进程和文件系统隔离
|
||||
- **资源配额**: 强制的 CPU、内存、存储限制
|
||||
- **网络隔离**: 可配置的网络访问控制
|
||||
- **只读挂载**: 支持只读文件系统挂载
|
||||
- **用户映射**: 容器内外用户权限映射
|
||||
|
||||
#### 通用安全
|
||||
|
||||
### 上传安全
|
||||
- 文件类型白名单
|
||||
- 文件大小限制
|
||||
- 恶意文件扫描
|
||||
- 压缩包安全检查
|
||||
|
||||
### 路径安全
|
||||
- 禁止访问系统目录
|
||||
- 路径遍历防护
|
||||
- 符号链接检查
|
||||
|
||||
### API 安全
|
||||
- CSRF 保护
|
||||
- 速率限制
|
||||
- Token 验证
|
||||
- Session 管理
|
||||
|
||||
### API 安全
|
||||
- CSRF 保护
|
||||
- 速率限制
|
||||
- Token 验证
|
||||
- Session 管理
|
||||
|
||||
## 使用场景建议
|
||||
|
||||
### 宿主机模式适用场景
|
||||
|
||||
1. **个人开发环境**
|
||||
- 本地开发和测试
|
||||
- 需要访问本地工具链
|
||||
- 频繁的文件系统操作
|
||||
|
||||
2. **可信环境**
|
||||
- 单用户使用
|
||||
- 内网环境
|
||||
- 完全可控的代码执行
|
||||
|
||||
3. **性能优先**
|
||||
- 大量文件操作
|
||||
- 编译构建任务
|
||||
- 需要原生性能的场景
|
||||
|
||||
4. **特殊工具依赖**
|
||||
- 需要特定系统工具
|
||||
- 硬件设备访问
|
||||
- 系统级操作
|
||||
|
||||
### Docker 模式适用场景
|
||||
|
||||
1. **多用户服务**
|
||||
- 公共服务部署
|
||||
- 多租户环境
|
||||
- 需要用户隔离
|
||||
|
||||
2. **生产环境**
|
||||
- 对外提供服务
|
||||
- 需要稳定性保证
|
||||
- 资源配额管理
|
||||
|
||||
3. **安全优先**
|
||||
- 执行不可信代码
|
||||
- 需要严格隔离
|
||||
- 防止恶意操作
|
||||
|
||||
4. **环境标准化**
|
||||
- 统一运行环境
|
||||
- 依赖管理
|
||||
- 可复现的执行环境
|
||||
|
||||
## 配置说明
|
||||
|
||||
主要配置项(通过环境变量或 `.env` 文件):
|
||||
|
||||
### API 配置
|
||||
- `API_BASE_KIMI`: Kimi API 地址
|
||||
- `API_KEY_KIMI`: Kimi API 密钥
|
||||
- `API_BASE_DEEPSEEK`: DeepSeek API 地址
|
||||
- `API_KEY_DEEPSEEK`: DeepSeek API 密钥
|
||||
- `config/custom_models.json`: 动态模型注册文件(生产环境建议放到 `~/.astrion/astrion/config/custom_models.json`,host/web 共享)
|
||||
- `AGENT_DEFAULT_MODEL`: 可选,指定默认模型 key;未设置时使用第一个可见动态模型
|
||||
- 模型配置中的 `url` / `apikey` 支持 `${ENV_NAME}`、`env:ENV_NAME`、`$ENV_NAME` 等环境变量引用
|
||||
|
||||
### 服务配置
|
||||
- `WEB_SERVER_PORT`: Web 服务器端口(默认 8091)
|
||||
- `DEFAULT_PROJECT_PATH`: 默认项目路径
|
||||
|
||||
### 运行态路径配置
|
||||
- `DATA_DIR`: 数据目录(对话、记忆、子智能体等)
|
||||
- `LOGS_DIR`: 日志目录
|
||||
- `USER_SPACE_DIR`: Web 用户工作区目录
|
||||
- `API_USER_SPACE_DIR`: API 用户工作区目录
|
||||
- `ASTRION_DATA_ROOT`: 运行态数据根目录(默认 `~/.astrion/astrion`,host/web 两个模式共享该根)
|
||||
- `DEPLOY_CONFIG_DIR`: 部署级配置目录(默认 `<ASTRION_DATA_ROOT>/config/`,host/web 共享)
|
||||
|
||||
### 限制配置
|
||||
- `MAX_ITERATIONS_PER_TASK`: 单任务最大迭代次数
|
||||
- `MAX_TOTAL_TOOL_CALLS`: 总工具调用次数限制
|
||||
- `MAX_UPLOAD_SIZE`: 最大上传文件大小
|
||||
- `PROJECT_MAX_STORAGE_MB`: 项目存储限制
|
||||
|
||||
### 日志策略
|
||||
- `AGENT_API_DUMP_ENABLED`: API 请求体落盘开关(`1/true/yes/on` 开启)
|
||||
- `AGENT_LOG_ROTATE_MAX_BYTES`: 单日志文件大小阈值(默认 20MB)
|
||||
- `AGENT_LOG_ROTATE_BACKUPS`: 日志轮转保留份数(默认 3)
|
||||
- `AGENT_DUMP_KEEP`: 按请求落盘的 dump 文件保留数量(默认 30)
|
||||
|
||||
### 运行环境配置
|
||||
|
||||
**宿主机模式**:
|
||||
@ -542,34 +534,84 @@ static/src/
|
||||
|
||||
## 数据存储
|
||||
|
||||
运行态数据默认存放在用户主目录的 `~/.astrion/astrion/<mode>/` 下(按 `TERMINAL_SANDBOX_MODE` 自动分流):
|
||||
|
||||
- **宿主机模式**(`TERMINAL_SANDBOX_MODE=host`)→ `~/.astrion/astrion/host`
|
||||
- **其它模式**(默认 docker / web)→ `~/.astrion/astrion/web`
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
data/ # 数据目录
|
||||
├── conversations/ # 对话历史
|
||||
│ └── <conversation_id>.json
|
||||
├── memory/ # 记忆文件
|
||||
│ ├── main_memory.md
|
||||
│ └── task_memory.md
|
||||
└── personalization.json # 个性化配置
|
||||
|
||||
logs/ # 日志目录
|
||||
├── tasks/ # 任务日志
|
||||
├── errors/ # 错误日志
|
||||
└── *.log
|
||||
|
||||
sub_agent/ # 子智能体数据
|
||||
└── tasks/ # 任务目录
|
||||
└── <task_id>/
|
||||
├── task.txt
|
||||
├── output.json
|
||||
└── progress.jsonl
|
||||
|
||||
users/ # 用户数据
|
||||
└── <username>/
|
||||
~/.astrion/astrion/ # 数据根目录(可通过 ASTRION_DATA_ROOT 覆盖)
|
||||
├── settings.json # 唯一配置文件
|
||||
├── config/ # 部署级配置(custom_models/host_workspaces/auto_approval 等,host/web 共享)
|
||||
├── host/ # 宿主机模式运行态
|
||||
│ ├── data/ # 数据目录
|
||||
│ │ ├── conversations/ # 对话历史
|
||||
│ │ │ └── <conversation_id>.json
|
||||
│ │ ├── memory/ # 记忆文件
|
||||
│ │ │ ├── main_memory.md
|
||||
│ │ │ └── task_memory.md
|
||||
│ │ ├── personalization.json
|
||||
│ │ ├── sub_agents.json # 子智能体注册表
|
||||
│ │ └── sub_agent_tasks/ # 子智能体任务数据
|
||||
│ │ └── <task_id>/
|
||||
│ │ ├── task.txt
|
||||
│ │ ├── output.json
|
||||
│ │ └── progress.jsonl
|
||||
│ └── logs/ # 日志目录
|
||||
│ ├── tasks/
|
||||
│ ├── errors/
|
||||
│ ├── approval_agent/
|
||||
│ └── *.log
|
||||
└── web/ # web/docker 模式运行态
|
||||
├── data/
|
||||
├── project/
|
||||
└── ...
|
||||
├── users/ # Web 多用户工作区
|
||||
│ └── <username>/
|
||||
│ ├── data/
|
||||
│ ├── project/
|
||||
│ └── ...
|
||||
├── api/users/ # API 用户工作区
|
||||
└── logs/
|
||||
```
|
||||
|
||||
### 路径解析优先级(从高到低)
|
||||
|
||||
1. **具体目录环境变量**:`DATA_DIR` / `LOGS_DIR` / `USER_SPACE_DIR` / `API_USER_SPACE_DIR`(单独覆盖某个目录)
|
||||
2. **数据根目录环境变量**:`ASTRION_DATA_ROOT`(整体搬迁运行态数据根目录,默认 `~/.astrion/astrion`)
|
||||
3. **兜底默认**:`~/.astrion/astrion/<mode>`
|
||||
|
||||
### Host / Web 双路径机制
|
||||
|
||||
在宿主机模式下,系统同时可读 web 模式数据:
|
||||
|
||||
- `WEB_DATA_DIR` 固定指向 `<data_root>/web/data`
|
||||
- `WEB_USER_SPACE_DIR` 固定指向 `<data_root>/web/users`
|
||||
- 用户列表合并 `host/data/users.json` + `web/data/users.json`
|
||||
- 写操作(创建/删除/重命名)始终走 host 路径
|
||||
|
||||
> 源码树内的 `data/`、`logs/`、`users/`、`api/`、`project/` 等目录已被 `.gitignore` 忽略,仅在通过环境变量显式指回源码树或历史遗留时才会出现,不参与默认数据存放。
|
||||
|
||||
## 测试现状
|
||||
|
||||
当前仓库内可见的自动化冒烟测试:
|
||||
|
||||
- `test/test_server_refactor_smoke.py`(基于 `unittest`)
|
||||
- 运行方式:`python -m unittest test.test_server_refactor_smoke`
|
||||
- `test/test_system_message.py` 依赖外部 `MOONSHOT_API_KEY` 与网络,不属于离线稳定 CI 用例。
|
||||
|
||||
CLI 最小可复现验证:
|
||||
|
||||
```bash
|
||||
npm --prefix cli run typecheck
|
||||
npm --prefix cli run build
|
||||
```
|
||||
|
||||
若改动 `server/chat/`、`server/status/`、`server/tasks/` 等后端接口适配,建议补充:
|
||||
|
||||
```bash
|
||||
python3 -m py_compile server/chat/*.py server/status/*.py server/tasks/*.py
|
||||
python -m unittest test.test_server_refactor_smoke
|
||||
```
|
||||
|
||||
## 扩展开发
|
||||
@ -583,9 +625,9 @@ users/ # 用户数据
|
||||
|
||||
### 添加新模型
|
||||
|
||||
1. 在 `config/model_profiles.py` 添加模型配置
|
||||
2. 在 `utils/api_client.py` 实现 API 调用
|
||||
3. 配置环境变量
|
||||
1. 在 `config/custom_models.json`(生产环境建议放到 `~/.astrion/astrion/config/custom_models.json`)注册模型配置
|
||||
2. 在 `utils/api_client/` 中实现或复用 API 调用逻辑
|
||||
3. 通过环境变量或 `.env` 注入密钥等敏感信息
|
||||
|
||||
### 创建技能包
|
||||
|
||||
@ -614,22 +656,25 @@ users/ # 用户数据
|
||||
|
||||
### 日志位置
|
||||
|
||||
- 主日志: `logs/main.log`
|
||||
- 错误日志: `logs/errors/`
|
||||
- 任务日志: `logs/tasks/`
|
||||
- Web 服务器: `web_server.log`
|
||||
运行态日志默认位于 `~/.astrion/astrion/<mode>/logs/`:
|
||||
|
||||
- 主日志: `~/.astrion/astrion/<mode>/logs/main.log`
|
||||
- 错误日志: `~/.astrion/astrion/<mode>/logs/errors/`
|
||||
- 任务日志: `~/.astrion/astrion/<mode>/logs/tasks/`
|
||||
- Web 服务器: `~/.astrion/astrion/<mode>/logs/web_server.log`
|
||||
- 自动审批智能体调试记录: `~/.astrion/astrion/<mode>/logs/approval_agent/`
|
||||
|
||||
## 版本信息
|
||||
|
||||
当前版本: 4.1.0
|
||||
|
||||
主要更新:
|
||||
- 完整的对话持久化系统
|
||||
- 子智能体并行执行
|
||||
- 多模型支持
|
||||
- 容器沙箱隔离
|
||||
- 个性化配置
|
||||
- 监控仪表板
|
||||
主要更新(2026-06):
|
||||
- 后端接口拆分为 `server/chat/`、`server/status/`、`server/tasks/` 子包
|
||||
- 运行态数据默认迁移到 `~/.astrion/astrion/<mode>/`,支持 host/web 双路径读取
|
||||
- 子智能体改为主进程内 `asyncio.Task` 实现,工具调用复用主进程链路
|
||||
- 新增宿主机权限模式(readonly/approval/auto_approval/unrestricted)与执行环境(sandbox/direct)双层控制
|
||||
- 新增日志轮转、API dump 可控、部署级配置外置等运维能力
|
||||
- 完整的对话持久化系统、多模型支持、容器沙箱隔离、个性化配置、监控仪表板
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
@ -1,211 +0,0 @@
|
||||
# 子智能体实现完成总结
|
||||
|
||||
## 已完成的工作
|
||||
|
||||
### 1. 复制 easyagent 代码 ✅
|
||||
- 将 easyagent 的核心代码复制到 `easyagent/` 目录
|
||||
- 包含 src/、prompts/、doc/、package.json
|
||||
|
||||
### 2. 创建批处理模式 ✅
|
||||
- 创建 `easyagent/src/batch/index.js` 批处理入口
|
||||
- 支持命令行参数:workspace、task-file、system-prompt-file、output-file、stats-file、agent-id、timeout
|
||||
- 实现对话循环和工具执行
|
||||
- 添加 finish_task 工具定义
|
||||
- 实现兜底机制:自动提醒调用 finish_task
|
||||
- 实现对话记录保存到 `.subagent/conversation.json`
|
||||
- 实现超时和轮次限制保护
|
||||
|
||||
### 3. 更新工具定义 ✅
|
||||
- 更新 `core/main_terminal_parts/tools_definition.py`
|
||||
- 替换旧的 create_sub_agent 工具定义
|
||||
- 移除 wait_sub_agent 工具(合并到 create_sub_agent 的 run_in_background 参数)
|
||||
- 添加 terminate_sub_agent 工具
|
||||
- 添加 get_sub_agent_status 工具
|
||||
- 详细的工具描述,包括使用场景和限制
|
||||
|
||||
### 4. 更新工具执行 ✅
|
||||
- 更新 `core/main_terminal_parts/tools_execution.py`
|
||||
- 修改 create_sub_agent 执行逻辑:
|
||||
- 支持 deliverables_dir 参数(替代 target_dir)
|
||||
- 支持 run_in_background 参数
|
||||
- 阻塞模式自动等待完成
|
||||
- 添加 terminate_sub_agent 执行逻辑
|
||||
- 添加 get_sub_agent_status 执行逻辑
|
||||
|
||||
### 5. 更新 SubAgentManager ✅
|
||||
- 已有新的实现(子进程模式)
|
||||
- 支持启动 Node.js 子进程
|
||||
- 实现任务状态管理
|
||||
- 实现进程监控和终止
|
||||
- 更新 `_build_system_prompt` 为完整版本:
|
||||
- 详细的工作流程
|
||||
- 工作原则(专注性、独立性、效率性、完整性)
|
||||
- 交付要求
|
||||
- 工具使用说明
|
||||
- 注意事项
|
||||
- 当前环境信息
|
||||
|
||||
### 6. 创建文档 ✅
|
||||
- `SUB_AGENT_IMPLEMENTATION_PLAN.md` - 完整实现方案
|
||||
- `SUB_AGENT_TESTING.md` - 测试指南
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 1. 简化的参数
|
||||
- 去掉 reference_files(子智能体自己搜索文件)
|
||||
- 去掉 delivery_mode(统一为文件交付)
|
||||
- 去掉 reason 参数(终止时不需要原因)
|
||||
- deliverables_dir 可选(默认 sub_agent_results/agent_{id})
|
||||
|
||||
### 2. 后台运行机制
|
||||
- run_in_background 参数(默认 false)
|
||||
- 后台模式:立即返回,完成后通知
|
||||
- 阻塞模式:等待完成后返回结果
|
||||
- 工具描述中明确说明何时使用
|
||||
|
||||
### 3. 完成任务工具
|
||||
- 只需两个参数:success 和 summary
|
||||
- 调用后立即退出
|
||||
- 兜底机制:忘记调用时自动提醒
|
||||
|
||||
### 4. 统计信息
|
||||
- 运行时间
|
||||
- 文件读取次数
|
||||
- 搜索次数
|
||||
- 网页查看次数
|
||||
- 命令执行次数
|
||||
- Token 使用量
|
||||
|
||||
### 5. 对话记录
|
||||
- 保存在交付目录的 `.subagent/conversation.json`
|
||||
- 包含完整的消息历史
|
||||
- 包含统计信息
|
||||
- 便于调试和审计
|
||||
|
||||
### 6. 安全限制
|
||||
- 最多 5 个并发子智能体
|
||||
- 禁止操作相同文件/目录
|
||||
- 禁止工作重叠
|
||||
- 超时自动终止
|
||||
- 轮次限制防止无限循环
|
||||
|
||||
## 待完成的工作
|
||||
|
||||
### 1. 后台任务轮询机制 ⏳
|
||||
需要在主智能体的工具执行后添加检查逻辑:
|
||||
```python
|
||||
# 在每次工具执行后
|
||||
completed_agents = sub_agent_manager.poll_updates()
|
||||
if completed_agents:
|
||||
# 插入 system 消息通知
|
||||
for agent in completed_agents:
|
||||
system_message = build_completion_message(agent)
|
||||
messages.append({"role": "system", "content": system_message})
|
||||
# 继续调用 API
|
||||
```
|
||||
|
||||
### 2. 对话状态管理 ⏳
|
||||
需要在主智能体完成但子智能体未完成时:
|
||||
- 设置对话状态标记
|
||||
- 前端显示警告(切换对话会终止子智能体)
|
||||
- 轮询等待完成
|
||||
- 完成后发送 user 消息触发新一轮
|
||||
|
||||
### 3. 前端集成 ⏳
|
||||
- 显示子智能体运行状态
|
||||
- 显示进度和统计信息
|
||||
- 支持手动终止
|
||||
- 支持查看对话记录
|
||||
|
||||
### 4. 测试和调试 ⏳
|
||||
- 按照 SUB_AGENT_TESTING.md 进行测试
|
||||
- 修复发现的 bug
|
||||
- 优化性能
|
||||
|
||||
### 5. 模型配置 ⏳
|
||||
需要将 easyagent 的 models.json 配置集成到主项目:
|
||||
- 复制或链接 models.json
|
||||
- 或者从主项目的配置生成 models.json
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 新增文件
|
||||
- `easyagent/` - easyagent 代码目录
|
||||
- `easyagent/src/batch/index.js` - 批处理入口
|
||||
- `SUB_AGENT_IMPLEMENTATION_PLAN.md` - 实现方案
|
||||
- `SUB_AGENT_TESTING.md` - 测试指南
|
||||
- `SUB_AGENT_COMPLETION_SUMMARY.md` - 本文件
|
||||
|
||||
### 修改文件
|
||||
- `core/main_terminal_parts/tools_definition.py` - 工具定义
|
||||
- `core/main_terminal_parts/tools_execution.py` - 工具执行
|
||||
- `modules/sub_agent_manager.py` - 子智能体管理器(已有新实现)
|
||||
|
||||
## 下一步行动
|
||||
|
||||
1. **测试批处理模式**
|
||||
```bash
|
||||
cd /tmp && mkdir test_subagent
|
||||
# 按照 SUB_AGENT_TESTING.md 的步骤测试
|
||||
```
|
||||
|
||||
2. **配置模型**
|
||||
```bash
|
||||
cp ~/Desktop/easyagent/models.json /Users/jojo/Desktop/agents/正在修复中/agents/easyagent/
|
||||
```
|
||||
|
||||
3. **测试主智能体集成**
|
||||
- 启动 Web 服务器
|
||||
- 创建子智能体
|
||||
- 观察执行过程
|
||||
|
||||
4. **添加轮询机制**
|
||||
- 在 WebTerminal 或主循环中添加 poll_updates 调用
|
||||
- 实现 system 消息插入
|
||||
- 实现 user 消息触发
|
||||
|
||||
5. **完善前端**
|
||||
- 显示子智能体状态
|
||||
- 添加控制按钮
|
||||
- 优化用户体验
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Node.js 依赖**
|
||||
- 确保 Node.js 18+ 已安装
|
||||
- 运行 `npm install` 安装依赖
|
||||
|
||||
2. **路径问题**
|
||||
- easyagent 批处理文件的路径是硬编码的
|
||||
- 如果项目位置改变,需要更新 SubAgentManager 中的路径
|
||||
|
||||
3. **模型配置**
|
||||
- easyagent 需要 models.json 配置文件
|
||||
- 需要与主项目的模型配置保持一致
|
||||
|
||||
4. **权限问题**
|
||||
- 确保交付目录有写权限
|
||||
- 确保 .subagent 目录可以创建
|
||||
|
||||
5. **调试**
|
||||
- 查看 logs/debug_stream.log
|
||||
- 查看子进程的 stdout/stderr
|
||||
- 查看 output.json 和 stats.json
|
||||
|
||||
## 总结
|
||||
|
||||
核心功能已经实现完成,包括:
|
||||
- ✅ 批处理模式
|
||||
- ✅ 工具定义和执行
|
||||
- ✅ 子智能体管理
|
||||
- ✅ 对话记录保存
|
||||
- ✅ 统计信息收集
|
||||
- ✅ 兜底机制
|
||||
|
||||
还需要完成:
|
||||
- ⏳ 后台任务轮询
|
||||
- ⏳ 对话状态管理
|
||||
- ⏳ 前端集成
|
||||
- ⏳ 测试和调试
|
||||
|
||||
整体架构清晰,代码结构合理,可以开始测试和完善了。
|
||||
@ -1,117 +0,0 @@
|
||||
# 子智能体问题修复总结
|
||||
|
||||
## 已修复的问题
|
||||
|
||||
### 1. 前端子智能体显示问题 ✅
|
||||
- **问题**:创建子智能体后不显示,只在完成后突然显示
|
||||
- **原因**:`get_overview` 方法中调用了旧的 `_call_service`(HTTP 模式),但新实现是子进程模式
|
||||
- **修复**:移除 `_call_service` 调用,改为直接检查进程状态
|
||||
|
||||
### 2. 暗色模式适配 ✅
|
||||
- **问题**:子智能体卡片没有暗色模式样式
|
||||
- **修复**:在 `static/src/styles/components/panels/_left-panel.scss` 中添加暗色模式样式
|
||||
|
||||
### 3. deliverables_dir 参数问题 ✅
|
||||
- **问题**:模型每次调用时都忘了提供这个参数
|
||||
- **修复**:将 `deliverables_dir` 改为必需参数(在 `required` 数组中)
|
||||
|
||||
### 4. 后台运行时的 system 消息插入 ✅
|
||||
- **状态**:已经实现
|
||||
- **位置**:`server/chat_flow_task_support.py` 中的 `process_sub_agent_updates` 函数
|
||||
- **调用**:在 `server/chat_flow_tool_loop.py` 的 `execute_tool_calls` 中每次工具执行后调用
|
||||
|
||||
## 还需要实现的功能
|
||||
|
||||
### 空闲期间的 user 消息自动发送 ⏳
|
||||
|
||||
**需求**:
|
||||
- 当主智能体完成任务进入空闲状态时
|
||||
- 如果还有子智能体在后台运行
|
||||
- 需要轮询等待子智能体完成
|
||||
- 完成后自动发送 user 消息触发新一轮对话
|
||||
|
||||
**实现位置**:
|
||||
需要在主对话循环结束时添加检查逻辑,可能的位置:
|
||||
1. `server/chat_flow_stream_loop.py` - 流式循环结束时
|
||||
2. `server/chat_flow_task_main.py` - 主任务完成时
|
||||
|
||||
**实现逻辑**:
|
||||
```python
|
||||
# 在对话循环结束时
|
||||
async def check_background_sub_agents(web_terminal, messages, sender):
|
||||
manager = getattr(web_terminal, "sub_agent_manager", None)
|
||||
if not manager:
|
||||
return False
|
||||
|
||||
# 检查是否有运行中的子智能体
|
||||
running_tasks = [
|
||||
task for task in manager.tasks.values()
|
||||
if task.get("status") not in {"completed", "failed", "timeout", "terminated"}
|
||||
and task.get("run_in_background")
|
||||
]
|
||||
|
||||
if not running_tasks:
|
||||
return False
|
||||
|
||||
# 设置对话状态:有子智能体运行中
|
||||
# 前端显示警告:切换对话会终止子智能体
|
||||
|
||||
# 轮询等待完成
|
||||
while running_tasks:
|
||||
await asyncio.sleep(5)
|
||||
updates = manager.poll_updates()
|
||||
|
||||
for update in updates:
|
||||
# 发送 user 消息触发新一轮
|
||||
user_message = build_completion_user_message(update)
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": user_message,
|
||||
"metadata": {"sub_agent_completion": True}
|
||||
})
|
||||
|
||||
# 触发新一轮 API 调用
|
||||
sender('user_message', {'content': user_message})
|
||||
return True # 表示需要继续对话
|
||||
|
||||
# 重新检查运行中的任务
|
||||
running_tasks = [
|
||||
task for task in manager.tasks.values()
|
||||
if task.get("status") not in {"completed", "failed", "timeout", "terminated"}
|
||||
and task.get("run_in_background")
|
||||
]
|
||||
|
||||
return False
|
||||
```
|
||||
|
||||
**user 消息格式**:
|
||||
```python
|
||||
def build_completion_user_message(update):
|
||||
agent_id = update.get("agent_id")
|
||||
summary = update.get("summary")
|
||||
result_summary = update.get("result_summary")
|
||||
deliverables_dir = update.get("deliverables_dir")
|
||||
|
||||
return f"""子智能体{agent_id} ({summary}) 已完成任务。
|
||||
|
||||
{result_summary}
|
||||
|
||||
交付目录:{deliverables_dir}"""
|
||||
```
|
||||
|
||||
## 测试清单
|
||||
|
||||
- [x] 创建子智能体后立即在前端显示
|
||||
- [x] 暗色模式下子智能体卡片样式正确
|
||||
- [x] deliverables_dir 必须提供,否则报错
|
||||
- [x] 后台运行时工具执行后插入 system 消息
|
||||
- [ ] 空闲期间子智能体完成后自动发送 user 消息
|
||||
- [ ] 切换对话时提示会终止子智能体
|
||||
- [ ] 强制切换对话时终止所有子智能体
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 实现空闲期间的 user 消息自动发送
|
||||
2. 添加对话状态管理(标记有子智能体运行中)
|
||||
3. 前端添加警告提示(切换对话会终止子智能体)
|
||||
4. 测试完整流程
|
||||
@ -1,382 +0,0 @@
|
||||
# 子智能体实现方案
|
||||
|
||||
## 概述
|
||||
|
||||
将 easyagent (Node.js) 集成为主智能体的子智能体系统,支持后台并行执行独立任务。
|
||||
|
||||
## 核心设计
|
||||
|
||||
### 1. 工具定义
|
||||
|
||||
#### create_sub_agent - 创建子智能体
|
||||
|
||||
**参数:**
|
||||
- `agent_id` (int, 必需): 子智能体编号 1-99,同一对话中唯一
|
||||
- `summary` (str, 必需): 任务摘要 10-30 字
|
||||
- `task` (str, 必需): 详细任务描述,包括目标、要求、交付内容、工作范围
|
||||
- `deliverables_dir` (str, 可选): 交付目录相对路径,默认 `sub_agent_results/agent_{agent_id}`
|
||||
- `run_in_background` (bool, 默认 false): 是否后台运行
|
||||
- `timeout_seconds` (int, 默认 600): 超时时间 60-3600 秒
|
||||
|
||||
**何时使用后台运行:**
|
||||
- 任务耗时较长(预计超过 5 分钟)
|
||||
- 可以继续处理其他工作,不需要立即使用结果
|
||||
- 多个独立任务可以并行执行
|
||||
|
||||
**何时使用阻塞运行(默认):**
|
||||
- 任务较快(几分钟内完成)
|
||||
- 后续工作依赖子智能体的结果
|
||||
- 需要立即查看和使用输出
|
||||
|
||||
**返回格式(阻塞模式):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"agent_id": 1,
|
||||
"status": "completed",
|
||||
"summary": "生成API文档",
|
||||
"message": "子智能体1 已完成任务",
|
||||
"deliverables_dir": "docs/api",
|
||||
"deliverables_files": ["api.md", "endpoints.json"],
|
||||
"result_summary": "已生成 API 文档,包含 15 个端点的详细说明。",
|
||||
"sub_conversation_id": "conv_abc123",
|
||||
"stats": {
|
||||
"runtime_seconds": 120,
|
||||
"files_read": 8,
|
||||
"searches": 3,
|
||||
"web_pages": 0,
|
||||
"commands": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式(后台模式):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"agent_id": 1,
|
||||
"status": "running",
|
||||
"summary": "生成API文档",
|
||||
"message": "子智能体1 已启动,正在后台运行",
|
||||
"deliverables_dir": "docs/api",
|
||||
"sub_conversation_id": "conv_abc123",
|
||||
"background": true
|
||||
}
|
||||
```
|
||||
|
||||
#### terminate_sub_agent - 终止子智能体
|
||||
|
||||
**参数:**
|
||||
- `agent_id` (int, 必需): 要终止的子智能体编号
|
||||
|
||||
**返回格式:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"agent_id": 1,
|
||||
"status": "terminated",
|
||||
"message": "子智能体1 已被终止",
|
||||
"partial_results": true,
|
||||
"deliverables_dir": "docs/api"
|
||||
}
|
||||
```
|
||||
|
||||
#### get_sub_agent_status - 查询子智能体状态
|
||||
|
||||
**参数:**
|
||||
- `agent_ids` (list[int], 必需): 要查询的子智能体编号列表
|
||||
|
||||
**返回格式:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": 1,
|
||||
"summary": "生成API文档",
|
||||
"status": "running",
|
||||
"runtime_seconds": 45,
|
||||
"files_read": 5,
|
||||
"searches": 2,
|
||||
"web_pages": 0,
|
||||
"commands": 1,
|
||||
"last_action": "正在读取 src/api/routes.py",
|
||||
"deliverables_dir": "docs/api"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### finish_task - 完成任务(子智能体工具)
|
||||
|
||||
**参数:**
|
||||
- `success` (bool, 必需): 任务是否成功完成
|
||||
- `summary` (str, 必需): 完成摘要 50-200 字,说明完成了什么、生成了哪些文件、关键发现
|
||||
|
||||
**返回格式:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "任务已完成,子智能体即将退出",
|
||||
"will_terminate": true
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 执行流程
|
||||
|
||||
#### 阻塞模式(run_in_background=false)
|
||||
|
||||
```
|
||||
主智能体调用 create_sub_agent
|
||||
↓
|
||||
启动 Node.js 子进程(easyagent 批处理模式)
|
||||
↓
|
||||
子进程读取任务文件和系统提示
|
||||
↓
|
||||
子进程执行对话循环
|
||||
↓
|
||||
子进程调用 finish_task 或超时
|
||||
↓
|
||||
主智能体读取输出文件
|
||||
↓
|
||||
返回结果给主智能体
|
||||
```
|
||||
|
||||
#### 后台模式(run_in_background=true)
|
||||
|
||||
```
|
||||
主智能体调用 create_sub_agent
|
||||
↓
|
||||
启动 Node.js 子进程(easyagent 批处理模式)
|
||||
↓
|
||||
立即返回 "running" 状态
|
||||
↓
|
||||
主智能体继续工作
|
||||
↓
|
||||
每次工具执行后,检查子智能体是否完成
|
||||
↓
|
||||
如果完成,插入 system 消息通知
|
||||
↓
|
||||
继续调用 API
|
||||
↓
|
||||
如果主智能体完成但子智能体未完成,设置对话状态
|
||||
↓
|
||||
轮询等待子智能体完成
|
||||
↓
|
||||
完成后发送 user 消息触发新一轮
|
||||
```
|
||||
|
||||
### 3. 消息拼接
|
||||
|
||||
#### 初始 user 消息(发送给子智能体)
|
||||
|
||||
```
|
||||
你是子智能体 {agent_id},负责以下任务:
|
||||
|
||||
【任务摘要】
|
||||
{summary}
|
||||
|
||||
【任务详情】
|
||||
{task}
|
||||
|
||||
【交付目录】
|
||||
{deliverables_dir}
|
||||
|
||||
【要求】
|
||||
1. 完成任务后,将所有结果文件放到交付目录
|
||||
2. 使用 finish_task 工具提交任务完成报告
|
||||
3. 任务超时时间:{timeout_seconds} 秒
|
||||
|
||||
现在开始执行任务。
|
||||
```
|
||||
|
||||
#### 完成通知(system 消息,工具执行后)
|
||||
|
||||
```
|
||||
子智能体{agent_id} ({summary}) 已完成。
|
||||
|
||||
运行时间:{runtime_seconds}秒
|
||||
阅读了 {files_read} 个文件
|
||||
搜索了 {searches} 次
|
||||
查看了 {web_pages} 个网页
|
||||
运行了 {commands} 个指令
|
||||
|
||||
交付目录:{deliverables_dir}
|
||||
结果摘要:{result_summary}
|
||||
```
|
||||
|
||||
#### 完成通知(user 消息,主智能体空闲时)
|
||||
|
||||
```
|
||||
子智能体{agent_id} ({summary}) 已完成任务。
|
||||
|
||||
{result_summary}
|
||||
|
||||
交付目录:{deliverables_dir}
|
||||
```
|
||||
|
||||
### 4. 兜底机制
|
||||
|
||||
如果子智能体输出结束但未调用 finish_task,自动发送 user 消息:
|
||||
|
||||
```
|
||||
如果你已经完成了任务,请调用 finish_task 工具提交完成报告。如果还没有完成,请继续执行任务。
|
||||
```
|
||||
|
||||
### 5. 子智能体 System Prompt
|
||||
|
||||
```
|
||||
你是一个专注的子智能体,负责独立完成分配的任务。
|
||||
|
||||
# 身份定位
|
||||
|
||||
你是主智能体创建的子智能体,拥有完整的工具能力(读写文件、执行命令、搜索网页等)。你的职责是专注完成分配的单一任务,不要偏离任务目标。
|
||||
|
||||
# 工作流程
|
||||
|
||||
1. **理解任务**:仔细阅读任务描述,明确目标和要求
|
||||
2. **制定计划**:规划完成任务的步骤
|
||||
3. **执行任务**:使用工具完成各个步骤
|
||||
4. **生成交付**:将所有结果文件放到指定的交付目录
|
||||
5. **提交报告**:使用 finish_task 工具提交完成报告并退出
|
||||
|
||||
# 工作原则
|
||||
|
||||
## 专注性
|
||||
- 只完成分配的任务,不要做额外的工作
|
||||
- 不要尝试与用户对话或询问问题
|
||||
- 遇到问题时,在能力范围内解决或在报告中说明
|
||||
|
||||
## 独立性
|
||||
- 你与主智能体共享工作区,可以访问所有文件
|
||||
- 你的工作范围应该与其他子智能体不重叠
|
||||
- 不要修改任务描述之外的文件
|
||||
|
||||
## 效率性
|
||||
- 直接开始工作,不要过度解释
|
||||
- 合理使用工具,避免重复操作
|
||||
- 注意超时限制,在时间内完成核心工作
|
||||
|
||||
## 完整性
|
||||
- 确保交付目录中的文件完整可用
|
||||
- 生成的文档要清晰、格式正确
|
||||
- 代码要包含必要的注释和说明
|
||||
|
||||
# 交付要求
|
||||
|
||||
所有结果文件必须放在指定的交付目录中,包括:
|
||||
- 主要成果文件(文档、代码、报告等)
|
||||
- 支持文件(数据、配置、示例等)
|
||||
- 不要在交付目录外创建文件
|
||||
|
||||
# 完成任务
|
||||
|
||||
任务完成后,必须调用 finish_task 工具:
|
||||
- success: 是否成功完成
|
||||
- summary: 完成摘要(说明做了什么、生成了什么)
|
||||
|
||||
调用 finish_task 后,你会立即退出,无法继续工作。
|
||||
|
||||
# 工具使用
|
||||
|
||||
你拥有以下工具能力:
|
||||
- read_file: 读取文件内容
|
||||
- write_file / edit_file: 创建或修改文件
|
||||
- search_workspace: 搜索文件和代码
|
||||
- run_command: 执行终端命令
|
||||
- web_search / extract_webpage: 搜索和提取网页内容
|
||||
- finish_task: 完成任务并退出(必须调用)
|
||||
|
||||
# 注意事项
|
||||
|
||||
1. **不要无限循环**:如果任务无法完成,说明原因并提交报告
|
||||
2. **不要超出范围**:只操作任务描述中指定的文件/目录
|
||||
3. **不要等待输入**:你是自主运行的,不会收到用户的进一步指令
|
||||
4. **注意时间限制**:超时会被强制终止,优先完成核心工作
|
||||
|
||||
# 当前环境
|
||||
|
||||
- 工作区路径: {workspace}
|
||||
- 系统: {system}
|
||||
- 当前时间: {current_time}
|
||||
|
||||
现在开始执行任务。
|
||||
```
|
||||
|
||||
### 6. 对话记录存储
|
||||
|
||||
子智能体的对话记录保存在:`{deliverables_dir}/.subagent/conversations.json`
|
||||
|
||||
格式:
|
||||
```json
|
||||
{
|
||||
"agent_id": 1,
|
||||
"summary": "生成API文档",
|
||||
"created_at": "2026-03-10T12:00:00Z",
|
||||
"completed_at": "2026-03-10T12:02:00Z",
|
||||
"messages": [
|
||||
{"role": "system", "content": "..."},
|
||||
{"role": "user", "content": "..."},
|
||||
{"role": "assistant", "content": "..."},
|
||||
...
|
||||
],
|
||||
"stats": {
|
||||
"runtime_seconds": 120,
|
||||
"files_read": 8,
|
||||
"searches": 3,
|
||||
"web_pages": 0,
|
||||
"commands": 2,
|
||||
"token_usage": {"prompt": 1000, "completion": 500, "total": 1500}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 文件结构
|
||||
|
||||
```
|
||||
project/
|
||||
├── sub_agent_results/
|
||||
│ └── agent_1/
|
||||
│ ├── .subagent/
|
||||
│ │ └── conversations.json # 对话记录
|
||||
│ ├── api.md # 交付文件
|
||||
│ ├── endpoints.json
|
||||
│ └── ...
|
||||
├── docs/
|
||||
│ └── api/
|
||||
│ ├── .subagent/
|
||||
│ │ └── conversations.json
|
||||
│ ├── api.md
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 8. 限制和约束
|
||||
|
||||
1. **最多 5 个并发子智能体**
|
||||
2. **禁止多个子智能体操作相同文件或目录**
|
||||
3. **禁止子智能体间的工作重叠**
|
||||
4. **超时后强制终止,已生成的部分结果保留**
|
||||
5. **切换对话会强制终止所有子智能体**
|
||||
|
||||
### 9. 实现步骤
|
||||
|
||||
1. ✅ 复制 easyagent 代码到 `easyagent/` 目录
|
||||
2. ✅ 创建 `easyagent/src/batch/index.js` 批处理入口
|
||||
3. ⏳ 修改 `SubAgentManager` 使用新的批处理模式
|
||||
4. ⏳ 添加工具定义到 `core/tool_config.py`
|
||||
5. ⏳ 修改主智能体的工具执行逻辑
|
||||
6. ⏳ 添加后台任务轮询机制
|
||||
7. ⏳ 添加对话状态管理(子智能体运行中)
|
||||
8. ⏳ 测试和调试
|
||||
|
||||
## 关键改进点
|
||||
|
||||
1. **简化参数**:去掉 reference_files、delivery_mode、reason 等不必要参数
|
||||
2. **清晰的后台机制**:默认阻塞,明确说明何时用后台
|
||||
3. **详细的统计信息**:工具使用次数一目了然
|
||||
4. **智能通知**:system 消息(工具执行后)+ user 消息(主智能体空闲时)
|
||||
5. **防止冲突**:工具描述中明确禁止重叠工作
|
||||
6. **兜底机制**:自动提醒调用 finish_task
|
||||
7. **对话记录保留**:便于调试和审计
|
||||
|
||||
@ -1,202 +0,0 @@
|
||||
# 子智能体集成测试
|
||||
|
||||
本文档说明如何测试新的子智能体系统。
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. 确保 Node.js 已安装(18+)
|
||||
2. 安装 easyagent 依赖:
|
||||
```bash
|
||||
cd easyagent
|
||||
npm install
|
||||
```
|
||||
|
||||
3. 确保 models.json 配置正确(复制 easyagent 根目录的 models.json 到 easyagent/ 目录)
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 测试批处理模式
|
||||
|
||||
```bash
|
||||
# 创建测试目录
|
||||
mkdir -p /tmp/test_subagent
|
||||
cd /tmp/test_subagent
|
||||
|
||||
# 创建测试任务文件
|
||||
cat > task.txt << 'EOF'
|
||||
你是子智能体 1,负责以下任务:
|
||||
|
||||
**任务摘要**:创建测试文件
|
||||
|
||||
**任务详情**:
|
||||
在当前目录创建一个名为 test.txt 的文件,内容为 "Hello from sub-agent"。
|
||||
|
||||
**交付目录**:/tmp/test_subagent/deliverables
|
||||
请将所有生成的文件保存到此目录。
|
||||
|
||||
**超时时间**:60 秒
|
||||
|
||||
完成任务后,请调用 finish_task 工具提交完成报告。
|
||||
EOF
|
||||
|
||||
# 创建系统提示文件
|
||||
cat > system.txt << 'EOF'
|
||||
你是一个专注的子智能体,负责独立完成分配的任务。
|
||||
完成任务后,必须调用 finish_task 工具。
|
||||
EOF
|
||||
|
||||
# 运行批处理
|
||||
node /Users/jojo/Desktop/agents/正在修复中/agents/easyagent/src/batch/index.js \
|
||||
--workspace /tmp/test_subagent \
|
||||
--task-file task.txt \
|
||||
--system-prompt-file system.txt \
|
||||
--output-file output.json \
|
||||
--stats-file stats.json \
|
||||
--agent-id 1 \
|
||||
--timeout 60
|
||||
|
||||
# 检查结果
|
||||
cat output.json
|
||||
ls -la deliverables/
|
||||
```
|
||||
|
||||
### 2. 测试主智能体集成
|
||||
|
||||
启动 Web 服务器:
|
||||
```bash
|
||||
cd /Users/jojo/Desktop/agents/正在修复中/agents
|
||||
python -m server.app
|
||||
```
|
||||
|
||||
在浏览器中测试:
|
||||
|
||||
1. 登录系统
|
||||
2. 创建新对话
|
||||
3. 发送消息:
|
||||
```
|
||||
请创建一个子智能体,编号1,任务是在项目根目录创建一个 test.md 文件,内容为"测试子智能体功能"。
|
||||
```
|
||||
|
||||
4. 观察子智能体执行过程
|
||||
5. 检查交付目录:`sub_agent_results/agent_1/`
|
||||
|
||||
### 3. 测试后台运行
|
||||
|
||||
发送消息:
|
||||
```
|
||||
请创建一个后台运行的子智能体,编号2,任务是搜索项目中所有的 Python 文件并统计行数,生成报告到 reports/line_count.md。设置为后台运行。
|
||||
```
|
||||
|
||||
然后继续发送其他消息,观察子智能体完成后的通知。
|
||||
|
||||
### 4. 测试多个并行子智能体
|
||||
|
||||
发送消息:
|
||||
```
|
||||
请同时创建3个子智能体:
|
||||
1. 编号1:分析 core/ 目录的代码结构
|
||||
2. 编号2:分析 modules/ 目录的代码结构
|
||||
3. 编号3:分析 utils/ 目录的代码结构
|
||||
|
||||
都设置为后台运行,交付目录分别为 reports/core、reports/modules、reports/utils。
|
||||
```
|
||||
|
||||
### 5. 测试查询状态
|
||||
|
||||
在子智能体运行时,发送消息:
|
||||
```
|
||||
查询子智能体 1、2、3 的状态
|
||||
```
|
||||
|
||||
### 6. 测试终止子智能体
|
||||
|
||||
发送消息:
|
||||
```
|
||||
终止子智能体 2
|
||||
```
|
||||
|
||||
## 预期结果
|
||||
|
||||
1. **批处理模式**:
|
||||
- output.json 包含 success 和 summary
|
||||
- deliverables/ 目录包含生成的文件
|
||||
- deliverables/.subagent/conversation.json 包含对话记录
|
||||
|
||||
2. **主智能体集成**:
|
||||
- 阻塞模式:立即返回完成结果
|
||||
- 后台模式:立即返回 running 状态,完成后收到通知
|
||||
|
||||
3. **并行执行**:
|
||||
- 多个子智能体同时运行
|
||||
- 各自独立完成任务
|
||||
- 不会相互干扰
|
||||
|
||||
4. **状态查询**:
|
||||
- 返回每个子智能体的运行时间、工具使用统计
|
||||
- 显示当前状态(running/completed)
|
||||
|
||||
5. **终止功能**:
|
||||
- 子智能体进程被终止
|
||||
- 部分结果保留
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. Node.js 找不到
|
||||
|
||||
确保 Node.js 已安装并在 PATH 中:
|
||||
```bash
|
||||
which node
|
||||
node --version
|
||||
```
|
||||
|
||||
### 2. 模型配置错误
|
||||
|
||||
检查 easyagent/models.json 是否存在且配置正确。
|
||||
|
||||
### 3. 权限问题
|
||||
|
||||
确保交付目录有写权限:
|
||||
```bash
|
||||
chmod -R 755 sub_agent_results/
|
||||
```
|
||||
|
||||
### 4. 子进程无法启动
|
||||
|
||||
检查 easyagent 批处理文件路径是否正确:
|
||||
```bash
|
||||
ls -la /Users/jojo/Desktop/agents/正在修复中/agents/easyagent/src/batch/index.js
|
||||
```
|
||||
|
||||
### 5. 对话记录未保存
|
||||
|
||||
检查交付目录的 .subagent/ 子目录权限。
|
||||
|
||||
## 调试技巧
|
||||
|
||||
1. 查看子进程输出:
|
||||
```bash
|
||||
tail -f logs/debug_stream.log
|
||||
```
|
||||
|
||||
2. 查看任务状态文件:
|
||||
```bash
|
||||
cat data/sub_agent_state.json
|
||||
```
|
||||
|
||||
3. 查看子智能体输出:
|
||||
```bash
|
||||
cat sub_agent_tasks/sub_1_*/output.json
|
||||
```
|
||||
|
||||
4. 查看统计信息:
|
||||
```bash
|
||||
cat sub_agent_tasks/sub_1_*/stats.json
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
测试通过后,可以:
|
||||
1. 添加更多工具到子智能体
|
||||
2. 优化性能和错误处理
|
||||
3. 添加前端展示界面
|
||||
4. 完善文档和示例
|
||||
80
_bootstrap.sh
Executable file
80
_bootstrap.sh
Executable file
@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# 共享引导逻辑:被 setup.sh / start.sh 引用(source)。
|
||||
# 负责定位项目根、准备 Python venv 与依赖、准备 Node 依赖。
|
||||
# 不直接执行;由入口脚本 source 后调用其中的函数。
|
||||
|
||||
# 出错即停;管道任一环节失败也算失败。
|
||||
set -euo pipefail
|
||||
|
||||
# 项目根目录 = 本脚本所在目录。
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="$ROOT/.venv"
|
||||
VENV_PY="$VENV_DIR/bin/python"
|
||||
|
||||
# 选择系统 Python:优先 python3,回退 python。
|
||||
_pick_system_python() {
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
echo "python3"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
echo "python"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# 准备虚拟环境与 Python 依赖(依赖系统已装 Python,不内置解释器)。
|
||||
# 已存在 venv 则复用;缺失则创建并安装 requirements.lock.txt(优先)或 requirements.txt。
|
||||
ensure_python_env() {
|
||||
if [ ! -x "$VENV_PY" ]; then
|
||||
local sys_py
|
||||
sys_py="$(_pick_system_python)"
|
||||
if [ -z "$sys_py" ]; then
|
||||
echo "[error] 未找到系统 Python(需要 python3 或 python)。请先安装 Python 3.9+。" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[setup] 使用 $sys_py 创建虚拟环境:$VENV_DIR"
|
||||
"$sys_py" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "[setup] 升级 pip 并安装依赖..."
|
||||
"$VENV_PY" -m pip install --upgrade pip >/dev/null
|
||||
if [ -f "$ROOT/requirements.lock.txt" ]; then
|
||||
"$VENV_PY" -m pip install -r "$ROOT/requirements.lock.txt"
|
||||
elif [ -f "$ROOT/requirements.txt" ]; then
|
||||
"$VENV_PY" -m pip install -r "$ROOT/requirements.txt"
|
||||
else
|
||||
echo "[error] 找不到 requirements.lock.txt 或 requirements.txt" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 准备 Node 依赖(依赖系统已装 Node;不内置 Node)。
|
||||
# easyagent 仅需运行时依赖;前端需要构建产物(vite build)。
|
||||
ensure_node_env() {
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "[warn] 未找到系统 Node。子智能体(easyagent)与前端构建将不可用。" >&2
|
||||
echo " 如需完整功能,请安装 Node.js 18+ 后重跑。" >&2
|
||||
return 0
|
||||
fi
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "[warn] 找到 node 但未找到 npm,跳过 Node 依赖安装。" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
# easyagent 运行时依赖
|
||||
if [ -f "$ROOT/easyagent/package.json" ] && [ ! -d "$ROOT/easyagent/node_modules" ]; then
|
||||
echo "[setup] 安装 easyagent 依赖(npm ci)..."
|
||||
( cd "$ROOT/easyagent" && npm ci )
|
||||
fi
|
||||
|
||||
# 前端构建产物(static/dist 之类)。仅在缺失时构建,避免每次启动都跑。
|
||||
if [ -f "$ROOT/package.json" ] && [ ! -d "$ROOT/node_modules" ]; then
|
||||
echo "[setup] 安装前端依赖并构建(npm ci && npm run build)..."
|
||||
( cd "$ROOT" && npm ci && npm run build )
|
||||
fi
|
||||
}
|
||||
|
||||
# .env 是否存在(首次启动判断依据)。
|
||||
has_env_file() {
|
||||
[ -f "$ROOT/.env" ]
|
||||
}
|
||||
@ -1,42 +1,79 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
|
||||
description: 创建具有鲜明风格、生产级品质和高设计水准的前端界面。当用户要求构建 Web 组件、页面、作品、海报或应用,以及创建html卡片时使用此 skill(例如网站、落地页、仪表盘、React 组件、HTML/CSS 布局,或任何 Web UI 的样式设计/美化)。生成富有创意、精致完善的代码与 UI 设计,避免泛泛的 AI 风格审美。
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
这个 skill 用于指导创建具有鲜明风格、生产级品质的前端界面,避免通用、粗糙的“AI 垃圾感”审美。请实现真正可工作的代码,并对审美细节与创意选择投入极高关注。
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
用户会提供前端需求:要构建的组件、页面、应用或界面。他们也可能提供用途、受众或技术约束等上下文。
|
||||
|
||||
## Design Thinking
|
||||
## 设计思考
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
在编码之前,先理解上下文,并明确采用一个**大胆且符合实际业务场景**的审美方向:
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
- **目的**:这个界面解决什么问题?谁会使用它?
|
||||
- **调性**:选择一种鲜明取向:极致极简、极繁混沌、复古未来主义、有机/自然、奢华/精致、玩具般俏皮、编辑部/杂志风、粗野主义/原始感、装饰艺术/几何、柔和/粉彩、工业/实用主义,等等。可从这些方向中获取灵感,但最终设计必须忠于所选的审美方向。
|
||||
- **约束**:技术要求(框架、性能、可访问性)。
|
||||
- **差异化**:是什么让它令人难忘?用户会记住的那一个点是什么?
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
**关键要求**:选择清晰的概念方向,并以精准方式执行。大胆不等于浮夸,克制也不等于平庸。大胆的极繁主义和克制的精致极简都可以成功——重点是意图明确、符合场景,而不是视觉强度本身。
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
审美方向必须服务于产品目标、用户心智和使用场景。例如,论文查重、AIGC 检测、金融、医疗、法律、政企工具、学术系统等场景,通常更需要可信、清晰、冷静、专业和可解释,而不是霓虹赛博朋克、过量发光边框、强烈蓝紫渐变或炫技动效。娱乐、游戏、活动页、艺术项目等场景可以更大胆实验,但仍然必须与内容和受众相匹配。
|
||||
|
||||
Focus on:
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
然后实现可工作的代码(HTML/CSS/JS、React、Vue 等),并确保它:
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
- 具备生产级质量且功能完整
|
||||
- 视觉上醒目、令人印象深刻
|
||||
- 具有统一且清晰的审美观点
|
||||
- 每一个细节都经过认真打磨
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
## 前端审美指南
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
重点关注:
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
- **字体排印**:选择美观、独特、有趣的字体。避免使用 Arial、Inter 这类常见字体;改用能提升前端审美的、有辨识度且富有性格的字体选择。将独特的展示字体与精致的正文字体搭配使用。
|
||||
- **色彩与主题**:坚持统一的审美体系。使用 CSS 变量保持一致性。主导色配合锐利强调色,通常比胆怯、平均分配的调色板更有表现力。
|
||||
- **动效**:使用动画增强效果与微交互。对于 HTML,优先采用纯 CSS 方案。React 中如可用,可使用 Motion 库。关注高影响力时刻:一次精心编排、带有错峰显现(animation-delay)的页面加载动画,往往比零散的微交互更令人愉悦。使用令人惊喜的滚动触发与悬停状态。
|
||||
- **空间构图**:采用出人意料的布局。不对称、重叠、对角线流动、打破网格的元素。可以使用充足的留白,也可以使用受控的高密度排布。
|
||||
- **背景与视觉细节**:营造氛围与深度,而不是默认使用纯色背景。添加符合整体审美的上下文效果与纹理。运用富有创意的形式,例如渐变网格、噪声纹理、几何图案、分层透明、戏剧化阴影、装饰性边框、自定义光标与颗粒叠层。
|
||||
|
||||
绝不要使用通用的 AI 生成式审美,例如过度使用的字体家族(Inter、Roboto、Arial、系统字体)、陈词滥调的配色方案(尤其是白色背景上的紫色渐变)、可预测的布局与组件模式,以及缺少特定上下文个性的模板化设计。
|
||||
|
||||
### 必须避免的 AI 味垃圾审美
|
||||
|
||||
尤其要避免以下常见问题:
|
||||
|
||||
- 大面积蓝色、紫色、粉紫色渐变背景,尤其是在与业务气质无关时。
|
||||
- 把标题、按钮、卡片、背景、图标全部做成蓝紫渐变或霓虹发光效果。
|
||||
- 机械套用玻璃拟态、毛玻璃、模糊圆斑、发光描边和大阴影。
|
||||
- 页面看起来像通用 AI SaaS 模板:大标题、紫色渐变按钮、三张功能卡片、虚假数据面板。
|
||||
- 为了“科技感”加入与产品无关的星空、网格、粒子、赛博朋克霓虹、3D 球体或漂浮光效。
|
||||
- 滥用 emoji,尤其是火箭、闪电、星星、魔法棒、机器人、彩虹等和产品气质不匹配的符号。
|
||||
- 用 emoji 替代真正设计过的图标系统。除非产品本身是轻松、社交、儿童、游戏或玩具化风格,否则应优先使用统一风格的 SVG 图标、线性图标、品牌插画或纯排版符号。
|
||||
- 图标风格混乱:emoji、线性 icon、填充 icon、3D 图标、表情符号随意混用。
|
||||
- 过度圆角、过度阴影、过度发光、过度动画,导致信息层级和专业感被削弱。
|
||||
- 动效只是为了炫酷存在,而不是用于强调状态变化、引导视线、解释结构或提升反馈。
|
||||
- 配色只追求“炫”,不考虑阅读性、可信度、业务语境、品牌一致性和可访问性。
|
||||
|
||||
要进行创造性解读,做出意想不到但真正贴合上下文的选择。任何设计都不应该彼此雷同。请在明暗主题、不同字体、不同审美方向之间变化。绝不要在多次生成中趋同到常见选择(例如 Space Grotesk)。
|
||||
|
||||
## HTML 卡片与小尺寸展示规范
|
||||
|
||||
在本项目中,HTML 卡片也是重要的展示形态。不要默认把所有 HTML 输出都设计成完整官网或大型落地页;很多时候它会出现在聊天流、报告流、任务结果或侧边面板中,更像一张精心设计的信息卡、报告摘要或小型操作面板。
|
||||
|
||||
设计 HTML 卡片时遵循:
|
||||
|
||||
- 默认按小容器设计,而不是按全屏页面设计。卡片常见显示区域约为 `600px` 左右宽高。
|
||||
- 实际渲染内容不要过大,避免在聊天界面或结果面板中出现超长滚动、巨大留白或需要大屏才能看清的布局。
|
||||
- 优先在有限空间内表达最关键的信息层级:标题/结论、核心数据或状态、简短说明、必要操作或提示。
|
||||
- 使用紧凑但不拥挤的间距。字号、图表、按钮、图标都要适配卡片尺寸。
|
||||
- 避免依赖全屏背景、大面积装饰、复杂滚动叙事、多栏大屏布局或过长动画。
|
||||
- 如果信息很多,优先使用摘要、分组、折叠、分页或“查看详情”结构,而不是把所有内容无差别堆进卡片。
|
||||
- 卡片可以有明确审美与细节,例如精致边框、轻量纹理、局部 SVG、状态色、微动效,但必须服务于信息表达。
|
||||
- 对于报告、检测、审核、论文、AIGC 率、风险分析等场景,卡片应优先体现可信、清晰、可读和结论明确,而不是浮夸科技感。
|
||||
- 卡片可以包含精美优雅的动画交互,但不要包含任何元素的初始动画或出场动画效果
|
||||
|
||||
**重要**:让实现复杂度与审美愿景相匹配。极繁主义设计需要更复杂的代码、大量动画与效果。极简或精致设计则需要克制、精准,以及对间距、字体排印和细微细节的谨慎关注。优雅来自于对愿景的高质量执行。
|
||||
|
||||
请记住:你能够完成非凡的创造性工作。不要保守,要展示在跳出框架思考并完全投入鲜明愿景时,真正能够创造出的作品。
|
||||
|
||||
@ -38,7 +38,7 @@ description: run_command 工具使用指南。介绍前台与后台模式、后
|
||||
|
||||
- timeout 最长 3600 秒。命令在后台继续执行,工具会等约 5 秒收集当前输出并返回,然后 **立即释放控制权**,让你继续处理其他任务。
|
||||
- 返回值里会包含 `command_id`,`status` 可能是 `running_background`。你会拿到已经产生的输出片段,但最终结果要靠系统通知或手动等待。
|
||||
- 成功或失败完成后,系统会自动发送一条 `system` 消息(内容类似 `后台命令已完成(completed)`,并附带 `command_id`、`command`、`output` 摘要)通知你。不要再依赖 `wait_sub_agent`/手动轮询。
|
||||
- 成功或失败完成后,系统会自动插入一条 user 消息(前缀 `[系统通知|background_command]`,附带 `command_id` 与输出摘要)通知你。不要依赖任何手动轮询。
|
||||
- 适合长时间任务(构建/安装/批处理)或你想“先发起再继续别的工作”。
|
||||
- 返回的 `command_id` 需要保留,用于后续通过 `sleep(wait_runcommand_id=...)` 等待结果或根据通知定位输出。
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: sub-agent-guide
|
||||
description: 子智能体使用指南。创建子智能体之前必须阅读此技能。子智能体仅用于并行处理独立的“非代码编写”任务(如项目初步探索、网络搜索与信息收集、程序测试执行)。关键限制:子智能体之间无法通信,看不到主对话历史。严格禁止将子智能体用于项目代码编写、代码修改、重构或实现功能。
|
||||
description: 子智能体使用指南。创建子智能体之前必须阅读此技能。子智能体仅用于并行处理独立的“非代码编写”任务(如项目初步探索、网络搜索与信息收集、程序测试执行)。关键限制:子智能体之间无法通信,看不到主对话历史。严格禁止将子智能体用于项目代码编写、代码修改、重构或实现功能。**如果运行模式为多智能体模式,禁止阅读并参考该 skill。**
|
||||
---
|
||||
|
||||
# 子智能体使用指南
|
||||
@ -18,6 +18,7 @@ description: 子智能体使用指南。创建子智能体之前必须阅读此
|
||||
**适合(推荐):**
|
||||
- **项目初期探索**:快速梳理目录结构、模块职责、运行方式,形成初步认知
|
||||
- **占用大量上下文的网络搜索和信息收集**:竞品调研、资料汇总、文档整理、新闻/资料比对
|
||||
- **重要**:进行大范围搜索或调查前,必须先自己进行一次浅层搜索/调查,摸清关键词、主要来源和任务边界,再创建子智能体并给出精确指导。
|
||||
- **程序测试与验证**:执行测试命令、收集日志、汇总失败用例与错误模式
|
||||
- **并行独立的文档/分析任务**:生成说明文档、运行报告、排查清单
|
||||
|
||||
@ -41,19 +42,19 @@ description: 子智能体使用指南。创建子智能体之前必须阅读此
|
||||
**好的示例:**
|
||||
|
||||
```
|
||||
# 信息搜集任务
|
||||
分析项目的前后端认证与鉴权实现:
|
||||
1. 查找所有与认证相关的代码文件(login, auth, jwt, session 等关键词)
|
||||
2. 分析前端如何存储和传递认证信息
|
||||
3. 分析后端如何验证和管理用户权限
|
||||
4. 整理成一份技术文档(auth_analysis.md),包含流程图和代码位置
|
||||
# 代码定位任务
|
||||
用户反馈了一个程序 bug:登录时偶尔报 500 错误。
|
||||
1. 先自己阅读项目结构,确认技术栈和登录相关模块位置
|
||||
2. 复现或定位错误触发条件,理解问题表现
|
||||
3. 创建子智能体:在 src/auth/、src/middleware/、src/api/login/ 等目录中查找可能导致 500 的代码路径
|
||||
4. 子智能体交付可疑文件列表和代码位置,生成 bug_location.md
|
||||
|
||||
# 网络调研任务
|
||||
搜索并整理英伟达2025年的财报信息:
|
||||
1. 搜索英伟达2025年Q1-Q4财报
|
||||
2. 提取关键财务数据(营收、利润、增长率)
|
||||
3. 分析主要业务板块表现
|
||||
4. 生成结构化报告(nvidia_2025_report.md)
|
||||
用户要求调查国产开源智能体功能:
|
||||
1. 先自己进行一次浅层搜索,列出当前主要的国产开源智能体(如 AgentScope、DB-GPT、ModelScope-Agent 等)
|
||||
2. 根据初步结果,为每个候选智能体创建独立的子智能体,分别调研其功能特性、架构设计、适用场景
|
||||
3. 每个子智能体生成独立报告
|
||||
4. 最后由你整合对比,生成 overall_comparison.md
|
||||
|
||||
# 系统信息收集
|
||||
收集当前计算机的硬件和系统信息:
|
||||
@ -122,13 +123,12 @@ create_sub_agent(
|
||||
- 你会在对话中看到类似:"这是一句系统自动发送的user消息,用于通知你子智能体已经运行完成..."
|
||||
|
||||
**完成通知机制说明:**
|
||||
- 后台子智能体完成的通知是系统自动插入的 user/system 消息,不依赖手动调用 `wait_sub_agent` 或其它轮询接口。
|
||||
- 后台子智能体完成的通知是系统自动插入的 user 消息(前缀 `[系统通知|sub_agent]`),不依赖手动调用任何轮询接口。
|
||||
- 你只需在收到通知后直接查看交付目录即可,系统会把所有必需信息一并带上。
|
||||
|
||||
**何时使用:**
|
||||
- ✅ 耗时任务(预计 > 1 分钟)
|
||||
- ✅ 多个并行任务
|
||||
- ✅ 你有其他工作可以先做(回答用户问题、处理其他任务)
|
||||
- ✅ 需要同时创建多个子智能体
|
||||
- ✅ 创建后你有其他工作可以先做(回答用户问题、处理其他任务)
|
||||
|
||||
**后台模式下你应该做什么:**
|
||||
|
||||
@ -149,35 +149,47 @@ create_sub_agent(agent_id=2, task="...", run_in_background=true, ...)
|
||||
# 子智能体完成时会自动插入一条 system 消息通知你
|
||||
```
|
||||
|
||||
**情况 B:运行子智能体期间不需要做别的事情**
|
||||
**情况 B:运行子智能体期间不需要做别的事情,且只需要一个子智能体**
|
||||
```python
|
||||
create_sub_agent(agent_id=1, task="大规模日志分析", run_in_background=true, timeout_seconds=1800)
|
||||
# 只有单个子智能体,且期间没有其他工作,直接使用阻塞模式
|
||||
create_sub_agent(agent_id=1, task="大规模日志分析", run_in_background=false, timeout_seconds=1800)
|
||||
|
||||
# 等待工具返回后,直接读取交付目录处理结果
|
||||
```
|
||||
|
||||
**情况 C:运行子智能体期间不需要做别的事情,但需要同时创建多个子智能体**
|
||||
```python
|
||||
# 多个子智能体同时运行,即使自己暂时没事做,也全部使用后台模式
|
||||
create_sub_agent(agent_id=1, task="分析前端认证实现", deliverables_dir="analysis/frontend_auth", run_in_background=true, ...)
|
||||
create_sub_agent(agent_id=2, task="分析后端鉴权实现", deliverables_dir="analysis/backend_auth", run_in_background=true, ...)
|
||||
|
||||
# 告诉用户预计时间
|
||||
"我已启动子智能体进行日志分析,这个任务比较耗时,预计需要20-30分钟。系统会在完成后自动通知我,届时我会立即处理结果并回复你。"
|
||||
"我已启动2个子智能体并行分析认证与鉴权实现,预计需要10-15分钟。系统会在完成后自动通知我,届时我会立即整合结果并回复你。"
|
||||
|
||||
# 然后停止工作和输出,等待系统通知
|
||||
# 子智能体运行完成后,系统会自动发送一条 user 消息通知你
|
||||
# 每个子智能体完成后,系统都会自动发送一条 user 消息通知你
|
||||
```
|
||||
|
||||
**重要提示:**
|
||||
- 系统会自动处理通知,你只需要在收到通知消息后处理结果即可
|
||||
- 如果你正在执行工具调用,系统会延迟通知,避免打断你的工作流
|
||||
- 通知类型取决于你是否继续工作:继续工作时收到 system 消息,停止输出时收到 user 消息
|
||||
- 后台完成由上述系统通知负责,不要再依赖 `wait_sub_agent` 或类似的手动等待流,系统会在能插入消息时自动通知你。
|
||||
- 不论运行中还是空闲,通知一律是带 `[系统通知|sub_agent]` 前缀的 user 消息;运行中只是通知不触发新一轮回复,空闲时等同于一条新输入会触发回复
|
||||
- 后台完成由上述系统通知负责,不要再依赖任何手动等待流,系统会在能插入消息时自动通知你。
|
||||
|
||||
### 模式选择决策树
|
||||
|
||||
```
|
||||
任务预计耗时 < 1 分钟?
|
||||
├─ 是 → 使用阻塞模式(run_in_background=false)
|
||||
└─ 否 → 有多个并行任务?
|
||||
├─ 是 → 使用后台模式(run_in_background=true)
|
||||
└─ 否 → 有其他工作可以先做?
|
||||
├─ 是 → 使用后台模式
|
||||
└─ 否 → 使用阻塞模式
|
||||
└─ 否 → 需要同时创建多个子智能体?
|
||||
├─ 是 → 使用后台模式(run_in_background=true),然后停止输出等待系统通知
|
||||
└─ 否 → 创建后你有没有其他工作可以先做?
|
||||
├─ 是 → 使用后台模式(run_in_background=true),继续处理其他工作
|
||||
└─ 否 → 使用阻塞模式(run_in_background=false)
|
||||
```
|
||||
|
||||
> **核心判断标准**:选择阻塞模式还是后台模式,**唯一标准是创建子智能体后你自己还有没有别的事情要做**,和子智能体本身被用来做什么任务无关。不要因为任务是“搜索”就一定用后台,也不要因为任务是“分析”就一定用阻塞。
|
||||
|
||||
## 并行执行模式
|
||||
|
||||
创建多个子智能体时,确保它们的任务**完全独立**:
|
||||
@ -190,9 +202,12 @@ create_sub_agent(agent_id=2, task="生成模块 B 的文档", deliverables_dir="
|
||||
# 然后你可以继续做其他事情,或者停止输出等待通知
|
||||
|
||||
# ✅ 好的并行:信息搜集
|
||||
create_sub_agent(agent_id=1, task="搜索并整理 Claude 最新模型信息", deliverables_dir="research/claude", run_in_background=true, ...)
|
||||
create_sub_agent(agent_id=2, task="搜索并整理 ChatGPT 最新模型信息", deliverables_dir="research/chatgpt", run_in_background=true, ...)
|
||||
# 等系统自动通知两个都完成后,你再对比整合
|
||||
# 先自己浅层搜索,列出 3-5 个国产开源智能体
|
||||
# 然后为每个智能体创建独立的子智能体并行调研
|
||||
create_sub_agent(agent_id=1, task="调研 AgentScope 的功能特性、架构和适用场景", deliverables_dir="research/agentscope", run_in_background=true, ...)
|
||||
create_sub_agent(agent_id=2, task="调研 DB-GPT 的功能特性、架构和适用场景", deliverables_dir="research/dbgpt", run_in_background=true, ...)
|
||||
create_sub_agent(agent_id=3, task="调研 ModelScope-Agent 的功能特性、架构和适用场景", deliverables_dir="research/modelscope_agent", run_in_background=true, ...)
|
||||
# 等系统自动通知全部完成后,你再整合生成对比报告
|
||||
|
||||
# ✅ 好的并行:系统调研
|
||||
create_sub_agent(agent_id=1, task="分析前端认证实现", deliverables_dir="analysis/frontend_auth", run_in_background=true, ...)
|
||||
@ -331,7 +346,7 @@ else:
|
||||
子智能体的本质是**并行执行完全独立的任务**。
|
||||
|
||||
**关键要点:**
|
||||
1. **执行模式选择**:快速任务用阻塞模式,耗时任务或并行任务用后台模式
|
||||
1. **执行模式选择**:运行子智能体期间没有别的事情要做且只创建一个子智能体 → 阻塞模式;需要同时创建多个子智能体,或期间有其他工作可以先做 → 后台模式
|
||||
2. **后台模式下**:创建后继续工作或停止输出,系统会自动通知完成
|
||||
3. **任务独立性**:子智能体之间无法通信,确保任务完全独立
|
||||
4. **任务描述**:子智能体看不到对话历史,task 参数必须包含所有信息
|
||||
|
||||
@ -354,8 +354,8 @@ terminal_input(command="vim file.txt", output_wait=5) # vim 需要完整 TTY
|
||||
|
||||
✅ **正确:**
|
||||
```python
|
||||
# 使用 run_python 执行 Python 代码
|
||||
run_python(code="print('hello')", timeout=5)
|
||||
# 使用 run_command 探测并调用合适的 Python 解释器
|
||||
run_command(command="python3 -c 'print(\"hello\")'", timeout=5)
|
||||
|
||||
# 使用 edit_file 修改文件
|
||||
edit_file(file_path="file.txt", old_string="...", new_string="...")
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
---
|
||||
name: Test Skill
|
||||
description: Validate skills loading and read_file flow.
|
||||
example_prompts:
|
||||
- "Use the test skill to explain how to proceed."
|
||||
tools:
|
||||
- read_file
|
||||
tags:
|
||||
- test
|
||||
---
|
||||
|
||||
# Test Skill
|
||||
|
||||
This skill exists to verify that the skills system can discover, list, sync,
|
||||
and read SKILL.md content via read_file. Replace this content later.
|
||||
150
agentskills/ui-aesthetic-design/SKILL.md
Normal file
150
agentskills/ui-aesthetic-design/SKILL.md
Normal file
@ -0,0 +1,150 @@
|
||||
---
|
||||
name: ui-aesthetic-design
|
||||
description: 用于进行高质量 UI 美学设计、界面精致化、组件视觉评审与前端 demo 设计的通用设计规范。当任务涉及现代系统界面、侧边栏、资源面板、消息流、代码展示、按钮、hover/active 状态、二级菜单、展示项/可选项区分、内容对齐、图标视觉体积对齐、浅色/深色主题等 UI 美学问题时使用。
|
||||
---
|
||||
|
||||
# UI Aesthetic Design
|
||||
|
||||
当设计、评审或实现 UI 改动时使用此 skill。目标不是做通用 SaaS 模板,而是形成克制、现代、精致、像成熟 AI 系统产品的界面。
|
||||
|
||||
## 核心审美方向
|
||||
|
||||
整体风格参考 ChatGPT / Claude / Linear 一类现代 AI 系统的克制感:
|
||||
|
||||
- 使用干净的浅色/深色表面、细边框、轻量阴影和安静的 hover 状态。
|
||||
- 避免过度发光、重阴影、毛玻璃、强模糊、圆角矩形套圆角矩形、霓虹渐变和无意义装饰。
|
||||
- 优先使用细线图标、浅灰 hover 底色、短而稳定的动效。
|
||||
- 布局要紧凑但不拥挤,信息密度可以高,但不能乱。
|
||||
- 精致感来自准确的间距、对齐、命中区域、状态反馈和微交互,而不是视觉噪音。
|
||||
|
||||
## 颜色与字体层级
|
||||
|
||||
使用少量语义化颜色:
|
||||
|
||||
- 背景/表面:浅色模式使用白色或近白;深色模式使用近黑或炭黑。
|
||||
- 主文字:高对比,用于主要内容。
|
||||
- 次文字:浅灰,用于路径、状态、元信息、辅助说明。
|
||||
- 边框:中性 1px 细边框。
|
||||
- Hover:低透明度灰色填充。
|
||||
- Active/当前项:略强的低透明度灰色,或明确但克制的选择标记。
|
||||
- Danger:只用于删除等破坏性操作。
|
||||
|
||||
字体层级:
|
||||
|
||||
- 主内容文字应更深、更大或更有字重。
|
||||
- 次内容文字应更浅、更小。
|
||||
- 不要为了“丰富界面”凭空添加解释性次级文本;只有真实数据结构里存在时才显示。
|
||||
- 紧凑面板里的行默认单行显示,长文本截断;不要因为某条内容更长就撑高行高,除非产品信息结构明确需要多行。
|
||||
|
||||
## 对齐原则
|
||||
|
||||
追求**内容对齐**,不是容器对齐,也不是机械文字左对齐。
|
||||
|
||||
- 对齐有意义的视觉内容,而不是外层矩形框。
|
||||
- 如果某一行由 SVG + 文字组成,标题或相关内容应根据 SVG 的视觉位置做对齐,而不是盲目对齐文字边缘。
|
||||
- 如果下方具体内容本身没有图标,不要为了凑齐文字起点而乱加假图标。
|
||||
- 按实际视觉体积对齐:不同 SVG 的 viewBox 和形状不同,真实尺寸可以不同,只要视觉大小一致。
|
||||
- 不要强行把所有 SVG 设成同样宽高。
|
||||
- 可以统一按钮命中区域,但图标本身大小要按视觉体积校准。
|
||||
|
||||
## 组件状态原则
|
||||
|
||||
状态设计必须反映真实语义:
|
||||
|
||||
- Hover:只用于可交互项。
|
||||
- Active/当前:只用于当前选择、当前模式或当前项。
|
||||
- Disabled:降低对比度,并移除点击暗示。
|
||||
- 展示内容:不要看起来像按钮;避免 hover 底色、阴影、指针光标。
|
||||
- 可点击内容:可以使用轻量 hover 底色、指针光标和当前项标记。
|
||||
|
||||
不要把“有没有阴影”当成唯一状态区别。状态语义应该通过结构、标记、间距和交互暗示共同表达。
|
||||
|
||||
## 可选项 vs 展示项
|
||||
|
||||
设计前必须先判断内容类型。
|
||||
|
||||
### 可选项
|
||||
|
||||
用于用户从多个选项中选择一个,例如环境切换、资源位置切换、账号/空间切换、筛选条件切换。
|
||||
|
||||
- 必须有明确的“可选择”视觉语义,例如 radio 圆点、check 标记或清晰的选中行。
|
||||
- 当前项必须一眼能看出来。
|
||||
- 可以有浅灰 hover 底色。
|
||||
- 行本身可以是可点击的。
|
||||
- `默认`、`当前` 这类 badge 只是元信息,不能单独承担“这是可选项”的职责。
|
||||
|
||||
### 展示项
|
||||
|
||||
用于展示状态或列表数据,例如任务条目、命令摘要、进程摘要、通知摘要,除非真实组件会打开详情。
|
||||
|
||||
- 保持安静,不要做成按钮或卡片。
|
||||
- 不要乱加假图标。
|
||||
- 不要加卡片阴影或嵌套圆角容器。
|
||||
- 如果真实产品里不是可点击项,就不要加 hover 效果。
|
||||
- 紧凑面板中使用统一行高和单行截断。
|
||||
- 只显示真实数据模型里存在的字段。
|
||||
|
||||
## 二级菜单与更多操作
|
||||
|
||||
二级菜单要一致、克制、可读:
|
||||
|
||||
- 使用紧凑浮层,带轻量边框、适中圆角和轻阴影。
|
||||
- 菜单选项无论文字长短,hover 背景宽度必须一样长。
|
||||
- 菜单宽度由最长内容决定,所有选项填满同一宽度。
|
||||
- 不要让每个选项的阴影/hover 背景长度随文字变短变长。
|
||||
- 文案要短。
|
||||
- 删除等破坏性项只用 danger 色区分,不要用更大尺寸或更重阴影。
|
||||
- 列表行右侧 `...` 使用简单圆点视觉;hover 可以只变色,不一定加背景。
|
||||
|
||||
## 侧边栏展开原则
|
||||
|
||||
消息类/导航类侧边栏可使用“遮罩露出”模型,而不是新建一个展开浮层:
|
||||
|
||||
- 把侧边栏理解为一张完整面板,被一个移动边界遮住。
|
||||
- 收起态只露出固定图标列。
|
||||
- 展开时右侧竖线向右移动,内容被裁切后逐渐露出。
|
||||
- 每个动作从始至终只有一个图标,不要展开后再出现第二个图标。
|
||||
- 图标右侧文字和新出现的列表内容同时执行两种效果:淡入 + 随移动边界逐渐露出。
|
||||
- 展开时不应挤压主内容布局;如设计目标是覆盖/露出,就保持主布局稳定。
|
||||
- 收起态 hover 只作用在图标的圆角正方形上,不要露出被裁切的大圆角整行背景。
|
||||
- 展开态 hover 才可以作用于整行。
|
||||
|
||||
## 资源面板原则
|
||||
|
||||
资源面板、管理面板、辅助面板应避免旧式“圆角矩形套圆角矩形套圆角矩形”的低级感。
|
||||
|
||||
- 优先做窄而扁平的面板;如果它位于主导航旁边,可以让展开后的侧边栏完整覆盖它。
|
||||
- 顶部标题显示当前内容类型,例如资源、环境、任务、命令、通知、日志等。不要把所有模式标题硬塞到同一种视觉层级里。
|
||||
- 标题下方可放一个“模式切换按钮”,点击展开二级菜单;旁边按需放紧凑管理图标按钮。
|
||||
- 不要把浅色小号“管理”文字按钮放在大标题同一行,容易层级违和;优先使用图标按钮。
|
||||
- 不要无意义增加搜索框;只有真实工作流需要搜索或过滤时才加。
|
||||
- 层级资源树可以使用文件/文件夹/类别图标,因为这类图标本身承载结构语义。
|
||||
- 环境/空间/资源位置切换属于可选项 UI,不是普通展示文本。
|
||||
- 任务、命令、进程、通知等区域按真实数据字段显示:
|
||||
- 任务/进程:标识、摘要、状态、可选当前步骤。
|
||||
- 命令/作业:命令或作业预览、状态、返回码/结果码。
|
||||
- 清单/待办:序号、标题、状态;如果存在总说明,可单独显示。
|
||||
- 内容行默认统一高度,除非真实信息结构明确需要展开。
|
||||
|
||||
## 代码展示原则
|
||||
|
||||
代码块、日志块或等宽文本展示精致化时:
|
||||
|
||||
- 做成现代 artifact/card 感:1px 细边框、较大圆角、清晰 header/body 分区。
|
||||
- 避免粗边框和重嵌套面板。
|
||||
- 复制/运行按钮要遵循同一套按钮系统。
|
||||
- Header 里的语言名称是次级信息;代码内容才是主内容。
|
||||
- padding、等宽字体、行高要统一;流式输出时避免视觉抖动。
|
||||
|
||||
## 实现前检查清单
|
||||
|
||||
修改 UI 前必须:
|
||||
|
||||
1. 先读取真实组件、状态或数据结构,不准凭印象设计假数据。
|
||||
2. 判断每个内容是可选项、可点击动作,还是展示内容。
|
||||
3. 按视觉内容对齐,而不是按容器边缘或文字边缘机械对齐。
|
||||
4. 复用产品已有图标资源或统一图标库,不要自己编造 SVG。
|
||||
5. 统一图标命中区域,但按视觉体积调整真实图标大小。
|
||||
6. 避免嵌套卡片、大阴影和假的 hover 暗示。
|
||||
7. 同时检查浅色和深色模式。
|
||||
8. 写 demo 时也要贴近真实产品结构和真实字段;如必须使用示例数据,要保持字段语义真实,不要写臆想说明。
|
||||
@ -1,5 +1,98 @@
|
||||
# App 更新说明
|
||||
|
||||
# 1.0.41
|
||||
- 修复 Android App 内文件下载报错 404 的问题:下载地址不再错误地把当前对话路径拼进去
|
||||
- 修复下载时 `CookieManager` 在非主线程调用导致的崩溃提示
|
||||
|
||||
# 1.0.40
|
||||
- 修复 Android App 内文件下载后系统提示「下载失败」的问题:
|
||||
- 放弃系统 DownloadManager,改为应用内下载到私有目录
|
||||
- 下载完成后弹出系统「分享文件」面板,让用户选择保存到下载管理、QQ浏览器、微信等目标
|
||||
- 仍保留 WebView Cookie,下载接口可正常鉴权
|
||||
|
||||
# 1.0.39
|
||||
- 修复 Android App 内图片/视频「从本地发送」无响应的问题:在唤起系统文件选择器前主动请求 `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` 权限,拒绝后支持再次请求并引导到系统设置
|
||||
- 修复 Android App 内所有下载(文件卡片、markdown 下载链接)提示「下载启动失败」的问题:
|
||||
- 前端在 App 内优先调用 `AndroidDownloadBridge`
|
||||
- Android 端将相对下载地址补全为绝对地址,并携带 WebView 登录 Cookie,避免 401 / 无效链接
|
||||
- Android 10+ 下载目录异常时自动回退到 DownloadManager 默认位置
|
||||
- Android 9 及以下未授予存储权限时先请求权限再开始下载
|
||||
|
||||
# 1.0.38
|
||||
- PDF 文件卡片改为前端 `vue-pdf-embed` 渲染:在卡片下方直接滚动预览多页,不再调用浏览器原生 PDF 预览或 Android 原生 PDF 预览 Activity
|
||||
- 与桌面端保持一致的文件卡片交互:PDF 和其他类型文件统一头部 + 下方预览布局
|
||||
|
||||
# 1.0.37
|
||||
- 修复 Android App 内 PDF 文件卡片无法 inline 显示的问题:WebView 不支持 iframe PDF,改为卡片下方显示占位区,点击后调原生 PDF 预览
|
||||
- 修复进入含 PDF 卡片的对话自动弹出「已开始下载」的误报:移除 WebView `setDownloadListener`,避免把 PDF 加载请求误判为下载
|
||||
- 修复文件卡片点击下载无反应的问题:保留 `AndroidDownloadBridge` 走系统 DownloadManager
|
||||
- 修复图片/视频「从本地发送」仍然唤起相册/Photo Picker 的问题:Android 端文件选择器改用 `ACTION_OPEN_DOCUMENT` 走系统文件管理器
|
||||
- 前端常规设置新增「上传诊断日志」按钮,便于排查 App 端问题
|
||||
|
||||
# 1.0.36
|
||||
- 修复 Android App 内文件卡片点击「下载」无反应的问题:前端优先调用 Android DownloadBridge,WebView 同时注册 `setDownloadListener` 兜底,使用系统 DownloadManager 下载到 Download 目录
|
||||
- PDF 文件卡片移除单独的「预览」按钮,改为与桌面端一致的卡片下方 inline 预览(iframe),视觉和交互统一
|
||||
- 修复 Android App 内图片/视频「从本地发送」容易失败的问题:
|
||||
- 延长文件选择器关闭后的 backdrop 点击屏蔽时长,避免 touch 穿透误关选择弹窗导致上传中断
|
||||
- Android 端统一使用 `ACTION_GET_CONTENT` 选择器(按 `image/*` / `video/*` 过滤),避免不同 ROM 的相册/Photo Picker 返回临时 URI 导致上传失效
|
||||
- 上传链路增强对 Android WebView 返回文件无文件名/无扩展名的兼容:前端根据 MIME 类型补全文件名,后端在 filename 缺失时自动推断兜底文件名
|
||||
|
||||
# 1.0.35
|
||||
- 新增 PDF 文件原生预览:集成 AndroidPdfViewer,点击卡片「预览」按钮可在 App 内直接查看 PDF,不再依赖浏览器
|
||||
|
||||
# 1.0.32
|
||||
- 识别引擎改为预初始化:模型下载完成后立即在后台加载,用户点击时直接可用(不再每次点击等3-5秒初始化)
|
||||
- startRecordingInternal 增加详细日志,便于排查录音问题
|
||||
|
||||
# 1.0.31
|
||||
- 修复语音识别无结果:去掉多余的 decode() 调用,对齐 sherpa-onnx Android 官方 API(getResult 内部已含 decode)
|
||||
- recognizeSegment 增加识别结果详细日志
|
||||
|
||||
# 1.0.30
|
||||
- 修复语音识别无声问题:前端处理 initializing 状态,防止初始化期间重复触发 startListening 导致录音永不停止
|
||||
- Android 端增加启动取消机制,初始化期间按停止不会意外开始录音
|
||||
- 完善录音链路日志,便于后续调试
|
||||
|
||||
# 1.0.29
|
||||
- 暂时禁用 VAD(Android 端 Silero VAD 存在 Native 兼容性问题),改为整段识别模式
|
||||
- 录音结束后一次性识别,效果无差异,彻底消除闪退
|
||||
|
||||
# 1.0.28
|
||||
- 修复 VAD 初始化导致闪退:maxSpeechDuration 30f→5.0f(默认值),VAD 失败时降级为整段识别不崩溃
|
||||
|
||||
# 1.0.27
|
||||
- 语音调试日志改为直接上传服务器(/api/voice_debug),无需手动找文件
|
||||
|
||||
# 1.0.26
|
||||
- 修复点击麦克风后闪退问题:initEngine 增加文件完整性前置校验,numThreads 降为 1 降低内存压力
|
||||
- 新增「保存日志」按钮,调试日志写入手机 Download/voice_debug.log
|
||||
- 下载/删除前自动释放引擎资源,避免文件锁定导致异常
|
||||
|
||||
# 1.0.25
|
||||
- 修复下载进度一直显示 0% 的问题(整数除法 bug)
|
||||
- 新增模型文件完整性校验(文件大小 ±5% 容差),避免下载中断后显示“已下载”
|
||||
- 新增「删除模型」按钮,可清理不完整的模型文件
|
||||
- 模型文件移至 agents 代码目录外(/opt/agent/voice_models/),避免部署时被覆盖
|
||||
|
||||
# 1.0.24
|
||||
- 修复语音按钮在首次启动(模型未下载)时不显示的问题:VoiceBridge 改为随 App 启动即注册,不再等待模型下载完成
|
||||
- 修复个人空间中「下载语音模型」按钮误提示「仅在 App 内支持」的问题
|
||||
|
||||
# 1.0.23
|
||||
- 新增端侧语音输入功能:集成 sherpa-onnx SenseVoice int8 离线语音识别
|
||||
- 支持中英混说 + 自动标点,完全离线运行,无需网络
|
||||
- 语音模型约 228MB,首次使用自动下载(可在「个人空间 → 语音模型」手动下载)
|
||||
- 输入框语音按钮仅在 App 端显示,桌面端隐藏
|
||||
- 新增录音权限请求(RECORD_AUDIO)
|
||||
|
||||
# 1.0.22
|
||||
- 修复 Android App 内从本地发送图片/视频无效的问题:补充 Android 13+ 所需的媒体读取权限(`READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO`)
|
||||
- 修复 Android App 文件选择器在 Android 13+ 上回调丢失的问题:将已废弃的 `startActivityForResult` 升级为 `registerForActivityResult`(Activity Result API)
|
||||
|
||||
# 1.0.21
|
||||
- 修复 Android App 内手机端展开“对话记录”时仍沿用旧宽度的问题,面板宽度与桌面端对话侧栏保持一致
|
||||
- 修复 Android App 内对话记录展开面板顶部残留系统栏空白的问题,内容从面板顶端开始显示
|
||||
|
||||
# 1.0.20
|
||||
- 新增“新用户欢迎弹窗 + 新手教程”持久化机制:普通账号首次进入会提示是否开始教程,选择“开始吧”或“不再提示”后将状态写入 `data/users.json`
|
||||
- 新增后端教程状态接口(查询/更新),并排除宿主机模式用户,避免 host 模式触发教程弹窗
|
||||
|
||||
@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.cyjai.agent"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 22
|
||||
versionName = "1.0.20"
|
||||
versionCode = 43
|
||||
versionName = "1.0.41"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@ -63,4 +63,14 @@ dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.activity:activity-ktx:1.9.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
|
||||
// PDF 预览(不依赖浏览器)
|
||||
implementation("com.github.mhiew:android-pdf-viewer:3.2.0-beta.3")
|
||||
|
||||
// sherpa-onnx 语音识别库(需手动下载 AAR 放到 app/libs/)
|
||||
// 下载地址:https://huggingface.co/csukuangfj/sherpa-onnx-libs/tree/main/android/aar
|
||||
// 下载最新 sherpa-onnx-*.aar 放到 app/libs/ 目录即可
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||
}
|
||||
|
||||
BIN
android-webview-app/app/libs/sherpa-onnx-1.10.45.aar
Normal file
BIN
android-webview-app/app/libs/sherpa-onnx-1.10.45.aar
Normal file
Binary file not shown.
@ -2,6 +2,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@ -13,6 +18,16 @@
|
||||
android:theme="@style/Theme.AgentWebView"
|
||||
android:usesCleartextTraffic="false">
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@ -23,6 +38,12 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".PdfPreviewActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|uiMode" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@ -1,13 +1,20 @@
|
||||
package com.cyjai.agent
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.View
|
||||
import android.content.Context
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.PermissionRequest
|
||||
@ -17,17 +24,50 @@ import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLConnection
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
private var filePathCallback: ValueCallback<Array<Uri>>? = null
|
||||
private var voiceBridge: VoiceBridge? = null
|
||||
private var pendingFileChooserParams: WebChromeClient.FileChooserParams? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val REQUEST_RECORD_AUDIO = 300
|
||||
private const val REQUEST_WRITE_STORAGE = 301
|
||||
private const val REQUEST_READ_MEDIA = 302
|
||||
private const val HOME_URL = "https://agent.cyjai.com"
|
||||
private const val WEB_ASSET_VERSION = "20260624_2"
|
||||
}
|
||||
|
||||
private val fileChooserLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val results = WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data)
|
||||
filePathCallback?.onReceiveValue(results)
|
||||
filePathCallback = null
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@ -45,7 +85,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
view.setPadding(0, systemBars.top, 0, systemBars.bottom)
|
||||
view.setPadding(0, 0, 0, systemBars.bottom)
|
||||
insets
|
||||
}
|
||||
ViewCompat.requestApplyInsets(webView)
|
||||
@ -73,6 +113,65 @@ class MainActivity : AppCompatActivity() {
|
||||
webView.clearCache(true)
|
||||
|
||||
webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge")
|
||||
webView.addJavascriptInterface(PdfPreviewBridge(), "AndroidPdfBridge")
|
||||
webView.addJavascriptInterface(DownloadBridge(), "AndroidDownloadBridge")
|
||||
|
||||
// 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化)
|
||||
voiceBridge = VoiceBridge(this@MainActivity, webView)
|
||||
webView.addJavascriptInterface(voiceBridge!!, "AndroidVoiceBridge")
|
||||
|
||||
// 后台检查并下载模型
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
ModelManager.init(this@MainActivity)
|
||||
if (!ModelManager.isModelReady(this@MainActivity)) {
|
||||
Log.i(TAG, "首次启动,开始下载语音模型...")
|
||||
withContext(Dispatchers.Main) {
|
||||
injectVoiceStatus("downloading")
|
||||
}
|
||||
val success = ModelManager.downloadModels(this@MainActivity) { pct, msg ->
|
||||
Log.i(TAG, "模型下载: ${pct}% - $msg")
|
||||
runOnUiThread {
|
||||
injectVoiceDownloadProgress(pct, msg)
|
||||
}
|
||||
}
|
||||
if (!success) {
|
||||
Log.e(TAG, "模型下载失败")
|
||||
withContext(Dispatchers.Main) {
|
||||
injectVoiceStatus("error")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "模型就绪")
|
||||
withContext(Dispatchers.Main) {
|
||||
injectVoiceStatus("ready")
|
||||
// 模型就绪后立即预初始化识别引擎,用户点击时直接可用
|
||||
voiceBridge?.ensureEngine()
|
||||
}
|
||||
}
|
||||
|
||||
// 请求录音权限
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||
REQUEST_RECORD_AUDIO
|
||||
)
|
||||
}
|
||||
|
||||
// Android 9 及以下需要写外部存储权限才能使用 DownloadManager
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||||
REQUEST_WRITE_STORAGE
|
||||
)
|
||||
}
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
@ -107,11 +206,17 @@ class MainActivity : AppCompatActivity() {
|
||||
this@MainActivity.filePathCallback?.onReceiveValue(null)
|
||||
this@MainActivity.filePathCallback = filePathCallback
|
||||
|
||||
val intent = fileChooserParams?.createIntent() ?: Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
// 对图片/视频选择,先检查并请求媒体权限;部分国产 ROM 的文件管理器会要求
|
||||
// 应用持有 READ_MEDIA_IMAGES/READ_MEDIA_VIDEO 才能展示本地媒体。
|
||||
val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() }
|
||||
?: listOf("*/*")
|
||||
if (needsMediaPermission(acceptTypes) && !hasMediaPermission(acceptTypes)) {
|
||||
pendingFileChooserParams = fileChooserParams
|
||||
requestMediaPermissions(acceptTypes)
|
||||
return true
|
||||
}
|
||||
startActivityForResult(intent, FILE_CHOOSER_REQUEST_CODE)
|
||||
|
||||
launchFileChooser(fileChooserParams)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -136,26 +241,117 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode != FILE_CHOOSER_REQUEST_CODE) return
|
||||
|
||||
val results = WebChromeClient.FileChooserParams.parseResult(resultCode, data)
|
||||
filePathCallback?.onReceiveValue(results)
|
||||
filePathCallback = null
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
webView.saveState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
voiceBridge?.destroy()
|
||||
webView.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun needsMediaPermission(acceptTypes: List<String>): Boolean {
|
||||
// 仅当选择器明确针对图片或视频时才需要媒体权限;通配类型仍走 DocumentsUI 的临时授权
|
||||
if (acceptTypes.isEmpty() || acceptTypes.any { it == "*/*" }) return false
|
||||
return acceptTypes.any { it.startsWith("image/") || it.startsWith("video/") }
|
||||
}
|
||||
|
||||
private fun getRequiredMediaPermissions(acceptTypes: List<String>): Array<String> {
|
||||
val needsImage = acceptTypes.any { it.startsWith("image/") }
|
||||
val needsVideo = acceptTypes.any { it.startsWith("video/") }
|
||||
return when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
|
||||
val perms = mutableListOf<String>()
|
||||
if (needsImage) perms.add(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
if (needsVideo) perms.add(Manifest.permission.READ_MEDIA_VIDEO)
|
||||
perms.toTypedArray()
|
||||
}
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
|
||||
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
else -> emptyArray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasMediaPermission(acceptTypes: List<String>): Boolean {
|
||||
val perms = getRequiredMediaPermissions(acceptTypes)
|
||||
if (perms.isEmpty()) return true
|
||||
return perms.all {
|
||||
ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMediaPermissions(acceptTypes: List<String>) {
|
||||
val perms = getRequiredMediaPermissions(acceptTypes)
|
||||
if (perms.isEmpty()) return
|
||||
ActivityCompat.requestPermissions(this, perms, REQUEST_READ_MEDIA)
|
||||
}
|
||||
|
||||
private fun launchFileChooser(fileChooserParams: WebChromeClient.FileChooserParams?) {
|
||||
pendingFileChooserParams = null
|
||||
val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() }
|
||||
?: listOf("*/*")
|
||||
val allowMultiple = fileChooserParams?.mode == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = acceptTypes.first()
|
||||
if (allowMultiple) {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
}
|
||||
if (acceptTypes.size > 1) {
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
|
||||
}
|
||||
}
|
||||
fileChooserLauncher.launch(intent)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
when (requestCode) {
|
||||
REQUEST_READ_MEDIA -> {
|
||||
val params = pendingFileChooserParams
|
||||
if (params != null) {
|
||||
if (grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
|
||||
launchFileChooser(params)
|
||||
} else {
|
||||
filePathCallback?.onReceiveValue(null)
|
||||
filePathCallback = null
|
||||
pendingFileChooserParams = null
|
||||
val permanentlyDenied = grantResults.isNotEmpty() &&
|
||||
permissions.isNotEmpty() &&
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(this, permissions[0])
|
||||
if (permanentlyDenied) {
|
||||
Toast.makeText(this, "请在系统设置中开启媒体访问权限", Toast.LENGTH_LONG).show()
|
||||
openAppSettings()
|
||||
} else {
|
||||
Toast.makeText(this, "需要媒体访问权限才能选择本地文件", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
REQUEST_WRITE_STORAGE, REQUEST_RECORD_AUDIO -> {
|
||||
// 启动时请求的权限,无需额外处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAppSettings() {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySystemBarTheme(rawTheme: String?) {
|
||||
val theme = (rawTheme ?: "").lowercase(Locale.getDefault())
|
||||
val isDark = theme == "dark"
|
||||
@ -207,8 +403,12 @@ class MainActivity : AppCompatActivity() {
|
||||
var styleId = '__android_mobile_overlay_width_patch';
|
||||
var css = [
|
||||
'.mobile-panel-sheet.mobile-panel-sheet--conversation {',
|
||||
' width: min(420px, 65vw) !important;',
|
||||
' max-width: 65vw !important;',
|
||||
' width: min(var(--conversation-expanded-width, 306px), 100vw) !important;',
|
||||
' max-width: 100vw !important;',
|
||||
' padding-top: 0 !important;',
|
||||
'}',
|
||||
'.mobile-panel-sheet--conversation .mobile-overlay-content {',
|
||||
' height: 100% !important;',
|
||||
'}',
|
||||
'.mobile-panel-sheet.mobile-panel-sheet--workspace {',
|
||||
' width: fit-content !important;',
|
||||
@ -274,6 +474,164 @@ class MainActivity : AppCompatActivity() {
|
||||
webView.evaluateJavascript(script, null)
|
||||
}
|
||||
|
||||
// ═══════════════════ 语音状态通知(JS 注入) ═══════════════════
|
||||
|
||||
private fun injectVoiceStatus(status: String) {
|
||||
val script = """
|
||||
(function() {
|
||||
if (window.__onVoiceStatus) window.__onVoiceStatus('$status');
|
||||
if (window.dispatchEvent) {
|
||||
window.dispatchEvent(new CustomEvent('voicebridge:status', { detail: '$status' }));
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
webView.evaluateJavascript(script, null)
|
||||
}
|
||||
|
||||
private fun injectVoiceDownloadProgress(pct: Int, msg: String) {
|
||||
val safeMsg = msg.replace("'", "\\'")
|
||||
val script = """
|
||||
(function() {
|
||||
if (window.__onVoiceDownloadProgress) {
|
||||
window.__onVoiceDownloadProgress($pct, '$safeMsg');
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
webView.evaluateJavascript(script, null)
|
||||
}
|
||||
|
||||
inner class PdfPreviewBridge {
|
||||
@JavascriptInterface
|
||||
fun previewPdf(pdfUrl: String?) {
|
||||
val url = pdfUrl ?: return
|
||||
runOnUiThread {
|
||||
val intent = Intent(this@MainActivity, PdfPreviewActivity::class.java)
|
||||
intent.putExtra(PdfPreviewActivity.EXTRA_PDF_URL, url)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun isPdfPreviewSupported(): Boolean = true
|
||||
}
|
||||
|
||||
inner class DownloadBridge {
|
||||
@JavascriptInterface
|
||||
fun downloadFile(fileUrl: String?, fileName: String?) {
|
||||
val url = fileUrl ?: return
|
||||
val name = fileName?.takeIf { it.isNotBlank() }
|
||||
?: deriveFileName("", null, url)
|
||||
runOnUiThread {
|
||||
startSystemDownload(url, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSystemDownload(rawUrl: String, fileName: String) {
|
||||
// 不再使用系统 DownloadManager(国产 ROM 上 enqueue 后经常静默失败)。
|
||||
// 改为应用内下载到私有目录,然后通过系统分享 sheet 让用户选择保存位置。
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val absoluteUrl = resolveAbsoluteUrl(rawUrl)
|
||||
if (absoluteUrl.isBlank()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@MainActivity, "下载链接无效", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@MainActivity, "正在下载:$fileName", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
// CookieManager 必须在主线程访问
|
||||
val cookie = withContext(Dispatchers.Main) {
|
||||
CookieManager.getInstance().getCookie(absoluteUrl)
|
||||
}
|
||||
|
||||
val downloadedFile = downloadFileToAppDir(absoluteUrl, fileName, cookie)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
shareDownloadedFile(downloadedFile)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "下载失败: ${e.message}", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(this@MainActivity, "下载失败:${e.message}", Toast.LENGTH_LONG).show()
|
||||
// 兜底:尝试用浏览器打开
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolveAbsoluteUrl(rawUrl)))
|
||||
startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadFileToAppDir(urlString: String, preferredName: String, cookie: String?): File {
|
||||
val dir = File(filesDir, "downloads").apply { mkdirs() }
|
||||
val connection = URL(urlString).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 30000
|
||||
connection.readTimeout = 30000
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.setRequestProperty("Cookie", cookie ?: "")
|
||||
connection.connect()
|
||||
|
||||
val finalUrl = connection.url.toString()
|
||||
val contentDisposition = connection.getHeaderField("Content-Disposition")
|
||||
val fileName = deriveFileName(preferredName, contentDisposition, finalUrl)
|
||||
|
||||
val file = File(dir, fileName)
|
||||
connection.inputStream.use { input ->
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
connection.disconnect()
|
||||
return file
|
||||
}
|
||||
|
||||
private fun shareDownloadedFile(file: File) {
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
|
||||
val mimeType = URLConnection.guessContentTypeFromName(file.name) ?: "application/octet-stream"
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_TITLE, file.name)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooser = Intent.createChooser(intent, "分享文件").apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(chooser)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(this, "没有可用的文件分享应用", Toast.LENGTH_LONG).show()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "分享文件失败: ${e.message}", e)
|
||||
Toast.makeText(this, "分享文件失败:${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deriveFileName(preferredName: String, contentDisposition: String?, url: String): String {
|
||||
contentDisposition?.let {
|
||||
val regex = Regex("filename\\*?=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
|
||||
regex.find(it)?.groupValues?.get(1)?.trim()?.takeIf { name -> name.isNotBlank() }?.let { return it }
|
||||
}
|
||||
if (preferredName.isNotBlank() && preferredName.contains(".")) return preferredName
|
||||
Uri.parse(url).lastPathSegment?.takeIf { it.isNotBlank() && it.contains(".") }?.let { return it }
|
||||
Uri.parse(url).getQueryParameter("path")?.split("/")?.lastOrNull()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
return "download_${System.currentTimeMillis()}"
|
||||
}
|
||||
|
||||
private fun resolveAbsoluteUrl(rawUrl: String): String {
|
||||
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return rawUrl
|
||||
// API 始终位于域名根路径,不能拿当前 WebView URL(可能包含对话路径 /conv_id)作为 base,
|
||||
// 否则会把 /api/download/file 拼到对话路径后面,导致 404。
|
||||
val base = HOME_URL.trimEnd('/')
|
||||
return if (rawUrl.startsWith("/")) "$base$rawUrl" else "$base/$rawUrl"
|
||||
}
|
||||
|
||||
inner class ThemeBridge {
|
||||
@JavascriptInterface
|
||||
fun onThemeChanged(theme: String?) {
|
||||
@ -293,12 +651,6 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val HOME_URL = "https://agent.cyjai.com"
|
||||
private const val WEB_ASSET_VERSION = "20260406_4"
|
||||
private const val FILE_CHOOSER_REQUEST_CODE = 9001
|
||||
}
|
||||
|
||||
private fun buildHomeUrl(): String {
|
||||
val vc = getInstalledVersionCode()
|
||||
val vn = Uri.encode(getInstalledVersionName())
|
||||
|
||||
@ -0,0 +1,194 @@
|
||||
package com.cyjai.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.*
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* 语音模型下载管理器
|
||||
* 首次启动时自动下载 SenseVoice + Silero VAD 模型到内部存储
|
||||
*
|
||||
* 模型来源:HuggingFace (csukuangfj)
|
||||
* 总大小:约 230MB(SenseVoice int8 228MB + tokens 308KB + Silero VAD 1.5MB)
|
||||
*/
|
||||
object ModelManager {
|
||||
private const val TAG = "ModelManager"
|
||||
|
||||
// 模型下载地址(从自有服务器下载,3 个文件约 230MB)
|
||||
// 需提前上传到服务器静态目录:/static/voice_models/
|
||||
private const val BASE_URL = "https://agent.cyjai.com/static/voice_models"
|
||||
private const val SENSEVOICE_MODEL_URL = "$BASE_URL/model.int8.onnx"
|
||||
private const val SENSEVOICE_TOKENS_URL = "$BASE_URL/tokens.txt"
|
||||
private const val SILERO_VAD_URL = "$BASE_URL/silero_vad.onnx"
|
||||
|
||||
private var _modelDir: File? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
_modelDir = File(context.filesDir, "voice_models")
|
||||
}
|
||||
|
||||
private fun getModelDir(context: Context): File {
|
||||
return _modelDir ?: File(context.filesDir, "voice_models").also { _modelDir = it }
|
||||
}
|
||||
|
||||
// 模型文件预期大小(字节),用于校验下载完整性(±5% 容差)
|
||||
private const val EXPECTED_MODEL_SIZE = 239_233_841L // model.int8.onnx ~228MB
|
||||
private const val EXPECTED_TOKENS_SIZE = 316_000L // tokens.txt ~308KB
|
||||
private const val EXPECTED_VAD_SIZE = 644_000L // silero_vad.onnx ~629KB
|
||||
private const val SIZE_TOLERANCE = 0.05 // 5% 容差
|
||||
|
||||
private fun isSizeValid(file: File, expected: Long): Boolean {
|
||||
if (!file.exists()) return false
|
||||
val size = file.length()
|
||||
val lower = (expected * (1.0 - SIZE_TOLERANCE)).toLong()
|
||||
val upper = (expected * (1.0 + SIZE_TOLERANCE)).toLong()
|
||||
return size in lower..upper
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否已下载完毕且文件完整
|
||||
*/
|
||||
fun isModelReady(context: Context): Boolean {
|
||||
val dir = getModelDir(context)
|
||||
val senseVoiceModel = File(dir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/model.int8.onnx")
|
||||
val senseVoiceTokens = File(dir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/tokens.txt")
|
||||
val sileroVad = File(dir, "silero_vad.onnx")
|
||||
return isSizeValid(senseVoiceModel, EXPECTED_MODEL_SIZE)
|
||||
&& isSizeValid(senseVoiceTokens, EXPECTED_TOKENS_SIZE)
|
||||
&& isSizeValid(sileroVad, EXPECTED_VAD_SIZE)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除所有已下载的模型文件
|
||||
*/
|
||||
fun deleteModels(context: Context): Boolean {
|
||||
val dir = getModelDir(context)
|
||||
return try {
|
||||
dir.deleteRecursively()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "删除模型文件失败", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载所有模型(应在后台线程调用)
|
||||
* @param onProgress 进度回调 (总百分比: Int, 阶段描述: String)
|
||||
* @return 是否全部成功
|
||||
*/
|
||||
suspend fun downloadModels(
|
||||
context: Context,
|
||||
onProgress: (Int, String) -> Unit = { _, _ -> }
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
val dir = getModelDir(context)
|
||||
val senseVoiceDir = File(dir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17")
|
||||
senseVoiceDir.mkdirs()
|
||||
|
||||
try {
|
||||
// 1. Silero VAD (~1.5MB)
|
||||
val sileroFile = File(dir, "silero_vad.onnx")
|
||||
if (!sileroFile.exists()) {
|
||||
onProgress(0, "下载 VAD 模型...")
|
||||
downloadFile(SILERO_VAD_URL, sileroFile) { pct ->
|
||||
onProgress(pct * 5 / 100, "下载 VAD 模型... ${pct}%")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. tokens.txt (~308KB)
|
||||
val tokensFile = File(senseVoiceDir, "tokens.txt")
|
||||
if (!tokensFile.exists()) {
|
||||
onProgress(5, "下载 tokens...")
|
||||
downloadFile(SENSEVOICE_TOKENS_URL, tokensFile) { pct ->
|
||||
onProgress(5 + pct * 2 / 100, "下载 tokens... ${pct}%")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. SenseVoice int8 (~228MB)
|
||||
val modelFile = File(senseVoiceDir, "model.int8.onnx")
|
||||
if (!modelFile.exists()) {
|
||||
onProgress(7, "下载 SenseVoice 模型(约 228MB)...")
|
||||
downloadFile(SENSEVOICE_MODEL_URL, modelFile) { pct ->
|
||||
onProgress(7 + pct * 93 / 100, "下载 SenseVoice 模型... ${pct}%")
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(100, "模型就绪")
|
||||
Log.i(TAG, "模型下载完成")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "模型下载失败", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadFile(urlStr: String, dest: File, onProgress: (Int) -> Unit) {
|
||||
var attempt = 0
|
||||
val maxAttempts = 3
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
try {
|
||||
attempt++
|
||||
doDownload(urlStr, dest, onProgress)
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "下载失败 (第${attempt}次): ${dest.name}", e)
|
||||
if (attempt >= maxAttempts) throw e
|
||||
Thread.sleep(2000) // 重试前等待
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun doDownload(urlStr: String, dest: File, onProgress: (Int) -> Unit) {
|
||||
val url = URL(urlStr)
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "GET"
|
||||
conn.connectTimeout = 15000
|
||||
conn.readTimeout = 120000
|
||||
|
||||
// 断点续传
|
||||
var downloaded = if (dest.exists()) dest.length() else 0L
|
||||
if (downloaded > 0) {
|
||||
conn.setRequestProperty("Range", "bytes=$downloaded-")
|
||||
}
|
||||
|
||||
conn.connect()
|
||||
|
||||
val totalSize = if (downloaded > 0) {
|
||||
conn.getHeaderField("Content-Range")?.substringAfter("/")?.toLongOrNull()
|
||||
?: (conn.contentLengthLong + downloaded)
|
||||
} else {
|
||||
conn.contentLengthLong
|
||||
}
|
||||
|
||||
val inputStream = conn.inputStream
|
||||
val outputStream = FileOutputStream(dest, downloaded > 0)
|
||||
val buffer = ByteArray(65536)
|
||||
var bytesRead: Int
|
||||
var totalRead = downloaded
|
||||
var lastProgressReport = System.currentTimeMillis()
|
||||
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
totalRead += bytesRead
|
||||
|
||||
// 限流进度回调(每 200ms 最多报一次)
|
||||
val now = System.currentTimeMillis()
|
||||
if (totalSize > 0 && now - lastProgressReport > 200) {
|
||||
val pct = (totalRead * 100 / totalSize).toInt()
|
||||
onProgress(pct.coerceIn(0, 100))
|
||||
lastProgressReport = now
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSize > 0 && totalRead < totalSize) {
|
||||
throw IOException("下载不完整: 期望 ${totalSize} 字节,实际 ${totalRead} 字节")
|
||||
}
|
||||
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
package com.cyjai.agent
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.barteksc.pdfviewer.PDFView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class PdfPreviewActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_PDF_URL = "pdf_url"
|
||||
private const val HOME_URL = "https://agent.cyjai.com"
|
||||
}
|
||||
|
||||
private lateinit var pdfView: PDFView
|
||||
private lateinit var loadingContainer: LinearLayout
|
||||
private lateinit var progressBar: ProgressBar
|
||||
private lateinit var statusText: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_pdf_preview)
|
||||
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
title = "PDF 预览"
|
||||
}
|
||||
|
||||
pdfView = findViewById(R.id.pdfView)
|
||||
loadingContainer = findViewById(R.id.loadingContainer)
|
||||
progressBar = findViewById(R.id.progressBar)
|
||||
statusText = findViewById(R.id.statusText)
|
||||
|
||||
val rawUrl = intent.getStringExtra(EXTRA_PDF_URL) ?: ""
|
||||
if (rawUrl.isBlank()) {
|
||||
showError("PDF 地址为空")
|
||||
return
|
||||
}
|
||||
|
||||
val url = if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
|
||||
rawUrl
|
||||
} else {
|
||||
HOME_URL.trimEnd('/') + (if (rawUrl.startsWith("/")) rawUrl else "/$rawUrl")
|
||||
}
|
||||
|
||||
loadPdf(url)
|
||||
}
|
||||
|
||||
private fun showLoading(msg: String) {
|
||||
loadingContainer.visibility = View.VISIBLE
|
||||
progressBar.visibility = View.VISIBLE
|
||||
statusText.visibility = View.VISIBLE
|
||||
statusText.text = msg
|
||||
pdfView.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
private fun hideLoading() {
|
||||
loadingContainer.visibility = View.GONE
|
||||
progressBar.visibility = View.GONE
|
||||
statusText.visibility = View.GONE
|
||||
pdfView.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun showError(msg: String) {
|
||||
loadingContainer.visibility = View.VISIBLE
|
||||
progressBar.visibility = View.GONE
|
||||
statusText.visibility = View.VISIBLE
|
||||
statusText.text = msg
|
||||
pdfView.visibility = View.INVISIBLE
|
||||
Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun loadPdf(url: String) {
|
||||
showLoading("加载中...")
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val file = downloadToCache(url)
|
||||
withContext(Dispatchers.Main) {
|
||||
showPdf(file)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
showError("PDF 加载失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadToCache(url: String): File {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 60_000
|
||||
conn.setRequestProperty("Accept", "application/pdf,*/*")
|
||||
conn.connect()
|
||||
|
||||
if (conn.responseCode !in 200..299) {
|
||||
throw RuntimeException("HTTP ${conn.responseCode}")
|
||||
}
|
||||
|
||||
val pathParam = Uri.parse(url).getQueryParameter("path")
|
||||
val fileName = if (!pathParam.isNullOrBlank()) {
|
||||
"preview_" + pathParam.replace("/", "_")
|
||||
} else {
|
||||
"preview_${System.currentTimeMillis()}.pdf"
|
||||
}
|
||||
val file = File(cacheDir, fileName)
|
||||
|
||||
FileOutputStream(file).use { out ->
|
||||
conn.inputStream.use { input ->
|
||||
input.copyTo(out)
|
||||
}
|
||||
}
|
||||
conn.disconnect()
|
||||
return file
|
||||
}
|
||||
|
||||
private fun showPdf(file: File) {
|
||||
pdfView.fromFile(file)
|
||||
.enableSwipe(true)
|
||||
.swipeHorizontal(false)
|
||||
.enableDoubletap(true)
|
||||
.defaultPage(0)
|
||||
.onError { showError("PDF 渲染失败") }
|
||||
.onLoad { hideLoading() }
|
||||
.load()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return if (item.itemId == android.R.id.home) {
|
||||
finish()
|
||||
true
|
||||
} else {
|
||||
super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,501 @@
|
||||
package com.cyjai.agent
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.k2fsa.sherpa.onnx.OfflineRecognizer
|
||||
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
|
||||
import com.k2fsa.sherpa.onnx.OfflineSenseVoiceModelConfig
|
||||
import com.k2fsa.sherpa.onnx.OfflineModelConfig
|
||||
import com.k2fsa.sherpa.onnx.FeatureConfig
|
||||
import com.k2fsa.sherpa.onnx.Vad
|
||||
import com.k2fsa.sherpa.onnx.VadModelConfig
|
||||
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
|
||||
import com.k2fsa.sherpa.onnx.SpeechSegment
|
||||
import android.os.Environment
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* 语音识别桥接 — 通过 JS Bridge 暴露给 WebView
|
||||
*
|
||||
* 前端调用:
|
||||
* window.AndroidVoiceBridge.startListening() // 开始录音
|
||||
* window.AndroidVoiceBridge.stopListening() // 停止录音
|
||||
* window.AndroidVoiceBridge.isSupported() // 是否支持 → true
|
||||
*
|
||||
* 结果通过全局回调传回前端:
|
||||
* window.__onVoiceResult(text) // 识别结果
|
||||
* window.__onVoiceStatus(status) // 状态变化: "idle"|"listening"|"processing"
|
||||
* window.__onVoiceError(error) // 错误
|
||||
*/
|
||||
class VoiceBridge(
|
||||
private val context: Context,
|
||||
private val webView: WebView
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "VoiceBridge"
|
||||
private const val SAMPLE_RATE = 16000
|
||||
private const val CHUNK_SIZE = 512 // 每次读取的采样数
|
||||
}
|
||||
|
||||
// ── 状态 ──
|
||||
private val isRecording = AtomicBoolean(false)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var recordingJob: Job? = null
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var recordingStartTime = 0L // 录音开始时间戳(用于防抖)
|
||||
private val MIN_RECORDING_MS = 400L // 最小录音时长(防抖)
|
||||
|
||||
// ── sherpa-onnx 引擎 ──
|
||||
private var recognizer: OfflineRecognizer? = null
|
||||
private var vad: Vad? = null
|
||||
private var initialized = false
|
||||
private var engineInitJob: Job? = null // 预初始化任务
|
||||
|
||||
// ── 调试日志 ──
|
||||
private val logFile: File
|
||||
get() = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "voice_debug.log")
|
||||
private val dateFmt = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||
|
||||
private fun logToFile(msg: String) {
|
||||
try {
|
||||
val ts = dateFmt.format(Date())
|
||||
logFile.parentFile?.mkdirs()
|
||||
FileWriter(logFile, true).use { it.write("[$ts] $msg\n") }
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// ── 模型路径 ──
|
||||
private val modelDir: File
|
||||
get() = File(context.filesDir, "voice_models")
|
||||
private val senseVoiceModel: File
|
||||
get() = File(modelDir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/model.int8.onnx")
|
||||
private val senseVoiceTokens: File
|
||||
get() = File(modelDir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/tokens.txt")
|
||||
private val sileroVadModel: File
|
||||
get() = File(modelDir, "silero_vad.onnx")
|
||||
|
||||
// ═══════════════════ JS Bridge 接口 ═══════════════════
|
||||
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
|
||||
/** 模型是否已就绪(含文件大小校验) */
|
||||
@JavascriptInterface
|
||||
fun isModelReady(): Boolean {
|
||||
return ModelManager.isModelReady(context)
|
||||
}
|
||||
|
||||
/** 是否有模型文件残留(存在但不完整,需清理) */
|
||||
@JavascriptInterface
|
||||
fun isModelPartial(): Boolean {
|
||||
val filesExist = senseVoiceModel.exists() || senseVoiceTokens.exists() || sileroVadModel.exists()
|
||||
return filesExist && !ModelManager.isModelReady(context)
|
||||
}
|
||||
|
||||
/** 删除已下载的模型文件 */
|
||||
@JavascriptInterface
|
||||
fun deleteModel(): Boolean {
|
||||
releaseEngine()
|
||||
return ModelManager.deleteModels(context)
|
||||
}
|
||||
|
||||
/** 触发模型下载(供个人空间手动下载),下载前先释放引擎并清理旧文件 */
|
||||
@JavascriptInterface
|
||||
fun downloadModel() {
|
||||
scope.launch {
|
||||
try {
|
||||
// 先释放引擎(避免文件被 onnxruntime 锁定导致删除失败)
|
||||
releaseEngine()
|
||||
ModelManager.deleteModels(context)
|
||||
postToJs("window.__onVoiceDownloadProgress(0, '开始下载...')")
|
||||
val success = ModelManager.downloadModels(context) { pct, msg ->
|
||||
postToJs("window.__onVoiceDownloadProgress($pct, '${escapeJs(msg)}')")
|
||||
}
|
||||
if (success) {
|
||||
postToJs("window.__onVoiceDownloadProgress(100, '下载完成')")
|
||||
// 下载完成后立即预初始化引擎,这样用户点击时直接可用
|
||||
ensureEngine()
|
||||
} else {
|
||||
postToJs("window.__onVoiceError('模型下载失败,请检查网络后重试')")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "下载流程异常", e)
|
||||
postToJs("window.__onVoiceError('${escapeJs(e.message ?: "未知错误")}')")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 释放识别引擎资源 */
|
||||
private fun releaseEngine() {
|
||||
initialized = false
|
||||
engineInitJob?.cancel()
|
||||
engineInitJob = null
|
||||
try { recognizer?.release() } catch (_: Exception) {}
|
||||
recognizer = null
|
||||
try { vad?.release() } catch (_: Exception) {}
|
||||
vad = null
|
||||
}
|
||||
|
||||
/** 预初始化引擎(后台,不阻塞调用者)。下载完成后或 App 启动时调用 */
|
||||
fun ensureEngine() {
|
||||
if (initialized) return
|
||||
if (!isModelReady()) {
|
||||
logToFile("ensureEngine: 模型未就绪,跳过")
|
||||
return
|
||||
}
|
||||
if (engineInitJob?.isActive == true) {
|
||||
logToFile("ensureEngine: 已有初始化任务运行中")
|
||||
return
|
||||
}
|
||||
logToFile("ensureEngine: 开始后台预初始化引擎")
|
||||
engineInitJob = scope.launch(Dispatchers.IO) {
|
||||
val ok = initEngine()
|
||||
logToFile("ensureEngine: initEngine 返回 $ok")
|
||||
if (ok) {
|
||||
withContext(Dispatchers.Main) {
|
||||
postToJs("window.__onVoiceModelReady()")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取模型大小(用于 UI 展示) */
|
||||
@JavascriptInterface
|
||||
fun getModelSizeMB(): Double {
|
||||
return 228.0
|
||||
}
|
||||
|
||||
/** 收集调试日志并上传到服务器 */
|
||||
@JavascriptInterface
|
||||
fun debugLog(msg: String) {
|
||||
logToFile("[JS] $msg")
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun collectDebugLog(): String {
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("=== 语音调试日志 ===")
|
||||
sb.appendLine("时间: ${dateFmt.format(Date())}")
|
||||
sb.appendLine("模型目录: ${modelDir.absolutePath}")
|
||||
sb.appendLine("senseVoice model: 存在=${senseVoiceModel.exists()}, 大小=${if (senseVoiceModel.exists()) senseVoiceModel.length() else -1}")
|
||||
sb.appendLine("senseVoice tokens: 存在=${senseVoiceTokens.exists()}, 大小=${if (senseVoiceTokens.exists()) senseVoiceTokens.length() else -1}")
|
||||
sb.appendLine("sileroVad: 存在=${sileroVadModel.exists()}, 大小=${if (sileroVadModel.exists()) sileroVadModel.length() else -1}")
|
||||
sb.appendLine("isModelReady: ${ModelManager.isModelReady(context)}")
|
||||
sb.appendLine("引擎已初始化: $initialized")
|
||||
sb.appendLine("recognizer: ${recognizer != null}, vad: ${vad != null}")
|
||||
sb.appendLine("录音权限: ${hasRecordPermission()}")
|
||||
sb.appendLine("isRecording: ${isRecording.get()}")
|
||||
sb.appendLine("")
|
||||
sb.appendLine("=== 文件日志 ===")
|
||||
if (logFile.exists()) {
|
||||
try { sb.append(logFile.readText()) } catch (e: Exception) { sb.appendLine("(读取日志失败: ${e.message})") }
|
||||
} else {
|
||||
sb.appendLine("(无文件日志)")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun startListening() {
|
||||
logToFile("startListening 被调用, initialized=$initialized")
|
||||
if (!hasRecordPermission()) {
|
||||
logToFile("startListening: 缺少录音权限")
|
||||
postToJs("window.__onVoiceError('缺少录音权限')")
|
||||
Log.e(TAG, "缺少录音权限")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查模型是否就绪
|
||||
if (!isModelReady()) {
|
||||
logToFile("startListening: 模型未就绪")
|
||||
postToJs("window.__onVoiceStatus('model_not_ready')")
|
||||
Log.w(TAG, "模型未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
if (isRecording.get()) {
|
||||
logToFile("startListening: 已在录音中,忽略")
|
||||
Log.w(TAG, "已经在录音中")
|
||||
return
|
||||
}
|
||||
|
||||
if (!initialized) {
|
||||
// 触发后台初始化并等待,完成后自动开始录音
|
||||
logToFile("startListening: 引擎未初始化,触发后台初始化并等待")
|
||||
postToJs("window.__onVoiceStatus('initializing')")
|
||||
pendingStartJob?.cancel()
|
||||
pendingStartJob = scope.launch(Dispatchers.IO) {
|
||||
val ok = initEngine()
|
||||
logToFile("startListening: initEngine 返回 $ok")
|
||||
withContext(Dispatchers.Main) {
|
||||
if (pendingStartJob == null || !pendingStartJob!!.isActive) {
|
||||
logToFile("startListening: 启动请求在初始化期间被取消")
|
||||
return@withContext
|
||||
}
|
||||
pendingStartJob = null
|
||||
if (!ok) {
|
||||
postToJs("window.__onVoiceError('模型初始化失败')")
|
||||
return@withContext
|
||||
}
|
||||
startRecordingInternal()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startRecordingInternal()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun stopListening() {
|
||||
logToFile("stopListening 被调用, isRecording=${isRecording.get()}")
|
||||
|
||||
// 取消待处理的启动(初始化期间的取消)
|
||||
pendingStartJob?.cancel()
|
||||
pendingStartJob = null
|
||||
|
||||
// 如果还没开始录音(初始化中),直接通知前端停止
|
||||
if (!isRecording.get()) {
|
||||
logToFile("stopListening: 录音尚未开始,取消启动请求")
|
||||
postToJs("window.__onVoiceStatus('idle')")
|
||||
return
|
||||
}
|
||||
|
||||
// 防抖:录音开始后 MIN_RECORDING_MS 内不允许停止
|
||||
val elapsed = System.currentTimeMillis() - recordingStartTime
|
||||
if (elapsed < MIN_RECORDING_MS) {
|
||||
logToFile("stopListening: 录音时长不足 ${MIN_RECORDING_MS}ms,忽略 (已录 ${elapsed}ms)")
|
||||
Log.w(TAG, "录音时长不足 ${MIN_RECORDING_MS}ms,忽略停止请求 (已录 ${elapsed}ms)")
|
||||
return
|
||||
}
|
||||
stopRecordingInternal()
|
||||
}
|
||||
|
||||
// ═══════════════════ 引擎初始化 ═══════════════════
|
||||
|
||||
private suspend fun initEngine(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 先做文件大小校验,避免用残缺文件初始化导致 Native crash
|
||||
if (!ModelManager.isModelReady(context)) {
|
||||
logToFile("initEngine: 模型文件不完整,拒绝初始化")
|
||||
Log.e(TAG, "模型文件不完整,拒绝初始化")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
logToFile("initEngine: 模型文件完整,开始加载 SenseVoice...")
|
||||
Log.i(TAG, "初始化 SenseVoice 识别器... model=${senseVoiceModel.length()} tokens=${senseVoiceTokens.length()}")
|
||||
|
||||
// SenseVoice 模型配置
|
||||
val senseVoiceConfig = OfflineSenseVoiceModelConfig(
|
||||
model = senseVoiceModel.absolutePath,
|
||||
useInverseTextNormalization = true
|
||||
)
|
||||
|
||||
val featConfig = FeatureConfig(
|
||||
sampleRate = SAMPLE_RATE,
|
||||
featureDim = 80
|
||||
)
|
||||
|
||||
val modelConfig = OfflineModelConfig()
|
||||
modelConfig.senseVoice = senseVoiceConfig
|
||||
modelConfig.tokens = senseVoiceTokens.absolutePath
|
||||
modelConfig.numThreads = 1 // 单线程降低内存压力
|
||||
modelConfig.provider = "cpu"
|
||||
|
||||
val config = OfflineRecognizerConfig(
|
||||
featConfig = featConfig,
|
||||
modelConfig = modelConfig
|
||||
)
|
||||
|
||||
Log.i(TAG, "开始创建 OfflineRecognizer...")
|
||||
logToFile("initEngine: 开始创建 OfflineRecognizer (numThreads=1)...")
|
||||
recognizer = OfflineRecognizer(
|
||||
assetManager = null,
|
||||
config = config
|
||||
)
|
||||
logToFile("initEngine: OfflineRecognizer 创建完成")
|
||||
Log.i(TAG, "SenseVoice 识别器初始化完成")
|
||||
|
||||
// VAD 暂时禁用(Android 端 Silero VAD 存在兼容性问题,改用整段识别)
|
||||
logToFile("initEngine: 跳过 VAD,使用整段识别模式")
|
||||
vad = null
|
||||
|
||||
initialized = true
|
||||
logToFile("initEngine: 全部初始化完成")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
logToFile("initEngine: 异常 ${e.javaClass.simpleName}: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════ 录音 ═══════════════════
|
||||
|
||||
// ── 待处理的启动任务(用于在初始化期间取消)──
|
||||
private var pendingStartJob: Job? = null
|
||||
|
||||
// ── 录音数据缓冲(无 VAD 模式,整段识别)──
|
||||
private val audioBuffer = mutableListOf<Short>()
|
||||
|
||||
private fun startRecordingInternal() {
|
||||
logToFile("startRecordingInternal: 开始, isRecording=${isRecording.get()}")
|
||||
if (isRecording.getAndSet(true)) {
|
||||
logToFile("startRecordingInternal: 已在录音中,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
val bufferSize = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
logToFile("startRecordingInternal: bufferSize=$bufferSize")
|
||||
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize * 2
|
||||
)
|
||||
|
||||
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
|
||||
logToFile("startRecordingInternal: AudioRecord 初始化失败 state=${audioRecord?.state}")
|
||||
Log.e(TAG, "AudioRecord 初始化失败")
|
||||
isRecording.set(false)
|
||||
postToJs("window.__onVoiceError('麦克风初始化失败')")
|
||||
return
|
||||
}
|
||||
|
||||
audioRecord?.startRecording()
|
||||
recordingStartTime = System.currentTimeMillis()
|
||||
audioBuffer.clear()
|
||||
postToJs("window.__onVoiceStatus('listening')")
|
||||
logToFile("startRecordingInternal: 录音已开始")
|
||||
Log.i(TAG, "开始录音")
|
||||
|
||||
recordingJob = scope.launch {
|
||||
processAudioLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processAudioLoop() {
|
||||
val buffer = ShortArray(CHUNK_SIZE)
|
||||
while (isRecording.get()) {
|
||||
val readCount = audioRecord?.read(buffer, 0, buffer.size) ?: -1
|
||||
if (readCount <= 0) continue
|
||||
for (i in 0 until readCount) {
|
||||
audioBuffer.add(buffer[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun recognizeFullAudio() {
|
||||
logToFile("recognizeFullAudio: buffer size=${audioBuffer.size}")
|
||||
if (audioBuffer.isEmpty()) {
|
||||
logToFile("recognizeFullAudio: buffer 为空")
|
||||
postToJs("window.__onVoiceError('未检测到语音')")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
postToJs("window.__onVoiceStatus('processing')")
|
||||
val samples = FloatArray(audioBuffer.size) { audioBuffer[it] / 32768.0f }
|
||||
logToFile("recognizeFullAudio: 开始识别 ${samples.size} 采样 (${samples.size / SAMPLE_RATE}s)")
|
||||
val text = withContext(Dispatchers.IO) { recognizeSegment(samples) }
|
||||
logToFile("recognizeFullAudio: 识别结果 text='$text' length=${text.length}")
|
||||
if (text.isNotBlank()) {
|
||||
Log.i(TAG, "识别结果: $text")
|
||||
logToFile("recognizeFullAudio: 准备调用 postToJs __onVoiceResult")
|
||||
postToJs("window.__onVoiceResult('${escapeJs(text)}')")
|
||||
logToFile("recognizeFullAudio: postToJs __onVoiceResult 已提交")
|
||||
} else {
|
||||
logToFile("recognizeFullAudio: 识别结果为空")
|
||||
postToJs("window.__onVoiceError('未识别到语音内容')")
|
||||
}
|
||||
postToJs("window.__onVoiceStatus('idle')")
|
||||
}
|
||||
}
|
||||
|
||||
private fun recognizeSegment(samples: FloatArray): String {
|
||||
val rec = recognizer ?: return ""
|
||||
return try {
|
||||
val stream = rec.createStream()
|
||||
stream.acceptWaveform(samples, SAMPLE_RATE)
|
||||
rec.decode(stream)
|
||||
val result = rec.getResult(stream)
|
||||
stream.release()
|
||||
val text = result.text?.trim() ?: ""
|
||||
logToFile("recognizeSegment: text='$text' lang='${result.lang}' emotion='${result.emotion}' event='${result.event}'")
|
||||
text
|
||||
} catch (e: Exception) {
|
||||
logToFile("recognizeSegment: 异常 ${e.javaClass.simpleName}: ${e.message}")
|
||||
Log.e(TAG, "识别错误", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecordingInternal() {
|
||||
logToFile("stopRecordingInternal 被调用, isRecording=${isRecording.get()}")
|
||||
if (!isRecording.getAndSet(false)) {
|
||||
logToFile("stopRecordingInternal: isRecording 已为 false,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
recordingJob?.cancel()
|
||||
recordingJob = null
|
||||
|
||||
try {
|
||||
audioRecord?.stop()
|
||||
audioRecord?.release()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "AudioRecord 释放异常", e)
|
||||
logToFile("stopRecordingInternal: AudioRecord 释放异常 ${e.message}")
|
||||
}
|
||||
audioRecord = null
|
||||
|
||||
logToFile("stopRecordingInternal: 录音已停止,共 ${audioBuffer.size} 采样")
|
||||
Log.i(TAG, "录音已停止,共 ${audioBuffer.size} 采样")
|
||||
recognizeFullAudio()
|
||||
}
|
||||
|
||||
// ═══════════════════ 工具方法 ═══════════════════
|
||||
|
||||
private fun hasRecordPermission(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(
|
||||
context, Manifest.permission.RECORD_AUDIO
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun postToJs(script: String) {
|
||||
webView.post {
|
||||
webView.evaluateJavascript(script, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun escapeJs(text: String): String {
|
||||
return text
|
||||
.replace("\\", "\\\\")
|
||||
.replace("'", "\\'")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
stopRecordingInternal()
|
||||
scope.cancel()
|
||||
releaseEngine()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.github.barteksc.pdfviewer.PDFView
|
||||
android:id="@+id/pdfView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="invisible" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/loadingContainer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
style="?android:attr/progressBarStyleLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:textColor="?android:attr/textColorPrimary" />
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
6
android-webview-app/app/src/main/res/xml/file_paths.xml
Normal file
6
android-webview-app/app/src/main/res/xml/file_paths.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 应用内部下载目录,用于通过系统分享 sheet 导出文件 -->
|
||||
<files-path name="downloads" path="downloads/" />
|
||||
<cache-path name="cache" path="." />
|
||||
</paths>
|
||||
@ -26,7 +26,7 @@
|
||||
"label": "终端指令",
|
||||
"default_enabled": true,
|
||||
"silent_when_disabled": false,
|
||||
"tools": ["run_command", "run_python"]
|
||||
"tools": ["run_command"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -38,4 +38,3 @@
|
||||
- `categories[].label`:分类展示名
|
||||
- `categories[].tools`:该分类下具体工具名称列表(用于前端渲染/筛选/白名单提示)
|
||||
- `default_enabled/silent_when_disabled`:用于 UI/策略展示(并不等于“你一定能调用到该工具”,真实可用性仍取决于服务端策略与运行时环境)
|
||||
|
||||
|
||||
3
cli-react-demo/.gitignore
vendored
Normal file
3
cli-react-demo/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
|
||||
.easyagent/
|
||||
226
cli-react-demo/demo.jsx
Normal file
226
cli-react-demo/demo.jsx
Normal file
@ -0,0 +1,226 @@
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {render, Box, Text, useApp, useInput} from 'ink';
|
||||
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const nowTime = () => new Date().toLocaleTimeString('zh-CN', {hour12: false});
|
||||
|
||||
function Header({running, turn}) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyan" bold>Agents CLI React/Ink Demo</Text>
|
||||
<Text color="gray">
|
||||
React 组件渲染到终端 · 支持粘贴多行 · 输入后回车发送 · Ctrl+C 取消当前任务 · /help 或 /exit
|
||||
</Text>
|
||||
<Box gap={2}>
|
||||
<Text>状态: <Text color={running ? 'yellow' : 'green'}>{running ? '运行中' : '空闲'}</Text></Text>
|
||||
<Text>Turn: <Text color="magenta">#{turn}</Text></Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({item}) {
|
||||
if (item.type === 'user') {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color="blue" bold>你</Text>
|
||||
<Text color="gray"> {item.time} </Text>
|
||||
<Text>{item.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'assistant') {
|
||||
return (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color="green" bold>助手 <Text color="gray">{item.time}</Text></Text>
|
||||
<Text>{item.text || <Text color="gray">正在生成...</Text>}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'tool') {
|
||||
const statusColor = item.status === 'done' ? 'green' : item.status === 'running' ? 'yellow' : 'red';
|
||||
return (
|
||||
<Box marginTop={1} borderStyle="round" borderColor={statusColor} paddingX={1} flexDirection="column">
|
||||
<Text color={statusColor} bold>{item.status === 'done' ? '✓' : '●'} 工具调用:{item.name}</Text>
|
||||
<Text color="gray">{item.detail}</Text>
|
||||
{item.output ? <Text>{item.output}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'system') {
|
||||
return <Text color="gray">◆ {item.text}</Text>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Transcript({items}) {
|
||||
const visible = items.slice(-10);
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={12}>
|
||||
{visible.map(item => <Message key={item.id} item={item} />)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Composer({value, running}) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box borderStyle="single" borderColor={running ? 'yellow' : 'cyan'} paddingX={1}>
|
||||
<Text color="cyan">› </Text>
|
||||
<Text>{value}</Text>
|
||||
<Text inverse> </Text>
|
||||
</Box>
|
||||
<Text color="gray">快捷键:Enter 发送 · 直接粘贴可保留多行 · Backspace 删除 · Ctrl+C {running ? '取消任务' : '退出'} · /help 查看命令</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const {exit} = useApp();
|
||||
const [input, setInput] = useState('');
|
||||
const [items, setItems] = useState(() => [
|
||||
{id: 'welcome', type: 'system', text: '这是一个本地 React/Ink CLI demo:消息区、工具卡片、输入框和状态栏都是 React 组件。'}
|
||||
]);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [turn, setTurn] = useState(0);
|
||||
const cancelRef = useRef(false);
|
||||
const nextId = useRef(1);
|
||||
|
||||
const append = useCallback(item => {
|
||||
setItems(prev => [...prev, {id: String(nextId.current++), ...item}]);
|
||||
}, []);
|
||||
|
||||
const patchLastAssistant = useCallback(delta => {
|
||||
setItems(prev => {
|
||||
const copy = [...prev];
|
||||
for (let i = copy.length - 1; i >= 0; i--) {
|
||||
if (copy[i].type === 'assistant') {
|
||||
copy[i] = {...copy[i], text: (copy[i].text || '') + delta};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markLastToolDone = useCallback(output => {
|
||||
setItems(prev => {
|
||||
const copy = [...prev];
|
||||
for (let i = copy.length - 1; i >= 0; i--) {
|
||||
if (copy[i].type === 'tool' && copy[i].status === 'running') {
|
||||
copy[i] = {...copy[i], status: 'done', output};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const runFakeAgent = useCallback(async prompt => {
|
||||
setRunning(true);
|
||||
cancelRef.current = false;
|
||||
const currentTurn = turn + 1;
|
||||
setTurn(currentTurn);
|
||||
|
||||
append({type: 'user', text: prompt, time: nowTime()});
|
||||
|
||||
if (prompt === '/help') {
|
||||
append({type: 'assistant', time: nowTime(), text: '可用命令:/help、/clear、/exit。普通文本会触发一段模拟 Agent 流程。现在可以直接粘贴多行内容,换行会保留在输入框和提交内容中。'});
|
||||
setRunning(false);
|
||||
return;
|
||||
}
|
||||
if (prompt === '/clear') {
|
||||
setItems([{id: 'cleared', type: 'system', text: '已清屏。'}]);
|
||||
setRunning(false);
|
||||
return;
|
||||
}
|
||||
if (prompt === '/exit') {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
append({type: 'assistant', time: nowTime(), text: ''});
|
||||
const sentence = '我会先读取项目结构,然后模拟一次工具调用,最后给出结论。这些内容是流式追加的,不会破坏下方输入框。';
|
||||
for (const char of sentence) {
|
||||
if (cancelRef.current) {
|
||||
patchLastAssistant('\n\n[已取消]');
|
||||
setRunning(false);
|
||||
return;
|
||||
}
|
||||
patchLastAssistant(char);
|
||||
await sleep(28);
|
||||
}
|
||||
|
||||
append({type: 'tool', status: 'running', name: 'list_files', detail: '扫描当前 workspace 的关键文件...'});
|
||||
await sleep(900);
|
||||
if (cancelRef.current) {
|
||||
markLastToolDone('任务取消,工具调用未继续。');
|
||||
setRunning(false);
|
||||
return;
|
||||
}
|
||||
markLastToolDone('发现 main.py、server/tasks.py、core/main_terminal.py。');
|
||||
|
||||
append({type: 'assistant', time: nowTime(), text: ''});
|
||||
const finalText = '结论:CLI 可以像一个终端 App 一样工作。后续只需要把这里的模拟事件替换成 /api/tasks 的真实事件流。';
|
||||
for (const char of finalText) {
|
||||
if (cancelRef.current) {
|
||||
patchLastAssistant('\n\n[已取消]');
|
||||
setRunning(false);
|
||||
return;
|
||||
}
|
||||
patchLastAssistant(char);
|
||||
await sleep(24);
|
||||
}
|
||||
setRunning(false);
|
||||
}, [append, exit, markLastToolDone, patchLastAssistant, turn]);
|
||||
|
||||
useInput((chunk, key) => {
|
||||
if (key.ctrl && chunk === 'c') {
|
||||
if (running) {
|
||||
cancelRef.current = true;
|
||||
append({type: 'system', text: '收到 Ctrl+C:正在取消当前模拟任务...'});
|
||||
} else {
|
||||
exit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
setInput('');
|
||||
void runFakeAgent(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.backspace || key.delete) {
|
||||
setInput(prev => Array.from(prev).slice(0, -1).join(''));
|
||||
return;
|
||||
}
|
||||
|
||||
if (chunk && !key.ctrl && !key.meta) {
|
||||
// Ink 在 bracketed paste 或快速粘贴时,可能一次性给到包含换行的 chunk。
|
||||
// 这里把 CRLF/CR 统一成 LF,并保留多行内容;单独按 Enter 的提交逻辑在上面处理。
|
||||
const normalized = chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
setInput(prev => prev + normalized);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Header running={running} turn={turn} />
|
||||
<Transcript items={items} />
|
||||
<Composer value={input} running={running} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render(<App />);
|
||||
1067
cli-react-demo/package-lock.json
generated
Normal file
1067
cli-react-demo/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
cli-react-demo/package.json
Normal file
14
cli-react-demo/package.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "agents-cli-react-demo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"demo": "tsx demo.jsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "^6.5.1",
|
||||
"react": "^19.2.1",
|
||||
"tsx": "^4.20.6"
|
||||
}
|
||||
}
|
||||
2
cli/.gitignore
vendored
Normal file
2
cli/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
1660
cli/package-lock.json
generated
Normal file
1660
cli/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
cli/package.json
Normal file
27
cli/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "agents-cli",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"agents": "./dist/main.js",
|
||||
"agents-cli": "./dist/main.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/main.tsx",
|
||||
"build": "esbuild src/main.tsx --bundle --platform=node --format=esm --target=node20 --outfile=dist/main.js --banner:js='import { createRequire } from \"node:module\"; const require = createRequire(import.meta.url);' && chmod +x dist/main.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "^6.5.1",
|
||||
"react": "^19.2.1",
|
||||
"react-devtools-core": "^6.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"esbuild": "^0.27.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
704
cli/src/App.tsx
Normal file
704
cli/src/App.tsx
Normal file
@ -0,0 +1,704 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { slashCommands, filterCommands } from './commands.js';
|
||||
import { createWorkspaceForPath, findWorkspaceByPath, loadWorkspaceCatalog, repoRoot } from './workspaces.js';
|
||||
import type { CliStatus, ExecutionMode, ModelDefinition, PermissionMode, RunMode, SkillDefinition, SlashCommand, TimelineItem, Workspace, WorkspaceCatalog } from './types.js';
|
||||
import { nowSessionId, shortPath } from './format.js';
|
||||
import { Composer, HelpPanel, Picker, SkillHint, SlashMenu, StatusLine, StatusPanel, Timeline, WelcomePanel, WorkspacePrompt } from './components.js';
|
||||
import { ApiClient, createDefaultApiClient } from './api.js';
|
||||
import { createEventRenderState, reduceTaskEvent } from './eventMapper.js';
|
||||
|
||||
type InputMode = 'workspace_prompt' | 'composer' | 'slash_menu' | 'picker' | 'status_panel' | 'help_panel' | 'approval';
|
||||
|
||||
type PickerOption = {
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
type ApprovalOption = {
|
||||
label: string;
|
||||
description?: string;
|
||||
decision: 'approved' | 'rejected';
|
||||
};
|
||||
|
||||
const cwd = process.cwd();
|
||||
const initialCatalog = loadWorkspaceCatalog();
|
||||
const matchedWorkspace = findWorkspaceByPath(initialCatalog, cwd);
|
||||
|
||||
function id(): string {
|
||||
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function initialStatus(workspace: Workspace): CliStatus {
|
||||
return {
|
||||
model: '连接中',
|
||||
runMode: 'thinking',
|
||||
directory: workspace.path,
|
||||
workspace,
|
||||
permissionMode: 'auto_approval',
|
||||
executionMode: 'sandbox',
|
||||
sessionId: nowSessionId(),
|
||||
contextUsed: 0,
|
||||
contextLimit: 0,
|
||||
versioningEnabled: false,
|
||||
backgroundAgents: 0,
|
||||
backgroundCommands: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceFromApi(item: any): Workspace {
|
||||
return {
|
||||
workspace_id: String(item.workspace_id || item.id || item.label || 'workspace'),
|
||||
label: String(item.label || item.workspace_id || 'workspace'),
|
||||
path: String(item.path || ''),
|
||||
};
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { exit } = useApp();
|
||||
const { stdout } = useStdout();
|
||||
const apiRef = useRef<ApiClient | null>(null);
|
||||
const runningTaskRef = useRef<string | null>(null);
|
||||
const currentConversationRef = useRef<string | undefined>(undefined);
|
||||
const bootstrappedRef = useRef(false);
|
||||
const [catalog, setCatalog] = useState<WorkspaceCatalog>(initialCatalog);
|
||||
const [mode, setMode] = useState<InputMode>(matchedWorkspace ? 'composer' : 'workspace_prompt');
|
||||
const [workspacePromptIndex, setWorkspacePromptIndex] = useState(0);
|
||||
const [input, setInput] = useState('');
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const [pickerTitle, setPickerTitle] = useState('');
|
||||
const [pickerOptions, setPickerOptions] = useState<PickerOption[]>([]);
|
||||
const [pickerIndex, setPickerIndex] = useState(0);
|
||||
const [approvalTitle, setApprovalTitle] = useState('');
|
||||
const [approvalId, setApprovalId] = useState('');
|
||||
const [approvalOptions] = useState<ApprovalOption[]>([
|
||||
{ label: '允许一次', decision: 'approved' },
|
||||
{ label: '拒绝', decision: 'rejected' },
|
||||
]);
|
||||
const [approvalIndex, setApprovalIndex] = useState(0);
|
||||
const [timeline, setTimeline] = useState<TimelineItem[]>([]);
|
||||
const [status, setStatus] = useState<CliStatus>(() => initialStatus(matchedWorkspace || initialCatalog.workspaces[0]!));
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runningStartedAt, setRunningStartedAt] = useState<number | null>(null);
|
||||
const [nowMs, setNowMs] = useState(Date.now());
|
||||
const [workingTextTick, setWorkingTextTick] = useState(0);
|
||||
const [workingDotTick, setWorkingDotTick] = useState(0);
|
||||
const [timelineDotTick, setTimelineDotTick] = useState(0);
|
||||
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
|
||||
const [models, setModels] = useState<ModelDefinition[]>([]);
|
||||
const [skills, setSkills] = useState<SkillDefinition[]>([]);
|
||||
const [permissionOptions, setPermissionOptions] = useState<PermissionMode[]>([]);
|
||||
const [executionOptions, setExecutionOptions] = useState<ExecutionMode[]>([]);
|
||||
|
||||
const commandMatches = useMemo(() => filterCommands(input), [input]);
|
||||
const runningSeconds = runningStartedAt ? Math.max(0, Math.floor((nowMs - runningStartedAt) / 1000)) : 0;
|
||||
const hasRunningTimeline = useMemo(() => timeline.some((item) => item.status === 'running'), [timeline]);
|
||||
const slashMenuVisibleCount = mode === 'slash_menu' ? Math.max(1, Math.min(5, commandMatches.length)) : 0;
|
||||
const composerCursorYOffset = mode === 'slash_menu' ? 1 + slashMenuVisibleCount : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = setInterval(() => {
|
||||
setNowMs(Date.now());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [running]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = setInterval(() => {
|
||||
setWorkingTextTick((value) => value + 1);
|
||||
}, 100);
|
||||
return () => clearInterval(timer);
|
||||
}, [running]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = setInterval(() => {
|
||||
setWorkingDotTick((value) => value + 1);
|
||||
}, 800);
|
||||
return () => clearInterval(timer);
|
||||
}, [running]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRunningTimeline) return;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const delay = setTimeout(() => {
|
||||
setTimelineDotTick((value) => value + 1);
|
||||
timer = setInterval(() => {
|
||||
setTimelineDotTick((value) => value + 1);
|
||||
}, 800);
|
||||
}, 400);
|
||||
return () => {
|
||||
clearTimeout(delay);
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [hasRunningTimeline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'composer' || bootstrappedRef.current) return;
|
||||
bootstrappedRef.current = true;
|
||||
getApi()
|
||||
.then((api) => bootstrapConversation(api))
|
||||
.catch((err) => addItem({ kind: 'tool', title: '初始化失败', status: 'failed', body: String(err.message || err) }));
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setLayoutRefreshTick((v) => v + 1);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [mode, commandMatches.length, running, input.length, timeline.length]);
|
||||
|
||||
function addItem(item: Omit<TimelineItem, 'id'>) {
|
||||
setTimeline((prev) => [...prev, { id: id(), ...item }]);
|
||||
}
|
||||
|
||||
function patchStatusFromServer(raw: any, workspaceOverride?: Workspace) {
|
||||
const execMode = typeof raw?.execution_mode === 'object' ? raw.execution_mode.mode : raw?.execution_mode;
|
||||
const contextUsed = Number(raw?.context?.total_size || raw?.context_tokens || raw?.token_statistics?.current_context_tokens || 0);
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
model: raw?.model_key || prev.model,
|
||||
runMode: raw?.run_mode || prev.runMode,
|
||||
directory: raw?.project_path || workspaceOverride?.path || prev.directory,
|
||||
workspace: workspaceOverride || prev.workspace,
|
||||
permissionMode: raw?.permission_mode || prev.permissionMode,
|
||||
executionMode: execMode || prev.executionMode,
|
||||
sessionId: raw?.conversation?.current_id || prev.sessionId,
|
||||
contextUsed: Number.isFinite(contextUsed) && contextUsed > 0 ? contextUsed : prev.contextUsed,
|
||||
}));
|
||||
if (raw?.conversation?.current_id) currentConversationRef.current = raw.conversation.current_id;
|
||||
}
|
||||
|
||||
async function getApi(): Promise<ApiClient> {
|
||||
if (!apiRef.current) apiRef.current = createDefaultApiClient(cwd, repoRoot);
|
||||
if (!connected && !connecting) {
|
||||
setConnecting(true);
|
||||
try {
|
||||
await apiRef.current.ensureConnected();
|
||||
setConnected(true);
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
} else if (!connected) {
|
||||
while (!connected && connecting) await sleep(100);
|
||||
}
|
||||
return apiRef.current;
|
||||
}
|
||||
|
||||
async function syncBackendState(api = apiRef.current) {
|
||||
if (!api) return;
|
||||
try {
|
||||
const wsData = await api.listWorkspaces();
|
||||
const workspaces = (wsData.workspaces || []).map(workspaceFromApi);
|
||||
const current = workspaces.find((ws) => ws.workspace_id === wsData.current_workspace_id) || workspaces.find((ws) => ws.path === status.workspace.path) || status.workspace;
|
||||
setCatalog({ default_workspace_id: wsData.default_workspace_id, workspaces });
|
||||
const rawStatus = await api.getStatus();
|
||||
patchStatusFromServer(rawStatus, current);
|
||||
const conv = await api.getCurrentConversation().catch(() => null);
|
||||
const convId = conv?.id || rawStatus?.conversation?.current_id;
|
||||
if (convId) currentConversationRef.current = convId;
|
||||
await syncRuntimeCatalogs(api, convId).catch(() => undefined);
|
||||
} catch (err) {
|
||||
addItem({ kind: 'tool', title: '同步后端状态失败', status: 'failed', body: String((err as Error).message || err) });
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRuntimeCatalogs(api: ApiClient, conversationId?: string) {
|
||||
const [modelItems, personalization, permission, execution, subAgents, backgroundCommands, tokens, versioning] = await Promise.all([
|
||||
api.listModels().catch(() => []),
|
||||
api.getPersonalization().catch(() => null),
|
||||
api.getPermissionMode().catch(() => null),
|
||||
api.getExecutionMode().catch(() => null),
|
||||
api.listSubAgents().catch(() => []),
|
||||
api.listBackgroundCommands().catch(() => []),
|
||||
conversationId ? api.getConversationTokens(conversationId).catch(() => null) : Promise.resolve(null),
|
||||
conversationId ? api.getConversationVersioning(conversationId).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
if (modelItems.length) setModels(modelItems);
|
||||
const enabled = new Set((personalization?.data?.enabled_skills || []).map(String));
|
||||
const skillItems = (personalization?.skills_catalog || [])
|
||||
.filter((skill: any) => !enabled.size || enabled.has(String(skill.id)))
|
||||
.map((skill: any) => ({ id: String(skill.id), label: String(skill.label || skill.id), description: String(skill.description || '') }));
|
||||
if (skillItems.length) setSkills(skillItems);
|
||||
const rawCompressionLimit = Number(
|
||||
personalization?.context_compression_settings?.context_window_tokens
|
||||
|| personalization?.context_compression_settings?.deep_trigger_tokens
|
||||
|| personalization?.data?.deep_compress_trigger_tokens
|
||||
|| 0,
|
||||
);
|
||||
const currentModel = modelItems.find((item) => item.model_key === status.model);
|
||||
const modelWindow = Number(currentModel?.context_window || 0);
|
||||
const compressionLimit = rawCompressionLimit > 0 && modelWindow > 0
|
||||
? Math.min(rawCompressionLimit, modelWindow)
|
||||
: rawCompressionLimit || modelWindow;
|
||||
if (Array.isArray(permission?.options)) setPermissionOptions(permission.options);
|
||||
if (Array.isArray(execution?.options)) setExecutionOptions(execution.options);
|
||||
const runningAgents = subAgents.filter((item: any) => !['completed', 'failed', 'cancelled', 'terminated', 'timeout'].includes(String(item.status || '').toLowerCase())).length;
|
||||
const runningCommands = backgroundCommands.filter((item: any) => !['completed', 'failed', 'cancelled', 'timeout'].includes(String(item.status || '').toLowerCase())).length;
|
||||
const tokenUsed = Number(tokens?.total_tokens || 0);
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
contextUsed: Number.isFinite(tokenUsed) && tokenUsed > 0 ? tokenUsed : prev.contextUsed,
|
||||
contextLimit: Number.isFinite(compressionLimit) && compressionLimit > 0 ? compressionLimit : prev.contextLimit,
|
||||
versioningEnabled: versioning ? Boolean(versioning.enabled) : prev.versioningEnabled,
|
||||
backgroundAgents: runningAgents,
|
||||
backgroundCommands: runningCommands,
|
||||
}));
|
||||
}
|
||||
|
||||
async function bootstrapConversation(api: ApiClient) {
|
||||
const result = await api.createConversation();
|
||||
const convId = result.conversation_id || result.data?.conversation_id || result.data?.id || result.id;
|
||||
if (convId) {
|
||||
currentConversationRef.current = String(convId);
|
||||
setStatus((prev) => ({ ...prev, sessionId: String(convId), contextUsed: 0 }));
|
||||
await syncBackendState(api);
|
||||
await syncRuntimeCatalogs(api, String(convId)).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function openPicker(title: string, options: PickerOption[]) {
|
||||
setPickerTitle(title);
|
||||
setPickerOptions(options);
|
||||
setPickerIndex(0);
|
||||
setMode('picker');
|
||||
}
|
||||
|
||||
function closeOverlay() {
|
||||
setMode('composer');
|
||||
setInput('');
|
||||
setSlashIndex(0);
|
||||
setPickerIndex(0);
|
||||
}
|
||||
|
||||
async function executeCommand(command: SlashCommand) {
|
||||
const name = command.name;
|
||||
try {
|
||||
if (name === '/new') {
|
||||
const api = await getApi();
|
||||
const result = await api.createConversation();
|
||||
const convId = result.conversation_id || result.data?.conversation_id;
|
||||
currentConversationRef.current = convId;
|
||||
setTimeline([]);
|
||||
setStatus((prev) => ({ ...prev, sessionId: convId || nowSessionId(), contextUsed: 0 }));
|
||||
if (convId) await syncRuntimeCatalogs(api, String(convId)).catch(() => undefined);
|
||||
closeOverlay();
|
||||
return;
|
||||
}
|
||||
if (name === '/resume') {
|
||||
const api = await getApi();
|
||||
const conversations = await api.listConversations(30);
|
||||
openPicker('Resume conversation', conversations.map((conv: any) => ({
|
||||
label: String(conv.title || conv.id || '未命名对话'),
|
||||
description: String(conv.updated_at || conv.created_at || conv.id || ''),
|
||||
onSelect: async () => {
|
||||
await api.loadConversation(String(conv.id));
|
||||
currentConversationRef.current = String(conv.id);
|
||||
setStatus((prev) => ({ ...prev, sessionId: String(conv.id) }));
|
||||
await syncRuntimeCatalogs(api, String(conv.id)).catch(() => undefined);
|
||||
addItem({ kind: 'system', body: `已切换对话:${conv.title || conv.id}` });
|
||||
closeOverlay();
|
||||
},
|
||||
})));
|
||||
return;
|
||||
}
|
||||
if (name === '/model') {
|
||||
const api = await getApi();
|
||||
const modelItems = (await api.listModels().catch(() => models)).filter((item) => item.model_key);
|
||||
if (modelItems.length) setModels(modelItems);
|
||||
const currentRunMode = status.runMode;
|
||||
openPicker('Model', modelItems.map((model) => ({
|
||||
label: model.name || model.model_key,
|
||||
description: `${model.model_key === status.model ? 'current · ' : ''}${model.description || model.model_key}`,
|
||||
onSelect: async () => {
|
||||
await api.setModel(model.model_key);
|
||||
setStatus((prev) => ({ ...prev, model: model.model_key, contextLimit: Number(model.context_window) || prev.contextLimit }));
|
||||
addItem({ kind: 'system', body: `已切换模型:${model.name || model.model_key}` });
|
||||
const modes = getRunModesForModel(model);
|
||||
openPicker('Run Mode', modes.map((runMode) => ({
|
||||
label: runMode,
|
||||
description: runMode === currentRunMode ? 'current' : '',
|
||||
onSelect: async () => {
|
||||
await api.setRunMode(runMode);
|
||||
setStatus((prev) => ({ ...prev, runMode }));
|
||||
await syncBackendState(api);
|
||||
addItem({ kind: 'system', body: `已切换运行模式:${runMode}` });
|
||||
closeOverlay();
|
||||
},
|
||||
})));
|
||||
},
|
||||
})));
|
||||
return;
|
||||
}
|
||||
if (name === '/versioning') {
|
||||
const api = await getApi();
|
||||
const convId = currentConversationRef.current || status.sessionId;
|
||||
const current = await api.getConversationVersioning(convId).catch(() => null);
|
||||
const enabled = current ? Boolean(current.enabled) : status.versioningEnabled;
|
||||
const trackingMode = current?.tracking_mode;
|
||||
openPicker('Versioning', [
|
||||
{ label: enabled ? '关闭版本控制' : '开启版本控制', onSelect: async () => {
|
||||
const result = await api.setConversationVersioning(convId, !enabled, trackingMode);
|
||||
setStatus((prev) => ({ ...prev, versioningEnabled: Boolean(result.enabled) }));
|
||||
addItem({ kind: 'system', body: `版本控制:${result.enabled ? '开' : '关'}` });
|
||||
closeOverlay();
|
||||
} },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (name === '/permission') {
|
||||
const api = await getApi();
|
||||
const current = await api.getPermissionMode().catch(() => null);
|
||||
const modes: PermissionMode[] = Array.isArray(current?.options) && current.options.length ? current.options : (permissionOptions.length ? permissionOptions : [status.permissionMode]);
|
||||
openPicker('Permission Mode', modes.map((permissionMode) => ({ label: permissionMode, description: permissionMode === status.permissionMode ? 'current' : '', onSelect: async () => {
|
||||
await api.setPermissionMode(permissionMode);
|
||||
setStatus((prev) => ({ ...prev, permissionMode }));
|
||||
addItem({ kind: 'system', body: `权限模式:${permissionMode}` });
|
||||
closeOverlay();
|
||||
} })));
|
||||
return;
|
||||
}
|
||||
if (name === '/execution') {
|
||||
const api = await getApi();
|
||||
const current = await api.getExecutionMode().catch(() => null);
|
||||
const modes: ExecutionMode[] = Array.isArray(current?.options) && current.options.length ? current.options : (executionOptions.length ? executionOptions : [status.executionMode]);
|
||||
openPicker('Execution Environment', modes.map((executionMode) => ({ label: executionMode, description: executionMode === status.executionMode ? 'current' : executionMode === 'direct' ? '高风险' : '', onSelect: async () => {
|
||||
await api.setExecutionMode(executionMode);
|
||||
setStatus((prev) => ({ ...prev, executionMode }));
|
||||
addItem({ kind: 'system', body: `执行环境:${executionMode}` });
|
||||
closeOverlay();
|
||||
} })));
|
||||
return;
|
||||
}
|
||||
if (name === '/workspace') {
|
||||
const api = await getApi();
|
||||
const wsData = await api.listWorkspaces();
|
||||
const workspaces = (wsData.workspaces || []).map(workspaceFromApi);
|
||||
setCatalog({ default_workspace_id: wsData.default_workspace_id, workspaces });
|
||||
openPicker('Workspace', workspaces.map((workspace) => ({ label: workspace.label, description: `${shortPath(workspace.path)}${workspace.workspace_id === wsData.current_workspace_id ? ' current' : ''}`, onSelect: async () => {
|
||||
await api.selectWorkspace(workspace.workspace_id);
|
||||
setStatus((prev) => ({ ...prev, workspace, directory: workspace.path }));
|
||||
addItem({ kind: 'system', body: `已切换工作区:${workspace.label}` });
|
||||
await syncBackendState(api);
|
||||
closeOverlay();
|
||||
} })));
|
||||
return;
|
||||
}
|
||||
if (name === '/compact') {
|
||||
const api = await getApi();
|
||||
const convId = currentConversationRef.current || status.sessionId;
|
||||
const result = await api.compressConversation(convId);
|
||||
addItem({ kind: 'tool', title: '对话已压缩', body: result.compact_file || '', status: 'success' });
|
||||
if (result.compressed_conversation_id) {
|
||||
currentConversationRef.current = result.compressed_conversation_id;
|
||||
setStatus((prev) => ({ ...prev, sessionId: result.compressed_conversation_id }));
|
||||
}
|
||||
closeOverlay();
|
||||
return;
|
||||
}
|
||||
if (name === '/tools') {
|
||||
const api = await getApi();
|
||||
const snapshot = await api.getToolSettings();
|
||||
const categories = snapshot.categories || {};
|
||||
openPicker('Tools', Object.entries(categories).map(([key, value]: [string, any]) => ({ label: `${value.enabled ? '[x]' : '[ ]'} ${key}`, description: value.label || '', onSelect: async () => {
|
||||
await api.setToolCategory(key, !value.enabled);
|
||||
addItem({ kind: 'system', body: `工具 ${key}:${!value.enabled ? '启用' : '禁用'}` });
|
||||
closeOverlay();
|
||||
} })));
|
||||
return;
|
||||
}
|
||||
if (name === '/path') {
|
||||
const api = await getApi();
|
||||
const auth = await api.getPathAuthorization();
|
||||
openPicker('Authorized Paths', [
|
||||
{ label: '当前可读写路径', description: (auth.writable_paths || []).join(', ') || '无', onSelect: () => closeOverlay() },
|
||||
{ label: '当前只读路径', description: (auth.readable_extra_paths || []).join(', ') || '无', onSelect: () => closeOverlay() },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (name === '/skill') {
|
||||
const api = await getApi();
|
||||
const personalization = await api.getPersonalization().catch(() => null);
|
||||
const enabled = new Set((personalization?.data?.enabled_skills || []).map(String));
|
||||
const skillItems = (personalization?.skills_catalog || skills)
|
||||
.filter((skill: any) => !enabled.size || enabled.has(String(skill.id)))
|
||||
.map((skill: any) => ({ id: String(skill.id), label: String(skill.label || skill.id), description: String(skill.description || '') }));
|
||||
if (skillItems.length) setSkills(skillItems);
|
||||
openPicker('Skill', skillItems.map((skill: SkillDefinition) => ({ label: skill.label, description: skill.description, onSelect: () => {
|
||||
setStatus((prev) => ({ ...prev, activeSkill: skill }));
|
||||
addItem({ kind: 'system', body: `下一条消息将使用 skill:${skill.label}` });
|
||||
closeOverlay();
|
||||
} })).concat(skillItems.length ? [] : [{ label: '暂无可用 skill', disabled: true, onSelect: () => closeOverlay() }]));
|
||||
return;
|
||||
}
|
||||
if (name === '/status') {
|
||||
await getApi().then((api) => syncBackendState(api)).catch(() => undefined);
|
||||
setMode('status_panel');
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
if (name === '/help') {
|
||||
setMode('help_panel');
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
if (name === '/clear') {
|
||||
setTimeline([]);
|
||||
closeOverlay();
|
||||
return;
|
||||
}
|
||||
if (name === '/exit') {
|
||||
if (runningTaskRef.current) await apiRef.current?.cancelTask(runningTaskRef.current).catch(() => undefined);
|
||||
exit();
|
||||
}
|
||||
} catch (err) {
|
||||
addItem({ kind: 'tool', title: `命令失败:${name}`, status: 'failed', body: String((err as Error).message || err) });
|
||||
closeOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUserMessage(text: string) {
|
||||
addItem({ kind: 'user', body: text });
|
||||
const skill = status.activeSkill;
|
||||
const message = skill ? `${text}\n\n先阅读 ${skill.label} skill` : text;
|
||||
if (skill) addItem({ kind: 'user', body: `先阅读 ${skill.label} skill` });
|
||||
setStatus((prev) => ({ ...prev, activeSkill: undefined }));
|
||||
setRunning(true);
|
||||
setRunningStartedAt(Date.now());
|
||||
try {
|
||||
const api = await getApi();
|
||||
const task = await api.createTask({ message, conversation_id: currentConversationRef.current, model_key: status.model, run_mode: status.runMode });
|
||||
runningTaskRef.current = task.task_id;
|
||||
if (task.conversation_id) currentConversationRef.current = task.conversation_id;
|
||||
await pollTask(api, task.task_id);
|
||||
await syncBackendState(api);
|
||||
} catch (err) {
|
||||
addItem({ kind: 'tool', title: '发送失败', status: 'failed', body: String((err as Error).message || err) });
|
||||
} finally {
|
||||
runningTaskRef.current = null;
|
||||
setRunning(false);
|
||||
setRunningStartedAt(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTask(api: ApiClient, taskId: string) {
|
||||
let offset = 0;
|
||||
const renderState = createEventRenderState();
|
||||
for (;;) {
|
||||
const result = await api.pollTask(taskId, offset);
|
||||
offset = result.next_offset;
|
||||
for (const event of result.events || []) {
|
||||
if (event.type === 'tool_approval_required') {
|
||||
const approval = event.data?.approval || event.data;
|
||||
if (approval?.approval_id) {
|
||||
setApprovalId(String(approval.approval_id));
|
||||
setApprovalTitle(`需要审批:${approval.tool_name || '工具调用'}`);
|
||||
setApprovalIndex(0);
|
||||
setMode('approval');
|
||||
}
|
||||
}
|
||||
setTimeline((prev) => reduceTaskEvent(prev, renderState, event));
|
||||
}
|
||||
if (!['pending', 'running', 'cancel_requested'].includes(String(result.status))) break;
|
||||
await sleep(700);
|
||||
}
|
||||
}
|
||||
|
||||
useInput((chunk, key) => {
|
||||
void handleInput(chunk, key);
|
||||
});
|
||||
|
||||
function handleComposerChange(next: string) {
|
||||
setInput(next);
|
||||
if (next.startsWith('/')) {
|
||||
if (mode !== 'slash_menu') setMode('slash_menu');
|
||||
setSlashIndex(0);
|
||||
} else if (mode === 'slash_menu') {
|
||||
setMode('composer');
|
||||
setSlashIndex(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComposerSubmit(raw: string) {
|
||||
const text = String(raw || '').trim();
|
||||
if (!text) return;
|
||||
if (text.startsWith('/')) {
|
||||
const command = filterCommands(text)[0];
|
||||
if (command) await executeCommand(command);
|
||||
return;
|
||||
}
|
||||
setInput('');
|
||||
await submitUserMessage(text);
|
||||
}
|
||||
|
||||
async function handleInput(chunk: string, key: any) {
|
||||
if (mode === 'workspace_prompt') {
|
||||
if (key.upArrow || chunk === 'k') setWorkspacePromptIndex(0);
|
||||
else if (key.downArrow || chunk === 'j') setWorkspacePromptIndex(1);
|
||||
else if (key.return) {
|
||||
if (workspacePromptIndex === 0) {
|
||||
const result = createWorkspaceForPath(catalog, cwd);
|
||||
setCatalog(result.catalog);
|
||||
setStatus((prev) => ({ ...prev, workspace: result.workspace, directory: result.workspace.path }));
|
||||
bootstrappedRef.current = true;
|
||||
setMode('composer');
|
||||
getApi().then(async (api) => {
|
||||
await api.createWorkspace(result.workspace.path, result.workspace.label).catch(() => undefined);
|
||||
const wsData = await api.listWorkspaces().catch(() => null);
|
||||
const backendWs = wsData?.workspaces?.find((ws: any) => String(ws.path) === result.workspace.path);
|
||||
if (backendWs) await api.selectWorkspace(String(backendWs.workspace_id));
|
||||
await bootstrapConversation(api);
|
||||
}).catch((err) => addItem({ kind: 'tool', title: '连接后端失败', status: 'failed', body: String(err.message || err) }));
|
||||
} else {
|
||||
exit();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'approval') {
|
||||
if (key.upArrow) setApprovalIndex((prev) => Math.max(0, prev - 1));
|
||||
else if (key.downArrow) setApprovalIndex((prev) => Math.min(approvalOptions.length - 1, prev + 1));
|
||||
else if (key.return && approvalId) {
|
||||
const selected = approvalOptions[approvalIndex]!;
|
||||
await apiRef.current?.decideApproval(approvalId, selected.decision).catch((err) => addItem({ kind: 'tool', title: '审批提交失败', status: 'failed', body: String(err.message || err) }));
|
||||
addItem({ kind: 'system', body: `审批已${selected.decision === 'approved' ? '允许' : '拒绝'}` });
|
||||
setMode('composer');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (runningTaskRef.current) {
|
||||
await apiRef.current?.cancelTask(runningTaskRef.current).catch(() => undefined);
|
||||
addItem({ kind: 'system', body: '已请求停止当前任务。' });
|
||||
return;
|
||||
}
|
||||
closeOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'status_panel' || mode === 'help_panel') {
|
||||
if (key.return || chunk === 'q') closeOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'picker') {
|
||||
if (key.upArrow) setPickerIndex((prev) => Math.max(0, prev - 1));
|
||||
else if (key.downArrow) setPickerIndex((prev) => Math.min(pickerOptions.length - 1, prev + 1));
|
||||
else if (key.return) await pickerOptions[pickerIndex]?.onSelect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.ctrl && chunk === 'c') {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'slash_menu') {
|
||||
if (key.upArrow) setSlashIndex((prev) => Math.max(0, prev - 1));
|
||||
else if (key.downArrow) setSlashIndex((prev) => Math.min(commandMatches.length - 1, prev + 1));
|
||||
else if (key.return) {
|
||||
const command = commandMatches[slashIndex];
|
||||
if (command) await executeCommand(command);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'composer') return;
|
||||
|
||||
if (key.return) {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
if (text.startsWith('/')) {
|
||||
const command = filterCommands(text)[0];
|
||||
if (command) await executeCommand(command);
|
||||
return;
|
||||
}
|
||||
setInput('');
|
||||
await submitUserMessage(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.backspace || key.delete) {
|
||||
setInput((prev) => Array.from(prev).slice(0, -1).join(''));
|
||||
return;
|
||||
}
|
||||
|
||||
if (chunk && !key.ctrl && !key.meta) {
|
||||
const normalized = chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
setInput((prev) => {
|
||||
const next = prev + normalized;
|
||||
if (next.startsWith('/')) {
|
||||
setMode('slash_menu');
|
||||
setSlashIndex(0);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'workspace_prompt') {
|
||||
return <WorkspacePrompt cwd={cwd} selectedIndex={workspacePromptIndex} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box flexDirection="column">
|
||||
{timeline.length === 0 ? <WelcomePanel status={status} /> : <Timeline items={timeline} pulseTick={timelineDotTick} width={Math.max(10, (stdout.columns || 80) - 2)} />}
|
||||
{timeline.length > 0 && timeline[timeline.length - 1]?.kind === 'assistant' ? <Text> </Text> : null}
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
{running ? (
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Text color={workingDotTick % 2 === 0 ? 'gray' : undefined}>{workingDotTick % 2 === 0 ? '◦' : '•'}</Text>{' '}
|
||||
<AnimatedSummary text="Working" tick={workingTextTick} />
|
||||
{` (${runningSeconds}s • 按 Esc 停止)`}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{mode === 'slash_menu' ? <SlashMenu query={input} commands={commandMatches} selectedIndex={slashIndex} /> : null}
|
||||
{mode === 'picker' ? <Picker title={pickerTitle} items={pickerOptions} selectedIndex={pickerIndex} /> : null}
|
||||
{mode === 'status_panel' ? <StatusPanel status={status} /> : null}
|
||||
{mode === 'help_panel' ? <HelpPanel commands={slashCommands} /> : null}
|
||||
{mode === 'approval' ? <Picker title={approvalTitle} items={approvalOptions} selectedIndex={approvalIndex} /> : null}
|
||||
{mode === 'composer' || mode === 'slash_menu' ? <><SkillHint skill={status.activeSkill} /><Composer value={input} disabled={running} marginTop={0} cursorYOffset={composerCursorYOffset} refreshTick={layoutRefreshTick} onChange={handleComposerChange} onSubmit={handleComposerSubmit} /></> : null}
|
||||
<StatusLine status={status} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedSummary({ text, tick }: { text: string; tick: number }) {
|
||||
const chars = Array.from(text || '正在执行...');
|
||||
const cycle = chars.length + 8;
|
||||
const head = tick % cycle;
|
||||
return (
|
||||
<>
|
||||
{chars.map((char, index) => {
|
||||
const distance = head - index;
|
||||
const active = head < chars.length + 4 && distance >= 0 && distance < 5;
|
||||
return (
|
||||
<Text key={`${index}-${char}`} color={active ? undefined : 'gray'}>
|
||||
{char === ' ' ? '\u00A0' : char}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getRunModesForModel(model?: ModelDefinition): RunMode[] {
|
||||
if (!model) return ['fast', 'thinking', 'deep'];
|
||||
if (model.deep_only) return ['deep'];
|
||||
if (model.fast_only || !model.supports_thinking) return ['fast'];
|
||||
return ['fast', 'thinking', 'deep'];
|
||||
}
|
||||
134
cli/src/ImeTextInput.tsx
Normal file
134
cli/src/ImeTextInput.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { Text, useCursor, useInput, useStdout, type DOMElement } from 'ink';
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
contentRef: React.RefObject<DOMElement | null>;
|
||||
cursorYOffset?: number;
|
||||
refreshTick?: number;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export default function ImeTextInput({ value, disabled, contentRef, cursorYOffset = 0, refreshTick = 0, onChange, onSubmit }: Props) {
|
||||
const { stdout } = useStdout();
|
||||
const { setCursorPosition } = useCursor();
|
||||
const cursorOffsetRef = React.useRef<number>(Array.from(value || '').length);
|
||||
const [, forceRender] = React.useReducer((x: number) => x + 1, 0);
|
||||
const pendingYOffsetRef = React.useRef<number>(0);
|
||||
const seenRefreshTickRef = React.useRef<number>(refreshTick);
|
||||
|
||||
React.useEffect(() => {
|
||||
const len = Array.from(value || '').length;
|
||||
if (cursorOffsetRef.current > len) cursorOffsetRef.current = len;
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (refreshTick !== seenRefreshTickRef.current) {
|
||||
seenRefreshTickRef.current = refreshTick;
|
||||
pendingYOffsetRef.current = cursorYOffset;
|
||||
forceRender();
|
||||
}
|
||||
}, [refreshTick, cursorYOffset]);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (disabled) return;
|
||||
if (key.return) {
|
||||
void onSubmit(value);
|
||||
return;
|
||||
}
|
||||
if (key.leftArrow) {
|
||||
const next = Math.max(0, cursorOffsetRef.current - 1);
|
||||
if (next !== cursorOffsetRef.current) {
|
||||
cursorOffsetRef.current = next;
|
||||
forceRender();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
const len = Array.from(value || '').length;
|
||||
const next = Math.min(len, cursorOffsetRef.current + 1);
|
||||
if (next !== cursorOffsetRef.current) {
|
||||
cursorOffsetRef.current = next;
|
||||
forceRender();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
if (cursorOffsetRef.current <= 0) return;
|
||||
const chars = Array.from(value || '');
|
||||
chars.splice(cursorOffsetRef.current - 1, 1);
|
||||
onChange(chars.join(''));
|
||||
cursorOffsetRef.current = Math.max(0, cursorOffsetRef.current - 1);
|
||||
return;
|
||||
}
|
||||
if (!input || key.ctrl || key.meta) return;
|
||||
const chars = Array.from(value || '');
|
||||
chars.splice(cursorOffsetRef.current, 0, input);
|
||||
onChange(chars.join(''));
|
||||
cursorOffsetRef.current += Array.from(input).length;
|
||||
}, { isActive: !disabled });
|
||||
|
||||
const origin = getAbsoluteOrigin(contentRef.current);
|
||||
if (origin) {
|
||||
const head = Array.from(value || '').slice(0, cursorOffsetRef.current).join('');
|
||||
const terminalRows = Math.max(1, stdout.rows || 24);
|
||||
const bottomCompensation = origin.y >= terminalRows - 2 ? 1 : 0;
|
||||
const yOffsetThisFrame = pendingYOffsetRef.current;
|
||||
pendingYOffsetRef.current = 0;
|
||||
setCursorPosition({
|
||||
x: origin.x + 2 + displayWidth(head),
|
||||
y: origin.y + yOffsetThisFrame + bottomCompensation,
|
||||
});
|
||||
} else {
|
||||
setCursorPosition(undefined);
|
||||
}
|
||||
|
||||
return <Text>{value}</Text>;
|
||||
}
|
||||
|
||||
function getAbsoluteOrigin(node: DOMElement | null): { x: number; y: number } | null {
|
||||
if (!node?.yogaNode) return null;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let current: DOMElement | undefined = node;
|
||||
while (current?.parentNode) {
|
||||
if (!current.yogaNode) return null;
|
||||
x += current.yogaNode.getComputedLeft();
|
||||
y += current.yogaNode.getComputedTop();
|
||||
current = current.parentNode;
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function displayWidth(text: string): number {
|
||||
let width = 0;
|
||||
for (const char of Array.from(text)) {
|
||||
const code = char.codePointAt(0) || 0;
|
||||
if (code === 0) continue;
|
||||
if (code < 32 || (code >= 0x7f && code < 0xa0)) continue;
|
||||
width += isWideCodePoint(code) ? 2 : 1;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
function isWideCodePoint(code: number): boolean {
|
||||
return (
|
||||
code >= 0x1100 && (
|
||||
code <= 0x115f ||
|
||||
code === 0x2329 ||
|
||||
code === 0x232a ||
|
||||
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
|
||||
(code >= 0xac00 && code <= 0xd7a3) ||
|
||||
(code >= 0xf900 && code <= 0xfaff) ||
|
||||
(code >= 0xfe10 && code <= 0xfe19) ||
|
||||
(code >= 0xfe30 && code <= 0xfe6f) ||
|
||||
(code >= 0xff00 && code <= 0xff60) ||
|
||||
(code >= 0xffe0 && code <= 0xffe6) ||
|
||||
(code >= 0x1f300 && code <= 0x1f64f) ||
|
||||
(code >= 0x1f900 && code <= 0x1f9ff) ||
|
||||
(code >= 0x20000 && code <= 0x3fffd)
|
||||
)
|
||||
);
|
||||
}
|
||||
313
cli/src/api.ts
Normal file
313
cli/src/api.ts
Normal file
@ -0,0 +1,313 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { basename } from 'node:path';
|
||||
import type { ExecutionMode, ModelDefinition, PermissionMode, RunMode, Workspace } from './types.js';
|
||||
|
||||
export type ApiConfig = {
|
||||
baseUrl: string;
|
||||
repoRoot: string;
|
||||
cwd: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
export type TaskPollResult = {
|
||||
task_id: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
conversation_id?: string;
|
||||
events: Array<{ idx: number; type: string; data: any; ts?: number }>;
|
||||
next_offset: number;
|
||||
};
|
||||
|
||||
export class ApiClient {
|
||||
private cookie = '';
|
||||
private csrfToken = '';
|
||||
private serverProcess: ChildProcess | null = null;
|
||||
|
||||
constructor(private readonly config: ApiConfig) {}
|
||||
|
||||
get baseUrl(): string {
|
||||
return this.config.baseUrl;
|
||||
}
|
||||
|
||||
async ensureConnected(): Promise<void> {
|
||||
const ok = await this.tryFetchCsrf();
|
||||
if (!ok) {
|
||||
this.startServer();
|
||||
await this.waitForServer();
|
||||
await this.fetchCsrf();
|
||||
}
|
||||
await this.hostLogin();
|
||||
await this.fetchCsrf();
|
||||
}
|
||||
|
||||
private startServer(): void {
|
||||
if (this.serverProcess) return;
|
||||
const env = {
|
||||
...process.env,
|
||||
TERMINAL_SANDBOX_MODE: 'host',
|
||||
HOST_PROJECT_PATH: this.config.cwd,
|
||||
WEB_SERVER_PORT: String(this.config.port),
|
||||
AGENTS_CLI_STARTED_SERVER: '1',
|
||||
};
|
||||
this.serverProcess = spawn(
|
||||
process.execPath.includes('node') ? 'python3' : 'python3',
|
||||
['-m', 'server.app', '--path', this.config.cwd, '--port', String(this.config.port), '--thinking-mode'],
|
||||
{
|
||||
cwd: this.config.repoRoot,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
this.serverProcess.unref();
|
||||
}
|
||||
|
||||
private async waitForServer(): Promise<void> {
|
||||
const deadline = Date.now() + 20_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
const ok = await this.tryFetchCsrf().catch((err) => {
|
||||
lastError = err;
|
||||
return false;
|
||||
});
|
||||
if (ok) return;
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`无法连接本地 Web 服务:${String(lastError || 'timeout')}`);
|
||||
}
|
||||
|
||||
private async tryFetchCsrf(): Promise<boolean> {
|
||||
try {
|
||||
await this.fetchCsrf();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchCsrf(): Promise<string> {
|
||||
const data = await this.request<{ token: string }>('/api/csrf-token', { method: 'GET', skipCsrf: true, allowUnauthorized: true });
|
||||
this.csrfToken = data.token;
|
||||
return this.csrfToken;
|
||||
}
|
||||
|
||||
async hostLogin(): Promise<void> {
|
||||
await this.request('/host-login', { method: 'POST' });
|
||||
}
|
||||
|
||||
async getStatus(): Promise<any> {
|
||||
return this.request('/api/status', { method: 'GET' });
|
||||
}
|
||||
|
||||
async listModels(): Promise<ModelDefinition[]> {
|
||||
const res = await this.request<{ items?: ModelDefinition[]; data?: any }>('/api/v1/models', { method: 'GET' });
|
||||
return res.items || res.data?.items || [];
|
||||
}
|
||||
|
||||
async getPersonalization(): Promise<any> {
|
||||
return this.request('/api/personalization', { method: 'GET' });
|
||||
}
|
||||
|
||||
async listWorkspaces(): Promise<{ current_workspace_id: string; default_workspace_id: string; workspaces: Workspace[] }> {
|
||||
const res = await this.request<{ data: any }>('/api/host/workspaces', { method: 'GET' });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async createWorkspace(path: string, label = basename(path)): Promise<{ workspace: Workspace; created: boolean }> {
|
||||
const res = await this.request<{ data: any }>('/api/host/workspaces/create', {
|
||||
method: 'POST',
|
||||
body: { path, label },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async selectWorkspace(workspaceId: string): Promise<void> {
|
||||
await this.request('/api/host/workspaces/select', {
|
||||
method: 'POST',
|
||||
body: { workspace_id: workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async createConversation(): Promise<any> {
|
||||
return this.request('/api/conversations', { method: 'POST', body: {} });
|
||||
}
|
||||
|
||||
async listConversations(limit = 20): Promise<any[]> {
|
||||
const res = await this.request<{ data: any }>(`/api/conversations?limit=${limit}`, { method: 'GET' });
|
||||
return res.data?.conversations || res.data || [];
|
||||
}
|
||||
|
||||
async loadConversation(conversationId: string): Promise<any> {
|
||||
return this.request(`/api/conversations/${encodeURIComponent(conversationId)}/load`, { method: 'PUT', body: {} });
|
||||
}
|
||||
|
||||
async getCurrentConversation(): Promise<any> {
|
||||
const res = await this.request<{ data: any }>('/api/conversations/current', { method: 'GET' });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async compressConversation(conversationId: string): Promise<any> {
|
||||
return this.request(`/api/conversations/${encodeURIComponent(conversationId)}/compress`, { method: 'POST', body: {} });
|
||||
}
|
||||
|
||||
async setModel(modelKey: string): Promise<any> {
|
||||
return this.request('/api/model', { method: 'POST', body: { model_key: modelKey } });
|
||||
}
|
||||
|
||||
async setRunMode(mode: RunMode): Promise<any> {
|
||||
return this.request('/api/thinking-mode', { method: 'POST', body: { mode } });
|
||||
}
|
||||
|
||||
async getPermissionMode(): Promise<any> {
|
||||
return this.request('/api/permission-mode', { method: 'GET' });
|
||||
}
|
||||
|
||||
async setPermissionMode(mode: PermissionMode): Promise<any> {
|
||||
return this.request('/api/permission-mode', { method: 'POST', body: { mode } });
|
||||
}
|
||||
|
||||
async getExecutionMode(): Promise<any> {
|
||||
return this.request('/api/execution-mode', { method: 'GET' });
|
||||
}
|
||||
|
||||
async getConversationVersioning(conversationId: string): Promise<any> {
|
||||
const res = await this.request<{ data: any }>(`/api/conversations/${encodeURIComponent(conversationId)}/versioning`, { method: 'GET' });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async setConversationVersioning(conversationId: string, enabled: boolean, trackingMode?: string): Promise<any> {
|
||||
const res = await this.request<{ data: any }>(`/api/conversations/${encodeURIComponent(conversationId)}/versioning`, {
|
||||
method: 'POST',
|
||||
body: { enabled, ...(trackingMode ? { tracking_mode: trackingMode } : {}) },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async getConversationTokens(conversationId: string): Promise<any> {
|
||||
const res = await this.request<{ data: any }>(`/api/conversations/${encodeURIComponent(conversationId)}/tokens`, { method: 'GET' });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async listSubAgents(): Promise<any[]> {
|
||||
const res = await this.request<{ data: any[] }>('/api/sub_agents', { method: 'GET' });
|
||||
return Array.isArray(res.data) ? res.data : [];
|
||||
}
|
||||
|
||||
async listBackgroundCommands(): Promise<any[]> {
|
||||
const res = await this.request<{ data: any[] }>('/api/background_commands', { method: 'GET' });
|
||||
return Array.isArray(res.data) ? res.data : [];
|
||||
}
|
||||
|
||||
async setExecutionMode(mode: ExecutionMode): Promise<any> {
|
||||
return this.request('/api/execution-mode', { method: 'POST', body: { mode } });
|
||||
}
|
||||
|
||||
async getToolSettings(): Promise<any> {
|
||||
return this.request('/api/tool-settings', { method: 'GET' });
|
||||
}
|
||||
|
||||
async setToolCategory(category: string, enabled: boolean): Promise<any> {
|
||||
return this.request('/api/tool-settings', { method: 'POST', body: { category, enabled } });
|
||||
}
|
||||
|
||||
async getPathAuthorization(): Promise<any> {
|
||||
return this.request('/api/path-authorization', { method: 'GET' });
|
||||
}
|
||||
|
||||
async setPathAuthorization(writablePaths: string[], readableExtraPaths: string[]): Promise<any> {
|
||||
return this.request('/api/path-authorization', {
|
||||
method: 'POST',
|
||||
body: { writable_paths: writablePaths, readable_extra_paths: readableExtraPaths },
|
||||
});
|
||||
}
|
||||
|
||||
async createTask(payload: { message: string; conversation_id?: string; model_key?: string; run_mode?: RunMode }): Promise<any> {
|
||||
const res = await this.request<{ data: any }>('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async pollTask(taskId: string, offset: number): Promise<TaskPollResult> {
|
||||
const res = await this.request<{ data: TaskPollResult }>(`/api/tasks/${encodeURIComponent(taskId)}?from=${offset}`, { method: 'GET' });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async cancelTask(taskId: string): Promise<void> {
|
||||
await this.request(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: 'POST', body: {} });
|
||||
}
|
||||
|
||||
async decideApproval(approvalId: string, decision: 'approved' | 'rejected'): Promise<any> {
|
||||
return this.request(`/api/tool-approvals/${encodeURIComponent(approvalId)}/decision`, {
|
||||
method: 'POST',
|
||||
body: { decision },
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T = any>(path: string, options: { method: string; body?: any; skipCsrf?: boolean; allowUnauthorized?: boolean }): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (this.cookie) headers.Cookie = this.cookie;
|
||||
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (!options.skipCsrf && options.method !== 'GET' && this.csrfToken) headers['X-CSRF-Token'] = this.csrfToken;
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}${path}`, {
|
||||
method: options.method,
|
||||
headers,
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
redirect: 'manual',
|
||||
});
|
||||
this.captureCookies(response);
|
||||
|
||||
if (response.status === 401 && !options.allowUnauthorized) {
|
||||
await this.fetchCsrf();
|
||||
await this.hostLogin();
|
||||
return this.request(path, { ...options, allowUnauthorized: true });
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let data: any = {};
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { success: false, error: text };
|
||||
}
|
||||
}
|
||||
if (!response.ok || data?.success === false) {
|
||||
throw new Error(data?.error || data?.message || `HTTP ${response.status}`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
private captureCookies(response: Response): void {
|
||||
const anyHeaders = response.headers as any;
|
||||
const rawCookies: string[] = typeof anyHeaders.getSetCookie === 'function'
|
||||
? anyHeaders.getSetCookie()
|
||||
: [];
|
||||
const fallback = response.headers.get('set-cookie');
|
||||
if (fallback) rawCookies.push(fallback);
|
||||
if (!rawCookies.length) return;
|
||||
|
||||
const current = new Map<string, string>();
|
||||
for (const part of this.cookie.split(';')) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq > 0) current.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
|
||||
}
|
||||
for (const raw of rawCookies) {
|
||||
const cookiePair = raw.split(';', 1)[0];
|
||||
const eq = cookiePair.indexOf('=');
|
||||
if (eq > 0) current.set(cookiePair.slice(0, eq), cookiePair.slice(eq + 1));
|
||||
}
|
||||
this.cookie = Array.from(current.entries()).map(([key, value]) => `${key}=${value}`).join('; ');
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultApiClient(cwd: string, repoRoot: string): ApiClient {
|
||||
const port = Number(process.env.AGENTS_API_PORT || process.env.WEB_SERVER_PORT || 8091);
|
||||
const baseUrl = process.env.AGENTS_API_BASE || `http://127.0.0.1:${port}`;
|
||||
return new ApiClient({ baseUrl, repoRoot, cwd, port });
|
||||
}
|
||||
28
cli/src/commands.ts
Normal file
28
cli/src/commands.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { SlashCommand } from './types.js';
|
||||
|
||||
export const slashCommands: SlashCommand[] = [
|
||||
{ name: '/new', description: '新建对话', kind: 'immediate' },
|
||||
{ name: '/resume', description: '查看对话列表并切换对话', kind: 'picker' },
|
||||
{ name: '/model', description: '切换模型与思考模式', kind: 'picker' },
|
||||
{ name: '/versioning', description: '管理版本控制', aliases: ['/version'], kind: 'picker' },
|
||||
{ name: '/permission', description: '切换权限模式', aliases: ['/perm'], kind: 'picker' },
|
||||
{ name: '/execution', description: '切换执行环境', aliases: ['/exec', '/env'], kind: 'picker' },
|
||||
{ name: '/workspace', description: '选择或切换工作区', kind: 'picker' },
|
||||
{ name: '/compact', description: '压缩当前对话上下文', kind: 'immediate' },
|
||||
{ name: '/tools', description: '启用或禁用工具', aliases: ['/tool'], kind: 'picker' },
|
||||
{ name: '/path', description: '管理路径授权', kind: 'form' },
|
||||
{ name: '/skill', description: '选择本轮要使用的 skill', kind: 'picker' },
|
||||
{ name: '/status', description: '查看当前状态', kind: 'panel' },
|
||||
{ name: '/help', description: '查看命令帮助', kind: 'panel' },
|
||||
{ name: '/clear', description: '清空当前 CLI 显示', kind: 'immediate' },
|
||||
{ name: '/exit', description: '退出 CLI', kind: 'immediate' },
|
||||
];
|
||||
|
||||
export function filterCommands(query: string): SlashCommand[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized || normalized === '/') return slashCommands;
|
||||
return slashCommands.filter((command) => {
|
||||
const candidates = [command.name, ...(command.aliases || [])];
|
||||
return candidates.some((name) => name.toLowerCase().startsWith(normalized) || name.toLowerCase().includes(normalized));
|
||||
});
|
||||
}
|
||||
325
cli/src/components.tsx
Normal file
325
cli/src/components.tsx
Normal file
@ -0,0 +1,325 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useStdout, type DOMElement } from 'ink';
|
||||
import ImeTextInput from './ImeTextInput.js';
|
||||
import type { CliStatus, SlashCommand, SkillDefinition, TimelineItem, Workspace } from './types.js';
|
||||
import { formatContext, shortPath } from './format.js';
|
||||
|
||||
const SHOW_THINKING_DETAILS = false;
|
||||
|
||||
export function StatusLine({ status }: { status: CliStatus }) {
|
||||
const skill = status.activeSkill ? ` skill:${status.activeSkill.label}` : '';
|
||||
const background = [
|
||||
status.backgroundAgents > 0 ? `${status.backgroundAgents}后台智能体` : '',
|
||||
status.backgroundCommands > 0 ? `${status.backgroundCommands}后台指令` : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
const left = `${status.model} · ${status.workspace.label} · ${status.permissionMode} · ${status.executionMode}${background ? ` · ${background}` : ''} · 版本控制:${status.versioningEnabled ? '开' : '关'}${skill}`;
|
||||
const right = formatContext(status.contextUsed, status.contextLimit);
|
||||
return (
|
||||
<Box justifyContent="space-between" width="100%">
|
||||
<Text color="gray">{left}</Text>
|
||||
<Text color="gray">{right}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function Composer(
|
||||
{
|
||||
value,
|
||||
disabled,
|
||||
marginTop = 1,
|
||||
cursorYOffset = 0,
|
||||
refreshTick = 0,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
marginTop?: number;
|
||||
cursorYOffset?: number;
|
||||
refreshTick?: number;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void | Promise<void>;
|
||||
},
|
||||
) {
|
||||
const contentRef = React.useRef<DOMElement>(null);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={marginTop}>
|
||||
<Box borderStyle="single" borderColor={disabled ? 'gray' : 'cyan'} paddingX={1} flexDirection="column">
|
||||
<Box ref={contentRef}>
|
||||
<Text color="cyan">› </Text>
|
||||
<ImeTextInput
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
contentRef={contentRef}
|
||||
cursorYOffset={cursorYOffset}
|
||||
refreshTick={refreshTick}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function displayWidth(text: string): number {
|
||||
let width = 0;
|
||||
for (const char of Array.from(text)) {
|
||||
const code = char.codePointAt(0) || 0;
|
||||
if (code === 0) continue;
|
||||
if (code < 32 || (code >= 0x7f && code < 0xa0)) continue;
|
||||
width += isWideCodePoint(code) ? 2 : 1;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
function isWideCodePoint(code: number): boolean {
|
||||
return (
|
||||
code >= 0x1100 && (
|
||||
code <= 0x115f ||
|
||||
code === 0x2329 ||
|
||||
code === 0x232a ||
|
||||
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
|
||||
(code >= 0xac00 && code <= 0xd7a3) ||
|
||||
(code >= 0xf900 && code <= 0xfaff) ||
|
||||
(code >= 0xfe10 && code <= 0xfe19) ||
|
||||
(code >= 0xfe30 && code <= 0xfe6f) ||
|
||||
(code >= 0xff00 && code <= 0xff60) ||
|
||||
(code >= 0xffe0 && code <= 0xffe6) ||
|
||||
(code >= 0x1f300 && code <= 0x1f64f) ||
|
||||
(code >= 0x1f900 && code <= 0x1f9ff) ||
|
||||
(code >= 0x20000 && code <= 0x3fffd)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomePanel({ status }: { status: CliStatus }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="gray">Astrion</Text>
|
||||
<Box borderStyle="round" borderColor="gray" paddingX={2} paddingY={1} flexDirection="column">
|
||||
<Text bold>Welcome back!</Text>
|
||||
<Text color="gray">{status.model} · {status.runMode}</Text>
|
||||
<Text color="gray">{shortPath(status.directory)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function Timeline({ items, pulseTick = 0, width = 78 }: { items: TimelineItem[]; pulseTick?: number; width?: number }) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{items.map((item) => (
|
||||
<TimelineRow key={item.id} item={item} pulseTick={pulseTick} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineRow({ item, pulseTick }: { item: TimelineItem; pulseTick: number }) {
|
||||
const separator = useFullWidthSeparator();
|
||||
if (item.kind === 'user') {
|
||||
const lines = String(item.body || '').split(/\r?\n/);
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1} flexDirection="column">
|
||||
{lines.map((line, index) => (
|
||||
<Text key={`${item.id}-user-${index}`}>
|
||||
{index === 0 ? <Text color="cyan">› </Text> : <Text> </Text>}
|
||||
<Text>{line}</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'assistant') {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="gray">{separator}</Text>
|
||||
<Text> </Text>
|
||||
{wrapIndentedText(String(item.body || ''), 2, separator.length).map((line, index) => (
|
||||
<Text key={`${item.id}-assistant-${index}`}>{line}</Text>
|
||||
))}
|
||||
{item.status === 'success' ? <><Text> </Text><Text color="gray">{separator}</Text></> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'system') {
|
||||
return <Text color="gray">• {item.body}</Text>;
|
||||
}
|
||||
const running = item.status === 'running';
|
||||
const failed = item.status === 'failed';
|
||||
const color = running ? (pulseTick % 2 === 0 ? 'gray' : undefined) : failed ? 'red' : item.kind === 'thinking' ? undefined : 'green';
|
||||
const dot = running ? (pulseTick % 2 === 0 ? '◦' : '•') : '•';
|
||||
const detailColor = item.kind === 'thinking' || item.kind === 'tool' || item.kind === 'sub_agent' ? 'gray' : undefined;
|
||||
const detailWidth = separator.length;
|
||||
const showDetails = item.kind === 'thinking' ? SHOW_THINKING_DETAILS : true;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text><Text color={color}>{dot}</Text> {item.title || item.kind}</Text>
|
||||
{showDetails && !running && item.body ? wrapIndentedText(`└ ${item.body}`, 2, detailWidth).map((line, index) => (
|
||||
<Text key={`${item.id}-body-${index}`} color={detailColor}>{line}</Text>
|
||||
)) : null}
|
||||
{showDetails && (item.kind === 'thinking' || !running) && (item.lines || []).map((line, index) => (
|
||||
wrapIndentedText(line, index === 0 && !item.body ? 2 : 4, detailWidth).map((wrapped, wrappedIndex) => (
|
||||
<Text key={`${item.id}-${index}-${wrappedIndex}`} color={detailColor}>
|
||||
{index === 0 && !item.body && wrappedIndex === 0 ? wrapped.replace(/^ /, ' └ ') : wrapped}
|
||||
</Text>
|
||||
))
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function useFullWidthSeparator(): string {
|
||||
const { stdout } = useStdout();
|
||||
const width = Math.max(2, (stdout.columns || 80) - 2);
|
||||
return '─'.repeat(width);
|
||||
}
|
||||
|
||||
function wrapIndentedText(text: string, indent: number, width: number): string[] {
|
||||
const prefix = ' '.repeat(indent);
|
||||
const contentWidth = Math.max(10, width - indent);
|
||||
const result: string[] = [];
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine || '';
|
||||
if (displayWidth(line) <= contentWidth) {
|
||||
result.push(`${prefix}${line}`);
|
||||
continue;
|
||||
}
|
||||
let current = '';
|
||||
let currentWidth = 0;
|
||||
for (const char of Array.from(line)) {
|
||||
const charWidth = displayWidth(char);
|
||||
if (current && currentWidth + charWidth > contentWidth) {
|
||||
result.push(`${prefix}${current}`);
|
||||
current = char;
|
||||
currentWidth = charWidth;
|
||||
} else {
|
||||
current += char;
|
||||
currentWidth += charWidth;
|
||||
}
|
||||
}
|
||||
result.push(`${prefix}${current}`);
|
||||
}
|
||||
return result.length ? result : [prefix];
|
||||
}
|
||||
|
||||
export function SlashMenu({ query, commands, selectedIndex }: { query: string; commands: SlashCommand[]; selectedIndex: number }) {
|
||||
const visibleCount = 5;
|
||||
const leftIndent = ' ';
|
||||
const start = Math.max(0, selectedIndex - 2);
|
||||
const end = Math.min(commands.length, start + visibleCount);
|
||||
const adjustedStart = Math.max(0, end - visibleCount);
|
||||
const visibleCommands = commands.slice(adjustedStart, end);
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="column">
|
||||
{visibleCommands.map((command, visibleIndex) => {
|
||||
const index = adjustedStart + visibleIndex;
|
||||
return (
|
||||
<Text key={command.name} color={index === selectedIndex ? 'cyan' : undefined}>
|
||||
{leftIndent}{index === selectedIndex ? '› ' : ' '}{command.name.padEnd(13)} {command.description}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{!commands.length ? <Text color="gray">{leftIndent} 无匹配命令</Text> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function Picker({ title, items, selectedIndex }: { title: string; items: Array<{ label: string; description?: string; disabled?: boolean }>; selectedIndex: number }) {
|
||||
const visibleCount = 5;
|
||||
const start = Math.max(0, selectedIndex - 2);
|
||||
const end = Math.min(items.length, start + visibleCount);
|
||||
const adjustedStart = Math.max(0, end - visibleCount);
|
||||
const visibleItems = items.slice(adjustedStart, end);
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>{title}</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{visibleItems.map((item, visibleIndex) => {
|
||||
const index = adjustedStart + visibleIndex;
|
||||
return (
|
||||
<Text key={`${item.label}-${index}`} color={item.disabled ? 'gray' : index === selectedIndex ? 'cyan' : undefined}>
|
||||
{index === selectedIndex ? '› ' : ' '}{item.label.padEnd(20)} {item.description || ''}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{!items.length ? <Text color="gray"> 暂无选项</Text> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspacePrompt({ cwd, selectedIndex }: { cwd: string; selectedIndex: number }) {
|
||||
const options = ['确认,添加并继续', '拒绝,退出'];
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">当前目录尚未添加为工作区:</Text>
|
||||
<Text> {shortPath(cwd)}</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text>Agent 将在此目录中读取文件、执行命令和修改内容。</Text>
|
||||
<Text>是否将此目录添加为工作区?</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{options.map((label, index) => (
|
||||
<Text key={label} color={index === selectedIndex ? 'cyan' : undefined}>
|
||||
{index === selectedIndex ? '› ' : ' '}{index + 1}. {label}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPanel({ status }: { status: CliStatus }) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor="gray" paddingX={1} flexDirection="column" marginTop={1}>
|
||||
<Text>{'>_ Astrion CLI'}</Text>
|
||||
<Text> </Text>
|
||||
<StatusField label="Model" value={status.model} />
|
||||
<StatusField label="Directory" value={shortPath(status.directory)} />
|
||||
<StatusField label="Workspace" value={status.workspace.label} />
|
||||
<StatusField label="Permissions" value={status.permissionMode} />
|
||||
<StatusField label="Execution" value={status.executionMode} />
|
||||
<StatusField label="Session" value={status.sessionId} />
|
||||
<StatusField label="Context window" value={formatContext(status.contextUsed, status.contextLimit)} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusField({ label, value }: { label: string; value: string }) {
|
||||
return <Text> {label.padEnd(15)} {value}</Text>;
|
||||
}
|
||||
|
||||
export function HelpPanel({ commands }: { commands: SlashCommand[] }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>Commands</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{commands.map((command) => (
|
||||
<Text key={command.name}> {command.name.padEnd(13)} {command.description}</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillHint({ skill }: { skill?: SkillDefinition }) {
|
||||
if (!skill) return null;
|
||||
return <Text color="gray"> + 先阅读 {skill.label} skill</Text>;
|
||||
}
|
||||
|
||||
export function WorkspaceList({ workspaces, selectedIndex }: { workspaces: Workspace[]; selectedIndex: number }) {
|
||||
return (
|
||||
<Picker
|
||||
title="Workspace"
|
||||
selectedIndex={selectedIndex}
|
||||
items={workspaces.map((workspace) => ({ label: workspace.label, description: shortPath(workspace.path) }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
278
cli/src/eventMapper.ts
Normal file
278
cli/src/eventMapper.ts
Normal file
@ -0,0 +1,278 @@
|
||||
import type { TimelineItem } from './types.js';
|
||||
import { lastLines } from './format.js';
|
||||
|
||||
type ToolState = {
|
||||
timelineId: string;
|
||||
name: string;
|
||||
args: any;
|
||||
title: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type EventRenderState = {
|
||||
thinkingId?: string;
|
||||
thinkingStartedAt?: number;
|
||||
thinkingText: string;
|
||||
thinkingLines: string[];
|
||||
assistantId?: string;
|
||||
assistantText: string;
|
||||
tools: Map<string, ToolState>;
|
||||
};
|
||||
|
||||
export function createEventRenderState(): EventRenderState {
|
||||
return { thinkingText: '', thinkingLines: [], assistantText: '', tools: new Map() };
|
||||
}
|
||||
|
||||
export function reduceTaskEvent(items: TimelineItem[], state: EventRenderState, event: { type: string; data: any }): TimelineItem[] {
|
||||
const data = event.data || {};
|
||||
switch (event.type) {
|
||||
case 'thinking_start': {
|
||||
const timelineId = newId();
|
||||
state.thinkingId = timelineId;
|
||||
state.thinkingStartedAt = Date.now();
|
||||
state.thinkingText = '';
|
||||
state.thinkingLines = [];
|
||||
return [...items, { id: timelineId, kind: 'thinking', title: '思考中', status: 'running', lines: [] }];
|
||||
}
|
||||
case 'thinking_chunk': {
|
||||
const content = String(data.content || '');
|
||||
state.thinkingText += content;
|
||||
const lines = state.thinkingText.split(/\r?\n/).filter((line) => line.length > 0);
|
||||
state.thinkingLines = lines;
|
||||
if (!state.thinkingId) {
|
||||
state.thinkingId = newId();
|
||||
state.thinkingStartedAt = Date.now();
|
||||
state.thinkingText = content;
|
||||
items = [...items, { id: state.thinkingId, kind: 'thinking', title: '思考中', status: 'running', lines: [] }];
|
||||
}
|
||||
return patchItem(items, state.thinkingId, { lines: lastLines(state.thinkingLines, 5) });
|
||||
}
|
||||
case 'thinking_end': {
|
||||
if (!state.thinkingId) return items;
|
||||
const elapsed = state.thinkingStartedAt ? `${((Date.now() - state.thinkingStartedAt) / 1000).toFixed(1)}s` : '';
|
||||
const id = state.thinkingId;
|
||||
state.thinkingId = undefined;
|
||||
state.thinkingStartedAt = undefined;
|
||||
state.thinkingText = '';
|
||||
state.thinkingLines = [];
|
||||
return patchItem(items, id, {
|
||||
title: `思考完成 ${elapsed}`.trim(),
|
||||
status: 'success',
|
||||
body: undefined,
|
||||
summary: undefined,
|
||||
lines: [],
|
||||
});
|
||||
}
|
||||
case 'text_start': {
|
||||
const timelineId = newId();
|
||||
state.assistantId = timelineId;
|
||||
state.assistantText = '';
|
||||
return [...items, { id: timelineId, kind: 'assistant', status: 'running', body: '' }];
|
||||
}
|
||||
case 'text_chunk': {
|
||||
const content = String(data.content || '');
|
||||
if (!state.assistantId) {
|
||||
state.assistantId = newId();
|
||||
state.assistantText = '';
|
||||
items = [...items, { id: state.assistantId, kind: 'assistant', status: 'running', body: '' }];
|
||||
}
|
||||
state.assistantText += content;
|
||||
return patchItem(items, state.assistantId, { body: state.assistantText });
|
||||
}
|
||||
case 'text_end': {
|
||||
const full = String(data.full_content || state.assistantText || '');
|
||||
if (!state.assistantId) return full ? [...items, { id: newId(), kind: 'assistant', title: '完成', status: 'success', body: full }] : items;
|
||||
const id = state.assistantId;
|
||||
state.assistantId = undefined;
|
||||
state.assistantText = '';
|
||||
return patchItem(items, id, { status: 'success', body: full });
|
||||
}
|
||||
case 'tool_preparing':
|
||||
case 'tool_start': {
|
||||
const toolId = String(data.id || data.execution_id || data.preparing_id || newId());
|
||||
const name = String(data.name || 'tool');
|
||||
const args = data.arguments || {};
|
||||
const title = toolTitle(name, args, 'running');
|
||||
const summary = toolSummaryPreview(data, name, args, 'running');
|
||||
const existingKey = data.preparing_id ? String(data.preparing_id) : toolId;
|
||||
const existing = state.tools.get(existingKey);
|
||||
if (existing) {
|
||||
state.tools.delete(existingKey);
|
||||
state.tools.set(toolId, { ...existing, name, args, title, summary });
|
||||
return patchItem(items, existing.timelineId, {
|
||||
title,
|
||||
summary,
|
||||
status: 'running',
|
||||
body: toolInitialBody(name, args),
|
||||
lines: undefined,
|
||||
});
|
||||
}
|
||||
const timelineId = newId();
|
||||
state.tools.set(toolId, { timelineId, name, args, title, summary });
|
||||
return [...items, { id: timelineId, kind: 'tool', title, summary, status: 'running', body: toolInitialBody(name, args) }];
|
||||
}
|
||||
case 'tool_update_action':
|
||||
case 'update_action': {
|
||||
const toolId = String(data.id || data.execution_id || data.preparing_id || '');
|
||||
const tool = state.tools.get(toolId);
|
||||
if (!tool) return items;
|
||||
if (data.result !== undefined || data.status) {
|
||||
const status = String(data.status || '').toLowerCase() === 'completed' ? 'success' : String(data.status || '').toLowerCase() === 'failed' ? 'failed' : 'running';
|
||||
return patchItem(items, tool.timelineId, {
|
||||
title: toolTitle(tool.name, tool.args, status),
|
||||
summary: toolSummaryPreview(data, tool.name, tool.args, status),
|
||||
status,
|
||||
body: toolResultBody(tool.name, tool.args, data.result),
|
||||
lines: toolResultLines(tool.name, tool.args, data.result),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
case 'append_payload':
|
||||
case 'modify_payload': {
|
||||
return [...items, renderPayloadEvent(data)];
|
||||
}
|
||||
case 'tool_approval_required': {
|
||||
const approval = data.approval || data;
|
||||
return [...items, { id: newId(), kind: 'tool', title: `需要审批:${approval.tool_name || '工具调用'}`, status: 'running', body: approval.preview?.summary || '' }];
|
||||
}
|
||||
case 'tool_approval_resolved': {
|
||||
return [...items, { id: newId(), kind: 'tool', title: `审批${data.decision === 'approved' ? '通过' : '拒绝'}`, status: data.decision === 'approved' ? 'success' : 'failed', body: data.reason || '' }];
|
||||
}
|
||||
case 'task_complete': {
|
||||
return items;
|
||||
}
|
||||
case 'task_stopped': {
|
||||
return [...items, { id: newId(), kind: 'tool', title: '任务已停止', status: 'cancelled', body: data.message || '' }];
|
||||
}
|
||||
case 'error': {
|
||||
return [...items, { id: newId(), kind: 'tool', title: '任务失败', status: 'failed', body: data.message || data.error || '未知错误' }];
|
||||
}
|
||||
case 'token_update': {
|
||||
return items;
|
||||
}
|
||||
default:
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
function toolTitle(name: string, args: any, status: string): string {
|
||||
if (name === 'run_command') return `运行 ${args.command || ''}`.trim();
|
||||
if (name === 'write_file') return `Write ${args.file_path || args.path || ''} ${countWrite(args.content || '')}`.trim();
|
||||
if (name === 'edit_file') return `Edited ${args.file_path || args.path || ''} ${countEdit(args)}`.trim();
|
||||
if (name === 'read_file' || name === 'read_skill') return `阅读 ${args.path || args.file_path || ''}`.trim();
|
||||
if (name === 'terminal_session') return `${status === 'running' ? '创建' : '终端'} ${args.session_name || args.name || ''}`.trim();
|
||||
if (name === 'terminal_input') return `终端输入 ${args.command || args.input || ''}`.trim();
|
||||
return name;
|
||||
}
|
||||
|
||||
function toolSummaryPreview(data: any, name: string, args: any, status: string): string {
|
||||
const intent = data?.intent_rendered || data?.intent_full || data?.intent || args?.intent_rendered || args?.intent_full || args?.intent || '';
|
||||
if (intent) return truncateSummaryPreview(firstLine(String(intent)));
|
||||
if (status === 'success') return '工具执行完成';
|
||||
if (status === 'failed') return `${name || '工具'} 执行失败`;
|
||||
if (data?.status === 'preparing') return `准备调用 ${name || '工具'}...`;
|
||||
return `正在调用 ${name || '工具'}...`;
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
return text.split(/\r?\n/)[0] || '';
|
||||
}
|
||||
|
||||
function truncateSummaryPreview(text: string): string {
|
||||
const value = String(text || '');
|
||||
return value.length > 25 ? `${value.slice(0, 25)}...` : value;
|
||||
}
|
||||
|
||||
function toolInitialBody(name: string, args: any): string {
|
||||
if (name === 'write_file' || name === 'edit_file') return '';
|
||||
return '';
|
||||
}
|
||||
|
||||
function toolResultBody(name: string, args: any, result: any): string {
|
||||
if (typeof result === 'string') return summarizeText(result);
|
||||
if (!result) return '';
|
||||
if (name === 'run_command' || name === 'terminal_input') {
|
||||
const exitCode = result.exit_code ?? result.returncode;
|
||||
const text = String(result?.output || result?.stdout || result?.stderr || '').trim();
|
||||
if (exitCode !== undefined && exitCode !== 0) return text ? `exit code ${exitCode}` : `exit code ${exitCode}\n(no output)`;
|
||||
return text ? '' : '(no output)';
|
||||
}
|
||||
if (name === 'read_file' || name === 'read_skill') {
|
||||
const content = result.content || result.text || '';
|
||||
const count = String(content).split(/\r?\n/).filter(Boolean).length;
|
||||
const type = result.type || 'read';
|
||||
if (type === 'extract') return `提取了 ${count} 行`;
|
||||
return `读取了 ${count} 行`;
|
||||
}
|
||||
if (name === 'write_file') return '';
|
||||
if (name === 'edit_file') return '';
|
||||
if (result.error) return String(result.error);
|
||||
return result.success === false ? '失败' : '成功';
|
||||
}
|
||||
|
||||
function toolResultLines(name: string, args: any, result: any): string[] | undefined {
|
||||
if (name === 'write_file') {
|
||||
return String(args.content || '').split('\n').map((line, idx) => `${idx + 1} +${line}`);
|
||||
}
|
||||
if (name === 'edit_file') {
|
||||
return editLines(args);
|
||||
}
|
||||
const text = typeof result === 'string' ? result : String(result?.output || result?.stdout || result?.content || result?.text || '');
|
||||
if (!text || name === 'read_file' || name === 'read_skill') return undefined;
|
||||
return summarizeLines(text);
|
||||
}
|
||||
|
||||
function renderPayloadEvent(data: any): TimelineItem {
|
||||
const path = data.path || data.file_path || '';
|
||||
const lines: string[] = [];
|
||||
if (Array.isArray(data.old_lines)) lines.push(...data.old_lines.map((line: string, idx: number) => `${idx + 1} -${line}`));
|
||||
if (Array.isArray(data.new_lines)) lines.push(...data.new_lines.map((line: string, idx: number) => `${idx + 1} +${line}`));
|
||||
return { id: newId(), kind: 'tool', title: `Edited ${path}`, status: 'success', lines };
|
||||
}
|
||||
|
||||
function countWrite(content: string): string {
|
||||
const added = content ? content.split('\n').length : 0;
|
||||
return `(+${added} -0)`;
|
||||
}
|
||||
|
||||
function countEdit(args: any): string {
|
||||
const lines = editLines(args);
|
||||
const removed = lines.filter((line) => line.includes(' -')).length;
|
||||
const added = lines.filter((line) => line.includes(' +')).length;
|
||||
return `(+${added} -${removed})`;
|
||||
}
|
||||
|
||||
function editLines(args: any): string[] {
|
||||
const replacements = Array.isArray(args.replacements) ? args.replacements : [];
|
||||
const result: string[] = [];
|
||||
const emitPair = (oldText: string, newText: string) => {
|
||||
const oldLines = oldText.replace(/\\n/g, '\n').split('\n');
|
||||
const newLines = newText.replace(/\\n/g, '\n').split('\n');
|
||||
oldLines.filter((line) => line.length > 0).forEach((line, idx) => result.push(`${idx + 1} -${line}`));
|
||||
newLines.filter((line) => line.length > 0).forEach((line, idx) => result.push(`${idx + 1} +${line}`));
|
||||
};
|
||||
if (replacements.length) {
|
||||
for (const rep of replacements) emitPair(String(rep.old_string || ''), String(rep.new_string || ''));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function summarizeText(text: string): string {
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
return summarizeLines(lines.join('\n')).join('\n');
|
||||
}
|
||||
|
||||
function summarizeLines(text: string): string[] {
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
|
||||
if (lines.length <= 5) return lines;
|
||||
return [lines[0]!, lines[1]!, `… +${lines.length - 4} lines`, lines[lines.length - 2]!, lines[lines.length - 1]!];
|
||||
}
|
||||
|
||||
function patchItem(items: TimelineItem[], id: string, patch: Partial<TimelineItem>): TimelineItem[] {
|
||||
return items.map((item) => item.id === id ? { ...item, ...patch } : item);
|
||||
}
|
||||
|
||||
function newId(): string {
|
||||
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
29
cli/src/format.ts
Normal file
29
cli/src/format.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
export function shortPath(path: string): string {
|
||||
const home = homedir();
|
||||
return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
||||
}
|
||||
|
||||
export function formatContext(used: number, limit: number): string {
|
||||
if (!limit || limit <= 0) return formatTokens(used);
|
||||
const left = Math.max(0, limit - used);
|
||||
const pct = limit > 0 ? Math.round((left / limit) * 100) : 0;
|
||||
return `${pct}% left ${formatTokens(used)} / ${formatTokens(limit)}`;
|
||||
}
|
||||
|
||||
export function formatTokens(value: number): string {
|
||||
if (value >= 1000) {
|
||||
const rounded = Math.round((value / 1000) * 10) / 10;
|
||||
return `${rounded}K`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function nowSessionId(): string {
|
||||
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function lastLines(lines: string[], limit = 5): string[] {
|
||||
return lines.slice(Math.max(0, lines.length - limit));
|
||||
}
|
||||
7
cli/src/main.tsx
Normal file
7
cli/src/main.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import App from './App.js';
|
||||
|
||||
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
||||
render(<App />);
|
||||
65
cli/src/types.ts
Normal file
65
cli/src/types.ts
Normal file
@ -0,0 +1,65 @@
|
||||
export type PermissionMode = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
|
||||
export type ExecutionMode = 'sandbox' | 'direct';
|
||||
export type RunMode = 'fast' | 'thinking' | 'deep';
|
||||
|
||||
export type Workspace = {
|
||||
workspace_id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type WorkspaceCatalog = {
|
||||
default_workspace_id: string;
|
||||
workspaces: Workspace[];
|
||||
};
|
||||
|
||||
export type CliStatus = {
|
||||
model: string;
|
||||
runMode: RunMode;
|
||||
directory: string;
|
||||
workspace: Workspace;
|
||||
permissionMode: PermissionMode;
|
||||
executionMode: ExecutionMode;
|
||||
sessionId: string;
|
||||
contextUsed: number;
|
||||
contextLimit: number;
|
||||
versioningEnabled: boolean;
|
||||
backgroundAgents: number;
|
||||
backgroundCommands: number;
|
||||
activeSkill?: SkillDefinition;
|
||||
};
|
||||
|
||||
export type SkillDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ModelDefinition = {
|
||||
model_key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
supports_thinking?: boolean;
|
||||
fast_only?: boolean;
|
||||
deep_only?: boolean;
|
||||
context_window?: number;
|
||||
};
|
||||
|
||||
export type TimelineItem = {
|
||||
id: string;
|
||||
kind: 'system' | 'user' | 'assistant' | 'tool' | 'thinking' | 'sub_agent';
|
||||
title?: string;
|
||||
summary?: string;
|
||||
body?: string;
|
||||
status?: 'running' | 'success' | 'failed' | 'cancelled';
|
||||
lines?: string[];
|
||||
};
|
||||
|
||||
export type SlashCommandKind = 'immediate' | 'picker' | 'panel' | 'form';
|
||||
|
||||
export type SlashCommand = {
|
||||
name: string;
|
||||
description: string;
|
||||
aliases?: string[];
|
||||
kind: SlashCommandKind;
|
||||
};
|
||||
97
cli/src/workspaces.ts
Normal file
97
cli/src/workspaces.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve, basename } from 'node:path';
|
||||
import type { Workspace, WorkspaceCatalog } from './types.js';
|
||||
|
||||
export const repoRoot = process.env.AGENTS_REPO_ROOT || resolve(new URL('../..', import.meta.url).pathname);
|
||||
const configPath = resolve(repoRoot, 'config/host_workspaces.json');
|
||||
|
||||
function defaultCatalog(): WorkspaceCatalog {
|
||||
const fallbackPath = resolve(repoRoot, 'project');
|
||||
return {
|
||||
default_workspace_id: 'default',
|
||||
workspaces: [
|
||||
{
|
||||
workspace_id: 'default',
|
||||
label: '默认工作区',
|
||||
path: fallbackPath,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return resolve(path);
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^[-._]+|[-._]+$/g, '');
|
||||
return slug || 'workspace';
|
||||
}
|
||||
|
||||
export function getHostWorkspacesConfigPath(): string {
|
||||
return configPath;
|
||||
}
|
||||
|
||||
export function loadWorkspaceCatalog(): WorkspaceCatalog {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(configPath, 'utf8')) as Partial<WorkspaceCatalog>;
|
||||
const workspaces = Array.isArray(raw.workspaces) ? raw.workspaces : [];
|
||||
const normalized = workspaces
|
||||
.filter((item): item is Workspace => Boolean(item && item.workspace_id && item.path))
|
||||
.map((item) => ({
|
||||
workspace_id: String(item.workspace_id),
|
||||
label: String(item.label || item.workspace_id),
|
||||
path: normalizePath(String(item.path)),
|
||||
}));
|
||||
if (!normalized.length) return defaultCatalog();
|
||||
const defaultId = raw.default_workspace_id && normalized.some((ws) => ws.workspace_id === raw.default_workspace_id)
|
||||
? String(raw.default_workspace_id)
|
||||
: normalized[0]!.workspace_id;
|
||||
return { default_workspace_id: defaultId, workspaces: normalized };
|
||||
} catch {
|
||||
return defaultCatalog();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWorkspaceCatalog(catalog: WorkspaceCatalog): void {
|
||||
mkdirSync(dirname(configPath), { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify(catalog, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export function findWorkspaceByPath(catalog: WorkspaceCatalog, cwd: string): Workspace | undefined {
|
||||
const target = normalizePath(cwd);
|
||||
return catalog.workspaces.find((ws) => normalizePath(ws.path) === target);
|
||||
}
|
||||
|
||||
export function createWorkspaceForPath(catalog: WorkspaceCatalog, cwd: string): { catalog: WorkspaceCatalog; workspace: Workspace; created: boolean } {
|
||||
const path = normalizePath(cwd);
|
||||
const existing = findWorkspaceByPath(catalog, path);
|
||||
if (existing) return { catalog, workspace: existing, created: false };
|
||||
|
||||
const baseLabel = basename(path) || 'workspace';
|
||||
const baseId = slugify(baseLabel);
|
||||
const used = new Set(catalog.workspaces.map((ws) => ws.workspace_id));
|
||||
let workspaceId = baseId;
|
||||
let suffix = 2;
|
||||
while (used.has(workspaceId)) {
|
||||
workspaceId = `${baseId}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
const workspace: Workspace = {
|
||||
workspace_id: workspaceId,
|
||||
label: baseLabel,
|
||||
path,
|
||||
};
|
||||
const nextCatalog: WorkspaceCatalog = {
|
||||
default_workspace_id: catalog.default_workspace_id || workspaceId,
|
||||
workspaces: [...catalog.workspaces, workspace],
|
||||
};
|
||||
saveWorkspaceCatalog(nextCatalog);
|
||||
return { catalog: nextCatalog, workspace, created: true };
|
||||
}
|
||||
14
cli/tsconfig.json
Normal file
14
cli/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node", "react"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@ -1,353 +0,0 @@
|
||||
# 对话已被第1次压缩
|
||||
|
||||
## 工作总结
|
||||
## 📋 工作总结报告
|
||||
|
||||
### 1. 任务目标与用户真实诉求
|
||||
|
||||
用户要求修复手机端前端的三个核心问题:
|
||||
- **问题1**:权限切换菜单在移动端跑出屏幕右侧,需自适应位置
|
||||
- **问题2**:设置菜单中"用量统计"点击无法展开面板(移动端限制导致)
|
||||
- **问题3**:审批面板在移动端自动关闭,需保持手动控制,且配色需与电脑端一致
|
||||
|
||||
额外需求:移除审批面板顶部灰色块,让内容顶头显示。
|
||||
|
||||
---
|
||||
|
||||
### 2. 已完成工作(按时间顺序)
|
||||
|
||||
| 时间 | 工作内容 | 状态 |
|
||||
|------|---------|------|
|
||||
| 初始 | 查看 Git 状态,分析未提交修改 | ✅ |
|
||||
| 阶段1 | 分批提交 run_python 功能 | ✅ |
|
||||
| 阶段2 | 修复权限菜单自适应位置(添加媒体查询) | ✅ |
|
||||
| 阶段3 | 修复用量统计点击问题(添加 `fromSettingsMenu` 参数穿透) | ✅ |
|
||||
| 阶段4 | 修复审批面板自动关闭问题(移除自动关闭逻辑) | ✅ |
|
||||
| 阶段5 | 禁用电脑端审批面板拖拽展开 | ✅ |
|
||||
| 阶段6 | 恢复审批面板点击背景关闭功能 | ✅ |
|
||||
| 阶段7 | 尝试修复审批面板顶部灰色块(进行中) | ⚠️ |
|
||||
|
||||
---
|
||||
|
||||
### 3. 关键决策与原因
|
||||
|
||||
**决策1:用量统计参数穿透**
|
||||
- 原因:移动端禁用用量面板展开,但设置菜单应例外
|
||||
- 方案:通过 `fromSettingsMenu` 参数绕过移动端限制
|
||||
- 实现:修改事件链路上的三个文件(QuickMenu.vue → InputComposer.vue → App.vue)
|
||||
|
||||
**决策2:审批面板不再自动关闭**
|
||||
- 原因:手机端与电脑端行为应一致,允许手动打开空面板
|
||||
- 方案:移除 `fetchPendingToolApprovals` 和 `taskPolling` 中的自动关闭逻辑
|
||||
|
||||
**决策3:审批面板配色统一**
|
||||
- 原因:手机端与电脑端视觉不一致
|
||||
- 方案:添加 `@media (max-width: 768px)` 样式覆盖
|
||||
|
||||
---
|
||||
|
||||
### 4. 修改过的文件与核心变更点
|
||||
|
||||
| 文件 | 核心变更 |
|
||||
|------|---------|
|
||||
| `static/src/styles/components/input/_composer.scss` | 权限菜单自适应、审批面板按钮样式、头部背景透明 |
|
||||
| `static/src/app/methods/ui.ts` | `handleTokenPanelToggleClick` 添加参数、`fetchPendingToolApprovals` 移除自动关闭、`handleApprovalPanelToggleClick` 移动端逻辑 |
|
||||
| `static/src/components/input/QuickMenu.vue` | 用量统计按钮事件添加 `true` 参数 |
|
||||
| `static/src/components/input/InputComposer.vue` | 事件转发添加参数穿透 `(val) => $emit(..., val)` |
|
||||
| `static/src/App.vue` | 审批面板遮罩层添加 `@click.self`、事件绑定添加参数 |
|
||||
| `static/src/stores/resource.ts` | `toggleTokenPanel` 添加调试日志 |
|
||||
| `static/src/composables/usePanelResize.ts` | 禁用右侧面板折叠时拖拽展开 |
|
||||
| `static/src/app/methods/taskPolling.ts` | 移除审批完成后自动关闭面板逻辑 |
|
||||
| `static/src/components/panels/ToolApprovalPanel.vue` | **进行中**:添加 `mobile-header` class 和样式 |
|
||||
|
||||
---
|
||||
|
||||
### 5. 工具调用中的重要结果/错误与修复
|
||||
|
||||
**重要修复1:事件参数丢失**
|
||||
- 错误:`fromSettingsMenu: false` 始终为 false
|
||||
- 原因:Vue 事件转发未传递 `$event` 参数
|
||||
- 修复:改为 `@toggle-token-panel="(val) => $emit('toggle-token-panel', val)"`
|
||||
|
||||
**重要修复2:审批面板瞬间关闭**
|
||||
- 原因:`fetchPendingToolApprovals` 在空列表时自动关闭面板
|
||||
- 修复:移除 `if (items.length === 0) closeMobileOverlay()` 逻辑
|
||||
|
||||
---
|
||||
|
||||
### 6. 当前未完成事项与下一步计划
|
||||
|
||||
**未完成事项:审批面板顶部灰色块**
|
||||
|
||||
已尝试方案:
|
||||
1. ✅ 在 `_composer.scss` 中添加 `.mobile-panel-sheet--approval .sidebar-header` 样式
|
||||
2. ✅ 使用 `!important` 提升优先级
|
||||
3. ✅ 在 `ToolApprovalPanel.vue` 添加 `mobile-header` class 和条件渲染
|
||||
|
||||
**下一步计划:**
|
||||
方案A:直接在 `ToolApprovalPanel.vue` 组件底部添加 `<style scoped>` 覆盖头部样式
|
||||
```vue
|
||||
<style scoped>
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-header.mobile-header {
|
||||
background: transparent !important;
|
||||
padding-top: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
方案B:检查父组件是否有更高优先级的样式覆盖
|
||||
|
||||
**当前构建状态**:✅ 已构建完成(v2.97s),可立即测试
|
||||
|
||||
---
|
||||
|
||||
### 7. 风险与注意事项
|
||||
|
||||
1. **样式优先级**:移动端样式可能被其他高优先级规则覆盖,需持续检查
|
||||
2. **事件链**:用量统计功能依赖多层事件转发,后续修改需注意参数传递
|
||||
3. **调试日志**:当前代码包含 `[UI_DEBUG]` 日志,生产环境可清理
|
||||
4. **浏览器缓存**:无痕模式仍有缓存,建议硬刷新(Ctrl+F5)测试
|
||||
|
||||
---
|
||||
|
||||
### 待执行操作
|
||||
|
||||
如需继续修复灰色块问题,请执行:
|
||||
1. 确认 `ToolApprovalPanel.vue` 的 scoped style 是否正确添加
|
||||
2. 重新构建:`npm run build`
|
||||
3. 硬刷新浏览器测试
|
||||
|
||||
当前代码已保存,可直接继续修改。
|
||||
|
||||
## 用户的所有输入(仅文字)
|
||||
1. 查看当前项目还有哪些没提交的修改
|
||||
2. 看一下具体内容,看看能分成几次commit
|
||||
3. 没问题,提交并push
|
||||
4. 查看之前的提交记录,格式是 英文摘要+中文说明
|
||||
5. 再做几个修改 手机版前端有一些问题 首先是权限切换的弹出选项菜单会有部分内容跑到屏幕右侧外,应该改为自适应左右位置,保证菜单完整显示
|
||||
还有一个问题手机端之前的一次修改中,让上下文快捷查看的圆圈点击不会打开用量菜单,这个修改导致手机端点击设置菜单里的用量统计也不显示菜单里
|
||||
最后一个问题,手机版的审批面板由于按钮位置和关闭面板的点击区域重合了,手机端的浏览器点击滞留效果总导致手机端一点击打开审批面板就自动关上了,要改为手机端的面板只能点击叉号关闭,手机端的审批面板的设计配色和电脑端也不太一样,改为一样的
|
||||
修完不要提交,我要看看效果
|
||||
6. build一下
|
||||
7. 再修改一个地方,审批菜单的侧边栏不能通过拖拽屏幕边缘展开
|
||||
8. 不是手机端的,是电脑端的
|
||||
9. 手机版点击审批面板瞬间关闭和设置里的用量统计点击不会展开弹窗的bug依然没有修复,请你加入控制台调试日志,我复现一遍给你日志
|
||||
10. 不要用debug标识,有太多的用这个了
|
||||
11. 我复现了,点了三次审批面板,两次用量统计
|
||||
[UI_DEBUG] closeMobileOverlay called from: unknown activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] handleApprovalPanelToggleClick called, isMobileViewport: true currentConversationId: conv_20260412_124008_293 activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] handleApprovalPanelToggleClick: mobile path, fetching approvals and opening overlay
|
||||
main.js:214 [UI_DEBUG] openMobileOverlay called, target: approval isMobileViewport: true activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] openMobileOverlay: opening target: approval
|
||||
main.js:214 [UI_DEBUG] closeMobileOverlay called from: unknown activeMobileOverlay: approval
|
||||
main.js:214 [UI_DEBUG] handleApprovalPanelToggleClick called, isMobileViewport: true currentConversationId: conv_20260412_124008_293 activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] handleApprovalPanelToggleClick: mobile path, fetching approvals and opening overlay
|
||||
main.js:214 [UI_DEBUG] openMobileOverlay called, target: approval isMobileViewport: true activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] openMobileOverlay: opening target: approval
|
||||
main.js:214 [UI_DEBUG] closeMobileOverlay called from: unknown activeMobileOverlay: approval
|
||||
main.js:214 [UI_DEBUG] handleApprovalPanelToggleClick called, isMobileViewport: true currentConversationId: conv_20260412_124008_293 activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] handleApprovalPanelToggleClick: mobile path, fetching approvals and opening overlay
|
||||
main.js:214 [UI_DEBUG] openMobileOverlay called, target: approval isMobileViewport: true activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] openMobileOverlay: opening target: approval
|
||||
main.js:214 [UI_DEBUG] closeMobileOverlay called from: unknown activeMobileOverlay: approval
|
||||
main.js:213 [UI_DEBUG] QuickMenu 用量统计按钮 clicked, emitting toggle-token-panel with true
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick called, fromSettingsMenu: false isMobileViewport: true tokenPanelCollapsed: true currentConversationId: conv_20260412_124008_293
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick: blocked - mobile viewport restriction
|
||||
main.js:213 [UI_DEBUG] QuickMenu 用量统计按钮 clicked, emitting toggle-token-panel with true
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick called, fromSettingsMenu: false isMobileViewport: true tokenPanelCollapsed: true currentConversationId: conv_20260412_124008_293
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick: blocked - mobile viewport restriction
|
||||
main.js:214 [UI_DEBUG] closeMobileOverlay called from: unknown activeMobileOverlay: null
|
||||
12. 你看看吧
|
||||
main.js:214 [UI_DEBUG] closeMobileOverlay called from: unknown activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] handleApprovalPanelToggleClick called, isMobileViewport: true currentConversationId: conv_20260412_124450_725 activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] handleApprovalPanelToggleClick: mobile path, fetching approvals and opening overlay
|
||||
main.js:214 [UI_DEBUG] openMobileOverlay called, target: approval isMobileViewport: true activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] openMobileOverlay: opening target: approval
|
||||
main.js:214 [UI_DEBUG] fetchPendingToolApprovals: closing approval panel because items.length is 0
|
||||
main.js:214 [UI_DEBUG] closeMobileOverlay called from: fetch-empty activeMobileOverlay: approval
|
||||
main.js:214 [UI_DEBUG] handleApprovalPanelToggleClick called, isMobileViewport: true currentConversationId: conv_20260412_124450_725 activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] handleApprovalPanelToggleClick: mobile path, fetching approvals and opening overlay
|
||||
main.js:214 [UI_DEBUG] openMobileOverlay called, target: approval isMobileViewport: true activeMobileOverlay: null
|
||||
main.js:214 [UI_DEBUG] openMobileOverlay: opening target: approval
|
||||
main.js:214 [UI_DEBUG] fetchPendingToolApprovals: closing approval panel because items.length is 0
|
||||
main.js:214 [UI_DEBUG] closeMobileOverlay called from: fetch-empty activeMobileOverlay: approval
|
||||
main.js:213 [UI_DEBUG] QuickMenu 用量统计按钮 clicked, emitting toggle-token-panel with true
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick called, fromSettingsMenu: false isMobileViewport: true tokenPanelCollapsed: true currentConversationId: conv_20260412_124450_725
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick: blocked - mobile viewport restriction
|
||||
main.js:213 [UI_DEBUG] QuickMenu 用量统计按钮 clicked, emitting toggle-token-panel with true
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick called, fromSettingsMenu: false isMobileViewport: true tokenPanelCollapsed: true currentConversationId: conv_20260412_124450_725
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick: blocked - mobile viewport restriction
|
||||
main.js:214 [UI_DEBUG] closeMobileOverlay called from: unknown activeMobileOverlay: null
|
||||
13. 还是不行
|
||||
main.js:213 [UI_DEBUG] QuickMenu 用量统计按钮 clicked, emitting toggle-token-panel with true
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick called, fromSettingsMenu: false isMobileViewport: true tokenPanelCollapsed: true currentConversationId: conv_20260412_124450_725
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick: blocked - mobile viewport restriction
|
||||
main.js:213 [UI_DEBUG] QuickMenu 用量统计按钮 clicked, emitting toggle-token-panel with true
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick called, fromSettingsMenu: false isMobileViewport: true tokenPanelCollapsed: true currentConversationId: conv_20260412_124450_725
|
||||
main.js:214 [UI_DEBUG] handleTokenPanelToggleClick: blocked - mobile viewport restriction
|
||||
main.js:214 [UI_DEBUG] closeMobileOverlay called from: unknown activeMobileOverlay: null
|
||||
14. 好的,可以正常打开了,现在把手机端审批菜单的也修复一下吧,你看看电脑端的代码,电脑端是可以在没有待审批时手动打开的,看看是不是这里的逻辑不一样
|
||||
15. 现在没问题了,把样式改一下,不要最上面这个空白的灰色的块,内容直接顶头,把点击空白区域关闭面板也加回来
|
||||
16. 点击外部关闭好了,但顶部依然有灰色块
|
||||
17. 依然有,我用的无痕模式都有,和之前一模一样
|
||||
|
||||
## 最近5条工具调用(参数 + 结果)
|
||||
### 1. edit_file
|
||||
- tool_call_id: functions.edit_file:166
|
||||
- 参数:
|
||||
```json
|
||||
{
|
||||
"file_path": "static/src/components/panels/ToolApprovalPanel.vue",
|
||||
"intent": "用条件class处理移动端样式",
|
||||
"old_string": " <div class=\"sidebar-header\" style=\"@media (max-width: 768px) { background: transparent !important; padding-top: 44px; }\">\n <h3 class=\"icon-label\">工具审批 ({{ approvals.length }})</h3>\n <button type=\"button\" class=\"approval-close-btn\" aria-label=\"关闭审批面板\" @click=\"handleCloseClick\">\n ×\n </button>\n </div>",
|
||||
"new_string": " <div class=\"sidebar-header\" :class=\"{ 'mobile-header': isMobileViewport }\">\n <h3 class=\"icon-label\">工具审批 ({{ approvals.length }})</h3>\n <button type=\"button\" class=\"approval-close-btn\" aria-label=\"关闭审批面板\" @click=\"handleCloseClick\">\n ×\n </button>\n </div>"
|
||||
}
|
||||
```
|
||||
- 结果:
|
||||
```text
|
||||
替换 1 处: static/src/components/panels/ToolApprovalPanel.vue
|
||||
```
|
||||
|
||||
### 2. read_file
|
||||
- tool_call_id: functions.read_file:167
|
||||
- 参数:
|
||||
```json
|
||||
{
|
||||
"path": "static/src/components/panels/ToolApprovalPanel.vue",
|
||||
"type": "extract",
|
||||
"intent": "查看script部分",
|
||||
"segments": [
|
||||
{
|
||||
"start_line": 115,
|
||||
"end_line": 145
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- 结果:
|
||||
```text
|
||||
从 static/src/components/panels/ToolApprovalPanel.vue 抽取 1 个片段 (max_chars=50000)
|
||||
[segment_1] 行 115~145
|
||||
```
|
||||
collapsed: boolean;
|
||||
width: number;
|
||||
approvals: Array<any>;
|
||||
decidingApprovalIds?: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'approve', approvalId: string): void;
|
||||
(event: 'reject', approvalId: string): void;
|
||||
(event: 'switch-unrestricted', approvalId: string): void;
|
||||
(event: 'close'): void;
|
||||
}>();
|
||||
|
||||
const handleCloseClick = () => {
|
||||
console.log('[UI_DEBUG] ToolApprovalPanel close button clicked');
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const isEditPreview = (item: any) => item?.tool_name === 'edit_file' && item?.preview?.edit_context;
|
||||
const isCommandPreview = (item: any) =>
|
||||
['run_command', 'terminal_input', 'run_python', 'runpython'].includes(item?.tool_name);
|
||||
const isWritePreview = (item: any) => item?.tool_name === 'write_file';
|
||||
const isRenamePreview = (item: any) => item?.tool_name === 'rename_file';
|
||||
const isPathOnlyPreview = (item: any) =>
|
||||
['create_file', 'create_folder', 'delete_file'].includes(item?.tool_name);
|
||||
|
||||
const isDeciding = (approvalId: string) =>
|
||||
Array.isArray(props.decidingApprovalIds) && props.decidingApprovalIds.includes(approvalId);
|
||||
|
||||
const resolvePath = (item: any) => {
|
||||
return item?.preview?.path || item?.preview?.file_path || item?.arguments?.path || item?.arguments?.file_path || '';
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
### 3. edit_file
|
||||
- tool_call_id: functions.edit_file:168
|
||||
- 参数:
|
||||
```json
|
||||
{
|
||||
"file_path": "static/src/components/panels/ToolApprovalPanel.vue",
|
||||
"intent": "添加移动端判断",
|
||||
"old_string": "const handleCloseClick = () => {\n console.log('[UI_DEBUG] ToolApprovalPanel close button clicked');\n emit('close');\n};\n\nconst isEditPreview = (item: any) => item?.tool_name === 'edit_file' && item?.preview?.edit_context;",
|
||||
"new_string": "const handleCloseClick = () => {\n console.log('[UI_DEBUG] ToolApprovalPanel close button clicked');\n emit('close');\n};\n\n// 检测是否为移动端视口\nconst isMobileViewport = typeof window !== 'undefined' && window.innerWidth <= 768;\n\nconst isEditPreview = (item: any) => item?.tool_name === 'edit_file' && item?.preview?.edit_context;"
|
||||
}
|
||||
```
|
||||
- 结果:
|
||||
```text
|
||||
替换 1 处: static/src/components/panels/ToolApprovalPanel.vue
|
||||
```
|
||||
|
||||
### 4. read_file
|
||||
- tool_call_id: functions.read_file:169
|
||||
- 参数:
|
||||
```json
|
||||
{
|
||||
"path": "static/src/components/panels/ToolApprovalPanel.vue",
|
||||
"type": "search",
|
||||
"intent": "查找文件末尾",
|
||||
"query": "getEditNewLines",
|
||||
"max_matches": 5
|
||||
}
|
||||
```
|
||||
- 结果:
|
||||
```text
|
||||
在 static/src/components/panels/ToolApprovalPanel.vue 中搜索 "getEditNewLines",返回 2/2 条结果(不区分大小写) (max_chars=50000)
|
||||
[match_1] 行 31~33 命中行: 32
|
||||
```
|
||||
class="diff-line diff-add"
|
||||
v-for="(line, idx) in getEditNewLines(item)"
|
||||
:key="`n-${item.approval_id}-${idx}`"
|
||||
|
||||
```
|
||||
[match_2] 行 177~179 命中行: 178
|
||||
```
|
||||
|
||||
const getEditNewLines = (item: any): string[] => {
|
||||
const rows = Array.isArray(item?.preview?.edit_context?.new) ? item.preview.edit_context.new : [];
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
### 5. run_command
|
||||
- tool_call_id: functions.run_command:170
|
||||
- 参数:
|
||||
```json
|
||||
{
|
||||
"command": "tail -20 static/src/components/panels/ToolApprovalPanel.vue",
|
||||
"intent": "查看文件末尾",
|
||||
"timeout": 10
|
||||
}
|
||||
```
|
||||
- 结果:
|
||||
```text
|
||||
};
|
||||
return map[name] || name || '待审批操作';
|
||||
};
|
||||
|
||||
const getWriteLines = (item: any): string[] => {
|
||||
const text = String(item?.preview?.content_preview || '');
|
||||
return text.split('\n');
|
||||
};
|
||||
|
||||
const getEditOldLines = (item: any): string[] => {
|
||||
const rows = Array.isArray(item?.preview?.edit_context?.old) ? item.preview.edit_context.old : [];
|
||||
return rows.map((row: any) => String(row?.content ?? ''));
|
||||
};
|
||||
|
||||
const getEditNewLines = (item: any): string[] => {
|
||||
const rows = Array.isArray(item?.preview?.edit_context?.new) ? item.preview.edit_context.new : [];
|
||||
return rows.map((row: any) => String(row?.content ?? ''));
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
```
|
||||
|
||||
|
||||
## 用户最新的一次输入
|
||||
依然有,我用的无痕模式都有,和之前一模一样
|
||||
@ -1,40 +1,117 @@
|
||||
"""Config package initializer,保持对旧 `from config import ...` 的兼容。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_dotenv():
|
||||
"""在未显式导入 python-dotenv 的情况下,尽量从仓库根目录加载 .env。"""
|
||||
env_path = Path(__file__).resolve().parents[1] / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
# 仅保护启动前已有的环境变量;.env 内重复键以后者为准
|
||||
"""加载配置:.env(仓库根)+ settings.json(数据根)。
|
||||
|
||||
1. 先读仓库根目录 .env(若存在),不覆盖已有的环境变量;
|
||||
2. 再读 {data_root}/settings.json,将其中的 env_vars 注入 os.environ,
|
||||
并将非 env_vars 的顶级字段也注入为 AGENT_CFG_<KEY> 环境变量(供 config/auth.py 等使用);
|
||||
3. settings.json 的加载依赖 data_root 路径,若 settings.json 不存在则静默跳过。
|
||||
"""
|
||||
import sys
|
||||
pre_existing_keys = set(os.environ.keys())
|
||||
try:
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if not key:
|
||||
continue
|
||||
# 若在进程启动前已存在,则尊重外部环境;否则允许 .env 内后续行覆盖前面行
|
||||
if key in pre_existing_keys:
|
||||
continue
|
||||
os.environ[key] = value
|
||||
except Exception:
|
||||
# 加载失败时静默继续,保持兼容
|
||||
pass
|
||||
|
||||
# 1) 仓库根 .env(开发便利,不覆盖已有的环境变量,但 ASTRION_DATA_ROOT
|
||||
# 作为项目数据根目录必须优先以 .env 为准,避免外部 shell 误指到 clone)
|
||||
if getattr(sys, 'frozen', False):
|
||||
env_path = Path.home() / '.astrion' / 'astrion' / '.env'
|
||||
else:
|
||||
env_path = Path(__file__).resolve().parents[1] / '.env'
|
||||
env_from_file: dict = {}
|
||||
if env_path.exists():
|
||||
try:
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if not key:
|
||||
continue
|
||||
env_from_file[key] = value
|
||||
if key in pre_existing_keys:
|
||||
continue
|
||||
os.environ[key] = value
|
||||
except Exception:
|
||||
pass
|
||||
if "ASTRION_DATA_ROOT" in env_from_file:
|
||||
os.environ["ASTRION_DATA_ROOT"] = str(Path(env_from_file["ASTRION_DATA_ROOT"]).expanduser())
|
||||
|
||||
# 2) settings.json(数据根下的统一配置)
|
||||
data_root = os.environ.get("ASTRION_DATA_ROOT", str(Path.home() / ".astrion" / "astrion"))
|
||||
settings_path = Path(data_root).expanduser() / "settings.json"
|
||||
if settings_path.exists():
|
||||
try:
|
||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
# 注入 env_vars(API key 等)
|
||||
env_vars = settings.get("env_vars", {})
|
||||
if isinstance(env_vars, dict):
|
||||
for k, v in env_vars.items():
|
||||
if k not in pre_existing_keys and v:
|
||||
os.environ[k] = str(v)
|
||||
|
||||
# 映射 settings.json 字段 → 旧版环境变量名(兼容现有 config/*.py 模块)
|
||||
_LEGACY_MAP = {
|
||||
"terminal.sandbox_mode": "TERMINAL_SANDBOX_MODE",
|
||||
"terminal.execution_mode_default": "HOST_EXECUTION_MODE_DEFAULT",
|
||||
"terminal.direct_ttl_seconds": "HOST_EXECUTION_DIRECT_TTL_SECONDS",
|
||||
"terminal.macos_writable_paths": "HOST_SANDBOX_MACOS_WRITABLE_PATHS",
|
||||
"terminal.max_active_containers": "MAX_ACTIVE_USER_CONTAINERS",
|
||||
"terminal.project_max_storage_mb": "PROJECT_MAX_STORAGE_MB",
|
||||
"server.port": "WEB_SERVER_PORT",
|
||||
"server.host": "WEB_SERVER_HOST",
|
||||
"admin.username": "AGENT_ADMIN_USERNAME",
|
||||
"admin.password_hash": "AGENT_ADMIN_PASSWORD_HASH",
|
||||
"admin.secondary_password_hash": "ADMIN_SECONDARY_PASSWORD_HASH",
|
||||
"secrets.web_secret_key": "WEB_SECRET_KEY",
|
||||
"secrets.api_token_secret": "API_TOKEN_SECRET",
|
||||
"search.tavily_api_key": "AGENT_TAVILY_API_KEY",
|
||||
"search.tavily_api_key_2": "AGENT_TAVILY_API_KEY_2",
|
||||
"balance.aliyun_access_key_id": "ALIYUN_ACCESS_KEY_ID",
|
||||
"balance.aliyun_access_key_secret": "ALIYUN_ACCESS_KEY_SECRET",
|
||||
"balance.usd_cny_rate": "USD_CNY_RATE",
|
||||
"models.default_response_max_tokens": "AGENT_DEFAULT_RESPONSE_MAX_TOKENS",
|
||||
}
|
||||
for dotted_key, env_name in _LEGACY_MAP.items():
|
||||
if env_name in pre_existing_keys:
|
||||
continue
|
||||
parts = dotted_key.split(".")
|
||||
val = settings
|
||||
for p in parts:
|
||||
val = val.get(p) if isinstance(val, dict) else None
|
||||
if val is None:
|
||||
break
|
||||
if val is not None:
|
||||
os.environ[env_name] = str(val)
|
||||
|
||||
# 注入非 env_vars 的顶级配置为 AGENT_CFG_* 环境变量(供 config/auth.py 等使用)
|
||||
for k, v in settings.items():
|
||||
if k == "env_vars":
|
||||
continue
|
||||
env_key = f"AGENT_CFG_{k.upper()}"
|
||||
if isinstance(v, dict):
|
||||
for sub_k, sub_v in v.items():
|
||||
flat_key = f"AGENT_CFG_{k.upper()}_{sub_k.upper()}"
|
||||
if flat_key not in pre_existing_keys:
|
||||
os.environ[flat_key] = str(sub_v)
|
||||
elif env_key not in pre_existing_keys:
|
||||
os.environ[env_key] = str(v)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_load_dotenv()
|
||||
|
||||
from . import api as _api
|
||||
from . import search as _search
|
||||
from . import paths as _paths
|
||||
from . import limits as _limits
|
||||
from . import terminal as _terminal
|
||||
@ -49,8 +126,10 @@ from . import uploads as _uploads
|
||||
from . import sub_agent as _sub_agent
|
||||
from . import custom_tools as _custom_tools
|
||||
from . import mcp as _mcp
|
||||
from . import server as _server
|
||||
|
||||
from .api import *
|
||||
from .search import *
|
||||
from .paths import *
|
||||
from .limits import *
|
||||
from .terminal import *
|
||||
@ -65,9 +144,10 @@ from .uploads import *
|
||||
from .sub_agent import *
|
||||
from .custom_tools import *
|
||||
from .mcp import *
|
||||
from .server import *
|
||||
|
||||
__all__ = []
|
||||
for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp):
|
||||
for module in (_api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp, _server):
|
||||
__all__ += getattr(module, "__all__", [])
|
||||
|
||||
del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp
|
||||
del _api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp, _server
|
||||
|
||||
@ -1,37 +1,19 @@
|
||||
"""API 和外部服务配置。"""
|
||||
"""API 和外部服务配置。
|
||||
|
||||
模型的 API 端点 / 密钥 / 模型 ID 现已统一由 ``config/custom_models.json``
|
||||
(经 ``config/model_profiles.py`` 解析为模型档案)描述,并在运行时通过
|
||||
``DeepSeekClient.apply_profile`` 注入。旧的 ``AGENT_API_BASE_URL`` /
|
||||
``AGENT_API_KEY`` / ``AGENT_MODEL_ID``(以及 THINKING / TITLE 三件套)属于早期
|
||||
把模型逻辑硬编码在程序里的残留,已移除。没有任何可用模型时,
|
||||
``model_profiles.get_default_model_key`` 会直接报错提示去配置模型。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _env(name: str, default: str = "", required: bool = False) -> str:
|
||||
value = os.environ.get(name, default)
|
||||
if required and not value:
|
||||
raise RuntimeError(f"缺少必要环境变量: {name}")
|
||||
return value
|
||||
|
||||
|
||||
API_BASE_URL = _env("AGENT_API_BASE_URL", "https://api.example.com")
|
||||
API_KEY = _env("AGENT_API_KEY", required=True)
|
||||
MODEL_ID = _env("AGENT_MODEL_ID", "deepseek-chat")
|
||||
|
||||
# 推理模型配置(智能思考模式使用)
|
||||
THINKING_API_BASE_URL = _env("AGENT_THINKING_API_BASE_URL", API_BASE_URL)
|
||||
THINKING_API_KEY = _env("AGENT_THINKING_API_KEY", API_KEY)
|
||||
THINKING_MODEL_ID = _env("AGENT_THINKING_MODEL_ID", MODEL_ID)
|
||||
|
||||
# Tavily 搜索(可选)
|
||||
TAVILY_API_KEY = _env("AGENT_TAVILY_API_KEY", "")
|
||||
|
||||
# 默认响应 token 限制
|
||||
# 默认响应 token 限制(与具体模型无关,保留为全局上限)
|
||||
DEFAULT_RESPONSE_MAX_TOKENS = int(os.environ.get("AGENT_DEFAULT_RESPONSE_MAX_TOKENS", "32768"))
|
||||
|
||||
__all__ = [
|
||||
"API_BASE_URL",
|
||||
"API_KEY",
|
||||
"MODEL_ID",
|
||||
"TAVILY_API_KEY",
|
||||
"DEFAULT_RESPONSE_MAX_TOKENS",
|
||||
"THINKING_API_BASE_URL",
|
||||
"THINKING_API_KEY",
|
||||
"THINKING_MODEL_ID",
|
||||
]
|
||||
|
||||
@ -1,46 +1,30 @@
|
||||
"""认证与后台账户配置。"""
|
||||
"""认证与后台账户配置。
|
||||
|
||||
配置来源(优先级从高到低):
|
||||
1. 环境变量(os.environ,由 config/__init__.py 从 settings.json / .env 注入)
|
||||
2. 默认值
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _dotenv_cache():
|
||||
cache = getattr(_dotenv_cache, "_cache", None)
|
||||
if cache is not None:
|
||||
return cache
|
||||
env_path = Path(__file__).resolve().parents[1] / ".env"
|
||||
data = {}
|
||||
if env_path.exists():
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
data[key.strip()] = value.strip().strip('"').strip("'")
|
||||
_dotenv_cache._cache = data
|
||||
return data
|
||||
|
||||
|
||||
def _get(name: str, default: str = "") -> str:
|
||||
# 优先读取 .env 文件,未找到再回退环境变量
|
||||
return _dotenv_cache().get(name, os.environ.get(name, default))
|
||||
"""从环境变量获取配置。"""
|
||||
return str(os.environ.get(name, "") or "").strip() or default
|
||||
|
||||
|
||||
ADMIN_USERNAME = _get("AGENT_ADMIN_USERNAME", "")
|
||||
ADMIN_PASSWORD_HASH = _get("AGENT_ADMIN_PASSWORD_HASH", "")
|
||||
ADMIN_USERNAME = _get("AGENT_CFG_ADMIN_USERNAME") or _get("AGENT_ADMIN_USERNAME", "")
|
||||
ADMIN_PASSWORD_HASH = _get("AGENT_CFG_ADMIN_PASSWORD_HASH") or _get("AGENT_ADMIN_PASSWORD_HASH", "")
|
||||
|
||||
# 管理员二级密码(可选)。
|
||||
# 优先使用哈希值;若未提供哈希,将回退到明文配置。
|
||||
ADMIN_SECONDARY_PASSWORD_HASH = _get("ADMIN_SECONDARY_PASSWORD_HASH", "")
|
||||
ADMIN_SECONDARY_PASSWORD_HASH = _get("AGENT_CFG_ADMIN_SECONDARY_PASSWORD_HASH") or _get("ADMIN_SECONDARY_PASSWORD_HASH", "")
|
||||
ADMIN_SECONDARY_PASSWORD = _get("ADMIN_SECONDARY_PASSWORD", "")
|
||||
|
||||
# 二级密码会话有效期(秒)。默认为 30 分钟,可根据需要在环境变量中覆盖。
|
||||
# 二级密码会话有效期(秒)。默认 30 分钟。
|
||||
ADMIN_SECONDARY_TTL_SECONDS = int(_get("AGENT_ADMIN_SECONDARY_TTL_SECONDS", "1800") or 1800)
|
||||
|
||||
# API Token 加密密钥来源;用于后台安全存储可回显的 Token。
|
||||
# 使用任意字符串即可,内部会通过 SHA256 导出 Fernet key。
|
||||
API_TOKEN_SECRET = _get("API_TOKEN_SECRET", "")
|
||||
# API Token 加密密钥来源。
|
||||
API_TOKEN_SECRET = _get("AGENT_CFG_SECRETS_API_TOKEN_SECRET") or _get("API_TOKEN_SECRET", "")
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_USERNAME",
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "auto-approval-agent",
|
||||
"url": "https://api.deepseek.com",
|
||||
"url": "https://api.example.com/v1",
|
||||
"key": "${API_KEY_DEEPSEEK}",
|
||||
"model": "deepseek-v4-flash",
|
||||
"model": "your-model-id",
|
||||
"extra_params": {
|
||||
"thinking": {
|
||||
"type": "enabled"
|
||||
@ -1,144 +0,0 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"model_name": "kimi-k2.6",
|
||||
"description": "Kimi-k2.6(测试别名),配置与 Kimi-k2.5 一致",
|
||||
"visible": true,
|
||||
"url": "${API_BASE_KIMI}",
|
||||
"apikey": "${API_KEY_KIMI}",
|
||||
"multimodal": "image,video",
|
||||
"reasoning_capability": "fast,thinking",
|
||||
"context_window": 256000,
|
||||
"max_output_tokens": 32768,
|
||||
"thinkmode_status": {
|
||||
"type": "param_toggle",
|
||||
"model_id": "kimi-k2.5",
|
||||
"fast_extra_parameter": {
|
||||
"thinking": {
|
||||
"type": "disabled"
|
||||
}
|
||||
},
|
||||
"thinking_extra_parameter": {
|
||||
"thinking": {
|
||||
"type": "enabled"
|
||||
},
|
||||
"enable_thinking": true
|
||||
}
|
||||
},
|
||||
"extra_parameter": {},
|
||||
"model_description": "你的基础模型是 Kimi-k2.6(测试别名),底层与 Kimi-k2.5 一致,并通过 thinking 参数开启/关闭思考能力。"
|
||||
},
|
||||
{
|
||||
"model_name": "DeepSeek-V4-Flash Max",
|
||||
"description": "DeepSeek V4 Flash(reasoning_effort=max),支持快速/思考/深度思考",
|
||||
"visible": true,
|
||||
"url": "${API_BASE_DEEPSEEK}",
|
||||
"apikey": "${API_KEY_DEEPSEEK}",
|
||||
"multimodal": "none",
|
||||
"reasoning_capability": "fast,thinking",
|
||||
"context_window": 1000000,
|
||||
"max_output_tokens": 384000,
|
||||
"thinkmode_status": {
|
||||
"type": "param_toggle",
|
||||
"model_id": "deepseek-v4-flash",
|
||||
"fast_extra_parameter": {
|
||||
"thinking": {
|
||||
"type": "disabled"
|
||||
}
|
||||
},
|
||||
"thinking_extra_parameter": {
|
||||
"thinking": {
|
||||
"type": "enabled"
|
||||
},
|
||||
"reasoning_effort": "max"
|
||||
}
|
||||
},
|
||||
"extra_parameter": {},
|
||||
"model_description": "你是DeepSeek-V4-Flash,一个快捷、高效的通用模型,具备接近旗舰级的推理能力,在简单到中等复杂度任务中表现出色,并支持 1M 上下文,适合追求响应速度与成本效率的使用场景。"
|
||||
},
|
||||
{
|
||||
"model_name": "DeepSeek-V4-Flah High",
|
||||
"description": "DeepSeek V4 Flash(reasoning_effort=high),支持快速/思考/深度思考",
|
||||
"visible": true,
|
||||
"url": "${API_BASE_DEEPSEEK}",
|
||||
"apikey": "${API_KEY_DEEPSEEK}",
|
||||
"multimodal": "none",
|
||||
"reasoning_capability": "fast,thinking",
|
||||
"context_window": 1000000,
|
||||
"max_output_tokens": 384000,
|
||||
"thinkmode_status": {
|
||||
"type": "param_toggle",
|
||||
"model_id": "deepseek-v4-flash",
|
||||
"fast_extra_parameter": {
|
||||
"thinking": {
|
||||
"type": "disabled"
|
||||
}
|
||||
},
|
||||
"thinking_extra_parameter": {
|
||||
"thinking": {
|
||||
"type": "enabled"
|
||||
},
|
||||
"reasoning_effort": "high"
|
||||
}
|
||||
},
|
||||
"extra_parameter": {},
|
||||
"model_description": "你是DeepSeek-V4-Flash,一个快捷、高效的通用模型,具备接近旗舰级的推理能力,在简单到中等复杂度任务中表现出色,并支持 1M 上下文,适合追求响应速度与成本效率的使用场景。"
|
||||
},
|
||||
{
|
||||
"model_name": "DeepSeek-V4-Pro Max",
|
||||
"description": "DeepSeek V4 Pro(reasoning_effort=max),支持快速/思考/深度思考",
|
||||
"visible": true,
|
||||
"url": "${API_BASE_DEEPSEEK}",
|
||||
"apikey": "${API_KEY_DEEPSEEK}",
|
||||
"multimodal": "none",
|
||||
"reasoning_capability": "fast,thinking",
|
||||
"context_window": 1000000,
|
||||
"max_output_tokens": 384000,
|
||||
"thinkmode_status": {
|
||||
"type": "param_toggle",
|
||||
"model_id": "deepseek-v4-pro",
|
||||
"fast_extra_parameter": {
|
||||
"thinking": {
|
||||
"type": "disabled"
|
||||
}
|
||||
},
|
||||
"thinking_extra_parameter": {
|
||||
"thinking": {
|
||||
"type": "enabled"
|
||||
},
|
||||
"reasoning_effort": "max"
|
||||
}
|
||||
},
|
||||
"extra_parameter": {},
|
||||
"model_description": "你是DeepSeek-V4-Pro,一个面向复杂任务的高性能通用模型,具备突出的 Agent 能力、丰富的世界知识和顶级推理表现,在编程、数学、STEM 与复杂问题分析场景中表现尤为出色,并支持 1M 上下文。"
|
||||
},
|
||||
{
|
||||
"model_name": "DeepSeek-V4-Pro High",
|
||||
"description": "DeepSeek V4 Pro(reasoning_effort=high),支持快速/思考/深度思考",
|
||||
"visible": true,
|
||||
"url": "${API_BASE_DEEPSEEK}",
|
||||
"apikey": "${API_KEY_DEEPSEEK}",
|
||||
"multimodal": "none",
|
||||
"reasoning_capability": "fast,thinking",
|
||||
"context_window": 1000000,
|
||||
"max_output_tokens": 384000,
|
||||
"thinkmode_status": {
|
||||
"type": "param_toggle",
|
||||
"model_id": "deepseek-v4-pro",
|
||||
"fast_extra_parameter": {
|
||||
"thinking": {
|
||||
"type": "disabled"
|
||||
}
|
||||
},
|
||||
"thinking_extra_parameter": {
|
||||
"thinking": {
|
||||
"type": "enabled"
|
||||
},
|
||||
"reasoning_effort": "high"
|
||||
}
|
||||
},
|
||||
"extra_parameter": {},
|
||||
"model_description": "你是DeepSeek-V4-Pro,一个面向复杂任务的高性能通用模型,具备突出的 Agent 能力、丰富的世界知识和顶级推理表现,在编程、数学、STEM 与复杂问题分析场景中表现尤为出色,并支持 1M 上下文。"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
config/goal_review.json.example
Normal file
13
config/goal_review.json.example
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "goal-review-agent",
|
||||
"url": "https://api.example.com/v1",
|
||||
"key": "${API_KEY_DEEPSEEK}",
|
||||
"model": "your-model-id",
|
||||
"extra_params": {
|
||||
"thinking": {
|
||||
"type": "enabled"
|
||||
}
|
||||
},
|
||||
"timeout_seconds": 600,
|
||||
"max_command_timeout": 60
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"macos_writable_paths": [
|
||||
"/Users/jojo/Desktop"
|
||||
],
|
||||
"macos_readable_extra_paths": [
|
||||
"/Users/jojo/Desktop"
|
||||
]
|
||||
}
|
||||
28
config/host_sandbox_policy.json.example
Normal file
28
config/host_sandbox_policy.json.example
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"macos_writable_paths": [],
|
||||
"macos_readable_extra_paths": [],
|
||||
"macos_deny_read_paths": [
|
||||
"~/.ssh",
|
||||
"~/.aws",
|
||||
"~/.azure",
|
||||
"~/.gcp",
|
||||
"~/.google",
|
||||
"~/.kube",
|
||||
"~/.docker",
|
||||
"~/.gnupg",
|
||||
"~/.npmrc",
|
||||
"~/.netrc",
|
||||
"~/.pypirc",
|
||||
"~/.git-credentials",
|
||||
"~/.bash_history",
|
||||
"~/.zsh_history",
|
||||
"~/.psql_history",
|
||||
"~/.mysql_history",
|
||||
"~/.pgpass",
|
||||
"/Library/Keychains",
|
||||
"~/Library/Keychains"
|
||||
],
|
||||
"macos_deny_read_regexes": [
|
||||
"^/.*\\.env(\\.[^/]*)?$"
|
||||
]
|
||||
}
|
||||
@ -3,296 +3,18 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default)
|
||||
from .paths import resolve_deploy_config
|
||||
|
||||
|
||||
def _env_optional(name: str) -> Optional[str]:
|
||||
"""从环境变量获取可选配置(已由 config/__init__.py 统一注入)。"""
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
# 回退读取 .env(支持运行中更新)
|
||||
env_path = Path(__file__).resolve().parents[1] / ".env"
|
||||
if env_path.exists():
|
||||
try:
|
||||
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, val = line.split("=", 1)
|
||||
if key.strip() == name:
|
||||
value = val.strip().strip('"').strip("'")
|
||||
break
|
||||
except Exception:
|
||||
value = None
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
# 模型上下文窗口(单位: token)
|
||||
CONTEXT_WINDOWS = {
|
||||
"kimi": 256_000,
|
||||
"kimi-k2.5": 256_000,
|
||||
"qwen3-max": 256_000,
|
||||
"qwen3-vl-plus": 256_000,
|
||||
"minimax-m2.5": 204_800,
|
||||
"deepseek": 128_000,
|
||||
}
|
||||
|
||||
|
||||
# 默认(Kimi)
|
||||
KIMI_BASE = _env("API_BASE_KIMI", _env("AGENT_API_BASE_URL", "https://api.moonshot.cn/v1"))
|
||||
KIMI_KEY = _env("API_KEY_KIMI", _env("AGENT_API_KEY", ""))
|
||||
KIMI_BASE_OFFICIAL = _env_optional("API_BASE_KIMI_OFFICIAL")
|
||||
KIMI_KEY_OFFICIAL = _env_optional("API_KEY_KIMI_OFFICIAL")
|
||||
KIMI_FAST_MODEL = _env("MODEL_KIMI_FAST", _env("AGENT_MODEL_ID", "kimi-k2-0905-preview"))
|
||||
KIMI_THINK_MODEL = _env("MODEL_KIMI_THINK", _env("AGENT_THINKING_MODEL_ID", "kimi-k2-thinking"))
|
||||
KIMI_25_MODEL = _env("MODEL_KIMI_25", "kimi-k2.5")
|
||||
|
||||
# DeepSeek
|
||||
DEEPSEEK_BASE = _env("API_BASE_DEEPSEEK", "https://api.deepseek.com")
|
||||
DEEPSEEK_KEY = _env("API_KEY_DEEPSEEK", _env("AGENT_DEEPSEEK_API_KEY", ""))
|
||||
DEEPSEEK_FAST_MODEL = _env("MODEL_DEEPSEEK_FAST", "deepseek-chat")
|
||||
DEEPSEEK_THINK_MODEL = _env("MODEL_DEEPSEEK_THINK", "deepseek-reasoner")
|
||||
|
||||
# Qwen
|
||||
QWEN_BASE = _env("API_BASE_QWEN", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
QWEN_KEY = _env("API_KEY_QWEN", _env("DASHSCOPE_API_KEY", ""))
|
||||
QWEN_BASE_OFFICIAL = _env_optional("API_BASE_QWEN_OFFICIAL")
|
||||
QWEN_KEY_OFFICIAL = _env_optional("API_KEY_QWEN_OFFICIAL")
|
||||
QWEN_MAX_MODEL = _env("MODEL_QWEN_MAX", "qwen3-max")
|
||||
QWEN_VL_MODEL = _env("MODEL_QWEN_VL", "qwen3.5-plus")
|
||||
|
||||
# MiniMax
|
||||
MINIMAX_BASE = _env("API_BASE_MINIMAX", "https://api.minimaxi.com/v1")
|
||||
MINIMAX_KEY = _env("API_KEY_MINIMAX", "")
|
||||
MINIMAX_BASE_OFFICIAL = _env_optional("API_BASE_MINIMAX_OFFICIAL")
|
||||
MINIMAX_KEY_OFFICIAL = _env_optional("API_KEY_MINIMAX_OFFICIAL")
|
||||
MINIMAX_MODEL = _env("MODEL_MINIMAX", "MiniMax-M2.5")
|
||||
|
||||
|
||||
MODEL_PROFILES = {
|
||||
"kimi": {
|
||||
"context_window": CONTEXT_WINDOWS["kimi"],
|
||||
"fast": {
|
||||
"base_url": KIMI_BASE,
|
||||
"api_key": KIMI_KEY,
|
||||
"model_id": KIMI_FAST_MODEL,
|
||||
"max_tokens": None,
|
||||
"context_window": CONTEXT_WINDOWS["kimi"],
|
||||
},
|
||||
"thinking": {
|
||||
"base_url": KIMI_BASE,
|
||||
"api_key": KIMI_KEY,
|
||||
"model_id": KIMI_THINK_MODEL,
|
||||
"max_tokens": None,
|
||||
"context_window": CONTEXT_WINDOWS["kimi"],
|
||||
},
|
||||
"supports_thinking": True,
|
||||
"fast_only": False,
|
||||
"name": "Kimi-k2",
|
||||
"description": "综合能力较强",
|
||||
"multimodal": "none",
|
||||
},
|
||||
"kimi-k2.5": {
|
||||
"context_window": CONTEXT_WINDOWS["kimi-k2.5"],
|
||||
"fast": {
|
||||
"base_url": KIMI_BASE,
|
||||
"api_key": KIMI_KEY,
|
||||
"model_id": KIMI_25_MODEL,
|
||||
"max_tokens": None,
|
||||
"context_window": CONTEXT_WINDOWS["kimi-k2.5"],
|
||||
"extra_params": {"thinking": {"type": "disabled"}}
|
||||
},
|
||||
"thinking": {
|
||||
"base_url": KIMI_BASE,
|
||||
"api_key": KIMI_KEY,
|
||||
"model_id": KIMI_25_MODEL,
|
||||
"max_tokens": None,
|
||||
"context_window": CONTEXT_WINDOWS["kimi-k2.5"],
|
||||
"extra_params": {"thinking": {"type": "enabled"}, "enable_thinking": True}
|
||||
},
|
||||
"supports_thinking": True,
|
||||
"fast_only": False,
|
||||
"name": "Kimi-k2.5",
|
||||
"description": "新版 Kimi,支持图文 & 思考开关",
|
||||
"multimodal": "image,video",
|
||||
},
|
||||
"deepseek": {
|
||||
"context_window": CONTEXT_WINDOWS["deepseek"],
|
||||
"fast": {
|
||||
"base_url": DEEPSEEK_BASE,
|
||||
"api_key": DEEPSEEK_KEY,
|
||||
"model_id": DEEPSEEK_FAST_MODEL,
|
||||
"max_tokens": 8192,
|
||||
"context_window": CONTEXT_WINDOWS["deepseek"]
|
||||
},
|
||||
"thinking": {
|
||||
"base_url": DEEPSEEK_BASE,
|
||||
"api_key": DEEPSEEK_KEY,
|
||||
"model_id": DEEPSEEK_THINK_MODEL,
|
||||
"max_tokens": 65536,
|
||||
"context_window": CONTEXT_WINDOWS["deepseek"]
|
||||
},
|
||||
"supports_thinking": True,
|
||||
"fast_only": False,
|
||||
"name": "DeepSeek",
|
||||
"description": "数学能力较强",
|
||||
"multimodal": "none",
|
||||
},
|
||||
"qwen3-max": {
|
||||
"context_window": CONTEXT_WINDOWS["qwen3-max"],
|
||||
"fast": {
|
||||
"base_url": QWEN_BASE,
|
||||
"api_key": QWEN_KEY,
|
||||
"model_id": QWEN_MAX_MODEL,
|
||||
"max_tokens": 65536,
|
||||
"context_window": CONTEXT_WINDOWS["qwen3-max"]
|
||||
},
|
||||
"thinking": None, # 不支持思考
|
||||
"supports_thinking": False,
|
||||
"fast_only": True,
|
||||
"name": "Qwen3-Max",
|
||||
"description": "仅快速模式",
|
||||
"multimodal": "none",
|
||||
"hidden": True,
|
||||
},
|
||||
"qwen3-vl-plus": {
|
||||
"context_window": CONTEXT_WINDOWS["qwen3-vl-plus"],
|
||||
"fast": {
|
||||
"base_url": QWEN_BASE,
|
||||
"api_key": QWEN_KEY,
|
||||
"model_id": QWEN_VL_MODEL,
|
||||
"max_tokens": 32768,
|
||||
"context_window": CONTEXT_WINDOWS["qwen3-vl-plus"],
|
||||
"extra_params": {}
|
||||
},
|
||||
"thinking": {
|
||||
"base_url": QWEN_BASE,
|
||||
"api_key": QWEN_KEY,
|
||||
"model_id": QWEN_VL_MODEL,
|
||||
"max_tokens": 32768,
|
||||
"context_window": CONTEXT_WINDOWS["qwen3-vl-plus"],
|
||||
"extra_params": {"enable_thinking": True}
|
||||
},
|
||||
"supports_thinking": True,
|
||||
"fast_only": False,
|
||||
"name": "Qwen3.5",
|
||||
"description": "图文视频多模态 + 深度思考",
|
||||
"multimodal": "image,video",
|
||||
},
|
||||
"minimax-m2.5": {
|
||||
"context_window": CONTEXT_WINDOWS["minimax-m2.5"],
|
||||
"fast": {
|
||||
"base_url": MINIMAX_BASE,
|
||||
"api_key": MINIMAX_KEY,
|
||||
"model_id": MINIMAX_MODEL,
|
||||
"max_tokens": 65536,
|
||||
"context_window": CONTEXT_WINDOWS["minimax-m2.5"],
|
||||
"extra_params": {"reasoning_split": True}
|
||||
},
|
||||
"thinking": {
|
||||
"base_url": MINIMAX_BASE,
|
||||
"api_key": MINIMAX_KEY,
|
||||
"model_id": MINIMAX_MODEL,
|
||||
"max_tokens": 65536,
|
||||
"context_window": CONTEXT_WINDOWS["minimax-m2.5"],
|
||||
"extra_params": {"reasoning_split": True}
|
||||
},
|
||||
"supports_thinking": True,
|
||||
"fast_only": False,
|
||||
"deep_only": True,
|
||||
"name": "MiniMax-M2.5",
|
||||
"description": "仅深度思考,超长上下文",
|
||||
"multimodal": "none",
|
||||
}
|
||||
}
|
||||
|
||||
MODEL_PROMPT_OVERRIDES = {
|
||||
"kimi": {
|
||||
"model_description": "你的基础模型是 Kimi-k2,由月之暗面公司开发,是一个开源的 MoE 架构模型,拥有 1T 参数和 32B 激活参数,当前智能助手应用由火山引擎提供 API 服务。",
|
||||
"thinking_model_line": "思考模式时,第一次请求的模型不是 Kimi-k2,而是 Kimi-k2-Thinking,一个更善于分析复杂问题、规划复杂流程的模型,在后续请求时模型会换回 Kimi-k2。",
|
||||
"deep_thinking_line": "在深度思考模式中,请求的模型是 Kimi-k2-Thinking,一个更善于分析复杂问题、规划复杂流程的模型。"
|
||||
},
|
||||
"kimi-k2.5": {
|
||||
"model_description": "你的基础模型是 Kimi-k2.5,支持图文多模态,并通过 thinking 参数开启/关闭思考能力。",
|
||||
"thinking_model_line": "思考模式时使用同一个 Kimi-k2.5 模型,但会在请求中注入 thinking={\"type\": \"enabled\"} 来开启思考;快速模式则传递 thinking={\"type\": \"disabled\"}。",
|
||||
"deep_thinking_line": "深度思考模式下,所有请求都会携带 thinking={\"type\": \"enabled\"},以获得持续的推理能力。"
|
||||
},
|
||||
"deepseek": {
|
||||
"model_description": "你的基础模型是 DeepSeek-V3.2(deepseek-chat),由 DeepSeek 提供,数学与推理能力较强,当前通过官方 API 调用。",
|
||||
"thinking_model_line": "思考模式时,第一次请求使用 DeepSeek-Reasoner,一个强化推理的模型,后续请求会切回 DeepSeek-V3.2。",
|
||||
"deep_thinking_line": "在深度思考模式中,请求的模型是 DeepSeek-Reasoner,用于深入分析复杂问题并规划步骤。"
|
||||
},
|
||||
"qwen3-max": {
|
||||
"model_description": "你的基础模型是 Qwen3-Max,由通义千问提供,当前仅支持快速模式,不提供思考或深度思考能力。",
|
||||
"thinking_model_line": "Qwen3-Max 仅支持快速模式,思考模式会被自动关闭。",
|
||||
"deep_thinking_line": "Qwen3-Max 不支持深度思考模式,将保持快速模式。"
|
||||
},
|
||||
"qwen3-vl-plus": {
|
||||
"model_description": "你的基础模型是 Qwen3.5,由通义千问提供,支持图文多模态理解。",
|
||||
"thinking_model_line": "思考模式时仍使用 Qwen3.5,并开启思考能力。",
|
||||
"deep_thinking_line": "深度思考模式下,所有请求都将启用思考能力,以获得更强的分析表现。"
|
||||
},
|
||||
"minimax-m2.5": {
|
||||
"model_description": "你的基础模型是 MiniMax-M2.5,支持超长上下文,当前仅以深度思考模式运行。",
|
||||
"thinking_model_line": "MiniMax-M2.5 为思考模型,快速模式不会使用。",
|
||||
"deep_thinking_line": "深度思考模式下,所有请求持续输出思考过程并给出最终回答。"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_model_profile(key: str) -> dict:
|
||||
profiles = get_registered_model_profiles()
|
||||
if key not in profiles:
|
||||
raise ValueError(f"未知模型 key: {key}")
|
||||
profile = profiles[key]
|
||||
try:
|
||||
from utils.aliyun_fallback import is_fallback_active
|
||||
except Exception:
|
||||
is_fallback_active = None
|
||||
|
||||
if is_fallback_active and is_fallback_active(key):
|
||||
if key == "kimi-k2.5":
|
||||
kimi_base_official = _env_optional("API_BASE_KIMI_OFFICIAL") or KIMI_BASE_OFFICIAL
|
||||
kimi_key_official = _env_optional("API_KEY_KIMI_OFFICIAL") or KIMI_KEY_OFFICIAL
|
||||
if kimi_base_official and kimi_key_official:
|
||||
profile = dict(profile)
|
||||
fast = dict(profile.get("fast") or {})
|
||||
thinking = dict(profile.get("thinking") or fast)
|
||||
fast.update({"base_url": kimi_base_official, "api_key": kimi_key_official})
|
||||
thinking.update({"base_url": kimi_base_official, "api_key": kimi_key_official})
|
||||
profile["fast"] = fast
|
||||
profile["thinking"] = thinking
|
||||
elif key == "qwen3-vl-plus":
|
||||
qwen_base_official = _env_optional("API_BASE_QWEN_OFFICIAL") or QWEN_BASE_OFFICIAL
|
||||
qwen_key_official = _env_optional("API_KEY_QWEN_OFFICIAL") or QWEN_KEY_OFFICIAL
|
||||
if qwen_base_official and qwen_key_official:
|
||||
profile = dict(profile)
|
||||
fast = dict(profile.get("fast") or {})
|
||||
thinking = dict(profile.get("thinking") or fast)
|
||||
fast.update({"base_url": qwen_base_official, "api_key": qwen_key_official})
|
||||
thinking.update({"base_url": qwen_base_official, "api_key": qwen_key_official})
|
||||
profile["fast"] = fast
|
||||
profile["thinking"] = thinking
|
||||
elif key == "minimax-m2.5":
|
||||
minimax_base_official = _env_optional("API_BASE_MINIMAX_OFFICIAL") or MINIMAX_BASE_OFFICIAL
|
||||
minimax_key_official = _env_optional("API_KEY_MINIMAX_OFFICIAL") or MINIMAX_KEY_OFFICIAL
|
||||
if minimax_base_official and minimax_key_official:
|
||||
profile = dict(profile)
|
||||
fast = dict(profile.get("fast") or {})
|
||||
thinking = dict(profile.get("thinking") or fast)
|
||||
fast.update({"base_url": minimax_base_official, "api_key": minimax_key_official})
|
||||
thinking.update({"base_url": minimax_base_official, "api_key": minimax_key_official})
|
||||
profile["fast"] = fast
|
||||
profile["thinking"] = thinking
|
||||
# 基础校验:必须有 fast 段且有 key
|
||||
fast = profile.get("fast") or {}
|
||||
if not fast.get("api_key"):
|
||||
raise ValueError(f"模型 {key} 缺少 API Key 配置")
|
||||
return profile
|
||||
|
||||
|
||||
def _normalize_multimodal(value: Any) -> str:
|
||||
text = str(value or "none").strip().lower()
|
||||
if text in {"image", "video", "none", "image,video", "video,image"}:
|
||||
@ -326,6 +48,13 @@ def _parse_env_ref(raw: Any) -> Optional[str]:
|
||||
return text
|
||||
|
||||
|
||||
def _to_optional_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
return int(value) if value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _reasoning_flags(capability: str) -> Dict[str, bool]:
|
||||
normalized = str(capability or "fast").replace(" ", "").lower()
|
||||
if normalized == "thinking":
|
||||
@ -343,20 +72,13 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
api_key = _parse_env_ref(item.get("apikey"))
|
||||
if not base_url or not api_key:
|
||||
return None
|
||||
context_window = item.get("context_window")
|
||||
max_output_tokens = item.get("max_output_tokens")
|
||||
try:
|
||||
context_window = int(context_window) if context_window is not None else None
|
||||
except (TypeError, ValueError):
|
||||
context_window = None
|
||||
try:
|
||||
max_output_tokens = int(max_output_tokens) if max_output_tokens is not None else None
|
||||
except (TypeError, ValueError):
|
||||
max_output_tokens = None
|
||||
|
||||
context_window = _to_optional_int(item.get("context_window"))
|
||||
max_output_tokens = _to_optional_int(item.get("max_output_tokens"))
|
||||
|
||||
capability = str(item.get("reasoning_capability") or "fast").replace(" ", "").lower()
|
||||
flags = _reasoning_flags(capability)
|
||||
thinkmode = item.get("thinkmode_status") or {}
|
||||
thinkmode = item.get("thinkmode_status") if isinstance(item.get("thinkmode_status"), dict) else {}
|
||||
mode_type = str((thinkmode.get("type") or thinkmode.get("mode") or "")).strip().lower()
|
||||
extra_global = item.get("extra_parameter") if isinstance(item.get("extra_parameter"), dict) else {}
|
||||
fast_extra = thinkmode.get("fast_extra_parameter") if isinstance(thinkmode.get("fast_extra_parameter"), dict) else {}
|
||||
@ -390,7 +112,7 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
fast_config = {
|
||||
"base_url": base_url,
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"api_key": api_key,
|
||||
"model_id": fast_model_id,
|
||||
"max_tokens": max_output_tokens,
|
||||
@ -398,7 +120,7 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"extra_params": {**extra_global, **(fast_extra or {})},
|
||||
}
|
||||
profile: Dict[str, Any] = {
|
||||
"name": key,
|
||||
"name": str(item.get("display_name") or key),
|
||||
"description": str(item.get("description") or ""),
|
||||
"model_description": str(item.get("model_description") or ""),
|
||||
"is_custom_model": True,
|
||||
@ -413,7 +135,7 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
profile["deep_only"] = True
|
||||
if flags["supports_thinking"]:
|
||||
profile["thinking"] = {
|
||||
"base_url": base_url,
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"api_key": api_key,
|
||||
"model_id": thinking_model_id or fast_model_id,
|
||||
"max_tokens": max_output_tokens,
|
||||
@ -426,7 +148,7 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
def _load_custom_models() -> Dict[str, Dict[str, Any]]:
|
||||
custom_path = Path(__file__).resolve().parent / "custom_models.json"
|
||||
custom_path = Path(resolve_deploy_config("custom_models.json"))
|
||||
if not custom_path.exists():
|
||||
return {}
|
||||
try:
|
||||
@ -451,9 +173,8 @@ def _load_custom_models() -> Dict[str, Dict[str, Any]]:
|
||||
|
||||
|
||||
def get_registered_model_profiles() -> Dict[str, Dict[str, Any]]:
|
||||
merged = dict(MODEL_PROFILES)
|
||||
merged.update(_load_custom_models()) # 同名覆盖内置,不报错
|
||||
return merged
|
||||
"""返回 API 扩展注册的模型。项目不再内置任何供应商模型。"""
|
||||
return _load_custom_models()
|
||||
|
||||
|
||||
def get_registered_model_keys(visible_only: bool = False) -> List[str]:
|
||||
@ -465,6 +186,40 @@ def get_registered_model_keys(visible_only: bool = False) -> List[str]:
|
||||
return keys
|
||||
|
||||
|
||||
def get_default_model_key(visible_only: bool = True) -> str:
|
||||
"""解析默认模型:环境变量优先,其次第一个已注册模型。"""
|
||||
profiles = get_registered_model_profiles()
|
||||
candidates = [key for key, profile in profiles.items() if not visible_only or not profile.get("hidden")]
|
||||
preferred = _env_optional("AGENT_DEFAULT_MODEL") or _env_optional("DEFAULT_MODEL_KEY")
|
||||
if preferred and preferred in profiles and (not visible_only or not profiles[preferred].get("hidden")):
|
||||
return preferred
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
raise ValueError("未配置可用模型,请在 config/custom_models.json 中添加至少一个可用模型")
|
||||
|
||||
|
||||
def resolve_model_key(candidate: Optional[str], *, visible_only: bool = False) -> str:
|
||||
"""将缺失的模型 key 解析为默认模型;过期/未知 key 仍显式报错。"""
|
||||
profiles = get_registered_model_profiles()
|
||||
if not candidate:
|
||||
return get_default_model_key(visible_only=visible_only)
|
||||
if candidate in profiles and (not visible_only or not profiles[candidate].get("hidden")):
|
||||
return candidate
|
||||
raise ValueError(f"未知模型 key: {candidate}")
|
||||
|
||||
|
||||
def get_model_profile(key: Optional[str]) -> dict:
|
||||
resolved_key = resolve_model_key(key)
|
||||
profiles = get_registered_model_profiles()
|
||||
profile = profiles[resolved_key]
|
||||
fast = profile.get("fast") or {}
|
||||
if not fast.get("api_key"):
|
||||
raise ValueError(f"模型 {resolved_key} 缺少 API Key 配置")
|
||||
if not fast.get("base_url") or not fast.get("model_id"):
|
||||
raise ValueError(f"模型 {resolved_key} 缺少 API 地址或模型 ID 配置")
|
||||
return profile
|
||||
|
||||
|
||||
def get_model_capabilities(key: str) -> Dict[str, Any]:
|
||||
profile = get_model_profile(key)
|
||||
multimodal = _normalize_multimodal(profile.get("multimodal"))
|
||||
@ -495,28 +250,16 @@ def model_supports_video(key: str) -> bool:
|
||||
|
||||
|
||||
def get_model_prompt_replacements(key: str) -> dict:
|
||||
"""获取模型相关的提示词替换字段,若缺失则回退到 Kimi 版本。"""
|
||||
fallback = MODEL_PROMPT_OVERRIDES.get("kimi", {})
|
||||
overrides = MODEL_PROMPT_OVERRIDES.get(key) or {}
|
||||
custom_model_description = ""
|
||||
is_custom_model = False
|
||||
"""获取模型相关提示词字段。仅使用 API 扩展配置中的描述,不做模型名硬编码。"""
|
||||
try:
|
||||
custom_profile = get_registered_model_profiles().get(key) or {}
|
||||
custom_model_description = str(custom_profile.get("model_description") or "")
|
||||
is_custom_model = bool(custom_profile.get("is_custom_model"))
|
||||
profile = get_registered_model_profiles().get(resolve_model_key(key)) or {}
|
||||
except Exception:
|
||||
custom_model_description = ""
|
||||
is_custom_model = False
|
||||
if is_custom_model:
|
||||
return {
|
||||
"model_description": custom_model_description or overrides.get("model_description") or fallback.get("model_description") or "",
|
||||
"thinking_model_line": "",
|
||||
"deep_thinking_line": "",
|
||||
}
|
||||
profile = {}
|
||||
model_description = str(profile.get("model_description") or profile.get("description") or "")
|
||||
return {
|
||||
"model_description": custom_model_description or overrides.get("model_description") or fallback.get("model_description") or "",
|
||||
"thinking_model_line": overrides.get("thinking_model_line") or fallback.get("thinking_model_line") or "",
|
||||
"deep_thinking_line": overrides.get("deep_thinking_line") or fallback.get("deep_thinking_line") or ""
|
||||
"model_description": model_description,
|
||||
"thinking_model_line": "",
|
||||
"deep_thinking_line": "",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
"""OCR 配置:DeepSeek-OCR 接口信息。"""
|
||||
"""OCR / VLM 配置。"""
|
||||
|
||||
OCR_API_BASE_URL = "https://api.siliconflow.cn"
|
||||
OCR_API_KEY = "sk-suqqgewtlwajjkylvnotdhkzmsrshmrqptkakdqjmlrilaes"
|
||||
OCR_MODEL_ID = "Qwen/Qwen3-VL-30B-A3B-Thinking"
|
||||
OCR_MAX_TOKENS = 200
|
||||
import os
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
OCR_API_BASE_URL = _env("OCR_API_BASE_URL", "")
|
||||
OCR_API_KEY = _env("OCR_API_KEY", "")
|
||||
OCR_MODEL_ID = _env("OCR_MODEL_ID", "")
|
||||
OCR_MAX_TOKENS = int(_env("OCR_MAX_TOKENS", "4096") or "4096")
|
||||
|
||||
__all__ = [
|
||||
"OCR_API_BASE_URL",
|
||||
|
||||
200
config/paths.py
200
config/paths.py
@ -1,5 +1,40 @@
|
||||
"""项目路径与目录配置。"""
|
||||
"""项目路径与目录配置。
|
||||
|
||||
运行态数据存放在 ``~/.astrion/astrion/`` 下:
|
||||
|
||||
::
|
||||
|
||||
~/.astrion/astrion/ ← data_root(默认,可通过 ASTRION_DATA_ROOT 覆盖)
|
||||
├── settings.json ← 唯一配置文件
|
||||
├── config/ ← 部署级 JSON,host/web 模式共享
|
||||
├── host/ ← host 模式(单用户)
|
||||
│ ├── data/ ← 对话/记忆/数据库
|
||||
│ └── logs/
|
||||
└── web/ ← web 模式(多用户)
|
||||
├── users/ ← 每用户独立工作区
|
||||
│ └── <name>/data/
|
||||
└── logs/
|
||||
|
||||
路径解析优先级(从高到低):
|
||||
|
||||
1. 具体目录环境变量(``DATA_DIR`` / ``LOGS_DIR`` / ``USER_SPACE_DIR`` /
|
||||
``API_USER_SPACE_DIR``)——单独覆盖某个目录,最高优先级;
|
||||
2. ``ASTRION_DATA_ROOT`` ——整体搬迁数据根目录;
|
||||
3. 兜底默认值 ``~/.astrion/astrion``。
|
||||
|
||||
注意:``config/*.json`` 分两类,不再一概锚定源码树:
|
||||
|
||||
- **开发者配置 / 程序能力**(``docker_risk_markers.json`` / ``skill_hints.json``):
|
||||
是程序行为的一部分,随版本演进,仍锚定源码树。
|
||||
- **部署者自定义配置**(``custom_models`` / ``host_workspaces`` /
|
||||
``auto_approval`` / ``goal_review`` / ``forbidden_commands`` /
|
||||
``host_sandbox_policy``):因部署/机器而异、或含密钥,外置到
|
||||
``<data_root>/config/``(即 ``DEPLOY_CONFIG_DIR``)。读取走
|
||||
``resolve_deploy_config``,回退链为:部署目录 -> 源码树 ``.json`` ->
|
||||
源码树 ``.json.example``。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
@ -7,40 +42,149 @@ from pathlib import Path
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _resolve_repo_path(raw_value: str, default: str) -> str:
|
||||
candidate = str(raw_value or "").strip() or str(default)
|
||||
path = Path(candidate).expanduser()
|
||||
def _norm(path: Path) -> Path:
|
||||
"""展开 ``~``、转绝对路径并归一化。相对路径相对仓库根目录展开。"""
|
||||
path = path.expanduser()
|
||||
if not path.is_absolute():
|
||||
path = (_REPO_ROOT / path).resolve()
|
||||
else:
|
||||
path = path.resolve()
|
||||
return str(path)
|
||||
path = _REPO_ROOT / path
|
||||
return path.resolve()
|
||||
|
||||
# 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录
|
||||
|
||||
def _env(name: str) -> str:
|
||||
return str(os.environ.get(name, "") or "").strip()
|
||||
|
||||
|
||||
def _resolve_repo_path(raw_value: str, default: str) -> str:
|
||||
"""解析锚定在源码树的路径(配置文件、提示词等)。
|
||||
|
||||
相对路径相对仓库根目录展开,绝对路径与 ``~`` 原样支持。
|
||||
"""
|
||||
candidate = str(raw_value or "").strip() or str(default)
|
||||
return str(_norm(Path(candidate)))
|
||||
|
||||
|
||||
def _runtime_mode() -> str:
|
||||
"""运行模式:``host`` 或 ``web``。
|
||||
|
||||
仅当 ``TERMINAL_SANDBOX_MODE=host`` 时为 host 模式。
|
||||
优先从 settings.json 读取,其次环境变量,最后默认 host。
|
||||
"""
|
||||
# 尝试从已加载的 settings.json 中读取
|
||||
mode = _env("TERMINAL_SANDBOX_MODE").lower()
|
||||
if mode == "host":
|
||||
return "host"
|
||||
if mode == "web" or mode == "docker":
|
||||
return "web"
|
||||
return "host"
|
||||
|
||||
|
||||
# ── 数据根目录(data_root)──
|
||||
# 优先级:ASTRION_DATA_ROOT 环境变量 > 默认 ~/.astrion/astrion
|
||||
_DEFAULT_DATA_ROOT = Path("~/.astrion/astrion")
|
||||
RUNTIME_ROOT = str(_norm(Path(_env("ASTRION_DATA_ROOT") or _DEFAULT_DATA_ROOT)))
|
||||
|
||||
_MODE = _runtime_mode()
|
||||
|
||||
|
||||
def _runtime_dir(env_name: str, subdir: str) -> str:
|
||||
"""解析运行态子目录。
|
||||
|
||||
优先级:具体目录环境变量 > data_root/{mode}/subdir。
|
||||
具体目录变量支持相对路径(相对仓库根目录)、绝对路径与 ``~``。
|
||||
"""
|
||||
raw = _env(env_name)
|
||||
if raw:
|
||||
return str(_norm(Path(raw)))
|
||||
return str(Path(RUNTIME_ROOT) / _MODE / subdir)
|
||||
|
||||
|
||||
# ── 运行态目录(默认落在 <data_root>/<mode>/ 下)──
|
||||
DATA_DIR = _runtime_dir("DATA_DIR", "data")
|
||||
LOGS_DIR = _runtime_dir("LOGS_DIR", "logs")
|
||||
USER_SPACE_DIR = _runtime_dir("USER_SPACE_DIR", "users")
|
||||
# API 专用用户与工作区(与网页用户隔离)
|
||||
API_USER_SPACE_DIR = _runtime_dir("API_USER_SPACE_DIR", "api/users")
|
||||
|
||||
# ── 用户自定义 skill 归档目录(host 模式全局;docker/web 模式可与 per-user 并存)──
|
||||
CUSTOM_SKILLS_DIR = str(Path(RUNTIME_ROOT) / _MODE / "agentskills")
|
||||
|
||||
# ── 是否宿主机模式 ──
|
||||
IS_HOST_MODE = _MODE == "host"
|
||||
|
||||
# ── 固定 web 模式路径(host 模式下作为额外数据源,同时读取)──
|
||||
_WEB_ROOT = Path(RUNTIME_ROOT) / "web"
|
||||
WEB_DATA_DIR = str(_WEB_ROOT / "data")
|
||||
WEB_USER_SPACE_DIR = str(_WEB_ROOT / "users")
|
||||
|
||||
# ── 部署级配置目录 —— 放在 data_root 根下,两个模式共享 ──
|
||||
_DEFAULT_DEPLOY_CONFIG = Path(RUNTIME_ROOT) / "config"
|
||||
DEPLOY_CONFIG_DIR = str(_norm(Path(_env("DEPLOY_CONFIG_DIR") or _DEFAULT_DEPLOY_CONFIG)))
|
||||
# 源码树内的配置种子目录(首次运行/开发环境的默认内容来源)。
|
||||
_SEED_CONFIG_DIR = _REPO_ROOT / "config"
|
||||
|
||||
|
||||
def deploy_config_path(filename: str) -> str:
|
||||
"""部署级配置文件应写入/读取的位置(``<data_root>/config`` 下)。
|
||||
|
||||
用于会被运行时写回的文件(如 host_workspaces / host_sandbox_policy),
|
||||
始终指向部署目录,不回退源码树。
|
||||
"""
|
||||
return str(Path(DEPLOY_CONFIG_DIR) / filename)
|
||||
|
||||
|
||||
def resolve_deploy_config(filename: str) -> str:
|
||||
"""解析部署级配置的只读路径,回退链(高→低):
|
||||
|
||||
1. ``<data_root>/config/<filename>``(部署者真实配置)
|
||||
2. 源码树 ``config/<filename>``(仓库内种子)
|
||||
3. 源码树 ``config/<filename>.example``(示例兜底)
|
||||
|
||||
都不存在时返回部署目录下的目标路径,由调用方处理「文件不存在」。
|
||||
仅用于定位,不在此做拷贝(拷贝交给 setup / 迁移脚本)。
|
||||
"""
|
||||
deployed = Path(DEPLOY_CONFIG_DIR) / filename
|
||||
if deployed.exists():
|
||||
return str(deployed)
|
||||
seed = _SEED_CONFIG_DIR / filename
|
||||
if seed.exists():
|
||||
return str(seed)
|
||||
example = _SEED_CONFIG_DIR / f"{filename}.example"
|
||||
if example.exists():
|
||||
return str(example)
|
||||
return str(deployed)
|
||||
|
||||
|
||||
# ── 启动工作区 ──
|
||||
DEFAULT_PROJECT_PATH = _resolve_repo_path(os.environ.get("DEFAULT_PROJECT_PATH", ""), "./project")
|
||||
# 宿主机模式工作区配置文件(JSON)
|
||||
HOST_WORKSPACES_FILE = _resolve_repo_path(os.environ.get("HOST_WORKSPACES_FILE", ""), "./config/host_workspaces.json")
|
||||
# 兼容旧配置:若仍有模块读取 HOST_PROJECT_PATH,保留该键(实际宿主机路径选择改由 JSON 管理)
|
||||
HOST_PROJECT_PATH = _resolve_repo_path(os.environ.get("HOST_PROJECT_PATH", ""), DEFAULT_PROJECT_PATH)
|
||||
PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts")
|
||||
DATA_DIR = _resolve_repo_path(os.environ.get("DATA_DIR", ""), "./data")
|
||||
LOGS_DIR = _resolve_repo_path(os.environ.get("LOGS_DIR", ""), "./logs")
|
||||
AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills")
|
||||
WORKSPACE_SKILLS_DIRNAME = "skills"
|
||||
|
||||
# 多用户空间
|
||||
USER_SPACE_DIR = _resolve_repo_path(os.environ.get("USER_SPACE_DIR", ""), "./users")
|
||||
# ── 源码树内的配置与资源(不随运行态根目录迁移)──
|
||||
PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts")
|
||||
AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills")
|
||||
# 多智能体预设角色目录(源码树内,随版本分发)
|
||||
PRESET_ROLES_DIR = _resolve_repo_path(os.environ.get("PRESET_ROLES_DIR", ""), "./multi_agent_roles")
|
||||
# 运行态预设/自定义角色目录(host 模式:预设+自定义同目录;web 模式:仅预设)
|
||||
CUSTOM_ROLES_DIR = str(Path(RUNTIME_ROOT) / _MODE / "mutiagents" / "agents")
|
||||
# web 模式预设角色目录(web 模式下使用,host 模式下不用)
|
||||
WEB_PRESET_ROLES_DIR = str(Path(RUNTIME_ROOT) / "web" / "mutiagents" / "agents")
|
||||
WORKSPACE_SKILLS_DIRNAME = ".astrion/skills"
|
||||
WORKSPACE_MEMORY_DIRNAME = ".astrion/memory"
|
||||
WORKSPACE_REVIEW_DIRNAME = ".astrion/review"
|
||||
HOST_WORKSPACES_FILE = _resolve_repo_path(
|
||||
os.environ.get("HOST_WORKSPACES_FILE", ""),
|
||||
deploy_config_path("host_workspaces.json"),
|
||||
)
|
||||
|
||||
# ── 基于 DATA_DIR 派生的数据文件 ──
|
||||
USERS_DB_FILE = f"{DATA_DIR}/users.json"
|
||||
INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json"
|
||||
ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json"
|
||||
|
||||
# API 专用用户与工作区(与网页用户隔离)
|
||||
API_USER_SPACE_DIR = _resolve_repo_path(os.environ.get("API_USER_SPACE_DIR", ""), "./api/users")
|
||||
API_USERS_DB_FILE = f"{DATA_DIR}/api_users.json"
|
||||
API_TOKENS_FILE = f"{DATA_DIR}/api_tokens.json"
|
||||
API_USAGE_FILE = f"{DATA_DIR}/api_usage.json"
|
||||
|
||||
__all__ = [
|
||||
"RUNTIME_ROOT",
|
||||
"DEFAULT_PROJECT_PATH",
|
||||
"HOST_WORKSPACES_FILE",
|
||||
"HOST_PROJECT_PATH",
|
||||
@ -49,12 +193,24 @@ __all__ = [
|
||||
"LOGS_DIR",
|
||||
"AGENT_SKILLS_DIR",
|
||||
"WORKSPACE_SKILLS_DIRNAME",
|
||||
"WORKSPACE_MEMORY_DIRNAME",
|
||||
"WORKSPACE_REVIEW_DIRNAME",
|
||||
"IS_HOST_MODE",
|
||||
"USER_SPACE_DIR",
|
||||
"WEB_DATA_DIR",
|
||||
"WEB_USER_SPACE_DIR",
|
||||
"USERS_DB_FILE",
|
||||
"INVITE_CODES_FILE",
|
||||
"ADMIN_POLICY_FILE",
|
||||
"API_USER_SPACE_DIR",
|
||||
"CUSTOM_SKILLS_DIR",
|
||||
"PRESET_ROLES_DIR",
|
||||
"CUSTOM_ROLES_DIR",
|
||||
"WEB_PRESET_ROLES_DIR",
|
||||
"API_USERS_DB_FILE",
|
||||
"API_TOKENS_FILE",
|
||||
"API_USAGE_FILE",
|
||||
"DEPLOY_CONFIG_DIR",
|
||||
"deploy_config_path",
|
||||
"resolve_deploy_config",
|
||||
]
|
||||
|
||||
17
config/search.py
Normal file
17
config/search.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""搜索相关配置。"""
|
||||
|
||||
import os
|
||||
|
||||
# 选择 Tavily 使用哪个环境变量中的密钥。
|
||||
# 默认保持兼容:仍使用 AGENT_TAVILY_API_KEY。
|
||||
# 你可以改成例如:AGENT_TAVILY_API_KEY_2 / AGENT_TAVILY_API_KEY_BACKUP
|
||||
TAVILY_API_KEY_ENV_NAME = "AGENT_TAVILY_API_KEY"
|
||||
|
||||
# 实际生效的 Tavily 密钥
|
||||
TAVILY_API_KEY = os.environ.get(TAVILY_API_KEY_ENV_NAME, "")
|
||||
|
||||
__all__ = [
|
||||
"TAVILY_API_KEY_ENV_NAME",
|
||||
"TAVILY_API_KEY",
|
||||
]
|
||||
|
||||
@ -3,9 +3,15 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from .paths import resolve_deploy_config
|
||||
|
||||
|
||||
def _load_forbidden_commands() -> list[str]:
|
||||
"""从独立 JSON 文件加载命令拦截关键词。"""
|
||||
"""从独立 JSON 文件加载命令拦截关键词。
|
||||
|
||||
属于部署级配置(部署者可调宽松/严苛),优先读 ~/.astrion/<mode>/config,
|
||||
回退源码树种子。
|
||||
"""
|
||||
fallback = [
|
||||
"rm -rf /",
|
||||
"rm -rf ~",
|
||||
@ -15,7 +21,7 @@ def _load_forbidden_commands() -> list[str]:
|
||||
"kill -9",
|
||||
"dd if=",
|
||||
]
|
||||
cfg_path = Path(__file__).resolve().parent / "forbidden_commands.json"
|
||||
cfg_path = Path(resolve_deploy_config("forbidden_commands.json"))
|
||||
if not cfg_path.exists():
|
||||
return fallback
|
||||
try:
|
||||
|
||||
42
config/server.py
Normal file
42
config/server.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Web 服务与运行环境配置(单一事实源)。
|
||||
|
||||
集中所有"部署/运行"相关配置,避免端口、监听地址、运行二进制路径等散落多处、
|
||||
各读各的。全项目应统一从 ``config`` 导入这里导出的常量,禁止再硬编码。
|
||||
|
||||
解析优先级:环境变量 > 此处默认值;命令行参数(如 ``--port``)可在入口层再覆盖。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return str(os.environ.get(name, "") or "").strip() or default
|
||||
|
||||
|
||||
def _flag(name: str, default: str = "0") -> bool:
|
||||
return _env(name, default).lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
# === Web 服务 ===============================================================
|
||||
# 监听端口(历史上散落在 main.py / server.app_legacy / server.state / cli)。
|
||||
WEB_SERVER_PORT = int(_env("WEB_SERVER_PORT", "8091"))
|
||||
# 监听地址:多用户/服务器用 0.0.0.0;单机便携包建议设 127.0.0.1 只听本机。
|
||||
WEB_SERVER_HOST = _env("WEB_SERVER_HOST", "0.0.0.0")
|
||||
# 调试模式(同时控制 Flask reloader)。
|
||||
WEB_SERVER_DEBUG = _flag("WEB_SERVER_DEBUG", "0")
|
||||
|
||||
# === 运行环境二进制 =========================================================
|
||||
# 便携 release 包启动脚本会注入这两个绝对路径,使主程序与子智能体使用内置运行时。
|
||||
# 留空/默认时回退到系统 PATH 上的 node(开发环境或系统已安装时)。
|
||||
NODE_BIN = _env("AGENT_NODE_BIN", "node")
|
||||
# Python 解释器路径;留空表示使用当前进程的 sys.executable。
|
||||
PYTHON_BIN = _env("AGENT_PYTHON_BIN", "")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WEB_SERVER_PORT",
|
||||
"WEB_SERVER_HOST",
|
||||
"WEB_SERVER_DEBUG",
|
||||
"NODE_BIN",
|
||||
"PYTHON_BIN",
|
||||
]
|
||||
@ -43,7 +43,7 @@
|
||||
"keywords": [
|
||||
"网页", "前端", "界面", "UI", "页面", "组件", "网站", "落地页",
|
||||
"仪表板", "dashboard", "react", "vue", "html", "css", "样式",
|
||||
"美化", "设计",
|
||||
"美化", "设计","画",
|
||||
"webpage", "frontend", "interface", "page", "component", "website",
|
||||
"landing page", "styling", "beautify", "design"
|
||||
],
|
||||
|
||||
@ -1,20 +1,13 @@
|
||||
"""子智能体相关配置。"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _resolve_repo_path(raw_value: str, default: str) -> str:
|
||||
candidate = str(raw_value or "").strip() or str(default)
|
||||
path = Path(candidate).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = (_REPO_ROOT / path).resolve()
|
||||
else:
|
||||
path = path.resolve()
|
||||
return str(path)
|
||||
from .paths import (
|
||||
_resolve_repo_path,
|
||||
DATA_DIR,
|
||||
DEFAULT_PROJECT_PATH,
|
||||
deploy_config_path,
|
||||
)
|
||||
|
||||
# 子智能体服务
|
||||
SUB_AGENT_SERVICE_BASE_URL = os.environ.get("SUB_AGENT_SERVICE_URL", "http://127.0.0.1:8092")
|
||||
@ -22,11 +15,16 @@ SUB_AGENT_DEFAULT_TIMEOUT = int(os.environ.get("SUB_AGENT_DEFAULT_TIMEOUT", "180
|
||||
SUB_AGENT_STATUS_POLL_INTERVAL = float(os.environ.get("SUB_AGENT_STATUS_POLL_INTERVAL", "2.0"))
|
||||
|
||||
# 存储与并发限制
|
||||
SUB_AGENT_TASKS_BASE_DIR = _resolve_repo_path(os.environ.get("SUB_AGENT_TASKS_BASE_DIR", ""), "./sub_agent/tasks")
|
||||
SUB_AGENT_PROJECT_RESULTS_DIR = _resolve_repo_path(os.environ.get("SUB_AGENT_PROJECT_RESULTS_DIR", ""), "./project/sub_agent_results")
|
||||
SUB_AGENT_STATE_FILE = _resolve_repo_path(os.environ.get("SUB_AGENT_STATE_FILE", ""), "./data/sub_agents.json")
|
||||
# 子智能体任务目录与状态文件属于运行态数据,跟随 DATA_DIR(默认 ~/.astrion/<mode>/data)。
|
||||
SUB_AGENT_TASKS_BASE_DIR = _resolve_repo_path(os.environ.get("SUB_AGENT_TASKS_BASE_DIR", ""), f"{DATA_DIR}/sub_agent_tasks")
|
||||
SUB_AGENT_STATE_FILE = _resolve_repo_path(os.environ.get("SUB_AGENT_STATE_FILE", ""), f"{DATA_DIR}/sub_agents.json")
|
||||
# 子智能体产出结果有意落在工作区内,跟随 DEFAULT_PROJECT_PATH,不迁入运行态根目录。
|
||||
SUB_AGENT_PROJECT_RESULTS_DIR = _resolve_repo_path(os.environ.get("SUB_AGENT_PROJECT_RESULTS_DIR", ""), f"{DEFAULT_PROJECT_PATH}/sub_agent_results")
|
||||
SUB_AGENT_MAX_ACTIVE = int(os.environ.get("SUB_AGENT_MAX_ACTIVE", "5"))
|
||||
|
||||
# 子智能体模型配置文件(仅从部署目录读取,读不到就报错)
|
||||
SUB_AGENT_MODELS_CONFIG_FILE = os.environ.get("SUB_AGENT_MODELS_CONFIG_FILE", "") or deploy_config_path("sub_agent_models.json")
|
||||
|
||||
__all__ = [
|
||||
"SUB_AGENT_SERVICE_BASE_URL",
|
||||
"SUB_AGENT_DEFAULT_TIMEOUT",
|
||||
@ -35,4 +33,5 @@ __all__ = [
|
||||
"SUB_AGENT_PROJECT_RESULTS_DIR",
|
||||
"SUB_AGENT_STATE_FILE",
|
||||
"SUB_AGENT_MAX_ACTIVE",
|
||||
"SUB_AGENT_MODELS_CONFIG_FILE",
|
||||
]
|
||||
|
||||
@ -34,7 +34,7 @@ def _parse_paths(raw_value: str):
|
||||
|
||||
|
||||
_env_prefix = "TERMINAL_SANDBOX_ENV_"
|
||||
TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "docker").lower()
|
||||
TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "host").lower()
|
||||
TERMINAL_SANDBOX_IMAGE = os.environ.get("TERMINAL_SANDBOX_IMAGE", "python:3.11-slim")
|
||||
TERMINAL_SANDBOX_MOUNT_PATH = os.environ.get("TERMINAL_SANDBOX_MOUNT_PATH", "/workspace")
|
||||
TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash")
|
||||
@ -58,6 +58,9 @@ HOST_EXECUTION_DIRECT_TTL_SECONDS = int(os.environ.get("HOST_EXECUTION_DIRECT_TT
|
||||
HOST_SANDBOX_MACOS_WRITABLE_PATHS = _parse_paths(
|
||||
os.environ.get("HOST_SANDBOX_MACOS_WRITABLE_PATHS", "")
|
||||
)
|
||||
HOST_SANDBOX_NETWORK_PERMISSION = os.environ.get(
|
||||
"HOST_SANDBOX_NETWORK_PERMISSION", "restricted"
|
||||
).strip().lower()
|
||||
|
||||
__all__ = [
|
||||
"MAX_TERMINALS",
|
||||
@ -87,4 +90,5 @@ __all__ = [
|
||||
"HOST_EXECUTION_MODE_DEFAULT",
|
||||
"HOST_EXECUTION_DIRECT_TTL_SECONDS",
|
||||
"HOST_SANDBOX_MACOS_WRITABLE_PATHS",
|
||||
"HOST_SANDBOX_NETWORK_PERMISSION",
|
||||
]
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
# core/main_terminal.py - 主终端(集成对话持久化)
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING
|
||||
@ -18,6 +19,7 @@ try:
|
||||
MCP_TOOLS_ENABLED,
|
||||
HOST_EXECUTION_MODE_DEFAULT,
|
||||
HOST_EXECUTION_DIRECT_TTL_SECONDS,
|
||||
HOST_SANDBOX_NETWORK_PERMISSION,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
@ -37,6 +39,7 @@ except ImportError:
|
||||
MCP_TOOLS_ENABLED,
|
||||
HOST_EXECUTION_MODE_DEFAULT,
|
||||
HOST_EXECUTION_DIRECT_TTL_SECONDS,
|
||||
HOST_SANDBOX_NETWORK_PERMISSION,
|
||||
)
|
||||
|
||||
from modules.file_manager import FileManager
|
||||
@ -45,7 +48,7 @@ from modules.terminal_ops import TerminalOperator
|
||||
from modules.memory_manager import MemoryManager
|
||||
from modules.terminal_manager import TerminalManager
|
||||
from modules.todo_manager import TodoManager
|
||||
from modules.sub_agent_manager import SubAgentManager
|
||||
from modules.sub_agent import SubAgentManager
|
||||
from modules.background_command_manager import BackgroundCommandManager
|
||||
from modules.ocr_client import OCRClient
|
||||
from modules.easter_egg_manager import EasterEggManager
|
||||
@ -62,8 +65,9 @@ except ImportError:
|
||||
from core.tool_config import TOOL_CATEGORIES
|
||||
from utils.api_client import DeepSeekClient
|
||||
from utils.context_manager import ContextManager
|
||||
from utils.conversation_manager import ConversationManager
|
||||
from utils.host_workspace_debug import write_host_workspace_debug
|
||||
from config.model_profiles import get_model_profile
|
||||
from config.model_profiles import get_default_model_key, get_model_profile
|
||||
|
||||
from core.main_terminal_parts import (
|
||||
MainTerminalCommandMixin,
|
||||
@ -100,7 +104,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
self.api_client = DeepSeekClient(thinking_mode=self.thinking_mode)
|
||||
self.api_client.project_path = project_path
|
||||
self.api_client.set_deep_thinking_mode(self.deep_thinking_mode)
|
||||
self.model_key = "kimi-k2.5"
|
||||
self.model_key = get_default_model_key()
|
||||
self.model_profile = get_model_profile(self.model_key)
|
||||
self.apply_model_profile(self.model_profile)
|
||||
self.context_manager = ContextManager(project_path, data_dir=str(self.data_dir))
|
||||
@ -130,7 +134,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
broadcast_callback=None, # CLI模式不需要广播
|
||||
container_session=container_session,
|
||||
)
|
||||
# 让 run_command/run_python 复用终端容器,保持环境一致
|
||||
# 让 run_command 复用终端容器,保持环境一致
|
||||
self.terminal_ops.attach_terminal_manager(self.terminal_manager)
|
||||
self._apply_container_session(container_session)
|
||||
|
||||
@ -140,6 +144,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
data_dir=str(self.data_dir),
|
||||
container_session=container_session,
|
||||
)
|
||||
self.sub_agent_manager.set_terminal(self)
|
||||
self.background_command_manager = BackgroundCommandManager(
|
||||
project_path=self.project_path,
|
||||
)
|
||||
@ -206,12 +211,15 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
self.current_permission_mode: str = "unrestricted"
|
||||
self.host_execution_mode: str = "sandbox"
|
||||
self.host_execution_direct_until: Optional[float] = None
|
||||
self.host_network_permission: str = "restricted"
|
||||
self.pending_permission_mode: Optional[str] = None
|
||||
self.pending_execution_mode: Optional[str] = None
|
||||
self.pending_network_permission: Optional[str] = None
|
||||
# 当前生效的启用 skills(用于强约束判定)
|
||||
self.enabled_skill_ids: Set[str] = set()
|
||||
self.apply_personalization_preferences()
|
||||
self._init_host_execution_mode()
|
||||
self._init_host_network_permission()
|
||||
|
||||
# 新增:自动开始新对话
|
||||
self._ensure_conversation()
|
||||
@ -256,10 +264,29 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
if getattr(self, "sub_agent_manager", None):
|
||||
self.sub_agent_manager.set_host_execution_mode(mode)
|
||||
|
||||
def _init_host_network_permission(self):
|
||||
default_permission = str(HOST_SANDBOX_NETWORK_PERMISSION or "restricted").strip().lower()
|
||||
if default_permission not in {"restricted", "full", "none"}:
|
||||
default_permission = "restricted"
|
||||
self.host_network_permission = default_permission
|
||||
os.environ["HOST_SANDBOX_NETWORK_PERMISSION"] = default_permission
|
||||
|
||||
def get_network_permission(self) -> str:
|
||||
return getattr(self, "host_network_permission", "restricted")
|
||||
|
||||
def set_network_permission(self, mode: str) -> str:
|
||||
normalized = str(mode or "").strip().lower()
|
||||
if normalized not in {"restricted", "full", "none"}:
|
||||
raise ValueError("无效网络权限,仅支持 restricted / full / none")
|
||||
self.host_network_permission = normalized
|
||||
os.environ["HOST_SANDBOX_NETWORK_PERMISSION"] = normalized
|
||||
return normalized
|
||||
|
||||
def get_pending_runtime_modes(self) -> Dict[str, Optional[str]]:
|
||||
return {
|
||||
"permission_mode": self.pending_permission_mode,
|
||||
"execution_mode": self.pending_execution_mode,
|
||||
"network_permission": getattr(self, "pending_network_permission", None),
|
||||
}
|
||||
|
||||
def queue_permission_mode_change(self, mode: str) -> str:
|
||||
@ -282,8 +309,18 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
})
|
||||
return normalized
|
||||
|
||||
def apply_pending_runtime_mode_changes(self) -> List[str]:
|
||||
notices: List[str] = []
|
||||
def queue_network_permission_change(self, mode: str) -> str:
|
||||
normalized = str(mode or "").strip().lower()
|
||||
if normalized not in {"restricted", "full", "none"}:
|
||||
raise ValueError("无效网络权限,仅支持 restricted / full / none")
|
||||
self.pending_network_permission = normalized
|
||||
self._persist_runtime_mode_metadata({
|
||||
"pending_network_permission": normalized,
|
||||
})
|
||||
return normalized
|
||||
|
||||
def apply_pending_runtime_mode_changes(self) -> List[Dict[str, str]]:
|
||||
notices: List[Dict[str, str]] = []
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
pending_permission = self.pending_permission_mode
|
||||
@ -297,7 +334,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
"auto_approval": "自动审核",
|
||||
"unrestricted": "无限制",
|
||||
}.get(pending_permission, pending_permission)
|
||||
notices.append(f"权限模式修改为 {label}")
|
||||
notices.append({"text": f"权限模式被用户修改为 {label}", "source": "权限变更"})
|
||||
updates["permission_mode"] = pending_permission
|
||||
updates["pending_permission_mode"] = None
|
||||
self.pending_permission_mode = None
|
||||
@ -308,11 +345,22 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
if pending_execution != current_exec:
|
||||
self.set_execution_mode(pending_execution)
|
||||
label = {"sandbox": "沙箱", "direct": "完全访问权限"}.get(pending_execution, pending_execution)
|
||||
notices.append(f"执行环境修改为 {label}")
|
||||
notices.append({"text": f"执行环境被用户修改为 {label}", "source": "执行环境变更"})
|
||||
updates["execution_mode"] = pending_execution
|
||||
updates["pending_execution_mode"] = None
|
||||
self.pending_execution_mode = None
|
||||
|
||||
pending_network = getattr(self, "pending_network_permission", None)
|
||||
if pending_network:
|
||||
current_network = self.get_network_permission()
|
||||
if pending_network != current_network:
|
||||
self.set_network_permission(pending_network)
|
||||
label = {"restricted": "受限", "full": "完全开放", "none": "完全禁止"}.get(pending_network, pending_network)
|
||||
notices.append({"text": f"网络权限被用户修改为 {label}", "source": "网络权限变更"})
|
||||
updates["network_permission"] = pending_network
|
||||
updates["pending_network_permission"] = None
|
||||
self.pending_network_permission = None
|
||||
|
||||
if updates:
|
||||
self._persist_runtime_mode_metadata(updates)
|
||||
|
||||
@ -326,7 +374,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
if not cm or not conv_id:
|
||||
return
|
||||
try:
|
||||
cm.conversation_manager.update_conversation_metadata(conv_id, updates)
|
||||
target_manager = cm._get_conversation_manager_for_id(conv_id) if hasattr(cm, "_get_conversation_manager_for_id") else cm.conversation_manager
|
||||
target_manager.update_conversation_metadata(conv_id, updates)
|
||||
if isinstance(getattr(cm, "conversation_metadata", None), dict):
|
||||
cm.conversation_metadata.update(updates)
|
||||
except Exception:
|
||||
@ -448,6 +497,24 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
current_context_resolved = str(
|
||||
getattr(getattr(self, "context_manager", None), "project_path", "")
|
||||
)
|
||||
try:
|
||||
current_conversation_scope = str(
|
||||
Path(
|
||||
getattr(
|
||||
getattr(getattr(self, "context_manager", None), "conversation_manager", None),
|
||||
"current_project_path",
|
||||
"",
|
||||
)
|
||||
).expanduser().resolve()
|
||||
)
|
||||
except Exception:
|
||||
current_conversation_scope = str(
|
||||
getattr(
|
||||
getattr(getattr(self, "context_manager", None), "conversation_manager", None),
|
||||
"current_project_path",
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
write_host_workspace_debug(
|
||||
"terminal.update_project_path.begin",
|
||||
@ -456,7 +523,11 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
target_project_path=resolved_path,
|
||||
)
|
||||
|
||||
if resolved_path == current_resolved and current_context_resolved == resolved_path:
|
||||
if (
|
||||
resolved_path == current_resolved
|
||||
and current_context_resolved == resolved_path
|
||||
and current_conversation_scope == resolved_path
|
||||
):
|
||||
write_host_workspace_debug(
|
||||
"terminal.update_project_path.skip_same_path",
|
||||
terminal_id=id(self),
|
||||
@ -481,6 +552,13 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
self.context_manager.initial_project_path = new_path
|
||||
self.context_manager.project_snapshot = None
|
||||
self.context_manager._host_runtime_cache = None
|
||||
self.context_manager.conversation_manager = ConversationManager(
|
||||
base_dir=str(getattr(self.context_manager, "data_dir", DATA_DIR)),
|
||||
project_path=str(new_path),
|
||||
)
|
||||
self.context_manager.current_conversation_id = None
|
||||
self.context_manager.conversation_history = []
|
||||
self.context_manager.todo_list = None
|
||||
metadata = getattr(self.context_manager, "conversation_metadata", None)
|
||||
if isinstance(metadata, dict):
|
||||
metadata["project_path"] = str(new_path)
|
||||
@ -495,6 +573,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
metadata.pop("project_file_tree", None)
|
||||
metadata.pop("project_statistics", None)
|
||||
metadata.pop("project_snapshot_at", None)
|
||||
self._ensure_conversation()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -577,8 +656,7 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
||||
"execution_mode": self.get_execution_mode(),
|
||||
"pending_permission_mode": None,
|
||||
"pending_execution_mode": None,
|
||||
"frozen_permission_prompt": self._build_permission_mode_message() or "",
|
||||
"frozen_execution_prompt": self._build_execution_mode_message() or "",
|
||||
# frozen_*_prompt 不在创建时预设,由第一次 build_messages 根据当时的实际模式懒加载并冻结
|
||||
},
|
||||
)
|
||||
print(f"{OUTPUT_FORMATS['info']} 新建对话: {conversation_id}")
|
||||
|
||||
@ -47,7 +47,7 @@ from modules.terminal_ops import TerminalOperator
|
||||
from modules.memory_manager import MemoryManager
|
||||
from modules.terminal_manager import TerminalManager
|
||||
from modules.todo_manager import TodoManager
|
||||
from modules.sub_agent_manager import SubAgentManager
|
||||
from modules.sub_agent import SubAgentManager
|
||||
from modules.webpage_extractor import extract_webpage_content, tavily_extract
|
||||
from modules.ocr_client import OCRClient
|
||||
from modules.easter_egg_manager import EasterEggManager
|
||||
@ -82,6 +82,7 @@ from config.model_profiles import (
|
||||
get_model_profile,
|
||||
get_model_prompt_replacements,
|
||||
get_model_context_window,
|
||||
resolve_model_key,
|
||||
model_supports_image,
|
||||
model_supports_video,
|
||||
)
|
||||
@ -332,7 +333,17 @@ class MainTerminalCommandMixin:
|
||||
)
|
||||
system_message = tool_result.get("system_message")
|
||||
if system_message:
|
||||
self._record_sub_agent_message(system_message, tool_result.get("task_id"), inline=False)
|
||||
from server.chat_flow_task_support import inject_runtime_user_message
|
||||
inject_runtime_user_message(
|
||||
web_terminal=self,
|
||||
messages=None,
|
||||
text=system_message,
|
||||
source="sub_agent",
|
||||
sender=None,
|
||||
conversation_id=getattr(self.context_manager, "current_conversation_id", None),
|
||||
inline=False,
|
||||
extra_metadata={"task_id": tool_result.get("task_id")},
|
||||
)
|
||||
|
||||
# 4. 在终端显示执行信息(不保存到历史)
|
||||
if collected_tool_calls:
|
||||
@ -357,8 +368,6 @@ class MainTerminalCommandMixin:
|
||||
print(f"{OUTPUT_FORMATS['terminal']} 执行终端命令")
|
||||
elif tool_name == "web_search":
|
||||
print(f"{OUTPUT_FORMATS['search']} 网络搜索")
|
||||
elif tool_name == "run_python":
|
||||
print(f"{OUTPUT_FORMATS['code']} 执行Python代码")
|
||||
elif tool_name == "run_command":
|
||||
print(f"{OUTPUT_FORMATS['terminal']} 执行系统命令")
|
||||
elif tool_name == "update_memory":
|
||||
@ -785,6 +794,7 @@ class MainTerminalCommandMixin:
|
||||
self.api_client.apply_profile(profile)
|
||||
|
||||
def set_model(self, model_key: str) -> str:
|
||||
model_key = resolve_model_key(model_key)
|
||||
profile = get_model_profile(model_key)
|
||||
if getattr(self.context_manager, "has_images", False) and not model_supports_image(model_key):
|
||||
raise ValueError("当前对话包含图片,目标模型不支持图片输入")
|
||||
|
||||
@ -1,498 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
from config import (
|
||||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
)
|
||||
|
||||
from modules.file_manager import FileManager
|
||||
from modules.search_engine import SearchEngine
|
||||
from modules.terminal_ops import TerminalOperator
|
||||
from modules.memory_manager import MemoryManager
|
||||
from modules.terminal_manager import TerminalManager
|
||||
from modules.todo_manager import TodoManager
|
||||
from modules.sub_agent_manager import SubAgentManager
|
||||
from modules.webpage_extractor import extract_webpage_content, tavily_extract
|
||||
from modules.ocr_client import OCRClient
|
||||
from modules.easter_egg_manager import EasterEggManager
|
||||
from modules.personalization_manager import (
|
||||
load_personalization_config,
|
||||
build_personalization_prompt,
|
||||
)
|
||||
from modules.skills_manager import (
|
||||
get_skills_catalog,
|
||||
build_skills_list,
|
||||
merge_enabled_skills,
|
||||
build_skills_prompt,
|
||||
)
|
||||
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
|
||||
from modules.custom_tool_executor import CustomToolExecutor
|
||||
|
||||
try:
|
||||
from config.limits import THINKING_FAST_INTERVAL
|
||||
except ImportError:
|
||||
THINKING_FAST_INTERVAL = 10
|
||||
|
||||
from modules.container_monitor import collect_stats, inspect_state
|
||||
from core.tool_config import TOOL_CATEGORIES
|
||||
from utils.api_client import DeepSeekClient
|
||||
from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER
|
||||
from utils.host_workspace_debug import write_host_workspace_debug
|
||||
from utils.tool_result_formatter import format_tool_result_for_context
|
||||
from utils.logger import setup_logger
|
||||
from config.model_profiles import (
|
||||
get_model_profile,
|
||||
get_model_prompt_replacements,
|
||||
get_model_context_window,
|
||||
model_supports_image,
|
||||
model_supports_video,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
DISABLE_LENGTH_CHECK = True
|
||||
|
||||
class MainTerminalContextMixin:
|
||||
# 权限模式说明(代码中存储的简要版本)
|
||||
_PERMISSION_MODE_DESC = {
|
||||
"unrestricted": "当前处于无限制模式,所有工具均可直接使用,无需额外批准。",
|
||||
"readonly": "当前处于只读模式,仅能执行不会修改工作区的读取类操作。",
|
||||
"approval": "当前处于批准模式,修改工作区的操作需要用户批准后方可执行。",
|
||||
"auto_approval": "当前处于自动审核模式,工作区内文件编辑可直接执行,高风险操作由后台审批智能体自动审核。",
|
||||
}
|
||||
|
||||
_PERMISSION_MODE_LABEL = {
|
||||
"unrestricted": "无限制",
|
||||
"readonly": "只读",
|
||||
"approval": "批准",
|
||||
"auto_approval": "自动审核",
|
||||
}
|
||||
|
||||
_EXECUTION_MODE_LABEL = {
|
||||
"sandbox": "沙箱",
|
||||
"direct": "完全访问权限",
|
||||
}
|
||||
|
||||
def _build_permission_mode_message(self) -> Optional[str]:
|
||||
"""根据当前权限模式构建权限说明消息(从模板读取并替换占位符)"""
|
||||
template = self.load_prompt("permission_mode")
|
||||
if not template:
|
||||
return None
|
||||
|
||||
mode = self.get_permission_mode()
|
||||
|
||||
# 根据模式生成详细规则
|
||||
if mode == "unrestricted":
|
||||
detailed_rules = (
|
||||
"- 所有命令和工具均可直接使用,无需用户批准\n"
|
||||
"- run_command:支持任意命令,包括管道、重定向、子shell等\n"
|
||||
"- 文件操作:read_file / write_file / edit_file 可直接使用;其他文件管理请通过 run_command 或 run_python 执行"
|
||||
)
|
||||
elif mode == "readonly":
|
||||
detailed_rules = (
|
||||
"- run_command:统一在系统只读沙箱中执行\n"
|
||||
"- 若命令触发写入,系统会直接拒绝并返回权限错误\n"
|
||||
"- 文件操作:仅允许 read_file、view_image、view_video 等读取类工具\n"
|
||||
"- 禁止:write_file、edit_file 及会修改工作区的命令操作"
|
||||
)
|
||||
elif mode == "approval":
|
||||
detailed_rules = (
|
||||
"- run_command:先在系统只读沙箱执行;仅当出现权限拒绝时才触发审批\n"
|
||||
"- 审批通过后:仅当前这一次命令会重试为可写沙箱执行\n"
|
||||
"- 需要用户批准:terminal_input、run_python、write_file、edit_file、save_webpage 及其他会修改工作区的操作\n"
|
||||
"- 被拒绝或超时:本次操作不会执行写入"
|
||||
)
|
||||
elif mode == "auto_approval":
|
||||
detailed_rules = (
|
||||
"- write_file / edit_file:若路径在当前工作区内,直接执行;工作区外路径会进入审批流程\n"
|
||||
"- run_command:先在系统只读沙箱执行;仅当出现权限拒绝时才触发自动审批\n"
|
||||
"- 自动审批由后台审批智能体执行,默认只判断危险性与越权风险,不判断任务必要性\n"
|
||||
"- 自动审批拒绝:本次工具调用会返回“拒绝+理由”,主循环继续;人工拒绝可随时接管\n"
|
||||
"- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准"
|
||||
)
|
||||
else:
|
||||
detailed_rules = ""
|
||||
|
||||
return template.format(
|
||||
permission_mode=mode,
|
||||
permission_mode_label=self._PERMISSION_MODE_LABEL.get(mode, mode),
|
||||
mode_description=self._PERMISSION_MODE_DESC.get(mode, ""),
|
||||
detailed_rules=detailed_rules,
|
||||
)
|
||||
|
||||
def _build_execution_mode_message(self) -> Optional[str]:
|
||||
"""根据当前执行环境模式构建提示消息。"""
|
||||
# 仅宿主机模式注入;Docker 模式不需要该提示。
|
||||
try:
|
||||
if not getattr(self, "_is_host_mode", lambda: False)():
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
template = self.load_prompt("execution_mode")
|
||||
if not template:
|
||||
return None
|
||||
state = {}
|
||||
if hasattr(self, "get_execution_mode_state"):
|
||||
try:
|
||||
state = self.get_execution_mode_state() or {}
|
||||
except Exception:
|
||||
state = {}
|
||||
mode = str(state.get("mode") or "sandbox").strip().lower()
|
||||
mode_label = self._EXECUTION_MODE_LABEL.get(mode, mode)
|
||||
if mode == "sandbox":
|
||||
rules = (
|
||||
"- 所有命令默认在系统 OS 沙箱中执行\n"
|
||||
"- 若操作受系统权限限制:先提供 1 个安全替代方案并执行;若仍无法满足目标,明确请求用户切换执行环境为“完全访问权限”后重试\n"
|
||||
"- 不要通过复杂绕过手段反复尝试规避沙箱限制;若任务本质需要更高权限,应直接说明并等待用户确认"
|
||||
)
|
||||
else:
|
||||
rules = (
|
||||
"- 当前为宿主机直接执行模式(完全访问权限)\n"
|
||||
"- 仅在必须时执行高权限操作,保持最小化命令范围\n"
|
||||
"- 涉及删除/覆盖/系统级变更前,先说明风险再执行"
|
||||
)
|
||||
return template.format(
|
||||
execution_mode=mode,
|
||||
execution_mode_label=mode_label,
|
||||
rules=rules,
|
||||
)
|
||||
|
||||
def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]:
|
||||
cm = getattr(self, "context_manager", None)
|
||||
meta = getattr(cm, "conversation_metadata", {}) if cm else {}
|
||||
cached = meta.get(key) if isinstance(meta, dict) else None
|
||||
if isinstance(cached, str):
|
||||
return cached
|
||||
|
||||
built = builder() or ""
|
||||
conv_id = getattr(cm, "current_conversation_id", None) if cm else None
|
||||
if cm and conv_id:
|
||||
try:
|
||||
cm.conversation_manager.update_conversation_metadata(conv_id, {key: built})
|
||||
if isinstance(cm.conversation_metadata, dict):
|
||||
cm.conversation_metadata[key] = built
|
||||
except Exception:
|
||||
pass
|
||||
return built
|
||||
|
||||
def build_context(self) -> Dict:
|
||||
"""构建主终端上下文"""
|
||||
# 读取记忆
|
||||
memory = self.memory_manager.read_main_memory()
|
||||
|
||||
# 构建上下文
|
||||
return self.context_manager.build_main_context(memory)
|
||||
|
||||
def _tool_calls_followed_by_tools(self, conversation: List[Dict], start_idx: int, tool_calls: List[Dict]) -> bool:
|
||||
"""判断指定助手消息的工具调用是否拥有后续的工具响应。"""
|
||||
if not tool_calls:
|
||||
return False
|
||||
expected_ids = [tc.get("id") for tc in tool_calls if tc.get("id")]
|
||||
if not expected_ids:
|
||||
return False
|
||||
matched: Set[str] = set()
|
||||
idx = start_idx + 1
|
||||
total = len(conversation)
|
||||
while idx < total and len(matched) < len(expected_ids):
|
||||
next_conv = conversation[idx]
|
||||
role = next_conv.get("role")
|
||||
if role == "tool":
|
||||
call_id = next_conv.get("tool_call_id")
|
||||
if call_id in expected_ids:
|
||||
matched.add(call_id)
|
||||
else:
|
||||
break
|
||||
elif role in ("assistant", "user"):
|
||||
break
|
||||
idx += 1
|
||||
return len(matched) == len(expected_ids)
|
||||
|
||||
def build_messages(self, context: Dict, user_input: str) -> List[Dict]:
|
||||
"""构建消息列表(添加终端内容注入)"""
|
||||
try:
|
||||
file_tree_preview = (context.get("project_info", {}).get("file_tree") or "").splitlines()
|
||||
write_host_workspace_debug(
|
||||
"main_terminal.build_messages.context_snapshot",
|
||||
terminal_id=id(self),
|
||||
terminal_project_path=str(getattr(self, "project_path", "")),
|
||||
context_project_path=str(getattr(getattr(self, "context_manager", None), "project_path", "")),
|
||||
project_info_path=str(context.get("project_info", {}).get("path", "")),
|
||||
file_tree_first_line=file_tree_preview[0] if file_tree_preview else "",
|
||||
current_conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# 加载系统提示(Qwen3.5 使用专用提示)
|
||||
current_model = getattr(self, "model_key", "kimi")
|
||||
prompt_name = "main_system_qwenvl" if (model_supports_image(current_model) or model_supports_video(current_model)) else "main_system"
|
||||
system_prompt = self.load_prompt(prompt_name)
|
||||
|
||||
# 格式化系统提示
|
||||
container_path = self.container_mount_path or "/workspace"
|
||||
container_cpus = self.container_cpu_limit
|
||||
container_memory = self.container_memory_limit
|
||||
project_storage = self.project_storage_limit
|
||||
model_key = getattr(self, "model_key", "kimi")
|
||||
prompt_replacements = get_model_prompt_replacements(model_key)
|
||||
system_prompt = system_prompt.format(
|
||||
project_path=container_path,
|
||||
container_path=container_path,
|
||||
container_cpus=container_cpus,
|
||||
container_memory=container_memory,
|
||||
project_storage=project_storage,
|
||||
file_tree=context["project_info"]["file_tree"],
|
||||
memory=context["memory"],
|
||||
current_time=datetime.now().strftime("%Y-%m-%d %H"),
|
||||
model_description=prompt_replacements.get("model_description", "")
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
|
||||
personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir)
|
||||
shallow_replace_enabled = bool(personalization_config.get("auto_shallow_compress_enabled", False)) if isinstance(personalization_config, dict) else False
|
||||
skills_catalog = get_skills_catalog()
|
||||
enabled_skills = merge_enabled_skills(
|
||||
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
|
||||
skills_catalog,
|
||||
personalization_config.get("skills_catalog_snapshot") if isinstance(personalization_config, dict) else None,
|
||||
)
|
||||
skills_template = self.load_prompt("skills_system").strip()
|
||||
skills_list = build_skills_list(skills_catalog, enabled_skills)
|
||||
skills_prompt = build_skills_prompt(skills_template, skills_list)
|
||||
if skills_prompt:
|
||||
messages.append({"role": "system", "content": skills_prompt})
|
||||
|
||||
workspace_system = self.context_manager._build_workspace_system_message(context)
|
||||
if workspace_system:
|
||||
messages.append({"role": "system", "content": workspace_system})
|
||||
|
||||
# 注入权限模式说明(根据当前模式动态生成)
|
||||
permission_mode_message = self._get_or_init_frozen_mode_prompt(
|
||||
"frozen_permission_prompt",
|
||||
self._build_permission_mode_message,
|
||||
)
|
||||
if permission_mode_message:
|
||||
messages.append({"role": "system", "content": permission_mode_message})
|
||||
execution_mode_message = self._get_or_init_frozen_mode_prompt(
|
||||
"frozen_execution_prompt",
|
||||
self._build_execution_mode_message,
|
||||
)
|
||||
if execution_mode_message:
|
||||
messages.append({"role": "system", "content": execution_mode_message})
|
||||
|
||||
if self.tool_category_states.get("sub_agent", True):
|
||||
sub_agent_prompt = self.load_prompt("sub_agent_guidelines").strip()
|
||||
if sub_agent_prompt:
|
||||
messages.append({"role": "system", "content": sub_agent_prompt})
|
||||
|
||||
if self.deep_thinking_mode:
|
||||
deep_prompt = self.load_prompt("deep_thinking_mode_guidelines").strip()
|
||||
if deep_prompt:
|
||||
deep_prompt = deep_prompt.format(
|
||||
deep_thinking_line=prompt_replacements.get("deep_thinking_line", "")
|
||||
)
|
||||
messages.append({"role": "system", "content": deep_prompt})
|
||||
elif self.thinking_mode:
|
||||
thinking_prompt = self.load_prompt("thinking_mode_guidelines").strip()
|
||||
if thinking_prompt:
|
||||
thinking_prompt = thinking_prompt.format(
|
||||
thinking_model_line=prompt_replacements.get("thinking_model_line", "")
|
||||
)
|
||||
messages.append({"role": "system", "content": thinking_prompt})
|
||||
|
||||
# 支持按对话覆盖的个性化配置
|
||||
personalization_block = build_personalization_prompt(personalization_config, include_header=False)
|
||||
if personalization_block:
|
||||
personalization_template = self.load_prompt("personalization").strip()
|
||||
if personalization_template and "{personalization_block}" in personalization_template:
|
||||
personalization_text = personalization_template.format(personalization_block=personalization_block)
|
||||
elif personalization_template:
|
||||
personalization_text = f"{personalization_template}\n{personalization_block}"
|
||||
else:
|
||||
personalization_text = personalization_block
|
||||
messages.append({"role": "system", "content": personalization_text})
|
||||
|
||||
# AGENTS.md 自动注入
|
||||
agents_md_inject_enabled = bool(personalization_config.get("agents_md_auto_inject", False)) if isinstance(personalization_config, dict) else False
|
||||
if agents_md_inject_enabled:
|
||||
agents_md_content = self._load_agents_md_content()
|
||||
if agents_md_content:
|
||||
agents_md_template = self.load_prompt("agents_md_inject").strip()
|
||||
if agents_md_template and "{{AGENTS_MD_CONTENT}}" in agents_md_template:
|
||||
agents_md_text = agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content)
|
||||
else:
|
||||
agents_md_text = f"【AGENTS.md 项目规范】\n\n{agents_md_content}\n\n---\n请注意:以上规范来自工作区根目录的 AGENTS.md 文件,若有冲突请以 AGENTS.md 为准。"
|
||||
messages.append({"role": "system", "content": agents_md_text})
|
||||
|
||||
# 支持按对话覆盖的自定义 system prompt(API 用途)。
|
||||
# 放在最后一个 system 消息位置,确保优先级最高,便于业务场景强约束。
|
||||
custom_system_prompt = getattr(self.context_manager, "custom_system_prompt", None)
|
||||
if isinstance(custom_system_prompt, str) and custom_system_prompt.strip():
|
||||
messages.append({"role": "system", "content": custom_system_prompt.strip()})
|
||||
|
||||
# 禁用工具提示也作为“开头 system prompt”注入,便于后续统一合并。
|
||||
disabled_notice = self._format_disabled_tool_notice()
|
||||
if disabled_notice:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": disabled_notice
|
||||
})
|
||||
|
||||
# 添加对话历史(保留完整结构,包括tool_calls和tool消息)
|
||||
conversation = context["conversation"]
|
||||
replaced_tool_count = 0
|
||||
for idx, conv in enumerate(conversation):
|
||||
metadata = conv.get("metadata") or {}
|
||||
if conv["role"] == "assistant":
|
||||
# Assistant消息可能包含工具调用
|
||||
message = {
|
||||
"role": conv["role"],
|
||||
"content": conv["content"]
|
||||
}
|
||||
# 对于思考模式(如 DeepSeek thinking),assistant 历史消息中的
|
||||
# reasoning_content 即使为空字符串也需要原样回传,否则下一轮可能被
|
||||
# API 判定为“未回传 reasoning_content”并返回 400。
|
||||
if "reasoning_content" in conv:
|
||||
message["reasoning_content"] = conv.get("reasoning_content", "")
|
||||
# 如果有工具调用信息,添加到消息中
|
||||
tool_calls = conv.get("tool_calls") or []
|
||||
if tool_calls and self._tool_calls_followed_by_tools(conversation, idx, tool_calls):
|
||||
message["tool_calls"] = tool_calls
|
||||
messages.append(message)
|
||||
|
||||
elif conv["role"] == "tool":
|
||||
if shallow_replace_enabled and metadata.get("auto_shallow_compacted"):
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"content": AUTO_SHALLOW_PLACEHOLDER,
|
||||
"tool_call_id": conv.get("tool_call_id", ""),
|
||||
"name": conv.get("name", "")
|
||||
})
|
||||
replaced_tool_count += 1
|
||||
continue
|
||||
# Tool消息需要保留完整结构
|
||||
images = conv.get("images") or metadata.get("images") or []
|
||||
videos = conv.get("videos") or metadata.get("videos") or []
|
||||
media_refs = conv.get("media_refs") or metadata.get("media_refs") or []
|
||||
content_value = conv.get("content")
|
||||
if isinstance(content_value, list):
|
||||
content_payload = content_value
|
||||
elif images or videos or media_refs:
|
||||
content_payload = self.context_manager._build_content_with_images(
|
||||
content_value,
|
||||
images,
|
||||
videos,
|
||||
media_refs=media_refs,
|
||||
)
|
||||
else:
|
||||
content_payload = content_value
|
||||
message = {
|
||||
"role": "tool",
|
||||
"content": content_payload,
|
||||
"tool_call_id": conv.get("tool_call_id", ""),
|
||||
"name": conv.get("name", "")
|
||||
}
|
||||
messages.append(message)
|
||||
|
||||
elif conv["role"] == "system" and metadata.get("sub_agent_notice"):
|
||||
# 转换为用户消息,让模型能及时响应
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": conv["content"]
|
||||
})
|
||||
else:
|
||||
# User 或普通 System 消息
|
||||
images = conv.get("images") or metadata.get("images") or []
|
||||
videos = conv.get("videos") or metadata.get("videos") or []
|
||||
media_refs = conv.get("media_refs") or metadata.get("media_refs") or []
|
||||
content_payload = (
|
||||
self.context_manager._build_content_with_images(
|
||||
conv["content"],
|
||||
images,
|
||||
videos,
|
||||
media_refs=media_refs,
|
||||
)
|
||||
if (images or videos or media_refs) else conv["content"]
|
||||
)
|
||||
# 调试:记录所有 system 消息
|
||||
if conv["role"] == "system":
|
||||
logger.info(f"[DEBUG build_messages] 添加 system 消息: content前50字={conv['content'][:50]}")
|
||||
messages.append({
|
||||
"role": conv["role"],
|
||||
"content": content_payload
|
||||
})
|
||||
|
||||
# 当前用户输入已经在conversation中了,不需要重复添加
|
||||
if shallow_replace_enabled:
|
||||
print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count} 条")
|
||||
return messages
|
||||
|
||||
def _load_agents_md_content(self) -> Optional[str]:
|
||||
"""加载工作区根目录的 AGENTS.md 文件内容。
|
||||
|
||||
如果存在多个 AGENTS.md 文件,返回最新更新的那一个。
|
||||
"""
|
||||
try:
|
||||
project_path = Path(self.project_path)
|
||||
if not project_path.exists():
|
||||
return None
|
||||
|
||||
# 查找所有 AGENTS.md 文件
|
||||
agents_md_files = list(project_path.rglob("AGENTS.md"))
|
||||
if not agents_md_files:
|
||||
return None
|
||||
|
||||
# 找到最新更新的文件
|
||||
latest_file = max(agents_md_files, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
# 读取文件内容
|
||||
content = latest_file.read_text(encoding='utf-8')
|
||||
return content.strip() if content else None
|
||||
except Exception as exc:
|
||||
logger.warning(f"[AGENTS.md] 读取失败: {exc}")
|
||||
return None
|
||||
|
||||
def load_prompt(self, name: str) -> str:
|
||||
"""加载提示模板"""
|
||||
prompt_file = Path(PROMPTS_DIR) / f"{name}.txt"
|
||||
if prompt_file.exists():
|
||||
with open(prompt_file, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
return "你是一个AI助手。"
|
||||
16
core/main_terminal_parts/context/__init__.py
Normal file
16
core/main_terminal_parts/context/__init__.py
Normal file
@ -0,0 +1,16 @@
|
||||
# core/main_terminal_parts/context.py - 主终端上下文构建
|
||||
# 本文件为兼容入口,实际实现已拆分到 core/main_terminal_parts/context/ 目录下。
|
||||
|
||||
from core.main_terminal_parts.context.base import MainTerminalContextBase
|
||||
from core.main_terminal_parts.context.mode import ModeMixin
|
||||
from core.main_terminal_parts.context.memory import MemoryMixin
|
||||
from core.main_terminal_parts.context.conversation import ConversationMixin
|
||||
from core.main_terminal_parts.context.messages import MessagesMixin
|
||||
from core.main_terminal_parts.context.prompt import PromptMixin
|
||||
|
||||
__all__ = ["MainTerminalContextMixin"]
|
||||
|
||||
|
||||
class MainTerminalContextMixin(MainTerminalContextBase, ModeMixin, MemoryMixin, ConversationMixin, MessagesMixin, PromptMixin):
|
||||
"""主终端上下文构建:组合各功能 mixin 的对外统一入口。"""
|
||||
pass
|
||||
103
core/main_terminal_parts/context/base.py
Normal file
103
core/main_terminal_parts/context/base.py
Normal file
@ -0,0 +1,103 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
from config import (
|
||||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
)
|
||||
|
||||
from modules.file_manager import FileManager
|
||||
from modules.search_engine import SearchEngine
|
||||
from modules.terminal_ops import TerminalOperator
|
||||
from modules.memory_manager import MemoryManager
|
||||
from modules.terminal_manager import TerminalManager
|
||||
from modules.todo_manager import TodoManager
|
||||
from modules.sub_agent import SubAgentManager
|
||||
from modules.webpage_extractor import extract_webpage_content, tavily_extract
|
||||
from modules.ocr_client import OCRClient
|
||||
from modules.easter_egg_manager import EasterEggManager
|
||||
from modules.personalization_manager import (
|
||||
load_personalization_config,
|
||||
build_personalization_prompt,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
|
||||
)
|
||||
from modules.skills_manager import (
|
||||
get_skills_catalog,
|
||||
build_skills_list,
|
||||
merge_enabled_skills,
|
||||
build_skills_prompt,
|
||||
infer_private_skills_dir,
|
||||
)
|
||||
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
|
||||
from modules.custom_tool_executor import CustomToolExecutor
|
||||
|
||||
try:
|
||||
from config.limits import THINKING_FAST_INTERVAL
|
||||
except ImportError:
|
||||
THINKING_FAST_INTERVAL = 10
|
||||
|
||||
from modules.container_monitor import collect_stats, inspect_state
|
||||
from core.tool_config import TOOL_CATEGORIES
|
||||
from utils.api_client import DeepSeekClient
|
||||
from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER
|
||||
from utils.host_workspace_debug import write_host_workspace_debug
|
||||
from utils.tool_result_formatter import format_tool_result_for_context
|
||||
from utils.logger import setup_logger
|
||||
from config.model_profiles import (
|
||||
get_model_profile,
|
||||
get_model_prompt_replacements,
|
||||
get_model_context_window,
|
||||
model_supports_image,
|
||||
model_supports_video,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
DISABLE_LENGTH_CHECK = True
|
||||
|
||||
|
||||
class MainTerminalContextBase:
|
||||
"""MainTerminalContextMixin 基础类。"""
|
||||
|
||||
def build_context(self) -> Dict:
|
||||
"""构建主终端上下文"""
|
||||
# 读取记忆
|
||||
memory = self.memory_manager.read_main_memory()
|
||||
|
||||
# 构建上下文
|
||||
return self.context_manager.build_main_context(memory)
|
||||
153
core/main_terminal_parts/context/conversation.py
Normal file
153
core/main_terminal_parts/context/conversation.py
Normal file
@ -0,0 +1,153 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
from config import (
|
||||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
)
|
||||
|
||||
from modules.file_manager import FileManager
|
||||
from modules.search_engine import SearchEngine
|
||||
from modules.terminal_ops import TerminalOperator
|
||||
from modules.memory_manager import MemoryManager
|
||||
from modules.terminal_manager import TerminalManager
|
||||
from modules.todo_manager import TodoManager
|
||||
from modules.sub_agent import SubAgentManager
|
||||
from modules.webpage_extractor import extract_webpage_content, tavily_extract
|
||||
from modules.ocr_client import OCRClient
|
||||
from modules.easter_egg_manager import EasterEggManager
|
||||
from modules.personalization_manager import (
|
||||
load_personalization_config,
|
||||
build_personalization_prompt,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
|
||||
)
|
||||
from modules.skills_manager import (
|
||||
get_skills_catalog,
|
||||
build_skills_list,
|
||||
merge_enabled_skills,
|
||||
build_skills_prompt,
|
||||
infer_private_skills_dir,
|
||||
)
|
||||
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
|
||||
from modules.custom_tool_executor import CustomToolExecutor
|
||||
|
||||
try:
|
||||
from config.limits import THINKING_FAST_INTERVAL
|
||||
except ImportError:
|
||||
THINKING_FAST_INTERVAL = 10
|
||||
|
||||
from modules.container_monitor import collect_stats, inspect_state
|
||||
from core.tool_config import TOOL_CATEGORIES
|
||||
from utils.api_client import DeepSeekClient
|
||||
from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER
|
||||
from utils.host_workspace_debug import write_host_workspace_debug
|
||||
from utils.tool_result_formatter import format_tool_result_for_context
|
||||
from utils.logger import setup_logger
|
||||
from config.model_profiles import (
|
||||
get_model_profile,
|
||||
get_model_prompt_replacements,
|
||||
get_model_context_window,
|
||||
model_supports_image,
|
||||
model_supports_video,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
DISABLE_LENGTH_CHECK = True
|
||||
|
||||
|
||||
class ConversationMixin:
|
||||
"""MainTerminalContextMixin conversation 能力 mixin。"""
|
||||
|
||||
def _build_recent_conversations_message(self, limit: int = 10) -> Optional[str]:
|
||||
"""构建最近对话提示(仅当前工作区)。"""
|
||||
try:
|
||||
manager = getattr(self.context_manager, "conversation_manager", None)
|
||||
if not manager:
|
||||
return None
|
||||
current_id = getattr(self.context_manager, "current_conversation_id", None)
|
||||
items = manager.get_recent_conversation_summaries(
|
||||
limit=limit,
|
||||
exclude_conversation_id=current_id,
|
||||
first_message_max_chars=100,
|
||||
)
|
||||
if not items:
|
||||
return None
|
||||
lines: List[str] = []
|
||||
for index, item in enumerate(items, start=1):
|
||||
title = str(item.get("title") or "未命名对话").strip()
|
||||
conv_id = str(item.get("id") or "").strip()
|
||||
first_message = str(item.get("first_user_message") or "").strip() or "(无首条用户消息)"
|
||||
updated_at = str(item.get("updated_at") or "").strip()
|
||||
lines.append(f"{index}. [{conv_id}] 标题:{title}")
|
||||
lines.append(f" 首条用户消息:{first_message}")
|
||||
if updated_at:
|
||||
lines.append(f" 更新时间:{updated_at}")
|
||||
recent_text = "\n".join(lines)
|
||||
template = self.load_prompt("recent_conversations").strip()
|
||||
if template and "{recent_conversations}" in template:
|
||||
return template.format(recent_conversations=recent_text)
|
||||
if template:
|
||||
return f"{template}\n\n{recent_text}"
|
||||
return f"## 最近对话(仅当前工作区)\n\n{recent_text}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _tool_calls_followed_by_tools(self, conversation: List[Dict], start_idx: int, tool_calls: List[Dict]) -> bool:
|
||||
"""判断指定助手消息的工具调用是否拥有后续的工具响应。"""
|
||||
if not tool_calls:
|
||||
return False
|
||||
expected_ids = [tc.get("id") for tc in tool_calls if tc.get("id")]
|
||||
if not expected_ids:
|
||||
return False
|
||||
matched: Set[str] = set()
|
||||
idx = start_idx + 1
|
||||
total = len(conversation)
|
||||
while idx < total and len(matched) < len(expected_ids):
|
||||
next_conv = conversation[idx]
|
||||
role = next_conv.get("role")
|
||||
if role == "tool":
|
||||
call_id = next_conv.get("tool_call_id")
|
||||
if call_id in expected_ids:
|
||||
matched.add(call_id)
|
||||
else:
|
||||
break
|
||||
elif role in ("assistant", "user"):
|
||||
break
|
||||
idx += 1
|
||||
return len(matched) == len(expected_ids)
|
||||
170
core/main_terminal_parts/context/memory.py
Normal file
170
core/main_terminal_parts/context/memory.py
Normal file
@ -0,0 +1,170 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
from config import (
|
||||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
WORKSPACE_MEMORY_DIRNAME,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
WORKSPACE_MEMORY_DIRNAME,
|
||||
)
|
||||
|
||||
from modules.file_manager import FileManager
|
||||
from modules.search_engine import SearchEngine
|
||||
from modules.terminal_ops import TerminalOperator
|
||||
from modules.memory_manager import MemoryManager
|
||||
from modules.terminal_manager import TerminalManager
|
||||
from modules.todo_manager import TodoManager
|
||||
from modules.sub_agent import SubAgentManager
|
||||
from modules.webpage_extractor import extract_webpage_content, tavily_extract
|
||||
from modules.ocr_client import OCRClient
|
||||
from modules.easter_egg_manager import EasterEggManager
|
||||
from modules.personalization_manager import (
|
||||
load_personalization_config,
|
||||
build_personalization_prompt,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
|
||||
)
|
||||
from modules.skills_manager import (
|
||||
get_skills_catalog,
|
||||
build_skills_list,
|
||||
merge_enabled_skills,
|
||||
build_skills_prompt,
|
||||
infer_private_skills_dir,
|
||||
)
|
||||
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
|
||||
from modules.custom_tool_executor import CustomToolExecutor
|
||||
|
||||
try:
|
||||
from config.limits import THINKING_FAST_INTERVAL
|
||||
except ImportError:
|
||||
THINKING_FAST_INTERVAL = 10
|
||||
|
||||
from modules.container_monitor import collect_stats, inspect_state
|
||||
from core.tool_config import TOOL_CATEGORIES
|
||||
from utils.api_client import DeepSeekClient
|
||||
from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER
|
||||
from utils.host_workspace_debug import write_host_workspace_debug
|
||||
from utils.tool_result_formatter import format_tool_result_for_context
|
||||
from utils.logger import setup_logger
|
||||
from config.model_profiles import (
|
||||
get_model_profile,
|
||||
get_model_prompt_replacements,
|
||||
get_model_context_window,
|
||||
model_supports_image,
|
||||
model_supports_video,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
DISABLE_LENGTH_CHECK = True
|
||||
|
||||
|
||||
class MemoryMixin:
|
||||
"""MainTerminalContextMixin memory 能力 mixin。"""
|
||||
|
||||
def _scan_project_memories(self):
|
||||
"""扫描 .astrion/memory/*.md 并解析 frontmatter 中的 name 和 description"""
|
||||
try:
|
||||
memory_dir = Path(self.project_path) / WORKSPACE_MEMORY_DIRNAME
|
||||
except Exception:
|
||||
return []
|
||||
if not memory_dir.exists() or not memory_dir.is_dir():
|
||||
return []
|
||||
results = []
|
||||
for md_file in sorted(memory_dir.glob("*.md")):
|
||||
try:
|
||||
text = md_file.read_text(encoding="utf-8")
|
||||
name = None
|
||||
description = None
|
||||
if text.startswith("---"):
|
||||
end = text.find("---", 3)
|
||||
if end > 0:
|
||||
frontmatter = text[3:end]
|
||||
for line in frontmatter.strip().split("\n"):
|
||||
line_stripped = line.strip()
|
||||
if line_stripped.startswith("name:"):
|
||||
name = line_stripped.split(":", 1)[1].strip()
|
||||
elif line_stripped.startswith("description:"):
|
||||
description = line_stripped.split(":", 1)[1].strip()
|
||||
results.append({
|
||||
"file": md_file.name,
|
||||
"name": name or md_file.stem,
|
||||
"description": description or "",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
def _build_memory_system_content(self) -> str:
|
||||
"""构建记忆系统的 prompt 内容"""
|
||||
template = self.load_prompt("memory_system").strip()
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
try:
|
||||
global_memory = self.memory_manager.read_main_memory()
|
||||
global_memory_text = global_memory.strip() if global_memory else ""
|
||||
except Exception:
|
||||
global_memory_text = ""
|
||||
|
||||
project_memories = self._scan_project_memories()
|
||||
if project_memories:
|
||||
lines = []
|
||||
for m in project_memories:
|
||||
desc = m.get("description", "")
|
||||
if desc:
|
||||
lines.append(f".astrion/memory/{m['file']}:{desc}")
|
||||
else:
|
||||
lines.append(f".astrion/memory/{m['file']}")
|
||||
project_memory_list = "\n".join(lines)
|
||||
else:
|
||||
project_memory_list = ""
|
||||
|
||||
result = template
|
||||
if global_memory_text:
|
||||
result = result.replace("{global_memory}", global_memory_text)
|
||||
result = result.replace("[global_memory_empty]", "").replace("[/global_memory_empty]", "")
|
||||
else:
|
||||
result = result.replace("{global_memory}", "")
|
||||
|
||||
if project_memory_list:
|
||||
result = result.replace("{project_memory_list}", project_memory_list)
|
||||
result = result.replace("[project_memory_empty]", "").replace("[/project_memory_empty]", "")
|
||||
else:
|
||||
result = result.replace("{project_memory_list}", "")
|
||||
|
||||
return result.strip()
|
||||
419
core/main_terminal_parts/context/messages.py
Normal file
419
core/main_terminal_parts/context/messages.py
Normal file
@ -0,0 +1,419 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
from config import (
|
||||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||||
TERMINAL_SANDBOX_MODE,
|
||||
TERMINAL_SANDBOX_CPUS,
|
||||
TERMINAL_SANDBOX_MEMORY,
|
||||
PROJECT_MAX_STORAGE_MB,
|
||||
CUSTOM_TOOLS_ENABLED,
|
||||
)
|
||||
|
||||
from modules.file_manager import FileManager
|
||||
from modules.search_engine import SearchEngine
|
||||
from modules.terminal_ops import TerminalOperator
|
||||
from modules.memory_manager import MemoryManager
|
||||
from modules.terminal_manager import TerminalManager
|
||||
from modules.todo_manager import TodoManager
|
||||
from modules.sub_agent import SubAgentManager
|
||||
from modules.webpage_extractor import extract_webpage_content, tavily_extract
|
||||
from modules.multi_agent.prompts import build_available_agents_prompt
|
||||
from modules.ocr_client import OCRClient
|
||||
from modules.easter_egg_manager import EasterEggManager
|
||||
from modules.personalization_manager import (
|
||||
load_personalization_config,
|
||||
build_personalization_prompt,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX,
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
|
||||
)
|
||||
from modules.skills_manager import (
|
||||
get_skills_catalog,
|
||||
build_skills_list,
|
||||
merge_enabled_skills,
|
||||
build_skills_prompt,
|
||||
infer_private_skills_dir,
|
||||
)
|
||||
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
|
||||
from modules.custom_tool_executor import CustomToolExecutor
|
||||
|
||||
try:
|
||||
from config.limits import THINKING_FAST_INTERVAL
|
||||
except ImportError:
|
||||
THINKING_FAST_INTERVAL = 10
|
||||
|
||||
from modules.container_monitor import collect_stats, inspect_state
|
||||
from core.tool_config import TOOL_CATEGORIES
|
||||
from utils.api_client import DeepSeekClient
|
||||
from utils.context_manager import ContextManager, AUTO_SHALLOW_PLACEHOLDER
|
||||
from utils.host_workspace_debug import write_host_workspace_debug
|
||||
from utils.tool_result_formatter import format_tool_result_for_context
|
||||
from utils.logger import setup_logger
|
||||
from config.model_profiles import (
|
||||
get_model_profile,
|
||||
get_model_prompt_replacements,
|
||||
get_model_context_window,
|
||||
model_supports_image,
|
||||
model_supports_video,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
DISABLE_LENGTH_CHECK = True
|
||||
|
||||
|
||||
class MessagesMixin:
|
||||
"""MainTerminalContextMixin messages 能力 mixin。"""
|
||||
|
||||
def build_messages(self, context: Dict, user_input: str) -> List[Dict]:
|
||||
"""构建消息列表(添加终端内容注入)"""
|
||||
try:
|
||||
file_tree_preview = (context.get("project_info", {}).get("file_tree") or "").splitlines()
|
||||
write_host_workspace_debug(
|
||||
"main_terminal.build_messages.context_snapshot",
|
||||
terminal_id=id(self),
|
||||
terminal_project_path=str(getattr(self, "project_path", "")),
|
||||
context_project_path=str(getattr(getattr(self, "context_manager", None), "project_path", "")),
|
||||
project_info_path=str(context.get("project_info", {}).get("path", "")),
|
||||
file_tree_first_line=file_tree_preview[0] if file_tree_preview else "",
|
||||
current_conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# 根据当前模型多模态能力选择系统提示。system prompt 按对话冻结;
|
||||
# 工具 schema 保持动态,由 define_tools 每轮根据真实状态生成。
|
||||
current_model = getattr(self, "model_key", None)
|
||||
prompt_name = "main_system_vl" if (model_supports_image(current_model) or model_supports_video(current_model)) else "main_system"
|
||||
model_key = getattr(self, "model_key", None)
|
||||
prompt_replacements = get_model_prompt_replacements(model_key)
|
||||
|
||||
is_multi_agent_mode = bool(getattr(self, "multi_agent_mode", False))
|
||||
|
||||
def _build_main_system_prompt() -> str:
|
||||
if is_multi_agent_mode:
|
||||
# 多智能体模式下主 prompt 使用 Team Leader 专属模板
|
||||
try:
|
||||
from modules.multi_agent.prompts import _load_template
|
||||
return _load_template("master")
|
||||
except Exception as exc:
|
||||
logger.warning(f"[messages] 加载多智能体主 prompt 失败: {exc}")
|
||||
system_prompt_template = self.load_prompt(prompt_name)
|
||||
# main_system.txt / main_system_vl.txt 仅使用 {model_description}
|
||||
return system_prompt_template.format(
|
||||
model_description=prompt_replacements.get("model_description", "")
|
||||
)
|
||||
|
||||
main_system_frozen_key = (
|
||||
"frozen_main_system_prompt_multi_agent"
|
||||
if is_multi_agent_mode
|
||||
else "frozen_main_system_prompt"
|
||||
)
|
||||
system_prompt = self._get_or_init_frozen_prompt(
|
||||
main_system_frozen_key,
|
||||
_build_main_system_prompt,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
|
||||
personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir)
|
||||
shallow_replace_enabled = bool(personalization_config.get("auto_shallow_compress_enabled", False)) if isinstance(personalization_config, dict) else False
|
||||
|
||||
# 顺序:主prompt → 权限模式 → 执行环境 → 最近对话 → 个性化配置 → 工作区信息 → AGENTS.md → skills → 记忆 → 自定义 → 禁用提示
|
||||
|
||||
# 权限模式
|
||||
permission_mode_message = self._get_or_init_frozen_mode_prompt(
|
||||
"frozen_permission_prompt",
|
||||
self._build_permission_mode_message,
|
||||
)
|
||||
if permission_mode_message:
|
||||
messages.append({"role": "system", "content": permission_mode_message})
|
||||
|
||||
# 执行环境
|
||||
execution_mode_message = self._get_or_init_frozen_mode_prompt(
|
||||
"frozen_execution_prompt",
|
||||
self._build_execution_mode_message,
|
||||
)
|
||||
if execution_mode_message:
|
||||
messages.append({"role": "system", "content": execution_mode_message})
|
||||
|
||||
# 最近对话
|
||||
def _build_recent_conversations_prompt() -> str:
|
||||
recent_conversations_enabled = (
|
||||
bool(personalization_config.get("recent_conversations_prompt_enabled", False))
|
||||
if isinstance(personalization_config, dict)
|
||||
else False
|
||||
)
|
||||
if not recent_conversations_enabled:
|
||||
return ""
|
||||
try:
|
||||
recent_limit = int(
|
||||
personalization_config.get(
|
||||
"recent_conversations_prompt_limit",
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
recent_limit = RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT
|
||||
recent_limit = max(
|
||||
RECENT_CONVERSATIONS_PROMPT_LIMIT_MIN,
|
||||
min(RECENT_CONVERSATIONS_PROMPT_LIMIT_MAX, recent_limit),
|
||||
)
|
||||
return self._build_recent_conversations_message(limit=recent_limit) or ""
|
||||
|
||||
recent_conversations_prompt = self._get_or_init_frozen_prompt(
|
||||
"frozen_recent_conversations_prompt",
|
||||
_build_recent_conversations_prompt,
|
||||
)
|
||||
if recent_conversations_prompt:
|
||||
messages.append({"role": "system", "content": recent_conversations_prompt})
|
||||
|
||||
# 个性化配置
|
||||
def _build_personalization_system_prompt() -> str:
|
||||
personalization_block = build_personalization_prompt(personalization_config, include_header=False)
|
||||
if not personalization_block:
|
||||
return ""
|
||||
personalization_template = self.load_prompt("personalization").strip()
|
||||
if personalization_template and "{personalization_block}" in personalization_template:
|
||||
return personalization_template.format(personalization_block=personalization_block)
|
||||
if personalization_template:
|
||||
return f"{personalization_template}\n{personalization_block}"
|
||||
return personalization_block
|
||||
|
||||
personalization_text = self._get_or_init_frozen_prompt(
|
||||
"frozen_personalization_prompt",
|
||||
_build_personalization_system_prompt,
|
||||
)
|
||||
if personalization_text:
|
||||
messages.append({"role": "system", "content": personalization_text})
|
||||
|
||||
# 工作区信息
|
||||
workspace_system = self._get_or_init_frozen_prompt(
|
||||
"frozen_workspace_prompt",
|
||||
lambda: self.context_manager._build_workspace_system_message(context) or "",
|
||||
)
|
||||
if workspace_system:
|
||||
messages.append({"role": "system", "content": workspace_system})
|
||||
|
||||
# AGENTS.md
|
||||
def _build_agents_md_prompt() -> str:
|
||||
agents_md_inject_enabled = bool(personalization_config.get("agents_md_auto_inject", False)) if isinstance(personalization_config, dict) else False
|
||||
if not agents_md_inject_enabled:
|
||||
return ""
|
||||
agents_md_content = self._load_agents_md_content()
|
||||
if not agents_md_content:
|
||||
return ""
|
||||
agents_md_template = self.load_prompt("agents_md_inject").strip()
|
||||
if agents_md_template and "{{AGENTS_MD_CONTENT}}" in agents_md_template:
|
||||
return agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content)
|
||||
return f"【AGENTS.md 项目规范】\n\n{agents_md_content}\n\n---\n请注意:以上规范来自工作区根目录的 AGENTS.md 文件,若有冲突请以 AGENTS.md 为准。"
|
||||
|
||||
# AGENTS.md 注入开关可能变化,只在开启时生成并缓存,避免关闭时把空字符串冻住
|
||||
agents_md_inject_enabled = (
|
||||
bool(personalization_config.get("agents_md_auto_inject", False))
|
||||
if isinstance(personalization_config, dict)
|
||||
else False
|
||||
)
|
||||
if agents_md_inject_enabled:
|
||||
agents_md_text = self._get_or_init_frozen_prompt(
|
||||
"frozen_agents_md_prompt",
|
||||
_build_agents_md_prompt,
|
||||
)
|
||||
if agents_md_text:
|
||||
messages.append({"role": "system", "content": agents_md_text})
|
||||
|
||||
# skills 列表
|
||||
skills_catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir))
|
||||
enabled_skills = merge_enabled_skills(
|
||||
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
|
||||
skills_catalog,
|
||||
personalization_config.get("skills_catalog_snapshot") if isinstance(personalization_config, dict) else None,
|
||||
)
|
||||
def _build_skills_system_prompt() -> str:
|
||||
skills_template = self.load_prompt("skills_system").strip()
|
||||
skills_list = build_skills_list(skills_catalog, enabled_skills)
|
||||
return build_skills_prompt(skills_template, skills_list) or ""
|
||||
|
||||
skills_prompt = self._get_or_init_frozen_prompt(
|
||||
"frozen_skills_prompt",
|
||||
_build_skills_system_prompt,
|
||||
)
|
||||
if skills_prompt:
|
||||
messages.append({"role": "system", "content": skills_prompt})
|
||||
|
||||
# 记忆系统(总体长期记忆 + 项目记忆)
|
||||
def _build_memory_system_prompt() -> str:
|
||||
return self._build_memory_system_content()
|
||||
|
||||
memory_prompt = self._get_or_init_frozen_prompt(
|
||||
"frozen_memory_prompt",
|
||||
_build_memory_system_prompt,
|
||||
)
|
||||
if memory_prompt:
|
||||
messages.append({"role": "system", "content": memory_prompt})
|
||||
|
||||
# API 自定义 system prompt
|
||||
custom_system_prompt = self._get_or_init_frozen_prompt(
|
||||
"frozen_custom_system_prompt",
|
||||
lambda: (
|
||||
getattr(self.context_manager, "custom_system_prompt", "").strip()
|
||||
if isinstance(getattr(self.context_manager, "custom_system_prompt", None), str)
|
||||
else ""
|
||||
),
|
||||
)
|
||||
if custom_system_prompt:
|
||||
messages.append({"role": "system", "content": custom_system_prompt})
|
||||
|
||||
# 禁用工具提示
|
||||
disabled_notice = self._get_or_init_frozen_prompt(
|
||||
"frozen_disabled_tools_prompt",
|
||||
lambda: self._format_disabled_tool_notice() or "",
|
||||
)
|
||||
|
||||
# 多智能体模式额外提示词:可用的子智能体角色(动态 prompt,第一个用户消息后冻结)
|
||||
if getattr(self, "multi_agent_mode", False):
|
||||
try:
|
||||
available_agents_prompt = self._get_or_init_frozen_prompt(
|
||||
"frozen_available_agents_prompt",
|
||||
lambda: build_available_agents_prompt() or "",
|
||||
)
|
||||
if available_agents_prompt:
|
||||
messages.append({"role": "system", "content": available_agents_prompt})
|
||||
except Exception as exc:
|
||||
logger.warning(f"[messages] 注入可用子智能体 prompt 失败: {exc}")
|
||||
if disabled_notice:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": disabled_notice
|
||||
})
|
||||
|
||||
# 添加对话历史(保留完整结构,包括tool_calls和tool消息)
|
||||
conversation = context["conversation"]
|
||||
replaced_tool_count = 0
|
||||
deep_compacted_skipped = 0
|
||||
for idx, conv in enumerate(conversation):
|
||||
metadata = conv.get("metadata") or {}
|
||||
# 深压缩:被标记为 deep_compacted 的消息(整段已压缩前缀)原文保留在历史中用于展示,
|
||||
# 但构建请求时整体跳过,不纳入上下文。由于按连续前缀整体标记,
|
||||
# 不会出现 assistant.tool_calls 与其 tool 响应被拆散导致的配对悬空。
|
||||
if metadata.get("deep_compacted"):
|
||||
deep_compacted_skipped += 1
|
||||
continue
|
||||
if conv["role"] == "assistant":
|
||||
# Assistant消息可能包含工具调用
|
||||
message = {
|
||||
"role": conv["role"],
|
||||
"content": conv["content"]
|
||||
}
|
||||
# 对于思考模式(如 DeepSeek thinking),assistant 历史消息中的
|
||||
# reasoning_content 即使为空字符串也需要原样回传,否则下一轮可能被
|
||||
# API 判定为“未回传 reasoning_content”并返回 400。
|
||||
if "reasoning_content" in conv:
|
||||
message["reasoning_content"] = conv.get("reasoning_content", "")
|
||||
# 如果有工具调用信息,添加到消息中
|
||||
tool_calls = conv.get("tool_calls") or []
|
||||
if tool_calls and self._tool_calls_followed_by_tools(conversation, idx, tool_calls):
|
||||
message["tool_calls"] = tool_calls
|
||||
messages.append(message)
|
||||
|
||||
elif conv["role"] == "tool":
|
||||
if shallow_replace_enabled and metadata.get("auto_shallow_compacted"):
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"content": AUTO_SHALLOW_PLACEHOLDER,
|
||||
"tool_call_id": conv.get("tool_call_id", ""),
|
||||
"name": conv.get("name", "")
|
||||
})
|
||||
replaced_tool_count += 1
|
||||
continue
|
||||
# Tool消息需要保留完整结构
|
||||
images = conv.get("images") or metadata.get("images") or []
|
||||
videos = conv.get("videos") or metadata.get("videos") or []
|
||||
media_refs = conv.get("media_refs") or metadata.get("media_refs") or []
|
||||
content_value = conv.get("content")
|
||||
if isinstance(content_value, list):
|
||||
content_payload = content_value
|
||||
elif images or videos or media_refs:
|
||||
content_payload = self.context_manager._build_content_with_images(
|
||||
content_value,
|
||||
images,
|
||||
videos,
|
||||
media_refs=media_refs,
|
||||
)
|
||||
else:
|
||||
content_payload = content_value
|
||||
message = {
|
||||
"role": "tool",
|
||||
"content": content_payload,
|
||||
"tool_call_id": conv.get("tool_call_id", ""),
|
||||
"name": conv.get("name", "")
|
||||
}
|
||||
messages.append(message)
|
||||
|
||||
else:
|
||||
# User 或普通 System 消息
|
||||
images = conv.get("images") or metadata.get("images") or []
|
||||
videos = conv.get("videos") or metadata.get("videos") or []
|
||||
media_refs = conv.get("media_refs") or metadata.get("media_refs") or []
|
||||
content_payload = (
|
||||
self.context_manager._build_content_with_images(
|
||||
conv["content"],
|
||||
images,
|
||||
videos,
|
||||
media_refs=media_refs,
|
||||
)
|
||||
if (images or videos or media_refs) else conv["content"]
|
||||
)
|
||||
# 调试:记录所有 system 消息
|
||||
if conv["role"] == "system":
|
||||
logger.info(f"[DEBUG build_messages] 添加 system 消息: content前50字={conv['content'][:50]}")
|
||||
# 调试:记录多智能体子智能体输出消息是否进入上下文
|
||||
if metadata.get("multi_agent_message"):
|
||||
logger.info(
|
||||
f"[DEBUG build_messages] 添加多智能体子智能体消息到上下文: "
|
||||
f"role={conv['role']}, subtype={metadata.get('multi_agent_subtype')}, "
|
||||
f"display_name={metadata.get('multi_agent_display_name')}, "
|
||||
f"content前80字={str(conv.get('content', ''))[:80]}"
|
||||
)
|
||||
messages.append({
|
||||
"role": conv["role"],
|
||||
"content": content_payload
|
||||
})
|
||||
|
||||
# 当前用户输入已经在conversation中了,不需要重复添加
|
||||
if shallow_replace_enabled:
|
||||
print(f"[ContextCompression] build_messages 替换tool占位符: {replaced_tool_count} 条")
|
||||
if deep_compacted_skipped:
|
||||
print(f"[ContextCompression] build_messages 跳过已深压缩消息: {deep_compacted_skipped} 条")
|
||||
return messages
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user