工作台:目录改名 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

+8 -8
View File
@@ -64,21 +64,21 @@ agent_created: true
用户说「打开工作台 / 打开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`)
3. 工作台采用 hash 路由,页面结构:
- **首页(#/)**:数据功能卡片(抖音周榜/热门账号/AI写脚本/AI写作复盘)+ 5 大技能功能块(问法点击复制)+ 三种创作模式
- **📈 抖音周榜(#/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 任务执行
- **视频详情(#/video/:id)**:信息卡 + 视频脚本/视频拆解/视频分析 3 tab
- **✍️ AI写脚本(#/rewrites)**:爆款视频列表 + 源脚本 vs AI脚本双栏对比 + 分镜提示词分块渲染
- **🧪 AI写作复盘(#/reviews)**:评分维度列表 + 复盘详情(评分条/问题清单/报告)
- **📁 产出浏览(#/files)**:默认读桌面 `MCNSkill项目/`,支持 ⚙️ 设置添加其他项目地址;md/json 在线预览
4. **数据边界**:数据库读工作台自有副本 `mcn-work-shop/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 为空而卡死)**
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-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:['短视频工作台']` 提交新任务**——新任务自动加载当前技能版本(含最新优化),无需手工搬内容。执行方式判定细则见「关键规则 〇·执行方式判定」。
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 副本**
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,随源仓库分发)
- **选型规则**:先读 `catalog.md` 按数据形状锁定图型编号,再打开 `templates/` 对应 gallery 的真实实现复制骨架;默认顺序 Lupi Editorial → Lupi Basics → Glance(工作台场景数据量小/需快读时可直用 Glance)
- **视觉 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 模板的视觉语法);不得拼接多个模板造混合图型
---
@@ -338,7 +338,7 @@ S1 意图识别 → S2 需求完善 → [G0 分析确认]
> 判定口诀:**"这个产物要不要进工作台(rewrite_log/入库/左侧会话栏)?"**——要进,就走任务;不进,才可会话内产出。
> 重新执行历史任务 = 复用原任务 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)
@@ -397,7 +397,7 @@ Skill部署到非原始开发路径时,所有用户新增的创作流程/接
├── SKILL.md ← 入口文件(流程引擎+规则摘要+子技能索引+目录索引)
├── 帮助文档.md ← 使用指南+触发示例+快速启动模板
├── 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,相对路径引用见「子技能索引」)
├── references/ ← 标准库
│ ├── 创作流程规范.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;
}
// ---------------------------------------------------------------- 凭据解析
// 凭据默认落点:**刻意放在仓库之外**。
// 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 的」环境变量剔除,并注入凭据
@@ -86,6 +130,9 @@ function buildChildEnv(opts) {
for (const k of Object.keys(env)) {
if (k.startsWith('CODEBUDDY_SESSION_') || k.startsWith('CODEBUDDY_CONVERSATION_') ||
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') {
delete env[k];
}
@@ -97,12 +144,17 @@ function buildChildEnv(opts) {
env.CODEBUDDY_SAFE_DELETE_ENABLED = '0'; // 工作台场景不需要工具删除保护
env.CODEBUDDY_GATEWAY_DISABLE_API_DOCS = '1';
env.CODEBUDDY_DISABLE_TELEMETRY = '1';
// 官方建议:纯 -p 单发不支持后台任务,开着会返回明确错误
env.CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS = '1';
// 【坑 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; // 优先级最高
if (key) env.CODEBUDDY_API_KEY = key;
// 【坑 3】凭据:独立进程必须自备。
// 注意:宿主也会用同名变量注入凭据且读后即删——必须先把继承来的残留清掉,再写我们自己的,
// 否则可能带着一个「空壳/过期」的值进入子进程。
delete env.CODEBUDDY_API_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.baseUrl) env.CODEBUDY_BASE_URL = opts.baseUrl;
@@ -123,11 +175,17 @@ function buildChildEnv(opts) {
* @param {string} o.cwd 工作目录(决定 CLI 能看到哪些项目文件)
* @param {string} [o.homeDir] CLI 的配置/数据目录(默认 <os.tmpdir>/mcn-cli-home)
* @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.credentialFile] 凭据文件路径,默认 ~/.workbuddy/mcn-workshop/ai-cli.json
* @param {string} [o.internetEnvironment] internal / ioa / 留空
* @param {string} [o.cliPath] 显式指定 CLI 路径
* @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.onThink] (text) => void 思考增量(可选)
* @param {function} [o.onEvent] (obj) => void 全部原始事件(可选,调试用)
@@ -154,6 +212,33 @@ function runCli(o) {
...(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({
homeDir,
apiKey: o.apiKey,
@@ -183,6 +268,7 @@ function runCli(o) {
}
let full = '';
let streamed = ''; // 已经通过 onDelta 推给前端的内容(用于终态比对)
let stderr = '';
let sessionId = '';
let usage = null;
@@ -190,6 +276,11 @@ function runCli(o) {
let errText = '';
let sawDelta = false;
let buf = '';
// 每次工具调用的发生位置(= 当时的 full 长度)。
// 根因:模型在发起 WebSearch 前常先吐一句过程话术(如
// "I'll search for ... before responding."),它不是答案。
// 只保留「最后一次工具调用之后」的文本作为正文。
const toolMarks = [];
const handleLine = (line) => {
line = line.trim();
@@ -206,6 +297,12 @@ function runCli(o) {
// 文本增量:stream_event → content_block_delta → text_delta
if (ev.type === 'stream_event' && 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.delta.type === 'text_delta' && e2.delta.text) {
const prev = full;
@@ -219,6 +316,7 @@ function runCli(o) {
// 已误判过一次的话,本段的累积都作废
if (isCliBoilerplate(prev)) { full = ''; errText = errText || prev; return; }
sawDelta = true;
streamed += e2.delta.text;
if (o.onDelta) { try { o.onDelta(e2.delta.text); } catch (e) {} }
} else if (e2.delta.type === 'thinking_delta' && e2.delta.thinking && o.onThink) {
try { o.onThink(e2.delta.thinking); } catch (e) {}
@@ -229,6 +327,10 @@ function runCli(o) {
// 兜底:未开启/未产生增量流时,整段文本在 assistant 消息里
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) {
const t = ev.message.content
.filter((c) => c && c.type === 'text' && typeof c.text === 'string')
@@ -236,6 +338,7 @@ function runCli(o) {
if (t && !full) {
if (isCliBoilerplate(t)) { errText = errText || t; return; }
full = t;
streamed += t;
if (o.onDelta) { try { o.onDelta(t); } catch (e) {} }
}
}
@@ -283,18 +386,22 @@ function runCli(o) {
child.on('close', (code) => {
clearTimeout(timer);
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) {
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) {
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;
}
module.exports = { runCli, resolveCli, buildChildEnv };
module.exports = { runCli, resolveCli, buildChildEnv, resolveCredentials, defaultCredentialFile };
@@ -4,17 +4,25 @@
"outputRoot": "MCNSkill项目",
"businessDataDir": ["MCNSkill项目", "三方数据", "红狐数据", "抖音榜单"],
"extraRoots": [],
"sessionCwd": "D:\\AI技能\\mcn-workshop",
"ai": {
"backend": "dify",
"backend": "cli",
"cli": {
"path": "",
"model": "custom:doubao-seed-2-1-pro-260628",
"model": "deepseek-v4.1-flash",
"effort": "low",
"apiKey": "",
"authToken": "",
"credentialFile": "",
"internetEnvironment": "internal",
"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 字、无编号);结合本条创意/人设落地,禁干巴术语 | 解析为气泡下内嵌按钮行,点选=以整行文本发一轮 |
| 需求 | `==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 内固化)**:
- 候选骨架受控词表:内容形式(剧情/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`。
> **最后更新**:2026-09-16(本轮把此前的 `工作台UI规范.md` 合并升级为本文件并重命名为「视觉交互规范」,内容为**迭代后的最终态**)
@@ -11,6 +11,10 @@ const DEFAULTS = {
outputRoot: 'MCNSkill项目',
businessDataDir: ['MCNSkill项目', '三方数据', '红狐数据', '抖音榜单'],
extraRoots: [],
// 10-08:工作台提交的 AI 任务会话归属哪个工作区 —— 侧边栏分组按会话 cwd 聚合,
// cwd 不是已打开过的工作区就会落到「未分组任务」。
// 留空 → 用工作台自身目录(ROOT);填绝对路径 → 会话归到那个工作区(改分组只改这里,不动代码)。
sessionCwd: '',
// 09-29 AI 通路配置:backend 决定走哪条链路
// "cli" 本地 CodeBuddy CLI 进程(stdio,无端口;需自备 CODEBUDDY_API_KEY)
// "dify" Dify chat-messages(原链路,保留兜底)
@@ -19,11 +23,23 @@ const DEFAULTS = {
cli: {
path: '', // 留空 → 自动探测(环境变量 CODEBUDDY_CLI_PATH > WorkBuddy 自带 CLI > PATH)
model: 'custom:doubao-seed-2-1-pro-260628',
apiKey: '', // 留空 → 读环境变量 CODEBUDDY_API_KEY
authToken: '', // 留空 → 读环境变量 CODEBUDDY_AUTH_TOKEN(优先级最高)
apiKey: '', // 一般留空:config.json 是 git 跟踪文件,key 别写这里
authToken: '', // 同上
// 凭据文件(JSON:{"apiKey":"..."} 或 {"authToken":"..."})。
// 留空 → 默认 ~/.workbuddy/mcn-workshop/ai-cli.json(刻意放仓库外,不会被技能同步复制)
credentialFile: '',
internetEnvironment: 'internal', // 中国版=internal,iOA=ioa,国际版留空
permissionMode: '',
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);
taskId = d.id;
showToast('✅ 任务已提交,请在左侧会话栏查看执行');
showToast('✅ 任务已提交,执行进度见右下角面板');
TaskProgress.show(opts.label || '工作台任务');
if (opts.onSubmit) { try { opts.onSubmit(taskId); } catch (e) {} }
} catch (e) {
showToast('❌ 任务提交失败: ' + e.message);
@@ -82,6 +83,7 @@ async function executeTask(opts) {
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5000));
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.state === 'done') { finished = true; finish(s); showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; }
if (s.state === 'error') { finished = true; finish(s); showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; }
@@ -90,7 +92,9 @@ async function executeTask(opts) {
}
restore();
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)
@@ -263,6 +267,58 @@ function showToast(msg) {
_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 渲染器 ================= */
function inlineMd(s) {
let out = esc(s);
@@ -1056,19 +1056,32 @@ async function updateRankingData(view, btn) {
body: JSON.stringify({ prompt: rankPrompt, force: !!inCooldown }),
});
if (!r || r.ok === false) throw new Error((r && r.error) || '任务提交失败');
showToast('✅ 榜单更新任务已提交,请到左侧会话栏查看执行');
showToast('✅ 榜单更新任务已提交,执行进度见右下角面板');
TaskProgress.show('更新榜单数据');
// 4) 轮询任务状态(最长 15 分钟;期间按钮保持 busy)
// 10-08 修复:工作台服务被回收时 api() 会持续抛错,原逻辑静默 continue 直到 15 分钟超时、用户零感知
// ⇒ 连续 3 次查询失败即判定服务已断开,提示并结束轮询(任务仍在后台跑,只是工作台看不到进度)
const deadline = Date.now() + 15 * 60 * 1000;
let state = 'running';
let pollFail = 0;
while (Date.now() < deadline) {
await new Promise((res) => setTimeout(res, 5000));
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.state === 'done') { state = 'done'; break; }
if (s.state === 'error' || s.state === 'review') { state = s.state; break; }
}
restore();
if (state !== 'running') setTimeout(() => TaskProgress.hide(), 8000);
if (state === 'done') {
showToast('✅ 榜单数据已更新,正在刷新…');
// 5) 整页重建:丢弃 _reload(同闭包 render 不清 meta 缓存)→ router 重新进入渲染 → 拉到最新数据期
@@ -1216,12 +1229,15 @@ function updateFollowedAccounts(btn) {
});
if (r.error) throw new Error(r.error);
const taskId = r.id;
const taskTitle = '更新账号·' + name;
TaskProgress.show(taskTitle);
const deadline = Date.now() + 20 * 60 * 1000;
let state = 'running';
while (Date.now() < deadline) {
await new Promise((res) => setTimeout(res, 5000));
let s = null;
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.state === 'done') { state = 'done'; 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 + ')'); }
}
restore();
TaskProgress.hide();
if (btn) btn.blur();
showToast(done.length ? `✅ 已更新 ${done.length} 个关注账号${failed.length ? `;${failed.length} 个未完成:${failed.join('、')}` : ''}` : '❌ 更新未完成:' + failed.join('、'));
refreshCurrentPage();
@@ -1950,12 +1967,15 @@ function readVideoTaskRec(videoId) {
/* 刷新后恢复轮询:跟随该视频解析任务直到终态 → 清理记录 → 提示并刷新本页按钮态 */
async function watchVideoParseTask(videoId, taskId, reload) {
let s = null;
TaskProgress.show('视频解析');
const deadline = Date.now() + 35 * 60 * 1000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5000));
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;
}
setTimeout(() => TaskProgress.hide(), 8000);
try { localStorage.removeItem(VIDEO_TASK_KEY(videoId)); } catch (e) {}
if (s && s.state === 'done') showToast('✅ 视频解析完成,已自动刷新本页');
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 .btn-sm { padding: 4px 12px; font-size: 14px; }
.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 { loadConfig } = require('./load-config');
// 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 监听且自动复活),
// 此时 127.0.0.1:8899 绑定不生效 → 默认直接起在 8900;仍可 `node server.js <端口>` 显式换端口
@@ -34,10 +34,46 @@ function aiBackend() {
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 动态推导,禁止写死盘符。
// 根因:原写死盘符指向仓库外的固定目录,仓库迁移后该目录不存在 → 浏览器锁失效、会话 cwd 悬空。
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,调用方自行兜底。
// 模板正文取 ==PROMPT== 与 ==CHANGELOG== 之间(头部说明/文末变更记录不发给模型)
@@ -359,7 +395,7 @@ function createOnceAutomation(name, prompt, skillsArr, connectorIdsArr) {
try {
const now = Date.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 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())}`;
@@ -401,6 +437,55 @@ function rankLastTaskState() {
} 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 ----
const server = http.createServer((req, res) => {
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 now = Date.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 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())}`;
@@ -809,10 +894,17 @@ const server = http.createServer((req, res) => {
try {
const { DatabaseSync } = require('node:sqlite');
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' }); }
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);
// 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();
let state = 'queued';
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 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) {
return sendErr(500, '状态查询失败: ' + e.message);
}
@@ -888,7 +990,7 @@ const server = http.createServer((req, res) => {
resp = await fetch(difyUrl + '/chat-messages', {
method: 'POST',
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,
});
} finally { clearTimeout(timer); }
@@ -915,10 +1017,7 @@ const server = http.createServer((req, res) => {
if (!t) return '';
return t.length <= 8 ? '***' : t.slice(0, 6) + '***' + t.slice(-4);
};
const envKey = process.env.CODEBUDDY_API_KEY || '';
const envTok = process.env.CODEBUDDY_AUTH_TOKEN || '';
const effKey = c.apiKey || envKey;
const effTok = c.authToken || envTok;
const cred = resolveCredentials({ apiKey: c.apiKey, authToken: c.authToken, credentialFile: c.credentialFile });
const info = {
backend,
cli: {
@@ -926,10 +1025,11 @@ const server = http.createServer((req, res) => {
pathConfigured: !!c.path,
model: c.model || '',
internetEnvironment: c.internetEnvironment || '',
credential: effTok ? 'CODEBUDDY_AUTH_TOKEN(' + mask(effTok) + ')'
: effKey ? 'CODEBUDDY_API_KEY(' + mask(effKey) + ')'
: '(未配置)',
ready: !!(effTok || effKey),
credential: cred.source + (cred.apiKey || cred.authToken
? ' (' + mask(cred.authToken || cred.apiKey) + ')' : ''),
credentialFile: cred.credentialFile,
credentialFileExists: (() => { try { return fs.existsSync(cred.credentialFile); } catch (e) { return false; } })(),
ready: !!(cred.authToken || cred.apiKey),
},
dify: {
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') {
const t0 = Date.now();
try {
const r = await runCli({
prompt: '只回复两个字:成功', cwd: ROOT, model: c.model,
apiKey: c.apiKey, authToken: c.authToken,
internetEnvironment: c.internetEnvironment, cliPath: c.path,
timeoutMs: 120000,
});
const r = await runCli(cliBaseOpts({ prompt: '只回复两个字:成功', timeoutMs: 120000 }));
info.probe = {
ok: r.ok, ms: Date.now() - t0,
text: (r.text || '').slice(0, 100),
@@ -1030,18 +1125,7 @@ const server = http.createServer((req, res) => {
// dify = 原链路(下方保留,未改动)
// 两条链路对外都产出同一套 SSE 协议:{delta} / {replace} / {error},前端无需感知差异。
if (aiBackend() === 'cli') {
const c = (CFG.ai && CFG.ai.cli) || {};
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),
};
const base = cliBaseOpts({ prompt: query });
if (stream) {
res.writeHead(200, {
'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 });
write({ error: r.error || 'CLI 执行失败' });
} else if (r.text && r.text !== acc) {
// 成功但「流过的内容」≠「终态正文」:典型是模型在联网搜索前先吐了一句过程话术,
// 后端已把工具调用前的部分切掉 → 这里用权威正文整体覆盖(前端原生支持 replace)
write({ replace: r.text });
}
log('AI 需求打磨(cli,stream): ' + accountName + ' | ' + acc.length + '字'
+ (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', {
method: 'POST',
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,
});
if (!difyResp.ok || !difyResp.body) {
@@ -1171,7 +1259,7 @@ const server = http.createServer((req, res) => {
difyResp = await fetch(difyUrl + '/chat-messages', {
method: 'POST',
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,
});
} 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-10 路径治本:cwd 由写死盘符改为 ROOT 动态推导(<技能根>/mcn-work-shop);
// 清理模式放宽为 '%mcn-work%',同时覆盖历史会话(旧 mcn-workshop)与新建会话(mcn-work-shop)
// 09-10 路径治本:cwd 由写死盘符改为 ROOT 动态推导(<技能根>/mcn-workshop);
// 清理模式放宽为 '%mcn-work%',同时覆盖历史会话(早期 D:\AgentSkill\mcn-workshop、迁移期 …\mcn-work-shop)与新建会话(…\V1.0\mcn-workshop)
// 判定:cwd 含 mcn-work + status=completed(执行中一律不删)+ 未删过 + 最后活动时间超过 6 小时
// 方式:软删除(置 deleted_at),客户端左侧会话栏按 deleted_at 过滤即不再显示;可逆安全
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; // 每小时检查一次
function cleanupExpiredSessions() {
@@ -1262,7 +1350,7 @@ function cleanupExpiredSessions() {
AND COALESCE(last_activity_at, updated_at, created_at, 0) < ?
`).run(now, SESSION_CWD_PATTERN, cutoff);
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) {
console.error('会话自动清理失败:', e.message);
}
@@ -129,11 +129,11 @@
## 2026-09-10(工作台配置收敛:端口/DB/目录单源化,代码层统一引用 config.json)
- **触发**:用户问「这些设定能否用配置,所有文件和脚本都引用,避免有调整时到处修改」。确认范围:只收敛代码层(不动文档/技能规范),配置放 `mcn-work-shop/config.json`。
- **触发**:用户问「这些设定能否用配置,所有文件和脚本都引用,避免有调整时到处修改」。确认范围:只收敛代码层(不动文档/技能规范),配置放 `mcn-workshop/config.json`。
- **目标**:工作台散落在 server.js / dsh-data.js / 4 个辅助脚本 / 前端 data-pages.js / start.bat 里的硬编码设定(端口 8900、DB 名 mcn-plugin.db、产出根 MCNSkill项目、榜单目录),统一收进 config.json,代码层一律从配置读,调整只需改 config.json 一处。
- **新增**:
- `mcn-work-shop/config.json`:单一配置源(port / dbName / outputRoot / businessDataDir / extraRoots)
- `mcn-work-shop/load-config.js`:配置读取唯一入口 + 默认值唯一来源(config 缺失/损坏回退 DEFAULTS)
- `mcn-workshop/config.json`:单一配置源(port / dbName / outputRoot / businessDataDir / extraRoots)
- `mcn-workshop/load-config.js`:配置读取唯一入口 + 默认值唯一来源(config 缺失/损坏回退 DEFAULTS)
- **改动**:
- `server.js`:`require('./load-config')` 取 CFG;`PORT_BASE = argv[2] || CFG.port`(argv 端口仍优先);`DEFAULT_ROOT` / `path.join(ROOT, CFG.dbName)` 从 CFG 读;`saveConfig` 合并写盘保留其它字段;错误提示「mcn-plugin.db」→ CFG.dbName 动态
- `dsh-data.js`:删本地 loadCfg,改用 loadConfig;`DSH_DB = path.join(__dirname, CFG.dbName)`;榜单目录 `path.join(...CFG.businessDataDir)`
@@ -152,7 +152,7 @@
- **判定层**:`V1.0/references-add/路径配置.md`、`Lite1.0/references-add/路径配置.md`、`subskills/mcn-dou-analysis/references/路径配置.md`、`subskills/mcn-video-prompt/references-add/路径配置.md`(判定表/执行规则/解析规则/变更记录,v3)
- **SKILL 层**:`V1.0/SKILL.md`(榜单读取 71/78、外部技能依赖 dsh 副本删除、产出路径表述)、`Lite1.0/SKILL.md`、`subskills/mcn-dou-analysis/SKILL.md`(42/44/51/52/58/59/82/95 行)、`subskills/mcn-video-prompt/SKILL.md`(502/505/531-539)、`subskills/mcn-data-insight/SKILL.md`(90-96/116 行)
- **标准库**:5 份 `references/规则/路径引用规范.md`(「三种机器」→「两种机器」)、`references/创作流程规范.md`、`references/创作流程/{11_脚本检查和诊断,4_账号设定解析和确认}.md`、`references/接口调用/{MCP_工具调用规范,自动化任务清理规范}.md`
- **数据源层**:`subskills/mcn-data-insight/scripts/fetch_week_ranks.py`(删 `.dsh` 分支代码)、`mcn-work-shop/dsh-data.js`(删 `isDshDeploy` 分支 + 注释)、`mcn-work-shop/public/data-pages.js`(SKILL_HINT_RANKING + 写库注释)、`subskills/mcn-data-insight/subskills/{douyin-rise-ranking,douyin-weekly-surge,douyin-content-surge,douyin-daily-hot}/SKILL.md`、`references/规则/抖音数据规则.md`
- **数据源层**:`subskills/mcn-data-insight/scripts/fetch_week_ranks.py`(删 `.dsh` 分支代码)、`mcn-workshop/dsh-data.js`(删 `isDshDeploy` 分支 + 注释)、`mcn-workshop/public/data-pages.js`(SKILL_HINT_RANKING + 写库注释)、`subskills/mcn-data-insight/subskills/{douyin-rise-ranking,douyin-weekly-surge,douyin-content-surge,douyin-daily-hot}/SKILL.md`、`references/规则/抖音数据规则.md`
- **账号分析层**:`references/账号数据分析方法.md`、`references/feature/{01_获取账号信息,03_提炼账号设定,04_分析视频数据,06_生成账号设定卡片}.md`、`references/铁律避坑规则/账号信息获取执行避坑.md`(删「dsh 原库」)、`references/浏览器搜索抖音账号操作规范.md`(159/175 行 browser-harness dsh 部署环境)
- **README**:`references-add/README.md`(两处)、`subskills/mcn-dou-analysis/references-add/README.md`、`subskills/mcn-video-prompt/references-add/README.md`、`subskills/mcn-video-prompt/references/README.md`
- **残留核对**:全树扫描 `.dsh`/`dshworkspace`/`dsh 部署`/`dsh 环境`/`dsh_MCNProject`/`②dsh`/`③用户环境`——**当前有效规则零残留**,剩余命中全部为豁免三类:①变更记录/变更日志历史行;②「09-10 起 dsh 环境已废弃」说明文字;③`路径引用规范.md` 用户级路径示例 `{主目录}/.dsh/...`(DSH 工具配置目录,非部署环境)。工作台重启验证 HTTP 200,`dsh-data.js`/`fetch_week_ranks.py` 语法校验通过。
@@ -172,8 +172,8 @@
- **MCP 文档**:`references/接口调用/MCP_工具调用规范.md`、`subskills/mcn-dou-analysis/references/接口调用/MCP_工具调用规范.md`(开发/非开发环境判定)
- **Lite1.0**:`SKILL.md` 外部技能依赖段落(见下)
- **真实读写路径修复(B 类 8 处)**:
- `mcn-work-shop/server.js`:新增 `BROWSER_LOCK_FILE = path.join(ROOT, '.browser-lock')`、`SESSION_CWD = ROOT`;2 处 `const cwd` 与 `BROWSER_LOCK` 改用常量;`SESSION_CWD_PATTERN` 由 `'%mcn-workshop%'` 放宽为 `'%mcn-work%'`(**同时兼容历史会话 mcn-workshop 与新建会话 mcn-work-shop**,避免 6 小时清理对旧会话失效)
- `mcn-work-shop/build-create-prompts.cjs` / `submit-review-tasks.cjs`:prompt 内 DB 绝对路径 → 改用文件内既有 `DB` 常量插值
- `mcn-workshop/server.js`:新增 `BROWSER_LOCK_FILE = path.join(ROOT, '.browser-lock')`、`SESSION_CWD = ROOT`;2 处 `const cwd` 与 `BROWSER_LOCK` 改用常量;`SESSION_CWD_PATTERN` 由 `'%mcn-workshop%'` 放宽为 `'%mcn-work%'`(**同时兼容历史会话 mcn-workshop 与新建会话 mcn-workshop**,避免 6 小时清理对旧会话失效)
- `mcn-workshop/build-create-prompts.cjs` / `submit-review-tasks.cjs`:prompt 内 DB 绝对路径 → 改用文件内既有 `DB` 常量插值
- `V1.0/SKILL.md` 第 5 条:文档里的运行时 `cwds=["D:\AgentSkill\mcn-workshop"]` → 改为 `<工作台目录>`(由 server.js 从 ROOT 动态推导)
- **Lite1.0 外部技能依赖修正(09-02 结构变更补同步)**:原段落仍写「三环境两步判定 + `D:\AgentSkill\mcn-dou-analysis` 外部路径」,既失效又未同步 09-02「mcn-dou-analysis 并入 `subskills/`」变更。修正为:**Lite1.0 目录下无 `subskills/`(实测),未内置该技能**;需要账号解析改用完整版「短视频工作台」V1.0,或先把技能同步进 `Lite1.0/subskills/` 后按相对路径 `subskills/mcn-dou-analysis/SKILL.md` 调用;禁止盘符绝对路径定位。
- **BROWSER_LOCK 契约同步**:`subskills/mcn-dou-analysis/references/浏览器搜索抖音账号操作规范.md` 5.6 节锁文件路径由 8 处盘符硬编码 → 改为相对 `.browser-lock`(工作台触发的会话 cwd 即工作台目录),并注明以工作台注入的绝对路径为准。
@@ -188,7 +188,7 @@
- **D·视觉脚本 → D·视觉短片(全量)**:S9 格式列举(SKILL.md)、S6 内容形式映射表(F04 测评种草/F07 纯视觉短片)、S9 正文 6 处、创作流程规范 3 处、03_框架节奏/07_脚本格式选择指南 8 处——与工作台需求框/用户既定术语对齐,消除「纯视觉短片→D·视觉脚本」混用。
- **预设钩子 → 开场钩子(全量)**:S5 选题方案字段(含 10 种开场方式引用)+ S7 大纲回扣语义 + 04_爆款开场/00_口径说明——对外统称「开场钩子」(=S6 场1 段名、工作台需求框 hook 字段同名同义),S5 阶段语义由「预设」改为直接以开场钩子表达。
- **需求框-技能衔接注明(mcn-work-shop/prompts/clarify.md)**:内容形式 4 项为粗选方向,落创作时细化为 S6 口径(口播→教程/知识分享/盘点,视觉短片→测评/沉浸式);故事框架四词(钩子前置/悬念反转/递进铺垫/先抑后扬)=达人倾向词,由 S6 用叙事形态 F01-F08 + 结构模板(三段式/五段式)定型。
- **需求框-技能衔接注明(mcn-workshop/prompts/clarify.md)**:内容形式 4 项为粗选方向,落创作时细化为 S6 口径(口播→教程/知识分享/盘点,视觉短片→测评/沉浸式);故事框架四词(钩子前置/悬念反转/递进铺垫/先抑后扬)=达人倾向词,由 S6 用叙事形态 F01-F08 + 结构模板(三段式/五段式)定型。
- **规则沉淀(09-07)**:新增 references/规则/概念分层与源头表述规范.md(先分层再落笔:策略/实现/容器各写源头正文;禁止 X=Y/括注跨文档打补丁,改源头正文;同族术语对外一个总称、改名一次全链路;含开场钩子家族案例);SKILL.md 红线摘要区新增 🔴 索引行;项目级 技能审计检查清单.md 一致性维新增第 11/12 条(概念分层、源头表述自洽),编号重排 1→15,使用方法补层级先于枚举。
- **开场钩子分层表述源头化(5_选题 / 6_框架 / 04_爆款开场·00_)**:三个源头正文自洽——开场方式(04_ 库 10 种标准分类)由 S5 依据用户要求/账号结构性符号/对标拆解(S3)选定并写入选题方案;S6 场次表「本场钩子」=五类留人机制(悬念提问/反差画面/直击痛点/身份共鸣/利益承诺),场1 的本场钩子用于把 S5 已选开场方式实现为镜头与台词。消除 S5「钩子类型」一词双指(表头改「开场方式」、方案字段行改为单一开场方式成分);不再以对照式/括注维护,概念各归其位。
@@ -228,7 +228,7 @@
## 2026-09-02(工作台 AI 任务契约与执行铁律)
- 工作台新增第 7 条「AI 任务入口技能契约(P0)」:所有 AI 会话任务入口须遵守功能入口×技能调用契约(prompt=技能前缀常量+业务正文+skills 参数级挂载),新增/修改入口前先读 `mcn-work-shop/docs/AI会话任务输入输出对照.md`(含新增入口检查清单 7 项)——防裸 prompt 跳步/技能声明混入业务串/BROWSER_HINT 误注入;原第 7/8 条顺延为 8/9。
- 工作台新增第 7 条「AI 任务入口技能契约(P0)」:所有 AI 会话任务入口须遵守功能入口×技能调用契约(prompt=技能前缀常量+业务正文+skills 参数级挂载),新增/修改入口前先读 `mcn-workshop/docs/AI会话任务输入输出对照.md`(含新增入口检查清单 7 项)——防裸 prompt 跳步/技能声明混入业务串/BROWSER_HINT 误注入;原第 7/8 条顺延为 8/9。
- 工作台任务执行方式铁律固化:创作/改写/复盘必须走 `POST /api/run` + skills 参数,禁会话内手写草稿。
- 调度机制实测定稿 + 端口 8899→8900。
@@ -33,7 +33,7 @@
| 场景 | 说明 |
|------|------|
| 仅开发机工具 | 不随技能分发的目录(如 `mcn-work-shop/`),内部可保留开发机路径 |
| 仅开发机工具 | 不随技能分发的目录(如 `mcn-workshop/`),内部可保留开发机路径 |
| 环境判别/路径配置文档 | `路径配置.md`、SKILL.md 环境判定节——锚点 git 检出特征是**规则内容**不是引用,保留 |
| 示例/教学文本 | `C:/Videos/demo.mp4`、`C:/path/to/file` 等明确示例(`path/to`、`demo` 字样) |
| 历史日志 | `.workbuddy/memory/` 既有日志保留原样,不改写历史 |
@@ -33,7 +33,7 @@
| 场景 | 说明 |
|------|------|
| 仅开发机工具 | 不随技能分发的目录(如 `mcn-work-shop/`),内部可保留开发机路径 |
| 仅开发机工具 | 不随技能分发的目录(如 `mcn-workshop/`),内部可保留开发机路径 |
| 环境判别/路径配置文档 | `路径配置.md`、SKILL.md 环境判定节——锚点 git 检出特征是**规则内容**不是引用,保留 |
| 示例/教学文本 | `C:/Videos/demo.mp4`、`C:/path/to/file` 等明确示例(`path/to`、`demo` 字样) |
| 历史日志 | `.workbuddy/memory/` 既有日志保留原样,不改写历史 |
@@ -39,7 +39,7 @@ from urllib.error import HTTPError, URLError
LIKES_API = "https://redfox.hk/story/api/dy/search/likesRank"
SURGE_API = "https://redfox.hk/story/api/dy/search/hotContentRank"
# 子目录(与 mcn-work-shop/dsh-data.js rankSubDir 对应)
# 子目录(与 mcn-workshop/dsh-data.js rankSubDir 对应)
SUB_DIR = {"hot": "视频热榜", "likes": "点赞榜"}
# 文件名前缀(与账号周榜 抖音周榜_{赛道}_{周一}.json 区分)
FILE_PREFIX = {"hot": "抖音周榜_视频热榜", "likes": "抖音周榜_点赞榜"}
@@ -50,7 +50,7 @@ CATEGORY_ALL = "全部"
def resolve_ranking_dir():
"""与 mcn-work-shop/dsh-data.js resolveRankingDir 同款:env → 桌面 抖音榜单(09-10 起无 dsh 分支)"""
"""与 mcn-workshop/dsh-data.js resolveRankingDir 同款:env → 桌面 抖音榜单(09-10 起无 dsh 分支)"""
env = os.environ.get("MCN_RANKING_DIR", "").strip()
if env:
return env
@@ -157,7 +157,7 @@ description: "MCN 达人爆款视频解析与人设卡生成技能:达人账
核心流程概要:
- **触发**:账号详情视频列表「解析视频」按钮 / 用户指定单条视频
- **流程**:`upload_douyin_video` 提交(3-5 分钟等待)→ `short_video_detail` 查询(content + analysis)→ `scripts/json_tool.py` 保存到 `{产出根目录}/{达人昵称}/视频对标/{视频文件夹}/`
- **可选写库**:① 环境有 MCN 插件接口(dsh)时补录 `account_video_source` 并提取选题;② **工作台环境(mcn-work-shop,本机 `http://localhost:8900`)**:功能三/五解析产物落盘后调用 `POST /api/import/account`(body `{"account":"{达人昵称}"}`)同步入库——扫描账号产出目录写工作台库 `mcn-plugin.db`(`视频分析/*/analysis.json`→`account_video_analysis`、`content.json`→`account_video_source`、`{达人昵称}账号设定.md`→`account_persona`、`{达人昵称}账号数据分析.md`→`account_analysis`、`短视频表格.xlsx`→`account_videos` 补视频列表),供工作台选题(`/api/ai/topics`)基于真实视频解析内容生成。入库实现见工作台 `server.js`(09-01 新增)
- **可选写库**:① 环境有 MCN 插件接口(dsh)时补录 `account_video_source` 并提取选题;② **工作台环境(mcn-workshop,本机 `http://localhost:8900`)**:功能三/五解析产物落盘后调用 `POST /api/import/account`(body `{"account":"{达人昵称}"}`)同步入库——扫描账号产出目录写工作台库 `mcn-plugin.db`(`视频分析/*/analysis.json`→`account_video_analysis`、`content.json`→`account_video_source`、`{达人昵称}账号设定.md`→`account_persona`、`{达人昵称}账号数据分析.md`→`account_analysis`、`短视频表格.xlsx`→`account_videos` 补视频列表),供工作台选题(`/api/ai/topics`)基于真实视频解析内容生成。入库实现见工作台 `server.js`(09-01 新增)
## 八、功能六:生成账号设定卡片
@@ -262,7 +262,7 @@ PY
**锁规则(每个需要浏览器的任务必须遵守)**:
1. **锁文件位置**:**工作台目录下的 `.browser-lock`**(工作台目录 = 本技能根下的 `mcn-work-shop/`)。工作台 `/api/run` 对浏览器类任务会把**运行时解析出的绝对路径**注入 prompt,AI 会话执行时**以注入的路径为准**;此处不写死盘符。
1. **锁文件位置**:**工作台目录下的 `.browser-lock`**(工作台目录 = 本技能根下的 `mcn-workshop/`)。工作台 `/api/run` 对浏览器类任务会把**运行时解析出的绝对路径**注入 prompt,AI 会话执行时**以注入的路径为准**;此处不写死盘符。
- 工作台触发的会话 cwd 即**工作台目录**,故可直接用相对路径 `.browser-lock`(推荐,跨机器可迁移)
- 手工执行(cwd 不是工作台目录)时,先定位工作台目录再拼 `.browser-lock`
2. **操作前**:检查锁文件是否存在(`ls`)→ 存在则等 10 秒重试(最多 18 次 ≈ 3 分钟)
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more