工作台:目录改名 mcn-workshop + 任务进度可见 + 会话归属配置化

1. 目录 mcn-work-shop -> mcn-workshop(空间分组名取会话 cwd 的目录名,只改字符串无效,必须真改名)
2. 全仓替换 mcn-work-shop -> mcn-workshop:37 文件 121 处;历史日志按沿革句规矩保留当时目录名
3. 任务进度可见:/api/run/status 产出 已运行时长/工具调用/最近动作/停滞判定(读会话日志尾部),前端新增右下角常驻面板,四处任务入口接入
4. 会话归属目录配置化:新增 config.sessionCwd(留空=工作台自身目录),现指向 D://AI技能//mcn-workshop,使任务显示为命名分组而非「未分组任务」
5. 修前端轮询静默缺陷:连续 3 次查询失败即提示服务断开(原逻辑静默空转到 15 分钟超时,用户零感知)
This commit is contained in:
maogeigei committed 2026-10-08 13:09:06 +08:00
1 parent d07049ecf1
commit f77735b206
115 files changed
+1103 -1514

No files matched your search

+2 -2
View File
@@ -9,8 +9,8 @@ Thumbs.db
# WorkBuddy 自动化任务运行数据(本地运行时生成,不提交) # WorkBuddy 自动化任务运行数据(本地运行时生成,不提交)
.workbuddy/automations/ .workbuddy/automations/
# 工作台运行日志与 CDP 自检截图(本地产物,不提交) # 工作台运行日志与 CDP 自检截图(本地产物,不提交)
**/mcn-work-shop/server.log **/mcn-workshop/server.log
**/mcn-work-shop/shot-*.png **/mcn-workshop/shot-*.png
# 工作台本地数据库(含账号真实数据,不入库;根目录 0 字节残留同规则) # 工作台本地数据库(含账号真实数据,不入库;根目录 0 字节残留同规则)
mcn-plugin.db mcn-plugin.db
**/mcn-plugin.db **/mcn-plugin.db
+147
View File
@@ -368,3 +368,150 @@ codebuddy -p → EADDRINUSE 后卡死,超时零输出
### 清理 ### 清理
本轮 91 个临时文件(`_*.py`/`_*.log`/`_*.txt`/`_debug/` 等,均在根目录与 mcn-work-shop 下)已全部分批删除。 本轮 91 个临时文件(`_*.py`/`_*.log`/`_*.txt`/`_debug/` 等,均在根目录与 mcn-work-shop 下)已全部分批删除。
---
## 18:45-19:30 A 方案落地完成 + 联网搜索接入(实测全通过)
### ✅ 结果:工作台 AI 链路已切到本地 CLI,可用
用户提供 API Key 后,端到端全部跑通:
`/api/ai/status` → ready:true / `?probe=1` → ok:true / `/api/ai/clarify`(SSE) → 正常出内容且格式合规(`==OPTS==/==REQ==` 完整)。
### 🔐 凭据存放(重要,防泄密)
- **`mcn-work-shop/config.json` 是 git 跟踪文件** → ⛔ 绝不能把 key 写进去。
- 凭据落在**仓库之外**:`%USERPROFILE%\.workbuddy\mcn-work-shop\ai-cli.json`(`{"apiKey":"..."}`)。
- 解析优先级:`config.apiKey/authToken` > **凭据文件** > 环境变量 `CODEBUDDY_API_KEY/AUTH_TOKEN`。
- `server.js` 新增 `cliBaseOpts()` 统一构造调用参数;新增环境变量覆盖开关 **`MCN_AI_MODEL`**(不改配置做模型 A/B)。
### ⭐⭐ doubao 不可用(钉死结论)
- `custom:doubao-seed-2-1-pro-260628` → **500**;`custom:doubao-seed-2-0-lite-260428` → **400 `Custom model [...] service info not found`**。
- CLI `--help` 的模型列表是**二进制静态清单,≠ 已开通**。桌面产品配置 48 个模型里没 doubao;`workbuddy.db` 历史只用过 `deepseek-v4.1-flash`/`hy4-preview`。
- **但 `custom:` 系并非全不可用** —— 同租户下有一大批已开通的自定义槽位。
### ⭐⭐ `custom:custom-model-*` 槽位 → 真实模型名映射(实测逐个跑出)
| 槽位 | 真名 |
|:--|:--|
| `a1` / `a2` / `a3` / `a4` | Claude-Opus-4.7 / Sonnet-4.6 / Opus-4.6 / **Opus-4.8** |
| `b1-standard` / `b1-priority` | GPT-5.4-Standard / GPT-5.4-Priority |
| `b3-priority` | GPT-5.2-Priority |
| `b4-standard` / `b4-priority` | GPT-5.1-Standard / GPT-5.1-Priority |
| `b5-standard` / `b5-priority` | **GPT-5.5-Standard / GPT-5.5-Priority** |
| `c2-flash` | Gemini-3.5-Flash-1 |
> **取证方法**(可复用):CLI 日志 `%TEMP%\mcn-cli-home\logs\<日期>\*.log` 里会有
> `"requestModelId":"custom:...","requestModelName":"Claude-Opus-4.8"` 配对 → 正则一次提取全部映射。
> 直接问模型「你是谁」不可靠(a1~a4 会统一答「我是AI智能编程助手」)。
### ⭐⭐ 三个性能杠杆(实测数据,都已在代码里落地)
| 杠杆 | 做法 | 效果 |
|:--|:--|:--|
| **工具面收敛** | `--tools "WebSearch,WebFetch"` | prompt **21,776 → 4,859 tokens(-78%)**,**47.6s → 15.0s** |
| **推理档位** | `--effort low`(新增,`minimal\|low\|medium\|high\|xhigh\|max`) | 首字 **61.7s → 19.7s** |
| **换模型** | `deepseek-v4-pro` 46.2s → `custom:custom-model-a4` 18.5s | 快 2.5 倍 |
- ⚠️ **`[INIT tools]` 事件列的是工具注册表全量,不是启用集** —— 判断 `--tools` 是否生效要看 `usage.input_tokens`,别被事件列表误导(本轮差点误判成"白名单没生效")。
- 附带安全收益:工具面收敛后模型**物理上碰不到文件系统**(Bash/Read/Write/Edit 全不在白名单)。
### ⭐ 联网搜索:CLI 原生自带,开箱可用
- `--tools` 里保留 `WebSearch,WebFetch` 即可;实测真的发起 `WebSearch {"query":...,"freshness":"d1"}`。
- **但不能常开也不能常关** → 用 `CLI_SEARCH_HINT` 作 `--append-system-prompt` 注入判据:
该搜(今天/近期的外部事实、热点、平台规则、事实核查)/不该搜(创作讨论、需求澄清、脚本撰写)/
**搜索预算硬上限 2 次**(1 WebSearch + 必要 1 WebFetch),搜到即答禁止反复搜。
- 教训:第一版提示词只写"最多搜 2 轮",模型实际连搜 4 次把耗时推到 3 分钟 → 改成显式预算 + `maxTurns: 6` 硬顶。
### ⭐ 两个文本污染(必须处理)
1. **报错当回答**:未认证时 CLI 把报错走文本通道发出 → `isCliBoilerplate()` 拦截。
2. **工具调用前的过程话术**(本轮新发现):模型搜前先吐一句
`I'll search for ... before responding.` → 被当正文显示。
**修法**:`runCli` 记录每次 `tool_use` 的位置(`toolMarks`),**只保留最后一次工具调用之后的文本**;
因为这段已流式推给前端,`server.js` 成功分支比对 `r.text !== acc` 时补发 `{replace}` 整体覆盖。
(前端 `data-pages.js` L653-672 对未知事件类型是 `continue` 安全跳过 → 加事件类型不破坏兼容。)
### ⭐ 本轮新识别并清掉的宿主干扰
- **宿主 MCP 注入**:`CODEBUDDY_MCP_CONFIG` 会在子 CLI 里自动挂上 `weixinpay` + `sheetagent` 两个 MCP,
纯浪费启动时间还可能被误调 → `buildChildEnv` 删该变量 + 传 `--strict-mcp-config --mcp-config '{"mcpServers":{}}'`。
实测 `[INIT mcp]` 从 2 个变成 `[]`。
- 另删 `CODEBUDDY_GATEWAY_PASSWORD`/`CODEBUDDY_GATEWAY_AUTH`/`WORKBUDDY_PAC_RPC_*`。
### 当前配置
`config.json → ai`:`backend:"cli"`,`model:"custom:custom-model-a4"`(Claude-Opus-4.8),
`effort:"low"`,`search:true`,`tools:""`,`strictMcp:true`,`maxTurns:6`。
### 遗留 / 待办
- **需求打磨单轮耗时仍 60-120s**(首字已降到 ~20s)。总耗时主要在模型推理+输出,属模型特性;若要更快可再换 `b5-standard`(GPT-5.5) 或加 `effort:minimal`。
- **前端 `sendTurn` 未实测**(本轮只测了后端 SSE;协议未变理论零改动)。
- 真实账号 + 参考选题走**完整 AI 创作链路**(S1-S11)仍未验证。
- `flash`/`minimal` 档位对产出质量的影响未评估。
---
## 19:32-19:40 模型切换:GPT-5.5(用户拍板)
用户选定 **GPT-5.5**,配置已改为 `custom:custom-model-b5-standard`。
### 实测对比(端到端 `/api/ai/clarify`,同一测试用例)
| 模型 | 普通创作问答 | 问热点(带搜索) | 格式合规 |
|:--|:--|:--|:--|
| **GPT-5.5-Standard**(当前) | **42.2s** | 137.2s | ✅ |
| Claude-Opus-4.8 | 105.5s | 139.6s | ✅ |
| DeepSeek-V4-Pro | 119.5s | 151.2s | ✅ |
- **普通问答快 2.5 倍**(42.2s vs 105.5s)—— 这是最常用的路径,收益最大。
- **触发搜索后各模型都是 130-150s** → 瓶颈转移到搜索本身,不是模型。
∴ `CLI_SEARCH_HINT` 把搜索预算压到 ≤2 次是对的,这是搜索场景唯一有效的提速手段。
- 首字延迟:普通问答 ~20s;搜索场景 ~67s(因为叙述被切掉了,搜索期间用户只看到"正在思考"占位)。
### b5 两个变体的区别
`custom:custom-model-b5-standard`(标准档,已选)/`custom:custom-model-b5-priority`(优先档,
资源紧张时优先路由,通常积分倍率更高)。b1/b3/b4 同族也是这套 -standard/-priority 后缀。
### 本轮验证方法(可复用)
测试脚本自检三件事,比只看耗时更有价值:
1. `==OPTS==` / `==REQ==` 双标记是否齐全(格式合规,前端要靠它解析候选与需求清单)
2. 英文过程话术残留(正则扫整行纯英文 ≥25 字符的段落)
3. 首字延迟 + 总耗时分开记(首字反映推理档位,总耗时反映输出+搜索)
### 当前最终配置
`config.json → ai`:`backend:"cli"`,`model:"custom:custom-model-b5-standard"`,
`effort:"low"`,`search:true`,`tools:""`,`strictMcp:true`,`maxTurns:6`;
凭据在 `%USERPROFILE%\.workbuddy\mcn-work-shop\ai-cli.json`。
---
## 19:40-19:50 切换 deepseek-v4.1-flash + 全流程跑通验证
用户要求「改为用 deepseek v4.1 flash 把流程跑通」。配置已改 `custom:custom-model-b5-standard` → **`deepseek-v4.1-flash`**。
### ✅ 全流程四环节实测全绿
| 环节 | 结果 |
|:--|:--|
| `GET /api/ai/status` | backend=cli · model=deepseek-v4.1-flash · ready=**true** |
| `?probe=1` | ok=**true** · 17.0s · 返回「成功」 |
| `POST /api/ai/clarify`(stream) | 371 字 · `==OPTS==/==REQ==` 合规 ✅ · 无英文残留 ✅ · 51.7s(首字 49.7s) |
| `POST /api/run` → 轮询 | queued(5s) → running(30s) → **done**(50s) · `model_id` 落库 = deepseek-v4.1-flash ✅ |
### ⭐⭐ 关键架构发现:完整流程 = 两段,模型来源互不相干
| 环节 | 接口 | 谁在跑 | 模型来源 |
|:--|:--|:--|:--|
| 需求打磨(弹窗对话) | `POST /api/ai/clarify` | **本地 CLI**(本方案) | `config.json → ai.cli.model` |
| 提交创作任务(S1-S11 生成脚本) | `POST /api/run` | **WorkBuddy 宿主**(写 automation,桌面端调度器执行) | **`sessions` 表最近活跃「手动会话」的 `model`**,兜底 `auto` |
- 🔴 **改 `ai.cli.model` 不影响创作任务的模型。**
- 创作任务模型是「跟随用户在 WorkBuddy UI 最近选过的模型」→ 从工作台侧看是**浮动的、不确定的**。
- 取证 SQL:`SELECT model_id, COUNT(*) FROM automations GROUP BY model_id ORDER BY 2 DESC;`
本机实测分布:`hy4-preview` **30** 条 / `deepseek-v4.1-flash` **6** 条(我们这次会话是 flash,所以新任务落到 flash)。
- **想让创作固定用某模型**:在 UI 把当前会话模型切到它;或改造 `/api/run` 支持显式 `model_id`(属行为变更,未做)。
### `deepseek-v4.1-flash` 的实测速度(澄清一个反直觉点)
- 首字 **49.7s**,总 51.7s;对比 GPT-5.5 首字 20.0s/总 42.2s。
- **"flash" 不等于首字更快** —— 它仍是推理型模型;但总耗时接近。选它主要看积分成本。
- 三个模型(deepseek-v4.1-flash / GPT-5.5 / Opus-4.8)**格式合规都 ✅、都无英文残留**,质量层面可用性一致。
### 顺带确认的死代码
- **`POST /api/ai/topics` 是遗留死代码** —— 前端从不调用(选题列表走 `GET /api/dsh/topics`)。
它仍写死 Dify,未配 `DIFY_MCN_CYLG_KEY` 会 500,但**不影响真实流程**。已记入文档 §11。
### 清理
- 自检 automation `automation-1790682160345`(名为「链路自检·deepseek-v4.1-flash(可删除)」)
已用 `automation_update mode=delete` 走正规通道删除(未用 sqlite 直改)。
- 临时脚本 `_t_flash.py` 已删。
+165
View File
@@ -0,0 +1,165 @@
# 2026-10-08
## 会话技能加载 + 项目记忆去重
- 按用户指令加载 `session-mechanism`:四类必读已读(`00-动手前必过.md` / `rules.md` / `manifest.md` / `01-文档索引.md`)+ SKILL.md 第一屏(触发句、第 0 步加载门槛、十条禁令、自决策白名单、现状地图)。
- 内化判据:**边界内自决策**(技术选型/实现路径/命名/调参/部署/排查/版本/兼容降级/文档技术内容);**只提报三类**(功能语义分叉/红线门禁/超边界:花钱·对外承诺·要凭据)。
- 项目 `MEMORY.md` 去重重组为 v4(17,841 → 17,315 B):新增 §0 两条指针(工作台 AI 通路 `docs/AI链路-本地CLI接入.md`、技能侧 WeKnora 调用);§2.1 补 09-29 AI 通路四条硬红线+两段流程模型来源;§4.2/§4.3 降级为「存根+硬红线」;清历史流水。
### 未决
- ⚠️ `MEMORY.md` 体积仍会被注入截断(阈值约 10–12 KB)。解法二选一:① 维持单文件(细节自动可见,但注入截断)② 拆专题文件(不截断,但细节失去自动注入)。**无客观优劣 ⇒ 待用户定**。
## 工作台「更新榜单数据」按钮排查(功能正常,是可见性问题)
- 现象:用户点击后「没有会话执行」。
- 取证(四处读数):宿主库 `automations` 有该 once 任务(ACTIVE、`next_run_at` 已过期、`model=hy4-preview`、`skills=["mcn-data-insight"]`);`automation_runs.status=IN_PROGRESS` + `automation_runtime_state.running=1`(⚠️ `running_conversation_id=null` 是常态,不代表没起会话);`sessions` 已建后台会话 `8a085794…`(bg=1,10:07:23);会话 jsonl 283 KB 且 mtime 持续更新(10:14),工具调用 30 Bash/14 Read/4 Write ⇒ **任务真的在跑**。`/api/dsh/ranking-update` 返回 `files=104`、`lastUpdateAt` 10:13:51 ⇒ 数据已在落盘。
- 真因两条(都与抓取逻辑无关):① 会话 `cwd = …\mcn-work-shop` ⇒ 落在侧边栏 **mcn-work-shop 空间分组**,不在用户当前空间,需切组才可见(这是设计,非 bug);② 工作台服务作为会话后台任务**被回收** ⇒ 前端轮询 `/api/run/status` 持续抛错。
- 修复:`public/data-pages.js` `updateRankingData()` 轮询加失败计数 —— 连续 3 次查询失败即提示「工作台服务已断开,任务仍在后台执行」并结束轮询(原逻辑静默 `continue` 到 15 分钟超时,用户零感知)。
- ⚠️ **排查方法论沉淀**:判「后台任务有没有在执行」的正确取證链 = `automations` → `automation_runs` → `automation_runtime_state` → `sessions` → 会话 jsonl 的 mtime/大小。⛔ 别只看 `running_conversation_id`(常为 null,会误判成没执行)。
## 工作台目录统一改名 `mcn-work-shop` → `mcn-workshop`
- 动因(用户):「分组已改为 mcn-workshop,需要全部替换;执行会话任务时需要按分组名称显示在会话列表」。
🔑 **机制**:WorkBuddy 侧边栏空间分组名 = 会话 `cwd` 的 **basename** ⇒ **只改字符串无效,必须真改名目录**。
- 执行五步:① 停服务释放句柄(否则 rename 必 `WinError 5`)→ ② `mv mcn-work-shop mcn-workshop`(成功)→ ③ 临时脚本全仓替换 **`mcn-work-shop`→`mcn-workshop`:37 文件 / 121 处**;⛔ 按**沿革句规矩跳过** `.workbuddy/memory/YYYY-MM-DD.md`(16 个历史日志保留当时目录名史实)→ ④ 修 2 处被替换改坏语义的注释(`server.js` L1265 / L1269,原「旧 mcn-workshop/新建 mcn-workshop」被换成同词,已补回三代路径)→ ⑤ 重启验证:`app.js`/`data-pages.js` 200,`data-pages.js` 含 `pollFail` 修复。
- ⚠️ **事实澄清(易踩)**:`D:\AI技能\mcn-workshop` **不是工作台**,是 09-01 路径拼接 bug 留下的**垃圾目录**(3 个 0 字节文件 + `outputs`)。工作台本体一直在仓库内 `…\V1.0\mcn-workshop`。sessions 表取证:该垃圾目录下**零会话**;未删除会话中仅 1 条 mcn-work 相关,cwd 仍是旧 `…\mcn-work-shop`。
- 净效果:新建后台会话 cwd = `…\V1.0\mcn-workshop` ⇒ 侧边栏分组显示 **`mcn-workshop`** ✅
- 残留:`project/…/subskills/mcn-data-insight/scripts/__pycache__/*.pyc`(二进制缓存含旧串,会自行重生成,无需处理)。
### 追加澄清(用户真实意图,⛔ 别再理解成"目录改名")
- 用户要的不是改目录名,而是:**工作台提交 WorkBuddy 定时任务后,产生的 AI 会话应归属哪个「空间分组」**。
- 机制:分组名 = 会话 `cwd` 的**目录名**;工作台侧由 `server.js` 的 `SESSION_CWD`(当前 = 工作台自身目录)决定 ⇒ **改目录名只是让分组名变成 `mcn-workshop` 的手段**,不是目的本身。
- ⚠️ **存量不掉头**:10:07 那条榜单更新任务 cwd 仍是旧 `…\mcn-work-shop` ⇒ 显示在**旧**分组;改名后**新建**的任务才落到 `mcn-workshop`。清理用的 `SESSION_CWD_PATTERN='%mcn-work%'` 覆盖新旧三代,不受影响。
- 待用户定(功能语义分叉):① 维持工作台专属分组 ② 归入用户当前主项目分组 ③ 自定义独立分组(需目录真实存在且在 workspaces 登记,08-27 即此法)。可选项:把 `SESSION_CWD` 提升为 `config.json` 可配字段,换分组只改配置不动代码。
## 后台任务进度可见(用户:「我需要看到 才知道执行是否正常」)
- 真需求:不是分组放哪,而是**在工作台内直接看到执行状态**,判断在跑还是卡住、做了什么。
- 后端 `server.js` `/api/run/status` 增强:automation → 关联它拉起的后台会话(`sessions.is_background_automation=1` 且创建时间落在任务创建 −15s~+300s)→ 读该会话 jsonl **尾部 256 KB** → 新增字段
`elapsedMs` / `sessionId` / `sessionTitle` / `lastAction`(最近一段文本,截断 160 字)/ `toolCalls`(按工具名计数)/ `updatedAgoMs` / `stalled`(>90 s 无新输出)/ `logBytes`。
- ⭐ 会话日志路径换算(可复用):cwd → projects 目录名 = **盘符小写 + 分隔符全转 `-`**(`escapeCwdForProjects()`);基目录 `<workbuddy>/projects/<转义名>/<sessionId>.jsonl`。
- ⚠️ `stalled` 只在 `state=running` 时有意义(已完成任务日志当然不再写入,会恒 true)。
- 前端新增 `window.TaskProgress`(实现在 `public/app.js`,样式 `style.css` 的 `.tp-*`):右下角常驻面板 = 状态/已运行时长/工具调用/最近动作/停滞警告。**四处轮询点全接入**:`app.js executeTask`、`data-pages.js` 的榜单更新/批量更新账号/视频解析。终态保留 8 秒自动收起,运行超时则保留供继续观察。
- 验证:三文件 `node --check` 全过;服务起来后 `app.js` 含 6 处、`data-pages.js` 含 9 处 `TaskProgress`,`style.css` 含 `.tp-panel`;状态接口返回完整进度字段。
### ✅ 分组归属定案:就用 `mcn-workshop` 文件夹对应的分组(已实测)
- 用户拍板:任务会话归到 **`mcn-workshop` 文件夹对应的那个分组**。
- 实测(提交轻量自检任务取证):新会话 `cwd = …\V1.0\mcn-workshop` ✅;对照 10:07 那条旧榜单任务仍是 `…\mcn-work-shop`(**两者在侧边栏分属不同分组**,属正常存量,不用管)。
- 结论:**不需要再改代码** —— `SESSION_CWD = ROOT`,目录改名后自动生效。自检任务已删排期。
- ⚠️ 认知要点:侧边栏分组按 **完整 cwd** 聚合,不是按文件夹名合并 ⇒ 同名不同路径会是两个分组。
### ⚠️ 更正(12:2x):分组靠 cwd **命中已打开的工作区**,光改目录名不够
- 上一条只对了一半:目录改名后会话 cwd 是**仓库内** `…\V1.0\mcn-workshop`,**不是用户的工作区** ⇒ 仍显示在「**未分组任务**」区域(用户实测反馈)。
- 用户纠正:`D:\AI技能\mcn-workshop` **才是那个工作区**。我此前仅凭目录里只有 3 个 0 字节垃圾文件就判它"是垃圾目录"——**没查登记表就下结论**,违反「判状态先问程序自己」。
- 🔑 **真机制**:侧边栏分组 = 会话 cwd **命中已打开过的工作区**才显示为命名分组;没命中 ⇒ 落「未分组任务」。
- 取证:`workspaces` 表仅 7 行且全是 `C:\Users\maidou\WorkBuddy\<时间戳>`,**不含**任何 `D:\AI技能\*` ⇒ 分组依据不是这张表,而是按会话 cwd 对已打开工作区的聚合。
- 修法(配置化,⛔ 不写死):`load-config.js` 增 `sessionCwd`(默认 `''` = 用 ROOT)→ `server.js` `SESSION_CWD = String(CFG.sessionCwd||'').trim() || ROOT` → `config.json` 设 `"sessionCwd": "D:\\AI技能\\mcn-workshop"`。**换分组只改配置,不动代码。**
- 实测:新会话 cwd = `D:/AI技能/mcn-workshop` ✅;任务在该工作区执行正常(done,结果"成功",73 秒)。对照 12:23 那条仍在仓库内路径 ⇒ 两组并存,旧的会随过期清理消失。
- ✅ 13:06 **二次模拟请求复验一致**(用户指示:不测真实功能,只发模拟请求看分组):写入 `cwds=["D:\\AI技能\\mcn-workshop"]`,会话 cwd 同样命中 ⇒ 配置化改法稳定。两次自检排期均已删,会话留在目标工作区供用户肉眼确认。
## 素材库补采:批 65 收尾 + 批 66/67 完成(30 卡)
- 承 09-29 进度:批 65(细节专业 15 卡)已 apply 完成,细节专业 40→55。
- **批 66(细节专业 15 卡)**:取材 31748 姜乘澜底妆 / 17511 笑笑易 / 19014 俊希做菜 / 24821 烟道逃生 / 25090 姜乘澜画眼线。✅ **本批 5 条原生即教程/实操题材**,与细节专业天然契合(不像批 65 从非专业题材硬提)。apply:接受 15/退回 0,问法改写 15 张平均 +32 字。细节专业 55→70。
- **批 67(自嘲反差 15 卡)**:取材 26484 路之坑爹感恩宴 / 31816 直男五合一洗护 / 31970 李普不离谱 / 21500 桃气小周买饭 / 26411 老弟受难日记。含 1 张**元层面自黑**卡(31970 全家崩溃时推销泡面)。apply:接受 15/退回 0,问法改写 15 张平均 +12 字。自嘲反差 56→71。
- 两批均走完整链路:`写 md → _tmp_bNN_spec.py 生成 spec → meta.json → build-pending → cat PROMPT_polish_v3.md + build-pending 组装 prompt → 派 doubao-seed-2-1-pro → _wNN.py 校验落盘 → apply-pending`。
### 本轮新踩坑
- ⚠️ **MCP `myai-mcp-production` 登录态会过期**:批 66 拉第 4 条时报「认证失败」。解法:先 `auth_status` 确认 → `feishu_login` 重授权(拉起浏览器)→ 重试即通。**连续拉多条详情时中途要留意**。
- ⚠️ **spec 脚本里别夹英文单词**:批 66 的 neg 句误写「等摊主 processing」,批量产出会污染语料。写完 spec 脚本前先自查。
### 当前缺口(修正后口径,2026-10-08 10:50)
| 标签 | 现有 | 缺口 | 候选 |
|:--|--:|--:|--:|
| 食材极致 | 8 | 92 | ⛔ 0 |
| 预见式服务 | 21 | 79 | ⛔ 2 |
| 感官沉浸 | 59 | 41 | 14 |
| 细节专业 | 70 | 30 | 22 |
| 自嘲反差 | 71 | 29 | 34 |
| 视觉冲击 | 76 | 24 | 10 |
达标:品质对比 110 / 氛围沉浸 106 / 反差 217 / 反常识 128 / 价值观冲击 108。
- ⛔ **卡点未解**:`食材极致`(缺口 92)与 `预见式服务`(缺口 79)候选池近乎空,占剩余缺口一半以上。需换关键词或换捞法才能推进。
- 暂存批累计 24 批(43–52、54–67,缺 53)。
## 补采续跑:批 68(感官沉浸 15 卡)/批 69(细节专业 9 卡)
**批 68 · 感官沉浸(33289 子杭自驾318 ×6 / 31773 萝卜乔乔英国留学回国 ×6 / 30090 姐弟双向送礼 ×3)**
- apply 结果:**接受 15/退回 0,问法改写 15 张,平均 +19 字**。感官沉浸 59 → 74。
- ⭐ **两条来源 0 卡,已在 md 写明判据**:`28012`(电焊工试戏)男主嘴配的「滋滋」是**谐音梗的音效**而非真实声音质感,事件主角是"身在曹营心在焊" → 归 `反常识`/`自嘲反差`;`28415`(男朋友的算计)踩碎眼镜/摔筷子/拍桌的动静是**发疯甩锅的附产品** → 归 `反差`/`价值观冲击`。
- 感官通道分布齐全(冷/缺氧失眠/冰雹砸车/撕羊腿/热水澡/久坐腰酸 · 热瓶/苦到脸垮/中药味/红米肠/赶机喘/长途疲惫 · 中暑虚脱/猛灌水/红糖蛋滋啦)。
**批 69 · 细节专业(28328 海鲜蒸汽 ×4 / 37316 巧克力棒手作 ×5)= 9 卡**
- apply 结果:**接受 9/退回 0,问法改写 9 张,平均 +20 字**。细节专业 70 → 79。
- ⭐ **本批 3 条来源 0 卡,坚持不凑数**(沿用批 56 口径):
- `20461`(机票骗局)—— 骗子的"专业"是**话术与骗术设计**(伪装客服、真退票取信、连环索要卡号),不落在"这一下换普通人来做就做不到位"的操作细节上 → `反差`/`反常识`。**这是细节专业最容易误判的一类:话术专业 ≠ 操作专业。**
- `24820`(烟道脱困)—— 主角是**作死翻车与体力消耗**,片尾"专业人士、专业场地、专业操作"是**反讽** → `自嘲反差`/`难度极限`;且同账号同题材 `24821` 已在批 66 取过两卡,本条脱困手法同构,不重复入库。
- `22163`(轮椅游渔岛)—— 倒拖轮椅过软沙滩属**体力付出** → `难度极限`;其余为情侣互坑 → `反差`/`自嘲反差`。
- ⭐ **取批命中率下降的信号**:细节专业候选池看着有 22 条,但主队列取 5 条只有 2 条可用(命中率 40%)。后续该标签每批卡数会低于 15,属正常,不要为了凑 15 张硬塞。
**新增踩坑与复用做法**
- ✅ **`_wNN.py` 用 sed 复用**(`sed -e 's/_raw68/_raw69/g' -e 's/pending_0068/pending_0069/g' -e 's/^N = 15/N = 9/' _w68.py > _w69.py`)—— 卡数变化时记得同步改 `N`。本轮连续 3 批(67/68/69)一次通过,`bad: []`。
- ✅ **超长详情自动落盘**:`33289` 返回 88,150 字符超限 → 工具落盘到 `D:\.workbuddy\projects\d-AI技能-mcn-short-video\<sessionId>\tool-results\*.txt`,用 Read 直接读(该文件只有 117 行,一次读完)。
- ⚠️ 派模型改用「让 Agent 自己 Read prompt 文件 → 自己 Write 落盘 `_rawNN.txt`」——比让模型把 JSON 回吐到对话再手工落盘更省上下文,连续 3 批稳定。
**当前缺口(批 69 后)**:自嘲反差 29(候选 34)|感官沉浸 26(候选 14)|视觉冲击 24(候选 10)|细节专业 21(候选 22)。食材极致 92/预见式服务 79 仍挂起(用户 2026-10-08 决策:先不处理)。
## 批 70(自嘲反差 15 卡)· 本轮第 5 批 → 复核点
**结果**:接受 15/退回 0,问法改写 15 张,平均 **+27 字**(前几批 +19/+20,本批问法增厚更充分)。自嘲反差 71→**86**(缺口 14)。
**来源**:35376 唐轩乌龙×6 | 35301 丁浩普信男×6 | 28146 妈妈硬核推理×3。**5 条视频里 2 条 0 卡**(33593、21923)。
**⭐ 自嘲反差口径新增形态「姿态过载」**(本批确立):
- 定义:主角自己把一件小事升格到远超其体量的规格,且全程一本正经(妈妈把女儿回家晚做成刑侦审讯:北风4级/100米/11分37秒 vs 6点12分39秒)。
- 判据仍落回一句话:**笑点落在主角自己身上**。妈妈是主角、推理的荒谬感由她自己一本正经地制造 → 收。
- ⛔ 反向凡尔赛揭晓(语数英 100/98/100 + 兑现平板)→ 笑点在预期被颠覆 → `反常识`;儿子 O 型嘴石化 → 笑点落在旁观者受击 → `反差`(世界参差)。均不收。
**两条 0 卡判据(写入 meta)**:
- **33593(张开父子的下场)**:家族护短复仇爽剧,爽点落在反派父子被制裁上,主角阵营全程降维打击,无一处笑点落在自己身上 → `反差`/`价值观冲击`。其「悲痛段落硬塞洗面奶口播」虽属元层面自黑,但批 67 已收同构卡(C1 全家崩溃时推销泡面)→ 不重复入库。
- **21923(消防设施瘫痪的代价)**:真实事件改编的沉重社会悲剧(烟花引火→消防栓被锁→接头拧不上→母亲丧生→儿子一夜白头),全片无笑点 → `价值观冲击`。
- ⭐ **通用判据沉淀**:**沉重社会悲剧类一律不进自嘲反差**;**元层面自黑要查是否已收同构卡**(批 67 已占一类)。
**其他弃卡判据**:35376「你那算盘珠子都崩我脸上了」是吐槽他人 → `反差`;「我没吃过我妈包的包子」与「我妈不会做饭」是同一场乌龙的两端 → 分作「立 flag」与「揭盅」两卡,不合并(同批 67 B2/B3/B4 细分做法)。35301 防晒植入段属商业植入;丽丽怒骂并入 B4 不单列。
**本轮 5 批(66–70)合计 69 卡**:66=15 | 67=15(自嘲反差)| 68=15(感官沉浸)| 69=9(细节专业,不凑数)| 70=15(自嘲反差)。
**当前缺口(批 70 后)**:感官沉浸 **26**(候选 14)→ 视觉冲击 24(10)→ 细节专业 21(22)→ 自嘲反差 14(34)。食材极致 92/预见式服务 79 挂起。
**体检**:`5_tag_coverage.py` 卡片总数 1017(源文件 69 个),12 维无自造标签;⛔ 零覆盖仅 `难度极限`(用户决策不处理),⚠️ 偏少仅 `食材极致`(8,挂起)。
**复核抽查**:批 70 卡 6(哭着嚼坚果上供)润色生效——台词前置 + 破折号拍点(「我以后再也不养小仓鼠了,我对不起你」——唐轩把相框平放在桌上…),锁定 4 字段零改动,无编造。spec 回写 sim=3/neg=2、tag 单一、src 3 个,全部符合。
**⭐ 入库状态核查(用户问「素材都同步到 wekonra 了吗」→ 答案:没有,两层原因)**
- **流程层**:按暂存模式,补采只落 `batches/pending_import/`,统一入库必须跑 `13_import_pending.py --run`。截至批 70,**27 批 397 卡全部堆在暂存区,一条都没进库**(`13_import_pending.py --help` 盘点:27 批/397 卡/异常 0)。`batches/*.md` 41 批(早期跑过 `2_import_batch.py` 的)是唯一进过库的部分。
- **环境层**:`WeKnora-app` 容器 **Exited (127)**,最后一条日志停在 **2026-09-29 23:53**(health 200)→ 31607 端口不通,连"库里现有多少条"都查不了。同项目其余容器(frontend 24719 / docreader / postgres / redis)都在跑。**整个 10-08 的补采从未连过库。**
- **部署信息**:镜像 `wechatopenai/weknora-app:latest`,restart `unless-stopped`,compose 工作目录标签 `/home/maidou/weknora`(WSL 路径,本机 `docker ps -a` 可见)。拉起命令 `docker start WeKnora-app`;若仍 127 需查 compose 的 command/entrypoint。
- **回炉链路同样未完成**:「删旧条目 + 重导 + 检索终验」都还没做;回炉批 31–33 也未跑完。
- ⚠️ 排查口诀补充:**问「同步了吗」时先查两处**——① `13_import_pending.py --help` 看暂存盘点 ② `docker ps -a` 看 WeKnora-app 是否 Up。两者都要看,只查一处会误判。
**⭐ 规则核对:入库前必须补全 question(用户 2026-10-08 追问「是否遵循」→ 核查 27 批结论)**
- **规则依据(能查到的最强出处)**:RUNBOOK §步骤 6「写 spec.json(检索问法)」=流水线必经步骤;**真正的动机在 RUNBOOK 红线**:「追加相似问不重建索引(`POST /faq/entries/{id}/similar-questions` 返回 200 但向量分数按位不变)→ **要改问法只能删 + 重导**」。所以「入库前补全」不是形式要求,而是**事后补的代价=整批删+重导**。
- ⚠️ **未找到该规则的原始对话出处**:`conversation_search` 两次 0 命中,本地 memory / RUNBOOK 也没有「入库前要补全 question」这句原话。**已按「规范 + 红线」等价认定,未擅自当作既有明文规则引用。**
- **核查结论(27 批全量)**:全部有 spec.json、`std` 全部非空 → **形式上 100% 遵循**。但有 **2 批偏离现行规范**:
| 批 | 偏离 | 范围 |
|:--|:--|:--|
| **43** | `neg=3` 条(现行应为 2),且内容是**关键词短语**(「白酒带货 / 餐厅推荐 / 商务礼仪培训」)不是问句 | 15/15 卡 |
| **48** | `sim=2` 条(现行应为 3) | 15/15 卡 |
其余 25 批:sim=3(批 47 起)或 sim=6(批 44/45/46,合旧规范)/neg=2,全部合规。
- ⚠️ **文档与执行脱节(待用户裁定)**:RUNBOOK §步骤 6 明文写 **sim「口语问法 4–6 条」**,但**批 47 起实际统一为 3 条**(`_wNN.py` 校验也写死 `!= 3`)。要么改文档、要么把 sim 补回 ≥4 条。
- ⭐ **核查脚本口诀**:`glob('batches/pending_import/batch_*.spec.json')` 逐批 `Counter(len(x['sim']))` + `Counter(len(x['neg']))` 打分布表,一眼看出条数偏离;**别用 md 的 ```text 计数 //2 算卡数**(会算成一半,以 spec 长度为准)。
**⭐ Docker WeKnora 启动失败排查(2026-10-08 · 已修复)**
- **根因(不是应用崩溃,是容器 init 前的 bind mount 失败)**:`docker inspect WeKnora-app` 的 `.State.Error` 给出原文——
`OCI runtime create failed: runc create failed: ... error mounting "/run/desktop/mnt/host/wsl/docker-desktop-bind-mounts/Ubuntu-24.04/4f4eee8c…" to rootfs at "/app/config/config.yaml": not a directory: Are you trying to mount a directory onto a file (or vice-versa)?`
- **链路**:compose 写 `./config/config.yaml:/app/config/config.yaml`(文件→文件,写法正确,源文件 `/home/maidou/weknora/config/config.yaml` 确实存在且是 6545 B 的文件)→ 但 **Docker Desktop 的 bind-mount 代理层**(WSL 9P 中转,路径被哈希成 `4f4eee8c…`)把源**识别成了目录** → 挂载失败 → 容器连 init 都没进 → **Exit 127**(entrypoint `./scripts/docker-entrypoint.sh` 根本没执行)。
- **时间线**:StartedAt 09-28 01:45,FinishedAt **10-08 02:51** —— 正常跑了 10 天,今天凌晨有一次重启尝试(Docker Desktop/WSL 层变动)时代理层状态不对而失败。restart `unless-stopped` 不会救「启动失败」,所以一直停在 Exited。
- ✅ **修复:`docker start WeKnora-app` 一次即恢复**(`Up (healthy)`,31607 通)→ 属**代理层临时状态问题**,重试可解。复发时的升级路径:重启 Docker Desktop → `docker compose up -d` 重建容器(会换新的代理哈希)。治本方向:单文件 bind mount 跨 WSL 本就脆弱,可改 `docker cp` 或 volume。
- ⭐ **排查口诀:容器 Exited(127) 先看 `docker inspect --format '{{.State.Error}}'`**,不要只看 `docker logs`(日志只到上次正常运行,看不出启动失败原因)。本次日志最后一条停在 09-29 23:53 health 200,完全看不出问题,是 inspect 的 State.Error 直接给出根因。
**库内实测(服务恢复后)**
- FAQ 总数 **631**(分页抖动,去重拉取 624)。**问法 100% 齐全**:无 std 0 / 无 sim 0 / 无 neg 0。
- sim 条数分布 `{5:312, 6:222, 3:46, 2:30, 4:14}`、neg `{2:542, 3:82}` —— 历史口径混着(旧 5/6 条、现 3 条、少量 2 条)。
- 库内 tag_name 分布:反差 172/反常识 117/价值观冲击 93/视觉冲击 67/自嘲反差 44/细节专业 32/感官沉浸 26/氛围沉浸 24/品质对比 19/预见式服务 19/食材极致 9/难度极限 2 → **与本地统计一致,即已入库 = 早期 41 批;暂存 27 批 397 卡确实未进。**
- ⚠️ **接口结构坑**:FAQ 列表返回是 **`data.data`**(`{data:{total,page,page_size,data:[...]}}`)两层嵌套,只取一层会得到空列表;条目标签字段是 **`tag_name`**(不是 `tag`/`tags`);`page_size=total` 一次拉全会返回空 → 必须分页 100 逐页 + 按 id 去重。
**新增踩坑**
- ⚠️ `_tmp_bNN_spec.py` 里卡片名提取必须 **`.replace("###","")`**:`head.split("|")[0].strip()` 会带 `### ` 前缀 → `assert name in Q` 报 `AssertionError: ### 吃包子吃出丧母感`。批 70 踩到一次,加 replace 后通过。
- ✅ prompt 组装字节数核对:模板 7,683 + 载荷 34,647 = 42,330(`wc -c` 可验),比批 68 记录的 15,772 大是因当时统计口径不同,**以 `wc -c` 为准**。
+40 -41
View File
@@ -1,28 +1,40 @@
# MCN 短视频项目长期记忆(2026-09-28 精简重写 v3) # MCN 短视频项目长期记忆(v4 · 2026-10-08 去重精简)
> 原则:**只放「索引 + 红线 + 踩坑」**。凡已在文件里可查的(SKILL.md / references / RUNBOOK / 工作台规范)只留指针。 > 原则:**只放「索引 + 红线 + 踩坑」**。凡文件里可查的(SKILL.md / references / RUNBOOK / 工作台 docs)只留指针,不抄内容。历史流水已清。
## 0. 权威源指针 ## 0. 权威源指针
| 主题 | 权威文件 | | 主题 | 权威文件 |
|:--|:--| |:--|:--|
| 技能全部口径 | `<仓库>/project/短视频脚本创作/V1.0/`(SKILL.md + references/references-add) | | 技能全部口径 | `<仓库>/project/短视频脚本创作/V1.0/`(SKILL.md + references + references-add) |
| 路径/环境 | `references-add/路径配置.md` | | 路径 / 环境 | `references-add/路径配置.md` |
| 留人密度 | `知识库/03_框架节奏/04_节奏控时叙事套路.md` §1.3 三级留人点体系 | | 留人密度 | `知识库/03_框架节奏/04_节奏控时叙事套路.md` §1.3 三级留人点体系 |
| 12 维标签 / 镜头 / 画面风格 | `素材库/分块定义/00_通用维度_分块定义.md` | | 12 维标签 / 镜头 / 画面风格 | `素材库/分块定义/00_通用维度_分块定义.md` |
| 事件卡 10 字段模板 | `素材库/分块定义/00_模板-各库分块模板.md` §极致事件 | | 事件卡 10 字段模板 | `素材库/分块定义/00_模板-各库分块模板.md` §极致事件 |
| 赛道词表 | `知识库/标签库/赛道标签.md`(S01–S27 × 8 叙事形态 × 三级垂类) | | 赛道词表 | `知识库/标签库/赛道标签.md`(S01–S27 × 8 叙事形态 × 三级垂类) |
| 卡片批量生产 / 回炉 | `tools/weknora-ingest/RUNBOOK.md`(自包含手册,**§2.4 召回实测 / §2.5 回炉 SOP / §2.6 触发率**) | | 卡片批量生产 / 回炉 | `tools/weknora-ingest/RUNBOOK.md`(§2.4 召回 / §2.5 回炉 SOP / §2.6 触发率) |
| 工作台 UI | `mcn-work-shop/docs/工作台视觉交互规范.md` | | 工作台 UI | `mcn-workshop/docs/工作台视觉交互规范.md` |
| 工作台 AI 通路 | `mcn-workshop/docs/AI链路-本地CLI接入.md` |
| 技能侧 WeKnora 调用 | 技能 `references/接口调用/WeKnora_极致事件检索.md` |
## 1. 仓库与环境 ## 1. 仓库与环境
- 本地 `D:\AI技能\mcn-short-video`;远程 `[email protected]:maogeigei/mcn-short-video.git`。 - 本地 `D:\AI技能\mcn-short-video`;远程 `[email protected]:maogeigei/mcn-short-video.git`。
- 技能软链 `~/.workbuddy/skills/短视频工作台` = junction → `project\短视频脚本创作\V1.0\`;失效判据 children=0 → node `unlinkSync` + PowerShell `New-Item -ItemType Junction`。 - 技能软链 `~/.workbuddy/skills/短视频工作台` = junction → `project\短视频脚本创作\V1.0\`;失效判据 children=0 → node `unlinkSync` + PowerShell `New-Item -ItemType Junction`。
- 环境判定(禁写死盘符):路径含 `.dsh` → dsh(**只读**);否则 `git rev-parse --show-toplevel` 成功=开发机 / 失败=用户环境。 - 环境判定(禁写死盘符):路径含 `.dsh` → dsh(**只读**);否则 `git rev-parse --show-toplevel` 成功=开发机 / 失败=用户环境。
- 产物落盘:桌面 `MCNSkill项目/{账号}/`。 - 产物落盘:桌面 `MCNSkill项目/{账号}/`。
- 技能版本:**只改 V1.0**;Lite1.0(无 subskills)默认不动;变更流程 = 方案→确认→执行→同步副本。 - 版本:**只改 V1.0**;Lite1.0(无 subskills)默认不动;变更流程 = 方案→确认→执行→同步副本。
## 2. 子技能要点 ## 2. 子技能要点
- **MCN工作台(Web)** `V1.0/mcn-work-shop/`:零依赖 Node,端口 8900。AI 任务 `POST /api/run`(写宿主 automation,once + `next_run_at`=now+5s)→ 轮询 `/api/run/status`;**创作/改写 prompt 必带 `SKILL_HINT_CREATE` 前缀**。配置单源 `config.json`;`.cjs` 无扩展 require 会 MODULE_NOT_FOUND → 配置模块用 `.js`;静态资源根映射,`/public/xxx` 必 404。
### 2.1 MCN工作台(Web)`V1.0/mcn-workshop/`
- 零依赖 Node,端口 8900。任务 `POST /api/run`(写宿主 automation,once + `now+5s`)→ 轮询 `/api/run/status`;**创作/改写 prompt 必带 `SKILL_HINT_CREATE` 前缀**。配置单源 `config.json`;`.cjs` 无扩展 require → MODULE_NOT_FOUND(配置模块用 `.js`);静态资源根映射,`/public/xxx` 必 404。
- **AI 通路(09-29 起)**:`ai.backend` = `cli`(本地 CodeBuddy CLI,stdio 无端口)|`dify`(旧链路兜底)。**细节全在 §0 权威文档,改动前必读**。四条硬红线:
- 🔴 **凭据**:`config.json` 是 git 跟踪文件 → key 只放 `%USERPROFILE%\.workbuddy\mcn-workshop\ai-cli.json`(仓库外,不被技能同步复制)。
- 🔴 **`SERVER__PORT` 必须删** —— CLI(含 `-p` 模式)内部起 HTTP server,继承宿主端口即 `EADDRINUSE` **静默卡死**,且 HTTP 层(health/info/sessions)看起来完全正常。**最容易误判的坑**。
- 🔴 **两段流程模型来源不同**:`/api/ai/clarify`(需求打磨)走**本地 CLI**(`ai.cli.model`);`/api/run`(提交创作)走**宿主调度器**,模型取 `sessions` 表最近活跃**手动会话**的 `model`(兜底 `auto`)⇒ **改 `ai.cli.model` 不影响创作任务模型**。
- 🔴 **`custom:` 模型按租户开通**:本机 Key 租户**无 doubao**(500 / `service info not found`);`custom-model-*` 真名从 `%TEMP%\mcn-cli-home\logs\**\*.log` 的 `requestModelId`/`requestModelName` 配对提取(问模型「你是谁」不可靠)。
- 其余备忘:性能三杠杆 `--tools "WebSearch,WebFetch"`(prompt -78%)/ `--effort low`(首字 61.7s→19.7s)/ 换模型;⚠️ `[INIT tools]` 是**注册表全量**,判生效看 `usage.input_tokens`;`/api/ai/topics` 是**死代码**;SSE 协议 `{delta}/{replace}/{error}/[DONE]`,cli 与 dify 一致(前端零改动)。
### 2.2 其他子技能
- **mcn-dou-analysis**:达人表 15 列、TOP6、≥60 条停、29 赛道枚举、浏览器 9223 + 标签页复用、**身份标识禁从 DB 回填**;产物 `POST /api/import/account` 幂等写 `mcn-plugin.db`。 - **mcn-dou-analysis**:达人表 15 列、TOP6、≥60 条停、29 赛道枚举、浏览器 9223 + 标签页复用、**身份标识禁从 DB 回填**;产物 `POST /api/import/account` 幂等写 `mcn-plugin.db`。
- **mcn-data-insight**:11 个红狐子技能,榜单即查即缓存。红狐周期 code=3203 → 账号周榜最新可用期恒为**上一周**(非 bug)。 - **mcn-data-insight**:11 个红狐子技能,榜单即查即缓存。红狐周期 code=3203 → 账号周榜最新可用期恒为**上一周**(非 bug)。
- **mcn-video-prompt**:F1 反推图/F2 反推视频/F3 生图/F4 分镜;格式优先级 = 用户格式 > 默认 6 层;llm-wiki 必须下沉读原始条目。 - **mcn-video-prompt**:F1 反推图/F2 反推视频/F3 生图/F4 分镜;格式优先级 = 用户格式 > 默认 6 层;llm-wiki 必须下沉读原始条目。
@@ -31,12 +43,11 @@
## 3. 创作红线 ## 3. 创作红线
- 三态:⚡自动(静默只落最终物)/🤝共创(+确认门)/🛠开发(+全落盘);产物链 05→06→07 禁跳步;硬编码禁止(P0)。 - 三态:⚡自动(静默只落最终物)/🤝共创(+确认门)/🛠开发(+全落盘);产物链 05→06→07 禁跳步;硬编码禁止(P0)。
- 脚本必须走 S1-S11,禁会话内直写;开场钩子 3-7s,硬上限 10s。 - 脚本必须走 S1-S11,禁会话内直写;开场钩子 3-7s,硬上限 10s。
- **留人点**:L1 极致触点 15-20s / L2 留人点 **每 10 秒 ≥1** / L3 增量点 3-5s。判据 = 观众产生明确「为什么」(抛问题/打破预期/说到自己/给承诺,≥1 种);纯陈述/纯动作/重复前文不算。自检 = ⌈时长÷10⌉。**禁止混层**。类型名沿用五类机制,**禁自造枚举**。 - **留人点**:L1 极致触点 15-20s / L2 留人点 **每 10 秒 ≥1** / L3 增量点 3-5s。判据 = 观众产生明确「为什么」(抛问题/打破预期/说到自己/给承诺,≥1 种);纯陈述/纯动作/重复前文不算。自检 = ⌈时长÷10⌉。**禁止混层**,类型名沿用五类机制,**禁自造枚举**。
- **三层定位**:素材库=存素材/极致事件=素材的内容层属性/知识库=讲方法。**事件卡模板与条目落在素材库侧**,`知识库/05_极致事件/` 只放类型层与判据。 - **三层定位**:素材库=存素材/极致事件=素材的内容层属性/知识库=讲方法。**事件卡模板与条目落在素材库侧**,`知识库/05_极致事件/` 只放类型层与判据。
- **事件卡 10 字段(顺序固定)**:`极致内容 → 内容含义 → 极致触点 → 事件标签 → 极致类型 → 创作手法 → 镜头语言 → 画面风格 → 复用场景 → 适用场景`。卡片头 `> 所属库:{NN 库名 | 横切未定}` 不占字段位。 - **事件卡 10 字段(顺序固定)**:`极致内容 → 内容含义 → 极致触点 → 事件标签 → 极致类型 → 创作手法 → 镜头语言 → 画面风格 → 复用场景 → 适用场景`。卡片头 `> 所属库:{NN 库名 | 横切未定}` 不占字段位。
- 三易混字段:**极致内容**=画面内容+氛围+质感(客观可拍);**内容含义**=`[期待A]—被X打破→[结果B]`;**极致触点**=**效果侧两项:用户心理 / 共鸣**(机制词归标签/类型,效果词不复用标签词)。 - 三易混字段:**极致内容**=画面内容+氛围+质感(客观可拍);**内容含义**=`[期待A]—被X打破→[结果B]`;**极致触点**=**效果侧两项:用户心理 / 共鸣**(机制词归标签/类型,效果词不复用标签词)。
- **事件标签 ≥2 值,主标签后缀「(主)」**;「事件标签」≠ 8 库「极致标签」。 - **事件标签 ≥2 值,主标签后缀「(主)」**;「事件标签」≠ 8 库「极致标签」。创作手法含**铺垫量级 短≤3s / 中 3-10s / 长>10s**;翻车样本同等入库。**极致触点 ≠ 留人点**。
- 创作手法含**铺垫量级 短≤3s / 中 3-10s / 长>10s**;翻车样本同等入库。**极致触点 ≠ 留人点**。
- **复用场景 ≠ 适用场景**:适用场景=原生在哪被调用(从哪来);复用场景=事件结构能搬到哪(到哪去),取值 `{赛道·叙事形态·段落}`,**禁自造赛道名**。 - **复用场景 ≠ 适用场景**:适用场景=原生在哪被调用(从哪来);复用场景=事件结构能搬到哪(到哪去),取值 `{赛道·叙事形态·段落}`,**禁自造赛道名**。
- **素材条目形态铁律**:`### {名称}` + `- **字段**:值` 平铺列表;**禁写成多节表格报告**。 - **素材条目形态铁律**:`### {名称}` + `- **字段**:值` 平铺列表;**禁写成多节表格报告**。
- 解析逆向:以 `analysis` 场次表为准(±1-2s 偏移);`极限维度` 是**上游自造词,禁直录**,三步归一:剥落差→类型层→落 12 维。 - 解析逆向:以 `analysis` 场次表为准(±1-2s 偏移);`极限维度` 是**上游自造词,禁直录**,三步归一:剥落差→类型层→落 12 维。
@@ -44,7 +55,7 @@
## 4. 检索层:WeKnora + 卡片流水线 ## 4. 检索层:WeKnora + 卡片流水线
- 容器:`MCN极致事件素材库`(FAQ 型,`e6772e41-7b72-499d-b46c-e5ca7c9d1740`,`127.0.0.1:31607`,key 见 `D:\.workbuddy\mcp.json`)。**执行前必读 RUNBOOK**。 - 容器:`MCN极致事件素材库`(FAQ 型,`e6772e41-7b72-499d-b46c-e5ca7c9d1740`,`127.0.0.1:31607`,key 见 `D:\.workbuddy\mcp.json`)。**执行前必读 RUNBOOK**。
- ⛔ **接口四铁律**:① 只走 `POST /faq/entries` 批量通道(单条 `/faq/entry` 建的检索不到);② 改相似问必须 DELETE + 重导;③ 检索必显式 `vector_threshold: 0.5`(默认 0.7);④ dry_run 异步且占通道,须轮询 completed。 - ⛔ **接口四铁律**:① 只走 `POST /faq/entries` 批量通道(单条 `/faq/entry` 建的检索不到);② 改相似问必须 DELETE + 重导;③ 检索必显式 `vector_threshold: 0.5`(默认 0.7);④ dry_run 异步且占通道,须轮询 completed。
- ⛔⛔ **检索参数名铁律**:数量参数名 = **`match_count`**;写 `top_k`/`limit`/`size`/`count`/`k` 会**静默忽略并恒返回 10 条**(不报错)。`vector_threshold` **必须显式传**(省略 → 返回 0 条)。阈值只做事后过滤,**不能提升质量**。分数间隔**不可判优**(top1~top10 首尾仅差 0.047)→ 靠**人工复筛落差结构同构性**。 - ⛔⛔ **检索参数名铁律**:数量参数 = **`match_count`**;写 `top_k`/`limit`/`size`/`count`/`k` 会**静默忽略并恒返回 10 条**(不报错)。`vector_threshold` **必须显式传**(省略 → 返回 0 条)。阈值只事后过滤,**不能提升质量**;分数间隔**不可判优**(top1~top10 首尾仅差 0.047)→ 靠**人工复筛落差结构同构性**。
- ⛔ **删 FAQ 条目**:`DELETE /knowledge-bases/{KB}/faq/entries` + body `{"ids":[int64,...]}`;`DELETE /faq/entries/{id}` 是 404。 - ⛔ **删 FAQ 条目**:`DELETE /knowledge-bases/{KB}/faq/entries` + body `{"ids":[int64,...]}`;`DELETE /faq/entries/{id}` 是 404。
- ⛔ **FAQ 列表分页抖动**:`page_size=100` 页边界会重复。解法 = 先 `page_size=1` 拿 `total` → 再 `page_size={total}` 一次拉全。 - ⛔ **FAQ 列表分页抖动**:`page_size=100` 页边界会重复。解法 = 先 `page_size=1` 拿 `total` → 再 `page_size={total}` 一次拉全。
- 其他:FAQ 检索恒为纯向量;条目**单标签**;`CustomMetadata` 不参与过滤;`auto_tag` 必须关。素材卡走 FAQ 型(文档型封顶 9/11)。**规则类文本不进检索层**。 - 其他:FAQ 检索恒为纯向量;条目**单标签**;`CustomMetadata` 不参与过滤;`auto_tag` 必须关。素材卡走 FAQ 型(文档型封顶 9/11)。**规则类文本不进检索层**。
@@ -55,36 +66,27 @@
### 4.1 补采自动化与标签治理 ### 4.1 补采自动化与标签治理
- 自动化 `MCN极致事件素材库·定向补采(每小时3批·暂存模式)`,id `c4d5eae8-e2ff-427a-99aa-d027d354bc19`,`FREQ=HOURLY;INTERVAL=1`,ACTIVE。**rrule 不支持分钟级** → 单次触发连跑 3 批。 - 自动化 `MCN极致事件素材库·定向补采(每小时3批·暂存模式)`,id `c4d5eae8-e2ff-427a-99aa-d027d354bc19`,`FREQ=HOURLY;INTERVAL=1`,ACTIVE。**rrule 不支持分钟级** → 单次触发连跑 3 批。
- 脚本:`4_sensory_candidates.py`(感官候选)/`5_tag_coverage.py`(12 标签体检)/`6_target_candidates.py`(定向筛,含 `NEGMAP`)/`7_dispatch_next.py`(**调度器**:算缺口+取批命令+SKIP/PRIORITY)。取批三入口:主队列/`--sensory`/`--target <标签> N`。 - 脚本:`4_sensory_candidates`(感官候选)/`5_tag_coverage`(12 标签体检)/`6_target_candidates`(定向筛,含 `NEGMAP`)/`7_dispatch_next`(**调度器**:算缺口+取批命令+SKIP/PRIORITY)。取批三入口:主队列/`--sensory`/`--target <标签> N`。
- **目标「每标签 ≥100 条」**:**严格主角判据** + **食材优先** + **难度极限暂不处理**。缺口跑 `7_dispatch_next.py`,**勿写死**。 - **目标「每标签 ≥100 条」**:**严格主角判据** + **食材优先** + **难度极限暂不处理**。缺口跑 `7_dispatch_next.py`,**勿写死**。
- ⭐ **「食材极致」严格主角判据 3 条**:① 部位知识型 ② 验鲜解剖全流程型 ③ 形态重组型。 - ⭐ **「食材极致」严格主角判据 3 条**:① 部位知识型 ② 验鲜解剖全流程型 ③ 形态重组型。
- ⭐⭐ **暂存模式(防返工关键机制)**:补采只产 `batches/pending_import/{batch_00NN.md,.spec.json,.meta.json}`,**禁跑 `2_import_batch.py`/`3_done_batch.py`、禁改 `state.json`**。统一入库 = **`13_import_pending.py --dry|--run|--sync`**。**安全结论**:回炉删除按 `polish_plan.json` 冻结的 526 id → 新采卡不会被误删;唯一真风险 = 重跑 `9_polish_plan.py`(已禁)。 - ⭐⭐ **暂存模式(防返工关键机制)**:补采只产 `batches/pending_import/{batch_00NN.md,.spec.json,.meta.json}`,**禁跑 `2_import_batch.py`/`3_done_batch.py`、禁改 `state.json`**。统一入库 = **`13_import_pending.py --dry|--run|--sync`**。**安全结论**:回炉删除按 `polish_plan.json` 冻结的 526 id → 新采卡不会被误删;唯一真风险 = 重跑 `9_polish_plan.py`(已禁)。
### 4.2 卡片回炉 + v3 双任务 SOP — 详见 RUNBOOK §2.5 ### 4.2 卡片回炉 + v3 SOP(细节 → RUNBOOK §2.5)
- 链路:`8_backup_all.py` → `9_polish_plan.py` → `10_polish_batch.py payload|apply <n>` → 删旧 + 重导 → `11_diff_report.py`。 - 链路 `8_backup_all` → `9_polish_plan` → `10_polish_batch payload|apply <n>` → 删旧 + 重导 → `11_diff_report`。
- **字段契约**:可润色 6(极致内容/内容含义/极致触点/创作手法/镜头语言/画面风格)/**锁定 4**(事件标签/极致类型/复用场景/适用场景)/可改写问法 3 组。 - **字段契约**:可润色 6(极致内容/内容含义/极致触点/创作手法/镜头语言/画面风格)/**锁定 4**(事件标签/极致类型/复用场景/适用场景)/可改写问法 3 组。
- **三重校验**:① 字段集合一致 ② 骨架完整 ③ 行数不变。任一失败 → 该卡退回。 - **三重校验**:① 字段集合一致 ② 骨架完整 ③ 行数不变。任一失败 → 该卡退回。
- ⭐ **v2「吸引力优先」**:判据 = **内容是否更短视频化**。三特征:破折号 +24%/口语化连接词 +28/强动作动词 +32%。三改动 = **台词前置/破折号拍点/动词升级**。 - ⭐ **v2「吸引力优先」**:判据 = **内容是否更短视频化**;三改动 = 台词前置/破折号拍点/动词升级。⭐ **宽约束**(严约束改动率仅 1% / 宽 46%)。
- ⭐ **约束强度对照**:堆「禁止…」严约束 → 改动率仅 1%;宽约束 → 46%。走宽约束。 - ⭐ **v3 问法增厚**:`std` 保留骨架补画面、`sim` 条数严格相等、`neg` 保持负例属性。**绝对边界 = 搬运原文已有信息**(每个信息点须能在该卡 6 个可润色字段 + 事件标签找到出处)。
- ⭐ **v3 第二任务:问法增厚**:`std` 保留骨架补画面、`sim` 条数严格相等、`neg` 保持负例属性。**v3.1 绝对边界**:增厚 = **搬运原文已有信息**。自检口诀:「问法里每一个具体信息点,都必须能在该卡 6 个可润色字段 + 事件标签里找到出处;找不到的删掉。」 - 🔴 **暂存批 v3 链路**:`build-pending <n>` → 喂 `PROMPT_polish_v3.md` + 载荷 → 存 `out/polish_result/pending_00NN.json` → `apply-pending <n>`;**`apply-pending` 必须同一次执行内跑完**(曾漏跑,续跑须先补,批次 48 实测)。
- ⭐ **`validate()` 返回 5 元组** `(ok, reasons, cleaned, q, autofix)`;**std 前缀校验 = autofix 语义**(原文无前缀时模型误加 → 自动剥离,不退回)。 - 其他:`validate()` 返 5 元组 `(ok,reasons,cleaned,q,autofix)`,std 前缀不符走 autofix(剥离、不退回);`fiction_check` 弯/直引号须归一化,只告警不拦截;工具 `11_diff_report`/`12_attractiveness`/`14_form_audit`/`15_sensory_recall`/`16-18*_question_*`。
- ⭐ **`fiction_check` 防误报**:引号字符类匹配弯/直引号;比对须归一化。实测残留告警全为误报 → 只告警不拦截。
- ⭐⭐ **暂存批 v3 链路(已跑通)**:`10_polish_batch.py build-pending <n>` → `Agent(model="doubao-seed-2-1-pro")` 喂 `PROMPT_polish_v3.md`+载荷 → 存 `out/polish_result/pending_00NN.json` → `apply-pending <n>`。
- ⚠️ **`apply-pending` 必须在同一次执行内跑完**:曾出现「已生成 pending_00NN.json 但未 apply」→ 续跑须先补 apply(批次 48 实测)。
- 工具:`12_attractiveness.py`、`11_diff_report.py`、`14_form_audit.py`、`15_sensory_recall.py`(含 `extract_name()`)、`16/17_question_ab.py`、`18/18b_question_validate(.r).py`。
### 4.3 ⭐⭐ 技能侧 WeKnora 调用(权威源 = 技能 `references/接口调用/WeKnora_极致事件检索.md`) ### 4.3 技能侧 WeKnora 调用(权威源 = 技能 `references/接口调用/WeKnora_极致事件检索.md`)
- 🔴 **素材召回触发率 ≈ 0(端到端实测 ×2 路径)**;**口径(用户确立,优先)**:架构 = 「**流程不主动召回素材,知识库按需被取**」= 设计意图,**不改**。`hybrid_search` 0 次 ≠ bug。判据只有两条:① 模型**真能写出桥段** → 跳过 = ✅;② **写不出却仍不检索** = ❌。 - 🔴 **素材召回触发率 ≈ 0(端到端实测 ×2 路径)**;**用户口径(优先)**:架构 = 「**流程不主动召回素材,知识库按需被取**」= 设计意图,**不改**。`hybrid_search` 0 次 ≠ bug。判据仅两条:① 模型**真能写出桥段** → 跳过 = ✅;② **写不出却仍不检索** = ❌。
- 实测:4 会话 191 次工具调用、召回 0 次;模型自述「中间步骤已在上下文按序完成,自动模式后台静默」→ 整段脑内跳过。 - ⭐ **素材库两个作用位**:① 给事件「填空」(S7 写不出桥段时取机制样本)② 给台词「润色」(S9 取原话/微反应/意外)。**同一链上下游**:S7 触发≈0 → S9 `{material_cards}` 恒空 → ② 退化;**∴ ② 失效是 ① 的直接后果**(修①即自动恢复)。文件位:`7_生成短视频大纲.md` §极致触点素材检索;`9_生成短视频脚本.md` L12 + L101-113。
- **⭐ 素材库两个作用位**:**① 给事件「填空」**(S7 写不出桥段时取机制样本)+ **② 给台词「润色」**(S9 取原话/微反应/意外)。**两者是同一条链上下游**:S7 触发 ≈ 0 → S9 的 `{material_cards}` 恒空 → ② 退化。**∴ ② 失效是 ① 的直接后果**(②修好①自动恢复,改动最小)。 - **结论**:不动架构;解「按需判据太软」→ 推荐 **C1 换外部可验证判据**("通用 vs 专属"可验证)。⛔ 作废「删跳过条款 → 默认必检」。
- 文件位置:① `7_生成短视频大纲.md` §极致触点素材检索;② `9_生成短视频脚本.md` L12 + L101-113 §3.1。 - ✅ **确定有效**:参数纠正(`match_count` 省 69% 上下文;`vector_threshold` 必显式传)。⚠️ **收益有限**:问法改写(反查法 R@1 34/35→35/35,**差异在噪声内**;⛔ 禁宣称"召回率 97%→100%")。🔴 **真实瓶颈 = 素材覆盖**(3/5 真实节点零命中)。
- **结论**:不动架构;解「"按需"判据太软」→ 推荐 **C1 换外部可验证判据**("通用 vs 专属"可验证,不像"我能不能写")。⛔ 作废「删除跳过条款 → 默认必检」。 - 🔬 **验证方法论铁律**:测检索优化 → ⛔ 禁用「凭空需求」法;✅ 必用**「反查法」**。⚠️ 信息保真审计口径 = **角色台词零丢失 + 数字零丢失 + 可拍动作/物品全在**。
- 会话记录 `D:/.workbuddy/projects/<cwd转义名>/<sessionId>.jsonl`;工具 `20_trace_recall.py`。工作台「提交创作」是**两段式**(`#reqCreate` 只弹确认,真提交在 `button[data-ok]`)。 - 杂项:会话记录 `D:/.workbuddy/projects/<cwd转义名>/<sessionId>.jsonl`;工具 `20_trace_recall.py`;工作台「提交创作」两段式(`#reqCreate` 弹确认,真提交在 `button[data-ok]`)。
- ✅ **确定有效 = 参数纠正**:`match_count` 实测省 69% 上下文;`vector_threshold` 必显式传。
- ⚠️ **收益有限 = 问法改写**:反查法旧 R@1 34/35 vs 新 35/35 → **差异在噪声内**。价值 = **信息量保障**,不是提升召回率。⛔ 禁宣称"召回率 97%→100%"。
- 🔴 **真实瓶颈 = 素材覆盖**:需求法暴露 3/5 真实节点零命中 → 问法再优化也召不回不存在的素材。
- 🔬 **验证方法论铁律**:测「检索优化」→ ⛔ 禁用「凭空需求」法;✅ 必用**「反查法」**。
- ⚠️ **信息保真审计口径**:真判据 = **角色台词零丢失 + 数字零丢失 + 可拍动作/物品全在**。
## 5. 麦芽平台采集(脚本在 `tools/`) ## 5. 麦芽平台采集(脚本在 `tools/`)
- ⭐ **接口直取,禁 DOM 翻页**:`GET /api/scriptwriting/shortVideo/hot?pageNum&pageSize&sortType&startLikeNum`(pageSize=50 最快)。 - ⭐ **接口直取,禁 DOM 翻页**:`GET /api/scriptwriting/shortVideo/hot?pageNum&pageSize&sortType&startLikeNum`(pageSize=50 最快)。
@@ -110,14 +112,11 @@
- ChatGPT 投材料(`tools/chatgpt_ask.py`):**长材料走附件上传**;完成判定 = 无 stop 按钮且文本连续 4 次不变。 - ChatGPT 投材料(`tools/chatgpt_ask.py`):**长材料走附件上传**;完成判定 = 无 stop 按钮且文本连续 4 次不变。
## 8. 工作台 UI 定稿要点 ## 8. 工作台 UI 定稿要点
> 完整铁律查 **`mcn-work-shop/docs/工作台视觉交互规范.md`**(权威源)。 > 完整铁律查 **`mcn-workshop/docs/工作台视觉交互规范.md`**(权威源)。
- 榜单四榜末列恒「功能」= `inDb ? 查看 : 关注`;热门榜与点赞榜字段体系不同 → 跨榜逻辑双向回退。 - 榜单四榜末列恒「功能」= `inDb ? 查看 : 关注`;热门榜与点赞榜字段体系不同 → 跨榜逻辑双向回退。
- 选题列表由 `rewrite_id` 驱动:点标题=详情;「查看脚本」=`openScriptModal({rewriteId})`;「编辑继续」=带 id UPDATE;「复制」=不带 id 新增。 - 选题列表由 `rewrite_id` 驱动:点标题=详情;「查看脚本」=`openScriptModal({rewriteId})`;「编辑继续」=带 id UPDATE;「复制」=不带 id 新增。
## 9. 历史时间线(压缩) ## 9. 历史时间线(压缩)
- 08 月:素材库扁平化 8 类型;赛道 8→27;三态创作;Lite1.0;`.dsh` 只读;工作台立项。 - 08 月:素材库扁平化 8 类型;赛道 8→27;三态创作;Lite1.0;`.dsh` 只读;工作台立项。
- 09-10 仓库迁移 + 环境判定|09-14 全量技能审计(P0=0/P1=8/P2=7)|09-16 工作台 AI 创作改版 + UI 规范定稿。 - 09-10 仓库迁移 + 环境判定|09-14 全量技能审计(P0=0/P1=8/P2=7)|09-16 工作台 AI 创作改版 + UI 规范定稿|09-21~22 极致事件定义与三层定位裁决、事件卡 10 字段定稿|09-23 WeKnora 检索层建成 + 留人点三级体系定稿|09-23~24 卡片流水线化|09-28 定向补采自动化 + 卡片回炉(v2/v3)+ 补采切暂存模式 + 检索参数名纠正。
- 09-21~22 极致事件定义与三层定位裁决、事件卡 10 字段定稿。 - 09-29 AI 通路改 `cli`(本地 CodeBuddy CLI)→ 端到端跑通(status/probe/clarify/run),模型 `deepseek-v4.1-flash`;权威文档 `docs/AI链路-本地CLI接入.md`。
- 09-23 WeKnora 检索层建成(FAQ 容器 + 阈值 0.5);留人点三级体系定稿。
- 09-23~24 卡片流水线化(RUNBOOK + 脚本 1/2/3)。
- 09-28 定向补采自动化 + 卡片回炉(v2 吸引力优先 + v3 问法增厚)+ 补采切暂存模式;检索参数名纠正。
File diff suppressed because it is too large. Load diff
@@ -53,7 +53,7 @@
|-------|-------|---------|-----------| |-------|-------|---------|-----------|
| 项目文件夹(桌面 `MCNSkill项目/俊希/`) | 账号设定/视频表格/脚本 | **仅 `提示词01/`**(分镜产出,属短视频分镜提示词技能) | **否**(本链路产出全新创建;提示词01 保留) | | 项目文件夹(桌面 `MCNSkill项目/俊希/`) | 账号设定/视频表格/脚本 | **仅 `提示词01/`**(分镜产出,属短视频分镜提示词技能) | **否**(本链路产出全新创建;提示词01 保留) |
| 达人账号表.xlsx | 俊希行 | **无** | 否(首次登记 70014) | | 达人账号表.xlsx | 俊希行 | **无** | 否(首次登记 70014) |
| 工作台 mcn-plugin.db(`…\mcn-work-shop\`,自有副本) | hot_accounts 俊希 | **有**(id=3,含完整账号信息 + content 账号设定文本、douyin_id=JJX0827、sec_uid、278.7万粉、source_dir=`D:\dshworkspace\账号解析\俊希` 为**旧路径残留**,目录已不存在) | **否**(见下判定) | | 工作台 mcn-plugin.db(`…\mcn-workshop\`,自有副本) | hot_accounts 俊希 | **有**(id=3,含完整账号信息 + content 账号设定文本、douyin_id=JJX0827、sec_uid、278.7万粉、source_dir=`D:\dshworkspace\账号解析\俊希` 为**旧路径残留**,目录已不存在) | **否**(见下判定) |
| 同上 | account_videos 俊希 | **有 108 条**(source_dir=`D:\dshworkspace\抖音账号分析\俊希` 旧路径) | 否 | | 同上 | account_videos 俊希 | **有 108 条**(source_dir=`D:\dshworkspace\抖音账号分析\俊希` 旧路径) | 否 |
| 同上 | account_persona 俊希 | **无**(5 行均为 1627-1630 测试占位,无 account_id=3) | 否 | | 同上 | account_persona 俊希 | **无**(5 行均为 1627-1630 测试占位,无 account_id=3) | 否 |
| 同上 | rewrite_log 俊希 | **有 3 条**(2026-08-19 AI 写脚本记录,id=12/15/16,topic 含完整选题) | 否 | | 同上 | rewrite_log 俊希 | **有 3 条**(2026-08-19 AI 写脚本记录,id=12/15/16,topic 含完整选题) | 否 |
@@ -65,7 +65,7 @@
1. **文件层面**:桌面 `MCNSkill项目/俊希/` 无本链路产出 → **无需清除**,全新创建;已有产出时(有就刷新)→ 覆盖更新 `短视频表格.xlsx`、新增/更新设定与脚本,**不删除旧产出**(保留可追溯) 1. **文件层面**:桌面 `MCNSkill项目/俊希/` 无本链路产出 → **无需清除**,全新创建;已有产出时(有就刷新)→ 覆盖更新 `短视频表格.xlsx`、新增/更新设定与脚本,**不删除旧产出**(保留可追溯)
2. **数据库层面(工作台 mcn-plugin.db 自有副本)**:俊希有历史数据但**不影响本测试**——关键判定 `account_persona` 无俊希 → 工作台 `confirmAiScript` 的 hasPersona=false → 弹窗走「先进行账号设定分析」分支,**首次场景成立**(hot_accounts.content 的旧设定文本不会被界面读取,界面只读 account_persona)。因此**无需清除**;若追求「完全干净」需用户明确授权后选择性清除(hot_accounts id=3 及其 videos/rewrite_log 行),且只动工作台自有副本 2. **数据库层面(工作台 mcn-plugin.db 自有副本)**:俊希有历史数据但**不影响本测试**——关键判定 `account_persona` 无俊希 → 工作台 `confirmAiScript` 的 hasPersona=false → 弹窗走「先进行账号设定分析」分支,**首次场景成立**(hot_accounts.content 的旧设定文本不会被界面读取,界面只读 account_persona)。因此**无需清除**;若追求「完全干净」需用户明确授权后选择性清除(hot_accounts id=3 及其 videos/rewrite_log 行),且只动工作台自有副本
3. **dsh 原库**:**红线只读,绝不写**(清理不适用) 3. **dsh 原库**:**红线只读,绝不写**(清理不适用)
4. **工作台副本与 dsh 原库的数据同步**:工作台读自有副本(`mcn-work-shop/mcn-plugin.db`),与 `~/.dsh/mcn-plugin.db` 隔离;测试新增数据(rewrite_log 第 4 条等)只落工作台副本,不影响 dsh 原库 4. **工作台副本与 dsh 原库的数据同步**:工作台读自有副本(`mcn-workshop/mcn-plugin.db`),与 `~/.dsh/mcn-plugin.db` 隔离;测试新增数据(rewrite_log 第 4 条等)只落工作台副本,不影响 dsh 原库
### 清除操作(如用户要求执行,需先备份) ### 清除操作(如用户要求执行,需先备份)
@@ -153,7 +153,7 @@
### TC-09 工作台 AI 参考最新视频选题(**界面模拟点击**) ### TC-09 工作台 AI 参考最新视频选题(**界面模拟点击**)
> **本环节用可执行脚本模拟真实点击测试**,脚本:`mcn-work-shop/cdp-test-junxi.mjs` > **本环节用可执行脚本模拟真实点击测试**,脚本:`mcn-workshop/cdp-test-junxi.mjs`
> 用法:`node cdp-test-junxi.mjs`(安全模式:验证到选题生成,不触发任务);`node cdp-test-junxi.mjs --confirm`(继续确认执行,真实触发创作任务) > 用法:`node cdp-test-junxi.mjs`(安全模式:验证到选题生成,不触发任务);`node cdp-test-junxi.mjs --confirm`(继续确认执行,真实触发创作任务)
> 机制:连接浏览器 9223 调试实例 → 新开标签页打开 `#/accounts` → `Runtime.evaluate` 模拟点击 → 轮询弹窗/AI 选题 → 自动关闭标签页(不干扰用户页面)。脚本复用 cdp-verify-*.mjs 的 CDP 模式。 > 机制:连接浏览器 9223 调试实例 → 新开标签页打开 `#/accounts` → `Runtime.evaluate` 模拟点击 → 轮询弹窗/AI 选题 → 自动关闭标签页(不干扰用户页面)。脚本复用 cdp-verify-*.mjs 的 CDP 模式。
@@ -248,7 +248,7 @@
- `MCNSkill项目/俊希/` 现有仅 `提示词01/` 子目录(分镜产出,属短视频分镜提示词技能),无账号分析产出 - `MCNSkill项目/俊希/` 现有仅 `提示词01/` 子目录(分镜产出,属短视频分镜提示词技能),无账号分析产出
- **工作台 mcn-plugin.db 俊希历史数据**:hot_accounts id=3(完整账号信息 + content 设定文本,source_dir=`D:\dshworkspace\账号解析\俊希` **旧路径已不存在**)、account_videos 108 条(source_dir=`D:\dshworkspace\抖音账号分析\俊希` 旧路径)、rewrite_log 3 条(2026-08-19,海鲜大餐主题);**account_persona 无俊希** → hasPersona=false → 弹窗走「先进行账号设定分析」分支,首次场景成立,无需清除 - **工作台 mcn-plugin.db 俊希历史数据**:hot_accounts id=3(完整账号信息 + content 设定文本,source_dir=`D:\dshworkspace\账号解析\俊希` **旧路径已不存在**)、account_videos 108 条(source_dir=`D:\dshworkspace\抖音账号分析\俊希` 旧路径)、rewrite_log 3 条(2026-08-19,海鲜大餐主题);**account_persona 无俊希** → hasPersona=false → 弹窗走「先进行账号设定分析」分支,首次场景成立,无需清除
- dsh 原库(~/.dsh/mcn-plugin.db)俊希数据与副本一致(.dsh 只读红线不写) - dsh 原库(~/.dsh/mcn-plugin.db)俊希数据与副本一致(.dsh 只读红线不写)
- **界面模拟点击测试脚本**:`mcn-work-shop/cdp-test-junxi.mjs`(2026-08-31 实测通过:账号列表定位俊希 PASS / AI写脚本弹窗结构 PASS / AI 选题 3 个方向 PASS);注意 `.modal` 选择器会被 index.html 隐藏的 `#settingsModal` 干扰,须限定 `:not([hidden])` 且含 `#aiTopicGen` - **界面模拟点击测试脚本**:`mcn-workshop/cdp-test-junxi.mjs`(2026-08-31 实测通过:账号列表定位俊希 PASS / AI写脚本弹窗结构 PASS / AI 选题 3 个方向 PASS);注意 `.modal` 选择器会被 index.html 隐藏的 `#settingsModal` 干扰,须限定 `:not([hidden])` 且含 `#aiTopicGen`
- MCP 解析单条视频需等待 3-5 分钟;已解析过的视频可直接查询 - MCP 解析单条视频需等待 3-5 分钟;已解析过的视频可直接查询
- 工作台 #/reviews「AI脚本诊断」复盘按钮明确按 **mcn-script-review(脚本复盘)技能**执行(本用例主链路不含复盘,如需回归可追加) - 工作台 #/reviews「AI脚本诊断」复盘按钮明确按 **mcn-script-review(脚本复盘)技能**执行(本用例主链路不含复盘,如需回归可追加)
- 产出根目录:开发机 → 桌面 `MCNSkill项目/{达人昵称}/`(`references/路径配置.md` 权威) - 产出根目录:开发机 → 桌面 `MCNSkill项目/{达人昵称}/`(`references/路径配置.md` 权威)
+6 -6
View File
@@ -60,8 +60,8 @@
### P1-2 | 契约表漏登记 2 个 AI 入口 ### P1-2 | 契约表漏登记 2 个 AI 入口
**现象** **现象**
- `mcn-work-shop/docs/AI会话任务输入输出对照.md`:入口表只有 1–9 行(9 为 clarify,非任务);L127 声明「前缀常量 = CREATE/EXTRACT/STORYBOARD/REVIEW + ACCOUNT」;检查清单第 1 项同样只列 5 个。 - `mcn-workshop/docs/AI会话任务输入输出对照.md`:入口表只有 1–9 行(9 为 clarify,非任务);L127 声明「前缀常量 = CREATE/EXTRACT/STORYBOARD/REVIEW + ACCOUNT」;检查清单第 1 项同样只列 5 个。
- 实际 `mcn-work-shop/public/data-pages.js` 还有两个入口: - 实际 `mcn-workshop/public/data-pages.js` 还有两个入口:
| 入口 | 触发点 | 前缀常量 | skills | label | 代码位置 | | 入口 | 触发点 | 前缀常量 | skills | label | 代码位置 |
|------|------|------|------|------|------| |------|------|------|------|------|------|
@@ -78,10 +78,10 @@
| 2 | `对照.md:127` 关键细节 | 常量清单补全为 `SKILL_HINT_CREATE/EXTRACT/STORYBOARD/REVIEW/ANALYZE/VIDEO_PARSE/ACCOUNT`,并注明**权威来源 = data-pages.js / app.js 顶部实际声明**(行号会漂,故不给行号,只给文件) | | 2 | `对照.md:127` 关键细节 | 常量清单补全为 `SKILL_HINT_CREATE/EXTRACT/STORYBOARD/REVIEW/ANALYZE/VIDEO_PARSE/ACCOUNT`,并注明**权威来源 = data-pages.js / app.js 顶部实际声明**(行号会漂,故不给行号,只给文件) |
| 3 | `对照.md:137` 检查清单第 1 项 | 前缀对应关系补 `分析=ANALYZE、视频解析=VIDEO_PARSE` | | 3 | `对照.md:137` 检查清单第 1 项 | 前缀对应关系补 `分析=ANALYZE、视频解析=VIDEO_PARSE` |
| 4 | `对照.md` 新增入口检查清单 | 追加第 8 项:`[ ] 表码对齐:本次新增/改动的入口已回填本表入口表行(含 skills / 前缀常量 / 代码位置),并核对 server.js BROWSER_HINT 正则不被误命中` | | 4 | `对照.md` 新增入口检查清单 | 追加第 8 项:`[ ] 表码对齐:本次新增/改动的入口已回填本表入口表行(含 skills / 前缀常量 / 代码位置),并核对 server.js BROWSER_HINT 正则不被误命中` |
| 5 | `SKILL.md:81` | `(SKILL_HINT_CREATE/EXTRACT/STORYBOARD/REVIEW/ACCOUNT,分别对应 创作/提炼/分镜/复盘/账号抓取)` → `(SKILL_HINT_CREATE/EXTRACT/STORYBOARD/REVIEW/ANALYZE/VIDEO_PARSE/ACCOUNT,分别对应 创作/提炼/分镜/复盘/账号数据分析/视频解析/账号抓取;完整清单以 mcn-work-shop/docs/AI会话任务输入输出对照.md 为准)` | | 5 | `SKILL.md:81` | `(SKILL_HINT_CREATE/EXTRACT/STORYBOARD/REVIEW/ACCOUNT,分别对应 创作/提炼/分镜/复盘/账号抓取)` → `(SKILL_HINT_CREATE/EXTRACT/STORYBOARD/REVIEW/ANALYZE/VIDEO_PARSE/ACCOUNT,分别对应 创作/提炼/分镜/复盘/账号数据分析/视频解析/账号抓取;完整清单以 mcn-workshop/docs/AI会话任务输入输出对照.md 为准)` |
**验证**: **验证**:
1. `grep -c "SKILL_HINT_" mcn-work-shop/public/data-pages.js mcn-work-shop/public/app.js` 得到的常量名集合 = 对照表清单,一一对应无缺; 1. `grep -c "SKILL_HINT_" mcn-workshop/public/data-pages.js mcn-workshop/public/app.js` 得到的常量名集合 = 对照表清单,一一对应无缺;
2. 逐个入口 grep `skills:` 与表内 skills 列一致。 2. 逐个入口 grep `skills:` 与表内 skills 列一致。
**风险**:低。纯文档补登记,不改代码逻辑。 **风险**:低。纯文档补登记,不改代码逻辑。
@@ -133,7 +133,7 @@
**注意**:`5_生成短视频选题.md:192`「S6 用「本场钩子」五类留人机制」与 S6 场次表列名「本场钩子」是**正确用法**,不动。 **注意**:`5_生成短视频选题.md:192`「S6 用「本场钩子」五类留人机制」与 S6 场次表列名「本场钩子」是**正确用法**,不动。
**验证**:`grep -rn "开篇钩子\|钩子预设\|钩子策略" SKILL.md references/ mcn-work-shop/` → 仅允许命中 `references-add/变更日志.md`(历史归档)。 **验证**:`grep -rn "开篇钩子\|钩子预设\|钩子策略" SKILL.md references/ mcn-workshop/` → 仅允许命中 `references-add/变更日志.md`(历史归档)。
--- ---
@@ -179,7 +179,7 @@
| # | 位置 | 动作 | | # | 位置 | 动作 |
|:--:|------|------| |:--:|------|------|
| 1 | `references/接口调用/本地接口调用规范.md` | **重写**为《工作台接口调用规范》:① 基地址 — 从 `mcn-work-shop/config.json` 的 `port` 读(默认 8900,`server.js` 支持 argv 覆盖),禁写死端口;② 写库接口 — `POST /api/import/account`,body `{"account":"{达人昵称}"}`,落库映射见 dou-analysis SKILL.md:160;③ 保留「大文本用 Python urllib + `json.dumps(ensure_ascii=False)` 直连,禁 PowerShell `ConvertTo-Json`」这条铁律;④ 接口清单**不另抄一份**,指向 `mcn-work-shop/docs/AI会话任务输入输出对照.md` + `server.js` 路由为权威 | | 1 | `references/接口调用/本地接口调用规范.md` | **重写**为《工作台接口调用规范》:① 基地址 — 从 `mcn-workshop/config.json` 的 `port` 读(默认 8900,`server.js` 支持 argv 覆盖),禁写死端口;② 写库接口 — `POST /api/import/account`,body `{"account":"{达人昵称}"}`,落库映射见 dou-analysis SKILL.md:160;③ 保留「大文本用 Python urllib + `json.dumps(ensure_ascii=False)` 直连,禁 PowerShell `ConvertTo-Json`」这条铁律;④ 接口清单**不另抄一份**,指向 `mcn-workshop/docs/AI会话任务输入输出对照.md` + `server.js` 路由为权威 |
| 2 | `references/铁律避坑规则/账号设定执行避坑.md:12` | 探测方法改为「用 `GET http://127.0.0.1:<工作台端口>/api/dsh/stats` 探连通,不用写接口探测」 | | 2 | `references/铁律避坑规则/账号设定执行避坑.md:12` | 探测方法改为「用 `GET http://127.0.0.1:<工作台端口>/api/dsh/stats` 探连通,不用写接口探测」 |
| 3 | `references/铁律避坑规则/账号设定执行避坑.md:25` | 沉淀描述改为「新建 `references/接口调用/本地接口调用规范.md`,记录工作台接口基地址判定、写库接口与请求构造方式」 | | 3 | `references/铁律避坑规则/账号设定执行避坑.md:25` | 沉淀描述改为「新建 `references/接口调用/本地接口调用规范.md`,记录工作台接口基地址判定、写库接口与请求构造方式」 |
| 4 | `feature/05_解析视频.md:55` | 整句改为「解析结果对应「原视频解析」数据:落盘后调用工作台 `POST /api/import/account`(body `{"account":"{达人昵称}"}`)把 `content.json` 补录进 `account_video_source` 并提取选题(topic);工作台未启动时跳过(不影响本地保存)。」 | | 4 | `feature/05_解析视频.md:55` | 整句改为「解析结果对应「原视频解析」数据:落盘后调用工作台 `POST /api/import/account`(body `{"account":"{达人昵称}"}`)把 `content.json` 补录进 `account_video_source` 并提取选题(topic);工作台未启动时跳过(不影响本地保存)。」 |
+1 -1
View File
@@ -69,7 +69,7 @@
### 4. 交互特征小结(对本项目最有参考价值) ### 4. 交互特征小结(对本项目最有参考价值)
| 特征 | flova 实现 | 借鉴点 | | 特征 | flova 实现 | 借鉴点 |
|------|-----------|--------| |------|-----------|--------|
| 双栏创作 | 左画布右对话,**对话永驻不打断** | mcn-work-shop 的 AI 弹窗可改为右侧常驻面板 | | 双栏创作 | 左画布右对话,**对话永驻不打断** | mcn-workshop 的 AI 弹窗可改为右侧常驻面板 |
| 创作规范注入 | 对话首轮自动载入 Skill("Skill已完成"状态行) | 用户选定 Skill 后第一步就声明规范,后续全部按规范执行 | | 创作规范注入 | 对话首轮自动载入 Skill("Skill已完成"状态行) | 用户选定 Skill 后第一步就声明规范,后续全部按规范执行 |
| 分步引导 | AI 每次只确认 1 个参数,确认完再问下一个 | 选题→时长→对白→形象→关系→框架→剧本 的渐进式锁定 | | 分步引导 | AI 每次只确认 1 个参数,确认完再问下一个 | 选题→时长→对白→形象→关系→框架→剧本 的渐进式锁定 |
| AI 提供选项 | 回复末尾出 2-4 个**可点选方向**,用户一键续接 | 降低输入成本、保持共创节奏 | | AI 提供选项 | 回复末尾出 2-4 个**可点选方向**,用户一键续接 | 降低输入成本、保持共创节奏 |
+8 -8
View File
@@ -64,21 +64,21 @@ agent_created: true
用户说「打开工作台 / 打开MCN工作台」时: 用户说「打开工作台 / 打开MCN工作台」时:
1. 后台启动本地服务:`node <本技能目录>/mcn-work-shop/server.js`(零依赖 Node,无需安装包;Windows 也可双击 `start.bat`) 1. 后台启动本地服务:`node <本技能目录>/mcn-workshop/server.js`(零依赖 Node,无需安装包;Windows 也可双击 `start.bat`)
2. 用内置浏览器打开 `http://localhost:8900`。**注意端口**:本机 8899 可能被 `MiPCAudio.exe`(小米 PC 管家)占用系统级 0.0.0.0:8899 且杀后自愈,工作台默认端口已迁移为 **8900**;如需指定端口用 `node server.js <端口>`(`PORT_BASE = parseInt(process.argv[2],10) || 8900`) 2. 用内置浏览器打开 `http://localhost:8900`。**注意端口**:本机 8899 可能被 `MiPCAudio.exe`(小米 PC 管家)占用系统级 0.0.0.0:8899 且杀后自愈,工作台默认端口已迁移为 **8900**;如需指定端口用 `node server.js <端口>`(`PORT_BASE = parseInt(process.argv[2],10) || 8900`)
3. 工作台采用 hash 路由,页面结构: 3. 工作台采用 hash 路由,页面结构:
- **首页(#/)**:数据功能卡片(抖音周榜/热门账号/AI写脚本/AI写作复盘)+ 5 大技能功能块(问法点击复制)+ 三种创作模式 - **首页(#/)**:数据功能卡片(抖音周榜/热门账号/AI写脚本/AI写作复盘)+ 5 大技能功能块(问法点击复制)+ 三种创作模式
- **📈 抖音周榜(#/ranking)**:读桌面 `MCNSkill项目\三方数据\红狐数据\抖音榜单\*.json`(运行时解析真实桌面、兼容 OneDrive 重定向;环境变量 `MCN_RANKING_DIR` 可覆盖),赛道筛选 + 日期切换 + 数值排序 - **📈 抖音周榜(#/ranking)**:读桌面 `MCNSkill项目\三方数据\红狐数据\抖音榜单\*.json`(运行时解析真实桌面、兼容 OneDrive 重定向;环境变量 `MCN_RANKING_DIR` 可覆盖),赛道筛选 + 日期切换 + 数值排序
- **👥 热门账号(#/accounts)**:读工作台自有数据库副本 `mcn-work-shop/mcn-plugin.db`(只读),分页/搜索/赛道/类型筛选,账号点击进详情 - **👥 热门账号(#/accounts)**:读工作台自有数据库副本 `mcn-workshop/mcn-plugin.db`(只读),分页/搜索/赛道/类型筛选,账号点击进详情
- **账号详情(#/account/:id)**:信息卡 + 账号信息/账号设定/数据分析 3 tab + 视频列表分页;解析/改写/提炼按钮直接提交 AI 任务执行 - **账号详情(#/account/:id)**:信息卡 + 账号信息/账号设定/数据分析 3 tab + 视频列表分页;解析/改写/提炼按钮直接提交 AI 任务执行
- **视频详情(#/video/:id)**:信息卡 + 视频脚本/视频拆解/视频分析 3 tab - **视频详情(#/video/:id)**:信息卡 + 视频脚本/视频拆解/视频分析 3 tab
- **✍️ AI写脚本(#/rewrites)**:爆款视频列表 + 源脚本 vs AI脚本双栏对比 + 分镜提示词分块渲染 - **✍️ AI写脚本(#/rewrites)**:爆款视频列表 + 源脚本 vs AI脚本双栏对比 + 分镜提示词分块渲染
- **🧪 AI写作复盘(#/reviews)**:评分维度列表 + 复盘详情(评分条/问题清单/报告) - **🧪 AI写作复盘(#/reviews)**:评分维度列表 + 复盘详情(评分条/问题清单/报告)
- **📁 产出浏览(#/files)**:默认读桌面 `MCNSkill项目/`,支持 ⚙️ 设置添加其他项目地址;md/json 在线预览 - **📁 产出浏览(#/files)**:默认读桌面 `MCNSkill项目/`,支持 ⚙️ 设置添加其他项目地址;md/json 在线预览
4. **数据边界**:数据库读工作台自有副本 `mcn-work-shop/mcn-plugin.db`(**dsh 侧已整套下线(09-10):`~/.dsh/mcn-plugin.db` 与 `D:\dshworkspace` 均不存在**,工作台 DB 为唯一读写副本);周榜读桌面 `MCNSkill项目\三方数据\红狐数据\抖音榜单\*.json` 文件 4. **数据边界**:数据库读工作台自有副本 `mcn-workshop/mcn-plugin.db`(**dsh 侧已整套下线(09-10):`~/.dsh/mcn-plugin.db` 与 `D:\dshworkspace` 均不存在**,工作台 DB 为唯一读写副本);周榜读桌面 `MCNSkill项目\三方数据\红狐数据\抖音榜单\*.json` 文件
5. **AI 任务触发(Automation 方案)**:各数据页「导入/创作/解析/改写/提炼/复盘」按钮点击后 → 工作台后端 `POST /api/run` → 向 `~/.workbuddy/workbuddy.db` 的 `automations` 表写入一条**一次性任务**(`schedule_type='once'`、**`scheduled_at=now+5s`(带秒)且显式写 `next_run_at=now+5000`**、`cwds=[<工作台目录>]`(由 `server.js` 从 `ROOT` 动态推导,值 = `<技能根>/mcn-work-shop`,**禁止写死盘符**))→ WorkBuddy 客户端调度器按 `next_run_at` 扫描拾取(周期≤30s)→ 创建 AI 会话执行 → **会话归入 `mcn-work-shop` 空间分组**(左侧会话栏按 cwd 分组,可在该分组查看任务执行过程与结果)。参考 dsh 插件 `dsh-plugin-mcn` 的 `/mcn/api/creative` 模式(固定会话 followup 触发);前端 5 秒防连点。**调度机制/状态机/并发上限/排查命令详见同目录 `自动化任务调度机制.md`(09-01 实测定稿,勿再按旧认知 `scheduled_at=now` 直写——会因 next_run_at 为空而卡死)** 5. **AI 任务触发(Automation 方案)**:各数据页「导入/创作/解析/改写/提炼/复盘」按钮点击后 → 工作台后端 `POST /api/run` → 向 `~/.workbuddy/workbuddy.db` 的 `automations` 表写入一条**一次性任务**(`schedule_type='once'`、**`scheduled_at=now+5s`(带秒)且显式写 `next_run_at=now+5000`**、`cwds=[<工作台目录>]`(由 `server.js` 从 `ROOT` 动态推导,值 = `<技能根>/mcn-workshop`,**禁止写死盘符**))→ WorkBuddy 客户端调度器按 `next_run_at` 扫描拾取(周期≤30s)→ 创建 AI 会话执行 → **会话归入 `mcn-workshop` 空间分组**(左侧会话栏按 cwd 分组,可在该分组查看任务执行过程与结果)。参考 dsh 插件 `dsh-plugin-mcn` 的 `/mcn/api/creative` 模式(固定会话 followup 触发);前端 5 秒防连点。**调度机制/状态机/并发上限/排查命令详见同目录 `自动化任务调度机制.md`(09-01 实测定稿,勿再按旧认知 `scheduled_at=now` 直写——会因 next_run_at 为空而卡死)**
6. **工作台任务执行方式铁律(P0,09-02 固化)**:凡工作台场景的**创作/改写/重写/复盘/解析**类任务(含"重新执行历史任务"),必须通过 `POST /api/run` 提交 AI 会话任务执行(automation 触发 → 独立 AI 会话按技能 S1-S11 执行 → 写库 rewrite_log/入库),**禁止在会话内直接产出脚本草稿**(不会入库、不在左侧会话栏可见、不走技能会话,等于"影子脚本")。**重新执行历史任务 = 复用原任务的 prompt(人设卡+视频选题+创作要求+落库字段)+ `skills:['短视频工作台']` 提交新任务**——新任务自动加载当前技能版本(含最新优化),无需手工搬内容。执行方式判定细则见「关键规则 〇·执行方式判定」。 6. **工作台任务执行方式铁律(P0,09-02 固化)**:凡工作台场景的**创作/改写/重写/复盘/解析**类任务(含"重新执行历史任务"),必须通过 `POST /api/run` 提交 AI 会话任务执行(automation 触发 → 独立 AI 会话按技能 S1-S11 执行 → 写库 rewrite_log/入库),**禁止在会话内直接产出脚本草稿**(不会入库、不在左侧会话栏可见、不走技能会话,等于"影子脚本")。**重新执行历史任务 = 复用原任务的 prompt(人设卡+视频选题+创作要求+落库字段)+ `skills:['短视频工作台']` 提交新任务**——新任务自动加载当前技能版本(含最新优化),无需手工搬内容。执行方式判定细则见「关键规则 〇·执行方式判定」。
7. **AI 任务入口技能契约(P0,09-02 固化)**:工作台每个触发 AI 会话任务的入口都必须遵守「功能入口 × 技能调用契约」——任务 prompt = **固定技能声明前缀常量**(SKILL_HINT_CREATE/EXTRACT/STORYBOARD/REVIEW/ACCOUNT,分别对应 创作/提炼/分镜/复盘/账号抓取)+ **业务正文**,并传对应 `skills` 数组(参数级技能挂载)。**新增/修改任何入口时先读 `mcn-work-shop/docs/AI会话任务输入输出对照.md`(契约总表 + 新增入口检查清单 7 项),再动手**——防裸 prompt 跳步(09-02 三入口缺约束根因)、防技能声明混入业务串、防非抓取任务命中 server.js BROWSER_HINT 正则被误注入浏览器锁。 7. **AI 任务入口技能契约(P0,09-02 固化)**:工作台每个触发 AI 会话任务的入口都必须遵守「功能入口 × 技能调用契约」——任务 prompt = **固定技能声明前缀常量**(SKILL_HINT_CREATE/EXTRACT/STORYBOARD/REVIEW/ACCOUNT,分别对应 创作/提炼/分镜/复盘/账号抓取)+ **业务正文**,并传对应 `skills` 数组(参数级技能挂载)。**新增/修改任何入口时先读 `mcn-workshop/docs/AI会话任务输入输出对照.md`(契约总表 + 新增入口检查清单 7 项),再动手**——防裸 prompt 跳步(09-02 三入口缺约束根因)、防技能声明混入业务串、防非抓取任务命中 server.js BROWSER_HINT 正则被误注入浏览器锁。
8. 本功能仅 V1.0 源技能环境提供,**不同步 dsh/Lite 副本** 8. 本功能仅 V1.0 源技能环境提供,**不同步 dsh/Lite 副本**
9. **改完必重开页面(硬性约定)**:对工作台任何页面/代码改动完成后,**最后一步必须用 present_files 重新打开对应页面**(带 hash 直接定位,如 `http://localhost:8900/#/accounts`),让用户在 WorkBuddy 内置浏览器立即看到最新效果。原因:内置浏览器无 CDP 调试端口(9222/9224 等均无响应),无法模拟 F5;重开页面是唯一可靠刷新方式。此约定对工作台所有 UI 改动强制生效 9. **改完必重开页面(硬性约定)**:对工作台任何页面/代码改动完成后,**最后一步必须用 present_files 重新打开对应页面**(带 hash 直接定位,如 `http://localhost:8900/#/accounts`),让用户在 WorkBuddy 内置浏览器立即看到最新效果。原因:内置浏览器无 CDP 调试端口(9222/9224 等均无响应),无法模拟 F5;重开页面是唯一可靠刷新方式。此约定对工作台所有 UI 改动强制生效
@@ -150,7 +150,7 @@ agent_created: true
- **技能本体**:`subskills/lieflat-charts/`(相对本文件;含 SKILL.md + catalog.md + templates/ 各 gallery,随源仓库分发) - **技能本体**:`subskills/lieflat-charts/`(相对本文件;含 SKILL.md + catalog.md + templates/ 各 gallery,随源仓库分发)
- **选型规则**:先读 `catalog.md` 按数据形状锁定图型编号,再打开 `templates/` 对应 gallery 的真实实现复制骨架;默认顺序 Lupi Editorial → Lupi Basics → Glance(工作台场景数据量小/需快读时可直用 Glance) - **选型规则**:先读 `catalog.md` 按数据形状锁定图型编号,再打开 `templates/` 对应 gallery 的真实实现复制骨架;默认顺序 Lupi Editorial → Lupi Basics → Glance(工作台场景数据量小/需快读时可直用 Glance)
- **视觉 token**:`mono-tokens.js`(纸 #F0EFEB / 墨 #1C1C1A / 灰 #8F8E88 / 网格 #DEDDD6),同一交付只锁一种色系 - **视觉 token**:`mono-tokens.js`(纸 #F0EFEB / 墨 #1C1C1A / 灰 #8F8E88 / 网格 #DEDDD6),同一交付只锁一种色系
- **ECharts 依赖**:已本地化至工作台 `mcn-work-shop/public/vendor/echarts.min.js`(零 CDN 依赖,离线可用);引用样例见工作台账号详情页视频列表上方「点赞/评论趋势折线图」(G8 Rainfall 骨架:双 grid、双序列随时间、tipLight tooltip、quarticOut 入场) - **ECharts 依赖**:已本地化至工作台 `mcn-workshop/public/vendor/echarts.min.js`(零 CDN 依赖,离线可用);引用样例见工作台账号详情页视频列表上方「点赞/评论趋势折线图」(G8 Rainfall 骨架:双 grid、双序列随时间、tipLight tooltip、quarticOut 入场)
- **使用限制**:库外新造是最后手段(须继承最接近 gallery 模板的视觉语法);不得拼接多个模板造混合图型 - **使用限制**:库外新造是最后手段(须继承最接近 gallery 模板的视觉语法);不得拼接多个模板造混合图型
--- ---
@@ -338,7 +338,7 @@ S1 意图识别 → S2 需求完善 → [G0 分析确认]
> 判定口诀:**"这个产物要不要进工作台(rewrite_log/入库/左侧会话栏)?"**——要进,就走任务;不进,才可会话内产出。 > 判定口诀:**"这个产物要不要进工作台(rewrite_log/入库/左侧会话栏)?"**——要进,就走任务;不进,才可会话内产出。
> 重新执行历史任务 = 复用原任务 prompt(从 automations 表取)+ `skills` 参数提交新任务,任务自动应用当前技能版本。 > 重新执行历史任务 = 复用原任务 prompt(从 automations 表取)+ `skills` 参数提交新任务,任务自动应用当前技能版本。
> **组装 prompt/选择 skills 时遵守「功能入口 × 技能调用契约」**(工作台章节第 7 条 + `mcn-work-shop/docs/AI会话任务输入输出对照.md`)——任务必须带技能前缀常量(SKILL_HINT_*)+ 对应 `skills` 数组。 > **组装 prompt/选择 skills 时遵守「功能入口 × 技能调用契约」**(工作台章节第 7 条 + `mcn-workshop/docs/AI会话任务输入输出对照.md`)——任务必须带技能前缀常量(SKILL_HINT_*)+ 对应 `skills` 数组。
### 〇·1、方案与执行分离(P0) ### 〇·1、方案与执行分离(P0)
@@ -397,7 +397,7 @@ Skill部署到非原始开发路径时,所有用户新增的创作流程/接
├── SKILL.md ← 入口文件(流程引擎+规则摘要+子技能索引+目录索引) ├── SKILL.md ← 入口文件(流程引擎+规则摘要+子技能索引+目录索引)
├── 帮助文档.md ← 使用指南+触发示例+快速启动模板 ├── 帮助文档.md ← 使用指南+触发示例+快速启动模板
├── scripts/ ← MCP配置 + Dify接口脚本 ├── scripts/ ← MCP配置 + Dify接口脚本
├── mcn-work-shop/ ← 本地 Web 工作台(server.js + public/,零依赖 Node) ├── mcn-workshop/ ← 本地 Web 工作台(server.js + public/,零依赖 Node)
├── subskills/ ← 6 个子技能(browser-harness / lieflat-charts / mcn-data-insight / mcn-dou-analysis / mcn-script-review / mcn-video-prompt,相对路径引用见「子技能索引」) ├── subskills/ ← 6 个子技能(browser-harness / lieflat-charts / mcn-data-insight / mcn-dou-analysis / mcn-script-review / mcn-video-prompt,相对路径引用见「子技能索引」)
├── references/ ← 标准库 ├── references/ ← 标准库
│ ├── 创作流程规范.md ← 确认门/格式/路径/硬编码 规则权威源 │ ├── 创作流程规范.md ← 确认门/格式/路径/硬编码 规则权威源
@@ -1,159 +0,0 @@
# AI 链路:本地 CodeBuddy CLI 接入(A 方案)
> 2026-09-29 落地。工作台的 AI 能力(需求打磨 / 创作)除 Dify 外,新增**本地 CodeBuddy CLI** 通路。
> 实现文件:`cli-backend.js`(后端)+ `server.js` 的 `/api/ai/clarify`、`/api/ai/status`。
---
## 1. 一句话
把「工作台 → 模型」从 **HTTP 调 Dify** 换成 **spawn 一个 `codebuddy` 子进程、用 stdio 通信**。
不涉及任何本地端口,与 WorkBuddy 桌面程序完全解耦。
---
## 2. 为什么不是「直接用宿主那个 CLI」
排查中确认了三条硬结论,决定了必须自备凭据:
| 结论 | 证据 |
|:--|:--|
| 宿主 gateway 端口/密码**每轮轮换**,且无处落盘 | 同机同天实测 `12134 → 13116 → 10261`;`D:/.workbuddy/` 下无 worker/registry 文件 |
| 唯一发现通道是环境变量,且**只存在于 WorkBuddy 派生的进程树内** | `CODEBUDDY_SERVICE_PROXY_URL=http://127.0.0.1:<port>/...`,用户双击 `start.bat` 起的工作台拿不到 |
| WorkBuddy **故意**不让子进程继承凭据 | 用 4 个环境变量把凭据交给 CLI,CLI 读完**立即 `delete`**;凭据不落明文盘 |
→ 所以走官方文档给的非交互认证通道:`CODEBUDDY_API_KEY` / `CODEBUDDY_AUTH_TOKEN`。
---
## 3. 配置步骤
### 步骤 1:拿 API Key
| 版本 | 地址 |
|:--|:--|
| **中国版**(本机是这版,`CODEBUDDY_INTERNET_ENVIRONMENT=internal`) | https://copilot.tencent.com/profile/ |
| 国际版 | https://www.codebuddy.ai/profile/keys |
| iOA | https://tencent.sso.copilot.tencent.com/profile/keys |
### 步骤 2:填进 `config.json`
```json
"ai": {
"backend": "cli",
"cli": {
"path": "",
"model": "custom:doubao-seed-2-1-pro-260628",
"apiKey": "<你的 API Key>",
"authToken": "",
"internetEnvironment": "internal",
"permissionMode": "",
"timeoutMs": 300000
}
}
```
只改两个值即可:`backend` → `"cli"`,`apiKey` → 你的 key。
### 步骤 3:自检
```bash
# 只报告配置(零消耗)
curl "http://localhost:8900/api/ai/status"
# 真跑一次极短提示,验证端到端(消耗一次调用)
curl "http://localhost:8900/api/ai/status?probe=1"
```
期望看到 `"ready": true` 且 `probe.ok = true`。若 `ready: false` → key 没填对;
若 `probe.error` 里出现 `Authentication required` → key 无效或 `internetEnvironment` 版本错了。
---
## 4. 字段说明
| 字段 | 说明 |
|:--|:--|
| `ai.backend` | `"cli"` 走本地 CLI;`"dify"` 走原链路。可用环境变量 `MCN_AI_BACKEND` 临时覆盖(不改文件、不重启) |
| `ai.cli.path` | 留空 → 自动探测:`CODEBUDDY_CLI_PATH` 环境变量 → WorkBuddy 自带 CLI → `PATH` 里的 `codebuddy` |
| `ai.cli.model` | 模型 ID。可用值见 `codebuddy --model` 帮助(含 `custom:doubao-seed-2-1-pro-260628`) |
| `ai.cli.apiKey` | 留空 → 读环境变量 `CODEBUDDY_API_KEY` |
| `ai.cli.authToken` | 留空 → 读环境变量 `CODEBUDDY_AUTH_TOKEN`。**优先级最高**(官方:`AUTH_TOKEN` > `apiKeyHelper` > `API_KEY`) |
| `ai.cli.internetEnvironment` | 中国版 `internal`;iOA `ioa`;国际版留空 |
| `ai.cli.timeoutMs` | 单次调用超时,默认 300000(5 分钟) |
---
## 5. 实现里必须保留的三条防护(改动前务必读)
`cli-backend.js` 的 `buildChildEnv()` 做了三件事,**每一条都是实测踩出来的坑**:
### 5.1 删掉 `SERVER__PORT` / `SERVER__HOST`
`codebuddy`(**连 `-p` 打印模式也**)内部会起一个 HTTP server:
```js
listen(parseInt(process.env.SERVER__PORT) || config.get("cell.server", { port: 3000 }).port)
```
在 WorkBuddy 进程树内启动时,会继承宿主给自己设的 `SERVER__PORT` → 去 listen 宿主已占用的端口 →
`EADDRINUSE` → **进程静默卡死**(HTTP 层看似正常,只有 agent 执行环节挂掉,极具迷惑性)。
官方自己在派生子进程时也是这么做的:`delete el.SERVER__PORT, delete el.SERVER__HOST`。
### 5.2 强制 `CODEBUDDY_FORCE_HEADLESS_BUNDLE=1`
`bin/codebuddy` 这个 launcher 按环境变量选 bundle:
| 环境变量 | 命中产物 |
|:--|:--|
| `CODEBUDDY_FORCE_LITE_WB_BUNDLE=1`(宿主注入,**优先级最高**) | `codebuddy-lite-wb.mjs`(WorkBuddy 特供精简包) |
| `CODEBUDDY_FORCE_HEADLESS_BUNDLE=1` | `codebuddy-headless.js`(官方 headless,**我们要的**) |
| 含 `--print` | 自动 headless |
| 都没有 | `dist/codebuddy`(**本安装里不存在** → `MODULE_NOT_FOUND`) |
不显式清除 wb 特供包分流,会误用给宿主定制的产物。
### 5.3 清掉 `NODE_OPTIONS` 与会话上下文变量
宿主会注入 `NODE_OPTIONS=--require=...node-language-shim.cjs`、`CODEBUDDY_SESSION_*`、
`CODEBUDDY_SERVICE_PROXY_URL`、`BAGGAGE` 等,子进程继承会串味。一并清掉,并把
`CODEBUDDY_CONFIG_DIR` 指向独立目录(默认 `<tmpdir>/mcn-cli-home`),避免污染 WorkBuddy 的配置与会话历史。
---
## 6. 输出协议
工作台对前端只暴露一套 SSE 协议,`cli` 与 `dify` 两条后端产出完全一致,**前端无需感知差异**:
| 事件 | 含义 |
|:--|:--|
| `{"delta": "..."}` | 文本增量,直接追加渲染 |
| `{"replace": "..."}` | 整体覆盖(终态校准 / 出错但已有产出) |
| `{"error": "..."}` | 错误(附带可操作的中文提示) |
| `data: [DONE]` | 流结束 |
CLI 侧的事件解析(`--output-format stream-json --include-partial-messages`):
| CLI 事件 | 处理 |
|:--|:--|
| `type:"system", subtype:"init"` | 取 `session_id` |
| `type:"stream_event"` → `event.content_block_delta` → `delta.type==="text_delta"` | 取 `delta.text` 作为增量 |
| `delta.type==="thinking_delta"` | 思考增量(可选回调) |
| `type:"assistant"` | 未产生增量流时的整段文本兜底 |
| `type:"result"` | 终态。`is_error:true` 时真实原因在 **`errors[]`**,正文作废 |
> ⚠️ 未认证时,CLI 会把报错文本**当成 assistant 文本**发出来。
> `isCliBoilerplate()` 负责识别并拦截,避免把英文报错显示成"AI 的回答"。
---
## 7. 排障速查
| 现象 | 原因 | 处理 |
|:--|:--|:--|
| 进程卡死无输出 | `SERVER__PORT` 未清除 → EADDRINUSE | 确认 `buildChildEnv` 的 delete 生效 |
| `Cannot find module '../dist/codebuddy'` | bundle 分流未命中 | 设 `CODEBUDDY_FORCE_HEADLESS_BUNDLE=1` |
| `Authentication required. Please use /login` | 无凭据 | 按 §3 填 key |
| `ready:false` 但 key 已填 | `internetEnvironment` 版本不对 | 中国版必须是 `internal` |
| HTTP 层全正常、agent 却不干活 | 典型的 EADDRINUSE 假象 | 看 §5.1 |
@@ -0,0 +1,9 @@
# 2026-10-08 工作日志
## 榜单数据更新(自动化 automation-1791425215326)
- 按 mcn-data-insight「榜单数据落盘规范」抓取热点数据四榜最新一期,落盘目录:`C:\Users\maidou\Desktop\MCNSkill项目\三方数据\红狐数据\抖音榜单\`
- 写入 8 个文件:账号日榜 1(数据期 2026-10-06)、账号周榜 5 赛道(数据期 2026-09-28~10-04)、视频热榜 1(周标号 2026-09-28)、点赞榜 1(周标号 2026-09-28),各 50 条
- 实际 API 调用 8 次(hot1 + likes1 + 日榜1 + 周榜5),无缓存命中(上一期:日榜 09-26、周榜 09-07、视频/点赞 09-21)
- 运行要点:`fetch_week_ranks.py` 需 cd 到 scripts 目录用 `D:/miniconda3/python.exe` 跑;`fetch_rank.py` 先落临时目录再按 JSON 内 dateStart 重命名到正式文件名
- 注意:Bash `mktemp` 得到的是 /tmp 路径,传给 Python 前需 `cygpath -w` 转 Windows 路径,否则 FileNotFoundError
@@ -0,0 +1,13 @@
# automation-1790682160345 执行记录
## 任务性质
链路自检(健康检查)任务。指令:仅回复「链路正常」四字。
## 历史执行
| 时间 | 结果 | 说明 |
| --- | --- | --- |
| 2026-09-29 19:43 | 成功 | 定时任务正常触发并返回,链路通畅。首次执行,无历史记录文件,已初始化本文件。 |
## 备注
- 该任务不产出任何交付文件,无需 present_files。
- 只做链路连通性验证:定时调度 → 会话唤起 → 模型响应 全链路可达。
@@ -0,0 +1,12 @@
# automation-1791425215326 · 榜单数据更新(热点数据四榜落盘)
## 执行约定(复用)
- 落盘目录:`C:\Users\maidou\Desktop\MCNSkill项目\三方数据\红狐数据\抖音榜单\`(桌面未 OneDrive 重定向;env `MCN_RANKING_DIR` 可覆盖)
- 四榜:账号日榜(全品类 1 次)/账号周榜(5 赛道:生活vlog 小剧场 亲子 美食 旅行)/视频热榜/点赞榜(各 1 次,不传赛道)
- 幂等:调用前先 ls 目标子目录,同榜名+赛道+数据日期已存在则跳过
- Python 用 `D:/miniconda3/python.exe`;`fetch_week_ranks.py` 在 scripts 目录执行;`fetch_rank.py` 先写临时目录,按 JSON 内 dateStart 定正式文件名
## 执行历史
| 运行日期 | 数据期 | 写入 | API 调用 | 备注 |
|---|---|---|---|---|
| 2026-10-08 | 日榜 2026-10-06;周榜/热榜/点赞 2026-09-28 | 8 个文件(各 50 条) | 8 次 | 该自动化首次记录执行;上一期缓存为日榜 09-26 / 周榜 09-07 / 视频·点赞 09-21,全部未命中故全量抓取 |
@@ -0,0 +1,6 @@
# automation-1791433363165 执行记录
## 2026-10-08 12:23
- 任务:按用户要求只回复两个字「成功」。
- 结果:已按字面要求输出「成功」,无文件产出。
- 备注:本次为首次执行,无历史记录。
@@ -69,6 +69,50 @@ function resolveCli(explicitPath) {
return null; return null;
} }
// ---------------------------------------------------------------- 凭据解析
// 凭据默认落点:**刻意放在仓库之外**。
// config.json 是 git 跟踪文件,绝不能让 key 落进去;本目录也不会被技能同步脚本复制。
function defaultCredentialFile() {
return path.join(os.homedir(), '.workbuddy', 'mcn-workshop', 'ai-cli.json');
}
// 读凭据文件:{"apiKey":"...","authToken":"..."}
function readCredentialFile(p) {
if (!p) return {};
try {
if (!fs.existsSync(p)) return {};
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
return {
apiKey: typeof j.apiKey === 'string' ? j.apiKey.trim() : '',
authToken: typeof j.authToken === 'string' ? j.authToken.trim() : '',
file: p,
};
} catch (e) { return {}; }
}
/**
* 凭据优先级(高 → 低):
* 1) 调用方显式传入(config.json 的 ai.cli.apiKey / authToken)
* 2) 凭据文件(默认 ~/.workbuddy/mcn-workshop/ai-cli.json)
* 3) 环境变量 CODEBUDDY_API_KEY / CODEBUDDY_AUTH_TOKEN
* 返回 { apiKey, authToken, source }
*/
function resolveCredentials(o) {
o = o || {};
const f = readCredentialFile(o.credentialFile || defaultCredentialFile());
const apiKey = o.apiKey || f.apiKey || process.env.CODEBUDDY_API_KEY || '';
const authToken = o.authToken || f.authToken || process.env.CODEBUDDY_AUTH_TOKEN || '';
let source = '(未配置)';
if (o.authToken) source = 'config:authToken';
else if (o.apiKey) source = 'config:apiKey';
else if (f.authToken) source = 'file:authToken ' + f.file;
else if (f.apiKey) source = 'file:apiKey ' + f.file;
else if (process.env.CODEBUDDY_AUTH_TOKEN) source = 'env:CODEBUDDY_AUTH_TOKEN';
else if (process.env.CODEBUDDY_API_KEY) source = 'env:CODEBUDDY_API_KEY';
return { apiKey, authToken, source, credentialFile: f.file || defaultCredentialFile() };
}
// ---------------------------------------------------------------- 环境构造 // ---------------------------------------------------------------- 环境构造
// 把「宿主 WorkBuddy 注入的、会干扰子 CLI 的」环境变量剔除,并注入凭据 // 把「宿主 WorkBuddy 注入的、会干扰子 CLI 的」环境变量剔除,并注入凭据
@@ -86,6 +130,9 @@ function buildChildEnv(opts) {
for (const k of Object.keys(env)) { for (const k of Object.keys(env)) {
if (k.startsWith('CODEBUDDY_SESSION_') || k.startsWith('CODEBUDDY_CONVERSATION_') || if (k.startsWith('CODEBUDDY_SESSION_') || k.startsWith('CODEBUDDY_CONVERSATION_') ||
k === 'CODEBUDDY_TOOL_CALL_ID' || k === 'CODEBUDDY_SERVICE_PROXY_URL' || k === 'CODEBUDDY_TOOL_CALL_ID' || k === 'CODEBUDDY_SERVICE_PROXY_URL' ||
k === 'CODEBUDDY_MCP_CONFIG' || // 宿主 MCP(weixinpay/sheetagent…)我们不要
k === 'CODEBUDDY_GATEWAY_PASSWORD' || k === 'CODEBUDDY_GATEWAY_AUTH' ||
k === 'WORKBUDDY_PAC_RPC_TOKEN' || k === 'WORKBUDDY_PAC_RPC_SOCKET' ||
k === 'SANDBOX_CENTER_IPC_ADDRESS' || k === 'BAGGAGE' || k === 'BASH_ENV') { k === 'SANDBOX_CENTER_IPC_ADDRESS' || k === 'BAGGAGE' || k === 'BASH_ENV') {
delete env[k]; delete env[k];
} }
@@ -97,12 +144,17 @@ function buildChildEnv(opts) {
env.CODEBUDDY_SAFE_DELETE_ENABLED = '0'; // 工作台场景不需要工具删除保护 env.CODEBUDDY_SAFE_DELETE_ENABLED = '0'; // 工作台场景不需要工具删除保护
env.CODEBUDDY_GATEWAY_DISABLE_API_DOCS = '1'; env.CODEBUDDY_GATEWAY_DISABLE_API_DOCS = '1';
env.CODEBUDDY_DISABLE_TELEMETRY = '1'; env.CODEBUDDY_DISABLE_TELEMETRY = '1';
// 官方建议:纯 -p 单发不支持后台任务,开着会返回明确错误
env.CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS = '1';
// 【坑 3】凭据:独立进程必须自备 // 【坑 3】凭据:独立进程必须自备。
const key = opts.apiKey || process.env.CODEBUDDY_API_KEY || ''; // 注意:宿主也会用同名变量注入凭据且读后即删——必须先把继承来的残留清掉,再写我们自己的,
const token = opts.authToken || process.env.CODEBUDDY_AUTH_TOKEN || ''; // 否则可能带着一个「空壳/过期」的值进入子进程。
if (token) env.CODEBUDDY_AUTH_TOKEN = token; // 优先级最高 delete env.CODEBUDDY_API_KEY;
if (key) env.CODEBUDDY_API_KEY = key; delete env.CODEBUDDY_AUTH_TOKEN;
const cred = resolveCredentials(opts);
if (cred.authToken) env.CODEBUDDY_AUTH_TOKEN = cred.authToken; // 优先级最高
if (cred.apiKey) env.CODEBUDDY_API_KEY = cred.apiKey;
if (opts.internetEnvironment) env.CODEBUDDY_INTERNET_ENVIRONMENT = opts.internetEnvironment; if (opts.internetEnvironment) env.CODEBUDDY_INTERNET_ENVIRONMENT = opts.internetEnvironment;
if (opts.baseUrl) env.CODEBUDY_BASE_URL = opts.baseUrl; if (opts.baseUrl) env.CODEBUDY_BASE_URL = opts.baseUrl;
@@ -123,11 +175,17 @@ function buildChildEnv(opts) {
* @param {string} o.cwd 工作目录(决定 CLI 能看到哪些项目文件) * @param {string} o.cwd 工作目录(决定 CLI 能看到哪些项目文件)
* @param {string} [o.homeDir] CLI 的配置/数据目录(默认 <os.tmpdir>/mcn-cli-home) * @param {string} [o.homeDir] CLI 的配置/数据目录(默认 <os.tmpdir>/mcn-cli-home)
* @param {string} [o.model] 模型 ID * @param {string} [o.model] 模型 ID
* @param {string} [o.apiKey] CODEBUDDY_API_KEY * @param {string} [o.apiKey] CODEBUDDY_API_KEY(优先级最高,一般留空走凭据文件)
* @param {string} [o.authToken] CODEBUDDY_AUTH_TOKEN * @param {string} [o.authToken] CODEBUDDY_AUTH_TOKEN
* @param {string} [o.credentialFile] 凭据文件路径,默认 ~/.workbuddy/mcn-workshop/ai-cli.json
* @param {string} [o.internetEnvironment] internal / ioa / 留空 * @param {string} [o.internetEnvironment] internal / ioa / 留空
* @param {string} [o.cliPath] 显式指定 CLI 路径 * @param {string} [o.cliPath] 显式指定 CLI 路径
* @param {number} [o.timeoutMs] * @param {number} [o.timeoutMs]
* @param {boolean} [o.search] 是否允许联网搜索(默认 true)。true → 工具面收敛为 WebSearch+WebFetch
* @param {string} [o.tools] 显式工具白名单:'default' | 'none' | 'WebSearch,WebFetch'(留空=按 search 推导)
* @param {boolean} [o.strictMcp] 是否屏蔽宿主 MCP(默认 true)
* @param {string} [o.appendSystemPrompt] 追加系统提示(如搜索使用策略)
* @param {number} [o.maxTurns] 最大 agent 轮数(0=不限)
* @param {function} o.onDelta (text) => void 文本增量 * @param {function} o.onDelta (text) => void 文本增量
* @param {function} [o.onThink] (text) => void 思考增量(可选) * @param {function} [o.onThink] (text) => void 思考增量(可选)
* @param {function} [o.onEvent] (obj) => void 全部原始事件(可选,调试用) * @param {function} [o.onEvent] (obj) => void 全部原始事件(可选,调试用)
@@ -154,6 +212,33 @@ function runCli(o) {
...(o.permissionMode ? ['--permission-mode', o.permissionMode] : []), ...(o.permissionMode ? ['--permission-mode', o.permissionMode] : []),
]; ];
// ---- 工具面收敛(既提速,也是安全边界)----
// 工作台的 AI 只负责「产出文本」,prompt 里已自带全部上下文(账号设定/需求/历史),
// 不需要读写文件、跑命令。默认只给联网搜索两个工具:
// · 启动快(不必加载 Bash/Edit/Write/... 的工具 schema)
// · 模型物理上碰不到本机文件系统
// o.search === false → 放开默认工具面,但明确禁用联网搜索
let tools = o.tools;
if (tools === undefined || tools === null || tools === '') {
tools = (o.search === false) ? 'default' : 'WebSearch,WebFetch';
}
if (tools === 'none') args.push('--tools', '');
else if (tools && tools !== 'default') args.push('--tools', tools);
if (o.search === false) args.push('--disallowedTools', 'WebSearch,WebFetch');
// ---- 屏蔽宿主 MCP ----
// 宿主会把 CODEBUDDY_MCP_CONFIG 注入进程树,里面挂着 weixinpay / sheetagent 等。
// 工作台场景一个都用不上,加载它们纯属浪费启动时间,还可能被模型误调。
if (o.strictMcp !== false) {
args.push('--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}');
}
// 推理开销:把首字延迟从几十秒压到十几秒的关键开关(minimal|low|medium|high|xhigh|max)
if (o.effort) args.push('--effort', o.effort);
if (o.appendSystemPrompt) args.push('--append-system-prompt', o.appendSystemPrompt);
if (parseInt(o.maxTurns, 10) > 0) args.push('--max-turns', String(parseInt(o.maxTurns, 10)));
const env = buildChildEnv({ const env = buildChildEnv({
homeDir, homeDir,
apiKey: o.apiKey, apiKey: o.apiKey,
@@ -183,6 +268,7 @@ function runCli(o) {
} }
let full = ''; let full = '';
let streamed = ''; // 已经通过 onDelta 推给前端的内容(用于终态比对)
let stderr = ''; let stderr = '';
let sessionId = ''; let sessionId = '';
let usage = null; let usage = null;
@@ -190,6 +276,11 @@ function runCli(o) {
let errText = ''; let errText = '';
let sawDelta = false; let sawDelta = false;
let buf = ''; let buf = '';
// 每次工具调用的发生位置(= 当时的 full 长度)。
// 根因:模型在发起 WebSearch 前常先吐一句过程话术(如
// "I'll search for ... before responding."),它不是答案。
// 只保留「最后一次工具调用之后」的文本作为正文。
const toolMarks = [];
const handleLine = (line) => { const handleLine = (line) => {
line = line.trim(); line = line.trim();
@@ -206,6 +297,12 @@ function runCli(o) {
// 文本增量:stream_event → content_block_delta → text_delta // 文本增量:stream_event → content_block_delta → text_delta
if (ev.type === 'stream_event' && ev.event) { if (ev.type === 'stream_event' && ev.event) {
const e2 = ev.event; const e2 = ev.event;
// 工具调用开始的边界(用于切掉调用前的过程话术)
if (e2.type === 'content_block_start' && e2.content_block &&
(e2.content_block.type === 'tool_use' || e2.content_block.name)) {
toolMarks.push(full.length);
return;
}
if (e2.type === 'content_block_delta' && e2.delta) { if (e2.type === 'content_block_delta' && e2.delta) {
if (e2.delta.type === 'text_delta' && e2.delta.text) { if (e2.delta.type === 'text_delta' && e2.delta.text) {
const prev = full; const prev = full;
@@ -219,6 +316,7 @@ function runCli(o) {
// 已误判过一次的话,本段的累积都作废 // 已误判过一次的话,本段的累积都作废
if (isCliBoilerplate(prev)) { full = ''; errText = errText || prev; return; } if (isCliBoilerplate(prev)) { full = ''; errText = errText || prev; return; }
sawDelta = true; sawDelta = true;
streamed += e2.delta.text;
if (o.onDelta) { try { o.onDelta(e2.delta.text); } catch (e) {} } if (o.onDelta) { try { o.onDelta(e2.delta.text); } catch (e) {} }
} else if (e2.delta.type === 'thinking_delta' && e2.delta.thinking && o.onThink) { } else if (e2.delta.type === 'thinking_delta' && e2.delta.thinking && o.onThink) {
try { o.onThink(e2.delta.thinking); } catch (e) {} try { o.onThink(e2.delta.thinking); } catch (e) {}
@@ -229,6 +327,10 @@ function runCli(o) {
// 兜底:未开启/未产生增量流时,整段文本在 assistant 消息里 // 兜底:未开启/未产生增量流时,整段文本在 assistant 消息里
if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) { if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {
// 工具调用边界(未走增量流时,tool_use 只会出现在 assistant 消息里)
for (const c of ev.message.content) {
if (c && c.type === 'tool_use') toolMarks.push(full.length);
}
if (!sawDelta) { if (!sawDelta) {
const t = ev.message.content const t = ev.message.content
.filter((c) => c && c.type === 'text' && typeof c.text === 'string') .filter((c) => c && c.type === 'text' && typeof c.text === 'string')
@@ -236,6 +338,7 @@ function runCli(o) {
if (t && !full) { if (t && !full) {
if (isCliBoilerplate(t)) { errText = errText || t; return; } if (isCliBoilerplate(t)) { errText = errText || t; return; }
full = t; full = t;
streamed += t;
if (o.onDelta) { try { o.onDelta(t); } catch (e) {} } if (o.onDelta) { try { o.onDelta(t); } catch (e) {} }
} }
} }
@@ -283,18 +386,22 @@ function runCli(o) {
child.on('close', (code) => { child.on('close', (code) => {
clearTimeout(timer); clearTimeout(timer);
if (buf.trim()) handleLine(buf); if (buf.trim()) handleLine(buf);
const text = full || resultText; // 切掉「最后一次工具调用之前」的过程话术:那部分不是答案。
if (aborted) return resolve({ ok: false, text, error: 'CLI 执行超时或被取消', sessionId, usage }); // (例:模型先输出 "I'll search for ... before responding." 再发起 WebSearch)
const cut = toolMarks.length ? toolMarks[toolMarks.length - 1] : 0;
const answer = (cut > 0 && cut < full.length) ? full.slice(cut).trim() : full;
const text = answer || resultText;
if (aborted) return resolve({ ok: false, text, streamed, error: 'CLI 执行超时或被取消', sessionId, usage });
// 事件里明确报了错(如未认证)→ 优先按错误返回 // 事件里明确报了错(如未认证)→ 优先按错误返回
if (errText) { if (errText) {
const raw = errText; const raw = errText;
return resolve({ ok: false, text, error: messageForCliError(raw) || raw, sessionId, usage, rawStderr: raw }); return resolve({ ok: false, text, streamed, error: messageForCliError(raw) || raw, sessionId, usage, rawStderr: raw });
} }
if (code !== 0 && !text) { if (code !== 0 && !text) {
const msg = (stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | '); const msg = (stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
return resolve({ ok: false, text, error: messageForCliError(msg) || ('CLI 退出码 ' + code), sessionId, usage, rawStderr: msg }); return resolve({ ok: false, text, streamed, error: messageForCliError(msg) || ('CLI 退出码 ' + code), sessionId, usage, rawStderr: msg });
} }
resolve({ ok: true, text, sessionId, usage }); resolve({ ok: true, text, streamed, sessionId, usage });
}); });
}); });
} }
@@ -324,4 +431,4 @@ function messageForCliError(msg) {
return msg; return msg;
} }
module.exports = { runCli, resolveCli, buildChildEnv }; module.exports = { runCli, resolveCli, buildChildEnv, resolveCredentials, defaultCredentialFile };
@@ -4,17 +4,25 @@
"outputRoot": "MCNSkill项目", "outputRoot": "MCNSkill项目",
"businessDataDir": ["MCNSkill项目", "三方数据", "红狐数据", "抖音榜单"], "businessDataDir": ["MCNSkill项目", "三方数据", "红狐数据", "抖音榜单"],
"extraRoots": [], "extraRoots": [],
"sessionCwd": "D:\\AI技能\\mcn-workshop",
"ai": { "ai": {
"backend": "dify", "backend": "cli",
"cli": { "cli": {
"path": "", "path": "",
"model": "custom:doubao-seed-2-1-pro-260628", "model": "deepseek-v4.1-flash",
"effort": "low",
"apiKey": "", "apiKey": "",
"authToken": "", "authToken": "",
"credentialFile": "",
"internetEnvironment": "internal", "internetEnvironment": "internal",
"permissionMode": "", "permissionMode": "",
"timeoutMs": 300000 "timeoutMs": 300000,
"search": true,
"tools": "",
"strictMcp": true,
"appendSystemPrompt": "",
"maxTurns": 6
} }
} }
} }
@@ -6,7 +6,7 @@
## 触发链路(所有入口共用) ## 触发链路(所有入口共用)
按钮点击 → 确认弹窗(confirmRun / confirmAiScript / confirmSaveAccount)→ `executeTask()` → `POST /api/run`(server.js 写 automations 表,scheduledAt=now+5s + next_run_at=now+5000,`skills` 数组→`skills_json` 参数级技能挂载)→ 客户端调度器拾取 → 左侧会话栏「mcn-work-shop」空间后台会话执行(cwd = 工作台目录,由 `server.js` 从 `ROOT` 动态推导)→ 轮询 `/api/run/status`(done/error/review)→ 完成刷新页面。 按钮点击 → 确认弹窗(confirmRun / confirmAiScript / confirmSaveAccount)→ `executeTask()` → `POST /api/run`(server.js 写 automations 表,scheduledAt=now+5s + next_run_at=now+5000,`skills` 数组→`skills_json` 参数级技能挂载)→ 客户端调度器拾取 → 左侧会话栏「mcn-workshop」空间后台会话执行(cwd = 工作台目录,由 `server.js` 从 `ROOT` 动态推导)→ 轮询 `/api/run/status`(done/error/review)→ 完成刷新页面。
## 功能入口 × 技能调用契约总表 ## 功能入口 × 技能调用契约总表
@@ -115,7 +115,7 @@
| 候选 | `==OPTS==` | 每行一个候选,格式「骨架词——具体展开」(≤45 字、无编号);结合本条创意/人设落地,禁干巴术语 | 解析为气泡下内嵌按钮行,点选=以整行文本发一轮 | | 候选 | `==OPTS==` | 每行一个候选,格式「骨架词——具体展开」(≤45 字、无编号);结合本条创意/人设落地,禁干巴术语 | 解析为气泡下内嵌按钮行,点选=以整行文本发一轮 |
| 需求 | `==REQ==` | 8 行清单:内容形式/一句话故事/开场钩子/故事爆点/故事爽点/故事框架/人物关系/其他要求(空项留空) | 解析回填需求框字段 | | 需求 | `==REQ==` | 8 行清单:内容形式/一句话故事/开场钩子/故事爆点/故事爽点/故事框架/人物关系/其他要求(空项留空) | 解析回填需求框字段 |
**System Prompt 已抽为文件**:`mcn-work-shop/prompts/clarify.md`(==PROMPT== 与 ==CHANGELOG== 之间为正文,头尾说明不发模型)。**迭代 prompt 只改该文件**,server.js 启动按 mtime 缓存读取、占位符注入(`{{accountName}}/{{personaText}}/{{contentText}}/{{attachText}}/{{reqMdText}}/{{histText}}/{{turnText}}`),不再改 server.js 字符串。 **System Prompt 已抽为文件**:`mcn-workshop/prompts/clarify.md`(==PROMPT== 与 ==CHANGELOG== 之间为正文,头尾说明不发模型)。**迭代 prompt 只改该文件**,server.js 启动按 mtime 缓存读取、占位符注入(`{{accountName}}/{{personaText}}/{{contentText}}/{{attachText}}/{{reqMdText}}/{{histText}}/{{turnText}}`),不再改 server.js 字符串。
**关键规则(clarify.md 内固化)**: **关键规则(clarify.md 内固化)**:
- 候选骨架受控词表:内容形式(剧情/Vlog/口播/**视觉短片**)、故事框架(钩子前置/悬念反转/递进铺垫/先抑后扬)、开场钩子(悬念提问/反差画面/直击痛点/身份共鸣/利益承诺)、剧情人物关系(一次一层);每项必须结合本条创意给「具体形态」 - 候选骨架受控词表:内容形式(剧情/Vlog/口播/**视觉短片**)、故事框架(钩子前置/悬念反转/递进铺垫/先抑后扬)、开场钩子(悬念提问/反差画面/直击痛点/身份共鸣/利益承诺)、剧情人物关系(一次一层);每项必须结合本条创意给「具体形态」
@@ -0,0 +1,283 @@
# AI 链路:本地 CodeBuddy CLI 接入(A 方案)
> 2026-09-29 落地并实测通过。工作台的 AI 能力(需求打磨 / 创作)除 Dify 外,新增**本地 CodeBuddy CLI** 通路。
> 实现:`cli-backend.js`(后端模块)+ `server.js` 的 `/api/ai/clarify`、`/api/ai/status`。
---
## 1. 一句话
把「工作台 → 模型」从 **HTTP 调 Dify** 换成 **spawn 一个 `codebuddy` 子进程、用 stdio 通信**。
**不涉及任何本地端口**,与 WorkBuddy 桌面程序完全解耦。
---
## 2. 为什么必须自备凭据(别再挖宿主那条路)
排查中钉死了三条硬结论:
| 结论 | 证据 |
|:--|:--|
| 宿主 gateway 端口/密码**每轮轮换** | 同机同天实测端口 `12134 → 13116 → 10261`;密码同步换 |
| 唯一发现通道只存在于 WorkBuddy 派生进程树内 | `CODEBUDDY_SERVICE_PROXY_URL`;用户双击 `start.bat` 起的工作台拿不到 |
| WorkBuddy **故意**不让子进程继承凭据 | 4 个环境变量交付 → CLI 读完**立即 `delete`**;凭据不落明文盘 |
→ 走官方文档给的非交互认证通道:`CODEBUDDY_API_KEY` / `CODEBUDDY_AUTH_TOKEN`。
---
## 3. 配置步骤
### 步骤 1:拿 API Key
| 版本 | 地址 |
|:--|:--|
| **中国版**(本机是这版,`internetEnvironment=internal`) | https://copilot.tencent.com/profile/ |
| 国际版 | https://www.codebuddy.ai/profile/keys |
| iOA | https://tencent.sso.copilot.tencent.com/profile/keys |
### 步骤 2:把 Key 放进凭据文件(⛔ 不要写进 config.json)
**`config.json` 是 git 跟踪文件,写进去会提交到仓库。**
默认凭据文件(仓库之外,不会被技能同步脚本复制):
```
%USERPROFILE%\.workbuddy\mcn-workshop\ai-cli.json
```
内容:
```json
{ "apiKey": "ck_xxxxxxxx._xxxxxxxx" }
```
也可用 `authToken` 字段(优先级更高)。或直接设环境变量 `CODEBUDDY_API_KEY`。
### 步骤 3:自检
```bash
curl "http://localhost:8900/api/ai/status" # 只报告配置,零消耗
curl "http://localhost:8900/api/ai/status?probe=1" # 真跑一次极短提示
```
期望 `"ready": true` 且 `probe.ok = true`。
---
## 4. 字段说明(`config.json` → `ai`)
| 字段 | 默认 | 说明 |
|:--|:--|:--|
| `backend` | `cli` | `"cli"` 走本地 CLI;`"dify"` 走原链路。环境变量 `MCN_AI_BACKEND` 可临时覆盖 |
| `cli.path` | `''` | 留空自动探测:`CODEBUDDY_CLI_PATH` → WorkBuddy 自带 CLI → `PATH` |
| `cli.model` | `deepseek-v4.1-flash` | **只影响 `/api/ai/clarify`**(需求打磨)。环境变量 `MCN_AI_MODEL` 可临时覆盖。⚠️ 创作任务的模型另见 §11 |
| `cli.effort` | `low` | 推理档位 `minimal\|low\|medium\|high\|xhigh\|max`。**首字延迟第一大杠杆** |
| `cli.search` | `true` | 是否允许联网搜索(见 §7) |
| `cli.tools` | `''` | 工具白名单。留空=按 `search` 推导;`none`=全禁;也可写 `WebSearch,WebFetch` |
| `cli.strictMcp` | `true` | 屏蔽宿主 MCP(weixinpay / sheetagent 等) |
| `cli.maxTurns` | `6` | agent 轮数硬上限,防搜索失控跑飞 |
| `cli.appendSystemPrompt` | `''` | 追加系统提示;留空时自动注入内置搜索策略(`CLI_SEARCH_HINT`) |
| `cli.credentialFile` | `''` | 凭据文件路径;留空走默认位置 |
| `cli.apiKey` / `cli.authToken` | `''` | 优先级最高,**但别填**(会被 git 记录) |
| `cli.timeoutMs` | `300000` | 单次调用超时 |
---
## 5. 三条必须保留的防护(改代码前务必读)
`cli-backend.js` 的 `buildChildEnv()` 做了三件事,每条都是实测踩出来的坑:
### 5.1 删掉 `SERVER__PORT` / `SERVER__HOST`
`codebuddy`(**连 `-p` 打印模式也**)内部会起一个 HTTP server:
```js
listen(parseInt(process.env.SERVER__PORT) || config.get("cell.server", { port: 3000 }).port)
```
在 WorkBuddy 进程树内启动 → 继承宿主的 `SERVER__PORT` → `EADDRINUSE` → **进程静默卡死**。
迷惑性极强:HTTP 层(health / info / sessions)全正常,只有 agent 执行环节挂,
极易被误判成「prewarm 占端口」。
官方自己也这么处理:`delete el.SERVER__PORT, delete el.SERVER__HOST`。
### 5.2 强制 `CODEBUDDY_FORCE_HEADLESS_BUNDLE=1`
`bin/codebuddy` launcher 按环境变量选 bundle:
| 环境变量 | 命中产物 |
|:--|:--|
| `CODEBUDDY_FORCE_LITE_WB_BUNDLE=1`(宿主注入,**优先级最高**) | `codebuddy-lite-wb.mjs`(WorkBuddy 特供精简包) |
| `CODEBUDDY_FORCE_HEADLESS_BUNDLE=1` | `codebuddy-headless.js`(**我们要的**) |
| 含 `--print` | 自动 headless |
| 都没有 | `dist/codebuddy` → **本安装不存在**,报 `MODULE_NOT_FOUND` |
### 5.3 清掉宿主注入的会话上下文
`NODE_OPTIONS`(node-language-shim)、`CODEBUDDY_SESSION_*`、`CODEBUDDY_MCP_CONFIG`、
`CODEBUDDY_GATEWAY_PASSWORD`、`BAGGAGE` 等一并删除,并把 `CODEBUDDY_CONFIG_DIR`
指向独立目录(默认 `<tmpdir>/mcn-cli-home`),避免污染 WorkBuddy 自身配置。
---
## 6. 性能:三个真正的杠杆(实测数据)
| 杠杆 | 做法 | 效果 |
|:--|:--|:--|
| **① 工具面收敛** | `--tools "WebSearch,WebFetch"` | prompt 从 **21,776 → 4,859 tokens(-78%)**,耗时 **47.6s → 15.0s** |
| **② 推理档位** | `--effort low` | 首字延迟 **61.7s → 19.7s** |
| **③ 换模型** | 见 §9 | `deepseek-v4-pro` 46.2s vs `Claude-Opus-4.8` 18.5s(同一提示词) |
> ⚠️ `[INIT tools]` 事件里列的是**工具注册表全量**,不是实际启用集。
> 判断 `--tools` 是否生效要看 `usage.input_tokens`,别被事件列表误导。
**额外收益**:工具面收敛后模型**物理上碰不到文件系统**(`Bash`/`Read`/`Write`/`Edit` 全不在白名单),
这是免费拿到的一道安全边界。
---
## 7. 联网搜索
CLI **原生带** `WebSearch` / `WebFetch`,开箱可用,无需额外配置。实测:
```
[TOOL_USE] WebSearch {"query":"今天热点新闻","freshness":"d1"}
```
但搜索会把耗时推到 1–2 分钟,所以**不能简单地常开或常关**,而是给模型一条明确的判据。
`server.js` 的 `CLI_SEARCH_HINT` 会作为 `--append-system-prompt` 注入:
- **该搜**:回答依赖「今天/近期」的外部事实(当下热点、平台规则与趋势、行业数据、事实核查)
- **不该搜**:创作讨论、需求澄清、脚本撰写、文案打磨 → 一律不搜
- **搜索预算**:最多 1 次 WebSearch(+ 必要 1 次 WebFetch),硬上限 2 次;搜到即答,禁止反复搜
- 失败/超时直接凭已有信息作答,不重试
想完全关闭:`"search": false`(会自动改用 `--disallowedTools WebSearch,WebFetch`)。
---
## 8. 输出协议
工作台对前端只暴露一套 SSE 协议,`cli` 与 `dify` 两条后端产出完全一致,**前端无需感知差异**:
| 事件 | 含义 |
|:--|:--|
| `{"delta": "..."}` | 文本增量,直接追加渲染 |
| `{"replace": "..."}` | 整体覆盖(终态校准) |
| `{"error": "..."}` | 错误(附可操作中文提示) |
| `data: [DONE]` | 流结束 |
CLI 侧事件解析(`--output-format stream-json --include-partial-messages`):
| CLI 事件 | 处理 |
|:--|:--|
| `type:"system", subtype:"init"` | 取 `session_id` |
| `stream_event` → `content_block_delta` → `delta.type==="text_delta"` | 取 `delta.text` 作为增量 |
| `stream_event` → `content_block_start`(tool_use) | 记录**工具调用边界**,见下 |
| `type:"assistant"` | 未产生增量流时的整段兜底 |
| `type:"result"` | 终态。`is_error:true` 时真实原因在 **`errors[]`**,正文作废 |
### 两个必须处理的文本污染
1. **报错被当成回答**:未认证时 CLI 把报错文本走文本通道发出来 →
`isCliBoilerplate()` 识别拦截,避免把英文报错显示成「AI 的回答」。
2. **工具调用前的过程话术**:模型发起搜索前常先吐一句
`I'll search for ... before responding.`,它不是答案。
→ `runCli` 记录每次 `tool_use` 的位置 `toolMarks`,**只保留最后一次工具调用之后的文本**;
由于这段已被流式推给前端,`server.js` 在成功分支比对 `r.text !== acc` 时补发 `{replace}` 整体覆盖。
---
## 9. 实测可用模型清单(本机 API Key 租户)
`--model` 的 `--help` 列表是**二进制里的静态清单,不等于已开通**。
下表是逐个真跑出来的结果:
| 模型 ID | 真实名称 | 实测 |
|:--|:--|:--|
| `deepseek-v4.1-flash` | DeepSeek-V4.1-Flash | ✅ **当前默认**(`clarify`) |
| `custom:custom-model-b5-standard` | GPT-5.5-Standard | ✅ 次选 |
| `custom:custom-model-b5-priority` | GPT-5.5-Priority | ✅ 同族「优先档」(资源紧张时优先路由,通常积分倍率更高) |
| `custom:custom-model-a4` | Claude-Opus-4.8 | ✅ |
| `custom:custom-model-b1-standard` / `-priority` | GPT-5.4-Standard / -Priority | ✅ |
| `custom:custom-model-b3-priority` | GPT-5.2-Priority | ✅ |
| `custom:custom-model-b4-standard` / `-priority` | GPT-5.1-Standard / -Priority | ✅ |
| `custom:custom-model-a1` | Claude-Opus-4.7 | ✅ |
| `custom:custom-model-a2` | Claude-Sonnet-4.6 | ✅ |
| `custom:custom-model-a3` | Claude-Opus-4.6 | ✅ |
| `custom:custom-model-c2-flash` | Gemini-3.5-Flash-1 | ✅ |
| `auto` / `hy3` / `glm-5.3` / `deepseek-v4-pro` | Auto / 混元3 / GLM-5.3 / DeepSeek-V4-Pro | ✅(deepseek 慢 3 倍) |
| `custom:doubao-seed-2-1-pro-260628` | — | ❌ **500**,未开通 |
| `custom:doubao-seed-2-0-lite-260428` | — | ❌ **400 `service info not found`** |
> **doubao 系在本机不可用**:既不在桌面产品配置的 48 个模型里,也不在该 API Key 租户的服务清单里。
> CLI 的静态清单里出现它,只是「名单上有、没开通」。
### 速度对比 A:短提示词(约 130 字输出,含 ~10s CLI 启动开销)
| 模型 | 耗时 |
|:--|:--|
| GPT-5.5-Standard | 15.7s |
| Claude-Opus-4.8 | 18.5s |
| Kimi-K2.5 | 21.7s |
| GLM-5.3 / GLM-5.3-Flash | 22.5s / 23.6s |
| **DeepSeek-V4-Pro** | **46.2s** 🐢 |
### 速度对比 B:端到端走 `/api/ai/clarify` 真实需求打磨(含账号设定 + 完整模板)
| 模型 | 普通创作问答 | 问热点(触发搜索) | `==OPTS==/==REQ==` 合规 |
|:--|:--|:--|:--|
| **GPT-5.5-Standard**(当前) | **42.2s** | 137.2s | ✅ |
| Claude-Opus-4.8 | 105.5s | 139.6s | ✅ |
| DeepSeek-V4-Pro | 119.5s | 151.2s | ✅ |
> 结论:**普通问答差距最大(2.5 倍)**;一旦触发联网搜索,各模型都要 130–150s,
> 瓶颈转移到搜索本身 → 这就是 `CLI_SEARCH_HINT` 要把搜索预算压到 ≤2 次的原因。
---
## 10. 排障速查
| 现象 | 原因 | 处理 |
|:--|:--|:--|
| 进程卡死无输出 | `SERVER__PORT` 未清除 → EADDRINUSE | 确认 §5.1 的 delete 生效 |
| `Cannot find module '../dist/codebuddy'` | bundle 分流未命中 | 设 `CODEBUDDY_FORCE_HEADLESS_BUNDLE=1` |
| `Authentication required. Please use /login` | 无凭据 | 按 §3 配 key |
| `ready:false` 但 key 已填 | `internetEnvironment` 版本不对 | 中国版必须是 `internal` |
| `500 internal server error` / `400 Custom model [...] service info not found` | 该模型未在当前 Key 租户开通 | 换 §9 表内 ✅ 的模型 |
| 首字要等 60s+ | 推理档位过高 | `"effort": "low"` |
| 回答里混进英文过程话术 | 工具调用前的叙述 | §8 的 `toolMarks` 切分(已内置) |
| HTTP 层全正常、agent 却不干活 | 典型 EADDRINUSE 假象 | 看 §5.1 |
---
## 11. 完整流程=两段,模型来源不同(⚠️ 容易搞混)
工作台「AI 创作」实际只有两个后端调用:
| 环节 | 接口 | 谁在跑 | 模型从哪来 |
|:--|:--|:--|:--|
| **需求打磨**(弹窗内对话) | `POST /api/ai/clarify` | **本方案**:本地 CLI 子进程 | `config.json → ai.cli.model` |
| **提交创作任务**(生成脚本 S1-S11) | `POST /api/run` | **WorkBuddy 宿主**:写 automation,由桌面端调度器执行 | **`sessions` 表里最近活跃的「手动会话」的 `model`**,兜底 `auto` |
> 🔴 **两者互不相干。** 改 `ai.cli.model` **不会**影响创作任务用的模型。
> 创作任务的模型是「跟随用户在 WorkBuddy UI 里最近选过的模型」——即浮动的,
> 从工作台侧看**不确定**。查证方法:
> ```sql
> SELECT model_id, COUNT(*) FROM automations GROUP BY model_id ORDER BY 2 DESC;
> ```
> 本机实测历史分布:`hy4-preview` 30 条 / `deepseek-v4.1-flash` 6 条。
> 想让创作任务固定用某个模型,需要在 UI 里把当前会话模型切到它(或改造 `/api/run` 支持显式 `model_id`)。
### 完整链路自检结果(2026-09-29,model = deepseek-v4.1-flash)
| 环节 | 结果 |
|:--|:--|
| `GET /api/ai/status` | backend=`cli` · model=`deepseek-v4.1-flash` · ready=**true** |
| `GET /api/ai/status?probe=1` | ok=**true** · 17.0s · 返回「成功」 |
| `POST /api/ai/clarify`(stream) | 371 字 · `==OPTS==/==REQ==` 合规 **✅** · 无英文残留 **✅** · 51.7s |
| `POST /api/run` → 轮询状态 | queued(5s) → running(30s) → **done**(50s) · `model_id` 落库 = `deepseek-v4.1-flash` **✅** |
> 注:`/api/ai/topics` 是**遗留死代码** —— 前端从不调用它(选题列表走 `/api/dsh/topics`)。
> 它仍写死 Dify,未配 `DIFY_MCN_CYLG_KEY` 时会 500,但**不影响真实流程**。
@@ -1,6 +1,6 @@
# 短视频工作台 · 视觉与交互规范(最终态) # 短视频工作台 · 视觉与交互规范(最终态)
> **定位**:本文件是工作台前端(`mcn-work-shop/`)视觉与交互的**唯一权威**——设计 token、布局框架、**逐页最终态**、组件规格、交互链路、已知坑。 > **定位**:本文件是工作台前端(`mcn-workshop/`)视觉与交互的**唯一权威**——设计 token、布局框架、**逐页最终态**、组件规格、交互链路、已知坑。
> **用法**:新增页面 / 改样式 / 改交互前先读本文件;改完**回写本文件**(含文末变更记录),避免同类坑重复踩。 > **用法**:新增页面 / 改样式 / 改交互前先读本文件;改完**回写本文件**(含文末变更记录),避免同类坑重复踩。
> **技术基线**:零依赖 Node 服务 + 原生 JS(无框架无构建);`public/` 下 `index.html` / `style.css` / `app.js` / `data-pages.js` / `help-guide.js`。 > **技术基线**:零依赖 Node 服务 + 原生 JS(无框架无构建);`public/` 下 `index.html` / `style.css` / `app.js` / `data-pages.js` / `help-guide.js`。
> **最后更新**:2026-09-16(本轮把此前的 `工作台UI规范.md` 合并升级为本文件并重命名为「视觉交互规范」,内容为**迭代后的最终态**) > **最后更新**:2026-09-16(本轮把此前的 `工作台UI规范.md` 合并升级为本文件并重命名为「视觉交互规范」,内容为**迭代后的最终态**)
@@ -11,6 +11,10 @@ const DEFAULTS = {
outputRoot: 'MCNSkill项目', outputRoot: 'MCNSkill项目',
businessDataDir: ['MCNSkill项目', '三方数据', '红狐数据', '抖音榜单'], businessDataDir: ['MCNSkill项目', '三方数据', '红狐数据', '抖音榜单'],
extraRoots: [], extraRoots: [],
// 10-08:工作台提交的 AI 任务会话归属哪个工作区 —— 侧边栏分组按会话 cwd 聚合,
// cwd 不是已打开过的工作区就会落到「未分组任务」。
// 留空 → 用工作台自身目录(ROOT);填绝对路径 → 会话归到那个工作区(改分组只改这里,不动代码)。
sessionCwd: '',
// 09-29 AI 通路配置:backend 决定走哪条链路 // 09-29 AI 通路配置:backend 决定走哪条链路
// "cli" 本地 CodeBuddy CLI 进程(stdio,无端口;需自备 CODEBUDDY_API_KEY) // "cli" 本地 CodeBuddy CLI 进程(stdio,无端口;需自备 CODEBUDDY_API_KEY)
// "dify" Dify chat-messages(原链路,保留兜底) // "dify" Dify chat-messages(原链路,保留兜底)
@@ -19,11 +23,23 @@ const DEFAULTS = {
cli: { cli: {
path: '', // 留空 → 自动探测(环境变量 CODEBUDDY_CLI_PATH > WorkBuddy 自带 CLI > PATH) path: '', // 留空 → 自动探测(环境变量 CODEBUDDY_CLI_PATH > WorkBuddy 自带 CLI > PATH)
model: 'custom:doubao-seed-2-1-pro-260628', model: 'custom:doubao-seed-2-1-pro-260628',
apiKey: '', // 留空 → 读环境变量 CODEBUDDY_API_KEY apiKey: '', // 一般留空:config.json 是 git 跟踪文件,key 别写这里
authToken: '', // 留空 → 读环境变量 CODEBUDDY_AUTH_TOKEN(优先级最高) authToken: '', // 同上
// 凭据文件(JSON:{"apiKey":"..."} 或 {"authToken":"..."})。
// 留空 → 默认 ~/.workbuddy/mcn-workshop/ai-cli.json(刻意放仓库外,不会被技能同步复制)
credentialFile: '',
internetEnvironment: 'internal', // 中国版=internal,iOA=ioa,国际版留空 internetEnvironment: 'internal', // 中国版=internal,iOA=ioa,国际版留空
permissionMode: '', permissionMode: '',
timeoutMs: 300000, timeoutMs: 300000,
// 推理开销档位:minimal|low|medium|high|xhigh|max(留空=模型默认)。
// 这是首字延迟的第一大杠杆:reasoning 模型默认档位会让首字等到 60s+。
effort: 'low',
// 联网搜索:true → 工具面收敛为 WebSearch+WebFetch(启动快,且模型碰不到文件系统)
search: true,
tools: '', // 显式覆盖工具白名单:'' = 按 search 推导;'none' = 全禁
strictMcp: true, // 屏蔽宿主 MCP(weixinpay/sheetagent 等)
appendSystemPrompt: '', // 追加系统提示
maxTurns: 6, // agent 轮数硬上限(防搜索/工具调用失控跑飞)
}, },
}, },
}; };
@@ -70,7 +70,8 @@ async function executeTask(opts) {
}); });
if (d.error) throw new Error(d.error); if (d.error) throw new Error(d.error);
taskId = d.id; taskId = d.id;
showToast('✅ 任务已提交,请在左侧会话栏查看执行'); showToast('✅ 任务已提交,执行进度见右下角面板');
TaskProgress.show(opts.label || '工作台任务');
if (opts.onSubmit) { try { opts.onSubmit(taskId); } catch (e) {} } if (opts.onSubmit) { try { opts.onSubmit(taskId); } catch (e) {} }
} catch (e) { } catch (e) {
showToast('❌ 任务提交失败: ' + e.message); showToast('❌ 任务提交失败: ' + e.message);
@@ -82,6 +83,7 @@ async function executeTask(opts) {
while (Date.now() < deadline) { while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5000)); await new Promise((r) => setTimeout(r, 5000));
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; } try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
if (s) TaskProgress.update(s, opts.label || '工作台任务');
if (!s || s.state === 'notfound') { finished = true; finish(s || null); break; } if (!s || s.state === 'notfound') { finished = true; finish(s || null); break; }
if (s.state === 'done') { finished = true; finish(s); showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; } if (s.state === 'done') { finished = true; finish(s); showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; }
if (s.state === 'error') { finished = true; finish(s); showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; } if (s.state === 'error') { finished = true; finish(s); showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; }
@@ -90,7 +92,9 @@ async function executeTask(opts) {
} }
restore(); restore();
if (!ended) finish(null); if (!ended) finish(null);
if (!finished) showToast('⏳ 任务仍在执行,请到左侧会话栏查看进度'); if (!finished) showToast('⏳ 任务仍在执行,右下角面板可继续观察进度');
// 终态保留 8 秒展示结果摘要(状态/工具调用/最近动作)后自动收起;未完成则保留面板供继续观察
if (finished) setTimeout(() => TaskProgress.hide(), 8000);
} }
// 通用确认弹窗:确认后执行 AI 任务(opts: title/desc/target/url/prompt/label/btn/afterText) // 通用确认弹窗:确认后执行 AI 任务(opts: title/desc/target/url/prompt/label/btn/afterText)
@@ -263,6 +267,58 @@ function showToast(msg) {
_toastTimer = setTimeout(() => t.classList.remove('show'), 1800); _toastTimer = setTimeout(() => t.classList.remove('show'), 1800);
} }
/* ================= 任务进度面板(10-08 新增) =================
后台任务原本只弹一句「已提交」,用户无法判断到底在跑还是卡住、做了什么。
本面板把 /api/run/status 的进度(状态/耗时/工具调用/最近动作/是否停滞)持续显示,
不必再去左侧会话栏翻找。用法:TaskProgress.show(标题) → 轮询中 TaskProgress.update(s) → TaskProgress.hide() */
const TaskProgress = (function () {
let el = null;
const fmtDur = (ms) => {
if (ms == null) return '—';
const s = Math.max(0, Math.round(Number(ms) / 1000));
if (s < 60) return s + ' 秒';
return Math.floor(s / 60) + ' 分 ' + (s % 60) + ' 秒';
};
const STATE_TXT = { queued: '排队中', pending: '等待调度', running: '执行中', done: '已完成', error: '执行失败', review: '待确认', notfound: '任务已清理' };
function ensure() {
if (el && document.body.contains(el)) return el;
el = document.createElement('div');
el.className = 'tp-panel';
document.body.appendChild(el);
return el;
}
function render(title, s) {
const box = ensure();
const st = (s && s.state) || 'queued';
const dotCls = st === 'running' ? 'run' : (st === 'done' ? 'done' : (st === 'error' ? 'err' : ''));
const tc = (s && s.toolCalls) ? s.toolCalls : {};
const tools = Object.keys(tc).map((k) => '<span>' + esc(k) + ' ×' + tc[k] + '</span>').join('');
const stalled = st === 'running' && !!(s && s.stalled);
const ago = (s && s.updatedAgoMs != null) ? fmtDur(s.updatedAgoMs) : null;
box.innerHTML =
'<div class="tp-head"><i class="tp-dot ' + dotCls + '"></i>'
+ '<div class="tp-title">' + esc(title || (s && s.sessionTitle) || 'AI 任务') + '</div>'
+ '<button class="tp-close" title="关闭">×</button></div>'
+ '<div class="tp-body">'
+ '<div class="tp-row"><span class="k">状态</span><span class="v">' + esc(STATE_TXT[st] || st)
+ (s && s.elapsedMs != null ? '(已运行 ' + fmtDur(s.elapsedMs) + ')' : '') + '</span></div>'
+ (tools ? '<div class="tp-row"><span class="k">工具</span><span class="v"><div class="tp-tools">' + tools + '</div></span></div>' : '')
+ (s && s.lastAction ? '<div class="tp-row"><span class="k">最近动作</span><span class="v"></span></div><div class="tp-act">' + esc(s.lastAction) + '</div>' : '')
+ (ago && st === 'running' ? '<div class="tp-row" style="margin-top:6px"><span class="k">活跃</span><span class="v">'
+ (stalled ? '已 ' : '') + esc(ago) + '前有输出</span></div>' : '')
+ (stalled ? '<div class="tp-warn">⚠️ 超过 90 秒没有新输出,可能卡住或等待中</div>' : '')
+ (s && s.error ? '<div class="tp-warn">错误:' + esc(String(s.error)) + '</div>' : '')
+ '</div>';
const btn = box.querySelector('.tp-close');
if (btn) btn.onclick = hide;
}
function show(title, s) { render(title, s || null); }
function update(s, title) { render(title, s); }
function hide() { if (el && el.parentNode) el.parentNode.removeChild(el); el = null; }
return { show, update, hide };
})();
window.TaskProgress = TaskProgress;
/* ================= 迷你 Markdown 渲染器 ================= */ /* ================= 迷你 Markdown 渲染器 ================= */
function inlineMd(s) { function inlineMd(s) {
let out = esc(s); let out = esc(s);
@@ -1056,19 +1056,32 @@ async function updateRankingData(view, btn) {
body: JSON.stringify({ prompt: rankPrompt, force: !!inCooldown }), body: JSON.stringify({ prompt: rankPrompt, force: !!inCooldown }),
}); });
if (!r || r.ok === false) throw new Error((r && r.error) || '任务提交失败'); if (!r || r.ok === false) throw new Error((r && r.error) || '任务提交失败');
showToast('✅ 榜单更新任务已提交,请到左侧会话栏查看执行'); showToast('✅ 榜单更新任务已提交,执行进度见右下角面板');
TaskProgress.show('更新榜单数据');
// 4) 轮询任务状态(最长 15 分钟;期间按钮保持 busy) // 4) 轮询任务状态(最长 15 分钟;期间按钮保持 busy)
// 10-08 修复:工作台服务被回收时 api() 会持续抛错,原逻辑静默 continue 直到 15 分钟超时、用户零感知
// ⇒ 连续 3 次查询失败即判定服务已断开,提示并结束轮询(任务仍在后台跑,只是工作台看不到进度)
const deadline = Date.now() + 15 * 60 * 1000; const deadline = Date.now() + 15 * 60 * 1000;
let state = 'running'; let state = 'running';
let pollFail = 0;
while (Date.now() < deadline) { while (Date.now() < deadline) {
await new Promise((res) => setTimeout(res, 5000)); await new Promise((res) => setTimeout(res, 5000));
let s = null; let s = null;
try { s = await api('/api/run/status?id=' + encodeURIComponent(r.id)); } catch (e) { continue; } try { s = await api('/api/run/status?id=' + encodeURIComponent(r.id)); pollFail = 0; if (s) TaskProgress.update(s, '更新榜单数据'); }
catch (e) {
if (++pollFail >= 3) {
restore();
showToast('⚠️ 工作台服务已断开(连续 3 次查询失败);任务仍在后台执行,重启工作台后请到左侧会话栏查看进度');
return;
}
continue;
}
if (!s || s.state === 'notfound') { state = 'done'; break; } if (!s || s.state === 'notfound') { state = 'done'; break; }
if (s.state === 'done') { state = 'done'; break; } if (s.state === 'done') { state = 'done'; break; }
if (s.state === 'error' || s.state === 'review') { state = s.state; break; } if (s.state === 'error' || s.state === 'review') { state = s.state; break; }
} }
restore(); restore();
if (state !== 'running') setTimeout(() => TaskProgress.hide(), 8000);
if (state === 'done') { if (state === 'done') {
showToast('✅ 榜单数据已更新,正在刷新…'); showToast('✅ 榜单数据已更新,正在刷新…');
// 5) 整页重建:丢弃 _reload(同闭包 render 不清 meta 缓存)→ router 重新进入渲染 → 拉到最新数据期 // 5) 整页重建:丢弃 _reload(同闭包 render 不清 meta 缓存)→ router 重新进入渲染 → 拉到最新数据期
@@ -1216,12 +1229,15 @@ function updateFollowedAccounts(btn) {
}); });
if (r.error) throw new Error(r.error); if (r.error) throw new Error(r.error);
const taskId = r.id; const taskId = r.id;
const taskTitle = '更新账号·' + name;
TaskProgress.show(taskTitle);
const deadline = Date.now() + 20 * 60 * 1000; const deadline = Date.now() + 20 * 60 * 1000;
let state = 'running'; let state = 'running';
while (Date.now() < deadline) { while (Date.now() < deadline) {
await new Promise((res) => setTimeout(res, 5000)); await new Promise((res) => setTimeout(res, 5000));
let s = null; let s = null;
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; } try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
if (s) TaskProgress.update(s, taskTitle);
if (!s || s.state === 'notfound') { state = 'done'; break; } if (!s || s.state === 'notfound') { state = 'done'; break; }
if (s.state === 'done') { state = 'done'; break; } if (s.state === 'done') { state = 'done'; break; }
if (s.state === 'error' || s.state === 'review') { state = s.state; break; } if (s.state === 'error' || s.state === 'review') { state = s.state; break; }
@@ -1231,6 +1247,7 @@ function updateFollowedAccounts(btn) {
} catch (e) { failed.push(name + '(' + e.message + ')'); } } catch (e) { failed.push(name + '(' + e.message + ')'); }
} }
restore(); restore();
TaskProgress.hide();
if (btn) btn.blur(); if (btn) btn.blur();
showToast(done.length ? `✅ 已更新 ${done.length} 个关注账号${failed.length ? `;${failed.length} 个未完成:${failed.join('、')}` : ''}` : '❌ 更新未完成:' + failed.join('、')); showToast(done.length ? `✅ 已更新 ${done.length} 个关注账号${failed.length ? `;${failed.length} 个未完成:${failed.join('、')}` : ''}` : '❌ 更新未完成:' + failed.join('、'));
refreshCurrentPage(); refreshCurrentPage();
@@ -1950,12 +1967,15 @@ function readVideoTaskRec(videoId) {
/* 刷新后恢复轮询:跟随该视频解析任务直到终态 → 清理记录 → 提示并刷新本页按钮态 */ /* 刷新后恢复轮询:跟随该视频解析任务直到终态 → 清理记录 → 提示并刷新本页按钮态 */
async function watchVideoParseTask(videoId, taskId, reload) { async function watchVideoParseTask(videoId, taskId, reload) {
let s = null; let s = null;
TaskProgress.show('视频解析');
const deadline = Date.now() + 35 * 60 * 1000; const deadline = Date.now() + 35 * 60 * 1000;
while (Date.now() < deadline) { while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5000)); await new Promise((r) => setTimeout(r, 5000));
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; } try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
if (s) TaskProgress.update(s, '视频解析');
if (!s || s.state === 'notfound' || s.state === 'done' || s.state === 'error' || s.state === 'review') break; if (!s || s.state === 'notfound' || s.state === 'done' || s.state === 'error' || s.state === 'review') break;
} }
setTimeout(() => TaskProgress.hide(), 8000);
try { localStorage.removeItem(VIDEO_TASK_KEY(videoId)); } catch (e) {} try { localStorage.removeItem(VIDEO_TASK_KEY(videoId)); } catch (e) {}
if (s && s.state === 'done') showToast('✅ 视频解析完成,已自动刷新本页'); if (s && s.state === 'done') showToast('✅ 视频解析完成,已自动刷新本页');
else if (s && s.state === 'error') showToast('❌ 视频解析失败' + (s.error ? ':' + s.error : '') + '(任务已结束,可重新点击解析)'); else if (s && s.state === 'error') showToast('❌ 视频解析失败' + (s.error ? ':' + s.error : '') + '(任务已结束,可重新点击解析)');
@@ -880,3 +880,28 @@ td.td-title { max-width: 240px; }
.g-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } .g-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.g-actions .btn-sm { padding: 4px 12px; font-size: 14px; } .g-actions .btn-sm { padding: 4px 12px; font-size: 14px; }
.g-actions .ghost:disabled { opacity: .5; cursor: default; } .g-actions .ghost:disabled { opacity: .5; cursor: default; }
/* ===== 任务进度面板(10-08 新增:后台 AI 任务执行过程可见) ===== */
.tp-panel {
position: fixed; right: 18px; bottom: 18px; z-index: 9999;
width: 380px; max-width: calc(100vw - 36px);
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 8px 28px rgba(0,0,0,.16); overflow: hidden;
font-size: 13.5px; color: var(--text);
}
.tp-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--primary-soft); }
.tp-title { font-weight: 700; font-size: 14px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tp-close { border: none; background: transparent; color: var(--dim); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 4px; }
.tp-body { padding: 10px 12px; }
.tp-row { display: flex; gap: 8px; margin-bottom: 6px; }
.tp-row .k { color: var(--dim); flex: 0 0 62px; }
.tp-row .v { flex: 1; word-break: break-all; }
.tp-tools { display: flex; flex-wrap: wrap; gap: 6px; }
.tp-tools span { background: #f0f2f5; border-radius: 20px; padding: 2px 9px; font-size: 12px; font-variant-numeric: tabular-nums; }
.tp-act { max-height: 92px; overflow: auto; background: #fafbfc; border: 1px solid var(--border); border-radius: 8px; padding: 8px 9px; color: var(--dim); font-size: 12.5px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
.tp-warn { margin-top: 8px; padding: 7px 9px; border-radius: 8px; background: #fff3e6; color: #b3541e; font-size: 12.5px; font-weight: 600; }
.tp-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--primary); flex: 0 0 8px; }
.tp-dot.run { animation: tp-pulse 1.1s ease-in-out infinite; }
.tp-dot.done { background: #2f9e44; }
.tp-dot.err { background: var(--danger); }
@keyframes tp-pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
@@ -13,7 +13,7 @@ const dsh = require('./dsh-data');
const { parseAttachment } = require('./scripts/attachment-parser'); const { parseAttachment } = require('./scripts/attachment-parser');
const { loadConfig } = require('./load-config'); const { loadConfig } = require('./load-config');
// 09-29 新增:本地 CodeBuddy CLI 后端(stdio 直连,替代 Dify;见 cli-backend.js 头部说明) // 09-29 新增:本地 CodeBuddy CLI 后端(stdio 直连,替代 Dify;见 cli-backend.js 头部说明)
const { runCli, resolveCli } = require('./cli-backend'); const { runCli, resolveCli, resolveCredentials } = require('./cli-backend');
// 09-01 默认端口由 8899 改为 8900:本机 8899 被小米 PC 管家 MiPCAudio.exe 系统服务占用(0.0.0.0 监听且自动复活), // 09-01 默认端口由 8899 改为 8900:本机 8899 被小米 PC 管家 MiPCAudio.exe 系统服务占用(0.0.0.0 监听且自动复活),
// 此时 127.0.0.1:8899 绑定不生效 → 默认直接起在 8900;仍可 `node server.js <端口>` 显式换端口 // 此时 127.0.0.1:8899 绑定不生效 → 默认直接起在 8900;仍可 `node server.js <端口>` 显式换端口
@@ -34,10 +34,46 @@ function aiBackend() {
return String((CFG.ai && CFG.ai.backend) || 'dify').toLowerCase(); return String((CFG.ai && CFG.ai.backend) || 'dify').toLowerCase();
} }
// 09-29 CLI 后端的联网搜索策略(追加到系统提示)。
// 根因:CLI 自带 WebSearch/WebFetch,但默认不会主动用;而每次都搜又会把首字延迟从十几秒拖到几十秒。
// 所以给模型一条明确的「什么时候才搜」的判据,而不是简单开关。
const CLI_SEARCH_HINT =
'你具备联网搜索能力(WebSearch / WebFetch),但搜索很慢,要严格按判据使用。'
+ '【该搜】回答依赖「今天/近期」的外部事实时:当下热点、平台规则与趋势、行业数据、事实核查。'
+ '【不该搜】创作讨论、需求澄清、脚本撰写、文案打磨——这些靠你的知识直接答,一律不搜。'
+ '【搜索预算】中文关键词;最多 1 次 WebSearch;仅当搜索结果指向必须打开的页面时才追加 1 次 WebFetch;'
+ '工具调用总数硬上限 2 次。搜到即答,禁止反复搜或换词重搜。'
+ '搜索失败或超时就直接凭已有信息作答,不要重试。';
// 由 config.json 的 ai.cli 段构造一次 CLI 调用参数(clarify / 自检 / 后续其它入口共用,避免多处硬编码)
function cliBaseOpts(over) {
const c = (CFG.ai && CFG.ai.cli) || {};
const searchOn = c.search !== false;
return Object.assign({
cwd: ROOT,
// MCN_AI_MODEL 可临时覆盖模型(用于不重启、不改配置的 A/B 对比与排障)
model: String(process.env.MCN_AI_MODEL || '').trim() || c.model,
apiKey: c.apiKey,
authToken: c.authToken,
credentialFile: c.credentialFile,
internetEnvironment: c.internetEnvironment,
permissionMode: c.permissionMode,
cliPath: c.path,
timeoutMs: Math.max(30000, parseInt(c.timeoutMs, 10) || 300000),
effort: c.effort || '',
search: searchOn,
tools: c.tools,
strictMcp: c.strictMcp !== false,
maxTurns: c.maxTurns,
appendSystemPrompt: c.appendSystemPrompt || (searchOn ? CLI_SEARCH_HINT : ''),
}, over || {});
}
// 09-10 路径治本(配套「仓库特征判定」):工作台运行时路径一律从 ROOT 动态推导,禁止写死盘符。 // 09-10 路径治本(配套「仓库特征判定」):工作台运行时路径一律从 ROOT 动态推导,禁止写死盘符。
// 根因:原写死盘符指向仓库外的固定目录,仓库迁移后该目录不存在 → 浏览器锁失效、会话 cwd 悬空。 // 根因:原写死盘符指向仓库外的固定目录,仓库迁移后该目录不存在 → 浏览器锁失效、会话 cwd 悬空。
const BROWSER_LOCK_FILE = path.join(ROOT, '.browser-lock'); // 浏览器互斥锁文件(与工作台同目录) const BROWSER_LOCK_FILE = path.join(ROOT, '.browser-lock'); // 浏览器互斥锁文件(与工作台同目录)
const SESSION_CWD = ROOT; // 工作台触发的 AI 会话 cwd(分组名 = mcn-work-shop) // 10-08:默认工作台自身目录;config.json 的 sessionCwd 可指定别的工作区(分组随之改变)
const SESSION_CWD = String(CFG.sessionCwd || '').trim() || ROOT;
// 09-07:prompt 模板文件化(prompts/*.md)——规则迭代只改文件不碰代码;文件缺失/损坏回退 null,调用方自行兜底。 // 09-07:prompt 模板文件化(prompts/*.md)——规则迭代只改文件不碰代码;文件缺失/损坏回退 null,调用方自行兜底。
// 模板正文取 ==PROMPT== 与 ==CHANGELOG== 之间(头部说明/文末变更记录不发给模型) // 模板正文取 ==PROMPT== 与 ==CHANGELOG== 之间(头部说明/文末变更记录不发给模型)
@@ -359,7 +395,7 @@ function createOnceAutomation(name, prompt, skillsArr, connectorIdsArr) {
try { try {
const now = Date.now(); const now = Date.now();
const id = 'automation-' + now; const id = 'automation-' + now;
const cwd = SESSION_CWD; // 工作台触发的会话归入 mcn-work-shop 空间分组(由 ROOT 动态推导) const cwd = SESSION_CWD; // 工作台触发的会话归入 mcn-workshop 空间分组(由 ROOT 动态推导)
const d = new Date(now + 5 * 1000); const d = new Date(now + 5 * 1000);
const pad = (n) => String(n).padStart(2, '0'); const pad = (n) => String(n).padStart(2, '0');
const scheduledAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; const scheduledAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
@@ -401,6 +437,55 @@ function rankLastTaskState() {
} catch (e) { return null; } } catch (e) { return null; }
} }
// ---------- 任务进度可见(10-08 新增:用户「我需要看到 才知道执行的是否正常」)----------
// 后台任务只返回 running/done 太粗:用户无法判断"在真跑"还是"卡住了"。
// 解法:automation → 关联它拉起的后台会话 → 读该会话 jsonl 尾部 → 产出
// 已运行时长 / 工具调用统计 / 最近一段动作 / 会话最后写入距今(判断是否停滞)
// 会话 cwd → WorkBuddy projects 目录名(实测:`D:\a\b\V1.0\mcn-workshop` → `d-a-b-V1.0-mcn-workshop`)
function escapeCwdForProjects(cwd) {
const s = String(cwd || '').replace(/\\/g, '/');
const m = s.match(/^([A-Za-z]):\/(.+)$/);
if (!m) return null;
return m[1].toLowerCase() + '-' + m[2].replace(/\//g, '-');
}
const TOOL_NAME_RE = /"name":"(Bash|Read|Write|Edit|Grep|Glob|WebSearch|WebFetch|WebFetch2|Skill|Task|Agent|TodoWrite|ExitPlanMode|mcp__[A-Za-z0-9_]+)"/g;
function readSessionProgress(sessionId, cwd) {
const out = { lastAction: '', toolCalls: {}, updatedAgoMs: null, stalled: false, logBytes: 0 };
try {
const esc = escapeCwdForProjects(cwd);
if (!esc) return out;
const f = path.join(os.homedir(), '.workbuddy', 'projects', esc, sessionId + '.jsonl');
if (!fs.existsSync(f)) return out;
const st = fs.statSync(f);
out.logBytes = st.size;
out.updatedAgoMs = Date.now() - Number(st.mtimeMs);
// 90 秒没再写入即视为停滞(模型正常推进时几乎每秒都在写日志)
out.stalled = out.updatedAgoMs > 90000;
if (st.size <= 0) return out;
const readLen = Math.min(st.size, 256 * 1024);
const fd = fs.openSync(f, 'r');
const buf = Buffer.alloc(readLen);
fs.readSync(fd, buf, 0, readLen, st.size - readLen);
fs.closeSync(fd);
const txt = buf.toString('utf8');
let m;
TOOL_NAME_RE.lastIndex = 0;
while ((m = TOOL_NAME_RE.exec(txt))) out.toolCalls[m[1]] = (out.toolCalls[m[1]] || 0) + 1;
const texts = [];
const re2 = /"text":"((?:[^"\\]|\\.){4,})"/g;
while ((m = re2.exec(txt))) {
let s = m[1];
try { s = JSON.parse('"' + s + '"'); } catch (e) { /* 解析失败保留原串 */ }
if (s && String(s).trim()) texts.push(String(s).trim());
}
if (texts.length) out.lastAction = texts[texts.length - 1].replace(/\s+/g, ' ').slice(0, 160);
} catch (e) { /* 读不到不影响状态判定 */ }
return out;
}
// ---- 静态文件 / API ---- // ---- 静态文件 / API ----
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://localhost'); const u = new URL(req.url, 'http://localhost');
@@ -777,7 +862,7 @@ const server = http.createServer((req, res) => {
const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db')); const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db'));
const now = Date.now(); const now = Date.now();
const id = 'automation-' + now; const id = 'automation-' + now;
const cwd = SESSION_CWD; // 工作台触发的会话归入 mcn-work-shop 空间分组(由 ROOT 动态推导) const cwd = SESSION_CWD; // 工作台触发的会话归入 mcn-workshop 空间分组(由 ROOT 动态推导)
const d = new Date(Date.now() + 5 * 1000); // 未来 5 秒(08-31 根因:客户端只对未来 scheduledAt 补算 next_run_at;写 now=过去时间→不补算→调度器扫不到→卡死) const d = new Date(Date.now() + 5 * 1000); // 未来 5 秒(08-31 根因:客户端只对未来 scheduledAt 补算 next_run_at;写 now=过去时间→不补算→调度器扫不到→卡死)
const pad = (n) => String(n).padStart(2, '0'); const pad = (n) => String(n).padStart(2, '0');
const scheduledAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; const scheduledAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
@@ -809,10 +894,17 @@ const server = http.createServer((req, res) => {
try { try {
const { DatabaseSync } = require('node:sqlite'); const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db'), { readOnly: true }); const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db'), { readOnly: true });
const auto = db.prepare('SELECT status, deleted_at, last_run_at FROM automations WHERE id=?').get(id); const auto = db.prepare('SELECT status, deleted_at, last_run_at, created_at FROM automations WHERE id=?').get(id);
if (!auto || auto.deleted_at) { db.close(); return sendJSON(200, { ok: true, id, state: 'notfound' }); } if (!auto || auto.deleted_at) { db.close(); return sendJSON(200, { ok: true, id, state: 'notfound' }); }
const st = db.prepare('SELECT running, last_error FROM automation_runtime_state WHERE automation_id=?').get(id); const st = db.prepare('SELECT running, last_error FROM automation_runtime_state WHERE automation_id=?').get(id);
const runs = db.prepare("SELECT status FROM automation_runs WHERE automation_id=? ORDER BY created_at DESC LIMIT 1").get(id); const runs = db.prepare("SELECT status FROM automation_runs WHERE automation_id=? ORDER BY created_at DESC LIMIT 1").get(id);
// 10-08:关联本任务拉起的后台会话(任务创建后 5 分钟内第一条自动化会话),读其日志产出进度
const t0 = Number(auto.created_at) || 0;
const sess = t0 ? db.prepare(
'SELECT id, title, cwd FROM sessions WHERE is_background_automation=1 AND created_at >= ? AND created_at <= ? ORDER BY created_at ASC LIMIT 1'
).get(t0 - 15000, t0 + 300000) : null;
const prog = sess ? readSessionProgress(sess.id, sess.cwd) : null;
const elapsedMs = t0 ? Date.now() - t0 : null;
db.close(); db.close();
let state = 'queued'; let state = 'queued';
if (st && Number(st.running) === 1) state = 'running'; if (st && Number(st.running) === 1) state = 'running';
@@ -824,7 +916,17 @@ const server = http.createServer((req, res) => {
else if (['ERROR', 'CANCELLED', 'FAILED', 'INTERRUPTED'].includes(s)) state = 'error'; else if (['ERROR', 'CANCELLED', 'FAILED', 'INTERRUPTED'].includes(s)) state = 'error';
else state = 'pending'; // ACCEPTED / QUEUED / PENDING 等 = 排队等待执行 else state = 'pending'; // ACCEPTED / QUEUED / PENDING 等 = 排队等待执行
} }
return sendJSON(200, { ok: true, id, state, error: (st && st.last_error) || null }); return sendJSON(200, {
ok: true, id, state, error: (st && st.last_error) || null,
elapsedMs,
sessionId: (sess && sess.id) || null,
sessionTitle: (sess && sess.title) || null,
lastAction: (prog && prog.lastAction) || '',
toolCalls: (prog && prog.toolCalls) || {},
updatedAgoMs: (prog && prog.updatedAgoMs) != null ? prog.updatedAgoMs : null,
stalled: !!(prog && prog.stalled),
logBytes: (prog && prog.logBytes) || 0,
});
} catch (e) { } catch (e) {
return sendErr(500, '状态查询失败: ' + e.message); return sendErr(500, '状态查询失败: ' + e.message);
} }
@@ -888,7 +990,7 @@ const server = http.createServer((req, res) => {
resp = await fetch(difyUrl + '/chat-messages', { resp = await fetch(difyUrl + '/chat-messages', {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'blocking', user: 'mcn-work-shop' }), body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'blocking', user: 'mcn-workshop' }),
signal: ctrl.signal, signal: ctrl.signal,
}); });
} finally { clearTimeout(timer); } } finally { clearTimeout(timer); }
@@ -915,10 +1017,7 @@ const server = http.createServer((req, res) => {
if (!t) return ''; if (!t) return '';
return t.length <= 8 ? '***' : t.slice(0, 6) + '***' + t.slice(-4); return t.length <= 8 ? '***' : t.slice(0, 6) + '***' + t.slice(-4);
}; };
const envKey = process.env.CODEBUDDY_API_KEY || ''; const cred = resolveCredentials({ apiKey: c.apiKey, authToken: c.authToken, credentialFile: c.credentialFile });
const envTok = process.env.CODEBUDDY_AUTH_TOKEN || '';
const effKey = c.apiKey || envKey;
const effTok = c.authToken || envTok;
const info = { const info = {
backend, backend,
cli: { cli: {
@@ -926,10 +1025,11 @@ const server = http.createServer((req, res) => {
pathConfigured: !!c.path, pathConfigured: !!c.path,
model: c.model || '', model: c.model || '',
internetEnvironment: c.internetEnvironment || '', internetEnvironment: c.internetEnvironment || '',
credential: effTok ? 'CODEBUDDY_AUTH_TOKEN(' + mask(effTok) + ')' credential: cred.source + (cred.apiKey || cred.authToken
: effKey ? 'CODEBUDDY_API_KEY(' + mask(effKey) + ')' ? ' (' + mask(cred.authToken || cred.apiKey) + ')' : ''),
: '(未配置)', credentialFile: cred.credentialFile,
ready: !!(effTok || effKey), credentialFileExists: (() => { try { return fs.existsSync(cred.credentialFile); } catch (e) { return false; } })(),
ready: !!(cred.authToken || cred.apiKey),
}, },
dify: { dify: {
url: String(process.env.DIFY_URL || 'https://mydify.youmanvideo.com/v1'), url: String(process.env.DIFY_URL || 'https://mydify.youmanvideo.com/v1'),
@@ -946,12 +1046,7 @@ const server = http.createServer((req, res) => {
if (url.searchParams.get('probe') === '1' && backend === 'cli') { if (url.searchParams.get('probe') === '1' && backend === 'cli') {
const t0 = Date.now(); const t0 = Date.now();
try { try {
const r = await runCli({ const r = await runCli(cliBaseOpts({ prompt: '只回复两个字:成功', timeoutMs: 120000 }));
prompt: '只回复两个字:成功', cwd: ROOT, model: c.model,
apiKey: c.apiKey, authToken: c.authToken,
internetEnvironment: c.internetEnvironment, cliPath: c.path,
timeoutMs: 120000,
});
info.probe = { info.probe = {
ok: r.ok, ms: Date.now() - t0, ok: r.ok, ms: Date.now() - t0,
text: (r.text || '').slice(0, 100), text: (r.text || '').slice(0, 100),
@@ -1030,18 +1125,7 @@ const server = http.createServer((req, res) => {
// dify = 原链路(下方保留,未改动) // dify = 原链路(下方保留,未改动)
// 两条链路对外都产出同一套 SSE 协议:{delta} / {replace} / {error},前端无需感知差异。 // 两条链路对外都产出同一套 SSE 协议:{delta} / {replace} / {error},前端无需感知差异。
if (aiBackend() === 'cli') { if (aiBackend() === 'cli') {
const c = (CFG.ai && CFG.ai.cli) || {}; const base = cliBaseOpts({ prompt: query });
const base = {
prompt: query,
cwd: ROOT,
model: c.model,
apiKey: c.apiKey,
authToken: c.authToken,
internetEnvironment: c.internetEnvironment,
permissionMode: c.permissionMode,
cliPath: c.path,
timeoutMs: Math.max(30000, parseInt(c.timeoutMs, 10) || 300000),
};
if (stream) { if (stream) {
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8', 'Content-Type': 'text/event-stream; charset=utf-8',
@@ -1063,6 +1147,10 @@ const server = http.createServer((req, res) => {
// 已产出的内容优先保留(模型中途失败 / 超时场景) // 已产出的内容优先保留(模型中途失败 / 超时场景)
if (acc && r.text && r.text !== acc) write({ replace: r.text }); if (acc && r.text && r.text !== acc) write({ replace: r.text });
write({ error: r.error || 'CLI 执行失败' }); write({ error: r.error || 'CLI 执行失败' });
} else if (r.text && r.text !== acc) {
// 成功但「流过的内容」≠「终态正文」:典型是模型在联网搜索前先吐了一句过程话术,
// 后端已把工具调用前的部分切掉 → 这里用权威正文整体覆盖(前端原生支持 replace)
write({ replace: r.text });
} }
log('AI 需求打磨(cli,stream): ' + accountName + ' | ' + acc.length + '字' log('AI 需求打磨(cli,stream): ' + accountName + ' | ' + acc.length + '字'
+ (r.sessionId ? ' | session ' + r.sessionId : '') + (r.ok ? '' : ' | 失败: ' + r.error)); + (r.sessionId ? ' | session ' + r.sessionId : '') + (r.ok ? '' : ' | 失败: ' + r.error));
@@ -1092,7 +1180,7 @@ const server = http.createServer((req, res) => {
const difyResp = await fetch(difyUrl + '/chat-messages', { const difyResp = await fetch(difyUrl + '/chat-messages', {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'streaming', user: 'mcn-work-shop' }), body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'streaming', user: 'mcn-workshop' }),
signal: ctrl.signal, signal: ctrl.signal,
}); });
if (!difyResp.ok || !difyResp.body) { if (!difyResp.ok || !difyResp.body) {
@@ -1171,7 +1259,7 @@ const server = http.createServer((req, res) => {
difyResp = await fetch(difyUrl + '/chat-messages', { difyResp = await fetch(difyUrl + '/chat-messages', {
method: 'POST', method: 'POST',
headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'blocking', user: 'mcn-work-shop' }), body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'blocking', user: 'mcn-workshop' }),
signal: ctrl.signal, signal: ctrl.signal,
}); });
} finally { clearTimeout(timer); } } finally { clearTimeout(timer); }
@@ -1238,14 +1326,14 @@ const server = http.createServer((req, res) => {
}); });
}); });
// ---- 会话自动清理:mcn-work-shop 空间超 6 小时无活动的已结束会话自动清除 ---- // ---- 会话自动清理:mcn-workshop 空间超 6 小时无活动的已结束会话自动清除 ----
// 09-01 用户需求:工作台触发(cwd = 工作台目录 SESSION_CWD)的 AI 会话任务,超过 6 小时自动清除 // 09-01 用户需求:工作台触发(cwd = 工作台目录 SESSION_CWD)的 AI 会话任务,超过 6 小时自动清除
// 09-10 路径治本:cwd 由写死盘符改为 ROOT 动态推导(<技能根>/mcn-work-shop); // 09-10 路径治本:cwd 由写死盘符改为 ROOT 动态推导(<技能根>/mcn-workshop);
// 清理模式放宽为 '%mcn-work%',同时覆盖历史会话(旧 mcn-workshop)与新建会话(mcn-work-shop) // 清理模式放宽为 '%mcn-work%',同时覆盖历史会话(早期 D:\AgentSkill\mcn-workshop、迁移期 …\mcn-work-shop)与新建会话(…\V1.0\mcn-workshop)
// 判定:cwd 含 mcn-work + status=completed(执行中一律不删)+ 未删过 + 最后活动时间超过 6 小时 // 判定:cwd 含 mcn-work + status=completed(执行中一律不删)+ 未删过 + 最后活动时间超过 6 小时
// 方式:软删除(置 deleted_at),客户端左侧会话栏按 deleted_at 过滤即不再显示;可逆安全 // 方式:软删除(置 deleted_at),客户端左侧会话栏按 deleted_at 过滤即不再显示;可逆安全
const SESSION_TTL_MS = 6 * 3600 * 1000; // 6 小时 const SESSION_TTL_MS = 6 * 3600 * 1000; // 6 小时
const SESSION_CWD_PATTERN = '%mcn-work%'; // 兼容 SESSION_CWD(mcn-work-shop)与历史 cwd(mcn-workshop) const SESSION_CWD_PATTERN = '%mcn-work%'; // 覆盖当前 SESSION_CWD(…\V1.0\mcn-workshop)与历史 cwd(早期 D:\AgentSkill\mcn-workshop、迁移期 …\mcn-work-shop)
const SESSION_CLEAN_INTERVAL_MS = 60 * 60 * 1000; // 每小时检查一次 const SESSION_CLEAN_INTERVAL_MS = 60 * 60 * 1000; // 每小时检查一次
function cleanupExpiredSessions() { function cleanupExpiredSessions() {
@@ -1262,7 +1350,7 @@ function cleanupExpiredSessions() {
AND COALESCE(last_activity_at, updated_at, created_at, 0) < ? AND COALESCE(last_activity_at, updated_at, created_at, 0) < ?
`).run(now, SESSION_CWD_PATTERN, cutoff); `).run(now, SESSION_CWD_PATTERN, cutoff);
db.close(); db.close();
if (Number(r.changes) > 0) log(`会话自动清理:已清除 mcn-work-shop 超 6 小时无活动的已完成会话 ${r.changes} 条`); if (Number(r.changes) > 0) log(`会话自动清理:已清除 mcn-workshop 超 6 小时无活动的已完成会话 ${r.changes} 条`);
} catch (e) { } catch (e) {
console.error('会话自动清理失败:', e.message); console.error('会话自动清理失败:', e.message);
} }
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more