81 Commits
Author SHA1 Message Date
lofyer 836f74a55d chore: release 0.9.0
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub Release (push) Canceled after 0s
2026-08-14 13:25:16 +08:00
lofyer 6c879ab34a refactor: remove ineffective Harness window guard 2026-08-14 13:25:00 +08:00
lofyer de497b9553 docs: expand product planning 2026-08-14 13:24:42 +08:00
lofyer 45aeecb6dd feat: expand DeepSeek Harness compatibility 2026-08-14 12:38:01 +08:00
lofyer 36e05d45fa feat: simplify DeepSeek Harness model selection
- replace competing model-source choices with one DeepSeek connection picker
- keep administrator-provided environment settings compatible without exposing them as a selectable UI source
- update bilingual guidance, tests, and runtime design documentation
2026-08-14 10:30:53 +08:00
lofyer 8286e120a1 feat: add DeepSeek Harness runtime 2026-08-14 10:04:34 +08:00
lofyer fca9888f83 fix: keep streamed reasoning visible 2026-08-14 09:55:07 +08:00
lofyer 7a86c96623 feat: add natural language configuration tools 2026-08-14 01:23:32 +08:00
lofyer 445ebbc002 fix: open the selected channel settings 2026-08-14 01:12:39 +08:00
lofyer 4d487729e8 fix: improve Chinese typography 2026-08-14 00:41:56 +08:00
lofyer 11f84ec0cf fix: correct WeChat QR scan instructions 2026-08-14 00:00:06 +08:00
lofyer 0309ff8734 docs: add English project overview
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub Release (push) Canceled after 0s
2026-08-13 17:10:50 +08:00
lofyer a52462aaf4 docs: open source under 0BSD
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub Release (push) Canceled after 0s
2026-08-13 16:39:24 +08:00
lofyer 7e04382829 chore: release 0.8.20
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub Release (push) Canceled after 0s
2026-08-13 15:55:53 +08:00
lofyer 48381cbb89 fix: remove chat message dividers 2026-08-13 15:45:54 +08:00
lofyer aab961226f fix: harden scoped tools and settings persistence 2026-08-13 14:56:53 +08:00
lofyer bf1ec5d2f1 fix: contain wide chat tables 2026-08-13 14:03:36 +08:00
lofyer 7a078c6ffe fix: publish knowledge rebuilds atomically 2026-08-13 06:35:07 +08:00
lofyer 0c46afba59 fix: preserve knowledge indexing state 2026-08-13 04:47:44 +08:00
lofyer e3b5702767 fix: bound document extraction 2026-08-13 04:46:30 +08:00
lofyer 5b579ae100 fix: bound model streaming 2026-08-13 03:18:55 +08:00
lofyer 980f3a0c8f fix: preserve channel message delivery 2026-08-13 02:09:09 +08:00
lofyer 67cb69f07d fix: recover interrupted schedules 2026-08-13 01:41:52 +08:00
lofyer 8cd23bada1 fix: serialize runtime cleanup 2026-08-13 01:36:00 +08:00
lofyer fd1ff92927 refactor: simplify scoped data tools 2026-08-13 01:32:43 +08:00
lofyer 04a260133a fix: preserve conversation and note data 2026-08-13 01:32:10 +08:00
lofyer 40696d9ac7 feat: render interactive Mermaid diagrams 2026-08-13 00:30:47 +08:00
lofyer 86b63406c2 fix: streamline document parsing settings 2026-08-13 00:08:15 +08:00
lofyer d33df979da feat: render Markdown formulas with KaTeX 2026-08-12 23:48:44 +08:00
lofyer 2e489d5bc3 fix: stream direct-model reasoning with tools 2026-08-12 23:29:37 +08:00
lofyer ca5b722571 fix: keep streamed reasoning expanded 2026-08-12 23:10:25 +08:00
lofyer d769f31492 fix: separate model credential status 2026-08-12 22:58:50 +08:00
lofyer c224da75fe feat: redesign knowledge workspace 2026-08-12 21:46:05 +08:00
lofyer 111f487e20 feat: enhance local knowledge retrieval 2026-08-12 21:45:47 +08:00
lofyer e0e5a8c1b3 docs: specify knowledge retrieval enhancements 2026-08-12 21:45:20 +08:00
lofyer 6a44335238 feat: support dynamic MCP tool loading 2026-08-11 23:49:18 +08:00
lofyer 98d7166ab3 feat: organize MCP settings into tabs 2026-08-11 23:04:41 +08:00
lofyer 2982f1ae33 chore: release 0.8.19
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Canceled after 0s
Cross-platform packages / Publish GitHub Release (push) Canceled after 0s
2026-08-11 21:18:10 +08:00
lofyer e2d7837d91 fix: make speech path test cross-platform 2026-08-11 21:13:32 +08:00
lofyer 6c0defcf04 chore: release 0.8.18
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Canceled after 0s
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Canceled after 0s
Cross-platform packages / Publish GitHub Release (push) Canceled after 0s
2026-08-11 21:01:53 +08:00
lofyer 44d30b428d feat: add bilingual release notes 2026-08-11 20:59:26 +08:00
lofyer 6942bef567 fix: preserve recovered opencode results 2026-08-11 20:58:10 +08:00
lofyer d8f1badad6 fix: preserve shared switch dimensions 2026-08-11 20:14:16 +08:00
lofyer beb756bb2e feat: add system time to model prompt 2026-08-11 20:08:43 +08:00
lofyer 9bbaa2c53b docs: rename UI design guide and clarify switches 2026-08-11 20:07:58 +08:00
lofyer 184180e618 feat: expand model tools and document handling 2026-08-11 19:52:58 +08:00
lofyer 71a8662690 feat: add project default runtime 2026-08-11 17:52:55 +08:00
lofyer e0e7bc573c feat: add document OCR and offline model archives 2026-08-11 16:49:51 +08:00
lofyer 19a4469561 fix: streamline tool failure feedback 2026-08-11 16:39:17 +08:00
lofyer fde18c1568 fix: allow execute runtime tools by default 2026-08-11 13:07:36 +08:00
lofyer aff3b82998 feat: localize interface and enrich magic notes 2026-08-11 12:01:58 +08:00
lofyer c8050f4a9a feat: expand local speech models 2026-08-11 01:34:28 +08:00
lofyer f16ef993bc docs: refine compatibility and add notes demo
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
2026-08-11 00:52:31 +08:00
lofyer 80c4ef5ed0 docs: make release tags optional 2026-08-11 00:05:44 +08:00
lofyer 1f44782b98 docs: simplify compatibility overview 2026-08-11 00:01:58 +08:00
lofyer 88e77cc5d4 chore: release 0.8.12 2026-08-10 23:50:25 +08:00
lofyer 90c4e9d8cc fix: align knowledge workspace controls 2026-08-10 23:23:54 +08:00
lofyer 7ce58da5f5 fix: propagate image input capability 2026-08-10 23:15:00 +08:00
lofyer 6fd41d2cfd feat: make magic todo status editable 2026-08-10 23:04:12 +08:00
lofyer 5cb99f3097 feat: expand magic notes MCP tools 2026-08-10 23:02:45 +08:00
lofyer cb0319c4d1 fix: use radial force for knowledge graph 2026-08-10 22:57:31 +08:00
lofyer 2cc76fc960 fix: unify settings center layout 2026-08-10 22:30:32 +08:00
lofyer 7d15e83153 feat: enhance magic notes AI comments 2026-08-10 22:27:50 +08:00
lofyer ad79659308 feat: expand multimodal and knowledge workflows 2026-08-10 21:25:47 +08:00
lofyer 0fab985f28 feat: enhance knowledge workflows and refresh interface 2026-08-10 16:27:40 +08:00
lofyer 2cb712e4ba fix: align WeChat channel modes 2026-08-10 11:47:03 +08:00
lofyer a9ae00a845 fix: register native OpenCode skills 2026-08-10 11:31:02 +08:00
lofyer be82caebc4 fix: deduplicate Continue system messages 2026-08-10 10:36:47 +08:00
lofyer 5ea022ad5c feat: globalize Magic Notes and improve runtime tools 2026-08-10 10:27:14 +08:00
lofyer 1a8e110866 fix: make skill budget test cross-platform
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
2026-08-10 00:40:52 +08:00
lofyer 5e40db4e51 fix: preserve fully truncated skill warnings
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
2026-08-10 00:33:37 +08:00
lofyer b249df116a feat: add configurable Magic Notes and polish UI
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
2026-08-10 00:24:19 +08:00
lofyer 1cc969317d fix: align channel runtime and settings UI 2026-08-09 21:54:25 +08:00
lofyer 66b098ae36 feat: add secure remote channel media 2026-08-09 20:33:35 +08:00
lofyer 6c891f3522 feat: add remote channels and richer notes 2026-08-09 15:48:52 +08:00
lofyer 417a9fccb6 feat: improve skill import and runtime delivery 2026-08-07 21:01:17 +08:00
lofyer 954b42ef55 fix: improve shutdown and project continuity 2026-08-07 18:46:26 +08:00
lofyer 53d18e2b06 feat: improve runtime visibility and browser interaction 2026-08-07 17:53:14 +08:00
lofyer 2c715e5e81 chore: prepare GoodBuddy 0.8.6
Cross-platform packages / Validate source (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Has been cancelled
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Has been cancelled
Cross-platform packages / Publish GitHub Release (push) Has been cancelled
2026-08-07 13:11:45 +08:00
lofyer 32aba176c8 docs: require release tags for GitHub pushes 2026-08-07 10:58:00 +08:00
lofyer 17e66a3369 chore: prepare GoodBuddy 0.8.5 2026-08-07 10:45:01 +08:00
440 changed files with 120392 additions and 14777 deletions
+11 -4
View File
@@ -33,6 +33,10 @@ jobs:
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
run: node -e "const p=require('./package.json'); const expected='v'+p.version; if(process.env.GITHUB_REF_NAME!==expected){throw new Error('Expected tag '+expected+', received '+process.env.GITHUB_REF_NAME)}" run: node -e "const p=require('./package.json'); const expected='v'+p.version; if(process.env.GITHUB_REF_NAME!==expected){throw new Error('Expected tag '+expected+', received '+process.env.GITHUB_REF_NAME)}"
- name: Verify bilingual release notes
if: github.ref_type == 'tag'
run: npm run release:notes:verify
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -153,6 +157,9 @@ jobs:
test "$GITHUB_REF_NAME" = "$expected" test "$GITHUB_REF_NAME" = "$expected"
test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA" test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA"
- name: Prepare bilingual release notes
run: node build/release-notes.cjs --output release-notes.md
- name: Download Windows packages - name: Download Windows packages
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
@@ -181,11 +188,11 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
tag="$GITHUB_REF_NAME" tag="$GITHUB_REF_NAME"
if gh release view "$tag" >/dev/null 2>&1; then
gh release edit "$tag" --draft
else
version="$(node -p "require('./package.json').version")" version="$(node -p "require('./package.json').version")"
gh release create "$tag" --draft --verify-tag --generate-notes --title "GoodBuddy $version" if gh release view "$tag" >/dev/null 2>&1; then
gh release edit "$tag" --draft --title "GoodBuddy $version" --notes-file release-notes.md
else
gh release create "$tag" --draft --verify-tag --title "GoodBuddy $version" --notes-file release-notes.md
fi fi
gh release upload "$tag" dist/release-upload/* --clobber gh release upload "$tag" dist/release-upload/* --clobber
gh release edit "$tag" --draft=false --latest gh release edit "$tag" --draft=false --latest
+74 -7
View File
@@ -13,7 +13,6 @@ primarily Simplified Chinese.
- `src/main`: privileged Electron main process, runtimes, persistence, IPC, - `src/main`: privileged Electron main process, runtimes, persistence, IPC,
knowledge, automation, and OS integration. knowledge, automation, and OS integration.
- `src/preload`: the narrow, typed bridge exposed to the renderer. - `src/preload`: the narrow, typed bridge exposed to the renderer.
- `src/renderer`: React UI. It must not receive secrets or direct Node access.
- `src/shared`: schemas, contracts, presets, and IPC channel definitions shared - `src/shared`: schemas, contracts, presets, and IPC channel definitions shared
across process boundaries. across process boundaries.
- `resources/skills`: bundled skills. - `resources/skills`: bundled skills.
@@ -27,12 +26,10 @@ Keep Electron security boundaries intact:
- Validate IPC input with shared Zod schemas and verify trusted senders. - Validate IPC input with shared Zod schemas and verify trusted senders.
- Expose only explicit preload methods. Do not pass raw Electron APIs. - Expose only explicit preload methods. Do not pass raw Electron APIs.
- Keep API keys in the main process and encrypted settings store. - Keep API keys in the main process and encrypted settings store.
- Never log or return credentials, authorization headers, private documents, or
unredacted provider payloads.
## Runtime Behavior ## Runtime Behavior
- Ask and Plan modes must remain read-only at the runtime boundary. - Ask mode must remain read-only at the runtime boundary.
- Execute mode may use tools only through the existing approval controls. - Execute mode may use tools only through the existing approval controls.
- Preserve cancellation, timeout, bounded-output, and shutdown behavior. - Preserve cancellation, timeout, bounded-output, and shutdown behavior.
- Treat OpenCode and Continue as untrusted child runtimes. Preserve environment - Treat OpenCode and Continue as untrusted child runtimes. Preserve environment
@@ -57,10 +54,36 @@ Keep Electron security boundaries intact:
- Keep changes focused. Do not add unrelated refactors or documentation. - Keep changes focused. Do not add unrelated refactors or documentation.
- Add or update focused tests for behavioral changes and regressions. - Add or update focused tests for behavioral changes and regressions.
- Avoid broad catches that erase HTTP status, cancellation, or provider error - Avoid broad catches that erase HTTP status, cancellation, or provider error
context. Bound and redact any surfaced error details. context.
- Keep UI accessible with labels, keyboard behavior, semantic roles, and visible - Keep UI accessible with labels, keyboard behavior, semantic roles, and visible
focus states. focus states.
## UI Consistency
- Treat `UI-DESIGN.md` as the canonical UI design system. Read and follow it
before changing renderer layout, shared controls, interaction feedback,
themes, responsive behavior, or accessibility semantics.
- Reuse the shared `PageTabs` and `SegmentedControl` primitives instead of
creating page-specific tab or toggle styles. A semantic tab set may use the
shared segmented visual variant, but it must retain `tablist`, `tab`,
`tabpanel`, `aria-selected`, roving focus, and arrow-key behavior.
- Use the shared sliding Switch pattern for persistent binary states and expose
`role="switch"` even when it is implemented with a checkbox input. Keep
Checkbox visuals and semantics for multi-select, assignment, and explicit
confirmation. Do not create page-specific Switch styling.
- Use the bundled `Inter Variable` and `Noto Sans SC Variable` UI fonts through
the shared typography tokens. Do not add remote font requests or page-local
font stacks. Keep redistributed font licenses in packaged resources and
retain system fallbacks for startup and unsupported glyphs.
- Route transient success and informational feedback, plus asynchronous errors
that are not tied to one field, through the application notification
viewport. Do not render page-local copies of the same notification pattern.
- Keep inline feedback only when it must remain attached to its context, such
as field validation, destructive confirmation, operation progress, a
blocking page state, or an error with an immediate local recovery action.
- Do not show the same event both inline and as an application notification.
Preserve user input and actionable error context when an operation fails.
## Release Packaging ## Release Packaging
- `.github/workflows/packages.yml` is the canonical cross-platform packaging - `.github/workflows/packages.yml` is the canonical cross-platform packaging
@@ -87,6 +110,49 @@ Keep Electron security boundaries intact:
CommonJS macOS icon tool. CommonJS macOS icon tool.
- Tag builds must use `v${package.version}`. The workflow also supports manual - Tag builds must use `v${package.version}`. The workflow also supports manual
dispatch and main-branch changes to release tooling. dispatch and main-branch changes to release tooling.
### Tagged Release Process
Every version-tag release must follow this sequence. A branch-only push does
not require release notes.
1. Confirm that the user wants a release tag and identify the exact release
commit and the new `package.json` version.
2. Find the latest stable version tag reachable before the release commit and
inspect the complete commit and file diff from that tag to the release
commit. For the first tagged release, inspect the relevant repository
history instead.
3. Draft concise, user-facing release notes in both Simplified Chinese and
English based only on verified changes in that range. Use the titles
`GoodBuddy <version> 更新内容` and
`What's New in GoodBuddy <version>`, with corresponding `功能更新` /
`Features` and `问题修复` / `Bug Fixes` sections when applicable. The two
language versions must describe the same changes. Do not expose
internal-only details, credentials, private content, or unverified claims.
4. Show the exact bilingual release-note draft to the user and wait for
explicit approval. If the release commit or either language version changes
after approval, inspect the updated tag range and request approval again.
5. Only after approval, verify that `package.json` and `package-lock.json`
contain the same release version, verify the candidate tag does not already
point elsewhere, create `v${package.version}` at the exact approved commit,
and push the branch and tag according to the synchronized-remote rules.
6. Keep both approved language versions as the single source for the GitHub
Release body and the packaged first-open release-notes modal. The modal
displays the release notes matching the current interface language and
contains no button linking to a full release page.
Never create or push a release tag, and never push a previously created
release tag, before the release-note draft has received explicit approval.
- Before a push that updates the `github` remote, ask whether the user wants a
release tag unless they already specified that choice. A branch-only push
does not require a version bump or tag. When the user requests a release,
verify that `package.json` and `package-lock.json` contain the same release
version, create `v${package.version}` at the exact commit being pushed, and
push that tag so the native package matrix and GitHub Release run.
- Never move or reuse an existing release tag. If `v${package.version}` already
exists locally or on a remote at another commit, increment the package
version and create a new matching tag before the release push.
- Verified baseline on 2026-08-04: commit `2f54938`, GitHub Actions run - Verified baseline on 2026-08-04: commit `2f54938`, GitHub Actions run
`30893805567` succeeded for validation and all six package targets, producing `30893805567` succeeded for validation and all six package targets, producing
six release artifacts plus the shared production bundle. six release artifacts plus the shared production bundle.
@@ -111,5 +177,6 @@ credentials, or private user artifacts.
This repository has two synchronized remotes, `origin` and `github`. Unless the This repository has two synchronized remotes, `origin` and `github`. Unless the
user explicitly names a remote, every requested push must update the current user explicitly names a remote, every requested push must update the current
branch on both remotes, plus any tags explicitly included in the request. branch on both remotes. When the user requests a release tag, push the new tag
Verify both remote refs after pushing. to every remote receiving the branch update. Verify all updated branch refs and
any applicable tag refs after pushing.
+10
View File
@@ -179,3 +179,13 @@ git push github "$tag"
6. OpenCode 与 Continue 的权限边界、取消和超时。 6. OpenCode 与 Continue 的权限边界、取消和超时。
7. 智能心跳的创建、暂停、恢复和历史记录。 7. 智能心跳的创建、暂停、恢复和历史记录。
8. 应用退出后无残留 Runtime 子进程。 8. 应用退出后无残留 Runtime 子进程。
DeepSeek Harness 的 Electron Utility Host 可单独执行无模型、无凭据冒烟测试:
```bash
npm run smoke:deepseek-harness
```
该命令先生成 production bundle,再从 CommonJS Electron 主入口启动实际
`utilityProcess`,等待固定 Host 完成沙箱探测与内部 ready 握手。它不会发起
模型请求,也不会读取或传递 API Key。
+34 -58
View File
@@ -1,12 +1,11 @@
# GoodBuddy 功能矩阵与路线图 # GoodBuddy 功能矩阵与路线图
本文集中记录 GoodBuddy 已提供、正在开发和计划中的主要能力路线图用于表达产品方向,不代表未完成能力已经包含在当前发布版本中。 本文记录 GoodBuddy 已提供能力路线图。未完成项目不代表已包含在当前版本中。
## 状态说明 ## 状态说明
- [x] **已提供**:已在当前代码和产品流程中提供。 - [x] 已提供
- [ ] **开发中**:已进入实现或集成阶段,完整交付前仍可能调整。 - [ ] 开发中或规划中,具体以条目标注为准
- [ ] **规划中**:已确认产品方向,尚未承诺具体发布时间。
## 功能总表 ## 功能总表
@@ -15,93 +14,70 @@
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64``arm64` 发布目标。 - [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64``arm64` 发布目标。
- [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更。 - [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更。
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。 - [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
- [ ] **项目 Agent Space 与策略包**(规划中):在现有 Project 中统一角色、知识、Skills/MCP、模型、变量、审批策略、预算和超时,并支持模板化复用 - [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
### Agent Runtime 与模型连接 ### Agent Runtime 与模型连接
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、取消、超时和活动记录。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、超时和活动记录。
- [x] **Ask 与 Execute 工作模式**Ask 保持只读;Execute 运行已启用且受边界约束的工具。 - [x] **Ask 与 Execute 工作模式**Ask 保持只读;Execute 运行已启用且受边界约束的工具。
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。 - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。 - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。 - [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
- [ ] **可执行 Subagent 与结构化委派**(规划中):在现有只读专家之外提供显式 Execute 委派,限制嵌套深度、并行、Token、时间和工具权限,并保留父子任务审计。 - [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,并保留父子任务审计。
### Skills、MCP 与知识库 ### Skills、MCP 与知识库
- [x] **Skills 按需接入**:使用有界资源和受控 Runtime 边界。 - [x] **Skills 按需接入**:使用有界资源和受控 Runtime 边界。
- [x] **MCP Tools**:直连模型可使用显式启用的 MCP Tools。 - [x] **MCP Tools**:直连模型可使用显式启用的 MCP Tools,并可在模型轮次间按需刷新动态 MCP 工具
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。 - [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。
- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。 - [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。 - [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
- [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。 - [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。
- [ ] **魔法笔记 / Magic Notes**(规划中):提供本地优先的结构化笔记工作空间,可摘录选中的对话、知识、文档和网页内容并保留来源追溯;AI 总结、改写、续写、整理和关联知识均由用户明确触发,不会静默修改来源知识 - [x] **混合检索测试台**:支持全文、中文词组、向量和图谱通道诊断,可调 Top K、阈值、权重、本地或学习型重排及上下文预算
- [ ] **MCP Server Control Plane**(规划中):扩展 MCP Agent Runtime Broker,统一生命周期、健康检查、重连、Schema 缓存、按项目或任务隔离、审批和审计,并受控接入 OpenCode、Continue - [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;创建、保存和评论结果使用统一应用通知。
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
### 工作管理、长期协作与工作流 ### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件。 - [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件;活动按会话分组并默认收起,避免长历史占满页面
- [x] **记忆与智能心跳**:提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 - [x] **记忆与智能心跳**:提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。 - [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。 - [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
- [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、审批检查点、取消恢复和成果节点;所有执行节点继续经过 Main Runtime 边界。 - [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、审批、取消恢复执行节点继续经过 Main Runtime 边界。
- [ ] **统一运行追踪与回放**(规划中):关联任务、Subagent、模型调用、知识命中、工具审批、活动和成果,提供节点级耗时、失败定位、重试和脱敏导出。 - [ ] **统一 Run Graph 与回放**(规划中):关联任务、Subagent、模型、知识、工具审批、用量和成果,支持失败定位、重试和脱敏导出。
### 浏览器、通信、语音与应用维护 ### 浏览器、通信、语音与应用维护
- [x] **浏览器和桌面受控工具**:保留范围、取消、超时、输出边界和执行记录。 - [x] **浏览器和桌面受控工具**:保留范围、取消、超时、输出边界和执行记录。
- [x] **企业微信与钉钉**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断 - [x] **远程消息通道项目**:微信 ClawBot、企业微信和钉钉分别拥有系统管理的项目、独立远程会话、工作目录、处理后端、默认 Ask/Execute 模式及任务活动归属
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。 - [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。
- [ ] **本地录音与离线转写**(开发中):采集麦克风音频并使用已选择的本地模型离线转写,补齐取消、资源释放和 Electron 打包验证 - [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放
- [x] **版本检查**:仅检查固定官方 Release 和当前平台清单,不自动下载或安装。 - [x] **版本检查**:仅检查固定官方 Release 和当前平台清单,不自动下载或安装。
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。 - [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
### 开源、构建与发布
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
- [x] **六平台原生发布矩阵**Windows、macOS、Linux 的 `x64``arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希。
### 开放接口、团队协作与远程执行 ### 开放接口、团队协作与远程执行
- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。 - [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。
- [ ] **Headless Runtime API 与受控分享**(规划中):提供本机优先的任务提交、流式事件、状态和成果 API并使用带范围、有效期、限流和撤销能力的访问令牌。 - [ ] **Headless Runtime API**(规划中):提供本机优先的任务事件、状态和成果 API以及有范围、有效期、限流和撤销能力的令牌。
- [ ] **GoodBuddy Team Hub**(规划中):以可选独立服务提供组织、成员、RBAC、项目共享、远程 Agent 注册、策略下发和租户审计。 - [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
- [ ] **多云远程沙盒 Agent**(规划中):管理阿里云 ECS、腾讯云 CVM、AWS EC2,并通过 SSH + Agent 提供专用自主沙盒 - [ ] **多云远程沙盒 Agent**(规划中):通过云厂商 API 和 SSH Agent 管理专用 Linux 沙盒;凭据留在 Main 进程,高风险控制面操作单独确认
## 重大功能规划 ## 规划原则
### Agent 框架与协作能力 规划中的工作流、Subagent、MCP、远程 API 和沙盒能力不得绕过现有 Main Runtime、Ask/Execute、权限、取消、超时和审计边界。
参考 MesaLogo 中已经存在或正在验证的 Action Space、受控工作流、Subagent、MCP 管理、批量实验和运行观测思路,GoodBuddy 计划在现有本地优先架构上逐步增加以下能力。这里列出的项目均为 GoodBuddy 自身规划,不表示 MesaLogo 的原型或路线图已在 GoodBuddy 中提供。
- [ ] **项目 Agent Space 与策略包**:不新增与 Project 重复的一级概念,而是在现有 Project 中统一角色、知识集合、Skills/MCP、默认模型、变量、工作模式、审批策略、预算和超时,并支持模板化复用。
- [ ] **统一 Run Graph**:先统一父子任务、节点、模型调用、知识命中、工具审批、用量、成果和取消事件,作为工作流、可执行 Subagent、批量实验和回放的共同基础。
- [ ] **MCP Server Control Plane**:由 Main 进程统一管理 `stdio`、HTTP 和 SSE Server,执行连接验证、健康检查、重连、Schema 缓存、环境变量白名单、资源配额、项目或任务隔离以及逐次审批和审计。
- [ ] **可视化受控工作流**:首版只支持开始、Agent/Subagent、知识、声明式条件、审批、成果和结束节点;流程需要版本化、校验、取消和恢复。任意网络请求或执行节点不得绕过现有 Runtime 与审批边界。
- [ ] **可执行 Subagent**:保留现有专家默认只读语义,只在显式 Execute 委派中允许受控工具,限制深度、并行数、Token、时间、成果范围和父子权限继承。
- [ ] **批量运行与对比实验室**:对模型、Prompt、角色和工作流版本进行参数扫描与 A/B 对比,展示质量评分、耗时、Token、费用、失败率和成果差异。
- [ ] **时态记忆与事实冲突检测**:在现有知识图谱和证据链上增加事实有效期、当前状态、过期与矛盾检测、核验流程及来源回溯。
- [ ] **Headless Runtime API**:作为可选、本机默认仅监听 loopback 的服务,提供任务提交、流式事件、状态和成果下载;访问令牌必须具有 scope、有效期、速率限制、项目限制和撤销能力。
- [ ] **GoodBuddy Team Hub**:作为独立可选服务提供组织、成员、RBAC、项目共享、远程 Agent 注册、策略下发和租户级审计,不把 Electron Renderer 或云端服务改造成用户凭据持有者。
安全边界保持不变:Ask/Plan 必须在 Runtime 边界只读;Execute、MCP、网络和 Subagent 工具均经过 Main 进程审批与审计;不得照搬进程内脚本执行、任意 URL 请求、仅以 `created_by` 模拟多租户或共享无隔离 MCP 会话等做法。
### 知识工作空间与魔法笔记
- [ ] **魔法笔记 / Magic Notes**:建设本地优先的结构化笔记工作空间,支持将用户选中的对话片段、知识条目、文档摘录和网页摘录收集为可编辑笔记,并持续保留来源、位置和引用关系。
- [ ] **受控 AI 笔记操作**:提供总结、改写、续写、整理和关联知识等显式操作;操作结果先进入笔记或待确认变更,不静默回写或修改来源知识。
### 多云远程沙盒 Agent
GoodBuddy 将支持把专用云主机作为模型可自主使用的远程沙盒。首批计划接入:
- 阿里云 ECS
- 腾讯云 CVM
- AWS EC2
- 其他可通过 SSH 管理的 Linux 主机
计划包含以下能力:
- [ ] **云主机控制面**:通过云厂商官方 API 发现、创建、启动、停止和删除实例,创建前展示地域、规格、镜像、网络和费用相关配置。
- [ ] **SSH + GoodBuddy Agent**:校验 SSH 主机指纹后,以专用非 root 账户安装、升级和配对 Agent,不要求模型接触 SSH 私钥。
- [ ] **沙盒内自主执行**:用户可将明确指定的专用实例设为自主沙盒。模型在该沙盒的工作目录内连续执行命令、修改文件和运行任务,不逐条请求审批。
- [ ] **控制面与执行面隔离**:云 API 密钥、临时凭据和 SSH 私钥只保存在 Main 进程的系统加密存储中,不下发给 Renderer、模型或远程任务。模型的自主权限不包含云账户管理权限。
- [ ] **高风险操作保护**:删除实例、修改安全组或网络、扩缩容和其他可能产生额外费用或数据损失的控制面操作仍需单独确认。
- [ ] **可观测与可恢复**:实时回传心跳、日志、进度、退出状态和有界成果文件,支持取消、超时、断线重连、失败诊断和完整活动审计。
- [ ] **跨云一致体验**:使用统一的实例状态、Agent 能力和任务协议;阿里云、腾讯云、AWS 的差异由独立 Provider Adapter 隔离。
+12
View File
@@ -0,0 +1,12 @@
Copyright (C) 2026 MesaLogo
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
+77
View File
@@ -0,0 +1,77 @@
# GoodBuddy
English | [简体中文](README.md)
A secure, cross-platform, local-first desktop AI assistant and Agent workspace.
![GoodBuddy workspace](docs/screenshots/workspace-overview.png)
## Highlights
- **Controlled execution**: `Ask` stays read-only; `Execute` runs only enabled tools within defined boundaries and records their activity.
- **Local-first data**: Conversations, tasks, artifacts, memory, knowledge bases, and graphs are stored in local SQLite. API keys are encrypted by the operating system.
- **Multiple runtimes**: Connect directly to models or use OpenCode and Continue, with cancellation, timeouts, output limits, and process cleanup.
- **Open integrations**: Supports OpenAI Responses, OpenAI-compatible Chat Completions, Anthropic Messages, OpenAI Images, Embeddings, Skills, and MCP.
- **Knowledge workspace**: Import files, folders, and web pages, then search them with full-text, phrase, vector, and graph retrieval.
- **Work management**: Organize projects, conversations, tasks, activity, artifacts, memory, Magic Notes, and Smart Heartbeat.
- **Remote channels**: Connect WeChat ClawBot, WeCom, and DingTalk with separate remote sessions for each sender.
- **Desktop context**: Add selected files, screenshots, application windows, clipboard content, and voice.
- **Offline speech**: Use local SenseVoice, Paraformer, and Whisper models.
- **Rich responses**: Render Markdown, LaTeX, and controlled Mermaid diagrams.
![GoodBuddy knowledge workspace](docs/screenshots/knowledge-workspace.png)
![GoodBuddy knowledge graph](docs/screenshots/knowledge-graph.png)
![GoodBuddy Magic Notes](docs/screenshots/GoodBuddy_MFSGeK0NoT.gif)
![GoodBuddy Smart Heartbeat](docs/screenshots/smart-heartbeat.png)
See [FEATURES.md](FEATURES.md) for the detailed feature matrix and roadmap.
## Install
Download a build from [GitHub Releases](https://github.com/mesalogo/goodbuddy/releases):
| Platform | Architectures | Formats |
| --- | --- | --- |
| Windows | `x64`, `arm64` | NSIS, portable ZIP |
| macOS | `x64`, `arm64` | DMG, ZIP |
| Linux | `x64`, `arm64` | AppImage, DEB |
Code signing and macOS notarization are not configured yet, so your operating system may display a security warning.
## Run from source
Requires Node.js 24 and npm:
```bash
git clone https://github.com/mesalogo/goodbuddy.git
cd goodbuddy
npm ci
npm run dev
```
See [BUILD.md](BUILD.md) for build and packaging instructions.
## Privacy and security
- Model requests are sent only to services selected by the user.
- Local data stays in the operating system's application data directory by default.
- The Renderer has no access to raw Electron APIs or model credentials.
- Remote delegation is disabled until the user configures an endpoint and token.
- Private-network compatibility permits in-app HTTP and non-standard HTTPS certificates. WeChat credential and media endpoints remain strictly validated.
## Contributing
Issues and pull requests are welcome. Read [AGENTS.md](AGENTS.md) first, then run:
```bash
npm test
npm run typecheck
npm run lint
```
## License
Original GoodBuddy code is released under the [0BSD License](LICENSE). You may use, modify, distribute, and commercialize it freely. Third-party components and resources retain their respective licenses.
+53 -78
View File
@@ -1,102 +1,77 @@
# GoodBuddy # GoodBuddy
面向专业工作与国产化环境的安全桌面智能助手。 [English](README.en.md) | 简体中文
GoodBuddy 将模型连接、Agent Runtime、本地知识库、知识图谱、任务协作和持续成长能力组织在同一个桌面工作空间中。它不是简单的聊天窗口,而是一套可审计、可控制、可长期使用的个人智能工作环境 安全、跨平台、本地优先的桌面 AI 助手与 Agent 工作空间
![GoodBuddy 工作空间](docs/screenshots/workspace-overview.png) ![GoodBuddy 工作空间](docs/screenshots/workspace-overview.png)
## 为什么选择 GoodBuddy ## 核心能力
### 安全可控的 Agent 执行 - **安全执行**`Ask` 保持只读;`Execute` 仅运行已启用且受边界约束的工具,并保留活动记录。
- **本地优先**:会话、任务、成果、记忆、知识库和图谱保存在本地 SQLite;API Key 由系统安全存储加密。
GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode 和 Continue。工具不会被直接暴露给界面,所有执行都受到工作模式、权限审批和运行边界约束 - **多 Runtime**:支持直连模型、OpenCode 和 Continue,统一处理取消、超时、输出限制和进程退出
- **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、Skills 和 MCP。
- `Ask`:只读问答,不调用工具 - **知识工作区**:支持文件、目录和网页导入,以及全文、词组、向量和图谱混合检索
- `Execute`:选择该模式即授权当前交互运行使用已启用的受控工具 - **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳
- 可在设置中禁止直连模型执行所有工具;工具调用仍记录到活动 - **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话
- 统一处理取消、超时、输出边界、进程退出和异常恢复 - **桌面上下文**:可选择文件、截图、应用窗口、剪贴板和语音作为上下文
- **离线语音**:支持 SenseVoice、Paraformer 和 Whisper 本地模型。
### 数据主权与本地优先 - **富文本回答**:支持 Markdown、LaTeX 公式和受控 Mermaid 图表。
- 会话、任务、成果、记忆、知识库和图谱保存在本地 SQLite。
- API Key 通过系统安全存储加密,不以明文写入配置。
- Electron Main、Preload、Renderer 严格分层,Renderer 仅能使用类型化 IPC。
- 子进程使用环境变量白名单,避免继承无关凭据。
- 默认不依赖 GoodBuddy 云端账户,也不代理用户的模型流量。
### 面向国产化环境交付
- 支持 Windows、macOS 与 Linux。
- 支持 Linux `x64``arm64`
- 提供适用于麒麟、统信 UOS 等 Debian 系桌面的 `deb` 安装包。
- 提供 AppImage,便于免安装验证与便携分发。
- 支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 与无认证本机模型。
- 可连接企业网关、私有模型服务和国产模型适配层。
## 核心功能
### 一体化智能工作空间
- Projects 与独立对话上下文。
- 专家角色和最多三个只读专家并行分析。
- 任务、活动、成果、记忆和自动化集中管理。
- 支持文件、桌面截图、应用窗口、剪贴板和语音上下文。
- 显示真实 Git 工作区变更。
- 支持远程任务委派与持久化结果发件箱。
- Skills 与 MCP 能力按需接入。
### 本地知识库与知识图谱
文件、目录和网页内容可以按知识库独立管理。GoodBuddy 会完成解析、索引、检索和图谱构建,并保留可追溯的来源与证据。
![GoodBuddy 知识工作区](docs/screenshots/knowledge-workspace.png) ![GoodBuddy 知识工作区](docs/screenshots/knowledge-workspace.png)
- SQLite FTS5 全文检索与有界上下文召回。
- 支持规则、模型和混合图谱抽取。
- 支持实体、关系、别名、证据与来源位置追溯。
- 图谱可搜索、筛选、缩放和拖动节点。
- 支持实体编辑、合并以及关系维护。
- 文档解析包含压缩包展开限制、路径校验和敏感字段过滤。
![GoodBuddy 知识图谱](docs/screenshots/knowledge-graph.png) ![GoodBuddy 知识图谱](docs/screenshots/knowledge-graph.png)
### 智能心跳 ![GoodBuddy 魔法笔记](docs/screenshots/GoodBuddy_MFSGeK0NoT.gif)
智能心跳让 GoodBuddy 不只响应当前问题,还能定期回顾近期工作,沉淀长期记忆,发现风险,并将洞察转化为可处理的建议。
![GoodBuddy 智能心跳](docs/screenshots/smart-heartbeat.png) ![GoodBuddy 智能心跳](docs/screenshots/smart-heartbeat.png)
- 按项目或全局配置周期回顾计划 完整功能和路线图见 [FEATURES.md](FEATURES.md)
- 展示心跳健康、记忆沉淀、洞察发现和行动转化。
- 提供成长趋势、最新报告和可审计的运行轨迹。
- 建议记忆可确认或忽略。
- 后续任务可带入 Ask 对话、标记完成或忽略。
- 支持手动运行、暂停、恢复和安全删除计划。
### 多 Runtime 与模型连接 ## 安装
| 能力 | 适用场景 | 控制方式 | 从 [GitHub Releases](https://github.com/mesalogo/goodbuddy/releases) 下载:
| 系统 | 架构 | 格式 |
| --- | --- | --- | | --- | --- | --- |
| 直连模型 | 问答、知识总结、受控工具执行、图像生成 | Ask 只读;Execute 自动授权已启用的工作区、浏览器与 MCP 工具,可设置为全部禁止 | | Windows | `x64``arm64` | NSIS、便携 ZIP |
| OpenCode | 完整编码与工作区任务 | Execute 不弹 GoodBuddy 审批,保留 Runtime 自身权限、取消和活动记录 | | macOS | `x64``arm64` | DMG、ZIP |
| Continue | Agent 编码与工作区任务 | Execute 不弹 GoodBuddy 审批,使用独立宿主、取消和活动记录 | | Linux | `x64``arm64` | AppImage、DEB |
## 功能矩阵与路线图 当前尚未配置代码签名和 macOS notarization,系统可能显示安全提示。
以下为仓库首页的简要路线图;完整能力说明、状态和重大规划统一记录在 [FEATURES.md](FEATURES.md)。 ## 从源码运行
- [x] [跨平台桌面工作空间与安全上下文](FEATURES.md#桌面基础工作空间与上下文) 需要 Node.js 24 和 npm
- [x] [多 Runtime、模型连接、Skills 与 MCP](FEATURES.md#agent-runtime-与模型连接)
- [x] [本地知识库、向量检索与知识图谱](FEATURES.md#skillsmcp-与知识库)
- [x] [任务、成果、记忆与智能心跳](FEATURES.md#工作管理长期协作与工作流)
- [ ] [本地录音与离线转写](FEATURES.md#浏览器通信语音与应用维护)
- [ ] [魔法笔记 / Magic Notes](FEATURES.md#知识工作空间与魔法笔记):本地优先的结构化笔记、可追溯摘录与受控 AI 整理。
- [ ] [Agent 框架、受控工作流与团队协作](FEATURES.md#agent-框架与协作能力)
- [ ] [多云远程沙盒 Agent](FEATURES.md#多云远程沙盒-agent)
`[x]` 表示当前已提供,`[ ]` 表示开发中或规划中;未完成项目不代表已包含在当前发布版本中。 ```bash
git clone https://github.com/mesalogo/goodbuddy.git
cd goodbuddy
npm ci
npm run dev
```
## 隐私说明 构建与打包说明见 [BUILD.md](BUILD.md)。
模型请求只会发送到用户选择的模型连接。本地数据保存在当前系统的应用数据目录中;远程委派仅在用户显式配置端点和令牌后启用。面向纯内网部署的“内网兼容模式”默认开启,允许 HTTP 并接受无效、自签名或过期的 HTTPS 证书;可在“安全与数据”中关闭并恢复严格校验。 ## 隐私与安全
- 模型请求只发送到用户选择的服务。
- 本地数据默认保存在系统应用数据目录。
- Renderer 不接触原始 Electron API 或模型凭据。
- 远程委派仅在用户配置端点和令牌后启用。
- 内网兼容模式允许应用内 HTTP 和非标准 HTTPS 证书;微信凭据和媒体端点仍执行严格校验。
## 参与贡献
欢迎提交 Issue 和 Pull Request。请先阅读 [AGENTS.md](AGENTS.md),提交前运行:
```bash
npm test
npm run typecheck
npm run lint
```
## 开源许可
GoodBuddy 的原创代码采用 [0BSD License](LICENSE),可自由使用、修改、分发和商用。第三方组件和资源遵循各自许可证。
+133 -16
View File
@@ -39,6 +39,8 @@
组件不得直接使用原始颜色值。主题差异只在令牌层定义,业务组件仅引用语义令牌。 组件不得直接使用原始颜色值。主题差异只在令牌层定义,业务组件仅引用语义令牌。
两种主题必须保持相同的信息层级,但不要求机械地反转明暗。浅色主题以白色主内容画布、冰蓝灰侧栏和轻微着色的顶栏建立空间关系;深色主题使用深海军蓝与蓝灰表面逐层提亮,避免纯黑。蓝色承担主要选择和交互,青绿色主要承担成功与可用状态,二者不得混用语义。
## 3. 设计令牌 ## 3. 设计令牌
令牌以 CSS 自定义属性实现。`:root` 提供浅色值,`[data-theme="dark"]` 覆盖深色值。组件样式不得新增只服务于单个页面的颜色、阴影、圆角或间距常量。 令牌以 CSS 自定义属性实现。`:root` 提供浅色值,`[data-theme="dark"]` 覆盖深色值。组件样式不得新增只服务于单个页面的颜色、阴影、圆角或间距常量。
@@ -69,6 +71,14 @@
浅色与深色具体值只在 `styles.css` 的主题根节点维护。状态组件必须同时显示文字或图标,不能仅靠颜色区分。 浅色与深色具体值只在 `styles.css` 的主题根节点维护。状态组件必须同时显示文字或图标,不能仅靠颜色区分。
表面与边框使用规则:
- 浅色主题的阅读、编辑和页面主内容使用白色或接近白色的 `--surface-raised`;主侧栏使用更深一阶的冰蓝灰 `--surface-canvas`,顶栏使用弱于侧栏的次级表面。相邻区域必须可辨,但不能形成高饱和色块。
- 深色主题从深海军蓝画布开始,以蓝灰表面逐层提亮。不同层级优先依靠表面亮度与语义边框区分,不使用纯黑底色或无边界的大面积同色区域。
- 浅色侧栏中,导航与最近会话、最近会话与账户区之间的结构分隔线使用 `--border-default`。列表行之间或卡片内部的弱分隔仍使用 `--border-subtle`,不得为了增强结构而给每一项加重边框。
- 控件边界、焦点环和选中边框必须达到至少 `3:1` 的非文本对比度;正文、状态色和弱文本分别遵守无障碍对比度要求。
- 业务组件不得通过主题条件分支写原始颜色;新增视觉层级时先确认能否复用现有表面、边框和状态令牌。
### 3.2 间距令牌 ### 3.2 间距令牌
采用 4 像素基准: 采用 4 像素基准:
@@ -91,27 +101,33 @@
### 3.3 字体令牌 ### 3.3 字体令牌
界面字体使用系统无衬线字体栈,代码、标识符和原始日志使用等宽字体栈。 界面英文和数字优先使用随客户端本地打包的 `Inter Variable`。简体中文优先使用平台原生 UI 字体:macOS 使用 `PingFang SC`Windows 使用 `Microsoft YaHei UI`,以获得与系统渲染匹配的小字号 hinting;其他平台及缺失字形使用本地打包的 `Noto Sans SC Variable`代码、标识符和原始日志使用等宽字体栈。字体不得通过运行时网络请求加载。
| 令牌 | 字号 / 行高 | 字重 | 用途 | | 令牌 | 字号 / 行高 | 字重 | 用途 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `--font-caption` | `10px` | 时间、短标签和紧凑元数据 | | `--font-caption` | `11px` | 时间、短标签和紧凑元数据 |
| `--font-body` | `12px` | 默认界面正文 | | `--font-body` | `13px` | 默认界面正文 |
| `--font-section-title` | `14px` | 卡片和区块标题 | | `--font-section-title` | `14px` | 卡片和区块标题 |
| `--font-page-title` | `24px` | 一级页面标题 | | `--font-page-title` | `24px` | 一级页面标题 |
连续阅读内容使用 `13px``14px`,持久辅助信息不得小于 `10px`。页面内不得通过同时放大字号、加粗和使用强调色制造多个同级主标题。 使用规则:
- 业务组件通过 `--font-family-ui` 与字体尺寸令牌继承字体,不创建页面专属字体栈。
- 表单按钮、输入框、选择框和文本域必须继承界面字体,避免回退为原生控件字体。
- 连续阅读内容使用 `14px`,持久辅助信息不得小于 `11px`
- 本地兜底字体资源必须随生产包交付,并同时包含 Inter 与 Noto Sans SC 的 OFL 许可证。
- 页面内不得通过同时放大字号、加粗和使用强调色制造多个同级主标题。
### 3.4 圆角、阴影与层级 ### 3.4 圆角、阴影与层级
| 令牌 | 值 | 用途 | | 令牌 | 值 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| `--radius-control` | `8px` | 输入框、按钮、菜单项 | | `--radius-control` | `10px` | 输入框、按钮、菜单项 |
| `--radius-card` | `12px` | 卡片和面板 | | `--radius-card` | `14px` | 卡片和面板 |
| `--shadow-card` | 主题定义 | 卡片和选中分段控件 | | `--shadow-card` | 主题定义 | 卡片和选中分段控件 |
| `--shadow-dialog` | 主题定义 | 对话框和浮层 | | `--shadow-dialog` | 主题定义 | 对话框和浮层 |
普通卡片通过表面色和边框区分,不默认添加阴影。阴影只表示真实的浮层关系。不允许页面自行创建高于 `--z-dialog` 的层级。 整体使用适度圆角:控件和卡片保持清晰、克制的几何轮廓,不使用胶囊化的大圆角替代信息层级。普通卡片通过表面色和边框区分,不默认添加阴影;输入区等需要从内容流中明确浮起的持续操作面板可以使用克制的 `--shadow-card`。菜单和对话框使用对应层级阴影,阴影只表示真实的浮层关系。不允许页面自行创建高于 `--z-dialog` 的层级。
### 3.5 动效令牌 ### 3.5 动效令牌
@@ -182,6 +198,8 @@
- 一级页面之间的导航由应用主导航承担,不复用 `PageTabs` - 一级页面之间的导航由应用主导航承担,不复用 `PageTabs`
- 标签保持短名词,不显示句号,不用页签承载开关或过滤条件。 - 标签保持短名词,不显示句号,不用页签承载开关或过滤条件。
- 项目过多时优先重组信息架构,不把一级页签做成多行。 - 项目过多时优先重组信息架构,不把一级页签做成多行。
- `PageTabs` 可使用默认视觉或共享的 `segmented` 视觉变体。紧凑主从工作台中的 2 至 4 个同级面板可使用与模型设置一致的分段外观,但不得因此改用按钮组语义。
- 视觉变体不改变组件含义:分段外观的 `PageTabs` 仍使用页签语义、单一激活面板、游标焦点和方向键切换,不复制页面专属样式。
### 6.2 SegmentedControl ### 6.2 SegmentedControl
@@ -255,18 +273,62 @@
- 活动记录必须保留操作者、动作、对象、范围、结果和时间等审计语义,不用纯图标代替关键字段。 - 活动记录必须保留操作者、动作、对象、范围、结果和时间等审计语义,不用纯图标代替关键字段。
- 表格密度可以选择“默认”或“紧凑”,但同一页面不得混用。 - 表格密度可以选择“默认”或“紧凑”,但同一页面不得混用。
### 6.8 应用顶栏与全局菜单 ### 6.8 应用侧栏
主侧栏用于一级导航、最近会话和稳定的账户入口,必须通过表面、结构线和选中状态建立清楚但不过度装饰的层级。
- 浅色侧栏使用冰蓝灰表面,与白色主内容画布形成明确边界;深色侧栏使用比主画布略亮的蓝灰表面。
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`
- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
### 6.9 应用顶栏与全局操作
应用顶栏用于窗口级状态、侧栏开关和低频全局操作,不承担页面标题或主要导航。顶栏必须保持紧凑,不能与页面内容争夺注意力。 应用顶栏用于窗口级状态、侧栏开关和低频全局操作,不承担页面标题或主要导航。顶栏必须保持紧凑,不能与页面内容争夺注意力。
- 顶栏高度默认为 `58px`,图标按钮使用 `34px × 34px` 点击区域。 - 顶栏高度默认为 `58px`,图标按钮使用 `34px × 34px` 点击区域。
- Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。 - Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。
- 全局菜单项使用 `--font-body`,图标为 `14px`,单项高度为 `32px` - 浅色与深色切换属于持续可用的窗口级操作,直接显示太阳或月亮图标,并通过可访问名称说明将切换到的主题。选择必须持久化,切换不得改变布局
- 菜单标签使用短名称,例如“安全与 Runtime 设置”“使用帮助”,不得同时使用大字号、粗体和强调色 - 顶栏只直接显示当前任务所需的高频操作。已有侧栏账户设置入口时,不再重复显示 Runtime/设置入口;使用帮助优先放在相关操作附近,而不是为单个帮助项创建“更多”菜单
- 全局菜单宽度由最长标签决定,建议为 `180px``200px`;说明性长文放入目标页面,不放在菜单项中 - 只有存在至少两个无法由稳定入口承载的低频全局操作时才增加全局菜单,不为了容纳一个冗余入口而显示省略号按钮
- 顶栏只直接显示当前任务所需的高频操作。设置、帮助、关于和版本检查等低频操作进入同一个全局菜单。
- 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。 - 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。
- 菜单使用 `menu``menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。 - 使用全局菜单时,菜单项使用 `--font-body``14px` 图标和约 `32px` 单项高度;标签使用短名称。菜单保留 `menu``menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。
### 6.10 上下文单选菜单
模型、专家角色和工作模式属于同一输入上下文,其选择器必须共享结构、尺寸和菜单视觉,不能出现一个精细菜单与两个风格不一致的原生下拉框。
- 触发按钮复用统一的模型选择按钮样式,保持相同高度、圆角、边框、展开指示和焦点状态。
- 菜单使用 `menu``menuitemradio` 语义,当前项同时显示选中标记和 `aria-checked`。选项可以包含一行简短说明,但标签和说明不得被截断到无法区分。
- 支持上、下方向键、Home、End、Enter 或 Space、Escape;打开后焦点进入当前项,关闭后返回触发按钮。
- 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和模式菜单互斥展开。
- 不可用选项保持可读并说明原因,键盘导航不得停留在不可选择项上。
- 仅在选项简单且不需要说明、禁用原因或一致菜单行为时使用原生 `select`
### 6.11 应用通知与就地反馈
应用级通知统一进入全局通知视口,页面不得自行复制通知卡片或在内容流中长期堆放短期消息。
- 异步操作成功、无需立即处理的信息,以及不属于某个字段的异步失败,使用应用级 `success``info``error` 通知。
- 成功和信息通知默认在约 4.5 秒后自动消失;错误通知保持可见,直到用户关闭或同一去重键的更新替换它。
- 同一语义和文案的重复通知应去重。通知正文必须有长度上限,不包含凭据、私人内容或未脱敏的提供商响应。
- 字段校验、破坏性确认、操作进度、阻塞整个页面的状态,以及需要就地重试或修正的错误保留在相关控件附近。
- 就地错误必须与对应字段或操作建立程序化关联;全局错误使用 `alert` 和 assertive 实时区域,成功与信息使用 `status` 和 polite 实时区域。
- 一个事件只能选择一种主要反馈位置,不得同时显示页内横幅和全局通知。失败时不得因通知切换而清空用户输入、筛选或未提交草稿。
### 6.12 Switch 与 Checkbox
Switch 用于在两个持久状态之间立即切换,例如启用能力、开启索引、允许群消息或显示平台入口。Checkbox 用于独立多选、范围分配或执行前确认,例如选择多个 Runtime、选择知识库、清除已保存密钥。两者不得只因底层都使用 `input[type="checkbox"]` 而混用视觉或语义。
- 二元启停必须使用共享滑动开关视觉,当前实现复用 `toggle-row`,不得显示为原生方形 Checkbox。
- Switch 底层可以使用 `input[type="checkbox"]`,但必须声明 `role="switch"`,通过原生 `checked` 状态暴露开关状态,并具有持久、明确的可访问名称。
- Checkbox 保留原生 Checkbox 语义和方形勾选视觉,不得添加 `role="switch"`。多项分配、列表选择、确认声明和“保存时清除密钥”等一次性选择均属于 Checkbox。
- 不创建页面专属 Switch 样式。需要紧凑布局时仍复用同一轨道、滑块、焦点环、禁用状态和动效,只调整共享组件支持的布局变体。
- Switch 支持 Tab 聚焦和 Space 切换,键盘焦点至少显示 `2px` 高对比焦点环。可见标签应描述被控制的能力,不能只显示“开 / 关”。
- 异步切换期间禁用重复操作并保留原状态。失败时恢复或保留最后确认状态,通过应用通知或就地可恢复错误说明原因。
- 涉及联网、上传、电脑控制或其他外部影响的 Switch,附近必须持续说明数据去向、权限范围或风险,不能只靠设置名称表达影响。
- 自动化测试应按 `switch` 角色查询二元开关,按 `checkbox` 角色查询多选或确认项,防止视觉迁移后语义回退。
## 7. 交互状态 ## 7. 交互状态
@@ -279,9 +341,9 @@
- 选中:同时使用背景、边框、图标或字重中的至少两种信号。 - 选中:同时使用背景、边框、图标或字重中的至少两种信号。
- 禁用:降低强调度,同时保留可读标签,并通过说明或工具提示解释原因。 - 禁用:降低强调度,同时保留可读标签,并通过说明或工具提示解释原因。
- 加载:防止重复提交,保留原按钮宽度并显示进行中标签。 - 加载:防止重复提交,保留原按钮宽度并显示进行中标签。
- 错误:就近显示可执行的错误说明,不只弹出短暂通知。 - 错误:字段或局部操作错误就近显示可执行说明;非局部异步错误使用不会自动消失的应用级错误通知。
异步提交成功后更新内容并提供明确反馈。失败时保留用户输入和筛选上下文。 异步提交成功后更新内容并通过统一应用通知提供明确反馈。失败时保留用户输入和筛选上下文。
## 8. 范围与数据语义 ## 8. 范围与数据语义
@@ -336,7 +398,8 @@
## 10. 深色主题 ## 10. 深色主题
- 深色主题通过语义令牌替换实现,不在组件中使用主题条件分支选择原始颜色。 - 深色主题通过语义令牌替换实现,不在组件中使用主题条件分支选择原始颜色。
- 表面层级主要依靠亮度和边框区分,避免大面积纯黑与高亮白形成刺眼对比。 - 主画布使用深海军蓝,侧栏、顶栏、输入区和浮层使用逐级提亮的蓝灰表面;表面层级主要依靠亮度和边框区分,避免大面积纯黑与高亮白形成刺眼对比。
- 深色强调色使用明亮但不荧光的蓝色,成功状态使用青绿色。用户消息等大面积强调表面使用更深的实心蓝,确保反白文字舒适可读。
- 输入框、代码块、表格悬停、选中行、弹窗遮罩和滚动条必须分别检查深色值。 - 输入框、代码块、表格悬停、选中行、弹窗遮罩和滚动条必须分别检查深色值。
- 图片、图表和状态色在深色背景下保持可读。图表系列不能只靠色相区分,还应使用形状、线型或标签。 - 图片、图表和状态色在深色背景下保持可读。图表系列不能只靠色相区分,还应使用形状、线型或标签。
- 焦点环、危险文本和弱文本在两种主题下都满足对比度要求。 - 焦点环、危险文本和弱文本在两种主题下都满足对比度要求。
@@ -408,6 +471,12 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 使用 `reading` 壳层,消息流与输入区共享宽度。 - 使用 `reading` 壳层,消息流与输入区共享宽度。
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。 - 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
- 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。 - 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。
- 模型、专家角色和工作模式使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。
- 已选择的工作模式在触发按钮中只显示 `Ask``Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。
- 宽度大于 `700px` 时,添加内容、知识范围、专家、模式和模型控件保持同一行;仅在窄输入区中换行,不能因为允许换行而让所有窗口都固定显示两行。
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,图片转换为本次消息附件。文件选择由上传按钮承担,不再提供独立“读取剪贴板”按钮;默认工具栏也不提供“截取当前屏幕”和“选择应用窗口”入口,避免与系统粘贴、文件选择和后续工具执行重复。
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。 - 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
### 13.2 最近对话 ### 13.2 最近对话
@@ -440,6 +509,43 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 活动记录保留审计字段和范围,支持独立容器横向滚动。 - 活动记录保留审计字段和范围,支持独立容器横向滚动。
- 批量停止、删除和清空历史遵循破坏性操作政策。 - 批量停止、删除和清空历史遵循破坏性操作政策。
### 13.6 魔法笔记
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs``segmented` 视觉变体,与模型设置的分段控件保持同一外观。
- 页签切换保留 `tablist``tab``tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
### 13.7 设置中心
- 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。
- 全页设置标题区依靠留白与内容区分层,不在标题下方绘制贯穿整个工作区的分隔线;模态设置可以保留标题边界。
- 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
### 13.8 文档解析设置
- 设置中心新增独立的“文档解析”分类,统一管理聊天附件、知识库导入以及后续文档审阅场景使用的提取、转换和 OCR 策略。OCR 不作为普通对话模型出现在“模型连接”中。
- 分类页头说明文档解析的跨场景作用,右侧依次显示“测试解析”和“保存设置”;保存位于最右侧。测试必须选择真实文件并执行实际解析,不能只检查模型文件或接口连通性。
- 页面首先显示原生解析、文档转换和 OCR 的运行状态,并明确当前可处理格式、回退能力与不可用原因。部分能力未配置时使用“部分可用”状态,不得把原生文本解析一并标记为失败。
- “使用场景”分别配置聊天附件和知识库导入。普通用户选择“自动解析”“快速文本”“完整索引”等预设;阈值、并发和超时放入默认折叠的高级设置。
- 本地 OCR 的全平台基线使用同一组 PP-OCRv6 ONNX 模型和 ONNX Runtime WebAssembly,在 Windows、macOS、Linux 的 x64 与 arm64 上保持相同功能。原生 ONNX、WebGPU、DirectML、CoreML 或 CUDA 只能作为可选加速,失败时必须回退到 WASM CPU。
- OCR 模型管理与语音模型保持一致:应用不内置权重,用户可按需从 ModelScope 下载,也可在联网设备导出 ZIP 并在离线或内网设备直接导入。语音和 OCR 模型的下载、取消、ZIP 导入、ZIP 导出、删除与打开受管目录使用同一交互语义;ZIP 操作不得隐式切换当前模型或保存解析设置。
- OCR 模型卡片必须持续显示来源、语言、运行时、体积、安装状态和许可。“打开 ModelScope”直接位于卡片右上角,不再使用“模型详情与手动导入”折叠区。窄窗口下仓库操作换行到模型摘要下方,仍须保持可访问名称和键盘操作。
- PP-OCRv6 提供三个已实现档位:Tiny 约 6 MiB,适合低资源设备;Small 约 30 MiB,官方支持 50 种语言并作为推荐档位;Medium 约 132 MiB,官方支持 50 种语言、质量更高但速度较慢,界面必须提示其更高的内存占用和延迟。
- 本地模型按受管目录和固定清单加载。ModelScope 下载地址必须固定不可变 revision、字节数和 SHA-256;下载先进入临时目录,全部校验成功后再原子安装。识别时不得从网络或可变分支临时加载模型。
- 模型 ZIP 使用版本化的 `goodbuddy-model.json` 清单,声明模型类型、内置目录 ID、文件角色、大小与 SHA-256。导出前重新校验已安装文件;导入时限制压缩包大小、条目数、单文件和总展开大小,拒绝路径穿越、重复、未知、缺失或嵌套条目,并以应用内置目录重新校验后原子安装。ZIP 内的自声明信息不能扩大受信任模型集合。
- PDF 先读取文本层。仅当页面无有效文本、乱码比例过高或用户选择“始终 OCR”时渲染该页并识别;不得因为单页需要 OCR 而丢弃其他页面已经提取的可靠文本。
- DOCX、XLSX、PPTX 优先保留段落、单元格、公式、备注等原生语义。转换为 PDF 用于补充版面、页码、图表和图片理解,不作为唯一中间格式。
- DOC、XLS、PPT 等旧格式通过受控转换 Provider 生成新式 Office 文档和 PDF。转换子进程必须禁用宏和网络,限制输入、输出、内存、超时与临时目录,并在关闭或取消时清理。
- OCR 来源使用“本地模型 / 远程服务”互斥选择。选择本地后显示模型下载、模型下拉选择和本地运行参数;选择远程后显示 MinerU、PaddleOCR-VL 等服务连接配置。未实现的远程服务入口保持可读但禁用,不再增加与来源选择重复的“隐私与云端处理”授权区。
- 用户配置并保存远程 OCR 服务即表示选择该处理路径,不再逐场景重复询问。界面仍须明确显示当前服务名称、处理范围和远程属性,API 密钥只保存在主进程加密设置中,未选中远程服务时不得上传文档。
- 解析结果使用统一文档结构,至少保留文档标题、来源格式、页码或工作表定位、正文块、置信度、处理方式和警告。聊天附件对结果做有界截断,知识库使用完整结果分块和索引。
- 测试结果显示文件类型、页数、实际工作流、提取字数、OCR 页数、耗时和警告。测试文件不得自动进入聊天上下文或知识库。
## 14. 文案规则 ## 14. 文案规则
- 使用简体中文,动词直接、对象明确。 - 使用简体中文,动词直接、对象明确。
@@ -454,9 +560,11 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
### 15.1 基础层 ### 15.1 基础层
- [ ] 建立浅色与深色语义颜色令牌,移除业务组件中的原始颜色值。 - [ ] 建立浅色与深色语义颜色令牌,移除业务组件中的原始颜色值。
- [ ] 建立白色浅色主画布、冰蓝灰侧栏与深海军蓝深色表面的稳定层级。
- [ ] 建立间距、字体、圆角、阴影、层级和动效令牌。 - [ ] 建立间距、字体、圆角、阴影、层级和动效令牌。
- [ ] 为主题切换、减少动态效果和原生控件设置全局规则。 - [ ] 为主题切换、减少动态效果和原生控件设置全局规则。
- [ ] 建立组件交互状态和焦点环基线。 - [ ] 建立组件交互状态和焦点环基线。
- [ ] 验证浅色侧栏结构分隔线与导航、会话选中状态清晰可辨。
### 15.2 页面壳层与层级 ### 15.2 页面壳层与层级
@@ -470,18 +578,25 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- [ ] 实现并迁移 `PageHeader` - [ ] 实现并迁移 `PageHeader`
- [ ] 使用 `PageTabs` 统一同级页面导航。 - [ ] 使用 `PageTabs` 统一同级页面导航。
- [ ] 使用 `SegmentedControl` 统一少量互斥视图和状态切换。 - [ ] 使用 `SegmentedControl` 统一少量互斥视图和状态切换。
- [ ] 需要分段外观的同级面板使用 `PageTabs` 的共享 `segmented` 变体,不复制控件样式。
- [ ] 建立统一筛选工具栏,移除以页签样式伪装的筛选。 - [ ] 建立统一筛选工具栏,移除以页签样式伪装的筛选。
- [ ] 二元启停统一使用共享 Switch 视觉与 `role="switch"`,多选、范围分配和确认项保留 Checkbox。
- [ ] 将短期成功、信息和非局部异步错误接入应用通知视口,移除页面专属通知横幅。
- [ ] 实现 `ScopeBadge` 并覆盖全局、项目、失效和可切换状态。 - [ ] 实现 `ScopeBadge` 并覆盖全局、项目、失效和可切换状态。
- [ ] 实现 `EmptyState` 的首次为空、无结果、失败和只读变体。 - [ ] 实现 `EmptyState` 的首次为空、无结果、失败和只读变体。
- [ ] 实现 `danger-ghost``danger-solid``danger-zone` - [ ] 实现 `danger-ghost``danger-solid``danger-zone`
- [ ] 统一模型、专家角色和工作模式的单选菜单结构、视觉与键盘行为。
### 15.4 页面迁移 ### 15.4 页面迁移
- [ ] 聊天迁移到 `reading`,统一消息流与输入区宽度。 - [ ] 聊天迁移到 `reading`,统一消息流与输入区宽度。
- [ ] 将输入快捷键与附件提示置于空输入框内部,输入区下方保持单行说明。
- [ ] 最近对话迁移到 `standard`,统一搜索、范围、时间和删除行为。 - [ ] 最近对话迁移到 `standard`,统一搜索、范围、时间和删除行为。
- [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。 - [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。
- [ ] 智能心跳迁移到 `dashboard`,统一状态卡片、配置和运行历史层级。 - [ ] 智能心跳迁移到 `dashboard`,统一状态卡片、配置和运行历史层级。
- [ ] 任务迁移到 `standard`,活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。 - [ ] 任务迁移到 `standard`,活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。
- [ ] 设置中心使用共享分类定义与 `SettingsCategoryHeader`,将保存与测试操作统一放到分类页头右侧,并把成功反馈接入应用通知。
- [ ] 文档解析设置统一聊天附件与知识库的解析预设、OCR 状态、转换状态、隐私限制和真实文件测试。
### 15.5 验收 ### 15.5 验收
@@ -492,6 +607,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- [ ] 验证页面范围、对象范围和操作范围在关键流程中始终可见。 - [ ] 验证页面范围、对象范围和操作范围在关键流程中始终可见。
- [ ] 验证删除、批量操作、停止运行和清空历史符合风险等级策略。 - [ ] 验证删除、批量操作、停止运行和清空历史符合风险等级策略。
- [ ] 验证加载中、首次为空、筛选无结果、搜索无结果、失败和只读状态不会互相混用。 - [ ] 验证加载中、首次为空、筛选无结果、搜索无结果、失败和只读状态不会互相混用。
- [ ] 在 Windows、macOS、Linux 的 x64 与 arm64 上执行真实本地 OCR,并验证 WASM CPU 回退、取消、超时和离线运行。
- [ ] 在联网设备导出语音与 OCR 模型 ZIP,在离线设备导入后执行真实推理;验证错误模型 ID、篡改文件、路径穿越、未知条目和压缩炸弹均被拒绝。
## 16. 完成标准 ## 16. 完成标准
+347
View File
@@ -24,6 +24,11 @@ const {
sep sep
} = require('node:path') } = require('node:path')
const { finished } = require('node:stream/promises') const { finished } = require('node:stream/promises')
const {
extractFile,
listPackage,
statFile
} = require('@electron/asar')
const { Zip, ZipDeflate } = require('fflate') const { Zip, ZipDeflate } = require('fflate')
const { sha256File } = require('./file-hash.cjs') const { sha256File } = require('./file-hash.cjs')
@@ -35,9 +40,27 @@ const productName = packageJson.build?.productName ?? packageJson.name
const releaseRoot = join(root, 'dist', 'release') const releaseRoot = join(root, 'dist', 'release')
const manifestName = 'release-manifest.json' const manifestName = 'release-manifest.json'
const portableMarkerName = '.goodbuddy-portable.json' const portableMarkerName = '.goodbuddy-portable.json'
const harnessHostEntry =
'out/main/deepseek-harness-host-bootstrap.js'
const harnessBundleManifest = 'out/main/package.json'
const harnessPackageVersions = {
'@deepseek-ai/dsh-agent': '0.1.0-rc.6',
'@deepseek-ai/dsh-sandbox-windows-acl': '0.1.0-rc.6',
'@deepseek-ai/node-addon-landlock-run': '0.1.1',
'node-pty': '1.1.0'
}
const koffiVersion = '3.1.4'
const harnessLicenseFiles = [
'agent-client-protocol-Apache-2.0.txt',
'deepseek-cordis-MIT.txt',
'deepseek-harness-MIT.txt',
'koffi-MIT.txt',
'node-pty-MIT.txt'
]
const portableRequiredFiles = [ const portableRequiredFiles = [
`${productName}.exe`, `${productName}.exe`,
'resources/app.asar', 'resources/app.asar',
'resources/release-notes.json',
'resources/icon.ico', 'resources/icon.ico',
'resources/tray-icon.png', 'resources/tray-icon.png',
'resources/runtimes/opencode/opencode.exe', 'resources/runtimes/opencode/opencode.exe',
@@ -358,6 +381,326 @@ function assertFile(filePath, description) {
} }
} }
function normalizeAsarEntry(filePath) {
return filePath.split('/').join(sep)
}
function asarEntryMetadata(
asarPath,
entryNames,
filePath,
description,
statAsarFile = statFile
) {
const entry = normalizeAsarEntry(filePath)
if (!entryNames.has(`${sep}${entry}`)) {
throw new Error(`${description}缺失:${filePath}`)
}
return statAsarFile(asarPath, entry)
}
function assertAsarEntry(entryNames, filePath, description) {
const entry = normalizeAsarEntry(filePath)
if (!entryNames.has(`${sep}${entry}`)) {
throw new Error(`${description}缺失:${filePath}`)
}
}
function assertBinaryArchitecture(filePath, expected, description) {
assertFile(filePath, description)
const actual = binaryArchitecture(filePath)
if (actual !== expected) {
throw new Error(
`${description}架构错误:期望 ${expected},实际 ${actual ?? '未知'}`
)
}
}
function targetHarnessPaths(options) {
const platformName = {
windows: 'win32',
macos: 'darwin',
linux: 'linux'
}[options.platform]
const koffiPackage = `@koromix/koffi-${platformName}-${options.arch}`
const koffiBinary = {
windows: `win32_${options.arch}/koffi.node`,
macos: `darwin_${options.arch}/koffi.node`,
linux: `linux_${options.arch}/koffi.node`
}[options.platform]
return {
koffiPackage,
koffiBinary,
nodePtyBinary:
options.platform === 'linux'
? 'build/Release/pty.node'
: `prebuilds/${platformName}-${options.arch}/pty.node`,
nodePtyDirectory: `${platformName}-${options.arch}`,
landlockPackage:
options.platform === 'linux'
? `@deepseek-ai/node-addon-landlock-run-linux-${options.arch}`
: undefined
}
}
function verifyHarnessPackage(
resources,
options,
dependencies = {}
) {
const asarPath = join(resources, 'app.asar')
const unpackedRoot = join(resources, 'app.asar.unpacked')
const listAsarEntries = dependencies.listPackage ?? listPackage
const statAsarFile = dependencies.statFile ?? statFile
const extractAsarFile = dependencies.extractFile ?? extractFile
const entries = new Set(listAsarEntries(asarPath))
const target = targetHarnessPaths(options)
assertAsarEntry(entries, harnessHostEntry, 'DeepSeek Harness Host')
const readJson = (filePath, description) => {
const metadata = asarEntryMetadata(
asarPath,
entries,
filePath,
description,
statAsarFile
)
if ('files' in metadata || 'link' in metadata) {
throw new Error(`${description}类型错误:${filePath}`)
}
return JSON.parse(
extractAsarFile(asarPath, normalizeAsarEntry(filePath))
)
}
const bundleManifest = readJson(
harnessBundleManifest,
'DeepSeek Harness bundle 元数据'
)
if (
bundleManifest.name !== '@deepseek-ai/dsh-llm' ||
bundleManifest.version !==
harnessPackageVersions['@deepseek-ai/dsh-agent']
) {
throw new Error('DeepSeek Harness bundle 元数据错误')
}
assertFile(
join(unpackedRoot, ...harnessBundleManifest.split('/')),
'DeepSeek Harness 可执行 bundle 元数据'
)
assertFile(
join(unpackedRoot, ...harnessHostEntry.split('/')),
'DeepSeek Harness 可执行 Host'
)
const harnessLlmChunk = [...entries]
.map((entry) => entry.slice(1).split(sep).join('/'))
.find((entry) =>
/^out\/main\/chunks\/deepseek-harness-llm-[^/]+\.js$/u.test(
entry
)
)
if (!harnessLlmChunk) {
throw new Error('DeepSeek Harness LLM chunk缺失')
}
const harnessLlmSource = extractAsarFile(
asarPath,
normalizeAsarEntry(harnessLlmChunk)
).toString('utf8')
const requiredChunkNames = new Set([
...[
...harnessLlmSource.matchAll(
/import\(["']\.\/([^/"']+\.js)["']\)/gu
)
].map((match) => match[1]),
...[...entries]
.map((entry) => entry.slice(1).split(sep).join('/'))
.filter((entry) =>
/^out\/main\/chunks\/[^/]+\.js$/u.test(entry)
)
.map((entry) => entry.slice('out/main/chunks/'.length))
])
if (requiredChunkNames.size === 0) {
throw new Error('DeepSeek Harness LLM lazy chunk closure缺失')
}
for (const chunkName of requiredChunkNames) {
const chunkPath = `out/main/chunks/${chunkName}`
const metadata = asarEntryMetadata(
asarPath,
entries,
chunkPath,
'DeepSeek Harness module chunk',
statAsarFile
)
if (!('unpacked' in metadata) || !metadata.unpacked) {
throw new Error(
`DeepSeek Harness module chunk未从 ASAR 解包:${chunkPath}`
)
}
assertFile(
join(unpackedRoot, ...chunkPath.split('/')),
'DeepSeek Harness 可执行 module chunk'
)
}
for (const [packageName, expectedVersion] of Object.entries(
harnessPackageVersions
)) {
const manifest = readJson(
`node_modules/${packageName}/package.json`,
`${packageName} 元数据`
)
if (manifest.version !== expectedVersion) {
throw new Error(
`${packageName} 版本错误:期望 ${expectedVersion},实际 ${String(manifest.version)}`
)
}
}
const targetKoffiManifest = readJson(
`node_modules/${target.koffiPackage}/package.json`,
`${target.koffiPackage} 元数据`
)
if (targetKoffiManifest.version !== koffiVersion) {
throw new Error(
`${target.koffiPackage} 版本错误:期望 ${koffiVersion},实际 ${String(targetKoffiManifest.version)}`
)
}
const ptyBinary = join(
unpackedRoot,
'node_modules',
'node-pty',
...target.nodePtyBinary.split('/')
)
const koffiBinary = join(
unpackedRoot,
'node_modules',
...target.koffiPackage.split('/'),
...target.koffiBinary.split('/')
)
assertBinaryArchitecture(
ptyBinary,
options.arch,
'DeepSeek Harness node-pty'
)
const nodePtyMetadata = asarEntryMetadata(
asarPath,
entries,
`node_modules/node-pty/${target.nodePtyBinary}`,
'DeepSeek Harness node-pty 元数据',
statAsarFile
)
const koffiMetadata = asarEntryMetadata(
asarPath,
entries,
`node_modules/${target.koffiPackage}/${target.koffiBinary}`,
'DeepSeek Harness Koffi 元数据',
statAsarFile
)
for (const [metadata, description] of [
[nodePtyMetadata, 'DeepSeek Harness node-pty'],
[koffiMetadata, 'DeepSeek Harness Koffi']
]) {
if (!('unpacked' in metadata) || !metadata.unpacked) {
throw new Error(`${description}未从 ASAR 解包`)
}
}
assertBinaryArchitecture(
koffiBinary,
options.arch,
'DeepSeek Harness Koffi'
)
if (options.platform === 'darwin') {
const helper = join(
unpackedRoot,
'node_modules',
'node-pty',
'prebuilds',
target.nodePtyDirectory,
'spawn-helper'
)
assertFile(helper, 'DeepSeek Harness node-pty spawn-helper')
if ((statSync(helper).mode & 0o111) === 0) {
throw new Error(
`DeepSeek Harness node-pty spawn-helper 不可执行:${helper}`
)
}
}
if (target.landlockPackage) {
const targetLandlockManifest = readJson(
`node_modules/${target.landlockPackage}/package.json`,
`${target.landlockPackage} 元数据`
)
if (
targetLandlockManifest.version !==
harnessPackageVersions[
'@deepseek-ai/node-addon-landlock-run'
]
) {
throw new Error(
`${target.landlockPackage} 版本错误:期望 ${harnessPackageVersions['@deepseek-ai/node-addon-landlock-run']},实际 ${String(targetLandlockManifest.version)}`
)
}
const launcher = join(
unpackedRoot,
'node_modules',
...target.landlockPackage.split('/'),
'bin',
'landlock-run'
)
assertBinaryArchitecture(
launcher,
options.arch,
'DeepSeek Harness Landlock launcher'
)
const launcherMetadata = asarEntryMetadata(
asarPath,
entries,
`node_modules/${target.landlockPackage}/bin/landlock-run`,
'DeepSeek Harness Landlock launcher 元数据',
statAsarFile
)
if (
!('unpacked' in launcherMetadata) ||
!launcherMetadata.unpacked
) {
throw new Error(
'DeepSeek Harness Landlock launcher 未从 ASAR 解包'
)
}
if ((statSync(launcher).mode & 0o111) === 0) {
throw new Error(
`DeepSeek Harness Landlock launcher 不可执行:${launcher}`
)
}
}
if (options.platform === 'windows') {
assertAsarEntry(
entries,
'node_modules/@deepseek-ai/dsh-sandbox-windows-acl/lib/runner.js',
'DeepSeek Harness Windows ACL runner'
)
assertFile(
join(
unpackedRoot,
'node_modules',
'@deepseek-ai',
'dsh-sandbox-windows-acl',
'lib',
'runner.js'
),
'DeepSeek Harness 可执行 Windows ACL runner'
)
}
for (const license of harnessLicenseFiles) {
assertFile(
join(resources, 'licenses', license),
'DeepSeek Harness 许可证'
)
}
}
function verifyUnpackedOutput(directory, options) { function verifyUnpackedOutput(directory, options) {
const definition = platformDefinitions[options.platform] const definition = platformDefinitions[options.platform]
const unpackedDirectory = findUnpackedDirectory( const unpackedDirectory = findUnpackedDirectory(
@@ -380,11 +723,13 @@ function verifyUnpackedOutput(directory, options) {
) )
assertFile(applicationExecutable, '应用主程序') assertFile(applicationExecutable, '应用主程序')
assertFile(join(resources, 'app.asar'), '应用 ASAR') assertFile(join(resources, 'app.asar'), '应用 ASAR')
assertFile(join(resources, 'release-notes.json'), '版本更新说明')
assertFile(runtimeExecutable, 'OpenCode Runtime') assertFile(runtimeExecutable, 'OpenCode Runtime')
assertFile( assertFile(
join(resources, 'runtimes', 'continue', 'dist', 'index.js'), join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
'Continue Runtime' 'Continue Runtime'
) )
verifyHarnessPackage(resources, options)
for (const [filePath, label] of [ for (const [filePath, label] of [
[applicationExecutable, '应用主程序'], [applicationExecutable, '应用主程序'],
[runtimeExecutable, 'OpenCode Runtime'] [runtimeExecutable, 'OpenCode Runtime']
@@ -990,6 +1335,8 @@ module.exports = {
parseArguments, parseArguments,
platformDefinitions, platformDefinitions,
replaceOutput, replaceOutput,
verifyHarnessPackage,
verifyUnpackedOutput,
verifyArtifacts, verifyArtifacts,
verifyArtifactSignature, verifyArtifactSignature,
verifyPortableZip, verifyPortableZip,
+214
View File
@@ -0,0 +1,214 @@
'use strict'
const {
mkdirSync,
mkdtempSync
} = require('node:fs')
const {
rm,
writeFile
} = require('node:fs/promises')
const { tmpdir } = require('node:os')
const {
isAbsolute,
join,
resolve
} = require('node:path')
const { app, utilityProcess } = require('electron/main')
const protocol = 'goodbuddy.deepseek-harness.control'
const version = 1
const byteProtocol = 'goodbuddy.deepseek-harness.byte-stream'
const configuredHostPath =
process.env.GOODBUDDY_HARNESS_SMOKE_HOST
const hostPath = configuredHostPath
? isAbsolute(configuredHostPath)
? configuredHostPath
: resolve(configuredHostPath)
: resolve('out/main/deepseek-harness-host-bootstrap.js')
const workspace = mkdtempSync(
join(tmpdir(), 'goodbuddy-harness-electron-smoke-')
)
const dshHome = join(workspace, 'dsh-home')
mkdirSync(dshHome)
const configuredResultPath =
process.env.GOODBUDDY_HARNESS_SMOKE_RESULT
const resultPath =
configuredResultPath && isAbsolute(configuredResultPath)
? configuredResultPath
: join(
tmpdir(),
`goodbuddy-harness-utility-smoke-${process.pid}.json`
)
let child
let timeout
let stderr = ''
let settled = false
let transportProbed = false
void writeFile(
resultPath,
JSON.stringify({ status: 'checkpoint', stage: 'script-start' }),
'utf8'
)
async function checkpoint(stage, detail = '') {
await writeFile(
resultPath,
JSON.stringify({ status: 'checkpoint', stage, detail }),
'utf8'
)
}
function finish(status, detail = '') {
if (settled) {
return
}
settled = true
if (timeout) {
clearTimeout(timeout)
}
void writeFile(
resultPath,
JSON.stringify({
status,
detail: detail.slice(0, 4_096)
}),
'utf8'
)
.catch(() => undefined)
.finally(() => {
child?.kill()
void rm(workspace, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100
})
.catch(() => undefined)
.finally(() => {
if (!configuredResultPath) {
console.log(
`GoodBuddy packaged Harness smoke: ${status}`
)
}
app.exit(status === 'ready' ? 0 : 1)
})
})
}
async function run() {
await checkpoint('module-loaded')
await app.whenReady()
await checkpoint('app-ready')
child = utilityProcess.fork(hostPath, [], {
cwd: workspace,
env: {
PATH: process.env.PATH ?? '',
Path: process.env.Path ?? '',
PATHEXT: process.env.PATHEXT ?? '',
SystemRoot: process.env.SystemRoot ?? '',
COMSPEC: process.env.COMSPEC ?? '',
TEMP: process.env.TEMP ?? '',
TMP: process.env.TMP ?? '',
USERPROFILE: process.env.USERPROFILE ?? '',
APPDATA: process.env.APPDATA ?? '',
LOCALAPPDATA: process.env.LOCALAPPDATA ?? '',
DSH_HOME: dshHome,
DSH_TELEMETRY_DISABLED: '1',
OTEL_SDK_DISABLED: 'true'
},
serviceName: 'GoodBuddy DeepSeek Harness Smoke',
stdio: ['ignore', 'ignore', 'pipe'],
allowLoadingUnsignedLibraries: false,
disclaim: false
})
await checkpoint('utility-forked', String(child.pid ?? ''))
child.stderr?.on('data', (chunk) => {
stderr = (stderr + String(chunk)).slice(-4_096)
})
child.on('message', (message) => {
if (
message?.protocol === protocol &&
message.version === version &&
message.type === 'ready'
) {
child.postMessage({
protocol: byteProtocol,
version,
type: 'data',
stream: 'stdin',
seq: 0,
bytes: Buffer.from('{}\n')
})
return
}
if (
message?.protocol === byteProtocol &&
message.version === version &&
message.type === 'ack' &&
message.stream === 'stdin' &&
message.seq === 0
) {
transportProbed = true
finish('ready')
return
}
if (
message?.protocol === protocol &&
message.version === version &&
message.type === 'fatal'
) {
finish('fatal', String(message.code))
}
})
child.on('exit', (code) => {
finish(
'exit',
`${code}:${stderr.replaceAll(/\s+/gu, ' ').trim()}`
)
})
child.postMessage({
protocol,
version,
type: 'start',
config: {
workspace,
dshHome,
baseUrl: 'https://gateway.example/openai/v1',
api: 'openai-completions',
provider: 'goodbuddy',
model: 'qwen-plus',
harnessVersion: '0.1.0-rc.6',
sandbox: {
provider:
process.platform === 'win32'
? 'windows-acl'
: process.platform === 'darwin'
? 'seatbelt'
: 'local-linux',
enforcement:
process.platform === 'win32' ? 'partial' : 'full'
},
credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'],
skillPackages: [],
maxFrameBytes: 1024 * 1024
}
})
timeout = setTimeout(() => {
finish(
'timeout',
`${transportProbed ? 'transport-probed ' : ''}${stderr.replaceAll(/\s+/gu, ' ').trim()}`
)
}, 20_000)
}
void run().catch((error) => {
finish(
'bootstrap-error',
error instanceof Error ? error.message : 'unknown error'
)
})
+173
View File
@@ -0,0 +1,173 @@
const { readFileSync, writeFileSync } = require('node:fs')
const { join, resolve } = require('node:path')
const root = resolve(__dirname, '..')
const packageJson = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8')
)
const releaseNotesFile = JSON.parse(
readFileSync(join(root, 'resources', 'release-notes.json'), 'utf8')
)
function fail(message) {
throw new Error(`Release notes validation failed: ${message}`)
}
function hasExactKeys(value, keys) {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length === keys.length &&
keys.every((key) => Object.hasOwn(value, key))
)
}
function validateItems(value, label) {
if (!Array.isArray(value) || value.length > 20) {
fail(`${label} must contain no more than 20 items`)
}
return value.map((item) => {
if (typeof item !== 'string') {
fail(`${label} contains a non-string item`)
}
const normalized = item.trim()
if (!normalized || normalized.length > 240) {
fail(`${label} contains an empty or oversized item`)
}
return normalized
})
}
function validateRelease(value, index) {
const label = `releases[${index}]`
if (!hasExactKeys(value, ['version', 'releasedAt', 'notes'])) {
fail(`${label} has invalid fields`)
}
if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(
value.version
)) {
fail(`${label}.version must be a stable semantic version`)
}
const date = new Date(`${value.releasedAt}T00:00:00.000Z`)
if (
!/^\d{4}-\d{2}-\d{2}$/u.test(value.releasedAt) ||
Number.isNaN(date.getTime()) ||
date.toISOString().slice(0, 10) !== value.releasedAt
) {
fail(`${label}.releasedAt must be a real YYYY-MM-DD date`)
}
if (!hasExactKeys(value.notes, ['zh-CN', 'en-US'])) {
fail(`${label}.notes must contain zh-CN and en-US`)
}
const notes = Object.fromEntries(
['zh-CN', 'en-US'].map((locale) => {
const localized = value.notes[locale]
if (!hasExactKeys(localized, ['features', 'fixes'])) {
fail(`${label}.notes.${locale} has invalid fields`)
}
const features = validateItems(
localized.features,
`${label}.notes.${locale}.features`
)
const fixes = validateItems(
localized.fixes,
`${label}.notes.${locale}.fixes`
)
if (features.length + fixes.length === 0) {
fail(`${label}.notes.${locale} must not be empty`)
}
return [locale, { features, fixes }]
})
)
if (
notes['zh-CN'].features.length !== notes['en-US'].features.length ||
notes['zh-CN'].fixes.length !== notes['en-US'].fixes.length
) {
fail(`${label} localized section counts do not match`)
}
return {
version: value.version,
releasedAt: value.releasedAt,
notes
}
}
if (
!hasExactKeys(releaseNotesFile, ['formatVersion', 'releases']) ||
releaseNotesFile.formatVersion !== 1 ||
!Array.isArray(releaseNotesFile.releases) ||
releaseNotesFile.releases.length < 1 ||
releaseNotesFile.releases.length > 100
) {
fail('unsupported file format')
}
const allReleases = releaseNotesFile.releases.map(validateRelease)
const uniqueVersionCount = new Set(
allReleases.map((release) => release.version)
).size
if (uniqueVersionCount !== allReleases.length) {
fail('release versions must be unique')
}
const releases = allReleases.filter(
(release) => release?.version === packageJson.version
)
if (releases.length !== 1) {
fail(
`expected exactly one entry for package version ${packageJson.version}`
)
}
const release = releases[0]
const localizedDefinitions = [
{
locale: 'zh-CN',
title: `GoodBuddy ${release.version} 更新内容`,
features: '功能更新',
fixes: '问题修复'
},
{
locale: 'en-US',
title: `What's New in GoodBuddy ${release.version}`,
features: 'Features',
fixes: 'Bug Fixes'
}
]
function markdownSection(title, items) {
if (items.length === 0) {
return []
}
return [`## ${title}`, '', ...items.map((item) => `- ${item}`), '']
}
const markdown = localizedDefinitions
.flatMap((definition, index) => {
const notes = release.notes[definition.locale]
return [
...(index === 0 ? [] : ['---', '']),
`# ${definition.title}`,
'',
...markdownSection(definition.features, notes.features),
...markdownSection(definition.fixes, notes.fixes)
]
})
.join('\n')
.trimEnd()
.concat('\n')
const outputIndex = process.argv.indexOf('--output')
if (outputIndex >= 0) {
const outputPath = process.argv[outputIndex + 1]
if (!outputPath) {
fail('--output requires a path')
}
writeFileSync(resolve(root, outputPath), markdown, 'utf8')
} else {
process.stdout.write(
`Validated bilingual release notes for ${packageJson.version}\n`
)
}
@@ -0,0 +1,149 @@
'use strict'
const { spawn } = require('node:child_process')
const {
readFile,
rm,
writeFile
} = require('node:fs/promises')
const { tmpdir } = require('node:os')
const { join, resolve } = require('node:path')
const electronPath = process.env.GOODBUDDY_HARNESS_SMOKE_ELECTRON
? resolve(process.env.GOODBUDDY_HARNESS_SMOKE_ELECTRON)
: require('electron')
const configuredAppPath =
process.env.GOODBUDDY_HARNESS_SMOKE_APP
const appPath = configuredAppPath
? resolve(configuredAppPath)
: resolve('build/smoke-app')
const temporaryAppPath =
configuredAppPath ||
process.env.GOODBUDDY_HARNESS_SMOKE_ELECTRON
? undefined
: join(
tmpdir(),
`goodbuddy-harness-smoke-app-${process.pid}`
)
const resultPath = join(
tmpdir(),
`goodbuddy-harness-utility-smoke-result-${process.pid}.json`
)
const profilePath = join(
tmpdir(),
`goodbuddy-harness-utility-smoke-profile-${process.pid}`
)
const environment = {
...process.env,
GOODBUDDY_HARNESS_SMOKE_RESULT: resultPath
}
delete environment.ELECTRON_RUN_AS_NODE
function runElectron(applicationPath) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(
electronPath,
[
applicationPath,
'--no-sandbox',
`--user-data-dir=${profilePath}`,
'--no-first-run'
],
{
cwd: resolve('.'),
env: environment,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
)
let output = ''
const capture = (chunk) => {
output = (output + String(chunk)).slice(-8_192)
}
child.stdout.on('data', capture)
child.stderr.on('data', capture)
const timeout = setTimeout(() => {
child.kill()
rejectRun(
new Error(
`DeepSeek Harness Electron smoke timed out: ${output.trim()}`
)
)
}, 30_000)
child.once('error', (error) => {
clearTimeout(timeout)
rejectRun(error)
})
child.once('exit', (code, signal) => {
clearTimeout(timeout)
resolveRun({ code, signal, output })
})
})
}
async function main() {
await rm(resultPath, { force: true })
await writeFile(
resolve('out/main/package.json'),
`${JSON.stringify(
{
name: '@deepseek-ai/dsh-llm',
version: '0.1.0-rc.6',
private: true,
type: 'module'
},
null,
2
)}\n`,
'utf8'
)
if (temporaryAppPath) {
await rm(temporaryAppPath, {
recursive: true,
force: true
})
const { cp, copyFile, mkdir } = require('node:fs/promises')
await mkdir(temporaryAppPath, { recursive: true })
await cp(resolve('build/smoke-app'), temporaryAppPath, {
recursive: true
})
await copyFile(
resolve('build/deepseek-harness-utility-smoke.cjs'),
join(temporaryAppPath, 'deepseek-harness-utility-smoke.cjs')
)
}
const execution = await runElectron(
temporaryAppPath ?? appPath
)
let result
try {
result = JSON.parse(await readFile(resultPath, 'utf8'))
} catch (error) {
throw new Error(
`DeepSeek Harness Electron smoke produced no valid result (code ${execution.code}, signal ${execution.signal ?? 'none'}): ${execution.output.trim()}`,
{ cause: error }
)
} finally {
await Promise.all([
rm(resultPath, { force: true }),
rm(profilePath, { recursive: true, force: true }),
temporaryAppPath
? rm(temporaryAppPath, {
recursive: true,
force: true
})
: Promise.resolve()
])
}
if (execution.code !== 0 || result.status !== 'ready') {
throw new Error(
`DeepSeek Harness Electron smoke failed (code ${execution.code}, status ${String(result.status)}): ${String(result.detail ?? execution.output).trim()}`
)
}
console.log('DeepSeek Harness Electron utility smoke: ready')
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
@@ -0,0 +1,165 @@
'use strict'
const { spawn } = require('node:child_process')
const {
copyFile,
mkdir,
mkdtemp,
readFile,
rm,
writeFile
} = require('node:fs/promises')
const { statSync } = require('node:fs')
const { tmpdir } = require('node:os')
const { join, resolve } = require('node:path')
const unpackedPath = process.argv[2]
? resolve(process.argv[2])
: resolve('dist/harness-package-probe/win-unpacked')
const executable = join(
unpackedPath,
process.platform === 'win32' ? 'GoodBuddy.exe' : 'goodbuddy'
)
const host = join(
unpackedPath,
'resources',
'app.asar.unpacked',
'out',
'main',
'deepseek-harness-host-bootstrap.js'
)
for (const [path, description] of [
[executable, 'packaged Electron executable'],
[host, 'packaged DeepSeek Harness host']
]) {
if (!statSync(path, { throwIfNoEntry: false })?.isFile()) {
throw new Error(`${description} is missing: ${path}`)
}
}
function run(command, args, env) {
return new Promise((resolveExit, rejectExit) => {
const child = spawn(command, args, {
cwd: resolve('.'),
env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
let output = ''
const capture = (chunk) => {
output = (output + String(chunk)).slice(-8_192)
}
child.stdout.on('data', capture)
child.stderr.on('data', capture)
child.once('error', rejectExit)
child.once('exit', (exitCode, signal) => {
resolveExit({ exitCode, signal, output })
})
})
}
async function main() {
const root = await mkdtemp(
join(tmpdir(), 'goodbuddy-packaged-harness-smoke-')
)
try {
const project = join(root, 'app')
const profile = join(root, 'profile')
const resultPath = join(root, 'result.json')
await mkdir(project, { recursive: true })
await copyFile(
resolve('build/deepseek-harness-utility-smoke.cjs'),
join(project, 'deepseek-harness-utility-smoke.cjs')
)
await writeFile(
join(project, 'package.json'),
`${JSON.stringify(
{
name: 'goodbuddy-packaged-harness-smoke',
version: '1.0.0',
private: true,
main: 'deepseek-harness-utility-smoke.cjs'
},
null,
2
)}\n`,
'utf8'
)
await writeFile(
join(project, 'electron-builder.yml'),
[
'appId: live.digiman.goodbuddy.harness-smoke',
'productName: GoodBuddyHarnessSmoke',
'electronVersion: "43.2.0"',
'asar: true',
'npmRebuild: false',
'files:',
' - package.json',
' - deepseek-harness-utility-smoke.cjs',
'win:',
' target:',
' - dir'
].join('\n'),
'utf8'
)
const packageArguments = [
resolve('node_modules/electron-builder/cli.js'),
'--projectDir',
project,
'--win',
'dir',
'--x64',
'--publish',
'never',
`--config.directories.output=${join(root, 'dist')}`
]
if (process.env.GOODBUDDY_ELECTRON_DIST) {
packageArguments.push(
`--config.electronDist=${resolve(process.env.GOODBUDDY_ELECTRON_DIST)}`
)
}
const packaged = await run(
process.execPath,
packageArguments,
process.env
)
if (packaged.exitCode !== 0 || packaged.signal) {
throw new Error(
`Unable to package Harness smoke app: ${packaged.output.trim()}`
)
}
const smokeEnvironment = {
...process.env,
GOODBUDDY_HARNESS_SMOKE_HOST: host,
GOODBUDDY_HARNESS_SMOKE_RESULT: resultPath
}
delete smokeEnvironment.ELECTRON_RUN_AS_NODE
const executed = await run(
join(root, 'dist', 'win-unpacked', 'GoodBuddyHarnessSmoke.exe'),
[`--user-data-dir=${profile}`, '--no-first-run'],
smokeEnvironment
)
const result = JSON.parse(await readFile(resultPath, 'utf8'))
if (
executed.exitCode !== 0 ||
executed.signal ||
result.status !== 'ready'
) {
throw new Error(
`Packaged DeepSeek Harness smoke failed (${executed.exitCode}, ${executed.signal ?? 'no signal'}): ${JSON.stringify(result)} ${executed.output.trim()}`
)
}
console.log('Packaged DeepSeek Harness utility smoke: ready')
} finally {
await rm(root, { recursive: true, force: true })
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
+18
View File
@@ -139,6 +139,24 @@ module.exports = async function prepareBundledRuntimes(context) {
architecture === 'x64' ? `${architecture}-baseline` : architecture architecture === 'x64' ? `${architecture}-baseline` : architecture
const packageName = `opencode-${packagePlatform}-${suffix}` const packageName = `opencode-${packagePlatform}-${suffix}`
const projectDir = context.packager.projectDir const projectDir = context.packager.projectDir
const projectPackage = JSON.parse(
await readFile(join(projectDir, 'package.json'), 'utf8')
)
await writeFile(
join(projectDir, 'out', 'main', 'package.json'),
`${JSON.stringify(
{
name: '@deepseek-ai/dsh-llm',
version:
projectPackage.dependencies['@deepseek-ai/dsh-llm'],
private: true,
type: 'module'
},
null,
2
)}\n`,
'utf8'
)
const integrity = await lockedIntegrity(projectDir, packageName) const integrity = await lockedIntegrity(projectDir, packageName)
const targetDirectory = join( const targetDirectory = join(
projectDir, projectDir,
+5
View File
@@ -0,0 +1,5 @@
{
"name": "goodbuddy-harness-utility-smoke",
"private": true,
"main": "deepseek-harness-utility-smoke.cjs"
}
-308
View File
@@ -1,308 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GoodBuddy 控制中心 · AI 运维仪表盘</title>
<meta name="description" content="GoodBuddy Control Center —— AI 代理运维监控与任务管理仪表盘" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<a class="skip-link" href="#main-content">跳转到主要内容</a>
<header class="site-header">
<div class="header-inner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">GB</span>
<span class="brand-name">GoodBuddy 控制中心</span>
</div>
<button
id="navToggleBtn"
class="nav-toggle"
type="button"
aria-expanded="false"
aria-controls="primaryNav"
>
<span class="visually-hidden">切换导航菜单</span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
</button>
<nav id="primaryNav" class="primary-nav" aria-label="主导航">
<ul>
<li><a href="#overview">概览</a></li>
<li><a href="#agents">代理状态</a></li>
<li><a href="#tasks">任务看板</a></li>
<li><a href="#activity">活动日志</a></li>
</ul>
</nav>
<div class="header-actions">
<button id="themeToggleBtn" class="theme-toggle" type="button" aria-pressed="false">
<span class="theme-toggle-icon" aria-hidden="true">🌙</span>
<span class="theme-toggle-label">深色模式</span>
</button>
</div>
</div>
</header>
<main id="main-content">
<section id="overview" class="hero" aria-labelledby="overview-heading">
<div class="section-inner">
<h1 id="overview-heading">今日运维概览</h1>
<p class="hero-subtitle">实时掌握 AI 代理集群健康状况与任务执行进度</p>
<div class="metrics" role="list">
<div class="metric-card" role="listitem">
<p class="metric-label">总任务数</p>
<p class="metric-value">128</p>
<p class="metric-delta metric-delta--up">较昨日 +12</p>
</div>
<div class="metric-card" role="listitem">
<p class="metric-label">运行中代理</p>
<p class="metric-value">6</p>
<p class="metric-delta metric-delta--up">全部在线</p>
</div>
<div class="metric-card" role="listitem">
<p class="metric-label">已完成任务</p>
<p class="metric-value">96</p>
<p class="metric-delta metric-delta--up">完成率 75%</p>
</div>
<div class="metric-card" role="listitem">
<p class="metric-label">平均响应时间</p>
<p class="metric-value">340ms</p>
<p class="metric-delta metric-delta--down">较昨日 -18ms</p>
</div>
</div>
</div>
</section>
<section id="agents" class="agents" aria-labelledby="agents-heading">
<div class="section-inner">
<h2 id="agents-heading">代理状态</h2>
<ul class="agent-grid">
<li class="agent-card">
<div class="agent-card-top">
<span class="agent-avatar" aria-hidden="true">A1</span>
<span class="status-badge status-running">运行中</span>
</div>
<h3 class="agent-name">Agent Alpha</h3>
<p class="agent-meta">负责:数据同步 &amp; 报表生成</p>
<p class="agent-meta">最近活跃:2 分钟前</p>
</li>
<li class="agent-card">
<div class="agent-card-top">
<span class="agent-avatar" aria-hidden="true">B2</span>
<span class="status-badge status-idle">空闲</span>
</div>
<h3 class="agent-name">Agent Beta</h3>
<p class="agent-meta">负责:客户工单处理</p>
<p class="agent-meta">最近活跃:15 分钟前</p>
</li>
<li class="agent-card">
<div class="agent-card-top">
<span class="agent-avatar" aria-hidden="true">G3</span>
<span class="status-badge status-maintenance">维护中</span>
</div>
<h3 class="agent-name">Agent Gamma</h3>
<p class="agent-meta">负责:库存预警检测</p>
<p class="agent-meta">最近活跃:1 小时前</p>
</li>
<li class="agent-card">
<div class="agent-card-top">
<span class="agent-avatar" aria-hidden="true">D4</span>
<span class="status-badge status-running">运行中</span>
</div>
<h3 class="agent-name">Agent Delta</h3>
<p class="agent-meta">负责:日志汇总分析</p>
<p class="agent-meta">最近活跃:刚刚</p>
</li>
</ul>
</div>
</section>
<section id="tasks" class="tasks" aria-labelledby="tasks-heading">
<div class="section-inner">
<div class="tasks-header">
<h2 id="tasks-heading">任务看板</h2>
<button id="addTaskBtn" class="btn btn-primary" type="button">
<span aria-hidden="true">+</span> 添加任务
</button>
</div>
<div class="filter-group" role="group" aria-label="按状态筛选任务">
<button class="filter-btn is-active" type="button" data-filter="all" aria-pressed="true">
全部
</button>
<button class="filter-btn" type="button" data-filter="running" aria-pressed="false">
运行中
</button>
<button class="filter-btn" type="button" data-filter="completed" aria-pressed="false">
已完成
</button>
</div>
<p id="taskEmptyState" class="empty-state" hidden>没有符合条件的任务。</p>
<ul id="taskList" class="task-list">
<li class="task-card" data-status="running">
<div class="task-card-top">
<h3 class="task-title">数据同步任务</h3>
<span class="status-badge status-running">运行中</span>
</div>
<p class="task-meta">负责人:Agent Alpha</p>
<p class="task-meta">截止时间:今天 18:00</p>
</li>
<li class="task-card" data-status="completed">
<div class="task-card-top">
<h3 class="task-title">客户工单自动回复</h3>
<span class="status-badge status-completed">已完成</span>
</div>
<p class="task-meta">负责人:Agent Beta</p>
<p class="task-meta">完成时间:今天 09:24</p>
</li>
<li class="task-card" data-status="running">
<div class="task-card-top">
<h3 class="task-title">每日日志汇总</h3>
<span class="status-badge status-running">运行中</span>
</div>
<p class="task-meta">负责人:Agent Delta</p>
<p class="task-meta">截止时间:今天 23:00</p>
</li>
<li class="task-card" data-status="completed">
<div class="task-card-top">
<h3 class="task-title">库存预警检测</h3>
<span class="status-badge status-completed">已完成</span>
</div>
<p class="task-meta">负责人:Agent Gamma</p>
<p class="task-meta">完成时间:昨天 21:10</p>
</li>
<li class="task-card" data-status="running">
<div class="task-card-top">
<h3 class="task-title">周报生成</h3>
<span class="status-badge status-running">运行中</span>
</div>
<p class="task-meta">负责人:Agent Alpha</p>
<p class="task-meta">截止时间:本周五 12:00</p>
</li>
</ul>
</div>
</section>
<section id="activity" class="activity" aria-labelledby="activity-heading">
<div class="section-inner">
<h2 id="activity-heading">活动日志</h2>
<div class="search-field">
<label for="activitySearch" class="visually-hidden">搜索活动日志</label>
<input
type="search"
id="activitySearch"
class="search-input"
placeholder="搜索活动日志,例如“Agent Alpha”或“同步”"
autocomplete="off"
/>
</div>
<p id="activityEmptyState" class="empty-state" hidden>没有找到匹配的活动记录。</p>
<ul id="activityList" class="activity-list">
<li class="activity-item">
<span class="activity-time">10:42</span>
<span class="activity-text">Agent Alpha 完成了「数据同步任务」的第 3 阶段</span>
</li>
<li class="activity-item">
<span class="activity-time">10:31</span>
<span class="activity-text">Agent Beta 自动回复了 12 条客户工单</span>
</li>
<li class="activity-item">
<span class="activity-time">09:58</span>
<span class="activity-text">Agent Gamma 触发了库存预警检测维护流程</span>
</li>
<li class="activity-item">
<span class="activity-time">09:20</span>
<span class="activity-text">Agent Delta 生成了每日日志汇总报告</span>
</li>
<li class="activity-item">
<span class="activity-time">08:47</span>
<span class="activity-text">系统检测到 Agent Alpha 响应时间恢复正常</span>
</li>
<li class="activity-item">
<span class="activity-time">08:02</span>
<span class="activity-text">Agent Delta 启动,开始监听新任务队列</span>
</li>
</ul>
</div>
</section>
</main>
<footer class="site-footer">
<div class="section-inner">
<p>© 2026 GoodBuddy Control Center. 保留所有权利。</p>
<p class="footer-marker">CONTINUE_WEB_DEMO_OK</p>
</div>
</footer>
<div class="modal-overlay" id="taskModalOverlay" hidden></div>
<div
class="modal"
id="taskModal"
role="dialog"
aria-modal="true"
aria-labelledby="taskModalTitle"
hidden
>
<form id="taskForm" class="modal-form" novalidate>
<div class="modal-header">
<h2 id="taskModalTitle">添加新任务</h2>
<button type="button" id="modalCloseBtn" class="modal-close" aria-label="关闭对话框">
×
</button>
</div>
<div class="modal-body">
<div class="form-field">
<label for="taskTitleInput">任务名称</label>
<input
type="text"
id="taskTitleInput"
name="taskTitle"
required
maxlength="80"
placeholder="例如:生成月度运营报告"
/>
</div>
<div class="form-field">
<label for="taskAssigneeSelect">负责人</label>
<select id="taskAssigneeSelect" name="taskAssignee">
<option value="Agent Alpha">Agent Alpha</option>
<option value="Agent Beta">Agent Beta</option>
<option value="Agent Gamma">Agent Gamma</option>
<option value="Agent Delta">Agent Delta</option>
</select>
</div>
<div class="form-field">
<label for="taskStatusSelect">初始状态</label>
<select id="taskStatusSelect" name="taskStatus">
<option value="running">运行中</option>
<option value="completed">已完成</option>
</select>
</div>
<p id="taskFormError" class="form-error" role="alert" hidden>请填写任务名称。</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" id="modalCancelBtn">取消</button>
<button type="submit" class="btn btn-primary">保存任务</button>
</div>
</form>
</div>
<script src="script.js"></script>
</body>
</html>
-382
View File
@@ -1,382 +0,0 @@
/*!
* GoodBuddy Control Center — script.js
* Vanilla JS only. No external dependencies.
* CONTINUE_WEB_DEMO_OK
*/
/* global document, window */
(function () {
"use strict";
/* ---------------------------------------------------------
* Utilities
* ------------------------------------------------------- */
/**
* Escapes HTML-sensitive characters in a string so that it is
* safe to insert as text content inside markup.
* @param {string} value
* @returns {string}
*/
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function qs(selector, scope) {
return (scope || document).querySelector(selector);
}
function qsa(selector, scope) {
return Array.prototype.slice.call((scope || document).querySelectorAll(selector));
}
/* ---------------------------------------------------------
* Theme toggle (persisted via localStorage)
* ------------------------------------------------------- */
var THEME_STORAGE_KEY = "goodbuddy-theme";
function initTheme() {
var themeToggleBtn = qs("#themeToggleBtn");
var iconEl = qs(".theme-toggle-icon", themeToggleBtn);
var labelEl = qs(".theme-toggle-label", themeToggleBtn);
function applyTheme(theme) {
var label;
if (theme === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
themeToggleBtn.setAttribute("aria-pressed", "true");
if (iconEl) iconEl.textContent = "☀️";
label = "浅色模式";
} else {
document.documentElement.removeAttribute("data-theme");
themeToggleBtn.setAttribute("aria-pressed", "false");
if (iconEl) iconEl.textContent = "🌙";
label = "深色模式";
}
if (labelEl) labelEl.textContent = label;
// .theme-toggle-label is visually hidden on narrow viewports and the
// icon is aria-hidden, so without an explicit aria-label the button
// has no accessible name on mobile. Keep it in sync with the visible
// desktop label on every theme change.
themeToggleBtn.setAttribute("aria-label", label);
}
var stored = null;
try {
stored = window.localStorage.getItem(THEME_STORAGE_KEY);
} catch {
/* localStorage unavailable, use the default theme */
}
var prefersDark =
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
var initialTheme = stored || (prefersDark ? "dark" : "light");
applyTheme(initialTheme);
themeToggleBtn.addEventListener("click", function () {
var isDark = document.documentElement.getAttribute("data-theme") === "dark";
var nextTheme = isDark ? "light" : "dark";
applyTheme(nextTheme);
try {
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
} catch {
/* localStorage unavailable — ignore silently */
}
});
}
/* ---------------------------------------------------------
* Mobile navigation toggle
* ------------------------------------------------------- */
function initNavToggle() {
var navToggleBtn = qs("#navToggleBtn");
var primaryNav = qs("#primaryNav");
if (!navToggleBtn || !primaryNav) return;
navToggleBtn.addEventListener("click", function () {
var isOpen = primaryNav.classList.toggle("is-open");
navToggleBtn.setAttribute("aria-expanded", String(isOpen));
});
qsa("a", primaryNav).forEach(function (link) {
link.addEventListener("click", function () {
primaryNav.classList.remove("is-open");
navToggleBtn.setAttribute("aria-expanded", "false");
});
});
}
/* ---------------------------------------------------------
* Task board: filtering + adding tasks
* ------------------------------------------------------- */
var STATUS_LABELS = {
running: "运行中",
completed: "已完成"
};
var STATUS_BADGE_CLASS = {
running: "status-running",
completed: "status-completed"
};
function initTaskBoard() {
var taskList = qs("#taskList");
var filterButtons = qsa(".filter-btn");
var emptyState = qs("#taskEmptyState");
function currentFilter() {
var activeBtn = qs(".filter-btn.is-active");
return activeBtn ? activeBtn.getAttribute("data-filter") : "all";
}
function applyFilter() {
var filter = currentFilter();
var cards = qsa(".task-card", taskList);
var visibleCount = 0;
cards.forEach(function (card) {
var status = card.getAttribute("data-status");
var matches = filter === "all" || status === filter;
card.hidden = !matches;
if (matches) visibleCount += 1;
});
if (emptyState) {
emptyState.hidden = visibleCount !== 0;
}
}
filterButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
filterButtons.forEach(function (b) {
b.classList.remove("is-active");
b.setAttribute("aria-pressed", "false");
});
btn.classList.add("is-active");
btn.setAttribute("aria-pressed", "true");
applyFilter();
});
});
/**
* Builds a task card list item using safe DOM APIs so that
* user-provided text can never be interpreted as markup.
*/
function createTaskCard(title, assignee, status) {
var li = document.createElement("li");
li.className = "task-card";
li.setAttribute("data-status", status);
var top = document.createElement("div");
top.className = "task-card-top";
var heading = document.createElement("h3");
heading.className = "task-title";
// textContent assigns the raw string as plain text — the browser
// never parses it as markup, so no HTML-escaping is needed (or
// wanted) here. Escaping first would make textContent render the
// escaped entities literally (e.g. "&lt;img...") instead of the
// user's exact text.
heading.textContent = title;
var badge = document.createElement("span");
badge.className = "status-badge " + STATUS_BADGE_CLASS[status];
badge.textContent = STATUS_LABELS[status];
top.appendChild(heading);
top.appendChild(badge);
var meta = document.createElement("p");
meta.className = "task-meta";
meta.textContent = "负责人:" + assignee;
var metaTime = document.createElement("p");
metaTime.className = "task-meta";
var now = new Date();
var timeLabel =
status === "completed"
? "完成时间:刚刚"
: "截止时间:" +
String(now.getHours()).padStart(2, "0") +
":" +
String(now.getMinutes()).padStart(2, "0");
metaTime.textContent = timeLabel;
li.appendChild(top);
li.appendChild(meta);
li.appendChild(metaTime);
return li;
}
applyFilter();
return {
addTask: function (title, assignee, status) {
// Pass the trimmed, un-escaped user text straight through.
// createTaskCard assigns it via textContent (never innerHTML),
// so it is rendered as inert plain text and can never execute as
// markup/script regardless of its contents.
var card = createTaskCard(title.trim(), assignee, status);
taskList.insertBefore(card, taskList.firstChild);
applyFilter();
return card;
}
};
}
/* ---------------------------------------------------------
* Activity log search
* ------------------------------------------------------- */
function initActivitySearch() {
var searchInput = qs("#activitySearch");
var activityList = qs("#activityList");
var emptyState = qs("#activityEmptyState");
if (!searchInput || !activityList) return;
function runSearch() {
var query = searchInput.value.trim().toLowerCase();
var items = qsa(".activity-item", activityList);
var visibleCount = 0;
items.forEach(function (item) {
var text = item.textContent.toLowerCase();
var matches = query === "" || text.indexOf(query) !== -1;
item.hidden = !matches;
if (matches) visibleCount += 1;
});
if (emptyState) {
emptyState.hidden = visibleCount !== 0;
}
}
searchInput.addEventListener("input", runSearch);
}
/* ---------------------------------------------------------
* Add-task modal (accessible dialog)
* ------------------------------------------------------- */
function initTaskModal(taskBoard) {
var overlay = qs("#taskModalOverlay");
var modal = qs("#taskModal");
var openBtn = qs("#addTaskBtn");
var closeBtn = qs("#modalCloseBtn");
var cancelBtn = qs("#modalCancelBtn");
var form = qs("#taskForm");
var titleInput = qs("#taskTitleInput");
var assigneeSelect = qs("#taskAssigneeSelect");
var statusSelect = qs("#taskStatusSelect");
var errorMsg = qs("#taskFormError");
if (!overlay || !modal || !openBtn || !form) return;
var lastFocusedElement = null;
function getFocusableElements() {
return qsa(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
modal
).filter(function (el) {
return el.offsetParent !== null;
});
}
function openModal() {
lastFocusedElement = document.activeElement;
overlay.hidden = false;
modal.hidden = false;
errorMsg.hidden = true;
form.reset();
document.body.style.overflow = "hidden";
titleInput.focus();
document.addEventListener("keydown", handleKeydown);
}
function closeModal() {
overlay.hidden = true;
modal.hidden = true;
document.body.style.overflow = "";
document.removeEventListener("keydown", handleKeydown);
if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
lastFocusedElement.focus();
}
}
function handleKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
closeModal();
return;
}
if (event.key === "Tab") {
var focusable = getFocusableElements();
if (focusable.length === 0) return;
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
openBtn.addEventListener("click", openModal);
closeBtn.addEventListener("click", closeModal);
cancelBtn.addEventListener("click", closeModal);
overlay.addEventListener("click", closeModal);
form.addEventListener("submit", function (event) {
event.preventDefault();
var title = titleInput.value.trim();
if (title === "") {
errorMsg.hidden = false;
titleInput.focus();
return;
}
errorMsg.hidden = true;
taskBoard.addTask(title, assigneeSelect.value, statusSelect.value);
closeModal();
});
}
/* ---------------------------------------------------------
* Bootstrap
* ------------------------------------------------------- */
function init() {
initTheme();
initNavToggle();
var taskBoard = initTaskBoard();
initActivitySearch();
initTaskModal(taskBoard);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Expose escapeHtml for testability / verification purposes only.
window.__goodbuddyEscapeHtml = escapeHtml;
})();
// CONTINUE_WEB_DEMO_OK
-782
View File
@@ -1,782 +0,0 @@
/* =========================================================
GoodBuddy Control Center — styles.css
========================================================= */
:root {
color-scheme: light;
--color-bg: #f4f6fb;
--color-bg-elevated: #ffffff;
--color-bg-header: rgba(255, 255, 255, 0.92);
--color-border: #dde3ee;
--color-text: #1c2333;
--color-text-muted: #5a6479;
--color-primary: #3a5bfd;
--color-primary-hover: #2a45d6;
--color-primary-contrast: #ffffff;
--color-success: #12a454;
--color-success-bg: #e4f8ec;
--color-warning: #b8860b;
--color-warning-bg: #fbf1d9;
--color-info: #2a6df4;
--color-info-bg: #e6edff;
--color-danger: #d43b3b;
--color-danger-bg: #fdeaea;
--shadow-sm: 0 1px 2px rgba(20, 26, 46, 0.06);
--shadow-md: 0 8px 24px rgba(20, 26, 46, 0.08);
--shadow-lg: 0 20px 48px rgba(20, 26, 46, 0.16);
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--transition-fast: 150ms ease;
--transition-base: 220ms ease;
--focus-ring: 0 0 0 3px rgba(58, 91, 253, 0.35);
--max-width: 1180px;
}
[data-theme="dark"] {
color-scheme: dark;
--color-bg: #0f1220;
--color-bg-elevated: #1a1f33;
--color-bg-header: rgba(15, 18, 32, 0.9);
--color-border: #2b3150;
--color-text: #eef1fb;
--color-text-muted: #a3aac4;
--color-primary: #7b93ff;
--color-primary-hover: #93a7ff;
--color-primary-contrast: #0f1220;
--color-success: #35d17d;
--color-success-bg: rgba(53, 209, 125, 0.14);
--color-warning: #e8c15b;
--color-warning-bg: rgba(232, 193, 91, 0.14);
--color-info: #7ea2ff;
--color-info-bg: rgba(126, 162, 255, 0.14);
--color-danger: #f27272;
--color-danger-bg: rgba(242, 114, 114, 0.14);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.55);
--focus-ring: 0 0 0 3px rgba(123, 147, 255, 0.45);
}
/* ---------- Reset & base ---------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
background: var(--color-bg);
color: var(--color-text);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui,
-apple-system, sans-serif;
line-height: 1.6;
transition: background-color var(--transition-base), color var(--transition-base);
}
h1,
h2,
h3,
p {
margin: 0 0 0.5em;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
button {
font-family: inherit;
}
input,
select {
font-family: inherit;
}
.section-inner {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 1.5rem;
}
/* ---------- Accessibility helpers ---------- */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
top: -100px;
left: 1rem;
background: var(--color-primary);
color: var(--color-primary-contrast);
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
z-index: 1000;
transition: top var(--transition-fast);
}
.skip-link:focus {
top: 1rem;
}
:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: var(--radius-sm);
}
a {
color: var(--color-primary);
}
/* ---------- Header ---------- */
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--color-bg-header);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--color-border);
}
.header-inner {
max-width: var(--max-width);
margin: 0 auto;
padding: 0.85rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-weight: 700;
font-size: 1.05rem;
white-space: nowrap;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, var(--color-primary), #7d5bfd);
color: #fff;
font-size: 0.85rem;
font-weight: 800;
}
.primary-nav {
flex: 1;
}
.primary-nav ul {
display: flex;
gap: 1.5rem;
}
.primary-nav a {
text-decoration: none;
color: var(--color-text-muted);
font-weight: 600;
padding: 0.4rem 0.2rem;
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.primary-nav a:hover {
color: var(--color-primary);
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-bg-elevated);
color: var(--color-text);
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
transition: transform var(--transition-fast), background-color var(--transition-fast);
}
.theme-toggle:hover {
transform: translateY(-1px);
}
.nav-toggle {
display: none;
flex-direction: column;
gap: 4px;
padding: 0.5rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.nav-toggle-bar {
display: block;
width: 20px;
height: 2px;
background: var(--color-text);
border-radius: 2px;
}
/* ---------- Hero ---------- */
.hero {
padding: 3rem 0 2.5rem;
}
.hero-subtitle {
color: var(--color-text-muted);
font-size: 1.05rem;
max-width: 46ch;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-top: 1.75rem;
}
.metric-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.metric-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.metric-label {
color: var(--color-text-muted);
font-size: 0.85rem;
font-weight: 600;
}
.metric-value {
font-size: 2rem;
font-weight: 800;
}
.metric-delta {
font-size: 0.82rem;
font-weight: 700;
}
.metric-delta--up {
color: var(--color-success);
}
.metric-delta--down {
color: var(--color-danger);
}
/* ---------- Sections generic ---------- */
.agents,
.tasks,
.activity {
padding: 2.5rem 0;
}
.agents h2,
.tasks h2,
.activity h2 {
font-size: 1.5rem;
}
/* ---------- Agent grid ---------- */
.agent-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-top: 1.25rem;
}
.agent-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.agent-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.agent-card-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.agent-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--color-info-bg);
color: var(--color-info);
font-weight: 800;
font-size: 0.85rem;
}
.agent-name {
font-size: 1.05rem;
}
.agent-meta {
color: var(--color-text-muted);
font-size: 0.88rem;
margin-bottom: 0.2rem;
}
/* ---------- Status badges ---------- */
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.28rem 0.7rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
}
.status-running {
background: var(--color-info-bg);
color: var(--color-info);
}
.status-completed {
background: var(--color-success-bg);
color: var(--color-success);
}
.status-idle {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.status-maintenance {
background: var(--color-danger-bg);
color: var(--color-danger);
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 1.1rem;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-weight: 700;
font-size: 0.92rem;
cursor: pointer;
transition: background-color var(--transition-fast), transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: var(--color-primary);
color: var(--color-primary-contrast);
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
.btn-ghost {
background: transparent;
border-color: var(--color-border);
color: var(--color-text);
}
.btn-ghost:hover {
background: var(--color-bg);
}
/* ---------- Tasks ---------- */
.tasks-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.filter-group {
display: flex;
gap: 0.6rem;
margin: 1.25rem 0 1rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.5rem 1rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-bg-elevated);
color: var(--color-text-muted);
font-weight: 700;
font-size: 0.88rem;
cursor: pointer;
transition: all var(--transition-fast);
}
.filter-btn:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
.filter-btn.is-active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-primary-contrast);
}
.task-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.task-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.1rem 1.25rem;
box-shadow: var(--shadow-sm);
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.task-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.task-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem;
}
.task-title {
font-size: 1rem;
word-break: break-word;
}
.task-meta {
color: var(--color-text-muted);
font-size: 0.85rem;
margin-bottom: 0.15rem;
}
.empty-state {
color: var(--color-text-muted);
font-style: italic;
padding: 1rem 0;
}
/* ---------- Activity ---------- */
.search-field {
margin: 1.25rem 0;
}
.search-input {
width: 100%;
max-width: 420px;
padding: 0.65rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-bg-elevated);
color: var(--color-text);
font-size: 0.95rem;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.activity-item {
display: flex;
gap: 1rem;
align-items: baseline;
padding: 0.75rem 1rem;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
}
.activity-time {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
color: var(--color-text-muted);
font-weight: 700;
font-size: 0.85rem;
min-width: 3.2rem;
}
.activity-text {
color: var(--color-text);
}
/* ---------- Footer ---------- */
.site-footer {
border-top: 1px solid var(--color-border);
padding: 1.5rem 0 2.5rem;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.footer-marker {
opacity: 0.55;
font-size: 0.75rem;
letter-spacing: 0.02em;
}
/* ---------- Modal ---------- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(10, 12, 24, 0.5);
z-index: 200;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(480px, calc(100vw - 2rem));
max-height: calc(100vh - 3rem);
overflow-y: auto;
background: var(--color-bg-elevated);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: 201;
border: 1px solid var(--color-border);
}
.modal[hidden],
.modal-overlay[hidden] {
display: none;
}
.modal-form {
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.modal-header h2 {
font-size: 1.15rem;
margin: 0;
}
.modal-close {
background: transparent;
border: none;
font-size: 1.4rem;
line-height: 1;
color: var(--color-text-muted);
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
}
.modal-close:hover {
color: var(--color-danger);
}
.modal-body {
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-field label {
font-weight: 700;
font-size: 0.9rem;
}
.form-field input,
.form-field select {
padding: 0.6rem 0.75rem;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.95rem;
}
.form-error {
color: var(--color-danger);
font-size: 0.85rem;
font-weight: 700;
margin: 0;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1.1rem 1.5rem;
border-top: 1px solid var(--color-border);
}
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.metrics {
grid-template-columns: repeat(2, 1fr);
}
.agent-grid {
grid-template-columns: repeat(2, 1fr);
}
.task-list {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.nav-toggle {
display: inline-flex;
}
.primary-nav {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-md);
display: none;
}
.primary-nav.is-open {
display: block;
}
.primary-nav ul {
flex-direction: column;
padding: 1rem 1.5rem;
gap: 0.25rem;
}
.primary-nav a {
display: block;
padding: 0.6rem 0.2rem;
}
.header-inner {
flex-wrap: wrap;
position: relative;
}
.theme-toggle-label {
display: none;
}
}
@media (max-width: 560px) {
.metrics {
grid-template-columns: 1fr;
}
.agent-grid {
grid-template-columns: 1fr;
}
.tasks-header {
flex-direction: column;
align-items: flex-start;
}
}
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
html {
scroll-behavior: auto;
}
}
@@ -1,6 +1,14 @@
# GoodBuddy 电脑控制开发进度 # GoodBuddy 电脑控制开发进度
最后更新:2026-08-05 ## 文档信息
| 项目 | 内容 |
| --- | --- |
| 文档类型 | 实施进度 |
| 状态 | 持续更新 |
| 版本 | 0.1 |
| 日期 | 2026-08-07 |
| 适用能力 | 电脑控制与托管浏览器 |
## 范围 ## 范围
@@ -16,7 +24,8 @@ Linux x64 和 Linux arm64。
- 已实现导航、可访问性快照、点击、输入、选择、返回和有界截图工具。 - 已实现导航、可访问性快照、点击、输入、选择、返回和有界截图工具。
- 浏览器工具只在直连模型的 Execute 模式中提供;用户选择 Execute 即授权 - 浏览器工具只在直连模型的 Execute 模式中提供;用户选择 Execute 即授权
本次交互运行,不再逐个弹出 GoodBuddy 工具审批。 本次交互运行,不再逐个弹出 GoodBuddy 工具审批。
- 已实现公网 URL、DNS、重定向、私有地址和元数据地址限制。 - 支持当前设备可连接的 HTTP、HTTPS、公网、内网、本机和元数据地址
导航及重定向仍经过 DNS 解析和目标地址固定。
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、 - 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
空闲回收和应用退出清理。 空闲回收和应用退出清理。
- 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和 - 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和
@@ -75,11 +84,12 @@ Linux x64 和 Linux arm64。
### P0:右侧没有浏览器实时画面,已修复 ### P0:右侧没有浏览器实时画面,已修复
浏览器窗口使用 `show: false`,但 BrowserService 现在从模型实际操作的同一 浏览器窗口默认使用 `show: false`,但 BrowserService 现在从模型实际操作的同一
会话捕获页面帧,并通过受限 IPC 发送状态、当前 URL 和约 220KB 的 JPEG 画面。右侧工作栏 会话捕获页面帧,并通过受限 IPC 发送状态、当前 URL 和约 220KB 的 JPEG 画面。右侧工作栏
新增“浏览器”页签;活动对话启动浏览器时会自动打开该页签,并显示创建中、 新增“浏览器”页签;活动对话启动浏览器时会自动打开该页签,并显示创建中、
加载中、操作中、就绪、失败和已停止状态。用户可在页签内立即停止当前对话的 加载中、操作中、用户交互中、就绪、失败和已停止状态。用户可点击“交互”打开
浏览器会话。 同一会话的子窗口辅助 Agent;交互时主窗口暂时禁用,关闭时先刷新最终画面再将
浏览器窗口最小化,页面和会话继续保留。用户也可立即停止当前对话的浏览器会话。
当前实现按导航、快照、点击、输入、选择、返回和截图操作刷新画面,而不是创建 当前实现按导航、快照、点击、输入、选择、返回和截图操作刷新画面,而不是创建
第二个预览浏览器,因此显示内容与模型受控页面一致。 第二个预览浏览器,因此显示内容与模型受控页面一致。
@@ -4,13 +4,16 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 产品代号 | GoodBuddy | | 文档类型 | 产品设计基线 |
| 状态 | 初始方案 |
| 版本 | 0.1 |
| 日期 | 2026-07-29 |
| 适用产品 | GoodBuddy |
| 产品形态 | 常驻型跨平台 AI 桌面助手 | | 产品形态 | 常驻型跨平台 AI 桌面助手 |
| 目标平台 | Windows、macOS、Linux(含统信 UOS、银河麒麟) | | 目标平台 | Windows、macOS、Linux(含统信 UOS、银河麒麟) |
| 目标架构 | x86_64、ARM64(含鲲鹏、飞腾) | | 目标架构 | x86_64、ARM64(含鲲鹏、飞腾) |
| 推荐技术栈 | Electron + React + TypeScript + Vite | | 推荐技术栈 | Electron + React + TypeScript + Vite |
| 可选扩展 | Rust Sidecar,用于本地索引、OCR、文档解析等性能敏感任务 | | 可选扩展 | Rust Sidecar,用于本地索引、OCR、文档解析等性能敏感任务 |
| 文档状态 | 初始方案 |
本文定义产品范围、功能模块、关键交互、权限安全、跨平台策略、非功能指标、版本路线及验收要求。产品参考通用 AI 桌面助手形态,不依赖任何第三方产品的私有实现。 本文定义产品范围、功能模块、关键交互、权限安全、跨平台策略、非功能指标、版本路线及验收要求。产品参考通用 AI 桌面助手形态,不依赖任何第三方产品的私有实现。
+678
View File
@@ -0,0 +1,678 @@
# GoodBuddy 自维护 DeepSeek Harness Runtime 设计
## 1. 文档信息
| 项目 | 内容 |
| --- | --- |
| 文档状态 | 实现与发布验收基线 |
| 设计目标 | 将 DeepSeek Harness 作为 GoodBuddy 的第三个 Agent Runtime |
| Runtime 标识 | `deepseek-harness` |
| 首版依赖基线 | 实际使用的 `@deepseek-ai/dsh-*` 底层库,精确锁定 `0.1.0-rc.6` |
| 上游状态 | Developer Preview,允许出现破坏性变更 |
| 上游许可证 | MIT |
| GoodBuddy 目标平台 | Windows、macOS、Linuxx64 与 arm64 |
| 本文性质 | 设计与发布验收约定 |
本文定义 DeepSeek Harness 在 GoodBuddy 中的架构边界、协议、安全策略、界面、打包和验收要求。实现必须继续遵守 GoodBuddy 已有的 Main 进程安全边界、Ask/Execute 语义、授权、取消、超时、有界输出和资源回收约定。
## 2. 摘要
DeepSeek Harness 的底层库使用 Cordis 组合服务。GoodBuddy 不采用官方产品 profile、插件安装或市场机制,也不让用户配置覆盖安全服务,而是增加一个实验性的第三 Runtime,并完全自行维护 Host、控制协议、生命周期和兼容层。上游 DSH 包只是精确锁定并逐次审查的实现依赖,不构成 GoodBuddy 对 DSH 插件 ABI、插件目录或产品路线的承诺。
GoodBuddy 并不迫切于把该能力做成 DSH 插件或进入插件市场。当前优先级是向用户提供稳定、可靠、可审计且可完整回收的 Runtime;只有未来真实用户需求和成熟度证明插件化确有价值时,才重新评估该形态。
整体分成两个互相约束的部分:
1. **GoodBuddy Main Control Plane**
- 运行在 Electron Main 进程。
- 持有加密设置、模型连接选择、Ask 拒绝与 Execute 自动授权策略、Runtime 生命周期和审计归属。
- 通过 Electron `utilityProcess` 启动受控 Harness 子进程。
- 对环境、输入、输出、超时、取消和进程树执行强制限制。
2. **GoodBuddy Harness Control Plane**
- 运行在 Harness 子进程内,是 Host 私有的内部控制组件,不导出 Cordis 插件入口。
- 使用 ACP 兼容的 JSON-RPC stdio 作为基础控制面。
- 增加 GoodBuddy 所需的能力握手、每轮权限准备、会话释放、工具事件、推理、用量和安全凭据请求扩展。
- 与 GoodBuddy Host 一起维护、构建和发布,不设计为独立 npm 包、`dsh.bundle` 或市场插件。
DeepSeek Harness 不替换 OpenCode、Continue 或直连模型 Runtime。用户可以按全局、项目、会话或消息通道继续选择现有 Runtime。
## 3. 背景与上游能力
### 3.1 已确认的官方能力
- `@deepseek-ai/dsh` 是官方 profile 启动器。
- Harness 插件是导出 `apply(ctx, config)` 的 Cordis 模块。
- npm 包可通过 `dsh.bundle` 声明配置补丁,再通过 `dsh plugin --profile <name> add <package>` 安装。
- ACP 支持:
- 初始化。
- 创建多个会话。
- 发送 Prompt。
- 按会话取消。
- 一次性权限选择。
- 已提交的助手文本。
- 官方本地沙箱支持:
- LinuxBubblewrap,或 Landlock 降级。
- macOSSeatbelt。
- Windows:ACL 受限令牌,官方明确标记为部分强制执行。
### 3.2 官方通道的缺口
官方 ACP 插件有意只输出已提交文本,不输出推理、工具进度、计划、标题和用量。它也没有标准的会话关闭方法。SDK JSON-RPC 的展示事件更完整,但缺少 GoodBuddy 需要的单轮取消和权限回传。
因此,首版不单独选用其中一个官方通道作为完整实现。GoodBuddy Harness Control Plane 以 ACP 语义为基础,补充有命名空间的扩展方法和事件。
### 3.3 自维护边界
GoodBuddy 不急于把该 Runtime 包装成标准 DSH 插件,也不以进入官方或第三方插件市场为近期目标。所有入口都随 GoodBuddy 发布,只有 GoodBuddy Main 可以启动并使用内部 Host。是否采用上游新版本或未来重新评估插件形态,只由真实用户价值、安全审查和六平台稳定性决定,不跟随市场机制或上游发布节奏。
## 4. 目标与非目标
### 4.1 首版目标
- 增加 `deepseek-harness` Runtime,并在设置、聊天和消息通道中可选择。
- 使用 GoodBuddy 管理的模型连接,不在 Renderer 或持久化 Harness 配置中写入 API Key。
- Ask 模式在 Runtime 边界强制只读,并禁止任何权限升级。
- Execute 模式下的工具权限请求由 Main 自动给予单次授权,不弹出交互审批;默认文件模式仍为 `workspace-write`,越界仅允许在真实沙箱拒绝后对完全相同操作单次重试。
- 支持多会话、同会话串行、跨会话并行。
- 支持按请求取消、超时、会话释放和应用退出时完整回收。
- 输出文本、推理、工具参数、工具结果、stderr 和协议队列全部有界。
- 使用真实 OpenAI 兼容 Chat Completions 模型验证调用,而不在日志、测试产物或提交中暴露凭据。
- 保留 Windows、macOS、Linux 的 x64 和 arm64 发布能力。
### 4.2 首版非目标
- 不替换 OpenCode、Continue 或直连模型 Runtime。
- 不开放用户 Cordis profile、cordis.patch.yml 或 $DSH_HOME 全局补丁覆盖。
- 不提供外部 Host、自定义 Harness Control Plane、DSH 插件安装或市场入口。
- 不加载 Harness Web UI、HMR、遥测、自动更新或目录选择器。
- 不支持 `danger-full-access` 作为会话默认值或持久设置。
- 不向 Utility 暴露 MCP 凭据或建立直连 MCP Client。只有用户明确分配给 Harness 的 MCP 工具可以通过 Main 代理调用。
- 不在首版向 Harness 暴露 GoodBuddy 浏览器控制、知识库或 Magic Notes。
- 不在首版支持图像输入、会话恢复、Harness Subagent、后台 Job、Hook、Web Search 或 Workflow。
- 不发布独立 npm 包,也不创建上游 PR。
## 5. 核心设计决策
### 5.1 第三个独立 Runtime
`deepseek-harness` 是明确的 Runtime 类型,不伪装成 `model``opencode``continue`。共享契约、设置迁移、Runtime 选择、检测、聊天标签、消息通道和模型用量都使用同一个稳定标识。
### 5.2 受控组合,不启动用户 profile
GoodBuddy 使用自己固定的 Harness Host 入口和只读组合模板,不调用 `dsh web`,也不启动用户已有 profile。运行时禁止以下来源参与组合:
- 当前工作目录的 `.env`
- 用户 Harness Home 的 `.env`
- `$DSH_HOME/cordis.patch.yml`
- 用户 profile 的 `cordis.patch.yml`
- 任意 `--patch`
- HMR 和动态插件安装。
模型名称、服务地址、工作区和非秘密策略通过严格校验的 Main 配置传给 Host。API Key 只通过受控凭据通道按需提供,不写入 YAML、命令行、Renderer 或日志。
### 5.3 双层内部控制面
Harness 子进程内控制面不能取代 Main 控制面,Main 控制面也不能代替进程内的 Session/Tool 适配层:
- Harness Control Plane 最接近 Session、Agent、Tool、Usage 和权限 seam,适合做内部协议转换。
- Main 控制面是可信安全边界,适合持有模式授权策略、加密设置、进程控制和 IPC。
任何一侧缺失能力握手时,Runtime 必须报告不可用,不能降级为不受控执行。
### 5.4 GoodBuddy 继续拥有持久会话
首版不启用 Harness JSONL 会话持久化和 SQLite 会话索引。原因如下:
- GoodBuddy 已经持久化对话、消息、活动、工具事件和用量。
- 再写一份 Harness 日志会扩大敏感数据副本和清理范围。
- GoodBuddy 在 Runtime 重启后可以用现有的有界历史创建新 Harness Session。
Harness Session 只在当前 Runtime 进程生命周期内存在。释放 GoodBuddy 会话时必须同步释放对应 Harness Agent。
## 6. 总体架构
```text
Renderer
│ 显式、经 schema 验证的 preload API
Electron Main
├─ RuntimeSettingsStore
├─ AgentRuntimeController
├─ RuntimeAuthorizerAsk 拒绝 / Execute 自动单次授权)
└─ DeepSeekHarnessRuntime / Main Control Plane
│ ACP + goodbuddy/* 扩展,stdin/stdout
Electron utilityProcess
└─ GoodBuddy Harness Host
├─ 固定 Cordis 组合
├─ GoodBuddy Harness Control Plane(内部组件)
├─ DSH Agent 与 LLM seam
├─ DSH Sandbox Policy
├─ 沙箱 Shell / Filesystem
└─ 最小工具集
│ HTTPS
用户选择的 OpenAI 兼容模型连接
```
### 6.1 信任边界
| 区域 | 信任级别 | 允许持有的内容 |
| --- | --- | --- |
| Renderer | 不可信展示层 | 脱敏设置、状态、用户可见事件 |
| Preload | 窄桥 | 明确方法和共享 schema |
| Electron Main | 可信控制面 | 加密设置、模式授权策略、Runtime 生命周期 |
| Harness utilityProcess | 不可信执行面 | 当前请求、临时凭据、受控工具和工作区权限 |
| Harness 工具子进程 | 最低信任 | 单次命令所需的最小环境和沙箱能力 |
Harness 子进程崩溃、输出异常、拒绝协议、加载错误或沙箱不可用时,Main 必须失败关闭。
## 7. GoodBuddy Harness Control Plane
### 7.1 内部组件职责
控制面负责:
- 启动 ACP 兼容的 JSON-RPC stdio 服务。
- 创建、查找和释放 Harness Agent。
- 在 Prompt 前应用 GoodBuddy 指定的 Ask/Execute 权限。
- 将 DSH Session 事件转换为有界的 GoodBuddy 事件。
- 将权限请求转发到 Main,并只接受一次性结果。
- 将 LLM 用量转换为稳定的模型用量事件。
- 在 dispose 时先取消 Agent,再等待子 Agent 和工具清理。
- 保证 stdout 只包含协议帧,诊断只写 stderr。
控制面不负责:
- 保存 GoodBuddy 设置。
- 持久保存 API Key。
- 决定 Main 的模式授权结果。
- 直接访问 Renderer 或 Electron API。
- 接受用户提供的插件、Host 或 profile 覆盖。
- 自行上传遥测。
### 7.2 非插件约束
控制面不导出 `apply(ctx, config)`,不提供默认 stdin/stdout 入口,不包含 `dsh.bundle``cordis.patch.yml` 或可安装 manifest,也不接受 Host 之外创建的 transport。它可以保留清晰的内部模块边界以便测试和维护,但该边界不是公开扩展点。
若未来确有来自 GoodBuddy 真实用户、经过研究验证的扩展需求,应先重新完成产品需求、威胁模型和兼容策略评审;不得因为上游已经提供插件或市场机制而默认开放。
## 8. 协议设计
### 8.1 传输
- stdin/stdout 使用换行分隔 JSON-RPC。
- stdout 不得出现日志、Banner、进度条或调试输出。
- stderr 只允许有界诊断,不得包含 Prompt、工具完整输出或凭据。
- 每一帧、每一字段和每个请求累计输出都必须在解析前或接收时限流。
### 8.2 标准 ACP 方法
首版保留 ACP 的初始化、`session/new``session/prompt``session/cancel` 语义。标准 ACP 客户端可以使用只读默认行为,但只有完成 GoodBuddy 能力握手的客户端才能启用 Execute。
### 8.3 GoodBuddy 扩展
扩展统一使用 `goodbuddy/` 命名空间:
| 方法或事件 | 方向 | 用途 |
| --- | --- | --- |
| `goodbuddy/handshake` | Main → Control Plane | 交换控制协议、Harness、ACP 版本和能力 |
| `goodbuddy/session/prepare` | Main → Control Plane | 在下一次 Prompt 前设置工作模式和请求标识 |
| `goodbuddy/session/release` | Main → Control Plane | 取消并释放指定 Session |
| `goodbuddy/session/event` | Control Plane → Main | 文本、推理、工具、状态和用量事件 |
| `goodbuddy/credential/resolve` | Control Plane → Main | 按已登记引用请求当前 Runtime 的临时凭据 |
| `goodbuddy/tools/list` | Control Plane → Main | 取得用户分配给 Harness 的有界 MCP 工具 schema |
| `goodbuddy/tools/call` | Control Plane → Main | 通过当前 Execute 请求、schema 校验和自动单次授权调用 MCP |
| `goodbuddy/shutdown` | Main → Control Plane | 停止接收新请求并有序清理 |
扩展版本独立于 ACP 版本。握手响应至少包含:
```ts
type GoodBuddyHarnessCapabilities = {
controlProtocolVersion: 1
harnessVersion: string
acpProtocolVersion: number
supports: {
cancellation: true
sessionRelease: true
oneShotApproval: true
reasoningEvents: boolean
toolEvents: boolean
usageEvents: boolean
}
sandbox: {
provider: string
enforcement: 'full' | 'partial'
}
}
```
版本不兼容、必需能力缺失或 `sandbox.enforcement` 不满足设置要求时,Main 不得开始模型请求。
### 8.4 每轮权限准备
GoodBuddy 的工作模式属于每个请求,不属于 Runtime 进程全局状态。同一对话可以在 Ask 和 Execute 之间切换。因此:
1. `session/new` 后默认是 `read-only + never`
2. 每个 Prompt 前,Main 发送一次 `goodbuddy/session/prepare`
3. Harness Control Plane 将准备状态绑定到 `sessionId + requestId`
4. `session/prompt` 只能消费匹配且尚未使用的准备状态。
5. 缺少准备状态、重复使用、请求标识不匹配时,Control Plane 使用只读且禁止授权的安全默认值,或直接拒绝请求。
6. 同一 Session 只允许一个 Prompt 在途。
### 8.5 事件模型
Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段:
- `status`:简短运行状态。
- `text`:已提交的助手文本分片。
- `reasoning`:可选的有界推理摘要分片。
- `tool`:工具 ID、名称、状态和有界输入/输出摘要。
- `model-usage`:模型、提供方、输入、输出和缓存 Token。
- `done`:停止原因和 Session ID。
禁止发送原始 Cordis Context、完整环境、内部对象、堆栈中的凭据或无界 Session 日志。
## 9. Runtime 生命周期
### 9.1 进程模型
- 每个活动的 DeepSeek Harness Runtime 实例拥有一个 `utilityProcess`
- 一个进程可以承载多个 Harness Session。
- 同一 GoodBuddy 对话的 Prompt 串行执行。
- 不同对话可以并行,但受全局并发上限控制。
- Runtime 设置变化时创建新实例,旧实例等待在途请求结束或在宽限期后被取消。
### 9.2 会话映射
Main 保存内存映射:
```text
GoodBuddy conversationId -> Harness sessionId + process generation
```
- 首次请求创建 Session。
- 已有 Session 只发送当前 Prompt。
- 进程重启或映射失效时,创建新 Session,并只在这一次加入 GoodBuddy 提供的有界历史。
- 历史以明确的“不可信会话数据”结构传入,不能拼接成系统指令。
- 用户分配的 Skill 只通过 Main 校验的包路径进入 Host,并在 Agent scope 注册;不得把 Skill 内容伪装成用户 Prompt。
### 9.3 取消与超时
- 用户取消时立即发送 `session/cancel`
- 取消等待有界,超时后关闭连接并终止整个 Harness 进程。
- 初始化、握手、Session 创建、Prompt、权限回传和关闭分别使用独立超时。
- Prompt 超时与用户取消使用不同错误类型,不能被宽泛 catch 抹平。
- 取消后仍可接收并丢弃该请求的最终协议结算帧,但不得写入下一请求。
### 9.4 释放与退出
- 删除或释放对话时调用 `goodbuddy/session/release`
- Runtime dispose 时先拒绝新请求,再取消所有 Session。
- Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。
- Main 在宽限期内等待正常退出。
- 超时后终止 utilityProcess,并在平台允许时清理完整进程树。
- 应用退出不得因 Harness 清理无限阻塞。
## 10. 权限与沙箱
### 10.1 模式映射
| GoodBuddy 模式 | DSH 文件模式 | DSH 权限策略 | 行为 |
| --- | --- | --- | --- |
| Ask | `read-only` | `never` | 允许受控读取,不允许写入,不允许升级 |
| Execute | `workspace-write` | `ask` | 允许工作区与受控临时目录写入;权限请求由 Main 自动单次授权,不弹出交互审批 |
`danger-full-access` 只能作为某个已被沙箱拒绝的完全相同操作的一次性、更宽重试。Main 仅对该次重试自动返回 `allow-once`;它不能保存为默认值、复用于后续操作,或通过“始终允许”返回。
### 10.2 Ask 模式
- Main 即使收到权限请求也固定拒绝。
- Harness Control Plane 禁止 `sandbox_permissions` 升级。
- 文件写入和 Shell 写入都由 DSH 共享 Sandbox Policy 强制拒绝。
- 只读不等于无限输出,读取仍受路径、字节和工具结果上限控制。
- 首版不向 Ask 暴露 GoodBuddy 的可变数据工具。
### 10.3 Execute 模式
- 工作区根来自 Session 创建时的规范化绝对路径。
- 工具不能自行更换工作区根。
- 工作区内操作按 DSH `workspace-write` 执行。
- 只有真实沙箱拒绝后的同一操作,才可请求一次升级。
- Main 不调用 `ToolApprovalBroker`,而是对当前 Execute 请求自动返回 `allow-once`;界面不进入等待审批状态,也不弹出审批对话框。
- 所有工具调用仍作为活动事件记录;Ask 和 delegation 路径继续固定拒绝。
- Harness Control Plane 不接受 `allow_always`,也不把未知结果解释为允许。
### 10.4 沙箱可用性
- `strict`:要求完整强制执行。仅有 `partial` 或无 Runner 时 Runtime 不可用。
- `auto`:允许官方报告的 `full``partial`,但必须在状态卡显示实际强制程度。
- `off`:不允许 Harness 退化到无限制工具执行。首版将 Execute 标记为不可用,Ask 仍只能在可强制只读时运行。
Windows ACL 和旧 Linux Landlock 可能只报告 `partial`。界面和诊断必须如实显示,不能写成“完全隔离”。
### 10.5 环境与凭据
- 使用环境变量白名单构造 utilityProcess 环境。
- 不继承 `NODE_OPTIONS`、调试端口、任意 npm 配置、用户 `DSH_*` 覆盖或白名单之外的凭据。
- `DSH_TELEMETRY_DISABLED=1` 必须固定设置。
- Harness Home 指向 GoodBuddy 管理的隔离目录。
- 不调用官方 `loadEnv``loadLayeredEnv`
- API Key 由 Main 从加密设置中解析。
- Harness Control Plane 只能用已握手登记的引用通过 `goodbuddy/credential/resolve` 请求当前 Runtime 的凭据。
- 凭据只在模型请求所需的子进程内存中短暂存在,不写磁盘、不进入工具环境、不打印。
## 11. 受控 Harness 组合
首版只加载完成文本对话、受控代码操作和用户明确分配能力所需的固定服务:
- Agent、Session、LLM 和 Tool Registry 基础服务。
- GoodBuddy Harness Control Plane。
- OpenAI 兼容 Chat Completions LLM 适配器。
- Sandbox Policy 与平台 Sandbox Provider。
- 平台对应的受沙箱 Shell。
- 受沙箱 Filesystem。
- 一次性权限请求服务。
- Token Meter 和必要的上下文压缩。
- 有界的读取、写入、编辑和 Shell 工具。
- Agent scope 的 Skill Registry 与 `skill` 工具。Skill 目录由 Main 选择并在 Launcher 和 Host 两次规范化、校验。
- Main 代理的 MCP schema 工具。Utility 不持有 MCP URL 凭据或 Transport。
首版明确不加载:
- Web UI、HMR、Host API 和目录选择器。
- Harness 遥测。
- Settings File 和 Local Credentials。
- 用户 profile 与全局补丁。
- Web Search、Fetch、Utility 直连 MCP、Hooks。
- Subagent、Workflow、Ralph、后台 Job。
- JSONL Session Persistence 和 SQLite Session Query。
- 自动技能发现和市场技能加载。
如果某个首版工具依赖被排除服务,启动审计必须失败,而不是自动加载更大的默认 bundle。
## 12. 模型配置
### 12.1 配置来源
DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
- 协议必须是 `openai-chat-completions`
- 认证必须是 API Key。
- 公网服务地址必须使用 HTTPS`localhost``127.0.0.1``::1` 回环地址可以使用 HTTP。
- 服务地址可以使用自定义主机、端口和部署路径,但不得包含用户名、密码、查询参数或片段。
- 模型名称不限制为 DeepSeek 品牌,由所选 OpenAI 兼容服务决定。
- 模型名称和服务地址由 Main 传入受控 Host。
- API Key 继续保存在 GoodBuddy 加密设置中。
- 启动环境提供的部署连接只由 Main 自动解析,不在 Renderer 中显示为可选来源。
不允许选择 Harness 自有的用户配置文件或自定义 Host。Runtime 始终使用随当前 GoodBuddy 版本发布的内置 Host,并通过完整内部能力握手。
### 12.2 设置变化
模型、凭据、沙箱、Skill 或 MCP 分配变化时,GoodBuddy 创建新 Runtime 实例。Harness Host 路径始终由当前 GoodBuddy 构建提供,不能由设置或环境变量替换。旧实例按现有 Runtime Controller 语义退役,不在一个活动进程内热替换安全配置。
### 12.3 输入限制
- 首版只支持文本。
- 图片输入应在发起网络调用前返回明确错误。
- GoodBuddy 历史、Prompt、系统指令分别保持不同信任层。
- 任何用户文本都不能进入 Cordis 配置表达式或模块名。
## 13. 输出和资源边界
建议首版默认限制:
| 项目 | 默认上限 |
| --- | --- |
| 单个 JSON-RPC 帧 | 1 MiB |
| 单个文本或推理事件 | 64 KiB |
| 单次请求累计协议输出 | 4 MiB |
| 工具输入摘要 | 4,000 字符 |
| 工具输出摘要 | 4,000 字符 |
| 待处理事件数 | 1,000 |
| stderr 累计 | 64 KiB |
| 初始化 | 10 秒 |
| 单次 Prompt | 10 分钟 |
| 有序关闭宽限期 | 2 秒 |
超过限制时应取消当前请求。协议帧、队列或 stderr 持续异常时,应终止 Runtime 进程,避免继续信任已失控的通道。
## 14. Runtime 检测与状态
### 14.1 检测
检测只验证:
- 内置 Host 路径是规范化文件。
- 版本可读取且在支持范围内。
- 内部控制面能力握手成功。
- 必需 Sandbox Provider 可用并报告强制程度。
检测不得调用付费模型,也不得读取或输出 API Key。真实模型测试是单独的显式操作。
### 14.2 设置界面
Agent Runtime 使用共享 `SegmentedControl` 展示 OpenCode、Continue 和 DeepSeek Harness。DeepSeek Harness 必须标记为“开发者预览”,并说明上游 RC 可能发生破坏性变更。
Runtime 的概览、模型配置和检测信息放在同一张详情卡中。当前单独显示的一行“已就绪”应移入卡片,与路径、版本号归为同一组:
```text
Runtime GoodBuddy 内置 DeepSeek Harness
模型配置: 跟随 GoodBuddy · 企业网关(qwen-plus
状态: 已就绪
路径: <受控 Host 路径>
版本: 0.1.0-rc.6
安全强制: 完整 / 部分
Host 始终由当前 GoodBuddy 版本提供,不存在自定义 Host 入口。
```
界面要求:
- 不再在卡片外重复一行检测结果。
- 使用语义化键值结构,路径允许换行,不截断关键信息。
- 状态不能只依靠绿色表达,必须同时有文字。
- 检测中、不可用和部分强制分别显示明确文案。
- 高级设置默认收起。
聊天顶栏只显示简短 Runtime 状态,不显示文件路径和版本。完整诊断只在设置页展示。
## 15. IPC 与共享契约
共享 schema 需要覆盖:
- `deepseek-harness` provider 和 Runtime ID。
- Runtime 选择中的 `deepseekHarness` 分支。
- 检测结果中的路径、版本、详情和沙箱强制程度。
- GoodBuddy 模型连接选择。
- DeepSeek Harness 模型用量归属。
- Skill 与 MCP 对 `deepseek-harness` 的显式分配。
Renderer 只接收脱敏状态。任何凭据、完整环境、启动参数或内部 Cordis 配置都不能进入共享契约。
已有设置迁移必须:
- 对没有新字段的用户使用安全默认值。
- 保留 OpenCode、Continue 和模型连接选择。
- 修复失效的 DeepSeek Harness 模型引用时给出可报告的迁移警告。
- 不把旧 Runtime 自动迁移为 DeepSeek Harness。
## 16. 打包与供应链
### 16.1 版本策略
- 官方 RC 包全部精确锁定,不使用 `^``~`
- 同一 Harness 核心包族必须保持同一 RC 版本。
- 升级前检查 release diff、协议 diff、沙箱 diff和依赖闭包。
- 内部握手同时检查锁定的 Harness 基线和 GoodBuddy 控制协议版本。
### 16.2 原生依赖
受控组合可能需要:
- `node-pty`,用于受管理的工具子进程。
- `koffi`,用于 Windows ACL 或相关本地能力。
- `@deepseek-ai/node-addon-landlock-run` 的平台包。
不得广泛批准所有安装脚本。只允许生产组合实际需要、来源已审查、版本已锁定的脚本。六个平台的构建必须验证:
- 对应架构的原生文件存在。
- Electron Utility Process 可加载原生模块。
- Runner 或 spawn helper 的权限正确。
- 包中没有混入其他平台不需要的可执行内容,除非上游包无法拆分且已记录。
### 16.3 生产闭包
发布包只包含受控 Host 需要的插件和许可证。应尽量避免把 Harness Web profile、HMR 和其他未加载产品面带入生产闭包。若 npm 依赖结构无法拆分,必须:
- 确认这些模块不会被加载。
- 评估它们带来的 audit 和体积风险。
- 在后续上游版本允许时改为最小包族。
- 确认 `tests/fixtures` 以及 Web3D 测试 Skill/MCP 不进入正式发布资源。
### 16.4 漏洞门禁
当前安装后的 `npm audit` 报告不能直接用 `npm audit fix --force` 处理。每项漏洞需要区分:
- GoodBuddy 既有依赖。
- Harness 新增生产依赖。
- 仅开发或打包依赖。
- 未加载但被带入的 Web 依赖。
进入 Harness 执行路径且有可利用条件的高危问题必须在发布前修复、替换或移出生产闭包。
### 16.5 发布验证
`build/build-release.cjs` 需要验证:
- Harness Host 和受控配置存在。
- GoodBuddy Host、内部控制协议与 Harness 依赖版本清单存在。
- 平台原生 Sandbox/PTY 依赖架构正确。
- Harness、ACP SDK 和其他新增第三方许可证已打包。
- `app.asar` 外需要执行或动态加载的资源位于预期目录。
- Web3D Skill/MCP 等测试 fixture 不在 `app.asar``extraResources` 中。
## 17. 测试策略
### 17.1 单元测试
- Runtime 选择、设置迁移和失效引用修复。
- 二进制检测、版本解析和路径规范化。
- ACP 握手、事件转换和请求关联。
- 每个会话单请求、跨会话并行。
- Ask 固定拒绝升级。
- Execute 权限请求由 Main 自动返回单次授权,Ask 与 delegation 固定拒绝。
- 未分配 Skill/MCP 不可见;分配后的 Skill catalog 可调用 `skill` 加载。
- Ask 不注册 MCP 工具;Execute 每轮刷新有界 schema,并在调用前再次校验活动请求、模式、参数和自动单次授权。
- MCP URL、启动命令和凭据不进入 Utility 启动配置或协议结果。
- 未知授权结果失败关闭。
- 超时、取消、迟到帧和进程意外退出。
- 协议帧、事件队列、工具摘要和 stderr 上限。
- release 和 dispose 的幂等性。
- 状态卡中的状态、路径、版本和强制程度。
### 17.2 本地集成测试
使用无网络的假控制面/模型验证:
- utilityProcess 管道。
- 多 Session。
- Session 释放。
- Runtime 替换。
- 进程树回收。
- 受控配置不会读取工作区 `.env` 和用户 DSH 配置。
### 17.3 真实模型测试
真实测试已经获得用户授权,但必须由显式环境门禁启用。Web3D Skill 和 MCP 仅作为 `tests/fixtures` 下的测试资产使用,不属于内置发布能力。至少验证:
1. 文本问答成功,并记录正确 Runtime 和模型用量。
2. Ask 可以读取工作区,但写入被拒绝,且不会弹出权限对话框。
3. Execute 可以在工作区创建测试文件。
4. Execute 越界操作先被拒绝,再对完全相同的重试自动给予单次授权,全程不弹出审批。
5. 不匹配的重试、Ask 和 delegation 不能换路径或重复绕过。
6. 取消长请求后不再产生文本,并可继续使用其他 Session。
7. 两个 Session 可并行,事件不会串线。
8. 释放会话和关闭应用后没有残留 Harness 或工具进程。
9. 从全新用户设置流程启用一个 3D 游戏 Skill 和实际本地或开放 MCP,工具事件能够证明二者确实被调用。
10. Harness 生成的 3D 游戏项目可以安装、启动和实际游玩,包含 3D 渲染、玩家控制、目标和反馈,浏览器无关键错误。
测试不得打印、快照或提交 API Key。测试创建的文件只能位于专用临时工作区,并在确认可再现后清理。
### 17.4 项目验证
源码完成后必须运行:
```text
npm test
npm run typecheck
npm run lint
npm run build
```
涉及发布资源后,还要按可用原生平台运行聚焦的 `release:package` 验证。无法在当前主机执行的目标必须由六平台 CI 验证。
## 18. 验收标准
功能只有同时满足以下条件才算完成:
- `deepseek-harness` 可被保存、选择、检测和显示。
- Runtime 详情卡内显示状态、路径、版本和沙箱强制程度。
- Skills 与 MCP 设置页可把能力分配给 DeepSeek Harness,布局、键盘语义、文案和保存回显通过真机检查。
- Ask 写入测试在 Runtime 边界失败。
- Execute 工作区内写入成功。
- 越界写入只有同一操作获得自动单次授权后才能执行一次,且不弹出审批。
- 取消、超时、切换 Runtime 和退出应用均能回收进程。
- 多会话不串流、不串权限请求、不串用量。
- 用户 DSH 配置、`.env`、遥测和 Web UI 未被加载。
- API Key 不进入 Renderer、配置文件、日志、错误文本或测试产物。
- 全量测试、类型检查、Lint 和生产构建通过。
- 真实 OpenAI 兼容 Chat Completions 请求成功。
- 真实请求调用已分配 Skill 和 MCP,并生成、启动和实际游玩一个可用的 3D 游戏项目。
- 新增第三方许可证和发布校验完整。
## 19. 已知限制
- DeepSeek Harness 底层库当前是 RC,但 GoodBuddy 不自动跟随升级;每次升级都可能要求同步修改内部控制面。
- Windows ACL 和部分 Linux Landlock 环境只能提供部分强制执行。
- 首版不恢复 Harness 原生 SessionRuntime 重启后由 GoodBuddy 历史重建。
- 首版不支持图片、知识库、浏览器工具和 Harness SubagentMCP 仅支持用户分配、Main 代理和 Execute 自动单次授权路径。
- 推理、工具和用量扩展属于 GoodBuddy 协议,不是标准 ACP 保证。
- 不支持 DSH 插件、市场包、用户 profile 或自定义 Host。
## 20. 自维护与升级策略
GoodBuddy 对该 Runtime 采用内部维护策略:
1. 当前通过验证的 Host、控制协议和依赖锁定随 GoodBuddy 一起版本化。
2. 不自动跟随 DSH RC、插件 ABI、profile 格式或市场元数据变化。
3. 升级前审查实际用户收益、上游 diff、沙箱与工具语义、协议行为、依赖闭包和许可证。
4. 六个平台的单元、假模型、UtilityProcess、沙箱和真实模型门禁全部通过后才能更新基线。
5. 若上游方向不再满足 GoodBuddy 用户需求或安全边界,允许维护兼容补丁、替换单个底层包,或逐步移除 DSH 依赖;`goodbuddy/*` 内部协议保持由 GoodBuddy 控制。
6. 不以进入官方插件目录、适配市场机制或服务非 GoodBuddy 客户端作为目标。
## 21. 备选方案记录
### 21.1 每次调用 `dsh --profile headless`
未采用。它适合一次性任务,但不能满足流式事件、多会话、细粒度取消、权限回传和低延迟复用。
### 21.2 只使用官方 ACP 插件
未采用。取消和一次性权限选择符合需求,但缺少工具、推理、用量和会话释放事件。
### 21.3 只使用官方 SDK JSON-RPC
未采用。事件更完整,但单轮取消和权限回传能力不足。
### 21.4 把全部安全逻辑放进 Harness 子进程
未采用。Harness 子进程属于不可信执行面,不能拥有最终模式授权策略、加密设置和进程回收权限。
### 21.5 把全部控制适配放在 Main
未采用。Main 无法可靠观察 Cordis 内部 Session、Tool、Usage 和权限 seam,只能得到不完整的外部进程行为。
当前选择的双层内部控制面放弃标准 DSH 插件形态,只复用锁定的底层库,并维持 GoodBuddy 的可信 Main 控制权。
@@ -0,0 +1,399 @@
# 自动任务、目标与调度 PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-08-13 |
| 依赖 | [自动化、监督与记忆平台总体设计](./automation-platform-architecture.md) |
## 1. 背景
GoodBuddy 当前的定时任务支持单次、每日和每周触发固定 Ask 提示,并保存任务和成果;
智能心跳支持每日或每周回顾有界的会话、任务和已确认记忆。两者尚不能表达事件触发、
目标、成功标准、预算、停止条件和安全恢复。
## 2. 产品边界
| 类型 | 用户意图 | 是否形成循环 |
| --- | --- | --- |
| 定时任务 | 在指定时间执行已知操作 | 否 |
| 事件任务 | 当明确事件发生时执行已知操作 | 否 |
| 目标任务 | 在预算内持续推进到可验证结果 | 是 |
智能心跳是特殊的定时观察任务。并行实验属于独立产品。
## 3. 已确认的产品决策
1. 自动化定义与每次运行分离,编辑计划不改变已启动 Run。
2. 第一阶段保留现有定时任务的 Ask 限制,Execute 分阶段开放。
3. Execute 自动化不能因无人值守而绕过现有审批、沙箱和工具控制。
4. 应用退出后不承诺继续运行,重启后只进行状态恢复和错过执行结算。
5. 目标任务必须有成功标准,以及预算或人工结束条件。
6. 模型可以提出计划,确定性状态机负责预算、停止、权限和恢复。
7. 同一计划默认最多一个活动 Run。
8. 后台任务可被背压延后,不能挤占用户正在等待的前台请求。
9. 结果未知的外部副作用步骤不自动重试。
10. 项目、知识库、记忆、目录和工具范围在保存和运行页持续可见。
## 4. 目标
- 支持单次、每日、每周、每月、工作日和受限 Cron。
- 支持任务完成、失败、会话完成等内部事件触发。
- 允许用户用自然语言生成结构化草稿,再检查后启用。
- 为目标任务建立有界的“观察、计划、行动、评估”循环。
- 提供幂等、租约、错过执行、取消、重试、恢复、预算和审计。
- 为后续并行实验和持续学习复用协议、指标和运行基础。
## 5. 非目标
- 第一阶段不提供任意节点、脚本和循环的通用 DAG 编辑器。
- 不允许模型编写并执行任意 Shell、SQL 或无限频率 Cron。
- 不支持应用退出后通过未安装的系统服务继续运行。
- 不把“模型说完成了”作为唯一成功标准。
- 不允许自动任务静默修改自身权限、触发器或预算。
- 不在目标循环中无限创建子任务或专家。
## 6. 创建与启用
用户可以先输入自然语言意图:
```text
每周五下午 5 点总结本项目本周完成和失败的任务,
列出下周三个优先事项,不要修改文件。
```
模型只生成草稿:
- 名称、说明和自动化类型。
- 触发器。
- 目标、输出和成功标准建议。
- 工作模式和 Runtime 建议。
- 数据范围。
- 预算、停止条件和通知。
草稿不能自动启用。用户必须检查结构化配置。
### 6.1 所有计划必填
- 名称、范围和类型。
- 触发器。
- 工作模式和 Runtime。
- 输入、输出和通知。
- 预算和数据保留。
- 知识库、记忆、目录和工具范围。
### 6.2 目标任务额外必填
- 目标描述。
- 至少一个成功标准。
- 约束。
- 最大轮数或截止时间。
- 每轮评估方式。
- 无进展处理。
### 6.3 启用前检查
- 时区和下一次运行时间可解析。
- 项目、目录、Runtime 和模型可用。
- Ask 没有写入或外部副作用要求。
- Execute 的工具和审批范围明确。
- 预算不是无界值。
- 事件来源存在且已启用。
- 目标任务存在停止条件。
## 7. 触发器
### 7.1 时间触发
```ts
type TimeTrigger =
| { type: 'once'; at: string; timezone: string }
| { type: 'daily'; localTime: string; timezone: string }
| {
type: 'weekly'
weekdays: number[]
localTime: string
timezone: string
}
| {
type: 'monthly'
day: number | 'last'
localTime: string
timezone: string
}
| {
type: 'cron'
expression: string
timezone: string
}
```
受限 Cron 只允许五字段,不支持秒、年份、宏、`L``W``#` 或供应商扩展。
Main 负责解析并展示未来五次运行时间,默认最小间隔为 15 分钟。
### 7.2 事件触发
第二阶段支持:
- `conversation.completed`
- `task.completed`
- `task.failed`
- `artifact.created`
- `knowledge.sync.completed`
- `magic_note.updated`
事件触发必须配置来源范围、确定性过滤、去重窗口、冷却时间和并发上限。
基础匹配不调用模型。
### 7.3 手动触发
- “立即运行”创建独立 Run,不改变下次计划时间。
- 多次点击使用调用级幂等键去重。
- 未保存的变更需先保存为新版本,或明确使用当前已发布版本。
### 7.4 错过执行
| 策略 | 行为 |
| --- | --- |
| `skip` | 记录跳过,不补跑 |
| `run_once` | 无论错过多少次,只补一次 |
| `catch_up_bounded` | 在数量和时间窗口上限内补跑 |
有界补跑默认最多 3 次、最多回溯 7 天。补跑同样受并发和预算控制。
### 7.5 时区和夏令时
- 保存 IANA 时区,不保存固定 UTC 偏移。
- 春季不存在的本地时间在当日第一个有效分钟运行。
- 秋季重复时间只运行一次。
- 系统时区变化不自动修改计划时区。
- UI 显示计划时区与本机时区差异。
## 8. 目标任务
### 8.1 目标模型
```ts
type AutomationObjective = {
statement: string
successCriteria: SuccessCriterion[]
constraints: Constraint[]
deadline?: string
}
type SuccessCriterion =
| { type: 'artifact_exists'; kind: string; minimumCount: number }
| { type: 'task_state'; taskId: string; expected: 'completed' }
| {
type: 'metric_threshold'
metric: string
operator: string
value: number
}
| { type: 'checklist'; items: string[] }
| { type: 'human_review' }
| {
type: 'model_rubric'
rubricId: string
minimumScore: number
}
```
模型 Rubric 不能是唯一标准,除非任务本质是开放内容评价且 UI 明确标注。
### 8.2 有界循环
```text
Observe
→ Plan next action
→ Check permissions and budget
→ Act or request approval
→ Evaluate progress
→ Complete, pause, revise or continue
```
每轮持久化观察摘要、下一步、实际任务或工具、成果、指标、预算、进展状态和
Supervisor 决策。只保存专门生成的结构化理由摘要,不保存隐藏推理。
### 8.3 无进展检测
出现任一情况进入 `attention_required`
- 连续两轮没有指标改善或新成果。
- 重复提出相同下一步。
- 连续失败达到上限。
- 需要的输入或权限不可用。
- 剩余预算不足。
- Supervisor 判定目标或前提需要澄清。
默认暂停并请求用户选择,不自动扩大范围。
### 8.4 计划修订
目标任务可以建议修改步骤、缩小目标、请求输入、增加预算或改变 Runtime。
修改范围、预算、Runtime、工作模式或权限必须用户确认,并形成新版本或 Run 修订记录。
## 9. 工作模式与审批
### 9.1 Ask
- 默认只读。
- 只使用明确开放的只读数据工具。
- 不写文件、不执行命令、不发送消息、不修改远程数据。
- 输出进入成果和通知。
### 9.2 Execute
按以下顺序开放:
1. 有人值守,沿用逐工具审批。
2. 预批准低风险工具和参数范围。
3. 经过专项验证的内置无人值守模板。
即使预批准,也不能扩大目录和能力。高风险或越界动作进入 `waiting_approval`
密码输入、支付、授权、删除、公开发布和生产变更不能预批准。
## 10. 预算与背压
```ts
type AutomationBudget = {
maximumDurationMs: number
maximumIterations: number
maximumModelCalls: number
maximumInputTokens?: number
maximumOutputTokens?: number
maximumToolCalls: number
maximumChildTasks: number
maximumArtifactBytes: number
maximumConcurrentChildren: number
}
```
建议默认值:
| 类型 | 最长时间 | 模型调用 | 子任务并发 |
| --- | --- | --- | --- |
| 定时 Ask | 5 分钟 | 4 | 1 |
| 心跳回顾 | 5 分钟 | 2 | 0 |
| 目标 Ask | 30 分钟 | 12 | 2 |
| 目标 Execute | 30 分钟 | 12 | 1 |
前台请求优先。后台使用独立并发池,达到上限时排队。高负载时低优先级心跳和维护任务
记录为 `deferred`,压力解除后有界恢复,不能一次性释放全部积压。
## 11. 重试、恢复与取消
| 失败类型 | 行为 |
| --- | --- |
| 瞬时网络或限流 | 指数退避,有界重试 |
| 模型格式错误 | 最多一次结构化修复 |
| 配置或权限错误 | 不重试,等待修复 |
| 无副作用的确定性工具失败 | 按工具策略重试 |
| 结果未知或已有外部副作用 | 不自动重试 |
应用退出时停止声明新 Run,取消可取消工作,活动 Run 标记为 `interrupted` 并保存安全
检查点。重启后用户可恢复、复制剩余步骤或放弃;结果未知步骤必须先人工核实。
暂停 Plan 只阻止新 Run,不终止当前 Run。取消 Run 必须传播到子任务和 Runtime,
但不能把已发生的外部副作用假装撤销。
## 12. 输出与通知
输出可保存为文字或文件成果、创建后续任务建议,或仅通知。后续可支持更新指定魔法笔记。
通知事件:
- Run 完成或失败。
- 等待审批。
- Supervisor 要求关注。
- 目标达成。
- 预算达到 80%。
- 连续无进展。
同一事件不同时显示重复页内横幅和全局通知。
## 13. 信息架构
计划列表显示名称、类型、范围、启用状态、下次运行、最近 Run、目标状态和需要关注数量。
计划详情页签:
- 概览。
- 目标与协议。
- 触发器。
- 权限与预算。
- 运行历史。
Run 详情展示总览、时间线、任务、审批、监督、指标、证据、成果以及实际读取的知识和记忆。
## 14. 数据模型建议
```ts
type AutomationPlan = {
id: string
projectId?: string
kind: 'scheduled_task' | 'heartbeat_review' | 'goal_loop'
name: string
description: string
status: 'draft' | 'active' | 'paused' | 'archived'
currentVersion: number
nextRunAt?: string
createdAt: string
updatedAt: string
}
type AutomationPlanVersion = {
planId: string
version: number
trigger: TriggerPolicy
objective?: AutomationObjective
protocol: ExecutionProtocol
budget: AutomationBudget
approvalPolicy: ApprovalPolicy
supervisorPolicy?: SupervisorPolicy
memoryBinding: MemoryBinding
}
```
状态、范围、下次运行、版本和索引字段使用显式列;版本化协议可以使用经过共享 Schema
验证的 JSON。
## 15. 安全要求
1. 所有输入由共享 Zod Schema 验证。
2. Main 重新验证项目、目录、Runtime、工具、知识库和记忆分区归属。
3. Renderer 不可直接声明 Run 完成或批准工具。
4. 自动化提示、事件、记忆和成果都视为不可信数据。
5. 事件过滤不执行用户 JavaScript、SQL 或无限复杂表达式。
6. Cron 有复杂度和最小间隔限制。
7. 自动化不能读取未绑定知识库、桌面上下文或其他项目记忆。
8. 日志和通知对私人内容、密钥和工具输出有界脱敏。
## 16. 实施顺序
1. 统一现有 Schedule 和 Heartbeat 的 Run 视图。
2. 增加幂等、租约、月度、工作日、受限 Cron、错过执行和未来运行预览。
3. 建立内部持久事件、过滤、冷却和去重,首期只支持 Ask。
4. 上线目标 Ask、有界循环、无进展检测和人工暂停。
5. 接入会话监督。
6. 再开放有人值守和预批准低风险 Execute。
## 17. 验收标准
- [ ] 支持单次、每日、每周、每月、工作日和受限 Cron。
- [ ] UI 显示计划时区和未来五次运行时间。
- [ ] 夏令时不会造成计划漂移或双跑。
- [ ] 同一计划同一时间点只产生一个 Run。
- [ ] 错过执行按配置跳过、补一次或有界补跑。
- [ ] 手动运行不改变下次计划时间。
- [ ] Ask 自动化在 Runtime 边界拒绝写工具和外部副作用。
- [ ] 目标任务必须有成功标准和停止条件。
- [ ] 每轮都有观察、行动、评估和预算记录。
- [ ] 连续无进展会暂停,不无限循环。
- [ ] 达到预算使用 `budget_exceeded`,不伪装为成功。
- [ ] 设置变化不影响已启动 Run。
- [ ] 重启后不自动重放结果未知的副作用步骤。
- [ ] 后台任务排队时不挤占前台模型请求。
@@ -0,0 +1,528 @@
# 自动化、监督与记忆平台总体设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-08-13 |
| 适用产品 | GoodBuddy 桌面端 |
| 文档角色 | 自动任务、目标、并行实验、会话监督、分区记忆与持续学习的总纲 |
## 1. 背景
GoodBuddy 当前已经具备若干长期助手能力,但它们仍是彼此分离的功能:
1. 定时任务支持单次、每日和每周触发,创建 Ask 任务并保存任务和成果。
2. 智能心跳支持全局或项目范围的每日、每周回顾,读取有界会话、任务和已确认记忆,
生成摘要、记忆建议和后续任务。
3. 专家子任务支持有限并发和只读综合,但没有实验变量、重复运行、统一指标和结果晋升。
4. 记忆已有全局、项目、会话三种作用域,以及偏好、事实、摘要、流程四种类型,
但检索、来源、时态、冲突和运行级隔离仍不完整。
5. 魔法笔记已经提供“内容旁持续出现 AI 评论”的交互,可作为会话监督的体验参考,
但它只分析笔记或待办,不观察会话和任务运行。
如果继续把更多能力加入“智能心跳”,心跳将同时承担调度、总结、执行、监督、学习和
记忆管理,最终无法解释一次后台行为为什么发生、读取了什么、是否越权、产生了什么影响。
本设计将这些能力统一到一个平台模型中,同时保留不同产品的清晰边界。
## 2. 核心产品判断
### 2.1 不把心跳升级成万能后台 Agent
智能心跳应继续承担周期性观察和回顾,不直接成为所有自动化的宿主。
- 定时任务解决“何时执行一个已知任务”。
- 目标任务解决“围绕结果持续规划和推进”。
- 并行实验解决“隔离多个候选并用相同标准比较”。
- 会话监督解决“独立观察并在必要时评论、告警或暂停”。
- 记忆系统解决“哪些经验可以在什么范围内被未来运行读取”。
- 持续学习解决“候选经验如何经过评估后改变未来行为”。
这些能力可以共享调度、运行、证据、预算和审计基础,但不能共享一段不断膨胀的提示词。
### 2.2 增加会话监督,但不把它等同于第二个聊天 Agent
建议新增会话监督功能,并借鉴魔法笔记的右侧 AI 评论流:
- 默认只观察和评论,不替用户发言。
- 只依据可见消息、工具事件、任务状态、成果和目标进行判断。
- 不读取或展示模型隐藏推理。
- 评论必须引用具体消息、步骤或证据。
- 模型监督可以建议暂停,只有确定性安全规则或用户预先批准的门禁才能自动暂停。
- 监督器不能自动批准工具、扩大目录、跨项目读取记忆或修改安全策略。
### 2.3 先做分区和来源,再做知识图谱
GoodBuddy 当前最需要的不是立即引入重型图数据库,而是保证:
1. 运行只能读取明确允许的记忆分区。
2. 并行实验的各个 Run 不共享可变记忆。
3. 每条记忆知道来自哪次会话、任务、监督判断或实验结果。
4. 新事实与旧事实冲突时保留时态和证据,不静默覆盖。
5. 记忆进入模型上下文前经过范围、状态、敏感度和预算过滤。
SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现明确的关系追踪和跨实体查询
需求后,才考虑时间知识图谱。
### 2.4 学习必须有评估门和回滚
“生成一条总结并保存”不等于持续学习。只有当候选经验通过回放或实验验证,并能安全改变
未来行为时,才构成学习闭环。
初期自动学习只允许产生可审查候选,不允许自动修改:
- 工具权限和审批策略。
- Electron 安全边界。
- 项目根目录和数据访问范围。
- Runtime 沙箱。
- 系统级提示词。
- 远程消息发送或其他外部副作用策略。
## 3. 目标
### 3.1 用户目标
- 用统一入口创建定时、事件、目标和实验型自动任务。
- 清楚知道自动任务的触发原因、当前目标、运行状态、预算和停止条件。
- 在一个工作台中观察多个候选运行,并追溯结论到原始证据。
- 为重要会话启用独立监督,及时发现偏题、遗漏、矛盾、证据不足和风险。
- 知道每条记忆属于哪个范围、从哪里产生、何时有效以及被哪些运行使用。
- 审查、批准、拒绝或回滚系统提出的记忆、模板和策略改进。
### 3.2 产品目标
- 复用现有 Project、Conversation、Task、Run、Artifact、Approval 和 Notification 能力。
- 为所有后台工作提供统一的幂等、租约、恢复、取消、预算和审计语义。
- 保持 Ask 只读,Execute 继续经过现有能力和审批控制。
- 保持本地优先,应用退出后不虚假承诺后台持续执行。
- 保证项目、会话、自动化和实验 Run 之间的记忆隔离。
- 先建立可观测和可评估能力,再允许任何形式的自动行为改变。
## 4. 非目标
本组设计不包含:
- 将 GoodBuddy 变为需要常驻服务器、Redis 或云端控制面的多租户平台。
- 在应用退出后依靠未安装的系统服务继续运行任务。
- 默认允许无人值守高风险 Execute。
- 让模型自行扩大工具、目录、知识库、记忆或网络访问范围。
- 允许多个实验 Run 并发修改同一个用户工作区。
- 记录键盘、持续录屏或静默监控其他应用。
- 把隐藏推理链作为监督、记忆或审计数据保存。
- 初期直接建设通用可视化工作流 DAG 编辑器。
- 将模型评分当作没有误差的客观真值。
## 5. 统一领域模型
### 5.1 核心实体
```text
AutomationPlan
├─ TriggerPolicy
├─ ObjectiveSet
├─ ExecutionProtocol
├─ BudgetPolicy
├─ ApprovalPolicy
├─ SupervisorPolicy
└─ MemoryBinding
└─ AutomationRun
├─ Task / Child Task
├─ Observation
├─ SupervisorDecision
├─ Artifact
├─ Metric
└─ MemoryCandidate
```
| 实体 | 职责 |
| --- | --- |
| `AutomationPlan` | 用户可编辑的长期定义,描述做什么、为何做、何时做和允许做什么 |
| `TriggerPolicy` | 手动、时间、事件或条件触发,以及错过执行策略 |
| `ObjectiveSet` | 成功标准、优化指标、约束和停止条件 |
| `ExecutionProtocol` | 本次运行冻结的提示、步骤模板、变量、Runtime、工具和数据范围 |
| `BudgetPolicy` | 最大耗时、模型调用、Token、工具次数、子任务数、成果大小和并发 |
| `ApprovalPolicy` | 哪些动作可自动执行、哪些等待批准、哪些禁止 |
| `SupervisorPolicy` | 观察维度、触发频率、干预级别和确定性门禁 |
| `MemoryBinding` | 运行可读取和可写入哪些记忆分区 |
| `AutomationRun` | 一次触发产生的不可变运行快照和聚合状态 |
| `Observation` | 对消息、步骤、工具、指标或系统状态的结构化观察 |
| `SupervisorDecision` | `continue``comment``warn``request_review``pause``stop` |
| `Metric` | 可复现的运行指标及其计算来源 |
| `MemoryCandidate` | 尚未进入未来上下文的候选经验 |
### 5.2 自动化类型
`AutomationPlan.kind` 第一阶段使用有限枚举,而不是任意工作流:
| 类型 | 说明 |
| --- | --- |
| `scheduled_task` | 到点运行一个固定任务 |
| `heartbeat_review` | 周期性观察会话、任务和记忆,输出回顾和建议 |
| `goal_loop` | 围绕目标重复执行“观察、计划、行动、评估” |
| `experiment` | 生成隔离候选 Run,按统一协议评估和比较 |
会话监督不是独立执行任务。它是可附着到 Conversation、Task、AutomationRun 或
Experiment 的 `SupervisorPolicy` 和监督会话。
### 5.3 运行快照
每次启动必须冻结:
- Plan 版本。
- 项目和工作目录。
- Runtime 和模型配置引用。
- 工作模式。
- 提示和变量。
- 工具、Skills、MCP 和知识库范围。
- 可读、可写记忆分区。
- 监督策略和评估器版本。
- 预算和并发限制。
- 审批策略。
运行开始后的设置变化只影响下一次 Run。用户可以查看当前 Run 与最新 Plan 的差异。
## 6. 统一状态模型
### 6.1 Plan 状态
```text
draft → active ↔ paused → archived
```
- `draft`:未通过配置校验,不能自动触发。
- `active`:可以被触发。
- `paused`:保留定义和历史,不产生新 Run。
- `archived`:只读保留,不能恢复运行,复制后可继续使用。
### 6.2 Run 状态
```text
queued
→ running
→ waiting_approval
→ paused
→ evaluating
→ completed
任意活动状态
→ failed | cancelled | interrupted | budget_exceeded | superseded
```
规则:
- `completed` 只表示协议成功结束,不自动表示目标达成。
- `goalStatus` 独立为 `met``not_met``inconclusive``not_applicable`
- 应用退出时活动 Run 标记为 `interrupted`,不自动重放有副作用步骤。
- `waiting_approval` 不占用 LLM 并发配额。
- 预算耗尽必须使用 `budget_exceeded`,不能伪装为普通失败。
### 6.3 Supervisor 状态
```text
inactive → observing → attention_required → paused → resolved
```
监督状态不覆盖 Run 状态。Run 可以仍在运行但存在 `attention_required`,也可以因确定性门禁
进入 `paused`
## 7. 统一运行循环
### 7.1 调度与执行分离
```text
Trigger
→ AutomationCoordinator 声明 Run
→ RunQueue 按优先级和预算排队
→ AutomationExecutor 创建 Task
→ Runtime 执行
→ Supervisor 观察
→ Evaluator 计算指标
→ 结果、证据和候选记忆入库
→ 用户审查或后续 Run
```
`AutomationCoordinator` 只负责触发、声明和恢复,不直接调用模型。执行仍通过任务和 Runtime
边界完成。
### 7.2 优先级
默认优先级从高到低:
1. 用户正在等待的前台对话。
2. 用户手动启动的 Run。
3. 等待批准后恢复的 Run。
4. 到期定时任务。
5. 目标循环和实验 Run。
6. 心跳回顾、记忆巩固和维护。
后台任务必须可被背压延后。延后记录为 `deferred`,不得丢失,也不得在系统恢复空闲时一次性
释放全部积压。
### 7.3 幂等和租约
- 每次计划触发使用 `planId + scheduledFor + planVersion` 形成幂等键。
- 手动触发使用调用方提供的单次幂等键。
- Run 和长步骤使用租约,租约过期后才能恢复或重试。
- 有外部副作用的步骤还需要工具级幂等键,无法确认结果时进入
`outcome_unknown`,不得自动重试。
- 同一个 Plan 可以限制最大活动 Run 数,默认 1。
## 8. 触发模型
### 8.1 支持顺序
| 阶段 | 触发类型 |
| --- | --- |
| 第一阶段 | 手动、单次、每日、每周、每月、受限 Cron |
| 第二阶段 | 应用启动、会话完成、任务完成或失败、文件同步完成、变量变化 |
| 后续 | 用户定义的组合条件和外部受信任事件 |
事件触发必须来自 Main 进程内的持久事件,不允许 Renderer 临时事件直接启动高影响自动化。
### 8.2 错过执行策略
| 策略 | 行为 |
| --- | --- |
| `skip` | 记录跳过,不补跑 |
| `run_once` | 无论错过多少次,只补一次 |
| `catch_up_bounded` | 在数量和时间窗口上限内补跑 |
默认:
- 日常摘要使用 `run_once`
- 高频事件使用 `skip` 或事件去重。
- 不允许无限补跑。
## 9. 目标、协议和实验的关系
```text
目标:想得到什么结果
协议:用什么固定方法尝试
运行:协议的一次执行
实验:同一问题下多个隔离协议或变量组合的运行集合
监督:运行过程中独立判断是否偏离目标、违反约束或需要人工介入
记忆:运行可读的历史经验,以及运行结束后提出的候选经验
```
关键规则:
- 没有可计算或可审查成功标准的目标,不允许宣称“已完成目标”。
- 实验的最佳结果只在成功 Run 中选择。
- 全部 Run 失败时,实验状态为失败,不生成伪最佳结果。
- 模型生成的实验协议必须先由用户审查,或在只读、低成本模板中明确启用自动接受。
- 实验结果不能直接修改生产自动化,只能创建候选版本。
## 10. 监督边界
监督分为两层:
### 10.1 确定性监督
由代码执行,适合:
- 权限、目录和工具白名单。
- Token、耗时、并发和输出大小预算。
- JSON Schema、状态机和幂等约束。
- 明确的停止条件和指标阈值。
- 数据分区和跨范围访问。
确定性监督可以阻止、暂停或终止运行。
### 10.2 模型监督
适合:
- 目标偏移。
- 计划遗漏。
- 结论与证据不一致。
- 多个候选之间的定性差异。
- 用户可能需要澄清的歧义。
- 质量、表达和风险评论。
模型监督默认只评论或请求关注。它不能替代确定性安全边界,也不能自动批准高风险动作。
## 11. 记忆边界
### 11.1 计划读取链
运行只读取显式绑定的分区。推荐优先级:
```text
当前 Run
→ 当前 Automation
→ 当前 Conversation(如有关联)
→ 当前 Project
→ Global
```
每一层都有独立结果数和字符预算。低层记忆不能通过同名内容自动覆盖高层记忆,
冲突必须被标记并交给上下文组装器处理。
### 11.2 写入规则
- Run 只能直接写入自己的运行分区和候选区。
- 向 Automation、Project 或 Global 晋升需要评估或用户确认。
- 实验 Run 不能直接互相读取运行记忆。
- Supervisor 的判断保存为观察或候选,不自动变成事实。
- 被拒绝的候选保留摘要指纹,避免重复建议,同时不进入模型上下文。
## 12. 信息架构
建议将现有“智能心跳”逐步扩展为“自动化中心”,但保留心跳作为一种计划:
```text
自动化中心
├─ 概览
│ ├─ 正在运行
│ ├─ 等待审批
│ ├─ 需要关注
│ └─ 最近结果
├─ 计划
│ ├─ 定时任务
│ ├─ 智能心跳
│ ├─ 目标任务
│ └─ 实验
├─ 运行
│ ├─ 时间线
│ ├─ 任务与步骤
│ ├─ 监督记录
│ ├─ 指标与证据
│ └─ 成果
├─ 建议
│ ├─ 记忆候选
│ ├─ 后续任务
│ └─ 学习候选
└─ 设置
├─ 全局预算
├─ 后台优先级
├─ 通知
└─ 数据保留
```
会话页面增加可折叠“监督”右栏,与任务、上下文和成果并列,或在已有右侧工作栏中新增页签。
## 13. 安全与隐私
1. Ask 在 Runtime 边界保持只读,而不只是提示词要求只读。
2. Execute 继续通过现有审批、沙箱、工具和目录控制。
3. 无人值守只允许用户显式批准的能力集合;遇到未预授权动作时进入等待审批。
4. Supervisor、Evaluator 和 Heartbeat 都把消息、工具输出、记忆和成果视为不可信数据。
5. 监督器不能读取隐藏推理,只能读取产品允许持久化和展示的事件。
6. 所有跨分区读取由 Main 根据绑定关系决定,Renderer 不能提交任意分区 ID。
7. 记忆和监督证据不得包含密钥、认证头、Cookie、完整私有文件或未经限制的工具输出。
8. 自动化产生的通知默认隐藏私人内容。
9. 应用退出时停止调度和新执行,持久化中断状态,释放 Runtime 和租约。
10. 清除项目时按外键和显式事务清理其计划、运行、运行分区、监督记录和候选,
不影响 Global 或其他项目。
## 14. 可观测性
每个 Run 至少展示:
- 触发来源和计划版本。
- 计划目标和当前 `goalStatus`
- Runtime、工作模式和工作目录。
- 实际读取的知识库与记忆分区。
- 实际调用的模型、Token、工具、耗时和成果大小。
- 当前预算和剩余预算。
- 任务、步骤和子任务状态。
- Supervisor 评论、证据、严重度和处理结果。
- 评估器版本、指标和证据。
- 产生的候选记忆或学习产物。
- 重试、延后、中断和恢复原因。
不得只显示一个模糊的“自动化成功率”而隐藏失败 Run、跳过 Run 或无结论 Run。
## 15. 建议的数据模型增量
以下为设计建议,字段在实现前仍需共享 Zod Schema 和 SQLite 迁移细化:
```text
automation_plans
automation_plan_versions
automation_triggers
automation_runs
automation_run_events
automation_metrics
automation_observations
supervisor_sessions
supervisor_decisions
memory_namespaces
memory_candidates
learning_artifacts
evaluation_cases
evaluation_results
experiments
experiment_variants
experiment_runs
```
现有 `schedules``schedule_runs``heartbeat_configs``heartbeat_runs`
`heartbeat_entries``tasks``runs` 不应一次性重写。迁移顺序应先增加统一只读视图和
关联字段,再逐步让新计划使用统一模型。
## 16. 分阶段实施
### 阶段 0:统一术语和可观测性
- 固定 Plan、Run、Goal、Protocol、Supervisor、Observation、Memory Candidate 等概念。
- 为现有定时任务、心跳和专家子任务建立统一活动视图。
- 补充触发来源、运行版本、预算和读写范围展示。
### 阶段 1:调度与运行基础
- 统一 Run 声明、幂等、租约、恢复和错过执行策略。
- 增加月度和受限 Cron。
- 增加后台优先级与并发预算。
- 保持现有任务执行器不变。
### 阶段 2:会话监督与分区记忆
- 上线评论型会话监督。
- 增加 Automation 和 Run 记忆分区。
- 建立来源、证据、时态、冲突和晋升流程。
### 阶段 3:目标任务
- 增加目标、成功标准、约束、预算和停止条件。
- 支持有界的观察、计划、行动、评估循环。
- 默认 Ask 或需要逐步审批的 Execute。
### 阶段 4:并行实验
- 变量和运行隔离。
- 候选、重复、指标、证据、失败结算和最佳结果选择。
- 复用现有任务和受限子专家并发。
### 阶段 5:持续学习
- 先建立回放集和评估门。
- 再增加候选、Shadow、晋升、监控、衰减和回滚。
- 初期只晋升记忆和自动化模板,不自动改变安全策略。
## 17. 相关文档
- [自动任务、目标与调度 PRD](./automation-goals-and-scheduling-prd.md)
- [并行实验工作台 PRD](./parallel-experiments-prd.md)
- [会话监督 PRD](./conversation-supervision-prd.md)
- [分区记忆 PRD](./partitioned-memory-prd.md)
- [持续学习与评估门 PRD](./continuous-learning-prd.md)
- [GoodBuddy 长期助手功能规划](../long-term-assistant-roadmap.md)
- [GoodBuddy 统一界面设计系统](../../UI-DESIGN.md)
## 18. 总体验收标准
- [ ] 心跳、定时、目标和实验使用统一的 Plan 与 Run 术语。
- [ ] 每个自动 Run 都能解释触发原因、目标、范围、预算、状态和结果。
- [ ] Ask 自动化无法调用写工具或产生外部副作用。
- [ ] Execute 自动化不能绕过现有审批、沙箱和能力控制。
- [ ] 会话监督默认只评论,不能替用户发言或批准工具。
- [ ] 并行 Run 的变量、会话、运行记忆、任务和成果相互隔离。
- [ ] 失败 Run 不参与最佳结果选择,全部失败不报告成功。
- [ ] 记忆跨分区读取必须显式授权并可审计。
- [ ] 候选经验在评估门和回滚能力完成前不能自动改变未来行为。
- [ ] 应用重启后状态可恢复,但不会自动重放结果未知的副作用步骤。
+408
View File
@@ -0,0 +1,408 @@
# 持续学习与评估门 PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中,远期能力 |
| 版本 | 0.1 |
| 日期 | 2026-08-13 |
| 依赖 | [自动化平台总体设计](./automation-platform-architecture.md)、[并行实验 PRD](./parallel-experiments-prd.md)、[分区记忆 PRD](./partitioned-memory-prd.md) |
## 1. 背景
智能心跳已经可以生成摘要、后续任务和记忆候选,但这还不是完整学习:
- 候选是否改善未来行为没有评估。
- 一条反思是否会被检索和使用并不确定。
- 没有 Baseline、回放集、Shadow、晋升和回滚。
- 没有持续监控候选生效后的收益与退化。
- 如果允许系统直接修改 Prompt、Skill 或规则,可能发生静默劣化。
持续学习必须建立为可观察、可评估、可批准、可回滚的闭环,而不是“让模型自动改自己”。
## 2. 产品定义
```text
Observe
→ Propose candidate
→ Validate structure and safety
→ Evaluate against baseline
→ Shadow
→ Promote with approval
→ Monitor
→ Keep, revise, rollback or archive
```
学习产物只有在改变未来行为后才算生效;只保存一条 Reflection 仍属于记忆候选。
## 3. 已确认的产品决策
1. 评估门必须先于任何自动应用能力上线。
2. 新候选默认 `candidate`,通过离线评估后先进入 `shadow`
3. 第一阶段只允许人工晋升。
4. 每次晋升必须记录 Baseline、候选、评估结果、作用域和回滚版本。
5. 学习不能修改安全边界、工具审批、目录权限、沙箱或 Electron 配置。
6. 失败案例和用户负反馈只作为评估数据,不直接成为新规则。
7. 回放案例必须脱敏、版本化,并得到用户明确选择或来自仓库公开样例。
8. 模型评估不是唯一真值,优先使用确定性验收和人工反馈。
9. 生效后的候选继续监控,发生退化可自动停用,但不能自动换上另一个候选。
10. 没有足够证据时保持 `inconclusive`,不强行晋升。
## 4. 学习产物
首期只支持:
| 产物 | 作用 | 是否可自动应用 |
| --- | --- | --- |
| Memory | 改善相关上下文召回 | 否,人工确认 |
| Automation Template | 改善目标、步骤、提示或预算默认值 | 否,创建新草稿 |
| Prompt Variant | 用于实验比较 | 否 |
| Rubric | 改善评估标准 | 否 |
| Retrieval Preference | 调整特定 Automation 的检索配置候选 | 否 |
后续评估:
| 产物 | 风险 |
| --- | --- |
| Skill | 可能扩大行为和工具使用 |
| Procedure | 可能长期影响多个任务 |
| Non-security Rule | 可能阻断或改变行为 |
| Agent Preference | 可能产生难以解释的个性漂移 |
永久禁止自动学习修改:
- 工具权限和审批策略。
- 工作区根目录和文件访问范围。
- 网络、远程消息和电脑控制权限。
- Electron 安全设置。
- API Key、凭据和 Provider Endpoint。
- 删除、支付、发布和生产操作政策。
## 5. 候选来源
- 用户对回答、任务或 Supervisor 意见的显式反馈。
- 智能心跳提出的重复模式。
- 自动化 Run 的成功与失败比较。
- 并行实验结论。
- 回放评估发现的稳定差异。
- 用户手动创建。
候选必须包含:
- 作用域。
- 产物类型。
- 来源证据。
- 预期改善的指标。
- 可能影响的行为。
- 风险级别。
- Baseline 引用。
- 建议的评估集。
模型不能仅凭一条成功案例宣称“已学习”。
## 6. 状态机
```text
candidate
→ evaluating
→ rejected
→ inconclusive
→ shadow
→ awaiting_approval
→ promoted
→ paused
→ rolled_back
→ archived
```
| 状态 | 含义 |
| --- | --- |
| `candidate` | 尚未评估 |
| `evaluating` | 正在运行离线评估 |
| `rejected` | 明确退化、安全不合格或无效 |
| `inconclusive` | 证据不足 |
| `shadow` | 计算候选决策但不影响真实行为 |
| `awaiting_approval` | 达到晋升标准,等待用户 |
| `promoted` | 已作为指定作用域的当前版本 |
| `paused` | 暂停影响,保留版本 |
| `rolled_back` | 已恢复前一版本 |
| `archived` | 不再评估和使用 |
## 7. 评估案例
### 7.1 案例来源
优先级:
1. 仓库内公开、无隐私的固定评测样例。
2. 用户手动创建的案例和期望。
3. 用户明确选择并脱敏的历史会话或任务。
4. 实验中产生、经用户批准保留的案例。
禁止默认采样所有私人会话用于学习。
### 7.2 案例结构
```ts
type EvaluationCase = {
id: string
suiteId: string
input: EvaluationInput
assertions: EvaluationAssertion[]
forbiddenBehaviors: EvaluationAssertion[]
source: EvaluationCaseSource
sensitivity: 'public' | 'private_local'
version: number
}
```
断言可以是:
- 输出符合 Schema。
- 包含或不包含确定文本模式。
- 引用来自允许知识库。
- 不调用工具。
- 任务状态和成果存在。
- 测试命令通过。
- 人工评分。
- 模型 Rubric 分项。
### 7.3 冻结
一次评估冻结:
- 案例版本。
- Baseline 版本。
- Candidate 版本。
- Runtime 和模型。
- 知识、记忆和工作区快照。
- 预算。
- 评估器版本。
设置变化不改变已开始的评估。
## 8. 评估门
### 8.1 判定
```ts
type GateVerdict = {
decision: 'reject' | 'inconclusive' | 'shadow'
baselineMetrics: MetricValue[]
candidateMetrics: MetricValue[]
regressions: Regression[]
caseIds: string[]
evaluatorVersions: string[]
notes: string
}
```
最小规则:
1. 任何安全、权限或硬约束退化立即 Reject。
2. 确定性质量指标不能低于配置阈值。
3. 成本和延迟退化必须在允许范围。
4. 开放质量指标至少非退化,或收益足以覆盖明确成本。
5. 案例数或评估器不足时 Inconclusive。
6. 通过离线门只进入 Shadow,不直接 Promote。
### 8.2 Baseline
Baseline 是当前已生效版本或明确的无候选行为。不能用另一个同时变化的实验配置充当 Baseline。
### 8.3 多模型评估
模型 Rubric 可使用与被评候选不同的模型,但必须:
- 固定版本和提示。
- 隐藏候选身份。
- 随机化顺序。
- 保存分项和证据。
- 在关键晋升中结合确定性或人工评估。
## 9. Shadow
Shadow 模式:
- 接收与当前真实行为相同的有界输入。
- 计算候选会做出的选择或输出。
- 不调用有副作用工具。
- 不替换用户看到的结果。
- 不写入长期记忆。
- 保存与实际结果可比较的指标。
对于成本较高的候选:
- 只对抽样的已授权案例运行。
- 用户可设置月度调用上限。
- 系统繁忙时延后。
Shadow 达到配置的最小观察数且无安全退化后进入 `awaiting_approval`
## 10. 晋升
晋升对话框必须显示:
- 候选将改变什么。
- 作用域和受影响计划。
- 来源。
- Baseline 与 Candidate 指标。
- 失败案例和不确定性。
- 额外成本。
- 回滚版本。
用户可以:
- 晋升。
- 继续 Shadow。
- 拒绝。
- 缩小作用域后重新评估。
晋升采用原子版本切换。不能在一半对象上成功、一半失败。
## 11. 上线后监控
监控:
- 使用次数。
- 成功、失败和无结论。
- 确定性指标。
- 用户采纳、撤销和负反馈。
- Token、耗时和工具调用变化。
- Supervisor 警告变化。
自动暂停条件:
- 安全或权限硬约束失败。
- 确定性错误率超过阈值。
- 连续崩溃或格式失败。
- 成本超过批准上限。
自动暂停只恢复到上一已批准版本,并通知用户。系统不能自行选择新候选替代。
## 12. 回滚
- 每个 Promoted 产物有不可变版本。
- 保存前一版本和作用域绑定。
- 一键回滚使用原子切换。
- 回滚不删除失败版本,保留指标和原因。
- 当前有运行使用该版本时,只影响下一次 Run;紧急安全暂停可取消尚未开始的 Run。
- 被回滚候选再次晋升必须重新评估。
## 13. 衰减与归档
- 长期未使用的候选和 Shadow 可归档。
- Promoted 产物不因时间静默删除。
- Memory 类型遵守分区记忆的衰减规则。
- 评估案例变化后,相关候选标记为“评估过期”。
- 模型或 Runtime 大版本变化时,可要求重新回放。
- 归档保留不含私人正文的指标和版本元数据。
## 14. 信息架构
建议在自动化中心增加“学习”:
1. **候选**:来源、作用域、预期收益和风险。
2. **评估中**:进度、案例和预算。
3. **Shadow**:观察数、差异和成本。
4. **待批准**:晋升摘要。
5. **已生效**:当前版本、使用量和健康状态。
6. **历史**:拒绝、回滚和归档。
候选详情页签:
- 概览。
- 变更 Diff。
- 评估案例。
- 指标和失败。
- Shadow。
- 版本与回滚。
## 15. 数据模型建议
```ts
type LearningArtifact = {
id: string
scopeKind: 'global' | 'project' | 'automation' | 'agent'
scopeId?: string
kind:
| 'memory'
| 'automation_template'
| 'prompt_variant'
| 'rubric'
| 'retrieval_preference'
status:
| 'candidate'
| 'evaluating'
| 'rejected'
| 'inconclusive'
| 'shadow'
| 'awaiting_approval'
| 'promoted'
| 'paused'
| 'rolled_back'
| 'archived'
payload: JsonValue
sourceRefs: LearningSourceRef[]
baselineVersionId?: string
promotedVersionId?: string
createdAt: string
updatedAt: string
}
```
建议表:
- `learning_artifacts`
- `learning_artifact_versions`
- `evaluation_suites`
- `evaluation_cases`
- `evaluation_runs`
- `evaluation_results`
- `shadow_observations`
- `promotion_events`
- `rollback_events`
## 16. 安全与隐私
1. Apply 层拒绝没有 Gate Verdict 的候选。
2. 产物类型和目标作用域使用代码白名单。
3. 安全与权限配置不在可学习目标白名单中。
4. 私人评估案例只在本地使用,不导出或发送到未授权 Provider。
5. Shadow 不调用有副作用工具。
6. Candidate 内容和评估输出都视为不可信数据。
7. Renderer 不能直接设置 Promoted 状态,Main 验证评估与审批。
8. 删除私人评估案例后清理派生缓存和 Embedding。
9. 日志不记录完整案例、Prompt、回答、文件或凭据。
10. 自动暂停采用确定性条件,不依赖模型自由判断。
## 17. 实施顺序
严格顺序:
1. 建立版本化评估案例和确定性断言。
2. 复用并行实验运行 Baseline 与 Candidate。
3. 实现 Gate Verdict,只有 Reject、Inconclusive 和 Shadow。
4. 实现 Shadow,但不允许 Apply。
5. 实现人工晋升和原子回滚。
6. 实现上线监控和确定性自动暂停。
7. 首先开放 Memory 和 Automation Template。
8. 经过长期验证后再评估 Skill、Procedure 和非安全规则。
不能先做自动改 Prompt,再补评估门。
## 18. 验收标准
- [ ] 没有评估结果的候选无法晋升。
- [ ] 安全、权限或硬约束退化必定 Reject。
- [ ] 评估不足时显示 Inconclusive,不强行选优。
- [ ] Baseline、Candidate、案例、Runtime 和评估器都被冻结和版本化。
- [ ] Shadow 不影响用户结果、不调用副作用工具、不写长期记忆。
- [ ] 晋升前展示收益、退化、成本、作用域和回滚版本。
- [ ] 第一阶段只有用户可以批准晋升。
- [ ] 晋升和回滚采用原子版本切换。
- [ ] 生效后出现确定性严重退化时自动暂停并恢复上一批准版本。
- [ ] 系统不会自动选择另一个候选替代。
- [ ] 私人会话不会默认进入评估集。
- [ ] 安全策略、权限、目录、凭据和 Electron 配置不属于可学习产物。
@@ -0,0 +1,409 @@
# 会话监督 PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-08-13 |
| 依赖 | [自动化平台总体设计](./automation-platform-architecture.md) |
| 体验参考 | GoodBuddy 魔法笔记 AI 评论流 |
## 1. 背景
GoodBuddy 的魔法笔记已经提供一种有价值的交互:用户持续写作,AI 在右侧以长评、建议和
警告进行评论,用户可以选择综合、扩展、润色、质疑或发散方向。该能力是内容分析,不是
会话监督:
- 只分析当前笔记或待办文本。
- 不观察聊天任务、工具调用、目标、预算或成果。
- 不参与任务状态机。
- 不引用具体会话步骤。
- 不支持关注、暂停和解决流程。
随着目标任务和并行实验出现,用户需要一个与执行 Agent 相互独立的观察者,帮助发现偏题、
遗漏、矛盾、证据不足、循环、成本失控和潜在风险。
## 2. 产品定义
会话监督是在明确范围和策略下,对 Conversation、Task、AutomationRun 或 ExperimentRun
的可见事件进行独立观察,产生带证据的评论、告警和人工介入请求。
它不是:
- 第二个替用户发言的聊天 Agent。
- 隐藏推理查看器。
- 工具审批器。
- 可以绕过安全边界的“总管理员”。
- 自动修正执行过程的通用控制器。
## 3. 核心产品判断
建议增加会话监督,首期采用“魔法笔记式右侧评论流”,但只开放以下能力:
```text
观察
→ 评论 / 警告
→ 用户查看证据
→ 用户忽略、采纳、询问、暂停或调整任务
```
首期模型监督不自动暂停。只有现有确定性安全规则、预算和用户显式配置的硬门禁可以自动暂停。
## 4. 已确认的产品决策
1. 监督默认关闭,由用户对会话、任务、自动化或实验显式启用。
2. 监督只读取用户可查看的消息、工具事件、状态、指标、成果摘要和目标。
3. 不读取、推断或保存模型隐藏推理链。
4. 每条重要判断必须引用具体消息、工具、步骤、指标或成果。
5. 模型监督默认只评论、警告或请求人工复核。
6. 确定性监督负责权限、预算、Schema、幂等和硬停止条件。
7. 监督器不能自动批准工具、扩大范围、修改安全策略或替用户发送消息。
8. 监督评论不是长期事实,默认不进入记忆。
9. 监督调用使用独立预算和低于前台对话的优先级。
10. 同一个事件不能同时产生重复页内警告、评论和全局通知。
## 5. 目标
### 5.1 用户目标
- 在重要会话旁获得不中断主对话的独立评论。
- 及时发现目标偏移、缺少证据、相互矛盾、重复循环和遗漏要求。
- 点击监督意见查看对应证据,而不是接受无来源判断。
- 对监督意见进行采纳、忽略、标记误报或追问。
- 对自动任务设置更严格的监督策略和人工检查点。
### 5.2 产品目标
- 为普通会话、目标任务和实验提供统一监督契约。
- 让确定性安全门禁与模型质量判断保持分层。
- 保存有界、可审计的监督事件,而非复制完整会话。
- 将用户反馈用于调整规则和评估监督器,但不自动训练或改策略。
## 6. 非目标
- 不展示内部 Chain of Thought。
- 不持续监控其他应用、键盘、麦克风或屏幕。
- 不把 Supervisor 设为拥有所有工具的超级 Agent。
- 不自动修改用户消息或助手回答。
- 不保证识别所有事实错误、偏见或安全风险。
- 不把一次模型警告作为任务失败的确定性依据。
- 不在首期支持 Supervisor 与执行 Agent 自主多轮辩论。
- 不让 Supervisor 读取未授权项目、会话、知识库或记忆。
## 7. 监督对象
| 对象 | 观察内容 | 典型用途 |
| --- | --- | --- |
| 普通会话 | 用户消息、助手回答、引用、工具事件 | 质量和证据评论 |
| 任务 | 目标、步骤、状态、工具、成果 | 偏离、循环和失败分析 |
| 自动化 Run | 触发、协议、预算、审批、指标 | 无人值守关注 |
| 实验 Run | 协议、变量、指标、证据 | 协议一致性 |
| 实验整体 | 各 Run 结算和比较 | 评估公平性与无结论提示 |
每个监督会话只能绑定一个主对象,并继承其项目范围。
## 8. 监督模式
### 8.1 评论方向
借鉴魔法笔记,普通会话支持:
| 模式 | 行为 |
| --- | --- |
| 综合 | 平衡总结目标、进展、风险和下一步 |
| 质疑 | 检查逻辑跳跃、前提、反例和证据 |
| 证据 | 检查重要结论是否有可追溯依据 |
| 目标 | 检查是否回应用户目标和约束 |
| 风险 | 检查权限、隐私、外部影响和不可逆行为 |
自动化和实验可组合多个检查维度,不用方向单选替代确定性规则。
### 8.2 触发方式
| 方式 | 说明 |
| --- | --- |
| 手动 | 用户点击“检查当前会话” |
| 每次回复后 | 助手一轮完成后异步分析 |
| 每 N 步 | 自动化或实验按有界步骤间隔分析 |
| 关键事件 | 工具失败、预算 80%、等待审批、指标异常 |
| Run 结束 | 进行最终监督回顾 |
首期优先手动和每次回复后。草稿输入不发送给监督器,除非未来明确增加类似魔法笔记的
草稿评论模式。
### 8.3 干预级别
```ts
type SupervisorAction =
| 'continue'
| 'comment'
| 'warn'
| 'request_review'
| 'pause'
| 'stop'
```
- 模型 Supervisor 首期只可产生前四种。
- `pause``stop` 只来自确定性门禁或用户操作。
- 后续若允许模型建议暂停,仍需确定性策略把建议转换为 `request_review` 或经过用户预授权。
## 9. 观察输入
### 9.1 可见输入
- 当前对象的名称、目标和约束。
- 最近有界消息。
- 工具名称、状态、参数摘要和输出摘要。
- 任务和子任务状态。
- 成果标题、类型、大小和有界摘要。
- 引用和知识检索诊断。
- 预算使用。
- 明确配置的监督规则。
- 已解决或被忽略的近期监督意见摘要。
### 9.2 禁止输入
- API Key、Token、Cookie 和认证头。
- 模型隐藏推理。
- 未授权文件和完整私人文档。
- 其他项目、会话或实验 Run 的数据。
- 原始无限长度工具输出。
- 已删除或用户要求忘记的记忆。
### 9.3 上下文窗口
- 普通会话默认最近 12 条消息和最多 24,000 字符。
- 任务按最近 20 个关键事件和当前目标组装。
- 长会话先使用确定性提取,再由监督器处理有界输入。
- 不能把 Supervisor 自己的旧评论无限回填,最多保留近期未解决摘要。
## 10. 监督输出
```ts
type SupervisorDecision = {
action:
| 'continue'
| 'comment'
| 'warn'
| 'request_review'
category:
| 'goal_drift'
| 'missing_requirement'
| 'evidence_gap'
| 'contradiction'
| 'repetition'
| 'quality'
| 'risk'
| 'budget'
severity: 'info' | 'low' | 'medium' | 'high'
title: string
content: string
evidence: SupervisorEvidenceRef[]
confidence: number
suggestedActions: SupervisorSuggestedAction[]
}
```
证据引用可以指向:
- `messageId`
- `toolCallId`
- `taskEventId`
- `artifactId`
- `metricId`
- `approvalId`
没有有效证据时,严重度最多为 `low`,且必须显示“推测”。
## 11. 确定性监督
以下检查由代码执行:
- Ask 出现写工具请求。
- 工具或路径超出计划快照。
- 未经批准的跨项目或跨分区读取。
- Token、时间、工具、子任务和成果预算。
- 幂等键冲突或结果未知。
- 输出 Schema 不匹配。
- 实验 Run 读取其他 Run 数据。
- 硬停止条件和必填成果。
确定性监督可以阻止、暂停或终止运行。结果必须包含规则 ID、实际值、阈值和触发事件,
不通过模型重新解释才能生效。
## 12. 模型监督
适合判断:
- 回答是否偏离用户问题。
- 计划是否遗漏明确要求。
- 重要结论是否缺少证据。
- 当前回答与前文是否矛盾。
- 是否重复尝试而没有进展。
- 是否存在值得用户注意的模糊风险。
模型监督输出严格经过 Schema 校验。格式错误最多修复一次;失败不阻塞普通前台会话,
但在配置为自动化门禁时必须明确记录“监督不可用”,不能假装检查通过。
## 13. 用户交互
### 13.1 右侧评论流
复用魔法笔记的体验方向:
- 长评卡。
- 建议卡。
- 警告卡。
- 证据链接。
- 评论方向和时间。
每条意见操作:
- 查看证据。
- 采纳建议。
- 追问。
- 忽略。
- 标记误报。
- 对自动化请求暂停。
“采纳”只是把建议带入输入框、计划草稿或任务操作,不让 Supervisor 直接执行。
### 13.2 会话输入区
提供监督状态入口:
```text
监督:关闭 / 综合 / 质疑 / 证据 / 目标 / 风险
```
这是持久二元启用加方向选择:
- 是否启用使用共享 Switch`role="switch"`
- 方向使用 `SegmentedControl` 或上下文单选菜单。
- 不把开关和方向做成一组含义不清的页签。
### 13.3 关注状态
会话或任务列表显示未解决意见数量和最高严重度,不能只用颜色。
只有 `request_review`、高风险警告或确定性暂停触发全局通知。
## 14. 解决流程
```text
open
→ acknowledged
→ resolved
→ dismissed
→ false_positive
```
- `acknowledged`:用户已查看,尚未解决。
- `resolved`:用户或后续运行说明已处理。
- `dismissed`:不采纳,但不一定是误报。
- `false_positive`:明确标记判断不正确。
后续监督输入可以包含未解决意见摘要,已解决意见默认不重复提醒。
## 15. 与任务控制的关系
Supervisor 建议“暂停”时:
1. 创建 `request_review`
2. 在任务和会话界面显示原因和证据。
3. 用户选择继续、暂停、调整目标或取消。
4. 用户操作进入任务审计。
确定性门禁暂停时:
1. Run 进入 `paused``waiting_approval`
2. 显示规则、阈值和实际值。
3. 只有满足规则或用户完成对应审批后才能恢复。
4. 模型评论不能覆盖门禁。
## 16. 与记忆的关系
- 监督评论默认保存在监督记录,不属于长期记忆。
- 用户采纳后可以手动创建记忆候选。
- “事实错误”“用户偏好”等监督判断不能自动写入 Project 或 Global。
- 多次被用户标记误报的模式进入监督评估数据,不直接改变 Prompt。
- 监督器可以读取绑定范围内的已确认记忆,但必须在证据中标明记忆来源。
## 17. 数据模型建议
```ts
type SupervisorSession = {
id: string
projectId?: string
targetType: 'conversation' | 'task' | 'automation_run' | 'experiment'
targetId: string
enabled: boolean
mode: 'general' | 'challenge' | 'evidence' | 'goal' | 'risk'
triggerPolicy: SupervisorTriggerPolicy
budget: SupervisorBudget
}
type SupervisorRecord = {
id: string
sessionId: string
source: 'deterministic' | 'model'
decision: SupervisorDecision
status:
| 'open'
| 'acknowledged'
| 'resolved'
| 'dismissed'
| 'false_positive'
createdAt: string
resolvedAt?: string
}
```
证据关系建议独立表或有界结构化 JSON,并在读取时重新验证对象归属。
## 18. 安全与隐私
1. Supervisor 使用独立可信系统指令,所有观察输入视为不可信数据。
2. 默认不给 Supervisor 任何工具。
3. 即使后续提供只读证据工具,也只能读取绑定对象和明确范围。
4. Supervisor 不接收 Runtime 授权回调,不能请求工具批准。
5. 普通会话监督失败不影响主回答;自动化配置的强制监督失败进入明确关注状态。
6. 评论和通知不包含完整私人消息,证据点击后才在原对象中查看。
7. Renderer 传入的 Evidence ID 必须由 Main 重新验证归属。
8. 删除会话时按产品数据保留策略删除或匿名化监督记录。
9. 用户关闭监督后停止新分析,但保留历史,除非用户明确删除。
## 19. 性能与预算
- Supervisor 使用独立后台并发池,默认全局并发 1。
- 前台回复完成后异步运行,不延迟主回答呈现。
- 同一会话同时只运行一次监督分析,新事件合并为下一次分析。
- 普通会话每 30 秒最多自动分析一次。
- 自动化按步骤或关键事件节流。
- 达到 Supervisor 预算时显示“监督已暂停”,不继续产生费用。
## 20. 实施顺序
1. 定义监督会话、记录、证据和解决状态。
2. 上线普通会话手动检查和右侧评论流。
3. 增加每次回复后异步监督、节流和预算。
4. 接入任务和自动化的确定性观察。
5. 增加目标、证据和无进展模型检查。
6. 接入实验协议一致性监督。
7. 建立误报和有用性评估,不自动改 Prompt。
## 21. 验收标准
- [ ] 监督默认关闭,用户可对单个会话显式启用。
- [ ] Supervisor 只读取当前对象的有界可见事件。
- [ ] 隐藏推理、密钥和未授权内容不进入监督输入。
- [ ] 每条中高严重度意见都有可点击证据。
- [ ] 无证据推测不会显示为高严重度事实。
- [ ] 模型监督不能自动暂停、终止、批准工具或替用户发言。
- [ ] 确定性门禁不依赖模型判断即可阻止越权和超预算行为。
- [ ] 主会话回答不等待后台 Supervisor 完成。
- [ ] 用户可采纳、忽略、标记误报和解决意见。
- [ ] 监督评论默认不进入长期记忆。
- [ ] 自动化强制监督不可用时明确请求关注,不假装检查通过。
- [ ] Supervisor 预算耗尽后停止新调用并显示状态。
+331
View File
@@ -0,0 +1,331 @@
# 文档解析与本地 OCR
## 1. 目标
GoodBuddy 需要用同一条可信文档解析链路服务以下场景:
- 聊天附件问答;
- 知识库导入、同步、分块与来源定位;
- 后续的合同审阅、表格分析、演示文稿理解和文档转换。
文档解析不是对话模型的附属功能。它是主进程管理的独立基础能力,设置入口为“设置中心 / 文档解析”。
## 2. 当前基线
原生解析器已经支持:
- UTF-8 文本、代码、配置、HTML;
- 带文本层的 PDF
- DOCX 正文;
- XLSX 工作表 XML 与共享字符串;
- PPTX 幻灯片文字。
现有局限:
- 纯扫描 PDF 没有文本层时无法提取内容;
- DOC、XLS、PPT 等旧版二进制 Office 格式不支持;
- Office 解析主要提取文字,不能完整保留表格、公式、图表和版面;
- 聊天附件和知识库直接调用底层解析函数,缺少可配置的统一工作流;
- 没有本地 OCR 模型状态、真实解析测试和按场景策略。
## 3. 产品原则
### 3.1 双通道解析
PDF 不是所有文档唯一的中间格式。解析应同时保留:
1. 原生语义通道:标题、段落、单元格、公式、备注和对象关系;
2. 渲染视觉通道:页码、版面、图表、图片和 OCR 结果。
两条通道合并为统一文档结构。转换为 PDF 用于补充视觉信息,不得覆盖更可靠的原生语义结果。
### 3.2 场景工作流
| 场景 | 默认预设 | 行为 |
| --- | --- | --- |
| 聊天附件 | 自动解析 | 优先快速提取,文本不足时按需 OCR,有界截断后加入当前请求 |
| 知识库导入 | 完整索引 | 完整解析、按页或工作表定位、按需 OCR、分块与索引 |
| 扫描文档 | OCR | 页面渲染、文字识别、置信度与定位保留 |
| 表格分析 | 语义优先 | 单元格和值优先,PDF 或图片补充图表与打印布局 |
| 高保真审阅 | 视觉增强 | 原生解析、页面渲染、OCR 或视觉理解合并 |
### 3.3 本地优先
- 文本层和本地 OCR 均在设备上处理;
- 本地处理不因 Ask 或 Execute 模式改变;
- OCR 来源必须在“本地模型 / 远程服务”之间明确选择;
- 配置并保存远程服务即表示用户选择该处理路径,不再增加逐场景授权;
- API 密钥只能保存在主进程加密设置中;
- 测试文件不得自动进入聊天或知识库。
## 4. 设置设计
设置中心新增“文档解析”分类,结构如下:
1. 分类页头:“测试解析”“保存设置”;
2. 运行状态:原生解析、文档转换、本地 OCR;
3. 使用场景:聊天附件、知识库导入;
4. 文档转换;
5. OCR 识别;
6. 高级解析设置;
OCR 模型区沿用语音模型管理模式:
- 应用不内置模型权重;
- 用户按需从 ModelScope 下载,下载完成后离线使用;
- 显示来源、语言、运行时、模型体积、安装与校验状态;
- 联网设备可导出已安装模型 ZIP,离线或内网设备可直接导入;
- 支持下载进度、取消、删除、ZIP 导入导出、打开模型仓库和受管目录;
- “打开 ModelScope”直接显示在 OCR 模型卡片右上角,不使用手动导入折叠区;
- 模型操作即时生效,解析策略仍通过分类页头的“保存设置”提交。
### 4.1 第一阶段字段
- 聊天附件预设:`auto``fast-text``high-fidelity`
- 知识库预设:`complete-index``fast-index``high-fidelity`
- PDF OCR 策略:`auto``always``disabled`
- OCR 来源:第一阶段固定为 `local`,远程服务入口禁用;
- 本地 OCR 模型:`pp-ocrv6-tiny``pp-ocrv6-small``pp-ocrv6-medium`
- 单文档最大页数;
- OCR 并发数;
- 单页超时。
OCR 来源使用互斥选择。本地模型选中后才显示模型下拉列表、按需下载、导入和本地 OCR 参数;远程服务计划接入 MinerU、PaddleOCR-VL 等接口,第一阶段保持可读但禁用。来源选择本身就是用户的明确决策,不再显示额外的“隐私与云端处理”授权区。
## 5. 架构
```text
聊天附件 ─┐
├─ DocumentParsingService
知识库导入 ┘ ├─ NativeDocumentParser
├─ PdfTextQualityEvaluator
├─ PdfPageRenderer
├─ LocalOcrProvider
├─ DocumentConversionProvider
└─ ParsedDocument merger
```
`DocumentParsingService` 是唯一场景入口:
```ts
type DocumentParsingPurpose = 'chat-attachment' | 'knowledge-index'
type DocumentParsingService = {
parse(
name: string,
bytes: Buffer,
purpose: DocumentParsingPurpose,
signal?: AbortSignal
): Promise<ParsedDocument>
}
```
聊天上下文管理器与知识库服务依赖该接口,不直接选择 OCR Provider。
## 6. 统一结果
第一阶段兼容现有 `ParsedDocument`,并逐步扩展:
```ts
type ParsedDocument = {
title: string
sourceFormat: string
content: string
sections: Array<{
locator: string
content: string
method?: 'native' | 'ocr' | 'converted' | 'vision'
confidence?: number
}>
warnings?: string[]
}
```
定位字段必须对使用者有意义:
- PDF`第 3 页`
- XLSX`工作表:预算 / A1:F28`
- PPTX`幻灯片 5`
- DOCX:标题路径或页码;
- 文本:`全文`
## 7. 本地 OCR 基线
### 7.1 模型与运行时
全平台功能基线:
- 模型:PP-OCRv6 ONNX/ORT
- 轻量下载档位:Tiny,约 6 MiB,用于低资源设备和六平台离线链路;
- 推荐下载档位:Small,约 30 MiB,官方支持 50 种语言;
- 高精度下载档位:Medium,约 132 MiB,官方支持 50 种语言,但识别较慢且需要更多内存;
- 运行时:ONNX Runtime WebAssembly
- 处理环境:隔离 Worker
- 加速:WebGPU 或平台原生执行 Provider,仅作为可选层;
- 回退:任何加速失败后使用 WASM CPU。
需要覆盖的发布矩阵:
- Windows x64、Windows arm64
- macOS x64、macOS arm64
- Linux x64、Linux arm64。
模型清单必须固定以下信息:
- 上游仓库和不可变 revision;
- 文件名、字节数和 SHA-256
- 模型族、语言、质量和速度;
- 许可证名称、完整许可证和来源;
- 检测模型、识别模型、字符字典的匹配关系。
运行时不得从 `main``latest` 或其他可变地址加载模型。
### 7.2 下载与安装
Tiny、Small 和 Medium 模型均由 PaddlePaddle 官方 ModelScope 仓库提供。Small 是默认推荐档位;Medium 面向更高识别质量,但具有更高内存占用和延迟。每个档位的检测模型、识别模型与字符字典配置分别使用固定提交,并在应用内记录文件字节数和 SHA-256。
下载流程:
1. 主进程从固定 ModelScope `resolve/<revision>/...` 地址读取文件;
2. 禁用凭据与缓存,限制重定向次数和单文件大小;
3. 写入受管目录下的随机临时安装目录;
4. 边下载边计算 SHA-256,并核对完整字节数;
5. 三个文件全部通过校验后写入安装清单;
6. 原子重命名为正式模型目录;
7. 失败、取消或退出时删除临时文件。
模型只在下载或用户显式打开仓库时访问网络。OCR 推理从受管目录读取已校验文件,不发起网络请求。
### 7.3 离线 ZIP 迁移
语音模型和 OCR 模型使用同一种离线迁移流程:
1. 联网设备完成受信任来源下载和校验;
2. 在模型卡片选择“导出 ZIP”;
3. 将 ZIP 通过组织批准的介质传输到离线或内网设备;
4. 在相同模型的卡片选择“导入 ZIP”;
5. 主进程按当前应用内置目录重新校验,并在全部通过后原子安装。
ZIP 根目录包含模型文件和 `goodbuddy-model.json`。清单格式为 `goodbuddy-model-archive`,当前版本为 `1`,记录:
- 模型类型:`speech``document-ocr`
- 内置模型 ID 和显示名称;
- 文件名、角色、原始字节数和 SHA-256;
- 导出时间。
导出不能直接信任已有安装清单,必须重新读取并校验每个文件。导入不能只信任 ZIP 自声明内容,模型 ID、文件角色、字节数和哈希必须再次与当前应用内置目录完全匹配。导入通过后复用普通本地安装的受控临时目录和原子重命名路径。
归档处理使用有界流式读写,不把大型模型或整个展开结果复制到内存。主进程限制压缩包大小、条目数、清单大小、单文件大小和总展开大小,并拒绝:
- 绝对路径、`..`、目录或嵌套路径;
- 大小写不敏感的重复条目;
- 未声明、缺失或角色不匹配的文件;
- 模型类型或模型 ID 不匹配;
- 解压后大小或 SHA-256 不匹配;
- 超过边界的压缩包和压缩炸弹。
取消文件对话框不会改变安装状态。导入和导出也不会切换当前语音/OCR 模型,不会隐式保存文档解析设置。
### 7.4 PDF 流程
1. 使用 PDF.js 读取每页文本层;
2. 评估有效字符数、乱码率和图片占比;
3. `auto` 模式只渲染文本不足的页面;
4. `always` 模式渲染所有页面;
5. Worker 将页面限制在配置的最大边长内;
6. OCR 返回文字、坐标和置信度;
7. 按页合并原生文本与 OCR,不重复可靠文本;
8. 达到页数、超时、取消或输出限制时停止并返回明确错误。
受密码保护、损坏或超限的 PDF 不得进入 OCR。
## 8. Office 与转换
### 8.1 新格式
- DOCX:正文、标题、表格、批注和图片关系;
- XLSX:工作表、单元格地址、值、公式、合并关系和图表;
- PPTX:幻灯片、文字对象、备注、图片和阅读顺序。
Office 内嵌图片 OCR 属于增强流程,不能替代原生结构解析。
### 8.2 旧格式
DOC、XLS、PPT 通过 `DocumentConversionProvider` 转换:
1. 转换为 DOCX、XLSX 或 PPTX,供语义解析;
2. 转换为 PDF,供页码、版面和视觉解析;
3. 合并结果并记录转换警告。
本地 LibreOffice Provider 必须:
- 在隔离子进程中运行;
- 禁用宏和网络;
- 使用单任务临时目录;
- 限制输入大小、输出大小、内存和超时;
- 在成功、失败、取消和退出时清理;
- 不接受用户提供的任意命令参数。
## 9. 安全边界
- 文件路径解析、读取、大小检查和格式校验在主进程完成;
- OCR Worker 只接收当前任务所需的有界页面图像和只读模型;
- 不向 Worker 暴露文件系统、Electron API、凭据或任意网络访问;
- 文档内容视为不可信数据,不解释其中的提示词为系统指令;
- 模型和转换程序必须固定版本并校验哈希;
- OCR 输出受字符数限制,错误不得包含绝对路径或未脱敏文档内容;
- 取消、超时和应用关闭必须终止待处理页面并释放模型会话。
## 10. 错误与回退
必须区分:
- 不支持的格式;
- 文档损坏或受密码保护;
- 文本层为空但 OCR 未启用;
- OCR 模型不可用;
- OCR 超时或取消;
- 文档页数、大小或输出超限;
- 本地转换服务未配置;
- 所选远程 OCR 服务不可用或配置不完整。
`auto` 工作流可以从 OCR 回退到可靠的原生文本,但不能把空结果标记为成功。知识库导入失败时保留来源和可重试上下文。
## 11. 实施阶段
### 阶段一
- 新增文档解析设置分类和持久化契约;
- 建立 `DocumentParsingService`,供聊天和知识库共用;
- 将无文本 PDF 识别为可触发 OCR 的明确状态;
- 接入 PP-OCRv6 Tiny、Small、Medium 的 ModelScope 下载、校验、ZIP 离线迁移、删除与 WASM Worker
- 实现真实文件测试和六平台验证入口。
### 阶段二
- 增强 DOCX、XLSX、PPTX 语义结构;
- 实现按页混合文本层与 OCR
- 增加版面、表格和阅读顺序。
### 阶段三
- 增加 LibreOffice 和 API 转换 Provider
- 支持 DOC、XLS、PPT
- 增加 MinerU、PaddleOCR-VL 等远程 OCR 服务连接配置;
- 增加高保真工作流和解析结果预览。
## 12. 验收
- 同一份扫描 PDF 可从聊天附件和知识库得到一致的逐页文本;
- 文本型 PDF 在 `auto` 模式下不运行 OCR
- 本地 OCR 在六个平台和两种架构上完全离线运行;
- 模型文件损坏时拒绝加载并显示可恢复错误;
- 未安装模型时扫描文档提示用户前往“文档解析”下载,文本型文档仍可原生解析;
- 下载中可显示文件与总进度并允许取消,失败或取消后不留下已安装状态;
- ModelScope 下载与 ZIP 导入均经过同一大小和 SHA-256 校验;
- 语音和 OCR 模型可在联网设备导出 ZIP,并在离线设备导入后完成真实推理;
- 路径穿越、未知条目、错误模型 ID、篡改文件和超限 ZIP 均被拒绝;
- 超页数、超时、取消和关闭不会留下运行任务;
- 测试解析不会创建聊天消息或知识库文档;
- 选择本地模型时没有任何文档上传;
- 文档中的提示词不会改变系统、模式或工具权限。
@@ -0,0 +1,591 @@
# 知识库检索与分块增强 PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 实施中 |
| 版本 | 0.1 |
| 日期 | 2026-08-11 |
| 适用产品 | GoodBuddy 桌面端 |
| 实施范围 | 第一阶段:可用、可见、可诊断;第二阶段:可调、可优化、可维护 |
## 1. 背景
GoodBuddy 已具备本地多知识库、文件与目录同步、网页导入、SQLite FTS5、
OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来源引用。现有实现
优先建立了本地数据主权、安全边界和跨 Runtime 工具授权,但用户仍难以稳定
获得“导入资料后即可准确问答”的体验。
当前主要问题不是缺少知识图谱,而是基础 RAG 链路缺少完整闭环:
1. 在对话中启用知识库只会开放搜索工具,是否检索仍由模型自行决定。
2. 默认向量检索关闭,中文全文检索对自然语言问法和同义表达的召回不足。
3. 向量请求失败会降级为全文检索,但知识库页面仍可能显示索引完成。
4. 大于 5,000 个向量分块的知识库会跳过向量召回。
5. 用户不能独立测试召回、查看各通道得分或确认实际送入模型的上下文。
6. 分块参数固定,缺少结构化、父子分块、分块预览和人工修正。
7. 引用只能阅读片段,不能查看完整上下文或打开原始来源。
本项目先完成稳定性和可观测性,再增加高级分块、重排与维护能力。知识图谱
继续作为可选召回通道,但不替代全文和向量检索的基础质量。
## 2. 已确认的产品决策
1. 保持本地优先,不引入必须联网的托管知识库服务。
2. 保持 Electron Main、Preload、Renderer 的安全边界,Renderer 不直接读取
数据库、原文件或向量。
3. 保留“模型按需检索”,并新增“每次先检索”模式。后者必须由 Main 进程
预检索,不能只依赖提示词要求模型调用工具。
4. 知识库新建后不默认启用全部已有知识库;对话中的范围继续由用户显式选择。
5. 向量服务不可用时保留全文检索,但必须返回明确降级状态。
6. 中文召回使用应用内可控的 CJK n-gram 索引,不新增远程服务依赖。
7. 混合检索保留 RRF 候选融合,并增加本地确定性重排、可选的
Cohere/Jina 兼容学习型重排、最低相关度和上下文预算。学习型重排失败时
安全降级,不影响全文、向量和图谱召回。
8. 向量搜索取消 5,000 分块静默失效,使用有界内存的分页扫描。在没有稳定
跨平台向量扩展前,接受本地 CPU 线性扫描,并持续显示性能诊断。
9. 向量索引兼容性同时校验 Provider、Model、维度和 Provider Fingerprint。
同名模型切换端点后,旧向量不能继续参与召回。
10. 失败或取消的重建不能停用上一版已就绪索引。新索引只有完整校验成功后才
原子替换当前服务版本。
11. 分块设置属于知识库,修改后不会伪装为立即生效。用户需要显式重建索引。
12. 分块允许预览、编辑、启用、停用和删除。来源再次同步可能覆盖人工修改,
UI 必须在修改前持续说明该行为。
13. 第一阶段和第二阶段均不新增付费或外部模型调用。现有 Embeddings 调用仍由
用户配置决定。
14. Ask 的运行时边界保持只读。知识库内容始终被标记为不可信证据,
不能成为系统指令。
## 3. 目标
### 3.1 用户目标
- 明确知道本次回答是否检索、检索了哪些知识库,以及是否发生降级。
- 在知识库页面输入真实问题,查看命中分块、通道、得分和最终上下文。
- 为不同文档选择适合的分块模式,并在导入前理解影响。
- 查看和修正错误分块,不需要删除并重新导入整个来源。
- 从回答引用查看完整上下文,并打开对应本地文件或网页。
- 在向量、解析或图谱失败时获得可恢复的状态和明确操作。
### 3.2 产品目标
- 默认中文问法在没有向量模型时仍具有可用的关键词召回。
- 向量服务故障、大知识库和模型变更不再产生静默空结果。
- 建立可复现的检索调试入口,支持固定问题进行回归测试。
- 将解析、全文、向量和图谱状态拆分,避免“索引完成”误导。
- 为后续元数据过滤、远程 Rerank Provider 和自动评测保留稳定契约。
### 3.3 质量目标
- 中文同义改写测试集的 Recall@5 相比现有全文检索基线提升至少 30%。
- 检索测试结果必须在本机重复执行时保持稳定排序。
- 任意向量失败都必须在检索诊断或任务状态中可见。
- 10,000 个分块的知识库不得因固定上限返回空向量结果。
- 每条展示引用都能找到仍存在且属于已授权知识库的分块和文档。
- 检索输出和上下文拼装均遵守字符、结果数和 IPC 大小上限。
## 4. 非目标
本项目不包含:
- 团队共享知识库、SSO、SCIM 或跨设备同步。
- 企业级 ACL、文档级角色继承和远程权限同步。
- 云端网站爬虫、Notion、飞书、语雀等第三方连接器。
- MinerU、PaddleOCR-VL 或其他远程文档解析服务。
- 专用向量数据库、外部 Elasticsearch 或打包平台原生向量扩展。
- 托管重排服务账户、计费或供应商绑定;仅提供通用兼容接口配置。
- 自动问题生成、FAQ 生成和训练数据标注平台。
- 完整 RAG 离线评测平台。第二阶段只提供手动检索测试与可导出的诊断信息。
- 在应用内高保真渲染所有原始 Office 和 PDF 文档。
## 5. 竞品基线与 GoodBuddy 定位
截至 2026-08-11Dify、FastGPT 和 RAGFlow 的公开文档均把检索测试、可配置
分块和可调检索参数作为知识库基础能力:
| 能力 | Dify | FastGPT | RAGFlow | GoodBuddy 本期 |
| --- | --- | --- | --- | --- |
| 检索测试 | 支持 | 支持 | 支持 | 第一阶段支持 |
| Top K / 阈值 | 支持 | 支持 | 支持 | 第一阶段支持 |
| 全文 + 向量 | 支持 | 支持 | 支持 | 已有,第一阶段增强中文 |
| Rerank | 模型 Rerank | 模型 Rerank | 模型 Rerank | 本地确定性与可选兼容模型重排 |
| 父子分块 | 支持 | 可通过索引与大分块组合 | 支持多种切分策略 | 第二阶段支持 |
| 分块维护 | 支持内容维护 | 支持数据维护 | 支持块级检查 | 第二阶段支持 |
| 深度文档理解 | 中等 | 中等 | 强 | 继续复用本地解析与 OCR |
| 本地目录监听 | 非核心 | 非核心 | 非核心 | GoodBuddy 差异化能力 |
| 本地可编辑图谱 | 非核心 | 非核心 | 部分版本支持 GraphRAG | GoodBuddy 差异化能力 |
本期不复制竞品的云端工作流平台,而是将其成熟 RAG 交互映射为桌面、本地、
受控的数据链路。
参考公开文档:
- Dify Knowledge
<https://docs.dify.ai/en/use-dify/knowledge/readme>
- Dify 检索测试:
<https://docs.dify.ai/en/use-dify/knowledge/test-retrieval>
- Dify 分块设置:
<https://docs.dify.ai/en/use-dify/knowledge/create-knowledge/chunking-and-cleaning>
- FastGPT 知识库搜索方案和参数:
<https://doc.fastgpt.io/docs/introduction/guide/knowledge_base/dataset_engine>
- RAGFlow Dataset 配置:
<https://ragflow.io/docs/configure_knowledge_base>
- RAGFlow 检索测试:
<https://ragflow.io/docs/run_retrieval_test>
## 6. 信息架构
知识工作区继续使用主从布局和现有四个页签:
```text
知识库
├─ 文档与来源
│ ├─ 来源管理
│ ├─ 检索测试入口
│ ├─ 文档状态
│ └─ 分块查看与维护
├─ 知识图谱
├─ 任务中心
└─ 设置
├─ 检索设置
├─ 分块设置
└─ 图谱设置
```
“检索测试”是当前知识库的高频诊断操作,通过知识库标题区次操作打开独立
工作台,不新增第五个一级页签。
对话输入区的知识范围弹层包含:
1. 已启用知识库多选。
2. 检索方式:模型按需检索、每次先检索。
3. 当前范围为空、索引降级或向量未配置时的短说明。
## 7. 第一阶段:可用、可见、可诊断
### 7.1 检索方式
新增请求级 `knowledgeRetrievalMode`
| 值 | 用户文案 | 行为 |
| --- | --- | --- |
| `auto` | 模型按需检索 | 保留当前 `knowledge_search` 工具,由模型决定是否调用 |
| `always` | 每次先检索 | Main 在启动 Runtime 前使用原始用户问题检索一次,再把有界证据作为不可信上下文提供给 Runtime |
规则:
- 没有启用知识库时不显示为“已检索”。
- `always` 预检索后仍保留 `knowledge_search`,模型可以改写查询再次检索。
- 预检索零结果不阻止回答,但必须显示“已检索,未找到相关内容”。
- 预检索失败不得自动扩大范围或访问未选知识库。
- 图片生成能力不执行知识预检索。
- Ask 和 Execute 使用相同的只读检索范围。
### 7.2 中文全文检索
在现有 `unicode61` FTS 之外增加本地 CJK n-gram 检索文本:
- 连续汉字生成二元词组,保留必要的单字符短查询回退。
- 拉丁字母和数字使用 NFKC、大小写归一化和现有 FTS。
- 多个查询词使用召回优先的 OR 候选,再通过覆盖率和短语命中重排。
- 不把整句中文问题转换成“所有汉字必须同时出现”的条件。
- 索引更新、分块编辑、停用和删除必须同步更新 CJK 索引。
- 数据库迁移必须为已有分块有界回填,不要求用户重新导入。
### 7.3 检索设置
每个知识库保存以下设置:
| 字段 | 范围 | 默认值 |
| --- | --- | --- |
| `topK` | 1 至 20 | 6 |
| `minimumVectorSimilarity` | 0 至 1 | 0(不过滤低相似度结果) |
| `ftsWeight` | 0 至 2 | 1 |
| `vectorWeight` | 0 至 2 | 1 |
| `graphWeight` | 0 至 2 | 0.8 |
| `candidateMultiplier` | 2 至 10 | 4 |
| `contextMaxCharacters` | 2,000 至 48,000 | 16,000 |
| `adjacentChunkCount` | 0 至 2 | 0 |
| `localRerankEnabled` | 布尔值 | false |
至少一个召回通道权重大于 0。图谱未启用时,图谱权重只读显示为不可用。
向量模型未启用或索引不兼容时,向量权重保留但当前请求降级。
### 7.4 检索测试工作台
用户输入最多 4,000 字符的问题,工作台显示:
- 当前知识库和生效设置。
- 总耗时、各通道耗时和候选数。
- 请求通道、实际使用通道和降级原因。
- 最终结果序号、文档、定位、片段和最终相关度。
- FTS、CJK、向量、图谱的独立排名与向量相似度。
- 本地重排前后排名。
- 相邻分块或父块合并后的实际上下文。
- “查看分块”“打开来源”操作。
检索测试不创建聊天消息、不写入会话历史、不调用 LLM,也不改变知识库内容。
### 7.5 可扩展向量搜索
移除“超过 5,000 个候选则返回空结果”的逻辑:
1. 按稳定游标分页读取同一知识库、Provider、Model 和维度的向量。
2. 每批计算余弦相似度。
3. 内存中只保留候选上限所需的最佳结果。
4. 支持取消和应用关闭。
5. 维度、校验和或索引状态不匹配的向量不参与结果。
6. 诊断返回扫描数量和向量耗时。
7. Provider Fingerprint 不匹配时标记索引不兼容,不回退到同名旧模型向量。
线性扫描是本期跨平台保底实现。后续接入稳定向量扩展时不得改变上层契约。
### 7.6 状态与降级
文档状态拆分为:
| 状态 | 含义 |
| --- | --- |
| 解析 | 等待、运行、完成、失败 |
| 全文索引 | 等待、完成、失败 |
| 向量索引 | 未启用、等待、运行、完成、失败、不兼容 |
| 图谱 | 未启用、按需、等待、运行、完成、失败 |
知识库汇总不得仅以“文档 metadata 不是 failed”计算完成。UI 至少显示:
- 可用于全文检索的文档数。
- 已完成向量化的文档数。
- 失败文档数。
- 当前向量模型与索引是否兼容。
降级事件包括:
- 未配置向量模型。
- 查询向量生成失败。
- 当前模型没有匹配索引。
- 部分文档向量失败。
- 图谱关闭或没有证据。
- 结果被相关度或上下文预算过滤。
### 7.7 引用查看
每条引用增加稳定 `chunkId`、最终相关度和检索通道。用户展开引用后可以:
1. 查看命中分块。
2. 查看相邻分块或父块形成的完整上下文。
3. 查看知识库、文档、来源和定位。
4. 对本地文件调用 Main 校验后的 `shell.openPath`
5. 对 HTTP(S) 来源调用 Main 校验后的外部打开。
Renderer 不能提交任意路径或 URL。Main 必须根据 `libraryId``documentId`
`chunkId` 重新读取已保存来源并验证归属。
界面把该列表描述为“本次检索证据”或“已查阅来源”,不把仅被召回的片段
自动宣称为回答中某个句子的精确出处。后续只有经过稳定 Citation ID 校验的
句级标注才能使用更强的“该句引用”语义。
## 8. 第二阶段:可调、可优化、可维护
### 8.1 分块模式
每个知识库选择一种模式:
| 模式 | 行为 | 适用内容 |
| --- | --- | --- |
| 固定分块 | 按目标长度、重叠和自然边界切分 | 普通文本、日志、代码 |
| 结构分块 | 优先保持解析 section、Markdown 标题和段落结构 | 手册、制度、长文档 |
| 父子分块 | 小块用于召回,大块用于模型上下文 | 长篇说明、合同、研究资料 |
设置:
| 字段 | 范围 | 默认值 |
| --- | --- | --- |
| `mode` | `fixed` / `structure` / `parent-child` | `structure` |
| `targetCharacters` | 400 至 8,000 | 1,600 |
| `overlapCharacters` | 0 至目标长度的 40% | 160 |
| `parentCharacters` | 1,600 至 16,000 | 4,800 |
| `childCharacters` | 300 至 4,000 | 900 |
父子分块要求:
- 父块只作为上下文,不进入 FTS、CJK 或向量候选。
- 子块用于召回,并保存父块关联。
- 引用默认突出子块,同时允许查看父块全文。
- 父块和子块总输出仍受上下文预算限制。
### 8.2 本地与学习型重排
第二阶段提供不调用外部模型的可选本地重排。评分特征包括:
- 原始 RRF 排名。
- 中文和拉丁词覆盖率。
- 完整短语命中。
- 文档标题、分块标题和路径命中。
- 向量相似度。
- 同文档重复结果惩罚。
重排结果必须:
- 归一化为 0 至 1 的 `relevance`
- 对相同输入和索引保持确定性。
- 保留重排前排名和各特征得分用于诊断。
- 在关闭时完全保留原有 RRF 排序。
学习型模式使用 Main 进程中的 Cohere/Jina 兼容客户端,凭据只进入加密设置和
Main 进程。请求限制为 100 个候选、每个候选 8,000 字符,并具有 15 秒默认
超时、取消传播和有界响应。失败时可回退本地重排或 RRF,并只返回脱敏诊断。
### 8.3 相邻分块合并与上下文预算
- 对最终候选按文档和 ordinal 合并相邻分块。
- 不把同一分块重复放入上下文。
- 保留每个命中分块的引用定位。
- 按相关度从高到低消耗 `contextMaxCharacters`
- 单个超长父块按安全边界截断并标记 `truncated`
- 不允许低排名结果挤掉已经选中的高排名证据。
### 8.4 分块管理
文档行提供“查看分块”,打开分块管理对话框:
- 显示 ordinal、角色、标题、定位、字符数、启用状态和内容预览。
- 支持分页和文档内搜索。
- 支持编辑内容。
- 支持启用或停用。
- 支持删除,并说明来源同步可能重新创建分块。
- 编辑后更新 FTS 和 CJK 索引,并使旧向量失效。
- 已配置向量模型时,编辑操作完成后为该文档重建向量。
- 删除最后一个可检索分块时,文档显示“无可检索内容”,不能显示完全就绪。
高影响删除使用具体确认文案。普通启停使用共享 Switch,并声明
`role="switch"`
### 8.5 单文档与全库重建
- 单文档重建重新读取来源、解析、分块、全文索引、向量和图谱。
- 全库重建按来源顺序执行,并显示文档级进度。
- 修改分块模式或关键参数后,知识库显示“设置已更新,等待重建”。
- 重建采用文档级原子替换,失败时保留上一版可用分块和向量。
- 用户可以取消全库重建;已经成功替换的文档保持可用。
- 文件不存在、网页失败或 OCR 不可用时保留可重试错误。
- 单来源允许的 2,000 个文件必须全部参与增量同步、删除检测和校验和跳过,
不受普通页面 500 项列表上限影响。
## 9. 数据模型与兼容性
### 9.1 KnowledgeBase
知识库增加版本化设置:
```ts
type KnowledgeRetrievalSettings = {
version: 1
topK: number
minimumVectorSimilarity: number
ftsWeight: number
vectorWeight: number
graphWeight: number
candidateMultiplier: number
contextMaxCharacters: number
adjacentChunkCount: number
localRerankEnabled: boolean
}
type KnowledgeChunkingSettings = {
version: 1
mode: 'fixed' | 'structure' | 'parent-child'
targetCharacters: number
overlapCharacters: number
parentCharacters: number
childCharacters: number
}
```
SQLite 使用 JSON 列保存设置,读写均经过共享 Zod Schema。迁移后的旧知识库使用
与当前行为接近的兼容默认值,不自动重建已有分块。
### 9.2 Chunk
分块增加以下语义:
```ts
type KnowledgeChunkRole = 'standalone' | 'parent' | 'child'
type KnowledgeChunkState = {
enabled: boolean
role: KnowledgeChunkRole
parentChunkId?: string
manuallyEdited: boolean
updatedAt?: string
}
```
实现可以使用显式列或受校验 metadata,但查询必须为旧数据提供默认值:
- 缺少 `enabled` 时视为 `true`
- 缺少 `role` 时视为 `standalone`
- 父块不参与召回索引。
### 9.3 检索响应
```ts
type KnowledgeRetrievalResponse = {
query: string
durationMs: number
settings: KnowledgeRetrievalSettings
diagnostics: {
requestedChannels: KnowledgeRetrievalChannel[]
usedChannels: KnowledgeRetrievalChannel[]
degradedChannels: Array<{
channel: KnowledgeRetrievalChannel
reason: string
}>
candidateCounts: Partial<Record<KnowledgeRetrievalChannel, number>>
}
results: KnowledgeRetrievalResult[]
context: {
characterCount: number
truncated: boolean
groups: KnowledgeContextGroup[]
}
}
```
错误、诊断和引用不得包含 API Key、Authorization Header、完整私人文档或未经
限制的 Provider 响应。
## 10. IPC 与安全边界
新增或扩展的 IPC
- `knowledge:retrieve`
- `knowledge:settings:update`
- `knowledge:document:rebuild`
- `knowledge:library:rebuild`
- `knowledge:chunks:list`
- `knowledge:chunk:update`
- `knowledge:chunk:delete`
- `knowledge:reference:context`
- `knowledge:reference:open`
要求:
- 所有输入由共享 Zod Schema 校验。
- 所有处理器校验可信 Renderer sender。
- ID 必须重新检查知识库、来源、文档和分块归属。
- 列表使用有界分页,单次最多返回 200 个分块。
- 内容编辑限制单块最大字符数。
- 外部打开只接受数据库已保存的本地普通文件或 HTTP(S) URL。
- 不向 Preload 暴露原始数据库、Electron `shell` 或文件系统 API。
- 更新与重建遵守取消、超时、应用关闭和有界错误规则。
## 11. 交互与无障碍
- 复用 `PageTabs``SegmentedControl`、共享 Switch 和应用通知。
- 检索方式是互斥选项,使用 `SegmentedControl` 或语义化单选组。
- 分块启停是持久二元状态,使用 `role="switch"`
- 检索结果列表使用可访问名称,得分不得只用颜色表达。
- 检索工作台打开后焦点进入问题输入框,关闭后返回触发按钮。
- 分块编辑和删除对话框遵守焦点陷阱、Escape 和焦点恢复。
- 异步成功使用应用通知;字段错误、检索进度和可就地恢复错误保留在工作台。
- 窄窗口下检索结果改为单列,配置摘要保持可读,不隐藏降级状态。
## 12. 失败与恢复
| 场景 | 行为 |
| --- | --- |
| 向量查询失败 | 继续全文和图谱检索,显示降级原因 |
| 部分文档无向量 | 使用可用文档,显示完成数和失败数 |
| CJK 索引迁移失败 | 回滚迁移,不损坏旧 FTS |
| 重排失败 | 回退 RRF 排序并显示诊断 |
| 分块编辑后向量失败 | 保留编辑和全文索引,标记向量失败 |
| 单文档重建失败 | 保留上一版可用索引 |
| 同名模型端点变化 | 旧 Fingerprint 索引标记不兼容,等待重建 |
| 新向量重建失败 | 保留上一版就绪向量继续服务,单独记录失败尝试 |
| 原文件已移动 | 显示来源不可用,提供重试或移除 |
| 引用对象已删除 | 显示引用已失效,不打开任意替代路径 |
| 上下文超预算 | 按排名截断并明确标记 |
| 请求取消或应用关闭 | 停止新批次,释放句柄,不留下半替换索引 |
## 13. 埋点与评测
GoodBuddy 不上传私人检索查询或文档内容。本地诊断至少记录有界统计:
- 检索模式。
- 启用知识库数量。
- 各通道候选数和耗时。
- 是否发生降级。
- 最终结果数和上下文字符数。
- 重建文档数、成功数、失败数和取消状态。
手动验收使用仓库内不含私人内容的固定样例集,覆盖:
- 中文自然语言改写和同义词。
- 中英文混合产品名。
- 精确编号、路径和代码标识。
- 多文档冲突信息。
- 无答案问题。
- 10,000 个以上分块。
- 向量服务断开和模型维度变化。
## 14. 实施顺序
### 14.1 第一阶段
1. 共享设置、请求和检索响应契约。
2. SQLite 迁移和 CJK 索引。
3. 可扩展向量扫描、检索诊断和状态模型。
4. 检索设置与工作台。
5. 对话“每次先检索”。
6. 引用上下文和打开来源。
7. 第一阶段单元、IPC 和 Renderer 测试。
### 14.2 第二阶段
1. 结构分块和父子分块。
2. 本地重排与相关度。
3. 相邻块合并和上下文预算。
4. 分块预览、编辑、启停和删除。
5. 单文档与全库重建。
6. 第二阶段回归、性能和生产构建验证。
## 15. 验收标准
### 15.1 第一阶段
- 用户可在对话中选择“模型按需检索”或“每次先检索”。
- “每次先检索”在 Runtime 启动前产生检索诊断和引用,即使模型未调用工具。
- 未配置向量模型时,中文改写问题仍能通过 CJK 索引召回相关分块。
- 向量查询失败时回答可继续,界面明确显示已降级。
- 10,000 个分块的向量测试能够返回正确 Top K,不出现固定上限空结果。
- 同名模型切换端点后,不会读取 Fingerprint 不匹配的旧向量。
- 重建失败时,上一版已就绪向量仍能继续召回。
- 包含 2,000 个文件的目录同步能够处理第 501 至 2,000 个文档的修改与删除。
- 检索测试展示通道、候选数、排名、相关度、上下文和降级原因。
- 引用可以查看完整上下文并打开 Main 校验后的来源。
- 查询长度在共享契约、IPC、MCP 和数据库层保持一致。
### 15.2 第二阶段
- 用户可选择固定、结构或父子分块并显式重建。
- 父块不参与召回,子块命中后可提供父块上下文。
- 本地重排可以开启或关闭,并显示重排前后排名。
- 上下文严格遵守字符预算,重复和相邻片段按规则合并。
- 用户可预览、编辑、启停和删除分块。
- 分块修改后 FTS、CJK 和向量状态保持一致。
- 单文档重建失败不会破坏上一版可用索引。
- 所有新增操作可用键盘完成,并在浅色、深色和窄窗口下可用。
### 15.3 工程验证
所有源代码变更完成后必须通过:
```text
npm test
npm run typecheck
npm run lint
npm run build
```
外部或付费模型调用不属于自动验证,只有获得明确授权后才运行。
@@ -0,0 +1,523 @@
# 知识库检索与分块增强 User Stories
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 实施中 |
| 版本 | 0.1 |
| 日期 | 2026-08-11 |
| 关联 PRD | [知识库检索与分块增强 PRD](knowledge-rag-enhancement-prd.md) |
## 1. 角色
### 1.1 普通知识使用者
已经导入公司制度、产品手册或项目资料,希望直接提问并得到稳定、带来源的回答,
不需要理解向量、RRF 或分块算法。
### 1.2 知识库维护者
负责导入、同步和清理资料,需要知道哪些文档成功、哪些索引失败,以及如何修复
错误解析或错误分块。
### 1.3 RAG 调试者
需要用真实问题验证召回,比较不同参数和通道,定位“文档里有但没有命中”的
原因。
### 1.4 本地与内网用户
不能把资料上传到外部知识库服务,希望全文检索、分块、重排和诊断均在本机
完成,只在显式配置 Embeddings 后发送有界文本。
## 2. Epic A:明确控制是否检索
### US-A1 模型按需检索
作为普通知识使用者,我希望保留由模型判断是否需要检索的模式,以便一般闲聊
不会产生不必要的知识搜索。
验收:
- Given 当前启用了至少一个知识库并选择“模型按需检索”
- When 用户发送问题
- Then Main 只向本次请求开放已选知识库的只读搜索能力
- And 模型没有调用知识搜索时,不显示虚假的“已检索”
- And 未选中的知识库不可被工具参数扩大范围
### US-A2 每次先检索
作为普通知识使用者,我希望选择“每次先检索”,以便模型不能跳过已启用的
知识库。
验收:
- Given 当前启用了至少一个知识库并选择“每次先检索”
- When 用户发送文本问题
- Then Main 在 Runtime 启动前使用原始问题执行一次有界检索
- And 命中证据以不可信上下文进入 Runtime
- And 模型仍可通过只读工具执行后续改写检索
- And 页面明确显示“已预检索”“零结果”或“已降级”
- And 图片生成请求不执行知识预检索
### US-A3 请求级范围
作为普通知识使用者,我希望每次请求只使用我勾选的知识库,以免不相关资料
干扰回答。
验收:
- 新建知识库后只新增该知识库到当前选择,不自动重新启用已取消的知识库
- 删除知识库后从当前范围中移除对应 ID
- 同一请求最多启用 20 个知识库
- 对话输入区持续显示已选数量和检索方式
- 范围为空时检索方式不产生误导状态
## 3. Epic B:检索可见、可诊断
### US-B1 打开检索测试
作为 RAG 调试者,我希望在当前知识库直接输入问题并测试,以便不通过聊天模型
也能验证索引。
验收:
- 知识库标题区提供“测试检索”次操作
- 工作台打开后焦点进入查询输入框
- 查询最多 4,000 字符
- 测试不创建聊天消息、任务成果或模型调用
- 关闭工作台后焦点返回触发按钮
### US-B2 查看通道诊断
作为 RAG 调试者,我希望看到每种检索通道的结果和降级原因,以便判断问题来自
全文、向量还是图谱。
验收:
- 结果显示请求通道和实际使用通道
- 结果显示 FTS/CJK、向量和图谱候选数
- 结果显示总耗时和有界通道耗时
- 向量未配置、请求失败或索引不兼容时显示明确原因
- 不在错误或诊断中显示 API Key、Authorization 或完整文档
### US-B3 查看排名与上下文
作为 RAG 调试者,我希望看到候选排名、最终相关度和送入模型的上下文,以便
解释最终回答为什么使用这些资料。
验收:
- 每条结果显示文档、定位、片段和最终排名
- 可用时显示全文、向量、图谱独立排名和向量相似度
- 启用本地重排后显示重排前排名
- 展示相邻块或父块合并后的上下文
- 展示上下文字符数、预算和截断状态
### US-B4 零结果诊断
作为普通知识使用者,我希望零结果时获得具体原因,而不是只有空列表。
验收:
- 区分“知识库为空”“索引不可用”“查询无命中”“被阈值过滤”
- 提供修改关键词、检查状态或调整阈值的下一步说明
- 零结果不显示为首次使用空状态
- 检索测试保留原查询和设置,方便再次执行
## 4. Epic C:中文与混合检索
### US-C1 中文自然语言召回
作为中文用户,我希望不用输入原文中的连续短语,也能找到表达相同意思的内容。
验收:
- 中文索引生成连续二元词组
- 中文查询不会要求所有不同汉字同时出现
- 短查询具有有界单字回退
- 中英文、数字和产品标识混合查询仍能召回
- 相同查询和索引产生稳定排序
### US-C2 向量服务降级
作为本地与内网用户,我希望向量服务断开时仍可使用全文搜索,同时清楚知道
语义召回不可用。
验收:
- 查询向量失败不阻止 FTS/CJK 和图谱检索
- 检索响应包含向量降级原因
- 文档状态不把向量失败显示成全部完成
- 同名模型切换端点后,Fingerprint 不匹配的旧向量不得参与召回
- 重建失败时,上一版已就绪向量继续服务
- 修复配置并重建后,降级状态消失
- 故障信息经过脱敏
### US-C3 大知识库向量检索
作为知识库维护者,我希望超过 5,000 个分块后语义搜索仍然工作。
验收:
- 向量分批扫描没有固定 5,000 分块空结果
- 只保留所需最佳候选,内存不会随全库候选等比例增长
- 扫描支持取消和应用关闭
- 10,000 个以上分块的测试返回正确 Top K
- 诊断显示扫描数量与耗时
### US-C4 大目录完整同步
作为知识库维护者,我希望包含 2,000 个文件的目录也能完整增量同步,以免后半
部分文档长期保留旧内容。
验收:
- 第 501 至 2,000 个文档参与校验和比较
- 未变化文档不会重复解析和向量化
- 已删除文件对应文档会被移除
- 页面分页上限不影响后台同步完整性
### US-C5 调整召回参数
作为 RAG 调试者,我希望调整 Top K、最低相关度和通道权重,以便适配不同知识
类型。
验收:
- Top K、阈值、候选倍数和权重具有明确范围和默认值
- 至少一个召回通道权重大于 0
- 图谱关闭时图谱权重不可生效并说明原因
- 设置持久化到当前知识库,不影响其他知识库
- 非法输入不能跨 IPC
## 5. Epic D:真实索引状态
### US-D1 查看分阶段状态
作为知识库维护者,我希望分别看到解析、全文、向量和图谱状态,以便准确判断
文档能否使用。
验收:
- 文档不再用单个“ready”代表所有索引完成
- 全文完成但向量失败时,明确显示“全文可用、向量失败”
- 向量未启用与向量失败是不同状态
- 图谱按需、未启用和失败是不同状态
- 汇总显示全文可用数、向量完成数和失败数
### US-D2 修复失败文档
作为知识库维护者,我希望单独重建失败文档,而不是重新同步整个目录。
验收:
- 文档行提供“重建文档”
- 重建重新执行解析、分块、全文、向量和图谱
- 失败时保留上一版可用索引
- 完成后更新任务和状态
- 原文件不存在时保留可重试错误
### US-D3 修改设置后重建
作为知识库维护者,我希望分块设置修改后明确提示需要重建,以免误以为旧文档
已经使用新设置。
验收:
- 保存关键分块设置后显示“等待重建”
- 设置保存本身不删除现有索引
- 用户可选择全库重建
- 全库重建可取消
- 已成功替换的文档继续可用
## 6. Epic E:高级分块
### US-E1 固定分块
作为知识库维护者,我希望配置目标长度和重叠,以便处理日志、代码或简单文本。
验收:
- 目标长度为 400 至 8,000 字符
- 重叠不超过目标长度的 40%
- 优先在自然边界切分
- 每个块保留来源 section、定位和 ordinal
- 旧知识库迁移后不自动改变已有分块
### US-E2 结构分块
作为知识库维护者,我希望分块尽量保持标题和段落结构,以便命中片段保留语义。
验收:
- 优先保持解析 section
- Markdown 标题能够成为分块 heading
- 标题随子段落进入索引元数据
- 超长 section 仍按有界规则继续切分
- 空标题和空段落不创建分块
### US-E3 父子分块
作为 RAG 调试者,我希望小块负责准确召回、大块负责完整上下文,以便兼顾精度
和完整性。
验收:
- 父块和子块具有稳定关系
- 父块不直接进入 FTS/CJK/向量候选
- 子块命中后可返回父块上下文
- 引用突出实际命中的子块
- 父块输出仍受上下文预算和截断限制
## 7. Epic F:重排与上下文
### US-F1 本地重排
作为本地与内网用户,我希望在不调用外部模型的情况下改善候选排序。
验收:
- 本地重排默认关闭并可按知识库开启
- 使用 RRF、词覆盖、短语、标题、路径、向量和重复惩罚等确定性特征
- 结果相关度归一化到 0 至 1
- 检索测试显示重排前后排名
- 关闭时保持原 RRF 行为
- UI 不把本地算法描述为 AI Rerank 模型
### US-F1.1 学习型重排
作为需要更高排序质量的用户,我希望可选择兼容的学习型重排模型,并在服务
不可用时继续获得本地结果。
验收:
- 模式明确区分关闭、本地规则和学习型重排
- Main 最多发送 100 个候选,每个候选不超过 8,000 字符
- API Key 仅通过环境变量或 Main 加密存储使用,不进入 Renderer
- 超时、无效响应和服务错误回退本地重排,并显示脱敏诊断
- 用户取消和应用关闭必须终止请求,不得按普通降级吞掉
### US-F2 相邻分块合并
作为普通知识使用者,我希望命中片段包含必要的上下文,而不是孤立半句话。
验收:
- 可配置向前、向后相邻 0 至 2 个块
- 只合并同文档且 ordinal 连续的启用分块
- 同一块不会重复输出
- 每个原命中仍保留引用定位
- 合并结果遵守上下文预算
### US-F3 上下文预算
作为普通知识使用者,我希望低质量内容不会挤占模型上下文。
验收:
- 按最终相关度从高到低选择上下文
- 已选择的高排名证据不会被低排名证据替换
- 超预算时明确标记截断
- 预算范围为 2,000 至 48,000 字符
- IPC 和 Runtime 输入继续受总大小限制
### US-F4 上下文索引
作为知识库维护者,我希望检索可以利用文档结构,而引用仍忠于原文。
验收:
- 可按知识库启用上下文索引,并在修改后提示显式重建
- 标题、标题层级、页码和块类型使用有界确定性前缀进入 FTS、CJK 和向量文本
- 原始分块、引用、模型上下文和图谱证据不显示生成前缀
- FTS、CJK、向量和内容校验使用同一规范索引文本
## 7.1 Epic F+:受控本体与检索评估
### US-F5 每库受控本体
作为知识库维护者,我希望控制可用实体和关系类型,以便图谱保持一致。
验收:
- 每库保存实体类型、关系类型、双语名称、别名和可选端点约束
- 手工编辑使用受控选择器并拒绝未知类型或不兼容端点
- 图谱抽取按类型解析实体,保留人工锁定字段和跨类型边界
- 证据保存原文偏移、置信度、抽取来源和有界 provenance
- 本体或启用中的图谱策略变化标记需要重建
### US-F6 离线检索评估
作为 RAG 维护者,我希望用固定双语样本检测召回回归,而不读取用户数据或调用
网络服务。
验收:
- `npm run eval:retrieval` 使用临时 SQLite 和确定性内存 Provider
- 报告 Recall@5/10、MRR@10、nDCG@10、上下文精度/召回、无答案误报和延迟
- 提供词法、确定性向量、混合及本地重排消融
- 质量门槛按中英文分别检查,报告不包含原文、查询、端点、模型名或凭据
- 可选报告路径仅允许工作区内非符号链接文件
## 8. Epic G:分块维护
### US-G1 查看分块
作为知识库维护者,我希望查看某篇文档实际生成的分块,以便确认解析和切分质量。
验收:
- 文档行提供“查看分块”
- 列表显示序号、角色、标题、定位、字符数和启用状态
- 支持有界分页和文档内搜索
- 可查看完整单块内容
- 父子块关系可辨认但不只靠颜色表达
### US-G2 编辑分块
作为知识库维护者,我希望修正错误文本,以便问答使用正确内容。
验收:
- 编辑限制单块最大字符数
- 保存后同步更新全文和 CJK 索引
- 旧向量立即失效并触发当前文档重建
- 编辑块标记为人工修改
- UI 说明来源再次同步可能覆盖修改
- 保存失败保留用户草稿
### US-G3 启停分块
作为知识库维护者,我希望暂时停用有害或无关片段,而不永久删除它。
验收:
- 使用共享 Switch 和 `role="switch"`
- 停用块不参与任何召回通道
- 重新启用后恢复全文索引,并按需重建向量
- 状态更新失败时保留最后确认状态
- 引用已停用块时显示引用已失效
### US-G4 删除分块
作为知识库维护者,我希望删除确定无用的分块,以便避免错误召回。
验收:
- 删除前说明来源同步可能重新创建该块
- 删除使用具体动作和对象文案
- 删除联动清理全文、CJK、向量和图谱证据
- 删除最后一个可检索块后文档显示“无可检索内容”
- 不删除原始文件
## 9. Epic H:引用和来源
### US-H1 查看完整引用上下文
作为普通知识使用者,我希望从回答引用查看完整上下文,以便验证回答是否忠于
资料。
验收:
- 引用携带稳定 `libraryId``documentId``chunkId`
- 点击引用由 Main 重新校验对象归属
- 展示命中分块、相邻块或父块
- 展示知识库、文档、来源和定位
- 对已删除对象显示明确失效状态
### US-H2 打开原始来源
作为普通知识使用者,我希望从引用打开原文件或网页,以便继续阅读。
验收:
- 本地来源只通过数据库保存的普通文件路径打开
- 网页来源只允许数据库保存的 HTTP(S) URL
- Renderer 不能传入任意待打开路径或 URL
- 文件已移动时显示可恢复错误
- 不能跨平台精确跳页时仍显示原定位信息
### US-H3 引用与回答一致
作为普通知识使用者,我希望引用列表只显示本次实际检索到的内容。
验收:
- Main 只收集本次 capability token 产生的引用
- 预检索和模型后续检索引用去重
- 引用顺序遵循最终相关度和首次使用顺序
- 单消息引用数和序列化大小有明确上限
- 不把未检索文档显示为来源
## 10. Epic I:迁移、安全和兼容
### US-I1 无损迁移
作为现有用户,我希望升级后保留知识库、来源、分块、图谱和向量。
验收:
- SQLite 迁移在事务中执行
- 旧分块默认启用并视为 standalone
- 旧知识库获得兼容检索和分块设置
- CJK 索引回填失败时回滚迁移
- 升级不自动删除或重建原有内容
### US-I2 安全边界
作为本地用户,我希望新增功能不扩大 Renderer 和子 Runtime 权限。
验收:
- 新增 IPC 全部校验可信 sender 和共享 Schema
- Main 重新检查知识库、文档、分块和来源归属
- Renderer 不访问 SQLite、文件系统、Electron shell 或凭据
- 知识内容标记为不可信证据
- Ask 不获得写工具
- 错误和日志不包含密钥、授权头和未限制正文
### US-I3 取消和关闭
作为用户,我希望大库检索或重建可以停止,不留下损坏索引。
验收:
- 长向量扫描、单文档重建和全库重建响应 AbortSignal
- 应用关闭停止新批次并等待有界清理
- 文档级替换成功前继续使用上一版索引
- 取消状态区别于失败
- 取消不会删除原文件或用户维护的其他文档
## 11. 优先级映射
### 第一阶段
- US-A1、US-A2、US-A3
- US-B1、US-B2、US-B3、US-B4
- US-C1、US-C2、US-C3、US-C4、US-C5
- US-D1
- US-H1、US-H2、US-H3
- US-I1、US-I2
### 第二阶段
- US-D2、US-D3
- US-E1、US-E2、US-E3
- US-F1、US-F2、US-F3
- US-G1、US-G2、US-G3、US-G4
- US-I3
## 12. Definition of Done
每个 User Story 只有在以下条件全部满足时才完成:
1. Main、Preload、Renderer 和共享契约保持明确边界。
2. 行为有聚焦的单元、IPC 或组件回归测试。
3. 中英文文案同时更新。
4. 浅色、深色、键盘和窄窗口核心流程可用。
5. 失败、取消、空结果和降级状态均有独立表现。
6. 不覆盖用户现有未提交或未跟踪文件。
7. `npm test``npm run typecheck``npm run lint``npm run build`
全部通过。
@@ -0,0 +1,112 @@
# Knowledge retrieval evaluation
GoodBuddy's retrieval evaluation is an offline Vitest suite that exercises the
real `KnowledgeService` and `KnowledgeDatabase` retrieval path without changing
production data. Run it with:
```text
npm run eval:retrieval
```
By default the suite returns the report only to its tests and leaves no file.
To retain a JSON report, set `GOODBUDDY_RETRIEVAL_EVAL_OUTPUT` to a
workspace-relative file path. Absolute paths and paths escaping the workspace
are rejected.
## Corpus and labels
The committed `synthetic-bilingual-v1` fixture is wholly synthetic, bilingual
(Simplified Chinese and English), and CC0. Stable document, chunk, and query IDs
make changes reviewable. The strict Zod schema bounds every field and rejects
unknown fields, duplicate or dangling IDs, inexact annotations, and
path/endpoint/secret-like values. It also rejects degenerate label sets: each
language must contain both an answerable and a no-answer query.
Each answerable query has graded chunk judgments:
- `3`: directly answers the question.
- `2`: substantially answers it.
- `1`: useful supporting evidence.
Every judgment also contains one or more exact, verbatim answer spans from its
chunk. A no-answer query has no judgments. When adding labels, two reviewers
should independently check relevance grades and exact spans, resolve
disagreements, then update the fixture version or ID when the corpus meaning
changes.
## Evaluation design
Each run creates a temporary SQLite database and directly seeds the production
knowledge classes with stable IDs. It uses deterministic in-memory embedding
providers with stable fingerprints; it does not read API keys, environment
provider settings, user databases, or network resources. Five ablations use
the same corpus:
1. lexical retrieval only;
2. topic-agnostic deterministic token-hash vector retrieval;
3. handcrafted-alias vector retrieval;
4. lexical/vector hybrid retrieval;
5. hybrid retrieval with the local heuristic reranker.
The token-hash provider hashes normalized input tokens without topic-specific
knowledge, so it is a transparent lexical-overlap vector ablation. The
handcrafted bilingual alias provider exists only as **regression plumbing** to
exercise vector, hybrid, and rerank production paths with stable cross-language
matches. It is fixture-aware and is not an embedding-quality model or a claim
about real provider quality.
The suite runs twice and compares the deterministic projection (IDs, hashes,
rank metrics, and failures). Wall-clock latency is intentionally excluded from
that equality check.
## Metrics
- **Recall@5 / Recall@10:** fraction of all annotated relevant chunks returned
within the cutoff, macro-averaged over answerable queries.
- **MRR@10:** reciprocal rank of the first relevant chunk, with zero when none
appears in the first ten.
- **Graded nDCG@10:** discounted cumulative gain using `2^grade - 1`, divided
by the ideal graded ordering.
- **Context precision:** characters in exact annotated spans found in returned
context divided by all returned context characters.
- **Context recall:** characters in exact annotated spans found in returned
context divided by all annotated span characters.
- **No-answer false-positive rate:** no-answer queries that return any result
divided by all no-answer queries.
- **Latency:** count, minimum, median, p95, maximum, and arithmetic mean in
milliseconds for each ablation. These are diagnostic, not deterministic
gates.
Rankings are deduplicated by chunk ID before cutoffs and ranking metrics are
computed. Overlapping or nested exact evidence spans are unioned, so duplicate
rank entries and overlapping annotations cannot inflate context precision or
recall. Aggregate metrics are also emitted per language.
## Privacy
Reports contain only fixture/query/ablation IDs, a SHA-256 corpus hash, an
evaluation-definition hash, a hash of provider definitions, aggregate metrics,
latency summaries, and ID-based actionable failures. The
`evaluationDefinitionHash` covers fixture version/ID, raw queries, judgments,
retrieval settings, ablations, provider definitions, and metric version; it
changes when the evaluated contract changes without disclosing that contract.
Reports omit raw queries, document titles, corpus text, snippets/context,
source paths, endpoints, fingerprints, model names, credentials, metadata, and
vectors. The integration test checks every fixture title, chunk, query, and
private provider identifier against the serialized report.
Retained report paths must be workspace-relative. Resolution uses async
filesystem APIs, rejects absolute/traversal paths and null bytes, checks each
parent component, and refuses symlink traversal or a symlink destination. The
report is first written to a same-directory temporary file and then renamed.
## Quality gates
The integration test gates stable lexical, topic-agnostic token-hash,
regression-vector, hybrid, context-precision/context-recall, and per-language
baselines. It also requires reranked MRR@10 of at least 0.78, reranked nDCG@10
of at least 0.75, no-answer false positives no higher than 0.34, and prevents
local reranking from reducing hybrid nDCG@10 by more than 0.05. Exact nDCG
arithmetic has a focused unit test. Gates are fixture baselines rather than
universal production-SLA claims; adjust them only with a reviewed fixture or
justified retrieval behavior change.
+388
View File
@@ -0,0 +1,388 @@
# 并行实验工作台 PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-08-13 |
| 依赖 | [自动化平台总体设计](./automation-platform-architecture.md)、[自动任务与目标 PRD](./automation-goals-and-scheduling-prd.md) |
## 1. 背景
GoodBuddy 已能把一个请求并行委派给最多三个只读专家,再综合结果。这适合“一次请求,
多种专业视角”,但不等同于实验:当前没有结构化变量、重复运行、统一指标、结果晋升和
Run 级记忆隔离。
本功能借鉴 MesaLogo ParallelLab 中变量隔离、批量 Run、失败结算、指标比较和运行证据
的思想,但不引入其 Action Space、服务端队列或重型仿真平台。
## 2. 产品定义
并行实验是在冻结的研究问题和执行协议下,生成多个相互隔离的候选 Run,以相同评估标准
比较结果,并将结论追溯到运行证据。
```text
Experiment
├─ Question / Hypothesis
├─ Protocol
├─ Variables and Variants
├─ Objectives and Evaluators
├─ Budget and Stop Conditions
└─ ExperimentRun × N
├─ Isolated Conversation
├─ Isolated Run Memory
├─ Tasks and Artifacts
├─ Metrics
└─ Evidence
```
## 3. 已确认的产品决策
1. 实验 Run 复用现有 Task、Runtime、Artifact 和审批机制。
2. 每个 Run 有独立变量、会话、运行记忆、任务和成果。
3. 默认实验是只读 Ask;写工作区的实验后续使用每 Run 独立沙箱。
4. 多个 Run 不能并发修改同一个用户工作区。
5. 失败、取消、预算耗尽或结果不完整的 Run 不参与最佳结果选择。
6. 没有成功 Run 时实验为失败或无结论,不能报告成功。
7. 模型生成的实验协议必须可审查、编辑和版本化。
8. 评估优先使用确定性指标;模型 Rubric 显示评估器版本和不确定性。
9. “最佳”只针对声明的目标和约束,不代表普遍最好。
10. 最佳结果只能创建候选,不能直接覆盖计划、记忆或工作区。
## 4. 目标
- 把问题转为可审查的实验问题、变量、候选和指标。
- 比较不同提示、模型、专家组合、参数或方案。
- 监控每个 Run 的状态、成本、证据和失败原因。
- 查看结果表、差异、稳定性和评估依据。
- 从候选创建普通任务、计划草稿或记忆候选。
- 为持续学习提供回放和非退化评估基础。
## 5. 非目标
- 第一阶段不模拟数千 Agent 或社会群体涌现。
- 不实现任意连续参数的自动贝叶斯优化。
- 不在样本不足时宣称统计显著性。
- 不把模型的自报置信度直接作为跨模型比较指标。
- 不允许实验自行增加样本数、预算或能力范围。
- 不允许自动部署结果或修改安全策略。
- 不把专家团队的一次回答自动包装成科学实验。
## 6. 实验类型
| 类型 | 变量示例 | 用途 |
| --- | --- | --- |
| Prompt 对比 | 系统说明、输出格式、示例 | 比较自动化协议 |
| 模型对比 | 已配置文本模型 | 质量、速度和 Token 权衡 |
| 专家组合 | 专家集合、综合策略 | 多视角研究 |
| 参数扫描 | 检索模式、Top K、轮数 | 寻找有限参数组合 |
| 方案候选 | 多个用户或模型方案 | 按统一 Rubric 比较 |
| 回放评估 | 历史脱敏案例集合 | 验证学习候选是否退化 |
后续多轮情景模拟需要单独定义角色、环境和状态变量。
## 7. 创建流程
### 7.1 研究问题
用户填写:
- 实验名称。
- 问题和可选假设。
- 探索、比较、优化或回放验证类型。
- 项目范围。
- 期望输出。
- 禁止行为。
### 7.2 协议
`ExperimentProtocol` 包含:
- 基准输入或案例集。
- 固定提示和步骤。
- 变量与候选。
- Runtime、模型和专家。
- 工具、知识库和记忆范围。
- 工作模式。
- 每 Run 预算。
- 指标、评估器和停止条件。
- 重复次数。
模型生成协议草稿时必须标明用户字段、模型建议、确定性指标和模型判断指标。
### 7.3 变量
```ts
type ExperimentVariable =
| { name: string; type: 'enum'; values: JsonValue[] }
| {
name: string
type: 'range'
start: number
end: number
step: number
}
| { name: string; type: 'boolean' }
| { name: string; type: 'prompt_variant'; values: string[] }
| {
name: string
type: 'model_profile'
profileIds: string[]
}
| {
name: string
type: 'expert_set'
expertIdSets: string[][]
}
```
第一阶段只支持有限、确定生成的组合。保存前展示组合数、重复后 Run 总数、最大模型调用、
Token 和耗时范围,以及最大并发。超过上限时要求缩小变量,不静默抽样。
### 7.4 基准与候选
- 至少一个 Variant。
- 对比实验建议设置 Baseline。
- Baseline 与 Candidate 使用相同案例和评估器。
- 评估器不能读取 Variant 标签和模型名称作为质量信号。
- 模型评分时随机化候选顺序并保存实际顺序。
## 8. Run 隔离
### 8.1 数据隔离
每个 Run 独立拥有:
- `experimentRunId` 和运行会话。
- 变量快照和临时上下文。
- Run 记忆分区。
- 任务、子任务和成果。
- 指标、证据和 Runtime 会话标识。
禁止:
- Run A 读取 Run B 的消息、临时记忆或中间成果。
- 多个 Run 共享可变变量对象。
- Run 候选记忆在实验结算前进入其他 Run。
- 通过全局列表误取其他项目或实验数据。
### 8.2 工作区隔离
阶段 1 只支持 Ask 和只读工具。阶段 2 的 Execute Run 使用独立临时沙箱或版本化工作树,
结果以 Patch 或成果展示,用户选择候选后再进入单独应用流程。
### 8.3 记忆隔离
Run 只读取冻结的 Global、Project、Automation 记忆快照和自己的 Run 分区,
不读取其他 Run 或实验期间新产生的候选记忆。
## 9. 调度与预算
- 默认最大并发 3,与现有子专家调度能力一致。
- 还需遵守全局后台并发和模型连接并发。
- 每个 Variant 使用相同的单 Run 预算。
- 不因候选暂时领先而静默给它更多预算。
- 提前停止必须来自预先声明的规则。
- UI 显示运行、排队、成功、失败和取消数量。
停止条件:
```ts
type ExperimentStopCondition =
| { type: 'all_runs_terminal' }
| { type: 'successful_run_count'; count: number }
| {
type: 'metric_threshold'
metric: string
operator: string
value: number
}
| { type: 'budget' }
| { type: 'deadline'; at: string }
| { type: 'manual' }
```
触发停止后不启动新 Run;是否取消正在运行的 Run 必须在条件中明确。保存停止原因,
未运行 Variant 不参与最终比较。
## 10. 评估与指标
### 10.1 指标类型
| 类型 | 示例 |
| --- | --- |
| 确定性结果 | Schema 有效、测试通过、文件存在、检查项完成 |
| 运行指标 | 耗时、模型调用、Token、工具调用、成果大小 |
| 检索指标 | 召回、引用覆盖、降级状态 |
| 人工评分 | 正确性、可用性、偏好 |
| 模型 Rubric | 结构、完整性、表达、风险 |
### 10.2 模型 Rubric
必须保存 Rubric 版本、评估模型、输入证据摘要、候选展示顺序、分项得分、结构化理由和
格式修复。它不能覆盖确定性失败,也不能在缺少证据时编造事实正确性判断。
### 10.3 多目标
```ts
type ExperimentObjective = {
metric: string
direction: 'maximize' | 'minimize' | 'target'
weight?: number
target?: number
hardConstraint?: boolean
}
```
结算先排除非成功和违反硬约束的 Run,再计算其余指标。存在明显权衡时展示 Pareto 候选,
不强行选唯一最佳。
## 11. 结算规则
Run 成功要求:
- Runtime 正常结束。
- 必填成果存在。
- 必填评估器成功。
- 未违反硬约束。
- 没有结果未知的副作用。
Experiment 结算:
| 情况 | 状态 |
| --- | --- |
| 至少一个成功 Run,所需 Run 已结算 | `completed` |
| 所有 Run 失败或无有效结果 | `failed` |
| 提前停止且已有可比较结果 | `stopped_with_results` |
| 提前停止且无可比较结果 | `cancelled` |
| 指标冲突或证据不足 | `inconclusive` |
最佳结果展示 Variant、参数、成功和失败数量、重复运行原始值与聚合、目标分项、硬约束、
证据和限制。只有一个成功 Run 时使用“当前最高分候选”,不使用“稳定最佳”。
## 12. 重复与复现
- 每个 Variant 默认重复 1 次,波动敏感实验建议至少 3 次。
- 重复 Run 使用相同变量和独立运行会话。
- Runtime 支持种子时保存种子,否则明确标注不可完全复现。
- 聚合展示原始值、中位数或均值,并说明计算方式。
- 样本不足时不展示统计显著性结论。
## 13. 会话监督接入
Supervisor 可以检查偏离协议、遗漏必填输出、证据不足和候选间协议不一致;
确定性预算或权限违规可以暂停 Run,模型判断默认只警告或请求人工复核。
Supervisor 不能:
- 根据其他候选结果提示当前 Run。
- 临时修改某个候选协议。
- 自动提高预算或批准工具。
## 14. 信息架构
实验工作台页签:
1. **设计**:问题、协议、变量、指标和预算。
2. **运行**:总体进度、Run 表和状态。
3. **比较**:指标表、图表、差异和 Pareto 候选。
4. **证据**:按结论、指标和 Run 查看证据。
5. **结论**:总结、限制和后续操作。
Run 详情展示参数、协议版本、时间线、消息、任务、成果、监督记录、指标、评估理由、
上下文和记忆快照、Token、耗时与错误。
## 15. 后续操作
允许:
- 用候选参数创建普通任务。
- 创建自动化计划草稿。
- 保存实验模板。
- 创建记忆候选。
- 追加确认 Run。
- 导出脱敏结果摘要。
不得自动启用新计划、覆盖现有计划、确认长期记忆、应用工作区 Patch 或扩大权限。
## 16. 数据模型建议
```ts
type Experiment = {
id: string
projectId?: string
name: string
question: string
status:
| 'draft'
| 'queued'
| 'running'
| 'paused'
| 'completed'
| 'failed'
| 'stopped_with_results'
| 'inconclusive'
| 'cancelled'
protocolVersion: number
totalRuns: number
successfulRuns: number
failedRuns: number
}
type ExperimentRun = {
id: string
experimentId: string
variantId: string
repetition: number
automationRunId: string
variables: Record<string, JsonValue>
status: string
goalStatus: 'met' | 'not_met' | 'inconclusive'
}
```
建议表:
- `experiments`
- `experiment_protocol_versions`
- `experiment_variants`
- `experiment_runs`
- `experiment_run_metrics`
- `experiment_evidence`
- `experiment_conclusions`
## 17. 安全与隐私
1. 协议、案例、输出和评估输入都视为不可信数据。
2. Renderer 不能指定其他项目的 Run 或记忆分区。
3. 每个 Run 使用唯一 Runtime conversation ID,并在结束后释放。
4. 实验默认不能写用户工作区。
5. 模型对比不能传递其他供应商的凭据或隐藏配置。
6. 导出默认不包含完整私人案例、提示、消息或文件内容。
7. 取消实验传播到排队和运行任务,但不伪装撤销已有副作用。
8. 实验删除不能误删已由用户独立保存的成果或计划候选。
## 18. 实施顺序
1. 建立 Experiment、Variant、Run 聚合实体和只读 Ask Run。
2. 实现有限组合、预算估算、并发调度和运行监控。
3. 增加确定性指标、失败结算和结果比较。
4. 增加模型 Rubric、人工评分和证据工作台。
5. 增加重复运行和回放评估。
6. 最后评估独立工作树中的 Execute 实验。
## 19. 验收标准
- [ ] 保存前显示变量组合、重复后 Run 总数和最大预算。
- [ ] 每个 Run 的会话、变量、记忆、任务和成果相互隔离。
- [ ] 默认实验无法写用户工作区。
- [ ] 最大并发和全局后台预算同时生效。
- [ ] 各 Variant 使用相同单 Run 预算。
- [ ] 失败、取消、预算耗尽和不完整 Run 不参与最佳选择。
- [ ] 全部 Run 失败时实验不报告成功或最佳结果。
- [ ] 模型 Rubric 显示版本、模型、分项和证据。
- [ ] 多目标冲突时可以展示多个 Pareto 候选。
- [ ] 用户可从候选创建草稿,但不会自动部署或确认记忆。
- [ ] 结论能追溯到具体 Run、指标、成果和证据。
+482
View File
@@ -0,0 +1,482 @@
# 分区记忆 PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-08-13 |
| 依赖 | [自动化平台总体设计](./automation-platform-architecture.md) |
## 1. 背景
GoodBuddy 当前记忆已经支持:
- `global``project``conversation` 三种作用域。
- `preference``fact``summary``procedure` 四种类型。
- `proposed``confirmed``rejected` 三种状态。
- 智能心跳提出 Global 或 Project 记忆候选,由用户确认。
但当前能力仍不足以支撑自动化和并行实验:
1. 交互请求会把已加载列表中的最多 20 条已确认记忆直接拼入提示,缺少查询相关度和明确的
会话级过滤契约。
2. 数据库有会话作用域,但心跳只提出 Global 和 Project 记忆。
3. 缺少 Automation、Experiment 和 Run 分区。
4. 来源字段存在于表结构,但普通创建和心跳候选尚未完整保存来源关系。
5. 缺少事实的有效时间、冲突、替代、访问记录和衰减。
6. 实验 Run 若共享可变记忆,会造成候选互相污染。
本设计先完成分区、来源、检索和生命周期,再评估时间知识图谱。
## 2. 核心产品判断
### 2.1 分区是权限和隔离边界
分区不是搜索标签。每次读取先根据运行快照确定允许分区,再在这些分区中检索。
模型不能请求任意分区 ID,Renderer 也不能把任意 ID 作为可信范围。
### 2.2 作用域和记忆种类是两个维度
- 作用域回答“谁可以读取”。
- 类型回答“这是什么信息”。
不能用 `summary` 表示会话范围,也不能用 `project` 表示事实类型。
### 2.3 记忆和知识库分离
| 记忆 | 知识库 |
| --- | --- |
| 用户偏好、项目约定、过程经验、会话摘要 | 文档、网页、文件和外部资料 |
| 小规模、动态、可确认和可遗忘 | 大规模、按来源同步和引用 |
| 强调作用域、来源、时态和行为影响 | 强调检索、分块和证据引用 |
不能把整个文档或长工具输出保存为记忆。
### 2.4 第一阶段不需要图数据库
SQLite 显式字段、FTS、来源关系和可选本地 Embedding 足以支持首期。时间图谱只有在以下
需求经过验证后再建设:
- 实体关系的多跳查询。
- 事实有效期和关系演变。
- 同一实体跨大量会话的别名消歧。
- 可解释的关系证据链。
## 3. 目标
- 为会话、自动化和并行 Run 提供严格隔离。
- 每条记忆显示范围、类型、状态、来源、时间和敏感度。
- 在允许分区内按相关性、重要性、新鲜度和预算检索。
- 保留冲突事实和时态,不静默覆盖。
- 让候选记忆经过确认或评估后再晋升。
- 支持编辑、移动、合并、拒绝、归档、删除和要求忘记。
- 记录哪些 Run 实际读取了哪些记忆。
## 4. 非目标
- 不保存完整聊天、文档、工具日志或隐藏推理作为记忆。
- 不自动确认敏感个人信息。
- 不默认跨项目共享 Project、Conversation 或 Run 记忆。
- 不允许模型自行创建新分区或跨分区移动记忆。
- 不承诺记忆中的事实永远正确。
- 第一阶段不建设 Memory Palace 五层空间隐喻。
- 不把向量相似度作为权限判定。
## 5. 分区模型
### 5.1 分区类型
```ts
type MemoryNamespaceKind =
| 'global'
| 'project'
| 'conversation'
| 'automation'
| 'experiment'
| 'run'
| 'agent'
```
| 分区 | 内容 | 生命周期 |
| --- | --- | --- |
| Global | 用户长期偏好和跨项目通用约定 | 长期,严格确认 |
| Project | 项目术语、目标、决策和流程 | 随项目 |
| Conversation | 当前会话摘要、局部约定和待澄清信息 | 随会话或短期 |
| Automation | 某计划的稳定协议经验和运行约定 | 随计划 |
| Experiment | 实验设计、结论和限制 | 随实验 |
| Run | 单次运行观察、中间状态和临时经验 | 短期、严格隔离 |
| Agent | 某专家或角色的个性化经验 | 后续,默认关闭 |
首期实现 Global、Project、Conversation、Automation 和 Run。Experiment 可复用
Automation 机制后增加;Agent 必须在专家长期身份明确后再开放。
### 5.2 分区标识
```text
global
project:{projectId}
conversation:{conversationId}
automation:{planId}
experiment:{experimentId}
run:{automationRunId}
agent:{expertId}
```
数据库使用 UUID 外键和显式 `kind`,上述字符串只用于日志和展示,不作为未经验证的访问凭据。
### 5.3 读取链
交互会话推荐:
```text
Conversation → Project → Global
```
自动化 Run
```text
Run → Automation → Conversation(可选)→ Project → Global
```
实验 Run
```text
Run → Experiment frozen snapshot → Project frozen snapshot → Global frozen snapshot
```
各层使用独立结果数和字符预算。Run 层不能覆盖权限更高层,只能提供更具体上下文。
## 6. 记忆条目
```ts
type MemoryItem = {
id: string
namespaceId: string
kind:
| 'preference'
| 'fact'
| 'summary'
| 'procedure'
| 'decision'
| 'constraint'
| 'reflection'
content: string
status:
| 'candidate'
| 'confirmed'
| 'rejected'
| 'superseded'
| 'archived'
confidence: number
salience: number
sensitivity: 'normal' | 'sensitive' | 'restricted'
validFrom?: string
validTo?: string
expiresAt?: string
sourceId: string
supersedesId?: string
createdAt: string
updatedAt: string
}
```
兼容映射:
- 当前 `proposed` 对应 `candidate`
- 当前 `confirmed``rejected` 保留。
- 当前四种类型保留,并按真实需求增加 `decision``constraint``reflection`
## 7. 来源与证据
### 7.1 来源类型
```ts
type MemorySource =
| { type: 'user_entry'; createdBy: 'user' }
| {
type: 'message'
conversationId: string
messageId: string
}
| { type: 'task'; taskId: string; eventId?: string }
| { type: 'heartbeat'; heartbeatRunId: string; entryId: string }
| { type: 'supervisor'; supervisorRecordId: string }
| { type: 'automation_run'; automationRunId: string }
| {
type: 'experiment_conclusion'
experimentId: string
conclusionId: string
}
| { type: 'artifact'; artifactId: string }
```
### 7.2 来源规则
- 每条非用户手动记忆必须有来源。
- 来源被删除时记忆不一定删除,但显示“来源不可用”并降低可信度。
- 来源内容不复制进记忆表,只保存有界证据摘要和引用。
- 用户确认只表示允许后续使用,不表示事实已被外部验证。
- Supervisor 判断只能生成候选,不能直接生成确认事实。
## 8. 候选生成
候选来源:
- 智能心跳。
- 用户明确“记住这个”。
- 会话结束总结。
- 自动化 Run 结束反思。
- 实验结论。
- Supervisor 建议后用户采纳。
候选生成必须:
- 限制数量和长度。
- 检查同分区近似重复。
- 标记推断和不确定性。
- 不自动提取密码、密钥、身份号码、健康和财务等敏感信息。
- 不把指令型工具输出自动当作用户偏好。
- 不从助手自己的未确认陈述提取事实。
## 9. 确认与晋升
### 9.1 允许路径
```text
Run candidate
→ Automation candidate
→ Project candidate
→ Global candidate
```
每次跨层都是显式晋升,不是移动原记录:
- 保留原候选和来源。
- 创建目标分区新版本。
- 保存晋升理由、评估和操作者。
- 可回滚到晋升前状态。
### 9.2 确认规则
- Global 默认必须人工确认。
- Project 默认人工确认,可对特定低敏感模板启用批量确认。
- Conversation 可以由用户“记住”直接确认。
- Automation 和 Run 由自动化协议决定,但只在自身范围有效。
- Experiment 结论必须结算成功且显示证据,才可成为 Project 候选。
### 9.3 拒绝
拒绝后:
- 不进入检索。
- 保存规范化摘要指纹,减少重复建议。
- 用户可查看和恢复。
- 不把拒绝内容回填给模型,除非用于“避免重复建议”的有界规则。
## 10. 检索
### 10.1 两步边界
```text
根据可信运行上下文确定允许分区
→ 在允许分区中检索和排序
```
这两步不能颠倒。先全库相似搜索再过滤会增加泄漏和实现风险。
### 10.2 排序
建议综合:
- 文本相关度。
- 可选向量相关度。
- Salience。
- Confidence。
- 新鲜度和有效时间。
- 类型匹配。
- 分区优先级。
- 最近是否已使用。
只有 `confirmed`、当前有效且敏感度允许的记忆进入普通上下文。
### 10.3 预算
建议默认:
| 层级 | 最大条数 | 最大字符 |
| --- | --- | --- |
| Run | 8 | 4,000 |
| Automation / Experiment | 8 | 4,000 |
| Conversation | 8 | 4,000 |
| Project | 10 | 5,000 |
| Global | 6 | 3,000 |
总预算还受模型上下文组装器限制。不能每层取满后无界拼接。
### 10.4 上下文格式
提供给 Runtime 的每条记忆包含:
- 类型。
- 范围。
- 内容。
- 有效时间。
- 来源类型和可选引用。
- 不确定或冲突标记。
可信指令明确说明记忆是用户确认的信息或候选证据,不是系统指令。
## 11. 冲突与时态
### 11.1 冲突
新条目与现有条目冲突时:
- 不静默覆盖。
- 创建冲突关系。
- 向用户展示两个内容、来源、时间和范围。
- 用户可选择保留两者、设定有效期、替代旧条目或拒绝新条目。
### 11.2 时态
事实和决策支持:
- `validFrom`:何时开始有效。
- `validTo`:何时不再有效。
- `observedAt`:何时被系统观察。
- `createdAt`:何时写入数据库。
例如“项目目标是 8 月发布”变更为“延期到 9 月”时,旧事实保留历史有效期,新事实成为
当前有效版本。
### 11.3 适用范围冲突
Project 记忆与 Global 偏好冲突时:
- 当前 Project 的更具体约定优先。
- 上下文中标明这是项目级覆盖。
- 不修改 Global 原记录。
## 12. 实验隔离
- 实验启动时冻结可读长期记忆快照。
- 各 Run 拥有独立 Run 分区。
- Run 期间产生的候选不互相可见。
- 实验结算后只从成功 Run 和有效证据生成 Experiment 候选。
- 最佳 Run 的临时经验不会自动晋升。
- 重跑相同协议可以选择复用原冻结快照或创建新版本,必须明确显示。
## 13. 生命周期与衰减
### 13.1 访问记录
保存有界使用记录:
- 哪个 Run 检索了该记忆。
- 是否实际进入模型上下文。
- 是否被用户或评估器认为有用。
- 最近使用时间和命中次数。
不保存完整请求副本。
### 13.2 衰减
- Preference、Constraint 和 Procedure 不仅因时间自动失效。
- Conversation、Run Summary 和 Reflection 可配置过期时间。
- 长期未命中、低 Salience 的候选可归档。
- 衰减先影响排序,再进入归档,不直接硬删除。
- Restricted 记忆可采用更短保留期。
### 13.3 删除与忘记
- 删除记忆后立即停止检索。
- “忘记”同时清理派生索引、Embedding 和缓存。
- 来源消息是否删除由其自身生命周期决定,不能反向静默删除用户会话。
- 删除 Project 时清理其分区、自动化和 Run 记忆,不影响 Global。
- 审计只保留不含原内容的删除事件和 ID 摘要。
## 14. 敏感信息
| 敏感度 | 行为 |
| --- | --- |
| Normal | 按普通确认和检索规则 |
| Sensitive | 必须人工确认,UI 持续标记 |
| Restricted | 默认不允许模型自动生成;仅用户手动创建,读取需要显式启用 |
禁止自动长期记忆:
- 密码、密钥、Token、Cookie。
- 完整身份证件、银行卡和账户凭据。
- 未经用户明确要求的健康、财务和高度私密信息。
- 工具输出中的认证数据。
## 15. 信息架构
记忆中心建议页签:
1. **记忆**:按范围、类型、状态和敏感度浏览。
2. **待确认**:候选、冲突和晋升请求。
3. **分区**Global、Project、Conversation、Automation、Run 的统计和访问策略。
4. **使用记录**:哪些 Run 使用了哪些记忆。
5. **设置**:候选生成、保留期、敏感信息和检索预算。
每条记忆展示内容、类型、范围、来源、状态、时间、置信度、重要性和冲突。
## 16. 数据模型建议
建议表:
- `memory_namespaces`
- `memory_items`
- `memory_sources`
- `memory_relations`
- `memory_access_events`
- `memory_promotion_events`
- `memory_embeddings`,可选
现有 `memory_items` 可渐进迁移:
1. 增加 Namespace 并回填现有 Scope。
2. 回填来源为空的旧记录为 `legacy_unknown`
3. 增加状态和类型兼容映射。
4. 上线新检索器后再停止旧的列表拼接方式。
## 17. 安全与隐私
1. 分区解析只在 Main 进行。
2. 所有 ID 重新验证对象归属和项目范围。
3. Renderer 无法指定任意分区进行搜索。
4. Runtime 只能获得有界记忆文本和来源摘要。
5. Embedding 只能发送用户已配置允许的记忆,Restricted 默认不发送外部服务。
6. 记忆内容和来源不出现在普通日志与通知。
7. 跨分区晋升需要明确操作和审计。
8. Ask 和 Execute 使用同一只读记忆检索边界。
9. 记忆不能绕过系统指令、工具审批和工作区权限。
## 18. 实施顺序
1. 修正当前交互请求的范围过滤,确保只读 Global、当前 Project 和当前 Conversation。
2. 增加来源记录和“实际进入上下文”的诊断。
3. 建立 Automation 和 Run Namespace。
4. 上线有界相关检索,替换简单列表前 20 条拼接。
5. 增加冲突、时态、替代和归档。
6. 增加实验冻结快照与 Run 隔离。
7. 增加可选本地 Embedding 和混合排序。
8. 只有明确需求后再评估时间知识图谱。
## 19. 验收标准
- [ ] 普通会话只读取 Global、当前 Project 和当前 Conversation 的允许记忆。
- [ ] 自动化 Run 只读取运行快照绑定的分区。
- [ ] 实验 Run 不能读取其他 Run 的消息或记忆。
- [ ] 每条非手动记忆都有可追溯来源。
- [ ] 候选和被拒绝记忆不进入普通上下文。
- [ ] Global 和 Project 晋升需要明确确认或评估。
- [ ] 冲突事实不被静默覆盖。
- [ ] 当前有效事实可通过有效时间正确选择。
- [ ] 上下文组装遵守各层和总字符预算。
- [ ] UI 能显示某次 Run 实际使用的记忆。
- [ ] 删除或忘记后,文本、索引和缓存不再可检索。
- [ ] Restricted 记忆不会自动生成或发送给外部 Embedding 服务。
@@ -0,0 +1,788 @@
# 远程消息通道项目与微信 ClawBot 集成 PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施,待真实微信账号联调 |
| 版本 | 1.4 |
| 日期 | 2026-08-09 |
| 适用产品 | GoodBuddy 桌面端 |
| 首期范围 | 微信 ClawBot、企业微信、钉钉的通道项目;微信文字、图片和文件 Ask 与 Execute |
## 1. 背景
GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入系统管理的通道项目。该信息架构解决了早期通道仅存在于设置页和后台服务中,远程消息进入任务系统后无法持续、明确识别以下信息的问题:
1. 消息来自哪个平台。
2. 当前正在与哪个发送者或群聊对话。
3. 远程执行任务使用哪个工作目录。
4. 执行请求、处理后端、工具调用和结果归属于哪个范围。
腾讯官方微信 ClawBot 使用扫码授权,不使用 App ID 或 Secret,并由本机独立 Sidecar 持续运行通信服务。文字、图片和文件收发、只读对话与受控执行已经实现;真实微信账号的认证媒体端到端联调仍作为发布前手动验收。
为避免把通道来源、发送者身份和执行范围混在一起,本功能使用“通道项目 + 远程会话”的两级结构:
- 通道项目标识平台并确定默认工作目录、处理后端和默认模式。
- 远程会话标识具体发送者或群聊。
- 消息记录具体发送者和本次实际使用的模式。
- 任务与活动记录执行、工具调用和结果。
## 2. 已确认的产品决策
1. 微信 ClawBot、企业微信、钉钉分别对应一个系统管理的通道项目。
2. 三个通道项目在通道设置服务初始化时幂等创建,不等待用户完成连接配置。
3. Renderer 不直接创建通道项目。Main 进程保证项目存在,设置卡片首次展示时即可引用对应项目。
4. 通道项目默认根目录为当前操作系统用户目录。
5. 同一通道中的不同私聊用户或群聊分别建立独立远程会话。
6. 微信 ClawBot 首期支持个人微信私聊中的文字、图片和文件;语音和视频后续支持。
7. 微信卡片可以配置默认“对话”或“执行”模式。
8. “对话”映射为 GoodBuddy `Ask`;“执行”映射为 `Execute`
9. 每个通道项目默认使用“模型连接”中的默认直连文本模型,也可以显式选择其他直连文本模型、OpenCode 或 Continue。选择 OpenCode/Continue 时,通道只保存 Runtime 类型,并在每次远程请求开始时动态跟随“Agent Runtime”中的对应全局配置,不维护第二套模型来源或 Runtime 配置。
10. 远程 Execute 不显示通道专属请求级或逐工具确认;收到合法消息后立即按所选后端运行。
11. 任务仍受工作目录、Runtime 能力、沙箱、能力开关、直连模型工具安全策略和活动审计约束。
12. 停用或断开通道不得删除通道项目、远程会话、任务、活动或成果历史。
13. 通道项目由系统管理,用户不能永久删除;用户可以修改其工作目录、处理后端和默认模式。
## 3. 目标
### 3.1 用户目标
- 在项目切换器中一眼识别微信、企业微信和钉钉来源。
- 在通道项目中区分不同发送者和群聊。
- 在设置卡片中完成连接、启停、处理后端、模式和工作目录配置。
- 通过微信进行只读问答或发起受控执行任务。
- 选择可信的执行后端,并查看完整执行记录。
- 保留通道关闭前后的历史上下文和审计记录。
### 3.2 产品目标
- 将远程通道纳入 GoodBuddy 现有 Project、Conversation、Task、Activity 和 Artifact 信息架构。
- 复用现有 ChannelService 的白名单、去重、并发、取消、输出限制和错误脱敏能力。
- 保持 Electron Main、Preload、Renderer 和不可信子进程之间的安全边界。
- 为后续语音、视频、多账号和更多通道提供稳定扩展点。
## 4. 非目标
首期不包含:
- 微信群聊。
- 微信语音和视频收发。
- 多个个人微信账号同时绑定。
- 通过微信临时扩大工具权限或安全策略。
- 主动群发、营销消息或任意联系人发现。
- 将微信会话自动合并进普通本地会话。
- 删除或迁移现有企业微信、钉钉历史数据。
- 把完整 OpenClaw Runtime 打包进 GoodBuddy。
## 5. 信息架构
### 5.1 项目分组
项目选择器增加“远程通道”分组:
```text
普通项目
├─ 默认项目
└─ 用户创建的其他项目
远程通道
├─ 微信 ClawBot
├─ 企业微信
└─ 钉钉
```
每个通道项目持续显示连接状态:
- 未配置
- 已停用
- 正在连接
- 等待扫码
- 已连接
- 连接失败
通道状态不得只通过颜色表达。
### 5.2 项目、会话和消息关系
```text
通道项目
└─ 远程会话
└─ 消息
└─ 可选任务 / 活动 / 成果
```
职责划分:
| 层级 | 负责内容 |
| --- | --- |
| 通道项目 | 平台、连接状态、默认根目录、处理后端、默认模式 |
| 远程会话 | 平台账号、私聊用户或群聊、连续上下文 |
| 消息 | 具体发送者、本次实际模式、正文、时间和处理状态 |
| 任务与活动 | Runtime、工具调用、执行结果和错误 |
### 5.3 会话命名
- 微信 ClawBot:优先使用已绑定用户昵称;不可用时显示“我的微信”。
- 企业微信私聊:优先使用平台显示名,否则显示脱敏发送者 ID。
- 钉钉私聊:优先使用平台显示名,否则显示脱敏发送者 ID。
- 群聊:使用群名称;每条消息仍显示具体发送者。
- 不得把原始 Token、上下文令牌或完整敏感标识作为会话标题。
## 6. 通道项目生命周期
### 6.1 自动创建
应用初始化通道设置服务时,Main 进程执行 `ensureChannelProjects()`
1. 按稳定通道标识查找 `weixin``wecom``dingtalk` 对应项目。
2. 缺失时创建系统管理项目。
3. 已存在时复用,不重复创建。
4. 如果存在同名普通项目,不得按名称占用或修改该项目。
5. 创建失败时保留其他通道可用,并在设置快照中返回有界错误。
默认值:
| 通道 | 项目名称 | 根目录 | 处理后端 | 默认模式 |
| --- | --- | --- | --- | --- |
| `weixin` | 微信 ClawBot | 用户目录 | 默认直连文本模型 | Ask |
| `wecom` | 企业微信 | 用户目录 | 默认直连文本模型 | Ask |
| `dingtalk` | 钉钉 | 用户目录 | 默认直连文本模型 | Ask |
### 6.2 系统管理约束
- 通道项目不能从普通项目危险区永久删除。
- 停用通道不归档项目。
- 断开微信不删除项目和历史。
- 数据迁移或异常导致项目缺失时,下次初始化自动修复。
- 用户可以修改项目根目录、描述、处理后端和默认模式。
- 项目名称首期由系统管理,避免来源名称被改到不可识别;后续如允许自定义,必须持续显示通道徽标。
### 6.3 默认用户目录
通道项目默认使用操作系统用户目录。设置卡片必须显示完整目录,并在首次启用 Execute 时明确说明该范围可能包含桌面、下载、文档和其他私人文件。
如果根目录不存在、不可访问或不是目录:
- Ask 仍可在不读取本地文件的边界内工作。
- Execute 不得启动。
- 设置卡片显示就近错误和“选择目录”操作。
## 7. 设置页产品需求
### 7.1 导航
设置页标签由“企业通信”改为:
- 标题:消息通道
- 说明:微信 ClawBot、企业微信与钉钉
### 7.2 卡片通用结构
三个通道使用与模型设置一致的分段外观页签,并保留
`tablist``tab``tabpanel`、游标焦点和方向键语义。每次只显示一个
通道面板,窄窗口下页签单行横向滚动。
每个通道面板包含:
1. 通道名称和连接状态。
2. 启用开关。
3. 默认工作目录。
4. 消息处理后端。
5. 默认处理模式。
6. 对应通道项目。
7. 平台特有的连接配置。
8. 最近错误或连接状态。
示意:
```text
微信 ClawBot 已连接
已绑定账号:微信用户 ****8a3f
最近收到消息:今天 14:32
启用微信通道 [开关]
默认处理模式
[ 对话 ] [ 执行 ]
默认工作目录
C:\Users\用户名 [选择目录]
消息处理后端
[默认模型 · sonnet-5 v]
通道项目
微信 ClawBot
[断开本机绑定(危险操作)]
```
“对话 / 执行”是互斥状态,应使用语义化分段控件,不使用两个独立复选框。
### 7.3 模式说明
- 对话:只读回答,不调用工具或修改内容。
- 执行:收到合法消息后立即交给所选后端,可在工作目录内调用已启用工具。
每个通道面板持续显示风险说明,不弹出一次性确认。默认 Ask 时也要说明白名单
发送者仍可通过 `/execute` 临时执行:
```text
远程消息可能要求 GoodBuddy 读取或修改默认工作目录中的内容。
执行消息会立即交给所选后端,不再逐次弹窗确认。
请只连接可信账号,并将工作目录限制在必要范围。
默认工作目录:
C:\Users\用户名
```
### 7.4 消息处理后端
每个通道项目提供相同的后端选择,不提供含义不明确的“自动”选项:
- 直连模型:默认选择“模型连接”中的默认文本连接;列出已配置的文本模型连接,不列出仅支持图像生成的连接。
- OpenCode:使用当前 OpenCode Runtime,并动态跟随“Agent Runtime”中的全局 OpenCode 模型来源、自有配置、程序路径和服务地址。
- Continue:使用当前 Continue Runtime,并动态跟随“Agent Runtime”中的全局 Continue 模型来源、自有配置和程序路径。
选择持久化在通道项目上。旧版本保存的 `auto` 选择在启动时迁移为当前默认
直连文本模型。模型连接删除、改为图片模型或凭据失效后,优先修复为默认或
首个可用文本模型;没有可用文本模型时,UI 明确提示用户完成模型配置或改选
Agent Runtime。OpenCode/Continue 的通道项目只持久化 `provider`,旧版残留的
通道级 `profileId` 在修复时移除;全局 Agent Runtime 配置变更从下一条远程
请求开始生效。远程会话记录通道项目的逻辑后端选择,不复制全局配置或凭据。
Execute 启动前检查解析后的后端是否支持工具执行,并返回可处理的配置错误。
通道面板的后端说明必须明确显示“跟随 Agent Runtime 全局配置”,不得在消息
通道设置中重复展示 OpenCode/Continue 的模型来源、自有配置文件或程序路径。
“Agent Runtime > 高级设置”中的来源选项、条件配置卡和后续路径字段保持
`12px` 区块间距,不能出现卡片边框贴合或内容归属不清。
### 7.5 通道项目会话界面
通道项目是系统管理的远程消息范围,不允许创建普通本地会话:
- 切换到通道项目时,只显示由对应客户端消息创建的远程会话。
- 隐藏“新建对话”和 `Ctrl+N` 提示;收到全局新建会话命令时不创建记录。
- 尚无远程会话时显示等待首条客户端消息的空状态和设置入口。
- 旧版本误建在通道项目中的普通本地会话不参与通道会话列表,但保留其数据。
- 远程会话底部说明客户端联动方式,只显示历史、任务和执行结果,不再提及已移除的审批流程。
- “任务与活动”页面按会话分组显示远程任务;所有分组首次进入时默认收起,包括进行中、失败和已完成状态,用户可通过原生展开控件查看明细。
### 7.6 微信扫码绑定
未绑定时显示“绑定个人微信”。扫码对话框包含:
- 本地渲染的二维码。
- 扫码和手机确认步骤。
- 二维码剩余有效时间。
- 刷新和取消操作。
- 等待扫码、已扫描、需要验证码、已连接、已过期和失败状态。
二维码过期时不得继续接受旧扫码结果。
需要配对数字时,在同一对话框中显示验证码输入。验证码不得写入日志或持久化。
绑定完成后显示“重新绑定”和“断开本机绑定”。“断开本机绑定”使用共享红色
危险操作样式,并持续说明该操作只清除本机凭据、不保证解除微信服务端授权,
也不删除通道项目、远程会话、任务、活动或成果历史。
### 7.7 企业微信和钉钉
企业微信和钉钉继续使用现有凭据表单、环境变量只读覆盖和连接测试,但增加:
- 通道项目显示。
- 消息处理后端。
- 默认处理模式。
- 默认工作目录。
- 打开通道项目。
现有发送者白名单和群聊提及设置继续有效。
## 8. 远程会话需求
### 8.1 会话创建与复用
收到合法消息后,根据以下稳定键查找会话:
```text
channel + accountId + externalConversationId
```
- 未找到时,在对应通道项目下创建远程会话。
- 已找到时继续使用现有会话。
- 微信私聊的 `externalConversationId` 首期可由绑定账号和发送者稳定标识组成。
- 不得仅按显示名称匹配会话。
- 消息重试不得创建重复会话或重复任务。
### 8.2 新建上下文
首期不提供手动“新建远程会话”入口,也不把通道项目中的全局“新建对话”
命令转为本地会话。客户端首条合法消息按稳定键自动创建远程会话,后续消息
继续复用该会话;未来如增加远程上下文重置命令,需要单独定义协议、去重和
历史保留语义。
### 8.3 展示
最近对话和聊天标题区显示:
- 通道图标和名称。
- 私聊用户或群聊名称。
- 当前连接状态。
- 默认模式。
- 未读状态。
每条远程消息记录实际模式:
- Ask
- Execute
- 执行中
- 已完成
- 失败
收到普通远程消息时不得强制切换当前页面。应增加未读标记和全局通知。
## 9. Ask 与 Execute
### 9.1 模式解析
每条消息的模式按以下优先级确定:
1. 显式 `/ask` 或“对话:”前缀使用 Ask。
2. 显式 `/execute``/exec` 或“执行:”前缀使用 Execute。
3. 没有前缀时使用通道项目的默认模式。
前缀仅用于选择模式,不进入发送给模型的正文。
### 9.2 Ask
- 在 Runtime 边界保持只读。
- 不提供工具授权回调,或所有工具请求返回拒绝。
- 不修改文件、数据库、系统状态或远程状态。
- 结果以有界文字返回原通道并写入远程会话。
### 9.3 Execute
Execute 消息通过身份、长度、去重和并发检查后:
1. 创建并立即启动远程执行任务。
2. 使用通道项目当前保存的逻辑处理后端;OpenCode/Continue 在此时解析“Agent Runtime”中的对应全局配置。
3. 将项目根目录作为本次 Runtime 工作目录。
4. 所选后端不支持工具执行时,不启动任务,并返回设置修复说明。
远程 Execute 不创建 GoodBuddy 通道专属请求确认或逐工具确认。安全边界由
发送者白名单、私聊限制、项目根目录、所选 Runtime、沙箱、能力开关和工具
安全策略共同提供。UI 必须持续说明该行为,不能让用户误以为仍会弹窗确认。
通道只回传最终结果或可操作的失败信息,不发送“执行已开始”等无操作价值的
中间状态消息。
### 9.4 工具控制
不同后端按现有行为运行:
- OpenCode 和 Continue 使用各自的工具系统、能力检查和沙箱配置。
- 直连模型只可调用已启用的内置工作区工具及已分配 MCP 工具。
- “Execute 自动授权已启用的工具”策略无需逐次确认;“禁止所有工具执行”策略拒绝所有直连模型工具调用。
- Runtime 沙箱模式继续有效。
- 任何工具结果都进入现有任务和活动审计。
### 9.5 结果回传
- 成功:回传有界文字结果。
- 当前任务生成的图片可以随最终结果回传;用户明确要求文件时,将当前任务的
有界文本结果生成为 Markdown 附件。
- 失败:回传经过脱敏、长度受限的用户可处理错误。
- 取消:回传“任务已取消”。
- 结果投递失败时保留发件箱记录并显示通道错误,不重复执行任务。
### 9.6 媒体与文件
- 单条微信消息最多接收或发送 4 个附件,解密后合计不超过 12MB。
- 入站仅处理官方图片和文件消息项。Sidecar 下载腾讯 CDN 内容并完成
AES-128-ECB 解密,Main 只接收有界字节、文件名、MIME 和大小。
- 图片进入现有视觉上下文;文本、代码、PDF 和 Office 文件进入现有不可信
文档上下文。不支持的类型显示可处理提示,不把原始 CDN 地址或密钥传给 Runtime。
- 入站附件元数据和有界预览写入远程会话,原始字节作为任务处理期间的临时上下文。
- 出站生成图片必须来自当前任务的 `generated-image` 事件。
- 出站文件只能由 Main 根据当前任务的最终文本结果生成,不接受 Runtime 路径,
不读取或发送任意现有工作区文件。
- 媒体发件箱在成功投递或达到重试上限后清除二进制负载。
## 10. 微信 ClawBot 通信架构
### 10.1 进程边界
微信通信运行在独立 Node Sidecar 中:
```text
微信
↕ 腾讯 iLink HTTPS / CDN
微信 Sidecar
↕ 严格、有界、可验证的进程协议
Main ChannelDriver
ChannelService
GoodBuddy Runtime 与工具安全策略
```
禁止:
- 在 Renderer 中加载微信通信代码。
- 向 Renderer 暴露 Token、上下文令牌或原始腾讯响应。
- 把腾讯插件直接加载进 Electron Main。
- 运行 `openclaw-weixin-cli` 安装器。
- 仅为微信通道打包完整 OpenClaw。
### 10.2 Sidecar 能力
- 获取和刷新二维码。
- 轮询扫码状态。
- 提交一次性验证码。
- 加载内存中的加密解封凭据。
- 长轮询文字、图片和文件消息。
- 从腾讯 CDN 有界下载并解密图片和文件。
- 调用 `getuploadurl`,加密上传当前任务图片和文件,并发送媒体回复。
- 保持会话 `context_token` 和同步游标。
- 有界重试、退避、停止和异常退出。
### 10.3 协议扩展
现有 `wechat-sidecar-protocol.ts` 需要拆分为两个方向:
Sidecar 到 Main
- `status`
- `qr`
- `verification_required`
- `connected`
- `inbound_message`,可包含有界图片或文件
- `reply_result`
- `fatal_error`
Main 到 Sidecar
- `start_login`
- `submit_verification`
- `start_account`
- `reply`,可包含有界图片或文件
- `cancel_reply`
- `disconnect`
- `shutdown`
所有消息使用严格 Zod Schema、版本号、最大长度和关联 ID。协议拒绝未知字段。Token、Cookie、Session 和上下文令牌不得出现在普通状态或消息事件中。
## 11. 凭据与网络安全
### 11.1 凭据
- 微信 bot token 使用 Electron `safeStorage` 加密后保存在 Main 管理的设置文件。
- 上下文令牌由 Sidecar 运行时持有;如需跨重启保存,必须由 Main 加密持久化。
- 凭据不得出现在命令行参数、普通环境变量、stdout、Renderer IPC、通知或错误消息中。
- Sidecar 通过私有继承管道接收本次运行所需凭据。
- 安全存储不可用时不能完成微信绑定或启用通道。
### 11.2 网络
- 扫码入口固定为已审核的腾讯 HTTPS 主机。
- 服务端返回的 API 主机和重定向主机必须通过腾讯主机允许列表验证后才能携带 Token 请求。
- 不允许明文 HTTP 发送微信凭据。
- 全局“内网兼容模式”不得放宽微信凭据端点的 HTTPS 和主机验证。
- Sidecar 使用独立环境允许列表并显式启用证书验证,不继承
`NODE_TLS_REJECT_UNAUTHORIZED=0`、代理变量、Node 加载钩子或提供商凭据。
- 日志中的 URL 移除查询字符串,响应体对 Token 和上下文令牌脱敏。
- 媒体下载和上传只允许腾讯微信 HTTPS 主机,所有重定向逐跳重新校验。
- CDN 响应按流读取并在解密前后分别执行硬字节限制,不信任 `Content-Length`
文件名、MIME、扩展名或服务端声明的原始大小。
### 11.3 断开与解绑
产品区分:
- 停用:停止收发,保留本地绑定凭据。
- 断开本机绑定:停止收发并清除本地凭据;入口使用红色危险操作样式。
- 微信端解除绑定:只有腾讯提供并验证服务端撤销能力后才可承诺。
当前不得把本地清除描述为“已在微信端彻底解绑”。断开后保留通道项目、
远程会话、任务、活动和成果历史。
## 12. 数据与契约建议
### 12.1 Project
为项目增加可向后兼容的来源字段:
```ts
type ProjectKind = 'user' | 'channel'
type ProjectChannel = 'weixin' | 'wecom' | 'dingtalk'
```
通道项目包含:
- `kind: 'channel'`
- `channel`
- `runtimeSelection`
- 稳定且唯一的通道绑定
现有项目迁移为 `kind: 'user'`。不得通过项目名称推断通道。
### 12.2 Conversation
增加远程会话映射,至少包含:
- `conversationId`
- `projectId`
- `channel`
- `accountId`
- `externalConversationId`
- `conversationType`
- 脱敏显示名
- 创建和最近消息时间
唯一约束:
```text
channel + accountId + externalConversationId
```
### 12.3 Channel Settings
通道公开设置增加:
- `projectId`
- `defaultWorkMode`
- `runtimeSelection`
- `rootPath`
- `status`
微信私有设置增加加密字段:
- bot token
- bot/account ID
- 绑定用户 ID
- 经验证的 API base URL
Renderer 快照只返回是否已配置和脱敏标识。
### 12.4 任务来源
远程任务保留:
- 通道项目 ID。
- 远程会话 ID。
- 通道。
- 脱敏发送者。
- 实际工作模式。
- 实际消息处理后端。
任务队列可以复用 delegation 调度分类,但 UI 和活动审计必须依据通道项目与
远程会话持续显示真实通道来源,不得呈现为普通本地任务。
## 13. IPC 与 Preload
建议增加窄接口:
- 获取通道设置快照。
- 保存通道项目配置。
- 开始微信扫码。
- 刷新微信扫码。
- 提交微信验证码。
- 断开微信本地连接。
- 订阅微信连接状态。
- 打开对应通道项目。
所有 IPC
- 使用共享 Zod Schema 验证。
- 验证可信 Renderer sender。
- 不接收 Renderer 提供的项目类型或通道身份作为可信事实。
- 不返回凭据。
## 14. 关键异常流程
### 14.1 项目重名
存在名为“微信 ClawBot”的普通项目时,仍创建独立通道项目,并通过通道类型而非名称识别。UI 可以显示同名,但必须有“远程通道”分组和微信徽标。
### 14.2 通道项目缺失
如果数据库异常或旧版本操作导致绑定项目缺失,Main 初始化时重新创建并修复设置引用。历史会话无法安全迁移时保留原归属并给出诊断,不静默丢弃。
### 14.3 连接中退出
- 取消二维码轮询。
- 清除内存验证码。
- 停止 Sidecar。
- 不保存未确认凭据。
### 14.4 执行期间断线
通道断开后不得启动新的 Execute。已开始的任务按现有取消策略处理,结果进入本地审计;恢复连接后不得自动重复执行。
### 14.5 重复消息
使用稳定平台消息 ID 去重。平台消息 ID 缺失时,使用账号、会话、发送者、时间和内容摘要构造有界稳定键。任务创建和回复必须共用同一个去重声明。
## 15. 可访问性与响应式
- 通道状态同时使用文字和图标。
- 三个通道使用共享 `PageTabs``segmented` 外观,保留页签语义和方向键切换。
- 模式选择使用语义化单选/分段控件和方向键。
- 消息处理后端使用持久标签和分组选项,并说明当前选择的实际行为。
- 二维码提供状态文字和备用刷新操作,但不把敏感二维码链接作为可复制文本。
- 验证码错误与输入框建立 `aria-describedby` 关联。
- 关闭对话框后焦点返回触发按钮。
- 窄窗口下卡片单列,二维码对话框保留 16px 外边距。
- 浅色、深色和 200% 文字缩放下可完成绑定、后端选择和保存。
## 16. 验收标准
### 16.1 通道项目
- [ ] 新安装首次启动后存在微信 ClawBot、企业微信和钉钉三个通道项目。
- [ ] 重启应用不会重复创建通道项目。
- [ ] 同名普通项目不会被占用或修改。
- [ ] 通道项目默认根目录为当前用户目录,默认模式为 Ask。
- [ ] 通道项目在项目选择器的“远程通道”分组中显示。
- [ ] 停用或断开通道不会删除项目和历史。
- [ ] 普通项目删除流程不能永久删除通道项目。
### 16.2 设置卡片
- [ ] 设置标签显示为“消息通道”。
- [ ] 三个通道使用与模型设置一致的分段外观页签,并保留完整页签键盘语义。
- [ ] 三个面板均显示项目、根目录、消息处理后端、默认模式和连接状态。
- [ ] 微信卡片可以完成扫码、过期刷新、验证码和连接状态展示。
- [ ] “断开本机绑定”使用共享红色危险操作样式,并明确说明只清除本机凭据、不删除历史或承诺服务端解绑。
- [ ] 不显示“自动”后端;首次创建和旧版 `auto` 配置均落到默认直连文本模型。
- [ ] 直连模型只列出文本连接,OpenCode 与 Continue 可直接选择。
- [ ] 选择 OpenCode/Continue 时只保存 Runtime 类型,每次远程请求动态跟随“Agent Runtime”中的对应全局配置,通道页不出现第二套 Runtime 配置。
- [ ] Agent Runtime 高级设置的来源选项、条件配置卡和路径字段之间保持 `12px` 间距且无横向溢出。
- [ ] 默认 Execute 时持续显示目录范围和无逐次确认的风险说明。
- [ ] Renderer 无法读取任何微信 Token 或上下文令牌。
### 16.3 会话
- [ ] 不同通道的消息进入不同通道项目。
- [ ] 不同发送者或群聊进入独立远程会话。
- [ ] 重复平台事件不会创建重复会话、消息或任务。
- [ ] 最近对话、聊天标题和消息均能识别通道与发送者。
- [ ] 收到普通消息不会强制切换当前页面。
- [ ] 切换到通道项目不会创建普通本地会话。
- [ ] 通道项目隐藏“新建对话”和 `Ctrl+N`,全局快捷命令也不创建会话。
- [ ] 没有远程会话时显示等待客户端首条消息的空状态。
- [ ] “任务与活动”中的会话分组默认收起,进行中、失败和已完成状态行为一致。
- [ ] 微信图片和文件显示在对应远程消息中,附件消息无需附带文字。
- [ ] 支持的附件进入所选后端现有图片或文档上下文;不支持和超限附件返回明确提示。
### 16.4 Ask
- [ ] 普通消息默认按卡片配置进入 Ask。
- [ ] Ask 在 Runtime 边界拒绝所有工具。
- [ ] 有界结果回传原通道并写入远程会话。
### 16.5 Execute
- [ ] 默认 Execute 或显式执行前缀会立即使用通道项目所选后端。
- [ ] 不显示通道专属请求级或逐工具确认。
- [ ] 直连模型、OpenCode 和 Continue 均按各自能力正确路由。
- [ ] 通道不发送“执行已开始”等中间占位消息,只发送最终结果或可操作失败。
- [ ] 任务使用对应通道项目根目录。
- [ ] Runtime、沙箱、能力和直连模型工具安全策略继续生效。
- [ ] 任务、活动、工具、成果和最终结果关联到通道项目与远程会话。
### 16.6 生命周期与安全
- [ ] Sidecar 异常退出不会导致 Main 崩溃,并有有界重启限制。
- [ ] 应用退出会取消长轮询并停止 Sidecar。
- [ ] 微信凭据使用系统安全存储加密。
- [ ] 任何普通日志、IPC、错误和通知中不存在凭据。
- [ ] Token 只发送到已审核的腾讯 HTTPS 主机。
- [ ] CDN 下载、上传和每次重定向只访问腾讯微信 HTTPS 主机。
- [ ] 入站和出站媒体最多 4 个、合计不超过 12MB,AES 密钥和 CDN URL 不跨越 Sidecar 边界。
- [ ] 只有当前任务生成图片或 Main 从本次最终文本生成的文件可以作为出站附件。
## 17. 实施阶段
### 阶段一:通道项目基础
- Project 数据迁移与通道类型。
- 三个通道项目幂等创建。
- 项目选择器“远程通道”分组。
- 三张设置卡片接入项目、目录和模式配置。
- 企业微信、钉钉消息建立独立远程会话。
### 阶段二:微信文字通道
- 微信 iLink Sidecar。
- 扫码、验证码、加密凭据和生命周期。
- 微信文字收发与稳定去重。
- 微信远程会话。
- Ask 模式。
### 阶段三:受控 Execute
- 通道项目处理后端选择与失效修复。
- OpenCode/Continue 通道后端动态跟随全局 Agent Runtime 配置。
- Execute Runtime 接入。
- 直连模型工具安全策略和活动关联。
- 结果回传、取消、超时和失败恢复。
### 阶段四:微信媒体
- 图片和文件 CDN 下载、AES 解密与有界上下文。
- 生成图片和 Main 生成的任务结果文件加密上传与回复。
- 远程会话附件展示和媒体发件箱清理。
### 阶段五:后续扩展
- 有界语音和视频。
- 多微信账号。
- 更细的项目路由。
- 已验证的微信端解除绑定。
## 18. 测试要求
至少覆盖:
- 数据迁移和通道项目幂等创建。
- 同名普通项目隔离。
- 设置 Schema 与 Renderer 脱敏快照。
- QR 状态机、过期、验证码和非法转换。
- Sidecar 双向协议未知字段、超长字段和凭据泄漏拒绝。
- 腾讯主机允许列表和重定向校验。
- CDN 媒体 AES 加解密、流式大小限制、声明大小校验和恶意重定向拒绝。
- 附件持久化展示、现有上下文接入、生成图片回传和任务成果目录隔离。
- 消息去重、会话映射、并发和取消。
- Ask 工具拒绝。
- Execute 使用直连模型、OpenCode 和 Continue 的路由。
- OpenCode/Continue 通道只保存 Runtime 类型,并在 Ask 与 Execute 开始时解析当前全局 Agent Runtime 配置。
- Execute 不创建通道专属审批,直连模型禁止工具策略仍然生效。
- 模型连接删除后的通道后端选择修复。
- 发件箱投递失败不重复执行。
- 项目选择器、分段页签、后端选择、设置面板和扫码对话框的键盘与无障碍行为。
- Runtime 高级设置区块间距、微信断开危险按钮和任务活动默认折叠的 UI 回归。
- Windows、macOS、Linux 的默认用户目录和 Sidecar 关闭行为。
实现完成后运行:
```text
npm test
npm run typecheck
npm run lint
npm run build
```
## 19. 发布条件
满足以下条件后才可默认向用户提供微信 Execute:
1. 微信文字 Ask 全流程稳定。
2. UI 明确说明远程 Execute 会立即运行,且默认工作目录和处理后端始终可见。
3. 凭据不会进入 Renderer、日志或普通子进程参数。
4. Sidecar 网络目标和重定向已实施严格允许列表。
5. 通道项目和远程会话的来源标识在所有入口持续可见。
6. 任务重复投递不会导致重复执行。
7. 应用退出、断线和更新过程中不会留下失控执行,直连模型禁止工具策略不能被远程来源绕过。
8. 腾讯 iLink 独立宿主使用范围和本地断开语义已完成发布前确认。
@@ -1,5 +1,15 @@
# GoodBuddy 长期助手功能规划 # GoodBuddy 长期助手功能规划
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 文档类型 | 产品路线图 |
| 状态 | 规划中 |
| 版本 | 0.1 |
| 日期 | 2026-08-12 |
| 适用产品 | GoodBuddy 桌面端 |
## 1. 文档目标 ## 1. 文档目标
本文定义 GoodBuddy 从“安全对话助手”演进为“可长期使用的桌面工作助手”所需的产品能力、交互结构、数据模型、权限边界、实施阶段和验收标准。 本文定义 GoodBuddy 从“安全对话助手”演进为“可长期使用的桌面工作助手”所需的产品能力、交互结构、数据模型、权限边界、实施阶段和验收标准。
@@ -95,12 +105,6 @@ GoodBuddy 应能够:
- 允许读取明确授权的上下文。 - 允许读取明确授权的上下文。
- 禁止文件写入、命令执行和外部副作用。 - 禁止文件写入、命令执行和外部副作用。
#### Plan
- Runtime 可读取上下文并生成结构化计划。
- 用户确认计划后才能进入 Execute。
- 计划变更需要重新确认。
#### Execute #### Execute
- 允许按现有逐工具审批机制执行。 - 允许按现有逐工具审批机制执行。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+84 -1
View File
@@ -4,7 +4,87 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
export default defineConfig({ export default defineConfig({
main: { main: {
plugins: [externalizeDepsPlugin()] plugins: [
externalizeDepsPlugin({
exclude: [
'@agentclientprotocol/sdk',
'@deepseek-ai/cordis',
'@deepseek-ai/dsh-agent',
'@deepseek-ai/dsh-agent-loop',
'@deepseek-ai/dsh-bash-sandbox',
'@deepseek-ai/dsh-credentials',
'@deepseek-ai/dsh-fs-sandbox',
'@deepseek-ai/dsh-llm',
'@deepseek-ai/dsh-llm-pi-ai',
'@deepseek-ai/dsh-pwsh-sandbox',
'@deepseek-ai/dsh-sandbox',
'@deepseek-ai/dsh-sandbox-local',
'@deepseek-ai/dsh-sandbox-policy',
'@deepseek-ai/dsh-session',
'@deepseek-ai/dsh-shell-env',
'@deepseek-ai/dsh-skill',
'@deepseek-ai/dsh-subprocess-local',
'@deepseek-ai/dsh-system-prompt',
'@deepseek-ai/dsh-token-meter',
'@deepseek-ai/dsh-tool-bash',
'@deepseek-ai/dsh-tool-fs',
'@deepseek-ai/dsh-tool-pwsh',
'@deepseek-ai/dsh-tool-skill',
'@deepseek-ai/dsh-tools',
'@deepseek-ai/dsh-user-approval',
'yaml',
'zod'
]
})
],
build: {
rollupOptions: {
input: {
index: resolve('src/main/index.ts'),
'wechat-sidecar': resolve(
'src/main/channels/wechat-sidecar.ts'
),
'deepseek-harness-host-bootstrap': resolve(
'src/main/deepseek-harness-host-bootstrap.ts'
)
},
external: [
'node-pty',
'koffi',
/^@koromix\/koffi-/u,
'@deepseek-ai/dsh-sandbox-windows-acl/runner',
/^@deepseek-ai\/node-addon-landlock-run-/u
],
output: {
entryFileNames(chunk) {
return chunk.name === 'deepseek-harness-host-bootstrap'
? 'deepseek-harness-host-bootstrap.js'
: '[name].js'
},
chunkFileNames(chunk) {
const moduleIds = chunk.moduleIds.join('\n')
return moduleIds.includes('deepseek-harness') ||
moduleIds.includes('deepseek-harness-utility')
? 'chunks/deepseek-harness-[name]-[hash].js'
: 'chunks/[name]-[hash].js'
},
manualChunks(id) {
if (
id.includes('@deepseek-ai/dsh-llm') ||
id.includes('@deepseek-ai/dsh-credentials') ||
id.includes('@deepseek-ai/dsh-settings') ||
id.includes('@deepseek-ai/dsh-timeout') ||
id.includes('@deepseek-ai/dsh-token-meter') ||
id.includes('@deepseek-ai/dsh-llm-pi-ai') ||
id.includes('@mariozechner/pi-ai')
) {
return 'deepseek-harness-llm'
}
return undefined
}
}
}
}
}, },
preload: { preload: {
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
@@ -24,6 +104,9 @@ export default defineConfig({
'@shared': resolve('src/shared') '@shared': resolve('src/shared')
} }
}, },
worker: {
format: 'es'
},
plugins: [react()] plugins: [react()]
} }
}) })
+4441 -56
View File
File diff suppressed because it is too large Load Diff
+138 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.8.4", "version": "0.9.0",
"private": true, "private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes", "description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy", "desktopName": "GoodBuddy",
@@ -8,7 +8,14 @@
"author": { "author": {
"name": "MesaLogo" "name": "MesaLogo"
}, },
"license": "UNLICENSED", "license": "0BSD",
"repository": {
"type": "git",
"url": "git+https://github.com/mesalogo/goodbuddy.git"
},
"bugs": {
"url": "https://github.com/mesalogo/goodbuddy/issues"
},
"main": "./out/main/index.js", "main": "./out/main/index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -18,8 +25,12 @@
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts",
"build": "npm run typecheck && npm run build:bundle", "build": "npm run typecheck && npm run build:bundle",
"build:bundle": "electron-vite build", "build:bundle": "electron-vite build",
"smoke:deepseek-harness": "npm run build:bundle && node build/run-deepseek-harness-utility-smoke.cjs",
"smoke:deepseek-harness:packaged": "node build/run-packaged-deepseek-harness-smoke.cjs",
"release:notes:verify": "node build/release-notes.cjs",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
"dist:win": "npm run build && electron-builder --win nsis --x64 --arm64", "dist:win": "npm run build && electron-builder --win nsis --x64 --arm64",
"dist:mac": "npm run build && electron-builder --mac dmg --x64 --arm64", "dist:mac": "npm run build && electron-builder --mac dmg --x64 --arm64",
@@ -40,6 +51,21 @@
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"beforePack": "build/runtime-hooks.cjs", "beforePack": "build/runtime-hooks.cjs",
"asar": true, "asar": true,
"asarUnpack": [
"out/main/package.json",
"out/main/deepseek-harness-*",
"out/main/chunks/**/*",
"node_modules/node-pty/lib/**/*",
"node_modules/node-pty/package.json",
"node_modules/node-pty/prebuilds/**/*",
"node_modules/node-pty/build/Release/**/*",
"node_modules/koffi/**/*",
"node_modules/@koromix/koffi-*/**/*",
"node_modules/@deepseek-ai/dsh-sandbox-windows-acl/**/*",
"node_modules/@deepseek-ai/node-addon-landlock-run/**/*",
"node_modules/@deepseek-ai/node-addon-landlock-run-*/**/*"
],
"npmRebuild": false,
"compression": "maximum", "compression": "maximum",
"files": [ "files": [
"out/**/*", "out/**/*",
@@ -53,6 +79,10 @@
"**/*" "**/*"
] ]
}, },
{
"from": "resources/release-notes.json",
"to": "release-notes.json"
},
{ {
"from": "build/icon-taskbar.ico", "from": "build/icon-taskbar.ico",
"to": "icon.ico" "to": "icon.ico"
@@ -77,6 +107,26 @@
"from": "node_modules/opencode-ai/LICENSE", "from": "node_modules/opencode-ai/LICENSE",
"to": "licenses/opencode-ai-LICENSE" "to": "licenses/opencode-ai-LICENSE"
}, },
{
"from": "node_modules/@deepseek-ai/dsh-agent/LICENSE",
"to": "licenses/deepseek-harness-MIT.txt"
},
{
"from": "node_modules/@deepseek-ai/cordis/LICENSE",
"to": "licenses/deepseek-cordis-MIT.txt"
},
{
"from": "node_modules/@agentclientprotocol/sdk/LICENSE",
"to": "licenses/agent-client-protocol-Apache-2.0.txt"
},
{
"from": "node_modules/node-pty/LICENSE",
"to": "licenses/node-pty-MIT.txt"
},
{
"from": "node_modules/koffi/LICENSE.txt",
"to": "licenses/koffi-MIT.txt"
},
{ {
"from": "node_modules/@continuedev/cli", "from": "node_modules/@continuedev/cli",
"to": "runtimes/continue", "to": "runtimes/continue",
@@ -90,6 +140,42 @@
{ {
"from": "node_modules/typescript/LICENSE.txt", "from": "node_modules/typescript/LICENSE.txt",
"to": "licenses/continuedev-cli-LICENSE" "to": "licenses/continuedev-cli-LICENSE"
},
{
"from": "node_modules/@fontsource-variable/inter/LICENSE",
"to": "licenses/inter-OFL-1.1.txt"
},
{
"from": "node_modules/@fontsource-variable/noto-sans-sc/LICENSE",
"to": "licenses/noto-sans-sc-OFL-1.1.txt"
},
{
"from": "node_modules/ppu-paddle-ocr/LICENSE",
"to": "licenses/ppu-paddle-ocr-MIT.txt"
},
{
"from": "node_modules/ppu-ocv/LICENSE",
"to": "licenses/ppu-ocv-MIT.txt"
},
{
"from": "node_modules/onnxruntime-web/LICENSE",
"to": "licenses/onnxruntime-web-MIT.txt"
},
{
"from": "node_modules/katex/LICENSE",
"to": "licenses/katex-MIT.txt"
},
{
"from": "node_modules/mermaid/LICENSE",
"to": "licenses/mermaid-MIT.txt"
},
{
"from": "node_modules/dompurify/LICENSE",
"to": "licenses/dompurify-Apache-2.0.txt"
},
{
"from": "node_modules/dompurify/LICENSE-MPL",
"to": "licenses/dompurify-MPL-2.0.txt"
} }
], ],
"win": { "win": {
@@ -129,21 +215,57 @@
} }
}, },
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
"@antv/g6": "^5.1.1",
"@deepseek-ai/cordis": "4.0.1",
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
"@deepseek-ai/dsh-agent-loop": "0.1.0-rc.6",
"@deepseek-ai/dsh-bash-sandbox": "0.1.0-rc.6",
"@deepseek-ai/dsh-credentials": "0.1.0-rc.6",
"@deepseek-ai/dsh-fs-sandbox": "0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
"@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.6",
"@deepseek-ai/dsh-pwsh-sandbox": "0.1.0-rc.6",
"@deepseek-ai/dsh-sandbox": "0.1.0-rc.6",
"@deepseek-ai/dsh-sandbox-local": "0.1.0-rc.6",
"@deepseek-ai/dsh-sandbox-policy": "0.1.0-rc.6",
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
"@deepseek-ai/dsh-shell-env": "0.1.0-rc.6",
"@deepseek-ai/dsh-skill": "0.1.0-rc.6",
"@deepseek-ai/dsh-subprocess-local": "0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "0.1.0-rc.6",
"@deepseek-ai/dsh-token-meter": "0.1.0-rc.6",
"@deepseek-ai/dsh-tool-bash": "0.1.0-rc.6",
"@deepseek-ai/dsh-tool-fs": "0.1.0-rc.6",
"@deepseek-ai/dsh-tool-pwsh": "0.1.0-rc.6",
"@deepseek-ai/dsh-tool-skill": "0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
"@deepseek-ai/dsh-user-approval": "0.1.0-rc.6",
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.30.0",
"@opencode-ai/sdk": "^1.18.9", "@opencode-ai/sdk": "^1.18.9",
"@wecom/aibot-node-sdk": "^1.0.6", "@wecom/aibot-node-sdk": "^1.0.6",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"dingtalk-stream": "^2.1.6-beta.1", "dingtalk-stream": "^2.1.6-beta.1",
"echarts": "^6.1.0", "dompurify": "^3.4.13",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"html-to-text": "^10.0.0", "html-to-text": "^10.0.0",
"i18next": "^25.10.10",
"json5": "^2.2.3", "json5": "^2.2.3",
"katex": "^0.16.47",
"lucide-react": "^1.27.0", "lucide-react": "^1.27.0",
"mermaid": "^11.16.1",
"onnxruntime-web": "^1.23.2",
"pdfjs-dist": "^6.2.108", "pdfjs-dist": "^6.2.108",
"ppu-paddle-ocr": "^6.4.0",
"qrcode": "^1.5.4",
"quill": "^2.0.3",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-i18next": "^16.6.6",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sherpa-onnx": "1.13.4", "sherpa-onnx": "1.13.4",
"undici": "^7.29.0", "undici": "^7.29.0",
"yaml": "^2.9.0", "yaml": "^2.9.0",
@@ -152,11 +274,14 @@
"devDependencies": { "devDependencies": {
"@continuedev/cli": "1.5.47", "@continuedev/cli": "1.5.47",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/noto-sans-sc": "^5.3.0",
"@testing-library/jest-dom": "^7.0.0", "@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/cross-spawn": "^6.0.6", "@types/cross-spawn": "^6.0.6",
"@types/html-to-text": "^9.0.4", "@types/html-to-text": "^9.0.4",
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
@@ -175,5 +300,15 @@
"typescript-eslint": "^8.65.0", "typescript-eslint": "^8.65.0",
"vite": "^7.3.6", "vite": "^7.3.6",
"vitest": "^4.1.10" "vitest": "^4.1.10"
},
"optionalDependencies": {
"@deepseek-ai/node-addon-landlock-run-linux-arm64": "0.1.1",
"@deepseek-ai/node-addon-landlock-run-linux-x64": "0.1.1",
"@koromix/koffi-darwin-arm64": "3.1.4",
"@koromix/koffi-darwin-x64": "3.1.4",
"@koromix/koffi-linux-arm64": "3.1.4",
"@koromix/koffi-linux-x64": "3.1.4",
"@koromix/koffi-win32-arm64": "3.1.4",
"@koromix/koffi-win32-x64": "3.1.4"
} }
} }
+111
View File
@@ -0,0 +1,111 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.9.0",
"releasedAt": "2026-08-14",
"notes": {
"zh-CN": {
"features": [
"新增 DeepSeek Harness 开发者预览 Runtime,支持安全的 OpenAI 兼容 Chat Completions 连接、任意模型名称、Skills 与自定义 MCP,并保留 Ask 只读、Execute 授权、取消和工作区沙箱边界。",
"新增自然语言配置工具,助手可查看脱敏设置并规划应用偏好、Skill 与 MCP 变更;配置只在原生审批通过后应用,凭据不会暴露给模型。",
"GoodBuddy 原创源代码现以 0BSD 许可证开放,并补充英文项目总览以及自动化、会话监督、分区记忆、并行实验和持续学习等产品规划文档。"
],
"fixes": [
"修复模型工具调用期间流式推理内容可能折叠或不可见的问题,并让推理区域在生成时自动跟随最新内容。",
"修复从通道入口打开设置时未定位到所选企业微信、钉钉或微信页面的问题,并更正微信二维码扫码提示。",
"优化简体中文界面的系统字体、字号和行高,改善 Windows 与 macOS 上的小字号可读性和排版一致性。"
]
},
"en-US": {
"features": [
"Added the DeepSeek Harness preview Runtime with secure OpenAI-compatible Chat Completions, arbitrary model names, Skills, and custom MCP, while preserving read-only Ask, authorized Execute, cancellation, and workspace sandbox boundaries.",
"Added natural-language configuration tools that let the assistant inspect sanitized settings and plan changes to app preferences, Skills, and MCP; changes apply only after native approval without exposing credentials to models.",
"Released GoodBuddy's original source under the 0BSD License and expanded the English overview and product plans for automation, conversation supervision, partitioned memory, parallel experiments, and continuous learning."
],
"fixes": [
"Fixed streamed reasoning becoming hidden during model tool calls, and kept the reasoning panel following the latest content while generation is in progress.",
"Fixed channel shortcuts opening the wrong settings page for WeCom, DingTalk, or WeChat, and corrected the WeChat QR-code scan guidance.",
"Improved Simplified Chinese typography with platform-native UI fonts, refined sizes, and line heights for clearer, more consistent text on Windows and macOS."
]
}
}
},
{
"version": "0.8.20",
"releasedAt": "2026-08-13",
"notes": {
"zh-CN": {
"features": [
"全面升级本地知识库,新增中文、全文、向量与知识图谱混合检索、本地及学习型重排、检索诊断工作台,并重新组织文档、图谱、任务和索引工作区。",
"重新组织 MCP 设置,并支持为自定义 MCP 服务选择启用动态工具列表更新;现有服务默认保持原有行为。",
"新增 KaTeX 数学公式渲染,支持在聊天 Markdown 中显示行内公式和块级公式。",
"新增交互式 Mermaid 图表渲染,支持查看源码、放大、缩放和拖动,并在渲染失败时回退到源码。"
],
"fixes": [
"修复异常退出后会话、笔记、运行中消息、工具调用和定时任务状态可能丢失或不一致的问题。",
"修复企业微信、钉钉和微信等远程通道消息发送失败后可能丢失的问题,未投递消息现在会持久化并重试。",
"修复知识索引任务在重启后状态不准确,以及索引重建中断或失败时可能暴露不完整结果的问题。",
"提升模型流式响应和文档提取的稳定性,对异常大的响应、工具参数和文档提供明确限制及错误提示。",
"修复直连模型使用工具时推理内容流式显示不完整、聊天宽表格溢出,以及部分设置和作用域工具保存不可靠的问题。"
]
},
"en-US": {
"features": [
"Upgraded the local knowledge base with hybrid Chinese, full-text, vector, and knowledge-graph retrieval, local and learning-based reranking, a retrieval diagnostics workbench, and reorganized document, graph, task, and indexing workspaces.",
"Reorganized MCP settings and added opt-in dynamic tool-list updates for custom MCP services, while preserving existing behavior by default.",
"Added KaTeX math rendering for inline and block formulas in chat Markdown.",
"Added interactive Mermaid diagram rendering with source viewing, zooming, panning, and source fallback when rendering fails."
],
"fixes": [
"Fixed lost or inconsistent conversation, note, in-progress message, tool-call, and scheduled-task states after an unexpected shutdown.",
"Fixed messages being lost after delivery failures on remote channels such as WeCom, DingTalk, and WeChat; undelivered messages are now persisted and retried.",
"Fixed inaccurate knowledge-index task states after restart and incomplete results becoming visible when an index rebuild was interrupted or failed.",
"Improved stability for model streaming and document extraction by enforcing clear limits and errors for unusually large responses, tool arguments, and documents.",
"Fixed incomplete streamed reasoning during direct-model tool use, overflowing wide chat tables, and unreliable persistence for some settings and scoped tools."
]
}
}
},
{
"version": "0.8.19",
"releasedAt": "2026-08-11",
"notes": {
"zh-CN": {
"features": [
"新增简体中文与英文界面,可在设置中即时切换并跟随系统语言。",
"新增统一的文档解析中心,为聊天附件和知识库导入提供原生文本提取、PDF 页面处理与真实文件诊断。",
"新增本地 PP-OCRv6 Tiny、Small 和 Medium 模型,支持校验下载、离线识别以及受管 ZIP 导入和导出。",
"扩展离线语音模型,新增中英与中粤英 Paraformer,以及 Whisper Small 和 Medium 多语言档位。",
"增强魔法笔记,支持富文本、图片、视频、附件、待办状态和可配置的 AI 评论方式。",
"支持为每个项目设置新对话的默认 Runtime。",
"扩展直连模型工具与文档处理能力,增加联网搜索、网页读取、附件解析进度和当前系统时间上下文。",
"新增首次启动版本更新说明,按当前界面语言展示且每个版本仅自动显示一次。"
],
"fixes": [
"修复 Execute 模式下内置 OpenCode 和 Continue 仍可能阻止已授权工具的问题。",
"修复工具失败信息重复显示、已恢复的 OpenCode 响应仍被判定失败,并仅为最近一次失败保留重新编辑入口。",
"修复共享开关在部分设置布局中尺寸被文本输入样式覆盖的问题。"
]
},
"en-US": {
"features": [
"Added Simplified Chinese and English interfaces with instant switching in Settings and system-language support.",
"Added a unified document parsing center for chat attachments and knowledge imports, with native text extraction, PDF page handling, and real-file diagnostics.",
"Added local PP-OCRv6 Tiny, Small, and Medium models with verified downloads, offline recognition, and managed ZIP import and export.",
"Expanded offline speech models with bilingual and Mandarin-Cantonese-English Paraformer options, plus Whisper Small and Medium multilingual tiers.",
"Enhanced Magic Notes with rich text, images, videos, attachments, editable todo states, and configurable AI comment modes.",
"Added a per-project default Runtime for new conversations.",
"Expanded direct-model tools and document handling with web search, webpage reading, attachment parsing progress, and current system-time context.",
"Added first-open release notes that follow the current interface language and appear automatically only once per version."
],
"fixes": [
"Fixed authorized tools still being blocked for bundled OpenCode and Continue in Execute mode.",
"Fixed duplicate tool-failure messages, preserved recovered OpenCode responses, and limited the edit-and-retry action to the latest failed response.",
"Fixed shared switches inheriting text-input dimensions in some settings layouts."
]
}
}
}
]
}
@@ -0,0 +1,56 @@
---
name: competitive-positioning
version: 1.0.0
description: |
基于公开、可定位且有日期的来源生成竞品矩阵、差异化定位和销售边界。用于市场
分析、产品定位和受控销售准备;不允许推断竞品“不支持”或生成无证据攻击性话术。
allowed-tools:
- Read
- Grep
- Glob
compatibility: Markdown/JSON;需要提供或获准获取竞品公开来源
---
# 竞品与定位分析
## 必要输入
- 本产品事实与证据。
- 明确的竞品名称、版本、目标市场和比较日期。
- 竞品官方文档、发布说明、公开价格页或经批准第三方来源。
- 比较目的、受众和允许公开的范围。
## 公平比较规则
1. 比较同一时间、版本、部署模式和授权范围。
2. 所有产品使用同一组维度和判定标准。
3. 每个单元格标记:
- `verified`:来源明确支持该结论。
- `inferred`:基于有限信息的推论,不能作为确定事实外发。
- `unknown`:未找到可验证信息。
4. 未公开的信息写 `unknown`,不能写“不支持”。
5. 价格必须注明日期、地区、计费单位、版本和附加条件。
6. 安全、合规和性能结论必须使用原始证书或测试条件。
## 输出
使用 `templates/competitive-positioning.md` 生成:
- 比较范围和方法。
- 竞品能力矩阵。
- 来源台账。
- 本产品适合赢得和不适合争夺的场景。
- 经证据支持的差异化表述。
- 销售问答与禁止话术。
## 定位原则
- 定位说明“对特定受众,在特定场景下为什么适合”,不是宣布全面领先。
- 差异点必须对应用户决策标准和可验证产品事实。
- 明确本产品限制,避免销售把定位扩张成产品承诺。
- 竞品材料过期、版本不明或来源撤回时,相关结论立即失效。
## 完成标准
每个比较结论有来源和日期;未知与不支持严格分开;比较维度一致;没有贬损性、
法律风险或未经批准的价格信息;定位与当前产品版本、功能状态和适用边界一致。
@@ -0,0 +1,39 @@
# {{产品名称}}竞品与定位分析
**比较日期**{{YYYY-MM-DD}}
**比较范围**{{市场、版本、部署和授权范围}}
**使用限制**:内部 / 受控销售 / 可公开
## 一、评价维度与方法
| 维度 | 判定标准 | 数据来源要求 |
|---|---|---|
| {{维度}} | {{统一标准}} | {{官方文档/测试报告}} |
## 二、竞品矩阵
| 维度 | 本产品 | 竞品 A | 竞品 B |
|---|---|---|---|
| {{维度}} | {{结论 [verified]}} | {{结论 [verified/inferred/unknown]}} | {{结论}} |
## 三、来源台账
| 来源 ID | 产品 | 标题 | URL/文档 | 版本 | 日期 | 访问日期 | 权威性 |
|---|---|---|---|---|---|---|---|
| SRC-001 | {{产品}} | {{标题}} | {{来源}} | {{版本}} | {{日期}} | {{日期}} | 官方 |
## 四、定位
### 适合优先争取的场景
- {{目标受众 + 场景 + 可验证差异}}
### 不适合或需谨慎的场景
- {{本产品限制或竞品明确优势}}
## 五、销售问答与禁止话术
| 客户问题 | 有依据的回答 | 来源 | 禁止表述 |
|---|---|---|---|
| {{问题}} | {{回答}} | {{SRC/CLM ID}} | {{无依据绝对化说法}} |
@@ -0,0 +1,55 @@
---
name: customer-case-study
version: 1.0.0
description: |
基于客户授权、实施记录和可复核指标生成客户案例、成功故事和案例摘要。用于公开
宣传、销售材料或受控投标引用;没有披露授权或历史结果时不得生成可发布案例。
allowed-tools:
- Read
- Grep
- Glob
compatibility: Markdown;建议配合 product-evidence
---
# 客户案例
## 发布前硬门禁
必须具备:
- 客户名称、Logo、引语和项目范围的书面披露授权,或明确匿名化要求。
- 实施前基线、实施后结果、测量周期、样本和统计口径。
- 产品版本、部署范围、客户责任和第三方条件。
- 对最终文案、数字和图片的审批责任人。
缺少任一项时只能输出内部案例草稿和缺口清单,不得生成“已发布”版本。
## 叙事结构
1. 客户背景,只保留获准披露的信息。
2. 具体任务和实施前状态。
3. 方案范围、实施过程和双方责任。
4. 产品如何参与工作流,不夸大为单一成功原因。
5. 结果、测量方法和限制。
6. 客户引语,仅使用获批原文。
7. 可复用经验和适用边界。
使用 `templates/customer-case-study.md` 起草。
## 指标规则
- 同时给出基线和结果,不能只给改善百分比。
- 说明周期、样本、排除项、数据来源和计算方法。
- 区分相关性与因果性,不把同期其他变化归功于产品。
- 预测收益、POC 目标和真实生产结果不能混写。
- 小样本、人工评分或模型评估必须明确说明。
## 匿名化
匿名案例仍需授权。删除或泛化名称、地点、项目编号、截图账号、内部系统名和可
反向识别组合信息;匿名化不能改变事实、行业范围和测量口径。
## 完成标准
授权范围覆盖全部文字、数字、Logo、引语和图片;案例描述与实施记录一致;指标可
复算;产品贡献不过度归因;限制清楚;公开版不含客户隐私、合同信息或内部路径。
@@ -0,0 +1,47 @@
# {{客户授权名称或匿名描述}}:{{案例主题}}
> 发布状态:内部草稿 / 客户审核中 / 已批准公开
> 授权记录:{{授权文件与范围}}
> 产品版本:{{版本}}
## 客户背景
{{只写获准披露的行业、规模和业务范围。}}
## 实施前任务与基线
| 指标 | 基线 | 周期与样本 | 数据来源 |
|---|---:|---|---|
| {{指标}} | {{数值}} | {{周期、样本}} | {{来源}} |
## 方案与实施范围
- 产品参与:{{工作流中的具体作用}}
- 客户责任:{{数据、流程、人员或审核}}
- 第三方条件:{{依赖}}
- 非范围项:{{不属于本案例的内容}}
## 实施过程
{{阶段、关键动作和变更,不写无法核验的戏剧化叙事。}}
## 结果与测量方法
| 指标 | 基线 | 结果 | 变化 | 测量条件 | 证据 |
|---|---:|---:|---:|---|---|
| {{指标}} | {{值}} | {{值}} | {{值}} | {{口径}} | {{EVD-001}} |
## 客户引语
> “{{仅使用获批原文}}”
## 适用边界与经验
{{限制、样本边界、人工复核要求和可复用经验。}}
## 发布审批
| 内容 | 授权范围 | 审批人 | 日期 | 状态 |
|---|---|---|---|---|
| 客户名称/Logo | {{范围}} | {{审批人}} | {{日期}} | {{状态}} |
| 指标与引语 | {{范围}} | {{审批人}} | {{日期}} | {{状态}} |
-37
View File
@@ -1,37 +0,0 @@
---
id: data-summary
name: 数据摘要
description: 将用户提供的数据或统计结果压缩为准确、易读的摘要,突出趋势、差异与限制。
version: 1.0.0
tags:
- 数据
- 摘要
- 汇报
---
# 数据摘要
## 工作原则
- 保留原始单位、时间范围、样本范围和统计口径。
- 不补造数值,不隐去影响解释的重要异常或限制。
- 使用绝对值与相对变化时,清楚标注基准。
- 避免把描述性结果升级为因果结论或普遍规律。
## 摘要流程
1. 明确摘要面向的读者和需要回答的问题。
2. 识别总量、趋势、结构、差异和异常。
3. 核对数字之间的关系及四舍五入口径。
4. 按重要性筛选少量关键发现。
5. 补充数据质量、样本和解释边界。
## 输出结构
- **一句话结论:** 最重要且有数据支持的信息
- **关键数字:** 数值、单位、周期和对比基准
- **主要趋势:** 方向、幅度和持续时间
- **值得关注:** 异常、分组差异或转折点
- **限制说明:** 缺失、偏差或不可比较之处
若用户未提供足够数据,先列出缺口,不以推测代替结果。
+272
View File
@@ -0,0 +1,272 @@
---
name: deai-writing
version: 1.1.0
description: |
中文正式文档「去 AI 味」审校。用于任何需要产出不露 AI 痕迹的正式中文文本:
投标方案、技术方案、公司官网文案、研究文章、汇报材料、说明文档、商务邮件。
在生成或润色中文正式文档之后调用,也可在评审阶段单独调用做质量门禁。
提供可执行的病症词典扫描脚本,把「凭感觉找 AI 味」变成「按清单定位并改写」。
触发词:去 AI 味、AI 腔、AI 味、文案审校、润色中文文档、官网文案评审。
allowed-tools:
- Read
- Grep
- Glob
- Execute
compatibility: Python 3.9+,不依赖第三方 Python 包
---
# 中文正式文档「去 AI 味」审校
AI 味不是玄学,而是一批可枚举、可正则命中、可批量修改的固定套路。所以这件事
能做成脚本 + 清单反复调用,不用每次靠人肉感觉。
## 什么时候用
- 刚用大模型生成或润色完一份中文正式文档,交付前。
- 长文方案编制流程里,接在关键词核验之后、人工通读之前,作为固定质量门禁
(见 `longdoc-docx` 技能)。
- 官网/产品文案评审,被人挑出「像 AI 写的」但说不清哪里像。
## 怎么用
先探测可用的 Python 3 解释器:Windows 优先使用 `python`macOS/Linux
优先使用 `python3`;不要使用未经验证的 Windows `py` 或 WindowsApps
`python3.exe`。下文 `<python>` 表示探测成功的解释器命令。
```bash
<python> "<skill-dir>/deai_scan.py" 方案.md # 单文件
<python> "<skill-dir>/deai_scan.py" docs/ --ext .md # 递归目录
<python> "<skill-dir>/deai_scan.py" public/ --rules my_site # 叠加项目词典
<python> "<skill-dir>/deai_scan.py" 方案.md --json # 结构化输出,喂给模型改写
<python> "<skill-dir>/deai_scan.py" 方案.md --fail-on-block # CI 门禁,阻断项非零则退出 1
```
`<skill-dir>` 指本 `SKILL.md` 所在目录。不要假定技能安装在固定路径,项目技能、
个人技能和插件技能的安装位置不同。
输出分两级:
- **阻断项**:命中即应改写,目标压到接近 0。
- **复核项**:只是候选,结合页面类型、事实边界和专业语境逐条判断,**不追求
机械清零**。研究文章里的「闭环」如果确有定义就该留着。
标准迭代:
1. 先确认文档类型、目标读者、称谓和不能改变的事实边界。
2. 扫描源文件,将 JSON 命中清单与原文一起交给 Agent 定向改写。
3. 逐条核对改写没有编造数字、删除限制条件或改变责任主体。
4. 复扫,直到阻断项收敛;逐条处理复核项,不机械清零。
5. 通读全文,检查关键词扫描无法发现的前后矛盾和主体错位。
扫描器只负责定位,不提供自动替换。语义改写必须由 Agent 结合上下文完成,
避免把专业术语、法定提示和事实边界误删。
脚本默认跳过 Markdown 代码块和 HTML 的 `<script>/<style>/<pre>/<code>`,避免
把代码里的 `not ... but` 误判成对照模板。
## 病征清单
### 1. 套路化连接词与转折
`不仅……而且``更是``无疑``毫无疑问``值得注意的是``需要指出的是`
`总而言之``综上所述``总的来说``换言之``一言以蔽之``众所周知`
改法:直接删掉这些提示词,把后面的内容当正文说。中文母语写作很少这样起承
转合,观点直接给结论。
### 2. 空心形容词与抽象术语
`强大的``卓越的``高效的``全方位``一站式``赋能``助力``打造`
`深耕``护航``保驾护航``量身定制``极致``无缝`
改法:换成可验证的具体事实。「强大的性能」→「单卡 141GB 显存,可常驻 3 个
模型」;「高效赋能研发」→「代码审查从人工 30 分钟降到自动 2 分钟」。**能用
数字或具体动作说清的,绝不用形容词。**
`闭环``底座``形成衔接``共同约束` 这类抽象搭配要复核:原句若没说明具体
组件、关系或动作,改成 `平台``连接``共同限定``由……校验`;确有定义的
架构、控制理论或工程语境可以保留。
**具体数字必须来自可复查证据,不能为替换空心形容词而编造指标。**
### 3. 排比与三段式强迫症
命中信号:连续三项结构完全对称的短语;每段都凑成三点;每个要点长度刻意一致。
改法:打破对称。该两点就两点,该五点就五点;长短句混用;把排比拆成陈述句。
### 4. 开头的宏大叙事
`随着……的快速发展``在……的今天``在数字化转型的大背景下``当前,……`
`近年来,……`
改法:删掉铺垫,第一句直接进入主题。正式方案的读者不需要背景朗诵。
### 5. 结尾的空洞升华
`让我们携手……``共同开创……的美好未来``为……贡献力量``必将……`
`奠定坚实基础`
改法:正式文档结尾给可执行结论或下一步动作,不喊口号。
### 6. 过度自我指涉与礼貌层
过量的 `我们``我方``本方案``本系统旨在``致力于``希望能对您有所帮助`
`如有需要,欢迎随时联系` 这类客服尾巴。
改法:正式技术文档以事实和系统为主语;删掉客服式收尾。
### 7. 机械的分点与加粗
命中信号:几乎每句话都是一个 bullet;每个 bullet 都加粗前半句做伪标题;
`首先/其次/再次/最后` 生硬编号。
改法:叙述性内容用段落写,列表只留真正并列、需要逐条对照的信息。
### 8. 中英标点与格式痕迹
滥用破折号 `——`;中文里夹英文半角逗号/括号;`` 后强行分号排比;Emoji
`✅❌🚀` 等符号。
改法:破折号能换成逗号、括号或分句就换掉;中文全角标点统一;不用 Emoji。
### 9. 冗余与同义反复
`进行了……的操作``做出了……的决定``起到了……的作用``具有……的特点`
`实现了……的功能`
改法:把「进行/做出/起到/具有/实现 + 名词」的绕弯结构还原成一个动词。
「进行了优化的操作」→「优化了」。
### 10. 过度对冲与免责
`可能``或许``在某种程度上``总体而言``一般来说` 的密集堆叠。
改法:有把握就直说;确需限定的地方保留一处即可。公司官网中的必要边界集中
说明一次,优先用正向范围表述,例如「支持在约定数据源与人工复核流程下运行」,
避免在标题、正文和 CTA 中反复出现 `不代表``不包含``尚未``不能`
**研究方法限制、法定提示、安全边界和人工复核要求不适用上述压缩规则,必须
按事实保留。**
### 11. 对照句式、问答式标题与人为凑数
句式:`不是 A,而是 B``并非 A,而是 B``A,而不是 B``不先谈 A,先看 B`
英文的 `not A but B``rather than``instead of` 同属一类。
标题:`结果回答了三个具体问题``以下四点值得关注``三个发现`
`我们需要回答什么`。这类标题只描述文章结构,没有说明本节内容。
改法:删除对照框架,直接写 B;标题直接写研究对象或结果。
- 「拆分依据不是模块名称,而是控制复杂度与数据流特征」
→ 「PS/PL 分工依据控制复杂度与数据流特征」
- 「实测结果回答了三个具体问题」→「正确性、批量性能与时序结果」
- 「交付标准围绕任务结果,而不是模型清单」→「以任务结果界定交付标准」
数量只能来自内容本身。确有三组测量结果时可以列三项,但标题不必强调「有三个
问题」。
### 12. 公司官网写成实施教程
命中信号:首屏用 `先把……接入……``从一个场景开始``第一步先……` 等操作
指令;公司介绍围绕实施顺序展开,没有说明服务领域和技术能力。
改法:首页首屏先回答「公司面向哪些领域、提供什么服务」。实施步骤放到交付
方式或产品详情里,不承担公司定位。标题用公司或能力主语,例如「面向专业领域,
构建行业智能系统」。
CTA 应指向项目咨询、合作沟通或联系团队,避免 `按清单准备材料``从第一步
开始``说明当前流程` 这类需求填报或实施指导语言;清单、模板和实施指南只放在
明确标注的资料或交付页面。
首屏说明业务对象、能力范围与交付方式,不展开接口字段、配置步骤、临时文件、
异常回退、队列状态和调试过程。必要技术细节下沉到技术说明,**不因去 AI 味而
删除**。
### 13. 元话语标题
`本节回答……``结果说明了什么``需要关注的几个问题``我们如何理解……`
标题在评论文章本身,没有给出信息。
改法:直接写主题、对象、指标或结论范围。研究文章优先用 `测试环境`
`批量性能``时序结果``适用边界` 等名词性标题。
慎用以 `把``让``先``再` 开头的操作口令;英文标题避免 `Bring...`
`Start...``Let...``First...` 祈使句,优先 `Project Consultation`
`Deployment Scope``Human Review` 等名词性标题。
研究和技术报告可以直接陈述测量范围与方法限制,例如「当前测量仅覆盖 INT8
点积」,不要套成「这不是完整检索,而只是……」。
公司官网不公开 `当前基线``当前证据``已知缺口``成熟度等级``页面所述`
等内部审查语言,改写为客户可理解的适用范围、接入条件和分阶段交付边界;规划
能力仍须用将来时或设计阶段表述。
## 改写纪律
去 AI 味不是把文本改得干瘪,而是去套路、留信息。四条底线:
1. **只删套路,不删事实。** 形容词换成数字/动作是「换」不是「删信息」;
连接词、铺垫、升华才是直接删。
2. **保留专业术语与必要限定。** 技术文档里的约束/前提/风险不是对冲水词,
该留;要删的是无意义的「可能、或许」堆叠。
3. **不删除事实边界。** 保留研究指标的测试条件与统计口径、第三方来源和归属、
人工复核要求、数据与接口条件及部署范围。去 AI 味不能改变成熟度,也不能把
规划能力写成已经实现。
4. **改完复读一遍出声。** AI 味的本质是结构过于工整、信息密度偏低,出声读
最容易发现。
## 一分钟自查清单(不跑脚本时)
- 开头有没有「随着/在……的今天」?删。
- 有没有「不仅……而且/综上所述/值得注意的是」?删。
- 有没有「不是 A,而是 B」或「回答三个问题」式标题?直接写 B 或具体结果。
- 公司首页是否写成了实施步骤?改成服务领域、产品能力和公司定位。
- CTA 是否像需求填报表?改成项目咨询或合作沟通。
- 官网是否出现「当前基线」「已知缺口」等内部审校语言?改成适用范围和交付条件。
- 标题是否以 `把/让/先/再``Bring/Start/Let/First` 发出操作口令?改名词性。
- 产品首屏是否塞入接口字段、回退链、调试过程?下沉到技术说明。
- 限制是否在同一页面重复出现?合并为一处正向范围说明,同时保留必要的研究、
安全和人工复核边界。
- 形容词能不能换成数字或具体动作?能就换。
- 是不是每段都凑三点、每句都加粗?打破它。
- 有没有破折号、Emoji、客服式结尾?清掉。
- 出声读一遍:像人说话,还是像念 PPT?
## 文件构成
```
deai-writing/
SKILL.md # 本文件
deai_scan.py # 扫描器
ai_smell_dict.py # 通用词典(跨项目)
project_rules/
example.py # 可复制的匿名项目词典模板
tests/
test_deai_scan.py # 扫描、屏蔽和项目词典回归测试
```
## 持续进化
每次评审被挑出的新 AI 味用词,回填进词典:
1. 记录原句、评审意见和最终改法。
2. 判断问题属于词语、句式、标题结构还是页面定位。
3. 跨项目通用的进 `ai_smell_dict.py`;只与某站点/项目相关的进
`project_rules/<项目>.py`,并在该文件的 `REVIEW_LOG` 里追加台账。
4. 在整个项目扫描同类表达,不只修改被点名的那一句。
5. 中英文同步处理,避免中文已改而英文仍留 `not...but``rather than`
元话语标题。
新增项目词典:在 `project_rules/` 下新建 `<名字>.py`,导出 `AI_SMELL`
`REVIEW_ONLY`(都可选)和 `REVIEW_LOG`,用 `--rules <名字>` 加载。
项目词典只允许上述变量的 Python 字面量赋值,扫描器不会执行其中的函数调用或
导入语句。
共享或导出技能时,只带通用词典和匿名模板。项目专属规则可能包含客户名称、
内部措辞和评审记录,不应进入分发包。
## 验证技能
```bash
<python> -m unittest discover -s "<skill-dir>/tests" -p "test_*.py"
<python> "<skill-dir>/deai_scan.py" "<skill-dir>/SKILL.md" --json
```
测试必须覆盖阻断项、复核项、Markdown/HTML 代码区屏蔽、目录扫描和自定义词典。
增加或调整规则后先补回归样例,再发布新版本。
## 已知边界
- 结构性问题(排比、三段式、每段凑三点)正则只能给候选,最终要人读。
- 「不仅……而且」等词单独出现误报率高,词典里已收敛成句式匹配;仍会有误报,
阻断项要逐条看过再改,不能盲目全局替换。
- 扫描器不判断事实正确性。改写时新引入的数字必须有证据支撑。
@@ -0,0 +1,109 @@
"""通用「AI 味」病症词典。
两级:
AI_SMELL 阻断项 —— 命中即应改写,目标压到接近 0。
REVIEW_ONLY 复核项 —— 只作人工判断候选,不能作为自动删除或发布失败条件。
词条可以是普通字符串(按子串匹配)或正则(`re` 语法)。扫描器统一用
`re.search` 处理,普通字符串里的正则元字符需要自行转义。
扩充规则:每次评审被挑出的新 AI 味用词,回填到这里;只与单个项目/站点
相关的规则不要放这里,放 project_rules/ 下的项目词典。
"""
# ---------------------------------------------------------------------------
# 阻断项
# ---------------------------------------------------------------------------
AI_SMELL = {
"套路连接词": [
# "不仅/而且" 单列误报率过高,收敛成句式匹配(原清单的精确化)
r"不仅.{0,40}(而且|并且|还|也)",
"更是", "无疑", "毫无疑问", "值得注意的是", "需要指出的是",
"总而言之", "综上所述", "总的来说", "换言之", "众所周知",
"一言以蔽之",
],
"对照模板": [
r"不是.{0,40}而是", r"并非.{0,40}而是", "而不是",
r"不先.{0,30}先", r"下一步是.{0,40}而不是",
],
"问答式标题": [
r"回答了?[一二三四五六七八九十0-9]+个.{0,20}问题",
r"以下[一二三四五六七八九十0-9]+[点项个]",
r"[一二三四五六七八九十0-9]+个具体问题",
r"结果说明了什么", r"需要关注的几个问题",
],
"实施口号": [
r"先把.{0,30}接入", "从单一场景切入", "从一个场景开始",
],
"英文对照模板": [
r"\bnot\b.{0,60}\bbut\b", r"\brather than\b", r"\binstead of\b",
],
"英文元话语": [
r"answers? (three|four|[0-9]+)",
"three specific questions", "three findings from",
],
"空心形容词": [
"强大的", "卓越的", "高效的", "全方位", "一站式", "赋能", "助力",
"打造", "深耕", "护航", "保驾护航", "量身定制", "极致", "无缝",
],
"宏大开头": [
r"随着.{0,30}(的)?(快速)?发展", "在当今", "在数字化", "近年来",
"大背景下",
],
"空洞升华": [
"携手", "美好未来", "贡献力量", "必将", "奠定坚实基础", "开创",
],
"客服尾巴": [
"希望能对您有所帮助", "如有需要", "欢迎随时", "感谢您的",
],
"绕弯结构": [
r"进行了.{0,15}的?(操作|处理|优化|改造|分析)",
r"做出了.{0,15}的?决定",
r"起到了.{0,15}的?作用",
r"具有.{0,20}的特点",
r"实现了.{0,20}的功能",
],
"过度对冲": [
"在某种程度上", "总体而言", "一般来说",
],
"格式痕迹": [
"——", "", "", "🚀", "💡", "🎯", "", "🔥", "📌", "",
],
}
# ---------------------------------------------------------------------------
# 复核项:结合页面类型与专业语境判断,不机械清零
# ---------------------------------------------------------------------------
REVIEW_ONLY = {
"内部审校话语": [
"当前基线", "当前证据", "已知缺口", "成熟度等级", "页面所述",
],
"抽象术语": [
"闭环", "底座", "形成衔接", "共同约束",
],
"教程式CTA": [
"说明当前流程", "从第一步开始", "按清单准备材料",
],
"标题口令": [
r"^\s*#{1,6}\s*(把|让|先|再)",
r"^\s*#{1,6}\s*(Bring|Start|Let|First)\b",
],
"否定句堆叠": [
"不代表", "不包含", "尚未", "不能",
],
}
# ---------------------------------------------------------------------------
# 结构类近似检测:正则给候选,最终要人读
# ---------------------------------------------------------------------------
STRUCTURE_CHECKS = {
# 短句里塞了 4 个以上顿号/逗号,多半是三段式排比或名词堆叠
"疑似排比堆叠": {
"pattern": r"[,、]",
"min_count": 4,
"max_line_len": 60,
},
}
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""中文正式文档「AI 味」扫描器。
用法:
python3 deai_scan.py 方案.md
python3 deai_scan.py docs/ --ext .md .html
python3 deai_scan.py public/index.html --rules my_site
python3 deai_scan.py 方案.md --json # 机器可读,供 agent 二次处理
python3 deai_scan.py 方案.md --fail-on-block # 阻断项非零时退出码 1,可做门禁
Markdown 的代码块(``` 围栏与缩进块)和 HTML 的 <script>/<style>/<pre>/<code>
默认跳过,避免把代码里的英文关键字误判成 AI 味。用 --no-skip-code 关闭。
"""
import argparse
import ast
import bisect
import importlib.util
import json
import os
import re
import sys
SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
RULES_DIR = os.path.join(SKILL_DIR, "project_rules")
LEVEL_BLOCK = "阻断"
LEVEL_REVIEW = "复核"
def _load_module(path, name):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _load_project_rule_data(path):
"""Read project rules as literals without executing repository code."""
with open(path, encoding="utf-8") as handle:
tree = ast.parse(handle.read(), filename=path)
allowed = {"AI_SMELL", "REVIEW_ONLY", "REVIEW_LOG"}
data = {}
for node in tree.body:
if (
isinstance(node, ast.Expr)
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)
):
continue
if (
isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id in allowed
):
try:
data[node.targets[0].id] = ast.literal_eval(node.value)
except (ValueError, SyntaxError) as exc:
raise ValueError(
f"项目词典仅允许字面量:{path}"
) from exc
continue
raise ValueError(
f"项目词典包含不可执行的语句:{path}:{getattr(node, 'lineno', '?')}"
)
return data
def load_rules(project_rules=None):
"""载入通用词典,可选叠加一个项目词典(同名分类合并,不覆盖)。"""
base = _load_module(os.path.join(SKILL_DIR, "ai_smell_dict.py"), "ai_smell_dict")
block = {k: list(v) for k, v in base.AI_SMELL.items()}
review = {k: list(v) for k, v in base.REVIEW_ONLY.items()}
structure = dict(getattr(base, "STRUCTURE_CHECKS", {}))
if project_rules:
path = project_rules
if not os.path.exists(path):
path = os.path.join(RULES_DIR, f"{project_rules}.py")
if not os.path.exists(path):
available = [f[:-3] for f in sorted(os.listdir(RULES_DIR))
if f.endswith(".py") and not f.startswith("_")]
sys.exit(f"找不到项目词典 {project_rules!r};可用:{available or '(无)'}")
proj = _load_project_rule_data(path)
for cat, words in proj.get("AI_SMELL", {}).items():
block.setdefault(cat, []).extend(words)
for cat, words in proj.get("REVIEW_ONLY", {}).items():
review.setdefault(cat, []).extend(words)
return block, review, structure
def compile_rule_groups(groups):
"""Compile rule groups once so directory scans do not recompile per file."""
compiled = []
for category, rules in groups.items():
for rule in rules:
try:
pattern = re.compile(rule, re.I | re.M | re.S)
except re.error as exc:
raise ValueError(
f"无效正则 {rule!r}{category}):{exc}"
) from exc
compiled.append((category, rule, pattern))
return compiled
# ---------------------------------------------------------------------------
# 代码区屏蔽:把不参与扫描的区间用空格替换,保持行号与列偏移不变
# ---------------------------------------------------------------------------
def _blank_out(text, pattern, flags=re.S | re.I):
def repl(m):
return re.sub(r"[^\n]", " ", m.group(0))
return re.sub(pattern, repl, text, flags=flags)
def mask_code(text, path):
ext = os.path.splitext(path)[1].lower()
if ext in (".md", ".markdown"):
text = _blank_out(text, r"```.*?```")
text = _blank_out(text, r"~~~.*?~~~")
text = _blank_out(text, r"(?m)^(?: {4}|\t).*$", flags=re.M)
text = _blank_out(text, r"`[^`\n]+`", flags=0)
elif ext in (".html", ".htm", ".xhtml"):
for tag in ("script", "style", "pre", "code"):
text = _blank_out(text, rf"<{tag}\b.*?</{tag}>")
text = _blank_out(text, r"<!--.*?-->")
return text
def strip_html_tags(text):
"""HTML 文件里把标签本身抹掉,只留可见文本,避免属性名命中词条。"""
return _blank_out(text, r"<[^>]+>", flags=re.S)
# ---------------------------------------------------------------------------
# 扫描
# ---------------------------------------------------------------------------
def scan_text(
text,
path,
block,
review,
structure,
skip_code=True,
compiled_block=None,
compiled_review=None,
):
scan_src = mask_code(text, path) if skip_code else text
if os.path.splitext(path)[1].lower() in (".html", ".htm", ".xhtml"):
scan_src = strip_html_tags(scan_src)
lines = scan_src.splitlines()
raw_lines = text.splitlines()
line_starts = [0]
line_starts.extend(match.end() for match in re.finditer("\n", scan_src))
findings = []
compiled_sets = (
(LEVEL_BLOCK, compiled_block or compile_rule_groups(block)),
(LEVEL_REVIEW, compiled_review or compile_rule_groups(review)),
)
for level, compiled in compiled_sets:
for category, rule, pattern in compiled:
for match in pattern.finditer(scan_src):
line_number = bisect.bisect_right(line_starts, match.start())
raw = (
raw_lines[line_number - 1]
if line_number <= len(raw_lines)
else ""
)
findings.append({
"file": path,
"line": line_number,
"level": level,
"category": category,
"rule": rule,
"match": match.group(0),
"excerpt": raw.strip()[:120],
})
for cat, cfg in structure.items():
rx = re.compile(cfg["pattern"])
for i, line in enumerate(lines, 1):
s = line.strip()
if not s:
continue
if len(rx.findall(s)) >= cfg.get("min_count", 4) and len(s) <= cfg.get("max_line_len", 60):
findings.append({
"file": path,
"line": i,
"level": LEVEL_REVIEW,
"category": cat,
"rule": cfg["pattern"],
"match": "",
"excerpt": s[:120],
})
findings.sort(key=lambda f: (f["line"], f["level"] != LEVEL_BLOCK, f["category"]))
return findings
def collect_files(targets, exts):
out = []
for t in targets:
if os.path.isdir(t):
for root, _dirs, files in os.walk(t):
_dirs[:] = [d for d in _dirs if d not in
{".git", "node_modules", ".venv", "__pycache__", "dist", "build"}]
for f in sorted(files):
if os.path.splitext(f)[1].lower() in exts:
out.append(os.path.join(root, f))
elif os.path.exists(t):
out.append(t)
else:
print(f"跳过不存在的路径:{t}", file=sys.stderr)
return list(dict.fromkeys(out))
def main():
ap = argparse.ArgumentParser(description="中文正式文档 AI 味扫描")
ap.add_argument("targets", nargs="+", help="待扫描的文件或目录")
ap.add_argument("--rules", help="项目词典名(project_rules/ 下的模块名)或路径")
ap.add_argument("--ext", nargs="+", default=[".md", ".markdown", ".txt", ".html", ".htm"],
help="目录递归时纳入的扩展名")
ap.add_argument("--json", action="store_true", help="输出 JSON,供 agent 二次处理")
ap.add_argument("--block-only", action="store_true", help="只报阻断项")
ap.add_argument("--no-skip-code", action="store_true", help="不跳过代码块")
ap.add_argument("--fail-on-block", action="store_true", help="存在阻断项时退出码 1")
args = ap.parse_args()
try:
block, review, structure = load_rules(args.rules)
compiled_block = compile_rule_groups(block)
compiled_review = compile_rule_groups(review)
except ValueError as exc:
sys.exit(str(exc))
exts = {e if e.startswith(".") else "." + e for e in args.ext}
files = collect_files(args.targets, exts)
if not files:
sys.exit("没有可扫描的文件")
all_findings = []
errors = []
scanned_files = 0
for path in files:
try:
with open(path, encoding="utf-8") as f:
text = f.read()
except (UnicodeDecodeError, OSError) as e:
errors.append({"file": path, "error": str(e)})
continue
scanned_files += 1
all_findings.extend(
scan_text(
text,
path,
block,
review,
structure,
skip_code=not args.no_skip_code,
compiled_block=compiled_block,
compiled_review=compiled_review,
)
)
if args.block_only:
all_findings = [f for f in all_findings if f["level"] == LEVEL_BLOCK]
n_block = sum(1 for f in all_findings if f["level"] == LEVEL_BLOCK)
n_review = len(all_findings) - n_block
if args.json:
print(json.dumps({
"files": scanned_files,
"requested_files": len(files),
"block": n_block,
"review": n_review,
"errors": errors,
"findings": all_findings,
}, ensure_ascii=False, indent=2))
else:
cur = None
for f in all_findings:
if f["file"] != cur:
cur = f["file"]
print(f"\n=== {cur} ===")
hit = f"{f['match']}" if f["match"] else ""
print(f"[{f['level']}·{f['category']}] L{f['line']}: {f['excerpt']}{hit}")
for error in errors:
print(f"[读取失败] {error['file']}{error['error']}", file=sys.stderr)
print(f"\n扫描 {scanned_files}/{len(files)} 个文件:阻断项 {n_block},人工复核项 {n_review}")
if n_block:
print("阻断项须改写到接近 0;复核项结合页面类型与专业语境逐条判断,不机械清零。")
if errors:
sys.exit(2)
if args.fail_on_block and n_block:
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,26 @@
"""匿名项目词典模板。
复制为有意义的模块名后,用 ``--rules <模块名>`` 加载。项目规则只收录特定
站点、文档类型或团队确认过的表达,跨项目通用规则应放回 ai_smell_dict.py。
"""
AI_SMELL = {
"项目禁用表达": [
r"示例阻断词",
],
}
REVIEW_ONLY = {
"项目复核表达": [
r"示例复核词",
],
}
REVIEW_LOG = [
{
"date": "YYYY-MM-DD",
"rejected": "原句",
"issue": "评审意见",
"fix": "最终改法",
},
]
@@ -0,0 +1,107 @@
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SKILL_DIR))
import deai_scan
class DeaiScanTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.block, cls.review, cls.structure = deai_scan.load_rules()
def scan(self, text, suffix=".md"):
return deai_scan.scan_text(
text,
f"sample{suffix}",
self.block,
self.review,
self.structure,
)
def test_reports_block_and_review_findings(self):
findings = self.scan("综上所述,方案提供一站式服务。\n当前基线需要人工复核。")
levels = {finding["level"] for finding in findings}
self.assertEqual(levels, {deai_scan.LEVEL_BLOCK, deai_scan.LEVEL_REVIEW})
def test_masks_markdown_code(self):
findings = self.scan("正文没有问题。\n```text\n综上所述,打造闭环。\n```\n")
self.assertEqual(findings, [])
def test_masks_html_code_and_attributes(self):
findings = self.scan(
'<div data-note="综上所述">正常正文</div>'
"<script>const text = '一站式';</script>",
".html",
)
self.assertEqual(findings, [])
def test_matches_sentence_across_markdown_line_break(self):
findings = self.scan("这不是普通说明,\n而是固定对照模板。")
self.assertTrue(
any(finding["category"] == "对照模板" for finding in findings)
)
self.assertEqual(findings[0]["line"], 1)
def test_loads_custom_rule_file(self):
with tempfile.TemporaryDirectory() as tmp:
rules = Path(tmp) / "custom.py"
rules.write_text(
'AI_SMELL = {"自定义": ["专属阻断词"]}\n'
'REVIEW_ONLY = {"自定义复核": ["专属复核词"]}\n',
encoding="utf-8",
)
block, review, _ = deai_scan.load_rules(str(rules))
self.assertIn("专属阻断词", block["自定义"])
self.assertIn("专属复核词", review["自定义复核"])
def test_project_rules_cannot_execute_code(self):
with tempfile.TemporaryDirectory() as tmp:
rules = Path(tmp) / "custom.py"
rules.write_text(
'AI_SMELL = {}\nopen("/tmp/should-not-exist", "w")\n',
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "不可执行"):
deai_scan.load_rules(str(rules))
def test_cli_json_and_failure_exit(self):
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "sample.md"
target.write_text("综上所述,本方案必将提供卓越的服务。", encoding="utf-8")
command = [sys.executable, str(SKILL_DIR / "deai_scan.py"), str(target), "--json"]
result = subprocess.run(command, check=True, capture_output=True, text=True)
report = json.loads(result.stdout)
self.assertGreater(report["block"], 0)
failed = subprocess.run(
command + ["--fail-on-block"],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(failed.returncode, 1)
def test_cli_fails_when_text_file_cannot_be_decoded(self):
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "sample.md"
target.write_bytes(b"\xff\xfe\x00")
result = subprocess.run(
[sys.executable, str(SKILL_DIR / "deai_scan.py"), str(target), "--json"],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 2)
self.assertEqual(len(json.loads(result.stdout)["errors"]), 1)
if __name__ == "__main__":
unittest.main()
@@ -1,33 +0,0 @@
---
id: document-writing
name: 文档写作
description: 协助起草结构清晰、语气专业的中文办公文档,并在信息不足时明确标注待确认内容。
version: 1.0.0
tags:
- 写作
- 文档
- 办公
---
# 文档写作
## 工作原则
- 先确认文档类型、目标读者、写作目的、语气和篇幅。
- 仅依据用户提供的信息写作,不臆造事实、数据、引语或结论。
- 信息缺失时使用“待确认”标记,并列出需要补充的问题。
- 涉及隐私、机密或敏感信息时,提醒用户审阅并酌情脱敏。
## 推荐流程
1. 提炼核心目标与读者需要采取的行动。
2. 设计“背景—要点—行动”或适合文体的结构。
3. 使用简洁标题、短段落和一致术语完成初稿。
4. 检查逻辑、事实边界、语气、格式与可读性。
5. 输出成稿,并附简短的待确认事项。
## 输出要求
- 默认提供标题、正文和必要的小标题。
- 重点结论前置,行动项写明负责人和时间要求(如已知)。
- 避免空话、重复表达、夸张承诺和含混指代。
-35
View File
@@ -1,35 +0,0 @@
---
id: email-assistant
name: 邮件助手
description: 协助撰写、改写和回复专业邮件,突出目的、关键信息与明确行动项。
version: 1.0.0
tags:
- 邮件
- 沟通
- 办公
---
# 邮件助手
## 工作原则
- 明确收件人关系、邮件目的、期望行动、截止时间和语气。
- 不编造姓名、职位、承诺、附件内容或已发生的沟通。
- 对敏感信息、外部收件人和群发场景提示用户复核。
- 避免施压、冒犯、歧义和不必要的冗长表达。
## 撰写流程
1. 用具体主题概括事项和所需行动。
2. 开头直接说明背景与来意。
3. 分点呈现事实、问题或请求。
4. 明确下一步、负责人和时间(如已知)。
5. 使用与关系和场景相符的结束语。
## 输出格式
- **主题:** 简短且可检索。
- **正文:** 称呼、目的、要点、行动请求、结束语。
- **待确认:** 列出缺失的收件人、日期、附件或事实。
回复邮件时,应区分已回答问题、尚待确认问题和新增行动项。
+168
View File
@@ -0,0 +1,168 @@
---
name: longdoc-docx
version: 1.0.0
description: |
将多章节 Markdown 构建为排版规范的 Word 长文,并通过临时 PDF 核验排版。用于
投标方案、技术方案、白皮书、验收报告等包含封面、目录、表格、图片、代码块和
分页规则的中文正式文档。不要用于只需简单复制文本的短文档。
allowed-tools:
- Read
- Grep
- Glob
- Execute
compatibility: Python 3.9+DOCX 构建需 requirements.txtPDF 核验需 LibreOffice Writer
---
# Markdown 长文转 Word
以 Markdown 和图表生成脚本为唯一信源。不要手工修改生成的 DOCX/PDF,修订应回到
源文件后重新构建,避免正文、图表、编号和交叉引用失去同步。
`<skill-dir>` 指本 `SKILL.md` 所在目录,不要假定技能安装在固定路径。
## 首次准备
先探测可用的 Python 3 解释器:Windows 优先使用 `python`macOS/Linux
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
```bash
<python> -m pip install -r "<skill-dir>/requirements.txt"
cp "<skill-dir>/templates/document.example.json" ./document.json
```
编辑 `document.json`,至少填写:
- `title``subtitle``author``date`
- `output`,生成的 DOCX 路径
- `chapters`,按最终顺序显式列出 Markdown 文件
- 每章的 `page_break_before`,只在真正的一级章节前设为 `true`
不得依赖目录排序自动拼接正文。大纲、README、评审记录等内部文件不要加入
`chapters`
## 目录约定
交付物与核验中间产物必须分处不同目录,避免整目录拷贝时把中间产物一并发出:
```text
build/ # 草稿与中间产物,可随时重建
document.json # 构建配置
chapters/ # 正文章节,按 01- 02- 前缀命名
01-overview.md
02-design.md
assets/ # 图片与图表脚本产出的 PNG
drafts/ # 大纲、评审记录、废弃稿,永不进入 chapters
check/ # 核验用 PDF、verification.json、页面 PNG
dist/ # 交付物,只存放 DOCX
document.docx
```
`output` 指向 `dist/`PDF、`--json``--render-dir` 一律指向 `build/check/`
目录名可随项目调整,但交付物目录内不得出现 PDF、PNG 和核验报告。
分章节时另有三条约束:
- 图片路径相对**引用它的 Markdown 文件**解析,不是相对 `document.json`。章节在
`chapters/` 而图片在 `assets/` 时,需回退一级再进入 assets 目录。
- 章节文件名前缀只用于人工排序,构建顺序完全由 `chapters` 数组决定。改动章节
顺序必须改数组,重命名文件不会生效。
- `drafts/``chapters/` 必须分开存放。混在一起时,评审记录和废弃稿极易被
误加入 `chapters`,且无法通过目视区分。
## 标准工作流
### 1. 核对源文件
1. 固定标题层级和编号体系,再开始合并。
2. 检查 Markdown 图片路径都相对当前 Markdown 文件所在目录可解析。
3. 搜索残留 ASCII 流程图和重复代码块,已有正式图片时删除旧占位图。
4. 关键设计变化后同步修改图表生成脚本。
5. 逐条比对 `chapters` 数组与 `chapters/` 内的实际文件:数组遗漏会静默少章,
多余路径会直接构建失败。章节数和顺序都要与目录核对一次。
如需脚本化绘制中文架构图,可导入 `diagram_kit.py`;先检查字体:
```bash
<python> "<skill-dir>/diagram_kit.py" --check-font
```
### 2. 构建 DOCX
```bash
<python> "<skill-dir>/scripts/build_docx.py" --config ./document.json
```
构建器支持标题、普通段落、粗体/斜体/行内代码、嵌套列表、表格、图片、图注、
围栏代码块、引用块、封面、目录域和页脚页码。表格按各列内容长度分配宽度,避免
长文本列过窄导致页数异常增长。
目录由 Word 域生成。首次在 Microsoft Word 或 LibreOffice Writer 中打开后需更新
目录域,未更新时看到提示文字属于正常情况。
### 3. 转换 PDF(仅用于核验)
PDF 是校验中间件,不是交付物。交付物为 DOCX;PDF 只用于第 4、5 步的乱码、
空白页和视觉复核,核验通过后应删除,除非用户明确要求交付 PDF。
```bash
soffice --headless --convert-to pdf --outdir ./build/check ./dist/document.docx
```
如果目标路径中已有同名 PDF,先确认它是可重建产物,再由 Agent 按当前工具安全
规则处理。不要覆盖用户手工维护的文件。
### 4. 程序化核验
```bash
<python> "<skill-dir>/scripts/verify_pdf.py" ./build/check/document.pdf \
--forbid "我方" "我们" \
--json ./build/check/verification.json \
--render-dir ./build/check/pages
```
核验器检查页数、乱码替换符、禁用词和疑似空白页,并可按 300 DPI 渲染逐页 PNG。
程序化文本抽取不能证明视觉排版正确,跨页表格尤其可能出现抽取顺序异常。
### 5. 人工门禁
- 逐页检查标题孤行、表格跨页、图片清晰度、图注和异常留白。
- 可疑文字必须查看 300 DPI 页面图,必要时裁剪放大,不能依据缩略图判断错字。
- 核对标题编号、图号、表号、交叉引用和正文设计是否一致。
- 检查事实边界、责任主体和前后逻辑,关键词清零不代表内容正确。
- 如安装了 `deai-writing` 技能,在 Markdown 源文件上完成扫描和定向改写后,
重新走完整构建链路。
## 完成标准
只有以下条件全部满足才可交付:
1. DOCX 可打开,标题、表格、图片和代码块数量符合源文件。
2. 核验用 PDF 转换成功,无非预期空白页和 `\ufffd` 乱码。
3. 禁用词与项目质量门禁通过。
4. 300 DPI 视觉复核通过,图文、编号和交叉引用一致。
5. 所有修改已回写 Markdown 或图表脚本,生成产物可重复构建。
6. 交付目录只有 DOCX,核验 PDF、报告和页面 PNG 都在中间产物目录内。
## 文件构成
```text
longdoc-docx/
SKILL.md
requirements.txt
diagram_kit.py
scripts/
build_docx.py
verify_pdf.py
templates/
document.example.json
chapter.example.md
tests/
test_build_docx.py
test_verify_pdf.py
```
## 验证技能
```bash
<python> -m unittest discover -s "<skill-dir>/tests" -p "test_*.py"
```
@@ -0,0 +1,169 @@
"""matplotlib 架构图/流程图通用工具箱(中文可用)。
不用画图工具手绘,用脚本画方框和箭头:改文字就是改字符串;配色字体统一由
常量控制;图表能进 git diff,方便 review 措辞变更。
用法:在你自己的 gen_diagrams.py 里
import sys, os
sys.path.insert(0, "<skill_dir>")
from diagram_kit import box, arrow, new_fig, save, row_layout, NAVY, RED
def diagram_architecture():
fig, ax = new_fig(13, 9.2)
box(ax, 0.5, 8.0, 12, 0.8, "接入层")
...
save(fig, "diagram1-总体技术架构图.png", out_dir=OUT_DIR)
自检字体:
python3 diagram_kit.py --check-font
"""
import os
import sys
import matplotlib
matplotlib.use("Agg") # 无显示环境必须
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
# ---------------------------------------------------------------------------
# 中文字体:matplotlib 默认字体不含中文字形,必须显式指定字体文件
# 按优先级探测;找不到时报错并给出安装提示,而不是静默输出方块字
# ---------------------------------------------------------------------------
FONT_CANDIDATES = [
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"),
("/usr/share/fonts/opentype/noto/NotoSansCJK-VF.otf.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-VF.otf.ttc"),
("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"),
("/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/PingFang.ttc"),
("C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/msyhbd.ttc"),
]
FONT_HINT = (
"未找到中文字体,图中中文会渲染成方块。安装:\n"
" Debian/Ubuntu: apt-get install fonts-noto-cjk\n"
" RHEL/CentOS: yum install google-noto-sans-cjk-ttc-fonts\n"
"确认:fc-list | grep -i 'noto sans cjk'\n"
"也可设环境变量 CJK_FONT_REGULAR / CJK_FONT_BOLD 指向字体文件。"
)
def _resolve_fonts():
reg = os.environ.get("CJK_FONT_REGULAR")
bold = os.environ.get("CJK_FONT_BOLD", reg)
if reg and os.path.exists(reg):
return reg, (bold if bold and os.path.exists(bold) else reg)
for r, b in FONT_CANDIDATES:
if os.path.exists(r):
return r, (b if os.path.exists(b) else r)
return None, None
FONT_PATH, FONT_PATH_BOLD = _resolve_fonts()
if FONT_PATH is None:
print("WARN: " + FONT_HINT, file=sys.stderr)
zh_font = fm.FontProperties()
zh_bold = fm.FontProperties(weight="bold")
else:
zh_font = fm.FontProperties(fname=FONT_PATH)
zh_bold = fm.FontProperties(fname=FONT_PATH_BOLD)
# ---------------------------------------------------------------------------
# 配色:中文商务文档惯例(藏青主色 + 红色强调 + 灰阶)
# 换主题只改这几个常量,所有图一起变
# ---------------------------------------------------------------------------
NAVY = "#1F3864"
NAVY_LIGHT = "#DCE6F1"
RED = "#C00000"
RED_LIGHT = "#FBE4E4"
GRAY = "#595959"
GRAY_LIGHT = "#F2F2F2"
WHITE = "#FFFFFF"
TEXT = "#1a1a1a"
def box(ax, x, y, w, h, text, fc=WHITE, ec=NAVY, lw=1.4, fontsize=10.5,
font=None, textcolor=TEXT,
boxstyle="round,pad=0.02,rounding_size=0.06", zorder=2):
"""圆角方框 + 居中文字。linespacing 保证多行文字换行后不挤在一起。"""
b = FancyBboxPatch((x, y), w, h, boxstyle=boxstyle, linewidth=lw,
edgecolor=ec, facecolor=fc, zorder=zorder)
ax.add_patch(b)
ax.text(x + w / 2, y + h / 2, text, ha="center", va="center",
fontsize=fontsize, fontproperties=font or zh_font,
color=textcolor, zorder=zorder + 1, linespacing=1.4)
return b
def arrow(ax, xy_from, xy_to, color=GRAY, lw=1.6, style="-|>",
connectionstyle="arc3,rad=0.0", zorder=3):
a = FancyArrowPatch(xy_from, xy_to, arrowstyle=style, mutation_scale=14,
linewidth=lw, color=color,
connectionstyle=connectionstyle, zorder=zorder)
ax.add_patch(a)
return a
def label(ax, x, y, text, fontsize=9.5, color=GRAY, ha="center", va="center",
font=None, zorder=4):
"""箭头旁的说明文字、图内小标注。"""
return ax.text(x, y, text, ha=ha, va=va, fontsize=fontsize,
fontproperties=font or zh_font, color=color, zorder=zorder)
def new_fig(w, h, dpi=200):
"""画布坐标系直接等于英寸尺寸,摆位时按网格心算即可。dpi=200 保证放大不糊。"""
fig, ax = plt.subplots(figsize=(w, h), dpi=dpi)
ax.set_xlim(0, w)
ax.set_ylim(0, h)
ax.axis("off")
return fig, ax
def save(fig, name, out_dir="."):
os.makedirs(out_dir, exist_ok=True)
path = os.path.join(out_dir, name)
fig.savefig(path, bbox_inches="tight", facecolor="white")
plt.close(fig)
print("saved:", path)
return path
def row_layout(n, start_x, total_w, gap=0.25):
"""横向等宽切分:返回 n 个 (x, width),用于并列分支摆放。
for (x, w), g in zip(row_layout(len(groups), 0.5, 12.0), groups):
box(ax, x, y0, w, h, g)
"""
w = (total_w - gap * (n - 1)) / n
return [(start_x + i * (w + gap), w) for i in range(n)]
def col_layout(n, top_y, total_h, gap=0.2):
"""纵向等高切分:返回 n 个 (y, height),自上而下。"""
h = (total_h - gap * (n - 1)) / n
return [(top_y - h - i * (h + gap), h) for i in range(n)]
def _check_font():
if FONT_PATH is None:
print("中文字体:未找到\n" + FONT_HINT)
return 1
print(f"中文字体:{FONT_PATH}")
print(f"粗体: {FONT_PATH_BOLD}")
fig, ax = new_fig(6, 2)
box(ax, 0.3, 0.5, 5.4, 1.0, "中文字体自检 CJK Font OK 123")
out = save(fig, "font_check.png", out_dir="/tmp")
print(f"已生成 {out},打开确认中文不是方块。")
return 0
if __name__ == "__main__":
if "--check-font" in sys.argv:
sys.exit(_check_font())
print(__doc__)
@@ -0,0 +1,6 @@
beautifulsoup4>=4.12,<5
Markdown>=3.5,<4
matplotlib>=3.8,<4
Pillow>=10,<13
PyMuPDF>=1.24,<2
python-docx>=1.1,<2
@@ -0,0 +1,598 @@
#!/usr/bin/env python3
"""Build a styled DOCX from an explicit Markdown chapter manifest."""
import argparse
import json
import re
import unicodedata
from pathlib import Path
import markdown
from bs4 import BeautifulSoup
from docx import Document
from docx.enum.section import WD_ORIENT
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
from PIL import Image
DEFAULTS = {
"body_font_zh": "宋体",
"body_font_en": "Times New Roman",
"heading_font_zh": "黑体",
"heading_color": "1F3864",
"body_size": 11.5,
"toc_depth": 3,
"max_image_width_cm": 14.66,
}
HEADING_SIZES = {1: 18, 2: 15, 3: 13, 4: 12, 5: 11.5, 6: 11.5}
def parse_args():
parser = argparse.ArgumentParser(description="将多章节 Markdown 构建为 DOCX")
parser.add_argument("--config", required=True, help="document.json 路径")
return parser.parse_args()
def load_config(path):
config_path = Path(path).expanduser().resolve()
with config_path.open(encoding="utf-8") as handle:
config = json.load(handle)
if not isinstance(config, dict):
raise ValueError("配置根节点必须是 JSON 对象")
for key in ("title", "output"):
if not isinstance(config.get(key), str) or not config[key].strip():
raise ValueError(f"{key} 必须是非空字符串")
if not isinstance(config.get("chapters"), list) or not config["chapters"]:
raise ValueError("chapters 必须是非空数组")
merged = {**DEFAULTS, **config}
if not isinstance(merged["toc_depth"], int) or isinstance(merged["toc_depth"], bool):
raise ValueError("toc_depth 必须是整数")
for key in ("body_size", "max_image_width_cm"):
value = merged[key]
if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
raise ValueError(f"{key} 必须是正数")
return config_path, merged
def rgb(value):
value = value.lstrip("#")
if not re.fullmatch(r"[0-9a-fA-F]{6}", value):
raise ValueError(f"颜色必须是六位十六进制值:{value!r}")
return RGBColor.from_string(value.upper())
def add_font(
run,
config,
size=None,
bold=False,
italic=False,
color=None,
code=False,
en_font=None,
zh_font=None,
):
en_font = en_font or ("Consolas" if code else config["body_font_en"])
zh_font = zh_font or ("Consolas" if code else config["body_font_zh"])
run.font.name = en_font
run.font.size = Pt(size or config["body_size"])
run.font.bold = bold
run.font.italic = italic
if color is not None:
run.font.color.rgb = color
rpr = run._element.get_or_add_rPr()
rfonts = rpr.find(qn("w:rFonts"))
if rfonts is None:
rfonts = OxmlElement("w:rFonts")
rpr.append(rfonts)
rfonts.set(qn("w:ascii"), en_font)
rfonts.set(qn("w:hAnsi"), en_font)
rfonts.set(qn("w:eastAsia"), zh_font)
def add_field(paragraph, instruction, placeholder=None):
run = paragraph.add_run()
begin = OxmlElement("w:fldChar")
begin.set(qn("w:fldCharType"), "begin")
instr = OxmlElement("w:instrText")
instr.set(qn("xml:space"), "preserve")
instr.text = instruction
separate = OxmlElement("w:fldChar")
separate.set(qn("w:fldCharType"), "separate")
end = OxmlElement("w:fldChar")
end.set(qn("w:fldCharType"), "end")
run._r.append(begin)
run._r.append(instr)
run._r.append(separate)
if placeholder:
text = OxmlElement("w:t")
text.text = placeholder
run._r.append(text)
run._r.append(end)
def add_shading(target, fill):
properties = (
target._tc.get_or_add_tcPr()
if hasattr(target, "_tc")
else target._p.get_or_add_pPr()
)
shading = OxmlElement("w:shd")
shading.set(qn("w:val"), "clear")
shading.set(qn("w:color"), "auto")
shading.set(qn("w:fill"), fill)
properties.append(shading)
def set_table_borders(table):
borders = OxmlElement("w:tblBorders")
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
element = OxmlElement(f"w:{edge}")
element.set(qn("w:val"), "single")
element.set(qn("w:sz"), "4")
element.set(qn("w:space"), "0")
element.set(qn("w:color"), "B0B0B0")
borders.append(element)
table._tbl.tblPr.append(borders)
def setup_document(doc, config):
section = doc.sections[0]
if config.get("orientation", "portrait") == "landscape":
section.orientation = WD_ORIENT.LANDSCAPE
section.page_width = Cm(29.7)
section.page_height = Cm(21)
else:
section.page_width = Cm(21)
section.page_height = Cm(29.7)
section.top_margin = Cm(config.get("margin_top_cm", 2.54))
section.bottom_margin = Cm(config.get("margin_bottom_cm", 2.54))
section.left_margin = Cm(config.get("margin_left_cm", 3.17))
section.right_margin = Cm(config.get("margin_right_cm", 3.17))
normal = doc.styles["Normal"]
normal.font.name = config["body_font_en"]
normal.font.size = Pt(config["body_size"])
normal.paragraph_format.line_spacing = config.get("line_spacing", 1.4)
normal.paragraph_format.space_after = Pt(8)
rpr = normal.element.get_or_add_rPr()
rfonts = rpr.find(qn("w:rFonts"))
if rfonts is None:
rfonts = OxmlElement("w:rFonts")
rpr.append(rfonts)
rfonts.set(qn("w:eastAsia"), config["body_font_zh"])
footer = section.footer.paragraphs[0]
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
add_field(footer, "PAGE")
def add_cover(doc, config):
if config.get("cover", True) is False:
return
landscape = config.get("orientation", "portrait") == "landscape"
for _ in range(config.get("cover_top_spacers", 3 if landscape else 6)):
doc.add_paragraph()
for text, size in (
(config["title"], 26),
(config.get("subtitle", ""), 22),
):
if not text:
continue
paragraph = doc.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = paragraph.add_run(text)
add_font(
run,
config,
size=size,
bold=True,
color=rgb(config["heading_color"]),
zh_font=config["heading_font_zh"],
)
for _ in range(config.get("cover_middle_spacers", 4 if landscape else 8)):
doc.add_paragraph()
for field in ("author", "date"):
text = config.get(field, "")
if text:
paragraph = doc.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
add_font(paragraph.add_run(text), config, size=14)
doc.add_page_break()
def add_toc(doc, config):
depth = int(config.get("toc_depth", 3))
if depth <= 0:
return
heading = doc.add_paragraph()
heading.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = heading.add_run(config.get("toc_title", "目 录"))
add_font(
run,
config,
size=18,
bold=True,
color=rgb(config["heading_color"]),
zh_font=config["heading_font_zh"],
)
doc.add_paragraph()
paragraph = doc.add_paragraph()
add_field(
paragraph,
f'TOC \\o "1-{depth}" \\h \\z \\u',
"右键点击此处选择“更新域”以生成目录",
)
doc.add_page_break()
def add_inline_runs(paragraph, node, config, bold=False, italic=False):
for child in node.children:
name = getattr(child, "name", None)
if name is None:
text = str(child).replace("\n", "")
if text:
add_font(
paragraph.add_run(text),
config,
bold=bold,
italic=italic,
)
elif name in ("strong", "b"):
add_inline_runs(paragraph, child, config, bold=True, italic=italic)
elif name in ("em", "i"):
add_inline_runs(paragraph, child, config, bold=bold, italic=True)
elif name == "code":
run = paragraph.add_run(child.get_text())
add_font(
run,
config,
size=config["body_size"] - 0.5,
bold=bold,
italic=italic,
color=RGBColor(0xA0, 0x30, 0x30),
code=True,
)
elif name == "br":
paragraph.add_run().add_break()
else:
add_inline_runs(paragraph, child, config, bold=bold, italic=italic)
def add_heading(doc, level, text, config):
paragraph = doc.add_paragraph(style=f"Heading {min(level, 9)}")
paragraph.paragraph_format.keep_with_next = True
paragraph.paragraph_format.space_before = Pt(14 if level == 1 else 10)
paragraph.paragraph_format.space_after = Pt(8 if level == 1 else 6)
run = paragraph.add_run(text)
add_font(
run,
config,
size=HEADING_SIZES.get(level, 11.5),
bold=True,
color=rgb(config["heading_color"]) if level <= 2 else RGBColor(0, 0, 0),
zh_font=config["heading_font_zh"],
)
def add_paragraph(doc, node, config):
paragraph = doc.add_paragraph()
add_inline_runs(paragraph, node, config)
paragraph.paragraph_format.line_spacing = config.get("line_spacing", 1.4)
paragraph.paragraph_format.space_after = Pt(8)
def add_list(doc, node, config, level=0):
ordered = node.name == "ol"
style = "List Number" if ordered else "List Bullet"
for item in node.find_all("li", recursive=False):
paragraph = doc.add_paragraph(style=style)
paragraph.paragraph_format.left_indent = Cm(0.5 + level * 0.6)
paragraph.paragraph_format.space_after = Pt(4)
for child in item.children:
if getattr(child, "name", None) in ("ul", "ol"):
continue
if getattr(child, "name", None) is None:
text = str(child).replace("\n", "")
if text:
add_font(paragraph.add_run(text), config)
else:
add_inline_runs(paragraph, child, config)
for nested in item.find_all(["ul", "ol"], recursive=False):
add_list(doc, nested, config, level + 1)
def display_width(text):
return sum(2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 for char in text)
def em_width(text):
total = 0.0
for char in text:
if unicodedata.east_asian_width(char) in ("W", "F"):
total += 1.0
elif char.isupper() or char.isdigit():
total += 0.62
else:
total += 0.5
return total
def compute_col_widths(rows, ncols, content_width_cm, body_size_pt=10.5):
lengths = [1] * ncols
longest_word = [1] * ncols
for row in rows:
for index, cell in enumerate(row.find_all(["th", "td"], recursive=False)):
if index < ncols:
text = cell.get_text(" ", strip=True)
lengths[index] = max(lengths[index], min(display_width(text), 160))
longest_word[index] = max(
longest_word[index],
max((em_width(word) for word in text.split()), default=1.0),
)
em_cm = body_size_pt / 28.35
padding_cm = 0.4
floors = [
min(em_cm * word + padding_cm, content_width_cm / ncols)
for word in longest_word
]
maximum = max(max(floors), content_width_cm * 0.55)
widths = [None] * ncols
remaining = content_width_cm
pending = set(range(ncols))
while pending:
weight = sum(lengths[i] for i in pending)
clamped = False
for index in sorted(pending):
share = remaining * lengths[index] / weight
floor = floors[index]
bound = floor if share < floor else (maximum if share > maximum else None)
if bound is not None:
widths[index] = bound
remaining -= bound
pending.discard(index)
clamped = True
break
if not clamped:
for index in pending:
widths[index] = remaining * lengths[index] / weight
break
total = sum(widths)
if total > content_width_cm:
widths = [width * content_width_cm / total for width in widths]
return widths
def set_col_widths(table, widths):
table.autofit = False
grid = table._tbl.find(qn("w:tblGrid"))
if grid is None:
grid = OxmlElement("w:tblGrid")
table._tbl.insert(0, grid)
else:
for child in list(grid):
grid.remove(child)
for width in widths:
column = OxmlElement("w:gridCol")
column.set(qn("w:w"), str(int(Cm(width).twips)))
grid.append(column)
for row in table.rows:
cells = row.cells
for index, width in enumerate(widths):
if index < len(cells):
cells[index].width = Cm(width)
def add_table(doc, node, config):
rows = node.find_all("tr")
if not rows:
return
ncols = max(len(row.find_all(["th", "td"], recursive=False)) for row in rows)
table = doc.add_table(rows=len(rows), cols=ncols)
table.alignment = WD_TABLE_ALIGNMENT.CENTER
section = doc.sections[-1]
content_width = (
section.page_width.cm - section.left_margin.cm - section.right_margin.cm
)
table_size = float(config.get("table_size", config["body_size"]))
widths = compute_col_widths(rows, ncols, content_width, table_size)
set_table_borders(table)
for row_index, (row_node, table_row) in enumerate(zip(rows, table.rows)):
cell_nodes = row_node.find_all(["th", "td"], recursive=False)
table_cells = table_row.cells
for column_index, cell_node in enumerate(cell_nodes):
if column_index >= len(table_cells):
break
cell = table_cells[column_index]
cell.text = ""
cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
paragraph = cell.paragraphs[0]
is_header = row_index == 0
add_inline_runs(paragraph, cell_node, config, bold=is_header)
for run in paragraph.runs:
add_font(
run,
config,
size=table_size,
bold=is_header or bool(run.font.bold),
italic=bool(run.font.italic),
color=RGBColor(255, 255, 255) if is_header else None,
)
if is_header:
add_shading(cell, config["heading_color"].lstrip("#"))
elif row_index % 2 == 0:
add_shading(cell, "F2F2F2")
set_col_widths(table, widths)
doc.add_paragraph().paragraph_format.space_after = Pt(4)
def add_code_block(doc, text, config):
paragraph = doc.add_paragraph()
paragraph.paragraph_format.left_indent = Cm(0.5)
paragraph.paragraph_format.space_before = Pt(4)
paragraph.paragraph_format.space_after = Pt(10)
lines = text.rstrip("\n").split("\n")
for index, line in enumerate(lines):
run = paragraph.add_run(line or " ")
add_font(
run,
config,
size=9.5,
color=RGBColor(0x33, 0x33, 0x33),
code=True,
)
if index < len(lines) - 1:
run.add_break()
add_shading(paragraph, "F5F5F5")
def add_blockquote(doc, node, config):
blocks = [
child.get_text(" ", strip=True)
for child in node.find_all("p", recursive=False)
]
if not blocks:
blocks = [node.get_text(" ", strip=True)]
blocks = [text for text in blocks if text]
for index, text in enumerate(blocks):
paragraph = doc.add_paragraph()
paragraph.paragraph_format.left_indent = Cm(0.8)
paragraph.paragraph_format.space_after = Pt(
10 if index == len(blocks) - 1 else 4
)
run = paragraph.add_run(text)
add_font(
run, config, size=10.5, italic=True, color=RGBColor(0x40, 0x40, 0x40)
)
add_shading(paragraph, "F7F7F7")
def add_image(doc, src, base_dir, config):
image_path = (base_dir / src).resolve()
if not image_path.is_file():
raise FileNotFoundError(f"图片不存在:{image_path}")
with Image.open(image_path) as image:
width_px = image.width
dpi = image.info.get("dpi", (150, 150))[0] or 150
natural_width_cm = width_px / dpi * 2.54
width_cm = min(natural_width_cm, float(config["max_image_width_cm"]))
paragraph = doc.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
paragraph.add_run().add_picture(str(image_path), width=Cm(width_cm))
paragraph.paragraph_format.space_before = Pt(6)
paragraph.paragraph_format.space_after = Pt(2)
def add_caption(doc, text, config):
paragraph = doc.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = paragraph.add_run(text)
add_font(run, config, size=10, italic=True, color=RGBColor(0x40, 0x40, 0x40))
paragraph.paragraph_format.space_after = Pt(12)
def render_markdown(doc, chapter_path, config):
text = chapter_path.read_text(encoding="utf-8")
html = markdown.markdown(text, extensions=["tables", "fenced_code"])
soup = BeautifulSoup(html, "html.parser")
previous_was_image = False
for node in soup.find_all(recursive=False):
name = node.name
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
add_heading(doc, int(name[1]), node.get_text(" ", strip=True), config)
elif name == "p":
image = node.find("img")
if image is not None:
if node.get_text(strip=True):
raise ValueError(
f"图片必须独占 Markdown 段落:{chapter_path}"
)
add_image(doc, image.get("src", ""), chapter_path.parent, config)
previous_was_image = True
continue
text_value = node.get_text()
emphasis = node.find("em")
if (
previous_was_image
and emphasis is not None
and node.get_text(strip=True) == emphasis.get_text(strip=True)
):
add_caption(doc, emphasis.get_text(" ", strip=True), config)
else:
if text_value.strip():
add_paragraph(doc, node, config)
elif name in ("ul", "ol"):
add_list(doc, node, config)
elif name == "table":
add_table(doc, node, config)
elif name == "blockquote":
add_blockquote(doc, node, config)
elif name == "pre":
add_code_block(doc, node.get_text(), config)
elif name == "hr":
paragraph = doc.add_paragraph("" * 40)
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
else:
if node.get_text(strip=True):
add_paragraph(doc, node, config)
previous_was_image = False
def chapter_entries(config, base_dir):
entries = []
for raw in config["chapters"]:
if isinstance(raw, str):
raw = {"path": raw}
if (
not isinstance(raw, dict)
or not isinstance(raw.get("path"), str)
or not raw["path"].strip()
):
raise ValueError("chapters 的每一项必须是路径字符串或包含 path 的对象")
if "page_break_before" in raw and not isinstance(
raw["page_break_before"], bool
):
raise ValueError("page_break_before 必须是布尔值")
path = (base_dir / raw["path"]).resolve()
if not path.is_file():
raise FileNotFoundError(f"章节不存在:{path}")
entries.append((path, bool(raw.get("page_break_before", False))))
return entries
def build(config_path, config):
base_dir = config_path.parent
chapters = chapter_entries(config, base_dir)
output = (base_dir / config["output"]).resolve()
if output.suffix.lower() != ".docx":
raise ValueError("output 必须使用 .docx 扩展名")
protected_paths = {config_path, *(chapter for chapter, _ in chapters)}
if output in protected_paths:
raise ValueError("output 不能覆盖配置文件或 Markdown 源文件")
output.parent.mkdir(parents=True, exist_ok=True)
doc = Document()
setup_document(doc, config)
add_cover(doc, config)
add_toc(doc, config)
for index, (chapter, page_break_before) in enumerate(chapters):
if page_break_before and index > 0:
doc.add_page_break()
render_markdown(doc, chapter, config)
doc.save(output)
return output
def main():
config_path, config = load_config(parse_args().config)
output = build(config_path, config)
print(f"saved: {output}")
if __name__ == "__main__":
main()
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Verify PDF text gates, blank pages, and optionally render page previews."""
import argparse
import json
import sys
from pathlib import Path
import fitz
def parse_args():
parser = argparse.ArgumentParser(description="核验长文 PDF 产物")
parser.add_argument("pdf", help="待核验 PDF")
parser.add_argument("--forbid", nargs="*", default=[], help="禁用关键词")
parser.add_argument(
"--allow-blank-page",
action="append",
type=int,
default=[],
help="允许为空白的页码,可重复指定",
)
parser.add_argument(
"--min-text-chars",
type=int,
default=30,
help="无图片页面低于该文本长度时视为疑似空白",
)
parser.add_argument("--json", dest="json_path", help="JSON 报告输出路径")
parser.add_argument("--render-dir", help="逐页 PNG 输出目录")
parser.add_argument("--dpi", type=int, default=300, help="页面渲染 DPI")
parser.add_argument(
"--no-fail",
action="store_true",
help="发现乱码、禁用词或非豁免空白页时仍返回 0",
)
return parser.parse_args()
def inspect_document(
document,
path,
forbidden=(),
min_text_chars=30,
allowed_blank_pages=(),
):
allowed = set(allowed_blank_pages)
terms = [term for term in dict.fromkeys(forbidden) if term]
forbidden_hits = {term: {"count": 0, "pages": []} for term in terms}
pages = []
replacement_characters = 0
suspicious_blank_pages = []
for index, page in enumerate(document):
page_number = index + 1
text = page.get_text().strip()
image_count = len(page.get_images(full=True))
pages.append(
{
"page": page_number,
"text_chars": len(text),
"images": image_count,
}
)
replacement_characters += text.count("\ufffd")
if (
len(text) < min_text_chars
and image_count == 0
and page_number not in allowed
):
suspicious_blank_pages.append(page_number)
for term in terms:
count = text.count(term)
if count:
forbidden_hits[term]["count"] += count
forbidden_hits[term]["pages"].append(page_number)
return {
"file": str(path),
"page_count": len(document),
"replacement_characters": replacement_characters,
"forbidden": {
term: result
for term, result in forbidden_hits.items()
if result["count"]
},
"suspicious_blank_pages": suspicious_blank_pages,
"allowed_blank_pages": sorted(allowed),
"pages": pages,
}
def inspect_pdf(pdf_path, forbidden=(), min_text_chars=30, allowed_blank_pages=()):
path = Path(pdf_path).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"PDF 不存在:{path}")
document = fitz.open(path)
try:
return inspect_document(
document,
path,
forbidden=forbidden,
min_text_chars=min_text_chars,
allowed_blank_pages=allowed_blank_pages,
)
finally:
document.close()
def render_document(document, output_dir, dpi=300):
if dpi < 72:
raise ValueError("dpi 不能低于 72")
output = Path(output_dir).expanduser().resolve()
output.mkdir(parents=True, exist_ok=True)
existing = sorted(output.glob("page-*.png"))
if existing:
raise FileExistsError(
f"渲染目录已有页面图,请改用空目录:{output}"
)
scale = dpi / 72
matrix = fitz.Matrix(scale, scale)
digits = max(3, len(str(len(document))))
rendered = []
for index, page in enumerate(document):
target = output / f"page-{index + 1:0{digits}d}.png"
page.get_pixmap(matrix=matrix, alpha=False).save(target)
rendered.append(str(target))
return rendered
def render_pages(pdf_path, output_dir, dpi=300):
document = fitz.open(Path(pdf_path).expanduser().resolve())
try:
return render_document(document, output_dir, dpi=dpi)
finally:
document.close()
def has_failures(report):
return bool(
report["replacement_characters"]
or report["forbidden"]
or report["suspicious_blank_pages"]
)
def main():
args = parse_args()
pdf_path = Path(args.pdf).expanduser().resolve()
if not pdf_path.is_file():
raise FileNotFoundError(f"PDF 不存在:{pdf_path}")
json_path = (
Path(args.json_path).expanduser().resolve()
if args.json_path
else None
)
render_dir = (
Path(args.render_dir).expanduser().resolve()
if args.render_dir
else None
)
if json_path == pdf_path:
raise ValueError("JSON 报告路径不能覆盖输入 PDF")
if render_dir == pdf_path:
raise ValueError("渲染目录不能与输入 PDF 同路径")
document = fitz.open(pdf_path)
try:
report = inspect_document(
document,
pdf_path,
forbidden=args.forbid,
min_text_chars=args.min_text_chars,
allowed_blank_pages=args.allow_blank_page,
)
if render_dir:
report["rendered_pages"] = render_document(
document,
render_dir,
args.dpi,
)
finally:
document.close()
output = json.dumps(report, ensure_ascii=False, indent=2)
if json_path:
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(output + "\n", encoding="utf-8")
print(f"report: {json_path}")
else:
print(output)
if has_failures(report) and not args.no_fail:
print("PDF 核验失败:存在乱码、禁用词或疑似空白页。", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,19 @@
# 一、章节标题
本章正文使用 Markdown 编写。图片路径相对于当前 Markdown 文件所在目录。
## 1. 二级标题
支持**粗体**、*斜体*、`行内代码`、列表和表格。
| 项目 | 说明 |
|---|---|
| 示例 | 表格会按内容长度分配列宽 |
![示例架构图](assets/example.png)
*图 1 示例架构图*
```python
print("围栏代码块会保留缩进和换行")
```
@@ -0,0 +1,18 @@
{
"title": "项目名称",
"subtitle": "技术方案",
"author": "编制单位:____________________",
"date": "编制日期:____________________",
"output": "../dist/document.docx",
"toc_depth": 3,
"chapters": [
{
"path": "chapters/01-overview.md",
"page_break_before": false
},
{
"path": "chapters/02-design.md",
"page_break_before": true
}
]
}
@@ -0,0 +1,227 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
from docx import Document
from PIL import Image
SKILL_DIR = Path(__file__).resolve().parents[1]
SCRIPT_PATH = SKILL_DIR / "scripts" / "build_docx.py"
SPEC = importlib.util.spec_from_file_location("build_docx", SCRIPT_PATH)
build_docx = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(build_docx)
class BuildDocxTests(unittest.TestCase):
def write_config(self, root, config):
path = root / "document.json"
path.write_text(json.dumps(config, ensure_ascii=False), encoding="utf-8")
return path
def test_builds_supported_markdown_elements(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
image_path = root / "diagram.png"
Image.new("RGB", (400, 200), "white").save(image_path, dpi=(200, 200))
chapter = root / "chapter.md"
chapter.write_text(
"# 一、概述\n\n"
"正文包含**粗体**、*斜体*和`代码`。\n\n"
"- 列表一\n- 列表二\n\n"
"| 项目 | 详细说明 |\n|---|---|\n| A | 一段较长的内容 |\n\n"
"![架构图](diagram.png)\n\n"
"*图 1 架构图*\n\n"
"```python\nprint('ok')\n```\n\n"
"> 引用说明\n",
encoding="utf-8",
)
config_path = self.write_config(
root,
{
"title": "测试文档",
"subtitle": "构建验证",
"output": "out/test.docx",
"toc_depth": 0,
"chapters": [{"path": "chapter.md"}],
},
)
loaded_path, config = build_docx.load_config(config_path)
output = build_docx.build(loaded_path, config)
self.assertTrue(output.is_file())
document = Document(output)
text = "\n".join(paragraph.text for paragraph in document.paragraphs)
self.assertIn("一、概述", text)
self.assertIn("图 1 架构图", text)
self.assertIn("print('ok')", text)
self.assertEqual(len(document.tables), 1)
images = [
rel
for rel in document.part.rels.values()
if "image" in rel.reltype
]
self.assertEqual(len(images), 1)
def test_landscape_widens_tables(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
header = "| " + " | ".join(f"{i}" for i in range(11)) + " |"
divider = "|" + "---|" * 11
row = "| " + " | ".join(f"{i}" for i in range(11)) + " |"
(root / "wide.md").write_text(
f"# 宽表\n\n{header}\n{divider}\n{row}\n", encoding="utf-8"
)
widths = {}
for mode in ("portrait", "landscape"):
config_path = self.write_config(
root,
{
"title": "宽表测试",
"output": f"out/{mode}.docx",
"toc_depth": 0,
"orientation": mode,
"chapters": [{"path": "wide.md"}],
},
)
loaded, config = build_docx.load_config(config_path)
document = Document(build_docx.build(loaded, config))
widths[mode] = sum(
cell.width.cm for cell in document.tables[0].rows[0].cells
)
self.assertGreater(widths["landscape"], widths["portrait"] + 5)
def test_wide_table_fits_longest_word_in_every_column(self):
from bs4 import BeautifulSoup
headers = [
"需求编号", "需求出处", "需求原文", "类别", "强制/评分", "响应状态",
"实现说明", "偏离说明", "证据编号", "方案章节", "验证方法",
]
body = [
"REQ-001", "示例技术要求 3.1.1",
"系统应提供与 OpenAI 接口兼容的统一调用入口。", "功能", "强制",
"compliant", "网关提供模型列表、对话补全和向量化四类接口,统一鉴权",
"", "FEAT-001", "三.1、四.1", "依次调用四类接口并核对返回结构",
]
head = "".join(f"<th>{h}</th>" for h in headers)
cells = "".join(f"<td>{c}</td>" for c in body)
rows = BeautifulSoup(
f"<table><tr>{head}</tr><tr>{cells}</tr></table>", "html.parser"
).find_all("tr")
widths = build_docx.compute_col_widths(rows, 11, 25.7, 9.5)
self.assertAlmostEqual(sum(widths), 25.7, places=3)
em_cm = 9.5 / 28.35
for index, (header, cell) in enumerate(zip(headers, body)):
longest = max(
build_docx.em_width(word)
for text in (header, cell)
for word in text.split()
)
self.assertGreaterEqual(
widths[index] + 1e-6,
min(em_cm * longest, 25.7 / 11),
f"column {index} ({header}) truncates its longest word",
)
self.assertGreater(widths[6], widths[3])
def test_blockquote_keeps_paragraph_breaks(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "quote.md").write_text(
"# 引用\n\n> 第一段说明。\n>\n> 第二段说明。\n", encoding="utf-8"
)
config_path = self.write_config(
root,
{
"title": "引用测试",
"output": "out/quote.docx",
"toc_depth": 0,
"chapters": [{"path": "quote.md"}],
},
)
loaded, config = build_docx.load_config(config_path)
document = Document(build_docx.build(loaded, config))
texts = [p.text for p in document.paragraphs]
self.assertIn("第一段说明。", texts)
self.assertIn("第二段说明。", texts)
def test_only_forced_chapter_boundary_adds_page_break(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "one.md").write_text("# 第一章\n", encoding="utf-8")
(root / "two.md").write_text("# 第二章\n", encoding="utf-8")
config_path = self.write_config(
root,
{
"title": "分页测试",
"cover": False,
"toc_depth": 0,
"output": "test.docx",
"chapters": [
{"path": "one.md", "page_break_before": False},
{"path": "two.md", "page_break_before": True},
],
},
)
loaded_path, config = build_docx.load_config(config_path)
output = build_docx.build(loaded_path, config)
document = Document(output)
self.assertEqual(document._element.xml.count('w:type="page"'), 1)
def test_rejects_missing_chapters(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config_path = self.write_config(
root,
{"title": "无章节", "output": "test.docx", "chapters": []},
)
with self.assertRaisesRegex(ValueError, "chapters"):
build_docx.load_config(config_path)
def test_rejects_non_boolean_page_break(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "chapter.md").write_text("# 章节\n", encoding="utf-8")
config_path = self.write_config(
root,
{
"title": "错误分页配置",
"output": "test.docx",
"chapters": [
{"path": "chapter.md", "page_break_before": "false"}
],
},
)
loaded_path, config = build_docx.load_config(config_path)
with self.assertRaisesRegex(ValueError, "page_break_before"):
build_docx.build(loaded_path, config)
def test_rejects_mixed_text_and_image_paragraph(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
Image.new("RGB", (100, 50), "white").save(root / "diagram.png")
(root / "chapter.md").write_text(
"说明文字 ![架构图](diagram.png)\n",
encoding="utf-8",
)
config_path = self.write_config(
root,
{
"title": "图片格式测试",
"cover": False,
"toc_depth": 0,
"output": "test.docx",
"chapters": ["chapter.md"],
},
)
loaded_path, config = build_docx.load_config(config_path)
with self.assertRaisesRegex(ValueError, "图片必须独占"):
build_docx.build(loaded_path, config)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,76 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path
import fitz
SKILL_DIR = Path(__file__).resolve().parents[1]
SCRIPT_PATH = SKILL_DIR / "scripts" / "verify_pdf.py"
SPEC = importlib.util.spec_from_file_location("verify_pdf", SCRIPT_PATH)
verify_pdf = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(verify_pdf)
class VerifyPdfTests(unittest.TestCase):
def create_pdf(self, path):
document = fitz.open()
text_page = document.new_page()
text_page.insert_text(
(72, 72),
"This page contains enough verification text and a forbidden term.",
)
document.new_page()
document.save(path)
document.close()
def test_reports_forbidden_terms_and_blank_pages(self):
with tempfile.TemporaryDirectory() as tmp:
pdf = Path(tmp) / "sample.pdf"
self.create_pdf(pdf)
report = verify_pdf.inspect_pdf(
pdf,
forbidden=["forbidden"],
min_text_chars=30,
)
self.assertEqual(report["page_count"], 2)
self.assertEqual(report["forbidden"]["forbidden"]["pages"], [1])
self.assertEqual(report["suspicious_blank_pages"], [2])
self.assertTrue(verify_pdf.has_failures(report))
def test_allows_known_blank_page(self):
with tempfile.TemporaryDirectory() as tmp:
pdf = Path(tmp) / "sample.pdf"
self.create_pdf(pdf)
report = verify_pdf.inspect_pdf(
pdf,
min_text_chars=30,
allowed_blank_pages=[2],
)
self.assertEqual(report["suspicious_blank_pages"], [])
self.assertFalse(verify_pdf.has_failures(report))
def test_renders_pages(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
pdf = root / "sample.pdf"
self.create_pdf(pdf)
rendered = verify_pdf.render_pages(pdf, root / "pages", dpi=72)
self.assertEqual(len(rendered), 2)
self.assertTrue(all(Path(path).is_file() for path in rendered))
def test_rejects_render_directory_with_old_pages(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
pdf = root / "sample.pdf"
pages = root / "pages"
pages.mkdir()
(pages / "page-999.png").write_bytes(b"old")
self.create_pdf(pdf)
with self.assertRaisesRegex(FileExistsError, "空目录"):
verify_pdf.render_pages(pdf, pages, dpi=72)
if __name__ == "__main__":
unittest.main()
-37
View File
@@ -1,37 +0,0 @@
---
id: meeting-minutes
name: 会议纪要
description: 将用户提供的会议记录整理为客观、可追踪的纪要,明确结论、分歧与行动项。
version: 1.0.0
tags:
- 会议
- 纪要
- 协作
---
# 会议纪要
## 工作原则
- 忠实整理原始记录,不推测未明确表达的决定或责任。
- 区分讨论内容、正式决策、待确认事项和行动项。
- 保留关键分歧及其依据,避免将建议误写为结论。
- 对姓名、日期、数字和专有名词进行一致性检查。
## 整理流程
1. 确认会议主题、时间、参会人和目标。
2. 按议题归纳背景、讨论要点与结论。
3. 提取每项行动的负责人、截止时间和交付物。
4. 汇总未决问题、风险和后续会议需求。
5. 标记原始记录中含糊或相互冲突的信息。
## 输出模板
- **会议信息:** 主题、时间、参会人
- **会议目标:** 本次会议要解决的问题
- **议题与结论:** 按议题分组
- **行动项:** 事项、负责人、截止时间、状态
- **待确认事项:** 缺失信息或未决问题
未提供的信息统一标注为“待确认”,不得自行补全。
@@ -1,39 +0,0 @@
---
id: presentation-outline
name: 演示大纲
description: 根据目标与受众设计逻辑清晰的演示文稿大纲,明确每页核心信息与叙事衔接。
version: 1.0.0
tags:
- 演示
- 大纲
- 表达
---
# 演示大纲
## 工作原则
- 先明确演示目的、受众、场合、时长和期望行动。
- 每页聚焦一个核心信息,标题应直接表达结论。
- 事实、数据与案例仅来自用户材料;缺少依据时标注待补充。
- 控制信息密度,避免用大段文字代替口头讲解。
## 设计流程
1. 用一句话定义演示的核心主张。
2. 选择适合目标的叙事结构,如“问题—分析—方案—行动”。
3. 为每页写结论式标题、关键要点和建议视觉形式。
4. 检查页面间逻辑、证据充分性和时间分配。
5. 以明确总结和下一步行动收尾。
## 输出格式
按页输出:
- **页码与标题:** 结论式标题
- **页面目的:** 该页要让受众理解什么
- **关键内容:** 不超过五个要点
- **视觉建议:** 图表、流程、时间线或重点数字
- **讲述提示:** 与前后页面的衔接
另附开场、总结和待补充材料清单。
@@ -0,0 +1,88 @@
---
name: product-evidence
version: 1.0.0
description: |
建立和维护产品市场材料的事实与证据清单,统一产品版本、功能状态、技术参数、
术语、适用边界和可公开主张。用于开始任何产品介绍、投标参数、PPT、技术方案、
白皮书或案例材料之前,也用于跨产物一致性核验。
allowed-tools:
- Read
- Grep
- Glob
- Execute
compatibility: Python 3.9+,校验脚本不依赖第三方包
---
# 产品事实与证据
所有产品市场产物都必须从同一份 `product-evidence.json` 取事实。缺少依据时标记
待核验,不允许由 Agent 补造参数、客户结果、认证、兼容性或竞争结论。
`<skill-dir>` 指本 `SKILL.md` 所在目录。
## 建立清单
```bash
cp "<skill-dir>/templates/product-evidence.example.json" ./product-evidence.json
```
逐项填写:
- `product`:产品名称、版本、类别、定位、成熟度和目标读者。
- `terminology`:统一术语、定义和禁用旧称。
- `features`:功能 ID、用户动作、结果、状态、版本范围和证据。
- `parameters`:参数值、单位、测试条件、适用版本、公开级别和证据。
- `claims`:允许对外使用的事实、目标或比较主张及适用产物。
- `use_cases`:角色、问题、工作流、人工复核点和已有结果。
- `differentiators`:比较对象、比较范围和支持证据。
- `limitations`:部署条件、依赖、适用边界和必要人工复核。
- `evidence`:来源、定位、核验日期、责任人和公开级别。
- `prohibited_claims`:不得出现在任何材料中的绝对化或未经批准表述。
## 事实分级
- `released`:当前版本已提供,必须有可定位证据。
- `beta`:可试用但存在范围限制,正文必须同时说明限制。
- `planned`:仅可用将来时或规划表述,不得写成现有能力。
- `deprecated`:不得作为当前卖点。
证据公开级别:
- `public`:可进入公开网站、彩页和公开演示。
- `restricted`:只在授权客户或受控投标材料中使用。
- `internal`:只用于内部判断,不能原样写入外发产物。
## 校验
先探测可用的 Python 3 解释器:Windows 优先使用 `python`macOS/Linux
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
```bash
<python> "<skill-dir>/scripts/validate_evidence.py" ./product-evidence.json
<python> "<skill-dir>/scripts/validate_evidence.py" ./product-evidence.json --json
<python> "<skill-dir>/scripts/validate_evidence.py" ./product-evidence.json \
--strict --channel public
```
结构错误、重复 ID、失效引用、无证据的已批准事实主张、非法状态或疑似密钥参数
必须阻断。缺证据、占位符和待核验项在普通模式下告警,在 `--strict` 下阻断。
`--channel` 按目标渠道核对公开级别:已批准且带 `allowed_outputs` 的主张,其
引用证据的 `disclosure` 不得低于渠道要求。对外产物必须以 `--strict` 加目标
渠道运行通过后才能进入下游技能。
## 给下游技能的输入
调用任何产品产物技能时,同时提供:
1. 已通过校验的 `product-evidence.json`
2. 目标受众、使用场景、发布渠道和保密级别。
3. 本次产物允许引用的证据范围。
4. 截止日期、页数或篇幅、格式和品牌要求。
下游产物中的每个数字、兼容性、认证、客户效果和比较结论都应能追溯到清单 ID。
## 完成标准
清单结构校验通过;公开级别与使用渠道匹配;所有现有功能、参数和批准主张有
证据;规划能力、限制和人工复核要求没有被省略;不含密钥、客户隐私或私有地址。
@@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""Validate the shared product evidence manifest used by marketing skills."""
import argparse
import datetime as dt
import json
import re
import sys
from pathlib import Path
COLLECTIONS = (
"features",
"parameters",
"claims",
"use_cases",
"differentiators",
"limitations",
"evidence",
)
REQUIRED_FIELDS = {
"features": ("id", "name", "summary", "status"),
"parameters": ("id", "name", "value", "conditions", "disclosure"),
"claims": ("id", "text", "type", "status"),
"use_cases": ("id", "name", "audience", "problem", "workflow", "outcome"),
"differentiators": ("id", "statement", "comparison_scope"),
"limitations": ("id", "text", "applies_to"),
"evidence": (
"id",
"type",
"title",
"source",
"locator",
"verified_on",
"owner",
"disclosure",
),
}
ALLOWED = {
"product.maturity": {"released", "beta", "planned", "deprecated"},
"features.status": {"released", "beta", "planned", "deprecated"},
"claims.type": {"fact", "goal", "comparison"},
"claims.status": {"approved", "draft", "rejected"},
"disclosure": {"public", "restricted", "internal"},
}
OUTPUT_TYPES = {
"feature-catalog",
"technical-spec",
"presentation",
"technical-proposal",
"one-pager",
"whitepaper",
"tender-response",
"sales-demo",
"case-study",
"competitive-positioning",
}
DISCLOSURE_RANK = {"internal": 0, "restricted": 1, "public": 2}
PLACEHOLDERS = re.compile(
r"(?i)(?:\bTBD\b|\bTODO\b|待补充|待确认|placeholder|changeme)"
)
SECRET_QUERY = re.compile(r"(?i)(?:token|api[_-]?key|secret|password)=")
def issue(path, message):
return {"path": path, "message": message}
def is_nonempty_string(value):
return isinstance(value, str) and bool(value.strip())
def validate_record_shape(collection, index, record, errors):
path = f"{collection}[{index}]"
if not isinstance(record, dict):
errors.append(issue(path, "必须是对象"))
return False
for field in REQUIRED_FIELDS[collection]:
value = record.get(field)
if field in ("applies_to",):
if not isinstance(value, list) or not value:
errors.append(issue(f"{path}.{field}", "必须是非空数组"))
elif not is_nonempty_string(value):
errors.append(issue(f"{path}.{field}", "必须是非空字符串"))
return True
def evidence_disclosure_map(records):
return {
record.get("id"): record.get("disclosure")
for record in records["evidence"]
if isinstance(record, dict)
}
def validate_disclosure_chain(records, errors):
"""A record must not be more public than the evidence backing it."""
evidence_disclosure = evidence_disclosure_map(records)
for collection in ("parameters", "claims"):
for index, record in enumerate(records[collection]):
if not isinstance(record, dict):
continue
own = record.get("disclosure")
if own not in DISCLOSURE_RANK:
continue
for ref in record.get("evidence_ids", []) or []:
backing = evidence_disclosure.get(ref)
if backing not in DISCLOSURE_RANK:
continue
if DISCLOSURE_RANK[backing] < DISCLOSURE_RANK[own]:
errors.append(
issue(
f"{collection}[{index}].disclosure",
f"标记为 {own},但证据 {ref} 仅为 {backing}",
)
)
def validate_channel(records, channel, errors):
"""Reject claims cleared for delivery whose evidence is too restricted."""
target = DISCLOSURE_RANK[channel]
evidence_disclosure = evidence_disclosure_map(records)
for index, claim in enumerate(records["claims"]):
if not isinstance(claim, dict) or claim.get("status") != "approved":
continue
if not claim.get("allowed_outputs"):
continue
for ref in claim.get("evidence_ids", []) or []:
disclosure = evidence_disclosure.get(ref)
if disclosure not in DISCLOSURE_RANK:
continue
if DISCLOSURE_RANK[disclosure] < target:
errors.append(
issue(
f"claims[{index}].evidence_ids",
f"证据 {ref}{disclosure},不足以支撑 {channel} 渠道",
)
)
def validate_manifest(data, channel=None):
errors = []
warnings = []
if not isinstance(data, dict):
return [issue("$", "根节点必须是对象")], warnings
if data.get("schema_version") != 1:
errors.append(issue("schema_version", "当前仅支持整数 1"))
product = data.get("product")
if not isinstance(product, dict):
errors.append(issue("product", "必须是对象"))
product = {}
for field in ("name", "version", "category", "summary", "maturity"):
if not is_nonempty_string(product.get(field)):
errors.append(issue(f"product.{field}", "必须是非空字符串"))
if product.get("maturity") not in ALLOWED["product.maturity"]:
errors.append(
issue(
"product.maturity",
f"必须是 {sorted(ALLOWED['product.maturity'])} 之一",
)
)
records = {}
all_ids = {}
for collection in COLLECTIONS:
values = data.get(collection, [])
if not isinstance(values, list):
errors.append(issue(collection, "必须是数组"))
values = []
records[collection] = values
for index, record in enumerate(values):
if not validate_record_shape(collection, index, record, errors):
continue
record_id = record.get("id")
if is_nonempty_string(record_id):
if record_id in all_ids:
errors.append(
issue(
f"{collection}[{index}].id",
f"ID 与 {all_ids[record_id]} 重复:{record_id}",
)
)
else:
all_ids[record_id] = f"{collection}[{index}]"
evidence_ids = {
record.get("id")
for record in records["evidence"]
if isinstance(record, dict) and is_nonempty_string(record.get("id"))
}
feature_ids = {
record.get("id")
for record in records["features"]
if isinstance(record, dict) and is_nonempty_string(record.get("id"))
}
use_case_ids = {
record.get("id")
for record in records["use_cases"]
if isinstance(record, dict) and is_nonempty_string(record.get("id"))
}
for collection in COLLECTIONS:
for index, record in enumerate(records[collection]):
if not isinstance(record, dict):
continue
path = f"{collection}[{index}]"
refs = record.get("evidence_ids", [])
if refs is not None and not isinstance(refs, list):
errors.append(issue(f"{path}.evidence_ids", "必须是数组"))
refs = []
for ref in refs or []:
if ref not in evidence_ids:
errors.append(
issue(f"{path}.evidence_ids", f"引用不存在:{ref}")
)
for ref in record.get("use_case_ids", []) or []:
if ref not in use_case_ids:
errors.append(
issue(f"{path}.use_case_ids", f"引用不存在:{ref}")
)
for index, limitation in enumerate(records["limitations"]):
if not isinstance(limitation, dict):
continue
for ref in limitation.get("applies_to", []) or []:
if ref not in feature_ids and ref not in all_ids:
errors.append(
issue(
f"limitations[{index}].applies_to",
f"引用不存在:{ref}",
)
)
for index, feature in enumerate(records["features"]):
if not isinstance(feature, dict):
continue
status = feature.get("status")
if status not in ALLOWED["features.status"]:
errors.append(
issue(
f"features[{index}].status",
f"必须是 {sorted(ALLOWED['features.status'])} 之一",
)
)
if status in {"released", "beta"} and not feature.get("evidence_ids"):
warnings.append(
issue(
f"features[{index}].evidence_ids",
"已发布或 beta 功能缺少证据",
)
)
for index, parameter in enumerate(records["parameters"]):
if not isinstance(parameter, dict):
continue
disclosure = parameter.get("disclosure")
if disclosure not in ALLOWED["disclosure"]:
errors.append(
issue(
f"parameters[{index}].disclosure",
f"必须是 {sorted(ALLOWED['disclosure'])} 之一",
)
)
if not parameter.get("evidence_ids"):
warnings.append(
issue(
f"parameters[{index}].evidence_ids",
"技术参数缺少证据",
)
)
for index, claim in enumerate(records["claims"]):
if not isinstance(claim, dict):
continue
if claim.get("type") not in ALLOWED["claims.type"]:
errors.append(
issue(
f"claims[{index}].type",
f"必须是 {sorted(ALLOWED['claims.type'])} 之一",
)
)
if claim.get("status") not in ALLOWED["claims.status"]:
errors.append(
issue(
f"claims[{index}].status",
f"必须是 {sorted(ALLOWED['claims.status'])} 之一",
)
)
if (
claim.get("type") in {"fact", "comparison"}
and claim.get("status") == "approved"
and not claim.get("evidence_ids")
):
errors.append(
issue(
f"claims[{index}].evidence_ids",
"已批准的事实或比较主张必须有证据",
)
)
outputs = claim.get("allowed_outputs", [])
if outputs is not None and not isinstance(outputs, list):
errors.append(
issue(f"claims[{index}].allowed_outputs", "必须是数组")
)
for output in outputs or []:
if output not in OUTPUT_TYPES:
errors.append(
issue(
f"claims[{index}].allowed_outputs",
f"未知产物类型:{output}",
)
)
for index, evidence in enumerate(records["evidence"]):
if not isinstance(evidence, dict):
continue
disclosure = evidence.get("disclosure")
if disclosure not in ALLOWED["disclosure"]:
errors.append(
issue(
f"evidence[{index}].disclosure",
f"必须是 {sorted(ALLOWED['disclosure'])} 之一",
)
)
verified_on = evidence.get("verified_on")
if is_nonempty_string(verified_on):
try:
dt.date.fromisoformat(verified_on)
except ValueError:
errors.append(
issue(
f"evidence[{index}].verified_on",
"必须使用 YYYY-MM-DD",
)
)
source = evidence.get("source", "")
if is_nonempty_string(source) and SECRET_QUERY.search(source):
errors.append(
issue(
f"evidence[{index}].source",
"来源中疑似包含密钥或令牌参数",
)
)
for index, differentiator in enumerate(records["differentiators"]):
if isinstance(differentiator, dict) and not differentiator.get(
"evidence_ids"
):
warnings.append(
issue(
f"differentiators[{index}].evidence_ids",
"差异点缺少证据",
)
)
validate_disclosure_chain(records, errors)
if channel in DISCLOSURE_RANK:
validate_channel(records, channel, errors)
for path, value in walk_strings(data):
if PLACEHOLDERS.search(value):
warnings.append(issue(path, f"包含占位内容:{value[:60]}"))
if not evidence_ids:
warnings.append(issue("evidence", "没有任何证据记录"))
return errors, warnings
def walk_strings(value, path="$"):
if isinstance(value, dict):
for key, item in value.items():
yield from walk_strings(item, f"{path}.{key}")
elif isinstance(value, list):
for index, item in enumerate(value):
yield from walk_strings(item, f"{path}[{index}]")
elif isinstance(value, str):
yield path, value
def parse_args():
parser = argparse.ArgumentParser(description="验证产品事实与证据清单")
parser.add_argument("manifest", help="product-evidence.json")
parser.add_argument("--json", action="store_true", help="输出 JSON")
parser.add_argument(
"--strict",
action="store_true",
help="存在占位符、缺证据等警告时也失败",
)
parser.add_argument(
"--channel",
choices=sorted(DISCLOSURE_RANK),
help="目标发布渠道,校验主张引用证据的公开级别是否足够",
)
return parser.parse_args()
def main():
args = parse_args()
path = Path(args.manifest).expanduser().resolve()
with path.open(encoding="utf-8") as handle:
data = json.load(handle)
errors, warnings = validate_manifest(data, channel=args.channel)
report = {
"file": str(path),
"channel": args.channel,
"valid": not errors and (not args.strict or not warnings),
"errors": errors,
"warnings": warnings,
"stats": {
collection: len(data.get(collection, []))
if isinstance(data, dict) and isinstance(data.get(collection, []), list)
else 0
for collection in COLLECTIONS
},
}
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
for level, items in (("错误", errors), ("警告", warnings)):
for item in items:
print(f"[{level}] {item['path']}{item['message']}")
print(
f"核验完成:错误 {len(errors)},警告 {len(warnings)}"
f"状态 {'通过' if report['valid'] else '失败'}"
)
return 0 if report["valid"] else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,120 @@
{
"schema_version": 1,
"product": {
"name": "示例产品",
"version": "1.0",
"category": "产品类别",
"summary": "用一句可验证的话说明产品面向谁、解决什么问题。",
"maturity": "released",
"audiences": [
"技术负责人",
"业务负责人"
],
"deployment_modes": [
"私有化部署"
]
},
"terminology": [
{
"term": "标准术语",
"definition": "术语在所有材料中的统一定义。",
"avoid": [
"不再使用的旧称谓"
]
}
],
"features": [
{
"id": "FEAT-001",
"name": "示例功能",
"summary": "说明用户可执行的动作和可观察结果。",
"status": "released",
"availability": "标准版本",
"evidence_ids": [
"EVD-001"
],
"use_case_ids": [
"CASE-001"
]
}
],
"parameters": [
{
"id": "PAR-001",
"name": "示例技术参数",
"value": "已核验值",
"unit": "",
"conditions": "说明测试条件、版本和统计口径。",
"scope": "标准版本",
"disclosure": "public",
"evidence_ids": [
"EVD-001"
]
}
],
"claims": [
{
"id": "CLM-001",
"text": "产品在指定版本和条件下提供示例功能。",
"type": "fact",
"status": "approved",
"allowed_outputs": [
"feature-catalog",
"technical-proposal",
"presentation"
],
"evidence_ids": [
"EVD-001"
]
}
],
"use_cases": [
{
"id": "CASE-001",
"name": "示例场景",
"audience": "技术负责人",
"problem": "说明现有工作中的具体问题。",
"workflow": "说明产品参与的步骤和人工复核点。",
"outcome": "只写已有证据支持的结果。",
"evidence_ids": [
"EVD-001"
]
}
],
"differentiators": [
{
"id": "DIF-001",
"statement": "说明可验证的产品差异。",
"comparison_scope": "明确比较对象和版本范围。",
"evidence_ids": [
"EVD-001"
]
}
],
"limitations": [
{
"id": "LIM-001",
"text": "说明适用边界、依赖条件或人工复核要求。",
"applies_to": [
"FEAT-001"
]
}
],
"evidence": [
{
"id": "EVD-001",
"type": "product-documentation",
"title": "产品说明书",
"source": "docs/product.md",
"locator": "功能章节",
"verified_on": "2026-01-01",
"owner": "产品负责人",
"disclosure": "public",
"notes": "发布前重新核验版本。"
}
],
"prohibited_claims": [
"绝对领先",
"百分之百准确"
]
}
@@ -0,0 +1,113 @@
import copy
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parents[1]
SCRIPT = SKILL_DIR / "scripts" / "validate_evidence.py"
SPEC = importlib.util.spec_from_file_location("validate_evidence", SCRIPT)
validate_evidence = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(validate_evidence)
class ProductEvidenceTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
template = SKILL_DIR / "templates" / "product-evidence.example.json"
cls.valid_data = json.loads(template.read_text(encoding="utf-8"))
def test_example_manifest_is_valid(self):
errors, warnings = validate_evidence.validate_manifest(self.valid_data)
self.assertEqual(errors, [])
self.assertEqual(warnings, [])
def test_rejects_duplicate_ids_and_unknown_references(self):
data = copy.deepcopy(self.valid_data)
data["parameters"][0]["id"] = "FEAT-001"
data["claims"][0]["evidence_ids"] = ["EVD-MISSING"]
errors, _ = validate_evidence.validate_manifest(data)
messages = "\n".join(item["message"] for item in errors)
self.assertIn("重复", messages)
self.assertIn("引用不存在", messages)
def test_rejects_secret_in_evidence_source(self):
data = copy.deepcopy(self.valid_data)
data["evidence"][0]["source"] = "https://example.test/doc?token=secret"
errors, _ = validate_evidence.validate_manifest(data)
self.assertTrue(
any("密钥" in item["message"] for item in errors)
)
def test_placeholder_is_warning(self):
data = copy.deepcopy(self.valid_data)
data["product"]["summary"] = "TODO"
errors, warnings = validate_evidence.validate_manifest(data)
self.assertEqual(errors, [])
self.assertTrue(warnings)
def test_rejects_record_more_public_than_evidence(self):
data = copy.deepcopy(self.valid_data)
for record in data["evidence"]:
if record["id"] == "EVD-001":
record["disclosure"] = "restricted"
errors, _ = validate_evidence.validate_manifest(data)
self.assertTrue(
any("仅为 restricted" in item["message"] for item in errors)
)
def test_restricted_parameter_is_not_a_public_channel_warning(self):
data = copy.deepcopy(self.valid_data)
data["parameters"][0]["disclosure"] = "restricted"
for record in data["evidence"]:
if record["id"] in data["parameters"][0]["evidence_ids"]:
record["disclosure"] = "restricted"
data["claims"][0]["evidence_ids"] = []
data["claims"][0]["type"] = "goal"
errors, warnings = validate_evidence.validate_manifest(
data, channel="public"
)
self.assertEqual(errors, [])
self.assertEqual(warnings, [])
def test_public_channel_rejects_internal_evidence(self):
data = copy.deepcopy(self.valid_data)
claim = data["claims"][0]
claim["allowed_outputs"] = ["one-pager"]
evidence_id = claim["evidence_ids"][0]
for record in data["evidence"]:
if record["id"] == evidence_id:
record["disclosure"] = "internal"
for parameter in data["parameters"]:
if evidence_id in parameter.get("evidence_ids", []):
parameter["disclosure"] = "internal"
errors, _ = validate_evidence.validate_manifest(data, channel="public")
self.assertTrue(
any("不足以支撑 public" in item["message"] for item in errors)
)
errors, _ = validate_evidence.validate_manifest(data, channel="internal")
self.assertEqual(errors, [])
def test_cli_json_report(self):
with tempfile.TemporaryDirectory() as tmp:
manifest = Path(tmp) / "product-evidence.json"
manifest.write_text(
json.dumps(self.valid_data, ensure_ascii=False),
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, str(SCRIPT), str(manifest), "--json"],
check=True,
capture_output=True,
text=True,
)
report = json.loads(result.stdout)
self.assertTrue(report["valid"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,56 @@
---
name: product-feature-catalog
version: 1.0.0
description: |
基于产品事实与证据生成结构化功能列表、模块树和版本能力矩阵。用于产品规划、
售前交流、招标附件或交付范围梳理;重点回答产品有什么功能、谁使用、产生什么
结果,不负责撰写技术参数或长篇方案。
allowed-tools:
- Read
- Grep
- Glob
compatibility: Markdown;建议配合 product-evidence
---
# 产品功能列表
## 必要输入
- `product-evidence.json`,至少包含 `product``features``limitations`
- 输出用途:内部全量、公开产品、指定版本或投标范围。
- 目标受众和所需粒度:模块、功能或子功能。
没有事实清单时不要自行拼凑:先用 `product-evidence` 建立并校验清单,或输出输入
缺口清单等待补齐;任何情况下都不得根据产品名称猜测功能。
## 生成流程
1. 按版本、公开级别和用途筛选功能。
2. 建立“产品域 → 一级模块 → 功能 → 子功能”层级,层级只服务导航,不凑数量。
3. 每条功能写清用户角色、触发动作、系统行为和可观察结果。
4. 标注 `released``beta``planned``deprecated`,规划能力不能混入现有功能。
5. 补充版本范围、依赖、限制和证据 ID。
6. 合并同义功能,拆开一行中包含多个独立用户动作的复合功能。
## 输出
复制并填写 `templates/feature-catalog.md`。默认输出两部分:
1. 面向读者的功能目录,只保留必要列。
2. 追溯附表,保留功能 ID、状态、证据和限制,供内部审核。
## 写作规则
- 功能名使用“对象 + 动作”或稳定名词,不用“强大、智能、高效、全方位”。
- 功能说明写能力边界,不写架构实现、性能参数和竞争结论。
- “支持”后必须接具体对象或动作,避免“全面支持”“灵活支持”。
- 同一功能在不同版本有差异时拆行或使用版本矩阵,不能用模糊脚注掩盖。
- 公开清单不得带出 `restricted``internal` 证据内容。
## 完成标准
- 每条功能可追溯到事实清单 ID。
- 当前能力、试用能力和规划能力明确分开。
- 模块树无重复、孤立节点和伪造层级。
- 功能粒度基本一致,名称、术语和版本范围统一。
- 限制条件没有因表格精简而丢失。
@@ -0,0 +1,27 @@
# {{产品名称}}功能列表
**产品版本**{{版本}}
**适用范围**{{版本/部署方式/授权范围}}
**清单日期**{{YYYY-MM-DD}}
## 功能目录
| 一级模块 | 二级模块 | 功能名称 | 使用角色 | 用户动作与结果 | 版本范围 | 状态 |
|---|---|---|---|---|---|---|
| {{模块}} | {{子模块}} | {{对象+动作}} | {{角色}} | {{触发动作;系统行为;可观察结果}} | {{版本}} | released |
## 版本能力矩阵
| 功能 ID | 标准版 | 专业版 | 企业版 | 依赖条件 |
|---|---:|---:|---:|---|
| {{FEAT-001}} | ✓ | ✓ | ✓ | {{依赖}} |
## 限制与说明
- {{功能 ID}}:{{适用边界、依赖或人工复核要求}}。
## 内部追溯表
| 功能 ID | 功能名称 | 状态 | 证据 ID | 复核结果 |
|---|---|---|---|---|
| {{FEAT-001}} | {{功能}} | released | {{EVD-001}} | {{通过/待核验}} |
+200
View File
@@ -0,0 +1,200 @@
---
name: product-marketing
version: 1.0.0
description: |
编排产品事实、功能列表、招标参数、产品 PPT、技术方案、一页纸、白皮书、招标响应、
演示套件、客户案例和竞品定位等多个独立技能。用于一次请求需要选择或组合多种
产品市场产物,并确保它们共享同一事实版本、术语和承诺边界。
allowed-tools:
- Read
- Grep
- Glob
- Execute
compatibility: 可独立规划;执行节点需要对应子技能可用
---
# 产品市场总编排
本技能只负责需求澄清、路由、依赖、门禁和跨产物一致性。各产物的方法和模板由
对应独立技能负责,不能在总技能中重新实现一份简化版本。
## 子技能
| 技能 | 唯一职责 |
|---|---|
| `product-evidence` | 冻结产品事实、状态、证据和限制 |
| `product-feature-catalog` | 功能目录和版本矩阵 |
| `tender-technical-spec` | 可采购、可验收的招标技术规格 |
| `product-presentation` | 产品介绍 PPT 与讲稿 |
| `technical-proposal` | 客户或投标技术方案 |
| `product-one-pager` | 一页纸产品概览和彩页文案 |
| `solution-whitepaper` | 原理、架构、证据和边界白皮书 |
| `tender-response-matrix` | 招标要求逐条响应、缺口和偏离 |
| `sales-demo-kit` | 可执行演示套件、回退和演练材料 |
| `customer-case-study` | 经授权且可复核的客户案例 |
| `competitive-positioning` | 有来源的竞品矩阵和定位 |
`deai-writing``longdoc-docx` 是可选质量与导出技能,不承担产品事实判断。
## 第一步:形成任务简报
必须确认:
- 产品和版本。
- 目标受众、决策目标和发布渠道。
- 需要的产物、格式、篇幅、语言和截止时间。
- 公开级别、客户信息和竞品信息的使用权限。
- 原始事实材料、招标文件、客户需求和品牌资产。
- 最终审批人。
信息不足时把缺口写入计划,不默认补齐。
## 第二步:生成路由计划
复制 `templates/route-plan.example.json`,只选择完成请求所需节点:
先探测可用的 Python 3 解释器:Windows 优先使用 `python`macOS/Linux
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
```bash
<python> "<skill-dir>/scripts/validate_route_plan.py" ./route-plan.json
```
所有产物必须直接或间接依赖唯一的 `product-evidence` 节点。节点缺少对应技能时
明确报告缺失,不允许由总技能静默生成低质量替代物。
`depends_on` 表示硬依赖:被依赖节点未 `completed` 时,本节点不能进入 `running`
`completed`。可选输入(例如尚无授权的客户案例)不要写进 `depends_on`,而是
`reason` 中说明“可用则引用,不可用则不提及”。
## 推荐路由
### 投标响应(我方应标)
```text
product-evidence
→ tender-response-matrix(提取要求与缺口)
→ technical-proposal
→ tender-response-matrix(回填方案章节和证明材料)
```
### 招标文件编制(采购方)
```text
product-evidence → product-feature-catalog → tender-technical-spec
```
`tender-technical-spec` 面向采购参数编写,不参与我方符合性判定,两条路由不要
混用。
### 客户方案
```text
product-evidence
→ product-feature-catalog
→ technical-proposal
→ [product-presentation, product-one-pager]
```
PPT 和一页纸从已批准方案摘要派生,避免重新解释范围。
### 产品发布与销售
```text
product-evidence
→ product-feature-catalog
→ [competitive-positioning, customer-case-study]
→ product-one-pager
→ product-presentation
→ sales-demo-kit
```
客户案例没有授权时把该节点标记 `skipped``required` 为 false,其他节点可
继续,但不得引用该案例。
### 白皮书
```text
product-evidence → product-feature-catalog → solution-whitepaper
```
## 第三步:执行门禁
每个节点开始前检查依赖是否通过;失败节点的下游必须阻断,不能标记成功。执行
过程中更新 `status`,交付前用 final 阶段复核:
```bash
<python> "<skill-dir>/scripts/validate_route_plan.py" ./route-plan.json \
--phase final --output-root .
```
final 阶段要求必需节点全部 `completed`、依赖链无未完成节点,并核验每个声明产物
文件存在且非空。
门禁分两类:
- `evidence-validation``cross-artifact-consistency` 由本套件自行判定,必须
`passed`,不能豁免。
- `confidentiality-review``human-approval` 取决于组织流程。需要评审时记为
`passed`;组织不要求时记为 `waived` 并在 `waiver` 写明责任人与理由,校验通过
但会输出警告,保留豁免记录。
最终统一核对:
- 产品名称、版本、功能状态和术语一致。
- 相同参数的数值、单位、条件和统计口径一致。
- `planned``beta``released` 没有跨产物变形。
- 客户案例、Logo、引语和竞品结论权限一致。
- 技术方案、PPT、一页纸和演示套件使用相同架构与工作流。
- 所有对外主张可追溯到同一版 `product-evidence.json`
- 不含密钥、私有地址、内部证据路径和未批准承诺。
## 第四步:质量与导出
中文叙事产物可调用 `deai-writing`;长文可调用 `longdoc-docx`PPT 使用
`product-presentation` 自带构建器。质量工具只能修正表达和排版,不能更改事实、
成熟度、参数或授权边界。
### 交付物形态
Markdown 与 JSON 是中间产物,不是交付物。除非用户另有指定,交付物为:
| 产物 | 交付格式 |
| --- | --- |
| 文档类(技术方案、白皮书、功能列表、响应矩阵、招标规格、一页纸、演示套件、竞争定位) | DOCX |
| 产品 PPT | PPTX |
PDF 只作为排版核验中间件,核验后删除,不放入交付目录;用户明确要求时才交付。
### 目录与密级
中间产物与交付物必须分离,交付目录按各产物自身声明的密级分区:
```text
build/ # 事实清单、路由计划、Markdown、构建配置
check/ # 核验用 PDF、核验报告、页面 PNG
deliverables/
public/ # 可公开
restricted/ # 受控客户交流与投标
internal/ # 仅内部
```
各子技能的 `output` 一律指向 `deliverables/<密级>/`,核验产物一律写入
`build/check/`。交付目录内只允许出现 DOCX 与 PPTX。
单文件产物直接放 `build/<产物名>.md`。技术方案、白皮书等需要分章节的长文,改用
`build/<产物名>/` 子目录,内部按 `longdoc-docx``chapters/``assets/`
`drafts/` 分层,避免多个长文的章节文件在 `build/` 根目录互相混淆。
密级以产物自身标注为准,不得由编排者主观下调。分区后须扫描公开级产物,确认
未夹带受控结论、内部路径与凭据。
路由计划节点的 `outputs` 必须声明真实交付物路径,Markdown 与 JSON 记入
`intermediates`。若 `outputs` 指向中间产物,交付门禁将只校验中间产物而放行缺失
的真实交付物。
## 完成标准
路由计划通过校验;所有必需节点完成;无失败依赖被忽略;各产物共享同一事实版本;
跨产物一致性、保密审查和人工审批全部通过,并保留选择、跳过和阻断理由;交付目录
中每个产物都以约定格式实际存在,且已按密级分区。
@@ -0,0 +1,422 @@
#!/usr/bin/env python3
"""Validate product-marketing orchestration route plans."""
import argparse
import json
import re
import sys
from pathlib import Path, PurePosixPath
SKILL_ARTIFACT = {
"product-evidence": "evidence",
"product-feature-catalog": "feature-catalog",
"tender-technical-spec": "technical-spec",
"product-presentation": "presentation",
"technical-proposal": "technical-proposal",
"product-one-pager": "one-pager",
"solution-whitepaper": "whitepaper",
"tender-response-matrix": "tender-response",
"sales-demo-kit": "sales-demo",
"customer-case-study": "case-study",
"competitive-positioning": "competitive-positioning",
}
STATUSES = {"pending", "running", "completed", "failed", "skipped"}
GATE_STATUSES = {"pending", "passed", "failed", "waived"}
CONFIDENTIALITY = {"public", "restricted", "internal"}
# Gates this suite can evaluate itself; they must actually pass.
CORE_GATES = {"evidence-validation", "cross-artifact-consistency"}
# Gates that depend on organizational policy; they may be waived with a reason.
POLICY_GATES = {"confidentiality-review", "human-approval"}
REQUIRED_GATES = CORE_GATES | POLICY_GATES
SECRET_PATTERN = re.compile(r"(?i)(?:token|api[_-]?key|secret|password)=")
def issue(path, message):
return {"path": path, "message": message}
def nonempty(value):
return isinstance(value, str) and bool(value.strip())
def validate_output_path(value, path, errors):
if not nonempty(value):
errors.append(issue(path, "必须是非空字符串"))
return
normalized = value.replace("\\", "/")
pure = PurePosixPath(normalized)
if pure.is_absolute() or ".." in pure.parts:
errors.append(issue(path, "必须是工作目录内的相对路径"))
if SECRET_PATTERN.search(value):
errors.append(issue(path, "路径疑似包含密钥或令牌"))
def known_dependencies(node, nodes_by_id):
return [
dependency
for dependency in node.get("depends_on", [])
if isinstance(dependency, str) and dependency in nodes_by_id
]
def topological_order(nodes_by_id):
"""Return (order, cycle). Kahn's algorithm keeps deep graphs iterative."""
dependents = {node_id: [] for node_id in nodes_by_id}
remaining = {}
for node_id, node in nodes_by_id.items():
dependencies = set(known_dependencies(node, nodes_by_id)) - {node_id}
remaining[node_id] = len(dependencies)
for dependency in dependencies:
dependents[dependency].append(node_id)
queue = [node_id for node_id, count in remaining.items() if count == 0]
order = []
while queue:
node_id = queue.pop()
order.append(node_id)
for dependent in dependents[node_id]:
remaining[dependent] -= 1
if remaining[dependent] == 0:
queue.append(dependent)
if len(order) == len(nodes_by_id):
return order, None
return order, sorted(node_id for node_id in nodes_by_id if remaining[node_id] > 0)
def evidence_reachability(order, nodes_by_id, evidence_id):
reaches = {}
for node_id in order:
dependencies = known_dependencies(nodes_by_id[node_id], nodes_by_id)
reaches[node_id] = any(
dependency == evidence_id or reaches.get(dependency, False)
for dependency in dependencies
)
return reaches
def validate_plan(data, phase="plan", output_root=None):
errors = []
warnings = []
if not isinstance(data, dict):
return [issue("$", "根节点必须是对象")], warnings
if data.get("schema_version") != 1:
errors.append(issue("schema_version", "当前仅支持整数 1"))
request = data.get("request")
if not isinstance(request, dict):
errors.append(issue("request", "必须是对象"))
request = {}
for field in ("id", "objective", "channel", "evidence_ref", "product_version"):
if not nonempty(request.get(field)):
errors.append(issue(f"request.{field}", "必须是非空字符串"))
if request.get("confidentiality") not in CONFIDENTIALITY:
errors.append(
issue(
"request.confidentiality",
f"必须是 {sorted(CONFIDENTIALITY)} 之一",
)
)
if nonempty(request.get("evidence_ref")):
validate_output_path(
request["evidence_ref"],
"request.evidence_ref",
errors,
)
nodes = data.get("nodes")
if not isinstance(nodes, list) or not nodes:
errors.append(issue("nodes", "必须是非空数组"))
return errors, warnings
nodes_by_id = {}
all_outputs = {}
evidence_nodes = []
for index, node in enumerate(nodes):
path = f"nodes[{index}]"
if not isinstance(node, dict):
errors.append(issue(path, "必须是对象"))
continue
node_id = node.get("id")
if not nonempty(node_id):
errors.append(issue(f"{path}.id", "必须是非空字符串"))
continue
if node_id in nodes_by_id:
errors.append(issue(f"{path}.id", f"节点 ID 重复:{node_id}"))
continue
nodes_by_id[node_id] = node
skill = node.get("skill")
if skill not in SKILL_ARTIFACT:
errors.append(issue(f"{path}.skill", f"未知技能:{skill!r}"))
elif node.get("artifact_type") != SKILL_ARTIFACT[skill]:
errors.append(
issue(
f"{path}.artifact_type",
f"{skill} 应生成 {SKILL_ARTIFACT[skill]}",
)
)
if skill == "product-evidence":
evidence_nodes.append(node_id)
dependencies = node.get("depends_on")
if not isinstance(dependencies, list):
errors.append(issue(f"{path}.depends_on", "必须是数组"))
elif len(dependencies) != len(set(dependencies)):
errors.append(issue(f"{path}.depends_on", "存在重复依赖"))
if not isinstance(node.get("required"), bool):
errors.append(issue(f"{path}.required", "必须是布尔值"))
if node.get("status") not in STATUSES:
errors.append(
issue(
f"{path}.status",
f"必须是 {sorted(STATUSES)} 之一",
)
)
if node.get("required") is True and node.get("status") == "skipped":
errors.append(issue(f"{path}.status", "必需节点不能标记 skipped"))
if not nonempty(node.get("reason")):
errors.append(issue(f"{path}.reason", "必须说明选择理由"))
outputs = node.get("outputs")
if not isinstance(outputs, list) or not outputs:
errors.append(issue(f"{path}.outputs", "必须是非空数组"))
else:
for output_index, output in enumerate(outputs):
output_path = f"{path}.outputs[{output_index}]"
validate_output_path(output, output_path, errors)
if output in all_outputs:
errors.append(
issue(
output_path,
f"输出与 {all_outputs[output]} 重复:{output}",
)
)
else:
all_outputs[output] = node_id
for node_id, node in nodes_by_id.items():
for dependency in node.get("depends_on", []):
if dependency == node_id:
errors.append(
issue(f"nodes.{node_id}.depends_on", "不能依赖自身")
)
elif dependency not in nodes_by_id:
errors.append(
issue(
f"nodes.{node_id}.depends_on",
f"依赖节点不存在:{dependency}",
)
)
order, cycle = topological_order(nodes_by_id)
if cycle:
errors.append(issue("nodes", f"依赖存在环:{''.join(cycle)}"))
if len(evidence_nodes) != 1:
errors.append(issue("nodes", "必须且只能有一个 product-evidence 节点"))
elif not cycle:
evidence_id = evidence_nodes[0]
reaches = evidence_reachability(order, nodes_by_id, evidence_id)
for node_id in nodes_by_id:
if node_id != evidence_id and not reaches.get(node_id, False):
errors.append(
issue(
f"nodes.{node_id}.depends_on",
"所有产物必须直接或间接依赖 product-evidence",
)
)
validate_execution(nodes_by_id, errors)
validate_gates(data.get("final_gates"), errors, warnings, phase)
if phase == "final":
validate_final_phase(nodes_by_id, output_root, errors)
if len(nodes_by_id) == 1:
warnings.append(issue("nodes", "路由计划没有任何最终产物节点"))
return errors, warnings
def validate_execution(nodes_by_id, errors):
for node_id, node in nodes_by_id.items():
status = node.get("status")
if status not in ("running", "completed"):
continue
for dependency in known_dependencies(node, nodes_by_id):
dependency_node = nodes_by_id[dependency]
dependency_status = dependency_node.get("status")
if dependency_status == "completed":
continue
if (
dependency_status == "skipped"
and dependency_node.get("required") is False
):
continue
errors.append(
issue(
f"nodes.{node_id}.status",
f"依赖 {dependency} 状态为 {dependency_status}"
f"不能标记 {status}",
)
)
def validate_gates(gates, errors, warnings, phase):
if not isinstance(gates, list):
errors.append(issue("final_gates", "必须是数组"))
return {}
resolved = {}
for index, gate in enumerate(gates):
path = f"final_gates[{index}]"
if isinstance(gate, str):
if not nonempty(gate):
errors.append(issue(path, "门禁名称不能为空"))
continue
resolved[gate] = {"status": None, "waiver": None}
elif isinstance(gate, dict):
name = gate.get("name")
if not nonempty(name):
errors.append(issue(f"{path}.name", "门禁名称不能为空"))
continue
status = gate.get("status")
if status not in GATE_STATUSES:
errors.append(
issue(
f"{path}.status",
f"必须是 {sorted(GATE_STATUSES)} 之一",
)
)
continue
waiver = gate.get("waiver")
if status == "waived":
if name not in POLICY_GATES:
errors.append(
issue(
f"{path}.status",
f"{name} 由本套件自行判定,不能豁免",
)
)
continue
if not nonempty(waiver):
errors.append(
issue(f"{path}.waiver", "豁免必须写明责任人与理由")
)
continue
resolved[name] = {"status": status, "waiver": waiver}
else:
errors.append(issue(path, "必须是字符串或对象"))
missing = REQUIRED_GATES - set(resolved)
if missing:
errors.append(issue("final_gates", f"缺少门禁:{sorted(missing)}"))
if phase == "final":
for name in sorted(REQUIRED_GATES & set(resolved)):
status = resolved[name]["status"]
if status == "passed":
continue
if status == "waived":
warnings.append(
issue(
"final_gates",
f"{name} 已豁免:{resolved[name]['waiver']}",
)
)
continue
errors.append(
issue(
"final_gates",
f"交付前门禁 {name} 必须记录 passed,当前为 "
f"{status or '未记录'}",
)
)
return resolved
def validate_final_phase(nodes_by_id, output_root, errors):
for node_id, node in nodes_by_id.items():
status = node.get("status")
if node.get("required") is True and status != "completed":
errors.append(
issue(
f"nodes.{node_id}.status",
f"交付前必需节点必须 completed,当前为 {status}",
)
)
elif status not in ("completed", "skipped"):
errors.append(
issue(
f"nodes.{node_id}.status",
f"交付前可选节点必须 completed 或 skipped,当前为 {status}",
)
)
if status != "completed" or output_root is None:
continue
outputs = node.get("outputs")
if not isinstance(outputs, list):
continue
for output in outputs:
if not nonempty(output):
continue
artifact = output_root / output
if not artifact.is_file():
errors.append(
issue(
f"nodes.{node_id}.outputs",
f"声明的产物不存在:{output}",
)
)
elif artifact.stat().st_size == 0:
errors.append(
issue(
f"nodes.{node_id}.outputs",
f"产物为空文件:{output}",
)
)
def parse_args():
parser = argparse.ArgumentParser(description="验证产品市场技能路由计划")
parser.add_argument("plan", help="route-plan.json")
parser.add_argument(
"--phase",
choices=("plan", "final"),
default="plan",
help="plan 校验结构,final 追加交付前状态与产物核验",
)
parser.add_argument(
"--output-root",
help="final 阶段解析 outputs 相对路径的根目录",
)
parser.add_argument("--json", action="store_true")
return parser.parse_args()
def main():
args = parse_args()
path = Path(args.plan).expanduser().resolve()
output_root = (
Path(args.output_root).expanduser().resolve() if args.output_root else None
)
with path.open(encoding="utf-8") as handle:
data = json.load(handle)
errors, warnings = validate_plan(data, phase=args.phase, output_root=output_root)
report = {
"file": str(path),
"phase": args.phase,
"valid": not errors,
"errors": errors,
"warnings": warnings,
"nodes": len(data.get("nodes", [])) if isinstance(data, dict) else 0,
}
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
for level, items in (("错误", errors), ("警告", warnings)):
for item in items:
print(f"[{level}] {item['path']}{item['message']}")
print(
f"路由核验({args.phase}):错误 {len(errors)},警告 {len(warnings)}"
f"状态 {'通过' if report['valid'] else '失败'}"
)
return 0 if report["valid"] else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,77 @@
{
"schema_version": 1,
"request": {
"id": "PM-001",
"objective": "为目标客户准备产品介绍与技术方案",
"audiences": [
"技术负责人",
"业务负责人"
],
"channel": "受控客户交流",
"confidentiality": "restricted",
"evidence_ref": "product-evidence.json",
"product_version": "1.0"
},
"nodes": [
{
"id": "evidence",
"skill": "product-evidence",
"artifact_type": "evidence",
"depends_on": [],
"required": true,
"status": "pending",
"outputs": [
"product-evidence.json"
],
"reason": "所有产物共享同一事实版本"
},
{
"id": "catalog",
"skill": "product-feature-catalog",
"artifact_type": "feature-catalog",
"depends_on": [
"evidence"
],
"required": true,
"status": "pending",
"outputs": [
"feature-catalog.md"
],
"reason": "先固定产品能力范围"
},
{
"id": "proposal",
"skill": "technical-proposal",
"artifact_type": "technical-proposal",
"depends_on": [
"catalog"
],
"required": true,
"status": "pending",
"outputs": [
"technical-proposal.md"
],
"reason": "客户方案依赖已核验能力目录"
},
{
"id": "presentation",
"skill": "product-presentation",
"artifact_type": "presentation",
"depends_on": [
"proposal"
],
"required": true,
"status": "pending",
"outputs": [
"product-presentation.pptx"
],
"reason": "PPT 从已批准方案摘要派生"
}
],
"final_gates": [
{ "name": "evidence-validation", "status": "pending" },
{ "name": "cross-artifact-consistency", "status": "pending" },
{ "name": "confidentiality-review", "status": "pending" },
{ "name": "human-approval", "status": "pending" }
]
}
@@ -0,0 +1,157 @@
import copy
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parents[1]
SCRIPT = SKILL_DIR / "scripts" / "validate_route_plan.py"
SPEC = importlib.util.spec_from_file_location("validate_route_plan", SCRIPT)
validate_route_plan = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(validate_route_plan)
class RoutePlanTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
template = SKILL_DIR / "templates" / "route-plan.example.json"
cls.plan = json.loads(template.read_text(encoding="utf-8"))
def test_example_plan_is_valid(self):
errors, warnings = validate_route_plan.validate_plan(self.plan)
self.assertEqual(errors, [])
self.assertEqual(warnings, [])
def test_rejects_cycle(self):
plan = copy.deepcopy(self.plan)
plan["nodes"][0]["depends_on"] = ["presentation"]
errors, _ = validate_route_plan.validate_plan(plan)
self.assertTrue(any("依赖存在环" in item["message"] for item in errors))
def test_rejects_artifact_without_evidence_dependency(self):
plan = copy.deepcopy(self.plan)
plan["nodes"][1]["depends_on"] = []
errors, _ = validate_route_plan.validate_plan(plan)
self.assertTrue(
any("product-evidence" in item["message"] for item in errors)
)
def test_rejects_duplicate_output(self):
plan = copy.deepcopy(self.plan)
plan["nodes"][2]["outputs"] = ["feature-catalog.md"]
errors, _ = validate_route_plan.validate_plan(plan)
self.assertTrue(any("输出与" in item["message"] for item in errors))
def test_rejects_completed_node_with_unfinished_dependency(self):
plan = copy.deepcopy(self.plan)
plan["nodes"][1]["status"] = "completed"
errors, _ = validate_route_plan.validate_plan(plan)
self.assertTrue(
any("不能标记 completed" in item["message"] for item in errors)
)
def test_final_phase_requires_completion_and_artifacts(self):
plan = copy.deepcopy(self.plan)
errors, _ = validate_route_plan.validate_plan(plan, phase="final")
self.assertTrue(
any("必须 completed" in item["message"] for item in errors)
)
self.assertTrue(
any("必须记录 passed" in item["message"] for item in errors)
)
def test_policy_gate_can_be_waived_with_reason(self):
plan = copy.deepcopy(self.plan)
for node in plan["nodes"]:
node["status"] = "completed"
for gate in plan["final_gates"]:
gate["status"] = "passed"
approval = next(
g for g in plan["final_gates"] if g["name"] == "human-approval"
)
approval["status"] = "waived"
errors, _ = validate_route_plan.validate_plan(plan, phase="final")
self.assertTrue(any("豁免必须写明" in i["message"] for i in errors))
approval["waiver"] = "内部草稿,责任人张三,2026-08-01"
errors, warnings = validate_route_plan.validate_plan(plan, phase="final")
self.assertEqual(errors, [])
self.assertTrue(any("已豁免" in i["message"] for i in warnings))
def test_core_gate_cannot_be_waived(self):
plan = copy.deepcopy(self.plan)
for node in plan["nodes"]:
node["status"] = "completed"
for gate in plan["final_gates"]:
gate["status"] = "passed"
core = next(
g for g in plan["final_gates"] if g["name"] == "evidence-validation"
)
core["status"] = "waived"
core["waiver"] = "跳过"
errors, _ = validate_route_plan.validate_plan(plan, phase="final")
self.assertTrue(any("不能豁免" in i["message"] for i in errors))
def test_final_phase_accepts_completed_plan_with_outputs(self):
plan = copy.deepcopy(self.plan)
for node in plan["nodes"]:
node["status"] = "completed"
for gate in plan["final_gates"]:
gate["status"] = "passed"
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for node in plan["nodes"]:
for output in node["outputs"]:
(root / output).write_text("content", encoding="utf-8")
errors, _ = validate_route_plan.validate_plan(
plan, phase="final", output_root=root
)
self.assertEqual(errors, [])
missing = copy.deepcopy(plan)
(root / missing["nodes"][-1]["outputs"][0]).unlink()
errors, _ = validate_route_plan.validate_plan(
missing, phase="final", output_root=root
)
self.assertTrue(
any("产物不存在" in item["message"] for item in errors)
)
def test_deep_chain_does_not_recurse(self):
nodes = [
{
"id": "evidence",
"skill": "product-evidence",
"artifact_type": "evidence",
"depends_on": [],
"required": True,
"status": "pending",
"outputs": ["product-evidence.json"],
"reason": "基线",
}
]
previous = "evidence"
for index in range(1500):
node_id = f"catalog-{index}"
nodes.append(
{
"id": node_id,
"skill": "product-feature-catalog",
"artifact_type": "feature-catalog",
"depends_on": [previous],
"required": True,
"status": "pending",
"outputs": [f"feature-catalog-{index}.md"],
"reason": "链式依赖",
}
)
previous = node_id
plan = copy.deepcopy(self.plan)
plan["nodes"] = list(reversed(nodes))
errors, _ = validate_route_plan.validate_plan(plan)
self.assertEqual(errors, [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,57 @@
---
name: product-one-pager
version: 1.0.0
description: |
将已核验产品信息压缩为一页纸产品概览、宣传彩页或官网下载页文案。用于首次触达、
展会资料和销售跟进;不替代完整功能清单、技术规格或技术方案。
allowed-tools:
- Read
- Grep
- Glob
compatibility: Markdown/HTML;建议配合 product-evidence
---
# 产品一页纸
## 必要输入
- 产品定位、目标受众和本次一页纸唯一目标。
- 3 至 5 项核心能力、一个典型工作流、适用范围和限制。
- 可公开的参数、案例、标识、截图和行动入口。
- 页面尺寸、语言、品牌规范和发布渠道。
## 内容预算
一页纸不是把长文缩小字号。默认预算:
- 主标题 1 个,副标题 1 段。
- 目标问题 2 至 3 条。
- 核心能力 3 至 5 项。
- 工作方式或架构图 1 张。
- 参数、案例或验证结果最多 3 项。
- 部署与适用边界 1 个区域。
- 行动入口 1 个。
超过预算时删减次要内容,不缩成无法阅读的小字。
## 生成流程
1. 用一句话回答“面向谁、提供什么、适用于什么范围”。
2. 选择与目标受众最相关的能力,不按后台菜单罗列。
3. 每项能力写“动作 + 结果”,避免形容词。
4. 将必要条件和人工复核要求放在正文可见区域。
5. 选择明确行动入口,例如项目咨询、申请演示或下载规格。
6. 使用 `templates/product-one-pager.md` 起草,再进入视觉排版。
## 禁止事项
- 编造客户 Logo、案例、认证、排名或性能数字。
- 使用“领先、唯一、全面、百分之百”等无依据绝对化表述。
- 首屏写成实施教程、需求调查表或接口配置说明。
-`restricted``internal` 内容放入公开彩页。
- 用二维码或短链掩盖未经审核的外部地址。
## 完成标准
打印或 100% 缩放时仍可读;读者在一分钟内能说出产品定位、核心能力、适用范围
和下一步;所有数字和主张有证据;没有因压缩而删除关键限制。
@@ -0,0 +1,37 @@
# {{产品名称}}
## {{面向目标角色,提供具体产品能力}}
{{一句话说明产品类别、主要用途和适用范围。}}
### 目标场景
- {{角色需要完成的具体任务}}
- {{现有流程中的可验证问题}}
### 核心能力
| 能力 | 用户动作与结果 |
|---|---|
| {{能力一}} | {{动作;结果}} |
| {{能力二}} | {{动作;结果}} |
| {{能力三}} | {{动作;结果}} |
### 工作方式
{{一张架构图、流程图或三步工作流。}}
### 已核验参数或结果
- **{{数值}}**:{{指标、版本、条件与口径}}
- **{{数值}}**:{{指标、版本、条件与口径}}
### 部署与适用范围
{{部署方式、数据条件、依赖、限制和人工复核要求。}}
### 下一步
{{项目咨询 / 申请演示 / 获取技术规格}}
<!-- 内部追溯:列出本页使用的 FEAT/PAR/CLM/EVD ID,发布前删除此注释。 -->
@@ -0,0 +1,82 @@
---
name: product-presentation
version: 1.0.0
description: |
生成面向特定受众的产品介绍 PPT、逐页叙事和演讲备注。用于产品发布、客户宣讲、
售前交流或内部汇报;不负责现场产品操作步骤,操作型演示应使用 sales-demo-kit。
allowed-tools:
- Read
- Grep
- Glob
- Execute
compatibility: 生成 PPTX 需 python-pptx 1.0+
---
# 产品介绍 PPT
## 必要输入
- 已核验的产品事实、功能、参数、案例授权和适用边界。
- 受众角色、演示目标、场合、时长、页数和品牌要求。
- 可用图片、架构图、截图及其授权范围。
先写一句演示目标,例如“让技术负责人理解部署边界并同意进入 POC”,再决定页序。
## 推荐叙事
按任务选择,不机械套用全部页面:
1. 封面与本次议题。
2. 受众当前面临的具体问题。
3. 产品定位和适用范围。
4. 核心工作流或架构。
5. 关键能力,按场景组织,不按后台菜单朗读。
6. 已核验参数、兼容性或安全边界。
7. 获准公开的案例或验证结果。
8. 部署、交付和下一步。
## 页面纪律
- 一页只有一个结论,标题直接写该页内容。
- 正文优先 3 至 5 个要点,每个要点只表达一个信息。
- 数字必须带条件和来源,不使用无依据的百分比。
- 规划能力、试用能力和当前能力使用不同标识。
- 讲稿可以补充上下文,但不能引入幻灯片中没有依据的新事实。
- 不把产品介绍写成按钮操作手册,也不使用满页功能清单。
## 生成 PPTX
先探测可用的 Python 3 解释器:Windows 优先使用 `python`macOS/Linux
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
复制并填写 `templates/deck.example.json`
```bash
<python> -m pip install -r "<skill-dir>/requirements.txt"
<python> "<skill-dir>/scripts/build_pptx.py" \
--input ./build/deck.json \
--output ./dist/product-presentation.pptx
```
`deck.json` 与配图是中间产物,放在 `build/`;交付物 PPTX 放在 `dist/`。复核用
PDF 和页面截图一律写入 `build/check/`,不得与 PPTX 同目录。
构建器支持封面、章节页、要点页、双栏页、指标页、图片页和收尾页。它负责稳定
排版,不负责补写内容;`deck.json` 中的文字必须先通过事实审查。
除章节页外每页必须提供非空 `notes`,否则构建失败。图片必须放在 `deck.json`
所在目录内,超过 40MB 或 8000 万像素会被拒绝;其余图片按版面尺寸和 150 DPI
重采样后嵌入,避免生成超大文件。
## 视觉复核
1. 使用 LibreOffice 或 PowerPoint 打开并导出 PDF;该 PDF 仅用于复核,交付物是
PPTX,复核后应删除。LibreOffice 需安装 Impress 组件,仅装 Writer 时无法转换。
2. 检查文字溢出、孤行、图片拉伸、低清截图和字号过小。
3. 快速朗读全套讲稿,确认时间预算和页面转场自然。
4. 核对所有数字、版本、案例名称和产品状态与事实清单一致。
## 完成标准
PPTX 可打开;页数和时长符合简报;每页目标明确;视觉层级统一;备注完整;无
未经授权的客户信息、竞品结论或内部证据路径。
@@ -0,0 +1 @@
python-pptx>=1.0,<2
@@ -0,0 +1,590 @@
#!/usr/bin/env python3
"""Build a 16:9 product presentation from a reviewed deck JSON file."""
import argparse
import json
import re
import tempfile
from pathlib import Path
from PIL import Image, ImageOps
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.util import Inches, Pt
DEFAULT_THEME = {
"primary": "1F3864",
"accent": "C00000",
"background": "FFFFFF",
"text": "1A1A1A",
"muted": "666666",
"light": "F2F5F9",
"font_zh": "Microsoft YaHei",
"font_en": "Arial",
}
SLIDE_TYPES = {
"title",
"section",
"bullets",
"two-column",
"metrics",
"image",
"closing",
}
NOTES_OPTIONAL_TYPES = {"section"}
MAX_IMAGE_BYTES = 40 * 1024 * 1024
MAX_IMAGE_PIXELS = 80_000_000
RENDER_DPI = 150
def color(value):
value = value.lstrip("#")
if not re.fullmatch(r"[0-9a-fA-F]{6}", value):
raise ValueError(f"颜色必须是六位十六进制值:{value!r}")
return RGBColor.from_string(value.upper())
def nonempty(value, path):
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{path} 必须是非空字符串")
return value.strip()
def load_deck(path):
input_path = Path(path).expanduser().resolve()
with input_path.open(encoding="utf-8") as handle:
deck = json.load(handle)
if not isinstance(deck, dict):
raise ValueError("deck 根节点必须是对象")
nonempty(deck.get("title"), "title")
slides = deck.get("slides")
if not isinstance(slides, list) or not slides:
raise ValueError("slides 必须是非空数组")
theme = {**DEFAULT_THEME, **deck.get("theme", {})}
for key in ("primary", "accent", "background", "text", "muted", "light"):
color(theme[key])
for index, slide in enumerate(slides):
validate_slide(slide, index)
deck["theme"] = theme
return input_path, deck
def validate_bullets(values, path, maximum=6):
if not isinstance(values, list) or not values:
raise ValueError(f"{path} 必须是非空数组")
if len(values) > maximum:
raise ValueError(f"{path} 最多 {maximum}")
for index, value in enumerate(values):
text = nonempty(value, f"{path}[{index}]")
if len(text) > 120:
raise ValueError(f"{path}[{index}] 超过 120 字")
def validate_slide(slide, index):
path = f"slides[{index}]"
if not isinstance(slide, dict):
raise ValueError(f"{path} 必须是对象")
slide_type = slide.get("type")
if slide_type not in SLIDE_TYPES:
raise ValueError(f"{path}.type 未知:{slide_type!r}")
nonempty(slide.get("title"), f"{path}.title")
if slide_type in {"bullets", "closing"}:
validate_bullets(slide.get("bullets"), f"{path}.bullets")
elif slide_type == "two-column":
for side in ("left", "right"):
column = slide.get(side)
if not isinstance(column, dict):
raise ValueError(f"{path}.{side} 必须是对象")
nonempty(column.get("title"), f"{path}.{side}.title")
validate_bullets(
column.get("bullets"),
f"{path}.{side}.bullets",
maximum=5,
)
elif slide_type == "metrics":
metrics = slide.get("metrics")
if not isinstance(metrics, list) or not 1 <= len(metrics) <= 4:
raise ValueError(f"{path}.metrics 必须包含 1 至 4 项")
for metric_index, metric in enumerate(metrics):
if not isinstance(metric, dict):
raise ValueError(
f"{path}.metrics[{metric_index}] 必须是对象"
)
for field in ("value", "label", "detail"):
nonempty(
metric.get(field),
f"{path}.metrics[{metric_index}].{field}",
)
elif slide_type == "image":
nonempty(slide.get("image"), f"{path}.image")
if slide_type not in NOTES_OPTIONAL_TYPES:
nonempty(slide.get("notes"), f"{path}.notes")
def add_run_font(run, theme, size, bold=False, color_value=None):
run.font.name = theme["font_en"]
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = color(color_value or theme["text"])
run.font._element.set("lang", "zh-CN")
def add_textbox(
slide,
theme,
x,
y,
width,
height,
text="",
size=24,
bold=False,
color_value=None,
align=PP_ALIGN.LEFT,
vertical=MSO_ANCHOR.TOP,
margin=0.08,
):
shape = slide.shapes.add_textbox(
Inches(x),
Inches(y),
Inches(width),
Inches(height),
)
frame = shape.text_frame
frame.clear()
frame.margin_left = Inches(margin)
frame.margin_right = Inches(margin)
frame.margin_top = Inches(margin)
frame.margin_bottom = Inches(margin)
frame.vertical_anchor = vertical
paragraph = frame.paragraphs[0]
paragraph.alignment = align
run = paragraph.add_run()
run.text = text
add_run_font(run, theme, size, bold=bold, color_value=color_value)
return shape
def set_background(slide, theme, key="background"):
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = color(theme[key])
def add_header(slide, theme, title):
add_textbox(
slide,
theme,
0.65,
0.35,
11.9,
0.65,
title,
size=26,
bold=True,
color_value=theme["primary"],
)
line = slide.shapes.add_shape(
MSO_SHAPE.RECTANGLE,
Inches(0.65),
Inches(1.07),
Inches(1.15),
Inches(0.06),
)
line.fill.solid()
line.fill.fore_color.rgb = color(theme["accent"])
line.line.fill.background()
def add_footer(slide, deck, number):
footer = deck.get("footer", "")
theme = deck["theme"]
if footer:
add_textbox(
slide,
theme,
0.65,
7.08,
10.8,
0.22,
footer,
size=8.5,
color_value=theme["muted"],
)
add_textbox(
slide,
theme,
12.0,
7.02,
0.55,
0.25,
str(number),
size=9,
color_value=theme["muted"],
align=PP_ALIGN.RIGHT,
)
def add_bullet_frame(slide, theme, x, y, width, height, bullets, size=21):
shape = slide.shapes.add_textbox(
Inches(x),
Inches(y),
Inches(width),
Inches(height),
)
frame = shape.text_frame
frame.clear()
frame.word_wrap = True
frame.margin_left = Inches(0.12)
frame.margin_right = Inches(0.08)
for index, item in enumerate(bullets):
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
paragraph.text = item
paragraph.level = 0
paragraph.space_after = Pt(12)
paragraph.line_spacing = 1.12
paragraph.font.size = Pt(size)
paragraph.font.name = theme["font_en"]
paragraph.font.color.rgb = color(theme["text"])
return shape
def add_notes(slide, notes):
if not notes:
return
notes_frame = slide.notes_slide.notes_text_frame
notes_frame.text = notes
def render_title(slide, deck, item):
theme = deck["theme"]
set_background(slide, theme, "primary")
add_textbox(
slide,
theme,
0.9,
1.75,
11.5,
1.3,
item["title"],
size=34,
bold=True,
color_value="FFFFFF",
vertical=MSO_ANCHOR.MIDDLE,
)
subtitle = item.get("subtitle", deck.get("subtitle", ""))
if subtitle:
add_textbox(
slide,
theme,
0.95,
3.2,
10.8,
0.8,
subtitle,
size=20,
color_value="DCE6F1",
)
author = deck.get("author", "")
if author:
add_textbox(
slide,
theme,
0.95,
6.35,
10.0,
0.35,
author,
size=12,
color_value="DCE6F1",
)
def render_section(slide, deck, item):
theme = deck["theme"]
set_background(slide, theme, "light")
add_textbox(
slide,
theme,
1.0,
2.2,
11.2,
1.0,
item["title"],
size=32,
bold=True,
color_value=theme["primary"],
align=PP_ALIGN.CENTER,
vertical=MSO_ANCHOR.MIDDLE,
)
if item.get("subtitle"):
add_textbox(
slide,
theme,
1.5,
3.35,
10.2,
0.7,
item["subtitle"],
size=18,
color_value=theme["muted"],
align=PP_ALIGN.CENTER,
)
def render_bullets(slide, deck, item):
theme = deck["theme"]
set_background(slide, theme)
add_header(slide, theme, item["title"])
add_bullet_frame(slide, theme, 0.9, 1.45, 11.5, 5.2, item["bullets"])
def render_two_column(slide, deck, item):
theme = deck["theme"]
set_background(slide, theme)
add_header(slide, theme, item["title"])
for x, column in ((0.75, item["left"]), (6.78, item["right"])):
panel = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(x),
Inches(1.55),
Inches(5.55),
Inches(4.95),
)
panel.fill.solid()
panel.fill.fore_color.rgb = color(theme["light"])
panel.line.color.rgb = color("D8E0EA")
add_textbox(
slide,
theme,
x + 0.3,
1.8,
4.95,
0.5,
column["title"],
size=20,
bold=True,
color_value=theme["primary"],
)
add_bullet_frame(
slide,
theme,
x + 0.25,
2.5,
5.0,
3.55,
column["bullets"],
size=17,
)
def render_metrics(slide, deck, item):
theme = deck["theme"]
set_background(slide, theme)
add_header(slide, theme, item["title"])
metrics = item["metrics"]
gap = 0.25
total_width = 11.8
width = (total_width - gap * (len(metrics) - 1)) / len(metrics)
for index, metric in enumerate(metrics):
x = 0.75 + index * (width + gap)
panel = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(x),
Inches(1.75),
Inches(width),
Inches(4.45),
)
panel.fill.solid()
panel.fill.fore_color.rgb = color(theme["light"])
panel.line.color.rgb = color("D8E0EA")
add_textbox(
slide,
theme,
x + 0.15,
2.05,
width - 0.3,
1.0,
metric["value"],
size=29,
bold=True,
color_value=theme["accent"],
align=PP_ALIGN.CENTER,
vertical=MSO_ANCHOR.MIDDLE,
)
add_textbox(
slide,
theme,
x + 0.15,
3.15,
width - 0.3,
0.6,
metric["label"],
size=17,
bold=True,
color_value=theme["primary"],
align=PP_ALIGN.CENTER,
)
add_textbox(
slide,
theme,
x + 0.2,
4.0,
width - 0.4,
1.45,
metric["detail"],
size=12,
color_value=theme["muted"],
align=PP_ALIGN.CENTER,
)
def prepare_image(image_path, max_width_in, max_height_in, staging_dir):
"""Normalize orientation and cap pixels so decks stay a usable size."""
if image_path.stat().st_size > MAX_IMAGE_BYTES:
raise ValueError(
f"图片超过 {MAX_IMAGE_BYTES // (1024 * 1024)}MB{image_path.name}"
)
with Image.open(image_path) as image:
if image.width * image.height > MAX_IMAGE_PIXELS:
raise ValueError(f"图片像素数过大:{image_path.name}")
image = ImageOps.exif_transpose(image)
width, height = image.size
scale = min(max_width_in / width, max_height_in / height)
target = (
max(1, round(width * scale * RENDER_DPI)),
max(1, round(height * scale * RENDER_DPI)),
)
if target[0] >= width and target[1] >= height:
if image_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif"}:
return image_path, width, height
target = (width, height)
resized = image.resize(target, Image.LANCZOS)
if resized.mode not in ("RGB", "RGBA", "L"):
resized = resized.convert("RGBA")
staged = staging_dir / f"{image_path.stem}-{target[0]}x{target[1]}.png"
resized.save(staged, format="PNG", optimize=True)
return staged, width, height
def render_image(slide, deck, item, base_dir, staging_dir):
theme = deck["theme"]
set_background(slide, theme)
add_header(slide, theme, item["title"])
image_path = (base_dir / item["image"]).resolve()
if base_dir != image_path and base_dir not in image_path.parents:
raise ValueError(f"图片必须位于 deck.json 目录内:{image_path}")
if not image_path.is_file():
raise FileNotFoundError(f"图片不存在:{image_path}")
max_width, max_height = 11.5, 5.35
source, width, height = prepare_image(
image_path, max_width, max_height, staging_dir
)
scale = min(max_width / width, max_height / height)
draw_width, draw_height = width * scale, height * scale
slide.shapes.add_picture(
str(source),
Inches((13.333 - draw_width) / 2),
Inches(1.35 + (5.35 - draw_height) / 2),
width=Inches(draw_width),
height=Inches(draw_height),
)
if item.get("caption"):
add_textbox(
slide,
theme,
1.0,
6.55,
11.3,
0.3,
item["caption"],
size=10,
color_value=theme["muted"],
align=PP_ALIGN.CENTER,
)
def render_closing(slide, deck, item):
theme = deck["theme"]
set_background(slide, theme, "primary")
add_textbox(
slide,
theme,
0.9,
1.2,
11.5,
0.9,
item["title"],
size=32,
bold=True,
color_value="FFFFFF",
align=PP_ALIGN.CENTER,
)
add_bullet_frame(
slide,
{**theme, "text": "FFFFFF"},
2.0,
2.55,
9.3,
3.2,
item["bullets"],
size=20,
)
RENDERERS = {
"title": render_title,
"section": render_section,
"bullets": render_bullets,
"two-column": render_two_column,
"metrics": render_metrics,
"closing": render_closing,
}
def build_presentation(input_path, deck, output_path):
output = Path(output_path).expanduser().resolve()
if output.suffix.lower() != ".pptx":
raise ValueError("输出必须使用 .pptx 扩展名")
if output == input_path:
raise ValueError("输出不能覆盖 deck.json")
output.parent.mkdir(parents=True, exist_ok=True)
presentation = Presentation()
presentation.slide_width = Inches(13.333)
presentation.slide_height = Inches(7.5)
blank = presentation.slide_layouts[6]
with tempfile.TemporaryDirectory(prefix="deck-images-") as staging:
staging_dir = Path(staging)
for index, item in enumerate(deck["slides"], 1):
slide = presentation.slides.add_slide(blank)
if item["type"] == "image":
render_image(slide, deck, item, input_path.parent, staging_dir)
else:
RENDERERS[item["type"]](slide, deck, item)
if item["type"] != "title":
add_footer(slide, deck, index)
add_notes(slide, item.get("notes", ""))
presentation.save(output)
return output
def parse_args():
parser = argparse.ArgumentParser(description="从 deck.json 生成产品介绍 PPTX")
parser.add_argument("--input", required=True, help="deck.json")
parser.add_argument("--output", required=True, help="输出 .pptx")
return parser.parse_args()
def main():
args = parse_args()
input_path, deck = load_deck(args.input)
output = build_presentation(input_path, deck, args.output)
print(f"saved: {output}")
if __name__ == "__main__":
main()
@@ -0,0 +1,80 @@
{
"title": "示例产品介绍",
"subtitle": "面向目标受众的产品说明",
"author": "产品团队",
"footer": "示例产品",
"theme": {
"primary": "1F3864",
"accent": "C00000",
"background": "FFFFFF",
"text": "1A1A1A",
"font_zh": "Microsoft YaHei",
"font_en": "Arial"
},
"slides": [
{
"type": "title",
"title": "示例产品介绍",
"subtitle": "面向目标受众的产品说明",
"notes": "说明本次交流目标和议程。"
},
{
"type": "bullets",
"title": "目标场景与问题",
"bullets": [
"说明目标角色当前需要完成的任务",
"说明现有流程中的可验证问题",
"说明本次介绍覆盖和不覆盖的范围"
],
"notes": "不要使用宏大行业背景替代具体问题。",
"evidence_ids": [
"CLM-001"
]
},
{
"type": "two-column",
"title": "产品工作方式",
"left": {
"title": "输入与条件",
"bullets": [
"部署环境",
"数据与接口条件"
]
},
"right": {
"title": "动作与结果",
"bullets": [
"用户触发的具体动作",
"系统产生的可观察结果"
]
},
"notes": "结合当前版本说明工作流。"
},
{
"type": "metrics",
"title": "已核验指标",
"metrics": [
{
"value": "参数值",
"label": "指标名称",
"detail": "版本、条件和统计口径"
},
{
"value": "参数值",
"label": "指标名称",
"detail": "版本、条件和统计口径"
}
],
"notes": "只使用事实清单中有证据的指标。"
},
{
"type": "closing",
"title": "下一步",
"bullets": [
"确认适用场景和边界",
"确定验证材料与责任人"
],
"notes": "给出明确、可执行的后续动作。"
}
]
}
@@ -0,0 +1,147 @@
import copy
import importlib.util
import io
import json
import tempfile
import unittest
from pathlib import Path
from PIL import Image
from pptx import Presentation
SKILL_DIR = Path(__file__).resolve().parents[1]
SCRIPT = SKILL_DIR / "scripts" / "build_pptx.py"
SPEC = importlib.util.spec_from_file_location("build_pptx", SCRIPT)
build_pptx = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(build_pptx)
class BuildPptxTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
template = SKILL_DIR / "templates" / "deck.example.json"
cls.deck = json.loads(template.read_text(encoding="utf-8"))
def test_builds_example_deck(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
input_path = root / "deck.json"
input_path.write_text(
json.dumps(self.deck, ensure_ascii=False),
encoding="utf-8",
)
loaded_path, deck = build_pptx.load_deck(input_path)
output = build_pptx.build_presentation(
loaded_path,
deck,
root / "deck.pptx",
)
presentation = Presentation(output)
self.assertEqual(len(presentation.slides), len(self.deck["slides"]))
all_text = "\n".join(
shape.text
for slide in presentation.slides
for shape in slide.shapes
if hasattr(shape, "text")
)
self.assertIn("目标场景与问题", all_text)
self.assertIn("下一步", all_text)
for slide, item in zip(presentation.slides, self.deck["slides"]):
self.assertEqual(
slide.notes_slide.notes_text_frame.text.strip(),
item["notes"].strip(),
)
def test_requires_notes_outside_section_slides(self):
deck = copy.deepcopy(self.deck)
deck["slides"][1].pop("notes")
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "deck.json"
path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8")
with self.assertRaisesRegex(ValueError, r"slides\[1\]\.notes"):
build_pptx.load_deck(path)
def test_downsamples_large_image_slide(self):
deck = copy.deepcopy(self.deck)
deck["slides"].insert(
3,
{
"type": "image",
"title": "参考架构",
"image": "architecture.png",
"caption": "示例架构图",
"notes": "说明组件边界。",
},
)
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
Image.new("RGB", (5000, 3000), "white").save(root / "architecture.png")
path = root / "deck.json"
path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8")
loaded_path, loaded = build_pptx.load_deck(path)
output = build_pptx.build_presentation(
loaded_path, loaded, root / "deck.pptx"
)
presentation = Presentation(output)
self.assertEqual(len(presentation.slides), len(deck["slides"]))
pictures = [
shape
for slide in presentation.slides
for shape in slide.shapes
if shape.shape_type == 13
]
self.assertEqual(len(pictures), 1)
with Image.open(io.BytesIO(pictures[0].image.blob)) as embedded:
self.assertLess(embedded.width, 5000)
def test_rejects_image_outside_deck_directory(self):
deck = copy.deepcopy(self.deck)
deck["slides"].insert(
1,
{
"type": "image",
"title": "外部图片",
"image": "../outside.png",
"notes": "越界图片。",
},
)
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "deck"
root.mkdir()
Image.new("RGB", (10, 10), "white").save(Path(tmp) / "outside.png")
path = root / "deck.json"
path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8")
loaded_path, loaded = build_pptx.load_deck(path)
with self.assertRaisesRegex(ValueError, "必须位于"):
build_pptx.build_presentation(
loaded_path, loaded, root / "deck.pptx"
)
def test_rejects_too_many_bullets(self):
deck = copy.deepcopy(self.deck)
deck["slides"][1]["bullets"] = [f"项目 {index}" for index in range(7)]
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "deck.json"
path.write_text(
json.dumps(deck, ensure_ascii=False),
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "最多 6 项"):
build_pptx.load_deck(path)
def test_rejects_unknown_slide_type(self):
deck = copy.deepcopy(self.deck)
deck["slides"][0]["type"] = "unknown"
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "deck.json"
path.write_text(
json.dumps(deck, ensure_ascii=False),
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "未知"):
build_pptx.load_deck(path)
if __name__ == "__main__":
unittest.main()
@@ -1,38 +0,0 @@
---
id: project-planning
name: 项目规划
description: 将项目目标拆解为范围、里程碑、任务、责任、风险与验收标准,形成可执行计划。
version: 1.0.0
tags:
- 项目
- 计划
- 管理
---
# 项目规划
## 工作原则
- 先明确目标、成功标准、范围边界、约束和关键相关方。
- 不虚构资源、工期、预算或团队承诺。
- 计划应体现任务依赖、决策节点和必要缓冲。
- 风险需描述触发条件、影响、负责人和应对措施。
## 规划流程
1. 将项目目标转化为可验收的交付物。
2. 明确范围内事项、范围外事项和关键假设。
3. 拆解阶段、里程碑、任务及其依赖关系。
4. 为任务指定负责人、时间和完成标准(如已知)。
5. 评估风险、资源缺口、沟通机制和变更方式。
## 输出结构
- **项目概述:** 背景、目标与成功标准
- **范围:** 包含、不包含与假设
- **里程碑:** 交付物、目标日期与验收标准
- **任务计划:** 任务、负责人、依赖、时间与状态
- **风险登记:** 风险、概率、影响与应对
- **治理机制:** 汇报节奏、决策人与变更流程
未知信息标注“待确认”,并说明其对计划可靠性的影响。
-35
View File
@@ -1,35 +0,0 @@
---
id: proofreading
name: 文本校对
description: 系统检查文本的错别字、语法、标点、格式与一致性,并在不改变原意的前提下提出修订。
version: 1.0.0
tags:
- 校对
- 编辑
- 质量
---
# 文本校对
## 工作原则
- 以保留作者原意、事实和语气为首要目标。
- 区分确定错误、风格建议和需要作者确认的内容。
- 不擅自改动数字、专有名词、引用、承诺或结论。
- 修改应保持全文术语、格式和标点规则一致。
## 校对流程
1. 确认文本用途、目标读者和采用的语言规范。
2. 检查错别字、语法、搭配、标点和病句。
3. 检查标题层级、编号、空格、日期与数字格式。
4. 检查术语、人名、缩写和指代的一致性。
5. 复核修改是否引入新歧义或改变原意。
## 输出方式
- **清洁版:** 已修正明确错误的完整文本。
- **修改说明:** 汇总影响含义或结构的主要调整。
- **待确认项:** 列出歧义、事实疑点或多种可接受写法。
纯风格调整应克制;如用户只要求找错,不主动重写全文。
@@ -1,39 +0,0 @@
---
id: requirements-analysis
name: 需求分析
description: 将业务诉求整理为边界明确、可验证、可追踪的需求,识别歧义、依赖与验收条件。
version: 1.0.0
tags:
- 需求
- 分析
- 验收
---
# 需求分析
## 工作原则
- 区分业务目标、用户问题、解决方案设想和正式需求。
- 不替相关方决定未确认的优先级、范围或业务规则。
- 每项需求应明确对象、触发条件、预期行为和验收结果。
- 主动识别歧义、冲突、异常场景、依赖与非功能要求。
## 分析流程
1. 明确目标用户、业务目标和衡量成功的指标。
2. 梳理现状、痛点、范围边界与关键术语。
3. 将诉求拆成独立、可验证的功能需求。
4. 补充权限、数据、性能、可用性和合规等约束。
5. 定义验收标准,并建立需求与目标的对应关系。
## 输出结构
- **背景与目标:** 问题、用户与预期价值
- **范围:** 包含、不包含与假设
- **功能需求:** 编号、描述、优先级与依赖
- **业务规则:** 条件、例外与边界
- **非功能需求:** 质量属性与约束
- **验收标准:** 可观察、可判断的结果
- **待确认问题:** 歧义、冲突与决策人
所有推断均标注为“假设”,未经确认不得写成既定要求。
@@ -1,38 +0,0 @@
---
id: research-synthesis
name: 研究综合
description: 综合用户提供的研究材料,比较观点与证据,形成可追溯、平衡且边界清晰的结论。
version: 1.0.0
tags:
- 研究
- 综合
- 证据
---
# 研究综合
## 工作原则
- 仅综合用户提供的材料,不声称查阅了未提供的信息。
- 清楚区分材料中的事实、作者观点、推论和自身归纳。
- 保留来源标识,使关键结论可追溯到具体材料。
- 同时呈现一致观点、分歧、证据缺口和适用边界。
## 综合流程
1. 明确研究问题、范围和评价标准。
2. 按主题整理各材料的主张、证据与方法。
3. 比较一致性、冲突点、证据强弱和时间适用性。
4. 提炼跨材料模式,并检查是否存在反例。
5. 形成有限度的结论及进一步研究问题。
## 输出结构
- **研究问题:** 范围与目标
- **材料概览:** 每份材料的主题与证据类型
- **主题综合:** 共识、差异与关联
- **证据评估:** 强项、局限与潜在偏差
- **综合结论:** 结论、置信边界与适用条件
- **待研究问题:** 现有材料无法回答的事项
引用或转述时保留用户材料中的来源名称,不伪造出处。
+58
View File
@@ -0,0 +1,58 @@
---
name: sales-demo-kit
version: 1.0.0
description: |
生成可执行的产品演示故事线、环境清单、操作脚本、讲解词、失败回退和演练检查表。
用于售前 Demo、POC 汇报或验收演示;不负责制作通用产品介绍 PPT。
allowed-tools:
- Read
- Grep
- Glob
- Execute
compatibility: Markdown/JSON;演示自动化需另行使用可用的控制工具
---
# 售前演示套件
## 必要输入
- 演示目标、受众、时长和希望获得的下一步。
- 可用产品环境、版本、账号角色、测试数据和网络限制。
- 允许展示的功能、参数、客户数据和日志范围。
- 已知不稳定点、恢复方法和备用材料。
不得把真实密码、令牌、私有主机名或客户数据写进演示包。
## 设计演示
1. 选择一个完整用户任务作为主线,不按菜单逐页点功能。
2. 为每一步写前置状态、操作、预期结果、讲解重点和事实 ID。
3. 明确哪些结果实时产生,哪些是预置数据或截图。
4. 为外部依赖、模型不稳定、网络异常和数据污染准备回退。
5. 定义演示结束后的环境重置步骤。
6. 将可选深挖问题放入问答分支,不打断主流程时间预算。
## 输出
复制 `templates/demo-kit.md`,形成:
- 演示故事线和时间分配。
- 环境、账号角色和测试数据清单。
- 逐步运行手册。
- 讲解词与常见问答。
- 失败回退、备用截图和重置步骤。
- 演练与现场检查表。
## 演练门禁
- 在与现场相同版本和权限下完整跑通至少一次。
- 每一步的预期结果可观察、可截图、可恢复。
- 不依赖浏览器历史、个人缓存或未记录的人工准备。
- 所有示例数据可公开或已匿名化。
- 规划能力不得通过预制截图伪装成实时功能。
- 讲解数字和产品介绍 PPT、技术方案保持一致。
## 完成标准
演示能在时间预算内重复执行;关键步骤有备用路径;失败不会暴露敏感信息或破坏
环境;操作脚本、讲解词和事实证据一致;现场人员知道何时停止、切换备用和重置。
@@ -0,0 +1,49 @@
# {{产品/场景}}演示套件
## 一、演示目标
- 受众:{{角色}}
- 目标:{{希望受众理解或同意的事项}}
- 时长:{{分钟}}
- 产品版本:{{版本}}
- 不展示范围:{{范围}}
## 二、环境与数据
| 项目 | 要求 | 检查方法 | 状态 |
|---|---|---|---|
| 演示环境 | {{环境,不写真实密钥}} | {{检查命令或页面}} | 待检查 |
| 账号角色 | {{角色}} | {{权限确认}} | 待检查 |
| 示例数据 | {{匿名数据集}} | {{完整性检查}} | 待检查 |
## 三、演示故事线
| 时间 | 阶段 | 目的 | 讲解重点 |
|---:|---|---|---|
| 0-2 分钟 | 场景说明 | {{目的}} | {{重点}} |
## 四、逐步运行手册
### STEP-01 {{步骤名称}}
- 前置状态:{{状态}}
- 操作:{{具体动作}}
- 预期结果:{{可观察结果}}
- 事实依据:{{FEAT/CLM ID}}
- 讲解词:{{简短讲解}}
- 失败判断:{{何时判定失败}}
- 回退:{{备用页面、截图或替代步骤}}
## 五、常见问答
| 问题 | 回答要点 | 依据 | 不应承诺 |
|---|---|---|---|
| {{问题}} | {{回答}} | {{证据 ID}} | {{边界}} |
## 六、重置与现场检查
- [ ] 清理上次演示数据。
- [ ] 恢复初始账号和权限。
- [ ] 验证网络、依赖和备用材料。
- [ ] 确认屏幕无通知、密钥和客户信息。
- [ ] 完整计时演练并记录问题。
@@ -0,0 +1,62 @@
---
name: solution-whitepaper
version: 1.0.0
description: |
编写解释行业问题、技术原理、参考架构、实现方法、测试证据和适用边界的产品或
解决方案白皮书。用于技术传播和决策评估;不编制客户项目计划,也不把宣传口号
当作技术论证。
allowed-tools:
- Read
- Grep
- Glob
- Execute
compatibility: Markdown;可配合 longdoc-docx 导出 WordPDF 仅用于核验)
---
# 解决方案白皮书
## 必要输入
- 白皮书主题、目标读者、研究问题和发布范围。
- 产品事实、技术来源、测试报告、标准和第三方参考。
- 可公开的架构图、数据、案例和限制。
- 引用格式、目标篇幅和评审要求。
## 论证结构
1. 明确问题范围,不用泛化行业背景凑篇幅。
2. 定义术语、对象和评价标准。
3. 解释方法、原理和参考架构。
4. 用产品实现或参考流程说明方法如何落地。
5. 给出测试方法、条件、结果和不确定性。
6. 说明安全、部署、治理和人工复核边界。
7. 总结适用场景、限制和后续研究,不做空洞升华。
使用 `templates/solution-whitepaper.md` 建立章节。
## 证据规则
- 标准、论文、第三方观点和产品事实分开引用。
- 指标必须说明样本、版本、环境、周期和计算方法。
- 实测结果、设计目标和规划能力使用不同标签。
- 无法访问原始来源时标记二手来源,不把摘要转述成原始结论。
- 第三方图表必须检查许可并保留出处。
- 参考文献编号、正文引用和图表来源必须一一对应。
## 写作规则
- 标题说明对象或结论范围,不写“我们如何理解”“结果说明了什么”。
- 先给定义和条件,再给结论。
- 架构章节解释边界与数据流,不罗列产品菜单。
- 限制章节必须保留,不能在营销审校时被删除。
- 技术白皮书可以有观点,但必须区分事实、推断和建议。
## 导出
如已安装 `deai-writing`,在最终通读前扫描中文套路表达;如已安装
`longdoc-docx`,用其生成 DOCX 并借助临时 PDF 执行高分辨率视觉复核。
## 完成标准
研究问题得到回答;术语统一;关键结论有来源;测试可复核;架构图与正文一致;
限制、依赖和适用范围完整;参考文献无缺失、重复或无法定位条目。
@@ -0,0 +1,42 @@
# {{白皮书标题}}
## 摘要
{{研究对象、问题、方法、主要结论和适用边界。}}
## 1. 问题范围与目标读者
## 2. 术语、对象与评价标准
| 术语 | 定义 | 范围 |
|---|---|---|
| {{术语}} | {{定义}} | {{适用范围}} |
## 3. 方法与技术原理
## 4. 参考架构与关键数据流
## 5. 产品实现与典型工作流
## 6. 测试方法与结果
### 6.1 测试环境
### 6.2 数据、样本与统计口径
### 6.3 测试结果
### 6.4 不确定性与结果解释
## 7. 安全、治理与部署考虑
## 8. 适用场景与限制
## 9. 结论
## 参考文献
1. {{作者/机构}},《{{标题}}》,{{版本或日期}},{{来源定位}}。
## 内部主张追溯
| 章节 | 主张 ID | 证据 ID | 公开级别 | 复核 |
|---|---|---|---|---|
| {{章节}} | {{CLM-001}} | {{EVD-001}} | public | 通过 |
@@ -1,38 +0,0 @@
---
id: spreadsheet-analysis
name: 表格分析
description: 基于用户提供的表格内容规划分析方法,识别数据质量问题并形成可解释的业务结论。
version: 1.0.0
tags:
- 表格
- 数据分析
- 洞察
---
# 表格分析
## 工作原则
- 先确认分析目标、字段含义、时间范围、单位和统计口径。
- 不猜测缺失值、异常值或字段关系,不将相关性表述为因果性。
- 明确区分原始数据、计算结果、解释和建议。
- 涉及个人或敏感数据时,建议最小化使用并进行脱敏。
## 分析流程
1. 盘点工作表、字段、数据类型与记录范围。
2. 检查缺失、重复、异常、口径冲突和格式不一致。
3. 根据问题选择汇总、分组、对比、趋势或分布分析。
4. 记录计算定义、筛选条件和必要假设。
5. 提炼证据充分的发现、局限与后续验证建议。
## 输出结构
- **分析目标:** 要回答的业务问题
- **数据概况:** 范围、字段、口径与质量
- **分析方法:** 分组维度、指标定义与假设
- **关键发现:** 结论及对应证据
- **限制与风险:** 数据不足或偏差来源
- **建议:** 可验证、可执行的下一步
对无法从现有数据支持的结论,应明确说明“证据不足”。
@@ -0,0 +1,74 @@
---
name: technical-proposal
version: 1.0.0
description: |
基于客户需求、产品事实和项目约束编制完整技术方案,覆盖需求分析、总体架构、
详细设计、实施、交付、质量、安全、风险和验收。用于投标技术方案或客户解决
方案;不负责制定招标参数,也不替代逐条招标响应矩阵。
allowed-tools:
- Read
- Grep
- Glob
- Execute
compatibility: Markdown;可配合 longdoc-docx 导出 WordPDF 仅用于核验)
---
# 技术方案
## 必要输入
- 客户需求原文及编号、评分点或验收目标。
- 产品事实与证据、功能状态、参数和限制。
- 部署环境、现有系统、接口、数据、安全和合规约束。
- 项目范围、责任边界、计划、交付物和非范围项。
不确定内容进入“假设与待确认事项”,不能默认为客户已具备或产品已支持。
## 编制顺序
1. 建立需求追溯表,为每项需求分配稳定编号。招标场景下
`tender-response-matrix` 是需求编号、响应状态和偏离结论的唯一权威来源,
本技能只派生视图,不得另建一套编号或改写其状态。
2. 区分业务目标、功能需求、非功能需求、接口约束和验收要求。
3. 先确定范围、假设和总体架构,再展开模块设计。
4. 对每个设计说明采用的产品能力、依赖条件和限制。
5. 将设计落实到实施任务、交付物、质量措施和验收方法。
6. 最后编制摘要,不能先写宣传性摘要再反推正文。
## 章节建议
复制 `templates/technical-proposal.md`,按项目裁剪:
- 方案摘要
- 项目理解与需求分析
- 范围、假设与责任边界
- 总体技术架构与数据流
- 详细功能和接口设计
- 部署、安全、性能和运维设计
- 实施计划、组织和质量保证
- 交付物、培训和知识转移
- 验收方法、风险和偏离说明
- 需求追溯矩阵
## 写作门禁
- 需求 → 设计 → 产品能力 → 交付物 → 验收方法必须可追溯。
- 规划能力必须使用将来时,并说明是否属于本项目交付范围。
- 架构图与正文必须使用相同组件名称和边界。
- 不把客户责任、第三方依赖或人工复核要求隐藏在脚注中。
- 不编造团队人数、工期、性能、案例、资质或承诺。
- 方案正文以系统和动作陈述为主,减少“我方/我们”堆叠。
## 导出与审校
如已安装相关技能:
1.`deai-writing` 扫描并定向改写 Markdown。
2.`longdoc-docx` 生成 DOCX,并借助临时 PDF 做空白页、乱码和 300 DPI 视觉复核。
未安装时仍应交付结构完整、可追溯的 Markdown。
## 完成标准
需求无遗漏;架构、功能、实施、交付和验收闭合;事实与产品版本一致;图表和编号
连续;假设、偏离、风险和非范围项明确;所有数字和承诺可定位到输入依据。
@@ -0,0 +1,71 @@
# {{项目名称}}技术方案
## 方案摘要
{{项目目标、方案范围、核心路径和验收结果,完成正文后编写。}}
## 一、项目理解与需求分析
### 1. 业务目标
### 2. 需求分类
| 需求 ID | 原始要求 | 类型 | 关键约束 | 验收目标 |
|---|---|---|---|---|
| REQ-001 | {{原文}} | 功能 | {{约束}} | {{可验证结果}} |
## 二、范围、假设与责任边界
### 1. 项目范围
### 2. 非范围项
### 3. 假设与待确认事项
### 4. 双方及第三方责任
## 三、总体技术方案
### 1. 总体架构
### 2. 组件职责与边界
### 3. 关键数据流
### 4. 部署与集成关系
## 四、详细设计
### 1. {{能力模块}}
- 对应需求:{{REQ-001}}
- 产品能力:{{FEAT-001 / CLM-001}}
- 处理流程:{{输入、动作、输出}}
- 依赖与限制:{{条件}}
- 验收方法:{{步骤和证据}}
## 五、非功能设计
### 1. 安全与审计
### 2. 性能与容量
### 3. 可用性、备份与恢复
### 4. 兼容性与可维护性
## 六、实施、质量与交付
### 1. 实施阶段与里程碑
### 2. 项目组织与沟通
### 3. 质量保证与变更管理
### 4. 交付物、培训与知识转移
## 七、验收、风险与偏离
### 1. 验收方案
### 2. 风险及应对
### 3. 技术偏离
## 八、需求追溯矩阵
| 需求 ID | 方案章节 | 产品事实 ID | 交付物 | 验收方法 | 状态 |
|---|---|---|---|---|---|
| REQ-001 | {{章节}} | {{FEAT-001}} | {{交付物}} | {{方法}} | 已覆盖 |
@@ -0,0 +1,61 @@
---
name: tender-response-matrix
version: 1.0.0
description: |
将招标文件技术要求逐条拆解并生成符合性响应矩阵、缺口清单和方案章节映射。用于
投标前要求解析、响应检查和技术偏离管理;不制定采购参数,也不代写整篇方案。
allowed-tools:
- Read
- Grep
- Glob
compatibility: Markdown/CSV;建议配合 product-evidence
---
# 招标响应矩阵
## 必要输入
- 招标文件原文及可定位的章节、页码或条款编号。
- 产品事实、参数、证据、限制和规划能力。
- 拟提交技术方案的章节结构。
- 招标方规定的“响应、偏离、证明材料”格式。
## 解析规则
1. 保留原始条款,不用概括替代原文。
2. 将一条中多个独立判定条件拆成子要求,同时保留父条款关系。
3. 标记要求类型:功能、参数、接口、安全、服务、交付、验收或商务边界。
4. 识别强制词、阈值、证明材料、评分点和截止条件。
5. 每项只允许以下状态:
- `compliant`:完全满足且有证据。
- `partial`:仅部分满足或有范围限制。
- `not-compliant`:当前不能满足。
- `clarification-required`:原文歧义或缺少必要输入。
6. `planned` 能力不能用于判定当前 `compliant`,除非招标明确允许项目期内交付。
## 输出
使用 `templates/tender-response-matrix.md` 生成:
- 逐条响应矩阵。
- 技术偏离和澄清清单。
- 证明材料清单。
- 要求到方案章节、产品事实和验收方法的映射。
## 响应纪律
- “完全响应”必须有事实 ID、证据 ID和方案位置。
- 原文要求高于已知产品能力时如实标记偏离,不得弱化原文。
- 响应说明写具体实现、范围和条件,不重复“满足、响应”。
- 尚未定稿的承诺标明审批责任人和截止时间,不能进入最终交付版。
- 招标文件中的客户名称、项目编号和保密内容不得进入可复用技能模板。
## 两阶段使用
1. **方案编制前**:识别缺口,决定方案结构和需补充的证据。
2. **方案完成后**:回填最终章节号、证明材料和验收方法,检查是否遗漏。
## 完成标准
原始要求覆盖率 100%;每个 `compliant` 有证据;偏离与澄清未被隐藏;条款编号、
阈值和方案引用准确;矩阵与最终技术方案使用相同版本和术语。
@@ -0,0 +1,28 @@
# {{项目名称}}技术响应矩阵
## 一、逐条响应
| 要求 ID | 原条款位置 | 原文 | 类型 | 强制/评分 | 响应状态 | 具体响应 | 条件/偏离 | 事实与证据 | 方案章节 | 验收方法 |
|---|---|---|---|---|---|---|---|---|---|---|
| REQ-001 | {{章节/页码}} | {{完整原文}} | 功能 | 强制 | compliant | {{具体能力与范围}} | 无 | {{FEAT-001 / EVD-001}} | {{3.1}} | {{操作或材料核验}} |
## 二、偏离与澄清
| 要求 ID | 状态 | 问题 | 影响 | 建议处理 | 责任人 | 截止时间 |
|---|---|---|---|---|---|---|
| REQ-002 | clarification-required | {{歧义或缺失信息}} | {{影响}} | {{澄清问题}} | {{责任人}} | {{日期}} |
## 三、证明材料
| 材料 ID | 材料名称 | 对应要求 | 来源 | 公开级别 | 是否齐备 |
|---|---|---|---|---|---|
| MAT-001 | {{测试报告/证书/截图}} | REQ-001 | {{EVD-001}} | restricted | 是 |
## 四、覆盖统计
- 原始要求数:{{数量}}
- 已拆分子要求数:{{数量}}
- 完全满足:{{数量}}
- 部分满足:{{数量}}
- 不满足:{{数量}}
- 待澄清:{{数量}}
@@ -0,0 +1,68 @@
---
name: tender-technical-spec
version: 1.0.0
description: |
将已核验产品能力转写为可采购、可测试、可验收的招标技术规格和参数表。用于编制
招标文件技术要求、采购参数或技术规格书;不负责判断投标方是否符合,也不负责
撰写整篇投标技术方案。
allowed-tools:
- Read
- Grep
- Glob
compatibility: Markdown;建议配合 product-evidence
---
# 招标技术参数
## 必要输入
- 产品事实与证据清单,尤其是功能、参数、限制、部署和兼容性。
- 本次采购范围、部署规模、适用环境和验收阶段。
- 强制项、推荐项和可选项的标记规则。
- 是否允许品牌、专利或特定实现方式出现在参数中。
## 编制原则
1. 参数描述采购目标和可验证结果,避免锁定非必要的内部实现。
2. 每项只表达一个可判定要求,不能把多个条件塞入一行。
3. 数值必须包含单位、适用版本、测试条件和统计口径。
4. 使用“应、须、不得”表示强制要求,“宜、可”表示推荐或可选要求。
5. 为每项定义验收方法和所需证据,避免“支持、具备、先进”等无法判定的表述。
6. 无证据或需采购方确认的内容进入待确认表,不得补造门槛值。
7. 安全、兼容性、部署和服务要求分别成组,不能混入功能参数。
## 参数分类
- 总体与部署
- 功能能力
- 接口与集成
- 性能与容量
- 安全与审计
- 兼容性与信创环境
- 运维、备份与升级
- 服务、培训与交付
- 验收与材料
只保留与本次采购目标有关的分类。
## 输出
复制 `templates/tender-technical-spec.md`,生成:
1. 技术规格正文。
2. 可机读或可复制到表格的参数明细。
3. 待确认参数与风险清单。
4. 参数到事实证据的内部追溯表。
## 风险检查
- 是否把规划能力写成强制现有参数。
- 是否为体现“先进”而编造性能阈值。
- 是否把特定品牌或架构写成唯一实现路径,造成不必要排他性。
- 是否存在无法复现的“高、快、强、稳定”等主观指标。
- 是否遗漏测试数据、环境、并发模型、持续时间或误差范围。
## 完成标准
每项要求具备唯一编号、级别、参数内容、适用条件、验收方法和证据;全文无互相
冲突的阈值;待确认项没有混入正式参数;公开与保密边界符合输入约束。
@@ -0,0 +1,32 @@
# {{项目名称}}招标技术规格
## 一、采购范围与适用条件
- 采购对象:{{产品/服务范围}}
- 部署环境:{{环境}}
- 适用版本:{{版本}}
- 验收阶段:{{阶段}}
## 二、技术参数
| 编号 | 分类 | 级别 | 技术要求 | 条件与口径 | 验收方法 | 证据/材料 |
|---|---|---|---|---|---|---|
| TP-001 | 功能能力 | 强制 | 系统应{{可验证行为}} | {{版本、环境或前提}} | {{操作、测量或材料审查}} | {{EVD-001}} |
## 三、交付与服务要求
| 编号 | 级别 | 要求 | 验收方式 |
|---|---|---|---|
| SV-001 | 强制 | {{交付物、培训或服务要求}} | {{材料或现场验收}} |
## 四、待确认事项
| 编号 | 待确认内容 | 缺少依据 | 责任方 | 截止时间 |
|---|---|---|---|---|
| TBD-001 | {{参数或范围}} | {{需要补充的证据}} | {{责任方}} | {{日期}} |
## 五、内部追溯表
| 参数编号 | 事实/主张 ID | 证据 ID | 复核结论 |
|---|---|---|---|
| TP-001 | {{CLM-001/PAR-001}} | {{EVD-001}} | {{通过/待确认}} |
@@ -1,36 +0,0 @@
---
id: translation-polish
name: 翻译润色
description: 在忠实保留原意、事实与格式的前提下完成翻译或润色,使表达自然、专业且符合目标语境。
version: 1.0.0
tags:
- 翻译
- 润色
- 语言
---
# 翻译润色
## 工作原则
- 确认源语言、目标语言、读者、场景、语气和术语偏好。
- 忠实保留事实、数字、日期、专有名词与不确定性。
- 不擅自增删立场、承诺、限定条件或法律含义。
- 对多义词、文化特定表达和术语冲突标注备选译法。
## 处理流程
1. 理解全文目的、上下文和语域。
2. 建立关键术语及固定译法。
3. 逐段转换含义,优先保证准确与连贯。
4. 调整句式、语气和标点,使目标语言自然。
5. 对照原文复核遗漏、误译、数字和格式。
## 输出方式
- 默认提供润色后的完整文本。
- 存在关键歧义时,附“译法说明”与简短理由。
- 用户要求对照时,按段落展示原文与译文。
- 无法确认的术语或专名保留原文并标记“待确认”。
对于合同、医疗或其他高风险文本,应提醒用户进行专业复核。
-47
View File
@@ -1,47 +0,0 @@
---
id: weekly-report
name: 周报整理
description: 将零散工作记录整理为结果导向的周报,呈现进展、价值、风险与下周计划。
version: 1.0.0
tags:
- 周报
- 汇报
- 进展
---
# 周报整理
## 工作原则
- 优先呈现已完成结果及其影响,而非简单罗列活动。
- 仅使用用户提供的数据,不夸大进度、效果或完成度。
- 明确区分已完成、进行中、受阻和计划事项。
- 风险描述应客观,并给出已知的应对方案或支持需求。
## 整理流程
1. 按目标或项目归类本周记录。
2. 将过程描述改写为“行动—结果—影响”。
3. 提取里程碑、关键数据、风险和依赖。
4. 按优先级排列下周计划。
5. 检查时间范围、状态和数据口径是否一致。
## 输出模板
### 本周成果
- 目标、完成结果及业务或团队影响。
### 进行中事项
- 当前状态、下一步与预计节点(如已知)。
### 风险与支持需求
- 风险、影响、应对措施和所需支持。
### 下周计划
- 按优先级列出目标、交付物与关键节点。
不确定的信息标注“待确认”,避免使用模糊的完成度表述。
+10 -16
View File
@@ -17,33 +17,27 @@ python -m http.server 4173 --bind 127.0.0.1 --directory sites
```powershell ```powershell
node sites/scripts/validate.mjs node sites/scripts/validate.mjs
node --check sites/app.js node --check sites/app.js
node --check sites/site.config.js
``` ```
校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式规则,以及未发布状态下的下载链接保护 校验脚本会检查必需文件、页内链接、本地资源、关键产品文案、主题与响应式规则,以及下载入口是否始终指向官方最新 Release
## Release 配置 ## 下载入口
当前版本的 Release 地址集中在 `site.config.js`,版本号必须与根目录 官网正文不展示具体版本号,所有下载入口直接指向 GitHub 最新正式
`package.json` 保持一致 Release
```js ```text
window.GOODBUDDY_SITE_CONFIG = Object.freeze({ https://github.com/mesalogo/goodbuddy/releases/latest
version: "0.8.1",
releasePublished: true,
releaseUrl: "https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.1",
});
``` ```
准备尚未发布的版本时,将 `releasePublished` 暂时设为 `false`;正式 新版本发布后 GitHub 会自动更新该地址的目标,官网无需同步修改版本号
Release 确认发布后改回 `true`,页面上的下载入口才会指向 Release 或安装资产名称。用户在 Release 页面按系统与架构选择文件并核对
页面。官网不配置或猜测具体安装资产名称 SHA-256 清单
## 文件 ## 文件
- `index.html`:页面结构与简体中文内容 - `index.html`:页面结构与简体中文内容
- `styles.css`:语义令牌、浅深主题、焦点与响应式布局 - `styles.css`:语义令牌、浅深主题、焦点与响应式布局
- `app.js`:主题、移动导航当前章节和 Release 状态 - `app.js`:主题、移动导航当前章节
- `site.config.js`:版本与未来 Release 地址
- `assets/favicon.svg`:站点图标 - `assets/favicon.svg`:站点图标
- `scripts/validate.mjs`:无依赖静态检查 - `scripts/validate.mjs`:无依赖静态检查
-35
View File
@@ -8,7 +8,6 @@
const themeToggle = document.querySelector("[data-theme-toggle]"); const themeToggle = document.querySelector("[data-theme-toggle]");
const themeColor = document.querySelector('meta[name="theme-color"]'); const themeColor = document.querySelector('meta[name="theme-color"]');
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
const config = window.GOODBUDDY_SITE_CONFIG;
const getSavedTheme = () => { const getSavedTheme = () => {
try { try {
@@ -46,41 +45,7 @@
header?.classList.toggle("is-scrolled", window.scrollY > 12); header?.classList.toggle("is-scrolled", window.scrollY > 12);
}; };
const configureReleaseLinks = () => {
const releaseLinks = document.querySelectorAll("[data-release-link]");
const hasValidVersion =
typeof config?.version === "string" &&
/^\d+\.\d+\.\d+$/.test(config.version);
const expectedReleaseUrl = hasValidVersion
? `https://github.com/mesalogo/goodbuddy/releases/tag/v${config.version}`
: "";
const isReady =
config?.releasePublished === true &&
typeof config.releaseUrl === "string" &&
config.releaseUrl === expectedReleaseUrl;
releaseLinks.forEach((link) => {
if (!isReady) {
link.removeAttribute("href");
link.removeAttribute("target");
link.removeAttribute("rel");
link.setAttribute("aria-disabled", "true");
link.classList.add("is-disabled");
link.textContent = "发布后开放";
return;
}
link.href = config.releaseUrl;
link.target = "_blank";
link.rel = "noreferrer";
link.removeAttribute("aria-disabled");
link.classList.remove("is-disabled");
link.innerHTML = `前往 v${config.version} Release<span class="sr-only">(在新窗口打开)</span>`;
});
};
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light")); applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
configureReleaseLinks();
setHeaderState(); setHeaderState();
themeToggle?.addEventListener("click", () => { themeToggle?.addEventListener("click", () => {
+111 -90
View File
@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta <meta
name="description" name="description"
content="GoodBuddy 是安全可控的桌面智能助手与 Agent 工作空间。0.8.1 改进语音反馈、文档附件与桌面交互稳定性。" content="GoodBuddy 是桌面 AI 助手,支持项目知识库、魔法笔记、远程消息通道和受控工具执行。"
/> />
<meta name="theme-color" content="#f6f8fb" /> <meta name="theme-color" content="#f6f8fb" />
<title>GoodBuddy安全可控的桌面智能助手</title> <title>GoodBuddy桌面 AI 助手</title>
<link rel="icon" href="./assets/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="./assets/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="./styles.css" /> <link rel="stylesheet" href="./styles.css" />
<script> <script>
@@ -57,7 +57,7 @@
<nav class="site-navigation" id="site-navigation" aria-label="主导航" data-navigation> <nav class="site-navigation" id="site-navigation" aria-label="主导航" data-navigation>
<a href="#features">功能</a> <a href="#features">功能</a>
<a href="#release">0.8.0</a> <a href="#release">亮点</a>
<a href="#download">下载</a> <a href="#download">下载</a>
<a href="#security">安全</a> <a href="#security">安全</a>
</nav> </nav>
@@ -91,33 +91,35 @@
<div class="hero-copy"> <div class="hero-copy">
<div class="eyebrow"> <div class="eyebrow">
<span class="status-dot" aria-hidden="true"></span> <span class="status-dot" aria-hidden="true"></span>
GoodBuddy 0.8.1 已发布 桌面 AI 助手
</div> </div>
<h1 id="hero-title">把 AI 放在桌面,<br /><span>也把控制权留在手中</span></h1> <h1 id="hero-title">在桌面上使用 AI<br /><span>工作过程看得见</span></h1>
<p class="hero-lead"> <p class="hero-lead">
GoodBuddy 是安全可控的桌面智能助手与 Agent 工作空间。连接模型、知识与工具 GoodBuddy 可以连接模型、知识库和工具。知识按全局或项目管理
在清晰的范围和审批边界内完成真正的工作 工具执行前可以确认,运行记录随时可查
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<a class="button button--primary" href="#release">查看 0.8.0 亮点</a> <a class="button button--primary" href="#features">查看功能</a>
<a <a
class="button button--secondary is-disabled" class="button button--secondary"
aria-disabled="true" href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link data-release-link
>发布后开放</a> >前往官方下载页<span class="sr-only">(在新窗口打开)</span></a>
</div> </div>
<ul class="hero-facts" aria-label="产品特性概览"> <ul class="hero-facts" aria-label="产品特性概览">
<li> <li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg> <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
Windows / macOS / Linux 支持 Windows / macOS / Linux
</li> </li>
<li> <li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg> <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
项目范围隔离 全局和项目知识分开管理
</li> </li>
<li> <li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg> <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
工具调用可审批 工具执行前可确认
</li> </li>
</ul> </ul>
</div> </div>
@@ -145,6 +147,7 @@
</div> </div>
<div class="side-item is-active"><span></span>对话</div> <div class="side-item is-active"><span></span>对话</div>
<div class="side-item"><span></span>知识库</div> <div class="side-item"><span></span>知识库</div>
<div class="side-item"><span></span>魔法笔记</div>
<div class="side-item"><span></span>智能心跳</div> <div class="side-item"><span></span>智能心跳</div>
<div class="side-item"><span></span>任务与活动</div> <div class="side-item"><span></span>任务与活动</div>
<div class="sidebar-spacer"></div> <div class="sidebar-spacer"></div>
@@ -153,24 +156,24 @@
<div class="app-content"> <div class="app-content">
<div class="app-content-header"> <div class="app-content-header">
<div> <div>
<strong>产品发布准备</strong> <strong>产品官网维护</strong>
<span>项目:GoodBuddy 0.8.0</span> <span>项目:GoodBuddy 官网</span>
</div> </div>
<div class="mode-pill">计划模式</div> <div class="mode-pill">计划模式</div>
</div> </div>
<div class="message-area"> <div class="message-area">
<div class="message message--user">梳理 0.8.0 发布前还需要完成的工作</div> <div class="message message--user">检查官网内容与下载入口是否需要更新</div>
<div class="message message--assistant"> <div class="message message--assistant">
<div class="assistant-label"> <div class="assistant-label">
<span class="assistant-avatar">G</span> <span class="assistant-avatar">G</span>
<strong>GoodBuddy</strong> <strong>GoodBuddy</strong>
</div> </div>
<p>我会先核对发布清单与项目知识,再给出不执行变更的计划</p> <p>我会先检查站点内容和发布页,不修改文件</p>
<div class="tool-card"> <div class="tool-card">
<div class="tool-icon"> <div class="tool-icon">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7" /></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h10M4 18h7" /></svg>
</div> </div>
<div><strong>读取项目知识</strong><span>范围:GoodBuddy 0.8.0</span></div> <div><strong>读取项目知识</strong><span>范围:GoodBuddy 官网</span></div>
<span class="tool-state">已完成</span> <span class="tool-state">已完成</span>
</div> </div>
<div class="plan-lines" aria-hidden="true"><span></span><span></span><span></span></div> <div class="plan-lines" aria-hidden="true"><span></span><span></span><span></span></div>
@@ -187,11 +190,11 @@
<span class="floating-icon"> <span class="floating-icon">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 5 6v5c0 4.5 2.8 8.6 7 10 4.2-1.4 7-5.5 7-10V6l-7-3Z" /><path d="m9 12 2 2 4-4" /></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 5 6v5c0 4.5 2.8 8.6 7 10 4.2-1.4 7-5.5 7-10V6l-7-3Z" /><path d="m9 12 2 2 4-4" /></svg>
</span> </span>
<span><strong>执行前确认</strong><small>每次工具调用都清晰可见</small></span> <span><strong>执行前确认</strong><small>查看工具名称和影响</small></span>
</div> </div>
<div class="floating-card floating-card--scope"> <div class="floating-card floating-card--scope">
<span class="scope-dot"></span> <span class="scope-dot"></span>
<span><strong>项目范围</strong><small>上下文不会悄悄混用</small></span> <span><strong>项目范围</strong><small>知识和任务按项目区分</small></span>
</div> </div>
</div> </div>
</div> </div>
@@ -201,8 +204,8 @@
<div class="section-inner proof-grid"> <div class="section-inner proof-grid">
<div><strong>3 种</strong><span>问答 / 计划 / 执行模式</span></div> <div><strong>3 种</strong><span>问答 / 计划 / 执行模式</span></div>
<div><strong>2 层</strong><span>全局与项目知识范围</span></div> <div><strong>2 层</strong><span>全局与项目知识范围</span></div>
<div><strong>明确</strong><span>工具权限与活动记录</span></div> <div><strong>可查看</strong><span>工具调用与活动记录</span></div>
<div><strong>跨平台</strong><span>x64 与 arm64</span></div> <div><strong>6 组</strong><span>系统与架构组合</span></div>
</div> </div>
</section> </section>
@@ -210,11 +213,11 @@
<div class="section-inner"> <div class="section-inner">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<p class="kicker">围绕真实工作流设计</p> <p class="kicker">主要功能</p>
<h2 id="features-title">不是另一个聊天窗口</h2> <h2 id="features-title">GoodBuddy 可以做什么</h2>
</div> </div>
<p> <p>
从上下文组织到执行审批,每一步都让范围、状态和风险保持可见 管理对话和知识,运行任务,并在需要时调用经过确认的工具
</p> </p>
</div> </div>
@@ -227,8 +230,8 @@
</svg> </svg>
</div> </div>
<span class="feature-number">01</span> <span class="feature-number">01</span>
<h3>受控 Agent 运行</h3> <h3>Agent 运行模式</h3>
<p>问答计划模式在运行时保持只读;执行模式中的工具操作经过现有审批控制,并保留取消、超时输出边界</p> <p>问答计划模式不执行工具。执行模式通过审批控制调用工具,并支持取消、超时输出限制</p>
<div class="mode-row" aria-label="三种工作模式"> <div class="mode-row" aria-label="三种工作模式">
<span>问答 <small>只读</small></span> <span>问答 <small>只读</small></span>
<span>计划 <small>只读</small></span> <span>计划 <small>只读</small></span>
@@ -244,8 +247,8 @@
</svg> </svg>
</div> </div>
<span class="feature-number">02</span> <span class="feature-number">02</span>
<h3>有范围的知识</h3> <h3>知识库按范围管理</h3>
<p>区分全局与项目知识。搜索、引用和创建都围绕当前范围展开,让上下文来源清楚可追溯</p> <p>全局知识和项目知识分开保存。搜索结果和引用会显示来源</p>
</article> </article>
<article class="feature-card"> <article class="feature-card">
@@ -256,8 +259,8 @@
</svg> </svg>
</div> </div>
<span class="feature-number">03</span> <span class="feature-number">03</span>
<h3>智能心跳与任务</h3> <h3>定时任务和运行记录</h3>
<p>将周期计划、运行状态、结果活动记录放在同一条可检查的工作链路中</p> <p>可以创建周期计划,查看每次运行状态、结果活动记录。</p>
</article> </article>
<article class="feature-card"> <article class="feature-card">
@@ -267,8 +270,8 @@
</svg> </svg>
</div> </div>
<span class="feature-number">04</span> <span class="feature-number">04</span>
<h3>文档与图像输入</h3> <h3>文档和图片</h3>
<p>单次最多添加 8 个附件,支持同时传入 5 张图片;在一个会话中汇集任务所需材料</p> <p>单次最多添加 8 个附件,支持同时传入 5 张图片。</p>
</article> </article>
<article class="feature-card"> <article class="feature-card">
@@ -280,8 +283,8 @@
</svg> </svg>
</div> </div>
<span class="feature-number">05</span> <span class="feature-number">05</span>
<h3>可控的图像生成</h3> <h3>生成图片</h3>
<p>生图质量支持 auto、low、medium、high 四档。结果以单张图像呈现,并作为本地工件保存</p> <p>支持 auto、low、medium、high 四档质量。生成结果会保存到本地</p>
</article> </article>
<article class="feature-card feature-card--wide feature-card--accent"> <article class="feature-card feature-card--wide feature-card--accent">
@@ -292,8 +295,8 @@
</svg> </svg>
</div> </div>
<span class="feature-number">06</span> <span class="feature-number">06</span>
<h3>模型与工具,由你连接</h3> <h3>模型、MCP 与运行时</h3>
<p>在桌面端管理模型配置、MCP 工具运行时。密钥留在主进程的加密设置存储中,不交给网页渲染层</p> <p>模型连接、MCP 工具运行时都在桌面端配置。API 密钥只保存在主进程</p>
<div class="provider-pills" aria-label="支持的连接类型"> <div class="provider-pills" aria-label="支持的连接类型">
<span>模型提供商</span><span>MCP</span><span>OpenCode</span><span>Continue</span> <span>模型提供商</span><span>MCP</span><span>OpenCode</span><span>Continue</span>
</div> </div>
@@ -306,13 +309,13 @@
<div class="section-inner"> <div class="section-inner">
<div class="release-heading"> <div class="release-heading">
<div class="version-lockup" aria-hidden="true"> <div class="version-lockup" aria-hidden="true">
<span>VERSION</span> <span>HIGHLIGHTS</span>
<strong>0.8.0</strong> <strong>NOW</strong>
</div> </div>
<div> <div>
<p class="kicker">下一站</p> <p class="kicker">近期新增</p>
<h2 id="release-title">0.8.0 更新亮点</h2> <h2 id="release-title">笔记、消息通道和运行时改进</h2>
<p>更聪明地组织工作,也更诚实地标注能力边界。以下功能状态以正式 Release 说明为准</p> <p>下面这些功能已经包含在当前正式版本中</p>
</div> </div>
</div> </div>
@@ -320,56 +323,56 @@
<li class="release-item"> <li class="release-item">
<div class="release-index">01</div> <div class="release-index">01</div>
<div class="release-copy"> <div class="release-copy">
<div class="release-label">0.8.0</div> <div class="release-label">魔法笔记</div>
<h3>Subagent 与智能路由</h3> <h3>魔法笔记</h3>
<p>面向复杂任务的协作与路由能力归入 0.8.0,不将仍在开发中的路径描述为当前稳定能力</p> <p>在本地管理笔记和待办,支持范围、筛选、富文本编辑和 AI 评论</p>
</div> </div>
<div class="release-visual route-visual" aria-hidden="true"> <div class="release-visual route-visual" aria-hidden="true">
<span class="route-node route-node--main">主任务</span> <span class="route-node route-node--main">笔记</span>
<span class="route-line route-line--one"></span> <span class="route-line route-line--one"></span>
<span class="route-line route-line--two"></span> <span class="route-line route-line--two"></span>
<span class="route-node route-node--sub-one">研究</span> <span class="route-node route-node--sub-one">待办</span>
<span class="route-node route-node--sub-two">验证</span> <span class="route-node route-node--sub-two">AI 评论</span>
</div> </div>
</li> </li>
<li class="release-item"> <li class="release-item">
<div class="release-index">02</div> <div class="release-index">02</div>
<div class="release-copy"> <div class="release-copy">
<div class="release-label release-label--preview">开发者预览</div> <div class="release-label release-label--preview">远程通道</div>
<h3>IM 渠道接入</h3> <h3>微信、企业微信和钉钉</h3>
<p>钉钉与企业微信以开发者预览提供;个人微信处于实验性边界,不作为面向生产环境的稳定承诺</p> <p>每个消息通道使用独立会话和系统项目,并记录发送者范围、运行模式和活动</p>
</div> </div>
<div class="release-visual channel-visual" aria-label="渠道状态"> <div class="release-visual channel-visual" aria-label="渠道状态">
<span><b>钉钉</b><small>开发者预览</small></span> <span><b>钉钉</b><small>独立会话</small></span>
<span><b>企业微信</b><small>开发者预览</small></span> <span><b>企业微信</b><small>范围控制</small></span>
<span class="is-experimental"><b>个人微信</b><small>实验性边界</small></span> <span class="is-experimental"><b>微信 ClawBot</b><small>扫码连接</small></span>
</div> </div>
</li> </li>
<li class="release-item"> <li class="release-item">
<div class="release-index">03</div> <div class="release-index">03</div>
<div class="release-copy"> <div class="release-copy">
<div class="release-label">多模态输入</div> <div class="release-label">安全媒体</div>
<h3>更多材料,一次带上</h3> <h3>远程消息中的图片和文件</h3>
<p>单次最多 8 个附件,并已验证同时传入 5 张图片。限制保持可见,避免把超出边界的输入静默带入任务</p> <p>微信私聊支持图片与文件,单条消息最多 4 个附件。回复不会自动发送工作区中的已有文件</p>
</div> </div>
<div class="release-visual attachment-visual" aria-hidden="true"> <div class="release-visual attachment-visual" aria-hidden="true">
<div class="attachment-stack"><span></span><span></span><span></span></div> <div class="attachment-stack"><span></span><span></span><span></span></div>
<div><strong>8</strong><small>附件上限</small></div> <div><strong>4</strong><small>单条附件</small></div>
<div><strong>5</strong><small>图片上限</small></div> <div><strong>12MB</strong><small>合计上限</small></div>
</div> </div>
</li> </li>
<li class="release-item"> <li class="release-item">
<div class="release-index">04</div> <div class="release-index">04</div>
<div class="release-copy"> <div class="release-copy">
<div class="release-label">图像生成</div> <div class="release-label">Agent Runtime</div>
<h3>清晰选择质量档位</h3> <h3>运行时与 Skills</h3>
<p>支持 auto、low、medium、high 四档质量。当前按单张结果呈现,不承诺批量多图生成</p> <p>OpenCode 与 Continue 共用更一致的 Skills、系统消息和工具配置,并保留环境白名单、取消、超时和审批控制</p>
</div> </div>
<div class="release-visual quality-visual" aria-label="图像质量档位"> <div class="release-visual quality-visual" aria-label="运行时能力">
<span>auto</span><span>low</span><span>medium</span><span class="is-selected">high</span> <span>Skills</span><span>Tools</span><span>OpenCode</span><span class="is-selected">Continue</span>
</div> </div>
</li> </li>
</ol> </ol>
@@ -380,11 +383,11 @@
<div class="section-inner"> <div class="section-inner">
<div class="section-heading section-heading--center"> <div class="section-heading section-heading--center">
<div> <div>
<p class="kicker">原生桌面体验</p> <p class="kicker">下载</p>
<h2 id="download-title">准备好,在你的设备上运行</h2> <h2 id="download-title">下载 GoodBuddy</h2>
</div> </div>
<p> <p>
v0.8.1 Release 提供经过校验的跨平台安装包与哈希清单,下载入口统一指向 GitHub Release 最新 Release 提供经过校验的跨平台安装包与哈希清单。进入官方下载页,按系统与架构选择安装包
</p> </p>
</div> </div>
@@ -396,7 +399,13 @@
</svg> </svg>
</div> </div>
<div><h3>Windows</h3><p>x64 / arm64 · NSIS / 便携版</p></div> <div><h3>Windows</h3><p>x64 / arm64 · NSIS / 便携版</p></div>
<a class="button button--download is-disabled" aria-disabled="true" data-release-link>发布后开放</a> <a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link
>选择 Windows 安装包<span class="sr-only">(在新窗口打开)</span></a>
</article> </article>
<article class="download-card"> <article class="download-card">
<div class="platform-icon"> <div class="platform-icon">
@@ -405,7 +414,13 @@
</svg> </svg>
</div> </div>
<div><h3>macOS</h3><p>x64 / arm64 · DMG / ZIP</p></div> <div><h3>macOS</h3><p>x64 / arm64 · DMG / ZIP</p></div>
<a class="button button--download is-disabled" aria-disabled="true" data-release-link>发布后开放</a> <a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link
>选择 macOS 安装包<span class="sr-only">(在新窗口打开)</span></a>
</article> </article>
<article class="download-card"> <article class="download-card">
<div class="platform-icon"> <div class="platform-icon">
@@ -415,7 +430,13 @@
</svg> </svg>
</div> </div>
<div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB</p></div> <div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB</p></div>
<a class="button button--download is-disabled" aria-disabled="true" data-release-link>发布后开放</a> <a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link
>选择 Linux 安装包<span class="sr-only">(在新窗口打开)</span></a>
</article> </article>
</div> </div>
@@ -424,8 +445,8 @@
<circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /> <circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
</svg> </svg>
<div> <div>
<strong>Release 状态:尚未发布</strong> <strong>下载与校验</strong>
<span>本站下载按钮由单一配置控制;正式发布前不会指向占位资产</span> <span>下载入口始终指向最新正式 Release;安装前请按系统与架构选择文件,并核对 SHA-256 清单</span>
</div> </div>
</div> </div>
</div> </div>
@@ -440,11 +461,10 @@
<path d="m17.5 24 4.5 4.5 9-10" /> <path d="m17.5 24 4.5 4.5 9-10" />
</svg> </svg>
</div> </div>
<p class="kicker">Security by boundary</p> <p class="kicker">安全设计</p>
<h2 id="security-title">安全不是开关,<br />而是每一层的边界</h2> <h2 id="security-title">主要安全边界</h2>
<p> <p>
GoodBuddy 将桌面渲染、密钥、工具运行与用户数据分层处理 渲染界面不能直接读取密钥或调用 Node。工具和子运行时通过主进程受控访问系统能力
风险操作保持可见,未受信运行时不会绕过审批边界。
</p> </p>
<a <a
class="text-link" class="text-link"
@@ -461,19 +481,19 @@
<div class="security-list"> <div class="security-list">
<article> <article>
<span class="security-number">01</span> <span class="security-number">01</span>
<div><h3>密钥不进入渲染层</h3><p>API 密钥留在主进程,并写入加密设置存储;网页界面不获得直接 Node 访问</p></div> <div><h3>密钥仅存主进程</h3><p>API 密钥写入加密设置存储,不会暴露给渲染界面</p></div>
</article> </article>
<article> <article>
<span class="security-number">02</span> <span class="security-number">02</span>
<div><h3>跨进程能力明确暴露</h3><p>通过窄化的预加载桥接调用能力,IPC 输入经过共享模式校验,并核验可信发送方。</p></div> <div><h3>IPC 输入经过校验</h3><p>预加载层只暴露明确的方法。IPC 输入使用共享模式校验,并检查发送方。</p></div>
</article> </article>
<article> <article>
<span class="security-number">03</span> <span class="security-number">03</span>
<div><h3>运行时按不可信处理</h3><p>OpenCode 与 Continue 子运行时受环境白名单、沙箱检查及逐工具审批约束</p></div> <div><h3>运行时受限</h3><p>OpenCode 与 Continue 使用环境白名单、沙箱检查工具审批。</p></div>
</article> </article>
<article> <article>
<span class="security-number">04</span> <span class="security-number">04</span>
<div><h3>状态与审计语义可见</h3><p>取消、超时输出边界和活动记录属于执行链路的一部分,不用模糊的“已完成”掩盖风险</p></div> <div><h3>工具执行可追踪</h3><p>工具名称、状态、取消、超时输出限制都会记录在活动中</p></div>
</article> </article>
</div> </div>
</div> </div>
@@ -484,16 +504,18 @@
<div class="cta-card"> <div class="cta-card">
<div class="cta-orbit" aria-hidden="true"><span></span><span></span></div> <div class="cta-orbit" aria-hidden="true"><span></span><span></span></div>
<div> <div>
<p class="kicker">GoodBuddy 0.8.1</p> <p class="kicker">下载</p>
<h2 id="cta-title">一个更能做事,也更懂边界的桌面伙伴。</h2> <h2 id="cta-title">选择适合你系统的安装包</h2>
<p>关注 Release,第一时间获取正式版本、校验信息与完整更新说明</p> <p>发布页提供安装文件、便携版和 SHA-256 校验清单</p>
</div> </div>
<div class="cta-actions"> <div class="cta-actions">
<a <a
class="button button--primary is-disabled" class="button button--primary"
aria-disabled="true" href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link data-release-link
>发布后开放</a> >前往官方下载页<span class="sr-only">(在新窗口打开)</span></a>
<a <a
class="button button--secondary" class="button button--secondary"
href="https://github.com/mesalogo/goodbuddy" href="https://github.com/mesalogo/goodbuddy"
@@ -519,10 +541,10 @@
</svg> </svg>
<span>GoodBuddy</span> <span>GoodBuddy</span>
</a> </a>
<p>安全可控的桌面智能助手与 Agent 工作空间。</p> <p>桌面 AI 助手与 Agent 工作空间。</p>
<div class="footer-links"> <div class="footer-links">
<a href="#features">功能</a> <a href="#features">功能</a>
<a href="#release">0.8.0</a> <a href="#release">亮点</a>
<a href="#security">安全</a> <a href="#security">安全</a>
<a href="https://github.com/mesalogo/goodbuddy" target="_blank" rel="noreferrer"> <a href="https://github.com/mesalogo/goodbuddy" target="_blank" rel="noreferrer">
GitHub<span class="sr-only">(在新窗口打开)</span> GitHub<span class="sr-only">(在新窗口打开)</span>
@@ -532,7 +554,6 @@
</div> </div>
</footer> </footer>
<script src="./site.config.js"></script>
<script src="./app.js"></script> <script src="./app.js"></script>
</body> </body>
</html> </html>
+23 -39
View File
@@ -3,14 +3,12 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const siteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const siteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repositoryRoot = path.resolve(siteRoot, "..");
const errors = []; const errors = [];
const requiredFiles = [ const requiredFiles = [
"index.html", "index.html",
"styles.css", "styles.css",
"app.js", "app.js",
"site.config.js",
"assets/favicon.svg", "assets/favicon.svg",
"README.md", "README.md",
]; ];
@@ -41,26 +39,16 @@ await Promise.all(
}), }),
); );
const [html, css, appJs, configJs] = await Promise.all([ const [html, css, appJs] = await Promise.all([
readSiteFile("index.html"), readSiteFile("index.html"),
readSiteFile("styles.css"), readSiteFile("styles.css"),
readSiteFile("app.js"), readSiteFile("app.js"),
readSiteFile("site.config.js"),
]); ]);
let packageVersion = "";
try {
packageVersion = JSON.parse(
await readFile(path.join(repositoryRoot, "package.json"), "utf8"),
).version;
} catch {
errors.push("无法读取 package.json 版本");
}
for (const [relativePath, content] of [ for (const [relativePath, content] of [
["index.html", html], ["index.html", html],
["styles.css", css], ["styles.css", css],
["app.js", appJs], ["app.js", appJs],
["site.config.js", configJs],
]) { ]) {
report(!/[ \t]+$/m.test(content), `${relativePath} 包含行尾空白`); report(!/[ \t]+$/m.test(content), `${relativePath} 包含行尾空白`);
report(!content.includes("\t"), `${relativePath} 包含 Tab 缩进`); report(!content.includes("\t"), `${relativePath} 包含 Tab 缩进`);
@@ -81,43 +69,39 @@ for (const breakpoint of ["1199px", "959px", "719px"]) {
} }
const requiredCopy = [ const requiredCopy = [
"Subagent 与智能路由", "在本地管理笔记和待办",
"钉钉与企业微信以开发者预览提供", "微信、企业微信和钉钉",
"个人微信处于实验性边界", "单条消息最多 4 个附件",
"OpenCode 与 Continue",
"单次最多添加 8 个附件,支持同时传入 5 张图片", "单次最多添加 8 个附件,支持同时传入 5 张图片",
"auto、low、medium、high", "auto、low、medium、high",
"当前按单张结果呈现,不承诺批量多图生成", "下载入口始终指向最新正式 Release",
"发布后开放", "主要安全边界",
"安全不是开关",
]; ];
for (const copy of requiredCopy) { for (const copy of requiredCopy) {
report(html.includes(copy), `缺少准确文案:${copy}`); report(html.includes(copy), `缺少准确文案:${copy}`);
} }
const htmlWithoutSvg = html.replace(/<svg\b[\s\S]*?<\/svg>/g, "");
report( report(
configJs.includes(`version: "${packageVersion}"`), !/\bv?\d+\.\d+\.\d+\b/.test(htmlWithoutSvg),
`site.config.js 版本必须与 package.json 的 ${packageVersion} 一致`, "官网正文不得写入需要随发布更新的具体版本号",
);
report(
/releasePublished:\s*true/.test(configJs),
`v${packageVersion} Release 发布后 releasePublished 必须为 true`,
);
report(
configJs.includes(
`releaseUrl: "https://github.com/mesalogo/goodbuddy/releases/tag/v${packageVersion}"`,
),
`v${packageVersion} Release URL 配置不正确`,
);
report(
appJs.includes("config?.releasePublished === true"),
"下载链接必须受 releasePublished 配置保护",
);
report(
appJs.includes("config.releaseUrl === expectedReleaseUrl"),
"下载链接必须与配置版本对应的 GitHub Release 地址一致",
); );
const releaseLinks = [
...html.matchAll(/<a\b(?=[^>]*data-release-link)[^>]*>/g),
].map((match) => match[0]);
report(releaseLinks.length >= 5, "缺少完整的官方下载入口");
for (const link of releaseLinks) {
report(
/href="https:\/\/github\.com\/mesalogo\/goodbuddy\/releases\/latest"/.test(link),
`下载入口必须指向官方最新 Release:${link}`,
);
report(/target="_blank"/.test(link), `下载入口必须在新窗口打开:${link}`);
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `下载入口缺少 noreferrer${link}`);
}
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
report(duplicateIds.length === 0, `存在重复 id${[...new Set(duplicateIds)].join(", ")}`); report(duplicateIds.length === 0, `存在重复 id${[...new Set(duplicateIds)].join(", ")}`);
-5
View File
@@ -1,5 +0,0 @@
window.GOODBUDDY_SITE_CONFIG = Object.freeze({
version: "0.8.1",
releasePublished: true,
releaseUrl: "https://github.com/mesalogo/goodbuddy/releases/tag/v0.8.1",
});

Some files were not shown because too many files have changed in this diff Show More