mingdao-harness 0.1.64 → 0.1.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  ## 为什么选 MingDao
8
8
 
9
9
  - ⚡ **真正的零依赖**:纯 Node.js ≥ 18,无任何 npm 运行时依赖、无构建步骤——装完即用,不拖 node_modules
10
- - 💰 **DeepSeek-V4 深度省钱**:384K 上下文预设、**缓存命中计价**(命中价仅为未命中的 1/30)+ 命中率仪表盘、峰谷计价(周末全天低价自动识别)、自动路由(pro 规划 / flash 执行 + 分类缓存 + 会话粘滞)、精确 tokenizer(官方词表 BPE)、滞回自动压缩、**Batch API 半价批处理**(`mingdao batch`)、**避峰调度**(`--offpeak` 高峰顺延 14:00 后)、**费用护栏**(每日上限防超支)
10
+ - 💰 **DeepSeek-V4 深度省钱**:384K 上下文预设、**缓存命中计价**(命中价仅为未命中的 1/30)+ 命中率仪表盘、峰谷计价(高峰=北京工作日 9:00–12:00、14:00–18:00,闲时半价自动识别)、自动路由(pro 规划 / flash 执行 + 分类缓存 + 会话粘滞)、精确 tokenizer(官方词表 BPE)、滞回自动压缩、**Batch API 半价批处理**(`mingdao batch`)、**避峰调度**(`--offpeak` 高峰顺延到最近闲时 12:00/18:00)、**费用护栏**(每日上限防超支)
11
11
  - 🖥 **双界面 + IDE 全家桶**:产品级 TUI(流式 Markdown、代码高亮、编辑 diff、Ctrl+C 中断、Tab 补全)与 `mingdao web` 一键 WebUI(PWA 可装桌面、多任务并行、全项设置面板);VS Code 侧边栏与 JetBrains 工具窗深度集成
12
12
  - 🧠 **36 个技能开箱即用**:14 个内置常驻 + 22 个可安装技能库(线上 registry 逐文件 sha256 校验防供应链篡改,`mingdao skill install sql` 一键装,可自建企业内 registry)
13
13
  - 🔌 **生态即插即用**:MCP 客户端(零依赖实现,`mcpServers` 配置即接入任意 MCP 服务器)+ Hooks 钩子 + 9 个 MCP 生态预设
@@ -91,7 +91,7 @@ PreToolUse(stdin 收 JSON,stdout 输出 `{decision:"block", reason}` 可阻
91
91
 
92
92
  1. **预设调优**:v4-pro(推理/规划:温度 0.4、输出上限 32k、预算 200k);v4-flash(日常:温度 0.6、输出 8k、预算 128k);两者 `contextWindow` 均为 384K(正式版规格:2026-08-17 起 V4-Pro 转正商用),预算可随时调高。
93
93
  2. **推理内容流式展示**:`reasoning_content` 以暗色增量渲染,与正文同流。
94
- 3. **峰谷定价适配**:每轮后展示 prompt/completion tokens,便于用户把批处理放在谷时段(v4 系列 2026-08-17 起峰谷定价,高峰 9:00–14:00 为闲时 2 倍)。官方同时提供 Responses API 与 Anthropic 兼容接口;MingDao 默认走 OpenAI 兼容 chat/completions,如需原生协议可写自定义 Provider 模块。
94
+ 3. **峰谷定价适配**:每轮后展示 prompt/completion tokens,便于用户把批处理放在谷时段(v4 系列 2026-08-17 起峰谷定价,高峰=北京工作日 9:00–12:00、14:00–18:00,闲时价=高峰一半)。官方同时提供 Responses API 与 Anthropic 兼容接口;MingDao 默认走 OpenAI 兼容 chat/completions,如需原生协议可写自定义 Provider 模块。
95
95
  4. **模型路由(路线图)**:规划用 v4-pro、执行用 v4-flash 的自动分工;Provider 抽象已支持任意切换。
96
96
  5. **可靠性**:超时 + 指数退避重试,长上下文下避免瞬时错误中断任务。
97
97
 
package/docs/CONFIG.md CHANGED
@@ -179,7 +179,7 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
179
179
  分账(`batch` 标记)。端点不支持的网关会明确报错;可用 `config.batchBaseUrl` 指定支持
180
180
  批处理的网关、`batchEndpoint`/`batchWindow` 覆盖协议字段。
181
181
  - **避峰执行(高峰输入价 2 倍)**:`mingdao run --offpeak` / `mingdao schedule add --offpeak`
182
- ——高峰时段(北京时间工作日 9:00–14:00)自动顺延到 14:00 后执行;**周末全天按闲时计价**
182
+ ——高峰时段(北京时间工作日 9:00–12:00、14:00–18:00 两段)自动顺延到最近闲时(12:00 / 18:00)执行;**周末与午间 12:00–14:00 按闲时计价**
183
183
  (DeepSeek 官方邮件确认),不触发避峰等待。WebUI 调度面板勾选「🌙 避峰执行」。
184
184
 
185
185
  ## 云同步
@@ -0,0 +1,85 @@
1
+ # 六份第三方技术评估综合分析与优化路线(2026-08-27)
2
+
3
+ 对 `/persistent/home/YouLi/办公云盘/MingDao-Harness` 下六份评估(DuMate / Hermes / Kimi / MiniMax / OfficeACE×3 / workbuddy)逐份研读后,结合当前代码核实的综合结论与行动计划。
4
+
5
+ ## 一、报告结论速览
6
+
7
+ | 报告 | 视角 | 结论 |
8
+ |---|---|---|
9
+ | DuMate | 逐文件代码审计 + 评分卡 | 架构 9/10、安全 9/10、可维护性 7/10;定位兑现度高 |
10
+ | Hermes | 省钱定位深度剖析 + 冒烟实跑 | "实现程度高、技术路线正确";缓存前缀稳定性是"最值钱的设计判断" |
11
+ | Kimi | 实现度评估 | 8.2/10;发现 1 个 P0(峰谷窗口)与多个 P1(思考强度/上下文字段) |
12
+ | MiniMax | Token 优化评估 | 重度使用可降至裸调 20–35%;P0-1"压缩破坏前缀"(与 Kimi 表扬相矛盾,经核实为观察角度差异) |
13
+ | OfficeACE | 迭代方向 + 优化方案 O-01~O-19 | 8.5/10;显式缓存控制/技能压缩/路由优化为共识 P0 |
14
+ | workbuddy | 全量克隆 + 实测 | 8.5/10;smoke 51 + e2e 16 全过 |
15
+
16
+ **共识**:六份报告一致认可"省 Token 省钱"定位兑现度高;共同短板是 WebUI 单体、价格/词表数据硬编码、记忆检索弱、评测基准缺失。
17
+
18
+ ## 二、已核实并修正的问题(本版 0.1.66 实施)
19
+
20
+ ### P0-1 峰谷窗口与避峰顺延错误(Kimi 报告 P0,官方口径核实)
21
+ - 现象:代码按单窗口"工作日 9:00–14:00 高峰"计价;`--offpeak` 把任务顺延到 14:00——恰是第二段高峰起点,**功能效果与意图相反**;14:00–18:00 费用被低估一倍。
22
+ - 官方权威口径(https://api-docs.deepseek.com/zh-cn/quick_start/pricing/):**高峰 = 北京时间周一至周五 9:00–12:00、14:00–18:00;其余为闲时;闲时价 = 高峰价一半**。
23
+ - 已修复(`src/pricing.js` / `cli.js` / `schedule.js` / `tasks.js` / `models.js` / WebUI / README / docs / smoke 12 组断言):
24
+ - `isPeakHour`:双窗口判定,午间 12:00–14:00 与周末全天为闲时;
25
+ - `deferToOffpeak`:上午高峰 → 顺延 12:00;下午高峰 → 顺延 18:00;闲时原时刻执行;
26
+ - 高峰窗口经 `config.pricing.peakWindows` 可配置(官方调整时可零代码跟随);
27
+ - 新增 `peakStatusLabel()` 供面板/备注显示当前时段。
28
+
29
+ ### P0-2 桌面版「检查更新」点击无响应(用户反馈)
30
+ - 现象:菜单点击后任何路径异常(electron-updater 导入失败 / 事件不触发 / 网络挂起)都会静默吞掉。
31
+ - 已修复(`desktop/main.js`):全程 try/catch + promise 结果兜底 + 30 秒超时兜底;已是最新时提示 **"当前版本:v0.1.66,已是最新版本"**;检查中并发给出可见反馈。
32
+
33
+ ### P1 桌面版设置改多级菜单(用户反馈)
34
+ - 现象:⚙ 设置一拉到底(模型/同步/沙箱/调度/工作空间/记忆/仪表盘/MCP/技能库 9 块混排)。
35
+ - 已修复(`src/web/index.html`):左侧分组导航 + 右侧面板(🔑模型与密钥 / ☁️云同步与协作 / 🛡️通用与权限 / ⏰调度队列 / 📁工作空间 / 🧠长期记忆 / 📊缓存仪表盘 / 🔌MCP 生态 / 🧩技能库),记住上次分组、窄屏自动横向导航,全部控件 id 不变。
36
+
37
+ ### P1 界面与会话样式对齐 DeepSeek-Harness
38
+ - 以 gitcode.com/MingDaoTCM/deepseek-harness 的 `packages/client/ui-theme/src/styles/design-platform.css` 实测值为准对齐暗色体系:
39
+ - 基色 `#151517`、侧栏 `#1B1B1C`、面板 `#2C2C2E`、悬浮 `#353638`、边框 `rgba(255,255,255,.12)`;
40
+ - 文字 `#F9FAFB` / `#ADB2B8` / `#81858C`;错误 `#F25A5A`、警告 `#F59E0B`;
41
+ - 用户气泡改 DSH 式扁平中性底 + **22px 圆角** + 16px/24px 排版(上限 min(525px,82%));
42
+ - 行内代码 `#2C2C2E` 底;代码块 `#1B1B1C` + 横幅 `#2C2C2E` + 12px 圆角 + 语言标签 + 一键复制;
43
+ - 主按钮 DSH 暗色近白填充;滚动条 `#3C3C3D`/hover `#545557`。
44
+
45
+ ## 三、下一步优化路线(按投入产出排序)
46
+
47
+ ### 近期(0.1.67~0.2.0,每项半天~1 天)
48
+ 1. **思考强度三档透传**(Kimi P1-B,单点收益最高):`models.js` 加 `reasoningEffort`、Provider 透传 `reasoning_effort`,辅助调用(标题/记忆/分类器)固定 `low`,`/think low|high|max` 切换。预期简单问答输出 token 降 50%+。
49
+ 2. **上下文窗口字段修正**(Kimi P1-C):`contextWindow` 1M、新增 `maxOutputCeiling`、pro 默认 `maxOutputTokens` 65536,减少大文件生成的续写重复计费。
50
+ 3. **价格表外置 + 自动刷新**(Hermes C1):内置表兜底 + `~/.mingdao/pricing.json` 官方价跟随(TTL 7 天),`/cost` 在数据时点超 60 天时提示"价格表可能过期"。
51
+ 4. **轻量语义回收**(Hermes B1):预算 80% 触发 O(1) 回收(最老 tool 消息 → 摘要行、旧 assistant 长文截前 200 字),不破坏前缀,重压缩线下移到 90%。
52
+ 5. **路由升级检测 + 第三态**(Hermes C2 / Kimi):粘滞会话内按生成词/截断次数/工具步数允许升 planner;分类器"不确定"→ 保守走 planner。
53
+
54
+ ### 中期(0.2.x,2–5 天)
55
+ 6. **reasoning 回填裁剪**(MiniMax P0-2):推理链 >1000 字符截尾 500 + 关键决策标记;>4000 完全丢弃留一句概括。
56
+ 7. **增量式压缩**(Kimi P2-D):`compressedUntil` 游标,只对增量段摘要,压缩输入从 O(历史) 降为 O(增量)。
57
+ 8. **护栏前置预估**(Kimi P2-E):发请求前按"trimmed prompt×未命中价 + maxOutput×输出价"估算最坏成本,超限发送前拦截。
58
+ 9. **同回合工具去重 + 会话级 read 缓存**(Hermes C4 / MiniMax P2-2):相同参数只读工具合并执行;read 按 mtime+size 去重。
59
+ 10. **工具 Schema 按需挂载**(MiniMax P3-5 / Hermes A1):只读阶段只给 read/ls/glob/grep,每请求省 1–2K tokens。
60
+ 11. **Batch 输入侧优化**(Kimi P3-F):文本哈希去重、单问超窗口报错、`--max-cost` 截断。
61
+ 12. **评测基准起步**(Hermes E2):`test/bench/` 压缩质量集 / 路由准确率集 / tokenizer 黄金值回归。
62
+
63
+ ### 长期(0.3.x+,可选)
64
+ 13. 跨平台沙箱补位(Windows Job Object + 降权 token;macOS seatbelt)。
65
+ 14. tokenizer 缓存真 LRU + 大文本分级缓存(Kimi P3 / Hermes E3)。
66
+ 15. 费用二级分账(reasoning/tool 维度)+ WebUI 看板扩展。
67
+ 16. WebUI 单体拆分(`routes/` + 前端区块化)——与"轻量单文件易部署"权衡后分阶段做。
68
+
69
+ ## 四、不建议采纳(通用 AI 幻觉式建议)
70
+
71
+ | 建议 | 原因 |
72
+ |---|---|
73
+ | TypeScript 全量迁移 / i18n 框架 / OpenTelemetry | 工具链级重写,与"零运行时依赖 + 单文件可部署"冲突;渐进 JSDoc 即可 |
74
+ | onnxruntime WASM tokenizer / fasttext 本地分类器 / 向量嵌入检索(外部库) | 引入重依赖;语义检索可用 `node:sqlite` + 简单相似度保持零依赖 |
75
+ | better-sqlite3 会话索引 | Node <22 无内置 `node:sqlite`;当前 JSONL+bigram 倒排够用 |
76
+ | A/B 双模型对比路由 | 与省钱目标直接矛盾(同时调两模型) |
77
+ | "固定 prefix 参数 / since message_id 增量 API" | 以可能不存在的 API 为前提 |
78
+ | LLMLingua 压缩 / Speculative Execution | 外部库或高风险推测收益 |
79
+ | MiniMax P0-1 "压缩延迟注入" | 与 Kimi 对滞回压缩的表扬相矛盾;压缩本就发生在轮间,收益被夸大 |
80
+
81
+ ## 五、口径勘误记录(报告间矛盾核实结果)
82
+
83
+ - 峰谷窗口:Kimi 正确(双窗口),MiniMax 漏查 → 已按官方口径修复。
84
+ - "无自动避峰调度"(OfficeACE 二/三):不实,`run --offpeak` / `schedule add --offpeak` / WebUI 均已实现 → 本版修正了顺延目标。
85
+ - 上下文窗口 384K vs 1M:Kimi 指出 384K 实为最大输出规格 → 已列入近期路线第 2 条。
package/docs/QA-REPORT.md CHANGED
@@ -282,7 +282,7 @@ Retry-After 退避;远期:拆 cli.js、子代理并行、插件生命周期
282
282
  | --- | --- |
283
283
  | **Batch API 半价通道(A1)** | `src/batch.js` + `mingdao batch <文件|->`:单轮批量任务走 OpenAI 兼容批处理协议(上传→建批→轮询→取回),结果落盘 JSONL 并计入 `/cost` 分账(`batch:true`,×0.5 计价);轮询连续失败 10 次报错、端点不支持明确报错;`batchBaseUrl/batchEndpoint/batchWindow` 可配。smoke #41 全协议 mock 演练 |
284
284
  | **费用护栏(A2)** | `src/cost-guard.js`:按北京时间自然日累计真实费用(含缓存折扣与 batch 半价),`costGuard{dailyLimitYuan,warnAtYuan,action:warn\|block}`;Agent 每轮开始前检查,block 时暂停并明确告知;`/cost`、WebUI 头部费用徽标实时显示。smoke #40 覆盖 |
285
- | **避峰调度** | `run --offpeak` / `schedule add --offpeak` / WebUI「🌙 避峰执行」:高峰(北京工作日 9:00–14:00)自动顺延到 14:00 后执行;**周末全天识别为闲时**(DeepSeek 2026-08 计费调整)。e2e-schedule #5 覆盖 |
285
+ | **避峰调度** | `run --offpeak` / `schedule add --offpeak` / WebUI「🌙 避峰执行」:高峰(北京工作日 9:00–12:0014:00–18:00,官方口径)自动顺延到最近闲时(12:00 / 18:00)执行;**周末与午间识别为闲时**。e2e-schedule #5 覆盖 |
286
286
  | **/cost 分账升级** | 按模型分账、今日费用、Batch 子项、节省归因(相比全未命中省 ¥X)、护栏状态;Web `/api/cache-stats` 同步输出 |
287
287
  | **token 预算可视化(A5)** | WebUI done 事件携带会话 token 占用/预算,提示栏显示 `预算 45K/128K(35%)`;头部费用徽标 15s 刷新「今日 ¥x · 命中 y% · 护栏 z%」 |
288
288
  | **Retry-After 退避(P3-1)** | provider 重试尊重 `Retry-After` 头(封顶 30s),错误对象透传响应头 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -256,7 +256,13 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
256
256
  // 参数 JSON 解析失败:回填错误给模型,不拿空参数去执行工具
257
257
  io.renderToolDenied(name, {}, '参数解析失败(输出超限被截断?建议分块)');
258
258
  if (auditOn) auditEntry({ denied: true, reason: '参数解析失败' });
259
- messages.push({ role: 'tool', tool_call_id: tc.id, content: '工具参数 JSON 解析失败,请重新输出合法参数。' });
259
+ // 审计(长生成截断循环):给出明确分块指引,避免模型反复用单个超大 write 撞输出上限
260
+ messages.push({
261
+ role: 'tool',
262
+ tool_call_id: tc.id,
263
+ content:
264
+ '工具参数 JSON 解析失败(大概率是输出超过模型单次上限被截断)。请把内容拆分成多个较小文件或多次调用逐步写入:单个 write 的参数总长控制在 6000 字符以内;先写核心骨架,再逐文件补充。',
265
+ });
260
266
  return null;
261
267
  }
262
268
  if (!args || typeof args !== 'object' || Array.isArray(args)) args = {};
package/src/cli.js CHANGED
@@ -201,10 +201,11 @@ async function runWorkerTask(id, question, { permission, model, offpeak }) {
201
201
  perm = 'readonly';
202
202
  note = 'ask 权限下后台任务按只读执行';
203
203
  }
204
- // 避峰(评估 A2/Kimi P-1):高峰时段(北京工作日 9:00–14:00)自动顺延到 14:00 执行,输入价省 50%
204
+ // 避峰(评估 A2/Kimi P-1):高峰时段(北京工作日 9:00–12:0014:00–18:00)
205
+ // 自动顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
205
206
  if (offpeak && isPeakHour(new Date())) {
206
207
  const defer = deferToOffpeak(new Date());
207
- finish({ note: `避峰等待至 ${defer.toISOString().slice(11, 16)}(北京时间 14:00 后)` });
208
+ finish({ note: `避峰等待至北京时间 ${defer.toISOString().slice(11, 16)}(闲时起执行,输入价省 50%)` });
208
209
  await new Promise((r) => setTimeout(r, defer.getTime() - Date.now() + 2000));
209
210
  finish({ note: '' });
210
211
  }
package/src/models.js CHANGED
@@ -9,7 +9,7 @@ export const PROVIDERS = {
9
9
  baseUrl: 'https://api.deepseek.com/v1',
10
10
  envKey: 'DEEPSEEK_API_KEY',
11
11
  models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp'],
12
- note: 'DeepSeek-V4 正式版(2026-08-17 起商用):384K 上下文,峰谷定价(高峰 9:00–14:00 为闲时 2 倍),支持工具调用 / Responses API / Anthropic 兼容接口。',
12
+ note: 'DeepSeek-V4 正式版(2026-08-17 起商用):384K 上下文,峰谷定价(高峰=北京工作日 9:00–12:00、14:00–18:00,闲时价=高峰一半),支持工具调用 / Responses API / Anthropic 兼容接口。',
13
13
  },
14
14
  openai: {
15
15
  label: 'OpenAI',
package/src/pricing.js CHANGED
@@ -12,36 +12,46 @@ import { mingdaoHome } from './config.js';
12
12
  // 内置价格表的数据时点(定价可能调整,配置覆盖可随时更新)
13
13
  export const PRICE_DATA_AS_OF = '2026-08';
14
14
 
15
- // 峰谷判断锚定北京时间(评估 P0-4:DeepSeek 按北京时间 9:00–14:00 高峰计价,
16
- // 用本机时区判断会让海外用户计费错位)。config.pricing.timezone 可覆盖,mtime 缓存避免每次读盘。
17
- let tzCache = { mtime: 0, timezone: 'Asia/Shanghai' };
18
- function peakTimezone() {
15
+ // 峰谷判断锚定北京时间(DeepSeek 官方定价页:高峰时段 = 北京时间周一至周五
16
+ // 9:00–12:00、14:00–18:00 两段;其余(含午间 12:00–14:00 与周末全天)为闲时,闲时价 = 高峰一半)。
17
+ // 用本机时区判断会让海外用户计费错位。config.pricing.timezone 可覆盖时区、
18
+ // config.pricing.peakWindows 可覆盖高峰窗口([[起,止],...] 北京时间整点),mtime 缓存避免每次读盘。
19
+ const DEFAULT_PEAK_WINDOWS = [[9, 12], [14, 18]];
20
+ let tzCache = { mtime: 0, timezone: 'Asia/Shanghai', peakWindows: DEFAULT_PEAK_WINDOWS };
21
+ function peakCfg() {
19
22
  try {
20
23
  const file = path.join(mingdaoHome(), 'config.json');
21
24
  const st = fs.statSync(file);
22
25
  if (st.mtimeMs !== tzCache.mtime) {
23
26
  const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
24
- tzCache = { mtime: st.mtimeMs, timezone: String(cfg?.pricing?.timezone || 'Asia/Shanghai') };
27
+ const pw = cfg?.pricing?.peakWindows;
28
+ tzCache = {
29
+ mtime: st.mtimeMs,
30
+ timezone: String(cfg?.pricing?.timezone || 'Asia/Shanghai'),
31
+ peakWindows: Array.isArray(pw) && pw.length ? pw : DEFAULT_PEAK_WINDOWS,
32
+ };
25
33
  }
26
34
  } catch {}
27
- return tzCache.timezone;
35
+ return tzCache;
28
36
  }
29
37
 
30
38
  export function isPeakHour(date = new Date()) {
39
+ const { timezone, peakWindows } = peakCfg();
31
40
  try {
32
41
  const parts = new Intl.DateTimeFormat('en-US', {
33
- timeZone: peakTimezone(),
42
+ timeZone: timezone,
34
43
  weekday: 'short',
35
44
  hour: 'numeric',
36
45
  hourCycle: 'h23',
37
46
  }).formatToParts(date);
38
47
  const weekday = parts.find((p) => p.type === 'weekday')?.value;
39
- // 周末全天按闲时计价(DeepSeek 官方邮件确认:周六/周日全天闲时低价,批量任务周末更划算)
48
+ // 周末全天按闲时计价
40
49
  if (weekday === 'Sat' || weekday === 'Sun') return false;
41
50
  const hour = Number(parts.find((p) => p.type === 'hour')?.value);
42
- return hour >= 9 && hour < 14;
51
+ return peakWindows.some(([s, e]) => hour >= s && hour < e);
43
52
  } catch {
44
- return date.getHours() >= 9 && date.getHours() < 14; // 非法时区回退本机
53
+ const h = date.getHours();
54
+ return peakWindows.some(([s, e]) => h >= s && h < e); // 非法时区回退本机
45
55
  }
46
56
  }
47
57
 
@@ -49,7 +59,7 @@ export function isPeakHour(date = new Date()) {
49
59
  export function beijingParts(date = new Date()) {
50
60
  let tz = 'Asia/Shanghai';
51
61
  try {
52
- tz = peakTimezone();
62
+ tz = peakCfg().timezone;
53
63
  new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(); // 非法时区在此抛错
54
64
  } catch {
55
65
  tz = 'Asia/Shanghai'; // 审计 B1:坏时区配置回退北京时间,绝不让计费/护栏崩溃
@@ -73,11 +83,24 @@ export function beijingToDate(parts) {
73
83
  return new Date(Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - 8 * 3600 * 1000);
74
84
  }
75
85
 
76
- // 避峰:当前处于高峰(9:00–14:00)→ 顺延到当天 14:00;否则原时刻
86
+ // 避峰顺延:当前处于高峰 顺延到该高峰段的结束整点(即最近一个闲时起点)
87
+ // · 9:00–12:00 高峰 → 12:00(午间闲时)
88
+ // · 14:00–18:00 高峰 → 18:00(晚间闲时)
89
+ // 其余(午间/晚间/清晨/周末)原时刻执行。窗口可经 pricing.peakWindows 覆盖。
77
90
  export function deferToOffpeak(date = new Date()) {
78
91
  if (!isPeakHour(date)) return date;
79
92
  const p = beijingParts(date);
80
- return beijingToDate({ ...p, hour: 14, minute: 0, second: 0 });
93
+ const w = peakCfg().peakWindows.find(([s, e]) => p.hour >= s && p.hour < e);
94
+ const targetHour = w ? w[1] : 18;
95
+ return beijingToDate({ ...p, hour: targetHour, minute: 0, second: 0 });
96
+ }
97
+
98
+ // 当前时段的人类可读描述(设置面板/调度备注共用)
99
+ export function peakStatusLabel(date = new Date()) {
100
+ if (!isPeakHour(date)) return '闲时';
101
+ const p = beijingParts(date);
102
+ const w = peakCfg().peakWindows.find(([s, e]) => p.hour >= s && p.hour < e);
103
+ return `高峰(至 ${String(w ? w[1] : 18).padStart(2, '0')}:00)`;
81
104
  }
82
105
 
83
106
  // 北京时间当日 0 点(费用护栏按自然日累计)
package/src/prompts.js CHANGED
@@ -12,13 +12,14 @@ const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI
12
12
  工作准则:
13
13
  1. 先了解再动手:修改或创建代码前,先用 read / ls / glob / grep 查看相关文件,不要凭空猜测。
14
14
  2. 精准修改:优先用 edit 做小步精确替换;新建文件用 write;保持改动最小化;改错可用 undo 撤销。
15
- 3. 任务管理:多步骤任务先建 todo 清单并逐项更新;与技能(skill)相关的任务先加载对应 SKILL.md。
16
- 4. 委托与专注:独立、可并行的子问题(调研、复核、独立实现)用 task 委托子代理,把结果拿回来继续主线。
17
- 5. 命令谨慎:执行 bash 前想清楚影响;命令失败时阅读错误输出并修复,同一问题最多重试 3 次。
18
- 6. 回答风格:使用用户的语言(默认中文),简洁直接,先结论后说明。
19
- 7. 诚实可靠:不编造不存在的文件、行号、API 或执行结果。
20
- 8. 善用工具:能用工具确认的事实就用工具确认;多个只读工具可连续调用以提高效率。
21
- 9. 交付收尾:完成工具操作后必须给出简短交付总结——生成了哪些文件(路径)、如何运行/使用(如「浏览器打开 angry-birds.html 即可游玩」或运行命令)、以及未完成或注意事项(若有)。绝不能不声不响结束。`;
15
+ 3. 大内容分批写:单个 write 的参数总长控制在 6000 字符以内;大文件先写核心骨架,再分多次 write/edit 逐步补充——单次输出超限会被截断并导致参数解析失败。
16
+ 4. 任务管理:多步骤任务先建 todo 清单并逐项更新;与技能(skill)相关的任务先加载对应 SKILL.md。
17
+ 5. 委托与专注:独立、可并行的子问题(调研、复核、独立实现)用 task 委托子代理,把结果拿回来继续主线。
18
+ 6. 命令谨慎:执行 bash 前想清楚影响;命令失败时阅读错误输出并修复,同一问题最多重试 3 次。
19
+ 7. 回答风格:使用用户的语言(默认中文),简洁直接,先结论后说明。
20
+ 8. 诚实可靠:不编造不存在的文件、行号、API 或执行结果。
21
+ 9. 善用工具:能用工具确认的事实就用工具确认;多个只读工具可连续调用以提高效率。
22
+ 10. 交付收尾:完成工具操作后必须给出简短交付总结——生成了哪些文件(路径)、如何运行/使用(如「浏览器打开 angry-birds.html 即可游玩」或运行命令)、以及未完成或注意事项(若有)。绝不能不声不响结束。`;
22
23
 
23
24
  function loadFile(p, cap) {
24
25
  try {
package/src/schedule.js CHANGED
@@ -134,7 +134,7 @@ export function addSchedule(home, question, /** @type {any} */ { at, every, afte
134
134
  runs: 0,
135
135
  pid: null,
136
136
  createdAt: Date.now(),
137
- note: offpeak ? '避峰:高峰时段自动顺延到 14:00 后执行' : '',
137
+ note: offpeak ? '避峰:高峰时段自动顺延到最近闲时(12:00 / 18:00)执行' : '',
138
138
  offpeak: Boolean(offpeak),
139
139
  };
140
140
  writeSchedule(home, job);
@@ -330,11 +330,12 @@ export async function runSleeper(home, id) {
330
330
  }
331
331
 
332
332
  const runOnce = async () => {
333
- // 避峰(评估 A2/Kimi P-1):高峰时段(北京工作日 9:00–14:00)顺延到 14:00 执行,输入价省 50%
333
+ // 避峰(评估 A2/Kimi P-1):高峰时段(北京工作日 9:00–12:0014:00–18:00)
334
+ // 顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
334
335
  if (job.offpeak && isPeakHour(new Date())) {
335
336
  const defer = deferToOffpeak(new Date());
336
337
  const curN = readSchedule(home, id);
337
- if (curN) writeSchedule(home, { ...curN, note: `避峰等待至 ${defer.toISOString().slice(11, 16)}Z+8` });
338
+ if (curN) writeSchedule(home, { ...curN, note: `避峰等待至北京时间 ${defer.toISOString().slice(11, 16)}(闲时起执行)` });
338
339
  await wait(defer.getTime() - Date.now() + 2000);
339
340
  }
340
341
  if (job.after?.length) {
package/src/tasks.js CHANGED
@@ -67,7 +67,7 @@ export function startTask(home, question, { permission, model, cwd, offpeak, qui
67
67
  usage: null,
68
68
  durationMs: null,
69
69
  error: '',
70
- note: offpeak ? '避峰:高峰时段顺延到 14:00 后执行(省 50%)' : '',
70
+ note: offpeak ? '避峰:高峰时段顺延到最近闲时(12:00 / 18:00)执行(省 50%)' : '',
71
71
  };
72
72
  writeTask(home, task);
73
73
  const args = [CLI_PATH, 'run-worker', id, '--question', String(question)];
@@ -10,12 +10,20 @@
10
10
  <meta name="theme-color" content="#0f1115">
11
11
  <style>
12
12
  :root{
13
- --bg:#0f1115; --bg2:#171a21; --bg3:#1f242e; --border:#2a303c;
14
- --text:#e6e9ef; --dim:#9aa3b2; --faint:#5c6675;
15
- --accent:#3ddc97; --accent2:#22b8cf; --warn:#e8b93d; --err:#e5534b;
16
- --user:#1f3d57; --code:#11141a;
13
+ /* 设计令牌对齐 DeepSeek-Harness 暗色体系(design-platform.css 实测值):
14
+ base #151517 / 侧栏 #1B1B1C / 面板层 #2C2C2E / 悬浮层 #353638,
15
+ 边框 rgba(255,255,255,.12),文字 #F9FAFB/#ADB2B8/#81858C */
16
+ --bg:#151517; --bg2:#1B1B1C; --bg3:#353638; --panel:#2C2C2E;
17
+ --border:rgba(255,255,255,.12);
18
+ --text:#F9FAFB; --dim:#ADB2B8; --faint:#81858C;
19
+ --accent:#3ddc97; --accent2:#22b8cf; --warn:#F59E0B; --err:#F25A5A;
20
+ --user:#2C2C2E; --code:#1B1B1C;
17
21
  }
18
22
  *{box-sizing:border-box}
23
+ *::-webkit-scrollbar{width:10px;height:10px}
24
+ *::-webkit-scrollbar-thumb{background:#3C3C3D;border-radius:6px;border:2px solid transparent;background-clip:content-box}
25
+ *::-webkit-scrollbar-thumb:hover{background:#545557;border:2px solid transparent;background-clip:content-box}
26
+ *::-webkit-scrollbar-track{background:transparent}
19
27
  html,body{height:100%}
20
28
  body{margin:0;background:var(--bg);color:var(--text);font:15px/1.65 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;display:flex;flex-direction:column;overflow-x:hidden}
21
29
  header{min-height:52px;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);flex:none;flex-wrap:nowrap;white-space:nowrap;overflow-x:auto}
@@ -46,13 +54,14 @@ header select{flex:none}
46
54
  header .spacer{flex:1}
47
55
  select,button{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-size:13px;cursor:pointer}
48
56
  select:hover,button:hover{border-color:var(--accent2)}
49
- button.primary{background:var(--accent);color:#06231a;border:none;font-weight:600}
57
+ button.primary{background:#F9FAFB;color:#0F1115;border:none;font-weight:600}
58
+ button.primary:hover{background:#CFD3D6}
50
59
  button.danger{background:var(--err);color:#fff;border:none}
51
60
  button:disabled{opacity:.45;cursor:not-allowed}
52
61
  main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
53
62
  #chat{max-width:860px;margin:0 auto;padding:0 18px;display:flex;flex-direction:column;gap:20px}
54
63
  .msg-user{display:flex;justify-content:flex-end}
55
- .msg-user .bubble{background:linear-gradient(135deg,#1f3d57,#173a33);border:1px solid #2c5a4a;border-radius:16px 16px 4px 16px;padding:10px 16px;max-width:78%;white-space:pre-wrap;word-break:break-word;font-size:14.5px;line-height:1.7}
64
+ .msg-user .bubble{background:var(--user);border:1px solid rgba(255,255,255,.08);border-radius:22px;padding:10px 16px;max-width:min(525px,82%);white-space:pre-wrap;word-break:break-word;font-size:16px;line-height:24px}
56
65
  .msg-ai{display:flex;flex-direction:column;gap:10px;font-size:15px;color:var(--text)}
57
66
  .msg-ai .content{white-space:normal;word-break:break-word;line-height:1.75}
58
67
  .msg-ai .content h1,.msg-ai .content h2,.msg-ai .content h3,.msg-ai .content h4{color:var(--text);font-weight:700;line-height:1.4}
@@ -68,8 +77,12 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
68
77
  .msg-ai .content blockquote{margin:0 0 12px;padding:8px 14px;border-left:3px solid var(--accent2);background:var(--bg3);border-radius:0 8px 8px 0;color:var(--dim)}
69
78
  .msg-ai .content hr{border:none;border-top:1px solid var(--border);margin:16px 0}
70
79
  .msg-ai .content b,.msg-ai .content strong{font-weight:700;color:var(--text)}
71
- .msg-ai .content code{background:var(--code);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font:12.5px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;color:#7ee0b8}
72
- .msg-ai .content pre{background:var(--code);border:1px solid var(--border);border-radius:10px;padding:12px 14px;overflow-x:auto;margin:.5em 0 12px}
80
+ .msg-ai .content code{background:var(--user);border:1px solid rgba(255,255,255,.08);border-radius:6px;padding:1px 6px;font:12.5px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--text)}
81
+ .msg-ai .content pre{background:var(--code);border:1px solid var(--border);border-radius:0 0 12px 12px;padding:12px 14px;overflow-x:auto;margin:0}
82
+ .msg-ai .content .codeblock{border:1px solid var(--border);border-radius:12px;overflow:hidden;margin:.5em 0 12px}
83
+ .msg-ai .content .cb-banner{display:flex;justify-content:space-between;align-items:center;gap:12px;background:var(--user);border-bottom:1px solid var(--border);padding:8px 14px;font-size:12px;color:var(--dim);font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
84
+ .msg-ai .content .cb-lang{display:flex;align-items:center;gap:8px}
85
+ .msg-ai .content .cb-dot{width:8px;height:8px;border-radius:50%;background:var(--accent)}
73
86
  .msg-ai .content pre code{background:none;border:none;padding:0;color:#c8d3e0}
74
87
  .msg-ai .content a{color:var(--accent2)}
75
88
  .msg-ai .content .hl-kw{color:#7aa2f7;font-weight:600}.hl-s{color:#9ece6a}.hl-n{color:#e0af68}.hl-c{color:#565f6e;font-style:italic}.hl-f{color:#7dcfff}
@@ -79,7 +92,7 @@ main{flex:1;overflow-y:auto;padding:18px 0 12px;min-width:0}
79
92
  .reasoning[open] summary:after{content:""}
80
93
  .reasoning summary.live{color:var(--accent2)}
81
94
  .reasoning summary.live:after{content:"(思考中…)";color:var(--accent2);animation:blink 1.2s infinite}
82
- .think{color:var(--dim);font-size:13px;padding:6px 2px;animation:blink 1.2s infinite}
95
+ .think{color:var(--accent2);font-size:13.5px;padding:8px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:10px;display:inline-flex;align-items:center;gap:8px;animation:blink 1.6s infinite}
83
96
  #composer{display:flex;gap:8px;align-items:flex-end}
84
97
  #attachBtn{padding:10px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:10px;cursor:pointer;font-size:15px}
85
98
  #attachList{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
@@ -116,7 +129,19 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
116
129
  #hint{font-size:11.5px;color:var(--faint);margin-top:6px;max-width:860px;margin-left:auto;margin-right:auto}
117
130
  #statusBar{font-size:11.5px;color:var(--faint);margin-top:2px;max-width:860px;margin-left:auto;margin-right:auto;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
118
131
  .modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:50}
119
- .modal{background:var(--bg2);border:1px solid var(--border);border-radius:14px;width:min(520px,92vw);padding:18px;max-height:88vh;overflow-y:auto}
132
+ .modal{background:var(--panel);border:1px solid var(--border);border-radius:14px;width:min(520px,92vw);padding:18px;max-height:88vh;overflow-y:auto}
133
+ /* 设置多级菜单(审计:一拉到底改为左侧分组导航 + 右侧面板) */
134
+ .cfg-modal{width:min(880px,94vw);display:flex;flex-direction:column;overflow:hidden}
135
+ .cfg-modal h3{margin:0 0 10px}
136
+ .cfg-body{display:flex;flex:1;min-height:0;border:1px solid var(--border);border-radius:12px;overflow:hidden}
137
+ .cfg-nav{width:152px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);padding:8px 6px;display:flex;flex-direction:column;gap:2px;overflow-y:auto}
138
+ .cfg-nav-btn{display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:8px 10px;font-size:13px;color:var(--dim);background:transparent;border:none;border-radius:8px;cursor:pointer;white-space:nowrap}
139
+ .cfg-nav-btn:hover{background:var(--bg3);color:var(--text)}
140
+ .cfg-nav-btn.active{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#06121a;font-weight:600}
141
+ .cfg-panels{flex:1;min-width:0;overflow-y:auto;padding:6px 16px 14px}
142
+ .cfg-panel{display:none}
143
+ .cfg-panel.active{display:block}
144
+ @media (max-width:860px){.cfg-modal{width:94vw}.cfg-body{flex-direction:column}.cfg-nav{width:100%;flex-direction:row;overflow-x:auto;border-right:none;border-bottom:1px solid var(--border)}.cfg-nav-btn{flex-shrink:0;width:auto}}
120
145
  .modal h3{margin:0 0 8px;font-size:15px;color:var(--warn)}
121
146
  .modal .q{color:var(--text);font-size:14px;white-space:pre-wrap;word-break:break-all;max-height:220px;overflow-y:auto;margin-bottom:14px}
122
147
  .modal .opts{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}
@@ -129,6 +154,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
129
154
  .dir-entry{padding:6px 10px;border-radius:6px;cursor:pointer;font-size:13.5px;word-break:break-all}
130
155
  .dir-entry:hover{background:var(--bg3)}
131
156
  .spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--accent);border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px}
157
+ #scrollBottomBtn{position:fixed;right:26px;bottom:118px;width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent2));color:#06121a;font-size:20px;font-weight:800;border:none;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.45);z-index:30;display:none;align-items:center;justify-content:center}
132
158
  @keyframes spin{to{transform:rotate(360deg)}}
133
159
  </style>
134
160
  </head>
@@ -164,7 +190,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
164
190
  <textarea id="input" rows="1" placeholder="输入任务…(Enter 发送,Shift+Enter 换行)"></textarea>
165
191
  <button id="sendBtn" class="primary">发送</button>
166
192
  </div>
167
- <div id="hint">工具执行与权限确认会实时展示;生成中可点「中断」停止。<label style="margin-left:12px;cursor:pointer;user-select:none"><input type="checkbox" id="journalChk" style="vertical-align:-2px;margin:0 4px 0 0">📌 带上文(最近会话日志,默认关=新会话全新开始)</label></div>
193
+ <div id="hint"><span id="hintText">工具执行与权限确认会实时展示;生成中可点「中断」停止。</span><label style="margin-left:12px;cursor:pointer;user-select:none"><input type="checkbox" id="journalChk" style="vertical-align:-2px;margin:0 4px 0 0">📌 带上文(最近会话日志,默认关=新会话全新开始)</label></div>
168
194
  <div id="statusBar" title="累计使用统计(轮次/步数/LLM 与工具时长/首 token/吞吐/缓存命中/输入输出 tokens)"></div>
169
195
  </footer>
170
196
  <div id="modalRoot"></div>
@@ -176,9 +202,22 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
176
202
  </div>
177
203
  </div>
178
204
  <div id="cfgModal" class="modal-mask" style="display:none">
179
- <div class="modal">
205
+ <div class="modal cfg-modal">
180
206
  <h3>⚙ 设置</h3>
181
- <hr style="border:none;border-top:1px solid var(--border);margin:0 0 10px">
207
+ <div class="cfg-body">
208
+ <nav class="cfg-nav">
209
+ <button class="cfg-nav-btn active" data-panel="models">🔑 模型与密钥</button>
210
+ <button class="cfg-nav-btn" data-panel="sync">☁️ 云同步与协作</button>
211
+ <button class="cfg-nav-btn" data-panel="general">🛡️ 通用与权限</button>
212
+ <button class="cfg-nav-btn" data-panel="schedule">⏰ 调度队列</button>
213
+ <button class="cfg-nav-btn" data-panel="workspace">📁 工作空间</button>
214
+ <button class="cfg-nav-btn" data-panel="memory">🧠 长期记忆</button>
215
+ <button class="cfg-nav-btn" data-panel="cache">📊 缓存仪表盘</button>
216
+ <button class="cfg-nav-btn" data-panel="mcp">🔌 MCP 生态</button>
217
+ <button class="cfg-nav-btn" data-panel="skills">🧩 技能库</button>
218
+ </nav>
219
+ <div class="cfg-panels">
220
+ <section class="cfg-panel active" data-panel="models">
182
221
  <h4 style="margin:4px 0;color:var(--accent2)">模型与 API Key</h4>
183
222
  <div id="pkList" style="max-height:150px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
184
223
  <div class="cfg-hint">自定义模型(OpenAI 兼容端点):</div>
@@ -193,6 +232,8 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
193
232
  </div>
194
233
  <div class="row" style="justify-content:flex-start"><button id="cmAdd">+ 添加自定义模型</button></div>
195
234
  <div class="cfg-row" style="display:flex;gap:6px"><input id="baseUrlOverride" placeholder="API 地址覆盖(可选,覆盖当前服务商地址)" style="flex:1;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"><button id="baseUrlSave">保存</button></div>
235
+ </section>
236
+ <section class="cfg-panel" data-panel="sync">
196
237
  <hr style="border:none;border-top:1px solid var(--border);margin:12px 0 8px">
197
238
  <h4 style="margin:4px 0;color:var(--accent2)">云同步(跨设备会话同步,服务器端运行 mingdao sync-server)</h4>
198
239
  <div id="syncStatus" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
@@ -216,6 +257,9 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
216
257
  <div id="shareAccepted" style="max-height:90px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
217
258
  <div class="cfg-hint">会话冲突(多设备同时修改同一会话时出现):</div>
218
259
  <div id="conflictList" style="max-height:120px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
260
+ </section>
261
+ <section class="cfg-panel" data-panel="general">
262
+ <h4 style="margin:4px 0;color:var(--accent2)">通用与权限</h4>
219
263
  <div class="cfg-row"><label>沙箱模式(bash 执行隔离)</label>
220
264
  <select id="sbxSel">
221
265
  <option value="off">off — 直接执行</option>
@@ -228,6 +272,8 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
228
272
  <div class="cfg-row"><label>上下文预算 tokens <input type="number" id="budgetInput" min="1000" step="1000"></label></div>
229
273
  <div class="cfg-row"><label><input type="checkbox" id="autoStartChk"> 开机自启(登录后自动启动服务器)</label></div>
230
274
  <div class="cfg-row"><label><input type="checkbox" id="notifyChk"> 后台任务完成/失败时桌面通知</label></div>
275
+ </section>
276
+ <section class="cfg-panel" data-panel="schedule">
231
277
  <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
232
278
  <h4 style="margin:4px 0;color:var(--accent2)">调度队列(定时 / 周期 / 依赖)</h4>
233
279
  <div id="schList" style="max-height:170px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
@@ -238,7 +284,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
238
284
  </select>
239
285
  </div>
240
286
  <div class="cfg-row" id="schAtRow"><input type="datetime-local" id="schAt" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"></div>
241
- <div class="cfg-row" style="align-items:center;gap:8px"><label style="font-size:12px;color:var(--dim);display:flex;align-items:center;gap:4px"><input type="checkbox" id="schOffpeak">🌙 避峰执行(高峰时段自动顺延到 14:00 后,输入价省 50%)</label></div>
287
+ <div class="cfg-row" style="align-items:center;gap:8px"><label style="font-size:12px;color:var(--dim);display:flex;align-items:center;gap:4px"><input type="checkbox" id="schOffpeak">🌙 避峰执行(高峰时段自动顺延到最近闲时 12:00 / 18:00,输入价省 50%)</label></div>
242
288
  <div class="cfg-row" id="schEveryRow" style="display:none">
243
289
  <select id="schEvery" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
244
290
  <option value="30s">每 30 秒</option><option value="5m">每 5 分钟</option><option value="1h">每 1 小时</option><option value="1d">每 1 天</option>
@@ -247,6 +293,8 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
247
293
  <div class="cfg-row" id="schAfterRow" style="display:none"><input id="schAfter" placeholder="任务 ID(mingdao run 返回)或调度 ID" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"></div>
248
294
  <div class="cfg-row" id="schChainRow" style="display:none"><textarea id="schChain" rows="4" placeholder="每行一个任务,按顺序执行(后者依赖前者成功)&#10;构建&#10;测试&#10;部署" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px;resize:vertical"></textarea></div>
249
295
  <div class="row"><button id="schAdd">+ 添加调度</button></div>
296
+ </section>
297
+ <section class="cfg-panel" data-panel="workspace">
250
298
  <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
251
299
  <h4 style="margin:4px 0;color:var(--accent2)">工作空间(项目目录注册表,与 CLI 共享)</h4>
252
300
  <div id="wsList" style="max-height:150px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
@@ -255,14 +303,20 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
255
303
  <input id="wsDir" placeholder="目录(留空 = 服务器工作目录)" style="flex:1.4;min-width:140px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
256
304
  </div>
257
305
  <div class="row" style="justify-content:flex-start"><button id="wsAdd">+ 登记工作空间</button></div>
306
+ </section>
307
+ <section class="cfg-panel" data-panel="memory">
258
308
  <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
259
309
  <h4 style="margin:4px 0;color:var(--accent2)">长期记忆(注入每次会话的偏好/约定)</h4>
260
310
  <textarea id="memArea" rows="5" placeholder="例如:所有文档用中文;提交信息用约定式提交…" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12.5px;resize:vertical"></textarea>
261
311
  <div class="row" style="justify-content:flex-start"><button id="memSave">保存记忆</button><button id="memDedupe">去重合并</button><span id="memMsg" style="font-size:12px;color:var(--faint)"></span></div>
312
+ </section>
313
+ <section class="cfg-panel" data-panel="cache">
262
314
  <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
263
315
  <h4 style="margin:4px 0;color:var(--accent2)">缓存命中率仪表盘(最近 10 次)</h4>
264
316
  <div id="cacheSummary" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
265
317
  <div id="cacheRecent" style="max-height:130px;overflow-y:auto"></div>
318
+ </section>
319
+ <section class="cfg-panel" data-panel="mcp">
266
320
  <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
267
321
  <h4 style="margin:4px 0;color:var(--accent2)">MCP 生态预设(一键接入,重启生效)</h4>
268
322
  <div id="mcpList" style="font-size:12px;color:var(--dim);padding:2px"></div>
@@ -271,13 +325,20 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
271
325
  <input id="mcpArg" placeholder="参数(按提示,留空用默认)" style="flex:1;min-width:140px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
272
326
  </div>
273
327
  <div class="row" style="justify-content:flex-start"><button id="mcpAdd">+ 接入 MCP 服务</button></div>
328
+ </section>
329
+ <section class="cfg-panel" data-panel="skills">
274
330
  <hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
275
331
  <h4 style="margin:4px 0;color:var(--accent2)">技能库(搜索 / 一键安装,装到用户级可改可删)<button id="skillRegRefresh" style="float:right;padding:1px 8px;font-size:11px">刷新线上</button></h4>
276
332
  <div class="cfg-row"><input id="skillSearch" placeholder="搜索技能…(留空显示全部)" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"></div>
277
333
  <div id="skillLibList" style="max-height:170px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:2px 8px"></div>
278
- <div class="row"><button id="cfgCancel">取消</button><button class="primary" id="cfgSave">保存</button></div>
334
+ </section>
335
+ </div>
336
+ </div>
337
+ <div class="row" style="margin-top:12px"><button id="cfgCancel">取消</button><button class="primary" id="cfgSave">保存</button></div>
279
338
  </div>
280
339
  </div>
340
+ <!-- 回到底部悬浮按钮(参照 DeepSeek-Harness:上滚时出现,点击回到最新消息) -->
341
+ <button id="scrollBottomBtn" title="回到最新消息">↓</button>
281
342
  <!-- 目录选择器(新建工作空间 / 改目录:浏览服务器磁盘目录树) -->
282
343
  <div id="dirModal" class="modal-mask" style="display:none">
283
344
  <div class="modal" style="width:min(520px,92vw);display:flex;flex-direction:column;max-height:78vh">
@@ -394,6 +455,24 @@ $('#dirPickOk').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirM
394
455
  $('#dirPickNone').onclick = () => { const cb = pickerCb; pickerCb = null; $('#dirModal').style.display = 'none'; if (cb) cb(null); };
395
456
  $('#dirPickCancel').onclick = () => { pickerCb = null; $('#dirModal').style.display = 'none'; };
396
457
  const chatEl = $('#chat'), input = $('#input'), sendBtn = $('#sendBtn');
458
+ // 回到底部悬浮按钮:滚动容器为 main;上滚超过 300px 时出现,点击平滑回底
459
+ (function(){
460
+ const scroller = document.querySelector('main');
461
+ const btn = $('#scrollBottomBtn');
462
+ if (scroller && btn) {
463
+ const farFromBottom = () => scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight > 300;
464
+ scroller.addEventListener('scroll', () => { btn.style.display = farFromBottom() ? 'flex' : 'none'; });
465
+ btn.onclick = () => { scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' }); };
466
+ }
467
+ })();
468
+ // 代码块「复制」按钮(事件委托:横幅随 markdown 渲染动态出现)
469
+ chatEl.addEventListener('click', (e) => {
470
+ const b = e.target.closest('.cb-copy'); if (!b) return;
471
+ const pre = b.closest('.codeblock')?.querySelector('pre');
472
+ const t = pre ? pre.textContent : '';
473
+ const done = () => { b.textContent = '已复制'; setTimeout(() => { b.textContent = '复制'; }, 1200); };
474
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(t).then(done).catch(() => {});
475
+ });
397
476
  let generating = false; // 生成中:发送按钮复用为停止按钮
398
477
  let currentSession = null, thinking = null;
399
478
 
@@ -414,7 +493,7 @@ function renderMarkdown(text){
414
493
  // 块级解析(美化:加粗标题 / 段落 / 真列表 / 引用 / 分割线,参照 DeepSeek-Harness 会话排版)
415
494
  const lines = String(text).split('\n');
416
495
  let out = '', code = null, codeLang = '';
417
- const flushCode = () => { if (code !== null) { out += '<pre><code class="lang-' + esc(codeLang) + '">' + highlight(code.join('\n'), codeLang) + '</code></pre>'; code = null; } };
496
+ const flushCode = () => { if (code !== null) { out += '<div class="codeblock"><div class="cb-banner"><span class="cb-lang"><span class="cb-dot"></span>' + (esc(codeLang) || 'code') + '</span><span class="cb-copy" style="cursor:pointer">复制</span></div><pre><code class="lang-' + esc(codeLang) + '">' + highlight(code.join('\n'), codeLang) + '</code></pre></div>'; code = null; } };
418
497
  let para = [];
419
498
  let list = null; // {type:'ul'|'ol', items:[]}
420
499
  const flushPara = () => { if (para.length) { out += '<p>' + para.join('<br>') + '</p>'; para = []; } };
@@ -451,7 +530,7 @@ function renderMarkdown(text){
451
530
  flushCode(); flushList(); flushPara();
452
531
  return out;
453
532
  }
454
- function scrollBottom(){ chatEl.scrollTop = chatEl.scrollHeight; }
533
+ function scrollBottom(){ const sc=document.querySelector('main'); if(sc) sc.scrollTop = sc.scrollHeight; }
455
534
 
456
535
  function newAiMsg(){ const el=document.createElement('div'); el.className='msg-ai'; chatEl.appendChild(el); scrollBottom(); return el; }
457
536
  function aiContent(msg){ const c=msg.querySelector('.content'); return c || (()=>{const d=document.createElement('div');d.className='content';msg.appendChild(d);return d;})(); }
@@ -608,17 +687,23 @@ async function send(){
608
687
  console.log('[MingDao] send 点击:generating=' + generating + ' text=' + text.length + ' attachments=' + attachments.length);
609
688
  if(generating){ console.log('[MingDao] send 被生成态拦截 → 走 abort'); abort(); return; } // 生成中:同一按钮承担停止职责
610
689
  if(!text && !attachments.length) return;
611
- generating=true; setBtn(); // 本地在途状态:不再依赖 /api/tasks 轮询
612
690
  const turnCtrl=new AbortController();
613
- // 回合看门狗:120 秒无任何响应强制中断——无论服务端处于什么状态,界面都能自动恢复可发送
614
- const watchdog=setTimeout(()=>{
691
+ // 回合看门狗(无活动超时,非总时长——审计:此前 120s 定时炸弹误杀长时间健康生成,
692
+ // 如模型持续输出大文件代码时到点被掐断:write 参数截断 + 「响应超时已中断」):
693
+ // 每收到一个 SSE 事件即重置;ask 权限等待期间暂停计时(用户思考不设限),
694
+ // 回复后的下一个事件自动重新武装。仅当 120 秒内真正没有任何事件(挂死)才强制中断。
695
+ let watchdog=null; let killedByWatchdog=false;
696
+ const disarm=()=>{ if(watchdog){ clearTimeout(watchdog); watchdog=null; } };
697
+ const arm=()=>{ disarm(); watchdog=setTimeout(()=>{
698
+ killedByWatchdog=true;
615
699
  try{ turnCtrl.abort(); }catch{}
616
700
  fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).catch(()=>{});
617
- },120000);
701
+ },120000); };
702
+ arm();
618
703
  input.value=''; input.style.height='44px';
619
704
  const payload={message:text,file:currentSession};
620
705
  // 带上文开关:勾选后系统提示注入最近会话日志(默认不注入——新会话全新开始,避免串到历史会话上下文)
621
- if($('#journalChk').checked) payload.withJournal=true;
706
+ if($('#journalChk')?.checked) payload.withJournal=true;
622
707
  if(attachments.length) payload.attachments=attachments;
623
708
  const sentAttachments=attachments;
624
709
  attachments=[]; renderAttachments();
@@ -629,12 +714,16 @@ async function send(){
629
714
  const think=document.createElement('div'); think.className='think'; think.textContent='💭 正在思考…'; msg.appendChild(think);
630
715
  const showThink=()=>{ if(!think.parentNode) msg.appendChild(think); };
631
716
  // 工作指示:提示栏实时显示已工作时长与执行步数,让用户时刻感受到智能体在干活
717
+ // 审计(第二问根因):文本只写 #hintText,绝不 textContent 覆盖 #hint——那会把其中的
718
+ // #journalChk 复选框从 DOM 抹掉,第二问读 .checked 抛异常 → 按钮卡红 → 全部发送静默
632
719
  let stepsCount=0; const workT0=Date.now();
633
- const hintEl=$('#hint'); const defaultHint=hintEl.textContent;
720
+ const hintEl=$('#hint'); const hintTextEl=$('#hintText'); const defaultHint=hintTextEl?hintTextEl.textContent:'';
634
721
  hintEl.classList.add('working');
635
- const hintTimer=setInterval(()=>{ hintEl.textContent='⏳ 正在工作 '+Math.round((Date.now()-workT0)/1000)+'s · 已执行 '+stepsCount+' 步 — 可点「■ 停止」中断'; },1000);
636
- const nearBottom=()=> chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 90;
637
- const scroll=()=>{ if(nearBottom()) chatEl.scrollTop = chatEl.scrollHeight; };
722
+ const hintTimer=setInterval(()=>{ if(hintTextEl) hintTextEl.textContent='⏳ 正在工作 '+Math.round((Date.now()-workT0)/1000)+'s · 已执行 '+stepsCount+' 步 — 可点「■ 停止」中断'; },1000);
723
+ generating=true; setBtn(); // 本地在途状态(前置 DOM 段完成后才置位,异常不影响按钮)
724
+ const scroller=document.querySelector('main');
725
+ const nearBottom=()=> scroller? (scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 90) : true;
726
+ const scroll=()=>{ if(nearBottom()&&scroller) scroller.scrollTop = scroller.scrollHeight; };
638
727
  let pending=false;
639
728
  const update=()=>{ if(pending) return; pending=true; requestAnimationFrame(()=>{ pending=false; content.innerHTML=renderMarkdown(raw); scroll(); }); };
640
729
  const onActivity=()=>{ if(think.parentNode) think.remove(); if(reason&&reason.parentElement){ reason.parentElement.open=false; reason.parentElement.querySelector('summary').classList.remove('live'); } };
@@ -644,21 +733,23 @@ async function send(){
644
733
  if(!resp.ok){ const j=await resp.json().catch(()=>({})); onActivity(); msg.innerHTML='<div class="errline">'+esc(j.error||('HTTP '+resp.status))+'</div>'; updateTasksPanel(); return; }
645
734
  await handleEvents(resp.body, ev=>{
646
735
  if(ev.taskId) taskId=ev.taskId;
736
+ if(ev.type==='ask') disarm(); else arm(); // 有活动→重置;权限等待→暂停计时
737
+ if(ev.type==='progress'){ const p=ev.seconds||0; if(think.parentNode) think.textContent='💭 正在工作 '+Math.floor(p/60)+' 分 '+Math.round(p%60)+' 秒 · 执行 '+ev.steps+' 步…'; }
647
738
  if(ev.type==='text'){ onActivity(); raw+=ev.delta; update(); }
648
- else if(ev.type==='reasoning'){ if(!reason){ reason=addReasoning(msg); } if(!reason.parentElement.open) reason.parentElement.open=true; reason.parentElement.querySelector('summary').classList.add('live'); reason.textContent+=ev.delta; reason.scrollTop=reason.scrollHeight; scroll(); }
739
+ else if(ev.type==='reasoning'){ if(!reason){ reason=addReasoning(msg); reason.dataset.full=''; } if(!reason.parentElement.open) reason.parentElement.open=true; reason.parentElement.querySelector('summary').classList.add('live'); reason.dataset.full+=ev.delta; const full=reason.dataset.full; reason.textContent=(full.length>9000?full.slice(-9000)+'\n…(思考内容较长,仅显示末尾;已 '+full.length+' 字符)':full); reason.scrollTop=reason.scrollHeight; scroll(); }
649
740
  else if(ev.type==='turnStart'){ stepsCount+=1; showThink(); scroll(); }
650
741
  else if(ev.type==='toolStart'){ stepsCount+=1; onActivity(); update(); renderToolStartEvent(ev); }
651
742
  else if(ev.type==='code'){ onActivity(); const pre=document.createElement('pre'); pre.innerHTML='<code>'+highlight(ev.code,ev.lang)+'</code>'; content.appendChild(pre); scroll(); }
652
743
  else if(ev.type==='tool'){ onActivity(); update(); const pending=runningTools.get(ev.seq); if(pending){ pending.remove(); runningTools.delete(ev.seq); } renderToolEvent(ev); }
653
744
  else if(ev.type==='toolDenied'){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent='✖ '+(ev.reason==='未授权'||!ev.reason?'未授权':ev.reason)+':'+ev.name; msg.appendChild(d); scroll(); }
654
745
  else if(ev.type==='banner'){ renderBanner(ev); }
655
- else if(ev.type==='ask'){ update(); askModal(ev).then(a=>fetch('/api/permission',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:ev.id,answer:a,taskId})}).catch(()=>{})); }
746
+ else if(ev.type==='ask'){ update(); askModal(ev).then(a=>fetch('/api/permission',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:ev.id,answer:a,taskId})}).then(()=>arm()).catch(()=>{})); }
656
747
  else if(ev.type==='usage'){ onActivity(); const u=document.createElement('div'); u.className='usage'; u.textContent=''+ev.modelName+' · ↑'+ev.usage.prompt_tokens+' ↓'+ev.usage.completion_tokens+' tokens · '+((ev.durationMs||0)/1000).toFixed(1)+'s'+ev.cost; msg.appendChild(u); scroll(); }
657
748
  else if(ev.type==='error'){ console.log('[MingDao] error 事件:' + ev.message); onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=ev.message; msg.appendChild(d); scroll(); }
658
- else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; hintEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
749
+ else if(ev.type==='done'){ console.log('[MingDao] done 事件:session=' + ev.session); onActivity(); if(ev.budget){ const b=ev.budget; if(hintTextEl) hintTextEl.textContent='预算 '+Math.round(b.used/1000)+'K/'+Math.round(b.total/1000)+'K('+Math.round(b.used/b.total*100)+'%)· 本轮完成 · 提示栏右侧为今日费用与命中率'; } refreshStatusBar(); if(ev.stats&&ev.stats.deliverables&&ev.stats.deliverables.length){ const card=document.createElement('div'); card.className='deliver'; card.innerHTML='<div class="t">📦 交付物('+ev.stats.deliverables.length+' 个文件)</div>'+ev.stats.deliverables.map(f=>'<div class="i">'+esc(f)+(f.toLowerCase().endsWith('.html')?' <span style="color:var(--accent2)">— 浏览器打开即可运行</span>':'')+'</div>').join(''); msg.appendChild(card); } if(ev.note){ const d=document.createElement('div'); d.className='errline'; d.style.color='var(--warn)'; d.textContent=ev.note; msg.appendChild(d); } currentSession=ev.session; update(); refreshSessions(); updateTasksPanel(); }
659
750
  });
660
- }catch(e){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=(e&&e.name==='AbortError')?'响应超时已中断(120 秒无响应),请重试':(e&&e.message)||'网络错误'; msg.appendChild(d); scroll(); }
661
- finally{ clearInterval(hintTimer); clearTimeout(watchdog); generating=false; setBtn(); console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); hintEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); scroll(); updateTasksPanel(); }
751
+ }catch(e){ onActivity(); const d=document.createElement('div'); d.className='errline'; d.textContent=(e&&e.name==='AbortError')?(killedByWatchdog?'响应超时已中断(120 秒无任何响应),请重试':'已中断'):(e&&e.message)||'网络错误'; msg.appendChild(d); scroll(); }
752
+ finally{ clearInterval(hintTimer); disarm(); generating=false; setBtn(); console.log('[MingDao] 回合收尾:generating=false,按钮恢复发送'); hintEl.classList.remove('working'); if(hintTextEl) hintTextEl.textContent=defaultHint; pending=false; content.innerHTML=renderMarkdown(raw); scroll(); updateTasksPanel(); }
662
753
  }
663
754
 
664
755
  function abort(){ fetch('/api/abort',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(()=>updateTasksPanel()).catch(()=>{}); }
@@ -750,7 +841,15 @@ async function init(){
750
841
  if(dj.text){ input.value=dj.text; input.style.height=Math.min(input.scrollHeight,200)+'px'; input.focus(); }
751
842
  }catch(e){}
752
843
  }
753
- $('#cfgBtn').onclick=()=>{ $('#cfgModal').style.display='flex'; };
844
+ // 设置多级菜单:左侧分组导航 ↔ 右侧面板切换(记住上次所在分组)
845
+ let cfgPanel='models';
846
+ function showCfgPanel(name){
847
+ cfgPanel=name;
848
+ document.querySelectorAll('.cfg-nav-btn').forEach(b=>b.classList.toggle('active', b.dataset.panel===name));
849
+ document.querySelectorAll('.cfg-panel').forEach(p=>p.classList.toggle('active', p.dataset.panel===name));
850
+ }
851
+ document.querySelectorAll('.cfg-nav-btn').forEach(b=>{ b.onclick=()=>showCfgPanel(b.dataset.panel); });
852
+ $('#cfgBtn').onclick=()=>{ $('#cfgModal').style.display='flex'; showCfgPanel(cfgPanel); };
754
853
  $('#cfgCancel').onclick=()=>{ $('#cfgModal').style.display='none'; };
755
854
  // —— 工作空间头部下拉(⚙ 右侧:切换 / 新建 / 管理) ——
756
855
  async function refreshWsSel(){
@@ -783,7 +882,7 @@ $('#wsSel').addEventListener('change', async e=>{
783
882
  return;
784
883
  }
785
884
  if(v==='__manage__'){
786
- $('#cfgModal').style.display='flex'; refreshWorkspaces();
885
+ $('#cfgModal').style.display='flex'; showCfgPanel('workspace'); refreshWorkspaces();
787
886
  refreshWsSel();
788
887
  return;
789
888
  }
package/src/web/server.js CHANGED
@@ -226,11 +226,6 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
226
226
  const taskId = `t${++taskSeq}`; // 服务端生成:客户端自选 taskId 可能覆盖他人任务
227
227
  const entry = { res, send: null, abortHandler: null, pendingAsk: null, session: null, startedAt: Date.now(), status: 'running', message: '', durationMs: 0 };
228
228
  srvlog('chat 开始 ' + taskId + ' session=' + (body.file || '新会话') + ' 消息长度=' + String(body.message || '').length);
229
- // 硬截止:任何异常路径 6 分钟内强制结束 SSE 流——前端回合必然收尾,按钮必然恢复
230
- const hardDeadline = setTimeout(() => {
231
- srvlog('chat 硬截止触发 ' + taskId + '(异常路径强制 res.end)');
232
- try { res.end(); } catch {}
233
- }, 6 * 60000);
234
229
  tasks.set(taskId, entry);
235
230
  const send = (obj) => {
236
231
  try {
@@ -238,6 +233,14 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
238
233
  } catch {}
239
234
  };
240
235
  entry.send = send;
236
+ // 进度心跳(审计:长时间健康生成的可感知性——模型持续输出工具参数期间可能长时间
237
+ // 无任何 SSE 事件,界面看似卡死;每 8 秒发一次 progress,前端实时显示已工作时长/步数,
238
+ // 同时充当客户端无活动看门狗的活动源)
239
+ const progressTimer = setInterval(() => {
240
+ try {
241
+ send({ type: 'progress', seconds: Math.round((Date.now() - entry.startedAt) / 1000), steps: io?.stats?.().toolCount || 0 });
242
+ } catch {}
243
+ }, 8000);
241
244
  const userMessage = String(body.message ?? '').trim();
242
245
  entry.message = (userMessage || '[附件]').slice(0, 40);
243
246
  const visionSupported = Boolean(modelPreset(modelName)?.supportsVision || cfg.customModels?.[modelName]?.vision);
@@ -245,6 +248,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
245
248
  if (built.error) {
246
249
  entry.status = 'failed';
247
250
  send({ type: 'error', message: built.error });
251
+ clearInterval(progressTimer);
248
252
  res.end();
249
253
  return;
250
254
  }
@@ -329,6 +333,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
329
333
  entry.status = 'failed';
330
334
  entry.durationMs = Date.now() - entry.startedAt;
331
335
  send({ type: 'error', message: `模型 ${runModel} 不可用:${String(err?.message || err)}` });
336
+ clearInterval(progressTimer);
332
337
  res.end();
333
338
  pruneTasks();
334
339
  return;
@@ -419,7 +424,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
419
424
  srvlog('chat 错误 ' + taskId + ' ' + String(err?.message || err));
420
425
  send({ type: 'error', message: String(err?.message || err) });
421
426
  } finally {
422
- clearTimeout(hardDeadline);
427
+ clearInterval(progressTimer);
423
428
  srvlog('chat 收尾 ' + taskId + ' res.end 前(客户端将收到流结束)');
424
429
  entry.abortHandler = null;
425
430
  entry.pendingAsk = null;