Compare commits

...

248 Commits

Author SHA1 Message Date
121793367a feat(prompts): 增加信息诚实与验证规范\n\n- 在主系统提示词、视觉版提示词、多智能体 Team Leader 提示词、\n 子智能体通用提示词中统一加入五小节约束:\n 1. 信息来源必须明确标注;\n 2. 结论确定性必须分级;\n 3. 禁止将操作完成等同于任务完成;\n 4. 替代方案必须经用户许可并说明缺陷;\n 5. 异常排查优先怀疑自己。 2026-07-18 00:57:25 +08:00
cfdec831d8 fix(static): 修复极简模式并行工具时摘要行空白与 reel 动画消失
089291f7 将 .summary-tool-reel-window 宽度改为 auto,但其内部
reel 轨道为 absolute 定位不贡献宽度,导致窗口塌缩为 0 宽:
并行工具调用时摘要行渲染空白、滚动 reel 动画不可见。

恢复 reel 窗口 width: min(100%, 36em) 与上下渐变遮罩,
同时保留 089291f7 的摘要行图标偏移修复(18px 固定图标容器
+ clip-path 右侧裁剪 + summary-icon-inner 左移 10px)。
2026-07-18 00:57:25 +08:00
1d0a7e95fa feat(multi-agent): 扩展 sleep 工具模式并修复 /new 模式切换状态继承
- sleep 工具支持 wait_sub_agent_output / wait_sub_agent_ids / wait_runcommand_id 模式
- 多智能体工具移除 ask_sub_agent,新增 stop_sub_agent
- 前后端补齐对应 formatter、渲染与工具执行分支
- 修复从多智能体模式切回 /new 后,新对话继承 terminal.multi_agent_mode 导致实际运行在多智能体模式的问题
2026-07-17 22:51:04 +08:00
d4c3e73134 fix(sub_agent): 移除 search_workspace 工具并清理内存调试代码
- 删除子智能体 search_workspace 工具定义、路由、formatter 与测试
- 传统/多智能体模式共用 SUB_AGENT_TOOLS,一处删除同时生效
- 老对话恢复后再调用 search_workspace 会返回 未知工具 错误
- 清理多智能体排查期间加入的 memory_debug 模块及所有调用点
  (modules/memory_debug.py 删除,server/ core/ utils/ modules/sub_agent/ 中相关埋点全部移除)
- 顺手删除 SubAgentActivityDialog 中 search_workspace 的历史显示文案
2026-07-17 14:05:35 +08:00
2b131de702 perf(debug): 内存监控增强为 100ms 采样 + 多维度指标
- 采样间隔从 1s 降到 100ms,spike 阈值降到 50MB
- 增加 uss/shared/private/data/dirty/compressed 等指标
- 更贴近 macOS 活动监视器'内存'列的统计口径
2026-07-17 12:16:16 +08:00
faef998a9a perf(debug): 增强内存监控:每秒采样 + 更多关键路径埋点
- 改用 psutil 实时 RSS 取代历史最大 ru_maxrss
- 新增后台每秒采样线程,检测到 >100MB/s 增长立即记录
- WebTerminal 初始化时自动启动采样
- 增加子智能体恢复加载 conversation.json、httpx 流式/非流式响应、
  工具返回结果、模型输出组装等埋点
2026-07-15 17:33:47 +08:00
248f5a4b1a fix(debug): 修正 macOS 内存单位换算 2026-07-15 17:26:09 +08:00
310d82f4b8 perf(debug): 添加多智能体模式内存监控埋点
- 新增 modules/memory_debug.py 统一内存监控模块
- 在子智能体任务、主对话 build_messages、多智能体消息注入、
  API 请求准备、深压缩等关键路径记录 RSS/VMS 和消息规模
- 日志输出到 ~/.astrion/astrion/host/logs/memory_debug.log
2026-07-15 17:02:33 +08:00
3138d23687 feat(multi-agent): 重构 Team Leader 提示词分工策略并新增 sleep wait_sub_agent_output_ids 2026-07-15 14:55:14 +08:00
11ffdc2bb5 fix: 优化浮动菜单位置计算与 token 处理 2026-07-15 04:37:26 +08:00
089291f77e fix(static): 修复极简模式摘要行图标偏移、侧边栏空工作区显示及 folder-closed 图标缺失 2026-07-15 04:34:24 +08:00
4f3ba722a1 fix(multi-agent): web模式支持加载预设角色,多智能体面板显示活跃子智能体 2026-07-15 02:38:12 +08:00
69e3ef51b6 feat(multi-agent): 明确 Team Leader 是子智能体的 user 2026-07-15 01:42:30 +08:00
b2b8013f3a fix(static): add avatar favicon to login and other standalone pages 2026-07-15 01:17:24 +08:00
f6cbe78d68 fix(static): make avatar favicon adapt to browser color scheme 2026-07-15 01:09:13 +08:00
57aaf76809 fix(static): make avatar favicon permanently white 2026-07-15 01:05:49 +08:00
a60e164aa2 fix(static): center avatar favicon and use pure black/white for light/dark modes 2026-07-15 01:01:59 +08:00
f8f5d26ee3 feat(static): add Astrion avatar favicon and touch icons 2026-07-15 00:51:20 +08:00
4e1d08f106 fix(frontend): 修复多个前端交互问题
- 修复运行时 intent Unicode 转义显示异常(后端 extract_intent_from_partial 解码)
- 修复 ask_user 弹窗问题/说明不换行
- 修复刷新页面后等待回答提示不恢复(添加 fetchPendingUserQuestions 并在初始化路径调用)
- 修复回答问题弹窗长问题撑满窗口(header/context 限高滚动)
- 修复角色编辑器模型选择菜单定位与滚动
- 在 AGENTS.md 中再次强调前端调试日志必须统一筛选词

测试:/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9 -m unittest test.test_server_refactor_smoke; npm run build
2026-07-15 00:31:13 +08:00
297a6cc5ec Merge branch 'multiagent-mode' 2026-07-14 23:41:20 +08:00
7ea4638c8c feat(multi_agent): 优化 Team Leader 与子智能体 prompt,新增 Brainstormer 角色 2026-07-14 23:40:02 +08:00
351cd311c2 fix(multi-agent): 修复压缩404、prompt冻结串扰、子智能体状态串扰及/multiagent/new跳转问题 2026-07-14 23:22:06 +08:00
ce92697181 feat(multi_agent): add formatters and structured rendering for multi-agent tools 2026-07-14 22:14:55 +08:00
571ed6c81f fix(sub-agent): 修复传统模式 terminate 后子智能体仍继续调用工具的问题
- _call_model 中硬取消时直接抛出 CancelledError,避免返回半成品 tool_calls
- 软停止时仍优雅 break,后续由 _run_loop 丢弃半成品并进入 idle
- 保持 _mark_task_done 不覆盖 terminated 状态
- 侧边栏当前对话运行态标记修复保留
2026-07-14 20:37:57 +08:00
0aa37b83ee fix(stop-button): 修复REST轮询路径两个bug
1. lifecycle.ts handleTaskStopped: 移除'再次按下停止按钮以结束后台任务'弹窗。
   后台任务停止不再走停止按钮二次点击路径。

2. 主对话空闲+后台任务跑时输入栏依然为停止按钮:
   根因 - App.vue 传给 InputComposer 的 streaming-message 用的是
   composerStreamingForInput (= composerBusy && !displayLockEngaged),
   只要有后台任务 taskInProgress=true 就让 streamingMessage=true。
   改为传 streamingUi (= streamingMessage || hasPendingToolActions),
   只看主对话自身是否在流式输出,与后台任务无关。

3. 后端 chat_flow_task_main.py 用户主动停止时发的 task_stopped
   事件没带 has_running_* 字段,导致前端误判为完全空闲。
   补全字段(has_running_sub_agents/has_running_background_commands/
   has_running_multi_agent),前端用此信息决定是否保持对话运行态。
2026-07-14 19:46:31 +08:00
ae70e4902e refactor(stop-button): 拆分停止按钮和后台任务控制
停止按钮现在只停主智能体,与后台任务无关。
- 后端 cancel_task 简化:移除两段式判断、force_stop参数、is_ma_mode分支
- 删除 _force_stop_multi_agent 方法
- 新增 /api/sub_agents/stop_all 接口(mode=terminate/soft_stop)
- 前端 stopTask 移除 forceStop 概念,只取消主智能体
- 取消期间不再二次点击终结子智能体

子智能体停止按钮:
- InputComposer state bar 加'x个子智能体运行中'按钮
- 独立浮层显示,不依赖 git 栏(参考 askuser 方案)
- 点击传统模式弹窗终结;多智能体弹窗软停止
- App.vue 新增 handleStopAllSubAgents 调 ui.confirmDialog

后台指令停止按钮:
- LeftPanel 后台指令标签右边加停止按钮
- 仅当 panelMode=backgroundCommands 且存在运行中后台指令时显示
- 调 backgroundCommandStore.stopAllCommands 无二次确认

showStopIcon 只看主对话 streaming 状态:
- 后台任务在跑但主对话空闲时显示发送按钮
- 多智能体 / 传统模式统一处理

子智能体 store 加 stopAllAgents(mode) action 调新 API

useLegacySocket task_stopped handler 简化:
- 移除 multi_agent_reloaded 重载
- 移除'再次按下停止按钮以结束后台任务'提示
- 保留 has_running_multi_agent/sub_agents 用于 taskInProgress 判定

清理调试日志:[TaskCancel][stop_debug], [STOP_DEBUG] 全部去掉
保留 ma_debug 框架用于多智能体调试
2026-07-14 18:34:56 +08:00
30bfb79cbd wip(multi-agent): 停止按钮软停止链路调试日志+对话切换500修复+子智能体进度面板+多智能体软停止机制 2026-07-14 17:50:15 +08:00
4fcd0ccc4d feat(multi-agent): 子智能体系统升级
- prompt改造:子智能体注入AGENTS.md/执行环境/工作区信息/skills/项目记忆
- 工具增加:read_skill/recall_project_memory/todo_create/todo_update_task/save_webpage
- 上下文压缩:深度压缩机制,记录current_context_tokens,默认150k阈值可配置
- 模型升级:sub_agent_models.json支持thinkmode_status/extra_parameter,与主智能体对齐
- 角色管理三层结构:源码树预设multi_agent_roles/ + 运行态host/web预设 + web按用户隔离
- 启动同步:initialize_system调用sync_preset_roles同步预设到host和web运行态
- 模式判断:API用session.host_mode,工具用data_dir路径判断,不依赖IS_HOST_MODE
- 前端:个人空间新增子智能体管理页(角色CRUD/压缩阈值/模型选择),复用个人空间样式
- 入口改造:登录页移除多智能体按钮,QuickMenu加模式切换项,运行中对话禁止切换
- 工具调整:多智能体模式create_sub_agent去掉timeout/deliverables_dir参数
- skill禁止:sub-agent-guide在多智能体模式下禁止阅读
- .agents/统一为.astrion/路径修复
2026-07-14 02:28:45 +08:00
3c4ae917b2 fix(web_terminal): 程序启动自动恢复最近对话时不恢复模型,避免 /new 页面显示旧对话模型;显式加载对话时仍恢复保存的模型 2026-07-13 21:46:03 +08:00
a95e969ba2 fix(web_terminal): 程序启动自动恢复最近对话时不恢复模型,避免 /new 页面显示旧对话模型;显式加载对话时仍恢复保存的模型 2026-07-13 21:43:51 +08:00
58ec936a8c docs(agents): 补充多智能体模式章节 §11
包含角色/实例、执行机制、消息池与派发链路(含情况1/2/3
和 4 条硬约束)、通知池独立、渲染判断、调试日志、已知坑
与对话切换状态保留等所有 Agent 改代码必须知道的约束。
2026-07-13 20:13:38 +08:00
e29ccb318e fix(multi-agent): 修复情况2主消息池派发链路
修复多智能体模式下主智能体空闲时收不到子智能体输出推送的一系列 bug:

1. poll_multi_agent_notifications 死锁:原实现先等所有 running 实例
   退出再 drain 消息池,导致 ask_master 在 await 期间永远等不到主对话
   回答。改为池优先:pool 有消息立即派发,不管 running 状态。

2. _dispatch_multi_agent_idle_messages 缺 import:调用
   inject_multi_agent_master_message 但文件顶部从未导入,NameError
   被外层 except 吞掉,task 永远建不起来。

3. dispatch 内调试日志引用 rec 错位:dispatch_ma_idle_sender_user_message
   被放到 create_chat_task 之前,触发 UnboundLocalError,task 同样建不起来。

4. session_data['auto_user_message_payload'] / preceding_user_notices
   payload 漏写 auto_message_type:前端 isMultiAgentMessage() 只认
   auto_message_type.startsWith('multi_agent_'),空字符串走 fallback
   通知渲染。

5. dispatch 第①步重复持久化:对包含 last 的全部消息都调
   inject_multi_agent_master_message 落盘,之后 task 又在 handle_task_with_sender
   再 add_conversation,导致历史里出现两条相同 user 消息(前一条多智能体渲染,
   后一条通知渲染)。前置 N-1 条只持久化一次,最后一条交给后续 task 自己持久化。

6. last 赋值时机错位:last_emit_payload 在 last=parsed_messages[-1] 之前引用,
   UnboundLocalError 再次吃掉后续链路。

7. handle_task_with_sender 多智能体分支漏写 visibility='chat':
   _user_message_ui_defaults('sub_agent') 默认 visibility='compact',
   透传到落盘 metadata 后,前端从后端加载历史时走通知渲染分支。显式
   user_message_metadata['visibility']='chat' 强制走多智能体专用渲染。
2026-07-13 20:05:02 +08:00
fe6fba3958 feat(multi_agent): 提取prompt、恢复任务、优化侧边栏与sleep工具
- 将多智能体/子智能体prompt从代码提取到prompts/multi_agent/和prompts/sub_agent/

- 多智能体模式添加可用的子智能体动态prompt并冻结

- 重启后自动从conversation.json恢复多智能体idle任务

- 修复新对话侧边栏显示其他对话子智能体的问题

- 简化子智能体弹窗输出样式

- sleep工具在多智能体模式下移除wait_sub_agent_ids参数
2026-07-13 14:01:48 +08:00
de5160ead0 feat(ui): 子智能体消息 Markdown 渲染与气泡展开淡出优化
- 子智能体输出内容使用 MarkdownRenderer 渲染
- 修复多智能体气泡内 Markdown 元素底色适配气泡背景
- 修复对话区域 Markdown 行内代码与加粗样式
- 用户消息/子智能体消息气泡展开按钮增加横纵向淡出效果
- 展开/收起时按钮位置通过 margin 平滑过渡
2026-07-13 00:48:05 +08:00
692748d567 fix(multi-agent): 修复消息渲染、分类、存储与滚动抖动
- 子智能体消息渲染为左侧对话气泡
- 多智能体通知细分为进度/完成/提问三类
- 修复多智能体会话存储到 mutiagents 目录并迁移数据
- 修复 token-statistics API 支持多智能体会话
- 修复 ma_debug 未定义报错
- 移除 .message-block content-visibility 修复滚动抖动
2026-07-12 23:09:56 +08:00
7f2ad9144d fix(multi-agent): 修复子智能体状态误判、后台任务隔离与 idle 等待异常 2026-07-12 18:39:21 +08:00
f652118527 fix(multi-agent): 跨事件循环问答、线程安全取消、任务结束汇报格式、工具参数可选 2026-07-12 15:09:16 +08:00
8e5d4f05d9 fix(multi-agent): 多智能体模式下侧边栏只加载对应模式的对话记录
后端 get_conversation_list / web_terminal / conversation路由 增加 multi_agent_mode 查询参数:
- multi_agent_mode=1 仅返回 multi_agent_mode 对话
- multi_agent_mode=0 仅返回常规对话
- 不传则不过滤

前端 loadConversationsList / loadWorkspaceConversations 根据 this.multiAgentMode 自动带参
2026-07-12 12:20:18 +08:00
811974d6e7 feat(multi-agent): 在现有架构上直接实现多智能体模式实验功能
放弃完全隔离策略,改为在现有 MainTerminal/SubAgentManager/SubAgentTask 主链路
按对话级开关 metadata.multi_agent_mode=true 增加多智能体分支。

新增模块:
- modules/multi_agent/__init__.py: 模块入口
- modules/multi_agent/role_store.py: 角色 Markdown Frontmatter 解析与归档
- modules/multi_agent/state.py: 多智能体会话状态机与消息格式化
- modules/multi_agent/prompts.py: 主智能体(Team Leader) + 子智能体提示词
- modules/multi_agent/tools.py: 9 个主智能体工具 + 4 个子智能体工具定义
- server/multi_agent.py: /multiagent/new 页面 + /api/multiagent/* 蓝图

现有代码改动:
- modules/sub_agent/task.py: 扩展 multi_agent_mode/multi_agent_state/display_name 字段,
  增加 ask_master/ask_other_agent/answer_other_agent/list_active_sub_agents 工具处理逻辑,
  子智能体自然结束 assistant 输出即本轮结束(不调用 finish_task),上下文保留。
- modules/sub_agent/manager.py: create_sub_agent 增加 multi_agent_mode/role_id/display_name 参数,
  增加 get_or_create_multi_agent_state/get_multi_agent_state/inject_message_to_sub_agent/_on_multi_agent_task_done 方法。
- core/main_terminal_parts/tools_definition/agent_tools.py: 多智能体模式下用 modules.multi_agent.tools 替换旧版工具集。
- core/main_terminal_parts/context/messages.py: 多智能体模式下追加 Team Leader 系统提示词。
- core/main_terminal_parts/tools_execution.py: create_sub_agent handler 增加多智能体分支,新增 send_message_to_sub_agent/ask_sub_agent/answer_sub_agent_question/create_custom_agent/list_agents/list_active_sub_agents handler。
- core/web_terminal.py: load_conversation 时检测 metadata.multi_agent_mode 设置 self.multi_agent_mode。
- server/app_legacy.py: 注册 multi_agent_bp 蓝图。

前端改动:
- static/src/auth/LoginApp.vue: 登录页增加'多智能体模式(beta)'按钮
- static/src/app/methods/ui/route.ts: 识别 /multiagent/new 和 /multiagent/conv_xxx 路径,进入多智能体模式并创建带 metadata.multi_agent_mode=true 的对话
- static/src/app/state.ts: 增加 multiAgentMode 状态字段

数据:
- ~/.astrion/astrion/host/mutiagents/agents/: 4 个预置角色 ui-operator / full-stack-engineer / code-reviewer / researcher
- ~/.astrion/astrion/host/mutiagents/conversations/: 会话数据

验证:所有 Python 文件语法检查通过;冒烟测试 test.test_server_refactor_smoke 6 项全通过;前端构建通过(6.04s);模块导入与功能断言测试全部通过。
2026-07-12 03:26:02 +08:00
e8b857f3ae docs(multi-agent): 添加多智能体模式设计文档7份 2026-07-12 02:28:47 +08:00
c08c4ab141 docs(agentskills): 更新子智能体使用指南
- 强调阻塞/后台模式的唯一判断标准是运行期间有没有别的事情要做
- 强调大范围搜索/调查前必须先自己进行浅层搜索
- 重写示例:国产开源智能体调研、程序 bug 定位
- 拆分后台模式场景:单个子智能体用阻塞,多个子智能体用后台并等待通知
2026-07-11 20:40:27 +08:00
3585967b40 fix(ui): 极简模式展开区追底改为stick-to-bottom逻辑 2026-07-11 20:27:07 +08:00
cc8d5817aa feat(ui): 极简模式展开高度限制开关与个人空间持久化 2026-07-11 19:58:35 +08:00
af56de6812 fix(sub_agent): 修复 wait_for_completion 因 _load_state 替换 tasks 字典导致的引用失效 2026-07-11 19:19:41 +08:00
6618ab03dc feat(personalization): 将堆叠块显示模式保存到后端 personalization.json 2026-07-11 11:14:48 +08:00
5244768494 feat(sidebar): 侧边栏按工作区/项目分组显示对话
- 新增个人化设置 group_sidebar_by_workspace / sidebar_pinned_workspaces /
  sidebar_workspace_order,支持分组视图、置顶、排序
- 后端 conversation API 支持 workspace_id 参数,用于获取/创建/加载/删除
  指定工作区的对话,避免切换视图时 session 漂移
- server/context.py 新增 update_session 参数,临时解析目标 terminal 时不污染
  当前会话
- 前端 ConversationSidebar 实现分组折叠、运行中指示器、新建/更多操作菜单、
  动画同步
- config/terminal.py 默认 TERMINAL_SANDBOX_MODE 由 docker 改为 host
2026-07-11 01:51:01 +08:00
6b431ed51a fix(config): 修复 .env 中 ASTRION_DATA_ROOT 的 ~ 未展开导致 settings.json 未加载的问题
config/__init__.py 读取 .env 后,ASTRION_DATA_ROOT 可能为 ~/.astrion/astrion。
原代码直接用 Path(data_root) 构造 settings.json 路径,Path 不会展开 ~,
导致 settings.json 被判定不存在,env_vars(API key 等)未注入,
进而使 model_profiles 解析所有模型失败,抛出未配置可用模型。
2026-07-11 01:50:08 +08:00
3f3662d3a5 fix(chat): 修复极简模式摘要行运行动画右侧被截断的问题 2026-07-10 16:21:52 +08:00
8594b43d8a feat(avatar): 增大工具 SVG 显示尺寸到 66px 2026-07-10 15:58:45 +08:00
19746e67d3 feat(personalization): 将回答时必须考虑的信息从条列式改为大段文字输入 2026-07-10 15:40:18 +08:00
b2cbe0ab5d fix(minimal): 补齐极简模式摘要行中缺失的子智能体/记忆/对话分类统计 2026-07-10 14:30:56 +08:00
67292f9fd3 feat(chat): 扩展极简模式工具分类显示映射 2026-07-10 14:03:06 +08:00
576fbb39d8 fix(ui): 调整输入栏浮层高度并统一/new页手机端间距
- 将 composer-avatar 与 floating-status-row 容器高度分别调整为 45px/34px,
  避免容器高度大于实际内容导致聊天区滚动高度虚增。
- 统一手机端 /new 页面输入栏上移量与欢迎语内边距为桌面端数值,
  修复手机端输入栏与欢迎语间距过大的问题。
2026-07-10 05:20:40 +08:00
d2170567e6 fix(input-composer): 调整AI形象与git状态栏的对齐、间距及文字定位 2026-07-10 04:17:39 +08:00
cb70bc2929 chore(deps): 同步 package-lock.json 与 package.json 2026-07-10 01:45:00 +08:00
f8bba77a60 fix(prompt): 更新 memory_system.txt 中项目记忆路径为 .astrion/memory/ 2026-07-10 01:32:37 +08:00
b7a535fc8f fix(memory): 将项目记忆文件从 .agents/memory 迁移到 .astrion/memory 并更新提示词路径 2026-07-10 01:28:13 +08:00
e3c897947f refactor(paths): 统一迁移运行态数据路径到 Astrion 命名空间
- 工作区内部路径 .agents/ -> .astrion/
- 默认运行态数据根 ~/.agents/agents/ -> ~/.astrion/astrion/
- 环境变量 AGENTS_DATA_ROOT -> ASTRION_DATA_ROOT(无兼容)
- 同步更新代码、测试、文档与脚本中的路径引用
- 新增 .gitignore 忽略 .astrion/ 与 .agents/ 运行态目录
2026-07-10 00:35:15 +08:00
6a2cb820bc fix(versioning): 修复浅备份文件修改归属错误及列表/详情不一致 2026-07-09 18:45:40 +08:00
765196ea3c feat(skills): host 模式 skill 归档目录迁移到运行态 agentskills 2026-07-09 18:42:42 +08:00
3db4e0beb4 feat(sandbox): macOS host sandbox deny-list for sensitive paths
- Add default deny_read_paths/deny_read_regexes to host_sandbox_policy.json
- Generate deny rules for ~/.ssh, ~/.aws, ~/.kube, keychains, .env, etc.
- Readonly sandbox: global read + deny sensitive + write only /dev/null
- Workspace-write sandbox: global read + deny sensitive + writable paths
- Keep workspace files readable by re-allowing workspace subpath after deny
- Backend API validates path authorization against deny list
- Path auth dialog hint explains readable paths behavior
2026-07-09 16:25:04 +08:00
15106e7d00 feat(branding): 将产品名统一为 Astrion
- 前端页面标题、登录/注册页、左侧栏、聊天头部、教程欢迎语全部改为 Astrion
- CLI 欢迎面板与状态栏改为 Astrion CLI
- 后端启动日志与 setup 向导改为 Astrion
- package.json 与 README 标题同步更新
- 主 system prompt 增加 Astrion 自我介绍,视觉模型 prompt 文件名从 qwenvl 改为 vl
- 项目记忆与 AGENTS.md/CLAUDE.md 新增产品名说明
2026-07-07 18:17:48 +08:00
2264a86851 feat(chat): 极简模式摘要行改为单行渐变+工具统计总结 2026-07-07 16:45:59 +08:00
478d24c4dd feat(versioning): 重构版本控制为对话级浅备份 + 可选完全备份
本次变更是对版本控制功能的大范围重构,核心目标:
- 默认使用浅备份(只追踪 write_file/edit_file 实际修改的文件),避免大工作区首次初始化耗时十几秒;
- 保留可选的完全备份(git write-tree),不污染 git log;
- 回溯时统一选择范围(仅对话 / 对话+工作区)和模式(覆盖 / 复制);
- 修复复制对话、侧边栏同步、新建对话延迟、diff 统计与渲染等连锁问题。

后端改动:
- modules/shallow_versioning.py(新增)
  - 实现 ShallowVersioningManager:track_edit、make_snapshot、rewind、list_snapshots。
  - 基于 difflib.SequenceMatcher 计算真实 insertions/deletions,replace 场景正确显示 +N/-M。
  - diff 详情使用结构化 add/remove/context 行,不再解析 unified_diff 文本。
- modules/versioning_manager.py
  - 完全备份从 git commit 改为 git write-tree,字段 commit/parent_commit 改为 tree_hash/parent_tree_hash。
  - 恢复改用 read-tree + checkout-index;create_checkpoint 支持 diff_summary 参数。
  - get_checkpoint_detail 保留 API 字段名 last_commit/restored_commit 以兼容前端。
- core/main_terminal_parts/tools_execution.py
  - write_file/edit_file 在执行前调用浅备份 track_edit,保留编辑前状态。
- server/chat_flow_task_main.py
  - 消息轮次结束时根据 backup_mode 选择完全备份 create_checkpoint 或浅备份 make_snapshot。
  - 浅备份模式下同时写入 ConversationVersioningManager 检查点行,让前端能统一展示。
- server/conversation.py
  - restore/detail/list API 支持 shallow 模式;list/detail 对浅备份实时重新计算统计。
  - 创建对话时读取 personalization 的 versioning_backup_mode,shallow 模式固定 tracking_mode 为 conversation_only。
- core/web_terminal.py
  - _ensure_conversation_versioning_enabled 读取默认备份模式并写入 versioning meta。
- modules/file_manager/crud_mixin.py / replace_mixin.py
  - write_file / edit_file 返回 original_file / new_file / replacement_details(真实旧行/新行)。
- modules/personalization_manager.py
  - 新增 versioning_enabled_by_default、versioning_backup_mode(shallow/full)。
- utils/perf_log.py(新增)
  - 创建新对话全链路性能日志,用于定位延迟瓶颈。
- utils/conversation_manager/{index,list_search,crud}_mixin.py
  - 加性能日志;index_mixin 主动检查新增对话文件,修复侧边栏不同步。

前端改动:
- static/src/components/overlay/VersioningDialog.vue
  - 去掉顶部管理范围下拉;底部新增回溯范围与回溯模式选择。
  - diff 详情改为 div.diff-line 网格布局(marker + content),支持横向滚动,状态显示缩写 A/M/D。
  - 修复 pre 标签空白黑行、容器被挤窄导致折行等问题。
- static/src/app/methods/versioning.ts
  - 移除 mismatch 弹窗逻辑;支持 copy 模式;selectVersioningCheckpoint 增强错误捕获。
- static/src/components/personalization/PersonalizationDrawer.vue
  - 「工作区与权限」新增版本控制默认开关与备份方式选择。
- static/src/stores/personalization.ts
  - 新增 versioning_enabled_by_default、versioning_backup_mode 字段。
- static/src/components/chat/actions/ToolAction.vue / toolRenderers.ts
  - edit_file / write_file 工具展示基于 result.details / result.new_file 的真实替换结果。
- static/src/app/state.ts / watchers.ts / App.vue
  - 新增 versioningInitializingBackupToastId,完全备份初始化时显示 toast。
- static/src/app/methods/conversation/action.ts / message/send.ts
  - host + 完全备份 + 默认开启时,创建新对话/从 /new 发送首条消息显示「正在初始化备份」toast。

验证:
- /Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9 -m unittest test.test_server_refactor_smoke 通过。
- npm run build 通过。
- 宿主机与 docker 模式新建对话、编辑文件、回溯、复制对话均验证通过。
2026-07-07 12:07:44 +08:00
7863e497d8 feat(ui): restore reply-header generating hint and refine composer avatar text 2026-07-06 04:47:40 +08:00
01dfec4c72 fix(tasks): return completion notification notices 2026-07-05 01:44:50 +08:00
4a54d6fbb7 feat(ui): add status avatar interactions 2026-07-05 00:57:06 +08:00
359caca672 feat(input): support file mentions 2026-07-04 22:58:58 +08:00
ae5676cd30 feat(ui): add assistant status avatar 2026-07-04 22:33:00 +08:00
dd5e94b294 fix(frontend): smooth initial conversation rendering 2026-06-28 10:57:54 +08:00
2b2ff44fcd Fix: user message timestamp from backend, bubble action layout 2026-06-28 01:48:18 +08:00
0a4c75ce45 Add fold/expand and hover actions to user message bubbles 2026-06-28 00:15:07 +08:00
5ac17cd279 Unify font-family for form controls in global styles 2026-06-27 23:43:48 +08:00
6f47221ed8 Enhance docker toolbox with Office, OCR, media and Node.js tooling 2026-06-27 23:43:39 +08:00
737c4d61a2 fix(chat): 修复流式代码块闪烁、复制错位及数学公式实时多行渲染
- 将聊天消息中的代码块提取为独立 Vue 组件,稳定 key 避免流式输出时重建

- 代码块复制按钮直接复制当前组件内容,全局复制逻辑改为按容器查找

- 预处理 LaTeX 公式占位符并在组件 onUpdated 中实时渲染,支持多行 49888...49888

- 公式块过长时提供横向滚动,渲染后主动追底避免滚动锁定失效
2026-06-27 23:11:09 +08:00
f7a4708a84 chore(git): 取消追踪已迁移到 .agents/skills/ 的 skills/ 目录 2026-06-25 13:17:39 +08:00
ed9bbeecb7 feat(frontend): 子智能体结果添加统计信息并规范 meta/content 分区
- create_sub_agent 增强显示增加工作时间、调用次数、工具次数
- 将统计信息与最终回复放入 tool-result-content(结果区)
- 工具参数保留在 tool-result-meta(参数区)
- 在 AGENTS.md 新增 §5.6 工具结果显示规范
2026-06-25 12:38:58 +08:00
38ff51416f fix(mcp_client_manager): restore missing imports and dataclass decorators after split 2026-06-25 11:56:19 +08:00
72a49a7c8d fix: 修复对话计时器持久化、子智能体工具美化与状态查询,统一后台通知池
- 新增 server/work_timer.py,在对话真正空闲(无前台任务、无后台子智能体/后台命令/压缩)时持久化 work_timer,并同步内存副本,解决刷新后计时器回退问题。
- server/chat_flow_task_main.py / chat_flow.py / tasks/models.py 在任务结束/取消时按空闲判定决定是否持久化。
- 前端 lifecycle.ts 在仍有后台任务时不提前停止计时器。
- 子智能体工具(create/terminate/get_status)渲染美化,task 参数放在顶部元信息区。
- 子智能体状态查询支持返回「已完成」「已终止」「不存在」;修复 wait_for_completion 在 final_result 就绪前返回导致的「ID 被占用」误报。
- 统一后台完成通知池 poll_completion_notifications,合并子智能体与后台 run_command 两路轮询,避免逐条触发工作循环与单工作区互斥冲突。
- 删除本次新增的各类 debug_log / notify_pool_log 调用及辅助脚本。
2026-06-25 04:16:44 +08:00
f3e166e258 feat: 上传文件/图片路径提示 + 文件树去 emoji + CSV 预览优化 2026-06-24 20:44:26 +08:00
d1a9a51bb8 fix(terminal_ops): ssh 远程命令中避免重写 /Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9/pip 路径 2026-06-24 17:33:12 +08:00
fd29b3b89c chore(android): 升级版本号到 1.0.41 (versionCode 43) 2026-06-24 17:06:57 +08:00
86eef988dc fix(android): 修正下载URL解析和CookieManager线程
- 解析相对下载地址时使用 HOME_URL 域名根路径,避免把当前对话路径拼进URL

- CookieManager.getCookie 在主线程调用后再传入IO线程
2026-06-24 17:02:14 +08:00
6a5ba2d95e fix(android): 下载改为应用内下载+系统分享sheet
- 移除 DownloadManager,改用 HttpURLConnection 下载到应用私有目录

- 通过 FileProvider 生成 content URI,调用 ACTION_SEND 弹出系统分享面板

- 下载请求携带 WebView Cookie,保证鉴权

- 版本号 1.0.40 (versionCode 42)
2026-06-24 16:51:20 +08:00
626b7eaa52 fix(android): 修复媒体权限重请求与下载失败
- 在唤起图片/视频文件选择器前主动请求 READ_MEDIA_IMAGES/VIDEO 权限,
  拒绝后支持再次请求并引导到系统设置页。
- Android 下载桥接补全相对 URL 为绝对地址,并携带 WebView 登录 Cookie。
- Android 10+ 指定公共下载目录失败时回退到 DownloadManager 默认位置。
- 前端 markdown 下载链接与资源下载统一优先走 AndroidDownloadBridge。
2026-06-24 16:13:24 +08:00
09a67f20dd fix(android): 禁止 Android App 文件卡片内图片点击预览 2026-06-24 15:43:44 +08:00
bb2b98fc82 feat(chat): 自研 pdfjs-dist canvas 渲染器替换 vue-pdf-embed,实现 PDF 卡片内联滚动预览 2026-06-24 13:18:31 +08:00
bd9af317e4 feat(chat): 使用 vue-pdf-embed 实现 PDF 卡片内联滚动预览
- 新增 PdfPreview 组件,基于 vue-pdf-embed essential/legacy 构建
- PDF 文件卡片与其他文件卡片统一在卡片下半部分预览
- 移除 iframe 浏览器原生预览和 AndroidPdfBridge 原生预览分支
- 同步更新 Android App 版本号与更新说明
2026-06-24 12:41:56 +08:00
4fcc6a1b25 chore(android): 版本号递增到1.0.37(39) 2026-06-24 01:55:50 +08:00
15dea9c2e5 fix(android): 修复PDF/下载/文件选择器,常规设置增加上传日志 2026-06-24 01:50:03 +08:00
426825d475 fix(server): 修正/api/app/version的project_root路径计算 2026-06-24 01:33:04 +08:00
cc22541560 chore(android): 更新WEB_ASSET_VERSION以打破缓存 2026-06-24 01:11:13 +08:00
f7b79418f9 fix(android): 修复App文件下载、PDF卡片展示与本地媒体发送 2026-06-24 00:59:48 +08:00
e4a663594d feat: Android App 原生 PDF 预览与前端 show_file 卡片
- 集成 AndroidPdfViewer (mhiew fork) 实现不依赖浏览器的 PDF 预览
- 新增 PdfPreviewActivity 与 JS Bridge (AndroidPdfBridge)
- ShowFileCard 在 Android App 内对 PDF 显示「预览」按钮,不内嵌 iframe
- 新增 /api/file/content 后端接口用于文件内容 inline 预览
- 前端支持 <show_file> 标签与 download:// 链接渲染
- 更新 Android 版本号至 1.0.35
2026-06-24 00:06:10 +08:00
53b3669f1d fix(stop): 强停止按钮两段式逻辑与 work_timer 持久化 2026-06-23 19:27:50 +08:00
e90610ed05 fix(frontend): 修复刷新后AI等待提示与终端侧边栏重复问题
- 修复模型调用工具后刷新页面,AI回复头部等待提示文字再次出现的bug
  - restoreTaskState 根据 hasAssistantContentEvent 精确设置 awaitingFirstContent
  - handleAiMessageStart 从头重建时保留 restoreTaskState 的设置
  - handleToolStart 兜底清除 awaitingFirstContent

- 修复刷新页面后终端侧边栏已有内容重复显示绿色箭头与输入内容的bug
  - 后端 get_snapshot/get_terminal_output 返回 last_event_time
  - 前端 TerminalPanel 基于 last_event_time 对 terminal_output 事件去重
2026-06-22 20:35:00 +08:00
2eed4d5b86 fix(search): switch Tavily key to AGENT_TAVILY_API_KEY 2026-06-22 14:33:12 +08:00
f03446a3bb fix: task 结束时清理残留 pending runtime mode,避免下次工作插入错误通知
场景:模型最后一次输出(无工具调用)后、task 仍 running 时切换权限/执行环境,
进入 queue_*_change 只设置 pending,task 随即结束未进入工具循环,
pending 残留到下次工作首次工具调用时才被应用并插入变更通知。

修复:在 handle_task_with_sender 末尾调用 apply_pending_runtime_mode_changes
立即应用并清空 pending,丢弃生成的 notices(等同空闲期切换语义)。
2026-06-22 14:17:55 +08:00
f44c09a7ce fix(search): 移除 web_search 的 Tavily AI 摘要 2026-06-22 00:18:29 +08:00
b4080f3880 docs: update runtime data paths and project status to 2026-06 2026-06-21 22:46:25 +08:00
b76708de13 fix(sub_agent): add missing datetime import in toolkit.py 2026-06-21 01:50:55 +08:00
2bfbddb0dd fix(server/tasks): 修复 skill 创建任务失败并补充调试日志
- 补全从 api.py 拆到 skills.py 时遗漏的 _build_skill_context_messages 导入
- 捕获 skill 文件读取时的 UnicodeDecodeError/OSError,转为 400 错误
- 在 skill 路径解析与读取流程中增加详细 debug_log
2026-06-21 01:45:45 +08:00
8e1a339102 refactor: split api_client, tool_result_formatter, tools_definition into sub-packages
- utils/api_client.py -> utils/api_client/ (mixin-based)
- utils/tool_result_formatter.py -> utils/tool_result_formatter/ (by tool category)
- core/main_terminal_parts/tools_definition.py -> core/main_terminal_parts/tools_definition/ (by tool group)
- Update AGENTS.md, CLAUDE.md, and split memory index
- Keep original files as compatibility re-exports
- Includes TerminalPanel display adjustment and ToolAction.vue diff line fix
2026-06-20 21:51:45 +08:00
6c368d2aa0 fix(frontend): preserve edit file trailing context 2026-06-20 19:19:58 +08:00
f661dc6456 refactor(server,modules,utils,frontend): split oversized modules into packages and fix regressions
- Split server/chat.py, server/status.py, server/tasks.py into sub-packages
- Split utils/context_manager.py, utils/conversation_manager.py into mixin packages
- Split modules/file_manager.py, persistent_terminal.py, terminal_ops.py, mcp_client_manager.py into packages
- Split core/main_terminal_parts/context.py into base + mixins
- Split static/src/app/methods/{ui,message,upload,taskPolling,conversation} into sub-modules
- Fix flattened method exports, dynamic import paths, missing cross-module imports
- Add missing permission/network/execution mode constants and _PERMISSION_MODE_LABEL
- Update AGENTS.md, CLAUDE.md and project memories to reflect new structure
2026-06-20 15:54:48 +08:00
8235c4ce70 fix(goal): 停止对话后清理工作区目标状态,避免继承到新对话
- /api/tasks/<task_id>/cancel 现在会同步停止该工作区的目标模式
- POST /api/tasks 创建非目标模式任务时清理残留活动目标
- 保留 /api/client_debug_log 前端日志上报端点供后续复用
2026-06-20 13:06:37 +08:00
7b683ab390 fix(sub_agent): export TERMINAL_STATUSES in package __init__
fix(api_client): classify httpx.RemoteProtocolError as connection_error
2026-06-20 12:15:18 +08:00
9ed956518c refactor(sub_agent): 子智能体从 Node.js 子进程改为主进程内 Python 协程
- 重写子智能体执行核心,不再启动 easyagent Node.js 子进程
- 新增 modules/sub_agent/ 包集中管理子智能体逻辑
- 工具调用复用主进程 WebTerminal.handle_tool_call,自然经过沙箱/容器链路
- 子智能体模型独立读取 ~/.agents/<mode>/config/sub_agent_models.json
- 支持 8 个工具:read_file/write_file/edit_file(replacements+replace_all)/run_command/web_search/extract_webpage/search_workspace/read_mediafile
- 修复子智能体进度弹窗:标题颜色、write_file 显示、过滤非 progress 条目、统一滚动条样式
- 更新 AGENTS.md / CLAUDE.md 子智能体描述
- 新增 test/test_sub_agent_regression.py 回归测试
2026-06-20 00:26:45 +08:00
a212a0076c feat: support setting default workspace/project from frontend
- Add set_default_host_workspace() and /api/host/workspaces/set-default
- Add default_workspace_id persistence in UserManager and /api/projects/set-default
- Add 'Set as default' button in HostWorkspaceManageDialog
- Sync default badge in LeftPanel workspace list
- Update all workspace/project APIs to return real default_workspace_id
2026-06-19 21:48:24 +08:00
2c6d208be7 fix(input): /菜单上下键滚动抖动与高亮条偶发闪烁
- 修复 rAF 第一帧 now 早于 startTime 导致 scrollTop 反向抖动
- 过滤动画结束后的延迟 scroll 事件,避免误进入 manualScrolled 模式导致高亮条闪一下消失
2026-06-19 21:24:47 +08:00
2766a8ab57 fix(sub-agent): 修复网络受限时无法创建子智能体,并修复详情页只显示最近一次工具调用的问题 2026-06-19 20:23:44 +08:00
09af37055a fix: 右侧面板拖拽与终端滚动
- 修复仅打开终端面板时右侧无法左右拖动的问题
- 修复切换终端会话后未自动滚动到最新输出的问题
2026-06-19 17:45:09 +08:00
56882f3169 fix: 新建对话后切换执行环境 prompt 未更新
根因:frozen_permission_prompt / frozen_execution_prompt 在对话创建时就被
提前构建并缓存到 conversation_metadata,后续切换模式后 build_messages 直接
返回旧缓存,不会根据当前模式重建。

修复:
- 删除 _ensure_conversation / create_new_conversation 中提前预置冻结 prompt
- conversation_manager 默认值从 '' 改为 None(空字符串会被误判为已缓存)
- _get_or_init_frozen_prompt 增加非空判断(防御性加固)

冻结时机改为第一次 build_messages 懒加载,确保首次发消息前任意模式切换都生效。
2026-06-19 17:33:52 +08:00
a6775598dc fix(terminal): 新建终端后侧边栏实时输出不更新
修复 TerminalPanel 中因空历史导致 _historyReady 始终为 false,
进而跳过所有 terminal_output 事件的问题。现在无论历史是否为空
都会标记加载完成;同时在历史加载期间暂存实时输出,避免首屏丢失。
2026-06-19 13:16:50 +08:00
9e4b0bdb97 fix: allow WebTerminal init without OCR API key (openai>=2.40 requires key) 2026-06-19 01:35:39 +08:00
fd8decc3c3 fix: preserve conversation model_key when creating new conversation and loading existing ones 2026-06-19 01:08:46 +08:00
c2d460a706 feat: 宿主机模式网络沙箱
- macOS sandbox-exec profile 支持 network_permission 参数 (restricted/full/none)
- backend: 透传网络权限至 run_command / terminal_session / sub_agent / background_command
- 后台 run_command 接入沙箱执行
- 自动审核模式兼容网络权限报错 markers
- 运行时切换网络权限通过 pending 机制 + user 消息通知
- 提示词注入网络状态 (仅沙箱模式)
- 前端权限菜单新增网络权限组 (受限/完全开放)
- direct 模式下网络权限组变灰禁用
- settings.json 默认 HOST_SANDBOX_NETWORK_PERMISSION=restricted
2026-06-19 00:22:30 +08:00
f75e2f07a3 refactor(deep_compression): 改造深度压缩消息结构、提示词加载与前端展示
- 删除 compact 文件和 inject guide 中的最近工具记录
- 新结构:用户所有输入(按压缩轮次分段)+ 历次压缩总结 + 最近一次输入
- 用户输入分段标记改为 <第N次压缩> 和 <当前触发的第X次压缩>
- 压缩总结提示词迁移到 prompts/deep_compression_summary.txt
- deep_compression_records 增加 user_inputs_before 和 summary 字段
- 压缩后清空全部 frozen prompt 缓存
- 修复 wait 模式 in-place 压缩前端不刷新问题
- 更新手动压缩确认弹窗文案
- 修复 _apply_workspace_personalization_preferences 测试 mock 签名
- 清理 context.py 中误导性的主提示词构建参数
2026-06-18 14:50:16 +08:00
c5dde27faa fix(frontend): terminal_input 显示参数从 timeout 改为 output_wait,标签改为等待时间 2026-06-17 22:02:06 +08:00
f43b5ea73b fix(chat): 运行期间恢复旧滚动逻辑,堆叠块更多按钮也接入锚定 2026-06-17 16:25:02 +08:00
edb557bdd3 feat(chat): 优化块展开/折叠滚动锚定,避免 stick-to-bottom 弹簧追底顿挫 2026-06-17 15:24:15 +08:00
55b7b7deea fix(web): _resolve_workspace_id 未匹配时兜底返回 default 而非 None
host_workspaces.json 存在但不匹配时,不应返回 None 导致
对话目录指向 conversations_root 而丢失 conversations_root/default/
下的所有对话。
2026-06-16 22:54:12 +08:00
f31f8d6fd7 feat(host): host 模式同时加载 host/ 和 web/ 用户数据
- paths.py: 新增 IS_HOST_MODE / WEB_DATA_DIR / WEB_USER_SPACE_DIR
- user_manager.py: host 模式合并两处用户列表与工作区
- 已有 web 工作区直接复用,新数据写入 host/
- 更新 AGENTS.md / CLAUDE.md 文档
2026-06-16 22:39:32 +08:00
ad1e9e1475 fix(easyagent): 移动 tools.json 到 src/tools/,修复被 .gitignore 中 doc/ 规则误排除的问题
tools.json 是子智能体运行时工具定义 Schema(OpenAI function calling),
不是文档,不应该放在 doc/ 下。doc/ 被 .gitignore 忽略导致部署后文件缺失。

- 移动 easyagent/doc/tools.json → easyagent/src/tools/tools.json
- 更新 batch/index.js 和 cli/index.js 中的引用路径
2026-06-16 20:18:45 +08:00
1da269e2aa fix(前端): 目标模式刷新/切换对话稳定性修复
- 切换/停止任务后立即 stopPolling,防止旧任务目标事件污染新对话
- 目标进度/完成/停止回调增加 conversation_id 归属校验
- 刷新恢复改为复用现有 assistant 容器+增量追加事件,去掉无条件强制重建,避免视觉闪回与同步重放卡死
- 压缩消息不再另起 work segment,继续前一段

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 19:46:19 +08:00
ea4e5dc14b fix: recall_project_memory 工具返回实际内容而非仅行号范围
复用 read_file 的格式化逻辑,修复只返回"行 X~Y"摘要、丢失 content 的问题

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 19:43:11 +08:00
f43025047e refactor(config): 统一配置源,迁移数据到 ~/.agents/agents/
- config/paths.py: data_root 改为 ~/.agents/agents,config/ 提至根目录共享
- config/__init__.py: 统一加载 .env + settings.json,注入 legacy 环境变量
- 移除 4 处独立 .env 读取 (auth/model_profiles/api_client/chat_flow/balance)
- 新增 docs/config_refactor_plan.md 完整改造计划
- 数据已从 ~/.agents/host/ 迁移至 ~/.agents/agents/
2026-06-13 21:37:02 +08:00
50e49d60ec fix: /菜单手动滚动后高亮条切换为鼠标hover模式 2026-06-12 01:17:51 +08:00
095e0631c1 feat(sidebar): 统一Git和终端侧边栏空状态样式
- Git空状态改为图标+大字,与终端风格一致
- 终端空状态去掉小字,文字改为当前没有开启的终端
- 统一头部栏高度为38px
- 统一空状态图标颜色为var(--text-tertiary) + 整体opacity
- 统一空tab文字为13px/600字重/左对齐
- 新增folder-git-2.svg图标
2026-06-12 01:04:40 +08:00
55097cb3fc fix: 消息队列自动发送消息metadata错误标记为guidance 2026-06-12 00:34:31 +08:00
7d716b8b90 fix(ui): 终端侧边栏自动开关改为仅数量变化时触发一次
- 旧逻辑每次轮询都执行开关,导致面板关不掉/瞬间关闭
- 新增 previousTerminalCount 追踪,仅 0→>0 时开、>0→0 时关
2026-06-12 00:26:15 +08:00
2e54f2c343 feat(terminal): 实时终端改造为侧边栏面板,支持拖拽分割与自动开关
- 将实时终端从独立页面改造为 Vue3 侧边栏面板,与 Git 面板上下排列
- 使用 xterm.js + Grid 布局,支持三主题配色适配
- 上下拖拽分割条,动态调整终端/Git 面板比例
- 输入栏上方新增终端数量按钮,点击快捷打开
- 个人空间新增「自动打开终端面板」开关(外观与显示分类)
- REST API /api/terminals 获取终端列表,多时机自动刷新
- 有终端自动打开面板,无终端自动关闭
2026-06-12 00:18:27 +08:00
788753f6fd feat(slash-menu): 扩展 / 菜单至19项,可见行数5→7,等比缩放样式,优化间距 2026-06-11 11:47:41 +08:00
af4ec5c1a8 feat(composer): / 菜单选中切换添加流畅动画
- 提取 skill-slash-menu-wrapper 外壳层,高亮条脱离滚动容器独立定位
- 一个 rAF 循环同时驱动 scrollTop 和高亮条 top,帧帧精确同步
- 中间区域:高亮条视觉上钉在正中,列表平滑滚动
- 两端边界:高亮条平滑跟随,无偏差
2026-06-11 01:29:03 +08:00
f9d194f100 fix(ui): 块模式展开箭头改用SVG mask,修复偏位和非中心旋转 2026-06-11 00:24:56 +08:00
04128fac14 fix(frontend): 修复 read_file 搜索结果显示"行??"的问题
后端 search_text 返回 line_start/line_end,前端错误地读取了不存在的 line_number 字段。
改为正确读取 line_start 和 line_end,且当上下文窗口合并时显示完整行范围。
2026-06-10 19:49:06 +08:00
76e4c5144e feat(personalization): 去掉保存按钮,所有选项改为实时自动保存
- personalization store 新增 scheduleAutoSave/autoSave 防抖方法(500ms)
- updateField 及所有 mutation 方法修改后自动触发保存
- PersonalizationDrawer 删除保存按钮和 settings-save-bar
- handleStackedHideBordersChange 移除显式 save 调用
2026-06-10 19:44:33 +08:00
0ed262c87b fix(ui): 深色模式块底部运行指示线太亮,改用 accent 灰色系 2026-06-10 19:27:28 +08:00
ee9ac28f26 feat(compression): 对话压缩提示词新增当前任务进度与下一步行动条目 2026-06-10 19:24:18 +08:00
dcb25dfe89 fix: 堆叠块深色hover不可见 & 底部块高度偏差 & 新增隐藏边线开关
- 深色模式 hover 修复:用 !important 覆盖 _overlays 中的 surface-panel 规则,使用 --hover-bg(6%白) 对齐对话列表 hover
- 底部块高度修正:shellHeight = targetShell + 2 补偿 border-box 下内容区裁剪
- 深色模式运行横条改为白色(var(--text-primary))
- 新增隐藏边线开关:后端 PersonalForm 持久化 stacked_hide_borders,前端条件渲染开关(仅堆叠模式可见)
2026-06-10 19:18:29 +08:00
3abbf30422 feat(admin): 管理员密码管理功能,支持搜索用户名/邮箱重置密码 2026-06-10 17:41:21 +08:00
4004bceb0c feat(config): 新用户默认deep模式+自动深压缩开启 2026-06-10 16:41:45 +08:00
820ec69354 feat(memory): 新增项目记忆系统,整合总体+项目记忆注入
- 新建 .agents/memory/ 项目记忆目录,支持 YAML frontmatter + Markdown
- 新增 update_project_memory / recall_project_memory 工具(复用 write_file/read_file)
- 新建 memory_system.txt prompt,总体长期记忆 + 项目记忆统一注入
- 记忆从 workspace_system.txt 中移出,独立为 memory_prompt 注入
- 重排 system prompt 注入顺序:主→权限→环境→对话→个性化→工作区→AGENTS.md→skills→记忆→禁用
- 压缩后选择性 invalidate 4 个 frozen keys(skills/workspace/personalization/memory)
- 前端:notebook/notebook-pen 图标、动画、状态文案、展开内容渲染
- 优化三个记忆工具的 description 和 memory_system prompt,引导模型主动调用
2026-06-10 15:14:01 +08:00
5d2da692b0 chore: remove easyagent/models.json with plaintext keys, add to gitignore 2026-06-10 03:26:35 +08:00
960d087db0 fix(sub-agent): stdout output, real-time progress, config externalization
- config/sub_agent: 子智能体模型配置走 deploy_config_path 直指,无回退
- easyagent/config: ensureConfig 文件不存在时 throw 而非自动创建
- easyagent/batch: 所有输出走 stdout JSONL (emit),不写文件;conversation 走 emit 不写工作区
- sub_agent_manager: 新增 conversation_file;运行期间 select 非阻塞 drain stdout 即时写 progress
2026-06-10 03:09:31 +08:00
6255efc594 perf: CSS content-visibility + containment 优化长对话渲染性能
- .message-block 添加 content-visibility: auto 跳过屏幕外消息渲染
- .messages-flow / .messages-area 添加 contain: layout style 隔离布局
- 减少长对话 DOM 重排开销,解决横向 resize 时的级联卡顿
2026-06-10 01:08:47 +08:00
d24e00b9b3 feat(voice): Android 端侧离线语音识别集成
基于 Sherpa-ONNX + SenseVoice int8 实现 APK 端侧语音识别:
- VoiceBridge: AudioRecord 录音 + 整段识别 + JS Bridge 回调
- ModelManager: 模型下载管理(自有服务器),支持断点续传/校验/删除
- 前端:语音按钮仅 APK 环境显示,识别结果回填 ProseMirror 编辑器
- 调试:文件日志 + /api/voice_debug 接收路由
- demo/sense_voice_demo.py: Python 端测 Demo

versionCode 24→36, versionName 1.0.22→1.0.34
2026-06-10 00:50:16 +08:00
62e2c08f5a fix(style): 统一三主题 token 圆环绿黄红状态颜色为夜间模式值 2026-06-09 15:01:58 +08:00
644b609d85 fix(ui): 修复Git侧边栏刷新时loading文字闪烁 - 只在首次加载显示loading,刷新时保持现有文字避免闪烁 2026-06-09 12:21:40 +08:00
ca4bf17639 fix(chat): 运行期间权限切换使用pending机制,避免来回切换插入多余消息
- update_permission_mode / update_execution_mode 运行期间改用 queue 机制
- pending 值可被覆盖,最终若与当前相同则不通知
- 返回值直接返回 target_mode,前端立即更新 UI
2026-06-09 12:13:15 +08:00
ff7108738e fix(style): 去除审批面板边缘光晕
desktop-approval-sheet 的 box-shadow 从大范围彩色扩散光晕
替换为项目既有的 var(--shadow-strong) 中性克制投影。
2026-06-09 11:46:20 +08:00
d5bf110b88 fix(style): 修复弹窗和审批面板文字颜色在深色模式下的异常显示
- confirm-title 添加 color: var(--text-primary)
- confirm-button--primary 使用 var(--on-accent) 替代 var(--text-primary),
  修复浅色模式下黑底黑字不可读
- 审批面板 auto-approval-block 标题/内容/决策/原因 补充显式颜色
- 审批面板 approval-lines/approval-diff/approval-kv strong/approval-value
  补充显式颜色,避免深色模式下继承为浅金色
2026-06-09 11:45:51 +08:00
033bea236c feat(ui): 预输入/引导消息队列存在时自动隐藏Git状态栏
与 / 菜单出现时隐藏逻辑相同,在 floatingStatusVisible 中新增
runtimeQueuedMessages 和 hasPendingRuntimeGuidance 两项检查。
2026-06-09 11:42:37 +08:00
59b81c4551 fix(git): 修复Git变更面板中文文件名八进制转义显示
_run_project_git / _run_project_git_raw 添加 -c core.quotePath=false,
让 git 直接输出 UTF-8 原始路径名而非八进制转义形式。
2026-06-09 11:42:20 +08:00
a2a04b9529 fix(permission): 新建对话时优先加载个人空间偏好的默认权限模式
- core/web_terminal.py: create_new_conversation 中反转权限模式优先级,
  优先使用 prefs.default_permission_mode,无效时再 fallback 到终端当前模式
- server/conversation.py: active_task 分支创建视图对话时同样使用用户偏好
2026-06-09 11:42:08 +08:00
bd338f7ad4 fix(runtime): 任务完成时不再注入权限/审核方式通知
模型准备结束工作时,队列中残留的权限/审核方式变更通知不应再
插入对话。改用 consume_runtime_guidance_for_injection 保留 source
信息,过滤掉 source 为'权限变更/执行环境变更/notify'的消息。
2026-06-09 11:41:55 +08:00
8d87068ba7 fix(chat): 修复内容大幅增长时滚动锁定不及时的问题
- 调整 vue-stick-to-bottom resize spring 参数,加快动画响应
  (damping 0.68→0.5, stiffness 0.11→0.28, mass 1.05→0.75)
- 在 scrollListener 中添加高度突变检测,当 scrollHeight 大幅增长
  (超过 200px 或 viewport 35%)且用户未脱离锁定时,用 instant
  瞬间追底,绕过 spring 动画延迟和去重机制

修复场景:思考块展开、Markdown 表格渲染等造成大幅度垂直距离增加时
滚动锁定失效或锁住不及时的问题
2026-06-09 10:47:22 +08:00
2fcd241d94 fix(frontend): 移除历史消息加载时action-item的入场动画,仅流式输出时保留动画 2026-06-09 10:27:48 +08:00
4807c0b9f1 fix(frontend): 重构堆叠块高度引擎,根治鬼畜抖动并统一动画
StackedBlocks 旧实现每帧用 getBoundingClientRect 读正在过渡的块高度,
叠加 360ms runSync 轮询 + ResizeObserver + animateMoreToggle 三个驱动源
互相触发,形成不收敛的测量-回写反馈环 = 高频上下抽搐;且新块出现/展开
收起无外框动画(瞬间赋值且无 transition)。

高度引擎重构:
- 改测真实 DOM 稳定值(header.offsetHeight + content-inner.offsetHeight
  + border)算每块高度。content-inner 自身高度不受父级 collapsible-content
  的 max-height 过渡/overflow 裁切影响,是稳定目标态值,杜绝读动画中间帧。
- 删除 runSync 轮询、animateMoreToggle JS lerp、逐帧 getBoundingClientRect。
  measureAndCompute 仅在状态变化时测一次。
- ResizeObserver 只观察 content-inner(流式增长源),不观察被 transition 的
  元素,断开写→RO→写回环。
- shell/viewport/inner 三者同一条 height/transform 260ms transition 同步过渡,
  外框增高、传送带上移、展开收起、收起裁切全平滑。

单块路径统一(修 1→2 块重建鬼畜):
- splitActionGroups 阈值 >=2 改 >=1,单个 thinking/tool 也走 StackedBlocks,
  消除 single↔stack 路径整体切换导致的组件重新挂载(表现为外框瞬间塌扁再展开)。
- 单块用 isSingle + .stacked-shell--single 还原原 single 外观(12px 圆角+透明底)。

首次/后续进场动画区分:
- ChatArea 传 conversation-running + is-latest-message。
- animated(运行中+最新) 控 TransitionGroup :appear/:css(块进场动画);
  ready(首次 measure 后一次性) 控 shell/viewport/inner/collapsible-content
  的 transition:初始挂载/历史批量加载高度直接到位不跳动,之后启用使展开/
  收起在历史对话里也平滑。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:05:28 +08:00
0d4f6c9e56 fix(frontend): 修复对话压缩后页面抽搐、重复显示与滚动重置
三管齐下,消除 in-place 压缩触发的多余重载:

1. handleTaskEvent: _rebuildingFromScratch 重放时跳过
   compression_finished/compression_state/shallow_compression,
   打破 restoreTaskState 重放 → 再次触发压缩完成的无限循环。

2. handleCompressionFinished + compressConversation:
   in-place 压缩(对话 id 不变)跳过 loadConversation +
   restoreTaskState + loadConversationsList,
   避免 resetAllStates 重置滚动状态和清空消息列表。

3. useLegacySocket conversation_loaded:
   同对话 clear_ui 跳过 resetAllStates + fetchAndDisplayHistory,
   避免后端 Socket.IO 推送触发二次重置。
2026-06-08 14:14:19 +08:00
66261e91c6 feat: 添加语音输入按钮,位于发送按钮左侧
- SVG线条图标风格,只用hover交互,不做边框
- 图标路径:static/icons/mic.svg
- 测试页面:static/voice_test.html
2026-06-08 14:14:19 +08:00
b6818bc0d0 fix: edit_file diff 显示修复 — 多行替换排序 & 行间间隙 2026-06-07 23:48:08 +08:00
84336b1802 fix(input): 重构消息队列动画 — 去掉容器外框、纯位移FLIP同步动画 2026-06-07 23:20:57 +08:00
d639278182 fix(overlay): 修复手机端图片/视频本地选择兼容性问题
- 将 local-files 事件类型由 FileList 改为 File[],避免 WebView 中 FileList 在 input 清空后失效
- 屏蔽文件选择器关闭后 500ms 内的 backdrop click,防止 touch 穿透误关 overlay
- 延迟 200ms 清空 input value,避免国产手机 WebView 异步填充 files 被截断
2026-06-07 17:19:53 +08:00
7c08031413 fix(android): 修复国产手机 WebView 中本地图片/视频选择无效的问题
- 将 .file-input-hidden 从 display:none 改为 opacity:0 + position:absolute
  国产手机(华为/小米/OPPO/vivo)WebView 对 display:none 的 input[type=file]
  选择后不会填充 files 属性,导致 change 事件触发但 files 为空
- 在 ImagePicker/VideoPicker 的 change 事件中增加空文件警告日志
- 在 upload.ts handleLocalImageFiles/handleLocalVideoFiles 中增加空文件
  时的 Toast 提示,避免静默失败
2026-06-07 16:56:55 +08:00
5723da8eb1 fix(android): 修复 App 内本地图片/视频选择无效的问题
- AndroidManifest.xml 补充 READ_MEDIA_IMAGES / READ_MEDIA_VIDEO 权限(Android 13+ 必需)
- 将已废弃的 startActivityForResult 替换为 registerForActivityResult(Activity Result API),解决 Android 13+ 文件选择器回调丢失
- 同步更新 versionCode 24 / versionName 1.0.22
2026-06-07 16:34:26 +08:00
180fb9bfbd chore(deps): add sherpa-onnx for voice/ASR support 2026-06-07 16:23:33 +08:00
78544cb205 refactor: remove run_python tool, consolidate all execution to run_command 2026-06-07 16:23:01 +08:00
4bf0733907 feat: add minimal tool reel animation 2026-06-07 14:59:44 +08:00
a1dd97d5f1 fix(ui): align composer status controls 2026-06-07 03:41:27 +08:00
f50d87fc79 feat(chat): render streaming code blocks 2026-06-07 03:34:00 +08:00
f383b0ec71 fix(ui): refine dark code block styling
Remove stray borders, outlines, shadows, and token backgrounds from markdown code content in dark mode to avoid the visible white frame around code text.

Adjust code block body padding so code content sits closer to the block edge while staying visually aligned with the language label.
2026-06-07 02:57:58 +08:00
8617304fd4 refactor(prompt): freeze system prompts and update personalization modes
Freeze all leading system prompt blocks per conversation, including the main prompt, skills, workspace context, recent conversations, personalization, AGENTS.md, custom system prompts, and disabled-tool notices. Tool schemas remain dynamically generated outside the frozen prompt path.

Remove obsolete prompt injection files and code paths for thinking mode, deep thinking mode, sub-agent guidelines, and todo guidelines so prompt assembly stays simpler and cache-friendly.

Add personalization-controlled conversation_continuity with high/medium/low levels, rename the UI copy to 对话连续性, and wire it through backend sanitization, prompt generation, manage_personalization, frontend settings, and tool result rendering.

Add auto communication style support via prompts/auto_style.txt, matching the existing separate-file pattern used by human_like_style.txt.
2026-06-07 02:52:00 +08:00
d095531bb8 refactor(frontend): unify chat content rail alignment
Normalize the chat content rail around the input composer as the width baseline. This introduces shared rail variables, compensates message content for the actual scrollbar gutter, and separates mobile/webview behavior so desktop, mobile web, and app shell layouts stay centered consistently.

Align assistant text, markdown blocks, tables, minimal-mode summaries, expanded minimal steps, slash/runtime/git floating UI, and brief-message separators to the same horizontal rail. Remove the collapsed right resize handle and mobile menu glow while preserving the intended percentage widths for floating composer affordances.

Validation: npm run build --silent 2>&1 | tail -n 5
2026-06-07 01:55:20 +08:00
05e8ea5e40 feat(frontend): 输入栏现代化重设计 + 配套高度/配色修复
- 输入栏由体育场形改为圆角矩形,输入区与按钮行分离
- +/发送按钮移至底部行(左下/右下),缩小尺寸、圆角 hover、同心圆角对齐
- 发送按钮去光晕:浅色黑底白三角、深色白底黑三角,仅留浅阴影
- logo 旁思考模式按钮去除扩散光晕,focus 仅留描边环
- 对话区预留高度基准 80→90 适配更高输入栏,欢迎语 padding 同步加大
- /菜单命令名配色:经典 accent 橙、浅/深色保留 state-info 蓝
- /菜单及深色 + 菜单 hover 统一为侧边栏同款 --theme-tab-active(修复深色全黑)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:25:20 +08:00
b4030c386e refactor(frontend): 颜色 token 系统两层重构 + 三主题填色 + 去半透明
将前端配色从散落的硬编码/双命名体系(--claude-* / --theme-*)重构为
两层 token 结构(原始层 + 中性语义层),并按"经典抄 Claude、浅色抄
ChatGPT、深色自研"的定位完成三主题填色,同时建立 stylelint 防回退栏杆。
代码大量由 AI 生成的历史遗留配色问题(同语义多值、跨主题塌缩、强调色
撞文字色、fallback 第二真相源等)在此一并收口。

== 一、Token 结构重构(_tokens.scss)==
- 重写为两层:原始层 + 语义层;组件只引用中性语义名
  (--surface-* / --text-* / --border-* / --accent* / --state-* 等)。
- --claude-* / --theme-* 降级为兼容别名(41 个转发),迁移完成后删除。
- 三主题(classic / light / dark)+ 首屏回退(:root:not([data-theme]),
  与经典同值)全部补齐,结构对齐、不再缺项。

== 二、三主题填色 ==
- 经典 = Claude 官网亮色盘:暖奶油表面阶梯
  canvas #faf9f5 < soft #f5f0e8 < card #efe9de < cream-strong #e8e0d2
  < 纯白浮起;文字 #141413;hairline #e6dfd8;暖橙 primary #cc785c。
- 浅色 = ChatGPT 亮色盘:冷白 #ffffff / 侧栏 #f9f9f9 / 嵌套 #f3f3f3,
  中性灰文字,近黑 primary #181818。
- 深色 = 自研中性灰阶,本轮完全不动(原样保留)。
- 修复塌缩 bug:强调色在 light/dark 原被误填成灰(CTA 失去品牌色),
  现统一回品牌 primary;light 表面原全塌成 #ffffff(过于平面),
  现按灰阶拉开层次。
- 强调色克制(CTA-only voltage):primary 仅用于发送按钮/logo 等极少
  数处;hover / 选中 / 运行态一律走中性灰(运行态指示器从 amber 改灰)。
- 对话区背景从纯白改为暖奶油 canvas;个人空间下拉菜单、git 状态条、
  二级菜单等收口到统一语义色。

== 三、全面去半透明 ==
- 所有实体面板/二级菜单背景改为不透明(surface token 由半透明白改实色,
  --mobile-menu 三亮色主题改实色)。
- 移除已不透明面板上失效的 backdrop-filter 磨砂(移动端菜单、右键菜单、
  模型模式下拉等)。
- 遮罩 scrim(--overlay-scrim)与刻意玻璃质感装饰保留半透明。

== 四、块展开底色统一 ==
- 思考/工具块展开区底色统一为对话背景色:.collapsible-block 及其 header
  背景由 --surface-soft 改 transparent,与堆叠块(stacked-block)一致,
  消除"单块奶油底 vs 多块透明"的不一致。

== 五、布局修复 ==
- 工作区隐藏后残留空白:.workspace-panel--collapsed 补 flex-basis:0 +
  width/min-width:0,修正 flex-basis:auto 被子元素内容宽度撑开导致内联
  width:0 失效、collapsed 后仍占 ~300px 的问题。

== 六、防回退栏杆(新增)==
- 新增 .stylelintrc.cjs:三条规则拦截裸 hex / rgb()|hsl() 字面色 /
  var(--x, fallback) 兜底 / prefers-color-scheme;存量未清理文件用
  BASELINE_EXEMPT 临时豁免(现仅剩 5 个 admin 页面,本轮按用户要求不处理),
  清理一个移除一个、不再回退。
- package.json:build 脚本接入 stylelint(tsc → stylelint → vite),
  新增 lint:css 脚本及 stylelint/postcss-scss/postcss-html 依赖。
- 修复 25 处 \!important 笔误(zsh 历史扩展污染导致的无效 CSS)。

== 七、辅助脚本(新增 scripts/)==
- lint_check.py:Node 环境不可用时的 stylelint 替身(复刻三规则 + 括号
  校验,支持多行声明检测)。
- color_map.py:硬编码色 → token 反查,判定可映射/野色。
- strip_fallback.py:安全剥离已定义 token 的冗余 fallback。

== 八、文档 ==
- CLAUDE.md / AGENTS.md 前端设计规范更新:颜色规则扩写为三主题定位 +
  两层 token + 表面层次 + 强调色克制 + color-mix 派生 + stylelint 栏杆;
  新增"实体面板禁止半透明"规则;"禁止边缘光晕"补充"仅在用户明确允许
  或要求时才可使用"。

注:前端构建验证(npm run build / lint:css)由用户本机完成;AI 工具
上下文 Node v24 段错误无法运行 node/npm/vite/stylelint。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:20:58 +08:00
e34e2c965c fix(frontend): 多行输入时保持光标可见
adjustTextareaSize 重算高度会打乱 scrollTop,导致多行输入时光标被滚出视野。
新增 keepEditorSelectionVisible:高度重设后,聚焦态用编辑器 coordsAtPos 算出
光标位置并滚回可视区(上下留 lineHeight*0.3 padding),非聚焦态恢复原 scrollTop。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:33:48 +08:00
9be775d346 feat(personalization): 简略消息显示改为持久化到配置文件
原来「简略消息显示」只存浏览器 localStorage(experiments),换设备/清缓存即丢。
现迁移到后端 personalization.json,与主题/默认隐藏工作区一致即时保存:

- 后端新增 compact_message_display 字段(默认 full)及清洗(仅 full/brief,
  容错大小写空格,非法回退 full)
- store: PersonalForm 加字段;setCompactMessageDisplay 写 form + 即时 POST;
  加载时同步进旧 experiments 镜像,并一次性迁移本地遗留 brief 回写配置文件
- 抽屉下拉与 ChatArea 改读 form.compact_message_display,experiments 作兜底
- 保留 localStorage 镜像保证向后兼容,老用户首次加载自动迁移偏好

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:31:06 +08:00
9399d3f41e fix(models): preserve session model selection 2026-06-02 20:36:51 +08:00
779140ef59 fix(frontend): 新建对话时重置 token 统计
新对话流程(resetNewConversation)清空消息时一并调用 resetTokenStatistics,
避免上一对话的 token 统计残留到新对话。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:09:00 +08:00
469e804e38 fix(frontend): 工具块展开内部滚动条样式统一,滚动槽透明
- MinimalBlocks 思考内容(.thinking-content)补全自定义滚动条
- ToolAction 结构化显示三类滚动区(content/diff/code/output pre)统一样式
- 参考 git 状态侧边栏滚动条:10px 圆角胶囊滑块、半透明主题色
- 滚动槽(track)设为透明,贴合工具块灰色底色,避免明显槽体
- 颜色走 --claude-*/--theme-* 变量,三模式自适应

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:07:11 +08:00
548455f127 feat(frontend): 状态栏 +/- 行数变化加数字滚筒动画
新增 RollingNumber.vue:逐位纵向滚筒,值变大下面顶上来、变小上面压下来,
高位先动(38ms 错开),一次变化两格直达不路过中间数字。接入 InputComposer
状态栏的 +additions / -deletions 两处。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:15:30 +08:00
04cdf964af fix(deps): 声明 httpx[http2] / h2,修复干净环境缺包启动失败
utils/api_client.py 用 httpx.AsyncClient(http2=True) 发请求,需要 h2 包。
旧 requirements 只写了 httpx,未声明 http2 extra;开发机碰巧已装 h2 所以
未暴露,干净 release 环境启动即报错:
  Using http2=True, but the 'h2' package is not installed.

- requirements.txt: httpx -> httpx[http2]
- requirements.lock.txt: httpx[http2]==0.28.1,并显式锁 h2==4.1.0
- scripts/gen_requirements_lock.py: 升级为可解析 extras,自动锁定 extra
  引入的传递依赖(如 httpx[http2] -> h2),避免以后重生 lock 丢失 h2;
  通用于任意 extra

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:49:00 +08:00
582f292114 feat(release): 新增开箱启动脚本 setup.sh / start.sh(P4 第一步)
为 release 开箱即用提供入口,把首启向导串进启动流程。Python/Node 均
依赖系统已装运行时(不内置解释器),便携包只带源码 + venv 创建逻辑。

- _bootstrap.sh: 共享引导逻辑(被 source)
  - ensure_python_env: 系统 python 建/复用 .venv,装 requirements.lock.txt
  - ensure_node_env: 系统 node 装 easyagent 依赖 + 前端构建(缺失才装)
  - has_env_file: 首启判断
- setup.sh: 初始化入口 → 备好环境 → 跑 python -m scripts.setup 向导
- start.sh: 启动入口 → 备好环境 → 无 .env 自动跑向导 → python -m server.app
  (透传参数给 server.app,如 --thinking-mode)
- CLAUDE.md: Build & Run 增加开箱方式(./setup.sh / ./start.sh)与
  requirements.lock 说明

验证:bash -n 语法通过;_bootstrap 函数逻辑(ROOT 定位 / 选系统 python /
has_env_file / node_modules 已存在时跳过)实测正确;setup 向导在系统
Python 3.9 下端到端跑通(含写文件),确认 from __future__ annotations
使新式类型注解在 3.9 兼容。

注:实际「建 venv + 联网装依赖 + 解压验证」需在本机网络环境执行;
沙箱内仅做了不联网的逻辑校验。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:19:45 +08:00
1d3703b0e5 build(deps): 固化 Python 依赖版本,新增 lock 与生成脚本
为便携 release 打包与可复现安装做准备(P3 依赖固化)。

- 新增 requirements.lock.txt:锁定 9 个运行时直接依赖的精确版本
- requirements.txt 顶部加注释,说明与 lock 文件的分工(宽松 vs 精确)
- 新增 scripts/gen_requirements_lock.py:从已验证环境一键重生 lock,
  避免手工对照版本出错

调查结论(已核实):
- 项目源码真实第三方依赖与 requirements.txt 完全一致,无遗漏
- tiktoken 已不再使用(token 统计改用 API usage 字段),无需纳入
- 三个 node 项目的 package-lock.json 均已存在并被 git 追踪,npm ci
  可直接用,node 端无需改动

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:10:28 +08:00
b8a51c1c63 refactor(config): 移除硬编码模型残留,部署级配置外置到 ~/.agents
## 模型逻辑清理
早期把模型 API 端点/密钥/模型 ID 硬编码的残留(AGENT_API_* / THINKING_* /
TITLE_* 三件套)已彻底移除。模型配置统一由 config/custom_models.json
(经 model_profiles 解析为档案)描述,运行时通过 apply_profile 注入;
没有任何可用模型时按既定行为报错。

- config/api.py: 删 9 个三件套符号,仅保留 DEFAULT_RESPONSE_MAX_TOKENS
- utils/api_client.py: client 改为空配置起步,移除三件套 import
- server/chat_flow_helpers.py: 删死 import(TITLE_* 符号)

## 部署级配置外置(按"决策权归谁"分类)
config/*.json 不再一概锚定源码树:
- 程序能力(docker_risk_markers / skill_hints)留源码树,随版本演进
- 部署者自定义(custom_models / host_workspaces / auto_approval /
  goal_review / forbidden_commands / host_sandbox_policy)外置到
  ~/.agents/<mode>/config/

新增统一解析机制(config/paths.py):
- DEPLOY_CONFIG_DIR(默认 ~/.agents/<mode>/config,可用环境变量覆盖)
- resolve_deploy_config():只读,回退链 部署目录→源码树.json→源码树.example
  (开发环境不必先跑 setup 也能用源码树种子)
- deploy_config_path():写回用,稳定指向部署目录

6 个加载点改用上述解析;顺带修复 approval_agent / goal_review_agent 的
DEBUG_TRANSCRIPT_DIR 仍指旧源码树 logs/ 的漏迁问题。

## 安全与运维
- 含密钥/机器特定的 5 个文件停止 git 追踪(git rm --cached,本地保留)
  并加入 .gitignore,仓库仅留 .example 种子;forbidden_commands 保留
  追踪作默认黑名单
- scripts/setup.py: 模型配置写到部署目录(用与 paths 一致的独立推导,
  不 import config 以免过早锁定路径)
- scripts/migrate_runtime_data.py: 新增 config/*.json → 部署目录迁移
  (备份 + 不覆盖已存在)

## 关联:P1 配置收敛 + P2 首启向导
- config/server.py: Web 端口/监听地址/debug/NODE_BIN/PYTHON_BIN 单一事实源
- 消灭 8091 多处重复定义(state.py 死代码、app_legacy、main.py 各读各的)
- 修复 sub_agent_manager 命令数组硬编码 "node" → NODE_BIN(便携包内置
  Node 的前提)
- scripts/setup.py: 终端首启向导(模式/端口/管理员/密钥/模型)

## 测试
test_config_paths_resolution 更新以反映新行为(host_workspaces 锚定部署
目录、新增 DEPLOY_CONFIG_DIR 用例);全部离线用例通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:59:34 +08:00
eaa5e3bee9 refactor(runtime): 运行态数据迁出源码树到 ~/.agents,日志默认关并加轮转,清理实验残留
将运行态数据(对话/用户工作区/日志等)默认迁出源码树到 ~/.agents/<mode>,
对齐 ~/.claude、~/.codex 惯例;同时为日志加上开关与轮转,并归档/清理仓库内
长期堆积的实验残留与误产生文件。

## 路径变量重写(核心)
- 重写 config/paths.py:运行态根按模式分流,host -> ~/.agents/host,
  其它(默认 docker/web)-> ~/.agents/web。
- 三级解析优先级(高→低):
  1) 具体目录变量 DATA_DIR / LOGS_DIR / USER_SPACE_DIR / API_USER_SPACE_DIR
  2) 模式根变量 AGENTS_HOST_HOME / AGENTS_WEB_HOME
  3) 兜底 ~/.agents/<mode>
- 导出变量名保持不变,20+ 处消费方零改动;统一 config/sub_agent.py 重复的
  路径解析逻辑,将 sub_agent 任务目录/状态文件收编进 DATA_DIR;
  SUB_AGENT_PROJECT_RESULTS_DIR 仍随工作区(有意保留)。
- config/*.json、prompts/、agentskills/ 属配置/资源,仍锚定源码树。

## 日志止血
- 新增 utils/log_rotation.py:按大小轮转(默认 20MB×3 份)+ 按份清理
  (dump 默认保留最近 30 个),阈值可由 AGENT_LOG_ROTATE_MAX_BYTES /
  AGENT_LOG_ROTATE_BACKUPS / AGENT_DUMP_KEEP 覆盖。
- utils/api_client.py:API 请求体 dump 默认关闭(AGENT_API_DUMP_ENABLED 开启),
  收编两处写死的 logs/ 硬编码路径改用 LOGS_DIR;dump 关闭时 request_dump 安全置空。
- utils/host_workspace_debug.py、server/utils_common.py(chunk/conn_diag 等)、
  utils/logger.py(TaskLogger/ErrorLogger)统一接入轮转。

## 迁移与运维脚本
- scripts/migrate_runtime_data.py:源码树 -> 运行态根,复制+备份+校验+可回滚+幂等,
  logs 丢弃不迁;import config 复用程序同一套路径解析(模式由 .env 决定),
  覆盖 data/users/api 及 sub_agent/tasks(-> data/sub_agent_tasks)。
- scripts/cleanup_misplaced_web.sh:清理误迁到 ~/.agents/web 的副本(带 chflags 兜底)。

## 测试
- 重写 test/test_config_paths_resolution.py:覆盖默认分流、模式切换、模式根变量覆盖、
  具体目录变量最高优先级、相对路径锚定 repo root、源码树配置项不随迁移等 6 个用例。

## 仓库清理
- 删除旧版子智能体目录 sub_agent/(逻辑已统一在 easyagent/)。
- 删除误产生/垃圾文件:误敲命令生成的 "ystemctl status ..."、空文件 .zhouyanbo /
  testfile_from_ai、运行态日志/pid、test_playwright.png、test_system_message.py 等。
- 实验残留与历史文档(BUG_FIX/POLLING 变更日志、SUB_AGENT 文档、翻译资料、
  model_tests、compact_result、奇奇怪怪的bug、截图、goal_research)归档到
  本地 _experiments/,并从版本控制移除(已加入 .gitignore)。
- .gitignore:新增 api/、_experiments/,清理已失效的 sub_agent/* 条目与冗余项。

## 文档
- CLAUDE.md / AGENTS.md:补充数据目录与路径变量、日志策略、迁移流程,
  更新目录结构(移除 sub_agent、新增 _experiments/scripts),调试日志路径改为 ~/.agents/<mode>/logs/。
2026-06-01 13:17:32 +08:00
be08026fca fix(runtime): preserve mode change notice source 2026-06-01 00:02:43 +08:00
ad20853965 fix(server): isolate session cookie per port to prevent login conflicts across instances 2026-05-31 23:36:22 +08:00
f2d7d8f3b1 fix(ui): refresh git summary while idle 2026-05-31 23:28:10 +08:00
2f0e269788 fix(ui): adjust composer status layering 2026-05-31 22:14:57 +08:00
0815625cfd docs: 前端改动不强制本地构建(沙箱无法跑 build)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:55:03 +08:00
75594aa2aa fix(ui): 修复 write_file 内容在 diff grid 下被挤成多行
.diff-line 改为 grid 三列布局后,write_file 仍输出裸文本,整段
被塞进 5ch 宽的首列竖排折行。改为与 edit_file 一致的行号/标记/内容
三 span 结构,内容落在 1fr 列正常换行。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:51:31 +08:00
a224537618 docs: add precise reading guideline 2026-05-31 17:28:51 +08:00
6f78277a03 feat(ui): add project git status panel 2026-05-31 17:27:24 +08:00
6d5630c820 feat(file-edit): support batched replacements 2026-05-31 13:15:37 +08:00
5bdc60006d fix(auth): allow custom session cookie name 2026-05-31 11:40:44 +08:00
fc70179b02 feat(web): 统一前端设计风格并重写回顾/版本管理弹窗
- 将 8 条前端设计风格原则写入 CLAUDE.md 与 AGENTS.md
- 重写对话回顾弹窗:扁平化去圆角套娃、移除彩色光晕、固定高度、
  自定义关闭按钮、滚动条隐藏、颜色全走三模式变量
- 回顾弹窗改用独立列表 state,加载更多改为追加不刷新、不复位滚动,
  按钮样式对齐侧边栏(全宽/文字靠左/固定高度)
- 后端 get_conversation_list 新增 non_empty 参数,回顾弹窗仅显示
  有内容的对话且分页数量一致(侧边栏不受影响)
- 重写版本管理弹窗:原生 select/checkbox 换自定义下拉与开关、
  拆圆角套娃、固定高度、颜色走变量(diff 红绿语义色保留)
- 版本管理开关旁文字随状态显示开启/关闭
- icons 注册 refreshCw 键

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:30:08 +08:00
3154cd8bc6 chore(config): raise goal review timeout to 600s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:32:30 +08:00
59a79b0063 fix(web): align slash menu spacing, corners, and scroll step
- Add bottom padding so the last option has a gap below it
- Size max-height for 5 rows + 6 gaps (incl. border) so all 5 show
- Equalize side/row gaps at 5px and make first-item corners concentric
- Read row height + gap from CSS vars in scroll logic instead of hardcoded 41px

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:30:46 +08:00
50310a1bf8 feat(web): expand slash command menu 2026-05-31 00:06:48 +08:00
39328cf6a9 fix(web): use tiptap mention for skill input 2026-05-30 23:31:13 +08:00
72c2b2af2a merge: feat/deep-compression-inplace 2026-05-30 22:19:51 +08:00
8fddf0d1ed fix(frontend): refine personal space copy 2026-05-30 22:19:41 +08:00
bb44120087 fix: apply personalization after workspace switch 2026-05-30 22:18:24 +08:00
dc0149f134 fix: refresh disabled custom tools 2026-05-30 22:13:12 +08:00
9f37479272 chore: remove autosaved presentation 2026-05-30 22:10:45 +08:00
1702ccacd2 fix(ui): 目标横幅在队列/skill菜单展开时收起为圆点且不顶高布局
目标模式就绪横幅原为流内元素,撑高 stadium-input-wrapper,使锚定其顶部的
消息队列与 skill 菜单被顶起,且其高度被计入聊天区预留高度,进而抬高可滚动范围。

- 收起态切为 absolute 脱离文档流,锚定输入框上方,圆点位置不变、文字收拢
- collectComposerVisualHeight 排除收起态横幅,不再计入预留高度
- 新增 watch(goalBannerCollapsed) 在 skill 菜单开关时重新上报高度

队列为空且无 skill 选择时自动展开恢复原样。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:51:32 +08:00
5693c50d3a chore: gitignore .claude/ 本地产物
忽略 Claude Code 的本地设置与 worktree 隔离副本,避免出现在 git status。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:01:35 +08:00
3fd2214da0 feat(compression): 深压缩改为 in-place 标记前缀,不再切换新对话
深压缩从"建新对话+切过去"重构为标记当前对话历史前缀(deep_compacted):
原文保留并照常显示,仅在 build_messages 构建请求时排除。conversation_id
不变,避免任务状态/目标模式/前端对话切换的大量适配带来的 bug。

- deep_compression.py: _mark_history_compacted 打标 + 重置 current_context_tokens
  防自动续接死循环;总结请求传入正常 tools 以 100% 命中前缀缓存
- context.py build_messages: 跳过 deep_compacted 消息
- conversation.py /compress: 去掉切对话,按 compress_behavior 决定续接/等待
- 新增个性化设置 deep_compress_form(file/inject) 与
  deep_compress_behavior(continue/wait,仅手动压缩生效)
- 前端去掉强制切换对话,改为重载当前对话刷新展示
- AGENTS.md/CLAUDE.md: 补充默认中文交流约定等说明

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:56:15 +08:00
f5e4541d83 feat(chat): add compact message display mode and goal-review/compression headers
- 审核(goal_review) user 消息气泡显示「审核 + circle-alert 图标」
- compact 消息新增「简略信息」显示形式(个人空间可切换),渲染为横线包裹的一行概要
- 压缩续接消息单独标记为 compression_handoff,visibility 改为 compact

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:05:59 +08:00
32865f6d6e fix(ui): refine message visibility and goal approval flow 2026-05-30 16:30:05 +08:00
e220b3703a feat(web): add skill slash selector 2026-05-30 14:50:00 +08:00
8a1e17b9a0 feat(goal): add autonomous goal mode workflow
Introduce workspace-level goal state persistence, goal prompt injection, and after-turn review handling so an active task can continue until the configured completion conditions are met.

Add a dedicated goal review agent with readonly and active evidence modes, configurable model settings, review prompt, token/turn boundaries, idle-no-tool protection, and progress/completed/stopped events.

Wire goal_mode through task creation, task restoration, compression handoff, runtime user messages, API message sanitization, and tool-call ordering so goal continuations survive long-running tasks and deep compression.

Add Vue UI for arming goal mode from the quick menu, showing running/completed banners, displaying progress metrics, restoring running goal state, and exposing personalization settings for review mode and stop limits.

Include goal mode research notes and default goal review configuration.
2026-05-30 12:51:42 +08:00
77752a22d4 fix(frontend): hide workspace status badges 2026-05-29 20:32:33 +08:00
3d4c483807 chore(config): stop tracking custom models
config/custom_models.json is local runtime configuration and is already listed in .gitignore. Remove it from the Git index while keeping the local file available for this machine.
2026-05-29 20:16:55 +08:00
b1cf4b9ff2 fix(frontend): restore conversation delete animation
Use a two-phase delete flow for conversation list items.

Instead of immediately removing the deleted conversation from the array and relying only on transition-group leave hooks, mark the target conversation as pending deletion first so it can play the left-slide animation, then remove it after the animation completes.

Also keep the list container mounted during delete mode so empty/loading branches do not interrupt the exit transition.

Validation: npm run build --silent 2>&1 | tail -n 20
2026-05-29 19:57:23 +08:00
08d9812f45 feat(docker): support isolated projects in web mode
Implement Docker Web project management on top of workspace_id while keeping host mode workspace behavior intact.

Backend changes:

- Move Docker user files to users/<user>/projects/<project_id>/{project,data,logs} and shared user state to users/<user>/personal.

- Add automatic legacy migration from old project/data/logs/agentskills layouts and from the temporary project/shared layout.

- Generate project IDs server-side from the project name, reserve internal IDs, and hide project paths from the UI.

- Add project rename/delete APIs; deleting a Docker project removes its project directory and releases terminal/container state.

- Add host workspace rename/delete APIs; deleting a host workspace only removes catalog entries and keeps files on disk.

- Scope Docker containers, terminals, uploads, conversations, storage, and running task metadata by workspace_id.

- Store private skills under users/<user>/personal/agentskills and sync them into each project workspace.

- Inject the current project/workspace name through prompts/workspace_system.txt.

Frontend changes:

- Replace Docker file-tree area with project management UX and reuse host multi-workspace running-task behavior.

- Add a management dialog for creating, renaming, and deleting projects/workspaces.

- Show project labels instead of internal IDs in toasts and project lists.

- Clear stale conversation history during project/workspace switching and show loading until the new list is ready.

- Keep running-task loader/check behavior consistent across host workspaces and Docker projects.

Validation:

- python3 -m py_compile modules/user_manager.py modules/host_workspace_manager.py modules/skills_manager.py core/main_terminal_parts/tools_execution.py server/auth.py server/context.py server/status.py server/conversation.py server/tasks.py utils/context_manager.py

- python3 -m unittest test.test_server_refactor_smoke

- npm run build --silent 2>&1 | tail -n 20
2026-05-29 19:49:48 +08:00
03cb39924a feat(host): support concurrent workspace conversations
Implement host-mode multi-workspace concurrency while preserving one active task per workspace. Host terminals are scoped by workspace id, host workspace switching no longer globally refreshes or blocks on tasks, and task APIs now expose workspace/conversation metadata for frontend coordination.

Update conversation navigation so creating/loading conversations during an active workspace task is view-only and does not mutate backend terminal context. This prevents running task output from being pushed into the currently viewed conversation after switching or creating a new conversation.

Add running task awareness to the frontend sidebar, composer, and polling flow. Running tasks can be shown across workspaces, current-workspace active conversations render inline loaders, completed unviewed tasks persist as check indicators across refresh, and completed tasks clear once viewed.

Restore running conversations correctly after switching workspace/conversation or refreshing during an active task. REST polling is rebound only for the matching conversation/task, stale poll responses are ignored, empty assistant placeholders are restored before first content, and completion clears loader/stop states without requiring page refresh.

Adjust host workspace UX so switching workspaces loads the active conversation if one is running, otherwise opens the new-conversation state. Other conversations in a busy workspace are view-only with disabled input instead of blocked navigation.

Validation: python3 -m py_compile server/auth.py server/context.py server/conversation.py server/status.py server/tasks.py; python3 -m unittest test.test_server_refactor_smoke; npm run build --silent 2>&1 | tail -n 12.
2026-05-29 17:54:11 +08:00
672110c213 docs: adjust agent reporting rules 2026-05-29 14:10:34 +08:00
1c1aee090c fix(sidebar): highlight current conversation 2026-05-29 13:56:01 +08:00
69ae036f08 perf(status): defer docker stats on startup 2026-05-29 13:55:54 +08:00
1c67468ba9 fix(cli): support esm bundle require 2026-05-29 00:21:08 +08:00
eb00a3522a refactor(models): use dynamic api model registry 2026-05-29 00:20:54 +08:00
8da51b3b68 fix(ui): refine dark mode menu highlights 2026-05-29 00:20:31 +08:00
bed194386a fix(mobile): align conversation overlay in app 2026-05-28 20:50:44 +08:00
f61a8df6fa feat: add blocking user question tool 2026-05-28 16:30:55 +08:00
92ca6e460e docs(agents): allow direct main development 2026-05-28 14:30:44 +08:00
0b6b39f262 fix(ui): refine sidebar and personal space styling 2026-05-28 14:15:02 +08:00
611fcdd6d3 fix(prompt): improve title generation wording 2026-05-28 13:17:55 +08:00
ecba45da65 feat(ui): add sidebar list and workspace animations 2026-05-28 04:49:52 +08:00
3511f0e6ee fix(prompt): remove emoji from title generation 2026-05-28 03:54:11 +08:00
3bc6f718eb feat(ui): refine sidebar workspace and code blocks 2026-05-28 03:53:49 +08:00
348cad2b1a fix(personalization): 调整个人空间头部对齐与内容区可视高度 2026-05-28 00:08:51 +08:00
1812ee7c27 fix(skills): migrate read_skill path to .agents/skills 2026-05-27 19:17:53 +08:00
cd82c6d658 fix(ui,runtime,search): merge runtime/frontend stabilization into main 2026-05-27 16:38:19 +08:00
e955ef73e0 feat(ui): redesign personal space settings 2026-05-27 16:15:21 +08:00
398ee6e760 feat(memory): refactor proactive memory and conversation recall
Refactor conversation persistence into workspace-scoped storage with host workspace legacy migration, current workspace filtering, and active host workspace state protection.

Add proactive recent-conversation prompt injection with personal-space controls, configurable item count, empty-conversation filtering, and frozen per-conversation prompt metadata to keep request caches stable.

Introduce conversation_search and conversation_review tools for current-workspace history: multi-keyword search, list mode, current conversation exclusion, message/tool counts, formatted model output, read/save review modes, long-review fallback, and .agents/review storage.

Move internal workspace artifacts under .agents, including uploads, skills, compact results, and reviews; update prompt file-tree visibility, compression output, upload paths, and hidden versioning exclusions.

Add create_skill workflow and a Skills tool category, move read_skill into Skills, validate/归档 skill folders, support host global skills and Docker/API private skills, and sync private skills into .agents/skills and skill prompts.

Update frontend tool icons, statuses, and expanded renderers for conversation recall and skills workflows; add workspace conversation and skills-manager tests.
2026-05-27 03:09:42 +08:00
ae9157c161 fix(skills): update frontend design skill and read_skill output 2026-05-26 02:02:14 +08:00
aff972b218 fix(input): polish runtime queue rendering 2026-05-25 22:57:29 +08:00
41305ae2a8 fix(composer): avoid draft restore while busy 2026-05-25 22:57:29 +08:00
b03d158ab6 fix(chat): avoid idle runtime notice injection 2026-05-25 22:57:29 +08:00
1e83bc9f68 chore(logging): tune title diagnostics and file logs 2026-05-25 22:57:29 +08:00
eaf270766f fix(frontend): sync generated conversation title 2026-05-25 22:52:03 +08:00
16473c824d chore: 合并多项独立 WIP 改动
- cli: 增加 layoutRefreshTick 与 IME 输入组件,处理 slash_menu 光标偏移
- config/auto_approval: 切到 opencode 中转端点
- modules/approval_agent: 调试 transcript 增加 trace / final_result 字段
- static/useLegacySocket: 重试 toast 显示具体错误信息

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 02:01:36 +08:00
67fdb715f5 refactor(messages): 统一运行期消息插入为 [系统通知|source] 前缀的 user 消息
- 新增 server/chat_flow_task_support.inject_runtime_user_message 作为唯一入口,
  统一处理 add_conversation + messages.insert + sender(user_message) 三件事
- 子智能体 / 后台 run_command / runtime_guidance / mode notice 全部走该入口,
  role 一律 user,inline 仅作 metadata 标记,不再复用 system role 表达语义
- inline poll 移出 tool 执行循环,改为整轮 tool 完成后批量注入,
  避免在 assistant.tool_calls 与 tool 序列之间插入 user 导致 API 报错
- 删除 _insert_completion_notice_message / _record_sub_agent_message
  以及 build_messages 中 system→user 的回转分支
- 移除已废弃的 wait_sub_agent 工具残留(后端注册、前端图标/动画、
  prompts、SKILL 文档;保留 sleep 的 wait_sub_agent_ids 参数)
- 前端取消对 is_auto_generated/auto_message_type 的过滤,
  让运行期注入的 user 消息在历史和实时推送中正常渲染

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 02:01:36 +08:00
30e7709df1 merge(cli): 合并 feature/cli-react-tui 到 main
引入基于 React/Ink 的 CLI 端,修复全屏布局导致滚轮失效及光标偏移问题。
详见 cc9df79。
2026-05-16 01:07:06 +08:00
cc9df7959a feat(cli): 新增 React/Ink CLI 端,修复全屏布局与光标定位
## 新增:基于 React/Ink 的 CLI 终端(cli/)

使用 Ink 框架构建完整的终端 UI,取代原有的纯 print 交互方式:

- App.tsx:主应用组件,管理对话状态、任务轮询、键盘输入、slash 命令
- components.tsx:Timeline、Composer、StatusLine、Picker、SlashMenu 等全部 UI 组件
- eventMapper.ts:将后端 SSE 事件流映射为 Timeline 条目,支持 thinking/tool/assistant/sub_agent 等类型
- api.ts:封装所有后端 REST 接口(任务、对话、模型、权限、工作区等)
- commands.ts:slash 命令定义(/new /resume /model /permission /execution /workspace /compact /tools 等)
- workspaces.ts:本地工作区目录的持久化管理
- types.ts:所有共享类型定义
- format.ts:context 格式化、路径缩写等工具函数

## 重大修复:去除固定高度全屏模式,恢复滚轮滚动

**问题根因**:根 Box 设置了 `height={stdout.rows}` 使 Ink 进入原地重绘模式
(类似 vim/htop),所有内容在同一块屏幕区域反复擦写,永远不会真正
"滚出"屏幕顶部,导致 terminal 的 scrollback buffer 始终为空,鼠标
滚轮无任何响应。

**修复方案**:
- 移除根 Box 的 `height` 约束和 `overflow="hidden"`
- 移除内层 Box 的 `flexGrow`/`justifyContent="flex-end"` 全屏布局
- Timeline 不再做行数截断,直接渲染全部条目
- 内容自然流入 terminal scrollback buffer,鼠标滚轮恢复正常

**同步清理**:删除 `getTimelineMaxRows`、`estimateTimelineRows`、
`scrollOffset` 状态及方向键滚动逻辑(这些都是全屏模式下的补丁,
去除固定高度后不再需要)

## 修复:Composer 光标位置偏移一行

`setCursorPosition` 中 y 轴使用 `lines.length` 导致单行时偏移 +1。
改为 `lines.length - 1`,与实际最后一行渲染位置对齐。

## 其他

- AGENTS.md / README.md:补充 CLI 启动方式与架构说明
- server/chat.py:补充任务轮询接口所需字段
- package.json:新增 cli 相关脚本入口
- docs/cli_slash_commands_spec.md、docs/cli_ui_display_spec.md:CLI 设计文档

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:06:51 +08:00
688 changed files with 95961 additions and 108070 deletions

View File

@ -0,0 +1,153 @@
---
name: agent_hooks_design
description: 当需要继续讨论或实现本项目的 hooks / 生命周期钩子功能时,应该索引本记忆
---
# Hooks / 生命周期钩子功能设计讨论(进行中)
## 调研结论
### 主流实现
- **Claude Code**最完整20+ 事件、5 种 handlercommand/http/mcp_tool/prompt/agent、多层配置全局/项目/本地/组织/插件/SDK
- **OpenAI Codex**11 个事件、command 回调为主、有信任审查机制和企业托管 hooks。
- **Cursor**`.cursor/hooks.json`Betacommand 回调,项目/用户/企业三级。
- **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 code0=通过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`
### 例子 2edit_file 单行全替换触发审核
- 事件:`PreToolUse`
- 条件:`matcher=edit_file` + `old_string` 只有一行 + `replace_all=true`
- 行为:返回 `permissionDecision: ask`
### 例子 3Stop 时自动插入 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`

View 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 插值动画,解决工作/休息切换时长度跳变问题。

View 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` 保持一致

View 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`

View 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 与单通知场景一致。

View 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 过渡期间(约 260300ms`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` 开始,并优先定位向上滚动跳回的根本原因

View File

@ -0,0 +1,148 @@
---
name: cloud_server_disk_usage
description: 当需要了解云服务器磁盘使用现状、规划Docker镜像构建空间、或决定清理哪些文件释放空间时应该索引本记忆
---
# 云服务器磁盘使用现状
> 记录时间2026-06-25
> 服务器59.110.19.30Ubuntu 24.04.2 LTS, x86_64
## 总体情况
```text
Filesystem Size Used Avail Use%
/dev/vda3 49G 43G 4.0G 92%
```
- 总磁盘:约 49 GB
- 已用:约 43 GB92%
- 可用:约 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`

View 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/`

View 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 发布。

View 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`(旧数据或新建对话),保持当前模型即可。

View 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、pdftoppmpoppler-utils
- 完全缺少 Node.js / npm
- 完全缺少中文字体(`fc-list` 为 0
导致用户常见的 Office 转换、图片处理、OCR、网页截图等需求无法跑通。
## 改造方案
### 方案 B已选定
在最小方案基础上扩展常见业务能力:
**系统 apt 包:**
- Officelibreoffice、pandoc、poppler-utils
- 图片/视频imagemagick、ffmpeg
- OCRtesseract-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`(服务器磁盘现状)

View 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` 创建非目标模式任务时清理残留的活动目标状态

View 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 时,应优雅降级而不是直接崩溃

View 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 替换为 key34 文件 20 处)。服务器尚未操作,当前仍为 key1。
- 2026-06-25将本地 `~/.astrion/astrion``~/.astrion/astrion-clone` 的配置从 key2 替换为 key34 文件 20 处),删除备份。服务器仍为 key1。
## 相关 key 记录
| 说明 | Key |
|---|---|
| key1 | `sk-zNU867TGGXNKxoM0H6Pg6Jq4PYUPUOPDgMlzrOl3SS6jECRMWK5nNi6YMSsHzoZ` |
| key2 | `sk-sgt4ERgERPx2axU18PSfmenHv9zNDD3y8M5yr0p4ryhcNSm04CZsCxDHftJ9AkFD` |
| key3 | `sk-Jhvi76vJdCEQzvEdl8cAS5MIwBxBS4iTrJYSru2zCAXVnDVQpiZIvrcn9hBwkdZ4` |
| legacy已停用等同 key1 轮换到 key2 | `sk-j0NdTvEDoqqkKCHEAFXXYneEsuXEiOXNCgLeZARJmfYUBf2Riym7KPFWq1amkB6z` |

View 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 桌面 AppPyWebview不使用 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` | 最后一个可用DMG527MB |
## 关键修复历史
### 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 errorsPyWebview 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.11Xcode 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` 是正常运行时数据

View 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
```

View File

@ -0,0 +1,68 @@
---
name: server_deployment_venv
description: 当服务器部署时遇到 venv/pip 损坏、Python 路径错误,或需要更新部署脚本时,应该索引本记忆
---
# 服务器 venv 部署注意事项
## 背景
服务器 `/opt/agent/venv` 最初是从 macOS 复制到 LinuxUbuntu 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`),需要同步修复代码

View 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 步骤,注释说明原因

View 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**,相同 easingeaseOutCubic、相同时间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 两层背景叠加。

View 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/容器模式下,网络由容器自身策略控制,上述配置不影响容器行为。

View 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` 中关于子智能体的描述已更新为当前实现。

View File

@ -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
View File

@ -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
View 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.scssToken 定义源_virtual-monitor.scsscanvas 动画资产
*/
// 颜色相关属性:这些属性的值必须用 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 色号,请使用设计 Tokenvar(--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,
},
],
};

View File

331
AGENTS.md
View File

@ -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/taskREST 任务轮询为主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`
### CLIReact / 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` 文件。
- Python4 空格、`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-onlyhover/选中/运行态走中性灰。半透明 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` 同步;如两者冲突以代码为准并同步修订本节。

View File

@ -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 测试功能。

View File

@ -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接近流式效果
- ✅ 刷新后不重复显示内容
- ✅ 任务完成后不重复加载历史
- ✅ 状态检查避免重复恢复
现在可以启动服务器测试了!

View File

@ -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 轮询)
现在可以测试了!刷新页面应该只显示一次内容,新内容会正常追加。

View File

@ -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
View File

@ -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 壳版本时,必须同步完成:

View File

@ -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
View File

@ -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.

View File

@ -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
## 总结
核心功能已经实现完成,包括:
- ✅ 批处理模式
- ✅ 工具定义和执行
- ✅ 子智能体管理
- ✅ 对话记录保存
- ✅ 统计信息收集
- ✅ 兜底机制
还需要完成:
- ⏳ 后台任务轮询
- ⏳ 对话状态管理
- ⏳ 前端集成
- ⏳ 测试和调试
整体架构清晰,代码结构合理,可以开始测试和完善了。

View File

@ -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. 测试完整流程

View File

@ -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. **对话记录保留**:便于调试和审计

View File

@ -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
View 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" ]
}

View File

@ -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 率、风险分析等场景,卡片应优先体现可信、清晰、可读和结论明确,而不是浮夸科技感。
- 卡片可以包含精美优雅的动画交互,但不要包含任何元素的初始动画或出场动画效果
**重要**:让实现复杂度与审美愿景相匹配。极繁主义设计需要更复杂的代码、大量动画与效果。极简或精致设计则需要克制、精准,以及对间距、字体排印和细微细节的谨慎关注。优雅来自于对愿景的高质量执行。
请记住:你能够完成非凡的创造性工作。不要保守,要展示在跳出框架思考并完全投入鲜明愿景时,真正能够创造出的作品。

View File

@ -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=...)` 等待结果或根据通知定位输出。

View File

@ -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 参数必须包含所有信息

View File

@ -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="...")

View File

@ -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.

View 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 时也要贴近真实产品结构和真实字段;如必须使用示例数据,要保持字段语义真实,不要写臆想说明。

View File

@ -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 DownloadBridgeWebView 同时注册 `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 官方 APIgetResult 内部已含 decode
- recognizeSegment 增加识别结果详细日志
# 1.0.30
- 修复语音识别无声问题:前端处理 initializing 状态,防止初始化期间重复触发 startListening 导致录音永不停止
- Android 端增加启动取消机制,初始化期间按停止不会意外开始录音
- 完善录音链路日志,便于后续调试
# 1.0.29
- 暂时禁用 VADAndroid 端 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 模式触发教程弹窗

View File

@ -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"))))
}

Binary file not shown.

View File

@ -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>

View File

@ -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())

View File

@ -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)
* 总大小 230MBSenseVoice 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()
}
}

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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>

View 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>

View File

@ -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
View File

@ -0,0 +1,3 @@
node_modules/
.easyagent/

226
cli-react-demo/demo.jsx Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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
View File

@ -0,0 +1,2 @@
node_modules/
dist/

1660
cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
cli/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}

View File

@ -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>
```
## 用户最新的一次输入
依然有,我用的无痕模式都有,和之前一模一样

View File

@ -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_varsAPI 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

View File

@ -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",
]

View File

@ -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",

View File

@ -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"

View File

@ -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 Flashreasoning_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 Flashreasoning_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 Proreasoning_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 Proreasoning_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 上下文。"
}
]
}

View 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
}

View File

@ -1,8 +0,0 @@
{
"macos_writable_paths": [
"/Users/jojo/Desktop"
],
"macos_readable_extra_paths": [
"/Users/jojo/Desktop"
]
}

View 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(\\.[^/]*)?$"
]
}

View File

@ -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.2deepseek-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": "",
}

View File

@ -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",

View File

@ -1,5 +1,40 @@
"""项目路径与目录配置。"""
"""项目路径与目录配置。
运行态数据存放在 ``~/.astrion/astrion/``
::
~/.astrion/astrion/ data_root默认可通过 ASTRION_DATA_ROOT 覆盖
settings.json 唯一配置文件
config/ 部署级 JSONhost/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
View 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",
]

View File

@ -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
View 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",
]

View File

@ -43,7 +43,7 @@
"keywords": [
"网页", "前端", "界面", "UI", "页面", "组件", "网站", "落地页",
"仪表板", "dashboard", "react", "vue", "html", "css", "样式",
"美化", "设计",
"美化", "设计","画",
"webpage", "frontend", "interface", "page", "component", "website",
"landing page", "styling", "beautify", "design"
],

View File

@ -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",
]

View 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",
]

View File

@ -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}")

View File

@ -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("当前对话包含图片,目标模型不支持图片输入")

View File

@ -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 promptAPI 用途)。
# 放在最后一个 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 thinkingassistant 历史消息中的
# 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助手。"

View 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

View 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)

View 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)

View 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()

View 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 thinkingassistant 历史消息中的
# 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