diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f9d51ae..8c58ac2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,7 @@ jobs: fix: true globs: | docs/**/*.md + !docs/需求收集会议文档.md *.md frontend: diff --git a/NOTICE b/NOTICE index 879c9828..d6c8509e 100644 --- a/NOTICE +++ b/NOTICE @@ -19,6 +19,18 @@ The tracked source of that generated material is third_party/. Run `scripts/generate_third_party_licenses.py --check` to verify that it still matches go.mod, go.sum, frontend/package.json and frontend/pnpm-lock.yaml. +================================================================================ +Third-party source metadata reused for offline promotion prompt building +================================================================================ + + awesome-gpt-image-2 style-library.json MIT Copyright (c) 2026 freestylefly + Source: https://github.com/freestylefly/awesome-gpt-image-2 + Fixed commit: 685469889fb72fd5adefae45e1645d527edcb5e7 + Local copy: scripts/promotion/awesome-gpt-image-2/style-library.json + Scope: template/category/style/scene metadata and guidance only; no case images, + third-party prompt corpus, website, payment code, or remote generation API is + redistributed. See scripts/promotion/awesome-gpt-image-2/UPSTREAM.md and LICENSE. + ================================================================================ Third-party software downloaded at runtime, not redistributed ================================================================================ diff --git a/cmd/bootagent-desktop/main_wails.go b/cmd/bootagent-desktop/main_wails.go index 2fae1d92..20983d9b 100644 --- a/cmd/bootagent-desktop/main_wails.go +++ b/cmd/bootagent-desktop/main_wails.go @@ -38,8 +38,9 @@ type updateProvider struct { func (p updateProvider) Name() string { return "bootagent-release" } -// Check asks the preferred source, and falls back to the official one when the -// mirror could not answer. +// Check asks the preferred source, and falls back to the other source when the +// selected source could not answer. This keeps a transient API outage or rate +// limit from making update checks fail when the other source is available. // // The mirror needs the fallback because it fails in ways the official source does // not. Fetching SHA256SUMS from Gitee answered 403 on roughly a third of @@ -48,28 +49,19 @@ func (p updateProvider) Name() string { return "bootagent-release" } // the user as a failure: the automatic check treats any error as "no update" // (AppUpdater), so before this a mirror user simply stopped being offered // updates, silently and with nothing to retry. -// -// The fallback only runs mirror -> official, never the reverse. Runtime archives -// can try both hosts in either order because runtimes.lock.json pins each -// artifact's SHA256, so a bad mirror can only fail rather than substitute -// anything (see install.downloadSources). An OTA release carries no such -// out-of-band pin -- the digest comes from the same host as the artifact -- so -// sending a user who did not choose the mirror to the mirror would widen the -// trust boundary instead of just improving availability. Falling back toward the -// authoritative source only ever narrows it. func (p updateProvider) Check(ctx context.Context, request updater.CheckRequest) (*updater.Release, error) { - mirror := p.preferMirror(ctx) - release, err := p.check(ctx, request, mirror) - if err == nil || !mirror { - return release, err + preferredMirror := p.preferMirror(ctx) + release, err := p.check(ctx, request, preferredMirror) + if err == nil { + return release, nil } // A cancelled check is the caller's decision, not a source that failed; // retrying elsewhere would reopen exactly the request that was abandoned. if ctx.Err() != nil { return release, err } - slog.Warn("BootAgent mirror update check failed; trying the official source", "error", err) - fallbackRelease, fallbackErr := p.check(ctx, request, false) + slog.Warn("BootAgent update check failed; trying the alternate source", "mirror", preferredMirror, "error", err) + fallbackRelease, fallbackErr := p.check(ctx, request, !preferredMirror) if fallbackErr != nil { // Both errors are kept: the mirror's is what the user's configured route // did, and joining preserves the sentinels updateError matches on. diff --git a/cmd/bootagent-desktop/main_wails_test.go b/cmd/bootagent-desktop/main_wails_test.go index 1aba19ed..e2c9063c 100644 --- a/cmd/bootagent-desktop/main_wails_test.go +++ b/cmd/bootagent-desktop/main_wails_test.go @@ -132,21 +132,19 @@ func TestUpdateCheckDoesNotFallBackAfterCancellation(t *testing.T) { } } -// The official source is never abandoned for the mirror. Runtime archives may try -// both hosts in either order because runtimes.lock.json pins each SHA256; an OTA -// release has no such out-of-band pin, so the digest arrives from whichever host -// served the artifact. Falling back to the mirror would put a user who never -// chose it inside the mirror's trust boundary. -func TestUpdateCheckNeverFallsBackToTheMirror(t *testing.T) { +// An official API outage (including GitHub rate limiting) should not prevent an +// update when the configured mirror can answer. The selected source is recorded +// on the release, so a later download still uses the source that answered. +func TestUpdateCheckFallsBackToMirrorWhenOfficialFails(t *testing.T) { officialErr := errors.New("official is unreachable") official, mirror := &updateProviderFake{checkErr: officialErr}, &updateProviderFake{} provider := updateProvider{official: official, mirror: mirror, preferMirror: func(context.Context) bool { return false }} - if _, err := provider.Check(context.Background(), updater.CheckRequest{}); !errors.Is(err, officialErr) { - t.Fatalf("Check() error = %v, want %v", err, officialErr) + if _, err := provider.Check(context.Background(), updater.CheckRequest{}); err != nil { + t.Fatalf("Check() after an official failure = %v, want the mirror result", err) } - if mirror.checks != 0 { - t.Errorf("mirror checks = %d, want 0: falling back to the mirror would widen the trust boundary", mirror.checks) + if mirror.checks != 1 { + t.Errorf("mirror checks = %d, want 1", mirror.checks) } } diff --git a/docs/assets/restyle/tile-a-swiss-blueprint.html b/docs/assets/restyle/tile-a-swiss-blueprint.html new file mode 100644 index 00000000..cd7f5f6c --- /dev/null +++ b/docs/assets/restyle/tile-a-swiss-blueprint.html @@ -0,0 +1,535 @@ + + + + + +Tile A — Swiss + Blueprint(主推) + + + + + + + +
+ +
+

Tile A · Swiss + Blueprint(主推)

+ 制图纸上的瑞士排版,蓝图作签名 — IBM Plex Sans / Plex Mono / Noto Sans SC · 取值同简报 §2 +
+ +
+
Palette · 实算对比度
+
+
#f2f4f4 图纸底page-bg
+
#152532 制图墨14.17:1 on page
+
#20618e 制图蓝6.53:1 · 白字 6.64:1
+
#2f6b46 成功6.23:1 on sheet
+
#8a5a12 警告5.81:1 on sheet
+
#b0301c 危险6.28:1 · 与蓝半个色环
+
#9dbcd0 导线纯装饰,不承载语义
+
+
+ +
+
Type · 一族 grotesque,字重承担层级
+
+ 环境总览 Environment + 正文 15px:本机已安装 Agent 及其当前配置。IBM Plex Sans 400 与 Noto Sans SC 混排。 + mono 注记 · claude-fable-5[1m] · ~/.claude/settings.json +
+
+ + + + + +
+
Components · 按钮 / 徽标 / 输入 / 焦点
+
+ + + + + + + + 已安装 + 待安装 + 未配置 + 失败 + + sk-…(API Key) +
+
+ + +
+
+
Dark · Cyanotype(蓝晒反转,同一套 token 名)
+
+
+ A2 + CC + Claude Codev2.1.220 + + + JieKou.AI · claude-fable-5[1m] + + 已安装 · 已配置 + + + + +
+
+ A3 + KL + Kilo CLI未安装 + + + 未配置 + + 待安装 + +
+
+
+ + + 失败 + 待安装 +
+
+
+ +

+ 签名元素只出现在三处:总览(图签 / 图区字母与位号 / 引出线 / 网格纸)、向导 review 的 + 尺寸标注 diff、空态制图框。零软阴影预算:卡片靠 1px 墨线分层,阴影仅保留给窗口层。 + 比稿经 CDN 加载字体仅供预览;产品实现本地打包 IBM Plex Sans / Plex Mono / Noto Sans SC。 +

+ +
+ + diff --git a/docs/assets/restyle/tile-b-neobrutalist.html b/docs/assets/restyle/tile-b-neobrutalist.html new file mode 100644 index 00000000..e418217f --- /dev/null +++ b/docs/assets/restyle/tile-b-neobrutalist.html @@ -0,0 +1,394 @@ + + + + + +Tile B — 新粗野主义(备选) + + + + + + + +
+ +
+

Tile B · 新粗野主义(备选)

+ 粗描边 · 硬阴影 · 色块 — Archivo Black / Archivo / IBM Plex Mono · 陶土色提为一等色块 +
+ +
+
Palette · 黑字规则(陶土块上永远用墨字:5.46:1;白字 3.35:1 不达标)
+
+
#edeee8 底bg
+
#151515 墨15.65:1 on bg
+
#d96e49 陶土墨字 5.46:1
+
#f5c542 黄墨字 11.26:1
+
#5fbf77 绿墨字 8.00:1
+
#ea6a55 红墨字 5.82:1
+
+
+ +
+
Type · Archivo Black 标题声部 + Archivo 正文
+
+ 环境总览 OVERVIEW + 正文 15px:本机已安装 Agent 及其当前配置。Archivo 400 与 Noto Sans SC 混排。 + claude-fable-5[1m] +
+
+ + + + + +
+
Components · 主要按钮为墨块(陶土留给品牌/选中,红块只做危险确认)
+
+ + + + + + + + + + 已安装 + 待安装 + 未配置 + 失败 + + sk-…(API Key) +
+
+ + +
+
+
Dark · 硬黑阴影在近黑底上不可见,只能换成亮描边 + 亮阴影模拟 —— 风格骨架在暗色下需要另一套语言
+
+
+ CC + Claude Codev2.1.220 + JieKou.AI · claude-fable-5[1m] + 已安装 · 已配置 + + + + +
+
+
+ + + 失败 + 待安装 +
+
评审注:亮阴影方案让「浮起」变成「发光」,与亮色态的物理隐喻不一致 —— 这是 ADR-010 未采纳本方向的理由之二。
+
+
+ +

+ 与 Tile A 使用完全相同的界面文案与 Agent 清单,比较的是风格本身。 + 字体经 CDN 加载仅供预览;若采纳该方向,产品同样本地打包。 +

+ +
+ + diff --git a/docs/decisions/ADR-010-visual-restyle-swiss-blueprint.md b/docs/decisions/ADR-010-visual-restyle-swiss-blueprint.md new file mode 100644 index 00000000..2088bb28 --- /dev/null +++ b/docs/decisions/ADR-010-visual-restyle-swiss-blueprint.md @@ -0,0 +1,93 @@ +# ADR-010: Visual restyle direction — drafting-sheet Swiss with a blueprint signature + +## Status + +Proposed (comparison comps attached; awaiting review) + +## Date + +2026-08-17 + +## Context + +The current visual language (warm cream ground `#efeae0`, terracotta accent +`#d96e49`) has two problems that grew over time rather than being chosen: + +1. The palette now matches the most common AI-generated interface look — warm + cream plus a terracotta accent that sits within a few RGB points of the + Claude product accent. For a tool that must be recognisable next to + competitors, this reads as "everyone" instead of "us". +2. The action accent (`--accent: #a94623`) and the danger colour + (`--red: #a32b1d`) are near neighbours in hue. Primary and destructive + actions in a tool that rewrites user configuration files should never share + a colour family. + +What must not be lost in a restyle: the token-first architecture +(`frontend/src/styles/tokens.css` feeding `base.css` and `app.css`), the +documented WCAG contrast maths on every colour token, the enforced light/dark +palette parity, and the CSS invariant tests in CI. The restyle replaces token +values and the component skin, not this architecture. + +Product constraints that filter any style choice: dense configuration UI needs +quiet surfaces and strong hierarchy; both light and dark palettes are +mandatory; the Linux build renders through WebKitGTK, so blur-heavy materials +are out; the UI ships bilingual (zh/en), so every typeface decision needs a +CJK answer; fonts must be bundled locally (no runtime font fetching), so +licences must permit redistribution. + +## Decision + +Adopt a Swiss-typographic base with an engineering-drawing (blueprint) +signature layer, specified in full — palette hex values with computed contrast +ratios, type roles, and signature element scope — in the +[design brief](../ui-restyle-brief.md). + +Summary of the direction: + +- Ground: drafting-paper white with a faint drawing grid; dark mode inverts to + a cyanotype-inspired deep blue-black. Surfaces are flat; 1px rules replace + soft shadows. +- Accent: drafting blue (`#20618e` light / `#6cb0dc` dark). Blue is far from + the red error family, resolving the accent/danger collision by construction. +- The existing terracotta becomes a brand-mark colour only (application icon, + splash, about page). Assets under `brand/` are not redrawn. +- Type: one grotesque family carries all Latin hierarchy through weight and + size (IBM Plex Sans), one mono for annotations and data (IBM Plex Mono), + Noto Sans SC for CJK. All three are SIL OFL licensed, compatible with local + bundling and the NOTICE generation flow. +- The signature layer — leader-line wiring, a title block, dimension-style + annotations — appears in exactly three places (overview, wizard review step, + empty states) and nowhere else. + +A neo-brutalist direction was built as a full alternative comp and rejected +for the reasons recorded below, with the comp kept for reference. + +Comparison comps (self-contained HTML, open in any browser): + +- [Tile A — Swiss + Blueprint (recommended)](../assets/restyle/tile-a-swiss-blueprint.html) +- [Tile B — Neo-brutalist (alternative)](../assets/restyle/tile-b-neobrutalist.html) + +Both tiles render the real overview screen content (same strings the +application ships) so the comparison is between styles, not between contents. + +## Consequences + +- `tokens.css` token names stay stable; values change wholesale. `base.css` + and component CSS migrate screen by screen against screenshot baselines, as + planned in the restyle phases of + [the optimization plan](../ux-and-agent-capability-optimization-plan.md). +- The contrast comments in `tokens.css` become an executable CI check; the + ratios in the brief were computed, not estimated, and the check must + reproduce them. +- Bundling three font families adds roughly 1–2 MB (Latin) plus a subset CJK + payload to the frontend bundle; the build gains a subsetting step for + Noto Sans SC. Licence texts enter NOTICE through the existing generator. +- The blueprint signature is scoped to three surfaces by decision; extending + it elsewhere requires revisiting this ADR, which is the intended brake + against theming creep. +- Rejecting neo-brutalism is a decision about the product's register: thick + strokes and hard offset shadows carry a playful, poster-like voice that + fights the quiet confidence a configuration tool needs, its hard shadows do + not survive dark mode honestly (they vanish against near-black and must be + faked with light borders), and its visual density budget is spent on the + frame rather than the content. diff --git a/docs/internal/promotion-content-system-spec.md b/docs/internal/promotion-content-system-spec.md new file mode 100644 index 00000000..aa9ae915 --- /dev/null +++ b/docs/internal/promotion-content-system-spec.md @@ -0,0 +1,371 @@ +# BootAgent 版本推广内容系统定版需求 + +**状态:** Draft for implementation(定版约束已冻结) +**适用范围:** 每次 BootAgent 发布新版本时,自动生成小红书与 X 两套推广内容 +**维护位置:** `docs/internal/` +**最后更新:** 2026-08-25 + +## 1. 目标与边界 + +版本发布事件触发后,系统自动读取 release 信息,生成一份统一 Brief,并分别产出: + +- 小红书:一张 3:4 竖版推广封面 + 一篇可直接复制的中文推文。 +- X:一张 16:9 横版推广封面 + 一篇可直接发布的英文优先推文。 +- 两条渠道共享产品能力事实、版本事实、品牌 Token、素材禁用项和审核结果,但不共享最终文案结构。 +- 文案面向首次接触 BootAgent 的小白用户;默认读者不熟悉 CLI、Provider、Profile、MCP、runtime 或配置文件。 + +第一阶段只实现: + +1. 版本事件接入。 +2. Brief 生成。 +3. 两套图片与推文生成。 +4. 人工审核。 +5. PNG 与 Markdown 导出。 + +第一阶段不自动操作小红书或 X 账号,不保存登录 Cookie,不自动发布。发布排期、平台 API 和数据回写属于第二阶段。 + +## 2. 已冻结的内容策略 + +### 2.1 统一品牌主张 + +主张:**Agent 很多,管理只需一个。** + +产品定位:BootAgent 是本地桌面工作台,用于统一管理 AI 编程 Agent,覆盖检测、安装、模型服务配置、Profile、MCP 和日常维护。 + +传播重点顺序: + +1. 小白用户能用 BootAgent 完成什么。 +2. 使用前后的具体变化:少找工具、少改配置、少记命令。 +3. 一个清晰、可想象的使用场景。 +4. 用户能立即获得的结果和下一步行动。 +5. 版本更新内容作为可信背书;只有影响用户体验的变化才进入正文。 + +产品能力优先级: + +- 统一管理多个 AI 编程 Agent。 +- 可视化完成检测、安装、更新和启动。 +- 连接模型服务,选择模型,并复用配置模版。 +- 准备 Node.js、uv 和 Aider 所需运行时。 +- 管理 Provider、Profile、MCP 和本地备份。 +- 在本机维护一套可重复的 Agent 工作环境。 + +技术修复的表达规则: + +- 普通 bug 修复、内部重构、测试补充、依赖升级默认不进入主标题或主体段落。 +- 只有用户能直接感知的修复,才以“体验更顺”“退出更符合预期”这类结果语言作为辅助信息出现。 +- 版本号只作为角标或文末信息,不作为小白用户的主要阅读入口。 +- 不把 Agent 名称、协议名和内部实现名堆在第一屏;需要出现时必须顺带解释它对用户有什么用。 + +### 2.2 小红书文案风格(冻结) + +- **语言:** 简体中文。 +- **语气:** 像一个已经踩过坑、正在分享解决方案的产品用户;清楚、具体、友好,不端着。 +- **默认认知:** 读者不知道 Agent、Provider、Profile、MCP 是什么,首段不能依赖这些术语。 +- **结构:** 用户痛点 → BootAgent 能做什么 → 3 个以内使用结果 → 简化后的上手方式 → 版本体验补充 → CTA → 话题。 +- **内容占比:** 产品能力与使用结果约 70%–80%;版本更新与 bug 修复约 10%–20%;品牌和 CTA 约 10%。 +- **长度:** 标题 18–28 个汉字;正文 180–420 个汉字;最多 6 个话题标签。 +- **表达:** 短段落、短句、适量 emoji;每段最多 1 个 emoji,不使用连续表情刷屏。 +- **标题方向:** 搜索型和结果型优先,例如“AI 工具太多?用 BootAgent 一起管理”、“不想反复改配置?试试这个 Agent 工作台”。 +- **CTA:** 引导读者查看下载页、评论区提问或收藏教程;海报本身不贴网址。 +- **禁止:** 空泛的“革命性”“颠覆性”“百分百成功”;未经 release 事实支持的性能、兼容性和数量承诺;把第三方 Agent 写成 BootAgent 自有产品。 + +### 2.3 X 文案风格(冻结) + +- **语言:** 英文优先;必要的产品名、Agent 名、命令和配置字段保持原文。 +- **语气:** clear, approachable, useful, builder-friendly;先讲结果,再讲实现。 +- **默认认知:** 读者可能不了解 Agent 管理工具,首句要能独立理解。 +- **结构:** user problem → what BootAgent does → 2–4 个 user outcomes → optional release note → CTA → link。 +- **内容占比:** 产品能力与使用结果约 70%;版本更新与 bug 修复约 10%–20%;品牌和 CTA 约 10%。 +- **长度:** 单条 240 字符以内;需要补充信息时拆为最多 3 条 thread;不使用长段落。 +- **表达:** 短句、动词开头、少术语;技术词第一次出现时必须解释用户收益;可使用 1–2 个 emoji,但默认不使用表情也成立。 +- **CTA:** 引导查看下载、体验或了解完整能力;链接放在推文中,不放在封面内。 +- **禁止:** 直接翻译小红书正文;堆叠中文营销话术;没有证据的 benchmark、star 增长、用户数量或“best/fastest”表述。 + +### 2.4 小白用户内容验收 + +每条推广文案必须先通过以下问题: + +1. 不知道 Agent 是什么的人,能否在前两句理解 BootAgent 是做什么的? +2. 文案是否至少讲清楚两个具体能力,而不是只说“更方便”“更强大”? +3. 是否把“少找工具、少改配置、少记命令”翻译成用户能想象的结果? +4. 是否把技术名词降级为补充说明,而不是开场主语? +5. 删除版本号和 bug 修复后,文案是否仍然成立?如果不成立,说明产品价值写得不够。 +6. 是否给出一个不需要专业背景也能执行的 CTA? + +不通过时,优先重写产品价值段,不优先增加版本细节。 + +## 3. 推广图片生成工具定版 + +### 3.1 唯一默认生成器 + +**GPT Image 2** 作为唯一默认的推广图片生成器。 + +编排层必须通过统一的 `ImageGenerator` 适配接口调用,不允许在同一条工作流中按失败情况随机切换多个模型。这样可以保证不同版本之间的画面风格、可追溯性和成本可控。 + +建议接口字段: + +```text +ImageGenerator.generate({ + channel: "xiaohongshu" | "x", + aspectRatio: "3:4" | "16:9", + prompt: string, + seed?: string, + version: string, + outputPath: string +}) +``` + +### 3.2 工具使用边界 + +- 图片生成器只负责生成无文字或少量非关键装饰元素的视觉底图。 +- 版本号、标题、CTA、网址、命令、API Key、产品能力说明必须由排版层使用可编辑文字写入,不能烘焙进图片。 +- 每个渠道每个版本只生成 1 张主封面;失败时允许同一 prompt 重试 1 次,仍失败则进入人工处理,不自动换模型。 +- 必须记录:模型名、prompt 版本、输入版本号、生成时间、输出路径、审核状态。 +- API Key、Cookie、用户隐私、未公开 roadmap 不得进入 prompt。 +- 推广封面不得生成二维码、网址、仿真 UI 截图或容易被误认为真实产品界面的内容。 + +## 4. 推广图片视觉系统定版 + +### 4.1 共享品牌 Token + +| Token | 值 | 用途 | +| --- | --- | --- | +| `background` | `#F8F4EC` | 主背景、留白 | +| `surface` | `#FBF9F5` | 卡片、内容承载面 | +| `ink` | `#211F1B` | 标题、深色块 | +| `brand` | `#A94622` | 主焦糖橙、重点信息 | +| `accent` | `#D97B4F` | 高亮、标签、微装饰 | +| `muted` | `#827C70` | 辅助信息、说明文字 | + +视觉约束:每张封面最多使用 4 个颜色角色;优先使用米白 + 炭黑 + 焦糖橙,避免赛博霓虹、玻璃拟态、随机渐变和泛科技 stock image。 + +### 4.2 共享构图原则 + +- 视觉核心只有一个:产品结果或一个抽象的工作流/Agent 主题。 +- 留出明确的文字安全区;关键文字不放在复杂纹理上。 +- 使用克制的编辑型产品视觉:大块留白、圆角卡片、轻微纸张/颗粒质感、清晰的模块节奏。 +- 不把版本号做成主标题;版本号最多作为角标或辅助标签。 +- 不在封面中放仓库地址、下载地址、二维码和长段落。 +- 任何包含截图的版本,截图必须来自真实产品,并且 API Key、路径中的隐私信息全部打码。 + +### 4.3 小红书封面规格 + +- 画布:`1080 × 1440`,比例 `3:4`。 +- 视觉重心:上方标题 + 中部产品/工作流视觉 + 下方极短行动提示。 +- 标题:最多 2 行,突出用户结果;建议 72–120 px。 +- 辅助说明:最多 2 行;不超过 36–40 px 的视觉层级。 +- 版本标签:`BootAgent · vX.Y.Z`,只做辅助标识。 +- 封面不贴链接;下载入口和仓库入口放正文或置顶评论。 + +### 4.4 X 封面规格 + +- 画布:`1600 × 900`,比例 `16:9`。 +- 视觉重心:左侧短标题 + 右侧单一抽象视觉或产品结果视觉。 +- 标题:最多 2 行,适合缩略图阅读。 +- 辅助说明:最多 1 行,强调 release 变化。 +- 版本标签:可保留,但不得大于主标题的 25%。 +- 封面不放长链接;链接放推文正文。 + +## 5. 固定提示词 + +以下提示词作为模板冻结。版本事件只允许替换 `{VERSION}`、`{RELEASE_THEME}`、`{FEATURE_FOCUS}`、`{PRODUCT_VISUAL}` 等变量,不得改变核心风格约束。 + +### 5.1 小红书主封面 Prompt + +```text +Create a polished editorial product-promotion cover for BootAgent, a local desktop workbench for managing AI coding agents. +Canvas ratio: 3:4 portrait, clean composition with generous negative space. +Use this exact visual direction: warm off-white paper background #F8F4EC, deep charcoal #211F1B, caramel orange #A94622, soft orange accent #D97B4F, restrained editorial-tech style, rounded cards, subtle paper grain, crisp geometric workflow motifs, calm and trustworthy, not cyberpunk, not neon, not glassmorphism, not stock photography. +The visual subject should communicate {PRODUCT_VISUAL} and the release theme {RELEASE_THEME}. +Keep a quiet safe area in the upper half for live typography. +Do not render any readable text, URL, QR code, API key, fake UI copy, logo imitation, or watermark inside the image. +Do not create a collage. Use one clear hero visual with 2–3 supporting geometric details. +This is a background visual for a Chinese social-media cover; leave enough clean space for a Chinese headline and a small version label. +``` + +### 5.2 X 主封面 Prompt + +```text +Create a refined editorial product-promotion banner for BootAgent, a local desktop workbench for managing AI coding agents. +Canvas ratio: 16:9 landscape, strong left-to-right reading flow. +Use this exact visual direction: warm off-white #F8F4EC, deep charcoal #211F1B, caramel orange #A94622, soft orange accent #D97B4F, minimal editorial-tech composition, rounded modular cards, subtle paper grain, precise workflow or agent-network motif, premium but approachable. +The visual subject should communicate {PRODUCT_VISUAL} and the release theme {RELEASE_THEME}. +Reserve a quiet area on the left for a short English release headline and a small version label. +Do not render any readable text, URL, QR code, API key, fake UI copy, logo imitation, or watermark inside the image. +Avoid cyberpunk neon, excessive gradients, generic robots, server-room clichés, stock photography, and busy collages. +Use one clear hero visual and a restrained supporting motif. +``` + +### 5.3 Prompt 变量规则 + +- `{VERSION}`:只接受 release tag,例如 `v0.7.0`;不能直接拼接未验证版本。 +- `{RELEASE_THEME}`:从 changelog 提炼的 3–8 个词,例如 `visual agent setup`。 +- `{FEATURE_FOCUS}`:一个用户结果,不超过 12 个词。 +- `{PRODUCT_VISUAL}`:抽象的产品结果,例如 `a clean visual setup flow for multiple AI agents`。 +- 任何变量为空时,工作流必须阻止生成并提示补齐,不允许让模型自由猜测。 + +## 6. 固定输入与输出 + +### 输入对象 `PromotionBrief` + +```json +{ + "version": "vX.Y.Z", + "release_url": "...", + "release_date": "YYYY-MM-DD", + "changelog_facts": [], + "feature_focus": "", + "target_audience": "", + "product_visual": "", + "download_cta": "", + "known_limits": [], + "safety_notes": [] +} +``` + +### 输出对象 `PromotionBundle` + +```text +promotion/ + {version}/ + xiaohongshu/ + cover.png + post.md + brief.json + generation.json + x/ + cover.png + post.md + brief.json + generation.json + review.md +``` + +### `review.md` 最低检查项 + +- 版本号与 release 一致。 +- 所有功能描述都能在 changelog 或产品文档中找到依据。 +- 两个平台文案没有互相复制粘贴。 +- 图片没有 URL、二维码、API Key 和错误 Logo。 +- 图片比例、品牌色、安全区符合规范。 +- 小红书海报未贴链接;X 链接只出现在推文正文。 +- 审核人、审核时间、修改意见可追溯。 + +## 7. 状态机约束 + +```text +RELEASE_DETECTED + → BRIEF_READY + → ASSETS_GENERATING + → ASSETS_READY + → HUMAN_REVIEW + → APPROVED + → EXPORTED +``` + +失败状态: + +- `BRIEF_INVALID`:缺少版本事实或变量,回到输入检查。 +- `GENERATION_FAILED`:同一渠道最多重试 1 次,之后进入人工处理。 +- `REVIEW_REJECTED`:必须记录原因,回到 Brief 或单渠道生成节点。 +- `EXPORT_FAILED`:保留审核结果,允许重新导出,不重复生成图片。 + +状态转移必须写入日志,日志中不得保存 API Key、Cookie 或完整用户环境变量。 + +## 8. 面向小白用户的生成工作流 + +```text +读取 release 与产品能力事实 + → 先生成“小白用户 Brief” + → 生成产品能力优先的两条渠道文案 + → 生成一张表达产品结果的封面 + → 将版本修复作为辅助信息补充 + → 人工检查是否无需专业背景即可理解 + → 审核通过后导出 +``` + +`PromotionBrief` 生成时必须优先填充: + +- `product_capabilities` +- `user_problems` +- `user_outcomes` +- `simple_use_case` +- `feature_focus` + +`changelog_facts` 仍然必须保留,但默认只用于事实校验和可选的辅助段落,不得自动成为标题。 + +## 9. awesome-gpt-image-2 风格库集成 + +### 9.1 集成范围 + +BootAgent 只离线复用 `freestylefly/awesome-gpt-image-2` 的 `data/style-library.json` 元数据:13 个分类、19 个风格、10 个场景和 22 个模板的标题、指导与常见问题。固定上游提交、文件哈希和 MIT 许可证记录在 `scripts/promotion/awesome-gpt-image-2/UPSTREAM.md`。 + +不引入上游案例图片、第三方提示词全文、Vite 展示站、Supabase、Stripe、支付宝或上游 `api/generate-image.js`。上游生图接口依赖登录、积分、次元 API,固定方形低质量 JPEG,与本系统的渠道比例和本地优先边界不一致,因此不直接复用。 + +### 9.2 离线 Prompt Builder + +使用 `scripts/promotion/build_prompt.py` 从渠道 `brief.json` 生成可审计的 `prompt.json`: + +```text +python3 scripts/promotion/build_prompt.py \\ + --brief outputs/promotion/v0.7.3/xiaohongshu/brief.json \\ + --channel xiaohongshu \\ + --output outputs/promotion/v0.7.3/xiaohongshu/prompt.json +``` + +Builder 负责选择海报模板、合并渠道比例、产品能力、用户结果和本地安全约束;不访问网络、不读取密钥、不调用远程 API。生成结果记录模板 ID、上游 commit、数据哈希、渠道、画布规格和 `remote_generation: false`,供既有 `ImageGenerator` 或人工生图环节消费。 + +### 9.3 复用、修改与新增文件 + +- 可直接复用:`style-library.json` 中的模板元数据、`guidance` 和 `pitfalls`;`poster-layout-system` 作为当前推广海报默认模板。 +- 新增:`scripts/promotion/awesome-gpt-image-2/style-library.json`、`LICENSE`、`UPSTREAM.md`、`scripts/promotion/build_prompt.py`、`scripts/test_promotion_prompt_builder.py`。 +- 修改:`NOTICE` 与本规格文档。 +- 暂不修改:Go/Wails 服务、React 页面、Provider/API Key 存储、Skill 安装逻辑和远程生图链路。 + +### 9.4 更新与安全要求 + +上游只允许固定提交人工升级;升级时必须重新核对 SHA-256、许可证和模板字段。不得运行时拉取上游数据。第三方案例图片与提示词来源不完整、商用权不确定,不得进入 BootAgent 发布包或推广资产。Prompt Builder 输出不得包含 API Key、Cookie、用户 HOME、完整环境变量或未公开 roadmap。 + +## 10. 定版后的实施计划 + +### Iteration 1:数据与模板 + +- 定义 `PromotionBrief`、`PromotionBundle` 和状态枚举。 +- 增加 release tag / changelog 解析器。 +- 增加 `product_capabilities`、`user_problems`、`user_outcomes`、`simple_use_case` 字段。 +- 建立面向小白用户的小红书与 X 文案模板和字段校验。 +- 将品牌 Token 与固定 prompt 模板放入版本化配置。 + +### Iteration 2:生成与资产落盘 + +- 实现 `ImageGenerator` 适配接口,默认绑定 GPT Image 2。 +- 实现每渠道单次生成、单次重试和生成元数据记录。 +- 生成 PNG、Markdown、JSON 与 review 素材包。 +- 增加图片比例、文件存在性和 prompt 变量完整性检查。 + +### Iteration 3:审核工作台 + +- 展示 Brief、产品能力事实、两套封面、两套推文和版本事实来源。 +- 增加“小白用户可理解性”检查:首句定位、能力解释、用户结果、术语降级、CTA 可执行。 +- 支持单渠道退回,不影响另一渠道已通过的资产。 +- 支持修改文案后重新审核;图片默认不重复生成。 +- 提供敏感词、链接、API Key、产品事实和版本事实检查。 + +### Iteration 4:导出与后续扩展 + +- 一键导出当前版本完整推广包。 +- 预留排期字段和平台发布适配器,但默认关闭。 +- 后续再评估小红书与 X 的账号授权、自动发布和数据回写。 + +## 10. 验收标准 + +实现完成后,使用一个真实 release 做端到端验收: + +1. 版本事件能生成正确的 `PromotionBrief`。 +2. 两个渠道都能得到一张符合比例和品牌规范的封面。 +3. 两个渠道都能得到独立文案,且内容事实一致、表达方式不同。 +4. 生成失败只重试一次,不会无限循环或随机切换模型。 +5. 审核不通过可以回退到正确节点,不丢失已生成资产。 +6. 导出的推广包目录结构稳定,能被人工直接使用。 +7. 日志不包含 API Key、Cookie、私密路径或完整环境变量。 +8. 未经人工审核,系统不会执行任何平台发布动作。 diff --git a/docs/internal/transfer-test-plan.md b/docs/internal/transfer-test-plan.md new file mode 100644 index 00000000..c278ae2a --- /dev/null +++ b/docs/internal/transfer-test-plan.md @@ -0,0 +1,40 @@ +# 导入导出功能测试方案 + +## 测试目标 + +验证 Provider、Profile、MCP、Skill 的迁移在真实文件选择、序列化、解析、冲突和失败恢复场景下保持数据完整,不泄露密钥,也不破坏现有 v1 文件兼容性。 + +## 功能矩阵 + +| 场景 | 预期结果 | 自动化层级 | +| --- | --- | --- | +| v1 JSON 导出/导入,无 API Key | Key 不出现在文件中,目标端保留已有 Key | Vitest + Wails E2E | +| v1 明文 Key 导出 | 必须二次确认,文件明确包含 Key | Vitest | +| v1 加密 Key 导出 | 正确密码可恢复,错误密码拒绝且不写入 | Vitest | +| v1 旧字段 `base_url`、`api_key` | 兼容读取并丢弃 Profile 本地敏感字段 | Vitest | +| MCP 脱敏/明文/加密 | 分别符合 secret mode,Agent 绑定不泄露 | Go | +| 单 Skill 文件夹导入 | 预览显示名称、描述、文件数、大小和目标 Agent | Go + Wails E2E | +| 单 Skill ZIP 导入 | 与文件夹导入产生相同候选结果 | Go | +| 单 Skill ZIP 导出后重新解压 | `SKILL.md`、目录结构和 SHA-256 保持一致 | Go | +| v2 ZIP 多 Skill 导出 | 清单、配置和每个 Skill 包都可读取 | Go | +| v2 ZIP 缺少清单/版本错误 | 拒绝导入,不修改本地数据 | Go | +| v2 ZIP 路径穿越、重复路径、符号链接 | 拒绝导入 | Go | +| 导入覆盖已有 Skill | 预览列出覆盖项,确认后原子替换并保留备份 | Go + Wails E2E | +| 导入取消 | 不产生文件、不改变草稿和现有配置 | Vitest + Wails E2E | +| 选择空文件/非 JSON/损坏 ZIP | 显示可本地化错误,不显示堆栈或敏感路径 | Vitest + Go | +| 大量数据(1000 个 Profile/Skill) | 页面固定滚动、搜索结果唯一,无水平溢出 | Vitest + Playwright | +| 单文件超过大小限制 | 在解析阶段拒绝,不解压到持久目录 | Go | +| 跨平台路径(Windows 分隔符) | 统一转换为 ZIP `/` 路径,不允许绝对路径 | Go | + +## 发布前执行 + +```text +go test ./... +go test -race ./... +go vet ./... +cd frontend && pnpm run typecheck && pnpm run test -- --run && pnpm run build +cd frontend && pnpm run test:e2e +python3 scripts/check-docs.py +``` + +人工验收需使用真实桌面二进制完成一次:导出 v1、导出单 Skill、导出 v2、重新选择文件导入,并确认取消和冲突预览不会改变原配置。 diff --git a/docs/ui-restyle-brief.md b/docs/ui-restyle-brief.md new file mode 100644 index 00000000..a4ba32d2 --- /dev/null +++ b/docs/ui-restyle-brief.md @@ -0,0 +1,137 @@ +# BootAgent 视觉重构设计简报(阶段 0) + +> 状态:随 [ADR-010](decisions/ADR-010-visual-restyle-swiss-blueprint.md) 一同评审。 +> 本文是唯一的取值来源:调色板、字体、签名元素规格都以此为准, +> 比稿 HTML 与后续 `tokens.css` 改造从这里抄值,不自行发明。 +> 所有对比度为按 WCAG 2.x 相对亮度公式实算,非目测。 + +## 1. 方向一句话 + +**制图纸上的瑞士排版,蓝图作签名。** BootAgent 做的事是「把一台机器按图纸接线」—— +Agent、Provider、Profile 之间的关系天然是接线图。界面主体是安静的瑞士式网格与字阶, +个性集中在一层克制的工程制图语言:引出线、图签、尺寸标注。暗色模式反转为蓝晒 +(cyanotype)底,蓝图的两种物理形态恰好对应明暗双色板。 + +现有陶土色不删除,降级为品牌印记色:只出现在应用图标、启动画面、关于页。 +`brand/` 资产不重绘。 + +## 2. 调色板 + +### 2.1 亮色(drafting paper) + +| Token(沿用现名) | 值 | 角色 | 实算对比度 | +| --- | --- | --- | --- | +| `--page-bg` | `#f2f4f4` | 图纸底(微冷灰,非奶油) | — | +| `--window-bg` | `#fdfdfc` | 图面/卡片(sheet white) | — | +| `--text-primary` | `#152532` | 主墨(蓝黑制图墨) | 14.17:1 on page,15.37:1 on sheet | +| `--text-secondary` | `#4a5b66` | 次级文本 | 6.38:1 on page | +| `--text-tertiary` | `#5f7280` | 三级文本 | 4.91:1 on sheet | +| `--accent` | `#20618e` | 制图蓝:操作、链接、选中 | 文本 6.53:1 on sheet;白字在其上 6.64:1 | +| `--accent-hover` | `#1b537a` | 悬停加深 | 白字 8.19:1 | +| `--focus-ring` | `#1b537a` | 焦点环(2px,offset 2px) | 7.42:1 on page(≥3:1 达标) | +| `--green` | `#2f6b46` | 成功文本 | 6.23:1 on sheet | +| `--orange` | `#8a5a12` | 警告文本(沿用现值,本就达标) | 5.81:1 on sheet | +| `--red` | `#b0301c` | 危险文本 | 6.28:1 on sheet | +| `--wire`(新增) | `#9dbcd0` | 装饰线:网格纸、引出线的静息态 | 1.96:1,纯装饰,永不承载语义 | + +软底色(`*-soft`)一律取对应色相的 8% 不透明度等效实色,仅作底、不作边界。 +边框族:`--border: rgba(21,37,50,0.20)`、`--border-strong: 0.32`、`--border-faint: 0.08`。 + +**危险与主操作的色相分离是本次换色的硬收益**:蓝 `#20618e` 与红 `#b0301c` 相距半个 +色环,替代现状 `#a94623` vs `#a32b1d` 的同族困境。 + +### 2.2 暗色(cyanotype) + +| Token | 值 | 实算对比度 | +| --- | --- | --- | +| `--page-bg` | `#0f161b` | — | +| `--window-bg` | `#161f26` | — | +| `--text-primary` | `#e9eef2` | 15.62:1 on page | +| `--text-secondary` | `#aebbc4` | 8.51:1 on sheet | +| `--accent`(文本/线) | `#6cb0dc` | 7.06:1 on sheet;作焦点环 7.72:1 on page | +| `--accent` 填充 | `#2e6f9e` | 白字在其上 5.41:1 | +| `--green` | `#7ec59a` | 8.22:1 on sheet | +| `--red` | `#e88a76` | 6.62:1 on sheet | +| `--wire` | `rgba(108,176,220,0.16)` | 装饰 | + +暗色沿用现有「media query + `.theme-light` 强制」双声明结构,两块必须同步改。 + +### 2.3 品牌印记色 + +`--brand: #d96e49`(亮)/ `#e4855c`(暗)保留原值,但从「可操作强调」的岗位上撤下: +允许出现的位置逐条列举为——应用图标、启动画面、关于页 logo、导出文件的封面标识。 +界面控件、链接、选中态一律不得使用。 + +## 3. 字体 + +三个角色,全部 SIL OFL,可打包进产物、许可证走 `scripts/generate_third_party_licenses.py` +入 NOTICE。产物内本地打包,不发外部字体请求(比稿 HTML 例外:预览用 CDN,已在文件内注明)。 + +| 角色 | 字体 | 理由与用法 | +| --- | --- | --- | +| Latin 正文与标题 | **IBM Plex Sans**(400/500/600) | 瑞士式纪律:一族字体靠字重与字号承担全部层级,不引入第二族展示字。Plex 本身就是为工程语境设计的 grotesque,与制图方向同源。标题 = 600 + 收紧字距,不另选花哨展示体。 | +| 注记与数据 | **IBM Plex Mono**(400/500) | 蓝图注记声:版本号、模型 ID、路径、图签、引出线标签全部走 mono。与 Plex Sans 原生同族配对。 | +| CJK | **Noto Sans SC**(400/500/700,子集化) | 覆盖完整、OFL、与 grotesque 的灰度匹配良好。构建期按实际用字子集化控制体积。比稿在未加载时回退 PingFang SC,形态相近。 | + +字阶(自 13px 基准,1.25 比例取整):13 / 15 / 17 / 21 / 26 / 33。行高:正文 1.6, +标题 1.25,mono 注记 1.5。中英混排场景(Agent 名 + 中文说明)以 15px 为主工作字号。 + +## 4. 签名元素规格(只此三处) + +签名层的预算刻意花在一个地方:**制图注记语言**。三处之外不得出现,扩散需重开 ADR。 + +### 4.1 总览页(比稿 Tile A 所示) + +- **网格纸底**:`--page-bg` 上以 `--wire` 画 8px 细网格、32px 主网格 + (opacity 分别约 0.5/1.0 倍的 `--wire`)。卡片(sheet)内部无网格——纸在图下。 +- **图签(title block)**:页头右侧一个 1px 边框小表格,mono 排印: + `SHEET 01 · ENVIRONMENT OVERVIEW / REV · <日期>`。 + REV 用真实构建哈希——图纸修订号即软件版本,这个双关是功能性的,不是装饰。 + (比稿初版曾含 `SCALE 1:1` 第三格,自评后删除:不承载信息的图签格是装饰噪声。) +- **分区字母**:三个区段按制图图区惯例编号 `A 命令行 Agent`、`B 桌面 Agent`、 + `C 运行时`;每行 Agent 携带小号 mono 位号(A1…A9、B1…B3),对应制图的元件位号。 + 位号同时是行内锚点,未来漂移徽标可引用「A7 检测到外部修改」。 +- **引出线(leader line)**:Agent 行右侧至其 Provider/模型注记之间画 1px 折线, + 起点一个 3px 空心圆端子。已配置 = 实线 `--accent`;未配置 = 虚线 `--wire` 指向 + 「未配置」。这是「接线图」隐喻的最小可视形态。 + +### 4.2 向导 review 步 / 激活前 diff 预览 + +尺寸标注样式:`|— 键名 · 旧值 → 新值 —|`,两端 tick、mono 排印、变更值 `--accent`。 +(对应优化方案 M2 的 diff 预览,视觉规格在此锁定。) + +### 4.3 空态 + +网格纸底上一个 1px 虚线制图框,框内居中一行说明 + 一个主按钮。不配插画。 + +## 5. 组件基调 + +- **零软阴影预算**:卡片与面板靠 1px 墨线(`--border`)与底色差分层,`--shadow-window` + 仅保留给真正浮起的窗口/对话框一级。 +- 圆角收紧:`--radius-control: 4px`、`--radius-panel/card: 6px`、`--radius-window: 10px` + ——制图语言容不下 12px 的软角。 +- 状态徽标:方角(2px)、1px 边框、mono 11px、色相点 + 文字(不只靠颜色)。 +- 按钮:主要 = `--accent` 填充白字;次要 = 1px 墨线透明底;危险 = `--red` 文本 + 边线, + 确认类危险操作才用红填充。 +- 动效:一组编排(页面载入时图签与分区标题先落、行随后 40ms 级联;引出线在行 + hover 时从端子向注记「画出」150ms)。`prefers-reduced-motion` 下全部关闭。 + +## 6. 已比稿的备选:新粗野主义(Tile B,未采纳) + +同一 overview 内容做了完整演绎:`#edeee8` 底、`#151515` 2px 描边、`4px 4px 0` 硬阴影、 +陶土/黄/绿色块、Archivo Black 标题。保留比稿以记录判断依据: + +1. 声部不合:粗野的海报腔与「安静地替你写配置文件」的产品人格相抵; +2. 暗色不诚实:硬黑阴影在近黑底上消失,只能用亮描边假装,风格骨架在暗色下塌掉一半; +3. 密度预算花错位置:13 行 Agent 的信息密度下,粗框比内容更响。 +4. 对比度上它并非不合格(黑字在陶土上 5.46:1 达标;白字在陶土上 3.35:1 不达标, + 故该方向若采用,规则必须是「陶土块上永远用墨字」)。 + +## 7. 比稿文件 + +- [Tile A — Swiss + Blueprint(主推)](assets/restyle/tile-a-swiss-blueprint.html) +- [Tile B — 新粗野(备选)](assets/restyle/tile-b-neobrutalist.html) + +两版使用完全相同的真实界面文案(取自 `EnvironmentOverviewPage` / `AgentManageRow` 的 +i18n 串)与相同的 Agent/Provider 清单,比较的是风格本身。每版尾部附暗色条带与 +组件规格行。 diff --git a/docs/ux-and-agent-capability-optimization-plan.md b/docs/ux-and-agent-capability-optimization-plan.md new file mode 100644 index 00000000..3c8fb948 --- /dev/null +++ b/docs/ux-and-agent-capability-optimization-plan.md @@ -0,0 +1,345 @@ +# 对标 cc-switch 的 UX 与 Agent 适配优化方案 + +> 状态:待评审。起草于 2026-08-17。对照对象为 cc-switch v3.19.2 源码(Tauri 2 + Rust), +> 本仓库现状核对至 `feat/skills-mcp-ui` 分支(`08de571`)。文中 cc-switch 的行为均出自 +> 其源码调研,BootAgent 的行为均带本仓库 `file:line`。 + +## 1. 对照结论:两边的产品模型差异 + +cc-switch 是「切换器」:打开就是供应商卡片列表,单击即切换;托盘不开窗口也能切, +且托盘里直接显示订阅用量。它的适配深度不均——Claude Code / Codex 的配置是整文件 +覆盖(靠切换前回填保住用户改动,回填失败仅告警继续),只有 OpenClaw / Hermes 做到 +注释级保留,只有 Claude Desktop 有配置漂移检测。 + +BootAgent 是「配置中心」:owned-key 白名单合并(`internal/config/write.go`)、语法冲突 +拒写、逐 Agent 考证凭据落点,写入正确性和可迁移性显著更强。代价是高频动作路径长 +(切换最短 3 步)、正确性优势用户不可见(备份存在但界面看不到)、能力约束靠激活时 +报错而非选择时预告。 + +由此得出本方案的主线:**把高频动作降到 1 步;把写入正确性变成看得见的界面;把 +Agent 能力差异从代码分支提升为声明式数据;补上 Claude Desktop 这一空白。** + +## 2. 目标与非目标 + +目标(全部可度量,见 §11 汇总): + +1. Overview 上切换 Profile 从 3 步降到 1–2 步,托盘可直切。 +2. 外部修改从「完全无感知」变为「刷新即见、精确到键、可恢复可采纳」。 +3. reasoning / context / 协议等能力约束在选择时预告,激活期报错仅剩兜底。 +4. 已有手工配置可一键导入为 Provider + Profile,不动 live 文件。 +5. macOS / Windows 上支持 Claude Desktop(直连模式)。 + +非目标(明确不做,防止范围蔓延): + +- **本地代理路由**(协议互转、故障转移、请求级用量计费)。这是 cc-switch 的护城河, + 但依赖常驻中间人代理,与本产品「不落敏感数据」的承诺冲突面大,且现有 + 「本地格式转换」功能已占据类似生态位。是否演进为可选路由层是独立的产品决策, + 不在本方案内。 +- 用量统计(依赖代理截流或会话日志解析,二期单独评估)。 +- Linux 桌面 Agent 支持(`internal/app/desktopapp.go:235` 的现状维持)。 + +## 3. 里程碑总览 + +| 里程碑 | 优先级 | 规模估算 | 依赖 | +| --- | --- | --- | --- | +| M1 一步切换(行内快切 + 托盘直切) | P0 | 中(前端为主 + 托盘 Go) | 无 | +| M2 配置健康(漂移检测 + 备份可视化 + 激活前预览) | P0 | 大 | 无,可与 M1 并行 | +| M3 Capability 矩阵声明化 | P1 | 中偏大 | 无 | +| M4 现有配置反向导入 | P1 | 中 | 复用 M2 的读回强化 | +| M5 Claude Desktop 直连适配 | P1 | 大 | M3 软前置(协议约束落点) | +| M6 防腐与技术债 | P2 | 小 × 4,穿插进行 | 无 | + +每个里程碑独立可发布,不做大爆炸式合并。 + +## 4. M1 一步切换 + +### 对标点 + +cc-switch 的日活体验建立在「1 步切换」上:主界面点卡片即切,托盘 +`handle_provider_tray_event` 直切且显示用量。BootAgent 目前最短路径 3 步 +(`/profiles` → 应用 → 勾选目标确认,或 overview → Agent 详情 → 选 Profile),托盘 +(`cmd/bootagent-desktop/main_wails.go:61-118`)只服务格式转换功能,且文案硬编码中文。 + +### 改动点 + +- 前端 `frontend/src/pages/EnvironmentOverviewPage.tsx`、`frontend/src/components/AgentRow.tsx` + / `AgentManageRow.tsx`:已安装且非 guide-only 的 Agent 行内加 Profile 快切下拉。 + 数据无需新接口:`StatusResponse` 已含 `profiles`(`ProfileSummary` 带 `protocol`)与 + `catalog`(`CatalogItem.Protocol`),前端即可完成协议兼容过滤。 +- 应用动作复用现有激活链路:`internal/app/agent.go:47`(`ActivateAgent`,统一写锁 + `writeMu` 天然防并发交错);桌面 Agent 走既有 `writeDesktopAgentConfig` 路径。 +- 托盘:`cmd/bootagent-desktop/main_wails.go` 增加「Agent → 兼容 Profile」二级菜单, + 当前绑定项打勾;直接调用 Go 用例(与 `SetConversionTargetProfile` 同构),不经前端。 + 托盘文案同步接入语言设置,消除硬编码中文。 + +### 方案要点 + +- 下拉只列协议兼容的 Profile;不兼容的置灰并展示原因(复用 `agent.go:130-132` 的 + 校验语义,文案前移)。 +- 应用中行内显示进行态;成功后行内更新 Profile 名与 restart 提示;失败行内展示后端 + 错误,不跳页、绑定不变。 +- 无兼容 Profile 时下拉给「去创建」入口,指向 `/profiles`。 + +### 验收标准(M5 Claude Desktop) + +1. Overview 上对任一已安装 CLI Agent 切换 Profile:点击展开 + 点击选择,共 2 次交互 + 完成(无确认弹窗;不再需要进入 Agent 详情页)。桌面 Agent 同样可用。 +2. 协议不匹配的 Profile 在下拉中置灰且带原因文案(例如「该 Profile 是 Anthropic + 协议,Codex 需要 OpenAI」),不可选中,而不是选中后报错。 +3. 应用失败(如网络探测失败、配置写入被拒)时:行内展示错误信息,Agent 原绑定与 + live 文件均不变(以 `AgentStatus.detected` 复核)。 +4. 托盘出现「切换 Profile」分组:每个已安装 Agent 一个子菜单,当前项打勾;主窗口 + 关闭状态下点击可完成切换,完成后菜单勾选状态刷新。 +5. 并发保护:新增一条并发测试证明快速连续切换两次不产生交错写入(断言最终 live + 与最后一次选择一致,备份序列完整)。 +6. 托盘菜单文案跟随应用语言设置。 +7. 门禁:`go test ./...`、`go test -race ./...`、frontend `pnpm run test` / + `pnpm run test:e2e` 全绿;e2e 新增行内快切主路径用例。托盘路径 e2e 无法覆盖, + 附手工验收清单(macOS / Windows / Linux 各跑一遍)。 + +## 5. M2 配置健康:漂移检测、备份可视化、激活前预览 + +### 对标点(M2 配置健康) + +cc-switch 只对 Claude Desktop 做漂移检测(expected vs actual base_url、非法模型名), +但它的黄条 +「重新切换当前供应商可修复」动作提示是很好的交互范式。BootAgent 的 +owned-key 模型让我们能把这件事做到**全部 13 个适配器、精确到键**——这是 cc-switch +整文件覆盖的架构做不到的,是本方案里最能拉开差距的一项。 + +### 现状地基(已核实) + +- `internal/config/discovery.go` 已有逐 Agent 读回(`ReadPIConfig:109`、 + `ReadOpenClawConfig:136` 等),刻意只读 BootAgent 自有条目;结果经 + `internal/app/status.go:507` 进入 `AgentStatus.detected`(`baseUrl` / `model` / + `managedByBootAgent` / `unreadable` 四个字段)。 +- 期望值基准已存在:Agent 绑定(`internal/profile` 的 `AgentBinding`,含 provider / + baseURL / model / reasoningEffort)。 +- 备份基建已存在:`securefs.AtomicWrite` 自动备份,`~/.bootagent/backup` 每目标保留 + 3 份,Settings 可调——但 UI 完全没有入口。 + +### 改动点(M2 配置健康) + +- `internal/config`:把各适配器隐含在 merge 实现里的 owned-key 集合提取为公共的 + `OwnedState(binding) → map[key]expected` + `ReadOwnedState(live) → map[key]actual`, + drift = 两者逐键对比。`Detected` 从「baseURL + model」扩展到 reasoning / context + 等全部受管键;API key 只比对存在性与哈希,任何界面与日志不出现明文。 +- `internal/app`:新增 `AgentConfigHealth` 用例与 DTO;漂移计算挂在既有 + `StatusResponse` 组装路径上(status 每次刷新即重算,天然覆盖「窗口重新聚焦 / + 手动刷新」两个触发时机,不引入文件监听)。 +- 两个修复动作:**恢复受管值**(用已存绑定重放 `activateAgentLocked`)与 + **采纳外部修改**(把 live 实际值写回绑定,若绑定挂 Profile 则提示「仅本 Agent + 生效」或「更新 Profile」二选一)。 +- 备份可视化:新增列出 / 回滚用例(回滚前先对当前文件再做一次备份),前端在 + Agent 详情页展示备份历史。 +- 激活前 diff 预览:应用 Profile 的确认界面展示「将写入哪些文件、改哪些键、 + 前后值」(owned-key 模型的天然能力;密钥显示为掩码)。 +- DTO 变化按 AGENTS.md 红线三处同步:重新生成 `frontend/bindings`、更新 + `frontend/src/backend/wails.ts` 与 `frontend/src/types/api.ts`。 + +### 验收标准(M2 配置健康) + +1. 手工把 `~/.codex/config.toml` 的受管 `model` 改成其他值 → 窗口重新聚焦后 + overview 该行出现漂移徽标;详情页显示「model:期望 X,实际 Y」,精确到键。 +2. **零误报是硬标准**:外部修改非受管内容(用户自己的 section、注释、无关键)不 + 触发漂移。13 个适配器各至少一条正例 + 一条反例测试(golden fixtures 扩展)。 +3. 「恢复受管值」后 live 恢复到绑定描述的状态,用户自有字段原样保留(复用既有 + merge 语义测试断言)。 +4. 「采纳外部修改」后徽标消失,绑定更新,重启 BootAgent 后状态一致(幂等)。 +5. API key 漂移显示为「凭据已被外部修改」,明文不出现在 DTO、界面、日志(对日志 + 与 DTO 序列化做 grep 断言测试)。 +6. 配置文件缺失 / 解析失败时健康区块给出确定态文案(沿用 `Detected.Unreadable` + 语义),不崩溃、不误报漂移。 +7. 备份历史:详情页能看到该 Agent 配置目标最近 N 份备份(时间戳排序);一键回滚 + 前自动再备份当前文件;回滚后展示恢复结果。 +8. 激活前预览与实际写入一致:预览列出的键值改动与落盘后的读回逐键相等(测试用 + 同一 OwnedState 函数断言,防止两套实现漂移)。 +9. 门禁全绿,含 `go test -race`(status 刷新与激活并发路径)。 + +## 6. M3 Capability 矩阵声明化 + +### 对标点(M3 Capability 矩阵) + +cc-switch 新增一个 Agent 要动 41 个文件里的 `AppType` match(Hermes 实际摸了 15 个 +文件 56 处)。BootAgent 的 CLI Agent 已数据驱动(`manifests/agents.lock.json` + +`internal/catalog/types.go:20`),但能力约束仍是代码:reasoning 三套收窄映射 +(`internal/config/write.go:74-153`)、7 个 Agent 的静默丢弃清单 +(`internal/app/agent.go:237-248`)、ZCode 协议写死 openai +(`internal/config/write.go:311-316`)。后果是 UI 无法预知约束,只能激活时报错。 + +### 改动点(M3 Capability 矩阵) + +- `internal/catalog/types.go` 的 `Agent` 增加 `Capabilities` 子结构(示意): + + ```json + "capabilities": { + "reasoning": { "levels": ["off", "low", "medium", "high", "xhigh"], + "map": { "max": "xhigh", "off": "none" } }, + "context_window": "none | boolean_1m | numeric", + "protocols": ["openai"], + "restart": "required | hot | none" + } + ``` + + 按现状**翻译而非改动行为**:Codex / Aider / OpenCode / Kilo / dsh-official 各自的 + 词表照搬现有映射函数;7 个不支持 reasoning 的 Agent 写显式 `"reasoning": null`。 + 显式否定与「漏写字段」在 schema 校验中区分开:漏写即加载失败,杜绝「忘记声明」 + 静默通过。 +- `internal/catalog` 加载期校验词表合法性;`CatalogItem` 向前端暴露所需投影 + (沿用 `SelectsModel` 的先例:UI 需要词表本身时暴露数组,需要决策时暴露布尔)。 +- `internal/app/agent.go` 与 `internal/config/write.go`:映射函数保留为实现,但由 + capability 数据选择路由;`ModelSelection` 字段并入 capabilities(保留 JSON 兼容读取)。 +- 前端:应用 Profile 弹窗与 Agent 详情页,对每个目标 Agent 显示该 Profile 的 + reasoning 将「直传 / 映射为 X / 被忽略(原因)」;档位选择器按能力联动。 + +### 验收标准(M3 Capability 矩阵) + +1. `agents.lock.json` 全部 13 个 CLI Agent 都有显式 capabilities;缺失或词表非法时 + `catalog.LoadEmbedded` 报错,配单测。 +2. **行为零回归**:改造前后 reasoning / context 的落盘结果完全一致——以现有 + `internal/config/write_test.go` 与 golden fixtures 为准绳,改造不修改任何既有 + fixture 内容即视为达标。 +3. 应用 Profile 弹窗中(举例验收):勾选 Claude Code 时 reasoning=high 的 Profile + 显示「Claude Code 无档位设置,将忽略」;勾选 Codex 显示「high 直传」;勾选 + dsh 官方 route 时 medium 显示「不支持」。文案与后端行为同源(同一 capability + 数据),新增测试断言 UI 展示逻辑与后端翻译函数在全档位 × 全 Agent 矩阵上一致。 +4. 原「激活时才报 reasoning 不支持」的路径全部前移为选择时预告;激活期该类报错 + 仅剩数据竞争兜底(能触发它的 UI 路径不复存在)。 +5. 扩展性演示测试:注入一个 test-only Agent 词表(不改 Go 代码),翻译与 UI 约束 + 即生效。 +6. 门禁全绿 + bindings / `wails.ts` / `api.ts` 三处同步。 + +## 7. M4 现有配置反向导入 + +### 对标点(M4 反向导入) + +cc-switch 有三套回流机制(首启动把 live 导入为 default 供应商、additive 类每次启动 +反向同步、切换前回填)。BootAgent 因 owned-key 模型不需要回填,但「把用户已有的 +手工配置变成 Provider + Profile」的入口完全缺失——现有 `discovery.go` 刻意只读 +BootAgent 自有条目(`ReadPIConfig:106-108` 的注释就是这个立场),对手工配置无能为力。 +这挡住了存量用户的迁移。 + +### 改动点(M4 反向导入) + +- `internal/config/discovery.go`:新增 foreign 读取路径(与现有 owned 读取并列, + 不改变后者语义),按适配器解析「用户手工配置的 baseURL / model / key 所在」。 + 只在用户显式点击导入时才读 key,读取结果直接进 secret 存储。 +- 新用例 `ImportDetectedConfig`:生成 custom Provider(baseURL 未知时)+ Profile + + 该 Agent 的绑定。**导入不写任何 Agent live 文件。** +- 前端:Setup 向导与 overview 空态出现「检测到已有配置,导入为 Profile」卡片。 + +### 验收标准(M4 反向导入) + +1. 机器上已有手配的 `~/.claude/settings.json`(第三方 baseURL + key + model):首启 + 向导出现导入卡片,一键后生成 Provider(baseURL 正确)+ Profile(model / 协议 + 正确)+ 绑定;全程 live 文件字节不变(对文件内容做前后哈希断言——mtime 不可靠)。 +2. key 进 secret 存储,UI 不回显明文,DTO 与日志无泄漏(grep 断言)。 +3. 幂等:重复导入同一配置不产生重复 Provider / Profile(按 baseURL + model 判重, + 命中时提示并复用)。 +4. 至少覆盖三种格式代表:Codex(TOML)、Claude Code(JSON env)、OpenCode + (JSON provider 段)的导入测试。 +5. 无法解析(损坏 / 未来版本 shape)时给出确定失败文案,不产生半成品 Provider。 + +## 8. M5 Claude Desktop 直连适配 + +### 对标点与机制依据 + +cc-switch 对 Anthropic 官方桌面客户端的适配依赖其自带的「第三方部署模式」: +`claude_desktop_config.json` 的 `deploymentMode`(`1p`/`3p`)+ `Claude-3p/configLibrary/` +下的 profile JSON(`inferenceGatewayBaseUrl` / `inferenceGatewayApiKey` / +`inferenceModels` / `inferenceProvider: "gateway"`)。cc-switch 用固定 UUID 注册自有 +profile,写前 4 文件快照、失败整体回滚,恢复官方时只摘除自己的痕迹。 + +BootAgent 桌面端已支持 ChatGPT Desktop / WorkBuddy / ZCode / DSH Desktop,唯独缺 +Claude Desktop。本里程碑只做**直连模式**:Anthropic 协议 Provider + `claude-*` 安全 +模型名。模型映射玩法(在 Claude Desktop 里用非 Claude 模型)依赖本地代理,属 §2 +非目标。 + +### 改动点(M5 Claude Desktop) + +- `internal/desktopapp/registry.go` 新增 Definition:`inspect`(检测安装 + 读 profile + 与 `deploymentMode` 现状)、`ManualInstall: true`(给官网链接)、`open`。 + 仅注册于 macOS / Windows。 +- 新写入适配器(落在 `internal/desktopapp` 或 `internal/config`): + - `deploymentMode` 读-改-写只碰这一个键(两个目录的 config 都写); + - `configLibrary/<固定UUID>.json` 写入 gateway profile(品牌名 BootAgent); + - `_meta.json` 只增删自己的条目,`appliedId` 移交逻辑对齐 cc-switch 语义; + - 恢复官方:`deploymentMode=1p` + 删自有 profile + meta 摘除; + - 4 文件组事务:写前快照、任一步失败全部回滚(`securefs` 原子写之上补组语义)。 +- 模型名校验:`claude-{sonnet,opus,haiku,fable}-` 前缀 + 非空尾;拒绝 `[1m]` 后缀 + 混入模型名(如需 1M 声明,翻译为 profile 的 `supports1m` 字段)。这是 Claude + Desktop fail-all validator 的硬约束——一个非法名会导致整组模型拒收。 +- Provider 侧:仅 Anthropic 协议 Provider 可选(M3 的 protocols 能力有了落点), + 探测走 `anthropic_base_url`。 + +### 验收标准 + +1. macOS 装有 Claude Desktop 时,桌面 Agent 列表出现该项;应用 Anthropic 协议 + Profile 后:两个 `claude_desktop_config.json` 的 `deploymentMode` 为 `3p` 且**其余 + 键逐字节不动**(fixture 断言);profile 文件含正确的 base_url / key / 模型数组; + `_meta.json` 仅多出自己一条。 +2. 重启 Claude Desktop 后模型菜单出现所配 `claude-*` 模型且能正常对话——人工验收, + 结果(客户端版本号 + 截图)记入 PR。 +3. 恢复官方:`deploymentMode=1p`、自有 profile 删除、meta 摘除自己、用户自建的其他 + profile 条目原样保留(fixture 断言)。 +4. 非法模型名(非 claude 前缀、`claude-sonnet-` 空尾、带 `[1m]`)在 UI 选择阶段即 + 拒绝;写入路径保留第二道校验(对齐 cc-switch 的同类测试用例集)。 +5. 模拟 profile 写入失败 → 4 文件全部回滚到写前内容(单测)。 +6. 非 Anthropic 协议 Profile 对 Claude Desktop 置灰并显示原因(M1 的交互范式)。 +7. Windows 的 `Claude` / `Claude-3p` 目录探测(含非精确目录名回退)有单测;Linux + 上该 Agent 完全不出现。 +8. 发布说明标注:该机制基于官方客户端未公开文档的行为,客户端更新可能失效; + `inspect` 检测到 profile 结构不符时降级为「不可配置 + 提示」,不硬写。 + +## 9. M6 防腐与技术债(穿插进行) + +| 项 | 内容 | 验收 | +| --- | --- | --- | +| Kilo JSONC 注释保留 | 现走 `internal/jsonorder` 只保 key 序不保注释,是「保留用户手改」承诺唯一破口。实现文本级注释保留写入(参照 cc-switch 对 OpenClaw/Hermes 的 section 级文本编辑思路) | 带注释 fixture 写入后注释逐字保留;既有合并语义测试零回归 | +| shape 防腐探测 | ZCode 配置 shape 是逆向观测(3.1.1→3.1.3 已变过),dsh 仍是 preview。激活前对目标文件做已知 schema 指纹校验,未知结构拒写并给出明确文案 | 伪造「未来版」shape 的 fixture 触发降级路径;正常 shape 不受影响 | +| dsh 双路径一般化 | `internal/app/agent.go:275-278` 的官方/通用 route 分支(provider × agent 二维适配唯一先例)改为 Provider 目录侧声明 | 该 per-agent 分支消失;`WriteDSHOfficial` 行为零回归(fixtures 不变) | +| 平台不可用 Agent 置灰 | 当前不支持平台的 Agent 完全不渲染(docs/status-dot-ui-plan.md 已记录)。改为置灰 + 「在 macOS/Windows 可用」 | Linux 上可见 ChatGPT Desktop 灰条与说明文案 | + +## 10. 统一验收门禁(每个里程碑发布前都要过) + +```text +go test ./... +go test -race ./... +go vet ./... # CI 同时跑 staticcheck +cd frontend && pnpm run test && pnpm run build && pnpm run test:e2e +python3 scripts/check-docs.py +``` + +另加三条纪律: + +1. DTO 变化必须三处同步(重新生成 `frontend/bindings`、`frontend/src/backend/wails.ts` + 和 `frontend/src/types/api.ts`),CodeGraph 查 `AgentService` 复核漏改位置。 +2. 行为改动与 bug 修复配聚焦测试;密钥不进 DTO、日志、错误信息。 +3. 涉及 live 文件写入的改动,golden fixtures 先行:先写「期望落盘内容」再写实现。 + +## 11. 预期结果度量汇总 + +| 维度 | 现状 | 完成后 | 归属 | +| --- | --- | --- | --- | +| Overview 切换 Profile 步数 | 3 步(进详情页) | 2 次点击行内完成 | M1 | +| 无窗口切换 | 不可能(托盘仅转换功能) | 托盘直切 + 状态勾选 | M1 | +| 外部修改感知 | 完全无感知 | 刷新/聚焦即见,键级定位,可恢复可采纳,零误报 | M2 | +| 备份 | 存在但不可见 | 可列出、可回滚(回滚前再备份) | M2 | +| 激活前可预知写入内容 | 否 | 逐键 diff 预览,与落盘一致 | M2 | +| reasoning 约束暴露时机 | 激活时报错 / 静默忽略 | 选择时预告(直传/映射/忽略+原因) | M3 | +| 新 Agent 能力接入成本 | 改 3 处 Go 映射/清单 | 改 `agents.lock.json` 一处 | M3 | +| 存量手工配置迁移 | 手动重录 | 一键导入,live 文件零改动 | M4 | +| Claude Desktop | 不支持 | macOS/Windows 直连模式,含完整还原 | M5 | +| 「保留用户手改」承诺 | Kilo 注释会丢 | 13/13 适配器含注释全保留 | M6 | + +## 12. 风险 + +1. **Claude Desktop 机制未公开文档**(最大单点风险):`3p` / `configLibrary` 行为来自 + 对 cc-switch 的逆向与实测,官方客户端更新可能改 schema 或收紧 validator。缓解: + M5 验收第 8 条的降级路径 + M6 的 shape 探测思路同样适用于它;把「客户端版本 × + 验证结果」记录进 PR 形成追踪基线。 +2. **托盘跨平台差异**:Linux 各桌面环境托盘菜单能力不一。缓解:托盘直切按 + 「macOS/Windows 保证、Linux 尽力」定验收,主窗口路径是全平台兜底。 +3. **capabilities 翻译错误**:把三套映射搬进数据时抄错一档。缓解:M3 验收第 2 条的 + 「fixtures 一字不改」硬标准 + 全档位 × 全 Agent 矩阵一致性测试。 +4. **漂移检测误报**引发用户不信任。缓解:M2 验收第 2 条把零误报列为硬标准,反例 + fixture 全适配器覆盖;徽标文案永远给出「期望 / 实际」证据而非笼统告警。 diff --git "a/docs/\351\234\200\346\261\202\346\224\266\351\233\206\344\274\232\350\256\256\346\226\207\346\241\243.md" "b/docs/\351\234\200\346\261\202\346\224\266\351\233\206\344\274\232\350\256\256\346\226\207\346\241\243.md" new file mode 100644 index 00000000..83db5141 --- /dev/null +++ "b/docs/\351\234\200\346\261\202\346\224\266\351\233\206\344\274\232\350\256\256\346\226\207\346\241\243.md" @@ -0,0 +1,204 @@ +# BootAgent 产品需求收集 + +## 🎯 BootAgent 产品简介 + +**定位一句话**:本地桌面 AI Agent 配置中心,统一管理多个 AI 编程助手的安装、配置与切换。 + +**当前支持的 Agent**(13 个 CLI + 5 个桌面): +- CLI:Codex、Claude Code、Kilo CLI、Aider、OpenCode、Hermes Agent、OpenClaw、Kimi Code、DeepSeek Harness 等 +- 桌面:DSH Desktop、Claude Desktop、ChatGPT Desktop、WorkBuddy、ZCode + +**核心能力**: +- 检测、安装、更新、启动 Agent +- 连接到内置或自定义 Provider(API 供应商) +- 保存可复用的 Profile(配置档)并一键应用 +- 跨 Agent 同步 MCP 服务器配置 +- 本地存储凭据,原子写入配置,保留 3 份历史备份 + +--- + +## 🔍 第一部分:用户痛点收集 + +> **收集目标**:了解当前使用过程中遇到的真实问题,按严重程度排序。 + +### 📊 痛点清单(请在表格中填写) + +**【此处插入飞书多维表格】** + +| 痛点描述 | 遇到频率 | 影响严重度 | 期望解决方式 | 提出人 | +|---------|---------|-----------|------------|--------| +| 示例:每次切换 Profile 需要进入详情页,步骤太多 | 每天多次 | ⭐⭐⭐⭐⭐ | 在总览页一键切换 | @张三 | +| | | | | | +| | | | | | +| | | | | | +| | | | | | +| | | | | | +| | | | | | + +**字段说明**: +- 遇到频率:每天多次 / 每天一次 / 每周几次 / 偶尔 +- 影响严重度:⭐(轻微) ~ ⭐⭐⭐⭐⭐(严重阻塞) + +--- + +### 💬 开放问题 + +#### Q1: 你最常用 BootAgent 的哪个功能?为什么? + +
+点击展开回答区 + +**【此处参会者直接在文档中回复,@自己的名字】** + +--- + +
+ +#### Q2: 有哪些操作你现在完全靠手动修改配置文件,还没用 BootAgent? + +
+点击展开回答区 + +**【此处参会者直接在文档中回复】** + +--- + +
+ +#### Q3: 如果可以砍掉一个功能来换取核心功能的完善,你会砍哪个? + +
+点击展开回答区 + +**【此处参会者直接在文档中回复】** + +--- + +
+ +--- + +## 📦 第二部分:功能需求收集 + +> **收集目标**:明确「必须有」与「很想要」的新功能,以及它们的优先级。 + +### ✨ 新功能提案表 + +**【此处插入飞书多维表格】** + +| 功能名称 | 功能描述(一句话) | 解决的问题 | 优先级投票 | 提出人 | +|---------|------------------|-----------|-----------|--------| +| 示例:配置漂移检测 | 检测外部手改配置并提示恢复或采纳 | 配置被外部修改后不可见,出问题难排查 | P0 × 3票, P1 × 2票 | @李四 | +| | | | | | +| | | | | | +| | | | | | +| | | | | | + +**优先级说明**: +- P0:阻塞性,没有它严重影响使用 +- P1:重要,明显提升体验 +- P2:锦上添花 +- P3:低优先级 + + + +## 🎯 第三部分:用户场景与期望 + +> **收集目标**:了解真实使用场景,发现隐藏需求。 + +### 🧑‍💻 典型使用场景描述 + +**请描述你使用 BootAgent 的一个完整场景**(从打开应用到完成任务),包括: + +**【此处插入飞书多维表格】** + +| 场景描述 | 涉及的功能 | 顺畅的环节 | 卡顿的环节 | 提出人 | +|---------|-----------|-----------|-----------|--------| +| 示例:每天早上切换到低成本模型写代码,晚上切回高性能模型改 bug | Profile 切换 | Agent 自动重启生效 | 需要进详情页选 Profile,3 步才能切 | @赵六 | +| | | | | | +| | | | | | +| | | | | | + +--- + +### 🔮 期望中的「理想状态」 + +#### Q6: 如果 BootAgent 可以"读懂你的心思",你希望它自动帮你做什么? + +
+点击展开回答区 + +**【此处参会者直接在文档中回复】** + +--- + +
+ +#### Q7: 你会向朋友推荐 BootAgent 吗?如果会,你会怎么介绍它的价值? + +
+点击展开回答区 + +--- + +
+ +--- + +## 🚀 第五部分:竞品对比与差异化 + +> **收集目标**:了解用户对 cc-switch 等竞品的认知,明确 BootAgent 的差异化方向。 + +### ⚖️ 对比感知调查 + +**【此处插入飞书投票】** + +**问题**:你是否使用过或了解 cc-switch? + +- [ ] 正在使用 cc-switch +- [ ] 用过 cc-switch,但已切换到 BootAgent +- [ ] 听说过,但没用过 +- [ ] 不了解 + +--- + +### 💬 差异化讨论 + +#### Q8: 相比 cc-switch,你认为 BootAgent 的核心优势是什么? + +
+点击展开回答区 + +**【参考维度】**: +- 配置写入的正确性(owned-key 合并 vs 整文件覆盖) +- 支持的 Agent 数量与种类 +- 本地存储策略(不落敏感数据) +- 界面易用性 +- 其他 + +--- + +
+ +#### Q9: cc-switch 有哪些功能是你希望 BootAgent 也具备的? + +
+点击展开回答区 + +**【已知 cc-switch 独有但本次不做的功能】**: +- 本地代理路由(协议互转、故障转移)→ 与「不落敏感数据」承诺冲突 +- 用量统计 → 二期单独评估 + +--- + +
+ +--- + +## 📝 第六部分:开放反馈与建议 + +> **收集目标**:捕捉任何未被上述框架覆盖的想法。 + +### 💭 自由发言区 + +**请在此处提出任何想法、疑问、建议、吐槽**: diff --git a/frontend/bindings/github.com/MaimoryLab/BootAgent/internal/binding/skillservice.ts b/frontend/bindings/github.com/MaimoryLab/BootAgent/internal/binding/skillservice.ts index 33d16be9..5571649b 100644 --- a/frontend/bindings/github.com/MaimoryLab/BootAgent/internal/binding/skillservice.ts +++ b/frontend/bindings/github.com/MaimoryLab/BootAgent/internal/binding/skillservice.ts @@ -19,6 +19,10 @@ export function Apply(request: app$0.SkillApplyRequest): $CancellablePromise { + return $Call.ByID(1167933261, id, hash); +} + export function List(): $CancellablePromise { return $Call.ByID(2228288007); } diff --git a/frontend/bindings/github.com/MaimoryLab/BootAgent/internal/binding/transferservice.ts b/frontend/bindings/github.com/MaimoryLab/BootAgent/internal/binding/transferservice.ts index a4c566fa..92362ddc 100644 --- a/frontend/bindings/github.com/MaimoryLab/BootAgent/internal/binding/transferservice.ts +++ b/frontend/bindings/github.com/MaimoryLab/BootAgent/internal/binding/transferservice.ts @@ -5,6 +5,10 @@ // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; +export function ExportV2(providerIDs: string[] | null, profileIDs: string[] | null, mcpIDs: string[] | null, skillIDs: string[] | null): $CancellablePromise { + return $Call.ByID(1870855265, providerIDs, profileIDs, mcpIDs, skillIDs); +} + export function Read(): $CancellablePromise { return $Call.ByID(824692923); } @@ -12,3 +16,7 @@ export function Read(): $CancellablePromise { export function Write(data: string): $CancellablePromise { return $Call.ByID(1519580634, data); } + +export function WriteBytes(encoded: string): $CancellablePromise { + return $Call.ByID(756993529, encoded); +} diff --git a/frontend/src/backend/wails.ts b/frontend/src/backend/wails.ts index 220a51e4..379801b4 100644 --- a/frontend/src/backend/wails.ts +++ b/frontend/src/backend/wails.ts @@ -123,6 +123,12 @@ function call(operation: () => PromiseLike): CancellableRequest { } } +const encodeBytes = (data: Uint8Array): string => { + let binary = ""; + for (let offset = 0; offset < data.length; offset += 0x8000) binary += String.fromCharCode(...data.subarray(offset, offset + 0x8000)); + return btoa(binary); +}; + export const wailsApi = { onInstallOutput, status: (): Promise => call(() => StatusService.GetStatus()) as Promise, @@ -256,6 +262,8 @@ export const wailsApi = { call(() => MarketplaceService.OpenExternal({ url })).then(() => undefined), readTransferFile: (): Promise => call(() => TransferService.Read()) as Promise, writeTransferFile: (data: string): Promise => call(() => TransferService.Write(data)).then(() => undefined), + writeTransferBytes: (data: Uint8Array): Promise => call(() => TransferService.WriteBytes(encodeBytes(data))).then(() => undefined), + exportTransferV2: (providerIDs: string[], profileIDs: string[], mcpIDs: string[], skillIDs: string[]): Promise => call(() => TransferService.ExportV2(providerIDs, profileIDs, mcpIDs, skillIDs)).then((data) => Uint8Array.from(atob(data ?? ""), (char) => char.charCodeAt(0))), listMCP: (): Promise => call(() => MCPService.List()).then((items) => items ?? []), scanMCP: (): Promise => call(() => MCPService.Scan()) as Promise, getMCP: (id: string, sourceAgent = ""): Promise => call(() => MCPService.Get(id, sourceAgent)) as Promise, @@ -267,6 +275,7 @@ export const wailsApi = { saveImportedMCP: (registry: import("../../bindings/github.com/MaimoryLab/BootAgent/internal/mcp/models.js").Registry): Promise => call(() => MCPService.SaveImported(registry)).then(() => undefined), setMCPDraftState: (dirty: boolean, locale: string): Promise => call(() => MCPService.SetDraftState(dirty, locale)).then(() => undefined), listSkills: (): Promise => call(() => SkillService.List()).then((items) => items ?? []), + exportSkill: (id: string, hash: string): Promise => call(() => SkillService.Export(id, hash)).then((data) => Uint8Array.from(atob(data ?? ""), (char) => char.charCodeAt(0))), scanSkills: (): Promise => call(() => SkillService.Scan()) as Promise, previewSkillImport: (source: string): Promise => call(() => SkillService.PreviewImport({ source })) as Promise, applySkills: (request: SkillApplyRequest): Promise => call(() => SkillService.Apply(request)) as Promise, diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index a18678d4..bf7638c4 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -218,6 +218,8 @@ const english = { "全选": "Select all", "取消全选": "Clear selection", "配置模版依赖": "Required by profile", + "个版本": "versions", + "暂无可导出的 Skills": "No Skills available for export", "语言": "Language", "外观": "Appearance", "跟随系统": "System", diff --git a/frontend/src/pages/TransferPage.tsx b/frontend/src/pages/TransferPage.tsx index 08ba5740..86759544 100644 --- a/frontend/src/pages/TransferPage.tsx +++ b/frontend/src/pages/TransferPage.tsx @@ -1,4 +1,4 @@ -import { CheckCheck, Eye, EyeOff, KeyRound, Upload } from "lucide-react"; +import { CheckCheck, Eye, EyeOff, KeyRound, Sparkles, Upload } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -47,6 +47,8 @@ export function TransferPage() { const [selectedProfiles, setSelectedProfiles] = useState(new Set()); const [mcpServers, setMcpServers] = useState<{ id: string; type: string }[]>([]); const [selectedMcp, setSelectedMcp] = useState(new Set()); + const [skills, setSkills] = useState([]); + const [selectedSkills, setSelectedSkills] = useState(new Set()); const [busy, setBusy] = useState(false); const [failure, setFailure] = useState(""); const [success, setSuccess] = useState(""); @@ -79,19 +81,21 @@ export function TransferPage() { const providers = status ? byProviderCreatedAt(status.providers) : []; const requiredProviders = useMemo(() => new Set(profiles.filter((profile) => selectedProfiles.has(profile.id)).map((profile) => profile.provider)), [profiles, selectedProfiles]); const exportProviders = new Set([...selectedProviders, ...requiredProviders]); - const canExport = selectedProfiles.size > 0 || exportProviders.size > 0 || selectedMcp.size > 0; - const allSelected = (providers.length > 0 || profiles.length > 0 || mcpServers.length > 0) - && selectedProviders.size === providers.length && selectedProfiles.size === profiles.length && selectedMcp.size === mcpServers.length; + const canExport = selectedProfiles.size > 0 || exportProviders.size > 0 || selectedMcp.size > 0 || selectedSkills.size > 0; + const allSelected = (providers.length > 0 || profiles.length > 0 || mcpServers.length > 0 || skills.length > 0) + && selectedProviders.size === providers.length && selectedProfiles.size === profiles.length && selectedMcp.size === mcpServers.length && selectedSkills.size === skills.length; const toggleAll = () => { if (allSelected) { setSelectedProviders(new Set()); setSelectedProfiles(new Set()); setSelectedMcp(new Set()); + setSelectedSkills(new Set()); return; } setSelectedProviders(new Set(providers.map(([id]) => id))); setSelectedProfiles(new Set(profiles.map((profile) => profile.id))); setSelectedMcp(new Set(mcpServers.map((server) => server.id))); + setSelectedSkills(new Set(skills.map((skill) => skill.id))); }; const askPassword = (mode: "export" | "import") => new Promise((resolve) => { @@ -145,7 +149,15 @@ export function TransferPage() { const entries = await Promise.all([...exportProviders].map((id) => api.getProvider(id))); const mcp = selectedMcp.size ? JSON.parse(await api.exportMCP(keys === "encrypted" ? "encrypted" : keys === "plain" ? "plaintext" : "omit", password || "", keys === "plain", [...selectedMcp])) : undefined; const selected = profiles.filter((profile) => selectedProfiles.has(profile.id)); - await api.writeTransferFile(stringifyTransfer(await makeTransfer(selected, entries, keys, password || "", mcp))); + if (selectedSkills.size) { + if (selectedSkills.size > 1 || selectedProfiles.size || exportProviders.size || selectedMcp.size) throw new Error("单 Skill 导出不能与其他类型同时选择"); + const skill = skills.find((item) => selectedSkills.has(item.id)); + const hash = skill?.variant_hashes?.[0]; + if (!skill || !hash) throw new Error("选中的 Skill 没有可导出的版本"); + await api.writeTransferBytes(await api.exportSkill(skill.id, hash)); + } else { + await api.writeTransferFile(stringifyTransfer(await makeTransfer(selected, entries, keys, password || "", mcp))); + } // Only the plain-text case needs a warning; the default carries no key at // all, and saying so is reassurance rather than a caveat. setSuccess(keys === "plain" @@ -214,7 +226,10 @@ export function TransferPage() { } }; - useEffect(() => { void api.listMCP().then((items) => setMcpServers((items ?? []).map(({ id, type }) => ({ id, type })))).catch(() => setMcpServers([])); }, []); + useEffect(() => { + void api.listMCP().then((items) => setMcpServers((items ?? []).map(({ id, type }) => ({ id, type })))).catch(() => setMcpServers([])); + void api.listSkills().then((items) => setSkills(items ?? [])).catch(() => setSkills([])); + }, []); if (!status) return
{t("正在读取环境状态")}
; @@ -336,6 +351,12 @@ export function TransferPage() {

{t("MCP 服务器")}

{t("已选择 {count} 项", { count: selectedMcp.size })}

{mcpServers.map((server) => )}
+
+

{t("Skills")}

{t("已选择 {count} 项", { count: selectedSkills.size })}

+
+ {skills.length ? skills.map((skill) => ) :

{t("暂无可导出的 Skills")}

} +
+
); diff --git a/internal/app/skill.go b/internal/app/skill.go index c039917b..740cee9d 100644 --- a/internal/app/skill.go +++ b/internal/app/skill.go @@ -90,6 +90,13 @@ type SkillBackupSummary struct { Variants int `json:"variants"` } +func (u *UseCases) ExportSkill(ctx context.Context, id, hash string) ([]byte, error) { + if err := contextError(ctx, "Skill export was cancelled"); err != nil { + return nil, err + } + return u.skillStore().Export(ctx, id, hash) +} + type skillPreview struct { expires time.Time source string diff --git a/internal/app/transfer.go b/internal/app/transfer.go new file mode 100644 index 00000000..62a6b6a4 --- /dev/null +++ b/internal/app/transfer.go @@ -0,0 +1,74 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/MaimoryLab/BootAgent/internal/mcp" + "github.com/MaimoryLab/BootAgent/internal/transfer" +) + +// ExportTransferV2 creates a portable, secret-free bundle. Skill archives are +// kept as nested ZIPs so the common container remains deterministic and the +// existing Skill ZIP importer can consume them without a second content model. +func (u *UseCases) ExportTransferV2(ctx context.Context, providerIDs, profileIDs, mcpIDs, skillIDs []string) ([]byte, error) { + if err := contextError(ctx, "transfer export was cancelled"); err != nil { + return nil, err + } + files := map[string][]byte{} + providers := make([]any, 0, len(providerIDs)) + for _, id := range providerIDs { + entry, err := u.GetProvider(ctx, id) + if err != nil { + return nil, err + } + entry.APIKey = "" + providers = append(providers, entry) + } + profiles, err := u.ListProfiles(ctx) + if err != nil { + return nil, err + } + selectedProfiles := make([]ProfileSummary, 0, len(profileIDs)) + allowed := make(map[string]bool, len(profileIDs)) + for _, id := range profileIDs { + allowed[id] = true + } + for _, profile := range profiles { + if allowed[profile.ID] { + selectedProfiles = append(selectedProfiles, profile) + } + } + providersJSON, err := json.MarshalIndent(map[string]any{"providers": providers}, "", " ") + if err != nil { + return nil, err + } + profilesJSON, err := json.MarshalIndent(selectedProfiles, "", " ") + if err != nil { + return nil, err + } + files["providers.json"], files["profiles.json"] = providersJSON, profilesJSON + if len(mcpIDs) > 0 { + data, err := u.ExportMCP(ctx, mcp.ExportOptions{Mode: mcp.SecretOmit, ServerIDs: mcpIDs}) + if err != nil { + return nil, err + } + files["mcp.json"] = data + } + for _, id := range skillIDs { + summary, err := u.GetSkill(ctx, id) + if err != nil { + return nil, err + } + if len(summary.VariantHashes) == 0 { + return nil, fmt.Errorf("Skill %q has no stored variant", id) + } + data, err := u.ExportSkill(ctx, id, summary.VariantHashes[0]) + if err != nil { + return nil, err + } + files["skills/"+id+".skill.zip"] = data + } + return transfer.Build(files) +} diff --git a/internal/binding/services.go b/internal/binding/services.go index 2d70630a..c684a7de 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -55,7 +55,7 @@ func NewServicesWithOptions(core *app.UseCases, opener BrowserOpener, options Se Profile: NewProfileService(core), Runtime: &RuntimeService{core: core, onOutput: options.InstallOutput, autostart: options.Autostart}, DesktopAgent: NewDesktopAgentService(core, options.InstallOutput), - Transfer: &TransferService{}, + Transfer: &TransferService{core: core}, MCP: NewMCPService(core), Skill: NewSkillService(core), Conversion: NewConversionService(core), diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index df2bd66a..a76b269d 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -54,8 +54,8 @@ func TestServiceMethodAllowlist(t *testing.T) { {&ProfileService{}, []string{"DeleteProfile", "ListProfiles", "SaveProfile"}}, {&RuntimeService{}, []string{"GetSettings", "InstallRuntime", "ListRuntimes", "SaveSettings"}}, {&DesktopAgentService{}, []string{"Configure", "GetStatus", "Install", "Open"}}, - {&TransferService{}, []string{"Read", "Write"}}, - {&SkillService{}, []string{"Apply", "List", "ListBackups", "PreviewImport", "RestoreBackup", "Scan", "SetDraftState", "Uninstall"}}, + {&TransferService{}, []string{"ExportV2", "Read", "Write", "WriteBytes"}}, + {&SkillService{}, []string{"Apply", "Export", "List", "ListBackups", "PreviewImport", "RestoreBackup", "Scan", "SetDraftState", "Uninstall"}}, {&ConversionService{}, []string{"Get", "Save"}}, {&MarketplaceService{}, []string{"ClearRecommendationHistory", "DeleteRecommendationHistory", "FetchShowcase", "FetchSkillDetail", "FetchSkillFile", "ListRecommendationHistory", "OpenExternal", "Recommend", "RecommendationAgents", "SaveRecommendationHistory"}}, {&UpdateService{}, []string{"Check", "DownloadAndInstall", "Restart", "Version"}}, diff --git a/internal/binding/skill.go b/internal/binding/skill.go index 2631fefa..9faded04 100644 --- a/internal/binding/skill.go +++ b/internal/binding/skill.go @@ -19,6 +19,16 @@ func NewSkillService(core *app.UseCases) *SkillService { return &SkillService{core: core} } +func (s *SkillService) Export(ctx context.Context, id, hash string) ([]byte, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + if s == nil || s.core == nil { + return nil, notReady("Skill service is not configured") + } + return s.core.ExportSkill(ctx, id, hash) +} + func (s *SkillService) List(ctx context.Context) ([]app.SkillSummary, error) { if err := contextError(ctx); err != nil { return nil, err diff --git a/internal/binding/transfer.go b/internal/binding/transfer.go index bfeff35e..9cccc352 100644 --- a/internal/binding/transfer.go +++ b/internal/binding/transfer.go @@ -2,18 +2,31 @@ package binding import ( "context" + "encoding/base64" "os" "runtime" + "github.com/MaimoryLab/BootAgent/internal/app" oneerrors "github.com/MaimoryLab/BootAgent/internal/errors" "github.com/MaimoryLab/BootAgent/internal/securefs" ) type TransferService struct { + core *app.UseCases openFile func() (string, error) saveFile func() (string, error) } +func (s *TransferService) ExportV2(ctx context.Context, providerIDs, profileIDs, mcpIDs, skillIDs []string) ([]byte, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + if s == nil || s.core == nil { + return nil, notReady("Transfer service is not configured") + } + return s.core.ExportTransferV2(ctx, providerIDs, profileIDs, mcpIDs, skillIDs) +} + func (s *TransferService) selectImport() (string, error) { if s.openFile != nil { return s.openFile() @@ -57,3 +70,22 @@ func (s *TransferService) Write(ctx context.Context, data string) error { } return nil } + +func (s *TransferService) WriteBytes(ctx context.Context, encoded string) error { + if err := contextError(ctx); err != nil { + return err + } + path, err := s.selectExport() + if err != nil || path == "" { + return oneerrors.New(oneerrors.InvalidRequest, "file selection cancelled") + } + filesystem := securefs.New(securefs.Options{OS: runtime.GOOS}) + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return oneerrors.New(oneerrors.InvalidRequest, "invalid export payload", oneerrors.WithCause(err)) + } + if _, err := filesystem.AtomicWrite(ctx, path, data, false); err != nil { + return oneerrors.New(oneerrors.ConfigWriteFailed, "Cannot write export file", oneerrors.WithCause(err)) + } + return nil +} diff --git a/internal/process/process_test.go b/internal/process/process_test.go index 7555cb1b..5e32b8d4 100644 --- a/internal/process/process_test.go +++ b/internal/process/process_test.go @@ -2,6 +2,7 @@ package process import ( "context" + "encoding/json" "errors" "io" "os" @@ -13,6 +14,23 @@ import ( "time" ) +func TestOutputIncludesDownloadSourceAndPhase(t *testing.T) { + payload, err := json.Marshal(Output{Kind: "source", Source: "https://example.test/runtime.zip"}) + if err != nil { + t.Fatal(err) + } + if got := string(payload); got != `{"kind":"source","source":"https://example.test/runtime.zip"}` { + t.Fatalf("source output JSON = %s", got) + } + payload, err = json.Marshal(Output{Kind: "phase", Phase: "verified"}) + if err != nil { + t.Fatal(err) + } + if got := string(payload); got != `{"kind":"phase","phase":"verified"}` { + t.Fatalf("phase output JSON = %s", got) + } +} + func TestProcessHelper(_ *testing.T) { if os.Getenv("TEST_PROCESS_HELPER") != "1" { return diff --git a/internal/provider/capability.go b/internal/provider/capability.go new file mode 100644 index 00000000..c0326263 --- /dev/null +++ b/internal/provider/capability.go @@ -0,0 +1,180 @@ +package provider + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" + + oneerrors "github.com/MaimoryLab/BootAgent/internal/errors" + "github.com/MaimoryLab/BootAgent/internal/securefs" +) + +const capabilitySchemaVersion = 1 + +// ProtocolCapability is one probe verdict. ErrorCode is kept alongside Supported +// because routing has to tell "this endpoint refuses this protocol" apart from "the +// key was rejected": only the first is a property of the endpoint, and only it +// justifies converting instead of failing. +type ProtocolCapability struct { + Supported bool `json:"supported"` + Reachable bool `json:"reachable"` + Status int `json:"status,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +// ModelCapabilities holds the verdicts for one model. Protocol support is a property +// of the model as much as the endpoint -- the measurements behind ADR-004 found one +// relay serving Chat Completions for 31 of 36 models but Responses for only 10 -- so +// a verdict may never be reused across models. +type ModelCapabilities struct { + ProbedAt time.Time `json:"probed_at"` + Protocols map[string]ProtocolCapability `json:"protocols"` +} + +// ProviderCapabilities groups every probed model for one Provider. Fingerprint binds +// them to the endpoints and key that produced them, so editing either invalidates +// the lot without any caller having to remember to clear the cache. +type ProviderCapabilities struct { + Fingerprint string `json:"fingerprint"` + Models map[string]ModelCapabilities `json:"models"` +} + +type capabilityFile struct { + SchemaVersion int `json:"schema_version"` + Providers map[string]ProviderCapabilities `json:"providers"` +} + +// CapabilityStore caches probe verdicts so deciding whether an Agent needs +// conversion does not spend a billed completion request on every activation. It is +// deliberately a separate file from providers.json: this is derived data that may be +// deleted at any moment, while providers.json is user truth returned through the +// binding. +type CapabilityStore struct { + home string + filesystem securefs.Store +} + +func NewCapabilityStore(home string, filesystem securefs.Store) CapabilityStore { + return CapabilityStore{home: home, filesystem: filesystem} +} + +func (s CapabilityStore) Path() string { + return filepath.Join(s.home, ".bootagent", "capabilities.json") +} + +// Fingerprint covers everything about a Provider that a verdict depends on. The key +// is hashed rather than stored: a verdict must change when the key does, but this +// file must never carry a credential. +func Fingerprint(entry Entry) string { + sum := sha256.Sum256([]byte(strings.Join([]string{entry.BaseURL, entry.AnthropicBaseURL, entry.APIKey}, "\x00"))) + return hex.EncodeToString(sum[:]) +} + +// Get reports the cached verdicts for one Provider and model. A stale fingerprint, an +// unknown model, or an unreadable file is a miss rather than an error: the cache is +// derived, and the caller's answer to a miss is simply to probe again. +func (s CapabilityStore) Get(entry Entry, model string) (ModelCapabilities, bool) { + file, err := s.load() + if err != nil { + return ModelCapabilities{}, false + } + saved, ok := file.Providers[entry.ID] + if !ok || saved.Fingerprint != Fingerprint(entry) { + return ModelCapabilities{}, false + } + verdicts, ok := saved.Models[strings.TrimSpace(model)] + if !ok || len(verdicts.Protocols) == 0 { + return ModelCapabilities{}, false + } + return verdicts, true +} + +// Save merges verdicts for one Provider and model. Protocols absent from the update +// are kept, so probing one protocol never discards what is already known about +// another, and other models keep their own records. +func (s CapabilityStore) Save(ctx context.Context, entry Entry, model string, verdicts map[string]ProtocolCapability) error { + model = strings.TrimSpace(model) + if model == "" { + return oneerrors.New(oneerrors.InvalidRequest, "A capability verdict must name the model it was measured with") + } + file, err := s.load() + if err != nil { + file = newCapabilityFile() + } + fingerprint := Fingerprint(entry) + current, ok := file.Providers[entry.ID] + if !ok || current.Fingerprint != fingerprint || current.Models == nil { + current = ProviderCapabilities{Fingerprint: fingerprint, Models: map[string]ModelCapabilities{}} + } + existing, ok := current.Models[model] + if !ok || existing.Protocols == nil { + existing = ModelCapabilities{Protocols: map[string]ProtocolCapability{}} + } + for protocolID, verdict := range verdicts { + existing.Protocols[protocolID] = verdict + } + existing.ProbedAt = time.Now().UTC() + current.Models[model] = existing + file.Providers[entry.ID] = current + return s.write(ctx, file) +} + +// Invalidate drops a Provider's records after an edit that changes what a probe would +// answer. A stale fingerprint is already caught on read, so this reclaims the entry +// rather than upholding correctness on its own. +func (s CapabilityStore) Invalidate(ctx context.Context, providerID string) error { + file, err := s.load() + if err != nil { + return nil + } + providerID = strings.TrimSpace(providerID) + if _, ok := file.Providers[providerID]; !ok { + return nil + } + delete(file.Providers, providerID) + return s.write(ctx, file) +} + +// CapabilityFromProbe reduces a probe result to what routing needs. +func CapabilityFromProbe(result ProbeResult) ProtocolCapability { + capability := ProtocolCapability{Supported: result.OK, Reachable: result.Reachable, Status: result.Status} + if result.ErrorCode != nil { + capability.ErrorCode = *result.ErrorCode + } + return capability +} + +func newCapabilityFile() capabilityFile { + return capabilityFile{SchemaVersion: capabilitySchemaVersion, Providers: map[string]ProviderCapabilities{}} +} + +func (s CapabilityStore) load() (capabilityFile, error) { + data, err := os.ReadFile(s.Path()) + if os.IsNotExist(err) { + return newCapabilityFile(), nil + } + if err != nil { + return newCapabilityFile(), err + } + file := newCapabilityFile() + if err := json.Unmarshal(data, &file); err != nil || file.SchemaVersion != capabilitySchemaVersion || file.Providers == nil { + return newCapabilityFile(), errors.New("cached Provider capabilities are unusable") + } + return file, nil +} + +func (s CapabilityStore) write(ctx context.Context, file capabilityFile) error { + data, err := json.MarshalIndent(file, "", " ") + if err != nil { + return oneerrors.New(oneerrors.ConfigWriteFailed, "Cannot encode cached Provider capabilities") + } + // Not written as a secret: the record holds verdicts and a hash, never a key. + _, err = s.filesystem.AtomicWrite(ctx, s.Path(), append(data, '\n'), false) + return err +} diff --git a/internal/provider/capability_test.go b/internal/provider/capability_test.go new file mode 100644 index 00000000..4b29a88a --- /dev/null +++ b/internal/provider/capability_test.go @@ -0,0 +1,204 @@ +package provider + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/MaimoryLab/BootAgent/internal/securefs" +) + +func capabilityStore(t *testing.T, home string) CapabilityStore { + t.Helper() + return NewCapabilityStore(home, securefs.New(securefs.Options{OS: "linux"})) +} + +func probedEntry() Entry { + return Entry{ID: "relay", Name: "Relay", BaseURL: "https://relay.test/v1", APIKey: "sk-secret-value"} +} + +func TestCapabilitiesRoundTrip(t *testing.T) { + store := capabilityStore(t, t.TempDir()) + entry := probedEntry() + verdicts := map[string]ProtocolCapability{ + ProtocolOpenAI: {Supported: true, Reachable: true, Status: 200}, + ProtocolResponses: {Reachable: true, Status: 404, ErrorCode: "PROTOCOL_UNSUPPORTED"}, + } + if err := store.Save(context.Background(), entry, "gpt-test", verdicts); err != nil { + t.Fatal(err) + } + saved, ok := store.Get(entry, "gpt-test") + if !ok { + t.Fatal("saved capabilities were not readable") + } + if !saved.Protocols[ProtocolOpenAI].Supported { + t.Error("chat completions verdict lost") + } + responses := saved.Protocols[ProtocolResponses] + if responses.Supported || responses.ErrorCode != "PROTOCOL_UNSUPPORTED" { + t.Errorf("responses verdict = %#v", responses) + } + if saved.ProbedAt.IsZero() { + t.Errorf("probe time lost: %#v", saved) + } +} + +// Protocol support varies by model on the same endpoint, so a verdict measured with +// one model must never answer for another. Reusing it would let BootAgent write a +// config for a model it never verified against that protocol. +func TestCapabilitiesAreNotSharedBetweenModels(t *testing.T) { + store := capabilityStore(t, t.TempDir()) + entry := probedEntry() + if err := store.Save(context.Background(), entry, "supports-responses", map[string]ProtocolCapability{ + ProtocolResponses: {Supported: true, Reachable: true, Status: 200}, + }); err != nil { + t.Fatal(err) + } + if _, ok := store.Get(entry, "never-probed"); ok { + t.Fatal("an unprobed model inherited another model's verdict") + } + if err := store.Save(context.Background(), entry, "chat-only", map[string]ProtocolCapability{ + ProtocolResponses: {Reachable: true, Status: 404, ErrorCode: "PROTOCOL_UNSUPPORTED"}, + }); err != nil { + t.Fatal(err) + } + first, ok := store.Get(entry, "supports-responses") + if !ok || !first.Protocols[ProtocolResponses].Supported { + t.Errorf("the first model's verdict was overwritten: %#v", first) + } + second, ok := store.Get(entry, "chat-only") + if !ok || second.Protocols[ProtocolResponses].Supported { + t.Errorf("the second model's verdict is wrong: %#v", second) + } +} + +// A verdict with no model attached could not be looked up again and would silently +// widen to every model on the Provider. +func TestCapabilitiesRejectAVerdictWithNoModel(t *testing.T) { + store := capabilityStore(t, t.TempDir()) + if err := store.Save(context.Background(), probedEntry(), " ", map[string]ProtocolCapability{ + ProtocolOpenAI: {Supported: true}, + }); err == nil { + t.Fatal("a verdict with no model was accepted") + } +} + +// The record has to stop applying when the endpoint or the credential changes, +// without any caller remembering to clear it. +func TestCapabilitiesMissAfterTheProviderChanges(t *testing.T) { + entry := probedEntry() + for name, changed := range map[string]Entry{ + "base URL": {ID: entry.ID, Name: entry.Name, BaseURL: "https://other.test/v1", APIKey: entry.APIKey}, + "anthropic base": {ID: entry.ID, Name: entry.Name, BaseURL: entry.BaseURL, AnthropicBaseURL: "https://other.test", APIKey: entry.APIKey}, + "api key": {ID: entry.ID, Name: entry.Name, BaseURL: entry.BaseURL, APIKey: "sk-rotated"}, + } { + t.Run(name, func(t *testing.T) { + store := capabilityStore(t, t.TempDir()) + if err := store.Save(context.Background(), entry, "gpt-test", map[string]ProtocolCapability{ + ProtocolOpenAI: {Supported: true, Reachable: true, Status: 200}, + }); err != nil { + t.Fatal(err) + } + if _, ok := store.Get(changed, "gpt-test"); ok { + t.Fatalf("a changed %s still matched the cached record", name) + } + // The unchanged Provider must still hit, or every activation re-probes. + if _, ok := store.Get(entry, "gpt-test"); !ok { + t.Fatal("the unchanged Provider stopped matching its own record") + } + }) + } +} + +// Probing one protocol must not discard what is already known about another, or a +// two-protocol decision would cost two probes every time. +func TestCapabilitiesSaveMergesProtocols(t *testing.T) { + store := capabilityStore(t, t.TempDir()) + entry := probedEntry() + if err := store.Save(context.Background(), entry, "gpt-test", map[string]ProtocolCapability{ + ProtocolOpenAI: {Supported: true, Reachable: true, Status: 200}, + }); err != nil { + t.Fatal(err) + } + if err := store.Save(context.Background(), entry, "gpt-test", map[string]ProtocolCapability{ + ProtocolAnthropic: {Reachable: true, Status: 404, ErrorCode: "PROTOCOL_UNSUPPORTED"}, + }); err != nil { + t.Fatal(err) + } + saved, ok := store.Get(entry, "gpt-test") + if !ok { + t.Fatal("capabilities missing after the second save") + } + if len(saved.Protocols) != 2 || !saved.Protocols[ProtocolOpenAI].Supported { + t.Errorf("second save overwrote the first verdict: %#v", saved.Protocols) + } +} + +// The cache is keyed by a hash of the credential, so the credential itself must not +// be recoverable from the file. +func TestCapabilityFileNeverStoresTheAPIKey(t *testing.T) { + home := t.TempDir() + store := capabilityStore(t, home) + entry := probedEntry() + if err := store.Save(context.Background(), entry, "gpt-test", map[string]ProtocolCapability{ + ProtocolOpenAI: {Supported: true, Reachable: true, Status: 200}, + }); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(store.Path()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), entry.APIKey) { + t.Fatalf("the API key reached %s", store.Path()) + } + if !strings.Contains(string(raw), Fingerprint(entry)) { + t.Error("the fingerprint that binds the record to its Provider is missing") + } +} + +func TestCapabilitiesInvalidateDropsTheRecord(t *testing.T) { + store := capabilityStore(t, t.TempDir()) + entry := probedEntry() + if err := store.Save(context.Background(), entry, "gpt-test", map[string]ProtocolCapability{ + ProtocolOpenAI: {Supported: true, Reachable: true, Status: 200}, + }); err != nil { + t.Fatal(err) + } + if err := store.Invalidate(context.Background(), entry.ID); err != nil { + t.Fatal(err) + } + if _, ok := store.Get(entry, "gpt-test"); ok { + t.Fatal("the record survived invalidation") + } + // Invalidating something that was never cached is a normal no-op. + if err := store.Invalidate(context.Background(), "never-probed"); err != nil { + t.Fatal(err) + } +} + +// A corrupt cache must read as a miss rather than an error: it is derived data, and +// failing here would block activation over a file nobody needs to keep. +func TestCapabilitiesTreatCorruptFileAsAMiss(t *testing.T) { + home := t.TempDir() + store := capabilityStore(t, home) + if err := os.MkdirAll(strings.TrimSuffix(store.Path(), "/capabilities.json"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.Path(), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := store.Get(probedEntry(), "gpt-test"); ok { + t.Fatal("a corrupt cache reported a hit") + } + // Saving over the corrupt file has to succeed, or the state is unrecoverable. + if err := store.Save(context.Background(), probedEntry(), "gpt-test", map[string]ProtocolCapability{ + ProtocolOpenAI: {Supported: true}, + }); err != nil { + t.Fatal(err) + } + if _, ok := store.Get(probedEntry(), "gpt-test"); !ok { + t.Fatal("the cache did not recover after a save") + } +} diff --git a/internal/skill/transfer.go b/internal/skill/transfer.go new file mode 100644 index 00000000..daef006e --- /dev/null +++ b/internal/skill/transfer.go @@ -0,0 +1,145 @@ +package skill + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" +) + +const maxExportBytes = 128 << 20 + +type ExportManifest struct { + Format string `json:"format"` + Version int `json:"version"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Variant ExportVariant `json:"variant"` +} + +type ExportVariant struct { + Hash string `json:"hash"` + Files int `json:"files"` + Bytes int64 `json:"bytes"` +} + +// Export creates a portable single-Skill archive. It never includes registry +// paths, credentials, symlinks, or files outside the selected variant. +func (s Store) Export(ctx context.Context, id, hash string) ([]byte, error) { + if err := ValidateID(id); err != nil || !hashPattern.MatchString(hash) { + return nil, errors.New("invalid Skill export target") + } + registry, err := s.Load() + if err != nil { + return nil, err + } + fact, ok := registry.Skills[id] + if !ok { + return nil, errors.New("Skill was not found") + } + path := s.VariantPath(id, hash) + stats, err := HashTree(ctx, path) + if err != nil { + return nil, err + } + if !s.hasVariant(fact, hash) || stats.Hash != hash { + return nil, errors.New("Skill variant is not registered") + } + manifest := ExportManifest{Format: "bootagent-skill", Version: 1, ID: id, Name: fact.Name, Description: fact.Description, Variant: ExportVariant{Hash: hash, Files: stats.Files, Bytes: stats.Bytes}} + return zipTree(ctx, path, manifest) +} + +func (s Store) hasVariant(fact Fact, hash string) bool { + for _, variant := range fact.Variants { + if variant.Hash == hash && variant.Stored { + return true + } + } + return false +} + +func zipTree(ctx context.Context, root string, manifest ExportManifest) ([]byte, error) { + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + manifestBytes, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, err + } + part, err := writer.Create("manifest.json") + if err != nil { + return nil, err + } + if _, err := part.Write(append(manifestBytes, '\n')); err != nil { + return nil, err + } + var files []string + err = filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := contextError(ctx); err != nil { + return err + } + if path == root { + return nil + } + if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) { + return errors.New("Skill tree contains unsupported file type") + } + if !info.IsDir() { + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(files) + for _, path := range files { + name, err := filepath.Rel(root, path) + if err != nil { + return nil, err + } + name = filepath.ToSlash(name) + if name == "manifest.json" { + continue + } + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if int64(buffer.Len())+info.Size() > maxExportBytes { + return nil, errors.New("Skill export exceeds size limit") + } + entry, err := writer.Create(name) + if err != nil { + return nil, err + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + _, copyErr := io.Copy(entry, file) + closeErr := file.Close() + if copyErr != nil { + return nil, copyErr + } + if closeErr != nil { + return nil, closeErr + } + } + if err := writer.Close(); err != nil { + return nil, err + } + if buffer.Len() > maxExportBytes { + return nil, fmt.Errorf("Skill export exceeds %d bytes", maxExportBytes) + } + return buffer.Bytes(), nil +} diff --git a/internal/skill/transfer_test.go b/internal/skill/transfer_test.go new file mode 100644 index 00000000..813fc16c --- /dev/null +++ b/internal/skill/transfer_test.go @@ -0,0 +1,87 @@ +package skill + +import ( + "archive/zip" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MaimoryLab/BootAgent/internal/securefs" +) + +func TestExportSkillProducesPortableArchive(t *testing.T) { + home := t.TempDir() + store := NewStore(home, securefs.New(securefs.Options{OS: "linux"})) + source := filepath.Join(home, "source") + if err := os.MkdirAll(filepath.Join(source, "nested"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("# Demo\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "nested", "notes.txt"), []byte("portable"), 0o600); err != nil { + t.Fatal(err) + } + stats, err := HashTree(context.Background(), source) + if err != nil { + t.Fatal(err) + } + root := store.VariantPath("demo", stats.Hash) + if err := CopyTree(context.Background(), source, root); err != nil { + t.Fatal(err) + } + registry := Registry{SchemaVersion: RegistrySchemaVersion, Skills: map[string]Fact{"demo": {Name: "Demo", Description: "test", Variants: []Variant{{Hash: stats.Hash, Stored: true}}}}} + if err := store.Save(context.Background(), registry); err != nil { + t.Fatal(err) + } + archive, err := store.Export(context.Background(), "demo", stats.Hash) + if err != nil { + t.Fatal(err) + } + reader, err := zip.NewReader(strings.NewReader(string(archive)), int64(len(archive))) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, entry := range reader.File { + seen[entry.Name] = true + } + for _, name := range []string{"manifest.json", "SKILL.md", "nested/notes.txt"} { + if !seen[name] { + t.Errorf("archive missing %s", name) + } + } + var manifest ExportManifest + for _, entry := range reader.File { + if entry.Name != "manifest.json" { + continue + } + file, err := entry.Open() + if err != nil { + t.Fatal(err) + } + if err := json.NewDecoder(file).Decode(&manifest); err != nil { + t.Fatal(err) + } + _ = file.Close() + } + if manifest.ID != "demo" || manifest.Variant.Hash != stats.Hash { + t.Fatalf("manifest = %#v", manifest) + } +} + +func TestExportSkillRejectsUnknownOrUnstoredVariant(t *testing.T) { + store := NewStore(t.TempDir(), securefs.New(securefs.Options{OS: "linux"})) + if err := store.Save(context.Background(), Registry{SchemaVersion: RegistrySchemaVersion, Skills: map[string]Fact{"demo": {Variants: []Variant{{Hash: strings.Repeat("a", 64), Stored: false}}}}}); err != nil { + t.Fatal(err) + } + if _, err := store.Export(context.Background(), "demo", strings.Repeat("a", 64)); err == nil { + t.Fatal("unstored variant exported") + } + if _, err := store.Export(context.Background(), "missing", strings.Repeat("a", 64)); err == nil { + t.Fatal("unknown skill exported") + } +} diff --git a/internal/transfer/v2.go b/internal/transfer/v2.go new file mode 100644 index 00000000..0cd99379 --- /dev/null +++ b/internal/transfer/v2.go @@ -0,0 +1,131 @@ +// Package transfer contains versioned import and export formats for local configuration. +package transfer + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "sort" + "strings" +) + +const ( + Format = "bootagent-transfer" + Version = 2 + MaxArchiveBytes = 256 << 20 + MaxArchiveEntries = 20_000 +) + +type Manifest struct { + Format string `json:"format"` + Version int `json:"version"` + Sections []string `json:"sections"` +} + +type Package struct { + Manifest Manifest + Files map[string][]byte +} + +func Build(files map[string][]byte) ([]byte, error) { + sections := make([]string, 0, len(files)) + for name := range files { + if err := validPath(name); err != nil { + return nil, err + } + if name == "manifest.json" { + return nil, errors.New("manifest.json is reserved") + } + sections = append(sections, name) + } + sort.Strings(sections) + manifest := Manifest{Format: Format, Version: Version, Sections: sections} + manifestBytes, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, err + } + var out bytes.Buffer + zw := zip.NewWriter(&out) + write := func(name string, data []byte) error { + w, err := zw.Create(name) + if err != nil { + return err + } + _, err = w.Write(data) + return err + } + if err := write("manifest.json", append(manifestBytes, '\n')); err != nil { + return nil, err + } + for _, name := range sections { + data := files[name] + if err := write(name, data); err != nil { + return nil, err + } + } + if err := zw.Close(); err != nil { + return nil, err + } + if out.Len() > MaxArchiveBytes { + return nil, errors.New("transfer archive exceeds size limit") + } + return out.Bytes(), nil +} + +func Parse(data []byte) (Package, error) { + if len(data) > MaxArchiveBytes { + return Package{}, errors.New("transfer archive exceeds size limit") + } + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return Package{}, errors.New("transfer archive is invalid") + } + if len(r.File) > MaxArchiveEntries { + return Package{}, errors.New("transfer archive exceeds entry limit") + } + files := make(map[string][]byte, len(r.File)) + var manifest Manifest + for _, entry := range r.File { + if entry.FileInfo().IsDir() { + continue + } + if err := validPath(entry.Name); err != nil { + return Package{}, err + } + if _, exists := files[entry.Name]; exists { + return Package{}, errors.New("transfer archive contains duplicate paths") + } + if entry.UncompressedSize64 > MaxArchiveBytes { + return Package{}, errors.New("transfer entry exceeds size limit") + } + reader, err := entry.Open() + if err != nil { + return Package{}, err + } + content, err := io.ReadAll(io.LimitReader(reader, MaxArchiveBytes+1)) + _ = reader.Close() + if err != nil { + return Package{}, err + } + if len(content) > MaxArchiveBytes { + return Package{}, errors.New("transfer entry exceeds size limit") + } + files[entry.Name] = content + } + if err := json.Unmarshal(files["manifest.json"], &manifest); err != nil || manifest.Format != Format || manifest.Version != Version { + return Package{}, errors.New("unsupported transfer package version") + } + return Package{Manifest: manifest, Files: files}, nil +} + +func validPath(name string) error { + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + if name == "" || clean != name || path.IsAbs(clean) || clean == "." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") || strings.ContainsRune(name, '\x00') { + return fmt.Errorf("unsafe transfer path %q", name) + } + return nil +} diff --git a/internal/transfer/v2_test.go b/internal/transfer/v2_test.go new file mode 100644 index 00000000..a57af894 --- /dev/null +++ b/internal/transfer/v2_test.go @@ -0,0 +1,27 @@ +package transfer + +import ( + "testing" +) + +func TestV2RoundTrip(t *testing.T) { + data, err := Build(map[string][]byte{"providers.json": []byte(`{"providers":[]}`), "skills/demo/SKILL.md": []byte("# Demo")}) + if err != nil { + t.Fatal(err) + } + pkg, err := Parse(data) + if err != nil { + t.Fatal(err) + } + if pkg.Manifest.Version != Version || string(pkg.Files["skills/demo/SKILL.md"]) != "# Demo" { + t.Fatalf("package = %#v", pkg) + } +} + +func TestV2RejectsUnsafePaths(t *testing.T) { + for _, name := range []string{"../escape", "/absolute", "skills/../escape", `skills\\escape`, "manifest.json"} { + if _, err := Build(map[string][]byte{name: []byte("x")}); err == nil { + t.Fatalf("Build accepted %q", name) + } + } +} diff --git a/scripts/check-docs.py b/scripts/check-docs.py index f7d8dc03..bae1034e 100644 --- a/scripts/check-docs.py +++ b/scripts/check-docs.py @@ -45,13 +45,13 @@ def tracked_markdown() -> list[pathlib.Path]: import subprocess out = subprocess.run( - ["git", "ls-files", "*.md"], + ["git", "-c", "core.quotePath=false", "ls-files", "-z", "*.md"], cwd=ROOT, capture_output=True, - text=True, + text=False, check=True, - ).stdout - return [ROOT / line for line in out.splitlines() if line] + ).stdout.decode("utf-8") + return [ROOT / line for line in out.split("\x00") if line] def check_links(paths: list[pathlib.Path]) -> list[str]: diff --git a/scripts/promotion/awesome-gpt-image-2/LICENSE b/scripts/promotion/awesome-gpt-image-2/LICENSE new file mode 100644 index 00000000..5470a1c5 --- /dev/null +++ b/scripts/promotion/awesome-gpt-image-2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 freestylefly + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/scripts/promotion/awesome-gpt-image-2/UPSTREAM.md b/scripts/promotion/awesome-gpt-image-2/UPSTREAM.md new file mode 100644 index 00000000..bafa6344 --- /dev/null +++ b/scripts/promotion/awesome-gpt-image-2/UPSTREAM.md @@ -0,0 +1,18 @@ +# awesome-gpt-image-2 集成说明 + +- 上游仓库:https://github.com/freestylefly/awesome-gpt-image-2 +- 固定提交:`685469889fb72fd5adefae45e1645d527edcb5e7` +- 引入文件:`data/style-library.json` +- 本地文件:`style-library.json` +- `style-library.json` SHA-256:`80f5cae039d0d6f312f0e2de2c9b3fc8a806640b0d517c120d704a71c5e4aa72` +- 上游许可证:MIT,许可证文本见同目录 `LICENSE` + +## 集成边界 + +本项目只复用风格库的分类、风格、场景、模板指导和常见问题字段,不引入上游案例图片、第三方提示词全文、展示站、支付模块、Supabase 或远程生图 API。 + +上游免责声明明确不保证第三方案例和图片可商用。因此 `data/images/` 不进入 BootAgent 的源代码、发布包或推广资产。 + +## 升级方式 + +升级必须人工执行:更新固定 commit,重新下载 `data/style-library.json` 与 `LICENSE`,核对哈希,运行 `python3 scripts/promotion/build_prompt.py` 的测试和项目门禁,并在变更记录中说明模板字段变化。 diff --git a/scripts/promotion/awesome-gpt-image-2/style-library.json b/scripts/promotion/awesome-gpt-image-2/style-library.json new file mode 100644 index 00000000..fc564fe2 --- /dev/null +++ b/scripts/promotion/awesome-gpt-image-2/style-library.json @@ -0,0 +1,819 @@ +{ + "version": 1, + "repository": "https://github.com/freestylefly/awesome-gpt-image-2", + "templateDocument": "docs/templates.md", + "tagLabels": { + "Art": { "en": "Art", "zh": "艺术" }, + "Campaign": { "en": "Campaign", "zh": "商业 Campaign" }, + "Chart": { "en": "Chart", "zh": "图表" }, + "Classical": { "en": "Classical", "zh": "古典" }, + "Creative": { "en": "Creative", "zh": "创意" }, + "Dashboard": { "en": "Dashboard", "zh": "仪表盘" }, + "Document": { "en": "Document", "zh": "文档" }, + "Education": { "en": "Education", "zh": "教育" }, + "Identity": { "en": "Identity", "zh": "身份系统" }, + "Interior": { "en": "Interior", "zh": "室内" }, + "Layout": { "en": "Layout", "zh": "版式" }, + "Lens": { "en": "Lens", "zh": "镜头" }, + "Logo": { "en": "Logo", "zh": "Logo" }, + "Map": { "en": "Map", "zh": "地图" }, + "Packaging": { "en": "Packaging", "zh": "包装" }, + "Pose": { "en": "Pose", "zh": "动作" }, + "Publishing": { "en": "Publishing", "zh": "出版" }, + "R&D": { "en": "R&D", "zh": "研发" }, + "Scene": { "en": "Scene", "zh": "场景" }, + "Screenshot": { "en": "Screenshot", "zh": "截图" }, + "Scroll": { "en": "Scroll", "zh": "长卷" }, + "Special": { "en": "Special", "zh": "特殊输出" }, + "Storyboard": { "en": "Storyboard", "zh": "分镜" }, + "Style": { "en": "Style", "zh": "风格" }, + "Typography": { "en": "Typography", "zh": "字体" } + }, + "categories": [ + { + "id": "cat-ui", + "value": "UI & Interfaces", + "anchor": "cat-ui", + "templateAnchor": "tpl-ui", + "cover": "/images/category-covers/ui.jpg", + "title": { "en": "UI & Interfaces", "zh": "UI 与界面" }, + "description": { + "en": "Apps, websites, dashboards, social screenshots, and product interfaces.", + "zh": "App、网页、仪表盘、社媒截图与产品界面。" + } + }, + { + "id": "cat-infographic", + "value": "Charts & Infographics", + "anchor": "cat-infographic", + "templateAnchor": "tpl-infographic", + "cover": "/images/category-covers/infographic.jpg", + "title": { "en": "Charts & Infographics", "zh": "图表与信息可视化" }, + "description": { + "en": "Infographics, knowledge maps, technical explainers, and structured diagrams.", + "zh": "信息图、知识图谱、技术解释与结构化图解。" + } + }, + { + "id": "cat-poster", + "value": "Posters & Typography", + "anchor": "cat-poster", + "templateAnchor": "tpl-poster", + "cover": "/images/category-covers/poster.jpg", + "title": { "en": "Posters & Typography", "zh": "海报与排版" }, + "description": { + "en": "Event posters, covers, type-driven visuals, and strong layout compositions.", + "zh": "活动海报、封面、字体视觉和强排版画面。" + } + }, + { + "id": "cat-product", + "value": "Products & E-commerce", + "anchor": "cat-product", + "templateAnchor": "tpl-product", + "cover": "/images/category-covers/product.jpg", + "title": { "en": "Products & E-commerce", "zh": "商品与电商" }, + "description": { + "en": "Product shots, detail pages, packaging, selling points, and ads.", + "zh": "商品图、详情页、包装卖点和商业广告。" + } + }, + { + "id": "cat-brand", + "value": "Brand & Logos", + "anchor": "cat-brand", + "templateAnchor": "tpl-brand", + "cover": "/images/category-covers/brand.jpg", + "title": { "en": "Brand & Logos", "zh": "品牌与标志" }, + "description": { + "en": "Logos, identity systems, brand touchpoints, and campaign visuals.", + "zh": "Logo、VI、品牌触点和 Campaign 视觉系统。" + } + }, + { + "id": "cat-architecture", + "value": "Architecture & Spaces", + "anchor": "cat-architecture", + "templateAnchor": "tpl-architecture", + "cover": "/images/category-covers/architecture.jpg", + "title": { "en": "Architecture & Spaces", "zh": "建筑与空间" }, + "description": { + "en": "Architecture renders, interiors, city maps, and spatial concepts.", + "zh": "建筑表现、室内空间、城市地图和空间概念。" + } + }, + { + "id": "cat-photo", + "value": "Photography & Realism", + "anchor": "cat-photo", + "templateAnchor": "tpl-photo", + "cover": "/images/category-covers/photo.jpg", + "title": { "en": "Photography & Realism", "zh": "摄影与写实" }, + "description": { + "en": "Portraits, phone photography, film texture, and commercial photography.", + "zh": "人像、手机纪实、胶片质感和商业摄影。" + } + }, + { + "id": "cat-illustration", + "value": "Illustration & Art", + "anchor": "cat-illustration", + "templateAnchor": "tpl-illustration", + "cover": "/images/category-covers/illustration.jpg", + "title": { "en": "Illustration & Art", "zh": "插画与艺术" }, + "description": { + "en": "Illustration, art styles, material experiments, and decorative images.", + "zh": "插画、艺术风格、材质实验和装饰画面。" + } + }, + { + "id": "cat-character", + "value": "Characters & People", + "anchor": "cat-character", + "templateAnchor": "tpl-character", + "cover": "/images/category-covers/character.jpg", + "title": { "en": "Characters & People", "zh": "人物与角色" }, + "description": { + "en": "Character design, pose references, cards, and 3D toys.", + "zh": "角色设定、动作参考、卡牌和 3D 玩具。" + } + }, + { + "id": "cat-scene", + "value": "Scenes & Storytelling", + "anchor": "cat-scene", + "templateAnchor": "tpl-scene", + "cover": "/images/category-covers/scene.jpg", + "title": { "en": "Scenes & Storytelling", "zh": "场景与叙事" }, + "description": { + "en": "Storyboards, narrative scenes, livestream frames, and worldbuilding.", + "zh": "分镜、故事场景、直播画面和世界观叙事。" + } + }, + { + "id": "cat-history", + "value": "History & Classical Themes", + "anchor": "cat-history", + "templateAnchor": "tpl-history", + "cover": "/images/category-covers/history.jpg", + "title": { "en": "History & Classical Themes", "zh": "历史与古风题材" }, + "description": { + "en": "Classical scrolls, historical figures, traditional themes, and poetry visuals.", + "zh": "古风长卷、历史人物、传统题材和诗词画面。" + } + }, + { + "id": "cat-document", + "value": "Documents & Publishing", + "anchor": "cat-document", + "templateAnchor": "tpl-document", + "cover": "/images/category-covers/document.jpg", + "title": { "en": "Documents & Publishing", "zh": "文档与出版物" }, + "description": { + "en": "White papers, manuals, encyclopedic plates, and publishing layouts.", + "zh": "白皮书、手册、百科图鉴和出版页设计。" + } + }, + { + "id": "cat-other", + "value": "Other Use Cases", + "anchor": "cat-other", + "templateAnchor": "tpl-other", + "cover": "/images/category-covers/other.jpg", + "title": { "en": "Other Use Cases", "zh": "其他应用场景" }, + "description": { + "en": "Creative experiments, special tasks, mixed workflows, and practical cases.", + "zh": "创意实验、特殊任务、混合玩法和实用场景。" + } + } + ], + "styles": [ + { "id": "3d", "value": "3D", "title": { "en": "3D", "zh": "3D" }, "keywords": ["3d", "toy", "render", "玩具"] }, + { "id": "architecture", "value": "Architecture", "title": { "en": "Architecture", "zh": "建筑" }, "keywords": [] }, + { "id": "brand", "value": "Brand", "title": { "en": "Brand", "zh": "品牌" }, "keywords": ["brand", "logo", "identity", "品牌", "标志"] }, + { "id": "character", "value": "Character", "title": { "en": "Character", "zh": "角色" }, "keywords": ["character", "avatar", "pose", "角色", "人物"] }, + { "id": "characters", "value": "Characters", "title": { "en": "Characters", "zh": "人物" }, "keywords": [] }, + { "id": "charts", "value": "Charts", "title": { "en": "Charts", "zh": "图表" }, "keywords": [] }, + { "id": "classical", "value": "Classical", "title": { "en": "Classical", "zh": "古典" }, "keywords": ["classical", "dynasty", "history", "古风", "历史"] }, + { "id": "documents", "value": "Documents", "title": { "en": "Documents", "zh": "文档" }, "keywords": [] }, + { "id": "history", "value": "History", "title": { "en": "History", "zh": "历史" }, "keywords": [] }, + { "id": "illustration", "value": "Illustration", "title": { "en": "Illustration", "zh": "插画" }, "keywords": ["illustration", "painting", "watercolor", "插画", "绘画"] }, + { "id": "infographic", "value": "Infographic", "title": { "en": "Infographic", "zh": "信息图" }, "keywords": ["infographic", "diagram", "信息图", "图解"] }, + { "id": "other", "value": "Other Use Cases", "title": { "en": "Other Use Cases", "zh": "其他应用场景" }, "keywords": [] }, + { "id": "photography", "value": "Photography", "title": { "en": "Photography", "zh": "摄影" }, "keywords": [] }, + { "id": "poster", "value": "Poster", "title": { "en": "Poster", "zh": "海报" }, "keywords": ["poster", "cover", "typography", "海报", "封面"] }, + { "id": "product", "value": "Product", "title": { "en": "Product", "zh": "商品" }, "keywords": ["product", "packaging", "商品", "包装"] }, + { "id": "products", "value": "Products", "title": { "en": "Products", "zh": "商品" }, "keywords": [] }, + { "id": "realistic", "value": "Realistic", "title": { "en": "Realistic", "zh": "写实" }, "keywords": ["photo", "realistic", "camera", "写真", "写实"] }, + { "id": "scenes", "value": "Scenes", "title": { "en": "Scenes", "zh": "场景" }, "keywords": [] }, + { "id": "ui", "value": "UI", "title": { "en": "UI", "zh": "界面" }, "keywords": ["ui", "interface", "dashboard", "界面", "截图"] } + ], + "scenes": [ + { "id": "creative", "value": "Creative", "title": { "en": "Creative", "zh": "创意" }, "keywords": [] }, + { "id": "tech", "value": "Tech", "title": { "en": "Tech", "zh": "科技" }, "keywords": ["ai", "rag", "tech", "data", "技术", "数据"] }, + { "id": "commerce", "value": "Commerce", "title": { "en": "Commerce", "zh": "商业" }, "keywords": ["product", "brand", "ad", "campaign", "商品", "商业", "广告"] }, + { "id": "education", "value": "Education", "title": { "en": "Education", "zh": "教育" }, "keywords": ["guide", "atlas", "science", "learning", "学习", "科普"] }, + { "id": "social", "value": "Social", "title": { "en": "Social", "zh": "社媒" }, "keywords": ["social", "x ", "wechat", "朋友圈", "社媒"] }, + { "id": "fashion", "value": "Fashion", "title": { "en": "Fashion", "zh": "时尚" }, "keywords": ["fashion", "clothing", "portrait", "服饰", "写真"] }, + { "id": "food", "value": "Food", "title": { "en": "Food", "zh": "食品饮品" }, "keywords": ["food", "drink", "coffee", "tea", "餐厅", "咖啡", "茶"] }, + { "id": "travel", "value": "Travel", "title": { "en": "Travel", "zh": "旅行" }, "keywords": ["city", "map", "street", "城市", "地图", "街头"] }, + { "id": "story", "value": "Story", "title": { "en": "Story", "zh": "叙事" }, "keywords": ["story", "scene", "world", "故事", "场景"] }, + { "id": "history", "value": "History", "title": { "en": "History", "zh": "历史" }, "keywords": ["history", "dynasty", "ancient", "历史", "古希腊", "唐"] } + ], + "templates": [ + { + "id": "ui-screenshot-system", + "anchor": "tpl-ui", + "cover": "/images/case17.jpg", + "title": { "en": "UI Screenshot System", "zh": "UI 截图系统" }, + "description": { + "en": "High-fidelity app, web, dashboard, and social interface prompts.", + "zh": "生成 App、网页、仪表盘、社媒截图等高保真界面。" + }, + "category": "UI & Interfaces", + "styles": ["UI"], + "scenes": ["Tech", "Social"], + "tags": ["UI", "Dashboard", "Screenshot"], + "useWhen": { + "en": "Use for app screens, dashboards, social screenshots, and live interface mockups.", + "zh": "用于 App 截图、仪表盘、社媒截图和直播界面。" + }, + "guidance": { + "en": ["Lock platform, aspect ratio, layout hierarchy, and exact visible text.", "Specify UI chrome such as status bars, tabs, action rows, or comment layers."], + "zh": ["锁定平台、比例、层级和画面文字。", "明确状态栏、Tab、操作区、评论层等 UI 元素。"] + }, + "pitfalls": { + "en": ["Avoid vague platform names and generic app mockups.", "Constrain text readability and platform-specific details."], + "zh": ["避免平台描述过泛。", "约束文字可读性和平台特征。"] + }, + "exampleCases": [17, 2, 4] + }, + { + "id": "infographic-engine", + "anchor": "tpl-infographic", + "cover": "/images/case334.png", + "title": { "en": "Infographic Engine", "zh": "信息图引擎" }, + "description": { + "en": "Structured diagrams, timelines, knowledge maps, and explainers.", + "zh": "生成结构化图解、时间线、知识图谱和技术解释图。" + }, + "category": "Charts & Infographics", + "styles": ["Infographic", "Charts"], + "scenes": ["Education", "Tech"], + "tags": ["Infographic", "Chart", "Education"], + "useWhen": { + "en": "Use for explainer graphics, technical diagrams, timelines, and knowledge cards.", + "zh": "用于解释图、技术图解、时间线和知识卡片。" + }, + "guidance": { + "en": ["Define 3-5 modules, information flow, visual hierarchy, and short labels.", "Use color groups, arrows, icons, and clean spacing to reduce clutter."], + "zh": ["定义 3-5 个模块、信息流、层级和短标签。", "用色块、箭头、图标和留白控制复杂度。"] + }, + "pitfalls": { + "en": ["Avoid long paragraphs inside the image.", "Limit module count before adding visual detail."], + "zh": ["避免把长段正文塞进画面。", "先限制模块数量,再补视觉细节。"] + }, + "exampleCases": [334, 1, 8] + }, + { + "id": "scientific-scale-diagram", + "anchor": "tpl-infographic", + "cover": "/images/case341.jpg", + "title": { "en": "Scientific Scale Diagram", "zh": "科学尺度缩放图" }, + "description": { + "en": "Multi-scale science visuals with readable labels and hierarchy.", + "zh": "生成多尺度科学信息图,强调层级、标签和可读性。" + }, + "category": "Charts & Infographics", + "styles": ["Infographic", "Charts", "Realistic"], + "scenes": ["Education", "Tech"], + "tags": ["Infographic", "Chart", "Education"], + "useWhen": { + "en": "Use when the topic needs micro-to-macro scale comparison and labeled detail windows.", + "zh": "用于需要从微观到宏观展示尺度变化的科普主题。" + }, + "guidance": { + "en": ["Use 6-8 scale frames and keep each label short.", "Show units, magnification, and distinct scale detail."], + "zh": ["使用 6-8 个尺度框,每个标签保持短句。", "展示单位、倍率和不同尺度的细节。"] + }, + "pitfalls": { + "en": ["Avoid making every scale frame visually identical.", "Avoid generic magnifying glass icon layouts."], + "zh": ["避免所有尺度框长得一样。", "避免通用放大镜式布局。"] + }, + "exampleCases": [341] + }, + { + "id": "poster-layout-system", + "anchor": "tpl-poster", + "cover": "/images/case345.jpg", + "title": { "en": "Poster Layout System", "zh": "海报排版系统" }, + "description": { + "en": "Event, product, movie, and social poster compositions.", + "zh": "生成活动、产品、电影和社媒传播海报。" + }, + "category": "Posters & Typography", + "styles": ["Poster"], + "scenes": ["Commerce", "Social"], + "tags": ["Poster", "Typography", "Campaign"], + "useWhen": { + "en": "Use for event posters, movie posters, covers, and social campaign visuals.", + "zh": "用于活动海报、电影海报、封面和社媒传播视觉。" + }, + "guidance": { + "en": ["Lock subject, headline, layout, palette, and aspect ratio.", "Make the title hierarchy and primary visual clear."], + "zh": ["锁定主体、标题、版式、配色和比例。", "突出标题层级和主视觉。"] + }, + "pitfalls": { + "en": ["Avoid mixed moodboards or process sheets when asking for one finished poster.", "Constrain extra text and decorative symbols."], + "zh": ["需要成品海报时,避免生成拼贴展示板。", "约束多余文字和装饰符号。"] + }, + "exampleCases": [345, 5, 10] + }, + { + "id": "sports-campaign-poster", + "anchor": "tpl-poster", + "cover": "/images/case350.jpg", + "title": { "en": "Sports Campaign Poster", "zh": "运动商业 Campaign" }, + "description": { + "en": "Commercial sports visuals with athletes, props, and brand color.", + "zh": "生成运动员、道具、品牌色统一的商业运动海报。" + }, + "category": "Posters & Typography", + "styles": ["Poster", "Realistic"], + "scenes": ["Commerce", "Fashion"], + "tags": ["Poster", "Campaign", "Typography"], + "useWhen": { + "en": "Use for sports brand campaigns, athlete posters, and product-led sport visuals.", + "zh": "用于运动品牌 Campaign、运动员海报和运动产品视觉。" + }, + "guidance": { + "en": ["Define sport, athlete pose, hero prop, title, and brand palette.", "Use dramatic light, clean composition, and readable data overlays."], + "zh": ["定义运动项目、姿态、核心道具、标题和品牌色。", "使用强光影、干净构图和可读数据层。"] + }, + "pitfalls": { + "en": ["Avoid wrong equipment and noisy collage.", "Keep the athlete and hero prop visually dominant."], + "zh": ["避免错误运动器材和杂乱拼贴。", "让运动员和核心道具占据主导。"] + }, + "exampleCases": [350, 3] + }, + { + "id": "conceptual-typography-poster", + "anchor": "tpl-poster", + "cover": "/images/case355.jpg", + "title": { "en": "Conceptual Typography Poster", "zh": "概念字体海报" }, + "description": { + "en": "Premium title-led posters with strong typographic systems.", + "zh": "生成以标题文字为主视觉的高级字体海报。" + }, + "category": "Posters & Typography", + "styles": ["Poster"], + "scenes": ["Creative", "Social"], + "tags": ["Typography", "Poster", "Style"], + "useWhen": { + "en": "Use when the exact title must become the main visual structure.", + "zh": "用于标题文字需要成为主视觉结构的海报。" + }, + "guidance": { + "en": ["Make typography the hero and spell the title exactly.", "Tie human figures, objects, or landscapes to the title meaning."], + "zh": ["让字体成为画面主角,并保证标题拼写准确。", "人物、物体或风景需要服务标题含义。"] + }, + "pitfalls": { + "en": ["Avoid default word art, unrelated icons, and misspelled title text.", "Limit the color system to a restrained palette."], + "zh": ["避免默认字效、无关图标和标题错字。", "控制配色数量,保持克制。"] + }, + "exampleCases": [355] + }, + { + "id": "ink-double-exposure-poster", + "anchor": "tpl-poster", + "cover": "/images/case359.jpg", + "title": { "en": "Ink Double Exposure Poster", "zh": "水墨双重曝光海报" }, + "description": { + "en": "Chinese ink, portrait, and layered atmosphere composition.", + "zh": "生成水墨、人像与层叠氛围结合的视觉海报。" + }, + "category": "Posters & Typography", + "styles": ["Poster", "Illustration", "Classical"], + "scenes": ["Story", "History"], + "tags": ["Poster", "Classical", "Style"], + "useWhen": { + "en": "Use for poetic portrait posters, ink atmospheres, and layered cultural visuals.", + "zh": "用于诗意人像海报、水墨氛围和文化主题视觉。" + }, + "guidance": { + "en": ["Blend portrait silhouette, ink texture, atmosphere, and negative space.", "Keep composition quiet, premium, and readable."], + "zh": ["融合人像剪影、水墨质感、氛围和留白。", "保持构图克制、高级、可读。"] + }, + "pitfalls": { + "en": ["Avoid cheap fantasy collage and overloaded scenery.", "Use subtle text or no text unless required."], + "zh": ["避免廉价奇幻拼贴和景物堆叠。", "非必要时减少文字。"] + }, + "exampleCases": [359] + }, + { + "id": "nature-science-poster", + "anchor": "tpl-poster", + "cover": "/images/case339.jpg", + "title": { "en": "Nature Science Poster", "zh": "自然科普海报" }, + "description": { + "en": "Minimal product-style science posters for natural subjects.", + "zh": "生成极简产品感自然科普海报。" + }, + "category": "Posters & Typography", + "styles": ["Poster", "Infographic"], + "scenes": ["Education"], + "tags": ["Poster", "Education", "Style"], + "useWhen": { + "en": "Use for natural subjects that need a premium, clean science poster feel.", + "zh": "用于自然主题的高级、干净科普海报。" + }, + "guidance": { + "en": ["Use a clear subject, minimal copy, soft shadows, and disciplined whitespace.", "Keep the scientific label short and visible."], + "zh": ["使用清晰主体、少量文案、柔和阴影和充足留白。", "让科普标签短而清楚。"] + }, + "pitfalls": { + "en": ["Avoid heavy advertising language.", "Avoid dense encyclopedia blocks."], + "zh": ["避免广告感太重。", "避免密集百科正文。"] + }, + "exampleCases": [339] + }, + { + "id": "product-commerce-visual", + "anchor": "tpl-product", + "cover": "/images/case373.jpg", + "title": { "en": "Product Commerce Visual", "zh": "商品商业视觉" }, + "description": { + "en": "Product shots, packaging, detail pages, and selling-point layouts.", + "zh": "生成商品图、包装、详情页和卖点排版。" + }, + "category": "Products & E-commerce", + "styles": ["Product", "Realistic"], + "scenes": ["Commerce", "Food"], + "tags": ["Product", "Commerce", "Packaging"], + "useWhen": { + "en": "Use for product hero shots, packaging visuals, detail pages, and sales layouts.", + "zh": "用于商品主图、包装视觉、详情页和销售卖点排版。" + }, + "guidance": { + "en": ["Define product, selling points, material, scene, lighting, and layout blocks.", "Separate hero product, benefit labels, and supporting props."], + "zh": ["定义商品、卖点、材质、场景、光线和版块。", "区分主商品、卖点标签和辅助道具。"] + }, + "pitfalls": { + "en": ["Avoid random props that weaken product recognition.", "Constrain packaging text and claim wording."], + "zh": ["避免无关道具削弱商品识别。", "约束包装文字和卖点表达。"] + }, + "exampleCases": [373, 358] + }, + { + "id": "personalized-beauty-report", + "anchor": "tpl-product", + "cover": "/images/case353.jpg", + "title": { "en": "Personalized Beauty Report", "zh": "个性化美妆报告" }, + "description": { + "en": "Recommendation-report layouts for beauty and lifestyle products.", + "zh": "生成美妆与生活方式产品的推荐报告版式。" + }, + "category": "Products & E-commerce", + "styles": ["Product", "UI"], + "scenes": ["Commerce", "Fashion"], + "tags": ["Product", "Layout", "Style"], + "useWhen": { + "en": "Use for beauty recommendations, skin reports, shopping assistants, and lifestyle product cards.", + "zh": "用于美妆推荐、肤质报告、导购助手和生活方式商品卡片。" + }, + "guidance": { + "en": ["Use a report-like hierarchy with diagnosis, recommendation, and product cards.", "Keep product images, labels, and ratings aligned."], + "zh": ["使用诊断、推荐和商品卡片的报告层级。", "对齐商品图、标签和评分。"] + }, + "pitfalls": { + "en": ["Avoid medical claims and unreadable dense notes.", "Keep recommendation logic simple."], + "zh": ["避免医疗化结论和难读小字。", "保持推荐逻辑清楚。"] + }, + "exampleCases": [353] + }, + { + "id": "brand-identity-package", + "anchor": "tpl-brand", + "cover": "/images/case354.jpg", + "title": { "en": "Brand Identity Package", "zh": "品牌身份包" }, + "description": { + "en": "Logo, palette, typography, applications, and brand touchpoints.", + "zh": "生成 Logo、配色、字体、应用触点与品牌系统。" + }, + "category": "Brand & Logos", + "styles": ["Brand"], + "scenes": ["Commerce"], + "tags": ["Brand", "Logo", "Identity"], + "useWhen": { + "en": "Use for logo systems, brand boards, visual identity kits, and application mockups.", + "zh": "用于 Logo 系统、品牌板、VI 套件和应用样机。" + }, + "guidance": { + "en": ["Define brand name, positioning, palette, typography, logo usage, and touchpoints.", "Ask for a coherent board with aligned applications."], + "zh": ["定义品牌名、定位、配色、字体、Logo 用法和触点。", "要求视觉板中的应用统一对齐。"] + }, + "pitfalls": { + "en": ["Avoid unrelated logo variants and inconsistent palettes.", "Keep brand text accurate."], + "zh": ["避免无关 Logo 变体和混乱配色。", "保持品牌文字准确。"] + }, + "exampleCases": [354] + }, + { + "id": "brand-touchpoint-board", + "anchor": "tpl-brand", + "cover": "/images/case362.jpg", + "title": { "en": "Brand Touchpoint Board", "zh": "品牌触点视觉板" }, + "description": { + "en": "Campaign boards across packaging, social, web, and display contexts.", + "zh": "生成包装、社媒、网页和展示场景里的品牌触点板。" + }, + "category": "Brand & Logos", + "styles": ["Brand", "Product"], + "scenes": ["Commerce", "Social"], + "tags": ["Brand", "Identity", "Campaign"], + "useWhen": { + "en": "Use for multi-touchpoint campaign boards and brand rollout previews.", + "zh": "用于多触点 Campaign 展示和品牌落地预览。" + }, + "guidance": { + "en": ["Specify touchpoint list, shared visual rules, and mockup arrangement.", "Use one palette and one typography logic across all panels."], + "zh": ["指定触点清单、统一视觉规则和样机排列。", "让所有面板共享配色和字体逻辑。"] + }, + "pitfalls": { + "en": ["Avoid mixing many unrelated campaign styles.", "Limit touchpoints if readability drops."], + "zh": ["避免混入多个无关 Campaign 风格。", "可读性下降时减少触点数量。"] + }, + "exampleCases": [362] + }, + { + "id": "architecture-space", + "anchor": "tpl-architecture", + "cover": "/images/case331.png", + "title": { "en": "Architecture & Space", "zh": "建筑与空间" }, + "description": { + "en": "Interior, exterior, city, map, and spatial concept visuals.", + "zh": "生成室内、建筑、城市地图和空间概念视觉。" + }, + "category": "Architecture & Spaces", + "styles": ["Architecture"], + "scenes": ["Travel", "Commerce"], + "tags": ["Architecture", "Interior", "Map"], + "useWhen": { + "en": "Use for interiors, architecture renders, city maps, spatial plans, and environment concepts.", + "zh": "用于室内、建筑表现、城市地图、空间规划和环境概念图。" + }, + "guidance": { + "en": ["Define viewpoint, scale, material, lighting, and spatial function.", "For maps, specify landmarks, labels, border decoration, and visual accuracy level."], + "zh": ["定义视角、尺度、材质、光线和空间功能。", "地图需要指定地标、标签、边框装饰和准确度。"] + }, + "pitfalls": { + "en": ["Avoid impossible perspectives unless the output is conceptual.", "Lock map label language and relative placement."], + "zh": ["概念图之外要避免不合理透视。", "锁定地图标签语言和相对位置。"] + }, + "exampleCases": [331, 11] + }, + { + "id": "realistic-photography", + "anchor": "tpl-photo", + "cover": "/images/case377.jpg", + "title": { "en": "Realistic Photography", "zh": "写实摄影" }, + "description": { + "en": "Lens, lighting, film texture, and documentary-style photo prompts.", + "zh": "控制镜头、光线、胶片质感和纪实摄影效果。" + }, + "category": "Photography & Realism", + "styles": ["Photography", "Realistic"], + "scenes": ["Fashion", "Commerce"], + "tags": ["Photography", "Realistic", "Lens"], + "useWhen": { + "en": "Use for portraits, street photos, product photography, and cinematic realism.", + "zh": "用于人像、街拍、商品摄影和电影感写实。" + }, + "guidance": { + "en": ["Specify camera distance, lens, light source, texture, background, and motion.", "Use believable imperfections for documentary realism."], + "zh": ["指定机位、镜头、光源、质感、背景和动作。", "加入可信的小瑕疵增强纪实感。"] + }, + "pitfalls": { + "en": ["Avoid over-polished plastic skin unless commercial beauty is required.", "Add negative constraints for hands, text, and anatomy when needed."], + "zh": ["商业美妆之外,避免过度磨皮。", "需要时加入手部、文字、结构类负面约束。"] + }, + "exampleCases": [377] + }, + { + "id": "street-accident-moment", + "anchor": "tpl-photo", + "cover": "/images/case376.jpg", + "title": { "en": "Street Accident Moment", "zh": "街头意外瞬间摄影" }, + "description": { + "en": "Realistic phone-photo scenes with negative constraints.", + "zh": "生成手机纪实风街头瞬间,并加入负面约束。" + }, + "category": "Photography & Realism", + "styles": ["Photography", "Realistic"], + "scenes": ["Travel", "Social"], + "tags": ["Photography", "Realistic", "Scene"], + "useWhen": { + "en": "Use for candid street moments, accidental spills, documentary phone shots, and fast action.", + "zh": "用于街头抓拍、意外泼洒、手机纪实和快速动作。" + }, + "guidance": { + "en": ["Describe the exact moment, camera height, motion blur, and street context.", "Add negative constraints for staged poses and fake ad lighting."], + "zh": ["描述具体瞬间、机位高度、运动模糊和街景。", "加入避免摆拍和广告棚拍感的限制。"] + }, + "pitfalls": { + "en": ["Avoid too-clean compositions.", "Keep the event plausible and grounded."], + "zh": ["避免画面过于干净。", "让事件看起来可信。"] + }, + "exampleCases": [376] + }, + { + "id": "illustration-art-style", + "anchor": "tpl-illustration", + "cover": "/images/case346.jpg", + "title": { "en": "Illustration & Art Style", "zh": "插画与艺术风格" }, + "description": { + "en": "Anime, watercolor, ink, material experiments, and art direction.", + "zh": "生成动漫、水彩、水墨、材质实验和艺术风格图。" + }, + "category": "Illustration & Art", + "styles": ["Illustration"], + "scenes": ["Story", "Creative"], + "tags": ["Illustration", "Art", "Style"], + "useWhen": { + "en": "Use for anime, watercolor, ink, decorative art, and style experiments.", + "zh": "用于动漫、水彩、水墨、装饰画和风格实验。" + }, + "guidance": { + "en": ["Define composition, subject, palette, brush material, mood, and rendering depth.", "For reference images, state what must be preserved."], + "zh": ["定义构图、主体、配色、笔触材质、情绪和完成度。", "参考图任务需要说明保留哪些特征。"] + }, + "pitfalls": { + "en": ["Avoid style-only prompts without composition.", "Lock character identity when using references."], + "zh": ["避免只写风格,不写构图。", "使用参考图时锁定角色识别。"] + }, + "exampleCases": [346, 6] + }, + { + "id": "character-design-sheet", + "anchor": "tpl-character", + "cover": "/images/case347.jpg", + "title": { "en": "Character Design Sheet", "zh": "角色设定表" }, + "description": { + "en": "Characters, pose sheets, action grids, and identity consistency.", + "zh": "生成角色设定、动作分解和一致性参考。" + }, + "category": "Characters & People", + "styles": ["Character", "Illustration"], + "scenes": ["Story"], + "tags": ["Character", "Pose", "Style"], + "useWhen": { + "en": "Use for character sheets, pose grids, action breakdowns, and identity references.", + "zh": "用于角色设定表、动作网格、动作拆解和一致性参考。" + }, + "guidance": { + "en": ["Define identity anchors, outfit, proportions, pose count, and sheet layout.", "Keep face, hairstyle, and costume details consistent."], + "zh": ["定义身份锚点、服装、比例、动作数量和版式。", "保持脸、发型和服装细节一致。"] + }, + "pitfalls": { + "en": ["Avoid changing costume details between poses.", "Limit pose count if the sheet becomes crowded."], + "zh": ["避免不同动作里服装细节变化。", "画面拥挤时减少动作数量。"] + }, + "exampleCases": [347] + }, + { + "id": "3d-collectible-toy", + "anchor": "tpl-character", + "cover": "/images/case378.jpg", + "title": { "en": "3D Collectible Toy", "zh": "3D 收藏玩具" }, + "description": { + "en": "Reference photo to premium 3D collectible figure prompts.", + "zh": "把参考图转换成高级 3D 收藏玩具效果。" + }, + "category": "Characters & People", + "styles": ["3D", "Character"], + "scenes": ["Commerce", "Creative"], + "tags": ["Character", "3D", "Style"], + "useWhen": { + "en": "Use for premium collectible figures, avatar toys, blind-box characters, and 3D display renders.", + "zh": "用于高级收藏玩具、头像公仔、潮玩角色和 3D 展示图。" + }, + "guidance": { + "en": ["Preserve face and outfit anchors from the reference.", "Specify material, packaging, base, lighting, and collectible scale."], + "zh": ["保留参考图中的脸和服装锚点。", "指定材质、包装、底座、光线和收藏比例。"] + }, + "pitfalls": { + "en": ["Avoid generic toy bodies without identity details.", "Keep packaging text minimal and accurate."], + "zh": ["避免没有身份细节的通用玩具。", "包装文字保持少量且准确。"] + }, + "exampleCases": [378] + }, + { + "id": "scene-storytelling", + "anchor": "tpl-scene", + "cover": "/images/case330.png", + "title": { "en": "Scene Storytelling", "zh": "场景叙事" }, + "description": { + "en": "Storyboards, worldbuilding, narrative scenes, and emotional pacing.", + "zh": "生成分镜、世界观、故事场景和情绪节奏。" + }, + "category": "Scenes & Storytelling", + "styles": ["Scenes", "Illustration"], + "scenes": ["Story", "Social"], + "tags": ["Scene", "Story", "Storyboard"], + "useWhen": { + "en": "Use for storyboards, worldbuilding, live scenes, and emotional narrative frames.", + "zh": "用于分镜、世界观、直播场景和情绪叙事画面。" + }, + "guidance": { + "en": ["Define who, where, when, conflict, emotion, and camera framing.", "Use scene details to support narrative rather than decoration."], + "zh": ["定义人物、地点、时间、冲突、情绪和机位。", "让场景细节服务故事。"] + }, + "pitfalls": { + "en": ["Avoid generic fantasy backgrounds.", "Keep narrative cues visible in the frame."], + "zh": ["避免通用幻想背景。", "让故事线索在画面里可见。"] + }, + "exampleCases": [330] + }, + { + "id": "history-classical-themes", + "anchor": "tpl-history", + "cover": "/images/case375.jpg", + "title": { "en": "History & Classical Themes", "zh": "历史与古风题材" }, + "description": { + "en": "Dynasty clothing, scroll narrative, poetry, and traditional motifs.", + "zh": "生成朝代服饰、长卷叙事、诗词和传统题材。" + }, + "category": "History & Classical Themes", + "styles": ["History", "Classical", "Illustration"], + "scenes": ["History", "Story"], + "tags": ["History", "Classical", "Scroll"], + "useWhen": { + "en": "Use for ancient Chinese themes, scrolls, dynasty clothing, poetry visuals, and historical scenes.", + "zh": "用于古风题材、长卷、朝代服饰、诗词视觉和历史场景。" + }, + "guidance": { + "en": ["Specify dynasty, clothing system, object references, layout format, and cultural mood.", "Use scroll, album page, or poster format deliberately."], + "zh": ["指定朝代、服饰制度、器物参考、版式和文化气质。", "明确长卷、册页或海报形式。"] + }, + "pitfalls": { + "en": ["Avoid mixing dynasties when historical accuracy matters.", "Constrain random modern props."], + "zh": ["需要历史准确时,避免朝代混搭。", "约束随机现代物件。"] + }, + "exampleCases": [375, 338] + }, + { + "id": "document-publishing", + "anchor": "tpl-document", + "cover": "/images/case360.jpg", + "title": { "en": "Document & Publishing", "zh": "文档与出版物" }, + "description": { + "en": "White papers, manuals, encyclopedic plates, and page systems.", + "zh": "生成白皮书、手册、百科图鉴和页面系统。" + }, + "category": "Documents & Publishing", + "styles": ["Documents", "Infographic"], + "scenes": ["Education", "Tech"], + "tags": ["Document", "Publishing", "Layout"], + "useWhen": { + "en": "Use for white papers, manuals, encyclopedic plates, report pages, and publication systems.", + "zh": "用于白皮书、手册、百科图鉴、报告页面和出版系统。" + }, + "guidance": { + "en": ["Define page size, columns, table of contents, figure system, and typography hierarchy.", "Use readable headings, tables, labels, and page rhythm."], + "zh": ["定义页面尺寸、分栏、目录、图表系统和字体层级。", "使用可读标题、表格、标签和页面节奏。"] + }, + "pitfalls": { + "en": ["Avoid tiny dense text.", "Keep charts and captions aligned to the page grid."], + "zh": ["避免密集小字。", "让图表和说明对齐页面网格。"] + }, + "exampleCases": [360] + }, + { + "id": "concept-product-breakdown", + "anchor": "tpl-other", + "cover": "/images/case370.jpg", + "title": { "en": "Concept Product Breakdown", "zh": "概念产品研发拆解" }, + "description": { + "en": "R&D boards, exploded views, mixed tasks, and special outputs.", + "zh": "生成研发板、拆解图、混合任务和特殊输出。" + }, + "category": "Other Use Cases", + "styles": ["Other Use Cases", "Product"], + "scenes": ["Creative", "Tech"], + "tags": ["Creative", "R&D", "Special"], + "useWhen": { + "en": "Use for experimental prompt tasks, R&D boards, exploded diagrams, and unusual visual systems.", + "zh": "用于实验型任务、研发视觉板、拆解图和特殊视觉系统。" + }, + "guidance": { + "en": ["Define the artifact type, components, labels, material logic, and final presentation format.", "Use clear callouts and a controlled technical style."], + "zh": ["定义产物类型、组件、标签、材质逻辑和展示格式。", "使用清晰标注和受控技术风格。"] + }, + "pitfalls": { + "en": ["Avoid unspecified mixed tasks.", "Keep labels short and component relationships visible."], + "zh": ["避免任务边界过泛。", "标签要短,组件关系要清楚。"] + }, + "exampleCases": [370, 361] + } + ] +} diff --git a/scripts/promotion/build_prompt.py b/scripts/promotion/build_prompt.py new file mode 100644 index 00000000..0dc69dc0 --- /dev/null +++ b/scripts/promotion/build_prompt.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Build an offline, auditable GPT-Image2 prompt from a PromotionBrief. + +This intentionally consumes only the upstream style-library metadata. It does +not download images, call a remote image API, or read secrets. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[2] +LIBRARY = ROOT / "scripts" / "promotion" / "awesome-gpt-image-2" / "style-library.json" +UPSTREAM_COMMIT = "685469889fb72fd5adefae45e1645d527edcb5e7" +EXPECTED_LIBRARY_SHA256 = "80f5cae039d0d6f312f0e2de2c9b3fc8a806640b0d517c120d704a71c5e4aa72" + +CHANNELS = { + "xiaohongshu": {"aspect_ratio": "3:4", "size": "1080x1440", "language": "zh-CN"}, + "x": {"aspect_ratio": "16:9", "size": "1600x900", "language": "en"}, +} + +LOCAL_CONSTRAINTS = [ + "产品概念海报,不是产品截图,不复刻真实产品界面。", + "不要生成真实 Agent 图标、品牌 Logo、厂商标志或未经确认的产品名称。", + "不要生成 URL、二维码、API Key、Cookie、密码、水印、乱码或伪文字。", + "所有元素必须完整位于画布内部,禁止裁切、溢出、漂浮、断裂、重复和错误透视。", + "保持单一主视觉,避免 UI 卡片、面板拼贴、复杂仪表盘和信息过载。", + "避免赛博朋克、霓虹、玻璃拟态、廉价 SaaS 模板和泛科技 stock image。", +] + + +def load_library() -> tuple[dict[str, Any], str]: + raw = LIBRARY.read_bytes() + digest = hashlib.sha256(raw).hexdigest() + if digest != EXPECTED_LIBRARY_SHA256: + raise ValueError(f"style library hash mismatch: {digest}") + data = json.loads(raw) + if data.get("version") != 1 or not isinstance(data.get("templates"), list): + raise ValueError("unsupported style library schema") + return data, digest + + +def require_brief(brief: dict[str, Any]) -> None: + required = ["version", "feature_focus", "target_audience", "product_visual"] + missing = [key for key in required if not str(brief.get(key, "")).strip()] + if missing: + raise ValueError("missing brief fields: " + ", ".join(missing)) + if not str(brief["version"]).startswith("v"): + raise ValueError("brief version must be a verified release tag") + + +def select_template(library: dict[str, Any], brief: dict[str, Any]) -> dict[str, Any]: + templates = library["templates"] + wanted = str(brief.get("template_id", "poster-layout-system")).strip() + for template in templates: + if template.get("id") == wanted: + return template + raise ValueError(f"template not found: {wanted}") + + +def lines(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def build_prompt(brief: dict[str, Any], channel: str) -> dict[str, Any]: + if channel not in CHANNELS: + raise ValueError(f"unsupported channel: {channel}") + require_brief(brief) + library, library_hash = load_library() + template = select_template(library, brief) + spec = CHANNELS[channel] + locale_key = "zh" if channel == "xiaohongshu" else "en" + guidance = lines(template.get("guidance", {}).get(locale_key)) + pitfalls = lines(template.get("pitfalls", {}).get(locale_key)) + capabilities = lines(brief.get("product_capabilities")) + outcomes = lines(brief.get("user_outcomes")) + copy = ( + "10+ AI 工具,一处管理;安装、配置、启动,都更简单。" + if channel == "xiaohongshu" + else "10+ AI tools. One workspace. Install, configure, launch." + ) + prompt_parts = [ + "生成一张完整、单一场景的产品概念推广海报。", + f"产品:BootAgent;目标受众:{brief['target_audience']}。", + f"产品视觉方向:{brief['product_visual']}。", + f"主题:{brief['feature_focus']}。", + f"画布比例:{spec['aspect_ratio']},输出尺寸:{spec['size']}。", + f"构图指导:{';'.join(guidance)}", + f"需要表达的产品能力:{';'.join(capabilities)}。" if capabilities else "", + f"用户结果:{';'.join(outcomes)}。" if outcomes else "", + "只允许在后期排版层加入少量准确文字,不要在底图中生成正文。", + f"渠道短文案参考:{copy}", + f"模板常见问题:{';'.join(pitfalls)}", + "本地强约束:" + ";".join(LOCAL_CONSTRAINTS), + ] + prompt = "".join(part for part in prompt_parts if part) + return { + "schema_version": 1, + "generator": "GPT Image 2", + "channel": channel, + "aspect_ratio": spec["aspect_ratio"], + "canvas": spec["size"], + "language": spec["language"], + "version": brief["version"], + "template_id": template["id"], + "template_title": template["title"].get(locale_key, template["title"].get("en", "")), + "upstream": { + "repository": library["repository"], + "commit": UPSTREAM_COMMIT, + "source_path": "data/style-library.json", + "style_library_sha256": library_hash, + }, + "prompt": prompt, + "negative_constraints": LOCAL_CONSTRAINTS + pitfalls, + "remote_generation": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--brief", type=Path, required=True) + parser.add_argument("--channel", choices=sorted(CHANNELS), required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + brief = json.loads(args.brief.read_text(encoding="utf-8")) + result = build_prompt(brief, args.channel) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"wrote {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_promotion_prompt_builder.py b/scripts/test_promotion_prompt_builder.py new file mode 100644 index 00000000..ddc01957 --- /dev/null +++ b/scripts/test_promotion_prompt_builder.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Tests for the offline promotion prompt builder.""" + +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MODULE_PATH = ROOT / "scripts" / "promotion" / "build_prompt.py" +spec = importlib.util.spec_from_file_location("build_prompt", MODULE_PATH) +assert spec and spec.loader +builder = importlib.util.module_from_spec(spec) +spec.loader.exec_module(builder) + + +class PromotionPromptBuilderTests(unittest.TestCase): + def setUp(self) -> None: + self.brief = json.loads( + (ROOT / "outputs/promotion/v0.7.3/xiaohongshu/brief.json").read_text(encoding="utf-8") + ) + + def test_builds_chinese_poster_prompt_from_current_brief(self) -> None: + result = builder.build_prompt(self.brief, "xiaohongshu") + self.assertEqual(result["template_id"], "poster-layout-system") + self.assertEqual(result["aspect_ratio"], "3:4") + self.assertEqual(result["canvas"], "1080x1440") + self.assertEqual(result["language"], "zh-CN") + self.assertIn("第一次接触 AI 编程工具的小白用户", result["prompt"]) + self.assertIn("不要生成真实 Agent 图标", result["prompt"]) + self.assertEqual(result["upstream"]["commit"], builder.UPSTREAM_COMMIT) + self.assertFalse(result["remote_generation"]) + + def test_builds_x_prompt_with_different_channel_contract(self) -> None: + result = builder.build_prompt( + json.loads((ROOT / "outputs/promotion/v0.7.3/x/brief.json").read_text(encoding="utf-8")), + "x", + ) + self.assertEqual(result["aspect_ratio"], "16:9") + self.assertEqual(result["canvas"], "1600x900") + self.assertEqual(result["language"], "en") + self.assertIn("10+ AI tools. One workspace.", result["prompt"]) + + def test_missing_product_fields_are_rejected(self) -> None: + brief = dict(self.brief) + brief["product_visual"] = "" + with self.assertRaisesRegex(ValueError, "product_visual"): + builder.build_prompt(brief, "xiaohongshu") + + def test_invalid_release_tag_is_rejected(self) -> None: + brief = dict(self.brief) + brief["version"] = "latest" + with self.assertRaisesRegex(ValueError, "verified release tag"): + builder.build_prompt(brief, "xiaohongshu") + + def test_output_is_json_serializable_without_secrets(self) -> None: + result = builder.build_prompt(self.brief, "xiaohongshu") + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "prompt.json" + path.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8") + loaded = json.loads(path.read_text(encoding="utf-8")) + serialized = json.dumps(loaded, ensure_ascii=False).lower() + self.assertNotIn("sk-test", serialized) + self.assertNotIn("authorization: bearer", serialized) + self.assertNotIn("cookie=", serialized) + self.assertNotIn("api_key=", serialized) + + +if __name__ == "__main__": + unittest.main()