Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e40db4e51 | ||
|
|
b249df116a | ||
|
|
1cc969317d | ||
|
|
66b098ae36 | ||
|
|
6c891f3522 | ||
|
|
417a9fccb6 | ||
|
|
954b42ef55 | ||
|
|
53d18e2b06 |
@@ -13,7 +13,6 @@ primarily Simplified Chinese.
|
||||
- `src/main`: privileged Electron main process, runtimes, persistence, IPC,
|
||||
knowledge, automation, and OS integration.
|
||||
- `src/preload`: the narrow, typed bridge exposed to the renderer.
|
||||
- `src/renderer`: React UI. It must not receive secrets or direct Node access.
|
||||
- `src/shared`: schemas, contracts, presets, and IPC channel definitions shared
|
||||
across process boundaries.
|
||||
- `resources/skills`: bundled skills.
|
||||
@@ -27,8 +26,6 @@ Keep Electron security boundaries intact:
|
||||
- Validate IPC input with shared Zod schemas and verify trusted senders.
|
||||
- Expose only explicit preload methods. Do not pass raw Electron APIs.
|
||||
- Keep API keys in the main process and encrypted settings store.
|
||||
- Never log or return credentials, authorization headers, private documents, or
|
||||
unredacted provider payloads.
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
@@ -57,10 +54,29 @@ Keep Electron security boundaries intact:
|
||||
- Keep changes focused. Do not add unrelated refactors or documentation.
|
||||
- Add or update focused tests for behavioral changes and regressions.
|
||||
- Avoid broad catches that erase HTTP status, cancellation, or provider error
|
||||
context. Bound and redact any surfaced error details.
|
||||
context.
|
||||
- Keep UI accessible with labels, keyboard behavior, semantic roles, and visible
|
||||
focus states.
|
||||
|
||||
## UI Consistency
|
||||
|
||||
- Reuse the shared `PageTabs` and `SegmentedControl` primitives instead of
|
||||
creating page-specific tab or toggle styles. A semantic tab set may use the
|
||||
shared segmented visual variant, but it must retain `tablist`, `tab`,
|
||||
`tabpanel`, `aria-selected`, roving focus, and arrow-key behavior.
|
||||
- Use the bundled `Inter Variable` and `Noto Sans SC Variable` UI fonts through
|
||||
the shared typography tokens. Do not add remote font requests or page-local
|
||||
font stacks. Keep redistributed font licenses in packaged resources and
|
||||
retain system fallbacks for startup and unsupported glyphs.
|
||||
- Route transient success and informational feedback, plus asynchronous errors
|
||||
that are not tied to one field, through the application notification
|
||||
viewport. Do not render page-local copies of the same notification pattern.
|
||||
- Keep inline feedback only when it must remain attached to its context, such
|
||||
as field validation, destructive confirmation, operation progress, a
|
||||
blocking page state, or an error with an immediate local recovery action.
|
||||
- Do not show the same event both inline and as an application notification.
|
||||
Preserve user input and actionable error context when an operation fails.
|
||||
|
||||
## Release Packaging
|
||||
|
||||
- `.github/workflows/packages.yml` is the canonical cross-platform packaging
|
||||
|
||||
@@ -91,16 +91,22 @@
|
||||
|
||||
### 3.3 字体令牌
|
||||
|
||||
界面字体使用系统无衬线字体栈,代码、标识符和原始日志使用等宽字体栈。
|
||||
界面默认使用随客户端本地打包的 `Inter Variable` 与 `Noto Sans SC Variable`:英文、数字优先使用 Inter,简体中文由 Noto Sans SC 覆盖。系统无衬线字体仅作为启动和缺失字形回退;代码、标识符和原始日志使用等宽字体栈。字体不得通过运行时网络请求加载。
|
||||
|
||||
| 令牌 | 字号 / 行高 | 字重 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--font-caption` | `10px` | 时间、短标签和紧凑元数据 |
|
||||
| `--font-body` | `12px` | 默认界面正文 |
|
||||
| `--font-caption` | `11px` | 时间、短标签和紧凑元数据 |
|
||||
| `--font-body` | `13px` | 默认界面正文 |
|
||||
| `--font-section-title` | `14px` | 卡片和区块标题 |
|
||||
| `--font-page-title` | `24px` | 一级页面标题 |
|
||||
|
||||
连续阅读内容使用 `13px` 至 `14px`,持久辅助信息不得小于 `10px`。页面内不得通过同时放大字号、加粗和使用强调色制造多个同级主标题。
|
||||
使用规则:
|
||||
|
||||
- 业务组件通过 `--font-family-ui` 与字体尺寸令牌继承字体,不创建页面专属字体栈。
|
||||
- 表单按钮、输入框、选择框和文本域必须继承界面字体,避免回退为原生控件字体。
|
||||
- 连续阅读内容使用 `14px`,持久辅助信息不得小于 `11px`。
|
||||
- 本地字体资源必须随生产包交付,并同时包含 Inter 与 Noto Sans SC 的 OFL 许可证。
|
||||
- 页面内不得通过同时放大字号、加粗和使用强调色制造多个同级主标题。
|
||||
|
||||
### 3.4 圆角、阴影与层级
|
||||
|
||||
@@ -182,6 +188,8 @@
|
||||
- 一级页面之间的导航由应用主导航承担,不复用 `PageTabs`。
|
||||
- 标签保持短名词,不显示句号,不用页签承载开关或过滤条件。
|
||||
- 项目过多时优先重组信息架构,不把一级页签做成多行。
|
||||
- `PageTabs` 可使用默认视觉或共享的 `segmented` 视觉变体。紧凑主从工作台中的 2 至 4 个同级面板可使用与模型设置一致的分段外观,但不得因此改用按钮组语义。
|
||||
- 视觉变体不改变组件含义:分段外观的 `PageTabs` 仍使用页签语义、单一激活面板、游标焦点和方向键切换,不复制页面专属样式。
|
||||
|
||||
### 6.2 SegmentedControl
|
||||
|
||||
@@ -268,6 +276,17 @@
|
||||
- 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。
|
||||
- 菜单使用 `menu`、`menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。
|
||||
|
||||
### 6.9 应用通知与就地反馈
|
||||
|
||||
应用级通知统一进入全局通知视口,页面不得自行复制通知卡片或在内容流中长期堆放短期消息。
|
||||
|
||||
- 异步操作成功、无需立即处理的信息,以及不属于某个字段的异步失败,使用应用级 `success`、`info` 或 `error` 通知。
|
||||
- 成功和信息通知默认在约 4.5 秒后自动消失;错误通知保持可见,直到用户关闭或同一去重键的更新替换它。
|
||||
- 同一语义和文案的重复通知应去重。通知正文必须有长度上限,不包含凭据、私人内容或未脱敏的提供商响应。
|
||||
- 字段校验、破坏性确认、操作进度、阻塞整个页面的状态,以及需要就地重试或修正的错误保留在相关控件附近。
|
||||
- 就地错误必须与对应字段或操作建立程序化关联;全局错误使用 `alert` 和 assertive 实时区域,成功与信息使用 `status` 和 polite 实时区域。
|
||||
- 一个事件只能选择一种主要反馈位置,不得同时显示页内横幅和全局通知。失败时不得因通知切换而清空用户输入、筛选或未提交草稿。
|
||||
|
||||
## 7. 交互状态
|
||||
|
||||
所有可交互组件必须实现:
|
||||
@@ -279,9 +298,9 @@
|
||||
- 选中:同时使用背景、边框、图标或字重中的至少两种信号。
|
||||
- 禁用:降低强调度,同时保留可读标签,并通过说明或工具提示解释原因。
|
||||
- 加载:防止重复提交,保留原按钮宽度并显示进行中标签。
|
||||
- 错误:就近显示可执行的错误说明,不只弹出短暂通知。
|
||||
- 错误:字段或局部操作错误就近显示可执行说明;非局部异步错误使用不会自动消失的应用级错误通知。
|
||||
|
||||
异步提交成功后更新内容并提供明确反馈。失败时保留用户输入和筛选上下文。
|
||||
异步提交成功后更新内容并通过统一应用通知提供明确反馈。失败时保留用户输入和筛选上下文。
|
||||
|
||||
## 8. 范围与数据语义
|
||||
|
||||
@@ -440,6 +459,13 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 活动记录保留审计字段和范围,支持独立容器横向滚动。
|
||||
- 批量停止、删除和清空历史遵循破坏性操作政策。
|
||||
|
||||
### 13.6 魔法笔记
|
||||
|
||||
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
|
||||
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
|
||||
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
|
||||
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
|
||||
|
||||
## 14. 文案规则
|
||||
|
||||
- 使用简体中文,动词直接、对象明确。
|
||||
@@ -470,7 +496,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- [ ] 实现并迁移 `PageHeader`。
|
||||
- [ ] 使用 `PageTabs` 统一同级页面导航。
|
||||
- [ ] 使用 `SegmentedControl` 统一少量互斥视图和状态切换。
|
||||
- [ ] 需要分段外观的同级面板使用 `PageTabs` 的共享 `segmented` 变体,不复制控件样式。
|
||||
- [ ] 建立统一筛选工具栏,移除以页签样式伪装的筛选。
|
||||
- [ ] 将短期成功、信息和非局部异步错误接入应用通知视口,移除页面专属通知横幅。
|
||||
- [ ] 实现 `ScopeBadge` 并覆盖全局、项目、失效和可切换状态。
|
||||
- [ ] 实现 `EmptyState` 的首次为空、无结果、失败和只读变体。
|
||||
- [ ] 实现 `danger-ghost`、`danger-solid` 和 `danger-zone`。
|
||||
|
||||
+12
-7
@@ -21,6 +21,7 @@
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、取消、超时和活动记录。
|
||||
- [x] **统一 Runtime 配置来源**:普通会话和消息通道共用“Agent Runtime”中的 OpenCode/Continue 模型来源、自有配置、程序路径和服务地址;通道只选择 Runtime 类型,每次远程请求动态解析当前全局配置。
|
||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
|
||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||
@@ -36,12 +37,12 @@
|
||||
- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
|
||||
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
|
||||
- [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。
|
||||
- [ ] **魔法笔记 / Magic Notes**(规划中):提供本地优先的结构化笔记工作空间,可摘录选中的对话、知识、文档和网页内容并保留来源追溯;AI 总结、改写、续写、整理和关联知识均由用户明确触发,不会静默修改来源知识。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;创建、保存和评论结果使用统一应用通知。
|
||||
- [ ] **MCP Server Control Plane**(规划中):扩展 MCP Agent Runtime Broker,统一生命周期、健康检查、重连、Schema 缓存、按项目或任务隔离、审批和审计,并受控接入 OpenCode、Continue。
|
||||
|
||||
### 工作管理、长期协作与工作流
|
||||
|
||||
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件。
|
||||
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件;活动按会话分组并默认收起,避免长历史占满页面。
|
||||
- [x] **记忆与智能心跳**:提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
|
||||
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
|
||||
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
|
||||
@@ -51,9 +52,12 @@
|
||||
### 浏览器、通信、语音与应用维护
|
||||
|
||||
- [x] **浏览器和桌面受控工具**:保留范围、取消、超时、输出边界和执行记录。
|
||||
- [x] **企业微信与钉钉**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||
- [x] **远程消息通道项目**:微信 ClawBot、企业微信和钉钉分别拥有系统管理的项目、独立远程会话、工作目录、处理后端、默认 Ask/Execute 模式及任务活动归属。
|
||||
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
|
||||
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
|
||||
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。
|
||||
- [ ] **本地录音与离线转写**(开发中):采集麦克风音频并使用已选择的本地模型离线转写,补齐取消、资源释放和 Electron 打包验证。
|
||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||
- [x] **版本检查**:仅检查固定官方 Release 和当前平台清单,不自动下载或安装。
|
||||
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
|
||||
|
||||
@@ -80,12 +84,13 @@
|
||||
- [ ] **Headless Runtime API**:作为可选、本机默认仅监听 loopback 的服务,提供任务提交、流式事件、状态和成果下载;访问令牌必须具有 scope、有效期、速率限制、项目限制和撤销能力。
|
||||
- [ ] **GoodBuddy Team Hub**:作为独立可选服务提供组织、成员、RBAC、项目共享、远程 Agent 注册、策略下发和租户级审计,不把 Electron Renderer 或云端服务改造成用户凭据持有者。
|
||||
|
||||
安全边界保持不变:Ask/Plan 必须在 Runtime 边界只读;Execute、MCP、网络和 Subagent 工具均经过 Main 进程审批与审计;不得照搬进程内脚本执行、任意 URL 请求、仅以 `created_by` 模拟多租户或共享无隔离 MCP 会话等做法。
|
||||
安全边界保持不变:Ask/Plan 必须在 Runtime 边界只读;Execute、MCP、网络和 Subagent 工具均受 Main 进程能力边界、权限策略、取消和审计约束。普通交互按对应策略审批;受信发送者发起的远程 Execute 不逐次弹窗确认,但不得绕过项目目录、Runtime、沙箱、能力开关或直连模型工具安全策略。不得照搬进程内脚本执行、任意 URL 请求、仅以 `created_by` 模拟多租户或共享无隔离 MCP 会话等做法。
|
||||
|
||||
### 知识工作空间与魔法笔记
|
||||
|
||||
- [ ] **魔法笔记 / Magic Notes**:建设本地优先的结构化笔记工作空间,支持将用户选中的对话片段、知识条目、文档摘录和网页摘录收集为可编辑笔记,并持续保留来源、位置和引用关系。
|
||||
- [ ] **受控 AI 笔记操作**:提供总结、改写、续写、整理和关联知识等显式操作;操作结果先进入笔记或待确认变更,不静默回写或修改来源知识。
|
||||
- [x] **魔法笔记 / Magic Notes 基础工作台**:已提供本地优先的笔记与待办页签、范围管理、编辑、筛选、删除和受控 AI 评论。
|
||||
- [ ] **可追溯摘录扩展**(规划中):支持将用户选中的对话片段、知识条目、文档摘录和网页摘录收集为可编辑笔记,并持续保留来源、位置和引用关系。
|
||||
- [ ] **扩展受控 AI 笔记操作**(规划中):在现有 AI 评论之外提供总结、改写、续写、整理和关联知识等显式操作;操作结果先进入笔记或待确认变更,不静默回写或修改来源知识。
|
||||
|
||||
### 多云远程沙盒 Agent
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
面向专业工作与国产化环境的安全桌面智能助手。
|
||||
|
||||
GoodBuddy 将模型连接、Agent Runtime、本地知识库、知识图谱、任务协作和持续成长能力组织在同一个桌面工作空间中。它不是简单的聊天窗口,而是一套可审计、可控制、可长期使用的个人智能工作环境。
|
||||
GoodBuddy 将模型连接、Agent Runtime、本地知识库、知识图谱、远程消息通道、任务协作和持续成长能力组织在同一个桌面工作空间中。它不是简单的聊天窗口,而是一套可审计、可控制、可长期使用的个人智能工作环境。
|
||||
|
||||

|
||||
|
||||
@@ -10,7 +10,7 @@ GoodBuddy 将模型连接、Agent Runtime、本地知识库、知识图谱、任
|
||||
|
||||
### 安全可控的 Agent 执行
|
||||
|
||||
GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode 和 Continue。工具不会被直接暴露给界面,所有执行都受到工作模式、权限审批和运行边界约束。
|
||||
GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode 和 Continue。工具不会被直接暴露给界面,所有执行都受到工作模式、权限策略和运行边界约束。
|
||||
|
||||
- `Ask`:只读问答,不调用工具。
|
||||
- `Execute`:选择该模式即授权当前交互运行使用已启用的受控工具。
|
||||
@@ -46,6 +46,19 @@ GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode
|
||||
- 支持远程任务委派与持久化结果发件箱。
|
||||
- Skills 与 MCP 能力按需接入。
|
||||
|
||||
### 远程消息通道与微信 ClawBot
|
||||
|
||||
微信 ClawBot、企业微信和钉钉分别使用系统管理的通道项目。远程发送者拥有独立会话,任务、活动和成果持续归属于对应通道与项目。
|
||||
|
||||
- 微信 ClawBot 使用本机扫码绑定,支持个人微信私聊文字、图片和文件。
|
||||
- 单条微信消息最多 4 个附件,解密后合计不超过 12MB;图片和支持的文档进入现有受控上下文。
|
||||
- 通道可选择直连文本模型、OpenCode 或 Continue。OpenCode/Continue 始终跟随“Agent Runtime”中的全局配置,不在通道中维护第二套 Runtime 配置。
|
||||
- 远程消息支持 Ask 与 Execute。Execute 不显示通道专属逐次审批,但仍受发送者范围、项目目录、Runtime、沙箱、能力开关和活动审计约束。
|
||||
- 当前任务生成的图片可以返回微信;明确要求文件时可将本次最终文本生成为 Markdown 附件,不自动发送已有工作区文件。
|
||||
- “断开本机绑定”只停止本机收发并清除本地凭据,不会删除通道项目、远程会话或历史,也不承诺解除微信服务端授权。
|
||||
|
||||
完整设计、安全边界和联调状态见[远程消息通道项目与微信 ClawBot 集成 PRD](docs/features/wechat-clawbot-channel-project-prd.md)。
|
||||
|
||||
### 本地知识库与知识图谱
|
||||
|
||||
文件、目录和网页内容可以按知识库独立管理。GoodBuddy 会完成解析、索引、检索和图谱构建,并保留可追溯的来源与证据。
|
||||
@@ -82,6 +95,8 @@ GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode
|
||||
| OpenCode | 完整编码与工作区任务 | Execute 不弹 GoodBuddy 审批,保留 Runtime 自身权限、取消和活动记录 |
|
||||
| Continue | Agent 编码与工作区任务 | Execute 不弹 GoodBuddy 审批,使用独立宿主、取消和活动记录 |
|
||||
|
||||
消息通道选择 OpenCode 或 Continue 时只选择 Runtime 类型,具体模型来源、自有配置、可执行文件和服务地址统一复用“Agent Runtime”设置,并在每次远程请求开始时解析当前全局配置。
|
||||
|
||||
## 功能矩阵与路线图
|
||||
|
||||
以下为仓库首页的简要路线图;完整能力说明、状态和重大规划统一记录在 [FEATURES.md](FEATURES.md)。
|
||||
@@ -90,8 +105,9 @@ GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode
|
||||
- [x] [多 Runtime、模型连接、Skills 与 MCP](FEATURES.md#agent-runtime-与模型连接)
|
||||
- [x] [本地知识库、向量检索与知识图谱](FEATURES.md#skillsmcp-与知识库)
|
||||
- [x] [任务、成果、记忆与智能心跳](FEATURES.md#工作管理长期协作与工作流)
|
||||
- [ ] [本地录音与离线转写](FEATURES.md#浏览器通信语音与应用维护)
|
||||
- [ ] [魔法笔记 / Magic Notes](FEATURES.md#知识工作空间与魔法笔记):本地优先的结构化笔记、可追溯摘录与受控 AI 整理。
|
||||
- [x] [微信 ClawBot、企业微信与钉钉消息通道](FEATURES.md#浏览器通信语音与应用维护)
|
||||
- [x] [本地录音与离线转写](FEATURES.md#浏览器通信语音与应用维护)
|
||||
- [x] [魔法笔记 / Magic Notes](FEATURES.md#知识工作空间与魔法笔记):本地优先的笔记与待办工作台,支持受控 AI 评论。
|
||||
- [ ] [Agent 框架、受控工作流与团队协作](FEATURES.md#agent-框架与协作能力)
|
||||
- [ ] [多云远程沙盒 Agent](FEATURES.md#多云远程沙盒-agent)
|
||||
|
||||
@@ -99,4 +115,4 @@ GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode
|
||||
|
||||
## 隐私说明
|
||||
|
||||
模型请求只会发送到用户选择的模型连接。本地数据保存在当前系统的应用数据目录中;远程委派仅在用户显式配置端点和令牌后启用。面向纯内网部署的“内网兼容模式”默认开启,允许 HTTP 并接受无效、自签名或过期的 HTTPS 证书;可在“安全与数据”中关闭并恢复严格校验。
|
||||
模型请求只会发送到用户选择的模型连接。本地数据保存在当前系统的应用数据目录中;远程委派仅在用户显式配置端点和令牌后启用。面向纯内网部署的“内网兼容模式”默认开启,允许 HTTP 并接受无效、自签名或过期的 HTTPS 证书;可在“安全与数据”中关闭并恢复严格校验。微信凭据和媒体端点不受该兼容模式放宽,始终只允许经过校验的腾讯微信 HTTPS 主机与重定向。
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GoodBuddy 控制中心 · AI 运维仪表盘</title>
|
||||
<meta name="description" content="GoodBuddy Control Center —— AI 代理运维监控与任务管理仪表盘" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">跳转到主要内容</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">GB</span>
|
||||
<span class="brand-name">GoodBuddy 控制中心</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="navToggleBtn"
|
||||
class="nav-toggle"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="primaryNav"
|
||||
>
|
||||
<span class="visually-hidden">切换导航菜单</span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<nav id="primaryNav" class="primary-nav" aria-label="主导航">
|
||||
<ul>
|
||||
<li><a href="#overview">概览</a></li>
|
||||
<li><a href="#agents">代理状态</a></li>
|
||||
<li><a href="#tasks">任务看板</a></li>
|
||||
<li><a href="#activity">活动日志</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<button id="themeToggleBtn" class="theme-toggle" type="button" aria-pressed="false">
|
||||
<span class="theme-toggle-icon" aria-hidden="true">🌙</span>
|
||||
<span class="theme-toggle-label">深色模式</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<section id="overview" class="hero" aria-labelledby="overview-heading">
|
||||
<div class="section-inner">
|
||||
<h1 id="overview-heading">今日运维概览</h1>
|
||||
<p class="hero-subtitle">实时掌握 AI 代理集群健康状况与任务执行进度</p>
|
||||
|
||||
<div class="metrics" role="list">
|
||||
<div class="metric-card" role="listitem">
|
||||
<p class="metric-label">总任务数</p>
|
||||
<p class="metric-value">128</p>
|
||||
<p class="metric-delta metric-delta--up">较昨日 +12</p>
|
||||
</div>
|
||||
<div class="metric-card" role="listitem">
|
||||
<p class="metric-label">运行中代理</p>
|
||||
<p class="metric-value">6</p>
|
||||
<p class="metric-delta metric-delta--up">全部在线</p>
|
||||
</div>
|
||||
<div class="metric-card" role="listitem">
|
||||
<p class="metric-label">已完成任务</p>
|
||||
<p class="metric-value">96</p>
|
||||
<p class="metric-delta metric-delta--up">完成率 75%</p>
|
||||
</div>
|
||||
<div class="metric-card" role="listitem">
|
||||
<p class="metric-label">平均响应时间</p>
|
||||
<p class="metric-value">340ms</p>
|
||||
<p class="metric-delta metric-delta--down">较昨日 -18ms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="agents" class="agents" aria-labelledby="agents-heading">
|
||||
<div class="section-inner">
|
||||
<h2 id="agents-heading">代理状态</h2>
|
||||
<ul class="agent-grid">
|
||||
<li class="agent-card">
|
||||
<div class="agent-card-top">
|
||||
<span class="agent-avatar" aria-hidden="true">A1</span>
|
||||
<span class="status-badge status-running">运行中</span>
|
||||
</div>
|
||||
<h3 class="agent-name">Agent Alpha</h3>
|
||||
<p class="agent-meta">负责:数据同步 & 报表生成</p>
|
||||
<p class="agent-meta">最近活跃:2 分钟前</p>
|
||||
</li>
|
||||
<li class="agent-card">
|
||||
<div class="agent-card-top">
|
||||
<span class="agent-avatar" aria-hidden="true">B2</span>
|
||||
<span class="status-badge status-idle">空闲</span>
|
||||
</div>
|
||||
<h3 class="agent-name">Agent Beta</h3>
|
||||
<p class="agent-meta">负责:客户工单处理</p>
|
||||
<p class="agent-meta">最近活跃:15 分钟前</p>
|
||||
</li>
|
||||
<li class="agent-card">
|
||||
<div class="agent-card-top">
|
||||
<span class="agent-avatar" aria-hidden="true">G3</span>
|
||||
<span class="status-badge status-maintenance">维护中</span>
|
||||
</div>
|
||||
<h3 class="agent-name">Agent Gamma</h3>
|
||||
<p class="agent-meta">负责:库存预警检测</p>
|
||||
<p class="agent-meta">最近活跃:1 小时前</p>
|
||||
</li>
|
||||
<li class="agent-card">
|
||||
<div class="agent-card-top">
|
||||
<span class="agent-avatar" aria-hidden="true">D4</span>
|
||||
<span class="status-badge status-running">运行中</span>
|
||||
</div>
|
||||
<h3 class="agent-name">Agent Delta</h3>
|
||||
<p class="agent-meta">负责:日志汇总分析</p>
|
||||
<p class="agent-meta">最近活跃:刚刚</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tasks" class="tasks" aria-labelledby="tasks-heading">
|
||||
<div class="section-inner">
|
||||
<div class="tasks-header">
|
||||
<h2 id="tasks-heading">任务看板</h2>
|
||||
<button id="addTaskBtn" class="btn btn-primary" type="button">
|
||||
<span aria-hidden="true">+</span> 添加任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-group" role="group" aria-label="按状态筛选任务">
|
||||
<button class="filter-btn is-active" type="button" data-filter="all" aria-pressed="true">
|
||||
全部
|
||||
</button>
|
||||
<button class="filter-btn" type="button" data-filter="running" aria-pressed="false">
|
||||
运行中
|
||||
</button>
|
||||
<button class="filter-btn" type="button" data-filter="completed" aria-pressed="false">
|
||||
已完成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="taskEmptyState" class="empty-state" hidden>没有符合条件的任务。</p>
|
||||
|
||||
<ul id="taskList" class="task-list">
|
||||
<li class="task-card" data-status="running">
|
||||
<div class="task-card-top">
|
||||
<h3 class="task-title">数据同步任务</h3>
|
||||
<span class="status-badge status-running">运行中</span>
|
||||
</div>
|
||||
<p class="task-meta">负责人:Agent Alpha</p>
|
||||
<p class="task-meta">截止时间:今天 18:00</p>
|
||||
</li>
|
||||
<li class="task-card" data-status="completed">
|
||||
<div class="task-card-top">
|
||||
<h3 class="task-title">客户工单自动回复</h3>
|
||||
<span class="status-badge status-completed">已完成</span>
|
||||
</div>
|
||||
<p class="task-meta">负责人:Agent Beta</p>
|
||||
<p class="task-meta">完成时间:今天 09:24</p>
|
||||
</li>
|
||||
<li class="task-card" data-status="running">
|
||||
<div class="task-card-top">
|
||||
<h3 class="task-title">每日日志汇总</h3>
|
||||
<span class="status-badge status-running">运行中</span>
|
||||
</div>
|
||||
<p class="task-meta">负责人:Agent Delta</p>
|
||||
<p class="task-meta">截止时间:今天 23:00</p>
|
||||
</li>
|
||||
<li class="task-card" data-status="completed">
|
||||
<div class="task-card-top">
|
||||
<h3 class="task-title">库存预警检测</h3>
|
||||
<span class="status-badge status-completed">已完成</span>
|
||||
</div>
|
||||
<p class="task-meta">负责人:Agent Gamma</p>
|
||||
<p class="task-meta">完成时间:昨天 21:10</p>
|
||||
</li>
|
||||
<li class="task-card" data-status="running">
|
||||
<div class="task-card-top">
|
||||
<h3 class="task-title">周报生成</h3>
|
||||
<span class="status-badge status-running">运行中</span>
|
||||
</div>
|
||||
<p class="task-meta">负责人:Agent Alpha</p>
|
||||
<p class="task-meta">截止时间:本周五 12:00</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="activity" class="activity" aria-labelledby="activity-heading">
|
||||
<div class="section-inner">
|
||||
<h2 id="activity-heading">活动日志</h2>
|
||||
|
||||
<div class="search-field">
|
||||
<label for="activitySearch" class="visually-hidden">搜索活动日志</label>
|
||||
<input
|
||||
type="search"
|
||||
id="activitySearch"
|
||||
class="search-input"
|
||||
placeholder="搜索活动日志,例如“Agent Alpha”或“同步”"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p id="activityEmptyState" class="empty-state" hidden>没有找到匹配的活动记录。</p>
|
||||
|
||||
<ul id="activityList" class="activity-list">
|
||||
<li class="activity-item">
|
||||
<span class="activity-time">10:42</span>
|
||||
<span class="activity-text">Agent Alpha 完成了「数据同步任务」的第 3 阶段</span>
|
||||
</li>
|
||||
<li class="activity-item">
|
||||
<span class="activity-time">10:31</span>
|
||||
<span class="activity-text">Agent Beta 自动回复了 12 条客户工单</span>
|
||||
</li>
|
||||
<li class="activity-item">
|
||||
<span class="activity-time">09:58</span>
|
||||
<span class="activity-text">Agent Gamma 触发了库存预警检测维护流程</span>
|
||||
</li>
|
||||
<li class="activity-item">
|
||||
<span class="activity-time">09:20</span>
|
||||
<span class="activity-text">Agent Delta 生成了每日日志汇总报告</span>
|
||||
</li>
|
||||
<li class="activity-item">
|
||||
<span class="activity-time">08:47</span>
|
||||
<span class="activity-text">系统检测到 Agent Alpha 响应时间恢复正常</span>
|
||||
</li>
|
||||
<li class="activity-item">
|
||||
<span class="activity-time">08:02</span>
|
||||
<span class="activity-text">Agent Delta 启动,开始监听新任务队列</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="section-inner">
|
||||
<p>© 2026 GoodBuddy Control Center. 保留所有权利。</p>
|
||||
<p class="footer-marker">CONTINUE_WEB_DEMO_OK</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="modal-overlay" id="taskModalOverlay" hidden></div>
|
||||
<div
|
||||
class="modal"
|
||||
id="taskModal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="taskModalTitle"
|
||||
hidden
|
||||
>
|
||||
<form id="taskForm" class="modal-form" novalidate>
|
||||
<div class="modal-header">
|
||||
<h2 id="taskModalTitle">添加新任务</h2>
|
||||
<button type="button" id="modalCloseBtn" class="modal-close" aria-label="关闭对话框">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-field">
|
||||
<label for="taskTitleInput">任务名称</label>
|
||||
<input
|
||||
type="text"
|
||||
id="taskTitleInput"
|
||||
name="taskTitle"
|
||||
required
|
||||
maxlength="80"
|
||||
placeholder="例如:生成月度运营报告"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="taskAssigneeSelect">负责人</label>
|
||||
<select id="taskAssigneeSelect" name="taskAssignee">
|
||||
<option value="Agent Alpha">Agent Alpha</option>
|
||||
<option value="Agent Beta">Agent Beta</option>
|
||||
<option value="Agent Gamma">Agent Gamma</option>
|
||||
<option value="Agent Delta">Agent Delta</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="taskStatusSelect">初始状态</label>
|
||||
<select id="taskStatusSelect" name="taskStatus">
|
||||
<option value="running">运行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p id="taskFormError" class="form-error" role="alert" hidden>请填写任务名称。</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" id="modalCancelBtn">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存任务</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,382 +0,0 @@
|
||||
/*!
|
||||
* GoodBuddy Control Center — script.js
|
||||
* Vanilla JS only. No external dependencies.
|
||||
* CONTINUE_WEB_DEMO_OK
|
||||
*/
|
||||
/* global document, window */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
* Utilities
|
||||
* ------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Escapes HTML-sensitive characters in a string so that it is
|
||||
* safe to insert as text content inside markup.
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function qs(selector, scope) {
|
||||
return (scope || document).querySelector(selector);
|
||||
}
|
||||
|
||||
function qsa(selector, scope) {
|
||||
return Array.prototype.slice.call((scope || document).querySelectorAll(selector));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
* Theme toggle (persisted via localStorage)
|
||||
* ------------------------------------------------------- */
|
||||
|
||||
var THEME_STORAGE_KEY = "goodbuddy-theme";
|
||||
|
||||
function initTheme() {
|
||||
var themeToggleBtn = qs("#themeToggleBtn");
|
||||
var iconEl = qs(".theme-toggle-icon", themeToggleBtn);
|
||||
var labelEl = qs(".theme-toggle-label", themeToggleBtn);
|
||||
|
||||
function applyTheme(theme) {
|
||||
var label;
|
||||
if (theme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
themeToggleBtn.setAttribute("aria-pressed", "true");
|
||||
if (iconEl) iconEl.textContent = "☀️";
|
||||
label = "浅色模式";
|
||||
} else {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
themeToggleBtn.setAttribute("aria-pressed", "false");
|
||||
if (iconEl) iconEl.textContent = "🌙";
|
||||
label = "深色模式";
|
||||
}
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
// .theme-toggle-label is visually hidden on narrow viewports and the
|
||||
// icon is aria-hidden, so without an explicit aria-label the button
|
||||
// has no accessible name on mobile. Keep it in sync with the visible
|
||||
// desktop label on every theme change.
|
||||
themeToggleBtn.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
var stored = null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
} catch {
|
||||
/* localStorage unavailable, use the default theme */
|
||||
}
|
||||
|
||||
var prefersDark =
|
||||
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
var initialTheme = stored || (prefersDark ? "dark" : "light");
|
||||
applyTheme(initialTheme);
|
||||
|
||||
themeToggleBtn.addEventListener("click", function () {
|
||||
var isDark = document.documentElement.getAttribute("data-theme") === "dark";
|
||||
var nextTheme = isDark ? "light" : "dark";
|
||||
applyTheme(nextTheme);
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
||||
} catch {
|
||||
/* localStorage unavailable — ignore silently */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
* Mobile navigation toggle
|
||||
* ------------------------------------------------------- */
|
||||
|
||||
function initNavToggle() {
|
||||
var navToggleBtn = qs("#navToggleBtn");
|
||||
var primaryNav = qs("#primaryNav");
|
||||
if (!navToggleBtn || !primaryNav) return;
|
||||
|
||||
navToggleBtn.addEventListener("click", function () {
|
||||
var isOpen = primaryNav.classList.toggle("is-open");
|
||||
navToggleBtn.setAttribute("aria-expanded", String(isOpen));
|
||||
});
|
||||
|
||||
qsa("a", primaryNav).forEach(function (link) {
|
||||
link.addEventListener("click", function () {
|
||||
primaryNav.classList.remove("is-open");
|
||||
navToggleBtn.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
* Task board: filtering + adding tasks
|
||||
* ------------------------------------------------------- */
|
||||
|
||||
var STATUS_LABELS = {
|
||||
running: "运行中",
|
||||
completed: "已完成"
|
||||
};
|
||||
|
||||
var STATUS_BADGE_CLASS = {
|
||||
running: "status-running",
|
||||
completed: "status-completed"
|
||||
};
|
||||
|
||||
function initTaskBoard() {
|
||||
var taskList = qs("#taskList");
|
||||
var filterButtons = qsa(".filter-btn");
|
||||
var emptyState = qs("#taskEmptyState");
|
||||
|
||||
function currentFilter() {
|
||||
var activeBtn = qs(".filter-btn.is-active");
|
||||
return activeBtn ? activeBtn.getAttribute("data-filter") : "all";
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
var filter = currentFilter();
|
||||
var cards = qsa(".task-card", taskList);
|
||||
var visibleCount = 0;
|
||||
|
||||
cards.forEach(function (card) {
|
||||
var status = card.getAttribute("data-status");
|
||||
var matches = filter === "all" || status === filter;
|
||||
card.hidden = !matches;
|
||||
if (matches) visibleCount += 1;
|
||||
});
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.hidden = visibleCount !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
filterButtons.forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
filterButtons.forEach(function (b) {
|
||||
b.classList.remove("is-active");
|
||||
b.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
btn.classList.add("is-active");
|
||||
btn.setAttribute("aria-pressed", "true");
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds a task card list item using safe DOM APIs so that
|
||||
* user-provided text can never be interpreted as markup.
|
||||
*/
|
||||
function createTaskCard(title, assignee, status) {
|
||||
var li = document.createElement("li");
|
||||
li.className = "task-card";
|
||||
li.setAttribute("data-status", status);
|
||||
|
||||
var top = document.createElement("div");
|
||||
top.className = "task-card-top";
|
||||
|
||||
var heading = document.createElement("h3");
|
||||
heading.className = "task-title";
|
||||
// textContent assigns the raw string as plain text — the browser
|
||||
// never parses it as markup, so no HTML-escaping is needed (or
|
||||
// wanted) here. Escaping first would make textContent render the
|
||||
// escaped entities literally (e.g. "<img...") instead of the
|
||||
// user's exact text.
|
||||
heading.textContent = title;
|
||||
|
||||
var badge = document.createElement("span");
|
||||
badge.className = "status-badge " + STATUS_BADGE_CLASS[status];
|
||||
badge.textContent = STATUS_LABELS[status];
|
||||
|
||||
top.appendChild(heading);
|
||||
top.appendChild(badge);
|
||||
|
||||
var meta = document.createElement("p");
|
||||
meta.className = "task-meta";
|
||||
meta.textContent = "负责人:" + assignee;
|
||||
|
||||
var metaTime = document.createElement("p");
|
||||
metaTime.className = "task-meta";
|
||||
var now = new Date();
|
||||
var timeLabel =
|
||||
status === "completed"
|
||||
? "完成时间:刚刚"
|
||||
: "截止时间:" +
|
||||
String(now.getHours()).padStart(2, "0") +
|
||||
":" +
|
||||
String(now.getMinutes()).padStart(2, "0");
|
||||
metaTime.textContent = timeLabel;
|
||||
|
||||
li.appendChild(top);
|
||||
li.appendChild(meta);
|
||||
li.appendChild(metaTime);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
applyFilter();
|
||||
|
||||
return {
|
||||
addTask: function (title, assignee, status) {
|
||||
// Pass the trimmed, un-escaped user text straight through.
|
||||
// createTaskCard assigns it via textContent (never innerHTML),
|
||||
// so it is rendered as inert plain text and can never execute as
|
||||
// markup/script regardless of its contents.
|
||||
var card = createTaskCard(title.trim(), assignee, status);
|
||||
taskList.insertBefore(card, taskList.firstChild);
|
||||
applyFilter();
|
||||
return card;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
* Activity log search
|
||||
* ------------------------------------------------------- */
|
||||
|
||||
function initActivitySearch() {
|
||||
var searchInput = qs("#activitySearch");
|
||||
var activityList = qs("#activityList");
|
||||
var emptyState = qs("#activityEmptyState");
|
||||
if (!searchInput || !activityList) return;
|
||||
|
||||
function runSearch() {
|
||||
var query = searchInput.value.trim().toLowerCase();
|
||||
var items = qsa(".activity-item", activityList);
|
||||
var visibleCount = 0;
|
||||
|
||||
items.forEach(function (item) {
|
||||
var text = item.textContent.toLowerCase();
|
||||
var matches = query === "" || text.indexOf(query) !== -1;
|
||||
item.hidden = !matches;
|
||||
if (matches) visibleCount += 1;
|
||||
});
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.hidden = visibleCount !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
searchInput.addEventListener("input", runSearch);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
* Add-task modal (accessible dialog)
|
||||
* ------------------------------------------------------- */
|
||||
|
||||
function initTaskModal(taskBoard) {
|
||||
var overlay = qs("#taskModalOverlay");
|
||||
var modal = qs("#taskModal");
|
||||
var openBtn = qs("#addTaskBtn");
|
||||
var closeBtn = qs("#modalCloseBtn");
|
||||
var cancelBtn = qs("#modalCancelBtn");
|
||||
var form = qs("#taskForm");
|
||||
var titleInput = qs("#taskTitleInput");
|
||||
var assigneeSelect = qs("#taskAssigneeSelect");
|
||||
var statusSelect = qs("#taskStatusSelect");
|
||||
var errorMsg = qs("#taskFormError");
|
||||
|
||||
if (!overlay || !modal || !openBtn || !form) return;
|
||||
|
||||
var lastFocusedElement = null;
|
||||
|
||||
function getFocusableElements() {
|
||||
return qsa(
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
|
||||
modal
|
||||
).filter(function (el) {
|
||||
return el.offsetParent !== null;
|
||||
});
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
lastFocusedElement = document.activeElement;
|
||||
overlay.hidden = false;
|
||||
modal.hidden = false;
|
||||
errorMsg.hidden = true;
|
||||
form.reset();
|
||||
document.body.style.overflow = "hidden";
|
||||
titleInput.focus();
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
overlay.hidden = true;
|
||||
modal.hidden = true;
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
|
||||
lastFocusedElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Tab") {
|
||||
var focusable = getFocusableElements();
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openBtn.addEventListener("click", openModal);
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
cancelBtn.addEventListener("click", closeModal);
|
||||
overlay.addEventListener("click", closeModal);
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
var title = titleInput.value.trim();
|
||||
if (title === "") {
|
||||
errorMsg.hidden = false;
|
||||
titleInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
errorMsg.hidden = true;
|
||||
taskBoard.addTask(title, assigneeSelect.value, statusSelect.value);
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------
|
||||
* Bootstrap
|
||||
* ------------------------------------------------------- */
|
||||
|
||||
function init() {
|
||||
initTheme();
|
||||
initNavToggle();
|
||||
var taskBoard = initTaskBoard();
|
||||
initActivitySearch();
|
||||
initTaskModal(taskBoard);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Expose escapeHtml for testability / verification purposes only.
|
||||
window.__goodbuddyEscapeHtml = escapeHtml;
|
||||
})();
|
||||
|
||||
// CONTINUE_WEB_DEMO_OK
|
||||
@@ -1,782 +0,0 @@
|
||||
/* =========================================================
|
||||
GoodBuddy Control Center — styles.css
|
||||
========================================================= */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--color-bg: #f4f6fb;
|
||||
--color-bg-elevated: #ffffff;
|
||||
--color-bg-header: rgba(255, 255, 255, 0.92);
|
||||
--color-border: #dde3ee;
|
||||
--color-text: #1c2333;
|
||||
--color-text-muted: #5a6479;
|
||||
--color-primary: #3a5bfd;
|
||||
--color-primary-hover: #2a45d6;
|
||||
--color-primary-contrast: #ffffff;
|
||||
--color-success: #12a454;
|
||||
--color-success-bg: #e4f8ec;
|
||||
--color-warning: #b8860b;
|
||||
--color-warning-bg: #fbf1d9;
|
||||
--color-info: #2a6df4;
|
||||
--color-info-bg: #e6edff;
|
||||
--color-danger: #d43b3b;
|
||||
--color-danger-bg: #fdeaea;
|
||||
--shadow-sm: 0 1px 2px rgba(20, 26, 46, 0.06);
|
||||
--shadow-md: 0 8px 24px rgba(20, 26, 46, 0.08);
|
||||
--shadow-lg: 0 20px 48px rgba(20, 26, 46, 0.16);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 220ms ease;
|
||||
--focus-ring: 0 0 0 3px rgba(58, 91, 253, 0.35);
|
||||
--max-width: 1180px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--color-bg: #0f1220;
|
||||
--color-bg-elevated: #1a1f33;
|
||||
--color-bg-header: rgba(15, 18, 32, 0.9);
|
||||
--color-border: #2b3150;
|
||||
--color-text: #eef1fb;
|
||||
--color-text-muted: #a3aac4;
|
||||
--color-primary: #7b93ff;
|
||||
--color-primary-hover: #93a7ff;
|
||||
--color-primary-contrast: #0f1220;
|
||||
--color-success: #35d17d;
|
||||
--color-success-bg: rgba(53, 209, 125, 0.14);
|
||||
--color-warning: #e8c15b;
|
||||
--color-warning-bg: rgba(232, 193, 91, 0.14);
|
||||
--color-info: #7ea2ff;
|
||||
--color-info-bg: rgba(126, 162, 255, 0.14);
|
||||
--color-danger: #f27272;
|
||||
--color-danger-bg: rgba(242, 114, 114, 0.14);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.55);
|
||||
--focus-ring: 0 0 0 3px rgba(123, 147, 255, 0.45);
|
||||
}
|
||||
|
||||
/* ---------- Reset & base ---------- */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui,
|
||||
-apple-system, sans-serif;
|
||||
line-height: 1.6;
|
||||
transition: background-color var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.section-inner {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* ---------- Accessibility helpers ---------- */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -100px;
|
||||
left: 1rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-contrast);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
z-index: 1000;
|
||||
transition: top var(--transition-fast);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--color-bg-header);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0.85rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--color-primary), #7d5bfd);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.primary-nav {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.primary-nav ul {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.primary-nav a {
|
||||
text-decoration: none;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.primary-nav a:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-toggle-bar {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--color-text);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
padding: 3rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.05rem;
|
||||
max-width: 46ch;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.metric-delta {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-delta--up {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.metric-delta--down {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ---------- Sections generic ---------- */
|
||||
.agents,
|
||||
.tasks,
|
||||
.activity {
|
||||
padding: 2.5rem 0;
|
||||
}
|
||||
|
||||
.agents h2,
|
||||
.tasks h2,
|
||||
.activity h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* ---------- Agent grid ---------- */
|
||||
.agent-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.agent-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.agent-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.agent-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-info-bg);
|
||||
color: var(--color-info);
|
||||
font-weight: 800;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.agent-meta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
/* ---------- Status badges ---------- */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.28rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: var(--color-info-bg);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-idle {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.status-maintenance {
|
||||
background: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
font-weight: 700;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-contrast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* ---------- Tasks ---------- */
|
||||
.tasks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin: 1.25rem 0 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filter-btn.is-active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-contrast);
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.1rem 1.25rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.task-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* ---------- Activity ---------- */
|
||||
.search-field {
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
min-width: 3.2rem;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 1.5rem 0 2.5rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.footer-marker {
|
||||
opacity: 0.55;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 12, 24, 0.5);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(480px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 3rem);
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 201;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal[hidden],
|
||||
.modal-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.15rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-field input,
|
||||
.form-field select {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1.1rem 1.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 900px) {
|
||||
.metrics {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.agent-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.task-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.nav-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.primary-nav {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-elevated);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.primary-nav.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.primary-nav ul {
|
||||
flex-direction: column;
|
||||
padding: 1rem 1.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.primary-nav a {
|
||||
display: block;
|
||||
padding: 0.6rem 0.2rem;
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-toggle-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.agent-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tasks-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Reduced motion ---------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
# 远程消息通道项目与微信 ClawBot 集成 PRD
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已实施,待真实微信账号联调 |
|
||||
| 版本 | 1.4 |
|
||||
| 日期 | 2026-08-09 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 首期范围 | 微信 ClawBot、企业微信、钉钉的通道项目;微信文字、图片和文件 Ask 与 Execute |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入系统管理的通道项目。该信息架构解决了早期通道仅存在于设置页和后台服务中,远程消息进入任务系统后无法持续、明确识别以下信息的问题:
|
||||
|
||||
1. 消息来自哪个平台。
|
||||
2. 当前正在与哪个发送者或群聊对话。
|
||||
3. 远程执行任务使用哪个工作目录。
|
||||
4. 执行请求、处理后端、工具调用和结果归属于哪个范围。
|
||||
|
||||
腾讯官方微信 ClawBot 使用扫码授权,不使用 App ID 或 Secret,并由本机独立 Sidecar 持续运行通信服务。文字、图片和文件收发、只读对话与受控执行已经实现;真实微信账号的认证媒体端到端联调仍作为发布前手动验收。
|
||||
|
||||
为避免把通道来源、发送者身份和执行范围混在一起,本功能使用“通道项目 + 远程会话”的两级结构:
|
||||
|
||||
- 通道项目标识平台并确定默认工作目录、处理后端和默认模式。
|
||||
- 远程会话标识具体发送者或群聊。
|
||||
- 消息记录具体发送者和本次实际使用的模式。
|
||||
- 任务与活动记录执行、工具调用和结果。
|
||||
|
||||
## 2. 已确认的产品决策
|
||||
|
||||
1. 微信 ClawBot、企业微信、钉钉分别对应一个系统管理的通道项目。
|
||||
2. 三个通道项目在通道设置服务初始化时幂等创建,不等待用户完成连接配置。
|
||||
3. Renderer 不直接创建通道项目。Main 进程保证项目存在,设置卡片首次展示时即可引用对应项目。
|
||||
4. 通道项目默认根目录为当前操作系统用户目录。
|
||||
5. 同一通道中的不同私聊用户或群聊分别建立独立远程会话。
|
||||
6. 微信 ClawBot 首期支持个人微信私聊中的文字、图片和文件;语音和视频后续支持。
|
||||
7. 微信卡片可以配置默认“对话”或“执行”模式。
|
||||
8. “对话”映射为 GoodBuddy `Ask`;“执行”映射为 `Execute`。
|
||||
9. 每个通道项目默认使用“模型连接”中的默认直连文本模型,也可以显式选择其他直连文本模型、OpenCode 或 Continue。选择 OpenCode/Continue 时,通道只保存 Runtime 类型,并在每次远程请求开始时动态跟随“Agent Runtime”中的对应全局配置,不维护第二套模型来源或 Runtime 配置。
|
||||
10. 远程 Execute 不显示通道专属请求级或逐工具确认;收到合法消息后立即按所选后端运行。
|
||||
11. 任务仍受工作目录、Runtime 能力、沙箱、能力开关、直连模型工具安全策略和活动审计约束。
|
||||
12. 停用或断开通道不得删除通道项目、远程会话、任务、活动或成果历史。
|
||||
13. 通道项目由系统管理,用户不能永久删除;用户可以修改其工作目录、处理后端和默认模式。
|
||||
|
||||
## 3. 目标
|
||||
|
||||
### 3.1 用户目标
|
||||
|
||||
- 在项目切换器中一眼识别微信、企业微信和钉钉来源。
|
||||
- 在通道项目中区分不同发送者和群聊。
|
||||
- 在设置卡片中完成连接、启停、处理后端、模式和工作目录配置。
|
||||
- 通过微信进行只读问答或发起受控执行任务。
|
||||
- 选择可信的执行后端,并查看完整执行记录。
|
||||
- 保留通道关闭前后的历史上下文和审计记录。
|
||||
|
||||
### 3.2 产品目标
|
||||
|
||||
- 将远程通道纳入 GoodBuddy 现有 Project、Conversation、Task、Activity 和 Artifact 信息架构。
|
||||
- 复用现有 ChannelService 的白名单、去重、并发、取消、输出限制和错误脱敏能力。
|
||||
- 保持 Electron Main、Preload、Renderer 和不可信子进程之间的安全边界。
|
||||
- 为后续语音、视频、多账号和更多通道提供稳定扩展点。
|
||||
|
||||
## 4. 非目标
|
||||
|
||||
首期不包含:
|
||||
|
||||
- 微信群聊。
|
||||
- 微信语音和视频收发。
|
||||
- 多个个人微信账号同时绑定。
|
||||
- 通过微信临时扩大工具权限或安全策略。
|
||||
- 主动群发、营销消息或任意联系人发现。
|
||||
- 将微信会话自动合并进普通本地会话。
|
||||
- 删除或迁移现有企业微信、钉钉历史数据。
|
||||
- 把完整 OpenClaw Runtime 打包进 GoodBuddy。
|
||||
|
||||
## 5. 信息架构
|
||||
|
||||
### 5.1 项目分组
|
||||
|
||||
项目选择器增加“远程通道”分组:
|
||||
|
||||
```text
|
||||
普通项目
|
||||
├─ 默认项目
|
||||
└─ 用户创建的其他项目
|
||||
|
||||
远程通道
|
||||
├─ 微信 ClawBot
|
||||
├─ 企业微信
|
||||
└─ 钉钉
|
||||
```
|
||||
|
||||
每个通道项目持续显示连接状态:
|
||||
|
||||
- 未配置
|
||||
- 已停用
|
||||
- 正在连接
|
||||
- 等待扫码
|
||||
- 已连接
|
||||
- 连接失败
|
||||
|
||||
通道状态不得只通过颜色表达。
|
||||
|
||||
### 5.2 项目、会话和消息关系
|
||||
|
||||
```text
|
||||
通道项目
|
||||
└─ 远程会话
|
||||
└─ 消息
|
||||
└─ 可选任务 / 活动 / 成果
|
||||
```
|
||||
|
||||
职责划分:
|
||||
|
||||
| 层级 | 负责内容 |
|
||||
| --- | --- |
|
||||
| 通道项目 | 平台、连接状态、默认根目录、处理后端、默认模式 |
|
||||
| 远程会话 | 平台账号、私聊用户或群聊、连续上下文 |
|
||||
| 消息 | 具体发送者、本次实际模式、正文、时间和处理状态 |
|
||||
| 任务与活动 | Runtime、工具调用、执行结果和错误 |
|
||||
|
||||
### 5.3 会话命名
|
||||
|
||||
- 微信 ClawBot:优先使用已绑定用户昵称;不可用时显示“我的微信”。
|
||||
- 企业微信私聊:优先使用平台显示名,否则显示脱敏发送者 ID。
|
||||
- 钉钉私聊:优先使用平台显示名,否则显示脱敏发送者 ID。
|
||||
- 群聊:使用群名称;每条消息仍显示具体发送者。
|
||||
- 不得把原始 Token、上下文令牌或完整敏感标识作为会话标题。
|
||||
|
||||
## 6. 通道项目生命周期
|
||||
|
||||
### 6.1 自动创建
|
||||
|
||||
应用初始化通道设置服务时,Main 进程执行 `ensureChannelProjects()`:
|
||||
|
||||
1. 按稳定通道标识查找 `weixin`、`wecom`、`dingtalk` 对应项目。
|
||||
2. 缺失时创建系统管理项目。
|
||||
3. 已存在时复用,不重复创建。
|
||||
4. 如果存在同名普通项目,不得按名称占用或修改该项目。
|
||||
5. 创建失败时保留其他通道可用,并在设置快照中返回有界错误。
|
||||
|
||||
默认值:
|
||||
|
||||
| 通道 | 项目名称 | 根目录 | 处理后端 | 默认模式 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `weixin` | 微信 ClawBot | 用户目录 | 默认直连文本模型 | Ask |
|
||||
| `wecom` | 企业微信 | 用户目录 | 默认直连文本模型 | Ask |
|
||||
| `dingtalk` | 钉钉 | 用户目录 | 默认直连文本模型 | Ask |
|
||||
|
||||
### 6.2 系统管理约束
|
||||
|
||||
- 通道项目不能从普通项目危险区永久删除。
|
||||
- 停用通道不归档项目。
|
||||
- 断开微信不删除项目和历史。
|
||||
- 数据迁移或异常导致项目缺失时,下次初始化自动修复。
|
||||
- 用户可以修改项目根目录、描述、处理后端和默认模式。
|
||||
- 项目名称首期由系统管理,避免来源名称被改到不可识别;后续如允许自定义,必须持续显示通道徽标。
|
||||
|
||||
### 6.3 默认用户目录
|
||||
|
||||
通道项目默认使用操作系统用户目录。设置卡片必须显示完整目录,并在首次启用 Execute 时明确说明该范围可能包含桌面、下载、文档和其他私人文件。
|
||||
|
||||
如果根目录不存在、不可访问或不是目录:
|
||||
|
||||
- Ask 仍可在不读取本地文件的边界内工作。
|
||||
- Execute 不得启动。
|
||||
- 设置卡片显示就近错误和“选择目录”操作。
|
||||
|
||||
## 7. 设置页产品需求
|
||||
|
||||
### 7.1 导航
|
||||
|
||||
设置页标签由“企业通信”改为:
|
||||
|
||||
- 标题:消息通道
|
||||
- 说明:微信 ClawBot、企业微信与钉钉
|
||||
|
||||
### 7.2 卡片通用结构
|
||||
|
||||
三个通道使用与模型设置一致的分段外观页签,并保留
|
||||
`tablist`、`tab`、`tabpanel`、游标焦点和方向键语义。每次只显示一个
|
||||
通道面板,窄窗口下页签单行横向滚动。
|
||||
|
||||
每个通道面板包含:
|
||||
|
||||
1. 通道名称和连接状态。
|
||||
2. 启用开关。
|
||||
3. 默认工作目录。
|
||||
4. 消息处理后端。
|
||||
5. 默认处理模式。
|
||||
6. 对应通道项目。
|
||||
7. 平台特有的连接配置。
|
||||
8. 最近错误或连接状态。
|
||||
|
||||
示意:
|
||||
|
||||
```text
|
||||
微信 ClawBot 已连接
|
||||
|
||||
已绑定账号:微信用户 ****8a3f
|
||||
最近收到消息:今天 14:32
|
||||
|
||||
启用微信通道 [开关]
|
||||
|
||||
默认处理模式
|
||||
[ 对话 ] [ 执行 ]
|
||||
|
||||
默认工作目录
|
||||
C:\Users\用户名 [选择目录]
|
||||
|
||||
消息处理后端
|
||||
[默认模型 · sonnet-5 v]
|
||||
|
||||
通道项目
|
||||
微信 ClawBot
|
||||
|
||||
[断开本机绑定(危险操作)]
|
||||
```
|
||||
|
||||
“对话 / 执行”是互斥状态,应使用语义化分段控件,不使用两个独立复选框。
|
||||
|
||||
### 7.3 模式说明
|
||||
|
||||
- 对话:只读回答,不调用工具或修改内容。
|
||||
- 执行:收到合法消息后立即交给所选后端,可在工作目录内调用已启用工具。
|
||||
|
||||
每个通道面板持续显示风险说明,不弹出一次性确认。默认 Ask 时也要说明白名单
|
||||
发送者仍可通过 `/execute` 临时执行:
|
||||
|
||||
```text
|
||||
远程消息可能要求 GoodBuddy 读取或修改默认工作目录中的内容。
|
||||
执行消息会立即交给所选后端,不再逐次弹窗确认。
|
||||
请只连接可信账号,并将工作目录限制在必要范围。
|
||||
|
||||
默认工作目录:
|
||||
C:\Users\用户名
|
||||
```
|
||||
|
||||
### 7.4 消息处理后端
|
||||
|
||||
每个通道项目提供相同的后端选择,不提供含义不明确的“自动”选项:
|
||||
|
||||
- 直连模型:默认选择“模型连接”中的默认文本连接;列出已配置的文本模型连接,不列出仅支持图像生成的连接。
|
||||
- OpenCode:使用当前 OpenCode Runtime,并动态跟随“Agent Runtime”中的全局 OpenCode 模型来源、自有配置、程序路径和服务地址。
|
||||
- Continue:使用当前 Continue Runtime,并动态跟随“Agent Runtime”中的全局 Continue 模型来源、自有配置和程序路径。
|
||||
|
||||
选择持久化在通道项目上。旧版本保存的 `auto` 选择在启动时迁移为当前默认
|
||||
直连文本模型。模型连接删除、改为图片模型或凭据失效后,优先修复为默认或
|
||||
首个可用文本模型;没有可用文本模型时,UI 明确提示用户完成模型配置或改选
|
||||
Agent Runtime。OpenCode/Continue 的通道项目只持久化 `provider`,旧版残留的
|
||||
通道级 `profileId` 在修复时移除;全局 Agent Runtime 配置变更从下一条远程
|
||||
请求开始生效。远程会话记录通道项目的逻辑后端选择,不复制全局配置或凭据。
|
||||
Execute 启动前检查解析后的后端是否支持工具执行,并返回可处理的配置错误。
|
||||
|
||||
通道面板的后端说明必须明确显示“跟随 Agent Runtime 全局配置”,不得在消息
|
||||
通道设置中重复展示 OpenCode/Continue 的模型来源、自有配置文件或程序路径。
|
||||
“Agent Runtime > 高级设置”中的来源选项、条件配置卡和后续路径字段保持
|
||||
`12px` 区块间距,不能出现卡片边框贴合或内容归属不清。
|
||||
|
||||
### 7.5 通道项目会话界面
|
||||
|
||||
通道项目是系统管理的远程消息范围,不允许创建普通本地会话:
|
||||
|
||||
- 切换到通道项目时,只显示由对应客户端消息创建的远程会话。
|
||||
- 隐藏“新建对话”和 `Ctrl+N` 提示;收到全局新建会话命令时不创建记录。
|
||||
- 尚无远程会话时显示等待首条客户端消息的空状态和设置入口。
|
||||
- 旧版本误建在通道项目中的普通本地会话不参与通道会话列表,但保留其数据。
|
||||
- 远程会话底部说明客户端联动方式,只显示历史、任务和执行结果,不再提及已移除的审批流程。
|
||||
- “任务与活动”页面按会话分组显示远程任务;所有分组首次进入时默认收起,包括进行中、失败和已完成状态,用户可通过原生展开控件查看明细。
|
||||
|
||||
### 7.6 微信扫码绑定
|
||||
|
||||
未绑定时显示“绑定个人微信”。扫码对话框包含:
|
||||
|
||||
- 本地渲染的二维码。
|
||||
- 扫码和手机确认步骤。
|
||||
- 二维码剩余有效时间。
|
||||
- 刷新和取消操作。
|
||||
- 等待扫码、已扫描、需要验证码、已连接、已过期和失败状态。
|
||||
|
||||
二维码过期时不得继续接受旧扫码结果。
|
||||
|
||||
需要配对数字时,在同一对话框中显示验证码输入。验证码不得写入日志或持久化。
|
||||
|
||||
绑定完成后显示“重新绑定”和“断开本机绑定”。“断开本机绑定”使用共享红色
|
||||
危险操作样式,并持续说明该操作只清除本机凭据、不保证解除微信服务端授权,
|
||||
也不删除通道项目、远程会话、任务、活动或成果历史。
|
||||
|
||||
### 7.7 企业微信和钉钉
|
||||
|
||||
企业微信和钉钉继续使用现有凭据表单、环境变量只读覆盖和连接测试,但增加:
|
||||
|
||||
- 通道项目显示。
|
||||
- 消息处理后端。
|
||||
- 默认处理模式。
|
||||
- 默认工作目录。
|
||||
- 打开通道项目。
|
||||
|
||||
现有发送者白名单和群聊提及设置继续有效。
|
||||
|
||||
## 8. 远程会话需求
|
||||
|
||||
### 8.1 会话创建与复用
|
||||
|
||||
收到合法消息后,根据以下稳定键查找会话:
|
||||
|
||||
```text
|
||||
channel + accountId + externalConversationId
|
||||
```
|
||||
|
||||
- 未找到时,在对应通道项目下创建远程会话。
|
||||
- 已找到时继续使用现有会话。
|
||||
- 微信私聊的 `externalConversationId` 首期可由绑定账号和发送者稳定标识组成。
|
||||
- 不得仅按显示名称匹配会话。
|
||||
- 消息重试不得创建重复会话或重复任务。
|
||||
|
||||
### 8.2 新建上下文
|
||||
|
||||
首期不提供手动“新建远程会话”入口,也不把通道项目中的全局“新建对话”
|
||||
命令转为本地会话。客户端首条合法消息按稳定键自动创建远程会话,后续消息
|
||||
继续复用该会话;未来如增加远程上下文重置命令,需要单独定义协议、去重和
|
||||
历史保留语义。
|
||||
|
||||
### 8.3 展示
|
||||
|
||||
最近对话和聊天标题区显示:
|
||||
|
||||
- 通道图标和名称。
|
||||
- 私聊用户或群聊名称。
|
||||
- 当前连接状态。
|
||||
- 默认模式。
|
||||
- 未读状态。
|
||||
|
||||
每条远程消息记录实际模式:
|
||||
|
||||
- Ask
|
||||
- Execute
|
||||
- 执行中
|
||||
- 已完成
|
||||
- 失败
|
||||
|
||||
收到普通远程消息时不得强制切换当前页面。应增加未读标记和全局通知。
|
||||
|
||||
## 9. Ask 与 Execute
|
||||
|
||||
### 9.1 模式解析
|
||||
|
||||
每条消息的模式按以下优先级确定:
|
||||
|
||||
1. 显式 `/ask` 或“对话:”前缀使用 Ask。
|
||||
2. 显式 `/execute`、`/exec` 或“执行:”前缀使用 Execute。
|
||||
3. 没有前缀时使用通道项目的默认模式。
|
||||
|
||||
前缀仅用于选择模式,不进入发送给模型的正文。
|
||||
|
||||
### 9.2 Ask
|
||||
|
||||
- 在 Runtime 边界保持只读。
|
||||
- 不提供工具授权回调,或所有工具请求返回拒绝。
|
||||
- 不修改文件、数据库、系统状态或远程状态。
|
||||
- 结果以有界文字返回原通道并写入远程会话。
|
||||
|
||||
### 9.3 Execute
|
||||
|
||||
Execute 消息通过身份、长度、去重和并发检查后:
|
||||
|
||||
1. 创建并立即启动远程执行任务。
|
||||
2. 使用通道项目当前保存的逻辑处理后端;OpenCode/Continue 在此时解析“Agent Runtime”中的对应全局配置。
|
||||
3. 将项目根目录作为本次 Runtime 工作目录。
|
||||
4. 所选后端不支持工具执行时,不启动任务,并返回设置修复说明。
|
||||
|
||||
远程 Execute 不创建 GoodBuddy 通道专属请求确认或逐工具确认。安全边界由
|
||||
发送者白名单、私聊限制、项目根目录、所选 Runtime、沙箱、能力开关和工具
|
||||
安全策略共同提供。UI 必须持续说明该行为,不能让用户误以为仍会弹窗确认。
|
||||
通道只回传最终结果或可操作的失败信息,不发送“执行已开始”等无操作价值的
|
||||
中间状态消息。
|
||||
|
||||
### 9.4 工具控制
|
||||
|
||||
不同后端按现有行为运行:
|
||||
|
||||
- OpenCode 和 Continue 使用各自的工具系统、能力检查和沙箱配置。
|
||||
- 直连模型只可调用已启用的内置工作区工具及已分配 MCP 工具。
|
||||
- “Execute 自动授权已启用的工具”策略无需逐次确认;“禁止所有工具执行”策略拒绝所有直连模型工具调用。
|
||||
- Runtime 沙箱模式继续有效。
|
||||
- 任何工具结果都进入现有任务和活动审计。
|
||||
|
||||
### 9.5 结果回传
|
||||
|
||||
- 成功:回传有界文字结果。
|
||||
- 当前任务生成的图片可以随最终结果回传;用户明确要求文件时,将当前任务的
|
||||
有界文本结果生成为 Markdown 附件。
|
||||
- 失败:回传经过脱敏、长度受限的用户可处理错误。
|
||||
- 取消:回传“任务已取消”。
|
||||
- 结果投递失败时保留发件箱记录并显示通道错误,不重复执行任务。
|
||||
|
||||
### 9.6 媒体与文件
|
||||
|
||||
- 单条微信消息最多接收或发送 4 个附件,解密后合计不超过 12MB。
|
||||
- 入站仅处理官方图片和文件消息项。Sidecar 下载腾讯 CDN 内容并完成
|
||||
AES-128-ECB 解密,Main 只接收有界字节、文件名、MIME 和大小。
|
||||
- 图片进入现有视觉上下文;文本、代码、PDF 和 Office 文件进入现有不可信
|
||||
文档上下文。不支持的类型显示可处理提示,不把原始 CDN 地址或密钥传给 Runtime。
|
||||
- 入站附件元数据和有界预览写入远程会话,原始字节作为任务处理期间的临时上下文。
|
||||
- 出站生成图片必须来自当前任务的 `generated-image` 事件。
|
||||
- 出站文件只能由 Main 根据当前任务的最终文本结果生成,不接受 Runtime 路径,
|
||||
不读取或发送任意现有工作区文件。
|
||||
- 媒体发件箱在成功投递或达到重试上限后清除二进制负载。
|
||||
|
||||
## 10. 微信 ClawBot 通信架构
|
||||
|
||||
### 10.1 进程边界
|
||||
|
||||
微信通信运行在独立 Node Sidecar 中:
|
||||
|
||||
```text
|
||||
微信
|
||||
↕ 腾讯 iLink HTTPS / CDN
|
||||
微信 Sidecar
|
||||
↕ 严格、有界、可验证的进程协议
|
||||
Main ChannelDriver
|
||||
↕
|
||||
ChannelService
|
||||
↕
|
||||
GoodBuddy Runtime 与工具安全策略
|
||||
```
|
||||
|
||||
禁止:
|
||||
|
||||
- 在 Renderer 中加载微信通信代码。
|
||||
- 向 Renderer 暴露 Token、上下文令牌或原始腾讯响应。
|
||||
- 把腾讯插件直接加载进 Electron Main。
|
||||
- 运行 `openclaw-weixin-cli` 安装器。
|
||||
- 仅为微信通道打包完整 OpenClaw。
|
||||
|
||||
### 10.2 Sidecar 能力
|
||||
|
||||
- 获取和刷新二维码。
|
||||
- 轮询扫码状态。
|
||||
- 提交一次性验证码。
|
||||
- 加载内存中的加密解封凭据。
|
||||
- 长轮询文字、图片和文件消息。
|
||||
- 从腾讯 CDN 有界下载并解密图片和文件。
|
||||
- 调用 `getuploadurl`,加密上传当前任务图片和文件,并发送媒体回复。
|
||||
- 保持会话 `context_token` 和同步游标。
|
||||
- 有界重试、退避、停止和异常退出。
|
||||
|
||||
### 10.3 协议扩展
|
||||
|
||||
现有 `wechat-sidecar-protocol.ts` 需要拆分为两个方向:
|
||||
|
||||
Sidecar 到 Main:
|
||||
|
||||
- `status`
|
||||
- `qr`
|
||||
- `verification_required`
|
||||
- `connected`
|
||||
- `inbound_message`,可包含有界图片或文件
|
||||
- `reply_result`
|
||||
- `fatal_error`
|
||||
|
||||
Main 到 Sidecar:
|
||||
|
||||
- `start_login`
|
||||
- `submit_verification`
|
||||
- `start_account`
|
||||
- `reply`,可包含有界图片或文件
|
||||
- `cancel_reply`
|
||||
- `disconnect`
|
||||
- `shutdown`
|
||||
|
||||
所有消息使用严格 Zod Schema、版本号、最大长度和关联 ID。协议拒绝未知字段。Token、Cookie、Session 和上下文令牌不得出现在普通状态或消息事件中。
|
||||
|
||||
## 11. 凭据与网络安全
|
||||
|
||||
### 11.1 凭据
|
||||
|
||||
- 微信 bot token 使用 Electron `safeStorage` 加密后保存在 Main 管理的设置文件。
|
||||
- 上下文令牌由 Sidecar 运行时持有;如需跨重启保存,必须由 Main 加密持久化。
|
||||
- 凭据不得出现在命令行参数、普通环境变量、stdout、Renderer IPC、通知或错误消息中。
|
||||
- Sidecar 通过私有继承管道接收本次运行所需凭据。
|
||||
- 安全存储不可用时不能完成微信绑定或启用通道。
|
||||
|
||||
### 11.2 网络
|
||||
|
||||
- 扫码入口固定为已审核的腾讯 HTTPS 主机。
|
||||
- 服务端返回的 API 主机和重定向主机必须通过腾讯主机允许列表验证后才能携带 Token 请求。
|
||||
- 不允许明文 HTTP 发送微信凭据。
|
||||
- 全局“内网兼容模式”不得放宽微信凭据端点的 HTTPS 和主机验证。
|
||||
- Sidecar 使用独立环境允许列表并显式启用证书验证,不继承
|
||||
`NODE_TLS_REJECT_UNAUTHORIZED=0`、代理变量、Node 加载钩子或提供商凭据。
|
||||
- 日志中的 URL 移除查询字符串,响应体对 Token 和上下文令牌脱敏。
|
||||
- 媒体下载和上传只允许腾讯微信 HTTPS 主机,所有重定向逐跳重新校验。
|
||||
- CDN 响应按流读取并在解密前后分别执行硬字节限制,不信任 `Content-Length`、
|
||||
文件名、MIME、扩展名或服务端声明的原始大小。
|
||||
|
||||
### 11.3 断开与解绑
|
||||
|
||||
产品区分:
|
||||
|
||||
- 停用:停止收发,保留本地绑定凭据。
|
||||
- 断开本机绑定:停止收发并清除本地凭据;入口使用红色危险操作样式。
|
||||
- 微信端解除绑定:只有腾讯提供并验证服务端撤销能力后才可承诺。
|
||||
|
||||
当前不得把本地清除描述为“已在微信端彻底解绑”。断开后保留通道项目、
|
||||
远程会话、任务、活动和成果历史。
|
||||
|
||||
## 12. 数据与契约建议
|
||||
|
||||
### 12.1 Project
|
||||
|
||||
为项目增加可向后兼容的来源字段:
|
||||
|
||||
```ts
|
||||
type ProjectKind = 'user' | 'channel'
|
||||
type ProjectChannel = 'weixin' | 'wecom' | 'dingtalk'
|
||||
```
|
||||
|
||||
通道项目包含:
|
||||
|
||||
- `kind: 'channel'`
|
||||
- `channel`
|
||||
- `runtimeSelection`
|
||||
- 稳定且唯一的通道绑定
|
||||
|
||||
现有项目迁移为 `kind: 'user'`。不得通过项目名称推断通道。
|
||||
|
||||
### 12.2 Conversation
|
||||
|
||||
增加远程会话映射,至少包含:
|
||||
|
||||
- `conversationId`
|
||||
- `projectId`
|
||||
- `channel`
|
||||
- `accountId`
|
||||
- `externalConversationId`
|
||||
- `conversationType`
|
||||
- 脱敏显示名
|
||||
- 创建和最近消息时间
|
||||
|
||||
唯一约束:
|
||||
|
||||
```text
|
||||
channel + accountId + externalConversationId
|
||||
```
|
||||
|
||||
### 12.3 Channel Settings
|
||||
|
||||
通道公开设置增加:
|
||||
|
||||
- `projectId`
|
||||
- `defaultWorkMode`
|
||||
- `runtimeSelection`
|
||||
- `rootPath`
|
||||
- `status`
|
||||
|
||||
微信私有设置增加加密字段:
|
||||
|
||||
- bot token
|
||||
- bot/account ID
|
||||
- 绑定用户 ID
|
||||
- 经验证的 API base URL
|
||||
|
||||
Renderer 快照只返回是否已配置和脱敏标识。
|
||||
|
||||
### 12.4 任务来源
|
||||
|
||||
远程任务保留:
|
||||
|
||||
- 通道项目 ID。
|
||||
- 远程会话 ID。
|
||||
- 通道。
|
||||
- 脱敏发送者。
|
||||
- 实际工作模式。
|
||||
- 实际消息处理后端。
|
||||
|
||||
任务队列可以复用 delegation 调度分类,但 UI 和活动审计必须依据通道项目与
|
||||
远程会话持续显示真实通道来源,不得呈现为普通本地任务。
|
||||
|
||||
## 13. IPC 与 Preload
|
||||
|
||||
建议增加窄接口:
|
||||
|
||||
- 获取通道设置快照。
|
||||
- 保存通道项目配置。
|
||||
- 开始微信扫码。
|
||||
- 刷新微信扫码。
|
||||
- 提交微信验证码。
|
||||
- 断开微信本地连接。
|
||||
- 订阅微信连接状态。
|
||||
- 打开对应通道项目。
|
||||
|
||||
所有 IPC:
|
||||
|
||||
- 使用共享 Zod Schema 验证。
|
||||
- 验证可信 Renderer sender。
|
||||
- 不接收 Renderer 提供的项目类型或通道身份作为可信事实。
|
||||
- 不返回凭据。
|
||||
|
||||
## 14. 关键异常流程
|
||||
|
||||
### 14.1 项目重名
|
||||
|
||||
存在名为“微信 ClawBot”的普通项目时,仍创建独立通道项目,并通过通道类型而非名称识别。UI 可以显示同名,但必须有“远程通道”分组和微信徽标。
|
||||
|
||||
### 14.2 通道项目缺失
|
||||
|
||||
如果数据库异常或旧版本操作导致绑定项目缺失,Main 初始化时重新创建并修复设置引用。历史会话无法安全迁移时保留原归属并给出诊断,不静默丢弃。
|
||||
|
||||
### 14.3 连接中退出
|
||||
|
||||
- 取消二维码轮询。
|
||||
- 清除内存验证码。
|
||||
- 停止 Sidecar。
|
||||
- 不保存未确认凭据。
|
||||
|
||||
### 14.4 执行期间断线
|
||||
|
||||
通道断开后不得启动新的 Execute。已开始的任务按现有取消策略处理,结果进入本地审计;恢复连接后不得自动重复执行。
|
||||
|
||||
### 14.5 重复消息
|
||||
|
||||
使用稳定平台消息 ID 去重。平台消息 ID 缺失时,使用账号、会话、发送者、时间和内容摘要构造有界稳定键。任务创建和回复必须共用同一个去重声明。
|
||||
|
||||
## 15. 可访问性与响应式
|
||||
|
||||
- 通道状态同时使用文字和图标。
|
||||
- 三个通道使用共享 `PageTabs` 的 `segmented` 外观,保留页签语义和方向键切换。
|
||||
- 模式选择使用语义化单选/分段控件和方向键。
|
||||
- 消息处理后端使用持久标签和分组选项,并说明当前选择的实际行为。
|
||||
- 二维码提供状态文字和备用刷新操作,但不把敏感二维码链接作为可复制文本。
|
||||
- 验证码错误与输入框建立 `aria-describedby` 关联。
|
||||
- 关闭对话框后焦点返回触发按钮。
|
||||
- 窄窗口下卡片单列,二维码对话框保留 16px 外边距。
|
||||
- 浅色、深色和 200% 文字缩放下可完成绑定、后端选择和保存。
|
||||
|
||||
## 16. 验收标准
|
||||
|
||||
### 16.1 通道项目
|
||||
|
||||
- [ ] 新安装首次启动后存在微信 ClawBot、企业微信和钉钉三个通道项目。
|
||||
- [ ] 重启应用不会重复创建通道项目。
|
||||
- [ ] 同名普通项目不会被占用或修改。
|
||||
- [ ] 通道项目默认根目录为当前用户目录,默认模式为 Ask。
|
||||
- [ ] 通道项目在项目选择器的“远程通道”分组中显示。
|
||||
- [ ] 停用或断开通道不会删除项目和历史。
|
||||
- [ ] 普通项目删除流程不能永久删除通道项目。
|
||||
|
||||
### 16.2 设置卡片
|
||||
|
||||
- [ ] 设置标签显示为“消息通道”。
|
||||
- [ ] 三个通道使用与模型设置一致的分段外观页签,并保留完整页签键盘语义。
|
||||
- [ ] 三个面板均显示项目、根目录、消息处理后端、默认模式和连接状态。
|
||||
- [ ] 微信卡片可以完成扫码、过期刷新、验证码和连接状态展示。
|
||||
- [ ] “断开本机绑定”使用共享红色危险操作样式,并明确说明只清除本机凭据、不删除历史或承诺服务端解绑。
|
||||
- [ ] 不显示“自动”后端;首次创建和旧版 `auto` 配置均落到默认直连文本模型。
|
||||
- [ ] 直连模型只列出文本连接,OpenCode 与 Continue 可直接选择。
|
||||
- [ ] 选择 OpenCode/Continue 时只保存 Runtime 类型,每次远程请求动态跟随“Agent Runtime”中的对应全局配置,通道页不出现第二套 Runtime 配置。
|
||||
- [ ] Agent Runtime 高级设置的来源选项、条件配置卡和路径字段之间保持 `12px` 间距且无横向溢出。
|
||||
- [ ] 默认 Execute 时持续显示目录范围和无逐次确认的风险说明。
|
||||
- [ ] Renderer 无法读取任何微信 Token 或上下文令牌。
|
||||
|
||||
### 16.3 会话
|
||||
|
||||
- [ ] 不同通道的消息进入不同通道项目。
|
||||
- [ ] 不同发送者或群聊进入独立远程会话。
|
||||
- [ ] 重复平台事件不会创建重复会话、消息或任务。
|
||||
- [ ] 最近对话、聊天标题和消息均能识别通道与发送者。
|
||||
- [ ] 收到普通消息不会强制切换当前页面。
|
||||
- [ ] 切换到通道项目不会创建普通本地会话。
|
||||
- [ ] 通道项目隐藏“新建对话”和 `Ctrl+N`,全局快捷命令也不创建会话。
|
||||
- [ ] 没有远程会话时显示等待客户端首条消息的空状态。
|
||||
- [ ] “任务与活动”中的会话分组默认收起,进行中、失败和已完成状态行为一致。
|
||||
- [ ] 微信图片和文件显示在对应远程消息中,附件消息无需附带文字。
|
||||
- [ ] 支持的附件进入所选后端现有图片或文档上下文;不支持和超限附件返回明确提示。
|
||||
|
||||
### 16.4 Ask
|
||||
|
||||
- [ ] 普通消息默认按卡片配置进入 Ask。
|
||||
- [ ] Ask 在 Runtime 边界拒绝所有工具。
|
||||
- [ ] 有界结果回传原通道并写入远程会话。
|
||||
|
||||
### 16.5 Execute
|
||||
|
||||
- [ ] 默认 Execute 或显式执行前缀会立即使用通道项目所选后端。
|
||||
- [ ] 不显示通道专属请求级或逐工具确认。
|
||||
- [ ] 直连模型、OpenCode 和 Continue 均按各自能力正确路由。
|
||||
- [ ] 通道不发送“执行已开始”等中间占位消息,只发送最终结果或可操作失败。
|
||||
- [ ] 任务使用对应通道项目根目录。
|
||||
- [ ] Runtime、沙箱、能力和直连模型工具安全策略继续生效。
|
||||
- [ ] 任务、活动、工具、成果和最终结果关联到通道项目与远程会话。
|
||||
|
||||
### 16.6 生命周期与安全
|
||||
|
||||
- [ ] Sidecar 异常退出不会导致 Main 崩溃,并有有界重启限制。
|
||||
- [ ] 应用退出会取消长轮询并停止 Sidecar。
|
||||
- [ ] 微信凭据使用系统安全存储加密。
|
||||
- [ ] 任何普通日志、IPC、错误和通知中不存在凭据。
|
||||
- [ ] Token 只发送到已审核的腾讯 HTTPS 主机。
|
||||
- [ ] CDN 下载、上传和每次重定向只访问腾讯微信 HTTPS 主机。
|
||||
- [ ] 入站和出站媒体最多 4 个、合计不超过 12MB,AES 密钥和 CDN URL 不跨越 Sidecar 边界。
|
||||
- [ ] 只有当前任务生成图片或 Main 从本次最终文本生成的文件可以作为出站附件。
|
||||
|
||||
## 17. 实施阶段
|
||||
|
||||
### 阶段一:通道项目基础
|
||||
|
||||
- Project 数据迁移与通道类型。
|
||||
- 三个通道项目幂等创建。
|
||||
- 项目选择器“远程通道”分组。
|
||||
- 三张设置卡片接入项目、目录和模式配置。
|
||||
- 企业微信、钉钉消息建立独立远程会话。
|
||||
|
||||
### 阶段二:微信文字通道
|
||||
|
||||
- 微信 iLink Sidecar。
|
||||
- 扫码、验证码、加密凭据和生命周期。
|
||||
- 微信文字收发与稳定去重。
|
||||
- 微信远程会话。
|
||||
- Ask 模式。
|
||||
|
||||
### 阶段三:受控 Execute
|
||||
|
||||
- 通道项目处理后端选择与失效修复。
|
||||
- OpenCode/Continue 通道后端动态跟随全局 Agent Runtime 配置。
|
||||
- Execute Runtime 接入。
|
||||
- 直连模型工具安全策略和活动关联。
|
||||
- 结果回传、取消、超时和失败恢复。
|
||||
|
||||
### 阶段四:微信媒体
|
||||
|
||||
- 图片和文件 CDN 下载、AES 解密与有界上下文。
|
||||
- 生成图片和 Main 生成的任务结果文件加密上传与回复。
|
||||
- 远程会话附件展示和媒体发件箱清理。
|
||||
|
||||
### 阶段五:后续扩展
|
||||
|
||||
- 有界语音和视频。
|
||||
- 多微信账号。
|
||||
- 更细的项目路由。
|
||||
- 已验证的微信端解除绑定。
|
||||
|
||||
## 18. 测试要求
|
||||
|
||||
至少覆盖:
|
||||
|
||||
- 数据迁移和通道项目幂等创建。
|
||||
- 同名普通项目隔离。
|
||||
- 设置 Schema 与 Renderer 脱敏快照。
|
||||
- QR 状态机、过期、验证码和非法转换。
|
||||
- Sidecar 双向协议未知字段、超长字段和凭据泄漏拒绝。
|
||||
- 腾讯主机允许列表和重定向校验。
|
||||
- CDN 媒体 AES 加解密、流式大小限制、声明大小校验和恶意重定向拒绝。
|
||||
- 附件持久化展示、现有上下文接入、生成图片回传和任务成果目录隔离。
|
||||
- 消息去重、会话映射、并发和取消。
|
||||
- Ask 工具拒绝。
|
||||
- Execute 使用直连模型、OpenCode 和 Continue 的路由。
|
||||
- OpenCode/Continue 通道只保存 Runtime 类型,并在 Ask 与 Execute 开始时解析当前全局 Agent Runtime 配置。
|
||||
- Execute 不创建通道专属审批,直连模型禁止工具策略仍然生效。
|
||||
- 模型连接删除后的通道后端选择修复。
|
||||
- 发件箱投递失败不重复执行。
|
||||
- 项目选择器、分段页签、后端选择、设置面板和扫码对话框的键盘与无障碍行为。
|
||||
- Runtime 高级设置区块间距、微信断开危险按钮和任务活动默认折叠的 UI 回归。
|
||||
- Windows、macOS、Linux 的默认用户目录和 Sidecar 关闭行为。
|
||||
|
||||
实现完成后运行:
|
||||
|
||||
```text
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 19. 发布条件
|
||||
|
||||
满足以下条件后才可默认向用户提供微信 Execute:
|
||||
|
||||
1. 微信文字 Ask 全流程稳定。
|
||||
2. UI 明确说明远程 Execute 会立即运行,且默认工作目录和处理后端始终可见。
|
||||
3. 凭据不会进入 Renderer、日志或普通子进程参数。
|
||||
4. Sidecar 网络目标和重定向已实施严格允许列表。
|
||||
5. 通道项目和远程会话的来源标识在所有入口持续可见。
|
||||
6. 任务重复投递不会导致重复执行。
|
||||
7. 应用退出、断线和更新过程中不会留下失控执行,直连模型禁止工具策略不能被远程来源绕过。
|
||||
8. 腾讯 iLink 独立宿主使用范围和本地断开语义已完成发布前确认。
|
||||
+6
-4
@@ -16,7 +16,8 @@ Linux x64 和 Linux arm64。
|
||||
- 已实现导航、可访问性快照、点击、输入、选择、返回和有界截图工具。
|
||||
- 浏览器工具只在直连模型的 Execute 模式中提供;用户选择 Execute 即授权
|
||||
本次交互运行,不再逐个弹出 GoodBuddy 工具审批。
|
||||
- 已实现公网 URL、DNS、重定向、私有地址和元数据地址限制。
|
||||
- 支持当前设备可连接的 HTTP、HTTPS、公网、内网、本机和元数据地址,
|
||||
导航及重定向仍经过 DNS 解析和目标地址固定。
|
||||
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
|
||||
空闲回收和应用退出清理。
|
||||
- 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和
|
||||
@@ -75,11 +76,12 @@ Linux x64 和 Linux arm64。
|
||||
|
||||
### P0:右侧没有浏览器实时画面,已修复
|
||||
|
||||
浏览器窗口仍使用 `show: false`,但 BrowserService 现在从模型实际操作的同一
|
||||
浏览器窗口默认使用 `show: false`,但 BrowserService 现在从模型实际操作的同一
|
||||
会话捕获页面帧,并通过受限 IPC 发送状态、当前 URL 和约 220KB 的 JPEG 画面。右侧工作栏
|
||||
新增“浏览器”页签;活动对话启动浏览器时会自动打开该页签,并显示创建中、
|
||||
加载中、操作中、就绪、失败和已停止状态。用户可在页签内立即停止当前对话的
|
||||
浏览器会话。
|
||||
加载中、操作中、用户交互中、就绪、失败和已停止状态。用户可点击“交互”打开
|
||||
同一会话的子窗口辅助 Agent;交互时主窗口暂时禁用,关闭时先刷新最终画面再将
|
||||
浏览器窗口最小化,页面和会话继续保留。用户也可立即停止当前对话的浏览器会话。
|
||||
|
||||
当前实现按导航、快照、点击、输入、选择、返回和截图操作刷新画面,而不是创建
|
||||
第二个预览浏览器,因此显示内容与模型受控页面一致。
|
||||
|
||||
+11
-1
@@ -4,7 +4,17 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
'wechat-sidecar': resolve(
|
||||
'src/main/channels/wechat-sidecar.ts'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
|
||||
Generated
+292
-13
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.8.6",
|
||||
"version": "0.8.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.8.6",
|
||||
"version": "0.8.9",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
@@ -20,6 +20,8 @@
|
||||
"json5": "^2.2.3",
|
||||
"lucide-react": "^1.27.0",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"qrcode": "^1.5.4",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -32,11 +34,14 @@
|
||||
"devDependencies": {
|
||||
"@continuedev/cli": "1.5.47",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/noto-sans-sc": "^5.3.0",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
@@ -1585,6 +1590,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||
"dev": true,
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/noto-sans-sc": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.3.0.tgz",
|
||||
"integrity": "sha512-lNar1dF7Ik/lHNPo/7JWG0TolXY29LtsqYgMvEysooZ5bsO9uH4shJmRrwyJ3PjyTPljhpMJEK0jDuLSU4vJ1w==",
|
||||
"dev": true,
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz",
|
||||
@@ -3122,6 +3147,16 @@
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
@@ -3679,7 +3714,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3689,7 +3723,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -4307,6 +4340,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
@@ -4470,7 +4512,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -4483,7 +4524,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
@@ -4702,6 +4742,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@@ -4863,6 +4912,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dingtalk-stream": {
|
||||
"version": "2.1.6-beta.1",
|
||||
"resolved": "https://registry.npmjs.org/dingtalk-stream/-/dingtalk-stream-2.1.6-beta.1.tgz",
|
||||
@@ -5289,7 +5344,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
@@ -5846,6 +5900,12 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -6133,7 +6193,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -6807,7 +6866,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -7150,6 +7208,25 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
@@ -8783,6 +8860,21 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/parse-entities": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||
@@ -8847,7 +8939,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -9270,6 +9361,150 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
@@ -9299,6 +9534,35 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/quill": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parchment": "^3.0.0",
|
||||
"quill-delta": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-diff": "^1.3.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isequal": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||
@@ -9506,7 +9770,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -9521,6 +9784,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resedit": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||
@@ -9833,6 +10102,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -10074,7 +10349,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -10103,7 +10377,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -11521,6 +11794,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
+14
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.8.6",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
@@ -90,6 +90,14 @@
|
||||
{
|
||||
"from": "node_modules/typescript/LICENSE.txt",
|
||||
"to": "licenses/continuedev-cli-LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/@fontsource-variable/inter/LICENSE",
|
||||
"to": "licenses/inter-OFL-1.1.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/@fontsource-variable/noto-sans-sc/LICENSE",
|
||||
"to": "licenses/noto-sans-sc-OFL-1.1.txt"
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
@@ -140,6 +148,8 @@
|
||||
"json5": "^2.2.3",
|
||||
"lucide-react": "^1.27.0",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"qrcode": "^1.5.4",
|
||||
"quill": "^2.0.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -152,11 +162,14 @@
|
||||
"devDependencies": {
|
||||
"@continuedev/cli": "1.5.47",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/noto-sans-sc": "^5.3.0",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: competitive-positioning
|
||||
version: 1.0.0
|
||||
description: |
|
||||
基于公开、可定位且有日期的来源生成竞品矩阵、差异化定位和销售边界。用于市场
|
||||
分析、产品定位和受控销售准备;不允许推断竞品“不支持”或生成无证据攻击性话术。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
compatibility: Markdown/JSON;需要提供或获准获取竞品公开来源
|
||||
---
|
||||
|
||||
# 竞品与定位分析
|
||||
|
||||
## 必要输入
|
||||
|
||||
- 本产品事实与证据。
|
||||
- 明确的竞品名称、版本、目标市场和比较日期。
|
||||
- 竞品官方文档、发布说明、公开价格页或经批准第三方来源。
|
||||
- 比较目的、受众和允许公开的范围。
|
||||
|
||||
## 公平比较规则
|
||||
|
||||
1. 比较同一时间、版本、部署模式和授权范围。
|
||||
2. 所有产品使用同一组维度和判定标准。
|
||||
3. 每个单元格标记:
|
||||
- `verified`:来源明确支持该结论。
|
||||
- `inferred`:基于有限信息的推论,不能作为确定事实外发。
|
||||
- `unknown`:未找到可验证信息。
|
||||
4. 未公开的信息写 `unknown`,不能写“不支持”。
|
||||
5. 价格必须注明日期、地区、计费单位、版本和附加条件。
|
||||
6. 安全、合规和性能结论必须使用原始证书或测试条件。
|
||||
|
||||
## 输出
|
||||
|
||||
使用 `templates/competitive-positioning.md` 生成:
|
||||
|
||||
- 比较范围和方法。
|
||||
- 竞品能力矩阵。
|
||||
- 来源台账。
|
||||
- 本产品适合赢得和不适合争夺的场景。
|
||||
- 经证据支持的差异化表述。
|
||||
- 销售问答与禁止话术。
|
||||
|
||||
## 定位原则
|
||||
|
||||
- 定位说明“对特定受众,在特定场景下为什么适合”,不是宣布全面领先。
|
||||
- 差异点必须对应用户决策标准和可验证产品事实。
|
||||
- 明确本产品限制,避免销售把定位扩张成产品承诺。
|
||||
- 竞品材料过期、版本不明或来源撤回时,相关结论立即失效。
|
||||
|
||||
## 完成标准
|
||||
|
||||
每个比较结论有来源和日期;未知与不支持严格分开;比较维度一致;没有贬损性、
|
||||
法律风险或未经批准的价格信息;定位与当前产品版本、功能状态和适用边界一致。
|
||||
@@ -0,0 +1,39 @@
|
||||
# {{产品名称}}竞品与定位分析
|
||||
|
||||
**比较日期**:{{YYYY-MM-DD}}
|
||||
**比较范围**:{{市场、版本、部署和授权范围}}
|
||||
**使用限制**:内部 / 受控销售 / 可公开
|
||||
|
||||
## 一、评价维度与方法
|
||||
|
||||
| 维度 | 判定标准 | 数据来源要求 |
|
||||
|---|---|---|
|
||||
| {{维度}} | {{统一标准}} | {{官方文档/测试报告}} |
|
||||
|
||||
## 二、竞品矩阵
|
||||
|
||||
| 维度 | 本产品 | 竞品 A | 竞品 B |
|
||||
|---|---|---|---|
|
||||
| {{维度}} | {{结论 [verified]}} | {{结论 [verified/inferred/unknown]}} | {{结论}} |
|
||||
|
||||
## 三、来源台账
|
||||
|
||||
| 来源 ID | 产品 | 标题 | URL/文档 | 版本 | 日期 | 访问日期 | 权威性 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| SRC-001 | {{产品}} | {{标题}} | {{来源}} | {{版本}} | {{日期}} | {{日期}} | 官方 |
|
||||
|
||||
## 四、定位
|
||||
|
||||
### 适合优先争取的场景
|
||||
|
||||
- {{目标受众 + 场景 + 可验证差异}}
|
||||
|
||||
### 不适合或需谨慎的场景
|
||||
|
||||
- {{本产品限制或竞品明确优势}}
|
||||
|
||||
## 五、销售问答与禁止话术
|
||||
|
||||
| 客户问题 | 有依据的回答 | 来源 | 禁止表述 |
|
||||
|---|---|---|---|
|
||||
| {{问题}} | {{回答}} | {{SRC/CLM ID}} | {{无依据绝对化说法}} |
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: customer-case-study
|
||||
version: 1.0.0
|
||||
description: |
|
||||
基于客户授权、实施记录和可复核指标生成客户案例、成功故事和案例摘要。用于公开
|
||||
宣传、销售材料或受控投标引用;没有披露授权或历史结果时不得生成可发布案例。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
compatibility: Markdown;建议配合 product-evidence
|
||||
---
|
||||
|
||||
# 客户案例
|
||||
|
||||
## 发布前硬门禁
|
||||
|
||||
必须具备:
|
||||
|
||||
- 客户名称、Logo、引语和项目范围的书面披露授权,或明确匿名化要求。
|
||||
- 实施前基线、实施后结果、测量周期、样本和统计口径。
|
||||
- 产品版本、部署范围、客户责任和第三方条件。
|
||||
- 对最终文案、数字和图片的审批责任人。
|
||||
|
||||
缺少任一项时只能输出内部案例草稿和缺口清单,不得生成“已发布”版本。
|
||||
|
||||
## 叙事结构
|
||||
|
||||
1. 客户背景,只保留获准披露的信息。
|
||||
2. 具体任务和实施前状态。
|
||||
3. 方案范围、实施过程和双方责任。
|
||||
4. 产品如何参与工作流,不夸大为单一成功原因。
|
||||
5. 结果、测量方法和限制。
|
||||
6. 客户引语,仅使用获批原文。
|
||||
7. 可复用经验和适用边界。
|
||||
|
||||
使用 `templates/customer-case-study.md` 起草。
|
||||
|
||||
## 指标规则
|
||||
|
||||
- 同时给出基线和结果,不能只给改善百分比。
|
||||
- 说明周期、样本、排除项、数据来源和计算方法。
|
||||
- 区分相关性与因果性,不把同期其他变化归功于产品。
|
||||
- 预测收益、POC 目标和真实生产结果不能混写。
|
||||
- 小样本、人工评分或模型评估必须明确说明。
|
||||
|
||||
## 匿名化
|
||||
|
||||
匿名案例仍需授权。删除或泛化名称、地点、项目编号、截图账号、内部系统名和可
|
||||
反向识别组合信息;匿名化不能改变事实、行业范围和测量口径。
|
||||
|
||||
## 完成标准
|
||||
|
||||
授权范围覆盖全部文字、数字、Logo、引语和图片;案例描述与实施记录一致;指标可
|
||||
复算;产品贡献不过度归因;限制清楚;公开版不含客户隐私、合同信息或内部路径。
|
||||
@@ -0,0 +1,47 @@
|
||||
# {{客户授权名称或匿名描述}}:{{案例主题}}
|
||||
|
||||
> 发布状态:内部草稿 / 客户审核中 / 已批准公开
|
||||
> 授权记录:{{授权文件与范围}}
|
||||
> 产品版本:{{版本}}
|
||||
|
||||
## 客户背景
|
||||
|
||||
{{只写获准披露的行业、规模和业务范围。}}
|
||||
|
||||
## 实施前任务与基线
|
||||
|
||||
| 指标 | 基线 | 周期与样本 | 数据来源 |
|
||||
|---|---:|---|---|
|
||||
| {{指标}} | {{数值}} | {{周期、样本}} | {{来源}} |
|
||||
|
||||
## 方案与实施范围
|
||||
|
||||
- 产品参与:{{工作流中的具体作用}}
|
||||
- 客户责任:{{数据、流程、人员或审核}}
|
||||
- 第三方条件:{{依赖}}
|
||||
- 非范围项:{{不属于本案例的内容}}
|
||||
|
||||
## 实施过程
|
||||
|
||||
{{阶段、关键动作和变更,不写无法核验的戏剧化叙事。}}
|
||||
|
||||
## 结果与测量方法
|
||||
|
||||
| 指标 | 基线 | 结果 | 变化 | 测量条件 | 证据 |
|
||||
|---|---:|---:|---:|---|---|
|
||||
| {{指标}} | {{值}} | {{值}} | {{值}} | {{口径}} | {{EVD-001}} |
|
||||
|
||||
## 客户引语
|
||||
|
||||
> “{{仅使用获批原文}}”
|
||||
|
||||
## 适用边界与经验
|
||||
|
||||
{{限制、样本边界、人工复核要求和可复用经验。}}
|
||||
|
||||
## 发布审批
|
||||
|
||||
| 内容 | 授权范围 | 审批人 | 日期 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| 客户名称/Logo | {{范围}} | {{审批人}} | {{日期}} | {{状态}} |
|
||||
| 指标与引语 | {{范围}} | {{审批人}} | {{日期}} | {{状态}} |
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
id: data-summary
|
||||
name: 数据摘要
|
||||
description: 将用户提供的数据或统计结果压缩为准确、易读的摘要,突出趋势、差异与限制。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 数据
|
||||
- 摘要
|
||||
- 汇报
|
||||
---
|
||||
|
||||
# 数据摘要
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 保留原始单位、时间范围、样本范围和统计口径。
|
||||
- 不补造数值,不隐去影响解释的重要异常或限制。
|
||||
- 使用绝对值与相对变化时,清楚标注基准。
|
||||
- 避免把描述性结果升级为因果结论或普遍规律。
|
||||
|
||||
## 摘要流程
|
||||
|
||||
1. 明确摘要面向的读者和需要回答的问题。
|
||||
2. 识别总量、趋势、结构、差异和异常。
|
||||
3. 核对数字之间的关系及四舍五入口径。
|
||||
4. 按重要性筛选少量关键发现。
|
||||
5. 补充数据质量、样本和解释边界。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- **一句话结论:** 最重要且有数据支持的信息
|
||||
- **关键数字:** 数值、单位、周期和对比基准
|
||||
- **主要趋势:** 方向、幅度和持续时间
|
||||
- **值得关注:** 异常、分组差异或转折点
|
||||
- **限制说明:** 缺失、偏差或不可比较之处
|
||||
|
||||
若用户未提供足够数据,先列出缺口,不以推测代替结果。
|
||||
@@ -0,0 +1,272 @@
|
||||
---
|
||||
name: deai-writing
|
||||
version: 1.1.0
|
||||
description: |
|
||||
中文正式文档「去 AI 味」审校。用于任何需要产出不露 AI 痕迹的正式中文文本:
|
||||
投标方案、技术方案、公司官网文案、研究文章、汇报材料、说明文档、商务邮件。
|
||||
在生成或润色中文正式文档之后调用,也可在评审阶段单独调用做质量门禁。
|
||||
提供可执行的病症词典扫描脚本,把「凭感觉找 AI 味」变成「按清单定位并改写」。
|
||||
触发词:去 AI 味、AI 腔、AI 味、文案审校、润色中文文档、官网文案评审。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Execute
|
||||
compatibility: Python 3.9+,不依赖第三方 Python 包
|
||||
---
|
||||
|
||||
# 中文正式文档「去 AI 味」审校
|
||||
|
||||
AI 味不是玄学,而是一批可枚举、可正则命中、可批量修改的固定套路。所以这件事
|
||||
能做成脚本 + 清单反复调用,不用每次靠人肉感觉。
|
||||
|
||||
## 什么时候用
|
||||
|
||||
- 刚用大模型生成或润色完一份中文正式文档,交付前。
|
||||
- 长文方案编制流程里,接在关键词核验之后、人工通读之前,作为固定质量门禁
|
||||
(见 `longdoc-docx` 技能)。
|
||||
- 官网/产品文案评审,被人挑出「像 AI 写的」但说不清哪里像。
|
||||
|
||||
## 怎么用
|
||||
|
||||
先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux
|
||||
优先使用 `python3`;不要使用未经验证的 Windows `py` 或 WindowsApps
|
||||
`python3.exe`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/deai_scan.py" 方案.md # 单文件
|
||||
<python> "<skill-dir>/deai_scan.py" docs/ --ext .md # 递归目录
|
||||
<python> "<skill-dir>/deai_scan.py" public/ --rules my_site # 叠加项目词典
|
||||
<python> "<skill-dir>/deai_scan.py" 方案.md --json # 结构化输出,喂给模型改写
|
||||
<python> "<skill-dir>/deai_scan.py" 方案.md --fail-on-block # CI 门禁,阻断项非零则退出 1
|
||||
```
|
||||
|
||||
`<skill-dir>` 指本 `SKILL.md` 所在目录。不要假定技能安装在固定路径,项目技能、
|
||||
个人技能和插件技能的安装位置不同。
|
||||
|
||||
输出分两级:
|
||||
|
||||
- **阻断项**:命中即应改写,目标压到接近 0。
|
||||
- **复核项**:只是候选,结合页面类型、事实边界和专业语境逐条判断,**不追求
|
||||
机械清零**。研究文章里的「闭环」如果确有定义就该留着。
|
||||
|
||||
标准迭代:
|
||||
|
||||
1. 先确认文档类型、目标读者、称谓和不能改变的事实边界。
|
||||
2. 扫描源文件,将 JSON 命中清单与原文一起交给 Agent 定向改写。
|
||||
3. 逐条核对改写没有编造数字、删除限制条件或改变责任主体。
|
||||
4. 复扫,直到阻断项收敛;逐条处理复核项,不机械清零。
|
||||
5. 通读全文,检查关键词扫描无法发现的前后矛盾和主体错位。
|
||||
|
||||
扫描器只负责定位,不提供自动替换。语义改写必须由 Agent 结合上下文完成,
|
||||
避免把专业术语、法定提示和事实边界误删。
|
||||
|
||||
脚本默认跳过 Markdown 代码块和 HTML 的 `<script>/<style>/<pre>/<code>`,避免
|
||||
把代码里的 `not ... but` 误判成对照模板。
|
||||
|
||||
## 病征清单
|
||||
|
||||
### 1. 套路化连接词与转折
|
||||
`不仅……而且`、`更是`、`无疑`、`毫无疑问`、`值得注意的是`、`需要指出的是`、
|
||||
`总而言之`、`综上所述`、`总的来说`、`换言之`、`一言以蔽之`、`众所周知`。
|
||||
|
||||
改法:直接删掉这些提示词,把后面的内容当正文说。中文母语写作很少这样起承
|
||||
转合,观点直接给结论。
|
||||
|
||||
### 2. 空心形容词与抽象术语
|
||||
`强大的`、`卓越的`、`高效的`、`全方位`、`一站式`、`赋能`、`助力`、`打造`、
|
||||
`深耕`、`护航`、`保驾护航`、`量身定制`、`极致`、`无缝`。
|
||||
|
||||
改法:换成可验证的具体事实。「强大的性能」→「单卡 141GB 显存,可常驻 3 个
|
||||
模型」;「高效赋能研发」→「代码审查从人工 30 分钟降到自动 2 分钟」。**能用
|
||||
数字或具体动作说清的,绝不用形容词。**
|
||||
|
||||
`闭环`、`底座`、`形成衔接`、`共同约束` 这类抽象搭配要复核:原句若没说明具体
|
||||
组件、关系或动作,改成 `平台`、`连接`、`共同限定`、`由……校验`;确有定义的
|
||||
架构、控制理论或工程语境可以保留。
|
||||
|
||||
**具体数字必须来自可复查证据,不能为替换空心形容词而编造指标。**
|
||||
|
||||
### 3. 排比与三段式强迫症
|
||||
命中信号:连续三项结构完全对称的短语;每段都凑成三点;每个要点长度刻意一致。
|
||||
|
||||
改法:打破对称。该两点就两点,该五点就五点;长短句混用;把排比拆成陈述句。
|
||||
|
||||
### 4. 开头的宏大叙事
|
||||
`随着……的快速发展`、`在……的今天`、`在数字化转型的大背景下`、`当前,……`、
|
||||
`近年来,……`。
|
||||
|
||||
改法:删掉铺垫,第一句直接进入主题。正式方案的读者不需要背景朗诵。
|
||||
|
||||
### 5. 结尾的空洞升华
|
||||
`让我们携手……`、`共同开创……的美好未来`、`为……贡献力量`、`必将……`、
|
||||
`奠定坚实基础`。
|
||||
|
||||
改法:正式文档结尾给可执行结论或下一步动作,不喊口号。
|
||||
|
||||
### 6. 过度自我指涉与礼貌层
|
||||
过量的 `我们`、`我方`、`本方案`、`本系统旨在`、`致力于`;`希望能对您有所帮助`、
|
||||
`如有需要,欢迎随时联系` 这类客服尾巴。
|
||||
|
||||
改法:正式技术文档以事实和系统为主语;删掉客服式收尾。
|
||||
|
||||
### 7. 机械的分点与加粗
|
||||
命中信号:几乎每句话都是一个 bullet;每个 bullet 都加粗前半句做伪标题;
|
||||
`首先/其次/再次/最后` 生硬编号。
|
||||
|
||||
改法:叙述性内容用段落写,列表只留真正并列、需要逐条对照的信息。
|
||||
|
||||
### 8. 中英标点与格式痕迹
|
||||
滥用破折号 `——`;中文里夹英文半角逗号/括号;`:` 后强行分号排比;Emoji;
|
||||
`✅❌🚀` 等符号。
|
||||
|
||||
改法:破折号能换成逗号、括号或分句就换掉;中文全角标点统一;不用 Emoji。
|
||||
|
||||
### 9. 冗余与同义反复
|
||||
`进行了……的操作`、`做出了……的决定`、`起到了……的作用`、`具有……的特点`、
|
||||
`实现了……的功能`。
|
||||
|
||||
改法:把「进行/做出/起到/具有/实现 + 名词」的绕弯结构还原成一个动词。
|
||||
「进行了优化的操作」→「优化了」。
|
||||
|
||||
### 10. 过度对冲与免责
|
||||
`可能`、`或许`、`在某种程度上`、`总体而言`、`一般来说` 的密集堆叠。
|
||||
|
||||
改法:有把握就直说;确需限定的地方保留一处即可。公司官网中的必要边界集中
|
||||
说明一次,优先用正向范围表述,例如「支持在约定数据源与人工复核流程下运行」,
|
||||
避免在标题、正文和 CTA 中反复出现 `不代表`、`不包含`、`尚未`、`不能`。
|
||||
|
||||
**研究方法限制、法定提示、安全边界和人工复核要求不适用上述压缩规则,必须
|
||||
按事实保留。**
|
||||
|
||||
### 11. 对照句式、问答式标题与人为凑数
|
||||
句式:`不是 A,而是 B`、`并非 A,而是 B`、`A,而不是 B`、`不先谈 A,先看 B`。
|
||||
英文的 `not A but B`、`rather than`、`instead of` 同属一类。
|
||||
|
||||
标题:`结果回答了三个具体问题`、`以下四点值得关注`、`三个发现`、
|
||||
`我们需要回答什么`。这类标题只描述文章结构,没有说明本节内容。
|
||||
|
||||
改法:删除对照框架,直接写 B;标题直接写研究对象或结果。
|
||||
|
||||
- 「拆分依据不是模块名称,而是控制复杂度与数据流特征」
|
||||
→ 「PS/PL 分工依据控制复杂度与数据流特征」
|
||||
- 「实测结果回答了三个具体问题」→「正确性、批量性能与时序结果」
|
||||
- 「交付标准围绕任务结果,而不是模型清单」→「以任务结果界定交付标准」
|
||||
|
||||
数量只能来自内容本身。确有三组测量结果时可以列三项,但标题不必强调「有三个
|
||||
问题」。
|
||||
|
||||
### 12. 公司官网写成实施教程
|
||||
命中信号:首屏用 `先把……接入……`、`从一个场景开始`、`第一步先……` 等操作
|
||||
指令;公司介绍围绕实施顺序展开,没有说明服务领域和技术能力。
|
||||
|
||||
改法:首页首屏先回答「公司面向哪些领域、提供什么服务」。实施步骤放到交付
|
||||
方式或产品详情里,不承担公司定位。标题用公司或能力主语,例如「面向专业领域,
|
||||
构建行业智能系统」。
|
||||
|
||||
CTA 应指向项目咨询、合作沟通或联系团队,避免 `按清单准备材料`、`从第一步
|
||||
开始`、`说明当前流程` 这类需求填报或实施指导语言;清单、模板和实施指南只放在
|
||||
明确标注的资料或交付页面。
|
||||
|
||||
首屏说明业务对象、能力范围与交付方式,不展开接口字段、配置步骤、临时文件、
|
||||
异常回退、队列状态和调试过程。必要技术细节下沉到技术说明,**不因去 AI 味而
|
||||
删除**。
|
||||
|
||||
### 13. 元话语标题
|
||||
`本节回答……`、`结果说明了什么`、`需要关注的几个问题`、`我们如何理解……`。
|
||||
标题在评论文章本身,没有给出信息。
|
||||
|
||||
改法:直接写主题、对象、指标或结论范围。研究文章优先用 `测试环境`、
|
||||
`批量性能`、`时序结果`、`适用边界` 等名词性标题。
|
||||
|
||||
慎用以 `把`、`让`、`先`、`再` 开头的操作口令;英文标题避免 `Bring...`、
|
||||
`Start...`、`Let...`、`First...` 祈使句,优先 `Project Consultation`、
|
||||
`Deployment Scope`、`Human Review` 等名词性标题。
|
||||
|
||||
研究和技术报告可以直接陈述测量范围与方法限制,例如「当前测量仅覆盖 INT8
|
||||
点积」,不要套成「这不是完整检索,而只是……」。
|
||||
|
||||
公司官网不公开 `当前基线`、`当前证据`、`已知缺口`、`成熟度等级`、`页面所述`
|
||||
等内部审查语言,改写为客户可理解的适用范围、接入条件和分阶段交付边界;规划
|
||||
能力仍须用将来时或设计阶段表述。
|
||||
|
||||
## 改写纪律
|
||||
|
||||
去 AI 味不是把文本改得干瘪,而是去套路、留信息。四条底线:
|
||||
|
||||
1. **只删套路,不删事实。** 形容词换成数字/动作是「换」不是「删信息」;
|
||||
连接词、铺垫、升华才是直接删。
|
||||
2. **保留专业术语与必要限定。** 技术文档里的约束/前提/风险不是对冲水词,
|
||||
该留;要删的是无意义的「可能、或许」堆叠。
|
||||
3. **不删除事实边界。** 保留研究指标的测试条件与统计口径、第三方来源和归属、
|
||||
人工复核要求、数据与接口条件及部署范围。去 AI 味不能改变成熟度,也不能把
|
||||
规划能力写成已经实现。
|
||||
4. **改完复读一遍出声。** AI 味的本质是结构过于工整、信息密度偏低,出声读
|
||||
最容易发现。
|
||||
|
||||
## 一分钟自查清单(不跑脚本时)
|
||||
|
||||
- 开头有没有「随着/在……的今天」?删。
|
||||
- 有没有「不仅……而且/综上所述/值得注意的是」?删。
|
||||
- 有没有「不是 A,而是 B」或「回答三个问题」式标题?直接写 B 或具体结果。
|
||||
- 公司首页是否写成了实施步骤?改成服务领域、产品能力和公司定位。
|
||||
- CTA 是否像需求填报表?改成项目咨询或合作沟通。
|
||||
- 官网是否出现「当前基线」「已知缺口」等内部审校语言?改成适用范围和交付条件。
|
||||
- 标题是否以 `把/让/先/再` 或 `Bring/Start/Let/First` 发出操作口令?改名词性。
|
||||
- 产品首屏是否塞入接口字段、回退链、调试过程?下沉到技术说明。
|
||||
- 限制是否在同一页面重复出现?合并为一处正向范围说明,同时保留必要的研究、
|
||||
安全和人工复核边界。
|
||||
- 形容词能不能换成数字或具体动作?能就换。
|
||||
- 是不是每段都凑三点、每句都加粗?打破它。
|
||||
- 有没有破折号、Emoji、客服式结尾?清掉。
|
||||
- 出声读一遍:像人说话,还是像念 PPT?
|
||||
|
||||
## 文件构成
|
||||
|
||||
```
|
||||
deai-writing/
|
||||
SKILL.md # 本文件
|
||||
deai_scan.py # 扫描器
|
||||
ai_smell_dict.py # 通用词典(跨项目)
|
||||
project_rules/
|
||||
example.py # 可复制的匿名项目词典模板
|
||||
tests/
|
||||
test_deai_scan.py # 扫描、屏蔽和项目词典回归测试
|
||||
```
|
||||
|
||||
## 持续进化
|
||||
|
||||
每次评审被挑出的新 AI 味用词,回填进词典:
|
||||
|
||||
1. 记录原句、评审意见和最终改法。
|
||||
2. 判断问题属于词语、句式、标题结构还是页面定位。
|
||||
3. 跨项目通用的进 `ai_smell_dict.py`;只与某站点/项目相关的进
|
||||
`project_rules/<项目>.py`,并在该文件的 `REVIEW_LOG` 里追加台账。
|
||||
4. 在整个项目扫描同类表达,不只修改被点名的那一句。
|
||||
5. 中英文同步处理,避免中文已改而英文仍留 `not...but`、`rather than` 或
|
||||
元话语标题。
|
||||
|
||||
新增项目词典:在 `project_rules/` 下新建 `<名字>.py`,导出 `AI_SMELL`、
|
||||
`REVIEW_ONLY`(都可选)和 `REVIEW_LOG`,用 `--rules <名字>` 加载。
|
||||
项目词典只允许上述变量的 Python 字面量赋值,扫描器不会执行其中的函数调用或
|
||||
导入语句。
|
||||
|
||||
共享或导出技能时,只带通用词典和匿名模板。项目专属规则可能包含客户名称、
|
||||
内部措辞和评审记录,不应进入分发包。
|
||||
|
||||
## 验证技能
|
||||
|
||||
```bash
|
||||
<python> -m unittest discover -s "<skill-dir>/tests" -p "test_*.py"
|
||||
<python> "<skill-dir>/deai_scan.py" "<skill-dir>/SKILL.md" --json
|
||||
```
|
||||
|
||||
测试必须覆盖阻断项、复核项、Markdown/HTML 代码区屏蔽、目录扫描和自定义词典。
|
||||
增加或调整规则后先补回归样例,再发布新版本。
|
||||
|
||||
## 已知边界
|
||||
|
||||
- 结构性问题(排比、三段式、每段凑三点)正则只能给候选,最终要人读。
|
||||
- 「不仅……而且」等词单独出现误报率高,词典里已收敛成句式匹配;仍会有误报,
|
||||
阻断项要逐条看过再改,不能盲目全局替换。
|
||||
- 扫描器不判断事实正确性。改写时新引入的数字必须有证据支撑。
|
||||
@@ -0,0 +1,109 @@
|
||||
"""通用「AI 味」病症词典。
|
||||
|
||||
两级:
|
||||
AI_SMELL 阻断项 —— 命中即应改写,目标压到接近 0。
|
||||
REVIEW_ONLY 复核项 —— 只作人工判断候选,不能作为自动删除或发布失败条件。
|
||||
|
||||
词条可以是普通字符串(按子串匹配)或正则(`re` 语法)。扫描器统一用
|
||||
`re.search` 处理,普通字符串里的正则元字符需要自行转义。
|
||||
|
||||
扩充规则:每次评审被挑出的新 AI 味用词,回填到这里;只与单个项目/站点
|
||||
相关的规则不要放这里,放 project_rules/ 下的项目词典。
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 阻断项
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AI_SMELL = {
|
||||
"套路连接词": [
|
||||
# "不仅/而且" 单列误报率过高,收敛成句式匹配(原清单的精确化)
|
||||
r"不仅.{0,40}(而且|并且|还|也)",
|
||||
"更是", "无疑", "毫无疑问", "值得注意的是", "需要指出的是",
|
||||
"总而言之", "综上所述", "总的来说", "换言之", "众所周知",
|
||||
"一言以蔽之",
|
||||
],
|
||||
"对照模板": [
|
||||
r"不是.{0,40}而是", r"并非.{0,40}而是", "而不是",
|
||||
r"不先.{0,30}先", r"下一步是.{0,40}而不是",
|
||||
],
|
||||
"问答式标题": [
|
||||
r"回答了?[一二三四五六七八九十0-9]+个.{0,20}问题",
|
||||
r"以下[一二三四五六七八九十0-9]+[点项个]",
|
||||
r"[一二三四五六七八九十0-9]+个具体问题",
|
||||
r"结果说明了什么", r"需要关注的几个问题",
|
||||
],
|
||||
"实施口号": [
|
||||
r"先把.{0,30}接入", "从单一场景切入", "从一个场景开始",
|
||||
],
|
||||
"英文对照模板": [
|
||||
r"\bnot\b.{0,60}\bbut\b", r"\brather than\b", r"\binstead of\b",
|
||||
],
|
||||
"英文元话语": [
|
||||
r"answers? (three|four|[0-9]+)",
|
||||
"three specific questions", "three findings from",
|
||||
],
|
||||
"空心形容词": [
|
||||
"强大的", "卓越的", "高效的", "全方位", "一站式", "赋能", "助力",
|
||||
"打造", "深耕", "护航", "保驾护航", "量身定制", "极致", "无缝",
|
||||
],
|
||||
"宏大开头": [
|
||||
r"随着.{0,30}(的)?(快速)?发展", "在当今", "在数字化", "近年来",
|
||||
"大背景下",
|
||||
],
|
||||
"空洞升华": [
|
||||
"携手", "美好未来", "贡献力量", "必将", "奠定坚实基础", "开创",
|
||||
],
|
||||
"客服尾巴": [
|
||||
"希望能对您有所帮助", "如有需要", "欢迎随时", "感谢您的",
|
||||
],
|
||||
"绕弯结构": [
|
||||
r"进行了.{0,15}的?(操作|处理|优化|改造|分析)",
|
||||
r"做出了.{0,15}的?决定",
|
||||
r"起到了.{0,15}的?作用",
|
||||
r"具有.{0,20}的特点",
|
||||
r"实现了.{0,20}的功能",
|
||||
],
|
||||
"过度对冲": [
|
||||
"在某种程度上", "总体而言", "一般来说",
|
||||
],
|
||||
"格式痕迹": [
|
||||
"——", "✅", "❌", "🚀", "💡", "🎯", "✨", "🔥", "📌", "⚡",
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 复核项:结合页面类型与专业语境判断,不机械清零
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REVIEW_ONLY = {
|
||||
"内部审校话语": [
|
||||
"当前基线", "当前证据", "已知缺口", "成熟度等级", "页面所述",
|
||||
],
|
||||
"抽象术语": [
|
||||
"闭环", "底座", "形成衔接", "共同约束",
|
||||
],
|
||||
"教程式CTA": [
|
||||
"说明当前流程", "从第一步开始", "按清单准备材料",
|
||||
],
|
||||
"标题口令": [
|
||||
r"^\s*#{1,6}\s*(把|让|先|再)",
|
||||
r"^\s*#{1,6}\s*(Bring|Start|Let|First)\b",
|
||||
],
|
||||
"否定句堆叠": [
|
||||
"不代表", "不包含", "尚未", "不能",
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 结构类近似检测:正则给候选,最终要人读
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STRUCTURE_CHECKS = {
|
||||
# 短句里塞了 4 个以上顿号/逗号,多半是三段式排比或名词堆叠
|
||||
"疑似排比堆叠": {
|
||||
"pattern": r"[,、]",
|
||||
"min_count": 4,
|
||||
"max_line_len": 60,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""中文正式文档「AI 味」扫描器。
|
||||
|
||||
用法:
|
||||
python3 deai_scan.py 方案.md
|
||||
python3 deai_scan.py docs/ --ext .md .html
|
||||
python3 deai_scan.py public/index.html --rules my_site
|
||||
python3 deai_scan.py 方案.md --json # 机器可读,供 agent 二次处理
|
||||
python3 deai_scan.py 方案.md --fail-on-block # 阻断项非零时退出码 1,可做门禁
|
||||
|
||||
Markdown 的代码块(``` 围栏与缩进块)和 HTML 的 <script>/<style>/<pre>/<code>
|
||||
默认跳过,避免把代码里的英文关键字误判成 AI 味。用 --no-skip-code 关闭。
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import bisect
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
RULES_DIR = os.path.join(SKILL_DIR, "project_rules")
|
||||
|
||||
LEVEL_BLOCK = "阻断"
|
||||
LEVEL_REVIEW = "复核"
|
||||
|
||||
|
||||
def _load_module(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_project_rule_data(path):
|
||||
"""Read project rules as literals without executing repository code."""
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
tree = ast.parse(handle.read(), filename=path)
|
||||
allowed = {"AI_SMELL", "REVIEW_ONLY", "REVIEW_LOG"}
|
||||
data = {}
|
||||
for node in tree.body:
|
||||
if (
|
||||
isinstance(node, ast.Expr)
|
||||
and isinstance(node.value, ast.Constant)
|
||||
and isinstance(node.value.value, str)
|
||||
):
|
||||
continue
|
||||
if (
|
||||
isinstance(node, ast.Assign)
|
||||
and len(node.targets) == 1
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id in allowed
|
||||
):
|
||||
try:
|
||||
data[node.targets[0].id] = ast.literal_eval(node.value)
|
||||
except (ValueError, SyntaxError) as exc:
|
||||
raise ValueError(
|
||||
f"项目词典仅允许字面量:{path}"
|
||||
) from exc
|
||||
continue
|
||||
raise ValueError(
|
||||
f"项目词典包含不可执行的语句:{path}:{getattr(node, 'lineno', '?')}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def load_rules(project_rules=None):
|
||||
"""载入通用词典,可选叠加一个项目词典(同名分类合并,不覆盖)。"""
|
||||
base = _load_module(os.path.join(SKILL_DIR, "ai_smell_dict.py"), "ai_smell_dict")
|
||||
block = {k: list(v) for k, v in base.AI_SMELL.items()}
|
||||
review = {k: list(v) for k, v in base.REVIEW_ONLY.items()}
|
||||
structure = dict(getattr(base, "STRUCTURE_CHECKS", {}))
|
||||
|
||||
if project_rules:
|
||||
path = project_rules
|
||||
if not os.path.exists(path):
|
||||
path = os.path.join(RULES_DIR, f"{project_rules}.py")
|
||||
if not os.path.exists(path):
|
||||
available = [f[:-3] for f in sorted(os.listdir(RULES_DIR))
|
||||
if f.endswith(".py") and not f.startswith("_")]
|
||||
sys.exit(f"找不到项目词典 {project_rules!r};可用:{available or '(无)'}")
|
||||
proj = _load_project_rule_data(path)
|
||||
for cat, words in proj.get("AI_SMELL", {}).items():
|
||||
block.setdefault(cat, []).extend(words)
|
||||
for cat, words in proj.get("REVIEW_ONLY", {}).items():
|
||||
review.setdefault(cat, []).extend(words)
|
||||
|
||||
return block, review, structure
|
||||
|
||||
|
||||
def compile_rule_groups(groups):
|
||||
"""Compile rule groups once so directory scans do not recompile per file."""
|
||||
compiled = []
|
||||
for category, rules in groups.items():
|
||||
for rule in rules:
|
||||
try:
|
||||
pattern = re.compile(rule, re.I | re.M | re.S)
|
||||
except re.error as exc:
|
||||
raise ValueError(
|
||||
f"无效正则 {rule!r}({category}):{exc}"
|
||||
) from exc
|
||||
compiled.append((category, rule, pattern))
|
||||
return compiled
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 代码区屏蔽:把不参与扫描的区间用空格替换,保持行号与列偏移不变
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _blank_out(text, pattern, flags=re.S | re.I):
|
||||
def repl(m):
|
||||
return re.sub(r"[^\n]", " ", m.group(0))
|
||||
return re.sub(pattern, repl, text, flags=flags)
|
||||
|
||||
|
||||
def mask_code(text, path):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".md", ".markdown"):
|
||||
text = _blank_out(text, r"```.*?```")
|
||||
text = _blank_out(text, r"~~~.*?~~~")
|
||||
text = _blank_out(text, r"(?m)^(?: {4}|\t).*$", flags=re.M)
|
||||
text = _blank_out(text, r"`[^`\n]+`", flags=0)
|
||||
elif ext in (".html", ".htm", ".xhtml"):
|
||||
for tag in ("script", "style", "pre", "code"):
|
||||
text = _blank_out(text, rf"<{tag}\b.*?</{tag}>")
|
||||
text = _blank_out(text, r"<!--.*?-->")
|
||||
return text
|
||||
|
||||
|
||||
def strip_html_tags(text):
|
||||
"""HTML 文件里把标签本身抹掉,只留可见文本,避免属性名命中词条。"""
|
||||
return _blank_out(text, r"<[^>]+>", flags=re.S)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 扫描
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan_text(
|
||||
text,
|
||||
path,
|
||||
block,
|
||||
review,
|
||||
structure,
|
||||
skip_code=True,
|
||||
compiled_block=None,
|
||||
compiled_review=None,
|
||||
):
|
||||
scan_src = mask_code(text, path) if skip_code else text
|
||||
if os.path.splitext(path)[1].lower() in (".html", ".htm", ".xhtml"):
|
||||
scan_src = strip_html_tags(scan_src)
|
||||
|
||||
lines = scan_src.splitlines()
|
||||
raw_lines = text.splitlines()
|
||||
line_starts = [0]
|
||||
line_starts.extend(match.end() for match in re.finditer("\n", scan_src))
|
||||
findings = []
|
||||
|
||||
compiled_sets = (
|
||||
(LEVEL_BLOCK, compiled_block or compile_rule_groups(block)),
|
||||
(LEVEL_REVIEW, compiled_review or compile_rule_groups(review)),
|
||||
)
|
||||
for level, compiled in compiled_sets:
|
||||
for category, rule, pattern in compiled:
|
||||
for match in pattern.finditer(scan_src):
|
||||
line_number = bisect.bisect_right(line_starts, match.start())
|
||||
raw = (
|
||||
raw_lines[line_number - 1]
|
||||
if line_number <= len(raw_lines)
|
||||
else ""
|
||||
)
|
||||
findings.append({
|
||||
"file": path,
|
||||
"line": line_number,
|
||||
"level": level,
|
||||
"category": category,
|
||||
"rule": rule,
|
||||
"match": match.group(0),
|
||||
"excerpt": raw.strip()[:120],
|
||||
})
|
||||
|
||||
for cat, cfg in structure.items():
|
||||
rx = re.compile(cfg["pattern"])
|
||||
for i, line in enumerate(lines, 1):
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
if len(rx.findall(s)) >= cfg.get("min_count", 4) and len(s) <= cfg.get("max_line_len", 60):
|
||||
findings.append({
|
||||
"file": path,
|
||||
"line": i,
|
||||
"level": LEVEL_REVIEW,
|
||||
"category": cat,
|
||||
"rule": cfg["pattern"],
|
||||
"match": "",
|
||||
"excerpt": s[:120],
|
||||
})
|
||||
|
||||
findings.sort(key=lambda f: (f["line"], f["level"] != LEVEL_BLOCK, f["category"]))
|
||||
return findings
|
||||
|
||||
|
||||
def collect_files(targets, exts):
|
||||
out = []
|
||||
for t in targets:
|
||||
if os.path.isdir(t):
|
||||
for root, _dirs, files in os.walk(t):
|
||||
_dirs[:] = [d for d in _dirs if d not in
|
||||
{".git", "node_modules", ".venv", "__pycache__", "dist", "build"}]
|
||||
for f in sorted(files):
|
||||
if os.path.splitext(f)[1].lower() in exts:
|
||||
out.append(os.path.join(root, f))
|
||||
elif os.path.exists(t):
|
||||
out.append(t)
|
||||
else:
|
||||
print(f"跳过不存在的路径:{t}", file=sys.stderr)
|
||||
return list(dict.fromkeys(out))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="中文正式文档 AI 味扫描")
|
||||
ap.add_argument("targets", nargs="+", help="待扫描的文件或目录")
|
||||
ap.add_argument("--rules", help="项目词典名(project_rules/ 下的模块名)或路径")
|
||||
ap.add_argument("--ext", nargs="+", default=[".md", ".markdown", ".txt", ".html", ".htm"],
|
||||
help="目录递归时纳入的扩展名")
|
||||
ap.add_argument("--json", action="store_true", help="输出 JSON,供 agent 二次处理")
|
||||
ap.add_argument("--block-only", action="store_true", help="只报阻断项")
|
||||
ap.add_argument("--no-skip-code", action="store_true", help="不跳过代码块")
|
||||
ap.add_argument("--fail-on-block", action="store_true", help="存在阻断项时退出码 1")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
block, review, structure = load_rules(args.rules)
|
||||
compiled_block = compile_rule_groups(block)
|
||||
compiled_review = compile_rule_groups(review)
|
||||
except ValueError as exc:
|
||||
sys.exit(str(exc))
|
||||
exts = {e if e.startswith(".") else "." + e for e in args.ext}
|
||||
files = collect_files(args.targets, exts)
|
||||
if not files:
|
||||
sys.exit("没有可扫描的文件")
|
||||
|
||||
all_findings = []
|
||||
errors = []
|
||||
scanned_files = 0
|
||||
for path in files:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except (UnicodeDecodeError, OSError) as e:
|
||||
errors.append({"file": path, "error": str(e)})
|
||||
continue
|
||||
scanned_files += 1
|
||||
all_findings.extend(
|
||||
scan_text(
|
||||
text,
|
||||
path,
|
||||
block,
|
||||
review,
|
||||
structure,
|
||||
skip_code=not args.no_skip_code,
|
||||
compiled_block=compiled_block,
|
||||
compiled_review=compiled_review,
|
||||
)
|
||||
)
|
||||
|
||||
if args.block_only:
|
||||
all_findings = [f for f in all_findings if f["level"] == LEVEL_BLOCK]
|
||||
|
||||
n_block = sum(1 for f in all_findings if f["level"] == LEVEL_BLOCK)
|
||||
n_review = len(all_findings) - n_block
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({
|
||||
"files": scanned_files,
|
||||
"requested_files": len(files),
|
||||
"block": n_block,
|
||||
"review": n_review,
|
||||
"errors": errors,
|
||||
"findings": all_findings,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
cur = None
|
||||
for f in all_findings:
|
||||
if f["file"] != cur:
|
||||
cur = f["file"]
|
||||
print(f"\n=== {cur} ===")
|
||||
hit = f" ← {f['match']}" if f["match"] else ""
|
||||
print(f"[{f['level']}·{f['category']}] L{f['line']}: {f['excerpt']}{hit}")
|
||||
for error in errors:
|
||||
print(f"[读取失败] {error['file']}:{error['error']}", file=sys.stderr)
|
||||
print(f"\n扫描 {scanned_files}/{len(files)} 个文件:阻断项 {n_block},人工复核项 {n_review}")
|
||||
if n_block:
|
||||
print("阻断项须改写到接近 0;复核项结合页面类型与专业语境逐条判断,不机械清零。")
|
||||
|
||||
if errors:
|
||||
sys.exit(2)
|
||||
if args.fail_on_block and n_block:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""匿名项目词典模板。
|
||||
|
||||
复制为有意义的模块名后,用 ``--rules <模块名>`` 加载。项目规则只收录特定
|
||||
站点、文档类型或团队确认过的表达,跨项目通用规则应放回 ai_smell_dict.py。
|
||||
"""
|
||||
|
||||
AI_SMELL = {
|
||||
"项目禁用表达": [
|
||||
r"示例阻断词",
|
||||
],
|
||||
}
|
||||
|
||||
REVIEW_ONLY = {
|
||||
"项目复核表达": [
|
||||
r"示例复核词",
|
||||
],
|
||||
}
|
||||
|
||||
REVIEW_LOG = [
|
||||
{
|
||||
"date": "YYYY-MM-DD",
|
||||
"rejected": "原句",
|
||||
"issue": "评审意见",
|
||||
"fix": "最终改法",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SKILL_DIR))
|
||||
|
||||
import deai_scan
|
||||
|
||||
|
||||
class DeaiScanTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.block, cls.review, cls.structure = deai_scan.load_rules()
|
||||
|
||||
def scan(self, text, suffix=".md"):
|
||||
return deai_scan.scan_text(
|
||||
text,
|
||||
f"sample{suffix}",
|
||||
self.block,
|
||||
self.review,
|
||||
self.structure,
|
||||
)
|
||||
|
||||
def test_reports_block_and_review_findings(self):
|
||||
findings = self.scan("综上所述,方案提供一站式服务。\n当前基线需要人工复核。")
|
||||
levels = {finding["level"] for finding in findings}
|
||||
self.assertEqual(levels, {deai_scan.LEVEL_BLOCK, deai_scan.LEVEL_REVIEW})
|
||||
|
||||
def test_masks_markdown_code(self):
|
||||
findings = self.scan("正文没有问题。\n```text\n综上所述,打造闭环。\n```\n")
|
||||
self.assertEqual(findings, [])
|
||||
|
||||
def test_masks_html_code_and_attributes(self):
|
||||
findings = self.scan(
|
||||
'<div data-note="综上所述">正常正文</div>'
|
||||
"<script>const text = '一站式';</script>",
|
||||
".html",
|
||||
)
|
||||
self.assertEqual(findings, [])
|
||||
|
||||
def test_matches_sentence_across_markdown_line_break(self):
|
||||
findings = self.scan("这不是普通说明,\n而是固定对照模板。")
|
||||
self.assertTrue(
|
||||
any(finding["category"] == "对照模板" for finding in findings)
|
||||
)
|
||||
self.assertEqual(findings[0]["line"], 1)
|
||||
|
||||
def test_loads_custom_rule_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
rules = Path(tmp) / "custom.py"
|
||||
rules.write_text(
|
||||
'AI_SMELL = {"自定义": ["专属阻断词"]}\n'
|
||||
'REVIEW_ONLY = {"自定义复核": ["专属复核词"]}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
block, review, _ = deai_scan.load_rules(str(rules))
|
||||
self.assertIn("专属阻断词", block["自定义"])
|
||||
self.assertIn("专属复核词", review["自定义复核"])
|
||||
|
||||
def test_project_rules_cannot_execute_code(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
rules = Path(tmp) / "custom.py"
|
||||
rules.write_text(
|
||||
'AI_SMELL = {}\nopen("/tmp/should-not-exist", "w")\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "不可执行"):
|
||||
deai_scan.load_rules(str(rules))
|
||||
|
||||
def test_cli_json_and_failure_exit(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = Path(tmp) / "sample.md"
|
||||
target.write_text("综上所述,本方案必将提供卓越的服务。", encoding="utf-8")
|
||||
command = [sys.executable, str(SKILL_DIR / "deai_scan.py"), str(target), "--json"]
|
||||
result = subprocess.run(command, check=True, capture_output=True, text=True)
|
||||
report = json.loads(result.stdout)
|
||||
self.assertGreater(report["block"], 0)
|
||||
|
||||
failed = subprocess.run(
|
||||
command + ["--fail-on-block"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(failed.returncode, 1)
|
||||
|
||||
def test_cli_fails_when_text_file_cannot_be_decoded(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = Path(tmp) / "sample.md"
|
||||
target.write_bytes(b"\xff\xfe\x00")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SKILL_DIR / "deai_scan.py"), str(target), "--json"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertEqual(len(json.loads(result.stdout)["errors"]), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
id: document-writing
|
||||
name: 文档写作
|
||||
description: 协助起草结构清晰、语气专业的中文办公文档,并在信息不足时明确标注待确认内容。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 写作
|
||||
- 文档
|
||||
- 办公
|
||||
---
|
||||
|
||||
# 文档写作
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 先确认文档类型、目标读者、写作目的、语气和篇幅。
|
||||
- 仅依据用户提供的信息写作,不臆造事实、数据、引语或结论。
|
||||
- 信息缺失时使用“待确认”标记,并列出需要补充的问题。
|
||||
- 涉及隐私、机密或敏感信息时,提醒用户审阅并酌情脱敏。
|
||||
|
||||
## 推荐流程
|
||||
|
||||
1. 提炼核心目标与读者需要采取的行动。
|
||||
2. 设计“背景—要点—行动”或适合文体的结构。
|
||||
3. 使用简洁标题、短段落和一致术语完成初稿。
|
||||
4. 检查逻辑、事实边界、语气、格式与可读性。
|
||||
5. 输出成稿,并附简短的待确认事项。
|
||||
|
||||
## 输出要求
|
||||
|
||||
- 默认提供标题、正文和必要的小标题。
|
||||
- 重点结论前置,行动项写明负责人和时间要求(如已知)。
|
||||
- 避免空话、重复表达、夸张承诺和含混指代。
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
id: email-assistant
|
||||
name: 邮件助手
|
||||
description: 协助撰写、改写和回复专业邮件,突出目的、关键信息与明确行动项。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 邮件
|
||||
- 沟通
|
||||
- 办公
|
||||
---
|
||||
|
||||
# 邮件助手
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 明确收件人关系、邮件目的、期望行动、截止时间和语气。
|
||||
- 不编造姓名、职位、承诺、附件内容或已发生的沟通。
|
||||
- 对敏感信息、外部收件人和群发场景提示用户复核。
|
||||
- 避免施压、冒犯、歧义和不必要的冗长表达。
|
||||
|
||||
## 撰写流程
|
||||
|
||||
1. 用具体主题概括事项和所需行动。
|
||||
2. 开头直接说明背景与来意。
|
||||
3. 分点呈现事实、问题或请求。
|
||||
4. 明确下一步、负责人和时间(如已知)。
|
||||
5. 使用与关系和场景相符的结束语。
|
||||
|
||||
## 输出格式
|
||||
|
||||
- **主题:** 简短且可检索。
|
||||
- **正文:** 称呼、目的、要点、行动请求、结束语。
|
||||
- **待确认:** 列出缺失的收件人、日期、附件或事实。
|
||||
|
||||
回复邮件时,应区分已回答问题、尚待确认问题和新增行动项。
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: longdoc-docx
|
||||
version: 1.0.0
|
||||
description: |
|
||||
将多章节 Markdown 构建为排版规范的 Word 长文,并通过临时 PDF 核验排版。用于
|
||||
投标方案、技术方案、白皮书、验收报告等包含封面、目录、表格、图片、代码块和
|
||||
分页规则的中文正式文档。不要用于只需简单复制文本的短文档。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Execute
|
||||
compatibility: Python 3.9+;DOCX 构建需 requirements.txt,PDF 核验需 LibreOffice Writer
|
||||
---
|
||||
|
||||
# Markdown 长文转 Word
|
||||
|
||||
以 Markdown 和图表生成脚本为唯一信源。不要手工修改生成的 DOCX/PDF,修订应回到
|
||||
源文件后重新构建,避免正文、图表、编号和交叉引用失去同步。
|
||||
|
||||
`<skill-dir>` 指本 `SKILL.md` 所在目录,不要假定技能安装在固定路径。
|
||||
|
||||
## 首次准备
|
||||
|
||||
先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux
|
||||
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
```bash
|
||||
<python> -m pip install -r "<skill-dir>/requirements.txt"
|
||||
cp "<skill-dir>/templates/document.example.json" ./document.json
|
||||
```
|
||||
|
||||
编辑 `document.json`,至少填写:
|
||||
|
||||
- `title`、`subtitle`、`author`、`date`
|
||||
- `output`,生成的 DOCX 路径
|
||||
- `chapters`,按最终顺序显式列出 Markdown 文件
|
||||
- 每章的 `page_break_before`,只在真正的一级章节前设为 `true`
|
||||
|
||||
不得依赖目录排序自动拼接正文。大纲、README、评审记录等内部文件不要加入
|
||||
`chapters`。
|
||||
|
||||
## 目录约定
|
||||
|
||||
交付物与核验中间产物必须分处不同目录,避免整目录拷贝时把中间产物一并发出:
|
||||
|
||||
```text
|
||||
build/ # 草稿与中间产物,可随时重建
|
||||
document.json # 构建配置
|
||||
chapters/ # 正文章节,按 01- 02- 前缀命名
|
||||
01-overview.md
|
||||
02-design.md
|
||||
assets/ # 图片与图表脚本产出的 PNG
|
||||
drafts/ # 大纲、评审记录、废弃稿,永不进入 chapters
|
||||
check/ # 核验用 PDF、verification.json、页面 PNG
|
||||
dist/ # 交付物,只存放 DOCX
|
||||
document.docx
|
||||
```
|
||||
|
||||
`output` 指向 `dist/`;PDF、`--json`、`--render-dir` 一律指向 `build/check/`。
|
||||
目录名可随项目调整,但交付物目录内不得出现 PDF、PNG 和核验报告。
|
||||
|
||||
分章节时另有三条约束:
|
||||
|
||||
- 图片路径相对**引用它的 Markdown 文件**解析,不是相对 `document.json`。章节在
|
||||
`chapters/` 而图片在 `assets/` 时,需回退一级再进入 assets 目录。
|
||||
- 章节文件名前缀只用于人工排序,构建顺序完全由 `chapters` 数组决定。改动章节
|
||||
顺序必须改数组,重命名文件不会生效。
|
||||
- `drafts/` 与 `chapters/` 必须分开存放。混在一起时,评审记录和废弃稿极易被
|
||||
误加入 `chapters`,且无法通过目视区分。
|
||||
|
||||
## 标准工作流
|
||||
|
||||
### 1. 核对源文件
|
||||
|
||||
1. 固定标题层级和编号体系,再开始合并。
|
||||
2. 检查 Markdown 图片路径都相对当前 Markdown 文件所在目录可解析。
|
||||
3. 搜索残留 ASCII 流程图和重复代码块,已有正式图片时删除旧占位图。
|
||||
4. 关键设计变化后同步修改图表生成脚本。
|
||||
5. 逐条比对 `chapters` 数组与 `chapters/` 内的实际文件:数组遗漏会静默少章,
|
||||
多余路径会直接构建失败。章节数和顺序都要与目录核对一次。
|
||||
|
||||
如需脚本化绘制中文架构图,可导入 `diagram_kit.py`;先检查字体:
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/diagram_kit.py" --check-font
|
||||
```
|
||||
|
||||
### 2. 构建 DOCX
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/scripts/build_docx.py" --config ./document.json
|
||||
```
|
||||
|
||||
构建器支持标题、普通段落、粗体/斜体/行内代码、嵌套列表、表格、图片、图注、
|
||||
围栏代码块、引用块、封面、目录域和页脚页码。表格按各列内容长度分配宽度,避免
|
||||
长文本列过窄导致页数异常增长。
|
||||
|
||||
目录由 Word 域生成。首次在 Microsoft Word 或 LibreOffice Writer 中打开后需更新
|
||||
目录域,未更新时看到提示文字属于正常情况。
|
||||
|
||||
### 3. 转换 PDF(仅用于核验)
|
||||
|
||||
PDF 是校验中间件,不是交付物。交付物为 DOCX;PDF 只用于第 4、5 步的乱码、
|
||||
空白页和视觉复核,核验通过后应删除,除非用户明确要求交付 PDF。
|
||||
|
||||
```bash
|
||||
soffice --headless --convert-to pdf --outdir ./build/check ./dist/document.docx
|
||||
```
|
||||
|
||||
如果目标路径中已有同名 PDF,先确认它是可重建产物,再由 Agent 按当前工具安全
|
||||
规则处理。不要覆盖用户手工维护的文件。
|
||||
|
||||
### 4. 程序化核验
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/scripts/verify_pdf.py" ./build/check/document.pdf \
|
||||
--forbid "我方" "我们" \
|
||||
--json ./build/check/verification.json \
|
||||
--render-dir ./build/check/pages
|
||||
```
|
||||
|
||||
核验器检查页数、乱码替换符、禁用词和疑似空白页,并可按 300 DPI 渲染逐页 PNG。
|
||||
程序化文本抽取不能证明视觉排版正确,跨页表格尤其可能出现抽取顺序异常。
|
||||
|
||||
### 5. 人工门禁
|
||||
|
||||
- 逐页检查标题孤行、表格跨页、图片清晰度、图注和异常留白。
|
||||
- 可疑文字必须查看 300 DPI 页面图,必要时裁剪放大,不能依据缩略图判断错字。
|
||||
- 核对标题编号、图号、表号、交叉引用和正文设计是否一致。
|
||||
- 检查事实边界、责任主体和前后逻辑,关键词清零不代表内容正确。
|
||||
- 如安装了 `deai-writing` 技能,在 Markdown 源文件上完成扫描和定向改写后,
|
||||
重新走完整构建链路。
|
||||
|
||||
## 完成标准
|
||||
|
||||
只有以下条件全部满足才可交付:
|
||||
|
||||
1. DOCX 可打开,标题、表格、图片和代码块数量符合源文件。
|
||||
2. 核验用 PDF 转换成功,无非预期空白页和 `\ufffd` 乱码。
|
||||
3. 禁用词与项目质量门禁通过。
|
||||
4. 300 DPI 视觉复核通过,图文、编号和交叉引用一致。
|
||||
5. 所有修改已回写 Markdown 或图表脚本,生成产物可重复构建。
|
||||
6. 交付目录只有 DOCX,核验 PDF、报告和页面 PNG 都在中间产物目录内。
|
||||
|
||||
## 文件构成
|
||||
|
||||
```text
|
||||
longdoc-docx/
|
||||
SKILL.md
|
||||
requirements.txt
|
||||
diagram_kit.py
|
||||
scripts/
|
||||
build_docx.py
|
||||
verify_pdf.py
|
||||
templates/
|
||||
document.example.json
|
||||
chapter.example.md
|
||||
tests/
|
||||
test_build_docx.py
|
||||
test_verify_pdf.py
|
||||
```
|
||||
|
||||
## 验证技能
|
||||
|
||||
```bash
|
||||
<python> -m unittest discover -s "<skill-dir>/tests" -p "test_*.py"
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
"""matplotlib 架构图/流程图通用工具箱(中文可用)。
|
||||
|
||||
不用画图工具手绘,用脚本画方框和箭头:改文字就是改字符串;配色字体统一由
|
||||
常量控制;图表能进 git diff,方便 review 措辞变更。
|
||||
|
||||
用法:在你自己的 gen_diagrams.py 里
|
||||
import sys, os
|
||||
sys.path.insert(0, "<skill_dir>")
|
||||
from diagram_kit import box, arrow, new_fig, save, row_layout, NAVY, RED
|
||||
|
||||
def diagram_architecture():
|
||||
fig, ax = new_fig(13, 9.2)
|
||||
box(ax, 0.5, 8.0, 12, 0.8, "接入层")
|
||||
...
|
||||
save(fig, "diagram1-总体技术架构图.png", out_dir=OUT_DIR)
|
||||
|
||||
自检字体:
|
||||
python3 diagram_kit.py --check-font
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # 无显示环境必须
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.font_manager as fm
|
||||
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 中文字体:matplotlib 默认字体不含中文字形,必须显式指定字体文件
|
||||
# 按优先级探测;找不到时报错并给出安装提示,而不是静默输出方块字
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FONT_CANDIDATES = [
|
||||
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"),
|
||||
("/usr/share/fonts/opentype/noto/NotoSansCJK-VF.otf.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-VF.otf.ttc"),
|
||||
("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"),
|
||||
("/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/PingFang.ttc"),
|
||||
("C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/msyhbd.ttc"),
|
||||
]
|
||||
|
||||
FONT_HINT = (
|
||||
"未找到中文字体,图中中文会渲染成方块。安装:\n"
|
||||
" Debian/Ubuntu: apt-get install fonts-noto-cjk\n"
|
||||
" RHEL/CentOS: yum install google-noto-sans-cjk-ttc-fonts\n"
|
||||
"确认:fc-list | grep -i 'noto sans cjk'\n"
|
||||
"也可设环境变量 CJK_FONT_REGULAR / CJK_FONT_BOLD 指向字体文件。"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_fonts():
|
||||
reg = os.environ.get("CJK_FONT_REGULAR")
|
||||
bold = os.environ.get("CJK_FONT_BOLD", reg)
|
||||
if reg and os.path.exists(reg):
|
||||
return reg, (bold if bold and os.path.exists(bold) else reg)
|
||||
for r, b in FONT_CANDIDATES:
|
||||
if os.path.exists(r):
|
||||
return r, (b if os.path.exists(b) else r)
|
||||
return None, None
|
||||
|
||||
|
||||
FONT_PATH, FONT_PATH_BOLD = _resolve_fonts()
|
||||
if FONT_PATH is None:
|
||||
print("WARN: " + FONT_HINT, file=sys.stderr)
|
||||
zh_font = fm.FontProperties()
|
||||
zh_bold = fm.FontProperties(weight="bold")
|
||||
else:
|
||||
zh_font = fm.FontProperties(fname=FONT_PATH)
|
||||
zh_bold = fm.FontProperties(fname=FONT_PATH_BOLD)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配色:中文商务文档惯例(藏青主色 + 红色强调 + 灰阶)
|
||||
# 换主题只改这几个常量,所有图一起变
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NAVY = "#1F3864"
|
||||
NAVY_LIGHT = "#DCE6F1"
|
||||
RED = "#C00000"
|
||||
RED_LIGHT = "#FBE4E4"
|
||||
GRAY = "#595959"
|
||||
GRAY_LIGHT = "#F2F2F2"
|
||||
WHITE = "#FFFFFF"
|
||||
TEXT = "#1a1a1a"
|
||||
|
||||
|
||||
def box(ax, x, y, w, h, text, fc=WHITE, ec=NAVY, lw=1.4, fontsize=10.5,
|
||||
font=None, textcolor=TEXT,
|
||||
boxstyle="round,pad=0.02,rounding_size=0.06", zorder=2):
|
||||
"""圆角方框 + 居中文字。linespacing 保证多行文字换行后不挤在一起。"""
|
||||
b = FancyBboxPatch((x, y), w, h, boxstyle=boxstyle, linewidth=lw,
|
||||
edgecolor=ec, facecolor=fc, zorder=zorder)
|
||||
ax.add_patch(b)
|
||||
ax.text(x + w / 2, y + h / 2, text, ha="center", va="center",
|
||||
fontsize=fontsize, fontproperties=font or zh_font,
|
||||
color=textcolor, zorder=zorder + 1, linespacing=1.4)
|
||||
return b
|
||||
|
||||
|
||||
def arrow(ax, xy_from, xy_to, color=GRAY, lw=1.6, style="-|>",
|
||||
connectionstyle="arc3,rad=0.0", zorder=3):
|
||||
a = FancyArrowPatch(xy_from, xy_to, arrowstyle=style, mutation_scale=14,
|
||||
linewidth=lw, color=color,
|
||||
connectionstyle=connectionstyle, zorder=zorder)
|
||||
ax.add_patch(a)
|
||||
return a
|
||||
|
||||
|
||||
def label(ax, x, y, text, fontsize=9.5, color=GRAY, ha="center", va="center",
|
||||
font=None, zorder=4):
|
||||
"""箭头旁的说明文字、图内小标注。"""
|
||||
return ax.text(x, y, text, ha=ha, va=va, fontsize=fontsize,
|
||||
fontproperties=font or zh_font, color=color, zorder=zorder)
|
||||
|
||||
|
||||
def new_fig(w, h, dpi=200):
|
||||
"""画布坐标系直接等于英寸尺寸,摆位时按网格心算即可。dpi=200 保证放大不糊。"""
|
||||
fig, ax = plt.subplots(figsize=(w, h), dpi=dpi)
|
||||
ax.set_xlim(0, w)
|
||||
ax.set_ylim(0, h)
|
||||
ax.axis("off")
|
||||
return fig, ax
|
||||
|
||||
|
||||
def save(fig, name, out_dir="."):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
path = os.path.join(out_dir, name)
|
||||
fig.savefig(path, bbox_inches="tight", facecolor="white")
|
||||
plt.close(fig)
|
||||
print("saved:", path)
|
||||
return path
|
||||
|
||||
|
||||
def row_layout(n, start_x, total_w, gap=0.25):
|
||||
"""横向等宽切分:返回 n 个 (x, width),用于并列分支摆放。
|
||||
|
||||
for (x, w), g in zip(row_layout(len(groups), 0.5, 12.0), groups):
|
||||
box(ax, x, y0, w, h, g)
|
||||
"""
|
||||
w = (total_w - gap * (n - 1)) / n
|
||||
return [(start_x + i * (w + gap), w) for i in range(n)]
|
||||
|
||||
|
||||
def col_layout(n, top_y, total_h, gap=0.2):
|
||||
"""纵向等高切分:返回 n 个 (y, height),自上而下。"""
|
||||
h = (total_h - gap * (n - 1)) / n
|
||||
return [(top_y - h - i * (h + gap), h) for i in range(n)]
|
||||
|
||||
|
||||
def _check_font():
|
||||
if FONT_PATH is None:
|
||||
print("中文字体:未找到\n" + FONT_HINT)
|
||||
return 1
|
||||
print(f"中文字体:{FONT_PATH}")
|
||||
print(f"粗体: {FONT_PATH_BOLD}")
|
||||
fig, ax = new_fig(6, 2)
|
||||
box(ax, 0.3, 0.5, 5.4, 1.0, "中文字体自检 CJK Font OK 123")
|
||||
out = save(fig, "font_check.png", out_dir="/tmp")
|
||||
print(f"已生成 {out},打开确认中文不是方块。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--check-font" in sys.argv:
|
||||
sys.exit(_check_font())
|
||||
print(__doc__)
|
||||
@@ -0,0 +1,6 @@
|
||||
beautifulsoup4>=4.12,<5
|
||||
Markdown>=3.5,<4
|
||||
matplotlib>=3.8,<4
|
||||
Pillow>=10,<13
|
||||
PyMuPDF>=1.24,<2
|
||||
python-docx>=1.1,<2
|
||||
@@ -0,0 +1,598 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a styled DOCX from an explicit Markdown chapter manifest."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
from docx import Document
|
||||
from docx.enum.section import WD_ORIENT
|
||||
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
from PIL import Image
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
"body_font_zh": "宋体",
|
||||
"body_font_en": "Times New Roman",
|
||||
"heading_font_zh": "黑体",
|
||||
"heading_color": "1F3864",
|
||||
"body_size": 11.5,
|
||||
"toc_depth": 3,
|
||||
"max_image_width_cm": 14.66,
|
||||
}
|
||||
HEADING_SIZES = {1: 18, 2: 15, 3: 13, 4: 12, 5: 11.5, 6: 11.5}
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="将多章节 Markdown 构建为 DOCX")
|
||||
parser.add_argument("--config", required=True, help="document.json 路径")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_config(path):
|
||||
config_path = Path(path).expanduser().resolve()
|
||||
with config_path.open(encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("配置根节点必须是 JSON 对象")
|
||||
for key in ("title", "output"):
|
||||
if not isinstance(config.get(key), str) or not config[key].strip():
|
||||
raise ValueError(f"{key} 必须是非空字符串")
|
||||
if not isinstance(config.get("chapters"), list) or not config["chapters"]:
|
||||
raise ValueError("chapters 必须是非空数组")
|
||||
merged = {**DEFAULTS, **config}
|
||||
if not isinstance(merged["toc_depth"], int) or isinstance(merged["toc_depth"], bool):
|
||||
raise ValueError("toc_depth 必须是整数")
|
||||
for key in ("body_size", "max_image_width_cm"):
|
||||
value = merged[key]
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
|
||||
raise ValueError(f"{key} 必须是正数")
|
||||
return config_path, merged
|
||||
|
||||
|
||||
def rgb(value):
|
||||
value = value.lstrip("#")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{6}", value):
|
||||
raise ValueError(f"颜色必须是六位十六进制值:{value!r}")
|
||||
return RGBColor.from_string(value.upper())
|
||||
|
||||
|
||||
def add_font(
|
||||
run,
|
||||
config,
|
||||
size=None,
|
||||
bold=False,
|
||||
italic=False,
|
||||
color=None,
|
||||
code=False,
|
||||
en_font=None,
|
||||
zh_font=None,
|
||||
):
|
||||
en_font = en_font or ("Consolas" if code else config["body_font_en"])
|
||||
zh_font = zh_font or ("Consolas" if code else config["body_font_zh"])
|
||||
run.font.name = en_font
|
||||
run.font.size = Pt(size or config["body_size"])
|
||||
run.font.bold = bold
|
||||
run.font.italic = italic
|
||||
if color is not None:
|
||||
run.font.color.rgb = color
|
||||
rpr = run._element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = OxmlElement("w:rFonts")
|
||||
rpr.append(rfonts)
|
||||
rfonts.set(qn("w:ascii"), en_font)
|
||||
rfonts.set(qn("w:hAnsi"), en_font)
|
||||
rfonts.set(qn("w:eastAsia"), zh_font)
|
||||
|
||||
|
||||
def add_field(paragraph, instruction, placeholder=None):
|
||||
run = paragraph.add_run()
|
||||
begin = OxmlElement("w:fldChar")
|
||||
begin.set(qn("w:fldCharType"), "begin")
|
||||
instr = OxmlElement("w:instrText")
|
||||
instr.set(qn("xml:space"), "preserve")
|
||||
instr.text = instruction
|
||||
separate = OxmlElement("w:fldChar")
|
||||
separate.set(qn("w:fldCharType"), "separate")
|
||||
end = OxmlElement("w:fldChar")
|
||||
end.set(qn("w:fldCharType"), "end")
|
||||
run._r.append(begin)
|
||||
run._r.append(instr)
|
||||
run._r.append(separate)
|
||||
if placeholder:
|
||||
text = OxmlElement("w:t")
|
||||
text.text = placeholder
|
||||
run._r.append(text)
|
||||
run._r.append(end)
|
||||
|
||||
|
||||
def add_shading(target, fill):
|
||||
properties = (
|
||||
target._tc.get_or_add_tcPr()
|
||||
if hasattr(target, "_tc")
|
||||
else target._p.get_or_add_pPr()
|
||||
)
|
||||
shading = OxmlElement("w:shd")
|
||||
shading.set(qn("w:val"), "clear")
|
||||
shading.set(qn("w:color"), "auto")
|
||||
shading.set(qn("w:fill"), fill)
|
||||
properties.append(shading)
|
||||
|
||||
|
||||
def set_table_borders(table):
|
||||
borders = OxmlElement("w:tblBorders")
|
||||
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||||
element = OxmlElement(f"w:{edge}")
|
||||
element.set(qn("w:val"), "single")
|
||||
element.set(qn("w:sz"), "4")
|
||||
element.set(qn("w:space"), "0")
|
||||
element.set(qn("w:color"), "B0B0B0")
|
||||
borders.append(element)
|
||||
table._tbl.tblPr.append(borders)
|
||||
|
||||
|
||||
def setup_document(doc, config):
|
||||
section = doc.sections[0]
|
||||
if config.get("orientation", "portrait") == "landscape":
|
||||
section.orientation = WD_ORIENT.LANDSCAPE
|
||||
section.page_width = Cm(29.7)
|
||||
section.page_height = Cm(21)
|
||||
else:
|
||||
section.page_width = Cm(21)
|
||||
section.page_height = Cm(29.7)
|
||||
section.top_margin = Cm(config.get("margin_top_cm", 2.54))
|
||||
section.bottom_margin = Cm(config.get("margin_bottom_cm", 2.54))
|
||||
section.left_margin = Cm(config.get("margin_left_cm", 3.17))
|
||||
section.right_margin = Cm(config.get("margin_right_cm", 3.17))
|
||||
|
||||
normal = doc.styles["Normal"]
|
||||
normal.font.name = config["body_font_en"]
|
||||
normal.font.size = Pt(config["body_size"])
|
||||
normal.paragraph_format.line_spacing = config.get("line_spacing", 1.4)
|
||||
normal.paragraph_format.space_after = Pt(8)
|
||||
rpr = normal.element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = OxmlElement("w:rFonts")
|
||||
rpr.append(rfonts)
|
||||
rfonts.set(qn("w:eastAsia"), config["body_font_zh"])
|
||||
|
||||
footer = section.footer.paragraphs[0]
|
||||
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
add_field(footer, "PAGE")
|
||||
|
||||
|
||||
def add_cover(doc, config):
|
||||
if config.get("cover", True) is False:
|
||||
return
|
||||
landscape = config.get("orientation", "portrait") == "landscape"
|
||||
for _ in range(config.get("cover_top_spacers", 3 if landscape else 6)):
|
||||
doc.add_paragraph()
|
||||
for text, size in (
|
||||
(config["title"], 26),
|
||||
(config.get("subtitle", ""), 22),
|
||||
):
|
||||
if not text:
|
||||
continue
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = paragraph.add_run(text)
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=size,
|
||||
bold=True,
|
||||
color=rgb(config["heading_color"]),
|
||||
zh_font=config["heading_font_zh"],
|
||||
)
|
||||
for _ in range(config.get("cover_middle_spacers", 4 if landscape else 8)):
|
||||
doc.add_paragraph()
|
||||
for field in ("author", "date"):
|
||||
text = config.get(field, "")
|
||||
if text:
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
add_font(paragraph.add_run(text), config, size=14)
|
||||
doc.add_page_break()
|
||||
|
||||
|
||||
def add_toc(doc, config):
|
||||
depth = int(config.get("toc_depth", 3))
|
||||
if depth <= 0:
|
||||
return
|
||||
heading = doc.add_paragraph()
|
||||
heading.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = heading.add_run(config.get("toc_title", "目 录"))
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=18,
|
||||
bold=True,
|
||||
color=rgb(config["heading_color"]),
|
||||
zh_font=config["heading_font_zh"],
|
||||
)
|
||||
doc.add_paragraph()
|
||||
paragraph = doc.add_paragraph()
|
||||
add_field(
|
||||
paragraph,
|
||||
f'TOC \\o "1-{depth}" \\h \\z \\u',
|
||||
"右键点击此处选择“更新域”以生成目录",
|
||||
)
|
||||
doc.add_page_break()
|
||||
|
||||
|
||||
def add_inline_runs(paragraph, node, config, bold=False, italic=False):
|
||||
for child in node.children:
|
||||
name = getattr(child, "name", None)
|
||||
if name is None:
|
||||
text = str(child).replace("\n", "")
|
||||
if text:
|
||||
add_font(
|
||||
paragraph.add_run(text),
|
||||
config,
|
||||
bold=bold,
|
||||
italic=italic,
|
||||
)
|
||||
elif name in ("strong", "b"):
|
||||
add_inline_runs(paragraph, child, config, bold=True, italic=italic)
|
||||
elif name in ("em", "i"):
|
||||
add_inline_runs(paragraph, child, config, bold=bold, italic=True)
|
||||
elif name == "code":
|
||||
run = paragraph.add_run(child.get_text())
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=config["body_size"] - 0.5,
|
||||
bold=bold,
|
||||
italic=italic,
|
||||
color=RGBColor(0xA0, 0x30, 0x30),
|
||||
code=True,
|
||||
)
|
||||
elif name == "br":
|
||||
paragraph.add_run().add_break()
|
||||
else:
|
||||
add_inline_runs(paragraph, child, config, bold=bold, italic=italic)
|
||||
|
||||
|
||||
def add_heading(doc, level, text, config):
|
||||
paragraph = doc.add_paragraph(style=f"Heading {min(level, 9)}")
|
||||
paragraph.paragraph_format.keep_with_next = True
|
||||
paragraph.paragraph_format.space_before = Pt(14 if level == 1 else 10)
|
||||
paragraph.paragraph_format.space_after = Pt(8 if level == 1 else 6)
|
||||
run = paragraph.add_run(text)
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=HEADING_SIZES.get(level, 11.5),
|
||||
bold=True,
|
||||
color=rgb(config["heading_color"]) if level <= 2 else RGBColor(0, 0, 0),
|
||||
zh_font=config["heading_font_zh"],
|
||||
)
|
||||
|
||||
|
||||
def add_paragraph(doc, node, config):
|
||||
paragraph = doc.add_paragraph()
|
||||
add_inline_runs(paragraph, node, config)
|
||||
paragraph.paragraph_format.line_spacing = config.get("line_spacing", 1.4)
|
||||
paragraph.paragraph_format.space_after = Pt(8)
|
||||
|
||||
|
||||
def add_list(doc, node, config, level=0):
|
||||
ordered = node.name == "ol"
|
||||
style = "List Number" if ordered else "List Bullet"
|
||||
for item in node.find_all("li", recursive=False):
|
||||
paragraph = doc.add_paragraph(style=style)
|
||||
paragraph.paragraph_format.left_indent = Cm(0.5 + level * 0.6)
|
||||
paragraph.paragraph_format.space_after = Pt(4)
|
||||
for child in item.children:
|
||||
if getattr(child, "name", None) in ("ul", "ol"):
|
||||
continue
|
||||
if getattr(child, "name", None) is None:
|
||||
text = str(child).replace("\n", "")
|
||||
if text:
|
||||
add_font(paragraph.add_run(text), config)
|
||||
else:
|
||||
add_inline_runs(paragraph, child, config)
|
||||
for nested in item.find_all(["ul", "ol"], recursive=False):
|
||||
add_list(doc, nested, config, level + 1)
|
||||
|
||||
|
||||
def display_width(text):
|
||||
return sum(2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 for char in text)
|
||||
|
||||
|
||||
def em_width(text):
|
||||
total = 0.0
|
||||
for char in text:
|
||||
if unicodedata.east_asian_width(char) in ("W", "F"):
|
||||
total += 1.0
|
||||
elif char.isupper() or char.isdigit():
|
||||
total += 0.62
|
||||
else:
|
||||
total += 0.5
|
||||
return total
|
||||
|
||||
|
||||
def compute_col_widths(rows, ncols, content_width_cm, body_size_pt=10.5):
|
||||
lengths = [1] * ncols
|
||||
longest_word = [1] * ncols
|
||||
for row in rows:
|
||||
for index, cell in enumerate(row.find_all(["th", "td"], recursive=False)):
|
||||
if index < ncols:
|
||||
text = cell.get_text(" ", strip=True)
|
||||
lengths[index] = max(lengths[index], min(display_width(text), 160))
|
||||
longest_word[index] = max(
|
||||
longest_word[index],
|
||||
max((em_width(word) for word in text.split()), default=1.0),
|
||||
)
|
||||
em_cm = body_size_pt / 28.35
|
||||
padding_cm = 0.4
|
||||
floors = [
|
||||
min(em_cm * word + padding_cm, content_width_cm / ncols)
|
||||
for word in longest_word
|
||||
]
|
||||
maximum = max(max(floors), content_width_cm * 0.55)
|
||||
widths = [None] * ncols
|
||||
remaining = content_width_cm
|
||||
pending = set(range(ncols))
|
||||
while pending:
|
||||
weight = sum(lengths[i] for i in pending)
|
||||
clamped = False
|
||||
for index in sorted(pending):
|
||||
share = remaining * lengths[index] / weight
|
||||
floor = floors[index]
|
||||
bound = floor if share < floor else (maximum if share > maximum else None)
|
||||
if bound is not None:
|
||||
widths[index] = bound
|
||||
remaining -= bound
|
||||
pending.discard(index)
|
||||
clamped = True
|
||||
break
|
||||
if not clamped:
|
||||
for index in pending:
|
||||
widths[index] = remaining * lengths[index] / weight
|
||||
break
|
||||
total = sum(widths)
|
||||
if total > content_width_cm:
|
||||
widths = [width * content_width_cm / total for width in widths]
|
||||
return widths
|
||||
|
||||
|
||||
def set_col_widths(table, widths):
|
||||
table.autofit = False
|
||||
grid = table._tbl.find(qn("w:tblGrid"))
|
||||
if grid is None:
|
||||
grid = OxmlElement("w:tblGrid")
|
||||
table._tbl.insert(0, grid)
|
||||
else:
|
||||
for child in list(grid):
|
||||
grid.remove(child)
|
||||
for width in widths:
|
||||
column = OxmlElement("w:gridCol")
|
||||
column.set(qn("w:w"), str(int(Cm(width).twips)))
|
||||
grid.append(column)
|
||||
for row in table.rows:
|
||||
cells = row.cells
|
||||
for index, width in enumerate(widths):
|
||||
if index < len(cells):
|
||||
cells[index].width = Cm(width)
|
||||
|
||||
|
||||
def add_table(doc, node, config):
|
||||
rows = node.find_all("tr")
|
||||
if not rows:
|
||||
return
|
||||
ncols = max(len(row.find_all(["th", "td"], recursive=False)) for row in rows)
|
||||
table = doc.add_table(rows=len(rows), cols=ncols)
|
||||
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
section = doc.sections[-1]
|
||||
content_width = (
|
||||
section.page_width.cm - section.left_margin.cm - section.right_margin.cm
|
||||
)
|
||||
table_size = float(config.get("table_size", config["body_size"]))
|
||||
widths = compute_col_widths(rows, ncols, content_width, table_size)
|
||||
set_table_borders(table)
|
||||
for row_index, (row_node, table_row) in enumerate(zip(rows, table.rows)):
|
||||
cell_nodes = row_node.find_all(["th", "td"], recursive=False)
|
||||
table_cells = table_row.cells
|
||||
for column_index, cell_node in enumerate(cell_nodes):
|
||||
if column_index >= len(table_cells):
|
||||
break
|
||||
cell = table_cells[column_index]
|
||||
cell.text = ""
|
||||
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
||||
paragraph = cell.paragraphs[0]
|
||||
is_header = row_index == 0
|
||||
add_inline_runs(paragraph, cell_node, config, bold=is_header)
|
||||
for run in paragraph.runs:
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=table_size,
|
||||
bold=is_header or bool(run.font.bold),
|
||||
italic=bool(run.font.italic),
|
||||
color=RGBColor(255, 255, 255) if is_header else None,
|
||||
)
|
||||
if is_header:
|
||||
add_shading(cell, config["heading_color"].lstrip("#"))
|
||||
elif row_index % 2 == 0:
|
||||
add_shading(cell, "F2F2F2")
|
||||
set_col_widths(table, widths)
|
||||
doc.add_paragraph().paragraph_format.space_after = Pt(4)
|
||||
|
||||
|
||||
def add_code_block(doc, text, config):
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.paragraph_format.left_indent = Cm(0.5)
|
||||
paragraph.paragraph_format.space_before = Pt(4)
|
||||
paragraph.paragraph_format.space_after = Pt(10)
|
||||
lines = text.rstrip("\n").split("\n")
|
||||
for index, line in enumerate(lines):
|
||||
run = paragraph.add_run(line or " ")
|
||||
add_font(
|
||||
run,
|
||||
config,
|
||||
size=9.5,
|
||||
color=RGBColor(0x33, 0x33, 0x33),
|
||||
code=True,
|
||||
)
|
||||
if index < len(lines) - 1:
|
||||
run.add_break()
|
||||
add_shading(paragraph, "F5F5F5")
|
||||
|
||||
|
||||
def add_blockquote(doc, node, config):
|
||||
blocks = [
|
||||
child.get_text(" ", strip=True)
|
||||
for child in node.find_all("p", recursive=False)
|
||||
]
|
||||
if not blocks:
|
||||
blocks = [node.get_text(" ", strip=True)]
|
||||
blocks = [text for text in blocks if text]
|
||||
for index, text in enumerate(blocks):
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.paragraph_format.left_indent = Cm(0.8)
|
||||
paragraph.paragraph_format.space_after = Pt(
|
||||
10 if index == len(blocks) - 1 else 4
|
||||
)
|
||||
run = paragraph.add_run(text)
|
||||
add_font(
|
||||
run, config, size=10.5, italic=True, color=RGBColor(0x40, 0x40, 0x40)
|
||||
)
|
||||
add_shading(paragraph, "F7F7F7")
|
||||
|
||||
|
||||
def add_image(doc, src, base_dir, config):
|
||||
image_path = (base_dir / src).resolve()
|
||||
if not image_path.is_file():
|
||||
raise FileNotFoundError(f"图片不存在:{image_path}")
|
||||
with Image.open(image_path) as image:
|
||||
width_px = image.width
|
||||
dpi = image.info.get("dpi", (150, 150))[0] or 150
|
||||
natural_width_cm = width_px / dpi * 2.54
|
||||
width_cm = min(natural_width_cm, float(config["max_image_width_cm"]))
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
paragraph.add_run().add_picture(str(image_path), width=Cm(width_cm))
|
||||
paragraph.paragraph_format.space_before = Pt(6)
|
||||
paragraph.paragraph_format.space_after = Pt(2)
|
||||
|
||||
|
||||
def add_caption(doc, text, config):
|
||||
paragraph = doc.add_paragraph()
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = paragraph.add_run(text)
|
||||
add_font(run, config, size=10, italic=True, color=RGBColor(0x40, 0x40, 0x40))
|
||||
paragraph.paragraph_format.space_after = Pt(12)
|
||||
|
||||
|
||||
def render_markdown(doc, chapter_path, config):
|
||||
text = chapter_path.read_text(encoding="utf-8")
|
||||
html = markdown.markdown(text, extensions=["tables", "fenced_code"])
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
previous_was_image = False
|
||||
|
||||
for node in soup.find_all(recursive=False):
|
||||
name = node.name
|
||||
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
add_heading(doc, int(name[1]), node.get_text(" ", strip=True), config)
|
||||
elif name == "p":
|
||||
image = node.find("img")
|
||||
if image is not None:
|
||||
if node.get_text(strip=True):
|
||||
raise ValueError(
|
||||
f"图片必须独占 Markdown 段落:{chapter_path}"
|
||||
)
|
||||
add_image(doc, image.get("src", ""), chapter_path.parent, config)
|
||||
previous_was_image = True
|
||||
continue
|
||||
text_value = node.get_text()
|
||||
emphasis = node.find("em")
|
||||
if (
|
||||
previous_was_image
|
||||
and emphasis is not None
|
||||
and node.get_text(strip=True) == emphasis.get_text(strip=True)
|
||||
):
|
||||
add_caption(doc, emphasis.get_text(" ", strip=True), config)
|
||||
else:
|
||||
if text_value.strip():
|
||||
add_paragraph(doc, node, config)
|
||||
elif name in ("ul", "ol"):
|
||||
add_list(doc, node, config)
|
||||
elif name == "table":
|
||||
add_table(doc, node, config)
|
||||
elif name == "blockquote":
|
||||
add_blockquote(doc, node, config)
|
||||
elif name == "pre":
|
||||
add_code_block(doc, node.get_text(), config)
|
||||
elif name == "hr":
|
||||
paragraph = doc.add_paragraph("─" * 40)
|
||||
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
else:
|
||||
if node.get_text(strip=True):
|
||||
add_paragraph(doc, node, config)
|
||||
previous_was_image = False
|
||||
|
||||
|
||||
def chapter_entries(config, base_dir):
|
||||
entries = []
|
||||
for raw in config["chapters"]:
|
||||
if isinstance(raw, str):
|
||||
raw = {"path": raw}
|
||||
if (
|
||||
not isinstance(raw, dict)
|
||||
or not isinstance(raw.get("path"), str)
|
||||
or not raw["path"].strip()
|
||||
):
|
||||
raise ValueError("chapters 的每一项必须是路径字符串或包含 path 的对象")
|
||||
if "page_break_before" in raw and not isinstance(
|
||||
raw["page_break_before"], bool
|
||||
):
|
||||
raise ValueError("page_break_before 必须是布尔值")
|
||||
path = (base_dir / raw["path"]).resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"章节不存在:{path}")
|
||||
entries.append((path, bool(raw.get("page_break_before", False))))
|
||||
return entries
|
||||
|
||||
|
||||
def build(config_path, config):
|
||||
base_dir = config_path.parent
|
||||
chapters = chapter_entries(config, base_dir)
|
||||
output = (base_dir / config["output"]).resolve()
|
||||
if output.suffix.lower() != ".docx":
|
||||
raise ValueError("output 必须使用 .docx 扩展名")
|
||||
protected_paths = {config_path, *(chapter for chapter, _ in chapters)}
|
||||
if output in protected_paths:
|
||||
raise ValueError("output 不能覆盖配置文件或 Markdown 源文件")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
doc = Document()
|
||||
setup_document(doc, config)
|
||||
add_cover(doc, config)
|
||||
add_toc(doc, config)
|
||||
for index, (chapter, page_break_before) in enumerate(chapters):
|
||||
if page_break_before and index > 0:
|
||||
doc.add_page_break()
|
||||
render_markdown(doc, chapter, config)
|
||||
doc.save(output)
|
||||
return output
|
||||
|
||||
|
||||
def main():
|
||||
config_path, config = load_config(parse_args().config)
|
||||
output = build(config_path, config)
|
||||
print(f"saved: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify PDF text gates, blank pages, and optionally render page previews."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import fitz
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="核验长文 PDF 产物")
|
||||
parser.add_argument("pdf", help="待核验 PDF")
|
||||
parser.add_argument("--forbid", nargs="*", default=[], help="禁用关键词")
|
||||
parser.add_argument(
|
||||
"--allow-blank-page",
|
||||
action="append",
|
||||
type=int,
|
||||
default=[],
|
||||
help="允许为空白的页码,可重复指定",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-text-chars",
|
||||
type=int,
|
||||
default=30,
|
||||
help="无图片页面低于该文本长度时视为疑似空白",
|
||||
)
|
||||
parser.add_argument("--json", dest="json_path", help="JSON 报告输出路径")
|
||||
parser.add_argument("--render-dir", help="逐页 PNG 输出目录")
|
||||
parser.add_argument("--dpi", type=int, default=300, help="页面渲染 DPI")
|
||||
parser.add_argument(
|
||||
"--no-fail",
|
||||
action="store_true",
|
||||
help="发现乱码、禁用词或非豁免空白页时仍返回 0",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def inspect_document(
|
||||
document,
|
||||
path,
|
||||
forbidden=(),
|
||||
min_text_chars=30,
|
||||
allowed_blank_pages=(),
|
||||
):
|
||||
allowed = set(allowed_blank_pages)
|
||||
terms = [term for term in dict.fromkeys(forbidden) if term]
|
||||
forbidden_hits = {term: {"count": 0, "pages": []} for term in terms}
|
||||
pages = []
|
||||
replacement_characters = 0
|
||||
suspicious_blank_pages = []
|
||||
for index, page in enumerate(document):
|
||||
page_number = index + 1
|
||||
text = page.get_text().strip()
|
||||
image_count = len(page.get_images(full=True))
|
||||
pages.append(
|
||||
{
|
||||
"page": page_number,
|
||||
"text_chars": len(text),
|
||||
"images": image_count,
|
||||
}
|
||||
)
|
||||
replacement_characters += text.count("\ufffd")
|
||||
if (
|
||||
len(text) < min_text_chars
|
||||
and image_count == 0
|
||||
and page_number not in allowed
|
||||
):
|
||||
suspicious_blank_pages.append(page_number)
|
||||
for term in terms:
|
||||
count = text.count(term)
|
||||
if count:
|
||||
forbidden_hits[term]["count"] += count
|
||||
forbidden_hits[term]["pages"].append(page_number)
|
||||
|
||||
return {
|
||||
"file": str(path),
|
||||
"page_count": len(document),
|
||||
"replacement_characters": replacement_characters,
|
||||
"forbidden": {
|
||||
term: result
|
||||
for term, result in forbidden_hits.items()
|
||||
if result["count"]
|
||||
},
|
||||
"suspicious_blank_pages": suspicious_blank_pages,
|
||||
"allowed_blank_pages": sorted(allowed),
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
|
||||
def inspect_pdf(pdf_path, forbidden=(), min_text_chars=30, allowed_blank_pages=()):
|
||||
path = Path(pdf_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"PDF 不存在:{path}")
|
||||
document = fitz.open(path)
|
||||
try:
|
||||
return inspect_document(
|
||||
document,
|
||||
path,
|
||||
forbidden=forbidden,
|
||||
min_text_chars=min_text_chars,
|
||||
allowed_blank_pages=allowed_blank_pages,
|
||||
)
|
||||
finally:
|
||||
document.close()
|
||||
|
||||
|
||||
def render_document(document, output_dir, dpi=300):
|
||||
if dpi < 72:
|
||||
raise ValueError("dpi 不能低于 72")
|
||||
output = Path(output_dir).expanduser().resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
existing = sorted(output.glob("page-*.png"))
|
||||
if existing:
|
||||
raise FileExistsError(
|
||||
f"渲染目录已有页面图,请改用空目录:{output}"
|
||||
)
|
||||
scale = dpi / 72
|
||||
matrix = fitz.Matrix(scale, scale)
|
||||
digits = max(3, len(str(len(document))))
|
||||
rendered = []
|
||||
for index, page in enumerate(document):
|
||||
target = output / f"page-{index + 1:0{digits}d}.png"
|
||||
page.get_pixmap(matrix=matrix, alpha=False).save(target)
|
||||
rendered.append(str(target))
|
||||
return rendered
|
||||
|
||||
|
||||
def render_pages(pdf_path, output_dir, dpi=300):
|
||||
document = fitz.open(Path(pdf_path).expanduser().resolve())
|
||||
try:
|
||||
return render_document(document, output_dir, dpi=dpi)
|
||||
finally:
|
||||
document.close()
|
||||
|
||||
|
||||
def has_failures(report):
|
||||
return bool(
|
||||
report["replacement_characters"]
|
||||
or report["forbidden"]
|
||||
or report["suspicious_blank_pages"]
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
pdf_path = Path(args.pdf).expanduser().resolve()
|
||||
if not pdf_path.is_file():
|
||||
raise FileNotFoundError(f"PDF 不存在:{pdf_path}")
|
||||
json_path = (
|
||||
Path(args.json_path).expanduser().resolve()
|
||||
if args.json_path
|
||||
else None
|
||||
)
|
||||
render_dir = (
|
||||
Path(args.render_dir).expanduser().resolve()
|
||||
if args.render_dir
|
||||
else None
|
||||
)
|
||||
if json_path == pdf_path:
|
||||
raise ValueError("JSON 报告路径不能覆盖输入 PDF")
|
||||
if render_dir == pdf_path:
|
||||
raise ValueError("渲染目录不能与输入 PDF 同路径")
|
||||
|
||||
document = fitz.open(pdf_path)
|
||||
try:
|
||||
report = inspect_document(
|
||||
document,
|
||||
pdf_path,
|
||||
forbidden=args.forbid,
|
||||
min_text_chars=args.min_text_chars,
|
||||
allowed_blank_pages=args.allow_blank_page,
|
||||
)
|
||||
if render_dir:
|
||||
report["rendered_pages"] = render_document(
|
||||
document,
|
||||
render_dir,
|
||||
args.dpi,
|
||||
)
|
||||
finally:
|
||||
document.close()
|
||||
|
||||
output = json.dumps(report, ensure_ascii=False, indent=2)
|
||||
if json_path:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text(output + "\n", encoding="utf-8")
|
||||
print(f"report: {json_path}")
|
||||
else:
|
||||
print(output)
|
||||
|
||||
if has_failures(report) and not args.no_fail:
|
||||
print("PDF 核验失败:存在乱码、禁用词或疑似空白页。", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
# 一、章节标题
|
||||
|
||||
本章正文使用 Markdown 编写。图片路径相对于当前 Markdown 文件所在目录。
|
||||
|
||||
## 1. 二级标题
|
||||
|
||||
支持**粗体**、*斜体*、`行内代码`、列表和表格。
|
||||
|
||||
| 项目 | 说明 |
|
||||
|---|---|
|
||||
| 示例 | 表格会按内容长度分配列宽 |
|
||||
|
||||

|
||||
|
||||
*图 1 示例架构图*
|
||||
|
||||
```python
|
||||
print("围栏代码块会保留缩进和换行")
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "项目名称",
|
||||
"subtitle": "技术方案",
|
||||
"author": "编制单位:____________________",
|
||||
"date": "编制日期:____________________",
|
||||
"output": "../dist/document.docx",
|
||||
"toc_depth": 3,
|
||||
"chapters": [
|
||||
{
|
||||
"path": "chapters/01-overview.md",
|
||||
"page_break_before": false
|
||||
},
|
||||
{
|
||||
"path": "chapters/02-design.md",
|
||||
"page_break_before": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from PIL import Image
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
SCRIPT_PATH = SKILL_DIR / "scripts" / "build_docx.py"
|
||||
SPEC = importlib.util.spec_from_file_location("build_docx", SCRIPT_PATH)
|
||||
build_docx = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(build_docx)
|
||||
|
||||
|
||||
class BuildDocxTests(unittest.TestCase):
|
||||
def write_config(self, root, config):
|
||||
path = root / "document.json"
|
||||
path.write_text(json.dumps(config, ensure_ascii=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def test_builds_supported_markdown_elements(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
image_path = root / "diagram.png"
|
||||
Image.new("RGB", (400, 200), "white").save(image_path, dpi=(200, 200))
|
||||
chapter = root / "chapter.md"
|
||||
chapter.write_text(
|
||||
"# 一、概述\n\n"
|
||||
"正文包含**粗体**、*斜体*和`代码`。\n\n"
|
||||
"- 列表一\n- 列表二\n\n"
|
||||
"| 项目 | 详细说明 |\n|---|---|\n| A | 一段较长的内容 |\n\n"
|
||||
"\n\n"
|
||||
"*图 1 架构图*\n\n"
|
||||
"```python\nprint('ok')\n```\n\n"
|
||||
"> 引用说明\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "测试文档",
|
||||
"subtitle": "构建验证",
|
||||
"output": "out/test.docx",
|
||||
"toc_depth": 0,
|
||||
"chapters": [{"path": "chapter.md"}],
|
||||
},
|
||||
)
|
||||
loaded_path, config = build_docx.load_config(config_path)
|
||||
output = build_docx.build(loaded_path, config)
|
||||
|
||||
self.assertTrue(output.is_file())
|
||||
document = Document(output)
|
||||
text = "\n".join(paragraph.text for paragraph in document.paragraphs)
|
||||
self.assertIn("一、概述", text)
|
||||
self.assertIn("图 1 架构图", text)
|
||||
self.assertIn("print('ok')", text)
|
||||
self.assertEqual(len(document.tables), 1)
|
||||
images = [
|
||||
rel
|
||||
for rel in document.part.rels.values()
|
||||
if "image" in rel.reltype
|
||||
]
|
||||
self.assertEqual(len(images), 1)
|
||||
|
||||
def test_landscape_widens_tables(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
header = "| " + " | ".join(f"列{i}" for i in range(11)) + " |"
|
||||
divider = "|" + "---|" * 11
|
||||
row = "| " + " | ".join(f"值{i}" for i in range(11)) + " |"
|
||||
(root / "wide.md").write_text(
|
||||
f"# 宽表\n\n{header}\n{divider}\n{row}\n", encoding="utf-8"
|
||||
)
|
||||
widths = {}
|
||||
for mode in ("portrait", "landscape"):
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "宽表测试",
|
||||
"output": f"out/{mode}.docx",
|
||||
"toc_depth": 0,
|
||||
"orientation": mode,
|
||||
"chapters": [{"path": "wide.md"}],
|
||||
},
|
||||
)
|
||||
loaded, config = build_docx.load_config(config_path)
|
||||
document = Document(build_docx.build(loaded, config))
|
||||
widths[mode] = sum(
|
||||
cell.width.cm for cell in document.tables[0].rows[0].cells
|
||||
)
|
||||
self.assertGreater(widths["landscape"], widths["portrait"] + 5)
|
||||
|
||||
def test_wide_table_fits_longest_word_in_every_column(self):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
headers = [
|
||||
"需求编号", "需求出处", "需求原文", "类别", "强制/评分", "响应状态",
|
||||
"实现说明", "偏离说明", "证据编号", "方案章节", "验证方法",
|
||||
]
|
||||
body = [
|
||||
"REQ-001", "示例技术要求 3.1.1",
|
||||
"系统应提供与 OpenAI 接口兼容的统一调用入口。", "功能", "强制",
|
||||
"compliant", "网关提供模型列表、对话补全和向量化四类接口,统一鉴权",
|
||||
"无", "FEAT-001", "三.1、四.1", "依次调用四类接口并核对返回结构",
|
||||
]
|
||||
head = "".join(f"<th>{h}</th>" for h in headers)
|
||||
cells = "".join(f"<td>{c}</td>" for c in body)
|
||||
rows = BeautifulSoup(
|
||||
f"<table><tr>{head}</tr><tr>{cells}</tr></table>", "html.parser"
|
||||
).find_all("tr")
|
||||
widths = build_docx.compute_col_widths(rows, 11, 25.7, 9.5)
|
||||
|
||||
self.assertAlmostEqual(sum(widths), 25.7, places=3)
|
||||
em_cm = 9.5 / 28.35
|
||||
for index, (header, cell) in enumerate(zip(headers, body)):
|
||||
longest = max(
|
||||
build_docx.em_width(word)
|
||||
for text in (header, cell)
|
||||
for word in text.split()
|
||||
)
|
||||
self.assertGreaterEqual(
|
||||
widths[index] + 1e-6,
|
||||
min(em_cm * longest, 25.7 / 11),
|
||||
f"column {index} ({header}) truncates its longest word",
|
||||
)
|
||||
self.assertGreater(widths[6], widths[3])
|
||||
|
||||
def test_blockquote_keeps_paragraph_breaks(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "quote.md").write_text(
|
||||
"# 引用\n\n> 第一段说明。\n>\n> 第二段说明。\n", encoding="utf-8"
|
||||
)
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "引用测试",
|
||||
"output": "out/quote.docx",
|
||||
"toc_depth": 0,
|
||||
"chapters": [{"path": "quote.md"}],
|
||||
},
|
||||
)
|
||||
loaded, config = build_docx.load_config(config_path)
|
||||
document = Document(build_docx.build(loaded, config))
|
||||
texts = [p.text for p in document.paragraphs]
|
||||
self.assertIn("第一段说明。", texts)
|
||||
self.assertIn("第二段说明。", texts)
|
||||
|
||||
def test_only_forced_chapter_boundary_adds_page_break(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "one.md").write_text("# 第一章\n", encoding="utf-8")
|
||||
(root / "two.md").write_text("# 第二章\n", encoding="utf-8")
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "分页测试",
|
||||
"cover": False,
|
||||
"toc_depth": 0,
|
||||
"output": "test.docx",
|
||||
"chapters": [
|
||||
{"path": "one.md", "page_break_before": False},
|
||||
{"path": "two.md", "page_break_before": True},
|
||||
],
|
||||
},
|
||||
)
|
||||
loaded_path, config = build_docx.load_config(config_path)
|
||||
output = build_docx.build(loaded_path, config)
|
||||
document = Document(output)
|
||||
self.assertEqual(document._element.xml.count('w:type="page"'), 1)
|
||||
|
||||
def test_rejects_missing_chapters(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{"title": "无章节", "output": "test.docx", "chapters": []},
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "chapters"):
|
||||
build_docx.load_config(config_path)
|
||||
|
||||
def test_rejects_non_boolean_page_break(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "chapter.md").write_text("# 章节\n", encoding="utf-8")
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "错误分页配置",
|
||||
"output": "test.docx",
|
||||
"chapters": [
|
||||
{"path": "chapter.md", "page_break_before": "false"}
|
||||
],
|
||||
},
|
||||
)
|
||||
loaded_path, config = build_docx.load_config(config_path)
|
||||
with self.assertRaisesRegex(ValueError, "page_break_before"):
|
||||
build_docx.build(loaded_path, config)
|
||||
|
||||
def test_rejects_mixed_text_and_image_paragraph(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
Image.new("RGB", (100, 50), "white").save(root / "diagram.png")
|
||||
(root / "chapter.md").write_text(
|
||||
"说明文字 \n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
config_path = self.write_config(
|
||||
root,
|
||||
{
|
||||
"title": "图片格式测试",
|
||||
"cover": False,
|
||||
"toc_depth": 0,
|
||||
"output": "test.docx",
|
||||
"chapters": ["chapter.md"],
|
||||
},
|
||||
)
|
||||
loaded_path, config = build_docx.load_config(config_path)
|
||||
with self.assertRaisesRegex(ValueError, "图片必须独占"):
|
||||
build_docx.build(loaded_path, config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,76 @@
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import fitz
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
SCRIPT_PATH = SKILL_DIR / "scripts" / "verify_pdf.py"
|
||||
SPEC = importlib.util.spec_from_file_location("verify_pdf", SCRIPT_PATH)
|
||||
verify_pdf = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(verify_pdf)
|
||||
|
||||
|
||||
class VerifyPdfTests(unittest.TestCase):
|
||||
def create_pdf(self, path):
|
||||
document = fitz.open()
|
||||
text_page = document.new_page()
|
||||
text_page.insert_text(
|
||||
(72, 72),
|
||||
"This page contains enough verification text and a forbidden term.",
|
||||
)
|
||||
document.new_page()
|
||||
document.save(path)
|
||||
document.close()
|
||||
|
||||
def test_reports_forbidden_terms_and_blank_pages(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pdf = Path(tmp) / "sample.pdf"
|
||||
self.create_pdf(pdf)
|
||||
report = verify_pdf.inspect_pdf(
|
||||
pdf,
|
||||
forbidden=["forbidden"],
|
||||
min_text_chars=30,
|
||||
)
|
||||
self.assertEqual(report["page_count"], 2)
|
||||
self.assertEqual(report["forbidden"]["forbidden"]["pages"], [1])
|
||||
self.assertEqual(report["suspicious_blank_pages"], [2])
|
||||
self.assertTrue(verify_pdf.has_failures(report))
|
||||
|
||||
def test_allows_known_blank_page(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pdf = Path(tmp) / "sample.pdf"
|
||||
self.create_pdf(pdf)
|
||||
report = verify_pdf.inspect_pdf(
|
||||
pdf,
|
||||
min_text_chars=30,
|
||||
allowed_blank_pages=[2],
|
||||
)
|
||||
self.assertEqual(report["suspicious_blank_pages"], [])
|
||||
self.assertFalse(verify_pdf.has_failures(report))
|
||||
|
||||
def test_renders_pages(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
pdf = root / "sample.pdf"
|
||||
self.create_pdf(pdf)
|
||||
rendered = verify_pdf.render_pages(pdf, root / "pages", dpi=72)
|
||||
self.assertEqual(len(rendered), 2)
|
||||
self.assertTrue(all(Path(path).is_file() for path in rendered))
|
||||
|
||||
def test_rejects_render_directory_with_old_pages(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
pdf = root / "sample.pdf"
|
||||
pages = root / "pages"
|
||||
pages.mkdir()
|
||||
(pages / "page-999.png").write_bytes(b"old")
|
||||
self.create_pdf(pdf)
|
||||
with self.assertRaisesRegex(FileExistsError, "空目录"):
|
||||
verify_pdf.render_pages(pdf, pages, dpi=72)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
id: meeting-minutes
|
||||
name: 会议纪要
|
||||
description: 将用户提供的会议记录整理为客观、可追踪的纪要,明确结论、分歧与行动项。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 会议
|
||||
- 纪要
|
||||
- 协作
|
||||
---
|
||||
|
||||
# 会议纪要
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 忠实整理原始记录,不推测未明确表达的决定或责任。
|
||||
- 区分讨论内容、正式决策、待确认事项和行动项。
|
||||
- 保留关键分歧及其依据,避免将建议误写为结论。
|
||||
- 对姓名、日期、数字和专有名词进行一致性检查。
|
||||
|
||||
## 整理流程
|
||||
|
||||
1. 确认会议主题、时间、参会人和目标。
|
||||
2. 按议题归纳背景、讨论要点与结论。
|
||||
3. 提取每项行动的负责人、截止时间和交付物。
|
||||
4. 汇总未决问题、风险和后续会议需求。
|
||||
5. 标记原始记录中含糊或相互冲突的信息。
|
||||
|
||||
## 输出模板
|
||||
|
||||
- **会议信息:** 主题、时间、参会人
|
||||
- **会议目标:** 本次会议要解决的问题
|
||||
- **议题与结论:** 按议题分组
|
||||
- **行动项:** 事项、负责人、截止时间、状态
|
||||
- **待确认事项:** 缺失信息或未决问题
|
||||
|
||||
未提供的信息统一标注为“待确认”,不得自行补全。
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
id: presentation-outline
|
||||
name: 演示大纲
|
||||
description: 根据目标与受众设计逻辑清晰的演示文稿大纲,明确每页核心信息与叙事衔接。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 演示
|
||||
- 大纲
|
||||
- 表达
|
||||
---
|
||||
|
||||
# 演示大纲
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 先明确演示目的、受众、场合、时长和期望行动。
|
||||
- 每页聚焦一个核心信息,标题应直接表达结论。
|
||||
- 事实、数据与案例仅来自用户材料;缺少依据时标注待补充。
|
||||
- 控制信息密度,避免用大段文字代替口头讲解。
|
||||
|
||||
## 设计流程
|
||||
|
||||
1. 用一句话定义演示的核心主张。
|
||||
2. 选择适合目标的叙事结构,如“问题—分析—方案—行动”。
|
||||
3. 为每页写结论式标题、关键要点和建议视觉形式。
|
||||
4. 检查页面间逻辑、证据充分性和时间分配。
|
||||
5. 以明确总结和下一步行动收尾。
|
||||
|
||||
## 输出格式
|
||||
|
||||
按页输出:
|
||||
|
||||
- **页码与标题:** 结论式标题
|
||||
- **页面目的:** 该页要让受众理解什么
|
||||
- **关键内容:** 不超过五个要点
|
||||
- **视觉建议:** 图表、流程、时间线或重点数字
|
||||
- **讲述提示:** 与前后页面的衔接
|
||||
|
||||
另附开场、总结和待补充材料清单。
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: product-evidence
|
||||
version: 1.0.0
|
||||
description: |
|
||||
建立和维护产品市场材料的事实与证据清单,统一产品版本、功能状态、技术参数、
|
||||
术语、适用边界和可公开主张。用于开始任何产品介绍、投标参数、PPT、技术方案、
|
||||
白皮书或案例材料之前,也用于跨产物一致性核验。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Execute
|
||||
compatibility: Python 3.9+,校验脚本不依赖第三方包
|
||||
---
|
||||
|
||||
# 产品事实与证据
|
||||
|
||||
所有产品市场产物都必须从同一份 `product-evidence.json` 取事实。缺少依据时标记
|
||||
待核验,不允许由 Agent 补造参数、客户结果、认证、兼容性或竞争结论。
|
||||
|
||||
`<skill-dir>` 指本 `SKILL.md` 所在目录。
|
||||
|
||||
## 建立清单
|
||||
|
||||
```bash
|
||||
cp "<skill-dir>/templates/product-evidence.example.json" ./product-evidence.json
|
||||
```
|
||||
|
||||
逐项填写:
|
||||
|
||||
- `product`:产品名称、版本、类别、定位、成熟度和目标读者。
|
||||
- `terminology`:统一术语、定义和禁用旧称。
|
||||
- `features`:功能 ID、用户动作、结果、状态、版本范围和证据。
|
||||
- `parameters`:参数值、单位、测试条件、适用版本、公开级别和证据。
|
||||
- `claims`:允许对外使用的事实、目标或比较主张及适用产物。
|
||||
- `use_cases`:角色、问题、工作流、人工复核点和已有结果。
|
||||
- `differentiators`:比较对象、比较范围和支持证据。
|
||||
- `limitations`:部署条件、依赖、适用边界和必要人工复核。
|
||||
- `evidence`:来源、定位、核验日期、责任人和公开级别。
|
||||
- `prohibited_claims`:不得出现在任何材料中的绝对化或未经批准表述。
|
||||
|
||||
## 事实分级
|
||||
|
||||
- `released`:当前版本已提供,必须有可定位证据。
|
||||
- `beta`:可试用但存在范围限制,正文必须同时说明限制。
|
||||
- `planned`:仅可用将来时或规划表述,不得写成现有能力。
|
||||
- `deprecated`:不得作为当前卖点。
|
||||
|
||||
证据公开级别:
|
||||
|
||||
- `public`:可进入公开网站、彩页和公开演示。
|
||||
- `restricted`:只在授权客户或受控投标材料中使用。
|
||||
- `internal`:只用于内部判断,不能原样写入外发产物。
|
||||
|
||||
## 校验
|
||||
|
||||
先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux
|
||||
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/scripts/validate_evidence.py" ./product-evidence.json
|
||||
<python> "<skill-dir>/scripts/validate_evidence.py" ./product-evidence.json --json
|
||||
<python> "<skill-dir>/scripts/validate_evidence.py" ./product-evidence.json \
|
||||
--strict --channel public
|
||||
```
|
||||
|
||||
结构错误、重复 ID、失效引用、无证据的已批准事实主张、非法状态或疑似密钥参数
|
||||
必须阻断。缺证据、占位符和待核验项在普通模式下告警,在 `--strict` 下阻断。
|
||||
|
||||
`--channel` 按目标渠道核对公开级别:已批准且带 `allowed_outputs` 的主张,其
|
||||
引用证据的 `disclosure` 不得低于渠道要求。对外产物必须以 `--strict` 加目标
|
||||
渠道运行通过后才能进入下游技能。
|
||||
|
||||
## 给下游技能的输入
|
||||
|
||||
调用任何产品产物技能时,同时提供:
|
||||
|
||||
1. 已通过校验的 `product-evidence.json`。
|
||||
2. 目标受众、使用场景、发布渠道和保密级别。
|
||||
3. 本次产物允许引用的证据范围。
|
||||
4. 截止日期、页数或篇幅、格式和品牌要求。
|
||||
|
||||
下游产物中的每个数字、兼容性、认证、客户效果和比较结论都应能追溯到清单 ID。
|
||||
|
||||
## 完成标准
|
||||
|
||||
清单结构校验通过;公开级别与使用渠道匹配;所有现有功能、参数和批准主张有
|
||||
证据;规划能力、限制和人工复核要求没有被省略;不含密钥、客户隐私或私有地址。
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the shared product evidence manifest used by marketing skills."""
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
COLLECTIONS = (
|
||||
"features",
|
||||
"parameters",
|
||||
"claims",
|
||||
"use_cases",
|
||||
"differentiators",
|
||||
"limitations",
|
||||
"evidence",
|
||||
)
|
||||
REQUIRED_FIELDS = {
|
||||
"features": ("id", "name", "summary", "status"),
|
||||
"parameters": ("id", "name", "value", "conditions", "disclosure"),
|
||||
"claims": ("id", "text", "type", "status"),
|
||||
"use_cases": ("id", "name", "audience", "problem", "workflow", "outcome"),
|
||||
"differentiators": ("id", "statement", "comparison_scope"),
|
||||
"limitations": ("id", "text", "applies_to"),
|
||||
"evidence": (
|
||||
"id",
|
||||
"type",
|
||||
"title",
|
||||
"source",
|
||||
"locator",
|
||||
"verified_on",
|
||||
"owner",
|
||||
"disclosure",
|
||||
),
|
||||
}
|
||||
ALLOWED = {
|
||||
"product.maturity": {"released", "beta", "planned", "deprecated"},
|
||||
"features.status": {"released", "beta", "planned", "deprecated"},
|
||||
"claims.type": {"fact", "goal", "comparison"},
|
||||
"claims.status": {"approved", "draft", "rejected"},
|
||||
"disclosure": {"public", "restricted", "internal"},
|
||||
}
|
||||
OUTPUT_TYPES = {
|
||||
"feature-catalog",
|
||||
"technical-spec",
|
||||
"presentation",
|
||||
"technical-proposal",
|
||||
"one-pager",
|
||||
"whitepaper",
|
||||
"tender-response",
|
||||
"sales-demo",
|
||||
"case-study",
|
||||
"competitive-positioning",
|
||||
}
|
||||
DISCLOSURE_RANK = {"internal": 0, "restricted": 1, "public": 2}
|
||||
PLACEHOLDERS = re.compile(
|
||||
r"(?i)(?:\bTBD\b|\bTODO\b|待补充|待确认|placeholder|changeme)"
|
||||
)
|
||||
SECRET_QUERY = re.compile(r"(?i)(?:token|api[_-]?key|secret|password)=")
|
||||
|
||||
|
||||
def issue(path, message):
|
||||
return {"path": path, "message": message}
|
||||
|
||||
|
||||
def is_nonempty_string(value):
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def validate_record_shape(collection, index, record, errors):
|
||||
path = f"{collection}[{index}]"
|
||||
if not isinstance(record, dict):
|
||||
errors.append(issue(path, "必须是对象"))
|
||||
return False
|
||||
for field in REQUIRED_FIELDS[collection]:
|
||||
value = record.get(field)
|
||||
if field in ("applies_to",):
|
||||
if not isinstance(value, list) or not value:
|
||||
errors.append(issue(f"{path}.{field}", "必须是非空数组"))
|
||||
elif not is_nonempty_string(value):
|
||||
errors.append(issue(f"{path}.{field}", "必须是非空字符串"))
|
||||
return True
|
||||
|
||||
|
||||
def evidence_disclosure_map(records):
|
||||
return {
|
||||
record.get("id"): record.get("disclosure")
|
||||
for record in records["evidence"]
|
||||
if isinstance(record, dict)
|
||||
}
|
||||
|
||||
|
||||
def validate_disclosure_chain(records, errors):
|
||||
"""A record must not be more public than the evidence backing it."""
|
||||
evidence_disclosure = evidence_disclosure_map(records)
|
||||
for collection in ("parameters", "claims"):
|
||||
for index, record in enumerate(records[collection]):
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
own = record.get("disclosure")
|
||||
if own not in DISCLOSURE_RANK:
|
||||
continue
|
||||
for ref in record.get("evidence_ids", []) or []:
|
||||
backing = evidence_disclosure.get(ref)
|
||||
if backing not in DISCLOSURE_RANK:
|
||||
continue
|
||||
if DISCLOSURE_RANK[backing] < DISCLOSURE_RANK[own]:
|
||||
errors.append(
|
||||
issue(
|
||||
f"{collection}[{index}].disclosure",
|
||||
f"标记为 {own},但证据 {ref} 仅为 {backing}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_channel(records, channel, errors):
|
||||
"""Reject claims cleared for delivery whose evidence is too restricted."""
|
||||
target = DISCLOSURE_RANK[channel]
|
||||
evidence_disclosure = evidence_disclosure_map(records)
|
||||
for index, claim in enumerate(records["claims"]):
|
||||
if not isinstance(claim, dict) or claim.get("status") != "approved":
|
||||
continue
|
||||
if not claim.get("allowed_outputs"):
|
||||
continue
|
||||
for ref in claim.get("evidence_ids", []) or []:
|
||||
disclosure = evidence_disclosure.get(ref)
|
||||
if disclosure not in DISCLOSURE_RANK:
|
||||
continue
|
||||
if DISCLOSURE_RANK[disclosure] < target:
|
||||
errors.append(
|
||||
issue(
|
||||
f"claims[{index}].evidence_ids",
|
||||
f"证据 {ref} 为 {disclosure},不足以支撑 {channel} 渠道",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_manifest(data, channel=None):
|
||||
errors = []
|
||||
warnings = []
|
||||
if not isinstance(data, dict):
|
||||
return [issue("$", "根节点必须是对象")], warnings
|
||||
if data.get("schema_version") != 1:
|
||||
errors.append(issue("schema_version", "当前仅支持整数 1"))
|
||||
|
||||
product = data.get("product")
|
||||
if not isinstance(product, dict):
|
||||
errors.append(issue("product", "必须是对象"))
|
||||
product = {}
|
||||
for field in ("name", "version", "category", "summary", "maturity"):
|
||||
if not is_nonempty_string(product.get(field)):
|
||||
errors.append(issue(f"product.{field}", "必须是非空字符串"))
|
||||
if product.get("maturity") not in ALLOWED["product.maturity"]:
|
||||
errors.append(
|
||||
issue(
|
||||
"product.maturity",
|
||||
f"必须是 {sorted(ALLOWED['product.maturity'])} 之一",
|
||||
)
|
||||
)
|
||||
|
||||
records = {}
|
||||
all_ids = {}
|
||||
for collection in COLLECTIONS:
|
||||
values = data.get(collection, [])
|
||||
if not isinstance(values, list):
|
||||
errors.append(issue(collection, "必须是数组"))
|
||||
values = []
|
||||
records[collection] = values
|
||||
for index, record in enumerate(values):
|
||||
if not validate_record_shape(collection, index, record, errors):
|
||||
continue
|
||||
record_id = record.get("id")
|
||||
if is_nonempty_string(record_id):
|
||||
if record_id in all_ids:
|
||||
errors.append(
|
||||
issue(
|
||||
f"{collection}[{index}].id",
|
||||
f"ID 与 {all_ids[record_id]} 重复:{record_id}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
all_ids[record_id] = f"{collection}[{index}]"
|
||||
|
||||
evidence_ids = {
|
||||
record.get("id")
|
||||
for record in records["evidence"]
|
||||
if isinstance(record, dict) and is_nonempty_string(record.get("id"))
|
||||
}
|
||||
feature_ids = {
|
||||
record.get("id")
|
||||
for record in records["features"]
|
||||
if isinstance(record, dict) and is_nonempty_string(record.get("id"))
|
||||
}
|
||||
use_case_ids = {
|
||||
record.get("id")
|
||||
for record in records["use_cases"]
|
||||
if isinstance(record, dict) and is_nonempty_string(record.get("id"))
|
||||
}
|
||||
|
||||
for collection in COLLECTIONS:
|
||||
for index, record in enumerate(records[collection]):
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
path = f"{collection}[{index}]"
|
||||
refs = record.get("evidence_ids", [])
|
||||
if refs is not None and not isinstance(refs, list):
|
||||
errors.append(issue(f"{path}.evidence_ids", "必须是数组"))
|
||||
refs = []
|
||||
for ref in refs or []:
|
||||
if ref not in evidence_ids:
|
||||
errors.append(
|
||||
issue(f"{path}.evidence_ids", f"引用不存在:{ref}")
|
||||
)
|
||||
for ref in record.get("use_case_ids", []) or []:
|
||||
if ref not in use_case_ids:
|
||||
errors.append(
|
||||
issue(f"{path}.use_case_ids", f"引用不存在:{ref}")
|
||||
)
|
||||
|
||||
for index, limitation in enumerate(records["limitations"]):
|
||||
if not isinstance(limitation, dict):
|
||||
continue
|
||||
for ref in limitation.get("applies_to", []) or []:
|
||||
if ref not in feature_ids and ref not in all_ids:
|
||||
errors.append(
|
||||
issue(
|
||||
f"limitations[{index}].applies_to",
|
||||
f"引用不存在:{ref}",
|
||||
)
|
||||
)
|
||||
|
||||
for index, feature in enumerate(records["features"]):
|
||||
if not isinstance(feature, dict):
|
||||
continue
|
||||
status = feature.get("status")
|
||||
if status not in ALLOWED["features.status"]:
|
||||
errors.append(
|
||||
issue(
|
||||
f"features[{index}].status",
|
||||
f"必须是 {sorted(ALLOWED['features.status'])} 之一",
|
||||
)
|
||||
)
|
||||
if status in {"released", "beta"} and not feature.get("evidence_ids"):
|
||||
warnings.append(
|
||||
issue(
|
||||
f"features[{index}].evidence_ids",
|
||||
"已发布或 beta 功能缺少证据",
|
||||
)
|
||||
)
|
||||
|
||||
for index, parameter in enumerate(records["parameters"]):
|
||||
if not isinstance(parameter, dict):
|
||||
continue
|
||||
disclosure = parameter.get("disclosure")
|
||||
if disclosure not in ALLOWED["disclosure"]:
|
||||
errors.append(
|
||||
issue(
|
||||
f"parameters[{index}].disclosure",
|
||||
f"必须是 {sorted(ALLOWED['disclosure'])} 之一",
|
||||
)
|
||||
)
|
||||
if not parameter.get("evidence_ids"):
|
||||
warnings.append(
|
||||
issue(
|
||||
f"parameters[{index}].evidence_ids",
|
||||
"技术参数缺少证据",
|
||||
)
|
||||
)
|
||||
|
||||
for index, claim in enumerate(records["claims"]):
|
||||
if not isinstance(claim, dict):
|
||||
continue
|
||||
if claim.get("type") not in ALLOWED["claims.type"]:
|
||||
errors.append(
|
||||
issue(
|
||||
f"claims[{index}].type",
|
||||
f"必须是 {sorted(ALLOWED['claims.type'])} 之一",
|
||||
)
|
||||
)
|
||||
if claim.get("status") not in ALLOWED["claims.status"]:
|
||||
errors.append(
|
||||
issue(
|
||||
f"claims[{index}].status",
|
||||
f"必须是 {sorted(ALLOWED['claims.status'])} 之一",
|
||||
)
|
||||
)
|
||||
if (
|
||||
claim.get("type") in {"fact", "comparison"}
|
||||
and claim.get("status") == "approved"
|
||||
and not claim.get("evidence_ids")
|
||||
):
|
||||
errors.append(
|
||||
issue(
|
||||
f"claims[{index}].evidence_ids",
|
||||
"已批准的事实或比较主张必须有证据",
|
||||
)
|
||||
)
|
||||
outputs = claim.get("allowed_outputs", [])
|
||||
if outputs is not None and not isinstance(outputs, list):
|
||||
errors.append(
|
||||
issue(f"claims[{index}].allowed_outputs", "必须是数组")
|
||||
)
|
||||
for output in outputs or []:
|
||||
if output not in OUTPUT_TYPES:
|
||||
errors.append(
|
||||
issue(
|
||||
f"claims[{index}].allowed_outputs",
|
||||
f"未知产物类型:{output}",
|
||||
)
|
||||
)
|
||||
|
||||
for index, evidence in enumerate(records["evidence"]):
|
||||
if not isinstance(evidence, dict):
|
||||
continue
|
||||
disclosure = evidence.get("disclosure")
|
||||
if disclosure not in ALLOWED["disclosure"]:
|
||||
errors.append(
|
||||
issue(
|
||||
f"evidence[{index}].disclosure",
|
||||
f"必须是 {sorted(ALLOWED['disclosure'])} 之一",
|
||||
)
|
||||
)
|
||||
verified_on = evidence.get("verified_on")
|
||||
if is_nonempty_string(verified_on):
|
||||
try:
|
||||
dt.date.fromisoformat(verified_on)
|
||||
except ValueError:
|
||||
errors.append(
|
||||
issue(
|
||||
f"evidence[{index}].verified_on",
|
||||
"必须使用 YYYY-MM-DD",
|
||||
)
|
||||
)
|
||||
source = evidence.get("source", "")
|
||||
if is_nonempty_string(source) and SECRET_QUERY.search(source):
|
||||
errors.append(
|
||||
issue(
|
||||
f"evidence[{index}].source",
|
||||
"来源中疑似包含密钥或令牌参数",
|
||||
)
|
||||
)
|
||||
|
||||
for index, differentiator in enumerate(records["differentiators"]):
|
||||
if isinstance(differentiator, dict) and not differentiator.get(
|
||||
"evidence_ids"
|
||||
):
|
||||
warnings.append(
|
||||
issue(
|
||||
f"differentiators[{index}].evidence_ids",
|
||||
"差异点缺少证据",
|
||||
)
|
||||
)
|
||||
|
||||
validate_disclosure_chain(records, errors)
|
||||
if channel in DISCLOSURE_RANK:
|
||||
validate_channel(records, channel, errors)
|
||||
|
||||
for path, value in walk_strings(data):
|
||||
if PLACEHOLDERS.search(value):
|
||||
warnings.append(issue(path, f"包含占位内容:{value[:60]}"))
|
||||
|
||||
if not evidence_ids:
|
||||
warnings.append(issue("evidence", "没有任何证据记录"))
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def walk_strings(value, path="$"):
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
yield from walk_strings(item, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
yield from walk_strings(item, f"{path}[{index}]")
|
||||
elif isinstance(value, str):
|
||||
yield path, value
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="验证产品事实与证据清单")
|
||||
parser.add_argument("manifest", help="product-evidence.json")
|
||||
parser.add_argument("--json", action="store_true", help="输出 JSON")
|
||||
parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="存在占位符、缺证据等警告时也失败",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--channel",
|
||||
choices=sorted(DISCLOSURE_RANK),
|
||||
help="目标发布渠道,校验主张引用证据的公开级别是否足够",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
path = Path(args.manifest).expanduser().resolve()
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
errors, warnings = validate_manifest(data, channel=args.channel)
|
||||
report = {
|
||||
"file": str(path),
|
||||
"channel": args.channel,
|
||||
"valid": not errors and (not args.strict or not warnings),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"stats": {
|
||||
collection: len(data.get(collection, []))
|
||||
if isinstance(data, dict) and isinstance(data.get(collection, []), list)
|
||||
else 0
|
||||
for collection in COLLECTIONS
|
||||
},
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
for level, items in (("错误", errors), ("警告", warnings)):
|
||||
for item in items:
|
||||
print(f"[{level}] {item['path']}:{item['message']}")
|
||||
print(
|
||||
f"核验完成:错误 {len(errors)},警告 {len(warnings)},"
|
||||
f"状态 {'通过' if report['valid'] else '失败'}"
|
||||
)
|
||||
return 0 if report["valid"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"product": {
|
||||
"name": "示例产品",
|
||||
"version": "1.0",
|
||||
"category": "产品类别",
|
||||
"summary": "用一句可验证的话说明产品面向谁、解决什么问题。",
|
||||
"maturity": "released",
|
||||
"audiences": [
|
||||
"技术负责人",
|
||||
"业务负责人"
|
||||
],
|
||||
"deployment_modes": [
|
||||
"私有化部署"
|
||||
]
|
||||
},
|
||||
"terminology": [
|
||||
{
|
||||
"term": "标准术语",
|
||||
"definition": "术语在所有材料中的统一定义。",
|
||||
"avoid": [
|
||||
"不再使用的旧称谓"
|
||||
]
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"id": "FEAT-001",
|
||||
"name": "示例功能",
|
||||
"summary": "说明用户可执行的动作和可观察结果。",
|
||||
"status": "released",
|
||||
"availability": "标准版本",
|
||||
"evidence_ids": [
|
||||
"EVD-001"
|
||||
],
|
||||
"use_case_ids": [
|
||||
"CASE-001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"id": "PAR-001",
|
||||
"name": "示例技术参数",
|
||||
"value": "已核验值",
|
||||
"unit": "",
|
||||
"conditions": "说明测试条件、版本和统计口径。",
|
||||
"scope": "标准版本",
|
||||
"disclosure": "public",
|
||||
"evidence_ids": [
|
||||
"EVD-001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"id": "CLM-001",
|
||||
"text": "产品在指定版本和条件下提供示例功能。",
|
||||
"type": "fact",
|
||||
"status": "approved",
|
||||
"allowed_outputs": [
|
||||
"feature-catalog",
|
||||
"technical-proposal",
|
||||
"presentation"
|
||||
],
|
||||
"evidence_ids": [
|
||||
"EVD-001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"use_cases": [
|
||||
{
|
||||
"id": "CASE-001",
|
||||
"name": "示例场景",
|
||||
"audience": "技术负责人",
|
||||
"problem": "说明现有工作中的具体问题。",
|
||||
"workflow": "说明产品参与的步骤和人工复核点。",
|
||||
"outcome": "只写已有证据支持的结果。",
|
||||
"evidence_ids": [
|
||||
"EVD-001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"differentiators": [
|
||||
{
|
||||
"id": "DIF-001",
|
||||
"statement": "说明可验证的产品差异。",
|
||||
"comparison_scope": "明确比较对象和版本范围。",
|
||||
"evidence_ids": [
|
||||
"EVD-001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
{
|
||||
"id": "LIM-001",
|
||||
"text": "说明适用边界、依赖条件或人工复核要求。",
|
||||
"applies_to": [
|
||||
"FEAT-001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"id": "EVD-001",
|
||||
"type": "product-documentation",
|
||||
"title": "产品说明书",
|
||||
"source": "docs/product.md",
|
||||
"locator": "功能章节",
|
||||
"verified_on": "2026-01-01",
|
||||
"owner": "产品负责人",
|
||||
"disclosure": "public",
|
||||
"notes": "发布前重新核验版本。"
|
||||
}
|
||||
],
|
||||
"prohibited_claims": [
|
||||
"绝对领先",
|
||||
"百分之百准确"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = SKILL_DIR / "scripts" / "validate_evidence.py"
|
||||
SPEC = importlib.util.spec_from_file_location("validate_evidence", SCRIPT)
|
||||
validate_evidence = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(validate_evidence)
|
||||
|
||||
|
||||
class ProductEvidenceTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
template = SKILL_DIR / "templates" / "product-evidence.example.json"
|
||||
cls.valid_data = json.loads(template.read_text(encoding="utf-8"))
|
||||
|
||||
def test_example_manifest_is_valid(self):
|
||||
errors, warnings = validate_evidence.validate_manifest(self.valid_data)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
def test_rejects_duplicate_ids_and_unknown_references(self):
|
||||
data = copy.deepcopy(self.valid_data)
|
||||
data["parameters"][0]["id"] = "FEAT-001"
|
||||
data["claims"][0]["evidence_ids"] = ["EVD-MISSING"]
|
||||
errors, _ = validate_evidence.validate_manifest(data)
|
||||
messages = "\n".join(item["message"] for item in errors)
|
||||
self.assertIn("重复", messages)
|
||||
self.assertIn("引用不存在", messages)
|
||||
|
||||
def test_rejects_secret_in_evidence_source(self):
|
||||
data = copy.deepcopy(self.valid_data)
|
||||
data["evidence"][0]["source"] = "https://example.test/doc?token=secret"
|
||||
errors, _ = validate_evidence.validate_manifest(data)
|
||||
self.assertTrue(
|
||||
any("密钥" in item["message"] for item in errors)
|
||||
)
|
||||
|
||||
def test_placeholder_is_warning(self):
|
||||
data = copy.deepcopy(self.valid_data)
|
||||
data["product"]["summary"] = "TODO"
|
||||
errors, warnings = validate_evidence.validate_manifest(data)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertTrue(warnings)
|
||||
|
||||
def test_rejects_record_more_public_than_evidence(self):
|
||||
data = copy.deepcopy(self.valid_data)
|
||||
for record in data["evidence"]:
|
||||
if record["id"] == "EVD-001":
|
||||
record["disclosure"] = "restricted"
|
||||
errors, _ = validate_evidence.validate_manifest(data)
|
||||
self.assertTrue(
|
||||
any("仅为 restricted" in item["message"] for item in errors)
|
||||
)
|
||||
|
||||
def test_restricted_parameter_is_not_a_public_channel_warning(self):
|
||||
data = copy.deepcopy(self.valid_data)
|
||||
data["parameters"][0]["disclosure"] = "restricted"
|
||||
for record in data["evidence"]:
|
||||
if record["id"] in data["parameters"][0]["evidence_ids"]:
|
||||
record["disclosure"] = "restricted"
|
||||
data["claims"][0]["evidence_ids"] = []
|
||||
data["claims"][0]["type"] = "goal"
|
||||
errors, warnings = validate_evidence.validate_manifest(
|
||||
data, channel="public"
|
||||
)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
def test_public_channel_rejects_internal_evidence(self):
|
||||
data = copy.deepcopy(self.valid_data)
|
||||
claim = data["claims"][0]
|
||||
claim["allowed_outputs"] = ["one-pager"]
|
||||
evidence_id = claim["evidence_ids"][0]
|
||||
for record in data["evidence"]:
|
||||
if record["id"] == evidence_id:
|
||||
record["disclosure"] = "internal"
|
||||
for parameter in data["parameters"]:
|
||||
if evidence_id in parameter.get("evidence_ids", []):
|
||||
parameter["disclosure"] = "internal"
|
||||
errors, _ = validate_evidence.validate_manifest(data, channel="public")
|
||||
self.assertTrue(
|
||||
any("不足以支撑 public" in item["message"] for item in errors)
|
||||
)
|
||||
errors, _ = validate_evidence.validate_manifest(data, channel="internal")
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_cli_json_report(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manifest = Path(tmp) / "product-evidence.json"
|
||||
manifest.write_text(
|
||||
json.dumps(self.valid_data, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), str(manifest), "--json"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
report = json.loads(result.stdout)
|
||||
self.assertTrue(report["valid"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: product-feature-catalog
|
||||
version: 1.0.0
|
||||
description: |
|
||||
基于产品事实与证据生成结构化功能列表、模块树和版本能力矩阵。用于产品规划、
|
||||
售前交流、招标附件或交付范围梳理;重点回答产品有什么功能、谁使用、产生什么
|
||||
结果,不负责撰写技术参数或长篇方案。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
compatibility: Markdown;建议配合 product-evidence
|
||||
---
|
||||
|
||||
# 产品功能列表
|
||||
|
||||
## 必要输入
|
||||
|
||||
- `product-evidence.json`,至少包含 `product`、`features`、`limitations`。
|
||||
- 输出用途:内部全量、公开产品、指定版本或投标范围。
|
||||
- 目标受众和所需粒度:模块、功能或子功能。
|
||||
|
||||
没有事实清单时不要自行拼凑:先用 `product-evidence` 建立并校验清单,或输出输入
|
||||
缺口清单等待补齐;任何情况下都不得根据产品名称猜测功能。
|
||||
|
||||
## 生成流程
|
||||
|
||||
1. 按版本、公开级别和用途筛选功能。
|
||||
2. 建立“产品域 → 一级模块 → 功能 → 子功能”层级,层级只服务导航,不凑数量。
|
||||
3. 每条功能写清用户角色、触发动作、系统行为和可观察结果。
|
||||
4. 标注 `released`、`beta`、`planned`、`deprecated`,规划能力不能混入现有功能。
|
||||
5. 补充版本范围、依赖、限制和证据 ID。
|
||||
6. 合并同义功能,拆开一行中包含多个独立用户动作的复合功能。
|
||||
|
||||
## 输出
|
||||
|
||||
复制并填写 `templates/feature-catalog.md`。默认输出两部分:
|
||||
|
||||
1. 面向读者的功能目录,只保留必要列。
|
||||
2. 追溯附表,保留功能 ID、状态、证据和限制,供内部审核。
|
||||
|
||||
## 写作规则
|
||||
|
||||
- 功能名使用“对象 + 动作”或稳定名词,不用“强大、智能、高效、全方位”。
|
||||
- 功能说明写能力边界,不写架构实现、性能参数和竞争结论。
|
||||
- “支持”后必须接具体对象或动作,避免“全面支持”“灵活支持”。
|
||||
- 同一功能在不同版本有差异时拆行或使用版本矩阵,不能用模糊脚注掩盖。
|
||||
- 公开清单不得带出 `restricted`、`internal` 证据内容。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 每条功能可追溯到事实清单 ID。
|
||||
- 当前能力、试用能力和规划能力明确分开。
|
||||
- 模块树无重复、孤立节点和伪造层级。
|
||||
- 功能粒度基本一致,名称、术语和版本范围统一。
|
||||
- 限制条件没有因表格精简而丢失。
|
||||
@@ -0,0 +1,27 @@
|
||||
# {{产品名称}}功能列表
|
||||
|
||||
**产品版本**:{{版本}}
|
||||
**适用范围**:{{版本/部署方式/授权范围}}
|
||||
**清单日期**:{{YYYY-MM-DD}}
|
||||
|
||||
## 功能目录
|
||||
|
||||
| 一级模块 | 二级模块 | 功能名称 | 使用角色 | 用户动作与结果 | 版本范围 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| {{模块}} | {{子模块}} | {{对象+动作}} | {{角色}} | {{触发动作;系统行为;可观察结果}} | {{版本}} | released |
|
||||
|
||||
## 版本能力矩阵
|
||||
|
||||
| 功能 ID | 标准版 | 专业版 | 企业版 | 依赖条件 |
|
||||
|---|---:|---:|---:|---|
|
||||
| {{FEAT-001}} | ✓ | ✓ | ✓ | {{依赖}} |
|
||||
|
||||
## 限制与说明
|
||||
|
||||
- {{功能 ID}}:{{适用边界、依赖或人工复核要求}}。
|
||||
|
||||
## 内部追溯表
|
||||
|
||||
| 功能 ID | 功能名称 | 状态 | 证据 ID | 复核结果 |
|
||||
|---|---|---|---|---|
|
||||
| {{FEAT-001}} | {{功能}} | released | {{EVD-001}} | {{通过/待核验}} |
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
name: product-marketing
|
||||
version: 1.0.0
|
||||
description: |
|
||||
编排产品事实、功能列表、招标参数、产品 PPT、技术方案、一页纸、白皮书、招标响应、
|
||||
演示套件、客户案例和竞品定位等多个独立技能。用于一次请求需要选择或组合多种
|
||||
产品市场产物,并确保它们共享同一事实版本、术语和承诺边界。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Execute
|
||||
compatibility: 可独立规划;执行节点需要对应子技能可用
|
||||
---
|
||||
|
||||
# 产品市场总编排
|
||||
|
||||
本技能只负责需求澄清、路由、依赖、门禁和跨产物一致性。各产物的方法和模板由
|
||||
对应独立技能负责,不能在总技能中重新实现一份简化版本。
|
||||
|
||||
## 子技能
|
||||
|
||||
| 技能 | 唯一职责 |
|
||||
|---|---|
|
||||
| `product-evidence` | 冻结产品事实、状态、证据和限制 |
|
||||
| `product-feature-catalog` | 功能目录和版本矩阵 |
|
||||
| `tender-technical-spec` | 可采购、可验收的招标技术规格 |
|
||||
| `product-presentation` | 产品介绍 PPT 与讲稿 |
|
||||
| `technical-proposal` | 客户或投标技术方案 |
|
||||
| `product-one-pager` | 一页纸产品概览和彩页文案 |
|
||||
| `solution-whitepaper` | 原理、架构、证据和边界白皮书 |
|
||||
| `tender-response-matrix` | 招标要求逐条响应、缺口和偏离 |
|
||||
| `sales-demo-kit` | 可执行演示套件、回退和演练材料 |
|
||||
| `customer-case-study` | 经授权且可复核的客户案例 |
|
||||
| `competitive-positioning` | 有来源的竞品矩阵和定位 |
|
||||
|
||||
`deai-writing` 和 `longdoc-docx` 是可选质量与导出技能,不承担产品事实判断。
|
||||
|
||||
## 第一步:形成任务简报
|
||||
|
||||
必须确认:
|
||||
|
||||
- 产品和版本。
|
||||
- 目标受众、决策目标和发布渠道。
|
||||
- 需要的产物、格式、篇幅、语言和截止时间。
|
||||
- 公开级别、客户信息和竞品信息的使用权限。
|
||||
- 原始事实材料、招标文件、客户需求和品牌资产。
|
||||
- 最终审批人。
|
||||
|
||||
信息不足时把缺口写入计划,不默认补齐。
|
||||
|
||||
## 第二步:生成路由计划
|
||||
|
||||
复制 `templates/route-plan.example.json`,只选择完成请求所需节点:
|
||||
|
||||
先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux
|
||||
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/scripts/validate_route_plan.py" ./route-plan.json
|
||||
```
|
||||
|
||||
所有产物必须直接或间接依赖唯一的 `product-evidence` 节点。节点缺少对应技能时
|
||||
明确报告缺失,不允许由总技能静默生成低质量替代物。
|
||||
|
||||
`depends_on` 表示硬依赖:被依赖节点未 `completed` 时,本节点不能进入 `running`
|
||||
或 `completed`。可选输入(例如尚无授权的客户案例)不要写进 `depends_on`,而是
|
||||
在 `reason` 中说明“可用则引用,不可用则不提及”。
|
||||
|
||||
## 推荐路由
|
||||
|
||||
### 投标响应(我方应标)
|
||||
|
||||
```text
|
||||
product-evidence
|
||||
→ tender-response-matrix(提取要求与缺口)
|
||||
→ technical-proposal
|
||||
→ tender-response-matrix(回填方案章节和证明材料)
|
||||
```
|
||||
|
||||
### 招标文件编制(采购方)
|
||||
|
||||
```text
|
||||
product-evidence → product-feature-catalog → tender-technical-spec
|
||||
```
|
||||
|
||||
`tender-technical-spec` 面向采购参数编写,不参与我方符合性判定,两条路由不要
|
||||
混用。
|
||||
|
||||
### 客户方案
|
||||
|
||||
```text
|
||||
product-evidence
|
||||
→ product-feature-catalog
|
||||
→ technical-proposal
|
||||
→ [product-presentation, product-one-pager]
|
||||
```
|
||||
|
||||
PPT 和一页纸从已批准方案摘要派生,避免重新解释范围。
|
||||
|
||||
### 产品发布与销售
|
||||
|
||||
```text
|
||||
product-evidence
|
||||
→ product-feature-catalog
|
||||
→ [competitive-positioning, customer-case-study]
|
||||
→ product-one-pager
|
||||
→ product-presentation
|
||||
→ sales-demo-kit
|
||||
```
|
||||
|
||||
客户案例没有授权时把该节点标记 `skipped` 且 `required` 为 false,其他节点可
|
||||
继续,但不得引用该案例。
|
||||
|
||||
### 白皮书
|
||||
|
||||
```text
|
||||
product-evidence → product-feature-catalog → solution-whitepaper
|
||||
```
|
||||
|
||||
## 第三步:执行门禁
|
||||
|
||||
每个节点开始前检查依赖是否通过;失败节点的下游必须阻断,不能标记成功。执行
|
||||
过程中更新 `status`,交付前用 final 阶段复核:
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/scripts/validate_route_plan.py" ./route-plan.json \
|
||||
--phase final --output-root .
|
||||
```
|
||||
|
||||
final 阶段要求必需节点全部 `completed`、依赖链无未完成节点,并核验每个声明产物
|
||||
文件存在且非空。
|
||||
|
||||
门禁分两类:
|
||||
|
||||
- `evidence-validation`、`cross-artifact-consistency` 由本套件自行判定,必须
|
||||
`passed`,不能豁免。
|
||||
- `confidentiality-review`、`human-approval` 取决于组织流程。需要评审时记为
|
||||
`passed`;组织不要求时记为 `waived` 并在 `waiver` 写明责任人与理由,校验通过
|
||||
但会输出警告,保留豁免记录。
|
||||
|
||||
最终统一核对:
|
||||
|
||||
- 产品名称、版本、功能状态和术语一致。
|
||||
- 相同参数的数值、单位、条件和统计口径一致。
|
||||
- `planned`、`beta`、`released` 没有跨产物变形。
|
||||
- 客户案例、Logo、引语和竞品结论权限一致。
|
||||
- 技术方案、PPT、一页纸和演示套件使用相同架构与工作流。
|
||||
- 所有对外主张可追溯到同一版 `product-evidence.json`。
|
||||
- 不含密钥、私有地址、内部证据路径和未批准承诺。
|
||||
|
||||
## 第四步:质量与导出
|
||||
|
||||
中文叙事产物可调用 `deai-writing`;长文可调用 `longdoc-docx`;PPT 使用
|
||||
`product-presentation` 自带构建器。质量工具只能修正表达和排版,不能更改事实、
|
||||
成熟度、参数或授权边界。
|
||||
|
||||
### 交付物形态
|
||||
|
||||
Markdown 与 JSON 是中间产物,不是交付物。除非用户另有指定,交付物为:
|
||||
|
||||
| 产物 | 交付格式 |
|
||||
| --- | --- |
|
||||
| 文档类(技术方案、白皮书、功能列表、响应矩阵、招标规格、一页纸、演示套件、竞争定位) | DOCX |
|
||||
| 产品 PPT | PPTX |
|
||||
|
||||
PDF 只作为排版核验中间件,核验后删除,不放入交付目录;用户明确要求时才交付。
|
||||
|
||||
### 目录与密级
|
||||
|
||||
中间产物与交付物必须分离,交付目录按各产物自身声明的密级分区:
|
||||
|
||||
```text
|
||||
build/ # 事实清单、路由计划、Markdown、构建配置
|
||||
check/ # 核验用 PDF、核验报告、页面 PNG
|
||||
deliverables/
|
||||
public/ # 可公开
|
||||
restricted/ # 受控客户交流与投标
|
||||
internal/ # 仅内部
|
||||
```
|
||||
|
||||
各子技能的 `output` 一律指向 `deliverables/<密级>/`,核验产物一律写入
|
||||
`build/check/`。交付目录内只允许出现 DOCX 与 PPTX。
|
||||
|
||||
单文件产物直接放 `build/<产物名>.md`。技术方案、白皮书等需要分章节的长文,改用
|
||||
`build/<产物名>/` 子目录,内部按 `longdoc-docx` 的 `chapters/`、`assets/`、
|
||||
`drafts/` 分层,避免多个长文的章节文件在 `build/` 根目录互相混淆。
|
||||
|
||||
密级以产物自身标注为准,不得由编排者主观下调。分区后须扫描公开级产物,确认
|
||||
未夹带受控结论、内部路径与凭据。
|
||||
|
||||
路由计划节点的 `outputs` 必须声明真实交付物路径,Markdown 与 JSON 记入
|
||||
`intermediates`。若 `outputs` 指向中间产物,交付门禁将只校验中间产物而放行缺失
|
||||
的真实交付物。
|
||||
|
||||
## 完成标准
|
||||
|
||||
路由计划通过校验;所有必需节点完成;无失败依赖被忽略;各产物共享同一事实版本;
|
||||
跨产物一致性、保密审查和人工审批全部通过,并保留选择、跳过和阻断理由;交付目录
|
||||
中每个产物都以约定格式实际存在,且已按密级分区。
|
||||
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate product-marketing orchestration route plans."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
SKILL_ARTIFACT = {
|
||||
"product-evidence": "evidence",
|
||||
"product-feature-catalog": "feature-catalog",
|
||||
"tender-technical-spec": "technical-spec",
|
||||
"product-presentation": "presentation",
|
||||
"technical-proposal": "technical-proposal",
|
||||
"product-one-pager": "one-pager",
|
||||
"solution-whitepaper": "whitepaper",
|
||||
"tender-response-matrix": "tender-response",
|
||||
"sales-demo-kit": "sales-demo",
|
||||
"customer-case-study": "case-study",
|
||||
"competitive-positioning": "competitive-positioning",
|
||||
}
|
||||
STATUSES = {"pending", "running", "completed", "failed", "skipped"}
|
||||
GATE_STATUSES = {"pending", "passed", "failed", "waived"}
|
||||
CONFIDENTIALITY = {"public", "restricted", "internal"}
|
||||
# Gates this suite can evaluate itself; they must actually pass.
|
||||
CORE_GATES = {"evidence-validation", "cross-artifact-consistency"}
|
||||
# Gates that depend on organizational policy; they may be waived with a reason.
|
||||
POLICY_GATES = {"confidentiality-review", "human-approval"}
|
||||
REQUIRED_GATES = CORE_GATES | POLICY_GATES
|
||||
SECRET_PATTERN = re.compile(r"(?i)(?:token|api[_-]?key|secret|password)=")
|
||||
|
||||
|
||||
def issue(path, message):
|
||||
return {"path": path, "message": message}
|
||||
|
||||
|
||||
def nonempty(value):
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def validate_output_path(value, path, errors):
|
||||
if not nonempty(value):
|
||||
errors.append(issue(path, "必须是非空字符串"))
|
||||
return
|
||||
normalized = value.replace("\\", "/")
|
||||
pure = PurePosixPath(normalized)
|
||||
if pure.is_absolute() or ".." in pure.parts:
|
||||
errors.append(issue(path, "必须是工作目录内的相对路径"))
|
||||
if SECRET_PATTERN.search(value):
|
||||
errors.append(issue(path, "路径疑似包含密钥或令牌"))
|
||||
|
||||
|
||||
def known_dependencies(node, nodes_by_id):
|
||||
return [
|
||||
dependency
|
||||
for dependency in node.get("depends_on", [])
|
||||
if isinstance(dependency, str) and dependency in nodes_by_id
|
||||
]
|
||||
|
||||
|
||||
def topological_order(nodes_by_id):
|
||||
"""Return (order, cycle). Kahn's algorithm keeps deep graphs iterative."""
|
||||
dependents = {node_id: [] for node_id in nodes_by_id}
|
||||
remaining = {}
|
||||
for node_id, node in nodes_by_id.items():
|
||||
dependencies = set(known_dependencies(node, nodes_by_id)) - {node_id}
|
||||
remaining[node_id] = len(dependencies)
|
||||
for dependency in dependencies:
|
||||
dependents[dependency].append(node_id)
|
||||
|
||||
queue = [node_id for node_id, count in remaining.items() if count == 0]
|
||||
order = []
|
||||
while queue:
|
||||
node_id = queue.pop()
|
||||
order.append(node_id)
|
||||
for dependent in dependents[node_id]:
|
||||
remaining[dependent] -= 1
|
||||
if remaining[dependent] == 0:
|
||||
queue.append(dependent)
|
||||
|
||||
if len(order) == len(nodes_by_id):
|
||||
return order, None
|
||||
return order, sorted(node_id for node_id in nodes_by_id if remaining[node_id] > 0)
|
||||
|
||||
|
||||
def evidence_reachability(order, nodes_by_id, evidence_id):
|
||||
reaches = {}
|
||||
for node_id in order:
|
||||
dependencies = known_dependencies(nodes_by_id[node_id], nodes_by_id)
|
||||
reaches[node_id] = any(
|
||||
dependency == evidence_id or reaches.get(dependency, False)
|
||||
for dependency in dependencies
|
||||
)
|
||||
return reaches
|
||||
|
||||
|
||||
def validate_plan(data, phase="plan", output_root=None):
|
||||
errors = []
|
||||
warnings = []
|
||||
if not isinstance(data, dict):
|
||||
return [issue("$", "根节点必须是对象")], warnings
|
||||
if data.get("schema_version") != 1:
|
||||
errors.append(issue("schema_version", "当前仅支持整数 1"))
|
||||
|
||||
request = data.get("request")
|
||||
if not isinstance(request, dict):
|
||||
errors.append(issue("request", "必须是对象"))
|
||||
request = {}
|
||||
for field in ("id", "objective", "channel", "evidence_ref", "product_version"):
|
||||
if not nonempty(request.get(field)):
|
||||
errors.append(issue(f"request.{field}", "必须是非空字符串"))
|
||||
if request.get("confidentiality") not in CONFIDENTIALITY:
|
||||
errors.append(
|
||||
issue(
|
||||
"request.confidentiality",
|
||||
f"必须是 {sorted(CONFIDENTIALITY)} 之一",
|
||||
)
|
||||
)
|
||||
if nonempty(request.get("evidence_ref")):
|
||||
validate_output_path(
|
||||
request["evidence_ref"],
|
||||
"request.evidence_ref",
|
||||
errors,
|
||||
)
|
||||
|
||||
nodes = data.get("nodes")
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
errors.append(issue("nodes", "必须是非空数组"))
|
||||
return errors, warnings
|
||||
|
||||
nodes_by_id = {}
|
||||
all_outputs = {}
|
||||
evidence_nodes = []
|
||||
for index, node in enumerate(nodes):
|
||||
path = f"nodes[{index}]"
|
||||
if not isinstance(node, dict):
|
||||
errors.append(issue(path, "必须是对象"))
|
||||
continue
|
||||
node_id = node.get("id")
|
||||
if not nonempty(node_id):
|
||||
errors.append(issue(f"{path}.id", "必须是非空字符串"))
|
||||
continue
|
||||
if node_id in nodes_by_id:
|
||||
errors.append(issue(f"{path}.id", f"节点 ID 重复:{node_id}"))
|
||||
continue
|
||||
nodes_by_id[node_id] = node
|
||||
skill = node.get("skill")
|
||||
if skill not in SKILL_ARTIFACT:
|
||||
errors.append(issue(f"{path}.skill", f"未知技能:{skill!r}"))
|
||||
elif node.get("artifact_type") != SKILL_ARTIFACT[skill]:
|
||||
errors.append(
|
||||
issue(
|
||||
f"{path}.artifact_type",
|
||||
f"{skill} 应生成 {SKILL_ARTIFACT[skill]}",
|
||||
)
|
||||
)
|
||||
if skill == "product-evidence":
|
||||
evidence_nodes.append(node_id)
|
||||
dependencies = node.get("depends_on")
|
||||
if not isinstance(dependencies, list):
|
||||
errors.append(issue(f"{path}.depends_on", "必须是数组"))
|
||||
elif len(dependencies) != len(set(dependencies)):
|
||||
errors.append(issue(f"{path}.depends_on", "存在重复依赖"))
|
||||
if not isinstance(node.get("required"), bool):
|
||||
errors.append(issue(f"{path}.required", "必须是布尔值"))
|
||||
if node.get("status") not in STATUSES:
|
||||
errors.append(
|
||||
issue(
|
||||
f"{path}.status",
|
||||
f"必须是 {sorted(STATUSES)} 之一",
|
||||
)
|
||||
)
|
||||
if node.get("required") is True and node.get("status") == "skipped":
|
||||
errors.append(issue(f"{path}.status", "必需节点不能标记 skipped"))
|
||||
if not nonempty(node.get("reason")):
|
||||
errors.append(issue(f"{path}.reason", "必须说明选择理由"))
|
||||
outputs = node.get("outputs")
|
||||
if not isinstance(outputs, list) or not outputs:
|
||||
errors.append(issue(f"{path}.outputs", "必须是非空数组"))
|
||||
else:
|
||||
for output_index, output in enumerate(outputs):
|
||||
output_path = f"{path}.outputs[{output_index}]"
|
||||
validate_output_path(output, output_path, errors)
|
||||
if output in all_outputs:
|
||||
errors.append(
|
||||
issue(
|
||||
output_path,
|
||||
f"输出与 {all_outputs[output]} 重复:{output}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
all_outputs[output] = node_id
|
||||
|
||||
for node_id, node in nodes_by_id.items():
|
||||
for dependency in node.get("depends_on", []):
|
||||
if dependency == node_id:
|
||||
errors.append(
|
||||
issue(f"nodes.{node_id}.depends_on", "不能依赖自身")
|
||||
)
|
||||
elif dependency not in nodes_by_id:
|
||||
errors.append(
|
||||
issue(
|
||||
f"nodes.{node_id}.depends_on",
|
||||
f"依赖节点不存在:{dependency}",
|
||||
)
|
||||
)
|
||||
|
||||
order, cycle = topological_order(nodes_by_id)
|
||||
if cycle:
|
||||
errors.append(issue("nodes", f"依赖存在环:{'、'.join(cycle)}"))
|
||||
|
||||
if len(evidence_nodes) != 1:
|
||||
errors.append(issue("nodes", "必须且只能有一个 product-evidence 节点"))
|
||||
elif not cycle:
|
||||
evidence_id = evidence_nodes[0]
|
||||
reaches = evidence_reachability(order, nodes_by_id, evidence_id)
|
||||
for node_id in nodes_by_id:
|
||||
if node_id != evidence_id and not reaches.get(node_id, False):
|
||||
errors.append(
|
||||
issue(
|
||||
f"nodes.{node_id}.depends_on",
|
||||
"所有产物必须直接或间接依赖 product-evidence",
|
||||
)
|
||||
)
|
||||
|
||||
validate_execution(nodes_by_id, errors)
|
||||
validate_gates(data.get("final_gates"), errors, warnings, phase)
|
||||
if phase == "final":
|
||||
validate_final_phase(nodes_by_id, output_root, errors)
|
||||
|
||||
if len(nodes_by_id) == 1:
|
||||
warnings.append(issue("nodes", "路由计划没有任何最终产物节点"))
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def validate_execution(nodes_by_id, errors):
|
||||
for node_id, node in nodes_by_id.items():
|
||||
status = node.get("status")
|
||||
if status not in ("running", "completed"):
|
||||
continue
|
||||
for dependency in known_dependencies(node, nodes_by_id):
|
||||
dependency_node = nodes_by_id[dependency]
|
||||
dependency_status = dependency_node.get("status")
|
||||
if dependency_status == "completed":
|
||||
continue
|
||||
if (
|
||||
dependency_status == "skipped"
|
||||
and dependency_node.get("required") is False
|
||||
):
|
||||
continue
|
||||
errors.append(
|
||||
issue(
|
||||
f"nodes.{node_id}.status",
|
||||
f"依赖 {dependency} 状态为 {dependency_status},"
|
||||
f"不能标记 {status}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_gates(gates, errors, warnings, phase):
|
||||
if not isinstance(gates, list):
|
||||
errors.append(issue("final_gates", "必须是数组"))
|
||||
return {}
|
||||
resolved = {}
|
||||
for index, gate in enumerate(gates):
|
||||
path = f"final_gates[{index}]"
|
||||
if isinstance(gate, str):
|
||||
if not nonempty(gate):
|
||||
errors.append(issue(path, "门禁名称不能为空"))
|
||||
continue
|
||||
resolved[gate] = {"status": None, "waiver": None}
|
||||
elif isinstance(gate, dict):
|
||||
name = gate.get("name")
|
||||
if not nonempty(name):
|
||||
errors.append(issue(f"{path}.name", "门禁名称不能为空"))
|
||||
continue
|
||||
status = gate.get("status")
|
||||
if status not in GATE_STATUSES:
|
||||
errors.append(
|
||||
issue(
|
||||
f"{path}.status",
|
||||
f"必须是 {sorted(GATE_STATUSES)} 之一",
|
||||
)
|
||||
)
|
||||
continue
|
||||
waiver = gate.get("waiver")
|
||||
if status == "waived":
|
||||
if name not in POLICY_GATES:
|
||||
errors.append(
|
||||
issue(
|
||||
f"{path}.status",
|
||||
f"{name} 由本套件自行判定,不能豁免",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not nonempty(waiver):
|
||||
errors.append(
|
||||
issue(f"{path}.waiver", "豁免必须写明责任人与理由")
|
||||
)
|
||||
continue
|
||||
resolved[name] = {"status": status, "waiver": waiver}
|
||||
else:
|
||||
errors.append(issue(path, "必须是字符串或对象"))
|
||||
missing = REQUIRED_GATES - set(resolved)
|
||||
if missing:
|
||||
errors.append(issue("final_gates", f"缺少门禁:{sorted(missing)}"))
|
||||
if phase == "final":
|
||||
for name in sorted(REQUIRED_GATES & set(resolved)):
|
||||
status = resolved[name]["status"]
|
||||
if status == "passed":
|
||||
continue
|
||||
if status == "waived":
|
||||
warnings.append(
|
||||
issue(
|
||||
"final_gates",
|
||||
f"{name} 已豁免:{resolved[name]['waiver']}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
errors.append(
|
||||
issue(
|
||||
"final_gates",
|
||||
f"交付前门禁 {name} 必须记录 passed,当前为 "
|
||||
f"{status or '未记录'}",
|
||||
)
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def validate_final_phase(nodes_by_id, output_root, errors):
|
||||
for node_id, node in nodes_by_id.items():
|
||||
status = node.get("status")
|
||||
if node.get("required") is True and status != "completed":
|
||||
errors.append(
|
||||
issue(
|
||||
f"nodes.{node_id}.status",
|
||||
f"交付前必需节点必须 completed,当前为 {status}",
|
||||
)
|
||||
)
|
||||
elif status not in ("completed", "skipped"):
|
||||
errors.append(
|
||||
issue(
|
||||
f"nodes.{node_id}.status",
|
||||
f"交付前可选节点必须 completed 或 skipped,当前为 {status}",
|
||||
)
|
||||
)
|
||||
if status != "completed" or output_root is None:
|
||||
continue
|
||||
outputs = node.get("outputs")
|
||||
if not isinstance(outputs, list):
|
||||
continue
|
||||
for output in outputs:
|
||||
if not nonempty(output):
|
||||
continue
|
||||
artifact = output_root / output
|
||||
if not artifact.is_file():
|
||||
errors.append(
|
||||
issue(
|
||||
f"nodes.{node_id}.outputs",
|
||||
f"声明的产物不存在:{output}",
|
||||
)
|
||||
)
|
||||
elif artifact.stat().st_size == 0:
|
||||
errors.append(
|
||||
issue(
|
||||
f"nodes.{node_id}.outputs",
|
||||
f"产物为空文件:{output}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="验证产品市场技能路由计划")
|
||||
parser.add_argument("plan", help="route-plan.json")
|
||||
parser.add_argument(
|
||||
"--phase",
|
||||
choices=("plan", "final"),
|
||||
default="plan",
|
||||
help="plan 校验结构,final 追加交付前状态与产物核验",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-root",
|
||||
help="final 阶段解析 outputs 相对路径的根目录",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
path = Path(args.plan).expanduser().resolve()
|
||||
output_root = (
|
||||
Path(args.output_root).expanduser().resolve() if args.output_root else None
|
||||
)
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
errors, warnings = validate_plan(data, phase=args.phase, output_root=output_root)
|
||||
report = {
|
||||
"file": str(path),
|
||||
"phase": args.phase,
|
||||
"valid": not errors,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"nodes": len(data.get("nodes", [])) if isinstance(data, dict) else 0,
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
for level, items in (("错误", errors), ("警告", warnings)):
|
||||
for item in items:
|
||||
print(f"[{level}] {item['path']}:{item['message']}")
|
||||
print(
|
||||
f"路由核验({args.phase}):错误 {len(errors)},警告 {len(warnings)},"
|
||||
f"状态 {'通过' if report['valid'] else '失败'}"
|
||||
)
|
||||
return 0 if report["valid"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"request": {
|
||||
"id": "PM-001",
|
||||
"objective": "为目标客户准备产品介绍与技术方案",
|
||||
"audiences": [
|
||||
"技术负责人",
|
||||
"业务负责人"
|
||||
],
|
||||
"channel": "受控客户交流",
|
||||
"confidentiality": "restricted",
|
||||
"evidence_ref": "product-evidence.json",
|
||||
"product_version": "1.0"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "evidence",
|
||||
"skill": "product-evidence",
|
||||
"artifact_type": "evidence",
|
||||
"depends_on": [],
|
||||
"required": true,
|
||||
"status": "pending",
|
||||
"outputs": [
|
||||
"product-evidence.json"
|
||||
],
|
||||
"reason": "所有产物共享同一事实版本"
|
||||
},
|
||||
{
|
||||
"id": "catalog",
|
||||
"skill": "product-feature-catalog",
|
||||
"artifact_type": "feature-catalog",
|
||||
"depends_on": [
|
||||
"evidence"
|
||||
],
|
||||
"required": true,
|
||||
"status": "pending",
|
||||
"outputs": [
|
||||
"feature-catalog.md"
|
||||
],
|
||||
"reason": "先固定产品能力范围"
|
||||
},
|
||||
{
|
||||
"id": "proposal",
|
||||
"skill": "technical-proposal",
|
||||
"artifact_type": "technical-proposal",
|
||||
"depends_on": [
|
||||
"catalog"
|
||||
],
|
||||
"required": true,
|
||||
"status": "pending",
|
||||
"outputs": [
|
||||
"technical-proposal.md"
|
||||
],
|
||||
"reason": "客户方案依赖已核验能力目录"
|
||||
},
|
||||
{
|
||||
"id": "presentation",
|
||||
"skill": "product-presentation",
|
||||
"artifact_type": "presentation",
|
||||
"depends_on": [
|
||||
"proposal"
|
||||
],
|
||||
"required": true,
|
||||
"status": "pending",
|
||||
"outputs": [
|
||||
"product-presentation.pptx"
|
||||
],
|
||||
"reason": "PPT 从已批准方案摘要派生"
|
||||
}
|
||||
],
|
||||
"final_gates": [
|
||||
{ "name": "evidence-validation", "status": "pending" },
|
||||
{ "name": "cross-artifact-consistency", "status": "pending" },
|
||||
{ "name": "confidentiality-review", "status": "pending" },
|
||||
{ "name": "human-approval", "status": "pending" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = SKILL_DIR / "scripts" / "validate_route_plan.py"
|
||||
SPEC = importlib.util.spec_from_file_location("validate_route_plan", SCRIPT)
|
||||
validate_route_plan = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(validate_route_plan)
|
||||
|
||||
|
||||
class RoutePlanTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
template = SKILL_DIR / "templates" / "route-plan.example.json"
|
||||
cls.plan = json.loads(template.read_text(encoding="utf-8"))
|
||||
|
||||
def test_example_plan_is_valid(self):
|
||||
errors, warnings = validate_route_plan.validate_plan(self.plan)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
def test_rejects_cycle(self):
|
||||
plan = copy.deepcopy(self.plan)
|
||||
plan["nodes"][0]["depends_on"] = ["presentation"]
|
||||
errors, _ = validate_route_plan.validate_plan(plan)
|
||||
self.assertTrue(any("依赖存在环" in item["message"] for item in errors))
|
||||
|
||||
def test_rejects_artifact_without_evidence_dependency(self):
|
||||
plan = copy.deepcopy(self.plan)
|
||||
plan["nodes"][1]["depends_on"] = []
|
||||
errors, _ = validate_route_plan.validate_plan(plan)
|
||||
self.assertTrue(
|
||||
any("product-evidence" in item["message"] for item in errors)
|
||||
)
|
||||
|
||||
def test_rejects_duplicate_output(self):
|
||||
plan = copy.deepcopy(self.plan)
|
||||
plan["nodes"][2]["outputs"] = ["feature-catalog.md"]
|
||||
errors, _ = validate_route_plan.validate_plan(plan)
|
||||
self.assertTrue(any("输出与" in item["message"] for item in errors))
|
||||
|
||||
def test_rejects_completed_node_with_unfinished_dependency(self):
|
||||
plan = copy.deepcopy(self.plan)
|
||||
plan["nodes"][1]["status"] = "completed"
|
||||
errors, _ = validate_route_plan.validate_plan(plan)
|
||||
self.assertTrue(
|
||||
any("不能标记 completed" in item["message"] for item in errors)
|
||||
)
|
||||
|
||||
def test_final_phase_requires_completion_and_artifacts(self):
|
||||
plan = copy.deepcopy(self.plan)
|
||||
errors, _ = validate_route_plan.validate_plan(plan, phase="final")
|
||||
self.assertTrue(
|
||||
any("必须 completed" in item["message"] for item in errors)
|
||||
)
|
||||
self.assertTrue(
|
||||
any("必须记录 passed" in item["message"] for item in errors)
|
||||
)
|
||||
|
||||
def test_policy_gate_can_be_waived_with_reason(self):
|
||||
plan = copy.deepcopy(self.plan)
|
||||
for node in plan["nodes"]:
|
||||
node["status"] = "completed"
|
||||
for gate in plan["final_gates"]:
|
||||
gate["status"] = "passed"
|
||||
approval = next(
|
||||
g for g in plan["final_gates"] if g["name"] == "human-approval"
|
||||
)
|
||||
approval["status"] = "waived"
|
||||
errors, _ = validate_route_plan.validate_plan(plan, phase="final")
|
||||
self.assertTrue(any("豁免必须写明" in i["message"] for i in errors))
|
||||
|
||||
approval["waiver"] = "内部草稿,责任人张三,2026-08-01"
|
||||
errors, warnings = validate_route_plan.validate_plan(plan, phase="final")
|
||||
self.assertEqual(errors, [])
|
||||
self.assertTrue(any("已豁免" in i["message"] for i in warnings))
|
||||
|
||||
def test_core_gate_cannot_be_waived(self):
|
||||
plan = copy.deepcopy(self.plan)
|
||||
for node in plan["nodes"]:
|
||||
node["status"] = "completed"
|
||||
for gate in plan["final_gates"]:
|
||||
gate["status"] = "passed"
|
||||
core = next(
|
||||
g for g in plan["final_gates"] if g["name"] == "evidence-validation"
|
||||
)
|
||||
core["status"] = "waived"
|
||||
core["waiver"] = "跳过"
|
||||
errors, _ = validate_route_plan.validate_plan(plan, phase="final")
|
||||
self.assertTrue(any("不能豁免" in i["message"] for i in errors))
|
||||
|
||||
def test_final_phase_accepts_completed_plan_with_outputs(self):
|
||||
plan = copy.deepcopy(self.plan)
|
||||
for node in plan["nodes"]:
|
||||
node["status"] = "completed"
|
||||
for gate in plan["final_gates"]:
|
||||
gate["status"] = "passed"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
for node in plan["nodes"]:
|
||||
for output in node["outputs"]:
|
||||
(root / output).write_text("content", encoding="utf-8")
|
||||
errors, _ = validate_route_plan.validate_plan(
|
||||
plan, phase="final", output_root=root
|
||||
)
|
||||
self.assertEqual(errors, [])
|
||||
missing = copy.deepcopy(plan)
|
||||
(root / missing["nodes"][-1]["outputs"][0]).unlink()
|
||||
errors, _ = validate_route_plan.validate_plan(
|
||||
missing, phase="final", output_root=root
|
||||
)
|
||||
self.assertTrue(
|
||||
any("产物不存在" in item["message"] for item in errors)
|
||||
)
|
||||
|
||||
def test_deep_chain_does_not_recurse(self):
|
||||
nodes = [
|
||||
{
|
||||
"id": "evidence",
|
||||
"skill": "product-evidence",
|
||||
"artifact_type": "evidence",
|
||||
"depends_on": [],
|
||||
"required": True,
|
||||
"status": "pending",
|
||||
"outputs": ["product-evidence.json"],
|
||||
"reason": "基线",
|
||||
}
|
||||
]
|
||||
previous = "evidence"
|
||||
for index in range(1500):
|
||||
node_id = f"catalog-{index}"
|
||||
nodes.append(
|
||||
{
|
||||
"id": node_id,
|
||||
"skill": "product-feature-catalog",
|
||||
"artifact_type": "feature-catalog",
|
||||
"depends_on": [previous],
|
||||
"required": True,
|
||||
"status": "pending",
|
||||
"outputs": [f"feature-catalog-{index}.md"],
|
||||
"reason": "链式依赖",
|
||||
}
|
||||
)
|
||||
previous = node_id
|
||||
plan = copy.deepcopy(self.plan)
|
||||
plan["nodes"] = list(reversed(nodes))
|
||||
errors, _ = validate_route_plan.validate_plan(plan)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: product-one-pager
|
||||
version: 1.0.0
|
||||
description: |
|
||||
将已核验产品信息压缩为一页纸产品概览、宣传彩页或官网下载页文案。用于首次触达、
|
||||
展会资料和销售跟进;不替代完整功能清单、技术规格或技术方案。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
compatibility: Markdown/HTML;建议配合 product-evidence
|
||||
---
|
||||
|
||||
# 产品一页纸
|
||||
|
||||
## 必要输入
|
||||
|
||||
- 产品定位、目标受众和本次一页纸唯一目标。
|
||||
- 3 至 5 项核心能力、一个典型工作流、适用范围和限制。
|
||||
- 可公开的参数、案例、标识、截图和行动入口。
|
||||
- 页面尺寸、语言、品牌规范和发布渠道。
|
||||
|
||||
## 内容预算
|
||||
|
||||
一页纸不是把长文缩小字号。默认预算:
|
||||
|
||||
- 主标题 1 个,副标题 1 段。
|
||||
- 目标问题 2 至 3 条。
|
||||
- 核心能力 3 至 5 项。
|
||||
- 工作方式或架构图 1 张。
|
||||
- 参数、案例或验证结果最多 3 项。
|
||||
- 部署与适用边界 1 个区域。
|
||||
- 行动入口 1 个。
|
||||
|
||||
超过预算时删减次要内容,不缩成无法阅读的小字。
|
||||
|
||||
## 生成流程
|
||||
|
||||
1. 用一句话回答“面向谁、提供什么、适用于什么范围”。
|
||||
2. 选择与目标受众最相关的能力,不按后台菜单罗列。
|
||||
3. 每项能力写“动作 + 结果”,避免形容词。
|
||||
4. 将必要条件和人工复核要求放在正文可见区域。
|
||||
5. 选择明确行动入口,例如项目咨询、申请演示或下载规格。
|
||||
6. 使用 `templates/product-one-pager.md` 起草,再进入视觉排版。
|
||||
|
||||
## 禁止事项
|
||||
|
||||
- 编造客户 Logo、案例、认证、排名或性能数字。
|
||||
- 使用“领先、唯一、全面、百分之百”等无依据绝对化表述。
|
||||
- 首屏写成实施教程、需求调查表或接口配置说明。
|
||||
- 将 `restricted`、`internal` 内容放入公开彩页。
|
||||
- 用二维码或短链掩盖未经审核的外部地址。
|
||||
|
||||
## 完成标准
|
||||
|
||||
打印或 100% 缩放时仍可读;读者在一分钟内能说出产品定位、核心能力、适用范围
|
||||
和下一步;所有数字和主张有证据;没有因压缩而删除关键限制。
|
||||
@@ -0,0 +1,37 @@
|
||||
# {{产品名称}}
|
||||
|
||||
## {{面向目标角色,提供具体产品能力}}
|
||||
|
||||
{{一句话说明产品类别、主要用途和适用范围。}}
|
||||
|
||||
### 目标场景
|
||||
|
||||
- {{角色需要完成的具体任务}}
|
||||
- {{现有流程中的可验证问题}}
|
||||
|
||||
### 核心能力
|
||||
|
||||
| 能力 | 用户动作与结果 |
|
||||
|---|---|
|
||||
| {{能力一}} | {{动作;结果}} |
|
||||
| {{能力二}} | {{动作;结果}} |
|
||||
| {{能力三}} | {{动作;结果}} |
|
||||
|
||||
### 工作方式
|
||||
|
||||
{{一张架构图、流程图或三步工作流。}}
|
||||
|
||||
### 已核验参数或结果
|
||||
|
||||
- **{{数值}}**:{{指标、版本、条件与口径}}
|
||||
- **{{数值}}**:{{指标、版本、条件与口径}}
|
||||
|
||||
### 部署与适用范围
|
||||
|
||||
{{部署方式、数据条件、依赖、限制和人工复核要求。}}
|
||||
|
||||
### 下一步
|
||||
|
||||
{{项目咨询 / 申请演示 / 获取技术规格}}
|
||||
|
||||
<!-- 内部追溯:列出本页使用的 FEAT/PAR/CLM/EVD ID,发布前删除此注释。 -->
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: product-presentation
|
||||
version: 1.0.0
|
||||
description: |
|
||||
生成面向特定受众的产品介绍 PPT、逐页叙事和演讲备注。用于产品发布、客户宣讲、
|
||||
售前交流或内部汇报;不负责现场产品操作步骤,操作型演示应使用 sales-demo-kit。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Execute
|
||||
compatibility: 生成 PPTX 需 python-pptx 1.0+
|
||||
---
|
||||
|
||||
# 产品介绍 PPT
|
||||
|
||||
## 必要输入
|
||||
|
||||
- 已核验的产品事实、功能、参数、案例授权和适用边界。
|
||||
- 受众角色、演示目标、场合、时长、页数和品牌要求。
|
||||
- 可用图片、架构图、截图及其授权范围。
|
||||
|
||||
先写一句演示目标,例如“让技术负责人理解部署边界并同意进入 POC”,再决定页序。
|
||||
|
||||
## 推荐叙事
|
||||
|
||||
按任务选择,不机械套用全部页面:
|
||||
|
||||
1. 封面与本次议题。
|
||||
2. 受众当前面临的具体问题。
|
||||
3. 产品定位和适用范围。
|
||||
4. 核心工作流或架构。
|
||||
5. 关键能力,按场景组织,不按后台菜单朗读。
|
||||
6. 已核验参数、兼容性或安全边界。
|
||||
7. 获准公开的案例或验证结果。
|
||||
8. 部署、交付和下一步。
|
||||
|
||||
## 页面纪律
|
||||
|
||||
- 一页只有一个结论,标题直接写该页内容。
|
||||
- 正文优先 3 至 5 个要点,每个要点只表达一个信息。
|
||||
- 数字必须带条件和来源,不使用无依据的百分比。
|
||||
- 规划能力、试用能力和当前能力使用不同标识。
|
||||
- 讲稿可以补充上下文,但不能引入幻灯片中没有依据的新事实。
|
||||
- 不把产品介绍写成按钮操作手册,也不使用满页功能清单。
|
||||
|
||||
## 生成 PPTX
|
||||
|
||||
先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux
|
||||
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
复制并填写 `templates/deck.example.json`:
|
||||
|
||||
```bash
|
||||
<python> -m pip install -r "<skill-dir>/requirements.txt"
|
||||
<python> "<skill-dir>/scripts/build_pptx.py" \
|
||||
--input ./build/deck.json \
|
||||
--output ./dist/product-presentation.pptx
|
||||
```
|
||||
|
||||
`deck.json` 与配图是中间产物,放在 `build/`;交付物 PPTX 放在 `dist/`。复核用
|
||||
PDF 和页面截图一律写入 `build/check/`,不得与 PPTX 同目录。
|
||||
|
||||
构建器支持封面、章节页、要点页、双栏页、指标页、图片页和收尾页。它负责稳定
|
||||
排版,不负责补写内容;`deck.json` 中的文字必须先通过事实审查。
|
||||
|
||||
除章节页外每页必须提供非空 `notes`,否则构建失败。图片必须放在 `deck.json`
|
||||
所在目录内,超过 40MB 或 8000 万像素会被拒绝;其余图片按版面尺寸和 150 DPI
|
||||
重采样后嵌入,避免生成超大文件。
|
||||
|
||||
## 视觉复核
|
||||
|
||||
1. 使用 LibreOffice 或 PowerPoint 打开并导出 PDF;该 PDF 仅用于复核,交付物是
|
||||
PPTX,复核后应删除。LibreOffice 需安装 Impress 组件,仅装 Writer 时无法转换。
|
||||
2. 检查文字溢出、孤行、图片拉伸、低清截图和字号过小。
|
||||
3. 快速朗读全套讲稿,确认时间预算和页面转场自然。
|
||||
4. 核对所有数字、版本、案例名称和产品状态与事实清单一致。
|
||||
|
||||
## 完成标准
|
||||
|
||||
PPTX 可打开;页数和时长符合简报;每页目标明确;视觉层级统一;备注完整;无
|
||||
未经授权的客户信息、竞品结论或内部证据路径。
|
||||
@@ -0,0 +1 @@
|
||||
python-pptx>=1.0,<2
|
||||
@@ -0,0 +1,590 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a 16:9 product presentation from a reviewed deck JSON file."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
from pptx import Presentation
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||||
from pptx.util import Inches, Pt
|
||||
|
||||
|
||||
DEFAULT_THEME = {
|
||||
"primary": "1F3864",
|
||||
"accent": "C00000",
|
||||
"background": "FFFFFF",
|
||||
"text": "1A1A1A",
|
||||
"muted": "666666",
|
||||
"light": "F2F5F9",
|
||||
"font_zh": "Microsoft YaHei",
|
||||
"font_en": "Arial",
|
||||
}
|
||||
SLIDE_TYPES = {
|
||||
"title",
|
||||
"section",
|
||||
"bullets",
|
||||
"two-column",
|
||||
"metrics",
|
||||
"image",
|
||||
"closing",
|
||||
}
|
||||
NOTES_OPTIONAL_TYPES = {"section"}
|
||||
MAX_IMAGE_BYTES = 40 * 1024 * 1024
|
||||
MAX_IMAGE_PIXELS = 80_000_000
|
||||
RENDER_DPI = 150
|
||||
|
||||
|
||||
def color(value):
|
||||
value = value.lstrip("#")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{6}", value):
|
||||
raise ValueError(f"颜色必须是六位十六进制值:{value!r}")
|
||||
return RGBColor.from_string(value.upper())
|
||||
|
||||
|
||||
def nonempty(value, path):
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"{path} 必须是非空字符串")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def load_deck(path):
|
||||
input_path = Path(path).expanduser().resolve()
|
||||
with input_path.open(encoding="utf-8") as handle:
|
||||
deck = json.load(handle)
|
||||
if not isinstance(deck, dict):
|
||||
raise ValueError("deck 根节点必须是对象")
|
||||
nonempty(deck.get("title"), "title")
|
||||
slides = deck.get("slides")
|
||||
if not isinstance(slides, list) or not slides:
|
||||
raise ValueError("slides 必须是非空数组")
|
||||
theme = {**DEFAULT_THEME, **deck.get("theme", {})}
|
||||
for key in ("primary", "accent", "background", "text", "muted", "light"):
|
||||
color(theme[key])
|
||||
for index, slide in enumerate(slides):
|
||||
validate_slide(slide, index)
|
||||
deck["theme"] = theme
|
||||
return input_path, deck
|
||||
|
||||
|
||||
def validate_bullets(values, path, maximum=6):
|
||||
if not isinstance(values, list) or not values:
|
||||
raise ValueError(f"{path} 必须是非空数组")
|
||||
if len(values) > maximum:
|
||||
raise ValueError(f"{path} 最多 {maximum} 项")
|
||||
for index, value in enumerate(values):
|
||||
text = nonempty(value, f"{path}[{index}]")
|
||||
if len(text) > 120:
|
||||
raise ValueError(f"{path}[{index}] 超过 120 字")
|
||||
|
||||
|
||||
def validate_slide(slide, index):
|
||||
path = f"slides[{index}]"
|
||||
if not isinstance(slide, dict):
|
||||
raise ValueError(f"{path} 必须是对象")
|
||||
slide_type = slide.get("type")
|
||||
if slide_type not in SLIDE_TYPES:
|
||||
raise ValueError(f"{path}.type 未知:{slide_type!r}")
|
||||
nonempty(slide.get("title"), f"{path}.title")
|
||||
if slide_type in {"bullets", "closing"}:
|
||||
validate_bullets(slide.get("bullets"), f"{path}.bullets")
|
||||
elif slide_type == "two-column":
|
||||
for side in ("left", "right"):
|
||||
column = slide.get(side)
|
||||
if not isinstance(column, dict):
|
||||
raise ValueError(f"{path}.{side} 必须是对象")
|
||||
nonempty(column.get("title"), f"{path}.{side}.title")
|
||||
validate_bullets(
|
||||
column.get("bullets"),
|
||||
f"{path}.{side}.bullets",
|
||||
maximum=5,
|
||||
)
|
||||
elif slide_type == "metrics":
|
||||
metrics = slide.get("metrics")
|
||||
if not isinstance(metrics, list) or not 1 <= len(metrics) <= 4:
|
||||
raise ValueError(f"{path}.metrics 必须包含 1 至 4 项")
|
||||
for metric_index, metric in enumerate(metrics):
|
||||
if not isinstance(metric, dict):
|
||||
raise ValueError(
|
||||
f"{path}.metrics[{metric_index}] 必须是对象"
|
||||
)
|
||||
for field in ("value", "label", "detail"):
|
||||
nonempty(
|
||||
metric.get(field),
|
||||
f"{path}.metrics[{metric_index}].{field}",
|
||||
)
|
||||
elif slide_type == "image":
|
||||
nonempty(slide.get("image"), f"{path}.image")
|
||||
if slide_type not in NOTES_OPTIONAL_TYPES:
|
||||
nonempty(slide.get("notes"), f"{path}.notes")
|
||||
|
||||
|
||||
def add_run_font(run, theme, size, bold=False, color_value=None):
|
||||
run.font.name = theme["font_en"]
|
||||
run.font.size = Pt(size)
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = color(color_value or theme["text"])
|
||||
run.font._element.set("lang", "zh-CN")
|
||||
|
||||
|
||||
def add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
text="",
|
||||
size=24,
|
||||
bold=False,
|
||||
color_value=None,
|
||||
align=PP_ALIGN.LEFT,
|
||||
vertical=MSO_ANCHOR.TOP,
|
||||
margin=0.08,
|
||||
):
|
||||
shape = slide.shapes.add_textbox(
|
||||
Inches(x),
|
||||
Inches(y),
|
||||
Inches(width),
|
||||
Inches(height),
|
||||
)
|
||||
frame = shape.text_frame
|
||||
frame.clear()
|
||||
frame.margin_left = Inches(margin)
|
||||
frame.margin_right = Inches(margin)
|
||||
frame.margin_top = Inches(margin)
|
||||
frame.margin_bottom = Inches(margin)
|
||||
frame.vertical_anchor = vertical
|
||||
paragraph = frame.paragraphs[0]
|
||||
paragraph.alignment = align
|
||||
run = paragraph.add_run()
|
||||
run.text = text
|
||||
add_run_font(run, theme, size, bold=bold, color_value=color_value)
|
||||
return shape
|
||||
|
||||
|
||||
def set_background(slide, theme, key="background"):
|
||||
fill = slide.background.fill
|
||||
fill.solid()
|
||||
fill.fore_color.rgb = color(theme[key])
|
||||
|
||||
|
||||
def add_header(slide, theme, title):
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.65,
|
||||
0.35,
|
||||
11.9,
|
||||
0.65,
|
||||
title,
|
||||
size=26,
|
||||
bold=True,
|
||||
color_value=theme["primary"],
|
||||
)
|
||||
line = slide.shapes.add_shape(
|
||||
MSO_SHAPE.RECTANGLE,
|
||||
Inches(0.65),
|
||||
Inches(1.07),
|
||||
Inches(1.15),
|
||||
Inches(0.06),
|
||||
)
|
||||
line.fill.solid()
|
||||
line.fill.fore_color.rgb = color(theme["accent"])
|
||||
line.line.fill.background()
|
||||
|
||||
|
||||
def add_footer(slide, deck, number):
|
||||
footer = deck.get("footer", "")
|
||||
theme = deck["theme"]
|
||||
if footer:
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.65,
|
||||
7.08,
|
||||
10.8,
|
||||
0.22,
|
||||
footer,
|
||||
size=8.5,
|
||||
color_value=theme["muted"],
|
||||
)
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
12.0,
|
||||
7.02,
|
||||
0.55,
|
||||
0.25,
|
||||
str(number),
|
||||
size=9,
|
||||
color_value=theme["muted"],
|
||||
align=PP_ALIGN.RIGHT,
|
||||
)
|
||||
|
||||
|
||||
def add_bullet_frame(slide, theme, x, y, width, height, bullets, size=21):
|
||||
shape = slide.shapes.add_textbox(
|
||||
Inches(x),
|
||||
Inches(y),
|
||||
Inches(width),
|
||||
Inches(height),
|
||||
)
|
||||
frame = shape.text_frame
|
||||
frame.clear()
|
||||
frame.word_wrap = True
|
||||
frame.margin_left = Inches(0.12)
|
||||
frame.margin_right = Inches(0.08)
|
||||
for index, item in enumerate(bullets):
|
||||
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
|
||||
paragraph.text = item
|
||||
paragraph.level = 0
|
||||
paragraph.space_after = Pt(12)
|
||||
paragraph.line_spacing = 1.12
|
||||
paragraph.font.size = Pt(size)
|
||||
paragraph.font.name = theme["font_en"]
|
||||
paragraph.font.color.rgb = color(theme["text"])
|
||||
return shape
|
||||
|
||||
|
||||
def add_notes(slide, notes):
|
||||
if not notes:
|
||||
return
|
||||
notes_frame = slide.notes_slide.notes_text_frame
|
||||
notes_frame.text = notes
|
||||
|
||||
|
||||
def render_title(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme, "primary")
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.9,
|
||||
1.75,
|
||||
11.5,
|
||||
1.3,
|
||||
item["title"],
|
||||
size=34,
|
||||
bold=True,
|
||||
color_value="FFFFFF",
|
||||
vertical=MSO_ANCHOR.MIDDLE,
|
||||
)
|
||||
subtitle = item.get("subtitle", deck.get("subtitle", ""))
|
||||
if subtitle:
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.95,
|
||||
3.2,
|
||||
10.8,
|
||||
0.8,
|
||||
subtitle,
|
||||
size=20,
|
||||
color_value="DCE6F1",
|
||||
)
|
||||
author = deck.get("author", "")
|
||||
if author:
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.95,
|
||||
6.35,
|
||||
10.0,
|
||||
0.35,
|
||||
author,
|
||||
size=12,
|
||||
color_value="DCE6F1",
|
||||
)
|
||||
|
||||
|
||||
def render_section(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme, "light")
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
1.0,
|
||||
2.2,
|
||||
11.2,
|
||||
1.0,
|
||||
item["title"],
|
||||
size=32,
|
||||
bold=True,
|
||||
color_value=theme["primary"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
vertical=MSO_ANCHOR.MIDDLE,
|
||||
)
|
||||
if item.get("subtitle"):
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
1.5,
|
||||
3.35,
|
||||
10.2,
|
||||
0.7,
|
||||
item["subtitle"],
|
||||
size=18,
|
||||
color_value=theme["muted"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
|
||||
def render_bullets(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme)
|
||||
add_header(slide, theme, item["title"])
|
||||
add_bullet_frame(slide, theme, 0.9, 1.45, 11.5, 5.2, item["bullets"])
|
||||
|
||||
|
||||
def render_two_column(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme)
|
||||
add_header(slide, theme, item["title"])
|
||||
for x, column in ((0.75, item["left"]), (6.78, item["right"])):
|
||||
panel = slide.shapes.add_shape(
|
||||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||
Inches(x),
|
||||
Inches(1.55),
|
||||
Inches(5.55),
|
||||
Inches(4.95),
|
||||
)
|
||||
panel.fill.solid()
|
||||
panel.fill.fore_color.rgb = color(theme["light"])
|
||||
panel.line.color.rgb = color("D8E0EA")
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
x + 0.3,
|
||||
1.8,
|
||||
4.95,
|
||||
0.5,
|
||||
column["title"],
|
||||
size=20,
|
||||
bold=True,
|
||||
color_value=theme["primary"],
|
||||
)
|
||||
add_bullet_frame(
|
||||
slide,
|
||||
theme,
|
||||
x + 0.25,
|
||||
2.5,
|
||||
5.0,
|
||||
3.55,
|
||||
column["bullets"],
|
||||
size=17,
|
||||
)
|
||||
|
||||
|
||||
def render_metrics(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme)
|
||||
add_header(slide, theme, item["title"])
|
||||
metrics = item["metrics"]
|
||||
gap = 0.25
|
||||
total_width = 11.8
|
||||
width = (total_width - gap * (len(metrics) - 1)) / len(metrics)
|
||||
for index, metric in enumerate(metrics):
|
||||
x = 0.75 + index * (width + gap)
|
||||
panel = slide.shapes.add_shape(
|
||||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||||
Inches(x),
|
||||
Inches(1.75),
|
||||
Inches(width),
|
||||
Inches(4.45),
|
||||
)
|
||||
panel.fill.solid()
|
||||
panel.fill.fore_color.rgb = color(theme["light"])
|
||||
panel.line.color.rgb = color("D8E0EA")
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
x + 0.15,
|
||||
2.05,
|
||||
width - 0.3,
|
||||
1.0,
|
||||
metric["value"],
|
||||
size=29,
|
||||
bold=True,
|
||||
color_value=theme["accent"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
vertical=MSO_ANCHOR.MIDDLE,
|
||||
)
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
x + 0.15,
|
||||
3.15,
|
||||
width - 0.3,
|
||||
0.6,
|
||||
metric["label"],
|
||||
size=17,
|
||||
bold=True,
|
||||
color_value=theme["primary"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
)
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
x + 0.2,
|
||||
4.0,
|
||||
width - 0.4,
|
||||
1.45,
|
||||
metric["detail"],
|
||||
size=12,
|
||||
color_value=theme["muted"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
|
||||
def prepare_image(image_path, max_width_in, max_height_in, staging_dir):
|
||||
"""Normalize orientation and cap pixels so decks stay a usable size."""
|
||||
if image_path.stat().st_size > MAX_IMAGE_BYTES:
|
||||
raise ValueError(
|
||||
f"图片超过 {MAX_IMAGE_BYTES // (1024 * 1024)}MB:{image_path.name}"
|
||||
)
|
||||
with Image.open(image_path) as image:
|
||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||
raise ValueError(f"图片像素数过大:{image_path.name}")
|
||||
image = ImageOps.exif_transpose(image)
|
||||
width, height = image.size
|
||||
scale = min(max_width_in / width, max_height_in / height)
|
||||
target = (
|
||||
max(1, round(width * scale * RENDER_DPI)),
|
||||
max(1, round(height * scale * RENDER_DPI)),
|
||||
)
|
||||
if target[0] >= width and target[1] >= height:
|
||||
if image_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif"}:
|
||||
return image_path, width, height
|
||||
target = (width, height)
|
||||
resized = image.resize(target, Image.LANCZOS)
|
||||
if resized.mode not in ("RGB", "RGBA", "L"):
|
||||
resized = resized.convert("RGBA")
|
||||
staged = staging_dir / f"{image_path.stem}-{target[0]}x{target[1]}.png"
|
||||
resized.save(staged, format="PNG", optimize=True)
|
||||
return staged, width, height
|
||||
|
||||
|
||||
def render_image(slide, deck, item, base_dir, staging_dir):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme)
|
||||
add_header(slide, theme, item["title"])
|
||||
image_path = (base_dir / item["image"]).resolve()
|
||||
if base_dir != image_path and base_dir not in image_path.parents:
|
||||
raise ValueError(f"图片必须位于 deck.json 目录内:{image_path}")
|
||||
if not image_path.is_file():
|
||||
raise FileNotFoundError(f"图片不存在:{image_path}")
|
||||
max_width, max_height = 11.5, 5.35
|
||||
source, width, height = prepare_image(
|
||||
image_path, max_width, max_height, staging_dir
|
||||
)
|
||||
scale = min(max_width / width, max_height / height)
|
||||
draw_width, draw_height = width * scale, height * scale
|
||||
slide.shapes.add_picture(
|
||||
str(source),
|
||||
Inches((13.333 - draw_width) / 2),
|
||||
Inches(1.35 + (5.35 - draw_height) / 2),
|
||||
width=Inches(draw_width),
|
||||
height=Inches(draw_height),
|
||||
)
|
||||
if item.get("caption"):
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
1.0,
|
||||
6.55,
|
||||
11.3,
|
||||
0.3,
|
||||
item["caption"],
|
||||
size=10,
|
||||
color_value=theme["muted"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
)
|
||||
|
||||
|
||||
def render_closing(slide, deck, item):
|
||||
theme = deck["theme"]
|
||||
set_background(slide, theme, "primary")
|
||||
add_textbox(
|
||||
slide,
|
||||
theme,
|
||||
0.9,
|
||||
1.2,
|
||||
11.5,
|
||||
0.9,
|
||||
item["title"],
|
||||
size=32,
|
||||
bold=True,
|
||||
color_value="FFFFFF",
|
||||
align=PP_ALIGN.CENTER,
|
||||
)
|
||||
add_bullet_frame(
|
||||
slide,
|
||||
{**theme, "text": "FFFFFF"},
|
||||
2.0,
|
||||
2.55,
|
||||
9.3,
|
||||
3.2,
|
||||
item["bullets"],
|
||||
size=20,
|
||||
)
|
||||
|
||||
|
||||
RENDERERS = {
|
||||
"title": render_title,
|
||||
"section": render_section,
|
||||
"bullets": render_bullets,
|
||||
"two-column": render_two_column,
|
||||
"metrics": render_metrics,
|
||||
"closing": render_closing,
|
||||
}
|
||||
|
||||
|
||||
def build_presentation(input_path, deck, output_path):
|
||||
output = Path(output_path).expanduser().resolve()
|
||||
if output.suffix.lower() != ".pptx":
|
||||
raise ValueError("输出必须使用 .pptx 扩展名")
|
||||
if output == input_path:
|
||||
raise ValueError("输出不能覆盖 deck.json")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
presentation = Presentation()
|
||||
presentation.slide_width = Inches(13.333)
|
||||
presentation.slide_height = Inches(7.5)
|
||||
blank = presentation.slide_layouts[6]
|
||||
with tempfile.TemporaryDirectory(prefix="deck-images-") as staging:
|
||||
staging_dir = Path(staging)
|
||||
for index, item in enumerate(deck["slides"], 1):
|
||||
slide = presentation.slides.add_slide(blank)
|
||||
if item["type"] == "image":
|
||||
render_image(slide, deck, item, input_path.parent, staging_dir)
|
||||
else:
|
||||
RENDERERS[item["type"]](slide, deck, item)
|
||||
if item["type"] != "title":
|
||||
add_footer(slide, deck, index)
|
||||
add_notes(slide, item.get("notes", ""))
|
||||
presentation.save(output)
|
||||
return output
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="从 deck.json 生成产品介绍 PPTX")
|
||||
parser.add_argument("--input", required=True, help="deck.json")
|
||||
parser.add_argument("--output", required=True, help="输出 .pptx")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
input_path, deck = load_deck(args.input)
|
||||
output = build_presentation(input_path, deck, args.output)
|
||||
print(f"saved: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"title": "示例产品介绍",
|
||||
"subtitle": "面向目标受众的产品说明",
|
||||
"author": "产品团队",
|
||||
"footer": "示例产品",
|
||||
"theme": {
|
||||
"primary": "1F3864",
|
||||
"accent": "C00000",
|
||||
"background": "FFFFFF",
|
||||
"text": "1A1A1A",
|
||||
"font_zh": "Microsoft YaHei",
|
||||
"font_en": "Arial"
|
||||
},
|
||||
"slides": [
|
||||
{
|
||||
"type": "title",
|
||||
"title": "示例产品介绍",
|
||||
"subtitle": "面向目标受众的产品说明",
|
||||
"notes": "说明本次交流目标和议程。"
|
||||
},
|
||||
{
|
||||
"type": "bullets",
|
||||
"title": "目标场景与问题",
|
||||
"bullets": [
|
||||
"说明目标角色当前需要完成的任务",
|
||||
"说明现有流程中的可验证问题",
|
||||
"说明本次介绍覆盖和不覆盖的范围"
|
||||
],
|
||||
"notes": "不要使用宏大行业背景替代具体问题。",
|
||||
"evidence_ids": [
|
||||
"CLM-001"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "two-column",
|
||||
"title": "产品工作方式",
|
||||
"left": {
|
||||
"title": "输入与条件",
|
||||
"bullets": [
|
||||
"部署环境",
|
||||
"数据与接口条件"
|
||||
]
|
||||
},
|
||||
"right": {
|
||||
"title": "动作与结果",
|
||||
"bullets": [
|
||||
"用户触发的具体动作",
|
||||
"系统产生的可观察结果"
|
||||
]
|
||||
},
|
||||
"notes": "结合当前版本说明工作流。"
|
||||
},
|
||||
{
|
||||
"type": "metrics",
|
||||
"title": "已核验指标",
|
||||
"metrics": [
|
||||
{
|
||||
"value": "参数值",
|
||||
"label": "指标名称",
|
||||
"detail": "版本、条件和统计口径"
|
||||
},
|
||||
{
|
||||
"value": "参数值",
|
||||
"label": "指标名称",
|
||||
"detail": "版本、条件和统计口径"
|
||||
}
|
||||
],
|
||||
"notes": "只使用事实清单中有证据的指标。"
|
||||
},
|
||||
{
|
||||
"type": "closing",
|
||||
"title": "下一步",
|
||||
"bullets": [
|
||||
"确认适用场景和边界",
|
||||
"确定验证材料与责任人"
|
||||
],
|
||||
"notes": "给出明确、可执行的后续动作。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from pptx import Presentation
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = SKILL_DIR / "scripts" / "build_pptx.py"
|
||||
SPEC = importlib.util.spec_from_file_location("build_pptx", SCRIPT)
|
||||
build_pptx = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(build_pptx)
|
||||
|
||||
|
||||
class BuildPptxTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
template = SKILL_DIR / "templates" / "deck.example.json"
|
||||
cls.deck = json.loads(template.read_text(encoding="utf-8"))
|
||||
|
||||
def test_builds_example_deck(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
input_path = root / "deck.json"
|
||||
input_path.write_text(
|
||||
json.dumps(self.deck, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
loaded_path, deck = build_pptx.load_deck(input_path)
|
||||
output = build_pptx.build_presentation(
|
||||
loaded_path,
|
||||
deck,
|
||||
root / "deck.pptx",
|
||||
)
|
||||
presentation = Presentation(output)
|
||||
self.assertEqual(len(presentation.slides), len(self.deck["slides"]))
|
||||
all_text = "\n".join(
|
||||
shape.text
|
||||
for slide in presentation.slides
|
||||
for shape in slide.shapes
|
||||
if hasattr(shape, "text")
|
||||
)
|
||||
self.assertIn("目标场景与问题", all_text)
|
||||
self.assertIn("下一步", all_text)
|
||||
for slide, item in zip(presentation.slides, self.deck["slides"]):
|
||||
self.assertEqual(
|
||||
slide.notes_slide.notes_text_frame.text.strip(),
|
||||
item["notes"].strip(),
|
||||
)
|
||||
|
||||
def test_requires_notes_outside_section_slides(self):
|
||||
deck = copy.deepcopy(self.deck)
|
||||
deck["slides"][1].pop("notes")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "deck.json"
|
||||
path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, r"slides\[1\]\.notes"):
|
||||
build_pptx.load_deck(path)
|
||||
|
||||
def test_downsamples_large_image_slide(self):
|
||||
deck = copy.deepcopy(self.deck)
|
||||
deck["slides"].insert(
|
||||
3,
|
||||
{
|
||||
"type": "image",
|
||||
"title": "参考架构",
|
||||
"image": "architecture.png",
|
||||
"caption": "示例架构图",
|
||||
"notes": "说明组件边界。",
|
||||
},
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
Image.new("RGB", (5000, 3000), "white").save(root / "architecture.png")
|
||||
path = root / "deck.json"
|
||||
path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8")
|
||||
loaded_path, loaded = build_pptx.load_deck(path)
|
||||
output = build_pptx.build_presentation(
|
||||
loaded_path, loaded, root / "deck.pptx"
|
||||
)
|
||||
presentation = Presentation(output)
|
||||
self.assertEqual(len(presentation.slides), len(deck["slides"]))
|
||||
pictures = [
|
||||
shape
|
||||
for slide in presentation.slides
|
||||
for shape in slide.shapes
|
||||
if shape.shape_type == 13
|
||||
]
|
||||
self.assertEqual(len(pictures), 1)
|
||||
with Image.open(io.BytesIO(pictures[0].image.blob)) as embedded:
|
||||
self.assertLess(embedded.width, 5000)
|
||||
|
||||
def test_rejects_image_outside_deck_directory(self):
|
||||
deck = copy.deepcopy(self.deck)
|
||||
deck["slides"].insert(
|
||||
1,
|
||||
{
|
||||
"type": "image",
|
||||
"title": "外部图片",
|
||||
"image": "../outside.png",
|
||||
"notes": "越界图片。",
|
||||
},
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "deck"
|
||||
root.mkdir()
|
||||
Image.new("RGB", (10, 10), "white").save(Path(tmp) / "outside.png")
|
||||
path = root / "deck.json"
|
||||
path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8")
|
||||
loaded_path, loaded = build_pptx.load_deck(path)
|
||||
with self.assertRaisesRegex(ValueError, "必须位于"):
|
||||
build_pptx.build_presentation(
|
||||
loaded_path, loaded, root / "deck.pptx"
|
||||
)
|
||||
|
||||
def test_rejects_too_many_bullets(self):
|
||||
deck = copy.deepcopy(self.deck)
|
||||
deck["slides"][1]["bullets"] = [f"项目 {index}" for index in range(7)]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "deck.json"
|
||||
path.write_text(
|
||||
json.dumps(deck, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "最多 6 项"):
|
||||
build_pptx.load_deck(path)
|
||||
|
||||
def test_rejects_unknown_slide_type(self):
|
||||
deck = copy.deepcopy(self.deck)
|
||||
deck["slides"][0]["type"] = "unknown"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "deck.json"
|
||||
path.write_text(
|
||||
json.dumps(deck, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "未知"):
|
||||
build_pptx.load_deck(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
id: project-planning
|
||||
name: 项目规划
|
||||
description: 将项目目标拆解为范围、里程碑、任务、责任、风险与验收标准,形成可执行计划。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 项目
|
||||
- 计划
|
||||
- 管理
|
||||
---
|
||||
|
||||
# 项目规划
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 先明确目标、成功标准、范围边界、约束和关键相关方。
|
||||
- 不虚构资源、工期、预算或团队承诺。
|
||||
- 计划应体现任务依赖、决策节点和必要缓冲。
|
||||
- 风险需描述触发条件、影响、负责人和应对措施。
|
||||
|
||||
## 规划流程
|
||||
|
||||
1. 将项目目标转化为可验收的交付物。
|
||||
2. 明确范围内事项、范围外事项和关键假设。
|
||||
3. 拆解阶段、里程碑、任务及其依赖关系。
|
||||
4. 为任务指定负责人、时间和完成标准(如已知)。
|
||||
5. 评估风险、资源缺口、沟通机制和变更方式。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- **项目概述:** 背景、目标与成功标准
|
||||
- **范围:** 包含、不包含与假设
|
||||
- **里程碑:** 交付物、目标日期与验收标准
|
||||
- **任务计划:** 任务、负责人、依赖、时间与状态
|
||||
- **风险登记:** 风险、概率、影响与应对
|
||||
- **治理机制:** 汇报节奏、决策人与变更流程
|
||||
|
||||
未知信息标注“待确认”,并说明其对计划可靠性的影响。
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
id: proofreading
|
||||
name: 文本校对
|
||||
description: 系统检查文本的错别字、语法、标点、格式与一致性,并在不改变原意的前提下提出修订。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 校对
|
||||
- 编辑
|
||||
- 质量
|
||||
---
|
||||
|
||||
# 文本校对
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 以保留作者原意、事实和语气为首要目标。
|
||||
- 区分确定错误、风格建议和需要作者确认的内容。
|
||||
- 不擅自改动数字、专有名词、引用、承诺或结论。
|
||||
- 修改应保持全文术语、格式和标点规则一致。
|
||||
|
||||
## 校对流程
|
||||
|
||||
1. 确认文本用途、目标读者和采用的语言规范。
|
||||
2. 检查错别字、语法、搭配、标点和病句。
|
||||
3. 检查标题层级、编号、空格、日期与数字格式。
|
||||
4. 检查术语、人名、缩写和指代的一致性。
|
||||
5. 复核修改是否引入新歧义或改变原意。
|
||||
|
||||
## 输出方式
|
||||
|
||||
- **清洁版:** 已修正明确错误的完整文本。
|
||||
- **修改说明:** 汇总影响含义或结构的主要调整。
|
||||
- **待确认项:** 列出歧义、事实疑点或多种可接受写法。
|
||||
|
||||
纯风格调整应克制;如用户只要求找错,不主动重写全文。
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
id: requirements-analysis
|
||||
name: 需求分析
|
||||
description: 将业务诉求整理为边界明确、可验证、可追踪的需求,识别歧义、依赖与验收条件。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 需求
|
||||
- 分析
|
||||
- 验收
|
||||
---
|
||||
|
||||
# 需求分析
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 区分业务目标、用户问题、解决方案设想和正式需求。
|
||||
- 不替相关方决定未确认的优先级、范围或业务规则。
|
||||
- 每项需求应明确对象、触发条件、预期行为和验收结果。
|
||||
- 主动识别歧义、冲突、异常场景、依赖与非功能要求。
|
||||
|
||||
## 分析流程
|
||||
|
||||
1. 明确目标用户、业务目标和衡量成功的指标。
|
||||
2. 梳理现状、痛点、范围边界与关键术语。
|
||||
3. 将诉求拆成独立、可验证的功能需求。
|
||||
4. 补充权限、数据、性能、可用性和合规等约束。
|
||||
5. 定义验收标准,并建立需求与目标的对应关系。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- **背景与目标:** 问题、用户与预期价值
|
||||
- **范围:** 包含、不包含与假设
|
||||
- **功能需求:** 编号、描述、优先级与依赖
|
||||
- **业务规则:** 条件、例外与边界
|
||||
- **非功能需求:** 质量属性与约束
|
||||
- **验收标准:** 可观察、可判断的结果
|
||||
- **待确认问题:** 歧义、冲突与决策人
|
||||
|
||||
所有推断均标注为“假设”,未经确认不得写成既定要求。
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
id: research-synthesis
|
||||
name: 研究综合
|
||||
description: 综合用户提供的研究材料,比较观点与证据,形成可追溯、平衡且边界清晰的结论。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 研究
|
||||
- 综合
|
||||
- 证据
|
||||
---
|
||||
|
||||
# 研究综合
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 仅综合用户提供的材料,不声称查阅了未提供的信息。
|
||||
- 清楚区分材料中的事实、作者观点、推论和自身归纳。
|
||||
- 保留来源标识,使关键结论可追溯到具体材料。
|
||||
- 同时呈现一致观点、分歧、证据缺口和适用边界。
|
||||
|
||||
## 综合流程
|
||||
|
||||
1. 明确研究问题、范围和评价标准。
|
||||
2. 按主题整理各材料的主张、证据与方法。
|
||||
3. 比较一致性、冲突点、证据强弱和时间适用性。
|
||||
4. 提炼跨材料模式,并检查是否存在反例。
|
||||
5. 形成有限度的结论及进一步研究问题。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- **研究问题:** 范围与目标
|
||||
- **材料概览:** 每份材料的主题与证据类型
|
||||
- **主题综合:** 共识、差异与关联
|
||||
- **证据评估:** 强项、局限与潜在偏差
|
||||
- **综合结论:** 结论、置信边界与适用条件
|
||||
- **待研究问题:** 现有材料无法回答的事项
|
||||
|
||||
引用或转述时保留用户材料中的来源名称,不伪造出处。
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: sales-demo-kit
|
||||
version: 1.0.0
|
||||
description: |
|
||||
生成可执行的产品演示故事线、环境清单、操作脚本、讲解词、失败回退和演练检查表。
|
||||
用于售前 Demo、POC 汇报或验收演示;不负责制作通用产品介绍 PPT。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Execute
|
||||
compatibility: Markdown/JSON;演示自动化需另行使用可用的控制工具
|
||||
---
|
||||
|
||||
# 售前演示套件
|
||||
|
||||
## 必要输入
|
||||
|
||||
- 演示目标、受众、时长和希望获得的下一步。
|
||||
- 可用产品环境、版本、账号角色、测试数据和网络限制。
|
||||
- 允许展示的功能、参数、客户数据和日志范围。
|
||||
- 已知不稳定点、恢复方法和备用材料。
|
||||
|
||||
不得把真实密码、令牌、私有主机名或客户数据写进演示包。
|
||||
|
||||
## 设计演示
|
||||
|
||||
1. 选择一个完整用户任务作为主线,不按菜单逐页点功能。
|
||||
2. 为每一步写前置状态、操作、预期结果、讲解重点和事实 ID。
|
||||
3. 明确哪些结果实时产生,哪些是预置数据或截图。
|
||||
4. 为外部依赖、模型不稳定、网络异常和数据污染准备回退。
|
||||
5. 定义演示结束后的环境重置步骤。
|
||||
6. 将可选深挖问题放入问答分支,不打断主流程时间预算。
|
||||
|
||||
## 输出
|
||||
|
||||
复制 `templates/demo-kit.md`,形成:
|
||||
|
||||
- 演示故事线和时间分配。
|
||||
- 环境、账号角色和测试数据清单。
|
||||
- 逐步运行手册。
|
||||
- 讲解词与常见问答。
|
||||
- 失败回退、备用截图和重置步骤。
|
||||
- 演练与现场检查表。
|
||||
|
||||
## 演练门禁
|
||||
|
||||
- 在与现场相同版本和权限下完整跑通至少一次。
|
||||
- 每一步的预期结果可观察、可截图、可恢复。
|
||||
- 不依赖浏览器历史、个人缓存或未记录的人工准备。
|
||||
- 所有示例数据可公开或已匿名化。
|
||||
- 规划能力不得通过预制截图伪装成实时功能。
|
||||
- 讲解数字和产品介绍 PPT、技术方案保持一致。
|
||||
|
||||
## 完成标准
|
||||
|
||||
演示能在时间预算内重复执行;关键步骤有备用路径;失败不会暴露敏感信息或破坏
|
||||
环境;操作脚本、讲解词和事实证据一致;现场人员知道何时停止、切换备用和重置。
|
||||
@@ -0,0 +1,49 @@
|
||||
# {{产品/场景}}演示套件
|
||||
|
||||
## 一、演示目标
|
||||
|
||||
- 受众:{{角色}}
|
||||
- 目标:{{希望受众理解或同意的事项}}
|
||||
- 时长:{{分钟}}
|
||||
- 产品版本:{{版本}}
|
||||
- 不展示范围:{{范围}}
|
||||
|
||||
## 二、环境与数据
|
||||
|
||||
| 项目 | 要求 | 检查方法 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 演示环境 | {{环境,不写真实密钥}} | {{检查命令或页面}} | 待检查 |
|
||||
| 账号角色 | {{角色}} | {{权限确认}} | 待检查 |
|
||||
| 示例数据 | {{匿名数据集}} | {{完整性检查}} | 待检查 |
|
||||
|
||||
## 三、演示故事线
|
||||
|
||||
| 时间 | 阶段 | 目的 | 讲解重点 |
|
||||
|---:|---|---|---|
|
||||
| 0-2 分钟 | 场景说明 | {{目的}} | {{重点}} |
|
||||
|
||||
## 四、逐步运行手册
|
||||
|
||||
### STEP-01 {{步骤名称}}
|
||||
|
||||
- 前置状态:{{状态}}
|
||||
- 操作:{{具体动作}}
|
||||
- 预期结果:{{可观察结果}}
|
||||
- 事实依据:{{FEAT/CLM ID}}
|
||||
- 讲解词:{{简短讲解}}
|
||||
- 失败判断:{{何时判定失败}}
|
||||
- 回退:{{备用页面、截图或替代步骤}}
|
||||
|
||||
## 五、常见问答
|
||||
|
||||
| 问题 | 回答要点 | 依据 | 不应承诺 |
|
||||
|---|---|---|---|
|
||||
| {{问题}} | {{回答}} | {{证据 ID}} | {{边界}} |
|
||||
|
||||
## 六、重置与现场检查
|
||||
|
||||
- [ ] 清理上次演示数据。
|
||||
- [ ] 恢复初始账号和权限。
|
||||
- [ ] 验证网络、依赖和备用材料。
|
||||
- [ ] 确认屏幕无通知、密钥和客户信息。
|
||||
- [ ] 完整计时演练并记录问题。
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: solution-whitepaper
|
||||
version: 1.0.0
|
||||
description: |
|
||||
编写解释行业问题、技术原理、参考架构、实现方法、测试证据和适用边界的产品或
|
||||
解决方案白皮书。用于技术传播和决策评估;不编制客户项目计划,也不把宣传口号
|
||||
当作技术论证。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Execute
|
||||
compatibility: Markdown;可配合 longdoc-docx 导出 Word(PDF 仅用于核验)
|
||||
---
|
||||
|
||||
# 解决方案白皮书
|
||||
|
||||
## 必要输入
|
||||
|
||||
- 白皮书主题、目标读者、研究问题和发布范围。
|
||||
- 产品事实、技术来源、测试报告、标准和第三方参考。
|
||||
- 可公开的架构图、数据、案例和限制。
|
||||
- 引用格式、目标篇幅和评审要求。
|
||||
|
||||
## 论证结构
|
||||
|
||||
1. 明确问题范围,不用泛化行业背景凑篇幅。
|
||||
2. 定义术语、对象和评价标准。
|
||||
3. 解释方法、原理和参考架构。
|
||||
4. 用产品实现或参考流程说明方法如何落地。
|
||||
5. 给出测试方法、条件、结果和不确定性。
|
||||
6. 说明安全、部署、治理和人工复核边界。
|
||||
7. 总结适用场景、限制和后续研究,不做空洞升华。
|
||||
|
||||
使用 `templates/solution-whitepaper.md` 建立章节。
|
||||
|
||||
## 证据规则
|
||||
|
||||
- 标准、论文、第三方观点和产品事实分开引用。
|
||||
- 指标必须说明样本、版本、环境、周期和计算方法。
|
||||
- 实测结果、设计目标和规划能力使用不同标签。
|
||||
- 无法访问原始来源时标记二手来源,不把摘要转述成原始结论。
|
||||
- 第三方图表必须检查许可并保留出处。
|
||||
- 参考文献编号、正文引用和图表来源必须一一对应。
|
||||
|
||||
## 写作规则
|
||||
|
||||
- 标题说明对象或结论范围,不写“我们如何理解”“结果说明了什么”。
|
||||
- 先给定义和条件,再给结论。
|
||||
- 架构章节解释边界与数据流,不罗列产品菜单。
|
||||
- 限制章节必须保留,不能在营销审校时被删除。
|
||||
- 技术白皮书可以有观点,但必须区分事实、推断和建议。
|
||||
|
||||
## 导出
|
||||
|
||||
如已安装 `deai-writing`,在最终通读前扫描中文套路表达;如已安装
|
||||
`longdoc-docx`,用其生成 DOCX 并借助临时 PDF 执行高分辨率视觉复核。
|
||||
|
||||
## 完成标准
|
||||
|
||||
研究问题得到回答;术语统一;关键结论有来源;测试可复核;架构图与正文一致;
|
||||
限制、依赖和适用范围完整;参考文献无缺失、重复或无法定位条目。
|
||||
@@ -0,0 +1,42 @@
|
||||
# {{白皮书标题}}
|
||||
|
||||
## 摘要
|
||||
|
||||
{{研究对象、问题、方法、主要结论和适用边界。}}
|
||||
|
||||
## 1. 问题范围与目标读者
|
||||
|
||||
## 2. 术语、对象与评价标准
|
||||
|
||||
| 术语 | 定义 | 范围 |
|
||||
|---|---|---|
|
||||
| {{术语}} | {{定义}} | {{适用范围}} |
|
||||
|
||||
## 3. 方法与技术原理
|
||||
|
||||
## 4. 参考架构与关键数据流
|
||||
|
||||
## 5. 产品实现与典型工作流
|
||||
|
||||
## 6. 测试方法与结果
|
||||
|
||||
### 6.1 测试环境
|
||||
### 6.2 数据、样本与统计口径
|
||||
### 6.3 测试结果
|
||||
### 6.4 不确定性与结果解释
|
||||
|
||||
## 7. 安全、治理与部署考虑
|
||||
|
||||
## 8. 适用场景与限制
|
||||
|
||||
## 9. 结论
|
||||
|
||||
## 参考文献
|
||||
|
||||
1. {{作者/机构}},《{{标题}}》,{{版本或日期}},{{来源定位}}。
|
||||
|
||||
## 内部主张追溯
|
||||
|
||||
| 章节 | 主张 ID | 证据 ID | 公开级别 | 复核 |
|
||||
|---|---|---|---|---|
|
||||
| {{章节}} | {{CLM-001}} | {{EVD-001}} | public | 通过 |
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
id: spreadsheet-analysis
|
||||
name: 表格分析
|
||||
description: 基于用户提供的表格内容规划分析方法,识别数据质量问题并形成可解释的业务结论。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 表格
|
||||
- 数据分析
|
||||
- 洞察
|
||||
---
|
||||
|
||||
# 表格分析
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 先确认分析目标、字段含义、时间范围、单位和统计口径。
|
||||
- 不猜测缺失值、异常值或字段关系,不将相关性表述为因果性。
|
||||
- 明确区分原始数据、计算结果、解释和建议。
|
||||
- 涉及个人或敏感数据时,建议最小化使用并进行脱敏。
|
||||
|
||||
## 分析流程
|
||||
|
||||
1. 盘点工作表、字段、数据类型与记录范围。
|
||||
2. 检查缺失、重复、异常、口径冲突和格式不一致。
|
||||
3. 根据问题选择汇总、分组、对比、趋势或分布分析。
|
||||
4. 记录计算定义、筛选条件和必要假设。
|
||||
5. 提炼证据充分的发现、局限与后续验证建议。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- **分析目标:** 要回答的业务问题
|
||||
- **数据概况:** 范围、字段、口径与质量
|
||||
- **分析方法:** 分组维度、指标定义与假设
|
||||
- **关键发现:** 结论及对应证据
|
||||
- **限制与风险:** 数据不足或偏差来源
|
||||
- **建议:** 可验证、可执行的下一步
|
||||
|
||||
对无法从现有数据支持的结论,应明确说明“证据不足”。
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: technical-proposal
|
||||
version: 1.0.0
|
||||
description: |
|
||||
基于客户需求、产品事实和项目约束编制完整技术方案,覆盖需求分析、总体架构、
|
||||
详细设计、实施、交付、质量、安全、风险和验收。用于投标技术方案或客户解决
|
||||
方案;不负责制定招标参数,也不替代逐条招标响应矩阵。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Execute
|
||||
compatibility: Markdown;可配合 longdoc-docx 导出 Word(PDF 仅用于核验)
|
||||
---
|
||||
|
||||
# 技术方案
|
||||
|
||||
## 必要输入
|
||||
|
||||
- 客户需求原文及编号、评分点或验收目标。
|
||||
- 产品事实与证据、功能状态、参数和限制。
|
||||
- 部署环境、现有系统、接口、数据、安全和合规约束。
|
||||
- 项目范围、责任边界、计划、交付物和非范围项。
|
||||
|
||||
不确定内容进入“假设与待确认事项”,不能默认为客户已具备或产品已支持。
|
||||
|
||||
## 编制顺序
|
||||
|
||||
1. 建立需求追溯表,为每项需求分配稳定编号。招标场景下
|
||||
`tender-response-matrix` 是需求编号、响应状态和偏离结论的唯一权威来源,
|
||||
本技能只派生视图,不得另建一套编号或改写其状态。
|
||||
2. 区分业务目标、功能需求、非功能需求、接口约束和验收要求。
|
||||
3. 先确定范围、假设和总体架构,再展开模块设计。
|
||||
4. 对每个设计说明采用的产品能力、依赖条件和限制。
|
||||
5. 将设计落实到实施任务、交付物、质量措施和验收方法。
|
||||
6. 最后编制摘要,不能先写宣传性摘要再反推正文。
|
||||
|
||||
## 章节建议
|
||||
|
||||
复制 `templates/technical-proposal.md`,按项目裁剪:
|
||||
|
||||
- 方案摘要
|
||||
- 项目理解与需求分析
|
||||
- 范围、假设与责任边界
|
||||
- 总体技术架构与数据流
|
||||
- 详细功能和接口设计
|
||||
- 部署、安全、性能和运维设计
|
||||
- 实施计划、组织和质量保证
|
||||
- 交付物、培训和知识转移
|
||||
- 验收方法、风险和偏离说明
|
||||
- 需求追溯矩阵
|
||||
|
||||
## 写作门禁
|
||||
|
||||
- 需求 → 设计 → 产品能力 → 交付物 → 验收方法必须可追溯。
|
||||
- 规划能力必须使用将来时,并说明是否属于本项目交付范围。
|
||||
- 架构图与正文必须使用相同组件名称和边界。
|
||||
- 不把客户责任、第三方依赖或人工复核要求隐藏在脚注中。
|
||||
- 不编造团队人数、工期、性能、案例、资质或承诺。
|
||||
- 方案正文以系统和动作陈述为主,减少“我方/我们”堆叠。
|
||||
|
||||
## 导出与审校
|
||||
|
||||
如已安装相关技能:
|
||||
|
||||
1. 用 `deai-writing` 扫描并定向改写 Markdown。
|
||||
2. 用 `longdoc-docx` 生成 DOCX,并借助临时 PDF 做空白页、乱码和 300 DPI 视觉复核。
|
||||
|
||||
未安装时仍应交付结构完整、可追溯的 Markdown。
|
||||
|
||||
## 完成标准
|
||||
|
||||
需求无遗漏;架构、功能、实施、交付和验收闭合;事实与产品版本一致;图表和编号
|
||||
连续;假设、偏离、风险和非范围项明确;所有数字和承诺可定位到输入依据。
|
||||
@@ -0,0 +1,71 @@
|
||||
# {{项目名称}}技术方案
|
||||
|
||||
## 方案摘要
|
||||
|
||||
{{项目目标、方案范围、核心路径和验收结果,完成正文后编写。}}
|
||||
|
||||
## 一、项目理解与需求分析
|
||||
|
||||
### 1. 业务目标
|
||||
|
||||
### 2. 需求分类
|
||||
|
||||
| 需求 ID | 原始要求 | 类型 | 关键约束 | 验收目标 |
|
||||
|---|---|---|---|---|
|
||||
| REQ-001 | {{原文}} | 功能 | {{约束}} | {{可验证结果}} |
|
||||
|
||||
## 二、范围、假设与责任边界
|
||||
|
||||
### 1. 项目范围
|
||||
|
||||
### 2. 非范围项
|
||||
|
||||
### 3. 假设与待确认事项
|
||||
|
||||
### 4. 双方及第三方责任
|
||||
|
||||
## 三、总体技术方案
|
||||
|
||||
### 1. 总体架构
|
||||
|
||||
### 2. 组件职责与边界
|
||||
|
||||
### 3. 关键数据流
|
||||
|
||||
### 4. 部署与集成关系
|
||||
|
||||
## 四、详细设计
|
||||
|
||||
### 1. {{能力模块}}
|
||||
|
||||
- 对应需求:{{REQ-001}}
|
||||
- 产品能力:{{FEAT-001 / CLM-001}}
|
||||
- 处理流程:{{输入、动作、输出}}
|
||||
- 依赖与限制:{{条件}}
|
||||
- 验收方法:{{步骤和证据}}
|
||||
|
||||
## 五、非功能设计
|
||||
|
||||
### 1. 安全与审计
|
||||
### 2. 性能与容量
|
||||
### 3. 可用性、备份与恢复
|
||||
### 4. 兼容性与可维护性
|
||||
|
||||
## 六、实施、质量与交付
|
||||
|
||||
### 1. 实施阶段与里程碑
|
||||
### 2. 项目组织与沟通
|
||||
### 3. 质量保证与变更管理
|
||||
### 4. 交付物、培训与知识转移
|
||||
|
||||
## 七、验收、风险与偏离
|
||||
|
||||
### 1. 验收方案
|
||||
### 2. 风险及应对
|
||||
### 3. 技术偏离
|
||||
|
||||
## 八、需求追溯矩阵
|
||||
|
||||
| 需求 ID | 方案章节 | 产品事实 ID | 交付物 | 验收方法 | 状态 |
|
||||
|---|---|---|---|---|---|
|
||||
| REQ-001 | {{章节}} | {{FEAT-001}} | {{交付物}} | {{方法}} | 已覆盖 |
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: tender-response-matrix
|
||||
version: 1.0.0
|
||||
description: |
|
||||
将招标文件技术要求逐条拆解并生成符合性响应矩阵、缺口清单和方案章节映射。用于
|
||||
投标前要求解析、响应检查和技术偏离管理;不制定采购参数,也不代写整篇方案。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
compatibility: Markdown/CSV;建议配合 product-evidence
|
||||
---
|
||||
|
||||
# 招标响应矩阵
|
||||
|
||||
## 必要输入
|
||||
|
||||
- 招标文件原文及可定位的章节、页码或条款编号。
|
||||
- 产品事实、参数、证据、限制和规划能力。
|
||||
- 拟提交技术方案的章节结构。
|
||||
- 招标方规定的“响应、偏离、证明材料”格式。
|
||||
|
||||
## 解析规则
|
||||
|
||||
1. 保留原始条款,不用概括替代原文。
|
||||
2. 将一条中多个独立判定条件拆成子要求,同时保留父条款关系。
|
||||
3. 标记要求类型:功能、参数、接口、安全、服务、交付、验收或商务边界。
|
||||
4. 识别强制词、阈值、证明材料、评分点和截止条件。
|
||||
5. 每项只允许以下状态:
|
||||
- `compliant`:完全满足且有证据。
|
||||
- `partial`:仅部分满足或有范围限制。
|
||||
- `not-compliant`:当前不能满足。
|
||||
- `clarification-required`:原文歧义或缺少必要输入。
|
||||
6. `planned` 能力不能用于判定当前 `compliant`,除非招标明确允许项目期内交付。
|
||||
|
||||
## 输出
|
||||
|
||||
使用 `templates/tender-response-matrix.md` 生成:
|
||||
|
||||
- 逐条响应矩阵。
|
||||
- 技术偏离和澄清清单。
|
||||
- 证明材料清单。
|
||||
- 要求到方案章节、产品事实和验收方法的映射。
|
||||
|
||||
## 响应纪律
|
||||
|
||||
- “完全响应”必须有事实 ID、证据 ID和方案位置。
|
||||
- 原文要求高于已知产品能力时如实标记偏离,不得弱化原文。
|
||||
- 响应说明写具体实现、范围和条件,不重复“满足、响应”。
|
||||
- 尚未定稿的承诺标明审批责任人和截止时间,不能进入最终交付版。
|
||||
- 招标文件中的客户名称、项目编号和保密内容不得进入可复用技能模板。
|
||||
|
||||
## 两阶段使用
|
||||
|
||||
1. **方案编制前**:识别缺口,决定方案结构和需补充的证据。
|
||||
2. **方案完成后**:回填最终章节号、证明材料和验收方法,检查是否遗漏。
|
||||
|
||||
## 完成标准
|
||||
|
||||
原始要求覆盖率 100%;每个 `compliant` 有证据;偏离与澄清未被隐藏;条款编号、
|
||||
阈值和方案引用准确;矩阵与最终技术方案使用相同版本和术语。
|
||||
@@ -0,0 +1,28 @@
|
||||
# {{项目名称}}技术响应矩阵
|
||||
|
||||
## 一、逐条响应
|
||||
|
||||
| 要求 ID | 原条款位置 | 原文 | 类型 | 强制/评分 | 响应状态 | 具体响应 | 条件/偏离 | 事实与证据 | 方案章节 | 验收方法 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| REQ-001 | {{章节/页码}} | {{完整原文}} | 功能 | 强制 | compliant | {{具体能力与范围}} | 无 | {{FEAT-001 / EVD-001}} | {{3.1}} | {{操作或材料核验}} |
|
||||
|
||||
## 二、偏离与澄清
|
||||
|
||||
| 要求 ID | 状态 | 问题 | 影响 | 建议处理 | 责任人 | 截止时间 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| REQ-002 | clarification-required | {{歧义或缺失信息}} | {{影响}} | {{澄清问题}} | {{责任人}} | {{日期}} |
|
||||
|
||||
## 三、证明材料
|
||||
|
||||
| 材料 ID | 材料名称 | 对应要求 | 来源 | 公开级别 | 是否齐备 |
|
||||
|---|---|---|---|---|---|
|
||||
| MAT-001 | {{测试报告/证书/截图}} | REQ-001 | {{EVD-001}} | restricted | 是 |
|
||||
|
||||
## 四、覆盖统计
|
||||
|
||||
- 原始要求数:{{数量}}
|
||||
- 已拆分子要求数:{{数量}}
|
||||
- 完全满足:{{数量}}
|
||||
- 部分满足:{{数量}}
|
||||
- 不满足:{{数量}}
|
||||
- 待澄清:{{数量}}
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: tender-technical-spec
|
||||
version: 1.0.0
|
||||
description: |
|
||||
将已核验产品能力转写为可采购、可测试、可验收的招标技术规格和参数表。用于编制
|
||||
招标文件技术要求、采购参数或技术规格书;不负责判断投标方是否符合,也不负责
|
||||
撰写整篇投标技术方案。
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
compatibility: Markdown;建议配合 product-evidence
|
||||
---
|
||||
|
||||
# 招标技术参数
|
||||
|
||||
## 必要输入
|
||||
|
||||
- 产品事实与证据清单,尤其是功能、参数、限制、部署和兼容性。
|
||||
- 本次采购范围、部署规模、适用环境和验收阶段。
|
||||
- 强制项、推荐项和可选项的标记规则。
|
||||
- 是否允许品牌、专利或特定实现方式出现在参数中。
|
||||
|
||||
## 编制原则
|
||||
|
||||
1. 参数描述采购目标和可验证结果,避免锁定非必要的内部实现。
|
||||
2. 每项只表达一个可判定要求,不能把多个条件塞入一行。
|
||||
3. 数值必须包含单位、适用版本、测试条件和统计口径。
|
||||
4. 使用“应、须、不得”表示强制要求,“宜、可”表示推荐或可选要求。
|
||||
5. 为每项定义验收方法和所需证据,避免“支持、具备、先进”等无法判定的表述。
|
||||
6. 无证据或需采购方确认的内容进入待确认表,不得补造门槛值。
|
||||
7. 安全、兼容性、部署和服务要求分别成组,不能混入功能参数。
|
||||
|
||||
## 参数分类
|
||||
|
||||
- 总体与部署
|
||||
- 功能能力
|
||||
- 接口与集成
|
||||
- 性能与容量
|
||||
- 安全与审计
|
||||
- 兼容性与信创环境
|
||||
- 运维、备份与升级
|
||||
- 服务、培训与交付
|
||||
- 验收与材料
|
||||
|
||||
只保留与本次采购目标有关的分类。
|
||||
|
||||
## 输出
|
||||
|
||||
复制 `templates/tender-technical-spec.md`,生成:
|
||||
|
||||
1. 技术规格正文。
|
||||
2. 可机读或可复制到表格的参数明细。
|
||||
3. 待确认参数与风险清单。
|
||||
4. 参数到事实证据的内部追溯表。
|
||||
|
||||
## 风险检查
|
||||
|
||||
- 是否把规划能力写成强制现有参数。
|
||||
- 是否为体现“先进”而编造性能阈值。
|
||||
- 是否把特定品牌或架构写成唯一实现路径,造成不必要排他性。
|
||||
- 是否存在无法复现的“高、快、强、稳定”等主观指标。
|
||||
- 是否遗漏测试数据、环境、并发模型、持续时间或误差范围。
|
||||
|
||||
## 完成标准
|
||||
|
||||
每项要求具备唯一编号、级别、参数内容、适用条件、验收方法和证据;全文无互相
|
||||
冲突的阈值;待确认项没有混入正式参数;公开与保密边界符合输入约束。
|
||||
@@ -0,0 +1,32 @@
|
||||
# {{项目名称}}招标技术规格
|
||||
|
||||
## 一、采购范围与适用条件
|
||||
|
||||
- 采购对象:{{产品/服务范围}}
|
||||
- 部署环境:{{环境}}
|
||||
- 适用版本:{{版本}}
|
||||
- 验收阶段:{{阶段}}
|
||||
|
||||
## 二、技术参数
|
||||
|
||||
| 编号 | 分类 | 级别 | 技术要求 | 条件与口径 | 验收方法 | 证据/材料 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| TP-001 | 功能能力 | 强制 | 系统应{{可验证行为}} | {{版本、环境或前提}} | {{操作、测量或材料审查}} | {{EVD-001}} |
|
||||
|
||||
## 三、交付与服务要求
|
||||
|
||||
| 编号 | 级别 | 要求 | 验收方式 |
|
||||
|---|---|---|---|
|
||||
| SV-001 | 强制 | {{交付物、培训或服务要求}} | {{材料或现场验收}} |
|
||||
|
||||
## 四、待确认事项
|
||||
|
||||
| 编号 | 待确认内容 | 缺少依据 | 责任方 | 截止时间 |
|
||||
|---|---|---|---|---|
|
||||
| TBD-001 | {{参数或范围}} | {{需要补充的证据}} | {{责任方}} | {{日期}} |
|
||||
|
||||
## 五、内部追溯表
|
||||
|
||||
| 参数编号 | 事实/主张 ID | 证据 ID | 复核结论 |
|
||||
|---|---|---|---|
|
||||
| TP-001 | {{CLM-001/PAR-001}} | {{EVD-001}} | {{通过/待确认}} |
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
id: translation-polish
|
||||
name: 翻译润色
|
||||
description: 在忠实保留原意、事实与格式的前提下完成翻译或润色,使表达自然、专业且符合目标语境。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 翻译
|
||||
- 润色
|
||||
- 语言
|
||||
---
|
||||
|
||||
# 翻译润色
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 确认源语言、目标语言、读者、场景、语气和术语偏好。
|
||||
- 忠实保留事实、数字、日期、专有名词与不确定性。
|
||||
- 不擅自增删立场、承诺、限定条件或法律含义。
|
||||
- 对多义词、文化特定表达和术语冲突标注备选译法。
|
||||
|
||||
## 处理流程
|
||||
|
||||
1. 理解全文目的、上下文和语域。
|
||||
2. 建立关键术语及固定译法。
|
||||
3. 逐段转换含义,优先保证准确与连贯。
|
||||
4. 调整句式、语气和标点,使目标语言自然。
|
||||
5. 对照原文复核遗漏、误译、数字和格式。
|
||||
|
||||
## 输出方式
|
||||
|
||||
- 默认提供润色后的完整文本。
|
||||
- 存在关键歧义时,附“译法说明”与简短理由。
|
||||
- 用户要求对照时,按段落展示原文与译文。
|
||||
- 无法确认的术语或专名保留原文并标记“待确认”。
|
||||
|
||||
对于合同、医疗或其他高风险文本,应提醒用户进行专业复核。
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
id: weekly-report
|
||||
name: 周报整理
|
||||
description: 将零散工作记录整理为结果导向的周报,呈现进展、价值、风险与下周计划。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 周报
|
||||
- 汇报
|
||||
- 进展
|
||||
---
|
||||
|
||||
# 周报整理
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 优先呈现已完成结果及其影响,而非简单罗列活动。
|
||||
- 仅使用用户提供的数据,不夸大进度、效果或完成度。
|
||||
- 明确区分已完成、进行中、受阻和计划事项。
|
||||
- 风险描述应客观,并给出已知的应对方案或支持需求。
|
||||
|
||||
## 整理流程
|
||||
|
||||
1. 按目标或项目归类本周记录。
|
||||
2. 将过程描述改写为“行动—结果—影响”。
|
||||
3. 提取里程碑、关键数据、风险和依赖。
|
||||
4. 按优先级排列下周计划。
|
||||
5. 检查时间范围、状态和数据口径是否一致。
|
||||
|
||||
## 输出模板
|
||||
|
||||
### 本周成果
|
||||
|
||||
- 目标、完成结果及业务或团队影响。
|
||||
|
||||
### 进行中事项
|
||||
|
||||
- 当前状态、下一步与预计节点(如已知)。
|
||||
|
||||
### 风险与支持需求
|
||||
|
||||
- 风险、影响、应对措施和所需支持。
|
||||
|
||||
### 下周计划
|
||||
|
||||
- 按优先级列出目标、交付物与关键节点。
|
||||
|
||||
不确定的信息标注“待确认”,避免使用模糊的完成度表述。
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
boundedToolDetail,
|
||||
safeToolArgumentSummary,
|
||||
safeToolErrorDetail
|
||||
} from './approval-summary'
|
||||
@@ -30,8 +31,29 @@ describe('safeToolArgumentSummary', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundedToolDetail', () => {
|
||||
it('preserves conversation details verbatim while bounding output', () => {
|
||||
expect(
|
||||
boundedToolDetail(
|
||||
{
|
||||
command: 'npm test',
|
||||
token: 'secret-token',
|
||||
output: 'Authorization: Bearer inline-secret'
|
||||
},
|
||||
1_000
|
||||
)
|
||||
).toBe(
|
||||
'{\n "command": "npm test",\n "token": "secret-token",\n "output": "Authorization: Bearer inline-secret"\n}'
|
||||
)
|
||||
expect(
|
||||
boundedToolDetail(' exact output\r\n', 1_000)
|
||||
).toBe(' exact output\r\n')
|
||||
expect(boundedToolDetail('x'.repeat(100), 20)).toHaveLength(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeToolErrorDetail', () => {
|
||||
it('extracts nested runtime errors while redacting secrets', () => {
|
||||
it('extracts nested runtime errors without rewriting their contents', () => {
|
||||
expect(
|
||||
safeToolErrorDetail([
|
||||
{
|
||||
@@ -39,14 +61,14 @@ describe('safeToolErrorDetail', () => {
|
||||
'exit code 1\nAuthorization: Bearer secret-token'
|
||||
}
|
||||
])
|
||||
).toBe('exit code 1\nAuthorization: [REDACTED]')
|
||||
).toBe('exit code 1\nAuthorization: Bearer secret-token')
|
||||
expect(
|
||||
safeToolErrorDetail({
|
||||
message:
|
||||
'{"token":"json-secret","authorization":"Basic abc123"}'
|
||||
})
|
||||
).toBe(
|
||||
'{"token":"[REDACTED]","authorization":"[REDACTED]"}'
|
||||
'{"token":"json-secret","authorization":"Basic abc123"}'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -66,4 +88,31 @@ describe('safeToolErrorDetail', () => {
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes nested fetch causes and network diagnostics', () => {
|
||||
const cause = Object.assign(
|
||||
new Error('connect ECONNREFUSED 127.0.0.1:11434'),
|
||||
{
|
||||
code: 'ECONNREFUSED',
|
||||
errno: -4078,
|
||||
syscall: 'connect',
|
||||
address: '127.0.0.1',
|
||||
port: 11434
|
||||
}
|
||||
)
|
||||
const error = new TypeError('fetch failed', { cause })
|
||||
|
||||
expect(safeToolErrorDetail(error)).toBe(
|
||||
[
|
||||
'fetch failed',
|
||||
'cause:',
|
||||
'connect ECONNREFUSED 127.0.0.1:11434',
|
||||
'code: ECONNREFUSED',
|
||||
'errno: -4078',
|
||||
'syscall: connect',
|
||||
'address: 127.0.0.1',
|
||||
'port: 11434'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@ function redactValue(
|
||||
if (depth > 8) {
|
||||
return '[TRUNCATED]'
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return redactSensitiveText(value)
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value
|
||||
}
|
||||
@@ -64,6 +67,32 @@ export function safeToolErrorDetail(
|
||||
let remaining = maximum
|
||||
const seen = new WeakSet<object>()
|
||||
|
||||
const append = (value: string): void => {
|
||||
const text = [...value]
|
||||
.filter((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return (
|
||||
code === 9 ||
|
||||
code === 10 ||
|
||||
code === 13 ||
|
||||
(code > 31 && code !== 127)
|
||||
)
|
||||
})
|
||||
.join('')
|
||||
.trim()
|
||||
if (!text || remaining <= 0) {
|
||||
return
|
||||
}
|
||||
const separator = parts.length > 0 ? '\n' : ''
|
||||
const available = Math.max(0, remaining - separator.length)
|
||||
if (available === 0) {
|
||||
return
|
||||
}
|
||||
const bounded = text.slice(0, available)
|
||||
parts.push(`${separator}${bounded}`)
|
||||
remaining -= separator.length + bounded.length
|
||||
}
|
||||
|
||||
const collect = (candidate: unknown, depth = 0): void => {
|
||||
if (remaining <= 0 || depth > 4 || candidate === undefined) {
|
||||
return
|
||||
@@ -73,30 +102,7 @@ export function safeToolErrorDetail(
|
||||
0,
|
||||
Math.min(candidate.length, remaining * 4)
|
||||
)
|
||||
const text = redactSensitiveText(
|
||||
[...boundedCandidate]
|
||||
.filter((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return (
|
||||
code === 9 ||
|
||||
code === 10 ||
|
||||
code === 13 ||
|
||||
(code > 31 && code !== 127)
|
||||
)
|
||||
})
|
||||
.join('')
|
||||
).trim()
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
const separator = parts.length > 0 ? '\n' : ''
|
||||
const available = Math.max(0, remaining - separator.length)
|
||||
if (available === 0) {
|
||||
return
|
||||
}
|
||||
const bounded = text.slice(0, available)
|
||||
parts.push(`${separator}${bounded}`)
|
||||
remaining -= separator.length + bounded.length
|
||||
append(boundedCandidate)
|
||||
return
|
||||
}
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
@@ -113,10 +119,33 @@ export function safeToolErrorDetail(
|
||||
return
|
||||
}
|
||||
const record = candidate as Record<string, unknown>
|
||||
collect(record.message, depth + 1)
|
||||
for (const key of [
|
||||
'code',
|
||||
'errno',
|
||||
'syscall',
|
||||
'hostname',
|
||||
'address',
|
||||
'port',
|
||||
'status',
|
||||
'statusCode'
|
||||
]) {
|
||||
const metadata = record[key]
|
||||
if (
|
||||
typeof metadata === 'string' ||
|
||||
typeof metadata === 'number'
|
||||
) {
|
||||
append(`${key}: ${metadata}`)
|
||||
}
|
||||
}
|
||||
if (record.cause !== undefined) {
|
||||
append('cause:')
|
||||
collect(record.cause, depth + 1)
|
||||
}
|
||||
for (const key of [
|
||||
'content',
|
||||
'message',
|
||||
'error',
|
||||
'errors',
|
||||
'stderr',
|
||||
'detail',
|
||||
'data'
|
||||
@@ -152,3 +181,23 @@ export function safeToolArgumentSummary(
|
||||
redactValue(toolArguments, new WeakSet())
|
||||
).slice(0, maximum)
|
||||
}
|
||||
|
||||
export function boundedToolDetail(
|
||||
value: unknown,
|
||||
maximum: number
|
||||
): string | undefined {
|
||||
if (!Number.isSafeInteger(maximum) || maximum < 1 || value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
let text: string | undefined
|
||||
if (typeof value === 'string') {
|
||||
text = value
|
||||
} else {
|
||||
try {
|
||||
text = JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return text ? text.slice(0, maximum) : undefined
|
||||
}
|
||||
|
||||
@@ -876,7 +876,7 @@ describe('ContinueHostAdapter', () => {
|
||||
name: 'Bash',
|
||||
state: 'failed',
|
||||
error:
|
||||
'PowerShell parser failed Authorization: [REDACTED]'
|
||||
'PowerShell parser failed Authorization: Bearer secret-token'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -940,7 +940,9 @@ describe('ContinueHostAdapter', () => {
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'running'
|
||||
state: 'running',
|
||||
input:
|
||||
'{"command":"npm test","token":"secret-token"}'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -973,7 +975,9 @@ describe('ContinueHostAdapter', () => {
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed'
|
||||
state: 'completed',
|
||||
output:
|
||||
'Tests passed\nAuthorization: Bearer secret-token'
|
||||
},
|
||||
{ type: 'text', delta: 'TOOLS_OK' }
|
||||
]
|
||||
@@ -1012,7 +1016,11 @@ describe('ContinueHostAdapter', () => {
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed'
|
||||
state: 'completed',
|
||||
input:
|
||||
'{"command":"npm test","token":"secret-token"}',
|
||||
output:
|
||||
'Tests passed\nAuthorization: Bearer secret-token'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -1023,7 +1031,9 @@ describe('ContinueHostAdapter', () => {
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'running'
|
||||
state: 'running',
|
||||
input:
|
||||
'{"command":"npm test","token":"secret-token"}'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1031,7 +1041,9 @@ describe('ContinueHostAdapter', () => {
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed'
|
||||
state: 'completed',
|
||||
output:
|
||||
'Tests passed\nAuthorization: Bearer secret-token'
|
||||
}
|
||||
},
|
||||
{ type: 'text', delta: 'TOOLS_OK' }
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
|
||||
import { createOpenAIApiBaseUrl } from './openai-endpoint'
|
||||
import {
|
||||
redactSensitiveText,
|
||||
boundedToolDetail,
|
||||
safeToolErrorDetail
|
||||
} from './approval-summary'
|
||||
|
||||
@@ -88,6 +88,8 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
|
||||
callId: z.string().min(1).max(256),
|
||||
name: z.string().min(1).max(200),
|
||||
state: z.enum(['running', 'completed', 'failed']),
|
||||
input: z.string().max(4_000).optional(),
|
||||
output: z.string().max(16_000).optional(),
|
||||
error: z.string().max(1_000).optional()
|
||||
})
|
||||
.strict()
|
||||
@@ -142,6 +144,8 @@ export type ContinueHostTool = {
|
||||
callId: string
|
||||
name: string
|
||||
state: 'pending' | 'running' | 'completed' | 'failed'
|
||||
input?: string
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -383,7 +387,7 @@ function parseContinueFailure(text: string): string | undefined {
|
||||
: record.message
|
||||
const detail =
|
||||
typeof message === 'string' && message.trim()
|
||||
? `:${redactSensitiveText(message.trim()).slice(0, 500)}`
|
||||
? `:${message.trim().slice(0, 500)}`
|
||||
: ''
|
||||
return `Continue 模型请求失败${detail}`
|
||||
} catch {
|
||||
@@ -465,10 +469,23 @@ function extractContinueTools(
|
||||
normalizedState === 'failed'
|
||||
? normalizeContinueToolError(state.output)
|
||||
: undefined
|
||||
const input =
|
||||
toolFunction && typeof toolFunction === 'object'
|
||||
? boundedToolDetail(
|
||||
(toolFunction as Record<string, unknown>).arguments,
|
||||
4_000
|
||||
)
|
||||
: undefined
|
||||
const output =
|
||||
normalizedState === 'completed'
|
||||
? boundedToolDetail(state.output, 16_000)
|
||||
: undefined
|
||||
tools.set(callId, {
|
||||
callId,
|
||||
name: name.trim().slice(0, 200),
|
||||
state: normalizedState,
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(error ? { error } : {})
|
||||
})
|
||||
}
|
||||
@@ -482,7 +499,14 @@ function mergeContinueTools(
|
||||
): ContinueHostTool[] {
|
||||
const tools = new Map(current.map((tool) => [tool.callId, tool]))
|
||||
for (const tool of updates) {
|
||||
tools.set(tool.callId, tool)
|
||||
const previous = tools.get(tool.callId)
|
||||
tools.set(tool.callId, {
|
||||
...previous,
|
||||
...tool,
|
||||
input: tool.input ?? previous?.input,
|
||||
output: tool.output ?? previous?.output,
|
||||
error: tool.error ?? previous?.error
|
||||
})
|
||||
}
|
||||
return [...tools.values()]
|
||||
}
|
||||
@@ -661,7 +685,7 @@ export class ContinueHostAdapter {
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
streamCallbacksMarker,
|
||||
'a={onContent:u=>{u&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"text",delta:u})},onContentComplete:u=>{},onToolStart:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:u,state:"running"})},onToolResult:(u,l,c,d)=>{d&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed"})},onToolError:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)})},onToolPermissionRequest:'
|
||||
'a={onContent:u=>{u&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"text",delta:u})},onContentComplete:u=>{},onToolStart:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:u,state:"running",input:(()=>{try{return JSON.stringify(l).slice(0,4e3)}catch{return"[无法序列化]"}})()})},onToolResult:(u,l,c,d)=>{d&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed",output:String(u).slice(0,16e3)})},onToolError:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)})},onToolPermissionRequest:'
|
||||
)
|
||||
patched = replaceExactly(
|
||||
patched,
|
||||
@@ -1112,6 +1136,12 @@ export class ContinueHostAdapter {
|
||||
callId: event.callId,
|
||||
name: event.name,
|
||||
state: event.state,
|
||||
...(event.input
|
||||
? { input: boundedToolDetail(event.input, 4_000) }
|
||||
: {}),
|
||||
...(event.output
|
||||
? { output: boundedToolDetail(event.output, 16_000) }
|
||||
: {}),
|
||||
...(event.error
|
||||
? { error: normalizeContinueToolError(event.error) }
|
||||
: {})
|
||||
@@ -1143,7 +1173,8 @@ export class ContinueHostAdapter {
|
||||
{
|
||||
callId: pendingCallId,
|
||||
name: pending.toolName,
|
||||
state: 'pending'
|
||||
state: 'pending',
|
||||
input: boundedToolDetail(pending.toolArgs, 4_000)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -225,6 +225,45 @@ describe('ContinueAgentRuntime', () => {
|
||||
expect(prompt).toContain('test')
|
||||
})
|
||||
|
||||
it('keeps a full bundled Skill payload on every platform', async () => {
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
binaryPath: '',
|
||||
configPath: 'C:\\safe config\\continue.yaml',
|
||||
defaultWorkspace: process.cwd(),
|
||||
hostCacheRoot: 'C:\\safe\\continue-host',
|
||||
skillInstructions: `# Skills\n${'技'.repeat(30_000)}`.slice(0, 30_000),
|
||||
createHostAdapter: () => ({
|
||||
getPreparedHost: mocks.prepareHost,
|
||||
run: mocks.runHost,
|
||||
dispose: mocks.disposeHost
|
||||
})
|
||||
})
|
||||
|
||||
await collectEvents(runtime)
|
||||
|
||||
const prompt = String(mocks.runHost.mock.calls[0]?.[0])
|
||||
expect(prompt).toContain('SYSTEM CAPABILITY INSTRUCTIONS')
|
||||
expect(prompt.length).toBeGreaterThan(24_000)
|
||||
})
|
||||
|
||||
it('reports oversized Skill payloads instead of dropping them silently', async () => {
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
binaryPath: '',
|
||||
configPath: 'C:\\safe config\\continue.yaml',
|
||||
defaultWorkspace: process.cwd(),
|
||||
hostCacheRoot: 'C:\\safe\\continue-host',
|
||||
skillInstructions: '巨'.repeat(130_000),
|
||||
createHostAdapter: () => ({
|
||||
getPreparedHost: mocks.prepareHost,
|
||||
run: mocks.runHost,
|
||||
dispose: mocks.disposeHost
|
||||
})
|
||||
})
|
||||
|
||||
await expect(collectEvents(runtime)).rejects.toThrow('超过 Continue')
|
||||
expect(mocks.runHost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks anonymous platform fallback without an explicit model configuration', async () => {
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
binaryPath: '',
|
||||
@@ -384,7 +423,13 @@ describe('ContinueAgentRuntime', () => {
|
||||
mocks.runHost.mockResolvedValue({
|
||||
text: 'Continue response',
|
||||
tools: [
|
||||
{ callId: 'call-1', name: 'Bash', state: 'completed' },
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
input: '{"command":"npm test"}',
|
||||
output: 'Tests passed'
|
||||
},
|
||||
{ callId: 'call-2', name: 'Write', state: 'completed' }
|
||||
]
|
||||
})
|
||||
@@ -396,7 +441,9 @@ describe('ContinueAgentRuntime', () => {
|
||||
type: 'tool',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
summary: 'Continue 工具:Bash'
|
||||
summary: 'Continue 工具:Bash',
|
||||
input: '{"command":"npm test"}',
|
||||
output: 'Tests passed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
|
||||
@@ -43,8 +43,9 @@ export type ContinueRuntimeOptions = {
|
||||
>
|
||||
}
|
||||
|
||||
const MAX_CONTINUE_PROMPT_CHARACTERS =
|
||||
process.platform === 'win32' ? 24_000 : 128_000
|
||||
// The prompt reaches the Continue host through a local HTTP POST body, so no
|
||||
// platform command-line limit applies to it.
|
||||
const MAX_CONTINUE_PROMPT_CHARACTERS = 128_000
|
||||
|
||||
function continueToolFailureMessage(tool: ContinueHostTool): string {
|
||||
const callId = tool.callId.slice(0, 128)
|
||||
@@ -72,6 +73,8 @@ function toContinueToolEvent(
|
||||
? 'failed'
|
||||
: tool.state,
|
||||
summary: `Continue 工具:${tool.name}`,
|
||||
...(tool.input ? { input: tool.input } : {}),
|
||||
...(tool.output ? { output: tool.output } : {}),
|
||||
...(tool.error ? { error: tool.error } : {})
|
||||
}
|
||||
}
|
||||
@@ -258,12 +261,19 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
||||
'CURRENT CONVERSATION:'
|
||||
].join('\n')
|
||||
: ''
|
||||
const conversationContext =
|
||||
if (
|
||||
skillPrefix &&
|
||||
skillPrefix.length + prompt.length <=
|
||||
MAX_CONTINUE_PROMPT_CHARACTERS
|
||||
? `${skillPrefix}\n${prompt}`
|
||||
: prompt
|
||||
skillPrefix.length + prompt.length > MAX_CONTINUE_PROMPT_CHARACTERS
|
||||
) {
|
||||
throw new Error(
|
||||
`已启用的 Skill 说明与当前请求合计 ${(
|
||||
skillPrefix.length + prompt.length
|
||||
).toLocaleString()} 字符,超过 Continue ${MAX_CONTINUE_PROMPT_CHARACTERS.toLocaleString()} 字符上限。请在设置中减少分配给 Continue 的 Skill。`
|
||||
)
|
||||
}
|
||||
const conversationContext = skillPrefix
|
||||
? `${skillPrefix}\n${prompt}`
|
||||
: prompt
|
||||
const detection = await this.getDetection()
|
||||
signal.throwIfAborted()
|
||||
if (!detection.available || !detection.path) {
|
||||
|
||||
@@ -286,7 +286,7 @@ describe('ModelAgentRuntime', () => {
|
||||
await expect(consume()).rejects.toThrow('意外中断')
|
||||
})
|
||||
|
||||
it('redacts credentials from provider error messages', async () => {
|
||||
it('preserves bounded provider error messages', async () => {
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://bigtoken.ai',
|
||||
@@ -319,7 +319,7 @@ describe('ModelAgentRuntime', () => {
|
||||
}
|
||||
|
||||
await expect(consume()).rejects.toThrow(
|
||||
'upstream failed Authorization: [REDACTED]'
|
||||
'upstream failed Authorization: Bearer secret-token'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -702,6 +702,15 @@ describe('ModelAgentRuntime', () => {
|
||||
.filter((event) => event.type === 'tool')
|
||||
.map((event) => event.state)
|
||||
).toEqual(['pending', 'running', 'completed'])
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
state: 'completed',
|
||||
input: '{\n "path": "README.md"\n}',
|
||||
output:
|
||||
'tool result\n\n[图片结果 1:image/png]'
|
||||
})
|
||||
)
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
@@ -1693,7 +1702,7 @@ describe('ModelAgentRuntime', () => {
|
||||
'x-request-id': 'image-request-502'
|
||||
},
|
||||
expected:
|
||||
'upstream unavailable Authorization: [REDACTED](HTTP 502,请求 ID image-request-502)'
|
||||
'upstream unavailable Authorization: Bearer secret-token(HTTP 502,请求 ID image-request-502)'
|
||||
},
|
||||
{
|
||||
body: '<html>Bad Gateway</html>',
|
||||
|
||||
@@ -31,8 +31,8 @@ import type {
|
||||
RuntimeModelUsageEvent
|
||||
} from './runtime'
|
||||
import {
|
||||
redactSensitiveText,
|
||||
safeToolArgumentSummary
|
||||
boundedToolDetail,
|
||||
safeToolErrorDetail
|
||||
} from './approval-summary'
|
||||
|
||||
type ConversationMessage = {
|
||||
@@ -121,7 +121,7 @@ function getErrorMessage(value: unknown): string | undefined {
|
||||
}
|
||||
const error = 'error' in value ? value.error : undefined
|
||||
if (typeof error === 'string') {
|
||||
return redactSensitiveText(error).slice(0, 1_000)
|
||||
return error.slice(0, 1_000)
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
@@ -129,13 +129,13 @@ function getErrorMessage(value: unknown): string | undefined {
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
return redactSensitiveText(error.message).slice(0, 1_000)
|
||||
return error.message.slice(0, 1_000)
|
||||
}
|
||||
if (
|
||||
'message' in value &&
|
||||
typeof value.message === 'string'
|
||||
) {
|
||||
return redactSensitiveText(value.message).slice(0, 1_000)
|
||||
return value.message.slice(0, 1_000)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -624,14 +624,29 @@ function getChatToolResultText(parts: ModelToolResultPart[]): string {
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function getToolResultPreview(parts: ModelToolResultPart[]): string {
|
||||
let imageNumber = 0
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (part.type === 'text') {
|
||||
return part.text
|
||||
}
|
||||
imageNumber += 1
|
||||
return `[图片结果 ${imageNumber}:${part.mimeType}]`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.slice(0, 16_000)
|
||||
}
|
||||
|
||||
function createRecoverableToolErrorResult(
|
||||
error: RecoverableModelToolError
|
||||
): ModelToolResult {
|
||||
const text = JSON.stringify({
|
||||
ok: false,
|
||||
recoverable: true,
|
||||
error: redactSensitiveText(error.message).slice(0, 1_000),
|
||||
nextAction: redactSensitiveText(error.nextAction).slice(0, 1_000)
|
||||
error: error.message.slice(0, 1_000),
|
||||
nextAction: error.nextAction.slice(0, 1_000)
|
||||
})
|
||||
return {
|
||||
parts: [{ type: 'text', text }],
|
||||
@@ -1250,7 +1265,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
providerMessage?.includes('模型接口请求失败')
|
||||
? '上游图像服务暂时不可用,请稍后重试或联系服务商'
|
||||
: providerMessage
|
||||
? redactSensitiveText(providerMessage).slice(0, 1_000)
|
||||
? providerMessage.slice(0, 1_000)
|
||||
: '图像生成请求失败'
|
||||
throw new Error(
|
||||
`${publicMessage}(HTTP ${response.status}${
|
||||
@@ -1546,13 +1561,15 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
seenCallIds.add(call.id)
|
||||
const tool = toolsByName.get(call.name)
|
||||
const displayName = tool?.displayName ?? call.name.slice(0, 128)
|
||||
const input = boundedToolDetail(call.arguments, 4_000)
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId: call.id,
|
||||
name: displayName,
|
||||
state: 'pending',
|
||||
summary: `直连模型工具:${displayName}`
|
||||
summary: `直连模型工具:${displayName}`,
|
||||
input
|
||||
}
|
||||
if (!tool) {
|
||||
yield {
|
||||
@@ -1561,7 +1578,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
callId: call.id,
|
||||
name: displayName,
|
||||
state: 'failed',
|
||||
summary: `直连模型请求了未知工具:${displayName}`
|
||||
summary: `直连模型请求了未知工具:${displayName}`,
|
||||
input
|
||||
}
|
||||
throw new Error(`模型请求了未知工具「${displayName}」`)
|
||||
}
|
||||
@@ -1581,19 +1599,22 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
this.toolProvider.getApproval(
|
||||
tool,
|
||||
call.arguments,
|
||||
safeToolArgumentSummary(call.arguments),
|
||||
boundedToolDetail(call.arguments, 1_000) ?? '',
|
||||
toolContext
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = safeToolErrorDetail(error)
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId: call.id,
|
||||
name: displayName,
|
||||
state: 'failed',
|
||||
summary: `直连模型工具审批失败:${displayName}`
|
||||
summary: `直连模型工具审批失败:${displayName}`,
|
||||
input,
|
||||
...(detail ? { error: detail } : {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -1604,7 +1625,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
callId: call.id,
|
||||
name: displayName,
|
||||
state: 'failed',
|
||||
summary: `用户拒绝了直连模型工具:${displayName}`
|
||||
summary: `用户拒绝了直连模型工具:${displayName}`,
|
||||
input
|
||||
}
|
||||
throw new Error(`用户拒绝了工具「${displayName}」`)
|
||||
}
|
||||
@@ -1615,7 +1637,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
callId: call.id,
|
||||
name: displayName,
|
||||
state: 'running',
|
||||
summary: `正在执行直连模型工具:${displayName}`
|
||||
summary: `正在执行直连模型工具:${displayName}`,
|
||||
input
|
||||
}
|
||||
|
||||
let result: ModelToolResult
|
||||
@@ -1629,6 +1652,7 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
)
|
||||
} catch (error) {
|
||||
const recoverable = error instanceof RecoverableModelToolError
|
||||
const detail = safeToolErrorDetail(error)
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
@@ -1638,7 +1662,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
summary:
|
||||
recoverable
|
||||
? `直连模型工具需要刷新后重试:${displayName}`
|
||||
: `直连模型工具执行失败:${displayName}`
|
||||
: `直连模型工具执行失败:${displayName}`,
|
||||
input,
|
||||
...(detail ? { error: detail } : {})
|
||||
}
|
||||
if (recoverable) {
|
||||
result = createRecoverableToolErrorResult(error)
|
||||
@@ -1657,7 +1683,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
callId: call.id,
|
||||
name: displayName,
|
||||
state: 'failed',
|
||||
summary: `直连模型工具结果超过限制:${displayName}`
|
||||
summary: `直连模型工具结果超过限制:${displayName}`,
|
||||
input
|
||||
}
|
||||
throw new Error('直连模型工具结果总量超过 1MB 安全限制')
|
||||
}
|
||||
@@ -1691,7 +1718,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
callId: call.id,
|
||||
name: displayName,
|
||||
state: 'completed',
|
||||
summary: `直连模型工具已完成:${displayName}`
|
||||
summary: `直连模型工具已完成:${displayName}`,
|
||||
input,
|
||||
output: getToolResultPreview(result.parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,14 @@ function completedToolEvent(
|
||||
callID: callId,
|
||||
type: 'tool',
|
||||
tool,
|
||||
state: { status: 'completed' }
|
||||
state: {
|
||||
status: 'completed',
|
||||
input: {
|
||||
command: 'npm test',
|
||||
token: 'visible-token'
|
||||
},
|
||||
output: 'Tests passed\nAuthorization: Bearer secret-token'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1503,7 +1510,11 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
state: 'completed'
|
||||
state: 'completed',
|
||||
input:
|
||||
'{\n "command": "npm test",\n "token": "visible-token"\n}',
|
||||
output:
|
||||
'Tests passed\nAuthorization: Bearer secret-token'
|
||||
})
|
||||
)
|
||||
expect(events).toContainEqual(
|
||||
@@ -1631,11 +1642,12 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
type: 'tool',
|
||||
callId: 'call-1',
|
||||
state: 'failed',
|
||||
error: 'write failed Authorization: [REDACTED]'
|
||||
error:
|
||||
'write failed Authorization: Bearer secret-token'
|
||||
}
|
||||
})
|
||||
await expect(stream.next()).rejects.toThrow(
|
||||
'write failed Authorization: [REDACTED]'
|
||||
'write failed Authorization: Bearer secret-token'
|
||||
)
|
||||
expect(session.abort).toHaveBeenCalledOnce()
|
||||
await runtime.dispose()
|
||||
@@ -1661,7 +1673,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
|
||||
const runtime = embeddedRuntime(client)
|
||||
|
||||
await expect(collectRun(runtime)).rejects.toThrow(
|
||||
'prompt rejected Authorization: [REDACTED]'
|
||||
'prompt rejected Authorization: Bearer secret-token'
|
||||
)
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type RuntimeSandboxResolution
|
||||
} from './runtime-sandbox'
|
||||
import {
|
||||
boundedToolDetail,
|
||||
safeToolErrorDetail
|
||||
} from './approval-summary'
|
||||
|
||||
@@ -978,6 +979,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
{
|
||||
name: string
|
||||
state: 'pending' | 'running' | 'completed' | 'failed'
|
||||
input?: string
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
>()
|
||||
@@ -1077,9 +1080,19 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
part.state.status === 'error'
|
||||
? safeToolErrorDetail(part.state.error)
|
||||
: undefined
|
||||
const input = isRecord(part.state.input)
|
||||
? boundedToolDetail(part.state.input, 4_000)
|
||||
: undefined
|
||||
const output =
|
||||
part.state.status === 'completed' &&
|
||||
typeof part.state.output === 'string'
|
||||
? part.state.output.slice(0, 16_000)
|
||||
: undefined
|
||||
toolStates.set(callId, {
|
||||
name: toolName,
|
||||
state,
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(error ? { error } : {})
|
||||
})
|
||||
yield {
|
||||
@@ -1089,6 +1102,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
name: toolName,
|
||||
state,
|
||||
summary: `OpenCode 工具:${toolName}`,
|
||||
...(input ? { input } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(error ? { error } : {})
|
||||
}
|
||||
}
|
||||
@@ -1309,6 +1324,8 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
name: tool.name,
|
||||
state: 'failed',
|
||||
summary: `OpenCode 工具:${tool.name}`,
|
||||
...(tool.input ? { input: tool.input } : {}),
|
||||
...(tool.output ? { output: tool.output } : {}),
|
||||
...(tool.error ? { error: tool.error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,31 @@ describe('AgentRuntimeController', () => {
|
||||
await controller.dispose()
|
||||
})
|
||||
|
||||
it('forces runtime disposal when active work does not stop during shutdown', async () => {
|
||||
const runtime = new TestRuntime(true)
|
||||
const controller = new AgentRuntimeController(runtime, 1)
|
||||
const stream = controller.run(
|
||||
{
|
||||
requestId: '1c608898-ecb7-4081-8174-2b6a52f53b12',
|
||||
conversationId: 'conversation-shutdown',
|
||||
prompt: 'test',
|
||||
workMode: 'ask'
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
const pendingEvent = stream.next()
|
||||
await runtime.started
|
||||
|
||||
await controller.dispose()
|
||||
expect(runtime.dispose).toHaveBeenCalledOnce()
|
||||
|
||||
runtime.finish()
|
||||
await expect(pendingEvent).resolves.toMatchObject({
|
||||
value: { type: 'text' }
|
||||
})
|
||||
await stream.return()
|
||||
})
|
||||
|
||||
it.each(['ask', 'plan'] as const)(
|
||||
'denies tool authorization in %s mode without prompting the user',
|
||||
async (workMode) => {
|
||||
|
||||
@@ -22,7 +22,10 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
private replacementQueue: Promise<void> = Promise.resolve()
|
||||
private closing = false
|
||||
|
||||
constructor(runtime: AgentRuntime) {
|
||||
constructor(
|
||||
runtime: AgentRuntime,
|
||||
private readonly shutdownGraceMs = 2_000
|
||||
) {
|
||||
this.current = {
|
||||
runtime,
|
||||
activeRequests: 0,
|
||||
@@ -212,9 +215,21 @@ export class AgentRuntimeController implements AgentRuntime {
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.closing = true
|
||||
const operation = this.replacementQueue.then(() =>
|
||||
this.retire(this.current)
|
||||
)
|
||||
const operation = this.replacementQueue.then(async () => {
|
||||
const slot = this.current
|
||||
const disposal = this.retire(slot)
|
||||
if (slot.activeRequests === 0) {
|
||||
return disposal
|
||||
}
|
||||
await Promise.race([
|
||||
disposal,
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, this.shutdownGraceMs)
|
||||
)
|
||||
])
|
||||
await this.disposeSlot(slot)
|
||||
return disposal
|
||||
})
|
||||
this.replacementQueue = operation.catch(() => undefined)
|
||||
await operation
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyRuntimeSelection,
|
||||
getConfiguredRuntimeTarget
|
||||
getConfiguredRuntimeTarget,
|
||||
resolveConfiguredAgentRuntimeSelection
|
||||
} from './runtime-selection'
|
||||
|
||||
const defaultProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
@@ -148,6 +149,41 @@ describe('runtime selection', () => {
|
||||
).toThrow('自动启动')
|
||||
})
|
||||
|
||||
it('resolves Agent Runtime backends from the global Runtime configuration', () => {
|
||||
const base = settings()
|
||||
const configured = settings({
|
||||
opencodeModelProfile: base.modelProfiles[1],
|
||||
continueModelProfile: base.modelProfiles[2]
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'opencode',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
).toEqual({
|
||||
provider: 'opencode',
|
||||
profileId: secondProfileId
|
||||
})
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'continue'
|
||||
})
|
||||
).toEqual({
|
||||
provider: 'continue',
|
||||
profileId: responsesProfileId
|
||||
})
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
})
|
||||
|
||||
it('routes legacy automatic settings through local OpenCode when the Server is blank', () => {
|
||||
expect(getConfiguredRuntimeTarget(settings())).toBe('opencode')
|
||||
expect(
|
||||
|
||||
@@ -35,6 +35,26 @@ export function getConfiguredRuntimeTarget(
|
||||
return 'model'
|
||||
}
|
||||
|
||||
export function resolveConfiguredAgentRuntimeSelection(
|
||||
settings: ResolvedRuntimeSettings,
|
||||
selection: AgentRuntimeSelection
|
||||
): AgentRuntimeSelection {
|
||||
if (
|
||||
selection.provider !== 'opencode' &&
|
||||
selection.provider !== 'continue'
|
||||
) {
|
||||
return selection
|
||||
}
|
||||
const profile =
|
||||
selection.provider === 'opencode'
|
||||
? settings.opencodeModelProfile
|
||||
: settings.continueModelProfile
|
||||
return {
|
||||
provider: selection.provider,
|
||||
...(profile ? { profileId: profile.id } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function applyRuntimeSelection(
|
||||
settings: ResolvedRuntimeSettings,
|
||||
selection: AgentRuntimeSelection
|
||||
|
||||
@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ApplicationSettingsStore,
|
||||
applicationSettingsSchema,
|
||||
applicationSettingsUpdateSchema,
|
||||
defaultApplicationSettings
|
||||
} from './application-settings-store'
|
||||
|
||||
@@ -51,18 +52,26 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(readdir(directory)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('persists only the versioned startup update preference', async () => {
|
||||
it('persists versioned application preferences', async () => {
|
||||
const { directory, filePath, store } = await createStore()
|
||||
|
||||
await expect(
|
||||
store.update({ checkUpdatesOnStartup: false })
|
||||
).resolves.toEqual({ checkUpdatesOnStartup: false })
|
||||
store.update({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 1,
|
||||
checkUpdatesOnStartup: false
|
||||
version: 2,
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})
|
||||
expect(
|
||||
(await readdir(directory)).filter((name) => name.endsWith('.tmp'))
|
||||
@@ -73,38 +82,103 @@ describe('ApplicationSettingsStore', () => {
|
||||
const { directory } = await createStore()
|
||||
const filePath = join(directory, 'nested', 'application-settings.json')
|
||||
const store = new ApplicationSettingsStore(filePath)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
await store.update({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})
|
||||
|
||||
await expect(
|
||||
new ApplicationSettingsStore(filePath).get()
|
||||
).resolves.toEqual({ checkUpdatesOnStartup: false })
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('strictly rejects unknown, missing, and mistyped input', async () => {
|
||||
const { directory, store } = await createStore()
|
||||
it.each([1, 2])(
|
||||
'loads version %s settings missing the field with Magic Notes disabled',
|
||||
async (version) => {
|
||||
const { filePath, store } = await createStore()
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
version,
|
||||
checkUpdatesOnStartup: false
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('strictly rejects incomplete full settings', () => {
|
||||
for (const input of [
|
||||
{},
|
||||
{ checkUpdatesOnStartup: 'true' },
|
||||
{ checkUpdatesOnStartup: true, anotherSetting: true },
|
||||
{
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: true,
|
||||
anotherSetting: true
|
||||
},
|
||||
{ checkUpdatesOnStartup: true },
|
||||
null
|
||||
]) {
|
||||
expect(applicationSettingsSchema.safeParse(input).success).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('strictly rejects empty, unknown, and mistyped updates', async () => {
|
||||
const { directory, store } = await createStore()
|
||||
for (const input of [
|
||||
{},
|
||||
{ checkUpdatesOnStartup: 'true' },
|
||||
{ anotherSetting: true },
|
||||
null
|
||||
]) {
|
||||
expect(
|
||||
applicationSettingsUpdateSchema.safeParse(input).success
|
||||
).toBe(false)
|
||||
await expect(store.update(input)).rejects.toThrow()
|
||||
}
|
||||
await expect(readdir(directory)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('merges partial updates without overwriting other settings', async () => {
|
||||
const { store } = await createStore()
|
||||
|
||||
await store.update({ magicNotesEnabled: true })
|
||||
await expect(
|
||||
store.update({ checkUpdatesOnStartup: false })
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'{not-json',
|
||||
JSON.stringify({ version: 2, checkUpdatesOnStartup: false }),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
version: 3,
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
}),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true,
|
||||
injected: true
|
||||
}),
|
||||
JSON.stringify({ version: 1, checkUpdatesOnStartup: 'false' })
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
checkUpdatesOnStartup: 'false',
|
||||
magicNotesEnabled: true
|
||||
})
|
||||
])('isolates corrupt persisted data and restores defaults', async (data) => {
|
||||
const { directory, filePath, store } = await createStore()
|
||||
await writeFile(filePath, data, 'utf8')
|
||||
@@ -142,28 +216,48 @@ describe('ApplicationSettingsStore', () => {
|
||||
const { filePath, store } = await createStore()
|
||||
|
||||
await Promise.all([
|
||||
store.update({ checkUpdatesOnStartup: false }),
|
||||
store.update({ checkUpdatesOnStartup: true }),
|
||||
store.update({ checkUpdatesOnStartup: false })
|
||||
store.update({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true
|
||||
}),
|
||||
store.update({
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: false
|
||||
}),
|
||||
store.update({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})
|
||||
])
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 1,
|
||||
checkUpdatesOnStartup: false
|
||||
version: 2,
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('continues accepting updates after a validation failure', async () => {
|
||||
const { store } = await createStore()
|
||||
await expect(
|
||||
store.update({ checkUpdatesOnStartup: 'invalid' })
|
||||
store.update({
|
||||
checkUpdatesOnStartup: 'invalid',
|
||||
magicNotesEnabled: true
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
await expect(
|
||||
store.update({ checkUpdatesOnStartup: false })
|
||||
).resolves.toEqual({ checkUpdatesOnStartup: false })
|
||||
store.update({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true
|
||||
})
|
||||
).resolves.toEqual({
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,12 +10,23 @@ import { dirname } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
applicationSettingsSchema,
|
||||
applicationSettingsUpdateSchema,
|
||||
type ApplicationSettings
|
||||
} from '../shared/application-settings-contracts'
|
||||
export { applicationSettingsSchema } from '../shared/application-settings-contracts'
|
||||
export {
|
||||
applicationSettingsSchema,
|
||||
applicationSettingsUpdateSchema
|
||||
} from '../shared/application-settings-contracts'
|
||||
export type { ApplicationSettings } from '../shared/application-settings-contracts'
|
||||
|
||||
const CURRENT_SETTINGS_VERSION = 1
|
||||
const CURRENT_SETTINGS_VERSION = 2
|
||||
|
||||
const legacyStoredApplicationSettingsSchema = z
|
||||
.object({
|
||||
version: z.union([z.literal(1), z.literal(2)]),
|
||||
checkUpdatesOnStartup: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const storedApplicationSettingsSchema = applicationSettingsSchema
|
||||
.extend({
|
||||
@@ -28,7 +39,8 @@ type StoredApplicationSettings = z.infer<
|
||||
>
|
||||
|
||||
export const defaultApplicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: true
|
||||
checkUpdatesOnStartup: true,
|
||||
magicNotesEnabled: false
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
@@ -81,6 +93,17 @@ export class ApplicationSettingsStore {
|
||||
}
|
||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
const legacyResult =
|
||||
legacyStoredApplicationSettingsSchema.safeParse(parsed)
|
||||
if (legacyResult.success) {
|
||||
this.settings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
checkUpdatesOnStartup:
|
||||
legacyResult.data.checkUpdatesOnStartup,
|
||||
magicNotesEnabled: false
|
||||
}
|
||||
return this.settings
|
||||
}
|
||||
await this.isolateCorruptFile()
|
||||
this.settings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
@@ -106,16 +129,19 @@ export class ApplicationSettingsStore {
|
||||
async get(): Promise<ApplicationSettings> {
|
||||
const stored = await this.loadStored()
|
||||
return {
|
||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup
|
||||
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
|
||||
magicNotesEnabled: stored.magicNotesEnabled
|
||||
}
|
||||
}
|
||||
|
||||
update(input: unknown): Promise<ApplicationSettings> {
|
||||
const operation = this.updateQueue.then(async () => {
|
||||
const settings = applicationSettingsSchema.parse(input)
|
||||
const updates = applicationSettingsUpdateSchema.parse(input)
|
||||
const current = await this.loadStored()
|
||||
const next: StoredApplicationSettings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
...settings
|
||||
...current,
|
||||
...updates,
|
||||
version: CURRENT_SETTINGS_VERSION
|
||||
}
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
const temporaryPath =
|
||||
@@ -137,7 +163,8 @@ export class ApplicationSettingsStore {
|
||||
}
|
||||
this.settings = next
|
||||
return {
|
||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup
|
||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||
magicNotesEnabled: next.magicNotesEnabled
|
||||
}
|
||||
})
|
||||
this.updateQueue = operation.then(
|
||||
|
||||
@@ -2,12 +2,15 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const channelDefaultProfileId =
|
||||
'00000000-0000-4000-8000-000000000001'
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
@@ -52,7 +55,50 @@ describe('AssistantDatabase', () => {
|
||||
unchanged.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 8', async () => {
|
||||
it('lists projects by creation time with newer projects last', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-07T00:00:00.000Z'))
|
||||
const database = await createDatabase()
|
||||
const defaultProject = database.listProjects()[0]!
|
||||
|
||||
vi.setSystemTime(new Date('2026-08-07T00:01:00.000Z'))
|
||||
const secondProject = database.createProject({
|
||||
name: '第二项目',
|
||||
description: '',
|
||||
rootPath: 'C:\\Second',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
vi.setSystemTime(new Date('2026-08-07T00:02:00.000Z'))
|
||||
const thirdProject = database.createProject({
|
||||
name: '第三项目',
|
||||
description: '',
|
||||
rootPath: 'C:\\Third',
|
||||
defaultWorkMode: 'execute'
|
||||
})
|
||||
|
||||
database.updateProject(secondProject.id, {
|
||||
name: '第二项目(已更新)',
|
||||
description: '',
|
||||
rootPath: 'C:\\Second',
|
||||
defaultWorkMode: 'ask'
|
||||
})
|
||||
|
||||
expect(database.listProjects().map((project) => project.id)).toEqual([
|
||||
defaultProject.id,
|
||||
secondProject.id,
|
||||
thirdProject.id
|
||||
])
|
||||
expect(
|
||||
database.listProjects(true).map((project) => project.id)
|
||||
).toEqual([
|
||||
defaultProject.id,
|
||||
secondProject.id,
|
||||
thirdProject.id
|
||||
])
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 15', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -65,6 +111,7 @@ describe('AssistantDatabase', () => {
|
||||
const oldDatabase = new DatabaseSync(databasePath)
|
||||
oldDatabase.exec(`
|
||||
DROP TABLE model_usage_calls;
|
||||
ALTER TABLE projects DROP COLUMN runtime_selection_json;
|
||||
PRAGMA user_version = 3;
|
||||
`)
|
||||
oldDatabase.close()
|
||||
@@ -80,7 +127,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(8)
|
||||
).toBe(16)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -89,6 +136,15 @@ describe('AssistantDatabase', () => {
|
||||
)
|
||||
.get()
|
||||
).toEqual({ name: 'model_usage_calls' })
|
||||
expect(
|
||||
current
|
||||
.prepare('PRAGMA table_info(projects)')
|
||||
.all()
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'runtime_selection_json' })
|
||||
])
|
||||
)
|
||||
const foreignKeys = current
|
||||
.prepare('PRAGMA foreign_key_list(model_usage_calls)')
|
||||
.all() as Array<{
|
||||
@@ -120,6 +176,28 @@ describe('AssistantDatabase', () => {
|
||||
{ name: 'messages_state_idx' },
|
||||
{ name: 'tasks_status_idx' }
|
||||
])
|
||||
expect(
|
||||
(
|
||||
current.prepare('PRAGMA table_info(tasks)').all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
).some((column) => column.name === 'visible')
|
||||
).toBe(true)
|
||||
expect(
|
||||
(
|
||||
current
|
||||
.prepare('PRAGMA table_info(magic_note_entries)')
|
||||
.all() as Array<{ name: string }>
|
||||
).some((column) => column.name === 'image_bytes')
|
||||
).toBe(true)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'magic_todos'`
|
||||
)
|
||||
.get()
|
||||
).toEqual({ name: 'magic_todos' })
|
||||
current.close()
|
||||
})
|
||||
|
||||
@@ -153,7 +231,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(8)
|
||||
).toBe(16)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -191,6 +269,52 @@ describe('AssistantDatabase', () => {
|
||||
current.close()
|
||||
})
|
||||
|
||||
it('backfills checklist todos when migrating existing magic notes', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-magic-todo-migration-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
const project = initial.listProjects()[0]!
|
||||
const note = initial.createMagicNote({
|
||||
projectId: project.id,
|
||||
title: '迁移笔记'
|
||||
})
|
||||
initial.createMagicNoteEntry({
|
||||
noteId: note.id,
|
||||
content: {
|
||||
version: 1,
|
||||
ops: [
|
||||
{ insert: '迁移待办' },
|
||||
{ insert: '\n', attributes: { list: 'unchecked' } }
|
||||
]
|
||||
},
|
||||
plainText: '迁移待办'
|
||||
})
|
||||
initial.close()
|
||||
|
||||
const legacy = new DatabaseSync(databasePath)
|
||||
legacy.exec(`
|
||||
DELETE FROM magic_todos;
|
||||
PRAGMA user_version = 9;
|
||||
`)
|
||||
legacy.close()
|
||||
|
||||
const migrated = new AssistantDatabase(databasePath)
|
||||
migrated.initialize('C:\\Workspace')
|
||||
expect(migrated.listMagicTodos(project.id)).toEqual([
|
||||
expect.objectContaining({
|
||||
noteId: note.id,
|
||||
source: 'note',
|
||||
title: '迁移待办',
|
||||
completed: false
|
||||
})
|
||||
])
|
||||
migrated.close()
|
||||
})
|
||||
|
||||
it('creates a default project and persists project updates', async () => {
|
||||
const database = await createDatabase()
|
||||
const [defaultProject] = database.listProjects()
|
||||
@@ -220,7 +344,6 @@ describe('AssistantDatabase', () => {
|
||||
name: '产品发布 2',
|
||||
defaultWorkMode: 'execute'
|
||||
})
|
||||
|
||||
database.setProjectArchived(project.id, true)
|
||||
expect(database.listProjects()).toHaveLength(1)
|
||||
expect(database.listProjects(true)).toEqual(
|
||||
@@ -234,6 +357,219 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('idempotently creates protected channel projects by channel identity', async () => {
|
||||
const database = await createDatabase()
|
||||
const sameName = database.createProject({
|
||||
name: '微信 ClawBot',
|
||||
description: '普通同名项目',
|
||||
rootPath: 'C:\\Ordinary',
|
||||
defaultWorkMode: 'execute'
|
||||
})
|
||||
|
||||
const first = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
channelDefaultProfileId
|
||||
)
|
||||
const second = database.ensureChannelProjects(
|
||||
'C:\\Ignored',
|
||||
channelDefaultProfileId
|
||||
)
|
||||
|
||||
expect(first).toEqual([
|
||||
expect.objectContaining({
|
||||
name: '微信 ClawBot',
|
||||
rootPath: 'C:\\Users\\test',
|
||||
defaultWorkMode: 'ask',
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: channelDefaultProfileId
|
||||
},
|
||||
kind: 'channel',
|
||||
channel: 'weixin'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: 'channel',
|
||||
channel: 'wecom'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: 'channel',
|
||||
channel: 'dingtalk'
|
||||
})
|
||||
])
|
||||
expect(second.map((project) => project.id)).toEqual(
|
||||
first.map((project) => project.id)
|
||||
)
|
||||
expect(database.getProject(sameName.id)).toMatchObject({
|
||||
kind: 'user',
|
||||
channel: undefined,
|
||||
rootPath: 'C:\\Ordinary'
|
||||
})
|
||||
|
||||
const weixin = first[0]!
|
||||
const updated = database.updateProject(weixin.id, {
|
||||
name: '不可重命名',
|
||||
description: '更新后的通道说明',
|
||||
rootPath: 'C:\\Remote',
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: {
|
||||
provider: 'opencode',
|
||||
profileId: '00000000-0000-4000-8000-000000000019'
|
||||
}
|
||||
})
|
||||
expect(updated).toMatchObject({
|
||||
name: '微信 ClawBot',
|
||||
description: '更新后的通道说明',
|
||||
rootPath: 'C:\\Remote',
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: {
|
||||
provider: 'opencode',
|
||||
profileId: '00000000-0000-4000-8000-000000000019'
|
||||
}
|
||||
})
|
||||
expect(() =>
|
||||
database.updateProject(weixin.id, {
|
||||
name: weixin.name,
|
||||
description: weixin.description,
|
||||
rootPath: ' ',
|
||||
defaultWorkMode: 'execute'
|
||||
})
|
||||
).toThrow('通道项目必须设置默认工作目录')
|
||||
expect(() =>
|
||||
database.setProjectArchived(weixin.id, true)
|
||||
).toThrow('系统通道项目不能归档')
|
||||
expect(() =>
|
||||
database.deleteProject(weixin.id, weixin.name)
|
||||
).toThrow('系统通道项目不能删除')
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('persists one protected remote conversation per channel identity', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
channelDefaultProfileId
|
||||
)[0]!
|
||||
const first = database.getOrCreateRemoteConversation({
|
||||
projectId: project.id,
|
||||
channel: 'weixin',
|
||||
accountId: 'default',
|
||||
externalConversationId: 'remote-user-1',
|
||||
conversationType: 'direct',
|
||||
title: '微信 ClawBot · ****0001',
|
||||
accountDisplay: '发送者 ****0001',
|
||||
runtimeSelection: { provider: 'continue' }
|
||||
})
|
||||
const second = database.getOrCreateRemoteConversation({
|
||||
projectId: project.id,
|
||||
channel: 'weixin',
|
||||
accountId: 'default',
|
||||
externalConversationId: 'remote-user-1',
|
||||
conversationType: 'direct',
|
||||
title: '微信 ClawBot · ****0001',
|
||||
accountDisplay: '发送者 ****0001',
|
||||
runtimeSelection: { provider: 'continue' }
|
||||
})
|
||||
expect(second.id).toBe(first.id)
|
||||
|
||||
database.appendRemoteConversationMessage({
|
||||
conversationId: first.id,
|
||||
role: 'user',
|
||||
content: '请分析状态',
|
||||
attachments: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000090',
|
||||
name: '状态.txt',
|
||||
size: 12,
|
||||
preview: '状态',
|
||||
kind: 'text'
|
||||
}
|
||||
],
|
||||
status: '微信 ClawBot · 对话'
|
||||
})
|
||||
database.appendRemoteConversationMessage({
|
||||
conversationId: first.id,
|
||||
role: 'assistant',
|
||||
content: '状态正常',
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000091'
|
||||
],
|
||||
status: '微信 ClawBot · 已完成'
|
||||
})
|
||||
expect(database.getConversation(first.id)).toMatchObject({
|
||||
projectId: project.id,
|
||||
runtimeSelection: { provider: 'continue' },
|
||||
remote: {
|
||||
channel: 'weixin',
|
||||
accountDisplay: '发送者 ****0001',
|
||||
conversationType: 'direct'
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: '请分析状态',
|
||||
attachments: [
|
||||
expect.objectContaining({ name: '状态.txt' })
|
||||
],
|
||||
status: '微信 ClawBot · 对话'
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '状态正常',
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000091'
|
||||
],
|
||||
status: '微信 ClawBot · 已完成'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
database.replaceConversations([])
|
||||
expect(database.getConversation(first.id).remote?.channel).toBe(
|
||||
'weixin'
|
||||
)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('persists remote event deduplication and failed reply outbox state', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-channel-state-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize('C:\\Workspace')
|
||||
expect(database.claimChannelEvent('weixin', 'event-1')).toBe(true)
|
||||
expect(database.claimChannelEvent('weixin', 'event-1')).toBe(false)
|
||||
expect(database.claimChannelEvent('dingtalk', 'event-1')).toBe(true)
|
||||
|
||||
const entry = database.enqueueChannelResult({
|
||||
channel: 'weixin',
|
||||
eventId: 'event-1',
|
||||
conversationId: 'conversation-1',
|
||||
recipientId: 'sender-1',
|
||||
status: 'completed',
|
||||
output: '已完成'
|
||||
})
|
||||
database.markChannelResult(entry.id, 'failed')
|
||||
expect(database.listUndeliveredChannelResults()).toEqual([
|
||||
{
|
||||
...entry,
|
||||
state: 'failed',
|
||||
attempts: 1
|
||||
}
|
||||
])
|
||||
database.markChannelResult(entry.id, 'delivered')
|
||||
expect(database.listUndeliveredChannelResults()).toEqual([])
|
||||
database.close()
|
||||
|
||||
const reopened = new AssistantDatabase(databasePath)
|
||||
reopened.initialize('C:\\Workspace')
|
||||
expect(reopened.claimChannelEvent('weixin', 'event-1')).toBe(
|
||||
false
|
||||
)
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('safely deletes a confirmed project and its scoped data', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.createProject({
|
||||
@@ -823,6 +1159,8 @@ describe('AssistantDatabase', () => {
|
||||
'00000000-0000-4000-8000-000000000292'
|
||||
const runtimeProfileId =
|
||||
'00000000-0000-4000-8000-000000000293'
|
||||
const imageProfileId =
|
||||
'00000000-0000-4000-8000-000000000294'
|
||||
database.replaceConversations(
|
||||
([
|
||||
['model', removedProfileId],
|
||||
@@ -837,12 +1175,66 @@ describe('AssistantDatabase', () => {
|
||||
messages: []
|
||||
}))
|
||||
)
|
||||
const channelProject = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
defaultProfileId
|
||||
)[0]!
|
||||
database.updateProject(channelProject.id, {
|
||||
name: channelProject.name,
|
||||
description: channelProject.description,
|
||||
rootPath: channelProject.rootPath,
|
||||
defaultWorkMode: channelProject.defaultWorkMode,
|
||||
runtimeSelection: {
|
||||
provider: 'opencode',
|
||||
profileId: runtimeProfileId
|
||||
}
|
||||
})
|
||||
const imageChannelProject = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
defaultProfileId
|
||||
)[1]!
|
||||
database.updateProject(imageChannelProject.id, {
|
||||
name: imageChannelProject.name,
|
||||
description: imageChannelProject.description,
|
||||
rootPath: imageChannelProject.rootPath,
|
||||
defaultWorkMode: imageChannelProject.defaultWorkMode,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: imageProfileId
|
||||
}
|
||||
})
|
||||
const automaticChannelProject = database.ensureChannelProjects(
|
||||
'C:\\Users\\test',
|
||||
defaultProfileId
|
||||
)[2]!
|
||||
database.updateProject(automaticChannelProject.id, {
|
||||
name: automaticChannelProject.name,
|
||||
description: automaticChannelProject.description,
|
||||
rootPath: automaticChannelProject.rootPath,
|
||||
defaultWorkMode: automaticChannelProject.defaultWorkMode,
|
||||
runtimeSelection: { provider: 'auto' }
|
||||
})
|
||||
const automaticRemoteConversation =
|
||||
database.getOrCreateRemoteConversation({
|
||||
projectId: automaticChannelProject.id,
|
||||
channel: 'dingtalk',
|
||||
accountId: 'default',
|
||||
externalConversationId: 'legacy-auto-conversation',
|
||||
conversationType: 'direct',
|
||||
title: '钉钉 · 旧版自动后端',
|
||||
accountDisplay: '发送者 ****0001',
|
||||
runtimeSelection: { provider: 'auto' }
|
||||
})
|
||||
|
||||
expect(
|
||||
database.repairConversationRuntimeSelections({
|
||||
modelProfiles: [
|
||||
{ id: defaultProfileId },
|
||||
{ id: runtimeProfileId }
|
||||
{ id: runtimeProfileId },
|
||||
{
|
||||
id: imageProfileId,
|
||||
protocol: 'openai-images-generations'
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: defaultProfileId,
|
||||
opencodeModelSource: {
|
||||
@@ -851,10 +1243,11 @@ describe('AssistantDatabase', () => {
|
||||
},
|
||||
continueModelSource: { kind: 'platform' }
|
||||
})
|
||||
).toBe(3)
|
||||
).toBe(7)
|
||||
expect(
|
||||
database
|
||||
.listConversations()
|
||||
.filter((conversation) => !conversation.remote)
|
||||
.sort((left, right) => left.title.localeCompare(right.title))
|
||||
.map((conversation) => conversation.runtimeSelection)
|
||||
).toEqual([
|
||||
@@ -863,6 +1256,29 @@ describe('AssistantDatabase', () => {
|
||||
{ provider: 'continue' },
|
||||
{ provider: 'model', profileId: runtimeProfileId }
|
||||
])
|
||||
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
|
||||
provider: 'opencode'
|
||||
})
|
||||
expect(
|
||||
database.getProject(imageChannelProject.id).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
expect(
|
||||
database.getProject(automaticChannelProject.id).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
expect(
|
||||
database.getConversation(
|
||||
automaticRemoteConversation.id
|
||||
).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
database.close()
|
||||
})
|
||||
|
||||
@@ -1303,6 +1719,202 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('persists scoped magic notes and AI comments without todo proposals', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.listProjects()[0]!
|
||||
const globalNote = database.createMagicNote({
|
||||
title: '全局笔记'
|
||||
})
|
||||
const projectNote = database.createMagicNote({
|
||||
projectId: project.id,
|
||||
title: '项目笔记'
|
||||
})
|
||||
|
||||
expect(database.listMagicNotes()).toEqual([
|
||||
expect.objectContaining({ id: globalNote.id, title: '全局笔记' })
|
||||
])
|
||||
expect(database.listMagicNotes(project.id)).toEqual([
|
||||
expect.objectContaining({ id: projectNote.id, title: '项目笔记' })
|
||||
])
|
||||
|
||||
const withEntry = database.createMagicNoteEntry({
|
||||
noteId: projectNote.id,
|
||||
content: {
|
||||
version: 1,
|
||||
ops: [
|
||||
{ insert: '整理发布清单', attributes: { bold: true } },
|
||||
{ insert: '\n' }
|
||||
]
|
||||
},
|
||||
plainText: '整理发布清单'
|
||||
})
|
||||
const entry = withEntry.entries[0]!
|
||||
expect(withEntry).toMatchObject({
|
||||
entryCount: 1,
|
||||
preview: '整理发布清单'
|
||||
})
|
||||
|
||||
const analyzed = database.saveMagicNoteAnalysis({
|
||||
entryId: entry.id,
|
||||
expectedRevision: entry.revision,
|
||||
comments: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000401',
|
||||
kind: 'suggestion',
|
||||
content: '可以拆成可检查的发布步骤。'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(analyzed.entries[0]!.comments).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'suggestion',
|
||||
content: '可以拆成可检查的发布步骤。'
|
||||
})
|
||||
])
|
||||
expect(database.listTasks()).toEqual([])
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('synchronizes note checklists and standalone magic todos bidirectionally', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.listProjects()[0]!
|
||||
const note = database.createMagicNote({
|
||||
projectId: project.id,
|
||||
title: '发布笔记'
|
||||
})
|
||||
const withEntry = database.createMagicNoteEntry({
|
||||
noteId: note.id,
|
||||
content: {
|
||||
version: 1,
|
||||
ops: [
|
||||
{ insert: '核对发布材料' },
|
||||
{ insert: '\n', attributes: { list: 'unchecked' } },
|
||||
{ insert: '上传构建产物' },
|
||||
{ insert: '\n', attributes: { list: 'checked' } }
|
||||
]
|
||||
},
|
||||
plainText: '核对发布材料\n上传构建产物'
|
||||
})
|
||||
const entry = withEntry.entries[0]!
|
||||
|
||||
const noteTodos = database.listMagicTodos(project.id)
|
||||
expect(noteTodos).toEqual([
|
||||
expect.objectContaining({
|
||||
noteId: note.id,
|
||||
entryId: entry.id,
|
||||
source: 'note',
|
||||
title: '核对发布材料',
|
||||
completed: false
|
||||
}),
|
||||
expect.objectContaining({
|
||||
source: 'note',
|
||||
title: '上传构建产物',
|
||||
completed: true
|
||||
})
|
||||
])
|
||||
|
||||
const completed = database.updateMagicTodo({
|
||||
todoId: noteTodos[0]!.id,
|
||||
completed: true,
|
||||
expectedRevision: noteTodos[0]!.revision
|
||||
})
|
||||
expect(completed.completed).toBe(true)
|
||||
expect(database.getMagicNote(note.id).entries[0]!.content.ops).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
insert: '\n',
|
||||
attributes: expect.objectContaining({ list: 'checked' })
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const updatedEntry = database.getMagicNote(note.id).entries[0]!
|
||||
database.updateMagicNoteEntry({
|
||||
entryId: entry.id,
|
||||
expectedRevision: updatedEntry.revision,
|
||||
content: {
|
||||
version: 1,
|
||||
ops: [
|
||||
{ insert: '新增首项' },
|
||||
{ insert: '\n', attributes: { list: 'unchecked' } },
|
||||
{ insert: '上传构建产物' },
|
||||
{ insert: '\n', attributes: { list: 'unchecked' } },
|
||||
{ insert: '核对发布材料' },
|
||||
{ insert: '\n', attributes: { list: 'checked' } }
|
||||
]
|
||||
},
|
||||
plainText: '新增首项\n上传构建产物\n核对发布材料'
|
||||
})
|
||||
const reordered = database.listMagicTodos(project.id)
|
||||
expect(
|
||||
reordered.find((todo) => todo.title === '核对发布材料')
|
||||
).toMatchObject({
|
||||
id: noteTodos[0]!.id,
|
||||
completed: true,
|
||||
sourceIndex: 2
|
||||
})
|
||||
expect(
|
||||
reordered.find((todo) => todo.title === '上传构建产物')
|
||||
).toMatchObject({
|
||||
id: noteTodos[1]!.id,
|
||||
completed: false,
|
||||
sourceIndex: 1
|
||||
})
|
||||
|
||||
const manual = database.createMagicTodo({
|
||||
projectId: project.id,
|
||||
title: '手动待办',
|
||||
instructions: '补充验收说明'
|
||||
})
|
||||
expect(manual).toMatchObject({
|
||||
source: 'manual',
|
||||
completed: false,
|
||||
title: '手动待办'
|
||||
})
|
||||
const edited = database.updateMagicTodo({
|
||||
todoId: manual.id,
|
||||
title: '更新后的手动待办',
|
||||
instructions: '新的说明',
|
||||
expectedRevision: manual.revision
|
||||
})
|
||||
expect(edited).toMatchObject({
|
||||
title: '更新后的手动待办',
|
||||
instructions: '新的说明'
|
||||
})
|
||||
database.deleteMagicTodo(edited.id)
|
||||
expect(
|
||||
database.listMagicTodos(project.id).some((todo) => todo.id === edited.id)
|
||||
).toBe(false)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('protects magic note records from stale revisions', async () => {
|
||||
const database = await createDatabase()
|
||||
const note = database.createMagicNote({ title: '并发笔记' })
|
||||
const withEntry = database.createMagicNoteEntry({
|
||||
noteId: note.id,
|
||||
content: { version: 1, ops: [{ insert: '初始内容\n' }] },
|
||||
plainText: '初始内容'
|
||||
})
|
||||
const entry = withEntry.entries[0]!
|
||||
|
||||
database.updateMagicNoteEntry({
|
||||
entryId: entry.id,
|
||||
expectedRevision: entry.revision,
|
||||
content: { version: 1, ops: [{ insert: '新内容\n' }] },
|
||||
plainText: '新内容'
|
||||
})
|
||||
expect(() =>
|
||||
database.updateMagicNoteEntry({
|
||||
entryId: entry.id,
|
||||
expectedRevision: entry.revision,
|
||||
content: { version: 1, ops: [{ insert: '过期内容\n' }] },
|
||||
plainText: '过期内容'
|
||||
})
|
||||
).toThrow('记录已被更新')
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('clears private assistant content while preserving workspace configuration', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.listProjects()[0]!
|
||||
@@ -1351,6 +1963,10 @@ describe('AssistantDatabase', () => {
|
||||
cacheRead: 2,
|
||||
cacheWrite: 1
|
||||
})
|
||||
database.createMagicNote({
|
||||
projectId: project.id,
|
||||
title: '待清除笔记'
|
||||
})
|
||||
expect(database.getTokenUsageSummary().totals.totalTokens).toBe(15)
|
||||
|
||||
database.clearAssistantData()
|
||||
@@ -1362,6 +1978,7 @@ describe('AssistantDatabase', () => {
|
||||
expect(database.listHeartbeatConfigs(project.id)).toEqual([])
|
||||
expect(database.listTasks()).toEqual([])
|
||||
expect(database.listArtifacts(project.id)).toEqual([])
|
||||
expect(database.listMagicNotes(project.id)).toEqual([])
|
||||
expect(database.getTokenUsageSummary()).toEqual({
|
||||
totals: {
|
||||
callCount: 0,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,7 +100,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
).count
|
||||
check.close()
|
||||
migrated.close()
|
||||
expect(version).toBe(8)
|
||||
expect(version).toBe(16)
|
||||
expect(heartbeatTableCount).toBe(3)
|
||||
})
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ describe('BrowserModelTools', () => {
|
||||
})
|
||||
expect(first.scopeKey).not.toBe(second.scopeKey)
|
||||
expect(first.allowPermanent).toBe(false)
|
||||
expect(first.description).toContain('包括密码字段')
|
||||
expect(JSON.stringify(first)).not.toContain('top-secret')
|
||||
|
||||
const result = await tools.callTool(
|
||||
|
||||
@@ -84,7 +84,7 @@ const definitions = [
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 8_192,
|
||||
description: '完整的公开 HTTP(S) URL'
|
||||
description: '当前设备可连接的完整 HTTP 或 HTTPS URL'
|
||||
}
|
||||
},
|
||||
required: ['url'],
|
||||
@@ -267,7 +267,7 @@ export class BrowserModelTools {
|
||||
scopeKey = `model:browser:click:${currentOrigin}:${input.ref}`
|
||||
} else if (name === 'browser_type') {
|
||||
const input = browserTypeInputSchema.parse(argumentsValue)
|
||||
description = `向 ${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本。密码、文件和隐藏字段会被拒绝。`
|
||||
description = `向 ${currentOrigin} 页面中的元素 ${input.ref} 输入已隐藏的文本,包括密码字段;文件、隐藏、禁用和只读字段不支持输入。`
|
||||
argumentSummary = `元素:${input.ref};内容:[已隐藏,${input.text.length} 个字符]`
|
||||
// A session approval must never authorize a later value, even for the
|
||||
// same element. The nonce intentionally makes this invocation-only.
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type BrowserSessionLike
|
||||
} from './browser-service'
|
||||
import type { BrowserWebContents } from './electron-browser-session'
|
||||
import type { BrowserLiveState } from '../../shared/contracts'
|
||||
|
||||
type HarnessSlot = {
|
||||
currentOrigin?: string
|
||||
@@ -30,19 +31,33 @@ function createHarness(options: {
|
||||
cleanupTimeoutMs?: number
|
||||
dispose?: () => Promise<void>
|
||||
sessionGate?: Promise<void>
|
||||
captureScreenshot?: (
|
||||
signal: AbortSignal
|
||||
) => Promise<{
|
||||
type: 'image'
|
||||
mimeType: 'image/jpeg'
|
||||
data: string
|
||||
}>
|
||||
driverScreenshot?: BrowserDriverLike['screenshot']
|
||||
} = {}) {
|
||||
const slots: HarnessSlot[] = []
|
||||
const byContents = new Map<BrowserWebContents, HarnessSlot>()
|
||||
const createSession = vi.fn(async (): Promise<BrowserSessionLike> => {
|
||||
await options.sessionGate
|
||||
const webContents = {} as BrowserWebContents
|
||||
const slot = {} as HarnessSlot
|
||||
const webContents = {
|
||||
getURL: () => `${slot.currentOrigin}/page`
|
||||
} as BrowserWebContents
|
||||
const session: BrowserSessionLike = {
|
||||
webContents,
|
||||
approveNavigation: vi.fn((target) => {
|
||||
slot.approvedOrigin = target.origin
|
||||
}),
|
||||
getCurrentOrigin: vi.fn(() => slot.currentOrigin),
|
||||
openInteraction: vi.fn(async () => undefined),
|
||||
...(options.captureScreenshot
|
||||
? { captureScreenshot: vi.fn(options.captureScreenshot) }
|
||||
: {}),
|
||||
dispose: vi.fn(options.dispose ?? (async () => undefined))
|
||||
}
|
||||
const driver: BrowserDriverLike = {
|
||||
@@ -67,11 +82,14 @@ function createHarness(options: {
|
||||
slot.currentOrigin = canonicalizeBrowserUrl(target.url).origin
|
||||
return { url: target.url }
|
||||
}),
|
||||
screenshot: vi.fn(async () => ({
|
||||
type: 'image' as const,
|
||||
mimeType: 'image/jpeg' as const,
|
||||
data: '/9j/2Q=='
|
||||
})),
|
||||
screenshot: vi.fn(
|
||||
options.driverScreenshot ??
|
||||
(async () => ({
|
||||
type: 'image' as const,
|
||||
mimeType: 'image/jpeg' as const,
|
||||
data: '/9j/2Q=='
|
||||
}))
|
||||
),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
Object.assign(slot, { session, driver })
|
||||
@@ -148,8 +166,8 @@ describe('BrowserService', () => {
|
||||
it('does not publish ready after a session is stopped during frame capture', async () => {
|
||||
const harness = createHarness()
|
||||
const signal = new AbortController().signal
|
||||
const states: string[] = []
|
||||
harness.service.onState((state) => states.push(state.status))
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
@@ -181,7 +199,128 @@ describe('BrowserService', () => {
|
||||
await harness.service.releaseConversation('conversation')
|
||||
|
||||
await expect(click).rejects.toThrow('浏览器会话已释放')
|
||||
expect(states.at(-1)).toBe('stopped')
|
||||
expect(states.at(-1)?.status).toBe('stopped')
|
||||
})
|
||||
|
||||
it('falls back to CDP when native capture cannot produce the live frame', async () => {
|
||||
const nativeCapture = vi.fn(async () => {
|
||||
throw new Error('native capture unavailable while hidden')
|
||||
})
|
||||
const harness = createHarness({
|
||||
captureScreenshot: nativeCapture
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(nativeCapture).toHaveBeenCalledOnce()
|
||||
expect(harness.slots[0]?.driver.screenshot).toHaveBeenCalledOnce()
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('retries live capture while a newly committed page starts painting', async () => {
|
||||
let attempts = 0
|
||||
const harness = createHarness({
|
||||
captureScreenshot: async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) {
|
||||
throw new Error('page has not painted yet')
|
||||
}
|
||||
return {
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: '/9j/2Q=='
|
||||
}
|
||||
},
|
||||
driverScreenshot: async () => {
|
||||
throw new Error('CDP frame not ready')
|
||||
}
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(attempts).toBe(2)
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('reports a live-frame failure instead of waiting indefinitely', async () => {
|
||||
const harness = createHarness({
|
||||
captureScreenshot: async () => {
|
||||
throw new Error('native capture failed')
|
||||
},
|
||||
driverScreenshot: async () => {
|
||||
throw new Error('CDP capture failed')
|
||||
}
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'failed',
|
||||
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('keeps the last frame when a later refresh cannot capture a minimized window', async () => {
|
||||
let nativeAttempts = 0
|
||||
const harness = createHarness({
|
||||
captureScreenshot: async () => {
|
||||
nativeAttempts += 1
|
||||
if (nativeAttempts === 1) {
|
||||
return {
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: '/9j/2Q=='
|
||||
}
|
||||
}
|
||||
throw new Error('minimized native capture unavailable')
|
||||
},
|
||||
driverScreenshot: async () => {
|
||||
throw new Error('minimized CDP capture unavailable')
|
||||
}
|
||||
})
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
const signal = new AbortController().signal
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://example.com/',
|
||||
signal
|
||||
)
|
||||
|
||||
await harness.service.click('conversation', 'button_ref', signal)
|
||||
|
||||
expect(states.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q=='
|
||||
})
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('isolates browser state and drivers by conversation', async () => {
|
||||
@@ -244,6 +383,58 @@ describe('BrowserService', () => {
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('pauses agent operations while the user interacts with the same session', async () => {
|
||||
const harness = createHarness()
|
||||
const signal = new AbortController().signal
|
||||
const states: BrowserLiveState[] = []
|
||||
harness.service.onState((state) => states.push(state))
|
||||
await harness.service.navigate(
|
||||
'conversation',
|
||||
'https://a.example/',
|
||||
signal
|
||||
)
|
||||
const interactionGate = deferred<
|
||||
Awaited<ReturnType<BrowserSessionLike['openInteraction']>>
|
||||
>()
|
||||
const slot = harness.slots[0]
|
||||
if (!slot) {
|
||||
throw new Error('slot missing')
|
||||
}
|
||||
vi.mocked(slot.session.openInteraction).mockReturnValueOnce(
|
||||
interactionGate.promise
|
||||
)
|
||||
|
||||
const interaction = harness.service.interact(
|
||||
'conversation',
|
||||
signal
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(slot.session.openInteraction).toHaveBeenCalledOnce()
|
||||
)
|
||||
const snapshot = harness.service.snapshot('conversation', signal)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(slot.driver.snapshot).not.toHaveBeenCalled()
|
||||
|
||||
interactionGate.resolve({
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: 'closing-frame'
|
||||
})
|
||||
await interaction
|
||||
expect(states.slice(-2).map((state) => state.status)).toEqual([
|
||||
'interactive',
|
||||
'ready'
|
||||
])
|
||||
expect(states.at(-1)?.frameDataUrl).toBe(
|
||||
'data:image/jpeg;base64,closing-frame'
|
||||
)
|
||||
expect(harness.service.getSessionCount()).toBe(1)
|
||||
expect(slot.session.dispose).not.toHaveBeenCalled()
|
||||
await snapshot
|
||||
expect(slot.driver.snapshot).toHaveBeenCalledOnce()
|
||||
await harness.service.dispose()
|
||||
})
|
||||
|
||||
it('does not let a canceled queued waiter clear the active operation owner', async () => {
|
||||
const harness = createHarness()
|
||||
const signal = new AbortController().signal
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { BrowserScreenshot } from './browser-screenshot'
|
||||
import {
|
||||
ElectronBrowserSession,
|
||||
type BrowserParentWindowHandle,
|
||||
type BrowserWebContents
|
||||
} from './electron-browser-session'
|
||||
import type { BrowserLiveState } from '../../shared/contracts'
|
||||
@@ -20,6 +21,7 @@ export type BrowserSessionLike = {
|
||||
target: Awaited<ReturnType<BrowserUrlPolicy['validate']>>
|
||||
): void
|
||||
getCurrentOrigin(): string | undefined
|
||||
openInteraction(): Promise<BrowserScreenshot | undefined>
|
||||
captureScreenshot?(signal: AbortSignal): Promise<BrowserScreenshot>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
@@ -45,6 +47,7 @@ export type BrowserServiceOptions = {
|
||||
idleTimeoutMs?: number
|
||||
cleanupTimeoutMs?: number
|
||||
liveFrameDelayMs?: number
|
||||
parentWindow?: BrowserParentWindowHandle
|
||||
createSession?: (
|
||||
policy: BrowserUrlPolicy,
|
||||
signal: AbortSignal
|
||||
@@ -112,9 +115,16 @@ async function boundedCleanup(
|
||||
|
||||
async function defaultCreateSession(
|
||||
policy: BrowserUrlPolicy,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
parentWindow?: BrowserParentWindowHandle
|
||||
): Promise<BrowserSessionLike> {
|
||||
return ElectronBrowserSession.create({ policy }, signal)
|
||||
return ElectronBrowserSession.create(
|
||||
{
|
||||
policy,
|
||||
...(parentWindow ? { parentWindow } : {})
|
||||
},
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike {
|
||||
@@ -152,7 +162,10 @@ export class BrowserService {
|
||||
this.cleanupTimeoutMs =
|
||||
options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS
|
||||
this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100
|
||||
this.createSession = options.createSession ?? defaultCreateSession
|
||||
this.createSession =
|
||||
options.createSession ??
|
||||
((policy, signal) =>
|
||||
defaultCreateSession(policy, signal, options.parentWindow))
|
||||
this.createDriver = options.createDriver ?? defaultCreateDriver
|
||||
if (
|
||||
!Number.isSafeInteger(this.maximumSessions) ||
|
||||
@@ -198,6 +211,9 @@ export class BrowserService {
|
||||
conversationId,
|
||||
status,
|
||||
...(previous?.url ? { url: previous.url } : {}),
|
||||
...(status !== 'stopped' && previous?.frameDataUrl
|
||||
? { frameDataUrl: previous.frameDataUrl }
|
||||
: {}),
|
||||
...update,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
@@ -235,40 +251,73 @@ export class BrowserService {
|
||||
signal
|
||||
)
|
||||
}
|
||||
const previewController = new AbortController()
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
previewController.abort(
|
||||
new Error('浏览器实时画面捕获超时')
|
||||
),
|
||||
2_000
|
||||
)
|
||||
try {
|
||||
const previewSignal = AbortSignal.any([
|
||||
signal,
|
||||
previewController.signal
|
||||
])
|
||||
frame = slot.session.captureScreenshot
|
||||
? await slot.session.captureScreenshot(previewSignal)
|
||||
: await slot.driver.screenshot(previewSignal)
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
// Browser control succeeds even when the optional live frame fails.
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
const captureDeadline = AbortSignal.any([
|
||||
signal,
|
||||
AbortSignal.timeout(6_000)
|
||||
])
|
||||
for (let attempt = 0; attempt < 3 && !frame; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
try {
|
||||
await waitFor(
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, attempt * 150)
|
||||
),
|
||||
captureDeadline
|
||||
)
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
break
|
||||
}
|
||||
}
|
||||
if (slot.session.captureScreenshot) {
|
||||
try {
|
||||
frame = await slot.session.captureScreenshot(
|
||||
AbortSignal.any([
|
||||
captureDeadline,
|
||||
AbortSignal.timeout(1_500)
|
||||
])
|
||||
)
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
if (!frame && !captureDeadline.aborted) {
|
||||
try {
|
||||
frame = await slot.driver.screenshot(
|
||||
AbortSignal.any([
|
||||
captureDeadline,
|
||||
AbortSignal.timeout(1_500)
|
||||
])
|
||||
)
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (slot.released || this.slots.get(conversationId) !== slot) {
|
||||
return
|
||||
}
|
||||
if (!frame) {
|
||||
const previousFrame =
|
||||
this.liveStates.get(conversationId)?.frameDataUrl
|
||||
if (previousFrame) {
|
||||
this.emitState(conversationId, 'ready', {
|
||||
...(url ? { url } : {}),
|
||||
frameDataUrl: previousFrame
|
||||
})
|
||||
return
|
||||
}
|
||||
this.emitState(conversationId, 'failed', {
|
||||
...(url ? { url } : {}),
|
||||
error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作'
|
||||
})
|
||||
return
|
||||
}
|
||||
this.emitState(conversationId, 'ready', {
|
||||
...(url ? { url } : {}),
|
||||
...(frame
|
||||
? {
|
||||
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
|
||||
}
|
||||
: {})
|
||||
frameDataUrl: `data:${frame.mimeType};base64,${frame.data}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -429,7 +478,7 @@ export class BrowserService {
|
||||
slot: BrowserSlot,
|
||||
signal: AbortSignal,
|
||||
operation: (effectiveSignal: AbortSignal) => Promise<T>,
|
||||
status?: 'loading' | 'acting'
|
||||
status?: 'loading' | 'acting' | 'interactive'
|
||||
): Promise<T> {
|
||||
signal.throwIfAborted()
|
||||
if (slot.released || this.disposed) {
|
||||
@@ -496,7 +545,7 @@ export class BrowserService {
|
||||
private async runInSession<T>(
|
||||
conversationId: string,
|
||||
signal: AbortSignal,
|
||||
status: 'loading' | 'acting',
|
||||
status: 'loading' | 'acting' | 'interactive',
|
||||
failureStage: string,
|
||||
operation: (
|
||||
slot: BrowserSlot,
|
||||
@@ -715,10 +764,17 @@ export class BrowserService {
|
||||
'浏览器截图',
|
||||
async (slot, effectiveSignal) => {
|
||||
await this.verifyCurrentOriginOrRelease(slot)
|
||||
const screenshot =
|
||||
slot.session.captureScreenshot
|
||||
? await slot.session.captureScreenshot(effectiveSignal)
|
||||
: await slot.driver.screenshot(effectiveSignal)
|
||||
let screenshot: BrowserScreenshot | undefined
|
||||
if (slot.session.captureScreenshot) {
|
||||
try {
|
||||
screenshot = await slot.session.captureScreenshot(
|
||||
effectiveSignal
|
||||
)
|
||||
} catch {
|
||||
effectiveSignal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
screenshot ??= await slot.driver.screenshot(effectiveSignal)
|
||||
await this.captureFrame(
|
||||
conversationId,
|
||||
slot,
|
||||
@@ -731,6 +787,36 @@ export class BrowserService {
|
||||
)
|
||||
}
|
||||
|
||||
async interact(
|
||||
conversationId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
await this.runInSession(
|
||||
conversationId,
|
||||
signal,
|
||||
'interactive',
|
||||
'浏览器交互',
|
||||
async (slot, effectiveSignal) => {
|
||||
await this.verifyCurrentOriginOrRelease(slot)
|
||||
const closingFrame = await waitFor(
|
||||
slot.session.openInteraction(),
|
||||
effectiveSignal
|
||||
)
|
||||
const currentUrl = canonicalizeBrowserUrl(
|
||||
slot.session.webContents.getURL()
|
||||
)
|
||||
slot.origin = currentUrl.origin
|
||||
await this.captureFrame(
|
||||
conversationId,
|
||||
slot,
|
||||
effectiveSignal,
|
||||
currentUrl.href,
|
||||
closingFrame
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async releaseConversation(conversationId: string): Promise<void> {
|
||||
this.releaseRequests.add(conversationId)
|
||||
let releasedSlot = false
|
||||
|
||||
@@ -536,7 +536,7 @@ describe('CdpBrowserDriver', () => {
|
||||
driver.dispose()
|
||||
})
|
||||
|
||||
it('rejects password, file, hidden, and stale typing targets', async () => {
|
||||
it('allows password typing while keeping the password value redacted', async () => {
|
||||
const harness = createHarness(standardCommand)
|
||||
const driver = new CdpBrowserDriver(harness.webContents)
|
||||
const snapshot = await driver.snapshot(new AbortController().signal)
|
||||
@@ -545,15 +545,16 @@ describe('CdpBrowserDriver', () => {
|
||||
throw new Error('password missing')
|
||||
}
|
||||
await expect(
|
||||
driver.type(password.ref, 'never-send', new AbortController().signal)
|
||||
).rejects.toThrow('受保护')
|
||||
driver.type(password.ref, 'login-secret', new AbortController().signal)
|
||||
).resolves.toBeUndefined()
|
||||
expect(
|
||||
harness.sendCommand.mock.calls.some(
|
||||
([method, parameters]) =>
|
||||
method === 'Input.insertText' &&
|
||||
parameters?.text === 'never-send'
|
||||
parameters?.text === 'login-secret'
|
||||
)
|
||||
).toBe(false)
|
||||
).toBe(true)
|
||||
expect(JSON.stringify(snapshot)).not.toContain('secret')
|
||||
driver.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ type RefBinding = {
|
||||
backendNodeId: number
|
||||
generation: number
|
||||
role: string
|
||||
protected: boolean
|
||||
}
|
||||
|
||||
export type CdpBrowserDriverOptions = {
|
||||
@@ -591,8 +590,7 @@ export class CdpBrowserDriver {
|
||||
this.refs.set(ref, {
|
||||
backendNodeId: node.backendDOMNodeId,
|
||||
generation: this.generation,
|
||||
role,
|
||||
protected: protectedNode
|
||||
role
|
||||
})
|
||||
output.push(item)
|
||||
}
|
||||
@@ -648,7 +646,6 @@ export class CdpBrowserDriver {
|
||||
typeof node.nodeName === 'string' ? node.nodeName.toLowerCase() : ''
|
||||
const inputType = (attributeMap.get('type') ?? '').toLowerCase()
|
||||
const blocked =
|
||||
binding.protected ||
|
||||
attributeMap.has('hidden') ||
|
||||
attributeMap.has('disabled') ||
|
||||
attributeMap.has('inert') ||
|
||||
@@ -656,10 +653,9 @@ export class CdpBrowserDriver {
|
||||
attributeMap.get('aria-hidden') === 'true' ||
|
||||
attributeMap.get('aria-disabled') === 'true' ||
|
||||
inputType === 'hidden' ||
|
||||
inputType === 'password' ||
|
||||
inputType === 'file'
|
||||
if (blocked) {
|
||||
throw new Error('浏览器拒绝操作受保护、隐藏或禁用字段')
|
||||
throw new Error('浏览器拒绝操作隐藏、禁用、只读或文件字段')
|
||||
}
|
||||
if (
|
||||
action === 'type' &&
|
||||
|
||||
@@ -21,6 +21,7 @@ function createHarness() {
|
||||
const debuggerEvents = new EventEmitter()
|
||||
const contentEvents = new EventEmitter()
|
||||
const partitionEvents = new EventEmitter()
|
||||
const windowEvents = new EventEmitter()
|
||||
let currentUrl = ''
|
||||
let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined
|
||||
const sendCommand = vi.fn(async () => ({}))
|
||||
@@ -71,6 +72,21 @@ function createHarness() {
|
||||
loadURL: vi.fn(async (url: string) => {
|
||||
currentUrl = url
|
||||
}),
|
||||
show: vi.fn(),
|
||||
minimize: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
isMinimized: vi.fn(() => false),
|
||||
focus: vi.fn(),
|
||||
on: (event, listener) =>
|
||||
windowEvents.on(
|
||||
event,
|
||||
listener as (...argumentsValue: unknown[]) => void
|
||||
),
|
||||
off: (event, listener) =>
|
||||
windowEvents.off(
|
||||
event,
|
||||
listener as (...argumentsValue: unknown[]) => void
|
||||
),
|
||||
destroy: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
@@ -125,6 +141,7 @@ function createHarness() {
|
||||
contentEvents,
|
||||
debuggerEvents,
|
||||
partitionEvents,
|
||||
windowEvents,
|
||||
partition,
|
||||
proxy,
|
||||
policy,
|
||||
@@ -276,6 +293,74 @@ describe('ElectronBrowserSession', () => {
|
||||
await session.dispose()
|
||||
})
|
||||
|
||||
it('restores the browser for interaction and minimizes it on close', async () => {
|
||||
const harness = createHarness()
|
||||
const parentWindow = {
|
||||
setEnabled: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
let createdWindowOptions: Record<string, unknown> | undefined
|
||||
const createWindow = vi.fn(
|
||||
async (options: Record<string, unknown>) => {
|
||||
createdWindowOptions = options
|
||||
return harness.window
|
||||
}
|
||||
)
|
||||
const session = await ElectronBrowserSession.create({
|
||||
policy: harness.policy,
|
||||
parentWindow,
|
||||
createPartition: async () => harness.partition,
|
||||
createWindow,
|
||||
createProxy: () => harness.proxy
|
||||
})
|
||||
|
||||
expect(createWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: parentWindow,
|
||||
show: false,
|
||||
title: 'GoodBuddy 浏览器交互'
|
||||
})
|
||||
)
|
||||
expect(createdWindowOptions?.modal).toBeUndefined()
|
||||
const interaction = session.openInteraction()
|
||||
expect(parentWindow.setEnabled).toHaveBeenCalledWith(false)
|
||||
expect(harness.window.show).toHaveBeenCalledOnce()
|
||||
expect(harness.window.focus).toHaveBeenCalledOnce()
|
||||
const closeEvent = { preventDefault: vi.fn() }
|
||||
harness.windowEvents.emit('close', closeEvent)
|
||||
|
||||
await expect(interaction).resolves.toEqual({
|
||||
type: 'image',
|
||||
mimeType: 'image/jpeg',
|
||||
data: '/9j/2Q=='
|
||||
})
|
||||
expect(closeEvent.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(harness.webContents.capturePage).toHaveBeenCalledOnce()
|
||||
expect(harness.window.minimize).toHaveBeenCalledOnce()
|
||||
expect(parentWindow.setEnabled).toHaveBeenLastCalledWith(true)
|
||||
expect(parentWindow.focus).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
vi.mocked(harness.webContents.capturePage!).mock
|
||||
.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
vi.mocked(harness.window.minimize).mock.invocationCallOrder[0] ??
|
||||
Number.POSITIVE_INFINITY
|
||||
)
|
||||
const repeatedCloseEvent = { preventDefault: vi.fn() }
|
||||
harness.windowEvents.emit('close', repeatedCloseEvent)
|
||||
expect(repeatedCloseEvent.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(harness.window.minimize).toHaveBeenCalledTimes(2)
|
||||
expect(harness.window.destroy).not.toHaveBeenCalled()
|
||||
vi.mocked(harness.window.isMinimized).mockReturnValue(true)
|
||||
const reopenedInteraction = session.openInteraction()
|
||||
expect(harness.window.restore).toHaveBeenCalledOnce()
|
||||
harness.windowEvents.emit('close', { preventDefault: vi.fn() })
|
||||
await reopenedInteraction
|
||||
await session.dispose()
|
||||
expect(harness.window.destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('detaches listeners and clears isolated data on idempotent disposal', async () => {
|
||||
const harness = createHarness()
|
||||
const session = await ElectronBrowserSession.create({
|
||||
|
||||
@@ -49,10 +49,23 @@ export type BrowserWebContents = {
|
||||
export type BrowserWindowHandle = {
|
||||
webContents: BrowserWebContents
|
||||
loadURL(url: string): Promise<unknown>
|
||||
show(): void
|
||||
minimize(): void
|
||||
restore(): void
|
||||
isMinimized(): boolean
|
||||
focus(): void
|
||||
on(event: string, listener: BrowserEventListener): unknown
|
||||
off(event: string, listener: BrowserEventListener): unknown
|
||||
destroy(): void
|
||||
isDestroyed(): boolean
|
||||
}
|
||||
|
||||
export type BrowserParentWindowHandle = {
|
||||
setEnabled?(enabled: boolean): void
|
||||
focus?(): void
|
||||
isDestroyed?(): boolean
|
||||
}
|
||||
|
||||
export type BrowserPartitionSession = {
|
||||
setPermissionCheckHandler(
|
||||
handler: (...argumentsValue: never[]) => boolean
|
||||
@@ -100,6 +113,7 @@ export type ElectronBrowserSessionOptions = {
|
||||
options: Record<string, unknown>
|
||||
) => Promise<BrowserWindowHandle>
|
||||
createProxy?: (policy: BrowserUrlPolicy) => FilteringProxyLike
|
||||
parentWindow?: BrowserParentWindowHandle
|
||||
}
|
||||
|
||||
type Listener = {
|
||||
@@ -211,6 +225,11 @@ export class ElectronBrowserSession {
|
||||
readonly webContents: BrowserWebContents
|
||||
private approvedOrigin?: string
|
||||
private readonly listeners: Listener[] = []
|
||||
private interaction?: {
|
||||
promise: Promise<BrowserScreenshot | undefined>
|
||||
resolve(frame?: BrowserScreenshot): void
|
||||
}
|
||||
private interactionClosing?: Promise<void>
|
||||
private disposed = false
|
||||
|
||||
private constructor(
|
||||
@@ -219,7 +238,8 @@ export class ElectronBrowserSession {
|
||||
private readonly window: BrowserWindowHandle,
|
||||
private readonly proxy: FilteringProxyLike,
|
||||
partition: string,
|
||||
private readonly cleanupTimeoutMs: number
|
||||
private readonly cleanupTimeoutMs: number,
|
||||
private readonly parentWindow?: BrowserParentWindowHandle
|
||||
) {
|
||||
this.partition = partition
|
||||
this.webContents = window.webContents
|
||||
@@ -301,6 +321,13 @@ export class ElectronBrowserSession {
|
||||
show: false,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
title: 'GoodBuddy 浏览器交互',
|
||||
autoHideMenuBar: true,
|
||||
...(options.parentWindow
|
||||
? {
|
||||
parent: options.parentWindow
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
partition,
|
||||
sandbox: true,
|
||||
@@ -308,6 +335,7 @@ export class ElectronBrowserSession {
|
||||
nodeIntegration: false,
|
||||
nodeIntegrationInSubFrames: false,
|
||||
nodeIntegrationInWorker: false,
|
||||
backgroundThrottling: false,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
plugins: false,
|
||||
@@ -337,7 +365,8 @@ export class ElectronBrowserSession {
|
||||
window,
|
||||
managedProxy,
|
||||
partition,
|
||||
cleanupTimeoutMs
|
||||
cleanupTimeoutMs,
|
||||
options.parentWindow
|
||||
)
|
||||
setupStage = '初始化浏览器协议'
|
||||
await boundedSetup(result.initialize(), signal, setupTimeoutMs)
|
||||
@@ -383,6 +412,21 @@ export class ElectronBrowserSession {
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
const contents = this.webContents
|
||||
this.listen(
|
||||
this.window,
|
||||
'close',
|
||||
(event: { preventDefault(): void }) => {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (this.interaction) {
|
||||
void this.captureAndFinishInteraction()
|
||||
} else {
|
||||
this.window.minimize()
|
||||
}
|
||||
}
|
||||
)
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
this.listen(contents, 'will-navigate', (event: { preventDefault(): void }, details: { url?: string } | string) => {
|
||||
const url = typeof details === 'string' ? details : details.url
|
||||
@@ -513,12 +557,98 @@ export class ElectronBrowserSession {
|
||||
this.approvedOrigin = target.origin
|
||||
}
|
||||
|
||||
openInteraction(): Promise<BrowserScreenshot | undefined> {
|
||||
this.assertOpen()
|
||||
if (this.interaction) {
|
||||
this.setParentEnabled(false)
|
||||
if (this.window.isMinimized()) {
|
||||
this.window.restore()
|
||||
}
|
||||
this.window.show()
|
||||
this.window.focus()
|
||||
return this.interaction.promise
|
||||
}
|
||||
let resolve!: (frame?: BrowserScreenshot) => void
|
||||
const promise = new Promise<BrowserScreenshot | undefined>(
|
||||
(resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
}
|
||||
)
|
||||
this.interaction = { promise, resolve }
|
||||
this.setParentEnabled(false)
|
||||
try {
|
||||
if (this.window.isMinimized()) {
|
||||
this.window.restore()
|
||||
}
|
||||
this.window.show()
|
||||
this.window.focus()
|
||||
} catch (error) {
|
||||
this.finishInteraction()
|
||||
throw error
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
private captureAndFinishInteraction(): Promise<void> {
|
||||
if (this.interactionClosing) {
|
||||
return this.interactionClosing
|
||||
}
|
||||
const operation = (async (): Promise<void> => {
|
||||
let frame: BrowserScreenshot | undefined
|
||||
try {
|
||||
frame = await this.captureScreenshot(AbortSignal.timeout(2_000))
|
||||
} catch {
|
||||
// The session remains usable even if the final visible frame fails.
|
||||
}
|
||||
try {
|
||||
if (!this.disposed && !this.window.isDestroyed()) {
|
||||
this.window.minimize()
|
||||
}
|
||||
} catch {
|
||||
// Resolving interaction must not depend on native minimize success.
|
||||
}
|
||||
this.finishInteraction(frame)
|
||||
})()
|
||||
this.interactionClosing = operation
|
||||
void operation.finally(() => {
|
||||
if (this.interactionClosing === operation) {
|
||||
this.interactionClosing = undefined
|
||||
}
|
||||
})
|
||||
return operation
|
||||
}
|
||||
|
||||
private finishInteraction(frame?: BrowserScreenshot): void {
|
||||
const interaction = this.interaction
|
||||
this.interaction = undefined
|
||||
this.setParentEnabled(true)
|
||||
interaction?.resolve(frame)
|
||||
}
|
||||
|
||||
private setParentEnabled(enabled: boolean): void {
|
||||
try {
|
||||
if (
|
||||
!this.parentWindow ||
|
||||
this.parentWindow.isDestroyed?.() === true
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.parentWindow.setEnabled?.(enabled)
|
||||
if (enabled) {
|
||||
this.parentWindow.focus?.()
|
||||
}
|
||||
} catch {
|
||||
// Parent-window state must not break browser-session cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
this.approvedOrigin = undefined
|
||||
this.finishInteraction()
|
||||
for (const { target, event, listener } of this.listeners.splice(0)) {
|
||||
target.off(event, listener)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CapabilityService,
|
||||
type CapabilityCipher
|
||||
} from './capability-service'
|
||||
import {
|
||||
BrowserProfileService,
|
||||
MemoryBrowserProfileStore
|
||||
} from './browser-profile-service'
|
||||
|
||||
const builtinSkillsRoot = join(
|
||||
process.cwd(),
|
||||
'resources',
|
||||
'skills'
|
||||
)
|
||||
|
||||
const cipher: CapabilityCipher = {
|
||||
isAvailable: () => true,
|
||||
encrypt: (value) => Buffer.from(`encrypted:${value}`),
|
||||
decrypt: (value) => value.toString().replace(/^encrypted:/u, '')
|
||||
}
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function createService(): Promise<CapabilityService> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-builtin-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return new CapabilityService(
|
||||
join(directory, 'capabilities.json'),
|
||||
builtinSkillsRoot,
|
||||
join(directory, 'imported'),
|
||||
cipher,
|
||||
{
|
||||
platform: 'win32',
|
||||
architecture: 'x64',
|
||||
electronTarget: true,
|
||||
browserProfiles: new BrowserProfileService(
|
||||
new MemoryBrowserProfileStore()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('bundled skills', () => {
|
||||
it('parses every bundled SKILL.md', async () => {
|
||||
const snapshot = await (await createService()).getSnapshot()
|
||||
|
||||
expect(snapshot.skills.length).toBeGreaterThan(0)
|
||||
expect(snapshot.skills.every((skill) => skill.source === 'builtin')).toBe(
|
||||
true
|
||||
)
|
||||
expect(snapshot.skills.map((skill) => skill.id)).toContain(
|
||||
'product-marketing'
|
||||
)
|
||||
})
|
||||
|
||||
it('injects every enabled bundled skill with its resolved directory', async () => {
|
||||
const service = await createService()
|
||||
const snapshot = await service.getSnapshot()
|
||||
|
||||
const instructions = await service.getSkillInstructions('continue')
|
||||
|
||||
expect(instructions).not.toContain('因超出注入上限未加载')
|
||||
for (const skill of snapshot.skills) {
|
||||
expect(instructions).toContain(join(builtinSkillsRoot, skill.id))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -266,6 +266,82 @@ describe('CapabilityService', () => {
|
||||
).rejects.toThrow('只能删除已导入')
|
||||
})
|
||||
|
||||
it('imports a standard SKILL.md that identifies itself by name', async () => {
|
||||
const { directory, service } = await createService()
|
||||
const source = join(directory, 'standard-source', 'summarize-diff')
|
||||
await mkdir(source, { recursive: true })
|
||||
await writeFile(
|
||||
join(source, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
'name: summarize-diff',
|
||||
'description: |',
|
||||
' 概括暂存的改动。',
|
||||
' 当用户需要待提交变更摘要时使用。',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Grep',
|
||||
'compatibility: droid',
|
||||
'---',
|
||||
'',
|
||||
'# Summarize Diff',
|
||||
'',
|
||||
'仅用于离线测试。'
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const imported = await service.importSkill(source)
|
||||
|
||||
expect(imported.skills).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'summarize-diff',
|
||||
source: 'imported',
|
||||
description: '概括暂存的改动。 当用户需要待提交变更摘要时使用。'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('imports every Skill found under a suite directory', async () => {
|
||||
const { directory, service } = await createService()
|
||||
const suite = join(directory, 'suite', 'skills')
|
||||
await writeSkill(suite, 'alpha-skill', 'Alpha')
|
||||
await writeSkill(suite, 'beta-skill', 'Beta')
|
||||
|
||||
const imported = await service.importSkill(join(directory, 'suite'))
|
||||
|
||||
expect(imported.skills.map((skill) => skill.id)).toEqual(
|
||||
expect.arrayContaining(['alpha-skill', 'beta-skill'])
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a readable error when the selected directory has no SKILL.md', async () => {
|
||||
const { directory, service } = await createService()
|
||||
const empty = join(directory, 'empty-directory')
|
||||
await mkdir(empty, { recursive: true })
|
||||
|
||||
await expect(service.importSkill(empty)).rejects.toThrow(
|
||||
'没有找到 SKILL.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes the skill directory and names skills dropped by the budget', async () => {
|
||||
const { builtinRoot, service } = await createService()
|
||||
await writeSkill(builtinRoot, 'oversized-skill', '超长技能')
|
||||
|
||||
const instructions = await service.getSkillInstructions('model')
|
||||
expect(instructions).toContain(join(builtinRoot, 'document-writing'))
|
||||
expect(instructions).toContain(join(builtinRoot, 'oversized-skill'))
|
||||
|
||||
const truncated = await service.getSkillInstructions('model', 200)
|
||||
expect(truncated).toContain('因超出注入上限未加载')
|
||||
|
||||
const fullyTruncated = await service.getSkillInstructions('model', 1)
|
||||
expect(fullyTruncated).toContain('因超出注入上限未加载')
|
||||
expect(fullyTruncated).toContain('文档写作')
|
||||
expect(fullyTruncated).toContain('超长技能')
|
||||
})
|
||||
|
||||
it('imports a managed Skill from a ZIP package', async () => {
|
||||
const { directory, importedRoot, service } = await createService()
|
||||
const packageRoot = join(directory, 'zip-source')
|
||||
|
||||
@@ -56,16 +56,31 @@ const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024
|
||||
const MAX_SKILL_PACKAGE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_SKILL_PACKAGE_FILES = 128
|
||||
const MAX_SKILL_DEPTH = 6
|
||||
const MAX_SKILL_DISCOVERY_DEPTH = 4
|
||||
const MAX_SKILL_DISCOVERY_RESULTS = 64
|
||||
const MAX_SKILL_INSTRUCTION_CHARACTERS = 262_144
|
||||
const SKILL_DISCOVERY_IGNORED_DIRECTORIES = new Set([
|
||||
'node_modules',
|
||||
'__pycache__',
|
||||
'__MACOSX'
|
||||
])
|
||||
|
||||
const skillMetadataSchema = z
|
||||
.object({
|
||||
id: skillIdSchema,
|
||||
name: z.string().trim().min(1).max(80),
|
||||
description: z.string().trim().min(1).max(500),
|
||||
version: z.string().trim().min(1).max(32).optional(),
|
||||
tags: z.array(z.string().trim().min(1).max(32)).max(12).default([])
|
||||
})
|
||||
.strict()
|
||||
// Block scalars in SKILL.md frontmatter carry newlines that would break the
|
||||
// single-line summary surfaces the renderer and runtimes rely on.
|
||||
function collapsedText(maximum: number): z.ZodType<string> {
|
||||
return z
|
||||
.string()
|
||||
.transform((value) => value.replace(/\s+/gu, ' ').trim())
|
||||
.pipe(z.string().min(1).max(maximum))
|
||||
}
|
||||
|
||||
const skillMetadataSchema = z.object({
|
||||
id: skillIdSchema.optional(),
|
||||
name: collapsedText(80),
|
||||
description: collapsedText(500),
|
||||
version: collapsedText(32).optional(),
|
||||
tags: z.array(collapsedText(32)).max(12).default([])
|
||||
})
|
||||
|
||||
const skillStateSchema = z
|
||||
.object({
|
||||
@@ -221,13 +236,22 @@ async function readSkill(
|
||||
throw new Error(`${basename(directoryPath)} 的 SKILL.md 格式无效`)
|
||||
}
|
||||
const metadata = skillMetadataSchema.parse(parseYaml(match[1]))
|
||||
if (expectedId !== null && metadata.id !== expectedId) {
|
||||
throw new Error(`Skill ID 必须与目录名一致:${metadata.id}`)
|
||||
// Standard SKILL.md files identify the skill by `name`; GoodBuddy packages
|
||||
// add an explicit `id` alongside a human-readable `name`.
|
||||
const identifier = skillIdSchema.safeParse(metadata.id ?? metadata.name)
|
||||
if (!identifier.success) {
|
||||
throw new Error(
|
||||
`${basename(directoryPath)} 的 SKILL.md 缺少可用的 Skill ID,请提供小写连字符格式的 id 或 name`
|
||||
)
|
||||
}
|
||||
if (expectedId !== null && identifier.data !== expectedId) {
|
||||
throw new Error(`Skill ID 必须与目录名一致:${identifier.data}`)
|
||||
}
|
||||
return skillSummarySchema
|
||||
.omit({ enabled: true, assignments: true })
|
||||
.parse({
|
||||
...metadata,
|
||||
id: identifier.data,
|
||||
source,
|
||||
digest: createHash('sha256').update(content).digest('hex')
|
||||
})
|
||||
@@ -261,6 +285,55 @@ async function listSkills(
|
||||
)
|
||||
}
|
||||
|
||||
async function pathExists(candidate: string): Promise<boolean> {
|
||||
return stat(candidate)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
// Mirrors the conventional layout where the first directory containing
|
||||
// SKILL.md is the skill root, so users can pick a suite directory that holds
|
||||
// many skills instead of one package at a time.
|
||||
async function discoverSkillDirectories(root: string): Promise<string[]> {
|
||||
if (await pathExists(join(root, 'SKILL.md'))) {
|
||||
return [root]
|
||||
}
|
||||
const found: string[] = []
|
||||
const walk = async (current: string, depth: number): Promise<void> => {
|
||||
if (depth > MAX_SKILL_DISCOVERY_DEPTH || found.length > MAX_SKILL_DISCOVERY_RESULTS) {
|
||||
return
|
||||
}
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
!entry.isDirectory() ||
|
||||
entry.name.startsWith('.') ||
|
||||
SKILL_DISCOVERY_IGNORED_DIRECTORIES.has(entry.name)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const child = join(current, entry.name)
|
||||
if (await pathExists(join(child, 'SKILL.md'))) {
|
||||
found.push(child)
|
||||
continue
|
||||
}
|
||||
await walk(child, depth + 1)
|
||||
}
|
||||
}
|
||||
await walk(root, 0)
|
||||
if (found.length > MAX_SKILL_DISCOVERY_RESULTS) {
|
||||
throw new Error(
|
||||
`所选目录包含的 Skill 超过 ${MAX_SKILL_DISCOVERY_RESULTS} 个,请选择更精确的目录`
|
||||
)
|
||||
}
|
||||
return found.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
async function copySkillPackage(
|
||||
sourceRoot: string,
|
||||
targetRoot: string
|
||||
@@ -943,6 +1016,46 @@ export class CapabilityService {
|
||||
})
|
||||
}
|
||||
|
||||
private async importSkillDirectory(
|
||||
sourceDirectory: string,
|
||||
expectedId: string | null | undefined
|
||||
): Promise<string> {
|
||||
const temporaryPath = join(
|
||||
this.importedSkillsRoot,
|
||||
`.import-${randomUUID()}`
|
||||
)
|
||||
try {
|
||||
const skill = await readSkill(
|
||||
sourceDirectory,
|
||||
'imported',
|
||||
expectedId
|
||||
)
|
||||
const builtins = await listSkills(this.builtinSkillsRoot, 'builtin')
|
||||
if (builtins.some((item) => item.id === skill.id)) {
|
||||
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
|
||||
}
|
||||
const targetPath = join(this.importedSkillsRoot, skill.id)
|
||||
if (await pathExists(targetPath)) {
|
||||
throw new Error('同名 Skill 已导入,请先删除后重试')
|
||||
}
|
||||
await copySkillPackage(sourceDirectory, temporaryPath)
|
||||
await readSkill(temporaryPath, 'imported', skill.id)
|
||||
await rename(temporaryPath, targetPath)
|
||||
const state = await this.load()
|
||||
await this.persist({
|
||||
...state,
|
||||
skills: {
|
||||
...state.skills,
|
||||
[skill.id]: defaultSkillState()
|
||||
}
|
||||
})
|
||||
return skill.id
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
importSkill(sourcePath: string): Promise<CapabilitySnapshot> {
|
||||
return this.queue(async () => {
|
||||
const canonicalSource = await realpath(sourcePath)
|
||||
@@ -955,52 +1068,61 @@ export class CapabilityService {
|
||||
throw new Error('所选 Skill 路径必须是目录或 .zip 文件')
|
||||
}
|
||||
await mkdir(this.importedSkillsRoot, { recursive: true })
|
||||
const temporaryPath = join(
|
||||
this.importedSkillsRoot,
|
||||
`.import-${randomUUID()}`
|
||||
)
|
||||
try {
|
||||
const archiveDirectoryName = isZip
|
||||
? await extractSkillZip(canonicalSource, temporaryPath)
|
||||
: undefined
|
||||
const skill = await readSkill(
|
||||
isDirectory ? canonicalSource : temporaryPath,
|
||||
'imported',
|
||||
isDirectory ? undefined : (archiveDirectoryName ?? null)
|
||||
|
||||
if (isZip) {
|
||||
const extractPath = join(
|
||||
this.importedSkillsRoot,
|
||||
`.extract-${randomUUID()}`
|
||||
)
|
||||
const builtins = await listSkills(
|
||||
this.builtinSkillsRoot,
|
||||
'builtin'
|
||||
)
|
||||
if (builtins.some((item) => item.id === skill.id)) {
|
||||
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
|
||||
try {
|
||||
const archiveDirectoryName = await extractSkillZip(
|
||||
canonicalSource,
|
||||
extractPath
|
||||
)
|
||||
await this.importSkillDirectory(
|
||||
extractPath,
|
||||
archiveDirectoryName ?? null
|
||||
)
|
||||
} finally {
|
||||
await rm(extractPath, { recursive: true, force: true })
|
||||
}
|
||||
const targetPath = join(this.importedSkillsRoot, skill.id)
|
||||
if (
|
||||
await stat(targetPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
) {
|
||||
throw new Error('同名 Skill 已导入,请先删除后重试')
|
||||
}
|
||||
if (isDirectory) {
|
||||
await copySkillPackage(canonicalSource, temporaryPath)
|
||||
}
|
||||
await readSkill(temporaryPath, 'imported', skill.id)
|
||||
await rename(temporaryPath, targetPath)
|
||||
const state = await this.load()
|
||||
await this.persist({
|
||||
...state,
|
||||
skills: {
|
||||
...state.skills,
|
||||
[skill.id]: defaultSkillState()
|
||||
}
|
||||
})
|
||||
return this.getSnapshot()
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
|
||||
const directories = await discoverSkillDirectories(canonicalSource)
|
||||
if (directories.length === 0) {
|
||||
throw new Error(
|
||||
'所选目录及其子目录中没有找到 SKILL.md,请选择 Skill 目录或包含多个 Skill 的目录'
|
||||
)
|
||||
}
|
||||
const failures: string[] = []
|
||||
let importedCount = 0
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
// A suite directory may nest skills below its own name, so the
|
||||
// directory name is only authoritative for a single-skill import.
|
||||
await this.importSkillDirectory(
|
||||
directory,
|
||||
directories.length === 1 ? undefined : null
|
||||
)
|
||||
importedCount += 1
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`${basename(directory)}:${
|
||||
error instanceof Error ? error.message : '导入失败'
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (importedCount === 0) {
|
||||
throw new Error(`Skill 导入失败。${failures.join(';')}`)
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`已导入 ${importedCount} 个 Skill,${failures.length} 个失败。${failures.join(';')}`
|
||||
)
|
||||
}
|
||||
return this.getSnapshot()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1202,10 +1324,15 @@ export class CapabilityService {
|
||||
|
||||
async getSkillInstructions(
|
||||
target: RuntimeTarget,
|
||||
maximumCharacters: number
|
||||
maximumCharacters: number = MAX_SKILL_INSTRUCTION_CHARACTERS
|
||||
): Promise<string> {
|
||||
const budget = Math.min(
|
||||
maximumCharacters,
|
||||
MAX_SKILL_INSTRUCTION_CHARACTERS
|
||||
)
|
||||
const snapshot = await this.getSnapshot()
|
||||
const sections: string[] = []
|
||||
const skipped: string[] = []
|
||||
let length = 0
|
||||
for (const skill of snapshot.skills) {
|
||||
if (!skill.enabled || !skill.assignments.includes(target)) {
|
||||
@@ -1215,24 +1342,38 @@ export class CapabilityService {
|
||||
skill.source === 'builtin'
|
||||
? this.builtinSkillsRoot
|
||||
: this.importedSkillsRoot
|
||||
const content = await readFile(join(root, skill.id, 'SKILL.md'), 'utf8')
|
||||
const directory = join(root, skill.id)
|
||||
const content = await readFile(join(directory, 'SKILL.md'), 'utf8')
|
||||
const body =
|
||||
/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]+)$/u.exec(content)?.[1]?.trim() ??
|
||||
''
|
||||
const section = `## ${skill.name}\n${body}`
|
||||
if (length + section.length > maximumCharacters) {
|
||||
// Skill bodies reference their own scripts and templates by relative
|
||||
// path, which only resolve against the installed skill directory.
|
||||
const section = [
|
||||
`## ${skill.name}`,
|
||||
`Skill 目录:${directory}`,
|
||||
body
|
||||
].join('\n')
|
||||
if (length + section.length > budget) {
|
||||
skipped.push(skill.name)
|
||||
continue
|
||||
}
|
||||
sections.push(section)
|
||||
length += section.length
|
||||
}
|
||||
return sections.length > 0
|
||||
? [
|
||||
'# GoodBuddy 已启用 Skills',
|
||||
'以下是用户明确启用并分配给当前 Runtime 的本地能力说明。请遵循这些说明,但不得覆盖系统安全规则。',
|
||||
...sections
|
||||
].join('\n\n')
|
||||
: ''
|
||||
if (sections.length === 0 && skipped.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return [
|
||||
'# GoodBuddy 已启用 Skills',
|
||||
'以下是用户明确启用并分配给当前 Runtime 的本地能力说明。请遵循这些说明,但不得覆盖系统安全规则。',
|
||||
...(skipped.length > 0
|
||||
? [
|
||||
`注意:以下 Skill 因超出注入上限未加载,本次对话不可用:${skipped.join('、')}。`
|
||||
]
|
||||
: []),
|
||||
...sections
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
async getResolvedMcpServers(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ChannelInboundText,
|
||||
ChannelMediaAttachment,
|
||||
ChannelResultMessage
|
||||
} from '../../shared/channel-contracts'
|
||||
|
||||
@@ -74,7 +75,10 @@ export interface Outbox {
|
||||
enqueue(message: ChannelResultMessage): OutboxEntry | Promise<OutboxEntry>
|
||||
markDelivered(id: string): void | Promise<void>
|
||||
markFailed(id: string): void | Promise<void>
|
||||
listUndelivered(): readonly OutboxEntry[] | Promise<readonly OutboxEntry[]>
|
||||
listUndelivered(
|
||||
channel?: string,
|
||||
limit?: number
|
||||
): readonly OutboxEntry[] | Promise<readonly OutboxEntry[]>
|
||||
}
|
||||
|
||||
export class MemoryOutbox implements Outbox {
|
||||
@@ -106,6 +110,7 @@ export class MemoryOutbox implements Outbox {
|
||||
}
|
||||
entry.state = 'delivered'
|
||||
entry.attempts += 1
|
||||
entry.message = this.withoutAttachments(entry.message)
|
||||
}
|
||||
|
||||
markFailed(id: string): void {
|
||||
@@ -115,11 +120,27 @@ export class MemoryOutbox implements Outbox {
|
||||
}
|
||||
entry.state = 'failed'
|
||||
entry.attempts += 1
|
||||
if (entry.attempts >= 5) {
|
||||
entry.message = this.withoutAttachments(entry.message)
|
||||
}
|
||||
}
|
||||
|
||||
listUndelivered(): readonly OutboxEntry[] {
|
||||
listUndelivered(
|
||||
channel?: string,
|
||||
limit = this.maximumEntries
|
||||
): readonly OutboxEntry[] {
|
||||
return [...this.entries.values()]
|
||||
.filter((entry) => entry.state !== 'delivered')
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.state !== 'delivered' &&
|
||||
(channel === undefined || entry.message.channel === channel)
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.attempts - right.attempts ||
|
||||
left.createdAt - right.createdAt
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((entry) => this.clone(entry))
|
||||
}
|
||||
|
||||
@@ -142,13 +163,27 @@ export class MemoryOutbox implements Outbox {
|
||||
message: structuredClone(entry.message)
|
||||
}
|
||||
}
|
||||
|
||||
private withoutAttachments(
|
||||
message: ChannelResultMessage
|
||||
): ChannelResultMessage {
|
||||
const sanitized = structuredClone(message)
|
||||
delete sanitized.attachments
|
||||
return sanitized
|
||||
}
|
||||
}
|
||||
|
||||
export type ChannelExecutor = (
|
||||
message: ChannelInboundText,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
reportProgress: (result: {
|
||||
status: string
|
||||
output?: string
|
||||
error?: string
|
||||
}) => Promise<void>
|
||||
) => Promise<{
|
||||
status: string
|
||||
output?: string
|
||||
error?: string
|
||||
attachments?: ChannelMediaAttachment[]
|
||||
}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user