Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f47049c40d | ||
|
|
4c18d8d9c6 | ||
|
|
8a63ac9ee8 | ||
|
|
02e59427a1 | ||
|
|
34215c72a1 | ||
|
|
f8d07419d2 | ||
|
|
d898f420fd | ||
|
|
c4492e4a92 | ||
|
|
e5aa9ed688 | ||
|
|
b755951ecd | ||
|
|
d4f1668c9b | ||
|
|
e1fe1cf10a | ||
|
|
fbba2e3abf | ||
|
|
511f50aa63 | ||
|
|
6c1e8105dd | ||
|
|
3b83c90d0f | ||
|
|
327b06587b | ||
|
|
de14a6157a | ||
|
|
81ae9e0324 | ||
|
|
f49d6678a3 | ||
|
|
0d7ba55a50 | ||
|
|
c71da4b6ae | ||
|
|
ba6f6b8f22 | ||
|
|
49377206cc | ||
|
|
1c22e1a06d | ||
|
|
a0456f687a | ||
|
|
59a1624396 | ||
|
|
a44de1441a | ||
|
|
f7f0332143 | ||
|
|
4222351f3c | ||
|
|
8ad26d43bb | ||
|
|
74a2f187ba | ||
|
|
ca4a58c762 | ||
|
|
ea45533137 | ||
|
|
e9679fecf1 | ||
|
|
3b560c9a5e | ||
|
|
b84250f62a | ||
|
|
032ebc313f | ||
|
|
719c9fd6cd | ||
|
|
d36a05f7ad | ||
|
|
fc36433d76 | ||
|
|
f6287589a3 | ||
|
|
b2c85b8ece | ||
|
|
073eb0d2f5 | ||
|
|
3bf720a32c | ||
|
|
1274c762e8 | ||
|
|
c0ad27ab44 | ||
|
|
4483c92b57 | ||
|
|
d9114c20bf | ||
|
|
877cf581f6 | ||
|
|
0eb5205b1c | ||
|
|
c037d7fb20 | ||
|
|
020a9ad0dc | ||
|
|
444902d8ad | ||
|
|
fedafa21bd | ||
|
|
c1848a9dc4 | ||
|
|
21ac8330ea | ||
|
|
b01a856bae | ||
|
|
80b0b18712 | ||
|
|
428b4c0d08 | ||
|
|
ec100f8176 | ||
|
|
c1f8ba80ee | ||
|
|
f3b695214b | ||
|
|
abd3d5c6bb | ||
|
|
f669e490b8 | ||
|
|
b6d4633a33 | ||
|
|
5782667a40 | ||
|
|
e35ea7692f | ||
|
|
5a77dfdb93 | ||
|
|
177ab2140e | ||
|
|
cbb6398851 | ||
|
|
b1ba14f98e | ||
|
|
e95c7a60d4 | ||
|
|
c82d0161a9 | ||
|
|
9b7cf730d0 | ||
|
|
22a60497cb | ||
|
|
78418a56fa | ||
|
|
fbc0f5e9f3 | ||
|
|
c1f4fd0580 | ||
|
|
28de0c45ba | ||
|
|
024e6fb4b2 | ||
|
|
f95acdc000 | ||
|
|
451b30d434 | ||
|
|
25e897c22b | ||
|
|
c8db340cc7 | ||
|
|
9aed926cda | ||
|
|
86daae8be2 | ||
|
|
19086d656e | ||
|
|
d3947c79dd | ||
|
|
6a1a671d3b | ||
|
|
dbb7c2fc80 | ||
|
|
237b8a8cee | ||
|
|
d08ba33c3e | ||
|
|
b03c25685c | ||
|
|
7003141e56 | ||
|
|
e692362414 | ||
|
|
ee804edcae | ||
|
|
85a4a374fa | ||
|
|
89a81dd07c | ||
|
|
0eb664c0b6 | ||
|
|
9c6d1611e2 | ||
|
|
1f01c26e04 | ||
|
|
dd8289fdef | ||
|
|
d5fa0ec67c | ||
|
|
6eb73657e0 | ||
|
|
4041edefa8 | ||
|
|
482cd0c467 | ||
|
|
5a1afb00f7 | ||
|
|
a4b64a1641 | ||
|
|
9588c4b901 | ||
|
|
d180f63abd | ||
|
|
670171ce5d | ||
|
|
4490e5fd15 | ||
|
|
374968d50b | ||
|
|
4e60a4ca61 | ||
|
|
3765a937bd | ||
|
|
feaebdf221 | ||
|
|
70ec2bf792 | ||
|
|
8b9dbf55df | ||
|
|
b1e3101857 | ||
|
|
7369c13bcf | ||
|
|
fba5e1ad30 | ||
|
|
468cf0483f | ||
|
|
6586192222 | ||
|
|
987a08ad78 | ||
|
|
35a93e61cd | ||
|
|
0cba3855ea | ||
|
|
74c3ec19b4 | ||
|
|
cdc8f6f57a | ||
|
|
b8c1d33e7b | ||
|
|
f18e4abb3c | ||
|
|
c52732441a | ||
|
|
ba000300d5 | ||
|
|
3b8d129693 | ||
|
|
fb5a9c6f43 | ||
|
|
26b87d7ed5 | ||
|
|
93721d69b6 | ||
|
|
8ecd87381f | ||
|
|
dc36e7a967 | ||
|
|
cdecb584d5 | ||
|
|
c4231c8f9c | ||
|
|
8a14cf356a | ||
|
|
19a3bafb63 | ||
|
|
5fac1d3c41 | ||
|
|
42cbfa43f1 | ||
|
|
4d9ab72778 | ||
|
|
be39665ca9 | ||
|
|
1530fa116c | ||
|
|
b36d688900 | ||
|
|
d36889d9f6 | ||
|
|
06c1123caa | ||
|
|
4f3e0bdcfd | ||
|
|
2fca39ddb2 | ||
|
|
2f1881a414 | ||
|
|
dba3380a06 | ||
|
|
77ce13aba3 | ||
|
|
5127f7d045 | ||
|
|
27055693bf | ||
|
|
73cc55bf75 | ||
|
|
00a231da07 | ||
|
|
2833a27f83 | ||
|
|
b1729125e1 | ||
|
|
561de29f00 | ||
|
|
f6f8d5e053 | ||
|
|
8c580a9174 | ||
|
|
efd304fa1d | ||
|
|
7c6f15d3c0 | ||
|
|
6dc219f3a3 | ||
|
|
acc5a8e2d8 | ||
|
|
9f0f231e3b | ||
|
|
967796539f | ||
|
|
8e08ab0bac | ||
|
|
edba31e873 | ||
|
|
0804f5ccd2 | ||
|
|
29639ff516 | ||
|
|
22c80b2d94 | ||
|
|
85e14ca626 | ||
|
|
a99b90cc02 | ||
|
|
ff4fde282f | ||
|
|
f327341b00 | ||
|
|
459cc21e3d | ||
|
|
eb947213c8 | ||
|
|
c2095037ee | ||
|
|
93bf3b908a | ||
|
|
38f6a23306 | ||
|
|
12463ad593 | ||
|
|
618e505b8b | ||
|
|
0a3bf6094a | ||
|
|
957e2ecde7 | ||
|
|
9316fed3e3 | ||
|
|
d6fea0c6c2 | ||
|
|
d9a554b4b1 | ||
|
|
b20c10f820 | ||
|
|
49700bc7cf | ||
|
|
fcdcc8e081 | ||
|
|
de64e9768a | ||
|
|
f9137f0463 | ||
|
|
798ac814a9 | ||
|
|
6913e9ce16 | ||
|
|
9aca3b2627 | ||
|
|
0cb7007429 | ||
|
|
bcd347e092 | ||
|
|
9c50de521e | ||
|
|
da00a850ec | ||
|
|
fcedaf578e | ||
|
|
53269f8b1a | ||
|
|
9388eb79d4 | ||
|
|
500f7d2735 | ||
|
|
e5aebaa67d | ||
|
|
88658aceaa | ||
|
|
098f21b47a | ||
|
|
efecf88af7 | ||
|
|
6c74f057eb | ||
|
|
607bb33249 | ||
|
|
3829a871f5 | ||
|
|
981a174e9f | ||
|
|
1154a6d8da | ||
|
|
076b3aaa7b | ||
|
|
58d774ae0c | ||
|
|
7c34abdfbf | ||
|
|
fb51793595 | ||
|
|
fee4686750 | ||
|
|
63f0f7f0b0 | ||
|
|
97419f3ef7 | ||
|
|
b29f2d7b0d | ||
|
|
a0977645a3 | ||
|
|
aa339a73d6 | ||
|
|
65c3ab9ab0 | ||
|
|
104ac2aec1 | ||
|
|
d7cb7ba181 | ||
|
|
f44a0dc907 | ||
|
|
20c15f74c6 | ||
|
|
34033053be | ||
|
|
993c439228 | ||
|
|
43e1d162dc | ||
|
|
c41d7e8b96 | ||
|
|
ccaab25d11 | ||
|
|
3935f50017 | ||
|
|
28b1590749 | ||
|
|
87dec9baf3 | ||
|
|
0413807b8f | ||
|
|
e387c09f52 | ||
|
|
08884c127c | ||
|
|
7a46422fc1 | ||
|
|
5dddccb282 | ||
|
|
60119a4317 | ||
|
|
7ecb735db0 | ||
|
|
255df74d87 | ||
|
|
e8c1c1f3f3 | ||
|
|
abe017a465 | ||
|
|
dc8f86ff3e | ||
|
|
ce15e7021c | ||
|
|
fa9ead895f | ||
|
|
9773f3ceb4 | ||
|
|
47214ef87f | ||
|
|
98fabe9e2e | ||
|
|
cbbe30896e | ||
|
|
f5ee9b2198 | ||
|
|
ddcc6dd1d9 | ||
|
|
e235522c0d | ||
|
|
2b556e38bd | ||
|
|
2df8fb1364 | ||
|
|
792d80e67c | ||
|
|
b751c70d75 | ||
|
|
18263a6b23 | ||
|
|
80526c57bf | ||
|
|
b56b0f8826 | ||
|
|
876f120a63 | ||
|
|
ff61b5f81d | ||
|
|
9e6f664e06 | ||
|
|
c98fe67f1a | ||
|
|
b3c753fac2 | ||
|
|
36b38ce2a8 | ||
|
|
0b96400e51 | ||
|
|
939c3ab1b1 | ||
|
|
1cde0f3385 | ||
|
|
c2b9ce4ff0 | ||
|
|
1031ea618b | ||
|
|
3f9defbad1 | ||
|
|
fd50d6555f | ||
|
|
faa4456661 | ||
|
|
79d3d73038 | ||
|
|
afa7719ff2 | ||
|
|
5f1ae7727a | ||
|
|
67d30d9e21 | ||
|
|
9cd565fcc4 | ||
|
|
f27dd37f42 | ||
|
|
ed8d1791b1 | ||
|
|
34bbfab2af | ||
|
|
79e2511f6f | ||
|
|
1329251b5a | ||
|
|
a5f1e31900 | ||
|
|
57c57d232c | ||
|
|
a83f24a334 |
@@ -0,0 +1,617 @@
|
||||
name: GoodBuddy Agent Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'agent-v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: agent-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
name: Authorize Agent release
|
||||
if: >-
|
||||
github.ref_type == 'tag' &&
|
||||
startsWith(github.ref_name, 'agent-v')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
authorized: ${{ steps.policy.outputs.authorized }}
|
||||
version: ${{ steps.policy.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify immutable Agent release tag
|
||||
id: policy
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
expected="agent-v$version"
|
||||
test "$GITHUB_REF_NAME" = "$expected"
|
||||
test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = "tag"
|
||||
test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA"
|
||||
git fetch --no-tags origin main
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.platform }} ${{ matrix.arch }} compound package
|
||||
needs: authorize
|
||||
if: needs.authorize.outputs.authorized == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
arch: x64
|
||||
runner: ubuntu-24.04
|
||||
- platform: linux
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
- platform: darwin
|
||||
arch: arm64
|
||||
runner: macos-15
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 25
|
||||
environment:
|
||||
name: agent-signing
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.19.0
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify native build target
|
||||
run: node -e "if(process.platform !== '${{ matrix.platform }}' || process.arch !== '${{ matrix.arch }}') throw new Error('Native Agent target mismatch')"
|
||||
|
||||
- name: Resolve locked native inputs
|
||||
id: inputs
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
const { appendFileSync } = require('node:fs')
|
||||
const { createHash } = require('node:crypto')
|
||||
const agent = require('./agent-runtime-lock.json')
|
||||
const remote = require('./remote-runtime-lock.json')
|
||||
const node = agent.node?.targets?.[`${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`]
|
||||
const runtime = remote.runtimes?.opencode
|
||||
const runtimeKey = process.env.AGENT_PLATFORM === 'linux'
|
||||
? process.env.AGENT_ARCH : `${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`
|
||||
const opencode = runtime?.targets?.[runtimeKey]
|
||||
if (
|
||||
!node ||
|
||||
!opencode ||
|
||||
!/^[A-Za-z0-9._-]+\.tar\.gz$/u.test(node.archive) ||
|
||||
!/^[a-f0-9]{64}$/u.test(node.sha256) ||
|
||||
!/^[a-z0-9][a-z0-9._-]*$/u.test(opencode.package) ||
|
||||
!/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(opencode.integrity)
|
||||
) {
|
||||
throw new Error('Locked native Agent package inputs are invalid')
|
||||
}
|
||||
const nodeUrl = new URL(node.archive, agent.node.source)
|
||||
if (
|
||||
nodeUrl.protocol !== 'https:' ||
|
||||
nodeUrl.hostname !== 'nodejs.org' ||
|
||||
nodeUrl.username ||
|
||||
nodeUrl.password ||
|
||||
nodeUrl.search ||
|
||||
nodeUrl.hash
|
||||
) {
|
||||
throw new Error('Agent Node runtime must use the official HTTPS origin')
|
||||
}
|
||||
const opencodeArchive =
|
||||
`${opencode.package}-${runtime.version}.tgz`
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
[
|
||||
`node-archive=${node.archive}`,
|
||||
`node-sha256=${node.sha256}`,
|
||||
`node-url=${nodeUrl.href}`,
|
||||
`opencode-package=${opencode.package}`,
|
||||
`opencode-version=${runtime.version}`,
|
||||
`opencode-integrity=${opencode.integrity}`,
|
||||
`opencode-archive=${opencodeArchive}`,
|
||||
`cache-key=${createHash('sha256')
|
||||
.update(`${node.sha256}:${opencode.integrity}`)
|
||||
.digest('hex')}`,
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
NODE
|
||||
|
||||
- name: Cache verified native inputs
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-release-inputs
|
||||
key: goodbuddy-agent-release-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.inputs.outputs.cache-key }}
|
||||
|
||||
- name: Acquire and verify native inputs
|
||||
shell: bash
|
||||
env:
|
||||
NODE_ARCHIVE: ${{ steps.inputs.outputs.node-archive }}
|
||||
NODE_SHA256: ${{ steps.inputs.outputs.node-sha256 }}
|
||||
NODE_URL: ${{ steps.inputs.outputs.node-url }}
|
||||
OPENCODE_ARCHIVE: ${{ steps.inputs.outputs.opencode-archive }}
|
||||
OPENCODE_INTEGRITY: ${{ steps.inputs.outputs.opencode-integrity }}
|
||||
OPENCODE_PACKAGE: ${{ steps.inputs.outputs.opencode-package }}
|
||||
OPENCODE_VERSION: ${{ steps.inputs.outputs.opencode-version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
directory="$RUNNER_TEMP/goodbuddy-agent-release-inputs"
|
||||
mkdir -p "$directory"
|
||||
node_archive="$directory/$NODE_ARCHIVE"
|
||||
if [[ ! -f "$node_archive" ]]; then
|
||||
curl --proto '=https' --tlsv1.2 \
|
||||
--fail --silent --show-error --location \
|
||||
--retry 3 --retry-all-errors \
|
||||
--connect-timeout 15 --max-time 300 \
|
||||
--max-filesize 104857600 \
|
||||
"$NODE_URL" --output "$node_archive.partial"
|
||||
mv "$node_archive.partial" "$node_archive"
|
||||
fi
|
||||
node - "$node_archive" "$NODE_SHA256" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const [archive, expected] = process.argv.slice(2)
|
||||
if (createHash('sha256').update(readFileSync(archive)).digest('hex') !== expected) {
|
||||
throw new Error('Node runtime archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
|
||||
opencode_archive="$directory/$OPENCODE_ARCHIVE"
|
||||
if [[ ! -f "$opencode_archive" ]]; then
|
||||
npm pack \
|
||||
--ignore-scripts \
|
||||
--registry https://registry.npmjs.org/ \
|
||||
--pack-destination "$directory" \
|
||||
"$OPENCODE_PACKAGE@$OPENCODE_VERSION"
|
||||
fi
|
||||
node - "$opencode_archive" "$OPENCODE_INTEGRITY" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync, statSync } = require('node:fs')
|
||||
const [archive, expected] = process.argv.slice(2)
|
||||
const size = statSync(archive).size
|
||||
if (size <= 0 || size > 536870912) {
|
||||
throw new Error('OpenCode Runtime archive exceeds its safety limit')
|
||||
}
|
||||
const actual = `sha512-${createHash('sha512')
|
||||
.update(readFileSync(archive))
|
||||
.digest('base64')}`
|
||||
if (actual !== expected) {
|
||||
throw new Error('OpenCode Runtime archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Preflight production GoodBuddy signing identity
|
||||
shell: bash
|
||||
env:
|
||||
GOODBUDDY_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_SIGNING_KEY_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node build/agent-bundle.cjs preflight
|
||||
|
||||
- name: Build production compound Agent package
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
GOODBUDDY_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_SIGNING_KEY_ID }}
|
||||
GOODBUDDY_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_SIGNING_PRIVATE_KEY }}
|
||||
NODE_ARCHIVE: ${{ steps.inputs.outputs.node-archive }}
|
||||
OPENCODE_ARCHIVE: ${{ steps.inputs.outputs.opencode-archive }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
output="$RUNNER_TEMP/goodbuddy-agent-output"
|
||||
mkdir -p "$output"
|
||||
node build/agent-package.cjs build \
|
||||
--platform "$AGENT_PLATFORM" \
|
||||
--arch "$AGENT_ARCH" \
|
||||
--minimum-desktop-version "$(node -p "require('./package.json').version")" \
|
||||
--node-archive "$RUNNER_TEMP/goodbuddy-agent-release-inputs/$NODE_ARCHIVE" \
|
||||
--runtime-archive "$RUNNER_TEMP/goodbuddy-agent-release-inputs/$OPENCODE_ARCHIVE" \
|
||||
--output "$output/goodbuddy-agent-$AGENT_VERSION-$AGENT_PLATFORM-$AGENT_ARCH.gbagent" \
|
||||
> "$output/result.json"
|
||||
node - "$output/result.json" "$AGENT_ARCH" "$AGENT_VERSION" <<'NODE'
|
||||
const { readFileSync } = require('node:fs')
|
||||
const [resultPath, architecture, version] = process.argv.slice(2)
|
||||
const result = JSON.parse(readFileSync(resultPath, 'utf8'))
|
||||
if (
|
||||
result.descriptor?.architecture !== architecture ||
|
||||
result.descriptor?.platform !== process.env.AGENT_PLATFORM ||
|
||||
result.descriptor?.version !== version ||
|
||||
result.archive !==
|
||||
`goodbuddy-agent-${version}-${process.env.AGENT_PLATFORM}-${architecture}.gbagent` ||
|
||||
!/^[a-f0-9]{64}$/u.test(result.sha256)
|
||||
) {
|
||||
throw new Error('Production Agent package result is invalid')
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Upload verified compound package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: goodbuddy-agent-package-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-output/goodbuddy-agent-*.gbagent
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
catalog:
|
||||
name: Sign cumulative Agent catalog
|
||||
needs:
|
||||
- authorize
|
||||
- build
|
||||
if: needs.authorize.outputs.authorized == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
environment:
|
||||
name: agent-signing
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.19.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download compound packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: goodbuddy-agent-package-*
|
||||
path: dist/agent-release
|
||||
merge-multiple: true
|
||||
|
||||
- name: Fetch previous signed catalog when available
|
||||
id: previous
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases"
|
||||
current_catalog_status="$(
|
||||
curl --silent --show-error \
|
||||
--output dist/agent-release/previous.json \
|
||||
--write-out '%{http_code}' \
|
||||
"$root/v$AGENT_VERSION/agent-catalog.json"
|
||||
)"
|
||||
current_signature_status="$(
|
||||
curl --silent --show-error \
|
||||
--output dist/agent-release/previous.sig \
|
||||
--write-out '%{http_code}' \
|
||||
"$root/v$AGENT_VERSION/agent-catalog.sig"
|
||||
)"
|
||||
if [[ "$current_catalog_status" == 200 && "$current_signature_status" == 200 ]]; then
|
||||
echo "arguments=--previous-catalog dist/agent-release/previous.json --previous-signature dist/agent-release/previous.sig" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$current_catalog_status" != 404 || "$current_signature_status" != 404 ]]; then
|
||||
echo "Current Agent catalog objects are inconsistent: $current_catalog_status / $current_signature_status" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f dist/agent-release/previous.json dist/agent-release/previous.sig
|
||||
pointer_status="$(
|
||||
curl --silent --show-error \
|
||||
--output dist/agent-release/previous-pointer.json \
|
||||
--write-out '%{http_code}' \
|
||||
"$root/latest.json"
|
||||
)"
|
||||
if [[ "$pointer_status" == 200 ]]; then
|
||||
readarray -t paths < <(
|
||||
node - dist/agent-release/previous-pointer.json <<'NODE'
|
||||
const { readFileSync } = require('node:fs')
|
||||
const pointer = JSON.parse(readFileSync(process.argv[2], 'utf8'))
|
||||
if (
|
||||
pointer?.formatVersion !== 1 ||
|
||||
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(pointer.version) ||
|
||||
pointer.catalog !== `v${pointer.version}/agent-catalog.json` ||
|
||||
pointer.signature !== `v${pointer.version}/agent-catalog.sig`
|
||||
) {
|
||||
throw new Error('Previous Agent catalog pointer is invalid')
|
||||
}
|
||||
process.stdout.write(`${pointer.catalog}\n${pointer.signature}\n`)
|
||||
NODE
|
||||
)
|
||||
curl --fail --silent --show-error \
|
||||
"$root/${paths[0]}" \
|
||||
--output dist/agent-release/previous.json
|
||||
curl --fail --silent --show-error \
|
||||
"$root/${paths[1]}" \
|
||||
--output dist/agent-release/previous.sig
|
||||
echo "arguments=--previous-catalog dist/agent-release/previous.json --previous-signature dist/agent-release/previous.sig" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$pointer_status" == 404 ]]; then
|
||||
rm -f dist/agent-release/previous-pointer.json
|
||||
echo "arguments=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Agent catalog pointer request failed: $pointer_status" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign cumulative Agent catalog
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
PREVIOUS_ARGUMENTS: ${{ steps.previous.outputs.arguments }}
|
||||
GOODBUDDY_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_SIGNING_KEY_ID }}
|
||||
GOODBUDDY_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_SIGNING_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
generated_at="$(git show -s --format=%cI "$GITHUB_SHA")"
|
||||
read -r -a previous <<< "$PREVIOUS_ARGUMENTS"
|
||||
node build/agent-catalog.cjs create \
|
||||
--x64-package "dist/agent-release/goodbuddy-agent-$AGENT_VERSION-linux-x64.gbagent" \
|
||||
--arm64-package "dist/agent-release/goodbuddy-agent-$AGENT_VERSION-linux-arm64.gbagent" \
|
||||
--darwin-arm64-package "dist/agent-release/goodbuddy-agent-$AGENT_VERSION-darwin-arm64.gbagent" \
|
||||
"${previous[@]}" \
|
||||
--catalog dist/agent-release/agent-catalog.json \
|
||||
--signature dist/agent-release/agent-catalog.sig \
|
||||
--generated-at "$generated_at"
|
||||
node build/agent-catalog.cjs verify \
|
||||
--catalog dist/agent-release/agent-catalog.json \
|
||||
--signature dist/agent-release/agent-catalog.sig
|
||||
|
||||
- name: Upload signed release set
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: goodbuddy-agent-release-${{ needs.authorize.outputs.version }}
|
||||
path: |
|
||||
dist/agent-release/goodbuddy-agent-*.gbagent
|
||||
dist/agent-release/agent-catalog.json
|
||||
dist/agent-release/agent-catalog.sig
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish Agent to GitHub and OSS
|
||||
needs:
|
||||
- authorize
|
||||
- catalog
|
||||
if: needs.authorize.outputs.authorized == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
environment:
|
||||
name: aliyun-oss-release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.19.0
|
||||
|
||||
- name: Install verification dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download signed release set
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: goodbuddy-agent-release-${{ needs.authorize.outputs.version }}
|
||||
path: dist/agent-release
|
||||
|
||||
- name: Verify signed release set
|
||||
run: |
|
||||
node build/agent-catalog.cjs verify \
|
||||
--catalog dist/agent-release/agent-catalog.json \
|
||||
--signature dist/agent-release/agent-catalog.sig
|
||||
|
||||
- name: Verify OSS release configuration
|
||||
shell: bash
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
OIDC_PROVIDER_ARN: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }}
|
||||
ROLE_ARN: ${{ vars.ALIYUN_ROLE_ARN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$OSS_BUCKET" = "goodbuddy"
|
||||
test "$OSS_ENDPOINT" = "https://oss-cn-beijing.aliyuncs.com"
|
||||
case "$OIDC_PROVIDER_ARN" in
|
||||
acs:ram::*:oidc-provider/*) ;;
|
||||
*) echo "OIDC Provider ARN 无效" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$ROLE_ARN" in
|
||||
acs:ram::*:role/*) ;;
|
||||
*) echo "RAM Role ARN 无效" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Authenticate to Alibaba Cloud
|
||||
uses: aliyun/configure-aliyun-credentials-action@v1
|
||||
with:
|
||||
role-to-assume: ${{ vars.ALIYUN_ROLE_ARN }}
|
||||
oidc-provider-arn: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }}
|
||||
role-session-name: goodbuddy-agent-${{ github.run_id }}
|
||||
role-session-expiration: 3600
|
||||
audience: sts.aliyuncs.com
|
||||
|
||||
- name: Install ossutil
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="2.3.0"
|
||||
archive="$RUNNER_TEMP/ossutil.zip"
|
||||
directory="$RUNNER_TEMP/ossutil"
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://gosspublic.alicdn.com/ossutil/v2/$version/ossutil-$version-linux-amd64.zip" \
|
||||
--output "$archive"
|
||||
mkdir "$directory"
|
||||
unzip -q "$archive" -d "$directory"
|
||||
binary="$(find "$directory" -type f -name ossutil -print -quit)"
|
||||
test -n "$binary"
|
||||
chmod +x "$binary"
|
||||
echo "$(dirname "$binary")" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Upload immutable Agent release to OSS
|
||||
id: oss
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
|
||||
export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET"
|
||||
export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN"
|
||||
endpoint_host="${OSS_ENDPOINT#https://}"
|
||||
region="${endpoint_host#oss-}"
|
||||
region="${region%.aliyuncs.com}"
|
||||
test "$region" = "cn-beijing"
|
||||
for file in dist/agent-release/*; do
|
||||
name="$(basename "$file")"
|
||||
ossutil cp "$file" \
|
||||
"oss://${OSS_BUCKET}/agent-releases/v${AGENT_VERSION}/${name}" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--region "$region" \
|
||||
--update
|
||||
done
|
||||
echo "region=$region" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify public immutable OSS packages
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/v$AGENT_VERSION"
|
||||
for target in linux-x64 linux-arm64 darwin-arm64; do
|
||||
name="goodbuddy-agent-$AGENT_VERSION-$target.gbagent"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$root/$name" --output "$RUNNER_TEMP/$name"
|
||||
cmp "dist/agent-release/$name" "$RUNNER_TEMP/$name"
|
||||
done
|
||||
for name in agent-catalog.json agent-catalog.sig; do
|
||||
curl --fail --silent --show-error --location \
|
||||
"$root/$name" --output "$RUNNER_TEMP/immutable-$name"
|
||||
cmp "dist/agent-release/$name" \
|
||||
"$RUNNER_TEMP/immutable-$name"
|
||||
done
|
||||
|
||||
- name: Publish non-Latest GitHub Agent release
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="agent-v$AGENT_VERSION"
|
||||
title="GoodBuddy Agent $AGENT_VERSION"
|
||||
notes="resources/agent-release-notes/$AGENT_VERSION.md"
|
||||
test -s "$notes"
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
is_draft="$(gh release view "$tag" --json isDraft --jq .isDraft)"
|
||||
if [[ "$is_draft" == true ]]; then
|
||||
gh release edit "$tag" \
|
||||
--title "$title" \
|
||||
--notes-file "$notes" \
|
||||
--latest=false
|
||||
gh release upload "$tag" dist/agent-release/* --clobber
|
||||
gh release edit "$tag" --draft=false --latest=false
|
||||
else
|
||||
verify="$RUNNER_TEMP/github-agent-release"
|
||||
mkdir "$verify"
|
||||
gh release download "$tag" --dir "$verify"
|
||||
for file in dist/agent-release/*; do
|
||||
cmp "$file" "$verify/$(basename "$file")"
|
||||
done
|
||||
fi
|
||||
else
|
||||
gh release create "$tag" \
|
||||
--draft \
|
||||
--verify-tag \
|
||||
--title "$title" \
|
||||
--notes-file "$notes" \
|
||||
--latest=false
|
||||
gh release upload "$tag" dist/agent-release/* --clobber
|
||||
gh release edit "$tag" --draft=false --latest=false
|
||||
fi
|
||||
test "$(gh api repos/mesalogo/goodbuddy/releases/latest --jq .tag_name)" != "$tag"
|
||||
|
||||
- name: Point OSS Agent catalog to published release
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
OSS_REGION: ${{ steps.oss.outputs.region }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
|
||||
export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET"
|
||||
export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN"
|
||||
node - "$AGENT_VERSION" > dist/agent-release/latest.json <<'NODE'
|
||||
const version = process.argv[2]
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
formatVersion: 1,
|
||||
version,
|
||||
catalog: `v${version}/agent-catalog.json`,
|
||||
signature: `v${version}/agent-catalog.sig`
|
||||
}, null, 2)}\n`)
|
||||
NODE
|
||||
ossutil cp dist/agent-release/latest.json \
|
||||
"oss://${OSS_BUCKET}/agent-releases/latest.json" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--region "$OSS_REGION" \
|
||||
--force
|
||||
|
||||
- name: Verify public Agent catalogs
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mirror="https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases"
|
||||
github="https://github.com/mesalogo/goodbuddy/releases/download/agent-v$AGENT_VERSION"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$mirror/latest.json" --output "$RUNNER_TEMP/mirror-pointer.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$mirror/v$AGENT_VERSION/agent-catalog.json" \
|
||||
--output "$RUNNER_TEMP/mirror.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$mirror/v$AGENT_VERSION/agent-catalog.sig" \
|
||||
--output "$RUNNER_TEMP/mirror.sig"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$github/agent-catalog.json" --output "$RUNNER_TEMP/github.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$github/agent-catalog.sig" --output "$RUNNER_TEMP/github.sig"
|
||||
cmp dist/agent-release/latest.json "$RUNNER_TEMP/mirror-pointer.json"
|
||||
cmp dist/agent-release/agent-catalog.json "$RUNNER_TEMP/mirror.json"
|
||||
cmp dist/agent-release/agent-catalog.sig "$RUNNER_TEMP/mirror.sig"
|
||||
cmp dist/agent-release/agent-catalog.json "$RUNNER_TEMP/github.json"
|
||||
cmp dist/agent-release/agent-catalog.sig "$RUNNER_TEMP/github.sig"
|
||||
@@ -0,0 +1,353 @@
|
||||
name: GoodBuddy Agents
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/agents.yml'
|
||||
- 'agent-runtime-lock.json'
|
||||
- 'build/agent-bundle.cjs'
|
||||
- 'build/agent-package.cjs'
|
||||
- 'build/agent-ci-bundle.cjs'
|
||||
- 'build/agent-build-target.cjs'
|
||||
- 'build/binary-architecture.cjs'
|
||||
- 'build/file-hash.cjs'
|
||||
- 'build/remote-runtime-bundle.cjs'
|
||||
- 'build/signing-key-preflight.cjs'
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'resources/agent-release-keys.json'
|
||||
- 'remote-runtime-lock.json'
|
||||
- 'src/agent-daemon/**'
|
||||
- 'src/shared/**'
|
||||
- 'tests/agent-bundle.test.ts'
|
||||
- 'tests/agent-darwin-build.test.ts'
|
||||
- 'tests/agent-package.test.ts'
|
||||
- 'tests/agents-workflow.test.ts'
|
||||
- 'tests/remote-runtime-bundle.test.ts'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.github/workflows/agents.yml'
|
||||
- 'agent-runtime-lock.json'
|
||||
- 'build/agent-bundle.cjs'
|
||||
- 'build/agent-package.cjs'
|
||||
- 'build/agent-ci-bundle.cjs'
|
||||
- 'build/agent-build-target.cjs'
|
||||
- 'build/binary-architecture.cjs'
|
||||
- 'build/file-hash.cjs'
|
||||
- 'build/remote-runtime-bundle.cjs'
|
||||
- 'build/signing-key-preflight.cjs'
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'resources/agent-release-keys.json'
|
||||
- 'remote-runtime-lock.json'
|
||||
- 'src/agent-daemon/**'
|
||||
- 'src/shared/**'
|
||||
- 'tests/agent-bundle.test.ts'
|
||||
- 'tests/agent-darwin-build.test.ts'
|
||||
- 'tests/agent-package.test.ts'
|
||||
- 'tests/agents-workflow.test.ts'
|
||||
- 'tests/remote-runtime-bundle.test.ts'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: agents-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Agent source
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.19.0
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Validate Agent source and workflow
|
||||
run: |
|
||||
npm test -- \
|
||||
tests/agent-bundle.test.ts \
|
||||
tests/agent-darwin-build.test.ts \
|
||||
tests/agent-package.test.ts \
|
||||
tests/remote-runtime-bundle.test.ts \
|
||||
tests/agents-workflow.test.ts
|
||||
npm run typecheck
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.platform }} ${{ matrix.arch }} Agent
|
||||
needs: validate
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
arch: x64
|
||||
runner: ubuntu-24.04
|
||||
- platform: linux
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
- platform: darwin
|
||||
arch: arm64
|
||||
runner: macos-15
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.19.0
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify native runner target
|
||||
run: node -e "if (process.platform !== '${{ matrix.platform }}' || process.arch !== '${{ matrix.arch }}') throw new Error('Agent CI requires the native target')"
|
||||
|
||||
- name: Resolve locked official Node runtime
|
||||
id: runtime
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
const { appendFileSync } = require('node:fs')
|
||||
const { lock } = require('./build/agent-ci-bundle.cjs').readCiLocks(
|
||||
process.env.AGENT_PLATFORM, process.env.AGENT_ARCH
|
||||
)
|
||||
const target = lock.node?.targets?.[`${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`]
|
||||
if (
|
||||
!target ||
|
||||
!/^[A-Za-z0-9._-]+\.tar\.gz$/u.test(target.archive) ||
|
||||
!/^[a-f0-9]{64}$/u.test(target.sha256)
|
||||
) {
|
||||
throw new Error('Locked Agent Node runtime metadata is invalid')
|
||||
}
|
||||
const url = new URL(target.archive, lock.node.source)
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.hostname !== 'nodejs.org' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
throw new Error('Agent Node runtime must use the official HTTPS origin')
|
||||
}
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
[
|
||||
`archive=${target.archive}`,
|
||||
`sha256=${target.sha256}`,
|
||||
`url=${url.href}`,
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
NODE
|
||||
|
||||
- name: Cache verified Node runtime input
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-node
|
||||
key: goodbuddy-agent-ci-node-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.runtime.outputs.sha256 }}
|
||||
|
||||
- name: Acquire and verify locked Node runtime
|
||||
shell: bash
|
||||
env:
|
||||
NODE_ARCHIVE: ${{ steps.runtime.outputs.archive }}
|
||||
NODE_SHA256: ${{ steps.runtime.outputs.sha256 }}
|
||||
NODE_URL: ${{ steps.runtime.outputs.url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
directory="$RUNNER_TEMP/goodbuddy-agent-node"
|
||||
archive="$directory/$NODE_ARCHIVE"
|
||||
mkdir -p "$directory"
|
||||
if [[ ! -f "$archive" ]]; then
|
||||
rm -f "$archive.partial"
|
||||
curl --proto '=https' --tlsv1.2 \
|
||||
--fail --silent --show-error --location \
|
||||
--retry 3 --retry-all-errors \
|
||||
--connect-timeout 15 --max-time 300 \
|
||||
--max-filesize 104857600 \
|
||||
"$NODE_URL" --output "$archive.partial"
|
||||
mv "$archive.partial" "$archive"
|
||||
fi
|
||||
node - "$archive" "$NODE_SHA256" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const [archive, expected] = process.argv.slice(2)
|
||||
if (createHash('sha256').update(readFileSync(archive)).digest('hex') !== expected) {
|
||||
throw new Error('Node runtime archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Resolve locked OpenCode Runtime
|
||||
id: opencode
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
const { appendFileSync } = require('node:fs')
|
||||
const { createHash: hash } = require('node:crypto')
|
||||
const { runtimeLock } = require('./build/agent-ci-bundle.cjs').readCiLocks(
|
||||
process.env.AGENT_PLATFORM, process.env.AGENT_ARCH
|
||||
)
|
||||
const runtime = runtimeLock.runtimes?.opencode
|
||||
const key = process.env.AGENT_PLATFORM === 'linux'
|
||||
? process.env.AGENT_ARCH
|
||||
: `${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`
|
||||
const target = runtime?.targets?.[key]
|
||||
if (
|
||||
!runtime ||
|
||||
!target ||
|
||||
!/^[a-z0-9][a-z0-9._-]*$/u.test(target.package) ||
|
||||
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(runtime.version) ||
|
||||
!/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(target.integrity)
|
||||
) {
|
||||
throw new Error('Locked OpenCode Runtime metadata is invalid')
|
||||
}
|
||||
const archive = `${target.package}-${runtime.version}.tgz`
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
[
|
||||
`package=${target.package}`,
|
||||
`version=${runtime.version}`,
|
||||
`integrity=${target.integrity}`,
|
||||
`archive=${archive}`,
|
||||
`cache-key=${hash('sha256').update(target.integrity).digest('hex')}`,
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
NODE
|
||||
|
||||
- name: Cache verified OpenCode Runtime input
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-opencode-runtime
|
||||
key: goodbuddy-agent-ci-opencode-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.opencode.outputs.cache-key }}
|
||||
|
||||
- name: Acquire and verify locked OpenCode Runtime
|
||||
shell: bash
|
||||
env:
|
||||
OPENCODE_ARCHIVE: ${{ steps.opencode.outputs.archive }}
|
||||
OPENCODE_INTEGRITY: ${{ steps.opencode.outputs.integrity }}
|
||||
OPENCODE_PACKAGE: ${{ steps.opencode.outputs.package }}
|
||||
OPENCODE_VERSION: ${{ steps.opencode.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
directory="$RUNNER_TEMP/goodbuddy-opencode-runtime"
|
||||
archive="$directory/$OPENCODE_ARCHIVE"
|
||||
mkdir -p "$directory"
|
||||
if [[ ! -f "$archive" ]]; then
|
||||
npm pack \
|
||||
--ignore-scripts \
|
||||
--registry https://registry.npmjs.org/ \
|
||||
--pack-destination "$directory" \
|
||||
"$OPENCODE_PACKAGE@$OPENCODE_VERSION"
|
||||
fi
|
||||
node - "$archive" "$OPENCODE_INTEGRITY" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync, statSync } = require('node:fs')
|
||||
const [archive, expected] = process.argv.slice(2)
|
||||
const size = statSync(archive).size
|
||||
if (size <= 0 || size > 536870912) {
|
||||
throw new Error('OpenCode Runtime archive exceeds its safety limit')
|
||||
}
|
||||
const actual = `sha512-${createHash('sha512')
|
||||
.update(readFileSync(archive))
|
||||
.digest('base64')}`
|
||||
if (actual !== expected) {
|
||||
throw new Error('OpenCode Runtime archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Run native Agent regressions
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
npm test -- \
|
||||
src/agent-daemon/model-bridge.test.ts \
|
||||
src/agent-daemon/lifecycle.test.ts
|
||||
|
||||
- name: Run native macOS process regressions
|
||||
if: matrix.platform == 'darwin'
|
||||
run: npm test -- src/agent-daemon/darwin-process-identity.test.ts
|
||||
|
||||
- name: Build and verify ephemeral signed compound Agent package twice
|
||||
id: agent
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
NODE_ARCHIVE: ${{ steps.runtime.outputs.archive }}
|
||||
OPENCODE_ARCHIVE: ${{ steps.opencode.outputs.archive }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
directory="$RUNNER_TEMP/goodbuddy-agent-ci"
|
||||
rm -rf "$directory"
|
||||
mkdir -p "$directory"
|
||||
agent_version="$(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
node build/agent-ci-bundle.cjs \
|
||||
--platform "$AGENT_PLATFORM" \
|
||||
--arch "$AGENT_ARCH" \
|
||||
--node-archive "$RUNNER_TEMP/goodbuddy-agent-node/$NODE_ARCHIVE" \
|
||||
--opencode-archive "$RUNNER_TEMP/goodbuddy-opencode-runtime/$OPENCODE_ARCHIVE" \
|
||||
--output-directory "$directory/bundle" \
|
||||
--archive "$directory/goodbuddy-agent-$agent_version-$AGENT_PLATFORM-$AGENT_ARCH.gbagent" |
|
||||
tee "$directory/result.json"
|
||||
node - <<'NODE'
|
||||
const { appendFileSync, readFileSync } = require('node:fs')
|
||||
const result = JSON.parse(
|
||||
readFileSync(
|
||||
`${process.env.RUNNER_TEMP}/goodbuddy-agent-ci/result.json`,
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
if (
|
||||
result.architecture !== process.env.AGENT_ARCH ||
|
||||
result.platform !== process.env.AGENT_PLATFORM ||
|
||||
!/^[a-f0-9]{64}$/u.test(result.archiveSha256)
|
||||
) {
|
||||
throw new Error('Agent CI result is invalid')
|
||||
}
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`sha256=${result.archiveSha256}\n`
|
||||
)
|
||||
NODE
|
||||
|
||||
- name: Record verified Agent build
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
AGENT_SHA256: ${{ steps.agent.outputs.sha256 }}
|
||||
run: |
|
||||
{
|
||||
echo "### $AGENT_PLATFORM $AGENT_ARCH Agent build"
|
||||
echo
|
||||
echo "Native compound Agent, Node, and OpenCode Runtime build, test-signature verification, launch smoke, and deterministic archive comparison passed."
|
||||
echo
|
||||
echo "Archive SHA-256: \`$AGENT_SHA256\`"
|
||||
echo
|
||||
echo "The ephemeral CI key and bundle are not published."
|
||||
echo "Native build checks do not replace real Host installation, Attach, and Ask/Execute acceptance."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.19.0
|
||||
cache: npm
|
||||
|
||||
- name: Verify release tag version
|
||||
@@ -50,7 +50,9 @@ jobs:
|
||||
run: npm run build:bundle
|
||||
|
||||
- name: Upload production bundle
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref_type == 'tag'
|
||||
if: >-
|
||||
github.ref_type == 'tag' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: goodbuddy-production-bundle
|
||||
@@ -58,10 +60,67 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
authorize-release:
|
||||
name: Authorize desktop packaging
|
||||
if: >-
|
||||
github.ref_type == 'tag' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
authorized: ${{ steps.policy.outputs.authorized }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify immutable release ref policy
|
||||
id: policy
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
git merge-base --is-ancestor \
|
||||
"$GITHUB_SHA" refs/remotes/origin/main
|
||||
|
||||
case "$GITHUB_EVENT_NAME" in
|
||||
push)
|
||||
test "$GITHUB_REF_TYPE" = "tag"
|
||||
test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).created)")" = "true"
|
||||
test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).forced)")" = "false"
|
||||
test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).deleted)")" = "false"
|
||||
expected="v$(node -p "require('./package.json').version")"
|
||||
test "$GITHUB_REF" = "refs/tags/$expected"
|
||||
test "$GITHUB_REF_NAME" = "$expected"
|
||||
git fetch --no-tags origin \
|
||||
"+refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = "tag"
|
||||
test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA"
|
||||
;;
|
||||
workflow_dispatch)
|
||||
test "$GITHUB_REF" = "refs/heads/main"
|
||||
test "$GITHUB_REF_TYPE" = "branch"
|
||||
test "$GITHUB_SHA" = "$(git rev-parse refs/remotes/origin/main)"
|
||||
;;
|
||||
*)
|
||||
echo "Desktop packaging is limited to immutable version-tag pushes or a manual run of refs/heads/main." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
package:
|
||||
name: ${{ matrix.platform }} ${{ matrix.arch }}
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref_type == 'tag'
|
||||
needs: validate
|
||||
if: >-
|
||||
needs.authorize-release.result == 'success' &&
|
||||
needs.authorize-release.outputs.authorized == 'true'
|
||||
needs:
|
||||
- validate
|
||||
- authorize-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -71,7 +130,7 @@ jobs:
|
||||
runner: windows-2025
|
||||
- platform: windows
|
||||
arch: arm64
|
||||
runner: windows-2025
|
||||
runner: windows-11-vs2026-arm
|
||||
- platform: macos
|
||||
arch: x64
|
||||
runner: macos-15-intel
|
||||
@@ -92,7 +151,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.19.0
|
||||
cache: npm
|
||||
|
||||
- name: Cache packaging toolsets
|
||||
@@ -115,7 +174,121 @@ jobs:
|
||||
name: goodbuddy-production-bundle
|
||||
path: out
|
||||
|
||||
- name: Build and verify release packages
|
||||
- name: Verify native runner architecture
|
||||
run: node -e "if(process.arch !== '${{ matrix.arch }}'){throw new Error('Expected ${{ matrix.arch }} runner, received '+process.arch)}"
|
||||
|
||||
- name: Install and validate managed Python
|
||||
run: npm exec vitest run tests/local-tool-environment-live.test.ts
|
||||
env:
|
||||
GOODBUDDY_RUN_LIVE_TOOL_ENVIRONMENT: '1'
|
||||
|
||||
- name: Install RPM packaging tools
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends --yes rpm
|
||||
rpmbuild --version
|
||||
|
||||
- name: Resolve macOS signing mode
|
||||
id: macos-signing
|
||||
if: matrix.platform == 'macos'
|
||||
shell: bash
|
||||
env:
|
||||
MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }}
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
names=(
|
||||
MACOS_CERTIFICATE_BASE64
|
||||
MACOS_CERTIFICATE_PASSWORD
|
||||
APPLE_API_KEY_BASE64
|
||||
APPLE_API_KEY_ID
|
||||
APPLE_API_ISSUER
|
||||
)
|
||||
configured=0
|
||||
missing=()
|
||||
for name in "${names[@]}"; do
|
||||
if [[ -n "${!name}" ]]; then
|
||||
configured=$((configured + 1))
|
||||
else
|
||||
missing+=("$name")
|
||||
fi
|
||||
done
|
||||
if [[ "$configured" -eq 0 ]]; then
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning title=Unsigned macOS packages::Apple signing credentials are not configured. The macOS DMG and ZIP will be unsigned and unnotarized."
|
||||
{
|
||||
echo "### macOS signing"
|
||||
echo
|
||||
echo "Apple signing credentials are not configured. This target produces unsigned and unnotarized packages that Gatekeeper may block on first launch."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$configured" -ne "${#names[@]}" ]]; then
|
||||
missing_names="$(IFS=,; echo "${missing[*]}")"
|
||||
echo "::error title=Incomplete macOS signing credentials::Missing: $missing_names"
|
||||
exit 1
|
||||
fi
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
printf '%s' "$MACOS_CERTIFICATE_BASE64" | base64 -D > "$RUNNER_TEMP/goodbuddy-developer-id.p12"
|
||||
printf '%s' "$APPLE_API_KEY_BASE64" | base64 -D > "$RUNNER_TEMP/AuthKey.p8"
|
||||
test -s "$RUNNER_TEMP/goodbuddy-developer-id.p12"
|
||||
test -s "$RUNNER_TEMP/AuthKey.p8"
|
||||
chmod 600 "$RUNNER_TEMP/goodbuddy-developer-id.p12" "$RUNNER_TEMP/AuthKey.p8"
|
||||
{
|
||||
echo "### macOS signing"
|
||||
echo
|
||||
echo "Complete Apple signing credentials were detected. This target will be signed, notarized, and verified."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build, sign and notarize macOS release packages
|
||||
if: matrix.platform == 'macos' && steps.macos-signing.outputs.enabled == 'true'
|
||||
run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --format dmg --skip-build
|
||||
env:
|
||||
ELECTRON_CACHE: ${{ runner.temp }}/electron
|
||||
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder
|
||||
CSC_LINK: ${{ runner.temp }}/goodbuddy-developer-id.p12
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
|
||||
APPLE_API_KEY: ${{ runner.temp }}/AuthKey.p8
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
|
||||
- name: Build unsigned macOS release packages
|
||||
if: matrix.platform == 'macos' && steps.macos-signing.outputs.enabled == 'false'
|
||||
run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --format dmg --skip-build --unsigned
|
||||
env:
|
||||
ELECTRON_CACHE: ${{ runner.temp }}/electron
|
||||
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
|
||||
- name: Verify macOS signature and notarization ticket
|
||||
if: matrix.platform == 'macos' && steps.macos-signing.outputs.enabled == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dmg="$(find "dist/release/macos-${{ matrix.arch }}" -maxdepth 1 -type f -name '*.dmg' -print -quit)"
|
||||
test -n "$dmg"
|
||||
mount_point="$RUNNER_TEMP/goodbuddy-dmg"
|
||||
mkdir "$mount_point"
|
||||
cleanup() {
|
||||
hdiutil detach "$mount_point" -quiet || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_point" -quiet
|
||||
app="$(find "$mount_point" -maxdepth 1 -type d -name '*.app' -print -quit)"
|
||||
test -n "$app"
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
spctl --assess --type execute --verbose=4 "$app"
|
||||
xcrun stapler validate "$app"
|
||||
|
||||
- name: Build and verify non-macOS release packages
|
||||
if: matrix.platform != 'macos'
|
||||
run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-build
|
||||
env:
|
||||
ELECTRON_CACHE: ${{ runner.temp }}/electron
|
||||
@@ -131,14 +304,22 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||
needs: package
|
||||
name: Publish GitHub and OSS release
|
||||
if: >-
|
||||
github.event_name == 'push' &&
|
||||
github.ref_type == 'tag' &&
|
||||
needs.authorize-release.outputs.authorized == 'true'
|
||||
needs:
|
||||
- authorize-release
|
||||
- package
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 35
|
||||
environment:
|
||||
name: aliyun-oss-release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -147,7 +328,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.19.0
|
||||
|
||||
- name: Verify release tag
|
||||
shell: bash
|
||||
@@ -181,7 +362,112 @@ jobs:
|
||||
- name: Verify and aggregate release assets
|
||||
run: node build/aggregate-release.cjs --input dist/release-downloads --output dist/release-upload
|
||||
|
||||
- name: Create or update draft release
|
||||
- name: Verify OSS release configuration
|
||||
shell: bash
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
OIDC_PROVIDER_ARN: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }}
|
||||
ROLE_ARN: ${{ vars.ALIYUN_ROLE_ARN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$OSS_BUCKET"
|
||||
test -n "$OSS_ENDPOINT"
|
||||
test -n "$OIDC_PROVIDER_ARN"
|
||||
test -n "$ROLE_ARN"
|
||||
case "$OSS_BUCKET" in
|
||||
goodbuddy) ;;
|
||||
*) echo "OSS Bucket 必须与应用内置镜像地址一致" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$OSS_ENDPOINT" in
|
||||
https://oss-cn-beijing.aliyuncs.com) ;;
|
||||
*) echo "OSS Endpoint 必须与应用内置镜像地址一致" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$OIDC_PROVIDER_ARN" in
|
||||
acs:ram::*:oidc-provider/*) ;;
|
||||
*) echo "OIDC Provider ARN 无效" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$ROLE_ARN" in
|
||||
acs:ram::*:role/*) ;;
|
||||
*) echo "RAM Role ARN 无效" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Authenticate to Alibaba Cloud
|
||||
uses: aliyun/configure-aliyun-credentials-action@v1
|
||||
with:
|
||||
role-to-assume: ${{ vars.ALIYUN_ROLE_ARN }}
|
||||
oidc-provider-arn: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }}
|
||||
role-session-name: goodbuddy-release-${{ github.run_id }}
|
||||
role-session-expiration: 3600
|
||||
audience: sts.aliyuncs.com
|
||||
|
||||
- name: Install ossutil
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="2.3.0"
|
||||
archive="$RUNNER_TEMP/ossutil.zip"
|
||||
directory="$RUNNER_TEMP/ossutil"
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://gosspublic.alicdn.com/ossutil/v2/$version/ossutil-$version-linux-amd64.zip" \
|
||||
--output "$archive"
|
||||
mkdir "$directory"
|
||||
unzip -q "$archive" -d "$directory"
|
||||
binary="$(find "$directory" -type f -name ossutil -print -quit)"
|
||||
test -n "$binary"
|
||||
chmod +x "$binary"
|
||||
echo "$(dirname "$binary")" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Prepare OSS website release index
|
||||
id: oss-release
|
||||
shell: bash
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
endpoint_host="${OSS_ENDPOINT#https://}"
|
||||
oss_region="${endpoint_host#oss-}"
|
||||
oss_region="${oss_region%.aliyuncs.com}"
|
||||
case "$oss_region" in
|
||||
*[!a-z0-9-]*|'') echo "无法从 OSS Endpoint 推导 Region" >&2; exit 1 ;;
|
||||
esac
|
||||
base_url="https://${OSS_BUCKET}.${endpoint_host}/releases/${GITHUB_REF_NAME}/"
|
||||
node build/create-site-release.cjs \
|
||||
--manifest dist/release-upload/release-manifest.json \
|
||||
--base-url "$base_url" \
|
||||
--output dist/site-release.json
|
||||
echo "base-url=$base_url" >> "$GITHUB_OUTPUT"
|
||||
echo "region=$oss_region" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload immutable release assets to OSS
|
||||
shell: bash
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
OSS_REGION: ${{ steps.oss-release.outputs.region }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
|
||||
export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET"
|
||||
export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN"
|
||||
test -n "$OSS_ACCESS_KEY_ID"
|
||||
test -n "$OSS_ACCESS_KEY_SECRET"
|
||||
test -n "$OSS_SESSION_TOKEN"
|
||||
test -n "$OSS_REGION"
|
||||
for file in dist/release-upload/* dist/site-release.json; do
|
||||
name="$(basename "$file")"
|
||||
ossutil cp "$file" \
|
||||
"oss://${OSS_BUCKET}/releases/${GITHUB_REF_NAME}/${name}" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--region "$OSS_REGION" \
|
||||
--update
|
||||
done
|
||||
|
||||
- name: Verify public OSS release assets
|
||||
run: node build/verify-site-release.cjs --manifest dist/site-release.json
|
||||
|
||||
- name: Create or update draft GitHub release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -196,3 +482,21 @@ jobs:
|
||||
fi
|
||||
gh release upload "$tag" dist/release-upload/* --clobber
|
||||
gh release edit "$tag" --draft=false --latest
|
||||
|
||||
- name: Point website to verified OSS release
|
||||
shell: bash
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
OSS_REGION: ${{ steps.oss-release.outputs.region }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
|
||||
export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET"
|
||||
export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN"
|
||||
test -n "$OSS_REGION"
|
||||
ossutil cp dist/site-release.json \
|
||||
"oss://${OSS_BUCKET}/releases/latest.json" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--region "$OSS_REGION" \
|
||||
--force
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Deploy website to GitHub Pages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'sites/**'
|
||||
- '.github/workflows/pages.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy static website
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Validate website
|
||||
run: |
|
||||
node sites/scripts/validate.mjs
|
||||
node --check sites/app.js
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload website artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: sites
|
||||
|
||||
- name: Deploy website
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -1,11 +1,17 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
# Local build output left by the extracted ShareServer project.
|
||||
shareserver/build-server/
|
||||
coverage/
|
||||
.vite/
|
||||
.runtime-resources/
|
||||
.remote-runtime-resources/
|
||||
.agent-resources/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -4,9 +4,72 @@
|
||||
|
||||
These instructions apply to the entire repository.
|
||||
|
||||
GoodBuddy is a secure, cross-platform Electron desktop assistant. It uses
|
||||
Electron, React, TypeScript, Vite, Vitest, and SQLite. User-facing copy is
|
||||
primarily Simplified Chinese.
|
||||
GoodBuddy is a cross-platform Electron desktop assistant for internal
|
||||
organizational networks. It uses Electron, React, TypeScript, Vite, Vitest, and
|
||||
SQLite. User-facing copy is primarily Simplified Chinese.
|
||||
|
||||
## Product Priorities
|
||||
|
||||
GoodBuddy is primarily an internal-network product. Unless the user states
|
||||
otherwise, assume a trusted organizational environment, not a hostile public,
|
||||
multi-tenant, or zero-trust deployment.
|
||||
|
||||
Use this order when requirements or implementation choices compete:
|
||||
|
||||
1. Complete, working product functionality.
|
||||
2. Measured performance and responsiveness.
|
||||
3. Clear, low-friction user experience.
|
||||
4. Simple, conventional, maintainable implementation.
|
||||
5. Security controls only for a concrete risk in the actual deployment model.
|
||||
|
||||
Functionality and performance are the primary acceptance criteria. Do not
|
||||
disable, delay, restrict, or complicate a working product path for speculative
|
||||
security hardening. Preserve the existing Electron, credential, user-data, and
|
||||
release-signing boundaries, but do not expand them without a concrete current
|
||||
risk or an explicit user requirement.
|
||||
|
||||
## Design Simplicity
|
||||
|
||||
Follow KISS and YAGNI. Use the smallest conventional design that completes the
|
||||
real user workflow.
|
||||
|
||||
- A demonstrated failure authorizes only the smallest proportional fix, not a
|
||||
general recovery, compatibility, or security framework.
|
||||
- Prefer deleting bounded staging and retrying, idempotent operations, and
|
||||
reconciliation from one authoritative source of truth. Do not persist copies
|
||||
of state that can be derived or safely retried.
|
||||
- Do not add durable snapshots, journals, versioned internal receipts, rollback
|
||||
engines, compatibility readers, multi-phase state machines, protocol phases,
|
||||
cryptographic identities, trust tiers, consent ceremonies, or quotas when
|
||||
cleanup, retry, or reconciliation is safe.
|
||||
- Before introducing any of that machinery, stop before coding. Explain the
|
||||
reproducible current failure, why the simple alternative is insufficient,
|
||||
and obtain the user's explicit approval for the added complexity. A reviewer
|
||||
suggestion, test-only scenario, hypothetical crash, or possible future need
|
||||
is not sufficient evidence.
|
||||
- Performance outranks simplicity only for a measured bottleneck. Security
|
||||
complexity requires a concrete threat at an actual trust boundary.
|
||||
- Ignore hypothetical threats that require a hostile public network,
|
||||
untrusted tenants, or zero-trust deployment unless that environment is an
|
||||
explicit product requirement. Do not turn such findings into implementation
|
||||
work.
|
||||
- A complete workflow means the normal production path plus demonstrated
|
||||
failure cases. It does not require exhaustive recovery from every
|
||||
hypothetical interruption point.
|
||||
- Test Hosts are test environments. Do not turn their workflows into production
|
||||
compliance exercises.
|
||||
|
||||
When the user asks to implement a feature, deliver the complete usable user
|
||||
workflow unless the user explicitly requests a prototype, scaffold, staged
|
||||
landing, or partial implementation. Internal contracts, managers, protocols,
|
||||
disabled UI, unavailable catalog entries, placeholders, and tests around
|
||||
injected fakes do not count as completion when the real product path still
|
||||
cannot be used. If required metadata, dependencies, or integration details are
|
||||
missing, investigate and resolve them as part of the implementation. Do not
|
||||
turn missing implementation work into a product "security gate", permanent
|
||||
unavailable state, or acceptance criterion. If a genuine external blocker
|
||||
cannot be resolved, stop and ask the user rather than presenting the partial
|
||||
work as the implemented feature.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -19,7 +82,8 @@ primarily Simplified Chinese.
|
||||
- `build`: packaging scripts and icons.
|
||||
- `out` and `dist`: generated output. Change source files instead.
|
||||
|
||||
Keep Electron security boundaries intact:
|
||||
Keep these existing Electron security boundaries intact. They do not authorize
|
||||
additional hardening beyond the actual internal-network threat model:
|
||||
|
||||
- Preserve context isolation and sandboxing.
|
||||
- Never enable renderer Node integration.
|
||||
@@ -30,10 +94,25 @@ Keep Electron security boundaries intact:
|
||||
## Runtime Behavior
|
||||
|
||||
- Ask mode must remain read-only at the runtime boundary.
|
||||
- Execute mode may use tools only through the existing approval controls.
|
||||
- Execute mode is the user's full authorization for the selected SSH account.
|
||||
It may use all tools, processes, network access, and writable paths available
|
||||
to that account. Do not add T2/T3 trust tiers, separate consent checklists,
|
||||
per-tool approvals, or a second "controlled execution" concept.
|
||||
- Preserve cancellation, timeout, bounded-output, and shutdown behavior.
|
||||
- Treat OpenCode and Continue as untrusted child runtimes. Preserve environment
|
||||
allowlists, sandbox checks, and per-tool approval enforcement.
|
||||
- When fixing Runtime behavior or limits, also inspect the remote Runtime path
|
||||
through GoodBuddy Agent (`gbagent`). Verify whether local and remote execution
|
||||
actually share the affected control logic; do not assume that a desktop fix
|
||||
covers the Agent, bridge, launcher, or bundled Runtime. Fix any affected
|
||||
remote-specific implementation and validate it under the GoodBuddy Agent
|
||||
development validation rules below. If no separate remote fix is needed,
|
||||
record which shared implementation covers it or why the remote path is
|
||||
unaffected.
|
||||
- Keep model/provider credentials in Main, but do not otherwise reduce Execute
|
||||
permissions with extra product policy gates. Keep child-process cleanup
|
||||
straightforward and reliable.
|
||||
- On test Hosts, GoodBuddy may install and start its own test resources and
|
||||
create dedicated work directories. Never delete or overwrite unrelated Host
|
||||
files.
|
||||
- A successful image-model configuration check does not prove generation works.
|
||||
Only an actual generation request verifies the provider path.
|
||||
- Do not fetch provider-returned image URLs. Accept and validate bounded inline
|
||||
@@ -41,7 +120,16 @@ Keep Electron security boundaries intact:
|
||||
|
||||
## Data and Compatibility
|
||||
|
||||
- Preserve existing user data and migrations.
|
||||
- Before changing released database schemas or large-data migrations, read
|
||||
[`docs/development/database-migrations.md`](./docs/development/database-migrations.md).
|
||||
Keep feature-specific storage rules and validation evidence in the owning
|
||||
feature documents.
|
||||
|
||||
- Preserve released user-owned data, migrations required by released data, and
|
||||
supported external contracts.
|
||||
- Compatibility does not apply to unshipped, branch-local, or safely disposable
|
||||
internal operation records. Replace or discard those records instead of
|
||||
adding migrations, legacy readers, versioned receipts, or recovery machinery.
|
||||
- Do not weaken SQLite transaction, lifecycle, deduplication, or cleanup logic.
|
||||
- Treat user workspaces and untracked files as user-owned.
|
||||
- Keep Windows, macOS, Linux x64, and Linux arm64 behavior in mind.
|
||||
@@ -52,12 +140,53 @@ Keep Electron security boundaries intact:
|
||||
- Follow surrounding TypeScript and React patterns.
|
||||
- Reuse installed libraries and shared contracts before adding dependencies.
|
||||
- Keep changes focused. Do not add unrelated refactors or documentation.
|
||||
- Reviewer findings do not expand task scope. Implement a finding only when it
|
||||
identifies a reachable current failure, a released compatibility contract,
|
||||
or reproducible user-visible harm. Record speculative concerns without
|
||||
building machinery around them.
|
||||
- Validate the real end-to-end path, not only schemas, mocks, injected test
|
||||
doubles, or isolated infrastructure. A feature is not complete until its
|
||||
normal UI action reaches the production implementation and produces the
|
||||
intended result.
|
||||
- Security work must remain proportional to a concrete risk and must not
|
||||
replace missing product functionality. Use established platform and
|
||||
repository mechanisms instead of inventing extra gates, ceremonies, or
|
||||
disabled states.
|
||||
- Add or update focused tests for behavioral changes and regressions.
|
||||
- Read [`docs/quality/recurring-defects.md`](./docs/quality/recurring-defects.md)
|
||||
before changing overlays, store lookups, retrieval entry points, cancellation
|
||||
behavior, or render-time derived values. It records defect types that have
|
||||
already recurred in this repository and the shared mechanism that covers each
|
||||
one. When fixing an instance of a listed type, extend that shared mechanism
|
||||
instead of patching the one reported case, and add an entry when a new defect
|
||||
type recurs.
|
||||
- After completing any functional change, inspect the affected product,
|
||||
architecture, design, feature, setup, and operational documentation and
|
||||
update every relevant document to match the implemented behavior. Treat the
|
||||
final code and validated runtime behavior as the source of truth: correct
|
||||
stale documentation rather than preserving outdated intent. Avoid
|
||||
documentation churn only when the change has no documented impact.
|
||||
- Avoid broad catches that erase HTTP status, cancellation, or provider error
|
||||
context.
|
||||
- Keep UI accessible with labels, keyboard behavior, semantic roles, and visible
|
||||
focus states.
|
||||
|
||||
## Product Design Documentation
|
||||
|
||||
- Organize feature documentation by product feature under
|
||||
`docs/features/<feature-name>/`, not by document type.
|
||||
- Treat each feature directory's `README.md` as its documentation entry point.
|
||||
- Follow `docs/features/AGENTS.md` for required document responsibilities,
|
||||
naming, traceability, progress evidence, and migration rules.
|
||||
- Keep cross-feature architecture, quality protocols, development procedures,
|
||||
and product-wide roadmaps outside feature directories only when they cannot
|
||||
be owned by one feature.
|
||||
- Maintain one authoritative definition for each requirement, state rule, UI
|
||||
behavior, technical contract, and implementation fact. Link to it instead of
|
||||
duplicating it across documents.
|
||||
- Update the affected feature documents after functional changes. The
|
||||
implemented and validated production behavior is the final source of truth.
|
||||
|
||||
## UI Consistency
|
||||
|
||||
- Treat `UI-DESIGN.md` as the canonical UI design system. Read and follow it
|
||||
@@ -84,78 +213,84 @@ Keep Electron security boundaries intact:
|
||||
- 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
|
||||
## Commit Messages and Release Notes
|
||||
|
||||
- `.github/workflows/packages.yml` is the canonical cross-platform packaging
|
||||
workflow. It validates and builds `out` once, then packages on six native
|
||||
runners: Windows, macOS, and Linux, each for x64 and arm64.
|
||||
- Run the unified packager with
|
||||
`npm run release:package -- --platform <platform> --arch <arch>`. It only
|
||||
packages for the native host and writes to
|
||||
`dist/release/<platform>-<arch>`.
|
||||
- Default deliverables are NSIS and portable ZIP for Windows, DMG and ZIP for
|
||||
macOS, and AppImage and DEB for Linux. Every target includes
|
||||
`release-manifest.json` with SHA-256 hashes.
|
||||
- `build/build-release.cjs` verifies the unpacked application, `app.asar`,
|
||||
bundled Continue and OpenCode runtimes, executable architecture, and package
|
||||
signatures before atomically replacing a release directory.
|
||||
- Keep electron-builder invocations on `--publish never`. Main-branch builds
|
||||
run validation and build the production bundle without running the native
|
||||
package matrix. Manual builds upload 30-day GitHub Actions artifacts.
|
||||
Version-tag builds verify and aggregate packages before publishing GitHub
|
||||
Release assets. Signing and macOS notarization are not configured.
|
||||
- Keep `ELECTRON_CACHE` and `ELECTRON_BUILDER_CACHE` under
|
||||
`${{ runner.temp }}` in step-level workflow contexts. A cache beneath the
|
||||
repository inherits the root `"type": "module"` and breaks electron-builder's
|
||||
CommonJS macOS icon tool.
|
||||
- Tag builds must use `v${package.version}`. The workflow also supports manual
|
||||
dispatch and main-branch changes to release tooling.
|
||||
Release notes are derived in part from commit history, so commits for
|
||||
user-visible changes must record product intent rather than only the
|
||||
implementation mechanism.
|
||||
|
||||
### Tagged Release Process
|
||||
- Classify the commit by the user-visible behavior. Use `feat` only for a
|
||||
capability users did not previously have. Use `fix` when restoring intended
|
||||
behavior, removing inconsistency, or making two existing entry points reflect
|
||||
the same underlying setting, even if the implementation adds new
|
||||
synchronization logic.
|
||||
- Keep the subject concise, then add a commit body for non-trivial user-visible
|
||||
changes. State the previous user-facing problem, the resulting behavior, and
|
||||
the affected surface or workflow. Include permissions, migration,
|
||||
compatibility, cost, data, preview-status, or other usage caveats when
|
||||
relevant.
|
||||
- Describe the user outcome precisely. Do not promote an internal refactor,
|
||||
synchronization mechanism, schema change, or newly added implementation code
|
||||
to a product feature unless it creates a genuinely new user capability.
|
||||
- When a change is release-note worthy, include a short `Release note:` line in
|
||||
the commit body written in user-facing language. Prefer a concrete usage
|
||||
scenario and benefit over technical implementation terminology.
|
||||
- Treat commit messages as evidence, not as the sole source of truth. Before
|
||||
drafting release notes, verify the diff and resulting behavior, correct any
|
||||
inaccurate `feat` or `fix` classification, and include actionable usage
|
||||
notes where the change affects defaults, synchronized settings, permissions,
|
||||
resource usage, compatibility, or user data.
|
||||
|
||||
Every version-tag release must follow this sequence. A branch-only push does
|
||||
not require release notes.
|
||||
Example:
|
||||
|
||||
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.
|
||||
```text
|
||||
fix: unify project settings across channel entry points
|
||||
|
||||
Never create or push a release tag, and never push a previously created
|
||||
release tag, before the release-note draft has received explicit approval.
|
||||
The top-left project settings and the project settings shown under messaging
|
||||
channels could present or save inconsistent values. They now edit the same
|
||||
project configuration for the project name, description, Runtime, and work
|
||||
mode.
|
||||
|
||||
- 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
|
||||
`30893805567` succeeded for validation and all six package targets, producing
|
||||
six release artifacts plus the shared production bundle.
|
||||
Release note: 修复左上角项目设置与消息通道项目设置不一致的问题;现在从任一入口修改后,另一处会同步显示相同配置。
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
Detailed release operations are governed by
|
||||
[`docs/development/release-runbook.md`](./docs/development/release-runbook.md).
|
||||
Read that runbook only when preparing, publishing, recovering, or verifying a
|
||||
desktop or GoodBuddy Agent release. It is an operator and CI procedure, not an
|
||||
authorization to add product persistence or recovery machinery.
|
||||
|
||||
Repository-wide release invariants:
|
||||
|
||||
- Write each release-note feature item as two or three clear sentences that
|
||||
identify the user situation, the available action, and the resulting
|
||||
behavior or important boundary. Do not impose an arbitrary per-item
|
||||
character limit; use the detail needed to make the user scenario clear.
|
||||
- Before approving the exact release commit and bilingual release notes,
|
||||
inspect the English `FEATURES.md` and Simplified Chinese
|
||||
`FEATURES.zh-CN.md` against the verified changes since the previous stable
|
||||
release. Keep both versions aligned, and update their provided capabilities,
|
||||
roadmap status, current versions, limitations, and remaining release
|
||||
acceptance work as needed. Include any required update in the release
|
||||
candidate; if no change is needed, explicitly confirm that both feature lists
|
||||
were reviewed and remain accurate.
|
||||
- Never create or push a release tag before the user approves the exact release
|
||||
commit and bilingual release notes.
|
||||
- Never move, delete, recreate, or reuse a release tag.
|
||||
- Keep desktop and Agent release paths, tags, artifacts, and Latest status
|
||||
separate.
|
||||
- GitHub Actions is the release build and cross-platform packaging authority.
|
||||
During release preparation, run local tests, type checks, and lint, but do
|
||||
not run `npm run build`, `npm run build:bundle`, local packaging, or
|
||||
packaged-app probes. Push the approved candidate commit first and require its
|
||||
main-branch CI validation and production build to pass before tagging it,
|
||||
unless the user explicitly requests an additional local build.
|
||||
- LoongArch is a separate experimental preview. Build or publish it only when
|
||||
the user explicitly requests it for that release.
|
||||
- Never commit or publish credentials. Production publication uses the existing
|
||||
protected environments and short-lived credentials described in the runbook.
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -167,14 +302,62 @@ npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Run `npm run build` for production build changes. Use `npm run portable` only
|
||||
when a current Windows portable package is requested. Gated runtime tests may
|
||||
make paid or external calls, so run them only with explicit authorization.
|
||||
Run `npm run build` for production build changes during development, but not
|
||||
solely as a release-preparation step; release candidates use the main-branch CI
|
||||
production build before tagging. Use `npm run portable` only when a current
|
||||
Windows portable package is requested. The user has granted standing
|
||||
authorization in this repository for bounded real text-model calls needed to
|
||||
validate changed product paths; do not ask again for each such call. Keep each
|
||||
validation call minimal, disable tools and attachments where possible, and
|
||||
report the exact call count. This standing authorization does not cover bulk,
|
||||
high-cost, destructive, publishing, messaging, purchasing, or other
|
||||
consequential external actions.
|
||||
|
||||
### GoodBuddy Agent development validation
|
||||
|
||||
Every source change that can alter the deployed GoodBuddy Agent or the
|
||||
desktop-to-Agent production path must be validated on the shared Linux x64 test
|
||||
Host while the change is being developed. This includes Agent daemon and
|
||||
protocol changes, packaging and installation, attach/update behavior, Runtime
|
||||
launch and process ownership, workspaces, model bridges, and lifecycle or
|
||||
recovery behavior. Unit tests, mocks, fixtures, CI, and waiting for release
|
||||
validation are not substitutes for this development-time check.
|
||||
|
||||
- Reach the shared Host at `192.168.0.23` on the LAN when available, otherwise
|
||||
use `10.7.0.23` over VPN. These are two routes to the same physical machine,
|
||||
not two independent test targets or architecture results.
|
||||
- Exercise the current source or package under development, not only the
|
||||
previously published Agent. Run the affected production path as soon as it is
|
||||
runnable and repeat the relevant scenario after the final change.
|
||||
- Select the real-Host scenario from the changed surface: attach/bootstrap or
|
||||
update for installation changes; Ask/Execute and a bounded real model request
|
||||
for Runtime or model-bridge changes; and the applicable disconnect, restart,
|
||||
cancellation, or reconnection sequence for lifecycle and recovery changes.
|
||||
- Use the existing pinned Host identity and GoodBuddy credential storage. Never
|
||||
add credentials to source, documentation, commands, logs, or test output, and
|
||||
never hard-code these test addresses into product behavior or defaults.
|
||||
- Keep all artifacts in GoodBuddy-owned test directories. Before stopping or
|
||||
changing a remote process, verify that it belongs to the test run; never
|
||||
modify unrelated Host files or processes.
|
||||
- During remote testing, prefer dedicated test directories under `/root/tmp`
|
||||
for temporary files. Avoid creating files under `/tmp` where possible, since
|
||||
it may be memory-backed and consume additional RAM. Set `TMPDIR` to the
|
||||
dedicated test directory when needed so test tools use it as well.
|
||||
- If neither route is reachable, report the real-Host validation as blocked and
|
||||
do not present the Agent work as complete or defer its first real validation
|
||||
to release time.
|
||||
|
||||
The detailed scenario rules are documented in
|
||||
[`docs/features/remote-host/technical-design.md`](./docs/features/remote-host/technical-design.md).
|
||||
|
||||
Before committing or pushing, inspect `git status`, `git diff`, and
|
||||
`git diff --cached`. Do not commit secrets, local databases, logs, generated
|
||||
credentials, or private user artifacts.
|
||||
|
||||
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.
|
||||
|
||||
This repository has two synchronized remotes, `origin` and `github`. Unless the
|
||||
user explicitly names a remote, every requested push must update the current
|
||||
branch on both remotes. When the user requests a release tag, push the new tag
|
||||
|
||||
@@ -31,6 +31,10 @@ npm start
|
||||
|
||||
## 质量验证
|
||||
|
||||
跨功能测试方法见[质量验证索引](./docs/quality/README.md)。桌面界面的测试分层、
|
||||
原生键盘、截图与计算样式取证、隔离启动和脱敏报告遵循
|
||||
[Electron UI 自动化测试规范](./docs/quality/electron-ui-automation.md)。
|
||||
|
||||
提交或打包前运行:
|
||||
|
||||
```bash
|
||||
@@ -51,7 +55,18 @@ npm run test:watch
|
||||
GOODBUDDY_RUN_RUNTIME_E2E=1 npm test -- src/main/agent/runtime-e2e.manual.test.ts
|
||||
```
|
||||
|
||||
该测试可能发起真实外部模型调用。测试不会输出 API Key,文件操作在临时工作区中执行。
|
||||
OpenCode/Continue 用例默认读取 `dist/harness-package-probe/win-unpacked`;也可用
|
||||
`GOODBUDDY_E2E_PACKAGED_ROOT` 指定其他已解包应用目录。该测试可能发起真实外部模型
|
||||
调用。测试不会输出 API Key,文件操作在临时工作区中执行。文件包含经 Main 回环
|
||||
broker 调用已分配自定义 MCP 的真实 OpenCode 和 Continue 用例。
|
||||
|
||||
不使用外部模型的 Runtime 原生交互回归位于
|
||||
`src/main/agent/opencode-runtime-permissions.test.ts`、
|
||||
`src/main/agent/continue-runtime-permissions.test.ts` 和
|
||||
`src/main/agent/deepseek-harness-acp-e2e.test.ts`。它们用本地确定性模型驱动真实 Runtime
|
||||
或固定 Host,覆盖工作区外访问、原生问答及 Ask 边界。OpenCode 用例默认使用开发缓存
|
||||
二进制;可通过 `GOODBUDDY_TEST_OPENCODE_BINARY` 指定已经安装的候选二进制,避免把
|
||||
仍在运行的旧缓存版本当作候选版本验证。这不替代当前源码 Agent 的 Linux Host 验收。
|
||||
|
||||
## 生产构建
|
||||
|
||||
@@ -63,6 +78,19 @@ npm run build
|
||||
|
||||
中间构建输出位于 `out`。该目录为生成内容,应修改源文件后重新构建,不要直接编辑。
|
||||
|
||||
## 图标生成
|
||||
|
||||
应用图标源文件位于 `icons`。修改源图或图标处理逻辑后运行:
|
||||
|
||||
```bash
|
||||
npm run icons
|
||||
```
|
||||
|
||||
脚本会精确裁出亮色和深色圆角卡片,清理圆角外侧背景并使用适合主题的
|
||||
边缘颜色生成透明抗锯齿,避免缩放后出现白边。它会统一更新 `build` 中的
|
||||
PNG / ICO、Renderer 图标,以及官网使用的亮色和深色品牌图标。任务栏和
|
||||
托盘图标保持透明背景。
|
||||
|
||||
## 平台打包
|
||||
|
||||
### 当前平台默认包
|
||||
@@ -111,26 +139,250 @@ npm run dist:linux:arm64
|
||||
每个 Linux 架构生成:
|
||||
|
||||
- `deb`:适用于麒麟、统信 UOS 等 Debian 系桌面。
|
||||
- `rpm`:适用于 Fedora、RHEL、Rocky Linux、openEuler 等 RPM 系桌面。
|
||||
- AppImage:适用于免安装验证和便携运行。
|
||||
|
||||
打包产物位于 `dist`。
|
||||
|
||||
所有桌面打包入口通过同一个 `electron-builder` 启动脚本运行。正常日志会隐藏
|
||||
`duplicate dependency references` 信息摘要,因为它只表示同一已解析依赖被多条
|
||||
生产依赖路径共享,并不要求构建操作者处理;调试日志仍会保留该摘要。Renderer 中
|
||||
已由 Vite 打包的 `@antv/g6` 归类为构建期依赖,不会再作为独立 Node 模块复制进桌面
|
||||
安装包。
|
||||
|
||||
以上 `dist*` 与 `portable` 命令用于普通本地桌面打包。它们会携带
|
||||
`agent-runtime-lock.json`、`remote-runtime-lock.json` 与公开的
|
||||
`agent-release-keys.json`,但所有桌面构建路径(包括本地 `portable`)都不会嵌入
|
||||
可安装的远端 Agent 或远端 Runtime payload。没有使用托管 SSH 时,无需下载任何额外
|
||||
组件,缺少远端包也不能阻塞普通桌面打包或发布。
|
||||
|
||||
“设置 > 平台功能 > 远程项目(技术预览)”列出用户数据目录中已经下载或导入的
|
||||
Linux x64、Linux arm64 和 macOS arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
|
||||
小型签名目录,显示本地版本、在线最新兼容版本以及 `有更新 / 已是最新 / 未下载 /
|
||||
校验失败`;此检查不会下载 `.gbagent`。只有用户点击“下载”或“更新到”后才传输包,
|
||||
用户也可导入或导出 `.gbagent` 离线包。
|
||||
Main 在写入缓存前校验签名目录、目录签名、大小、SHA-256、桌面最低版本、Agent 协议、
|
||||
内部 Agent/Runtime 签名和完整 payload。缓存位于 Electron `userData` 下的
|
||||
`remote-components/agent-packages`,不暴露给 Renderer。
|
||||
|
||||
公钥 registry 的信任来自严格 schema、唯一 key ID、规范 Base64、有效 Ed25519 key、
|
||||
production/test 环境和撤销列表,不来自 JSON 空白或换行。读取桌面资源、复合包和 Host
|
||||
已有 registry 时接受等价 UTF-8 JSON 格式;向 Host 写入前统一重新序列化为确定性 LF
|
||||
JSON。签名目录、包描述符、manifest、签名和 payload 的原始字节校验保持不变,包下载
|
||||
继续流式计算 SHA-256。
|
||||
|
||||
“SSH 主机 > 远程运行环境”只有一个按版本事实显示“安装远程环境”“更新远程环境”或
|
||||
“重新安装”的主按钮。次级 SegmentedControl 选择默认且不持久化的“自动”、“Host 下载”
|
||||
或“GoodBuddy 传输”:
|
||||
|
||||
- “Host 下载”使用签名目录中的固定 GitHub/北京镜像 URL、大小、SHA-256、
|
||||
有界重定向链和固定 SSH prepare channel。GoodBuddy 桌面控制面通过该通道发送有界
|
||||
one-shot installer;Host 校验目录绑定的归档大小和 SHA-256 后,由包内固定 Node 解码
|
||||
并运行 installer。
|
||||
- 显式“GoodBuddy 传输”在在线目录不可用,或本地已验证包版本不低于在线候选时优先使用
|
||||
本地包,包括版本更高的离线导入;线上存在更新且可用时仍取得线上新包。需要下载时,
|
||||
同一次用户操作会校验 SHA-256 与签名、缓存并取得 lease,再以有界流式 SFTP 上传一个
|
||||
compound archive 和归档内已验证的 bootstrap Node;约 294 MiB 的整包不会一次读入
|
||||
Main `Buffer`,Host 仍再次校验。
|
||||
- “自动”只在 operation/prepare 前执行 Host capability probe;直连明确可用才选择
|
||||
Host 下载,否则选择 GoodBuddy 传输。显式选择保持有效,prepare、commit 或 adoption
|
||||
失败后不跨 acquisition 自动 fallback。
|
||||
|
||||
两种 acquisition 都把同一签名 compound `.gbagent` 交付到固定 operation staging,随后
|
||||
共用 control-plane `prepare → commit → Agent activate/health → Runtime activate →
|
||||
finalize → cleanup`。Main 只持久化清理 operation staging 所需的 operation ID,不保存
|
||||
Agent/Runtime metadata 副本。操作中断或 adoption 失败后,下一次更新尽力清理旧暂存并
|
||||
重新 prepare,不恢复或续写旧操作。确认 adoption 后显式 cleanup。Host 在 prepare
|
||||
解包时完成一次完整 payload 校验;commit 只复核签名 metadata
|
||||
并原子发布,Agent/Runtime 注册后的 health、capabilities 和 prompt 启动不再遍历或哈希
|
||||
完整 payload。cleanup 失败不回滚已健康环境,也不阻塞下一次 fresh prepare。
|
||||
|
||||
进入“主机与远程执行”设置页只读取本地 Host 列表,不逐台连接。用户点击某台 Host 的
|
||||
“刷新版本”后才执行远端版本和直连准备能力探测。项目切换只更新本地选择,不建立 SSH
|
||||
连接;首次实际使用远程 Workspace 或 Runtime 时才建立连接。当前进程会复用该 Host 已确认
|
||||
的 Agent/Runtime identity 和 Agent 连接,Host 编辑或环境更新会定向清空它们。
|
||||
直连安装本身只依赖 SSH,不依赖已有 Agent。显式选择 Host 下载时由实际安装流程重新探测
|
||||
并报告工具、权限、空间或来源不可达等原因;本次操作不会自动改用 GoodBuddy 传输。
|
||||
新建远程项目弹窗也只读本地 Host 验证记录;目录浏览或保存才连接所选 Host。Host 卡片
|
||||
在版本相同时仍允许显式重装;损坏的 GoodBuddy-owned 同 digest 目录会先隔离、替换并在
|
||||
失败时恢复。直连 bootstrap 使用固定短命令 `exec sh -s`,脚本和有界输入经 stdin 发送,
|
||||
避免 SSH command 长度限制。
|
||||
|
||||
新增 Host 只保存并探测,不提供自动安装开关;只有用户明确点击主按钮后才传输完整包。现有
|
||||
package format v1 归档已经包含固定 Agent 和 Node,可直接用于 Host 直连;归档无需携带
|
||||
`agent/lib/package-installer.cjs`,签名目录不提供也不检查额外的 bootstrap 能力元数据。
|
||||
首次 bootstrap 不依赖已安装 Agent daemon。目标契约见
|
||||
[SSH Host 远程环境准备与直连下载设计](./docs/features/remote-host/environment-provisioning-technical-design.md)。
|
||||
|
||||
删除 Host 或远程项目属于纯本地管理操作。Host 确认框列出全部关联项目记录并级联清理
|
||||
本地数据库,但不连接 Host、不检查远端目录,也不删除远端文件;Host 设置文件和项目事务
|
||||
的普通失败会回滚已写入的一侧。
|
||||
|
||||
## Runtime 资源
|
||||
|
||||
发布包会携带经过版本与完整性校验的 OpenCode 和 Continue Runtime:
|
||||
桌面包会携带经过版本与完整性校验的 OpenCode、Continue 和 DSH 插件安装 Runtime:
|
||||
|
||||
- OpenCode 平台二进制来自 `.runtime-resources/<arch>`。
|
||||
- Desktop 的离线配置复制以 `.runtime-resources` 为源、`opencode-config{,/**/*}` 为过滤器,避免 electron-builder 对复制源根目录下 `node_modules` 的固定排除。最终包逐文件核对离线配置与依赖;DSH bootstrap 和 chunk 不得残留外部 `@deepseek-ai/*` 导入。Windows x64 CI 在删除解包目录前实际导入包内 OpenCode 插件并运行隔离的 DSH UtilityProcess 握手与 npm 探针,失败即停止发布。
|
||||
- OpenCode 离线配置位于 `.runtime-resources/opencode-config`,构建时按已安装 `@opencode-ai/plugin/package.json` 的版本复用,不依赖根 lockfile 或自定义 ready/integrity 标记。缺失、不可读或版本不匹配时,在 staging 中执行精确版本 npm 安装,成功后替换缓存;安装失败保留旧缓存并清理 staging。本机共享配置同样比较随包插件与缓存插件的版本,版本不同时通过 staging 替换。
|
||||
- 离线依赖安装禁用 npm bin links;远程 Runtime 组包排除不使用的 `.bin` 命令链接,并把复制后的普通依赖文件统一为 `0644`,OpenCode 入口仍为 `0755`。目录元数据、每个依赖文件及许可证继续纳入签名校验。
|
||||
- Continue Runtime 来自锁定版本的 `@continuedev/cli`。
|
||||
- DSH 插件安装使用精确锁定并从 `app.asar` 解包的 npm CLI,通过当前 Electron 的 Node 模式运行;最终用户不需要另装 Node.js 或 npm。
|
||||
- DSH 图片输入使用精确锁定的 `@napi-rs/canvas` 完整解码 JPEG/PNG。通用包与目标平台、目标架构的 Skia 原生包必须从 `app.asar` 解包;当打包 Runner 的架构与目标架构不同时,发布脚本会根据 lockfile 的精确版本、下载地址和 integrity 临时暂存目标原生包,完成后清理。发布校验会检查版本、目标架构和 MIT 许可证。
|
||||
- 打包钩子位于 `build/runtime-hooks.cjs`。
|
||||
|
||||
跨架构打包前,确认目标架构的 OpenCode 资源已经准备完成。不要用其他架构的二进制替代目标资源。
|
||||
|
||||
GoodBuddy 管理的本机 OpenCode 启动后会轮询已认证的 loopback health 接口,最多等待
|
||||
30 秒。该窗口用于兼容 Linux ARM 等较慢环境;不要改回依赖 stdout 文案或 10 秒固定上限。
|
||||
聊天中的 Runtime 切换只执行路径和模型凭据就绪检查,不为状态显示启动临时 Server。
|
||||
设置中的显式连接测试、原生能力清单读取和首次实际请求仍会启动 OpenCode,并继续使用
|
||||
上述完整健康检查。
|
||||
|
||||
## GoodBuddy Agent 工件
|
||||
|
||||
面向用户发布的是按远端 Host 架构区分的复合 `.gbagent`,每个包同时包含签名
|
||||
Agent、固定 Node 和当前桌面源码维护并适配的签名 OpenCode Runtime。Runtime 不建立
|
||||
独立的用户版本流。远程直连所需的有界 one-shot installer 由桌面控制面打包并通过固定
|
||||
SSH prepare channel 发送,不是 `.gbagent` 的必需条目。Host 按签名目录校验完整归档
|
||||
大小和 SHA-256 后,包内固定 Node 解码并运行 installer。两种 acquisition 都在固定
|
||||
operation staging 中执行同一 prepare/commit;本地 pending record 只保存清理暂存所需的
|
||||
operation ID。失败或中断后的下一次更新尽力清理旧暂存并重新 prepare,不保存或恢复
|
||||
Agent/Runtime metadata 副本。生产构建命令为:
|
||||
|
||||
```bash
|
||||
node build/agent-package.cjs build \
|
||||
--arch <x64|arm64> \
|
||||
--minimum-desktop-version <desktop-version> \
|
||||
--node-archive <locked-node-runtime.tar.gz> \
|
||||
--runtime-archive <locked-opencode-package.tgz> \
|
||||
--output goodbuddy-agent-<agent-version>-linux-<arch>.gbagent
|
||||
```
|
||||
|
||||
底层 `agent:build/import/verify` 与 `remote-runtime:build/import/verify` 命令继续用于
|
||||
开发、签名和复合包组装验证;其 `.agent-resources` 与
|
||||
`.remote-runtime-resources` 输出都是不提交的生成内容,也不会被任何桌面打包命令发现
|
||||
或嵌入。
|
||||
|
||||
### Agent 原生 CI 策略
|
||||
|
||||
Agent 源码、共享协议、lock、bundle 工具和测试与桌面应用保持在同一仓库、同一 commit;
|
||||
不维护长期分叉的 Agent 源码分支。`.github/workflows/agents.yml` 在相关 Pull Request、
|
||||
`main` push 和手工触发时,分别使用 `ubuntu-24.04` 与 `ubuntu-24.04-arm` 原生构建
|
||||
Linux x64/arm64 复合包,并使用 `macos-15` 原生构建 Darwin arm64
|
||||
复合包。所有 job 从 `agent-runtime-lock.json` 解析官方 Node HTTPS
|
||||
地址与 digest,并从 `remote-runtime-lock.json` 解析固定 OpenCode npm 包与 integrity;
|
||||
下载后再次校验,随后使用仅存在于进程内的临时 Ed25519 测试 key 构建两次,完成内外层
|
||||
测试签名验证、Agent CLI 加载 smoke 和确定性 `.gbagent` 对比。所有目标还验证
|
||||
Koffi 原生模块加载与 OpenCode `--version`;Linux job 继续运行 Agent 原生回归。
|
||||
|
||||
Darwin arm64 的 Node 和 OpenCode 固定输入分别由同一份
|
||||
`agent-runtime-lock.json` 与 `remote-runtime-lock.json` 提供,不再维护 CI 专用副本。
|
||||
组包器区分 ELF 与 Mach-O,Darwin CI 产物命名为
|
||||
`goodbuddy-agent-<version>-darwin-arm64.gbagent`,不构建 Intel Mac 目标。
|
||||
macOS Host 继续通过 SSH 按需启动 detached Agent,不注册 launchd 或开机服务。
|
||||
进程身份使用 macOS 原生接口,Unix Socket 使用 `getpeereid`;Host 下载兼容系统自带
|
||||
`shasum -a 256`,不要求安装 GNU coreutils。真实 Host 验证记录见
|
||||
[远程主机技术设计](./docs/features/remote-host/technical-design.md)。
|
||||
|
||||
三个目标共用 `agent-catalog.json` 和现有签名格式,条目按平台与架构区分。旧版 Desktop
|
||||
只接受 Linux catalog 条目;首次发布包含 Darwin 的累计 catalog 前必须先发布新版
|
||||
Desktop,并在发布说明中要求用户升级 Desktop。旧版的在线 Agent 目录检查会失败,
|
||||
本地已验证包不被删除。不得为此改写历史包或复用发布标签。
|
||||
|
||||
该验证 workflow 不读取 production signing secret,不修改公开 key registry,也不上传
|
||||
可安装 Agent 工件。`.github/workflows/agent-release.yml` 是唯一 production Agent
|
||||
发布路径:只接受指向受保护 `main` 历史的 annotated
|
||||
`agent-v${agent-runtime-lock.agentVersion}` 标签,在 `agent-signing` Environment 中
|
||||
原生构建 Linux x64、Linux arm64、macOS arm64 三个目标,生成并签名累计目录,然后同步到非 Latest 的 GitHub Agent Release
|
||||
与北京 OSS。该 Environment 只保护一组 GoodBuddy 通用发布变量与 Secret:
|
||||
|
||||
- `GOODBUDDY_SIGNING_KEY_ID` /
|
||||
`GOODBUDDY_SIGNING_PRIVATE_KEY`
|
||||
|
||||
该 GoodBuddy 身份统一签署内部 Agent、固定 Runtime manifest、外层 `.gbagent`
|
||||
描述符和累计目录,各层通过既有签名域区分用途,不再要求内部 Runtime 单独配置生产
|
||||
身份。私钥只注入签名步骤;预检只读取 key ID 并确认公钥已在
|
||||
`resources/agent-release-keys.json` 注册为 production 且未撤销。
|
||||
|
||||
累计目录使用 `agent-catalog.json` 与 `agent-catalog.sig`。每个条目绑定 Agent 版本、
|
||||
最低桌面版本、Agent 协议、远端 OpenCode 版本/digest、架构、文件名、大小、SHA-256
|
||||
以及固定 GitHub/OSS URL。同一版本和架构的字节不可改变;目录最多保留 200 个条目。
|
||||
OSS 先写 `agent-releases/v<version>/` 不可变对象,GitHub Release 公开且验证后才更新
|
||||
单一 `agent-releases/latest.json` 指针。该指针只引用同一不可变版本目录中的
|
||||
`agent-catalog.json` 与 `agent-catalog.sig`,避免客户端观察到跨版本组合。Agent Release 必须使用
|
||||
`--latest=false`,不得改变桌面 Release 的 Latest 标记。
|
||||
|
||||
### 远程 OpenCode Runtime 基础
|
||||
|
||||
`remote-runtime-lock.json` 独立锁定首个远程 Runtime:OpenCode 1.18.29 的 Linux x64
|
||||
baseline 与 arm64 官方包 integrity、`bin/opencode` 入口和固定 `acp` 参数。它不同于
|
||||
`agent-runtime-lock.json`,后者锁定 Agent 自带 Node 以及用于 Linux `SO_PEERCRED`
|
||||
的 Koffi 版本。Agent bundle 将 Koffi loader 作为 external module,并只携带目标
|
||||
Linux 架构的 glibc/musl 原生 binding;构建与导入都会校验其 ELF 架构和 MIT 许可证。
|
||||
|
||||
远程 Runtime 工件由以下命令独立管理:
|
||||
|
||||
```bash
|
||||
# 在对应 Linux 原生 Runner 上从 lock 指定的 npm 归档构建并签名
|
||||
npm run remote-runtime:build -- --arch <x64|arm64> \
|
||||
--runtime-archive <locked-opencode-package.tgz>
|
||||
|
||||
# 为复合 Agent 包组装导入并完整验证签名归档
|
||||
npm run remote-runtime:import -- --arch x64 --archive <runtime-x64.tar>
|
||||
npm run remote-runtime:import -- --arch arm64 --archive <runtime-arm64.tar>
|
||||
```
|
||||
|
||||
默认生成目录为 `.remote-runtime-resources/linux-<arch>/opencode/<digest>`,
|
||||
不提交到仓库。生产 workflow 从 `remote-runtime-lock.json` 解析固定包名、版本和
|
||||
integrity,以 `--ignore-scripts` 从官方 npm registry 获取归档;bundle 工具在签名前
|
||||
再次验证归档 SHA-512、包名、版本和 ELF 架构。
|
||||
|
||||
源码已经实现 Runtime bundle build/import/verify、Daemon 侧
|
||||
manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership 和
|
||||
`runtime/model-bridge` v1。Agent 通过私有 Unix socket 按需 detached 启动,不依赖
|
||||
systemd、D-Bus、Linger 或 bubblewrap。Ask 与 Execute 都直接启动签名 Runtime;两者的
|
||||
差异由 OpenCode Ask 权限配置和 Agent 工具权限分发边界共同执行,Execute 不注入该
|
||||
权限覆盖。
|
||||
|
||||
OpenCode 通过已签名 Agent helper 和每次 Prompt 的私有 Unix socket 使用 Main-only 模型
|
||||
网关;Provider URL、API Key 和真实 Provider 认证头不进入远端。helper 的 loopback
|
||||
HTTP 入口使用每个进程随机生成的路径 capability,Anthropic/OpenAI SDK 需要的固定非秘密
|
||||
本地兼容标记由 helper 核对后丢弃。GoodBuddy 自己维护会话标题,所以生成的 OpenCode
|
||||
配置必须禁用 title Agent;工具循环只使用 build Agent 的模型轮次。
|
||||
|
||||
Ask 模式的 ACP 权限中介只可为原生 `read` 选择 `allow_once`;其他工具种类和
|
||||
`allow_always` 请求必须拒绝。Unix 模型桥
|
||||
broker 必须按 socket 当前已缓冲字节增量读取长度帧,不能等待 `read(remaining)` 一次返回
|
||||
完整大响应。Agent 传输或权限实现变化后,除了普通测试,还要在原生 Linux 上运行包含
|
||||
至少 256 KiB 响应的模型桥回归。
|
||||
|
||||
`agent-runtime-lock.json` 维护独立于桌面应用的 Agent 版本,
|
||||
`remote-runtime-lock.json` 维护 OpenCode Runtime 版本。Agent 内容变化时必须更新
|
||||
`agentVersion`;Runtime 更新时使用 Runtime 自身版本。签名 manifest 或 bundle digest
|
||||
唯一标识对应版本的精确工件。变更后必须重新构建、签名并导入 Linux x64/arm64 两套工件;
|
||||
旧版本或旧 digest 会按设计校验失败。不要手工把本机 `.runtime-resources` 当作远程 bundle。
|
||||
|
||||
桌面应用更新后不会在项目切换时下载或安装 Agent。用户先在 SSH Host 的远程运行环境
|
||||
卡片显式选择直连下载或 GoodBuddy 传输。托管 SSH 项目只保存 Host ID、远端路径、
|
||||
Runtime 选择和默认模式;创建或保存时验证 Host 当前环境,普通项目切换只读取这些本地
|
||||
配置。首次实际使用 Workspace 或 Runtime 时解析 Host current identity;同一进程内后续
|
||||
项目复用已确认的 Agent/Runtime identity 和 Agent 连接。该流程不取得安装包、不重读完整
|
||||
payload,也不发布组件。
|
||||
|
||||
两条手动准备路径都支持阶段进度和取消;成功后定向失效该 Host 的连接与 Runtime 缓存,
|
||||
引用项目下一次打开自然使用新的 current 环境。验收应覆盖两种准备方式与项目当前环境
|
||||
验证入口,并确认失败或取消不会删除 Host 配置、凭据、项目设置、Workspace 文件、全局
|
||||
可信元数据或仍可用的旧安装。
|
||||
|
||||
## 跨平台 CI 与 GitHub Release
|
||||
|
||||
`.github/workflows/packages.yml` 是统一发布工作流。它先验证并生成一次
|
||||
`out` 生产 bundle,再在六个原生 Runner 上分别打包 Windows、macOS 和
|
||||
Linux 的 `x64`、`arm64` 版本。生产 bundle 仅作为短期 Actions artifact
|
||||
供打包任务复用,不会上传到 GitHub Release。
|
||||
供打包任务复用,不会上传到 GitHub Release。该工作流不构建、下载、签名或嵌入
|
||||
远端 Agent/OpenCode 复合包,也不依赖 `agent-signing` Environment;远端包缺失或
|
||||
Agent 发布失败不会阻塞任何桌面目标。
|
||||
|
||||
本地构建单个平台目标:
|
||||
|
||||
@@ -138,8 +390,21 @@ Linux 的 `x64`、`arm64` 版本。生产 bundle 仅作为短期 Actions artifac
|
||||
npm run release:package -- --platform <windows|macos|linux> --arch <x64|arm64>
|
||||
```
|
||||
|
||||
默认发布产物为 Windows 的 NSIS 安装包与 portable ZIP、macOS 的 DMG 与
|
||||
ZIP,以及 Linux 的 AppImage 与 DEB。Windows portable ZIP 解压后可直接
|
||||
`release:package` 仅构建桌面产品。基础配置统一携带
|
||||
`agent-release-keys.json`、`agent-runtime-lock.json` 与 `remote-runtime-lock.json`
|
||||
供 Main 校验独立下载的复合包,但 release 配置不增加 `.agent-resources` 或
|
||||
`.remote-runtime-resources`。发布校验继续检查桌面自身的 OpenCode、Continue 与
|
||||
DeepSeek Harness Runtime、`app.asar`、目标架构和安装包签名。
|
||||
|
||||
在 macOS 上明确生成未签名、未公证的开发验证包:
|
||||
|
||||
```bash
|
||||
npm run release:package -- --platform macos --arch <x64|arm64> --unsigned
|
||||
```
|
||||
|
||||
默认发布产物为 Windows 的 NSIS 安装包与 portable ZIP、macOS 的 DMG,
|
||||
以及 Linux 的 AppImage、DEB 与 RPM。Linux 原生 Runner 必须安装
|
||||
`rpm`/`rpmbuild` 工具后再调用 electron-builder。Windows portable ZIP 解压后可直接
|
||||
运行 `GoodBuddy.exe`,并包含启用便携数据目录的
|
||||
`.goodbuddy-portable.json`。每个目标目录都包含带文件大小和 SHA-256 的
|
||||
`release-manifest.json`。
|
||||
@@ -148,24 +413,109 @@ ZIP,以及 Linux 的 AppImage 与 DEB。Windows portable ZIP 解压后可直
|
||||
打包矩阵,避免随后推送版本标签时对同一提交重复完整打包。手动触发会运行
|
||||
验证和六平台打包,并保留 30 天 Actions artifacts,但不会创建 Release。
|
||||
|
||||
### Desktop 发布顺序(强制)
|
||||
|
||||
Desktop 正式发布必须把候选分支推送与标签推送拆成两个阶段,不能在同一批操作中先后
|
||||
推送 `main` 和标签:
|
||||
|
||||
1. 完成源码、版本、双语 Release Notes 与相关文档修改,运行 Release Notes 校验、
|
||||
`npm test`、`npm run typecheck` 和 `npm run lint`。常规发布准备不在本机运行
|
||||
production build、打包或安装包探针。
|
||||
2. 提交候选,确认用户批准的准确 commit SHA 与双语 Release Notes,fetch 两个远端后,
|
||||
将该候选分别推送到 `origin/main` 和 `github/main`。
|
||||
3. 等待这个准确 SHA 的 `main` 分支 `Cross-platform packages` workflow 完成。只有
|
||||
`Run validators` 和 `Build production bundle` 都成功,候选才具备打标签条件。
|
||||
4. 如果 `main` workflow 失败,不得创建标签。修复必须形成新的 commit,重新完成本地
|
||||
校验、双远端 `main` 推送,并等待新 SHA 的 `main` workflow 成功;旧 SHA 的成功结果
|
||||
不能替代新 SHA 的验证。
|
||||
5. `main` 成功后再次 fetch 两个远端,确认工作区干净、两个远端 `main` 都等于获批 SHA、
|
||||
目标标签在本地和远端均未使用。此时才可在该 SHA 创建 annotated tag,并把标签分别
|
||||
推送到两个远端,随后等待六平台打包和正式发布完成。
|
||||
|
||||
不可移动、删除、重建或复用失败的发布标签。完整审批、恢复和发布后检查以
|
||||
[发布 Runbook](./docs/development/release-runbook.md) 为准。
|
||||
|
||||
推送 `v${package.version}` 标签时,工作流运行验证和六平台打包。只有在
|
||||
全部目标成功后,才会严格校验并聚合所有平台产物,生成按平台重命名的
|
||||
manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流创建或
|
||||
更新 draft GitHub Release,上传全部资产成功后才发布。重跑会保留人工
|
||||
编辑的 Release notes 和未知附件。
|
||||
manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流通过
|
||||
GitHub OIDC 获取短期 STS 凭据,将发布资产和 `site-release.json` 上传到
|
||||
北京 OSS 的不可变版本目录,并公开校验 12 个安装包。验证通过后才创建或
|
||||
更新 draft GitHub Release、上传 20 个 Release 资产并正式发布,最后原子
|
||||
切换官网 `latest.json`。任一步失败都不会提前切换官网最新版本。
|
||||
|
||||
同一标签重跑时,工作流会根据 `resources/release-notes.json` 重新生成并
|
||||
覆盖 GitHub Release 正文,以 `--clobber` 更新已知发布资产,同时保留未知
|
||||
附件。若源码与发布元数据未变化,应修正外部配置后重跑同一不可变标签;
|
||||
只有必须修改代码或元数据时才递增版本并创建新标签。
|
||||
|
||||
Agent 发布使用另一套标签和 workflow:
|
||||
|
||||
```bash
|
||||
agent_tag="agent-v$(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
git tag -a "$agent_tag" -m "GoodBuddy Agent $(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
```
|
||||
|
||||
实际创建或推送前同样必须获得用户确认。`.github/workflows/agent-release.yml`
|
||||
原生构建两个 `.gbagent`、签名累计目录、上传
|
||||
`agent-releases/v<agent-version>/`,创建 `--latest=false` GitHub Release,再切换
|
||||
OSS 签名目录指针。生产发布需要 `agent-signing` 与 `aliyun-oss-release` 两个受保护
|
||||
Environment;普通分支/PR 的 `agents.yml` 不使用其中任何 Secret。
|
||||
|
||||
Agent 标签也必须遵循与 Desktop 相同的 pre-tag 顺序。更新 Agent 生产源码或
|
||||
`agentVersion` 后,先把候选提交推送到两个 `main`,等待该准确 SHA 的
|
||||
`GoodBuddy Agents` main workflow 全部成功,再次 fetch 并确认两个远端 `main`、干净
|
||||
工作区和未使用标签,最后才可创建和推送 annotated `agent-v<version>`。不得把 Agent
|
||||
标签与候选 `main` 放在同一批推送中;任何修正 commit 都必须重新等待新 SHA 的 Agent
|
||||
main workflow,旧 SHA 的成功结果不能复用。
|
||||
|
||||
中英文发布说明统一维护在 `resources/release-notes.json`。新版本按“本次
|
||||
亮点 / Highlights”“功能更新 / Features”“问题修复 / Bug Fixes”“使用前
|
||||
请留意 / Before You Start”四段组织,应用首次启动弹窗与 GitHub Release
|
||||
正文共用该来源。旧版两段式记录会兼容读取,无需改写。提交发布候选前运行
|
||||
`npm run release:notes:verify` 校验版本、双语条目数量并生成 Markdown。
|
||||
|
||||
发布标签必须与 `package.json` 版本完全一致。实际推送标签和触发发布前仍
|
||||
需人工确认,例如当前版本应使用:
|
||||
|
||||
```bash
|
||||
tag="v$(node -p "require('./package.json').version")"
|
||||
git tag "$tag"
|
||||
git tag -a "$tag" -m "GoodBuddy $(node -p "require('./package.json').version")"
|
||||
git push origin "$tag"
|
||||
git push github "$tag"
|
||||
```
|
||||
|
||||
当前未配置 Windows/macOS 代码签名或 macOS notarization。对外分发前应按
|
||||
目标平台配置签名凭据并重新验证安装、升级和系统安全提示。
|
||||
macOS 发布 job 会先原子判断 Apple 凭据状态:以下五项 Actions Secrets 全部
|
||||
存在时,使用 Developer ID Application 证书签名,并通过 App Store Connect
|
||||
API Key 提交 Apple notarization;五项全部缺失时,明确生成未签名、未公证的
|
||||
DMG,并在 Actions 日志与摘要中警告 Gatekeeper 限制;只配置一部分时
|
||||
任务失败,不能静默降级为未签名包。
|
||||
|
||||
- `MACOS_CERTIFICATE_BASE64`:包含证书及私钥的 `.p12` 文件经 Base64 编码后的内容。
|
||||
- `MACOS_CERTIFICATE_PASSWORD`:导出 `.p12` 时设置的密码。
|
||||
- `APPLE_API_KEY_BASE64`:App Store Connect API Key 的 `.p8` 文件经 Base64 编码后的内容。
|
||||
- `APPLE_API_KEY_ID`:App Store Connect API Key 的 Key ID。
|
||||
- `APPLE_API_ISSUER`:App Store Connect API Key 的 Issuer ID。
|
||||
|
||||
在 macOS 上生成适合 Secrets 的单行 Base64 内容:
|
||||
|
||||
```bash
|
||||
base64 -i DeveloperIDApplication.p12 | tr -d '\n'
|
||||
base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n'
|
||||
```
|
||||
|
||||
证书必须是 Apple Developer 后台创建的 `Developer ID Application`,并在导出
|
||||
`.p12` 的 Mac 钥匙串中同时包含对应私钥。API Key 建议使用团队级 App Store
|
||||
Connect Key;`.p8` 只能下载一次。签名材料只放入 GitHub Secrets,不提交到仓库。
|
||||
|
||||
凭据完整时,macOS 打包完成后会挂载 DMG,并分别执行 `codesign`、Gatekeeper
|
||||
`spctl` 和 `stapler` 校验;签名无效或 notarization ticket 不存在时,发布矩阵
|
||||
会在上传产物前失败。完全没有凭据时,六平台矩阵和标签发布仍可完成,但 macOS
|
||||
产物没有 Developer ID 签名或 Apple 公证,Gatekeeper 可能阻止首次打开。发布
|
||||
工作流必须在 Actions 日志与摘要中明确这一限制;获得完整凭据后应恢复签名
|
||||
发布并重新验证。
|
||||
|
||||
Windows 代码签名仍未配置。对外分发前还应配置 Windows 签名凭据,并重新验证
|
||||
安装、升级和系统安全提示。
|
||||
|
||||
## 发布前冒烟测试
|
||||
|
||||
@@ -177,8 +527,12 @@ git push github "$tag"
|
||||
4. 本地知识库导入、检索和知识图谱。
|
||||
5. Ask、Execute 的权限边界与旧版 Plan 数据兼容。
|
||||
6. OpenCode 与 Continue 的权限边界、取消和超时。
|
||||
7. 智能心跳的创建、暂停、恢复和历史记录。
|
||||
8. 应用退出后无残留 Runtime 子进程。
|
||||
7. DeepSeek Harness Ask 拒绝写入和第三方插件工具,可调用 Main 管理的 Web Search/Fetch;Execute 可调用已启用插件工具。文本模型在网络调用前拒绝图片,声明图片能力的模型可以实际接收 JPEG/PNG。
|
||||
8. OpenCode Agent/Command、原生上下文 Compact,以及 Continue Rules/Prompt 预设、结构化提问和 GoodBuddy 手动摘要压缩。
|
||||
9. Runtime 原生清单把 Tools 与 Commands/LSP/Formatters 分开,显示来源及 Ask/Execute 可用性,不混入 GoodBuddy 分配的 Skills/MCP;外部 OpenCode 只报告连接状态,Continue 明确标记原生 Tools 静态发现不支持;内置 MCP 的启停与 Runtime 分配会持久化并限制后续请求,DeepSeek Harness 保持不可分配;MCP 测试只读取有界 Prompt/Resource 元数据,不读取 Resource 内容。
|
||||
10. DSH 市场可安装、停用、重新启用和移除插件;启动失败插件不会阻止 Host,并显示为自动停用。
|
||||
11. 智能心跳的创建、暂停、恢复和历史记录。
|
||||
12. 应用退出后无残留 Runtime 子进程。
|
||||
|
||||
DeepSeek Harness 的 Electron Utility Host 可单独执行无模型、无凭据冒烟测试:
|
||||
|
||||
@@ -187,5 +541,40 @@ npm run smoke:deepseek-harness
|
||||
```
|
||||
|
||||
该命令先生成 production bundle,再从 CommonJS Electron 主入口启动实际
|
||||
`utilityProcess`,等待固定 Host 完成沙箱探测与内部 ready 握手。它不会发起
|
||||
`utilityProcess`,等待固定 Host 完成本地主机执行器初始化与内部 ready 握手。它不会发起
|
||||
模型请求,也不会读取或传递 API Key。
|
||||
|
||||
Windows x64 完整打包后的 Utility Host 与内置 npm 冒烟测试:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
node node_modules/electron-builder/cli.js --win dir --x64 --publish never --config.directories.output=dist/harness-package-probe
|
||||
npm run smoke:deepseek-harness:packaged
|
||||
```
|
||||
|
||||
该测试启动打包后的 Host,并使用打包资源中的准确 npm 版本安装一个本地临时包,
|
||||
确认 npm 依赖闭包、Electron Node 模式、`node` shim 和生命周期脚本都可用。它不访问
|
||||
npm registry,也不运行模型请求。
|
||||
|
||||
真实 DSH 市场测试会访问公共 npm、运行第三方安装与插件代码,因此只在已明确授权时启用:
|
||||
|
||||
```bash
|
||||
GOODBUDDY_DSH_MARKETPLACE_E2E=1 npm test -- src/main/agent/dsh-extension-marketplace.e2e.test.ts
|
||||
```
|
||||
|
||||
该测试使用临时用户目录,经捆绑 npm 路径安装已审查的最小测试插件,并验证 Host
|
||||
加载和真实工具调用;测试结束后删除临时目录。
|
||||
|
||||
要让真实模型同时验证插件的 Ask 拒绝与 Execute 调用,可显式提供兼容的
|
||||
OpenAI Chat Completions 配置:
|
||||
|
||||
```bash
|
||||
GOODBUDDY_DSH_MODEL_E2E=1 \
|
||||
GOODBUDDY_DSH_API_KEY=... \
|
||||
GOODBUDDY_DSH_BASE_URL=https://api.deepseek.com \
|
||||
GOODBUDDY_DSH_MODEL=deepseek-chat \
|
||||
npm test -- src/main/agent/deepseek-harness-acp-e2e.test.ts -t "rejects a real npm plugin"
|
||||
```
|
||||
|
||||
如需覆盖发布包内置 npm 路径,再设置 `GOODBUDDY_DSH_NPM_CLI` 与
|
||||
`GOODBUDDY_DSH_NODE_EXECUTABLE` 指向已解包应用中的 npm CLI 和应用主程序。
|
||||
|
||||
@@ -1,83 +1,799 @@
|
||||
# GoodBuddy 功能矩阵与路线图
|
||||
# GoodBuddy Feature Matrix and Roadmap
|
||||
|
||||
本文记录 GoodBuddy 的已提供能力和路线图。未完成项目不代表已包含在当前版本中。
|
||||
**English** | [简体中文](./FEATURES.zh-CN.md)
|
||||
|
||||
## 状态说明
|
||||
This document records the capabilities available in GoodBuddy and its roadmap.
|
||||
Unchecked items are not included in the current release unless the item says
|
||||
otherwise.
|
||||
|
||||
- [x] 已提供
|
||||
- [ ] 开发中或规划中,具体以条目标注为准
|
||||
## Status
|
||||
|
||||
## 功能总表
|
||||
- [x] Completed
|
||||
- [ ] To implement
|
||||
|
||||
### 桌面基础、工作空间与上下文
|
||||
Status describes implementation, not publication or test coverage. Validation
|
||||
records are listed separately and do not introduce another feature status.
|
||||
|
||||
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
|
||||
- [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更。
|
||||
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
|
||||
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
|
||||
## Feature Matrix
|
||||
|
||||
### Agent Runtime 与模型连接
|
||||
### Desktop foundation, workspaces, and context
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、超时和活动记录。
|
||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
|
||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
|
||||
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
|
||||
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,并保留父子任务审计。
|
||||
- [x] **Cross-platform desktop application**: Supports Windows, macOS, and
|
||||
Linux release targets on `x64` and `arm64`.
|
||||
- [x] **Chinese hardware and operating-system coverage**: Standard Linux
|
||||
packages target compatible x64 and arm64 environments, including UOS, Kylin,
|
||||
Hygon, Zhaoxin, Kunpeng, and Phytium systems. This is not vendor certification.
|
||||
LoongArch has a separate experimental loong64 preview outside standard
|
||||
releases and automatic updates; no preview is planned for 0.13.5. See the
|
||||
[preview boundaries](./docs/development/loongarch-preview-build.md).
|
||||
- [x] **Configurable global shortcut**: Enable, disable, or record an Electron
|
||||
accelerator under Platform Features / General. The default remains
|
||||
`CommandOrControl+Shift+Space`; if registration conflicts or saving fails,
|
||||
GoodBuddy keeps the previously registered shortcuts and shows an actionable
|
||||
status.
|
||||
- [x] **Projects, independent conversations, and conversation branches**:
|
||||
Isolates context by project and manages conversations, attachments, and Git
|
||||
workspace changes. A stable local conversation can be copied into an
|
||||
independent branch; the branch retains a source badge but does not copy
|
||||
Tasks, queues, or artifact ownership. The project selector distinguishes
|
||||
local, managed SSH, and remote messaging-channel projects. Managed SSH
|
||||
projects are grouped by Host, with the real Agent connection state on the
|
||||
Host heading and only the remote path on each project row.
|
||||
- [x] **Cross-project conversation activity**: A shared activity entry lists
|
||||
running conversations and conversations needing attention across projects,
|
||||
including older conversations with live work. Anchored project-to-conversation
|
||||
menus provide keyboard navigation and exact conversation opening without a
|
||||
full activity dialog. The summary hides when idle, long conversation submenus
|
||||
scroll independently, and project selection continues to show activity counts.
|
||||
- [x] **Compact conversation controls**: Composer options share a compact
|
||||
settings panel while Runtime, mode, sending, and queue controls remain
|
||||
available. Conversation search has an inline clear action that restores the
|
||||
scoped list and returns focus to the input.
|
||||
- [x] **File, screenshot, window, and clipboard context**: Added to model
|
||||
context only after explicit user selection. Paste one or more supported local
|
||||
files into the chat composer with `Ctrl+V` to add attachments, using the same
|
||||
parsing progress and limits as the attachment button. Text and screenshot
|
||||
paste remain available. See [attachment rules](./docs/features/document-processing/prd.md#321-聊天附件入口).
|
||||
- [x] **Text recognition in image-based PPTX files**: With local OCR configured
|
||||
and an OCR-enabled parsing workflow, embedded PNG, JPEG, and WebP images are
|
||||
recognized alongside native text, retaining slide locations. OCR releases
|
||||
model memory after 60 seconds without active or queued work and reloads on
|
||||
demand; fast text/index modes still skip OCR. See the
|
||||
[parsing behavior and real-file evidence](./docs/features/document-processing/chat-attachments-technical-design.md).
|
||||
- [x] **Per-file workspace diffs**: Changed files expose separate staged and
|
||||
unstaged diffs, including deleted, renamed, and untracked files. Refresh
|
||||
reloads the selected diff, browsed directory, and expanded directories; more than 50 changes
|
||||
remain accessible through incremental loading.
|
||||
- [x] **Workspace file and Git management**: Create, rename, move, and delete
|
||||
files or directories, inspect properties, search and switch local or remote
|
||||
branches, create branches, explicitly Fetch, and browse commit history and
|
||||
per-file commit diffs. File and Git controls are grouped by view, branch
|
||||
search uses the shared compact form style, and subdirectories retain text
|
||||
breadcrumbs without an isolated root icon. Commit history stays docked at
|
||||
the bottom, initially expands to half the Git content area, and supports
|
||||
resizing with independently scrolling panes and one shared refresh action.
|
||||
Project switches automatically detect Git without reusing another project's
|
||||
status. Moves stay inside the workspace,
|
||||
deletion requires confirmation, and branch conflicts never trigger automatic
|
||||
stash or discard. Managed SSH operations require Agent `0.11.23`.
|
||||
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math,
|
||||
constrained Mermaid diagrams, and static in-conversation HTML previews.
|
||||
Complete HTML and HTML code blocks can be previewed in place after an Agent
|
||||
reply finishes, with icon actions to reveal the source or open a full-screen
|
||||
preview. The feature is on by default and can be disabled under Platform
|
||||
Features / General; previews cannot run scripts, access the network, submit
|
||||
forms, or open windows.
|
||||
- [x] **AI response and full-conversation copy**: Completed AI responses expose
|
||||
a bottom action that copies Markdown source without reasoning, tool logs, or
|
||||
citation metadata. Full-conversation copy uses the same validated
|
||||
Preload/Main clipboard path.
|
||||
- [x] **Assistant workbar, multiple terminals, and resizable layouts**: The
|
||||
toggle sits beside the theme switch. The right workbar uses a persistent “+”
|
||||
capability catalog and application tabs.
|
||||
Task Center and Workspace are permanent singleton tabs; browser tabs are
|
||||
independent instances. Task Center can show the current project, global
|
||||
tasks, or all projects, and retains the selected scope with the layout.
|
||||
Users can open multiple independent terminals for the current local or
|
||||
managed SSH project with bounded output, resizing, termination, and explicit
|
||||
reconnection. Closing a terminal tab ends its Shell; restarting the app
|
||||
restores only an ended tab description and never restarts the Shell. The
|
||||
main sidebar and Magic Notes list support pointer and keyboard resizing;
|
||||
the notes list can be hidden, remembers its layout, and stacks at narrow
|
||||
widths.
|
||||
- [ ] **Project Agent Space** (planned): Unifies roles, knowledge, Skills/MCP,
|
||||
models, approval policy, budgets, and timeouts in a Project, with reusable
|
||||
templates.
|
||||
- [ ] **Application navigation and system-tool shortcuts** (design only):
|
||||
Plans fixed system entries, configurable built-in application visibility,
|
||||
and footer shortcuts into the existing workbar. These are not implemented
|
||||
interface changes. See the [design](./docs/features/application-tool-navigation/README.md).
|
||||
- [ ] **Additional assistant workbar and execution-space capabilities**
|
||||
(planned): Builds on the current workbar and multiple terminals with
|
||||
supervision, unified Runtime monitoring, managed processes, safe static HTML
|
||||
preview, target-pinnable workspace/browser/artifact instances, bottom
|
||||
docking, and separate windows. Task Center remains the singleton Task index;
|
||||
attachments and knowledge remain in the conversation composer, while memory,
|
||||
when implemented, and historical execution context belong to the associated Task. See
|
||||
the [Feature PRD](./docs/features/assistant-workbar/prd.md).
|
||||
- [ ] **ShareServer Office co-editing** (design): Uses an optional ShareServer
|
||||
integration with ONLYOFFICE Docs to open multiple filename-labelled document
|
||||
tabs in the assistant workbar. The planned scope covers DOCX, XLSX, and PPTX
|
||||
manual editing, save and undo, source-file conflict protection, and later AI
|
||||
selection edits. The selected ShareServer and editor process document content;
|
||||
Desktop neither bundles nor starts Document Server and does not claim offline
|
||||
editing when no service is configured. See the
|
||||
[design](./docs/features/office-document-editing/README.md).
|
||||
|
||||
### Skills、MCP 与知识库
|
||||
### Agent Runtimes and model connections
|
||||
|
||||
- [x] **Skills 按需接入**:使用有界资源和受控 Runtime 边界。
|
||||
- [x] **MCP Tools**:直连模型可使用显式启用的 MCP Tools,并可在模型轮次间按需刷新动态 MCP 工具。
|
||||
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。
|
||||
- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
|
||||
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
|
||||
- [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。
|
||||
- [x] **混合检索测试台**:支持全文、中文词组、向量和图谱通道诊断,可调 Top K、阈值、权重、本地或学习型重排及上下文预算。
|
||||
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
|
||||
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
|
||||
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;创建、保存和评论结果使用统一应用通知。
|
||||
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
|
||||
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
|
||||
- [x] **Compact subagent history without lost details**: Local and managed SSH
|
||||
subagent events store changes instead of repeated full progress snapshots.
|
||||
The first upgrade automatically converts existing history and reclaims disk
|
||||
space, with visible progress and quit/retry support. Chats, execution details,
|
||||
results, and remote event deduplication are preserved; older clients cannot
|
||||
reopen the upgraded database. The schema-35 repair released in 0.13.2 also handles
|
||||
repeated tool blocks written after upgrading to 0.13.1, without deleting events.
|
||||
Terminal tasks release their write caches; duplicate remote replay does not
|
||||
rebuild a released cache. See
|
||||
[storage and upgrade behavior](./docs/features/assistant-workbar/execution-history-storage.md).
|
||||
|
||||
### 工作管理、长期协作与工作流
|
||||
- [x] **Direct model Runtime**: Supports question answering, knowledge
|
||||
synthesis, controlled tool execution, image generation, and reference-image
|
||||
editing through providers supporting the OpenAI-compatible image-editing
|
||||
endpoint. Editing sends attached images as multipart data and still requires
|
||||
validated inline image output rather than fetching provider-returned URLs.
|
||||
Follow-up image requests reuse the latest successful image and conversation
|
||||
text, including after reopening a saved conversation; explicit attachments
|
||||
take priority. Missing editing support does not block text-based generation:
|
||||
the affected reply shows a quiet footer explaining that reference images were
|
||||
not used. See [image generation](./docs/features/image-generation/README.md).
|
||||
- [x] **Image tools in the current conversation**:
|
||||
Enable conversation access for an image model to let tool-capable chat models
|
||||
generate images and edit uploads or earlier artifacts in Execute, without a
|
||||
separate conversation or manually switching to an image model. Direct text
|
||||
models, local OpenCode, Continue, DeepSeek Harness, and managed remote OpenCode
|
||||
use the shared service; Runtime assignment is derived from model settings.
|
||||
Requests may incur provider charges and editing requires provider support.
|
||||
See the [implementation and validation records](./docs/features/conversation-media-generation/progress.md).
|
||||
- [x] **Cross-project Runtime process reuse**: Compatible local OpenCode and
|
||||
DeepSeek Harness configurations share heavyweight processes; managed OpenCode
|
||||
sessions share a process on the same Host. Project adapters and session
|
||||
workspaces, models, tools, questions, and cancellation stay independently
|
||||
routed. Remote pending questions and task status recover after reconnect or
|
||||
Desktop restart without resending accepted prompts. Requires Desktop `0.13.5`
|
||||
and Agent `0.13.0` together for the new remote behavior. See the
|
||||
[design and measured validation](./docs/features/assistant-workbar/runtime-process-reuse-technical-design.md).
|
||||
- [x] **Direct model programming agent**: Local direct text models can run the
|
||||
platform Shell in Execute mode and delegate one level of programming
|
||||
Subagent work while inheriting the parent request's mode, model, workspace,
|
||||
and capability scope. OpenCode, Continue, DeepSeek Harness, and managed SSH
|
||||
do not receive duplicate copies of these tools. The Windows local command
|
||||
path and a real-model edit, test, fix, and review loop have passed; native
|
||||
macOS and Linux command validation remains. Execute commands can select
|
||||
absolute, relative, or symbolic-link directories outside the workspace;
|
||||
the workspace stays the default and relative-path base. Ask remains read-only.
|
||||
- [x] **OpenCode and Continue**: Use isolated child processes, an environment
|
||||
variable allowlist, unified configuration, cancellation, startup and control-request
|
||||
deadlines, bounded streaming output, and activity records. Shared process
|
||||
cleanup preserves complete Windows process-tree termination and terminates
|
||||
POSIX process groups when a child uses an independent process group. Chat
|
||||
status checks use a one-shot Runtime probe that is immediately cleaned up
|
||||
rather than entering the model-and-project execution cache. For managed
|
||||
local OpenCode, this passive check validates the selected path and model
|
||||
credential without starting a throwaway server; the explicit settings test,
|
||||
native inventory still performs full startup and health checks, then shares
|
||||
that execution Runtime with the first real request instead of cold-starting
|
||||
another server. Execution Runtime adapters remain reusable per project, while local
|
||||
OpenCode configuration dependencies and content-addressed Skill snapshots are
|
||||
shared globally across projects. Session data, tool output, and request-scoped
|
||||
MCP state remain isolated. GoodBuddy-managed local OpenCode keeps
|
||||
requests in the same conversation ordered, while different conversations in
|
||||
the same project and different projects can run in parallel. Request-scoped
|
||||
dynamic MCP tools remain isolated through default wildcard disablement and
|
||||
explicit current-request enablement. Managed local and remote OpenCode skip
|
||||
unused automatic Git snapshots to avoid synchronous diff stalls; file tools,
|
||||
subagents, and workspace Git diffs remain available. Continue continuously
|
||||
drains unused utility-host stdout so console output cannot fill its pipe.
|
||||
These changes apply to newly started Runtimes, not external OpenCode Servers.
|
||||
OpenCode and Continue runs do not stop
|
||||
at a fixed tool-call or activity count, and every observed tool and Subagent
|
||||
activity remains in the local conversation instead of being discarded while
|
||||
the native task continues.
|
||||
Interactive questions are answered only by foreground conversations; scheduled tasks, remote channels,
|
||||
delegated work, and other background runs fail immediately with guidance to
|
||||
rerun in the foreground
|
||||
instead of waiting indefinitely. Local OpenCode startup uses the selected
|
||||
absolute path directly without checking the file or running `--version`
|
||||
first; invalid paths fail at actual launch. On managed Linux ARM Hosts,
|
||||
Runtime activation reuses the registry and manifest verified during setup
|
||||
instead of rehashing or rechecking the complete OpenCode binary.
|
||||
- [x] **Managed SSH OpenCode loop (technical preview)**: Controlled by the
|
||||
separate Remote Projects (Technical Preview) tab under Settings / Platform
|
||||
Features and disabled by default. Disabling it does not affect local
|
||||
projects, ordinary desktop capabilities, or desktop releases. When enabled,
|
||||
users can manage SSH Hosts with pinned Host Keys, browse bounded remote
|
||||
directories, and create Ask or Execute projects. Both modes start the
|
||||
signed Runtime directly without bubblewrap. Ask applies OpenCode's Ask
|
||||
permission configuration and permits only native read approvals at the
|
||||
Agent tool-dispatch boundary, while Execute explicitly allows native permissions
|
||||
and retains all permissions of the selected SSH account. The Agent owns accepted Prompts,
|
||||
provider/tool rounds, Runtime processes, a stable model ledger, and a bounded
|
||||
semantic transcript over a private Unix socket and ACP v5. Work continues on
|
||||
the Host after Desktop exit, network loss, or local-process termination.
|
||||
Reconnection attaches only to the original controller, binding, and
|
||||
operation, then atomically merges provenance into the original conversation
|
||||
without resending the Prompt. Model profiles and API keys enter Agent memory
|
||||
only for the accepted operation and are not written to the Renderer, SSH
|
||||
arguments, remote environment, or disk. Remote components are not embedded
|
||||
in the desktop package; switching projects updates only local selection and
|
||||
does not connect or download. Settings reads a small signed catalog and
|
||||
reports the local version, latest compatible online version, and update
|
||||
state for Linux x64, Linux arm64, and macOS arm64. Intel Macs are not supported.
|
||||
Downloads occur only after a user action, and
|
||||
packages can also be imported or exported offline. Each compound `.gbagent`
|
||||
package contains the Agent, fixed Node, and the compatible OpenCode Runtime
|
||||
maintained by the desktop source. Online sources follow the GitHub/Beijing
|
||||
OSS choice in About and Updates. The cumulative signed catalog binds minimum
|
||||
Desktop version, Agent protocol, platform, architecture, size, SHA-256, and fixed URL.
|
||||
All three targets share one catalog. Users must upgrade Desktop before the
|
||||
first mixed-platform catalog is published because older Linux-only readers
|
||||
reject catalogs containing Darwin entries.
|
||||
The public-key registry accepts equivalent JSON whitespace and line endings
|
||||
while still strictly validating schema, Ed25519 keys, environment, and
|
||||
revocation; catalog, package, manifest, payload signatures, and streaming
|
||||
SHA-256 remain unchanged. A missing architecture package disables managed
|
||||
SSH only for that architecture. Projects store only Host, remote path,
|
||||
Runtime selection, and mode. Current Host identity is read when a
|
||||
Workspace/Runtime is first used, then reused by other projects in the same
|
||||
process. Managed SSH conversations expose only supported OpenCode choices.
|
||||
Multiple projects and conversations can run concurrently on one Host;
|
||||
Agent-owned prompts are not stopped at a fixed model-call count or
|
||||
prompt-wide output-token total, and Runtime output uses transport
|
||||
backpressure instead of cancellation when GoodBuddy buffers fill;
|
||||
recovery separately reports network, Agent, Runtime, committed-event cursor,
|
||||
completion, or failure/retry and blocks only the affected project. A real
|
||||
Linux x64 Host has covered short and long detach, forced local harness exit,
|
||||
concurrency, SSH relay loss, cancellation, definite and uncertain provider
|
||||
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
|
||||
database. Successful tool START/END events appear exactly once, with no
|
||||
Prompt, provider, or tool replay observed. The current Agent source lock is
|
||||
`0.13.0`, while the current Desktop release candidate is `0.13.5`; formal
|
||||
publication status follows the separate Agent and Desktop
|
||||
release channels. Previous macOS validation covered native package installation,
|
||||
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
|
||||
separate process groups on a real Host; this does not imply publication.
|
||||
Agent `0.11.23` adds remote workspace management and optional model limits;
|
||||
its bundled Runtime no longer imposes a fixed ten-minute Prompt deadline.
|
||||
Unlimited request duration retains a separate connection timeout. Agent
|
||||
`0.11.23` requires Desktop `0.12.11` or later.
|
||||
Agent `0.11.24` requires Desktop `0.13.0` and fixes native question routing
|
||||
and sending another message after cancelling a pending question.
|
||||
- [x] **Manual SSH Host environment provisioning source path**: After Host Key,
|
||||
authentication, and system probes succeed, GoodBuddy saves the Host and
|
||||
read-only probes the shared Agent/Runtime. Saving a Host or opening a project
|
||||
never installs automatically. A Host card has one primary action based on
|
||||
version facts, Install Remote Environment, Update Remote Environment, or
|
||||
Reinstall, plus a secondary SegmentedControl for Auto, Host Download, or
|
||||
GoodBuddy Transfer. The selection defaults to Auto and is not persisted; a
|
||||
Version Matched badge does not mean the environment is healthy. Auto probes
|
||||
only before operation preparation and chooses one acquisition route.
|
||||
Explicit choices remain in effect, and prepare, commit, or adoption failures
|
||||
do not silently fall back across acquisition routes. Both routes deliver the
|
||||
same signed compound `.gbagent` into fixed staging and share control-plane
|
||||
prepare, commit, Agent activation/health, Runtime activation, finalize, and
|
||||
explicit cleanup. The GoodBuddy route can download and validate a missing
|
||||
candidate during the same operation, cache it with a lease, and stream one
|
||||
archive plus its verified bootstrap Node over bounded SFTP without loading
|
||||
the roughly 294 MiB package into a Main-process `Buffer`; the Host performs
|
||||
one complete payload verification while extracting. An unfinished operation
|
||||
stores only the operation ID needed to clean staging. A later update makes a
|
||||
best-effort cleanup and starts prepare again, without persisting a remote
|
||||
metadata copy or allowing cleanup failure to block a new update or roll back
|
||||
a healthy environment. Existing projects resolve current Host identity on
|
||||
demand and run a fixed `attach-or-bootstrap`; registered health,
|
||||
capabilities, and prompt startup do not scan the full payload. See the
|
||||
[design](./docs/features/remote-host/environment-provisioning-technical-design.md).
|
||||
- **SSH Host environment provisioning validation records**: The
|
||||
current source passed isolated Linux x64 package installation, Ask/Execute,
|
||||
native subagent external writes, reconnection, and stop/bootstrap.
|
||||
The complete Host-card acquisition matrix across GitHub, Beijing mirror,
|
||||
Linux x64/arm64, cancellation, and offline GoodBuddy transfer remains.
|
||||
Candidate CI/native packaging and system sleep/wake are not yet verified;
|
||||
this partial development evidence does not imply publication.
|
||||
- [x] **DeepSeek Harness (preview)**: Uses the fixed GoodBuddy Host and an
|
||||
OpenAI-compatible model connection. It prefers an administrator-provided
|
||||
connection, otherwise follows the compatible default model or first
|
||||
compatible connection without requiring a duplicate selection. Settings
|
||||
displays the actual administrator or fallback model source. Ask permits
|
||||
only real Host-registered `read` and `skill` tools plus Main-managed Web
|
||||
Search/Fetch proxies, and rejects plugin impersonation of those names.
|
||||
Execute allows all enabled built-in and plugin tools with the current user's
|
||||
permissions. Image input follows the selected model connection's declared
|
||||
capability: text models reject images before Host or model invocation, while
|
||||
image-capable models receive bounded inline JPEG/PNG content through a
|
||||
temporary Attachment Store. On Windows, Host startup and ACP sessions share
|
||||
one canonical workspace path, so equivalent path spellings reuse the same
|
||||
working Runtime instead of failing session creation.
|
||||
- [x] **DSH npm plugin marketplace**: Disabled by default and searches public
|
||||
npm `dsh-plugin` packages only after explicit user enablement. It uses the
|
||||
bundled npm to install exact versions with ordinary lifecycle scripts and
|
||||
supports enable/disable, JSON configuration, removal, automatic disablement
|
||||
after startup failure, and offline management of installed plugins.
|
||||
Disabling the marketplace hides only the catalog and management interface;
|
||||
it does not change the enabled state of installed plugins, and third-party
|
||||
code is not subject to Ask initialization isolation.
|
||||
- [x] **Ask and Execute work modes**: Ask remains read-only. Execute is the
|
||||
user's authorization for all tools, processes, network access, and writable
|
||||
paths available to the current local or SSH account, including paths outside
|
||||
the workspace and native subagent work.
|
||||
- [x] **Efficient direct-model workspace tools**: Direct models use bundled
|
||||
ripgrep for compact file discovery and content search, read large UTF-8 files
|
||||
by line, and apply multi-file patches in Execute. Ask can search and read but
|
||||
cannot patch or run commands; packaged ripgrep does not require a system install.
|
||||
Actionable read/search errors let the model correct its arguments and
|
||||
continue. Partial search results remain available with an explicit
|
||||
incomplete-coverage warning.
|
||||
- [x] **Long context and paged tool output**: Message history, long replies,
|
||||
and parsed document text no longer use the previous fixed truncation limits.
|
||||
Direct models can continue reading stored command and Subagent output in
|
||||
pages; the selected model's context window and bounded transports still apply.
|
||||
- [x] **Native Runtime interaction routing**: OpenCode and Continue questions
|
||||
support choices, yes/no, free-text answers, and skipping through the existing
|
||||
question card. Local and managed SSH OpenCode also route questions from
|
||||
owned child sessions. Managed SSH requires Desktop `0.13.0`, Agent `0.11.24`,
|
||||
and a newly started managed Runtime;
|
||||
this does not extend question support to arbitrary ACP services.
|
||||
Successful answers and skips retain the original questions and answers
|
||||
across rounds and local conversation reloads; answers discarded by older
|
||||
versions cannot be recovered.
|
||||
Concurrent questions wait in order, duplicate events preserve drafts, and
|
||||
failed submissions remain retryable. Cancelling a pending managed SSH
|
||||
question allows another message in the same conversation.
|
||||
Execute permission confirmations are handled automatically rather than
|
||||
waiting for another approval. See the [interaction boundaries](./docs/features/assistant-workbar/runtime-interactions.md).
|
||||
- [x] **Experts and Subagents**: Supports explicit experts, team analysis, and
|
||||
up to three experts running in parallel. Experts inherit the parent
|
||||
Ask/Execute mode and can use enabled local direct-model tools; Ask remains
|
||||
read-only. They are not remote OpenCode child sessions. Chat shows each
|
||||
expandable full expert response first and the main Agent's synthesis below,
|
||||
and persists both with the conversation.
|
||||
- [x] **OpenCode child progress and final results**: Child cards show ordered
|
||||
text, reasoning, and tool progress separately from the final result. Remote
|
||||
live progress requires Agent `0.11.20`; older packages still expose final
|
||||
results without reconstructing missing progress.
|
||||
- [x] **OpenCode event-stream cleanup**: Chat requests and native context
|
||||
compaction close their own subscriptions before ending event iteration,
|
||||
including completion, failure, cancellation, and early consumer exit,
|
||||
without cancelling parallel conversations.
|
||||
- [x] **Accurate message-footer status**: Distinguishes request preparation,
|
||||
retry waiting, retry dispatch, tool activity, pending answers, and terminal
|
||||
states. Local OpenCode reports native retry attempts and scheduled times;
|
||||
direct models report their own backoff phase. Footer dots are static, and
|
||||
unsupported Runtime retry details are not invented. Conversation-list
|
||||
indicators and send/stop behavior remain unchanged.
|
||||
- [x] **Simplified Runtime selection**: Conversation and project menus no
|
||||
longer enumerate every Runtime/model combination. Configure Runtime models
|
||||
in system settings; saved fixed project selections remain usable and
|
||||
explicitly display their fixed model.
|
||||
- [x] **Role-bound model connections**: Each role can inherit the default model
|
||||
or select an independent text-model connection. Invalid connections safely
|
||||
fall back to the default; the synthesis role always inherits the default.
|
||||
- [x] **Multiple model protocols**: Supports Anthropic Messages, OpenAI
|
||||
Responses, OpenAI Chat Completions, OpenAI Images, and unauthenticated local
|
||||
models. New users receive a local Ollama-compatible default and no
|
||||
preconfigured third-party cloud provider. During upgrades, GoodBuddy
|
||||
replaces a historical default only when it has never received credentials
|
||||
and still exactly matches the old built-in value; explicitly saved
|
||||
connections and encrypted credentials remain unchanged. Existing
|
||||
deployments that supply credentials only through compatible environment
|
||||
variables retain their historical connection parameters, while generic
|
||||
model environment variables still take precedence. Save and Test Model sends
|
||||
a bounded real text or image-generation request and validates the generated
|
||||
result instead of testing HTTP reachability alone, so it may incur a small
|
||||
provider usage charge.
|
||||
- [x] **Custom model request fields**: Each LLM connection accepts bounded
|
||||
JSON objects for additional request headers and top-level body fields.
|
||||
Direct requests and Continue support both; local OpenCode and DeepSeek
|
||||
Harness use only their natively supported headers, while the managed SSH
|
||||
OpenCode model gateway supports both. Runtime, protocol, and authentication
|
||||
fields take precedence, and these ordinary connection settings must not be
|
||||
used to store API keys or other secrets. See
|
||||
[model request customization](./docs/features/model-connections/README.md).
|
||||
- [x] **Context usage and automatic compaction**: Direct models update usage
|
||||
from each successful provider call. Images and tool rounds use the same
|
||||
accounting, with estimation only when the provider omits usage. The UI
|
||||
distinguishes This Model Call from Post-Compaction Conversation Estimate,
|
||||
and the compaction line is recalculated from current settings and the
|
||||
selected model window rather than storing old configuration per
|
||||
conversation. Before/after compaction markers use one estimation method,
|
||||
while activity history retains provider usage for each call. Conversations
|
||||
and multi-round tool Agents can compact repeatedly after a completed call
|
||||
crosses the threshold, reserving budget first for fixed prompts, tool
|
||||
definitions, and summaries. One response separately preserves compaction
|
||||
markers for Agent tool context and conversation history, and summaries remain
|
||||
reusable after app restart or when older messages leave the local history
|
||||
window.
|
||||
- [x] **Persistent credential protection**: Main encrypts API keys with secure
|
||||
system storage and never exposes them to the Renderer. A managed SSH accepted
|
||||
Prompt places the current profile and key in Agent memory only, never in SSH
|
||||
arguments, the remote environment, or disk; other paths keep credentials
|
||||
inside Main. Credentials remain attached to their model connection when the
|
||||
service URL changes or authentication is temporarily disabled, and are
|
||||
removed only when the user explicitly clears them or deletes the connection.
|
||||
- [x] **Bounded failure diagnostics**: Desktop stores a fixed allowlist of
|
||||
startup, Runtime, and remote-connection failure stages in the user-data
|
||||
directory, rotating at four 256 KiB files. Each GoodBuddy Agent rotates
|
||||
daemon, connection, recovery, and Runtime lifecycle diagnostics at three
|
||||
64 KiB files in its private installation state and exposes them through the
|
||||
fixed `diagnostics --installation-id` command. Both sides store only
|
||||
allowlisted stages, stable error codes/types, and fixed short messages, never
|
||||
Prompts, credentials, file contents, paths, environments, SSH arguments, or
|
||||
raw provider responses. Diagnostic-write failure does not alter normal
|
||||
operation.
|
||||
- [x] **OpenCode Runtime customization**: GoodBuddy-managed OpenCode can
|
||||
discover native Agents, Tools, Commands, LSPs, Formatters, MCP, Skills,
|
||||
Prompts, and Resources. Tools show read, file-edit, command, network, Agent
|
||||
orchestration, and other types, source, and Ask/Execute availability while
|
||||
hiding OpenCode's internal `invalid` tool and temporary GoodBuddy MCP tools.
|
||||
Users can save a default Agent, override it per request, run Commands through
|
||||
the native SDK, view context usage, and invoke native Compact with a total
|
||||
timeout. Questions from concurrent external Server conversations are mapped
|
||||
by public request ID so answers cannot cross conversations. External
|
||||
OpenCode Servers report connection state only and do not claim native
|
||||
catalog access. Arbitrary plugin installation, Session Share, automatic
|
||||
Worktree, and OpenCode native-session persistence remain unavailable.
|
||||
- [x] **Continue Runtime customization**: Exposes native Rules, prompt
|
||||
templates, and MCP from static configuration plus editable GoodBuddy Rules
|
||||
and Prompt presets. Chat can select a preset per request and insert an
|
||||
editable Prompt. The current Continue Host has no trustworthy static native
|
||||
Tool discovery API and runs with an isolated `CONTINUE_GLOBAL_DIR`, so the UI
|
||||
explicitly marks static Tool discovery unsupported and does not present
|
||||
workspace or user Skills that the Host will not load as native capabilities.
|
||||
GoodBuddy-assigned Skills still execute through per-request staging. The
|
||||
temporary Continue Host does not reuse native session compaction; manual
|
||||
compaction uses the GoodBuddy summary model and verifies persisted summary
|
||||
coverage. Agent questions become unified question cards. Resources, Hooks,
|
||||
background Jobs, and Continue native-session management remain deferred.
|
||||
- [x] **Native Runtime catalog semantics**: Native capabilities are displayed
|
||||
across 11 tabs: Agents, Tools, Commands, Skills, MCP, Rules, Prompts,
|
||||
Resources, LSP, Formatters, and Context. Catalog state is independent from
|
||||
Runtime connectivity and distinguishes complete, partial, unavailable,
|
||||
connection-only, and unsupported. DeepSeek Harness enumerates bounded
|
||||
built-in/plugin Tools and Skills through the Host Registry, reports real
|
||||
Ask/Execute boundaries, and excludes per-request GoodBuddy Skills and
|
||||
Web/MCP proxies.
|
||||
- [ ] **Runtime supervision section** (planned): A fixed section in the
|
||||
application-level assistant workbar for Task-level delegation, background
|
||||
execution, Workflows/Hooks, long-running work, and native-session
|
||||
supervision across OpenCode, Continue, and DeepSeek Harness. Users select
|
||||
only Conversation or Task; Job/Run remains internal rather than becoming a
|
||||
tree or independent object.
|
||||
- [ ] **Advanced Subagent supervision** (planned): Task-level aggregation in
|
||||
the workbar's fixed Runtime section, configurable nesting, parallelism,
|
||||
budgets, and lifecycle controls. Basic expert-mode inheritance and
|
||||
single-level direct-model programming delegation are already available.
|
||||
|
||||
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件;活动按会话分组并默认收起,避免长历史占满页面。
|
||||
- [x] **记忆与智能心跳**:提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
|
||||
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
|
||||
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
|
||||
- [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、审批、取消和恢复,执行节点继续经过 Main Runtime 边界。
|
||||
- [ ] **统一 Run Graph 与回放**(规划中):关联任务、Subagent、模型、知识、工具审批、用量和成果,支持失败定位、重试和脱敏导出。
|
||||
### Skills, MCP, and knowledge
|
||||
|
||||
### 浏览器、通信、语音与应用维护
|
||||
- [x] **On-demand Skills**: Assignable to direct models, OpenCode, Continue,
|
||||
and DeepSeek Harness with bounded resources and controlled Runtime boundaries.
|
||||
- [x] **Local tool-execution environment source path**: Under Capabilities and
|
||||
Tools / Tool Environment, users can select GoodBuddy-managed Node.js,
|
||||
on-demand managed Python, or a genuinely validated custom interpreter for
|
||||
local Skills and stdio MCP. The page provides an independent Native/OSS
|
||||
source choice, diagnostics, installation progress, cancellation, and
|
||||
removal. New local Runtimes and stdio MCP processes receive immutable PATH
|
||||
snapshots without changing ordinary terminals, the system environment, or
|
||||
remote Hosts. Managed Node and native-source Python have passed real
|
||||
installation validation on Windows x64. Managed Python archive validation
|
||||
follows the target filesystem: Linux preserves valid case-distinct paths,
|
||||
while every target still rejects exact duplicates and unsafe entries.
|
||||
- **Local tool-execution environment validation records**: All six
|
||||
platform/architecture OSS mirror objects have passed public byte, size, and
|
||||
SHA-256 verification. Managed Python remains an on-demand download and does
|
||||
not add license files to the Desktop package. Each standard package job uses
|
||||
a native target-architecture Runner and performs a real managed-Python
|
||||
install, SSL, pip, and venv probe before packaging. The immutable `v0.12.0`
|
||||
attempt passed Windows and macOS but exposed Linux TAR case handling before
|
||||
publication. The immutable `v0.12.1` attempt was cancelled before native
|
||||
packaging while synchronizing the Agent release. Published `v0.12.2`
|
||||
subsequently passed all six native package jobs and their managed-Python
|
||||
install probes. Real Skill/MCP execution, custom interpreter execution, and
|
||||
coordination with affected running processes remain
|
||||
acceptance work. Six-platform release acceptance must not be claimed before
|
||||
the applicable checks pass.
|
||||
- [x] **On-demand built-in MCP**: Knowledge, Magic Notes, GoodBuddy
|
||||
configuration, and built-in browser MCP can be enabled independently and assigned to direct
|
||||
models, GoodBuddy-managed OpenCode, and Continue. Settings explicitly marks
|
||||
DeepSeek Harness unsupported. Built-in MCP uses short-lived local authority
|
||||
for the current request, and user configuration cannot loosen Ask/Execute
|
||||
read/write boundaries.
|
||||
- [x] **MCP Tools**: Explicitly enabled custom MCP can be assigned to direct
|
||||
models, GoodBuddy-managed OpenCode, Continue Agent Execute, and DeepSeek
|
||||
Harness, and loads only in Execute. Agent child processes receive only
|
||||
per-request local-loopback authority; MCP addresses, commands, and
|
||||
credentials remain in Main. Dynamic tools pass through discovery and the
|
||||
existing activity and permission boundaries; healthy direct-model
|
||||
and Harness calls reuse the discovered request catalog instead of
|
||||
rediscovering it before every call.
|
||||
- [x] **MCP Prompts and Resource metadata**: MCP testing discovers bounded
|
||||
Prompt, parameter, and Resource metadata only when the Server declares the
|
||||
corresponding capability and does not read Resource content. Supported
|
||||
Prompts can be inserted into the chat draft for further editing. OpenCode can
|
||||
report an experimental Resource catalog; the current Continue version
|
||||
explicitly does not support Resources.
|
||||
- [x] **Local knowledge bases**: Supports file, directory, and web imports,
|
||||
SQLite FTS5 retrieval, and source tracing. Knowledge selection persists per
|
||||
conversation and starts empty for new conversations. Advanced creation and
|
||||
retrieval parameters are collapsed by default. Documents distinguish ready,
|
||||
processing, and failed states, with source opening, retry, and confirmation
|
||||
before source removal.
|
||||
- [x] **External knowledge-base connections**: Manage Dify, FastGPT,
|
||||
and RAGFlow instances from the Knowledge page, then discover remote
|
||||
knowledge bases or enter their IDs and verify bindings with provider-specific
|
||||
retrieval settings and citations. Conversations support on-demand and
|
||||
required pre-answer retrieval. External systems provide retrieval only:
|
||||
GoodBuddy does not use
|
||||
their App, Chat, Workflow, or Agent APIs, and does not bulk-sync, locally
|
||||
index, or modify remote content. Bounded cited snippets are retained locally
|
||||
with their conversations. See the
|
||||
[external knowledge-base PRD](./docs/features/knowledge-base/external-knowledge-prd.md).
|
||||
- **External knowledge-base validation records**: All three providers have passed
|
||||
real service, local HTTP MCP, and production-IPC short-answer checks; Dify
|
||||
additionally passed the complete App composer-to-answer-and-citation path.
|
||||
FastGPT/RAGFlow desktop-specific parameters, complex multi-library answers,
|
||||
and remote pre-answer retrieval remain unverified. See the
|
||||
[validation evidence and remaining work](./docs/features/knowledge-base/progress.md).
|
||||
- [x] **Knowledge graph**: Supports rule-based, model-based, and hybrid
|
||||
extraction plus entity, relationship, alias, and evidence maintenance.
|
||||
- [x] **Embedding configuration and retrieval**: Configures compatible
|
||||
Embeddings endpoints and uses them for semantic retrieval.
|
||||
- [x] **Embedding diagnostics and indexing jobs**: Provides real embedding
|
||||
diagnostics, per-document rebuild progress, cancellation, failure state, and
|
||||
result recovery after restart. Each successful document becomes immediately
|
||||
searchable.
|
||||
- [x] **Hybrid retrieval workbench**: Diagnoses full-text, Chinese phrase,
|
||||
vector, and graph channels with configurable Top K, thresholds, weights,
|
||||
local or learned reranking, and context budgets.
|
||||
- [x] **Chunking, maintenance, and evaluation**: Supports fixed, structured,
|
||||
and parent-child chunking, chunk maintenance, cancellable rebuilds, and
|
||||
bilingual retrieval evaluation.
|
||||
- [x] **Controlled knowledge ontology**: Each knowledge base can define
|
||||
entities, relationships, aliases, and endpoint constraints while retaining
|
||||
evidence offsets, confidence, and extraction source, with explicit guidance
|
||||
to rebuild the graph.
|
||||
- [x] **Required retrieval and cited context**: Conversations can retrieve on
|
||||
demand or before every response, show zero-result, degraded, failed, and
|
||||
cancelled states, and expose cited context or safely open its source.
|
||||
- [x] **Magic Notes**: A local-first notes and todo workbench with scope
|
||||
management, editing, filtering, and controlled AI comments. The todo view
|
||||
displays the source entry inside the selected item's details, without
|
||||
showing the previous item's source after a selection change. Failed reads
|
||||
use application notifications, and missing sources have an explicit fallback. The left
|
||||
navigation can show the incomplete-todo count, and create, save, and comment
|
||||
results use application-wide notifications. Agent/MCP note writes automatically
|
||||
refresh the open workbench while preserving selection and unsaved drafts.
|
||||
- [ ] **MCP Server Control Plane** (planned): Unified MCP lifecycle, health
|
||||
checks, reconnection, schema cache, isolation, approval, and audit.
|
||||
- [ ] **Traceable note excerpts and AI editing** (planned): Collects sourced
|
||||
excerpts from conversations, knowledge, and the web and provides
|
||||
confirmation-based summarization, rewriting, and organization.
|
||||
|
||||
- [x] **浏览器和桌面受控工具**:保留范围、取消、超时、输出边界和执行记录。
|
||||
- [x] **远程消息通道项目**:微信 ClawBot、企业微信和钉钉分别拥有系统管理的项目、独立远程会话、工作目录、处理后端、默认 Ask/Execute 模式及任务活动归属。
|
||||
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
|
||||
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
|
||||
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。
|
||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||
- [x] **版本检查**:仅检查固定官方 Release 和当前平台清单,不自动下载或安装。
|
||||
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
|
||||
### Work management, long-term collaboration, and workflows
|
||||
|
||||
### 开源、构建与发布
|
||||
- [x] **Tasks, activity, and artifacts**: Centrally manages task state, audit
|
||||
activity, and independent artifact files. Ordinary chat responses remain
|
||||
only in the conversation and are no longer copied into Artifacts; existing
|
||||
duplicate chat Markdown is hidden from the artifact list but not physically
|
||||
deleted. Run history is stored in Main SQLite without the previous 500-item,
|
||||
4,000-character, or 2 MB Renderer limits; the page renders it in batches,
|
||||
without a persistent legacy-cache truncation warning; previously lost
|
||||
history is not restored. Token usage is grouped by Runtime and
|
||||
model and normalizes the different OpenAI-compatible and Anthropic Messages
|
||||
cache-reporting semantics when showing cache hit rate. Activity is grouped
|
||||
by conversation and collapsed by default so long histories do not fill the
|
||||
page.
|
||||
- [x] **Compact conversation tool records**: Expand individual tool records
|
||||
to read or copy results, errors, and input parameters. Conversation and
|
||||
child-task progress use concise rows without repeated Runtime summaries.
|
||||
OpenCode, Continue, DeepSeek Harness, and direct models show available file
|
||||
paths, commands, or search summaries and retain them through completion and
|
||||
conversation reloads.
|
||||
- [x] **Task and custom-task experience**: Each product-level Task belongs to
|
||||
one Conversation, while one Conversation can contain multiple Tasks. The
|
||||
left conversation list exposes Task children through a leading expand
|
||||
button; children share status dots, the parent does not repeat task badges,
|
||||
and the UI stops at Task rather than exposing Job/Run levels. A new custom
|
||||
Task can use the current or a new Conversation, defaults to Execute, and
|
||||
preserves Runtime, tool, and approval boundaries. Repeated triggers reuse the
|
||||
same Task; text returns to the Conversation, while independent files and
|
||||
images remain artifacts. Ordinary messages and due Scheduled Tasks share a
|
||||
persistent Conversation queue that runs one item at a time. Users can keep
|
||||
sending while a response is active; queued items continue in order and can
|
||||
be removed or inserted immediately by interrupting the current item. Task
|
||||
Center remains the complete index rather than creating a separate Automation
|
||||
Center. Current schedules support one-time, daily, and weekly triggers;
|
||||
advanced time zones, Cron, event triggers, and retry governance remain
|
||||
incremental PRD work. Scheduled messages use the conversation's current
|
||||
history, Runtime, work mode, and saved knowledge retrieval settings; task
|
||||
details show the actual mode used, and an active occurrence blocks duplicate
|
||||
Run Now actions. See the
|
||||
[Task Center PRD](./docs/features/task-and-job/task-center-prd.md) and
|
||||
[Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md).
|
||||
- [x] **Memory and Smart Heartbeat**: Provides periodic review, suggested
|
||||
memories, insights, follow-up tasks, and auditable run history.
|
||||
- [x] **Improved Smart Heartbeat entry and scope**: Smart Heartbeat / Heartbeat
|
||||
Plans is the sole authoritative full-configuration entry. Plans can be
|
||||
created and edited for Global or one or more selected Projects. Legacy
|
||||
single-project settings migrate without loss, and project-level memory and
|
||||
action output must explicitly target a Project in scope. Task Center and
|
||||
Settings no longer duplicate the form. Partition-aware review, candidate
|
||||
generation, and recall triggers remain to be designed. See the
|
||||
[Smart Heartbeat PRD](./docs/features/smart-heartbeat/prd.md).
|
||||
- [ ] **General supervision** (planned): Uses a fixed supervision section to
|
||||
observe user-selected conversations, Tasks, automations, or experiments and
|
||||
provide evidence-backed comments and requests for human intervention,
|
||||
without automatically speaking, approving tools, or switching to Execute.
|
||||
See the
|
||||
[Conversation Supervision PRD](./docs/features/conversation-supervision/prd.md).
|
||||
- [ ] **Batch runs and comparison lab** (planned): Compares model, Prompt,
|
||||
role, and workflow configurations in batches and summarizes quality,
|
||||
duration, tokens, cost, failure rate, and artifact differences.
|
||||
- [ ] **Temporal memory and fact-conflict detection** (planned): Adds validity
|
||||
periods, current/expired/conflicting fact detection, fact checking, and
|
||||
evidence tracing to memory and the knowledge graph.
|
||||
- [ ] **Visual controlled workflows** (planned): Versioned DAGs, conditional
|
||||
branches, approvals, cancellation, and recovery, with execution still
|
||||
passing through Main Runtime boundaries.
|
||||
- [ ] **Unified Run Graph and replay** (planned): Connects Tasks, Subagents,
|
||||
models, knowledge, tool approvals, usage, and artifacts for failure
|
||||
diagnosis, retry, and redacted export.
|
||||
|
||||
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
|
||||
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
|
||||
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希。
|
||||
### Browser, communication, voice, and application maintenance
|
||||
|
||||
### 开放接口、团队协作与远程执行
|
||||
- [x] **Shared built-in browser for runtimes**: Uses GoodBuddy's isolated
|
||||
Chromium and never controls a browser installed by the user. A separate
|
||||
master switch decides whether assigned runtimes receive the capability in
|
||||
Execute, with no per-use prompt after enablement. Turning Agent access off
|
||||
does not disable manual browser workbar actions. The Browser workbar and
|
||||
Agent share the same Conversation-owned session and serialized operation
|
||||
path, with Back, Refresh/Stop Loading, address entry, Go, Interaction, and
|
||||
Close actions. Stop Loading does not close the session, and user navigation
|
||||
changes the page the Agent sees next.
|
||||
Multiple tabs in one conversation share login state while keeping separate
|
||||
pages and navigation. Each model request retains its starting tab binding;
|
||||
unused request reservations do not create browser resources. Released tabs
|
||||
can be reopened, explicit screenshots remain available, and ordinary
|
||||
operations no longer trigger unused automatic screenshot capture.
|
||||
Tabs can be closed while an AI request uses them without cancelling the
|
||||
whole request. A later explicit navigation creates a separate replacement
|
||||
tab; blank pages cannot reload, and switching or closing panels clears stale
|
||||
action errors.
|
||||
- [x] **Client-computer control tools**: Managed separately from the built-in
|
||||
browser with scope, cancellation, timeout, output, and activity boundaries.
|
||||
- [x] **Remote messaging-channel projects**: WeChat ClawBot, WeCom, and
|
||||
DingTalk each have a system-managed project, independent remote
|
||||
conversations, working directory, processing backend, default Ask/Execute
|
||||
mode, and task/activity ownership. Each channel controls complete-response
|
||||
length and segmentation according to platform capabilities rather than
|
||||
relying on a shared service truncation.
|
||||
- [x] **WeChat ClawBot QR login and media**: A separate Sidecar handles local
|
||||
QR scanning, verification codes, encrypted credentials, and text messaging.
|
||||
Personal WeChat chats support images and files, with at most four
|
||||
attachments and 12 MB total after decryption per message.
|
||||
- [x] **Safe WeChat responses**: Can return images generated by the current
|
||||
task, or generate a Markdown attachment from the current final text after an
|
||||
explicit user request. It never automatically reads or sends existing
|
||||
workspace files.
|
||||
- [x] **WeCom and DingTalk connections**: Main-only encrypted settings,
|
||||
read-only environment-variable overrides, connection tests, dynamic
|
||||
enable/disable, sender scope, and status diagnostics.
|
||||
- [x] **Managed local-model download source**: Platform Features / General
|
||||
selects ModelScope (default) or Hugging Face globally for future speech-input
|
||||
and OCR model downloads. A source without a complete verified file is
|
||||
explicitly unavailable and never silently falls back or combines files.
|
||||
- [x] **Optional local speech-model management**: Model weights are not bundled.
|
||||
Provides verified downloads, progress and cancellation, source links, ZIP or
|
||||
local-directory import, switching, and removal.
|
||||
- [x] **Local recording and offline transcription**: Captures microphone audio
|
||||
and transcribes with the selected local model, with stop, cancel, state
|
||||
feedback, and resource cleanup.
|
||||
- [x] **Version checks and mirror source**: About and Updates selects GitHub
|
||||
(default) or the mirror. Manual checks, startup checks, and the download page
|
||||
use the same choice and read only fixed trusted release indexes; GoodBuddy
|
||||
does not automatically download or install updates.
|
||||
- [x] **Window recovery controls**: Rendering errors show a localized Reload
|
||||
action and renderer crashes offer a native recovery confirmation. Reload may
|
||||
lose unsaved input. See [window recovery](./docs/development/window-recovery.md).
|
||||
- [x] **In-app feedback**: About and Updates can submit problems, suggestions,
|
||||
or experience feedback with an optional email and one screenshot.
|
||||
Diagnostics are not uploaded by default; users can explicitly attach a
|
||||
bounded recent Desktop diagnostic summary. Failed submission preserves the
|
||||
draft, diagnostic choice, and request ID. Conversations, Prompts,
|
||||
credentials, file contents, paths, raw provider responses, and remote Agent
|
||||
logs are never attached.
|
||||
- [x] **Private-network compatibility**: Permits in-app HTTP plus invalid,
|
||||
self-signed, or expired HTTPS certificates. Fixed platform services such as
|
||||
feedback and WeChat credential or media endpoints retain strict certificate
|
||||
validation.
|
||||
|
||||
- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。
|
||||
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
|
||||
- [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
|
||||
- [ ] **多云远程沙盒 Agent**(规划中):通过云厂商 API 和 SSH Agent 管理专用 Linux 沙盒;凭据留在 Main 进程,高风险控制面操作单独确认。
|
||||
### Open source, builds, and releases
|
||||
|
||||
## 规划原则
|
||||
- Current source candidates are Desktop `0.13.5` and Agent `0.13.0`, with
|
||||
OpenCode pinned to `1.18.29`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Agent `0.13.0` adds remote image-tool integration and shared Runtime processes
|
||||
with conversation recovery fixes. Its packages require Desktop `0.13.5`;
|
||||
update Desktop first, then the Host environment. Node remains `24.19.0`.
|
||||
- Validation records remain separate from implementation status. Current-source
|
||||
storage validation covers real local and Linux x64 Host tool workloads,
|
||||
concurrent projects/conversations, cancellation, and lossless database migration.
|
||||
Runtime process reuse has real Windows full-App and Linux x64 Host evidence;
|
||||
PPTX OCR and idle-memory release have real Windows file evidence. Image tools
|
||||
have real provider generation/editing and Host transport evidence. Full
|
||||
UI-driven natural-language invocation and local/remote switching are not
|
||||
covered by those records. The candidate still requires main CI and
|
||||
native release packaging. See the [Runtime evidence](./docs/features/assistant-workbar/progress.md)
|
||||
and [image-tool evidence](./docs/features/conversation-media-generation/progress.md).
|
||||
- Candidate checks on 2026-09-15 passed: bilingual release notes, `npm test`
|
||||
(4,293 passed, 67 skipped), `npm run typecheck`, and `npm run lint`.
|
||||
This metadata/documentation preparation made no new real model calls and did
|
||||
not rerun Host scenarios or build local production packages.
|
||||
- [x] **0BSD open-source license**: Original code can be freely used, copied,
|
||||
modified, distributed, and commercialized. Third-party components and
|
||||
resources retain their own licenses.
|
||||
- [x] **Reproducible dependency installation and source builds**: Uses locked
|
||||
dependencies, Node.js 24, and unified test, type-check, lint, and production
|
||||
build commands.
|
||||
- [x] **Six-target native release matrix**: Native runners build Windows,
|
||||
macOS, and Linux `x64` and `arm64` targets with release manifests and SHA-256
|
||||
hashes. Windows provides NSIS and portable ZIP, macOS DMG only, and Linux
|
||||
AppImage, DEB, and RPM, for 12 installers and 20 published assets.
|
||||
- [x] **Desktop Runtime package verification**: Offline OpenCode dependencies
|
||||
are copied without electron-builder's root node_modules exclusion; DSH
|
||||
session projection is bundled into the unpacked Host. Final package checks
|
||||
compare the offline dependency tree and reject external DeepSeek imports.
|
||||
Windows x64 CI additionally imports the packaged OpenCode plugin and checks
|
||||
the real DSH UtilityProcess handshake before publishing.
|
||||
- [x] **DMG-only update manifests**: Current Desktop and website readers accept
|
||||
DMG-only and historical DMG/ZIP manifests. Older macOS GitHub readers and all
|
||||
older mirror readers require a manual upgrade from the download page.
|
||||
|
||||
规划中的工作流、Subagent、MCP、远程 API 和沙盒能力不得绕过现有 Main Runtime、Ask/Execute、权限、取消、超时和审计边界。
|
||||
### Open interfaces, team collaboration, and remote execution
|
||||
|
||||
- [x] **Remote task delegation**: Enabled only after the user explicitly
|
||||
configures an endpoint and token, uses HTTP(S) according to the global
|
||||
private-network compatibility setting, and writes results to a persistent
|
||||
outbox.
|
||||
- [ ] **Headless Runtime API** (planned): A local-first Task, event, state, and
|
||||
artifact API with scoped, expiring, rate-limited, and revocable tokens.
|
||||
- [ ] **GoodBuddy Team Hub** (planned): An optional service for organizations,
|
||||
RBAC, project sharing, remote Agents, policy distribution, and tenant audit.
|
||||
- **SSH Host and remote execution-space validation records**: Host CRUD,
|
||||
Host Key, encrypted credentials, Project UI, Workspace, OpenCode ACP v5,
|
||||
Agent-owned Prompt/gateway/transcript, read-only Ask, full-account Execute,
|
||||
cancellation, exact detached-Agent reconnection, and release-only
|
||||
dual-architecture resource verification are wired. Current Linux x64 source
|
||||
has passed a real-model and tool matrix covering detach, process exit, relay
|
||||
loss, concurrency, cancellation, provider failures, Agent restart, and
|
||||
Desktop SQLite recovery. A current-source, test-signed Agent `0.11.17`
|
||||
package on Linux x64 additionally passed two concurrent Ask reads, an
|
||||
Execute write/read, and six real managed-gateway model requests with the
|
||||
configured custom headers and body fields. No provider request was replayed,
|
||||
and scanning the isolated Agent state found no API key or custom request
|
||||
value. The public signing-key registry is provisioned. Direct control-plane
|
||||
source does not wait for a new installer-bearing package, but the current
|
||||
formally signed Linux x64/arm64 artifact matrix still requires public
|
||||
verification, along with GitHub/Beijing mirror, dual-architecture, and
|
||||
offline GoodBuddy transfer release acceptance. Until these gates pass, this
|
||||
path must not be described as published or as having passed formal release
|
||||
acceptance.
|
||||
- [ ] **Multi-cloud remote sandbox Agents** (planned): Manages dedicated Linux
|
||||
sandboxes through cloud-provider APIs and SSH Agents. Credentials remain in
|
||||
Main, and high-risk control-plane operations receive separate confirmation.
|
||||
|
||||
## Roadmap Principles
|
||||
|
||||
Planned workflows, Subagents, MCP, remote APIs, and sandbox capabilities must
|
||||
not bypass existing Main Runtime, Ask/Execute, permission, cancellation,
|
||||
timeout, or audit boundaries.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# GoodBuddy 功能矩阵与路线图
|
||||
|
||||
[English](./FEATURES.md) | **简体中文**
|
||||
|
||||
本文记录 GoodBuddy 的已提供能力和路线图。未完成项目不代表已包含在当前版本中。
|
||||
|
||||
## 状态说明
|
||||
|
||||
- [x] 已完成
|
||||
- [ ] 待实现
|
||||
|
||||
状态表示功能实现情况,不等同于版本已发布或全部测试已执行。验证记录单独列出,不作为第三种功能状态。
|
||||
|
||||
## 功能总表
|
||||
|
||||
### 桌面基础、工作空间与上下文
|
||||
|
||||
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.5` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
|
||||
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
|
||||
- [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话。锚定的“项目 → 会话”菜单支持键盘导航和精确打开对话,不再使用整块活动弹窗;空闲时隐藏摘要,长会话子菜单独立滚动,项目选择器继续显示活动数量。
|
||||
- [x] **紧凑会话控件**:输入选项集中在紧凑设置面板,Runtime、工作模式、发送和队列操作保持可用;会话搜索提供内嵌清除按钮,清除后恢复当前范围列表并把焦点返回输入框。
|
||||
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。聊天输入框支持 `Ctrl+V` 粘贴一个或多个本地支持文件并自动添加为附件,沿用附件按钮的解析进度与限制;普通文本和截图仍可直接粘贴。详见[聊天附件规则](./docs/features/document-processing/prd.md#321-聊天附件入口)。
|
||||
- [x] **图片型 PPTX 文字识别**:配置本地 OCR 并选择启用 OCR 的解析方式后,可识别内嵌 PNG、JPEG 和 WebP 图片,保留原生文字与幻灯片定位。OCR 在没有活动或排队任务时空闲 60 秒自动释放模型内存,下次按需重载;快速文本/索引模式仍跳过 OCR。详见[解析行为与真实文件验证](./docs/features/document-processing/chat-attachments-technical-design.md)。
|
||||
- [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异、当前浏览目录和已展开目录,超过 50 项变更可继续加载。
|
||||
- [x] **工作区文件与 Git 管理**:支持文件和目录的新建、重命名、移动、删除、属性查看,以及本地/远程分支搜索与切换、新建分支、显式 Fetch、提交历史和单文件提交差异。工具按文件/Git 视图分组,分支搜索使用统一紧凑表单,子目录保留文字导航而根目录不再显示孤立图标。提交历史贴底,首次展开占 Git 内容区一半,支持调整比例、独立滚动和统一刷新;切换项目自动检测 Git,不沿用其他项目状态。移动限于工作区内,删除须确认,分支冲突不自动 stash 或丢弃改动;托管 SSH 操作需要 Agent `0.11.23`。
|
||||
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
|
||||
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
|
||||
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目、全局任务和全部项目,范围随布局保存。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
|
||||
- [ ] **应用导航与系统工具快捷入口**(仅设计):规划固定系统入口、内置应用显隐和打开现有工作栏的底部工具按钮;尚未实现新界面。详见[设计文档](./docs/features/application-tool-navigation/README.md)。
|
||||
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入监督、统一 Runtime、受管进程、静态安全 HTML 预览、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。
|
||||
- [ ] **ShareServer Office 协同编辑**(设计中):通过可选 ShareServer 集成 ONLYOFFICE Docs,在助手工作栏中以文件名打开多个文档 Tab,支持 DOCX、XLSX、PPTX 的人工编辑、保存、撤销、源文件冲突保护和后续 AI 选区修改。Office 正文会由所选 ShareServer 和编辑引擎处理;Desktop 不内置或启动 Document Server,未配置服务时不宣称离线编辑。详见[设计文档](./docs/features/office-document-editing/README.md)。
|
||||
|
||||
### Agent Runtime 与模型连接
|
||||
|
||||
- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。已随 0.13.2 发布的 schema 35 补修还会整理升级到 0.13.1 后重复写入的工具块,不删除事件;终态及时释放写入缓存,远程重复重放不重建已释放的缓存。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。连续请求自动复用最近一次成功生成的图片和文字历史,已保存会话重开后仍可继续修改,本轮显式附图优先;上游明确不支持编辑时继续按文字生成,仅在回复底部提示未使用参考图。编辑使用 multipart 上传图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。详见[图片生成与连续修改](./docs/features/image-generation/README.md)。
|
||||
- [x] **当前会话图片工具**:为图片模型开启会话调用后,支持工具调用的聊天模型可在 Execute 中生成图片、修改上传图片或历史成果,无需另开会话或手动切换图片模型。直连文本模型、本地 OpenCode、Continue、DeepSeek Harness 和托管远程 OpenCode 复用统一服务,Runtime 分配由模型设置派生;图片请求可能产生费用,编辑需服务商支持。详见[实现与验证记录](./docs/features/conversation-media-generation/progress.md)。
|
||||
- [x] **跨项目 Runtime 进程复用**:兼容配置的本地 OpenCode 和 DeepSeek Harness 共享重型进程,托管 OpenCode 会话在同一 Host 上复用进程。项目适配器及会话的工作区、模型、工具、问答和取消仍独立路由;远程待答问题和任务状态可在重连或桌面重启后恢复,不重发已接受请求。新的远程行为需要 Desktop `0.13.5` 与 Agent `0.13.0` 配套更新,详见[设计与实测](./docs/features/assistant-workbar/runtime-process-reuse-technical-design.md)。
|
||||
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 紧凑地发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 可以搜索和读取,但不能应用补丁或运行命令;用户无需另行安装 ripgrep。可处理的读取或搜索错误允许模型调整参数后继续,部分搜索结果保留并明确提示覆盖不完整。
|
||||
- [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node 和 OpenCode 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。macOS 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证;这不等于新版本已经发布。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
|
||||
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
|
||||
- **SSH Host 环境准备验证记录**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。
|
||||
- [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。
|
||||
- [x] **远程原生问答与取消修复**:Agent `0.11.24` 修复远程原生问答转交和取消待答后同一会话续发,需要先升级 Desktop 至 `0.13.0`。
|
||||
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择,设置页显示实际管理员或回退模型来源。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。Windows Host 启动和 ACP 会话共用规范工作区路径,同目录不同写法可复用正常 Runtime,不再因路径不一致而创建会话失败。
|
||||
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
|
||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权,包括工作区外路径及原生子代理工作。
|
||||
- [x] **Runtime 原生交互转交**:OpenCode 与 Continue 的选择、yes/no、自由文本回答和跳过使用现有问答卡片,本机与托管 SSH OpenCode 同时转交属于当前请求的子会话提问。托管 SSH 需要 Desktop `0.13.0`、Agent `0.11.24` 和新启动的托管 Runtime,不扩展到任意 ACP 服务。回答或跳过成功后保留原问题与答案,支持多轮记录和本地会话重载;旧版本已丢弃的答案不能恢复。并行问题按顺序等待回答,重复事件保留草稿,提交失败可重试;取消远程待答后可在同一会话继续发送。Execute 权限确认自动处理,不等待第二次审批;具体支持范围见[交互边界](./docs/features/assistant-workbar/runtime-interactions.md)。
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个专家并行分析。专家继承父请求 Ask/Execute 模式,可使用已启用的本机直连模型工具,Ask 仍只读;这些工具不是远程 OpenCode 子会话。聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
|
||||
- [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。
|
||||
- [x] **OpenCode 事件连接回收**:聊天与原生上下文压缩在结束事件迭代前关闭各自订阅,覆盖完成、失败、取消和消费方提前结束,不取消其他并行会话。
|
||||
- [x] **准确的消息底部状态**:区分请求准备、等待重试、开始重试、工具活动、待回答和终态;本机 OpenCode 使用原生重试次数和计划时间,直连模型显示自身退避阶段。消息底部状态点保持静态,未接入的 Runtime 重试不编造提示,会话列表闪点及发送、停止行为不变。
|
||||
- [x] **直连模型命令工作目录**:Execute 命令可通过绝对路径、相对路径或符号链接使用工作区外目录;工作区仍为默认目录和相对路径基准,Ask 保持只读。
|
||||
- [x] **简化 Runtime 选择**:会话与项目菜单不再枚举每个 Runtime 与模型的组合,Runtime 模型在系统设置中配置;已保存的项目固定模型仍可使用,并明确显示其固定模型。
|
||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
|
||||
- [x] **模型请求定制**:每个 LLM 连接可填写有界 JSON 自定义请求头和顶层请求体。直连与 Continue 支持两者;本地 OpenCode 和 DeepSeek Harness 只使用各自原生支持的请求头,受管 SSH OpenCode 模型网关支持两者。Runtime、协议和认证字段始终优先,这些普通连接设置不能用于保存 API Key 或其他密钥。详见[模型连接请求定制](./docs/features/model-connections/README.md)。
|
||||
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
||||
- [x] **持久凭据保护**:API Key 由 Main 使用系统安全存储加密且不暴露给 Renderer。托管 SSH accepted Prompt 只把当前 profile 与密钥放入 Agent 内存,不写入 SSH 参数、远端环境或磁盘;其他路径仍在 Main 内使用。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。
|
||||
- [x] **有界故障诊断**:Desktop 在用户数据目录中轮转保存启动、Runtime 和远程连接的固定阶段失败,最多 4 个 256 KiB 文件;GoodBuddy Agent 在各 installation 的私有 state 目录中轮转保存 daemon、连接、恢复和 Runtime 生命周期,最多 3 个 64 KiB 文件,并可通过固定 `diagnostics --installation-id` 命令读取。两端都只记录白名单阶段、稳定错误码/类型和固定短消息,不保存 Prompt、凭据、文件内容、路径、环境、SSH 参数或 Provider 原始响应;诊断写入失败不改变正常运行。
|
||||
- [x] **OpenCode Runtime 定制**:GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 Resources;Tools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用有总时限的原生 Compact;并发外部 Server 对话的提问使用请求级公开 ID 映射,回答不会串到其他会话。外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。
|
||||
- [x] **Continue Runtime 定制**:提供静态配置中的原生 Rules、Prompt 模板与 MCP 清单,以及可编辑的 GoodBuddy Rules/Prompt 配置预设;聊天可按请求选择预设和填入可继续编辑的 Prompt。当前 Continue Host 没有可信的静态原生 Tool 发现接口,且使用隔离的 `CONTINUE_GLOBAL_DIR`,因此界面明确标记 Tools 不支持静态发现,也不把 Host 实际不会加载的工作区或用户 Skills 冒充原生能力;GoodBuddy 分配的 Skills 仍按请求暂存执行。Continue 临时 Host 不复用原生会话压缩,手动压缩由 GoodBuddy 摘要模型完成并验证持久化摘要覆盖范围;Agent 交互提问转换为统一问答卡片。Resources、Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。
|
||||
- [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示真实 Ask/Execute 边界,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。
|
||||
- [ ] **Runtime 监督栏目**(规划中):在应用级助手工作栏的固定 Runtime 栏目统一承载 OpenCode、Continue 和 DeepSeek Harness 的 Task 级委派、后台执行、Workflow/Hook、长任务与原生会话监督;用户只选择 Conversation 或 Task,Job/Run 保持内部,不形成树或独立操作对象。
|
||||
- [ ] **Subagent 高级监督**(规划中):在工作栏固定 Runtime 栏目按 Task 聚合,提供可配置的嵌套、并行、预算和生命周期控制。基础专家模式继承与单层直连模型编程委派已经可用。
|
||||
|
||||
### Skills、MCP 与知识库
|
||||
|
||||
- [x] **Skills 按需接入**:可分配给直连模型、OpenCode、Continue 和 DeepSeek Harness,并使用有界资源和受控 Runtime 边界。
|
||||
- [x] **本机工具执行环境源码链路**:在“能力与工具 > 工具执行环境”中为本机 Skills 与 stdio MCP 选择 GoodBuddy 托管 Node.js、按需安装的托管 Python,或经过真实验证的自定义解释器;提供独立的原生地址/OSS 镜像选择、诊断、安装进度、取消和删除。新的本机 Runtime 与 stdio MCP 获得不可变 PATH 快照,不修改普通终端、系统环境或远程 Host。Windows x64 托管 Node 与原生地址 Python 已通过真实安装验证;托管 Python 归档按目标文件系统验证,Linux 保留大小写不同的合法路径,所有目标仍拒绝完全重复路径和不安全条目。
|
||||
- **本机工具执行环境验证记录**:六个平台/架构的 OSS 镜像对象已完成字节、大小和 SHA-256 公开回读验证。托管 Python 保持按需下载,不向 Desktop 发行包额外带入许可证文件。每个标准打包任务使用目标架构原生 Runner,并在打包前真实安装托管 Python,验证 SSL、pip 与 venv。不可变的 `v0.12.0` 尝试在发布前通过 Windows 与 macOS,但暴露了 Linux TAR 大小写处理问题;`v0.12.1` 在同步 Agent 发布时于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python 安装探针。现有记录未覆盖真实 Skill/MCP、自定义解释器和运行中进程协调。
|
||||
- [x] **内置 MCP 按需接入**:知识库、魔法笔记、GoodBuddy 配置与内置浏览器 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;DeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。
|
||||
- [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍经过发现、执行记录与权限边界。直连模型与 Harness 的健康调用复用当前请求已发现的工具清单,不在每次调用前重复发现。
|
||||
- [x] **MCP Prompts 与 Resources 元数据**:MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。
|
||||
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。知识范围按对话保存,新对话默认不选择知识库;创建与检索高级参数默认折叠。文档区分可用、处理中和失败,支持打开来源、失败重试及移除来源前确认。
|
||||
- [x] **外部知识库接入**:在知识库页面管理 Dify、FastGPT 和 RAGFlow 实例,通过目录或手工 ID 验证并绑定远端知识库,保留 Provider 专属检索配置和引用;对话支持按需检索及每次回答前检索。外部系统只负责知识检索,不接入其 App、Chat、Workflow 或 Agent,也不批量同步、本地索引或修改远端内容。被回答引用的有界检索片段随会话保存在本机。详见[外部知识库接入 PRD](./docs/features/knowledge-base/external-knowledge-prd.md)。
|
||||
- **外部知识库验证记录**:三家 Provider 已通过真实服务、本机 HTTP MCP 和生产 IPC 短答案检查,Dify 另通过完整 App 输入区到回答及引用的链路。现有记录未覆盖 FastGPT/RAGFlow 桌面专属参数、复杂多库问答和远程回答前检索,详见[验证记录](./docs/features/knowledge-base/progress.md)。
|
||||
- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
|
||||
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
|
||||
- [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。
|
||||
- [x] **混合检索测试台**:支持全文、中文词组、向量和图谱通道诊断,可调 Top K、阈值、权重、本地或学习型重排及上下文预算。
|
||||
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
|
||||
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
|
||||
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。Agent / MCP 写入后,已打开的工作台自动更新,并保留当前选择与未保存草稿。
|
||||
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
|
||||
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
|
||||
|
||||
### 工作管理、长期协作与工作流
|
||||
|
||||
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
|
||||
- [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。OpenCode、Continue、DeepSeek Harness 和直连模型可显示已有文件路径、命令或搜索摘要,并在工具完成和会话重载后保留。
|
||||
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
|
||||
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
|
||||
- [x] **定时消息沿用会话**:定时消息使用会话当前历史、Runtime、工作模式和已保存的知识检索设置;任务详情显示实际运行模式,当前 occurrence 未结束时禁止重复“立即运行”。
|
||||
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单;按分区回顾、候选生成和唤起条件仍待设计。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
|
||||
- [ ] **通用监督**(规划中):通过固定监督栏目观察用户选择的会话、任务、自动化或实验对象,提供带证据的评论与人工介入请求,但不自动发言、批准工具或切换 Execute。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
|
||||
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
|
||||
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
|
||||
- [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、审批、取消和恢复,执行节点继续经过 Main Runtime 边界。
|
||||
- [ ] **统一 Run Graph 与回放**(规划中):关联任务、Subagent、模型、知识、工具审批、用量和成果,支持失败定位、重试和脱敏导出。
|
||||
|
||||
### 浏览器、通信、语音与应用维护
|
||||
|
||||
- [x] **Runtime 共享内置浏览器**:使用 GoodBuddy 内置的隔离 Chromium,不控制客户端已安装的浏览器;用户通过内置 MCP 总开关和 Runtime 分配决定是否提供给 Execute,开启后不逐次询问。关闭 Agent 浏览器能力不影响用户继续在浏览器工作栏中手动前往、返回、刷新、停止加载、交互或关闭浏览器。直连模型、GoodBuddy 管理的 OpenCode 和 Continue 通过请求级权限共享按 Conversation 归属的会话和串行操作路径,用户导航会改变 Agent 下一步看到的页面。
|
||||
- [x] **浏览器多页签与按需资源**:同一对话可打开多个共享登录状态、页面与导航独立的浏览器页签,模型请求固定使用开始时绑定的目标。未使用的请求预留不创建浏览器资源,已释放页面可重新打开;保留显式截图,普通操作不再触发无用的自动截图。
|
||||
- [x] **浏览器关闭与刷新**:AI 使用中的页签也可关闭,不取消整个请求;后续显式导航会创建独立替代页。空白页不能刷新,切换或关闭面板后清除旧操作错误。
|
||||
- [x] **客户端电脑控制工具**:与内置浏览器分开管理,并保留范围、取消、超时、输出边界和执行记录。
|
||||
- [x] **远程消息通道项目**:微信 ClawBot、企业微信和钉钉分别拥有系统管理的项目、独立远程会话、工作目录、处理后端、默认 Ask/Execute 模式及任务活动归属;完整回复交由各通道按平台能力控制长度与分段,不再由公共服务统一截断。
|
||||
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
|
||||
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
|
||||
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||
- [x] **受管本地模型下载源**:在“平台功能 / 通用设置”中为后续语音输入与 OCR 模型下载全局选择 ModelScope(默认)或 Hugging Face;所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件。
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。
|
||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
|
||||
- [x] **窗口恢复入口**:渲染失败时显示本地化的重新加载页面,渲染进程崩溃时提供原生恢复确认;重新加载可能丢失未保存输入。详见[窗口恢复说明](./docs/development/window-recovery.md)。
|
||||
- [x] **应用内反馈**:在“关于与更新”中直接提交问题、建议或体验反馈,支持可选邮箱和单张截图;默认不上传诊断,用户可主动附加有界的最近桌面诊断摘要。失败后保留草稿、诊断选择和同一请求编号;不会附加对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。
|
||||
- [x] **内网兼容**:允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书;反馈、微信凭据与媒体等固定平台服务仍保持严格证书校验。
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
- 当前源码候选为 Desktop `0.13.5`、Agent `0.13.0`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
|
||||
- Agent `0.13.0` 接入远程图片工具、共享 Runtime 进程并修复会话恢复,复合包要求 Desktop `0.13.5`;请先升级 Desktop,再更新 Host 环境。Node 保持 `24.19.0`。
|
||||
- 验证记录与功能状态分开。存储已有本地及 Linux x64 Host 真实工具任务、多项目多会话、取消和无损迁移验证;进程复用有 Windows 完整 App 与 Linux x64 Host 证据,PPTX OCR 与空闲内存释放有 Windows 真实文件证据。图片工具已有真实服务商生成/编辑及 Host 传输证据,这些记录未覆盖完整界面自然语言调用和本地/远程切换。候选仍需主分支 CI 及原生发布打包,详见[Runtime 验证记录](./docs/features/assistant-workbar/progress.md)与[图片工具验证记录](./docs/features/conversation-media-generation/progress.md)。
|
||||
- 2026-09-15 候选检查通过:双语更新说明、`npm test`(4,293 项通过、67 项跳过)、`npm run typecheck`、`npm run lint`。本次版本元数据与文档准备新增真实模型调用为 0,未重跑 Host 场景或进行本地生产打包。
|
||||
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
|
||||
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
|
||||
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希。Windows 提供 NSIS 与 portable ZIP,macOS 仅 DMG,Linux 提供 AppImage、DEB 和 RPM,共 12 个安装包、20 个发布资产。
|
||||
- [x] **桌面 Runtime 包校验**:OpenCode 离线依赖复制避开 electron-builder 对源根目录 node_modules 的排除,DSH 会话投影组件打入解包后的 Host。最终包逐文件核对离线依赖并拒绝外部 DeepSeek 导入;Windows x64 CI 还会导入包内 OpenCode 插件、验证真实 DSH UtilityProcess 握手,通过后才发布。
|
||||
- [x] **DMG-only 更新清单**:当前 Desktop 和官网读取器同时接受仅 DMG 与历史 DMG/ZIP 清单。旧版 macOS 的 GitHub 更新检查及所有旧版镜像更新检查需要从下载页手动升级。
|
||||
|
||||
### 开放接口、团队协作与远程执行
|
||||
|
||||
- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。
|
||||
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
|
||||
- [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
|
||||
- **SSH 主机与远程执行空间验证记录**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v5、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验已经接线。Linux x64 当前源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;当前源码测试签名 Agent `0.11.17` 又通过两个并发 Ask 读取、一次 Execute 写入并读回,以及 6 次带自定义 Header 和 Body 的真实受管网关模型请求,未发现 Provider 请求重放,隔离 Agent state 中也没有 API Key 或自定义请求值。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包;现有记录未覆盖当前 Linux x64/arm64 正式签名工件的完整公开核对及 GitHub/北京镜像、双架构和离线 GoodBuddy 传输矩阵,发布结果另行记录。
|
||||
- [ ] **多云远程沙盒 Agent**(规划中):通过云厂商 API 和 SSH Agent 管理专用 Linux 沙盒;凭据留在 Main 进程,高风险控制面操作单独确认。
|
||||
|
||||
## 规划原则
|
||||
|
||||
规划中的工作流、Subagent、MCP、远程 API 和沙盒能力不得绕过现有 Main Runtime、Ask/Execute、权限、取消、超时和审计边界。
|
||||
@@ -1,77 +0,0 @@
|
||||
# GoodBuddy
|
||||
|
||||
English | [简体中文](README.md)
|
||||
|
||||
A secure, cross-platform, local-first desktop AI assistant and Agent workspace.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -1,49 +1,113 @@
|
||||
<div align="center">
|
||||
<img src="./sites/assets/goodbuddy-light.png" width="96" alt="GoodBuddy" />
|
||||
|
||||
# GoodBuddy
|
||||
|
||||
[English](README.en.md) | 简体中文
|
||||
**Desktop access to model chat, coding agents, knowledge, and scheduled work**
|
||||
|
||||
安全、跨平台、本地优先的桌面 AI 助手与 Agent 工作空间。
|
||||
**English** | [简体中文](./README.zh-CN.md)
|
||||
|
||||

|
||||
[Website](https://mesalogo.github.io/goodbuddy/en.html) ·
|
||||
[Download](https://github.com/mesalogo/goodbuddy/releases) ·
|
||||
[Feature Matrix](./FEATURES.md) ·
|
||||
[Build Guide](./BUILD.md)
|
||||
|
||||
## 核心能力
|
||||
[](https://github.com/mesalogo/goodbuddy/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/mesalogo/goodbuddy/releases)
|
||||
[](https://github.com/mesalogo/goodbuddy/releases)
|
||||
[](https://github.com/mesalogo/goodbuddy/releases)
|
||||
</div>
|
||||
|
||||
- **安全执行**:`Ask` 保持只读;`Execute` 仅运行已启用且受边界约束的工具,并保留活动记录。
|
||||
- **本地优先**:会话、任务、成果、记忆、知识库和图谱保存在本地 SQLite;API Key 由系统安全存储加密。
|
||||
- **多 Runtime**:支持直连模型、OpenCode 和 Continue,统一处理取消、超时、输出限制和进程退出。
|
||||
- **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、Skills 和 MCP。
|
||||
- **知识工作区**:支持文件、目录和网页导入,以及全文、词组、向量和图谱混合检索。
|
||||
- **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳。
|
||||
- **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话。
|
||||
- **桌面上下文**:可选择文件、截图、应用窗口、剪贴板和语音作为上下文。
|
||||
- **离线语音**:支持 SenseVoice、Paraformer 和 Whisper 本地模型。
|
||||
- **富文本回答**:支持 Markdown、LaTeX 公式和受控 Mermaid 图表。
|
||||

|
||||
|
||||

|
||||
GoodBuddy is a desktop application for model chat, coding agents, knowledge
|
||||
retrieval, notes, and scheduled tasks. No GoodBuddy account is required.
|
||||
Workspace data is stored locally by default. Model endpoints can run on the
|
||||
same device, on a private network, or at a provider selected by the user.
|
||||
|
||||

|
||||
## Highlights
|
||||
|
||||

|
||||
- **Local-first workspace**: No GoodBuddy account is required. Workspace data
|
||||
stays local by default, and users choose where model services run.
|
||||
- **Unified Agent Runtimes**: Use direct models, OpenCode, Continue, and the
|
||||
preview DeepSeek Harness from one desktop interface.
|
||||
- **Explicit execution boundaries**: `Ask` remains read-only, while `Execute`
|
||||
uses the current account's permissions. Tool activity, cancellation,
|
||||
timeouts, and token usage are recorded.
|
||||
- **Broad platform coverage**: Official Linux releases cover `x64` and `arm64`,
|
||||
including compatible distributions used in China's domestic computing
|
||||
ecosystem. A separate
|
||||
experimental `loong64` preview is available for LoongArch systems.
|
||||
- **Knowledge and long-running work**: Knowledge bases, graphs, Magic Notes,
|
||||
tasks, and Smart Heartbeat share one desktop workspace.
|
||||
|
||||

|
||||
Platform coverage describes operating-system, instruction-set, and package
|
||||
compatibility, not certification for every hardware vendor or distribution
|
||||
release. LoongArch is outside the official release matrix; see the
|
||||
[LoongArch preview notes](./docs/development/loongarch-preview-build.md) for its
|
||||
current limitations.
|
||||
|
||||
完整功能和路线图见 [FEATURES.md](FEATURES.md)。
|
||||
## Capabilities
|
||||
|
||||
## 安装
|
||||
| Area | Available in GoodBuddy |
|
||||
| --- | --- |
|
||||
| Agent Runtimes | Direct model connections, OpenCode, Continue, and the preview DeepSeek Harness |
|
||||
| Data and execution | Local SQLite storage, read-only `Ask`, full-account `Execute`, and recorded tool activity |
|
||||
| Knowledge | File, folder, and web imports with full-text, Chinese phrase, vector, and knowledge graph retrieval |
|
||||
| Desktop releases | Official Windows, macOS, and Linux builds for `x64` and `arm64`, plus an experimental LoongArch preview |
|
||||
| Integrations | Local or hosted model endpoints, custom MCP servers, WeChat ClawBot, WeCom, and DingTalk |
|
||||
|
||||
从 [GitHub Releases](https://github.com/mesalogo/goodbuddy/releases) 下载:
|
||||
## Product tour
|
||||
|
||||
| 系统 | 架构 | 格式 |
|
||||
### Knowledge bases and graphs
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Magic Notes and Smart Heartbeat
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Agent Runtimes
|
||||
|
||||
| Runtime | Functions | Configuration |
|
||||
| --- | --- | --- |
|
||||
| Windows | `x64`、`arm64` | NSIS、便携 ZIP |
|
||||
| macOS | `x64`、`arm64` | DMG、ZIP |
|
||||
| Linux | `x64`、`arm64` | AppImage、DEB |
|
||||
| Direct models | Chat, retrieval, image generation, and built-in tools | GoodBuddy model connections |
|
||||
| OpenCode | Coding agents, native commands, tools, and skills | GoodBuddy-managed or native OpenCode configuration |
|
||||
| Continue | Rules, prompt presets, and coding tasks | GoodBuddy-managed or native Continue configuration |
|
||||
| DeepSeek Harness (preview) | A fixed host with optional plugin capabilities | GoodBuddy-managed OpenAI-compatible connection |
|
||||
|
||||
当前尚未配置代码签名和 macOS notarization,系统可能显示安全提示。
|
||||
Each run reports cancellation, timeouts, token usage, tool calls, and history.
|
||||
Runtime-specific capabilities and configuration remain separate.
|
||||
|
||||
## 从源码运行
|
||||
## Download
|
||||
|
||||
需要 Node.js 24 和 npm:
|
||||
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, RPM |
|
||||
|
||||
The experimental LoongArch `loong64` build is distributed through a separate
|
||||
preview channel on the [product website](https://mesalogo.github.io/goodbuddy/#download).
|
||||
|
||||
Desktop installers do not contain remote Agent payloads. Local projects do not
|
||||
need an Agent package; managed SSH users download or import one explicitly in
|
||||
Settings.
|
||||
|
||||
Code signing and macOS notarization are not configured yet, so the 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
|
||||
@@ -52,19 +116,35 @@ npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
构建与打包说明见 [BUILD.md](BUILD.md)。
|
||||
See [BUILD.md](./BUILD.md) for build and packaging instructions.
|
||||
|
||||
## 隐私与安全
|
||||
## Documentation
|
||||
|
||||
- 模型请求只发送到用户选择的服务。
|
||||
- 本地数据默认保存在系统应用数据目录。
|
||||
- Renderer 不接触原始 Electron API 或模型凭据。
|
||||
- 远程委派仅在用户配置端点和令牌后启用。
|
||||
- 内网兼容模式允许应用内 HTTP 和非标准 HTTPS 证书;微信凭据和媒体端点仍执行严格校验。
|
||||
- [Feature matrix and roadmap](./FEATURES.md)
|
||||
- [Product feature documentation](./docs/features/)
|
||||
- [UI design system](./UI-DESIGN.md)
|
||||
- [Build and packaging](./BUILD.md)
|
||||
- [Release runbook](./docs/development/release-runbook.md)
|
||||
|
||||
## 参与贡献
|
||||
## Privacy and execution boundaries
|
||||
|
||||
欢迎提交 Issue 和 Pull Request。请先阅读 [AGENTS.md](AGENTS.md),提交前运行:
|
||||
- Model requests are sent only to services selected by the user.
|
||||
- Local data stays in the operating system's application data directory by
|
||||
default. API keys are encrypted by secure system storage.
|
||||
- The Renderer has no access to raw Electron APIs or model credentials.
|
||||
- The DeepSeek Harness plugin marketplace is off by default. Third-party
|
||||
install scripts, initialization code, and Execute tools run with the current
|
||||
user's permissions.
|
||||
- Remote delegation is enabled only after 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
|
||||
@@ -72,6 +152,17 @@ npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## 开源许可
|
||||
## Community communication
|
||||
|
||||
GoodBuddy 的原创代码采用 [0BSD License](LICENSE),可自由使用、修改、分发和商用。第三方组件和资源遵循各自许可证。
|
||||
Scan the QR code to join the GoodBuddy WeChat group:
|
||||
|
||||
<img width="1250" height="1743" alt="46b5c3b5936388b0a55db6fe034a7d7f" src="https://github.com/user-attachments/assets/45cd1736-f1ae-4f99-85be-cdb2cc1160c0" />
|
||||
|
||||
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<div align="center">
|
||||
<img src="./sites/assets/goodbuddy-light.png" width="96" alt="GoodBuddy" />
|
||||
|
||||
# GoodBuddy
|
||||
|
||||
**模型对话、编程 Agent、本地知识库和定时任务的桌面入口**
|
||||
|
||||
[English](./README.md) | **简体中文**
|
||||
|
||||
[产品官网](https://mesalogo.github.io/goodbuddy/) ·
|
||||
[下载](https://github.com/mesalogo/goodbuddy/releases) ·
|
||||
[功能矩阵](./FEATURES.zh-CN.md) ·
|
||||
[构建说明](./BUILD.md)
|
||||
|
||||
[](https://github.com/mesalogo/goodbuddy/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/mesalogo/goodbuddy/releases)
|
||||
[](https://github.com/mesalogo/goodbuddy/releases)
|
||||
[](https://github.com/mesalogo/goodbuddy/releases)
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
GoodBuddy 是用于模型对话、编程 Agent、知识检索、笔记和定时任务的桌面应用。使用 GoodBuddy 无需注册账号,工作数据默认保存在本机。模型服务可以运行在本机、组织内网或用户选择的云端服务。
|
||||
|
||||
## 产品亮点
|
||||
|
||||
- **本地优先**:无需注册 GoodBuddy 账号,工作数据默认保存在本机,模型服务由用户选择。
|
||||
- **统一 Agent Runtime**:在一个桌面界面中使用直连模型、OpenCode、Continue 和预览版 DeepSeek Harness。
|
||||
- **执行边界明确**:`Ask` 保持只读,`Execute` 使用当前账号权限,工具调用、取消、超时和 Token 用量均有记录。
|
||||
- **支持信创软硬件**:正式 Linux 版本覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境;龙芯 LoongArch 提供独立 `loong64` 实验预览版。
|
||||
- **知识与长期工作一体化**:知识库、知识图谱、魔法笔记、任务和智能心跳共享同一个桌面工作空间。
|
||||
|
||||
信创支持按操作系统、指令集和安装包兼容性描述,不代表已完成所有厂商、整机和系统版本的兼容认证。龙芯版本尚未进入正式发布矩阵,具体限制见[龙芯预览版说明](./docs/development/loongarch-preview-build.md)。
|
||||
|
||||
## 主要能力
|
||||
|
||||
| 范围 | GoodBuddy 提供的能力 |
|
||||
| --- | --- |
|
||||
| Agent Runtime | 直连模型、OpenCode、Continue 和预览版 DeepSeek Harness |
|
||||
| 数据与执行 | 本地 SQLite、只读 `Ask`、使用当前账号完整权限的 `Execute`、工具调用记录 |
|
||||
| 知识库 | 导入文件、目录和网页,通过全文、中文词组、向量和知识图谱检索 |
|
||||
| 桌面版本 | Windows、macOS、Linux 的 `x64` 与 `arm64` 正式版本,以及龙芯 LoongArch 实验预览版 |
|
||||
| 外部连接 | 本机或内网模型服务、自定义 MCP、微信 ClawBot、企业微信和钉钉 |
|
||||
|
||||
## 产品界面
|
||||
|
||||
### 知识库与知识图谱
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 魔法笔记与智能心跳
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Agent Runtime
|
||||
|
||||
| Runtime | 适用场景 | 配置来源 |
|
||||
| --- | --- | --- |
|
||||
| 直连模型 | 对话、知识检索、图片生成和内置工具 | GoodBuddy 模型连接 |
|
||||
| OpenCode | 编程 Agent、原生 Commands、Tools 和 Skills | GoodBuddy 管理或 OpenCode 自有配置 |
|
||||
| Continue | Rules、Prompt 预设和编程任务 | GoodBuddy 管理或 Continue 自有配置 |
|
||||
| DeepSeek Harness(预览) | 固定 Host 与可选插件能力 | GoodBuddy 管理的 OpenAI 兼容连接 |
|
||||
|
||||
每次运行都会记录取消、超时、Token 用量、工具调用和执行历史。各 Runtime 的原生能力与配置仍分别管理。
|
||||
|
||||
## 下载
|
||||
|
||||
从 [GitHub Releases](https://github.com/mesalogo/goodbuddy/releases) 下载:
|
||||
|
||||
| 系统 | 架构 | 格式 |
|
||||
| --- | --- | --- |
|
||||
| Windows | `x64`、`arm64` | NSIS、便携 ZIP |
|
||||
| macOS | `x64`、`arm64` | DMG、ZIP |
|
||||
| Linux | `x64`、`arm64` | AppImage、DEB、RPM |
|
||||
|
||||
龙芯 LoongArch 使用独立的 `loong64` 实验预览通道,可在[产品官网](https://mesalogo.github.io/goodbuddy/#download)选择下载。
|
||||
|
||||
桌面安装包不包含远端 Agent payload。本地项目无需 Agent 包;托管 SSH 用户可在设置中单独下载或导入。
|
||||
|
||||
当前未配置代码签名和 macOS notarization,操作系统可能显示安全提示。
|
||||
|
||||
## 从源码运行
|
||||
|
||||
需要 Node.js 24 和 npm:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mesalogo/goodbuddy.git
|
||||
cd goodbuddy
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
构建与打包说明见 [BUILD.md](./BUILD.md)。
|
||||
|
||||
## 文档
|
||||
|
||||
- [功能矩阵与路线图](./FEATURES.zh-CN.md)
|
||||
- [产品功能文档](./docs/features/)
|
||||
- [UI 设计系统](./UI-DESIGN.md)
|
||||
- [构建与打包](./BUILD.md)
|
||||
- [发布流程](./docs/development/release-runbook.md)
|
||||
|
||||
## 隐私与执行边界
|
||||
|
||||
- 模型请求只发送到用户选择的服务。
|
||||
- 本地数据默认保存在系统应用数据目录,API Key 由系统安全存储加密。
|
||||
- Renderer 不接触原始 Electron API 或模型凭据。
|
||||
- DeepSeek Harness 插件市场默认关闭。第三方插件安装脚本、初始化代码和 Execute 工具以当前用户权限运行。
|
||||
- 远程委派只在用户配置端点和令牌后启用。
|
||||
- 内网兼容模式允许应用内 HTTP 和非标准 HTTPS 证书;微信凭据和媒体端点仍执行严格校验。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request。请先阅读 [AGENTS.md](./AGENTS.md),提交前运行:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## 社区交流
|
||||
|
||||
扫描二维码加入 GoodBuddy 微信群:
|
||||
|
||||
<img width="1250" height="1743" alt="46b5c3b5936388b0a55db6fe034a7d7f" src="https://github.com/user-attachments/assets/cc094468-2885-447a-afb4-91c7ac117fd3" />
|
||||
|
||||
|
||||
|
||||
## 开源许可
|
||||
|
||||
GoodBuddy 原创代码采用 [0BSD License](./LICENSE),可自由使用、修改、分发和商用。第三方组件和资源遵循各自许可证。
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## 1. 目的与适用范围
|
||||
|
||||
本文定义 GoodBuddy 桌面端的统一界面规则,适用于聊天与最近对话、知识库、智能心跳、任务与活动记录,以及后续新增的一级页面。
|
||||
本文定义 GoodBuddy 桌面端的统一界面规则,适用于聊天与最近对话、任务中心、知识库、
|
||||
智能心跳、运行记录,以及后续新增的一级页面。
|
||||
|
||||
设计系统解决两类问题:
|
||||
|
||||
@@ -144,7 +145,7 @@
|
||||
| 变体 | 最大内容宽度 | 适用场景 | 页面映射 |
|
||||
| --- | --- | --- | --- |
|
||||
| `reading` | `820px` | 连续阅读、单列编辑、对话撰写 | 聊天正文与输入区 |
|
||||
| `standard` | `960px` | 常规列表、设置、表单与任务管理 | 最近对话、任务 |
|
||||
| `standard` | `960px` | 常规列表、设置与表单 | 最近对话、设置 |
|
||||
| `dashboard` | `1040px` | 指标、卡片网格、宽表格与审计数据 | 智能心跳、活动记录 |
|
||||
| `master-detail` | 可用空间内流式铺开 | 左侧选择、右侧编辑或预览 | 知识库 |
|
||||
|
||||
@@ -192,7 +193,7 @@
|
||||
|
||||
### 6.1 PageTabs
|
||||
|
||||
用于同一一级页面内的同级内容面板,例如心跳的“成长概览”和“心跳计划”。
|
||||
用于同一一级页面内的同级内容面板,例如智能心跳的“运行概览”和“心跳计划”。
|
||||
|
||||
- 使用 `tablist`、`tab` 和 `tabpanel` 语义,当前项使用 `aria-selected="true"`。
|
||||
- 一级页面之间的导航由应用主导航承担,不复用 `PageTabs`。
|
||||
@@ -200,6 +201,7 @@
|
||||
- 项目过多时优先重组信息架构,不把一级页签做成多行。
|
||||
- `PageTabs` 可使用默认视觉或共享的 `segmented` 视觉变体。紧凑主从工作台中的 2 至 4 个同级面板可使用与模型设置一致的分段外观,但不得因此改用按钮组语义。
|
||||
- 视觉变体不改变组件含义:分段外观的 `PageTabs` 仍使用页签语义、单一激活面板、游标焦点和方向键切换,不复制页面专属样式。
|
||||
- 在独立滚动的 Flex 页面中,`PageTabs` 及其直接包装层必须保持 `flex: 0 0 auto`,不得被剩余内容压缩到零高度。新增页签容器时必须提供对应的 CSS 回归测试。
|
||||
|
||||
### 6.2 SegmentedControl
|
||||
|
||||
@@ -208,6 +210,8 @@
|
||||
- 选项数量为 2 至 4 个。
|
||||
- 使用带 `aria-pressed` 的按钮组语义,支持方向键切换。
|
||||
- 不能用于多选筛选、页面导航或执行即时命令。
|
||||
- 与 `PageTabs` 一样,共享控件根节点必须 `flex: 0 0 auto`,不能被同级长表单或滚动区域
|
||||
压缩高度。横向空间不足时保留控件自身滚动,不把标签裁成一条窄缝。
|
||||
- 控件宽度由内容决定,除移动窄屏外不默认等分整行。
|
||||
|
||||
### 6.3 筛选工具栏
|
||||
@@ -281,32 +285,160 @@
|
||||
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
|
||||
- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。
|
||||
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
|
||||
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
- 项目选择器下方常驻跨项目活动汇总,空闲时显示“暂无活动”;项目计数与项目、会话级联菜单遵守
|
||||
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
|
||||
活动数量不改变项目菜单的稳定分组和顺序。
|
||||
- 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整,
|
||||
并记住用户选择;分隔条使用 `separator` 语义,支持方向键、Home 和 End。小于
|
||||
`900px` 时继续使用固定宽度抽屉,不显示拖拽分隔条。
|
||||
- 关联 Task 的 Conversation 在行最左侧显示独立展开按钮,父会话行不重复任务标签或数量;
|
||||
展开后的每个 Task 子项左侧显示名称,模式、计划和共享状态标记收在右侧,与父会话的
|
||||
更新时间位置一致;展开层级只到 Task,不展示 Job、Subjob 或 Run。
|
||||
- 会话标题保持单行且不挤压更新时间。更新时间默认靠近行右侧;低频的更多操作按钮不常驻
|
||||
占位,只在悬停会话行时替换右侧时间显示。键盘焦点进入会话行或操作菜单展开时,按钮仍
|
||||
保持可见。标题实际溢出时,悬停会话行才在标题的固定裁切区域内平滑横向滑动以展示完整
|
||||
名称;移开后快速复位。未溢出标题不产生位移动效,`prefers-reduced-motion` 下禁用滑动
|
||||
并保留完整标题提示。
|
||||
- 分支会话在侧栏标题前和聊天顶栏标题旁显示同一个横向分叉徽标;徽标通过可访问名称和
|
||||
工具提示说明来源会话,不能只靠图形表达。分支不改成树状会话列表,也不挤压更新时间。
|
||||
- 新建 Task 后可以首次自动展开所属 Conversation;用户手动折叠后保持其选择,后台状态变化
|
||||
只更新状态提示,不强制展开或抢占焦点。
|
||||
- Task 子项右侧复用任务中心的共享状态标记:运行中使用脉冲强调色,已完成使用带勾成功点,
|
||||
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
|
||||
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
|
||||
颜色或动效表达状态。
|
||||
- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只
|
||||
影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品
|
||||
身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。
|
||||
- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅
|
||||
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||
单行截断,不能撑宽或挤压侧栏。
|
||||
- 侧栏底部固定显示文件、终端、浏览器和资源监控四个系统工具图标,设置按钮独立靠右。工具
|
||||
图标只负责打开或聚焦助手工作栏面板,不在侧栏复制工具内容。已有稳定设置入口时,不在顶栏
|
||||
重复提供同一入口。
|
||||
|
||||
### 6.9 应用顶栏与全局操作
|
||||
### 6.9 助手工作栏
|
||||
|
||||
应用顶栏用于窗口级状态、侧栏开关和低频全局操作,不承担页面标题或主要导航。顶栏必须保持紧凑,不能与页面内容争夺注意力。
|
||||
助手工作栏是应用级右侧工具容器,不归属于聊天页面,也不根据当前页面、项目或 Runtime
|
||||
自动增删入口。产品契约见
|
||||
[通用助手工作栏与执行空间 PRD](./docs/features/assistant-workbar/prd.md),
|
||||
动态 Tab、“+”和终端细节见
|
||||
[助手工作栏多终端页签 PRD](./docs/features/assistant-workbar/terminal-tabs-prd.md)。
|
||||
|
||||
- 顶栏高度默认为 `58px`,图标按钮使用 `34px × 34px` 点击区域。
|
||||
- Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。
|
||||
- 升级后默认打开任务中心、工作区、一个浏览器实例和成果。任务中心与工作区是不可关闭的
|
||||
单实例基础应用;浏览器是可关闭的多实例应用;成果保持可关闭的单实例应用。不可关闭不
|
||||
等于锁定顺序。
|
||||
- 资源监控作为本机单实例应用保留在能力目录,但不加入升级后的默认打开实例。面板只在可见
|
||||
活动时采样,首期不持久化历史或提供后台负载告警。
|
||||
- Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。
|
||||
“+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。
|
||||
- 应用注册表分别声明单实例或多实例、默认打开、必须存在、可关闭和排序策略。在工作栏“+”目录中,
|
||||
单实例重复选择时聚焦已有 Tab;浏览器和终端每次选择创建独立实例。主侧栏系统工具快捷
|
||||
入口按当前上下文聚焦最近使用的匹配实例,额外实例仍从“+”目录创建。
|
||||
- Task Center 是默认跟随当前项目的单实例索引,不使用多实例模式。用户可在同一 Tab 内切换
|
||||
“当前项目”“全局任务”和“全部项目”;范围变化不得创建第二个 Task Center。每个 Task 只
|
||||
关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,也
|
||||
不得把 Job/Run 提升为可导航 UI 对象。
|
||||
- 各栏目读取当前会话或项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因。
|
||||
- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。
|
||||
- 用户可以主动排序栏目,并可关闭可关闭实例或恢复默认布局;任务中心和工作区不能隐藏或
|
||||
关闭。应用不能用用户偏好机制实现自动能力裁剪。
|
||||
- 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。
|
||||
实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。不可关闭实例不渲染
|
||||
关闭按钮,也不响应关闭快捷命令。
|
||||
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
|
||||
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
|
||||
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
|
||||
- 工作栏 Tab、Tab 关闭按钮和“+”入口沿用共享控件圆角;终端面板外框及终端顶部工具栏按钮
|
||||
使用直角,形成紧凑的停靠式终端界面。
|
||||
- 选择“终端”后直接绑定当前项目,由项目执行空间自动决定本机或托管 SSH;不再显示第二层
|
||||
目标选择器。终端面板内部不重复提供关闭会话按钮,关闭统一由 Tab 入口完成。
|
||||
- 下一次请求使用的附件和知识库由会话输入区展示与管理,不在工作栏重复提供“上下文”栏目;
|
||||
记忆功能实现前不展示占位内容,未来的记忆与历史执行上下文归入关联 Task 的任务中心详情。
|
||||
- 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。
|
||||
- 浏览器 Tab 的内容顶部固定使用一条紧凑工具栏,按“返回、刷新/停止加载、地址输入、
|
||||
前往、交互、关闭浏览器”排列。“刷新”在当前页面加载时原位切换为“停止加载”;
|
||||
“关闭浏览器”必须使用明确文字和与页面导航控件清楚区分的危险操作视觉,不能继续标为
|
||||
含义模糊的“停止浏览器”。地址输入显示浏览器权威提交 URL,但用户编辑期间保留本地草稿,
|
||||
不被画面或状态刷新覆盖;提交裸域名时自动添加 `http://`,提交、失焦后的同步规则不得让
|
||||
尚未提交的文字改变 Agent 看到的页面。
|
||||
- 浏览器工具栏操作的是该实例绑定的 Conversation 和 Browser Tab。同一 Conversation 的多个
|
||||
Tab 共享隔离登录上下文,但页面、导航、操作队列和元素引用相互独立。模型请求开始时冻结
|
||||
工具目标,用户切换工作栏 Tab 不改变进行中的 Agent 操作。创建中、Agent 操作中或已进入完整交互窗口时禁用其余
|
||||
工具栏控件;仅当前导航仍在加载时保留“停止加载”,且该操作只中止页面加载,不关闭浏览器、
|
||||
不释放 Runtime 上下文。没有会话的活动 Conversation 可以直接在地址栏输入 URL 并“前往”
|
||||
创建会话,但必须复用 Agent 浏览器相同的 URL 策略与能力路径。
|
||||
- 所有窗口宽度下均停靠在主工作区右侧并参与水平布局回流;展开时压缩主工作区,不能覆盖、
|
||||
遮罩或隔离其内容。分栏计算排除最左侧主导航,主工作区与工作栏采用相同的 `300px`
|
||||
最小宽度;拖动保存两者在可用分栏宽度中的比例,任一侧到达最小值后都不得继续侵占。
|
||||
所有可容纳两侧最小值的尺寸均支持指针和键盘调整,并须保留全部栏目入口。
|
||||
- 工作栏开关固定在顶栏 `.topbar__actions` 内、紧邻浅色/深色主题按钮之前,同一个按钮负责展开与收起并持续暴露状态;图标中的
|
||||
箭头方向必须随当前动作切换,收起状态提示展开,展开状态提示收起。按钮保持普通图标按钮
|
||||
视觉,不通过持续高亮表达展开状态。工作栏内部不再重复关闭按钮;当前会话没有 Task 时,
|
||||
不得为了放置该开关而保留一条空上下文栏。
|
||||
- 停靠工作栏不得获得对话框语义、焦点陷阱或背景遮罩。关闭后焦点返回同一个工作栏开关;
|
||||
主工作区与工作栏在展开期间都保持可达。
|
||||
- 对话消息流、输入区和输入区元信息必须在主工作区内部保留对称的左右安全边距。工作栏
|
||||
分割线只能位于主工作区边界之外,不能覆盖、裁切或挤掉输入框边框、发送按钮或右侧边距。
|
||||
- 终端、宽日志和大型成果可以由用户切换到底部停靠或独立窗口,应用不得因内容变化自动改变用户已选布局。
|
||||
|
||||
### 6.10 应用顶栏与全局操作
|
||||
|
||||
应用顶栏用于当前会话标题、范围、窗口级状态、侧栏开关和低频全局操作,不承担主要导航。
|
||||
顶栏必须保持紧凑,不能与页面内容争夺注意力。
|
||||
|
||||
- 应用壳层在水平方向分为全高主侧栏和右侧内容框架。主侧栏从窗口顶部贯穿到底部,品牌区、
|
||||
项目切换器、导航和账户区保持同一连续表面;不能用全宽标题栏把整条侧栏向下推移。
|
||||
- 无边框窗口的紧凑标题栏只覆盖右侧内容框架,保留侧栏开关、当前会话标题、范围、Runtime
|
||||
状态、主题开关和窗口控制,不得为了新增工作栏入口把这一整组控件下移。普通图标按钮使用
|
||||
`34px × 34px` 点击区域。
|
||||
- 发布说明 Modal 与其他应用级 Modal 一样使用整窗遮罩;遮罩必须覆盖主侧栏、右侧内容和
|
||||
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
|
||||
- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,避免页面层叠上下文影响;
|
||||
保留 React Context、关闭与焦点管理,表单样式不得依赖已移出的页面祖先选择器。
|
||||
- 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放
|
||||
浏览器 viewport;非模态对话框、菜单、局部确认或应用通知与浏览器区域相交时也释放。
|
||||
浮层关闭后只恢复当前活动实例的 viewport,不关闭、重建或刷新网页。接线与测试见
|
||||
[浏览器多实例技术设计](./docs/features/assistant-workbar/browser-tabs-technical-design.md#4-renderer-与-viewport)。
|
||||
- 遮罩必须 `inset: 0` 铺满整窗,并由 `styles.css` 中统一的遮罩选择器声明
|
||||
`-webkit-app-region: no-drag`,否则 `.brand` 与 `.topbar` 的原生拖动区域会截获指针,
|
||||
使遮罩上的控件无法悬停和点击。新增整窗遮罩时扩展该共享选择器,不要在各自规则里复制
|
||||
`no-drag`,也不要用 `inset: 38px 0 0` 之类的偏移绕开标题栏。
|
||||
- 当前会话存在 Task 时,Task 上下文栏位于标题栏下方并承载任务摘要与详情;没有 Task 时
|
||||
整行隐藏。折叠状态保持为 `46px` 单行表面,与右侧工作栏标签栏等高,左右分隔线必须水平
|
||||
对齐。该栏不重复放置新建入口。
|
||||
- “新建任务”只位于工作栏“任务索引”标题后方;任务状态筛选占满工作栏可用宽度,四个选项
|
||||
等分,不能保留内容宽度的短控件。
|
||||
- Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。顶栏 Runtime
|
||||
状态由状态点和 Runtime/模型名称紧凑表达:就绪使用绿色,连接中使用黄色,不可用使用灰色,
|
||||
不再重复显示状态文字;完整状态详情继续通过可访问说明和工具提示提供。
|
||||
- 浅色与深色切换属于持续可用的窗口级操作,直接显示太阳或月亮图标,并通过可访问名称说明将切换到的主题。选择必须持久化,切换不得改变布局。
|
||||
- 顶栏只直接显示当前任务所需的高频操作。已有侧栏账户设置入口时,不再重复显示 Runtime/设置入口;使用帮助优先放在相关操作附近,而不是为单个帮助项创建“更多”菜单。
|
||||
- 助手工作栏开关固定在顶栏 `.topbar__actions` 内、紧邻浅色/深色主题按钮之前;展开后该开关保持原位并
|
||||
负责收起,不在工作栏标题区域增加第二个关闭入口。
|
||||
- 只有存在至少两个无法由稳定入口承载的低频全局操作时才增加全局菜单,不为了容纳一个冗余入口而显示省略号按钮。
|
||||
- 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。
|
||||
- 使用全局菜单时,菜单项使用 `--font-body`、`14px` 图标和约 `32px` 单项高度;标签使用短名称。菜单保留 `menu`、`menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。
|
||||
|
||||
### 6.10 上下文单选菜单
|
||||
### 6.11 单选选择器
|
||||
|
||||
模型、专家角色和工作模式属于同一输入上下文,其选择器必须共享结构、尺寸和菜单视觉,不能出现一个精细菜单与两个风格不一致的原生下拉框。
|
||||
模型、专家角色、工作模式、Runtime Agent、Runtime 预设和 Runtime 快捷操作属于同一输入上下文,其选择器必须共享结构、尺寸和菜单视觉,不能出现一个精细菜单与多个风格不一致的原生下拉框。
|
||||
|
||||
- 只有选项能由单行短标签充分区分、不需要补充来源、位置、状态或说明时,才使用原生 `select`。
|
||||
- 当用户必须在选择前比较来源、范围、位置、连接状态或其他辅助信息时,使用与输入区选择器同类的自定义富信息单选菜单,不用 CSS 修饰原生 `select` 冒充富信息菜单。
|
||||
- 触发按钮复用统一的模型选择按钮样式,保持相同高度、圆角、边框、展开指示和焦点状态。
|
||||
- 菜单使用 `menu` 与 `menuitemradio` 语义,当前项同时显示选中标记和 `aria-checked`。选项可以包含一行简短说明,但标签和说明不得被截断到无法区分。
|
||||
- 支持上、下方向键、Home、End、Enter 或 Space、Escape;打开后焦点进入当前项,关闭后返回触发按钮。
|
||||
- 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和模式菜单互斥展开。
|
||||
- 不可用选项保持可读并说明原因,键盘导航不得停留在不可选择项上。
|
||||
- 仅在选项简单且不需要说明、禁用原因或一致菜单行为时使用原生 `select`。
|
||||
- 当某个选项需要用户在其展开区域内完成路径选择、授权或其他前置操作时,不得因为前置条件
|
||||
尚未完成而禁用该入口。选择入口只展开流程,验证成功前不提交实际值;不得自动采用第一个
|
||||
候选。取消保持原值,验证错误在对应操作附近显示。
|
||||
- 项目选择器属于富信息单选菜单。收起时保持当前项目名称紧凑可见;展开后每项至少显示项目名称,并按数据可用性补充目录、远程来源或其他能帮助辨认项目的信息。托管 SSH 项目在“远程项目”内按已保存 SSH Host 做一层轻量分组,Host 标题旁显示真实 Agent 连接状态;项目副标题只显示远端路径,不重复“托管 SSH”类型。
|
||||
- 项目菜单区分“本地项目 / 远程项目 / 远程通道”,不把消息通道命名为远程项目。“远程项目(技术预览)”关闭时完整移除远程项目分组,而不是显示不可操作的残留选项;本地项目和远程通道继续可用。Agent 连接状态使用“未连接 / 连接中 / 就绪 / 异常”等文字标签,读取状态不得发起连接,且不能只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。
|
||||
|
||||
### 6.11 应用通知与就地反馈
|
||||
### 6.12 应用通知与就地反馈
|
||||
|
||||
应用级通知统一进入全局通知视口,页面不得自行复制通知卡片或在内容流中长期堆放短期消息。
|
||||
|
||||
@@ -317,7 +449,7 @@
|
||||
- 就地错误必须与对应字段或操作建立程序化关联;全局错误使用 `alert` 和 assertive 实时区域,成功与信息使用 `status` 和 polite 实时区域。
|
||||
- 一个事件只能选择一种主要反馈位置,不得同时显示页内横幅和全局通知。失败时不得因通知切换而清空用户输入、筛选或未提交草稿。
|
||||
|
||||
### 6.12 Switch 与 Checkbox
|
||||
### 6.13 Switch 与 Checkbox
|
||||
|
||||
Switch 用于在两个持久状态之间立即切换,例如启用能力、开启索引、允许群消息或显示平台入口。Checkbox 用于独立多选、范围分配或执行前确认,例如选择多个 Runtime、选择知识库、清除已保存密钥。两者不得只因底层都使用 `input[type="checkbox"]` 而混用视觉或语义。
|
||||
|
||||
@@ -469,21 +601,62 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
### 13.1 聊天
|
||||
|
||||
- 使用 `reading` 壳层,消息流与输入区共享宽度。
|
||||
- 消息头像放在正文区域外侧:助手头像居左,用户头像居右。输入框左边缘与助手正文左边缘对齐,
|
||||
右边缘与用户气泡右边缘对齐;两侧为头像保留独立安全边距,滚动条出现或工作栏缩放不得改变对齐。
|
||||
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
|
||||
- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown
|
||||
原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示
|
||||
复制入口。
|
||||
- 默认开启“会话 HTML 渲染”时,已完成 Agent 回复中的完整 HTML 与 `html` / `htm`
|
||||
代码块在原位置显示隔离静态预览;用户消息、推理过程和流式中的回复不得执行 HTML。
|
||||
每个预览持续标注脚本和网络已禁用,并在标题右侧复用与 Mermaid 图表相同的图标按钮组:
|
||||
源码按钮使用代码图标并暴露 `aria-expanded`,全屏按钮使用放大图标打开统一 Modal。
|
||||
全屏预览遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape 关闭和焦点恢复规则,
|
||||
并沿用同一套清理与 Sandbox/CSP 边界。关闭平台功能后必须显示 HTML 源码,不能隐藏内容
|
||||
或保留旧预览。
|
||||
- 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行
|
||||
或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态,
|
||||
使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。
|
||||
- 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。
|
||||
- 模型、专家角色和工作模式使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。
|
||||
- 模型、专家角色、工作模式、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。
|
||||
- Runtime 选择菜单必须反映当前执行空间的真实能力。托管 SSH 项目当前只支持 OpenCode,因此输入区只渲染已配置的 OpenCode 选项和管理入口,不显示直连模型、Continue 或 DeepSeek Harness 的不可用入口;旧会话保存的其他 Runtime 选择在项目激活时恢复为当前 OpenCode 配置。
|
||||
- 输入区使用一个完整容器,主工具栏保留附件、语音、Runtime、工作模式、会话选项及停止/发送。知识范围、专家角色、OpenCode Agent、Continue 预设和 Runtime 快捷操作收纳在会话选项浮层中;Runtime 专属选择在浮层内按提供方分组,无可选功能时不保留空分组。输入框上方以简短摘要显示生效的专家、知识范围、检索方式和 Runtime 专属选择。
|
||||
- 会话选项浮层与主行菜单互斥,内部复用上下文选择菜单;子菜单就地展开,浮层统一限制宽高并滚动。Escape 先关闭子菜单并恢复其触发按钮焦点,再关闭会话选项并恢复入口焦点;点击外部、焦点离开、切换会话或开始/结束回复时关闭浮层。
|
||||
- 会话选项入口紧随录音键,使用无常驻边框的调节图标,保留可访问名称、悬停说明和展开态反馈;浮层在工具栏左侧展开,最大宽度为 `300px`,窄窗口不超过输入区。浮层内选择器使用整行轻量列表样式,避免重复的按钮边框;知识库行右侧显示展开箭头,展开时朝上。
|
||||
- OpenCode、Continue 和 DeepSeek Harness 后续的 Task 级委派与取消、后台执行进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止及原生会话监督统一进入应用级助手工作栏固定的“Runtime”栏目,不加入 Composer。栏目入口始终存在;只选择 Conversation 或 Task,并按 Task 聚合执行状态,不显示 Job/Run 树或独立操作菜单。内部可选区域按用户所选目标和 Runtime 的真实能力显示,不为未支持能力渲染空卡片或成排禁用按钮。切换跟随目标时必须清理上一归属的监督状态,固定目标则保持不变。
|
||||
- 设置中心只管理持久 Runtime 配置、默认值和能力清单;右侧 Runtime 栏目管理用户当前跟随或固定目标的生命周期。两处不得复制同一实时操作,栏目中的高风险操作仍须就地确认并保留取消、权限、用量和活动审计。
|
||||
- Runtime Prompt 快捷操作只把模板填入输入草稿,用户可以继续编辑;OpenCode Command 由 Runtime 原生 API 执行,输入框只承载可选参数,不以普通斜杠文本冒充执行。
|
||||
- OpenCode 原生 Task 调用在本地项目和托管 SSH 项目中统一解析为子 Agent 状态卡,不显示为普通工具卡。状态卡持续显示 Agent 类型、任务说明、运行状态、完成输出或失败原因;身份徽标、Agent 名称和状态位于首层,任务说明作为主信息,Runtime 来源作为次级元数据,输出与错误在同一卡片内按需展开。父 Agent 的推理不冒充子 Agent 过程,必须与状态卡保持明确间距。状态不能只靠颜色表达。推理、子 Agent、工具和正文统一按事件首次出现顺序进入消息块;增量更新只更新原位置,Task 临时工具块重分类后必须原位替换,不能把子 Agent 卡统一提升到消息顶部。同一调用的增量事件按稳定调用 ID 更新,不能因 ACP 首帧尚无参数而永久保留重复工具卡或活动记录。一次请求内已观察到的子 Agent 均保留在消息与活动记录中;父请求取消、失败或意外中断时仍处于活动状态的卡片必须结算为对应终态,父请求成功结束但子 Agent 未报告终态时必须显示为失败而不是推断为完成。
|
||||
- Agent 回复进行中锁定模型、专家角色、工作模式和 Runtime 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。
|
||||
- 普通工具在主会话和子 Agent 过程中使用同一轻量折叠记录:不显示外层“工具执行”标题、计数、背景和边框,保留无障碍区域名称。记录纵向左对齐,头部按内容收宽且不超过消息宽度;展开箭头在左,工具名作为主标题,有额外信息的单行摘要弱化显示。右侧以绿色对勾显示成功完成,以图标显示运行和待执行状态,提供悬停文字和无障碍名称;失败和可恢复状态保留文字。仅重复工具名和执行状态的已知 Runtime 模板摘要在头部与详情中均隐藏;刷新重试、审批等额外提示保留。默认折叠,状态和输出更新不改变用户的展开选择;折叠时露出一行错误,展开详情使用消息可用宽度、浅背景和左侧细线,按错误、结果、独立折叠的输入参数阅读。详情保留原始文本,完整 JSON 输入可缩进显示,复制仍使用收到的原文,并通过应用通知反馈。详情工具栏只保留复制按钮,文本默认自动换行,长内容在固定最大高度内滚动,不提供换行切换和展开全部按钮。
|
||||
- OpenCode 主会话和子 Agent 的工具记录均保留 Runtime 提供的操作标题作为摘要,显示文件路径、命令或操作描述;本地 SDK 标题去除首尾空白后最多保留 240 字符,没有标题时沿用工具名回退规则。主会话工具失败或中断时保留已收到的标题;远端 ACP 继续在记录名称中显示其提供的标题。
|
||||
- 工具取消后保留“已取消”文字,避免与失败图标混淆。
|
||||
- Continue、DeepSeek Harness 和直连模型的工具名后显示单行操作摘要,内容提取、长度与完成态保留规则见[工具操作摘要](./docs/features/assistant-workbar/runtime-interactions.md#工具操作摘要)。
|
||||
- Agent 回复进行中不能禁用普通消息发送。新消息与到期 Scheduled Task 共用 Conversation 级待发送队列。队列位于 Composer 容器之外、与输入框等宽并保持独立间距;有待发送项时直接显示无标题栏的极简列表,空队列不保留占位。每项固定为一行,按进入顺序显示轻量来源图标和截断摘要;普通消息使用消息图标,Scheduled Task 统一使用淡出时钟图标。
|
||||
- 待发送项默认在当前回复结束后顺序执行。每项提供明确的“立即中断并插入”(空闲时为“立即运行”)和删除操作;前者先取消当前 Conversation 的活动执行,再将所选项提升为下一项,不允许同一 Conversation 并发写入时间线。
|
||||
- 队列区域向上增长并设置有界滚动高度,不提供展开、收起或冗余计数标题。宽容器中的操作采用弱化的行内样式,不渲染成强调卡片或胶囊;窄容器中保留来源、摘要和图标操作并隐藏冗长按钮文字。区域、来源和每个操作必须有可读无障碍名称。队列异步错误进入应用通知,不在 Composer 内复制通知样式。
|
||||
- 支持上下文状态的 Runtime 在输入区下方复用同一紧凑用量条;文案必须区分“本次模型调用”和“压缩后对话估算”。手动压缩仅在当前 Runtime 明确支持且没有活动回复时显示,作为元信息区左下角的浮动次操作,不参与输入区高度计算;元信息区始终预留稳定高度,切换 Runtime 不得让输入框上下位移。元信息区与窗口底部只保留紧凑安全留白,不形成额外空白区。进行中禁用重复操作,结果通过应用通知反馈。
|
||||
- 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。
|
||||
- 宽度大于 `700px` 时,添加内容、知识范围、专家、模式和模型控件保持同一行;仅在窄输入区中换行,不能因为允许换行而让所有窗口都固定显示两行。
|
||||
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,图片转换为本次消息附件。文件选择由上传按钮承担,不再提供独立“读取剪贴板”按钮;默认工具栏也不提供“截取当前屏幕”和“选择应用窗口”入口,避免与系统粘贴、文件选择和后续工具执行重复。
|
||||
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
|
||||
- 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、模式、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。
|
||||
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,系统截图和从文件管理器复制的一个或多个支持文件加入本次消息附件。文件粘贴与上传按钮共用读取、解析进度和数量限制;读取期间阻止重复导入及发送,错误就地关联输入框和附件按钮。具体类型、大小及批次失败规则见[聊天附件要求](./docs/features/document-processing/prd.md#321-聊天附件入口)。默认工具栏不提供独立“读取剪贴板”“截取当前屏幕”和“选择应用窗口”入口。
|
||||
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴文件、图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
|
||||
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
|
||||
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
|
||||
- 消息流的滚动范围必须使用已渲染消息的真实高度。长会话可以按批次只挂载最近消息并由用户
|
||||
加载更早内容,但不得用固定的消息占位高度替代实际布局,避免推理、工具列表或长正文被
|
||||
排除在可滚动范围之外。
|
||||
|
||||
### 13.2 最近对话
|
||||
|
||||
- 使用 `standard` 壳层和统一 `PageHeader`。
|
||||
- 搜索、范围和时间筛选位于筛选工具栏。
|
||||
- 行项目统一显示标题、范围、最近更新时间和必要状态。
|
||||
- 侧栏最近会话的更新时间在当天显示本地时间,非当天显示本地日期;非当年记录必须同时显示年份。悬停时间信息时提供完整日期和时间。
|
||||
- 侧栏中有关联 Task 的 Conversation 显示行首展开按钮,父行不显示任务标签或数量;展开后
|
||||
最多直接显示 3 个 Task,“查看全部 N 个任务”打开该 Conversation 的完整任务区。
|
||||
- Task 子项左侧显示名称,右侧显示本地化的 Ask/Execute、计划摘要和共享状态标记,与父会话
|
||||
的更新时间位置一致;已完成使用带勾成功点,不重复显示完成文字。点击子项打开同一
|
||||
Conversation 并定位 Task,不继续展开 Job/Run。
|
||||
- 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。
|
||||
|
||||
### 13.3 知识库
|
||||
@@ -497,36 +670,89 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
### 13.4 智能心跳
|
||||
|
||||
- 使用 `dashboard` 壳层。
|
||||
- 顶部先呈现运行状态、当前范围和主操作,再呈现指标和配置。
|
||||
- 顶部先呈现运行状态、实际范围和主操作,再呈现指标、建议、历史与配置。
|
||||
- 范围明确区分 Global 与指定的一个或多个 Project;Global 与指定项目互斥,多项目选择使用
|
||||
Checkbox,不能依靠进入页面时的当前项目推断。
|
||||
- 状态卡片使用统一状态令牌,不只依赖颜色。
|
||||
- 运行历史与配置使用明确区块,不以多套相似页签混合导航、开关和筛选。
|
||||
- 保留“运行概览 / 待处理建议 / 报告与记录 / 心跳计划”四个同级页面。
|
||||
- 智能心跳菜单入口是完整配置的权威位置;任务中心和设置中心不得复制同一 CRUD 表单。
|
||||
- 分区记忆协同与唤起条件尚未实现,不得显示占位入口或平行的记忆类型。
|
||||
|
||||
### 13.5 任务与活动
|
||||
### 13.5 Task Center
|
||||
|
||||
- 任务使用 `standard` 壳层,活动记录使用 `dashboard` 壳层。
|
||||
- “任务 / 活动”作为同级页面时使用 `PageTabs`。
|
||||
- 任务状态筛选使用 `SegmentedControl` 或筛选工具栏,不再模拟页签。
|
||||
- 活动记录保留审计字段和范围,支持独立容器横向滚动。
|
||||
- 批量停止、删除和清空历史遵循破坏性操作政策。
|
||||
- 保留现有助手工作栏入口,首期在窄栏内适度完善,不先扩张成新的独立一级页面。
|
||||
- 只展示 Task;普通 Conversation、Job、Run、工具步骤、Subagent 和心跳事项不独立占行。
|
||||
- 每项显示名称、关联 Conversation、Global 或 Project 范围、Ask/Execute、状态、最近进展、
|
||||
最近真实活动时间及需要关注信息。
|
||||
- 点击列表项打开关联 Conversation 并定位该 Task,不显示第二份内容载体。
|
||||
- 需要关注、进行中、已暂停和已结束使用共享 `SegmentedControl`;窄栏不足时单行滚动。
|
||||
- 完整消息留在 Conversation;长错误、工具、审批和成果按 Task 关联到 Runtime、活动记录和
|
||||
成果查看器,不撑高列表,也不显示 Job/Run 树。
|
||||
- “新建定制任务”使用共享 Modal,明确选择当前或新 Conversation。聊天入口默认当前
|
||||
Conversation,Task Center 入口默认新 Conversation;选择必须持续可见。
|
||||
- 定时消息创建 Modal 使用关联 Conversation 的 Runtime 与 Ask/Execute 模式,不再提供独立
|
||||
模式开关。未运行计划的详情显示会话当前模式,已运行任务显示该次执行记录的模式;
|
||||
不得用旧计划字段表示下一次执行权限。不支持工具时不得静默降级。
|
||||
|
||||
### 13.6 魔法笔记
|
||||
### 13.6 运行记录
|
||||
|
||||
- 使用 `dashboard` 壳层,并通过 `PageTabs` 提供“任务与会话 / 活动时间线 / 用量统计”三个同级视图。
|
||||
- 默认视图按“项目 → 任务或会话 → 活动详情”组织,项目范围持续可见,任务或会话详情可以折叠。
|
||||
- 任务或会话的综合状态以最近一次顶层请求对应的最终 Agent 结果为准;最终结果尚未产生时使用该请求的当前状态。中间工具或子专家的失败、取消和中断保留在活动详情中,但不得覆盖最终成功状态。
|
||||
- 活动时间线按项目分组、按任务或会话建立横向轨道,所有轨道共享同一执行顺序并按事件时间排列,以带身份名称的节点表达用户、主 Agent、子专家、工具、审批、状态和并行关系。节点内使用 `U / G / S / T / A` 拉丁字母简称,节点下显示完整身份或名称;选择节点后显示所属会话、不可变范围快照和完整详情。
|
||||
- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度,宽表格在独立容器内横向滚动。
|
||||
- 活动状态筛选使用 `SegmentedControl`,不与页面页签混合。清空历史遵循破坏性操作政策。
|
||||
|
||||
### 13.7 魔法笔记
|
||||
|
||||
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
|
||||
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
|
||||
- 笔记编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
|
||||
- 左侧笔记与待办列表在双栏布局中允许在 `180px` 至 `360px` 范围内通过指针或键盘调整,
|
||||
也可从页面标题区隐藏和恢复;宽度与显示状态均记住用户选择。分隔条保留 `separator`
|
||||
语义及方向键、Home、End 操作,窄窗口堆叠布局不显示拖拽分隔条。
|
||||
- 左侧笔记列表依次显示标题、最多两行内容预览,以及同一行的记录数和本地化更新时间。记录数靠左、更新时间靠右,窄宽度下均保持单行省略,不挤压标题或扩大列表栏。
|
||||
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||
- Agent / MCP 或其他入口成功写入笔记后,已打开的工作台自动更新笔记列表、当前详情、待办及来源记录,不增加刷新按钮。保留仍存在的笔记与待办选择、搜索和筛选,以及未保存的标题、输入区和记录编辑草稿;普通后台刷新不重建编辑器。
|
||||
- 外部删除当前笔记时,无草稿则选择剩余笔记;有草稿则保留编辑区并提示先复制草稿。外部删除正在编辑的记录时,同样保留该编辑器并明确提示记录已删除;保存失败不得清空草稿。
|
||||
- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
|
||||
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
|
||||
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
|
||||
|
||||
### 13.7 设置中心
|
||||
### 13.8 设置中心
|
||||
|
||||
- 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。
|
||||
- 全页设置标题区依靠留白与内容区分层,不在标题下方绘制贯穿整个工作区的分隔线;模态设置可以保留标题边界。
|
||||
- 新建项目与项目设置弹窗固定标题、关闭按钮和底部操作区,仅表单内容滚动;滚动条不进入标题区,关闭按钮保留至少 40 × 40px 的点击区域。
|
||||
- 项目弹窗通过 Portal 挂载到 `document.body`,避免侧栏内的弹窗与主窗口标题栏拖动区域重叠时,Electron 截获关闭按钮的鼠标输入。
|
||||
- 左侧分类导航在宽屏使用 `220px`,中等窗口使用 `196px`,窄窗口转为横向滚动;纵向滚动条仅在内容溢出时占用右侧空间,不在左侧创建镜像预留,选项与左侧可见边界保持默认内距。分类标题使用正文级字号,分类说明使用辅助字号;右侧内容区在可用空间内流式伸缩,最大宽度使用 `standard` 壳层的 `960px`,不得以页面专属较窄宽度压缩表单。
|
||||
- 全页设置标题区与双栏内容使用共享 `--page-gutter`,标题、分类导航和内容区在同一页面边距基线上;标题继续使用标准 `PageHeader` 的底部分隔线与下内距。固定标题和双独立滚动区域不改变这些一级页面壳层规则;模态设置保留自身外框与标题边界。
|
||||
- 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。
|
||||
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
|
||||
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
|
||||
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
|
||||
- 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供
|
||||
“打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。
|
||||
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
|
||||
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
|
||||
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
|
||||
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
|
||||
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
|
||||
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
|
||||
更新,保存失败时保留最后确认状态。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
|
||||
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
|
||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
|
||||
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
|
||||
- OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。
|
||||
- MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。联网搜索保留在直连模型;内置浏览器归入内置 MCP,提供持久启停和 Runtime 分配,并明确说明其操作 GoodBuddy 隔离浏览器、不控制客户端已安装的浏览器,开启后可由已分配 Runtime 的 Execute 直接使用,不逐次询问。该开关只控制 Agent 工具访问,关闭时不得禁用浏览器工作栏中的用户手动导航、返回、刷新、停止加载、交互或关闭操作。尚未生效的命名浏览器配置不得显示在界面中,“电脑控制”只显示实际操作客户端电脑的能力。内置 MCP 卡片与 Skills 一样提供持久启停和 Runtime 分配;直连模型、GoodBuddy 管理的 OpenCode 与 Continue 默认选中且可调整,DeepSeek Harness 必须以置灰、未选择和“暂不支持”文案持续显示,不能呈现为可保存的分配。魔法笔记 MCP 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。
|
||||
- MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。
|
||||
- 内置 MCP 中的“图片生成与编辑”是自动管理例外:该行始终可见,标记“内置 · 自动管理”,不提供独立启停、分配保存、移除或凭据编辑。直连 Model、OpenCode、Continue、DeepSeek Harness 的分配只由已保存图片模型的“允许 AI 在会话中调用”设置推导;至少一个开启则全部勾选,没有开启模型则全部取消。使用禁用的原生 Checkbox、Runtime 标签及关联说明,保留可聚焦的“前往图片模型设置”导航;远程适用范围通过 GoodBuddy Agent 说明持续可见。Ask、离线、无工具能力或调用错误不改变勾选。图片模型默认关闭会话调用;默认生图模型独立于普通默认连接,关闭或删除后保留失效选择并提示改选,不自动回退。
|
||||
|
||||
### 13.8 文档解析设置
|
||||
### 13.9 文档解析设置
|
||||
|
||||
- 设置中心新增独立的“文档解析”分类,统一管理聊天附件、知识库导入以及后续文档审阅场景使用的提取、转换和 OCR 策略。OCR 不作为普通对话模型出现在“模型连接”中。
|
||||
- 分类页头说明文档解析的跨场景作用,右侧依次显示“测试解析”和“保存设置”;保存位于最右侧。测试必须选择真实文件并执行实际解析,不能只检查模型文件或接口连通性。
|
||||
@@ -543,7 +769,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- DOC、XLS、PPT 等旧格式通过受控转换 Provider 生成新式 Office 文档和 PDF。转换子进程必须禁用宏和网络,限制输入、输出、内存、超时与临时目录,并在关闭或取消时清理。
|
||||
- OCR 来源使用“本地模型 / 远程服务”互斥选择。选择本地后显示模型下载、模型下拉选择和本地运行参数;选择远程后显示 MinerU、PaddleOCR-VL 等服务连接配置。未实现的远程服务入口保持可读但禁用,不再增加与来源选择重复的“隐私与云端处理”授权区。
|
||||
- 用户配置并保存远程 OCR 服务即表示选择该处理路径,不再逐场景重复询问。界面仍须明确显示当前服务名称、处理范围和远程属性,API 密钥只保存在主进程加密设置中,未选中远程服务时不得上传文档。
|
||||
- 解析结果使用统一文档结构,至少保留文档标题、来源格式、页码或工作表定位、正文块、置信度、处理方式和警告。聊天附件对结果做有界截断,知识库使用完整结果分块和索引。
|
||||
- 解析结果使用统一文档结构,至少保留文档标题、来源格式、页码或工作表定位、正文块、置信度、处理方式和警告。聊天附件保留完整解析文本,超过暂存容量时报错;知识库使用完整结果分块和索引,具体要求见[文档处理 PRD](./docs/features/document-processing/prd.md#32-场景工作流)。
|
||||
- 测试结果显示文件类型、页数、实际工作流、提取字数、OCR 页数、耗时和警告。测试文件不得自动进入聊天上下文或知识库。
|
||||
|
||||
## 14. 文案规则
|
||||
@@ -593,13 +819,16 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- [ ] 将输入快捷键与附件提示置于空输入框内部,输入区下方保持单行说明。
|
||||
- [ ] 最近对话迁移到 `standard`,统一搜索、范围、时间和删除行为。
|
||||
- [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。
|
||||
- [ ] 智能心跳迁移到 `dashboard`,统一状态卡片、配置和运行历史层级。
|
||||
- [ ] 任务迁移到 `standard`,活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。
|
||||
- [x] 智能心跳使用 `dashboard`,保留概览、建议、轨迹和计划,并在计划中支持 Global / 多 Project 范围。
|
||||
- [x] 在现有工作栏中完善任务中心,统一范围、状态、最近进展和筛选,不新建平行任务平台。
|
||||
- [ ] 活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。
|
||||
- [ ] 设置中心使用共享分类定义与 `SettingsCategoryHeader`,将保存与测试操作统一放到分类页头右侧,并把成功反馈接入应用通知。
|
||||
- [ ] 文档解析设置统一聊天附件与知识库的解析预设、OCR 状态、转换状态、隐私限制和真实文件测试。
|
||||
|
||||
### 15.5 验收
|
||||
|
||||
执行与取证方法见 [Electron UI 自动化测试规范](./docs/quality/electron-ui-automation.md),包括真实 Portal、原生键盘、计算样式与 PNG 审阅;视觉 fixture 与完整生产 App 的结果须分别标注。
|
||||
|
||||
- [ ] 在浅色与深色主题下检查所有页面、弹窗、菜单、输入框、表格和空状态。
|
||||
- [ ] 在大于等于 `1200px`、`960px`、`720px` 和小于 `720px` 的窗口宽度检查布局。
|
||||
- [ ] 仅使用键盘完成导航、筛选、创建、编辑、确认和取消。
|
||||
@@ -620,3 +849,16 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
4. 全局或项目范围在浏览、创建、编辑和危险操作中均可见。
|
||||
5. 浅色、深色、键盘和各窗口宽度下均可完成核心任务。
|
||||
6. 空状态、错误状态和危险操作符合本文规则。
|
||||
|
||||
## 17. 一级页面加载性能
|
||||
|
||||
- 首次启动只可在低优先级空闲时预加载轻量一级页面;Knowledge、Magic Notes、
|
||||
Settings、Activity 等较重页面应在对应导航控件获得指针意图或键盘焦点时预加载。
|
||||
- 点击、快捷键和程序化导航不能依赖预加载完成,必须保留页面级 `Suspense` 加载
|
||||
状态、错误边界和 KeepAlive 行为。
|
||||
- Workspace 与 Conversation 的 KeepAlive 缓存必须在每次访问时立即执行容量上限
|
||||
与 LRU 保护规则;定时清理只负责过期与数据失效兜底,不能作为容量门禁。
|
||||
- 页面内大型可选视图应使用局部加载边界。知识图谱画布加载失败时,只替换画布
|
||||
区域并提供可访问的重试操作,不得替换整个 Knowledge 页面或丢失其余页面状态。
|
||||
- 加载状态使用 `role="status"`、`aria-live="polite"` 与 `aria-busy="true"`;
|
||||
局部加载失败使用 `role="alert"`,并保留明确的恢复操作。
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.13.0",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
},
|
||||
"node": {
|
||||
"version": "24.19.0",
|
||||
"source": "https://nodejs.org/dist/v24.19.0/",
|
||||
"targets": {
|
||||
"linux-x64": {
|
||||
"archive": "node-v24.19.0-linux-x64.tar.gz",
|
||||
"sha256": "f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4",
|
||||
"binaryPath": "node-v24.19.0-linux-x64/bin/node",
|
||||
"licensePath": "node-v24.19.0-linux-x64/LICENSE"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"archive": "node-v24.19.0-darwin-arm64.tar.gz",
|
||||
"sha256": "8294b7aa9b03997481c06babf1e8b270c859358f27da57a11509afe537ac381d",
|
||||
"binaryPath": "node-v24.19.0-darwin-arm64/bin/node",
|
||||
"licensePath": "node-v24.19.0-darwin-arm64/LICENSE"
|
||||
},
|
||||
"linux-arm64": {
|
||||
"archive": "node-v24.19.0-linux-arm64.tar.gz",
|
||||
"sha256": "d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f",
|
||||
"binaryPath": "node-v24.19.0-linux-arm64/bin/node",
|
||||
"licensePath": "node-v24.19.0-linux-arm64/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"koffi": {
|
||||
"version": "3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const { readFileSync } = require('node:fs')
|
||||
const {
|
||||
detectBinaryArchitecture,
|
||||
detectElfArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
|
||||
function targetName(architecture, platform = 'linux') {
|
||||
if (
|
||||
!(platform === 'linux' && ['x64', 'arm64'].includes(architecture)) &&
|
||||
!(platform === 'darwin' && architecture === 'arm64')
|
||||
) {
|
||||
throw new Error(`Unsupported Agent build target: ${platform}-${architecture}`)
|
||||
}
|
||||
return `${platform}-${architecture}`
|
||||
}
|
||||
|
||||
function assertTargetBinary(filePath, architecture, platform, label) {
|
||||
targetName(architecture, platform)
|
||||
const header = readFileSync(filePath).subarray(0, 64)
|
||||
const isMachO = header.length >= 8 &&
|
||||
(header.readUInt32LE(0) === 0xfeedfacf ||
|
||||
header.readUInt32BE(0) === 0xfeedfacf)
|
||||
const actual = platform === 'linux'
|
||||
? detectElfArchitecture(header)
|
||||
: isMachO ? detectBinaryArchitecture(header) : undefined
|
||||
if (actual !== architecture) {
|
||||
throw new Error(
|
||||
`${label} architecture mismatch: expected ${platform}-${architecture}, received ${actual ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { targetName, assertTargetBinary }
|
||||
@@ -0,0 +1,942 @@
|
||||
const {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
sign,
|
||||
verify
|
||||
} = require('node:crypto')
|
||||
const {
|
||||
closeSync,
|
||||
fstatSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { basename, resolve } = require('node:path')
|
||||
const { crc32 } = require('node:zlib')
|
||||
const {
|
||||
productionSigningKey,
|
||||
publicKeySpkiBase64,
|
||||
readTrustedKeyRegistry,
|
||||
redactSecrets
|
||||
} = require('./agent-bundle.cjs')
|
||||
const {
|
||||
canonicalJson,
|
||||
descriptorContentDigest,
|
||||
signatureDomain: packageSignatureDomain
|
||||
} = require('./agent-package.cjs')
|
||||
const {
|
||||
localFileHeaderSignature,
|
||||
readExactZipBytes,
|
||||
readZipCentralDirectory
|
||||
} = require('./zip-central-directory.cjs')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
function supportedTarget(platform, architecture) {
|
||||
return (platform === 'linux' && ['x64', 'arm64'].includes(architecture)) ||
|
||||
(platform === 'darwin' && architecture === 'arm64')
|
||||
}
|
||||
const catalogName = 'agent-catalog.json'
|
||||
const catalogSignatureName = 'agent-catalog.sig'
|
||||
const catalogSignatureDomain = Buffer.from(
|
||||
'GoodBuddy Agent Package Catalog Signature v1\0',
|
||||
'utf8'
|
||||
)
|
||||
const semanticVersionPattern =
|
||||
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?$/u
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u
|
||||
const maximumPackageBytes = 512 * 1024 * 1024
|
||||
const maximumPackageEntries = 50_002
|
||||
const maximumPackageEntryBytes = 384 * 1024 * 1024
|
||||
const maximumExpandedPackageBytes = 1024 * 1024 * 1024
|
||||
const maximumMetadataBytes = 1024 * 1024
|
||||
const maximumDescriptorBytes = 4 * 1024 * 1024
|
||||
const maximumCentralDirectoryBytes = 32 * 1024 * 1024
|
||||
const maximumCatalogEntries = 200
|
||||
const windowsReservedNamePattern =
|
||||
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu
|
||||
|
||||
function canonicalBytes(value) {
|
||||
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function trustedProductionKey(registry, keyId) {
|
||||
const key = registry.keys.find(
|
||||
(candidate) =>
|
||||
candidate.keyId === keyId &&
|
||||
candidate.environment === 'production'
|
||||
)
|
||||
if (
|
||||
!key ||
|
||||
registry.revocations.some(
|
||||
(revocation) => revocation.keyId === keyId
|
||||
)
|
||||
) {
|
||||
throw new Error(`Agent signing key is not trusted: ${keyId}`)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function verifyDetachedSignature(
|
||||
bytes,
|
||||
signatureText,
|
||||
key,
|
||||
domain,
|
||||
label
|
||||
) {
|
||||
const signature = Buffer.from(
|
||||
signatureText.toString('utf8').trim(),
|
||||
'base64'
|
||||
)
|
||||
const publicKey = createPublicKey({
|
||||
key: Buffer.from(key.publicKeySpkiBase64, 'base64'),
|
||||
format: 'der',
|
||||
type: 'spki'
|
||||
})
|
||||
if (
|
||||
signature.length !== 64 ||
|
||||
!verify(
|
||||
null,
|
||||
Buffer.concat([domain, bytes]),
|
||||
publicKey,
|
||||
signature
|
||||
)
|
||||
) {
|
||||
throw new Error(`${label} signature verification failed`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseCanonicalJson(bytes, label) {
|
||||
const value = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
)
|
||||
if (!canonicalBytes(value).equals(bytes)) {
|
||||
throw new Error(`${label} is not canonical`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function assertProtocol(value, label) {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!Number.isSafeInteger(value.major) ||
|
||||
value.major < 1 ||
|
||||
!Number.isSafeInteger(value.minor) ||
|
||||
value.minor < 0
|
||||
) {
|
||||
throw new Error(`${label} is invalid`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackageDescriptor(descriptor) {
|
||||
if (
|
||||
typeof descriptor !== 'object' ||
|
||||
descriptor === null ||
|
||||
descriptor.format !== 'goodbuddy-agent-package' ||
|
||||
descriptor.formatVersion !== 1 ||
|
||||
descriptor.product !== 'GoodBuddy' ||
|
||||
descriptor.component !== 'agent' ||
|
||||
!semanticVersionPattern.test(descriptor.version ?? '') ||
|
||||
!semanticVersionPattern.test(
|
||||
descriptor.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
!supportedTarget(descriptor.platform, descriptor.architecture) ||
|
||||
!['x64', 'arm64'].includes(descriptor.architecture) ||
|
||||
typeof descriptor.signingKeyId !== 'string' ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(
|
||||
descriptor.contentDigest ?? ''
|
||||
) ||
|
||||
!Array.isArray(descriptor.files) ||
|
||||
descriptor.files.length === 0 ||
|
||||
descriptor.files.length > 50_000 ||
|
||||
typeof descriptor.remoteRuntime !== 'object' ||
|
||||
descriptor.remoteRuntime === null ||
|
||||
descriptor.remoteRuntime.runtimeId !== 'opencode' ||
|
||||
descriptor.remoteRuntime.provider !== 'opencode' ||
|
||||
!semanticVersionPattern.test(
|
||||
descriptor.remoteRuntime.version ?? ''
|
||||
) ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(
|
||||
descriptor.remoteRuntime.bundleDigest ?? ''
|
||||
)
|
||||
) {
|
||||
throw new Error('Agent package descriptor is invalid')
|
||||
}
|
||||
assertProtocol(descriptor.agentProtocol, 'Agent protocol')
|
||||
assertProtocol(
|
||||
descriptor.remoteRuntime.protocol,
|
||||
'Remote Runtime protocol'
|
||||
)
|
||||
const paths = new Set()
|
||||
for (const file of descriptor.files) {
|
||||
if (
|
||||
typeof file !== 'object' ||
|
||||
file === null ||
|
||||
!safePackagePath(file.path) ||
|
||||
!Number.isSafeInteger(file.size) ||
|
||||
file.size < 0 ||
|
||||
!sha256Pattern.test(file.sha256 ?? '') ||
|
||||
!['0644', '0755'].includes(file.mode) ||
|
||||
paths.has(file.path)
|
||||
) {
|
||||
throw new Error('Agent package file inventory is invalid')
|
||||
}
|
||||
paths.add(file.path)
|
||||
}
|
||||
if (
|
||||
descriptor.contentDigest !==
|
||||
descriptorContentDigest(descriptor)
|
||||
) {
|
||||
throw new Error('Agent package content identity is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageMetadata(archivePath, registry) {
|
||||
const archive = resolve(archivePath)
|
||||
const streamed = streamPackageMetadata(archive)
|
||||
const descriptorBytes = streamed.descriptor
|
||||
const signatureBytes = streamed.signature
|
||||
if (!descriptorBytes || !signatureBytes) {
|
||||
throw new Error('Agent package metadata is missing')
|
||||
}
|
||||
const descriptor = parseCanonicalJson(
|
||||
Buffer.from(descriptorBytes),
|
||||
'Agent package descriptor'
|
||||
)
|
||||
assertPackageDescriptor(descriptor)
|
||||
verifyPackageFileInventory(descriptor, streamed.files)
|
||||
const key = trustedProductionKey(
|
||||
registry,
|
||||
descriptor.signingKeyId
|
||||
)
|
||||
verifyDetachedSignature(
|
||||
Buffer.from(descriptorBytes),
|
||||
Buffer.from(signatureBytes),
|
||||
key,
|
||||
packageSignatureDomain,
|
||||
'Agent package'
|
||||
)
|
||||
const expectedArchive =
|
||||
`goodbuddy-agent-${descriptor.version}` +
|
||||
`-${descriptor.platform}-${descriptor.architecture}.gbagent`
|
||||
if (basename(archive) !== expectedArchive) {
|
||||
throw new Error(
|
||||
`Agent package archive must be named ${expectedArchive}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
archive: expectedArchive,
|
||||
descriptor,
|
||||
size: streamed.size,
|
||||
sha256: streamed.sha256
|
||||
}
|
||||
}
|
||||
|
||||
function safePackagePath(path) {
|
||||
return (
|
||||
typeof path === 'string' &&
|
||||
path.length > 0 &&
|
||||
path.length <= 512 &&
|
||||
!path.startsWith('/') &&
|
||||
!path.endsWith('/') &&
|
||||
!path.includes('\\') &&
|
||||
path.split('/').every(
|
||||
(part) =>
|
||||
part.length > 0 &&
|
||||
part !== '.' &&
|
||||
part !== '..' &&
|
||||
!part.endsWith('.') &&
|
||||
!part.endsWith(' ') &&
|
||||
!part.includes(':') &&
|
||||
[...part].every(
|
||||
(character) => character.charCodeAt(0) > 0x1f
|
||||
) &&
|
||||
!windowsReservedNamePattern.test(part)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function verifyPackageFileInventory(descriptor, actualFiles) {
|
||||
const expected = new Set([
|
||||
...descriptor.files.map((file) => file.path),
|
||||
'agent-package.json',
|
||||
'agent-package.sig'
|
||||
])
|
||||
if (
|
||||
actualFiles.size !== expected.size ||
|
||||
[...actualFiles.keys()].some((path) => !expected.has(path))
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package contains undeclared or missing files'
|
||||
)
|
||||
}
|
||||
for (const file of descriptor.files) {
|
||||
const actual = actualFiles.get(file.path)
|
||||
if (
|
||||
!actual ||
|
||||
actual.size !== file.size ||
|
||||
actual.sha256 !== file.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`Agent package payload verification failed: ${file.path}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function centralDirectoryEntries(handle, archiveSize) {
|
||||
const parsed = readZipCentralDirectory(handle, archiveSize, {
|
||||
label: 'Agent package',
|
||||
minimumEntries: 1,
|
||||
maximumEntries: maximumPackageEntries,
|
||||
maximumCentralDirectoryBytes
|
||||
})
|
||||
const entries = []
|
||||
const seen = new Set()
|
||||
let expandedBytes = 0
|
||||
for (const entry of parsed.entries) {
|
||||
if (
|
||||
entry.flags !== 0x0800 ||
|
||||
entry.compression !== 0 ||
|
||||
entry.compressedSize === 0xffffffff ||
|
||||
entry.size === 0xffffffff ||
|
||||
entry.compressedSize !== entry.size ||
|
||||
entry.size > maximumPackageEntryBytes ||
|
||||
entry.diskStart !== 0 ||
|
||||
entry.localOffset === 0xffffffff
|
||||
) {
|
||||
throw new Error('Agent package ZIP entry is unsupported')
|
||||
}
|
||||
if (
|
||||
!safePackagePath(entry.name) ||
|
||||
seen.has(entry.name)
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package contains an unsafe or duplicate path'
|
||||
)
|
||||
}
|
||||
seen.add(entry.name)
|
||||
expandedBytes += entry.size
|
||||
if (expandedBytes > maximumExpandedPackageBytes) {
|
||||
throw new Error(
|
||||
'Agent package expanded payload exceeds its limit'
|
||||
)
|
||||
}
|
||||
const local = readExactZipBytes(
|
||||
handle,
|
||||
30,
|
||||
entry.localOffset,
|
||||
'Agent package local file header'
|
||||
)
|
||||
const localNameLength = local.readUInt16LE(26)
|
||||
const localExtraLength = local.readUInt16LE(28)
|
||||
if (
|
||||
local.readUInt32LE(0) !== localFileHeaderSignature ||
|
||||
local.readUInt16LE(6) !== entry.flags ||
|
||||
local.readUInt16LE(8) !== entry.compression ||
|
||||
local.readUInt32LE(14) !== entry.checksum ||
|
||||
local.readUInt32LE(18) !== entry.compressedSize ||
|
||||
local.readUInt32LE(22) !== entry.size ||
|
||||
localNameLength !== entry.nameBytes.length
|
||||
) {
|
||||
throw new Error('Agent package local file header is invalid')
|
||||
}
|
||||
const localName = readExactZipBytes(
|
||||
handle,
|
||||
localNameLength,
|
||||
entry.localOffset + 30,
|
||||
'Agent package local file name'
|
||||
)
|
||||
if (!localName.equals(entry.nameBytes)) {
|
||||
throw new Error('Agent package local file name is invalid')
|
||||
}
|
||||
const dataOffset =
|
||||
entry.localOffset +
|
||||
30 +
|
||||
localNameLength +
|
||||
localExtraLength
|
||||
const recordEnd = dataOffset + entry.compressedSize
|
||||
if (recordEnd > parsed.centralOffset) {
|
||||
throw new Error('Agent package entry exceeds its data region')
|
||||
}
|
||||
entries.push({
|
||||
checksum: entry.checksum,
|
||||
dataOffset,
|
||||
name: entry.name,
|
||||
recordStart: entry.localOffset,
|
||||
recordEnd
|
||||
})
|
||||
}
|
||||
entries.sort(
|
||||
(left, right) => left.recordStart - right.recordStart
|
||||
)
|
||||
for (let index = 1; index < entries.length; index += 1) {
|
||||
if (entries[index].recordStart < entries[index - 1].recordEnd) {
|
||||
throw new Error('Agent package ZIP entries overlap')
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function sameFileSnapshot(left, right) {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.size === right.size &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
left.ctimeMs === right.ctimeMs
|
||||
)
|
||||
}
|
||||
|
||||
function streamPackageMetadata(archivePath) {
|
||||
const handle = openSync(archivePath, 'r')
|
||||
const values = new Map()
|
||||
const files = new Map()
|
||||
try {
|
||||
const initialStatus = fstatSync(handle)
|
||||
if (
|
||||
!initialStatus.isFile() ||
|
||||
initialStatus.size <= 0 ||
|
||||
initialStatus.size > maximumPackageBytes
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package archive is not a bounded file'
|
||||
)
|
||||
}
|
||||
const entries = centralDirectoryEntries(
|
||||
handle,
|
||||
initialStatus.size
|
||||
)
|
||||
const states = entries.map((entry) => {
|
||||
const wanted =
|
||||
entry.name === 'agent-package.json' ||
|
||||
entry.name === 'agent-package.sig'
|
||||
const size = entry.recordEnd - entry.dataOffset
|
||||
const maximum = entry.name === 'agent-package.json'
|
||||
? maximumDescriptorBytes
|
||||
: maximumMetadataBytes
|
||||
if (wanted && size > maximum) {
|
||||
throw new Error('Agent package metadata exceeds its limit')
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
bytes: 0,
|
||||
contents: wanted
|
||||
? Buffer.allocUnsafe(size)
|
||||
: undefined,
|
||||
crc32: 0,
|
||||
hash: createHash('sha256')
|
||||
}
|
||||
})
|
||||
const archiveHash = createHash('sha256')
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
let position = 0
|
||||
let firstEntry = 0
|
||||
while (position < initialStatus.size) {
|
||||
const bytesRead = readSync(
|
||||
handle,
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, initialStatus.size - position),
|
||||
position
|
||||
)
|
||||
if (bytesRead <= 0) {
|
||||
throw new Error('Agent package archive is truncated')
|
||||
}
|
||||
const chunk = buffer.subarray(0, bytesRead)
|
||||
const chunkEnd = position + bytesRead
|
||||
archiveHash.update(chunk)
|
||||
while (
|
||||
firstEntry < states.length &&
|
||||
states[firstEntry].recordEnd <= position
|
||||
) {
|
||||
firstEntry += 1
|
||||
}
|
||||
for (
|
||||
let index = firstEntry;
|
||||
index < states.length &&
|
||||
states[index].dataOffset < chunkEnd;
|
||||
index += 1
|
||||
) {
|
||||
const state = states[index]
|
||||
const dataStart = Math.max(position, state.dataOffset)
|
||||
const dataEnd = Math.min(chunkEnd, state.recordEnd)
|
||||
if (dataStart >= dataEnd) {
|
||||
continue
|
||||
}
|
||||
const data = chunk.subarray(
|
||||
dataStart - position,
|
||||
dataEnd - position
|
||||
)
|
||||
state.hash.update(data)
|
||||
state.crc32 = crc32(data, state.crc32)
|
||||
if (state.contents) {
|
||||
data.copy(
|
||||
state.contents,
|
||||
dataStart - state.dataOffset
|
||||
)
|
||||
}
|
||||
state.bytes += data.byteLength
|
||||
}
|
||||
position = chunkEnd
|
||||
}
|
||||
if (!sameFileSnapshot(initialStatus, fstatSync(handle))) {
|
||||
throw new Error(
|
||||
'Agent package archive changed during verification'
|
||||
)
|
||||
}
|
||||
for (const state of states) {
|
||||
const size = state.recordEnd - state.dataOffset
|
||||
if (
|
||||
state.bytes !== size ||
|
||||
(state.crc32 >>> 0) !== state.checksum
|
||||
) {
|
||||
throw new Error(
|
||||
`Agent package ZIP checksum verification failed: ${state.name}`
|
||||
)
|
||||
}
|
||||
files.set(state.name, {
|
||||
size,
|
||||
sha256: state.hash.digest('hex')
|
||||
})
|
||||
if (state.contents) {
|
||||
values.set(state.name, state.contents)
|
||||
}
|
||||
}
|
||||
return {
|
||||
descriptor: values.get('agent-package.json'),
|
||||
signature: values.get('agent-package.sig'),
|
||||
files,
|
||||
sha256: archiveHash.digest('hex'),
|
||||
size: initialStatus.size
|
||||
}
|
||||
} finally {
|
||||
closeSync(handle)
|
||||
}
|
||||
}
|
||||
|
||||
function catalogEntry(metadata) {
|
||||
const descriptor = metadata.descriptor
|
||||
const {
|
||||
contentDigest: _contentDigest,
|
||||
files: _files,
|
||||
signingKeyId: _signingKeyId,
|
||||
...identity
|
||||
} = descriptor
|
||||
void _contentDigest
|
||||
void _files
|
||||
void _signingKeyId
|
||||
return {
|
||||
...identity,
|
||||
archive: metadata.archive,
|
||||
size: metadata.size,
|
||||
sha256: metadata.sha256,
|
||||
downloads: {
|
||||
github: {
|
||||
url:
|
||||
'https://github.com/mesalogo/goodbuddy/releases/download/' +
|
||||
`agent-v${descriptor.version}/${metadata.archive}`
|
||||
},
|
||||
mirror: {
|
||||
url:
|
||||
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/' +
|
||||
`agent-releases/v${descriptor.version}/${metadata.archive}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertCatalog(catalog) {
|
||||
if (
|
||||
typeof catalog !== 'object' ||
|
||||
catalog === null ||
|
||||
catalog.formatVersion !== 1 ||
|
||||
catalog.product !== 'GoodBuddy' ||
|
||||
catalog.component !== 'agent' ||
|
||||
typeof catalog.signingKeyId !== 'string' ||
|
||||
typeof catalog.generatedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(catalog.generatedAt)) ||
|
||||
new Date(catalog.generatedAt).toISOString() !==
|
||||
catalog.generatedAt ||
|
||||
!Array.isArray(catalog.entries) ||
|
||||
catalog.entries.length === 0 ||
|
||||
catalog.entries.length > maximumCatalogEntries
|
||||
) {
|
||||
throw new Error('Agent catalog is invalid')
|
||||
}
|
||||
const identities = new Set()
|
||||
for (const entry of catalog.entries) {
|
||||
if (
|
||||
typeof entry !== 'object' ||
|
||||
entry === null ||
|
||||
!semanticVersionPattern.test(entry.version ?? '') ||
|
||||
!semanticVersionPattern.test(
|
||||
entry.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
!supportedTarget(entry.platform, entry.architecture) ||
|
||||
!['x64', 'arm64'].includes(entry.architecture) ||
|
||||
entry.archive !==
|
||||
`goodbuddy-agent-${entry.version}` +
|
||||
`-${entry.platform}-${entry.architecture}.gbagent` ||
|
||||
!Number.isSafeInteger(entry.size) ||
|
||||
entry.size <= 0 ||
|
||||
!sha256Pattern.test(entry.sha256 ?? '') ||
|
||||
entry.downloads?.github?.url !==
|
||||
'https://github.com/mesalogo/goodbuddy/releases/download/' +
|
||||
`agent-v${entry.version}/${entry.archive}` ||
|
||||
entry.downloads?.mirror?.url !==
|
||||
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/' +
|
||||
`agent-releases/v${entry.version}/${entry.archive}` ||
|
||||
entry.format !== 'goodbuddy-agent-package' ||
|
||||
entry.formatVersion !== 1 ||
|
||||
entry.product !== 'GoodBuddy' ||
|
||||
entry.component !== 'agent' ||
|
||||
entry.remoteRuntime?.runtimeId !== 'opencode' ||
|
||||
entry.remoteRuntime?.provider !== 'opencode' ||
|
||||
!semanticVersionPattern.test(
|
||||
entry.remoteRuntime?.version ?? ''
|
||||
) ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(
|
||||
entry.remoteRuntime?.bundleDigest ?? ''
|
||||
)
|
||||
) {
|
||||
throw new Error('Agent catalog entry is invalid')
|
||||
}
|
||||
assertProtocol(entry.agentProtocol, 'Agent protocol')
|
||||
assertProtocol(
|
||||
entry.remoteRuntime.protocol,
|
||||
'Remote Runtime protocol'
|
||||
)
|
||||
const identity = `${entry.version}:${entry.platform}:${entry.architecture}`
|
||||
if (identities.has(identity)) {
|
||||
throw new Error('Agent catalog contains duplicate entries')
|
||||
}
|
||||
identities.add(identity)
|
||||
}
|
||||
}
|
||||
|
||||
function readVerifiedCatalog(catalogPath, signaturePath, registry) {
|
||||
const bytes = readFileSync(resolve(catalogPath))
|
||||
const catalog = parseCanonicalJson(bytes, 'Agent catalog')
|
||||
assertCatalog(catalog)
|
||||
const key = trustedProductionKey(
|
||||
registry,
|
||||
catalog.signingKeyId
|
||||
)
|
||||
verifyDetachedSignature(
|
||||
bytes,
|
||||
readFileSync(resolve(signaturePath)),
|
||||
key,
|
||||
catalogSignatureDomain,
|
||||
'Agent catalog'
|
||||
)
|
||||
return catalog
|
||||
}
|
||||
|
||||
function parseVersion(value) {
|
||||
const match =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/u
|
||||
.exec(value)
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semantic version: ${value}`)
|
||||
}
|
||||
return [
|
||||
BigInt(match[1]),
|
||||
BigInt(match[2]),
|
||||
BigInt(match[3]),
|
||||
match[4]?.split('.')
|
||||
]
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const leftParts = parseVersion(left)
|
||||
const rightParts = parseVersion(right)
|
||||
for (const index of [0, 1, 2]) {
|
||||
if (leftParts[index] !== rightParts[index]) {
|
||||
return leftParts[index] > rightParts[index] ? 1 : -1
|
||||
}
|
||||
}
|
||||
const leftPre = leftParts[3]
|
||||
const rightPre = rightParts[3]
|
||||
if (!leftPre && !rightPre) return 0
|
||||
if (!leftPre) return 1
|
||||
if (!rightPre) return -1
|
||||
for (
|
||||
let index = 0;
|
||||
index < Math.max(leftPre.length, rightPre.length);
|
||||
index += 1
|
||||
) {
|
||||
const leftValue = leftPre[index]
|
||||
const rightValue = rightPre[index]
|
||||
if (leftValue === undefined) return -1
|
||||
if (rightValue === undefined) return 1
|
||||
if (leftValue === rightValue) continue
|
||||
const leftNumeric = /^\d+$/u.test(leftValue)
|
||||
const rightNumeric = /^\d+$/u.test(rightValue)
|
||||
if (leftNumeric && rightNumeric) {
|
||||
return BigInt(leftValue) > BigInt(rightValue) ? 1 : -1
|
||||
}
|
||||
if (leftNumeric !== rightNumeric) {
|
||||
return leftNumeric ? -1 : 1
|
||||
}
|
||||
return leftValue > rightValue ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function createCatalog(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const signingIdentity =
|
||||
options.signingIdentity ??
|
||||
productionSigningKey(options.environment ?? process.env)
|
||||
const signingRecord = trustedProductionKey(
|
||||
registry,
|
||||
signingIdentity.keyId
|
||||
)
|
||||
if (
|
||||
publicKeySpkiBase64(signingIdentity.privateKey) !==
|
||||
signingRecord.publicKeySpkiBase64
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent catalog signing key does not match the trusted registry'
|
||||
)
|
||||
}
|
||||
const packages = [
|
||||
readPackageMetadata(options.x64Package, registry),
|
||||
readPackageMetadata(options.arm64Package, registry),
|
||||
...(options.darwinArm64Package ? [readPackageMetadata(options.darwinArm64Package, registry)] : [])
|
||||
]
|
||||
const [first, second] = packages
|
||||
const {
|
||||
bundleDigest: _firstRuntimeDigest,
|
||||
...firstRuntimeIdentity
|
||||
} = first.descriptor.remoteRuntime
|
||||
const {
|
||||
bundleDigest: _secondRuntimeDigest,
|
||||
...secondRuntimeIdentity
|
||||
} = second.descriptor.remoteRuntime
|
||||
void _firstRuntimeDigest
|
||||
void _secondRuntimeDigest
|
||||
if (
|
||||
first.descriptor.version !== second.descriptor.version ||
|
||||
first.descriptor.minimumDesktopVersion !==
|
||||
second.descriptor.minimumDesktopVersion ||
|
||||
canonicalJson(first.descriptor.agentProtocol) !==
|
||||
canonicalJson(second.descriptor.agentProtocol) ||
|
||||
canonicalJson(firstRuntimeIdentity) !==
|
||||
canonicalJson(secondRuntimeIdentity) ||
|
||||
new Set(packages.map(
|
||||
(metadata) => metadata.descriptor.architecture
|
||||
).slice(0, 2)).size !== 2 ||
|
||||
first.descriptor.platform !== 'linux' ||
|
||||
second.descriptor.platform !== 'linux'
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package matrix does not describe one coherent release'
|
||||
)
|
||||
}
|
||||
if (packages[2]) {
|
||||
const mac = packages[2].descriptor
|
||||
const { bundleDigest: _digest, ...runtimeIdentity } = mac.remoteRuntime
|
||||
void _digest
|
||||
if (
|
||||
mac.platform !== 'darwin' || mac.architecture !== 'arm64' ||
|
||||
mac.version !== first.descriptor.version ||
|
||||
mac.minimumDesktopVersion !== first.descriptor.minimumDesktopVersion ||
|
||||
canonicalJson(mac.agentProtocol) !== canonicalJson(first.descriptor.agentProtocol) ||
|
||||
canonicalJson(runtimeIdentity) !== canonicalJson(firstRuntimeIdentity)
|
||||
) throw new Error('Agent package matrix does not describe one coherent release')
|
||||
}
|
||||
const previous =
|
||||
options.previousCatalog && options.previousSignature
|
||||
? readVerifiedCatalog(
|
||||
options.previousCatalog,
|
||||
options.previousSignature,
|
||||
registry
|
||||
)
|
||||
: undefined
|
||||
if (
|
||||
Boolean(options.previousCatalog) !==
|
||||
Boolean(options.previousSignature)
|
||||
) {
|
||||
throw new Error(
|
||||
'Previous Agent catalog and signature must be provided together'
|
||||
)
|
||||
}
|
||||
const entries = new Map()
|
||||
for (const entry of previous?.entries ?? []) {
|
||||
entries.set(`${entry.version}:${entry.platform}:${entry.architecture}`, entry)
|
||||
}
|
||||
for (const metadata of packages) {
|
||||
const entry = catalogEntry(metadata)
|
||||
const identity = `${entry.version}:${entry.platform}:${entry.architecture}`
|
||||
const existing = entries.get(identity)
|
||||
if (
|
||||
existing &&
|
||||
canonicalJson(existing) !== canonicalJson(entry)
|
||||
) {
|
||||
throw new Error(
|
||||
`Agent release identity is immutable: ${identity}`
|
||||
)
|
||||
}
|
||||
entries.set(identity, entry)
|
||||
}
|
||||
const sortedEntries = [...entries.values()].sort(
|
||||
(left, right) =>
|
||||
compareVersions(right.version, left.version) ||
|
||||
left.platform.localeCompare(right.platform, 'en') ||
|
||||
left.architecture.localeCompare(right.architecture, 'en')
|
||||
)
|
||||
if (sortedEntries.length > maximumCatalogEntries) {
|
||||
throw new Error('Agent catalog exceeds its retention limit')
|
||||
}
|
||||
const catalog = {
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
component: 'agent',
|
||||
signingKeyId: signingIdentity.keyId,
|
||||
generatedAt: new Date(options.generatedAt).toISOString(),
|
||||
entries: sortedEntries
|
||||
}
|
||||
assertCatalog(catalog)
|
||||
const bytes = canonicalBytes(catalog)
|
||||
const signature = sign(
|
||||
null,
|
||||
Buffer.concat([catalogSignatureDomain, bytes]),
|
||||
signingIdentity.privateKey
|
||||
)
|
||||
writeFileSync(resolve(options.outputCatalog), bytes)
|
||||
writeFileSync(
|
||||
resolve(options.outputSignature),
|
||||
`${signature.toString('base64')}\n`,
|
||||
'utf8'
|
||||
)
|
||||
return {
|
||||
version: first.descriptor.version,
|
||||
catalog: basename(options.outputCatalog),
|
||||
signature: basename(options.outputSignature),
|
||||
entries: sortedEntries.length
|
||||
}
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const command = argv[0]
|
||||
if (!['create', 'verify'].includes(command)) {
|
||||
throw new Error(
|
||||
'Usage: agent-catalog.cjs <create|verify> [options]'
|
||||
)
|
||||
}
|
||||
const options = {}
|
||||
for (let index = 1; index < argv.length; index += 2) {
|
||||
const flag = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (
|
||||
![
|
||||
'--x64-package',
|
||||
'--arm64-package',
|
||||
'--darwin-arm64-package',
|
||||
'--previous-catalog',
|
||||
'--previous-signature',
|
||||
'--catalog',
|
||||
'--signature',
|
||||
'--generated-at'
|
||||
].includes(flag) ||
|
||||
!value ||
|
||||
value.startsWith('--') ||
|
||||
options[flag] !== undefined
|
||||
) {
|
||||
throw new Error('Invalid Agent catalog argument')
|
||||
}
|
||||
options[flag] = value
|
||||
}
|
||||
for (const flag of ['--catalog', '--signature']) {
|
||||
if (!options[flag]) {
|
||||
throw new Error(`Agent catalog argument is required: ${flag}`)
|
||||
}
|
||||
}
|
||||
if (command === 'verify') {
|
||||
return {
|
||||
command,
|
||||
catalog: resolve(options['--catalog']),
|
||||
signature: resolve(options['--signature'])
|
||||
}
|
||||
}
|
||||
for (const flag of [
|
||||
'--x64-package',
|
||||
'--arm64-package',
|
||||
'--generated-at'
|
||||
]) {
|
||||
if (!options[flag]) {
|
||||
throw new Error(`Agent catalog argument is required: ${flag}`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
command,
|
||||
x64Package: resolve(options['--x64-package']),
|
||||
arm64Package: resolve(options['--arm64-package']),
|
||||
...(options['--darwin-arm64-package'] ? {
|
||||
darwinArm64Package: resolve(options['--darwin-arm64-package'])
|
||||
} : {}),
|
||||
...(options['--previous-catalog']
|
||||
? {
|
||||
previousCatalog: resolve(
|
||||
options['--previous-catalog']
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
...(options['--previous-signature']
|
||||
? {
|
||||
previousSignature: resolve(
|
||||
options['--previous-signature']
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
outputCatalog: resolve(options['--catalog']),
|
||||
outputSignature: resolve(options['--signature']),
|
||||
generatedAt: options['--generated-at']
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArguments(argv)
|
||||
const registry = readTrustedKeyRegistry(root)
|
||||
const result =
|
||||
options.command === 'create'
|
||||
? createCatalog(options)
|
||||
: {
|
||||
entries: readVerifiedCatalog(
|
||||
options.catalog,
|
||||
options.signature,
|
||||
registry
|
||||
).entries.length
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
catalogName,
|
||||
catalogSignatureDomain,
|
||||
catalogSignatureName,
|
||||
canonicalBytes,
|
||||
catalogEntry,
|
||||
createCatalog,
|
||||
parseArguments,
|
||||
readPackageMetadata,
|
||||
readVerifiedCatalog
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${redactSecrets(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)}\n`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
const { generateKeyPairSync } = require('node:crypto')
|
||||
const {
|
||||
mkdirSync,
|
||||
rmSync
|
||||
} = require('node:fs')
|
||||
const {
|
||||
basename,
|
||||
dirname,
|
||||
join,
|
||||
resolve
|
||||
} = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const {
|
||||
buildAgentBundle,
|
||||
publicKeySpkiBase64,
|
||||
readRuntimeLock,
|
||||
supportedArchitectures
|
||||
} = require('./agent-bundle.cjs')
|
||||
const {
|
||||
buildRuntimeBundle,
|
||||
readRemoteRuntimeLock
|
||||
} = require('./remote-runtime-bundle.cjs')
|
||||
const {
|
||||
assembleAgentPackage
|
||||
} = require('./agent-package.cjs')
|
||||
const { sha256File } = require('./file-hash.cjs')
|
||||
const { targetName } = require('./agent-build-target.cjs')
|
||||
|
||||
const ciSigningKeyId = 'goodbuddy-agent-ci-ephemeral'
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
if (
|
||||
![
|
||||
'--arch',
|
||||
'--platform',
|
||||
'--node-archive',
|
||||
'--opencode-archive',
|
||||
'--output-directory',
|
||||
'--archive'
|
||||
].includes(argument)
|
||||
) {
|
||||
throw new Error(`Unknown Agent CI bundle argument: ${argument}`)
|
||||
}
|
||||
const value = argv[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${argument} requires a value`)
|
||||
}
|
||||
const key = argument.slice(2).replaceAll('-', '')
|
||||
if (options[key] !== undefined) {
|
||||
throw new Error(`${argument} may be provided only once`)
|
||||
}
|
||||
options[key] = value
|
||||
index += 1
|
||||
}
|
||||
if (!supportedArchitectures.includes(options.arch)) {
|
||||
throw new Error('--arch must be x64 or arm64')
|
||||
}
|
||||
const platform = options.platform ?? 'linux'
|
||||
targetName(options.arch, platform)
|
||||
for (const key of [
|
||||
'nodearchive',
|
||||
'opencodearchive',
|
||||
'outputdirectory',
|
||||
'archive'
|
||||
]) {
|
||||
if (!options[key]) {
|
||||
throw new Error(
|
||||
'Usage: agent-ci-bundle.cjs --arch <x64|arm64> --node-archive <path> --opencode-archive <path> --output-directory <path> --archive <path>'
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
arch: options.arch,
|
||||
platform,
|
||||
nodeArchive: resolve(options.nodearchive),
|
||||
opencodeArchive: resolve(options.opencodearchive),
|
||||
outputDirectory: resolve(options.outputdirectory),
|
||||
archive: resolve(options.archive)
|
||||
}
|
||||
}
|
||||
|
||||
function readCiLocks(platform = 'linux', architecture = 'x64') {
|
||||
const projectRoot = resolve(__dirname, '..')
|
||||
targetName(architecture, platform)
|
||||
const lock = readRuntimeLock(projectRoot)
|
||||
const runtimeLock = readRemoteRuntimeLock(projectRoot)
|
||||
return { lock, runtimeLock }
|
||||
}
|
||||
|
||||
function ephemeralSigningIdentity() {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
return {
|
||||
privateKey,
|
||||
registry: {
|
||||
formatVersion: 1,
|
||||
keys: [
|
||||
{
|
||||
keyId: ciSigningKeyId,
|
||||
publicKeySpkiBase64: publicKeySpkiBase64(publicKey),
|
||||
environment: 'test'
|
||||
}
|
||||
],
|
||||
revocations: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildCiAgentBundle(options) {
|
||||
const projectRoot = resolve(__dirname, '..')
|
||||
const platform = options.platform ?? 'linux'
|
||||
const target = targetName(options.arch, platform)
|
||||
if (process.platform !== platform || process.arch !== options.arch) {
|
||||
throw new Error(`Agent CI build requires a native ${target} host`)
|
||||
}
|
||||
const { lock, runtimeLock } = readCiLocks(platform, options.arch)
|
||||
const signing = ephemeralSigningIdentity()
|
||||
const firstArchiveRoot = resolve(
|
||||
join(
|
||||
dirname(options.archive),
|
||||
`.agent-ci-first-${process.pid}`
|
||||
)
|
||||
)
|
||||
mkdirSync(firstArchiveRoot, { recursive: true })
|
||||
const firstArchive = join(
|
||||
firstArchiveRoot,
|
||||
basename(options.archive)
|
||||
)
|
||||
const runtimeRoot = resolve(
|
||||
`${options.outputDirectory}-runtime`
|
||||
)
|
||||
try {
|
||||
const identity = {
|
||||
keyId: ciSigningKeyId,
|
||||
privateKey: signing.privateKey
|
||||
}
|
||||
rmSync(options.outputDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
buildAgentBundle({
|
||||
platform,
|
||||
lock,
|
||||
projectRoot,
|
||||
arch: options.arch,
|
||||
runtimeArchive: options.nodeArchive,
|
||||
outputDirectory: options.outputDirectory,
|
||||
registry: signing.registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
platform,
|
||||
lock: runtimeLock,
|
||||
projectRoot,
|
||||
architecture: options.arch,
|
||||
runtimeArchive: options.opencodeArchive,
|
||||
outputRoot: runtimeRoot,
|
||||
registry: signing.registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
const assemble = (archive) =>
|
||||
assembleAgentPackage({
|
||||
platform,
|
||||
projectRoot,
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion:
|
||||
require('../package.json').version,
|
||||
output: archive,
|
||||
agentBundle: options.outputDirectory,
|
||||
runtimeBundle: runtime.bundleDirectory,
|
||||
agentLock: lock,
|
||||
runtimeLock,
|
||||
registry: signing.registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
|
||||
assemble(firstArchive)
|
||||
const firstDigest = await sha256File(firstArchive)
|
||||
const second = assemble(options.archive)
|
||||
const archiveDigest = await sha256File(options.archive)
|
||||
if (firstDigest !== archiveDigest) {
|
||||
throw new Error('Agent CI archives are not deterministic')
|
||||
}
|
||||
|
||||
const smoke = spawnSync(
|
||||
resolve(options.outputDirectory, 'goodbuddy-agent'),
|
||||
['ci-invalid-command'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (
|
||||
smoke.error ||
|
||||
smoke.status !== 2 ||
|
||||
!smoke.stderr.includes(
|
||||
'Expected daemon, attach-or-bootstrap, doctor'
|
||||
)
|
||||
) {
|
||||
throw new Error('Built Agent native launch smoke failed')
|
||||
}
|
||||
|
||||
const nativeChecks = [
|
||||
[
|
||||
resolve(options.outputDirectory, 'node'),
|
||||
['-e', `const k = require(${JSON.stringify(resolve(options.outputDirectory, 'lib/node_modules/koffi'))}); if (k.sizeof('int') !== 4) process.exit(1)`]
|
||||
],
|
||||
[join(runtime.bundleDirectory, 'bin', 'opencode'), ['--version']]
|
||||
]
|
||||
for (const [executable, args] of nativeChecks) {
|
||||
const result = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
windowsHide: true
|
||||
})
|
||||
if (
|
||||
result.error ||
|
||||
result.status !== 0 ||
|
||||
(args[0] === '--version' &&
|
||||
result.stdout.trim() !== runtimeLock.runtimes.opencode.version)
|
||||
) {
|
||||
throw new Error(`Built native dependency smoke failed: ${basename(executable)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
platform,
|
||||
agentVersion: lock.agentVersion,
|
||||
remoteRuntimeVersion:
|
||||
runtimeLock.runtimes.opencode.version,
|
||||
architecture: options.arch,
|
||||
archive: second.archive,
|
||||
archiveSha256: archiveDigest
|
||||
}
|
||||
} finally {
|
||||
rmSync(runtimeRoot, { recursive: true, force: true })
|
||||
rmSync(firstArchive, { force: true })
|
||||
rmSync(firstArchiveRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
const result = await buildCiAgentBundle(parseArguments(argv))
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(result)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`
|
||||
)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildCiAgentBundle,
|
||||
parseArguments,
|
||||
readCiLocks
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
const {
|
||||
createHash,
|
||||
sign
|
||||
} = require('node:crypto')
|
||||
const {
|
||||
closeSync,
|
||||
copyFileSync,
|
||||
fsyncSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeSync,
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { basename, dirname, join, relative, resolve, sep } = require('node:path')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { targetName } = require('./agent-build-target.cjs')
|
||||
const { crc32 } = require('node:zlib')
|
||||
const {
|
||||
buildAgentBundle,
|
||||
expectedManifestMode: expectedAgentManifestMode,
|
||||
preflightProductionSigningKey,
|
||||
productionSigningKey,
|
||||
publicKeySpkiBase64,
|
||||
readRuntimeLock,
|
||||
readTrustedKeyRegistry,
|
||||
redactSecrets,
|
||||
verifyLockedBundle
|
||||
} = require('./agent-bundle.cjs')
|
||||
const {
|
||||
buildRuntimeBundle,
|
||||
canonicalJson,
|
||||
readRemoteRuntimeLock,
|
||||
verifyBundleDirectory: verifyRuntimeBundle
|
||||
} = require('./remote-runtime-bundle.cjs')
|
||||
const {
|
||||
centralDirectoryHeaderSignature: zipCentralHeaderSignature,
|
||||
endOfCentralDirectorySignature: zipEndSignature,
|
||||
localFileHeaderSignature: zipLocalHeaderSignature
|
||||
} = require('./zip-central-directory.cjs')
|
||||
|
||||
const root = join(__dirname, '..')
|
||||
const packageDescriptorName = 'agent-package.json'
|
||||
const packageSignatureName = 'agent-package.sig'
|
||||
const signatureDomain = Buffer.from(
|
||||
'GoodBuddy Agent Package Descriptor Signature v1\0',
|
||||
'utf8'
|
||||
)
|
||||
const zipUtf8Flag = 0x0800
|
||||
const zipVersion = 20
|
||||
const zipMadeByUnix = (3 << 8) | zipVersion
|
||||
const zipDosDate = (1 << 5) | 1
|
||||
const semanticVersionPattern =
|
||||
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?$/u
|
||||
|
||||
function descriptorBytes(descriptor) {
|
||||
return Buffer.from(`${JSON.stringify(descriptor, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function descriptorContentDigest(descriptor) {
|
||||
const { contentDigest: _contentDigest, ...content } = descriptor
|
||||
void _contentDigest
|
||||
return `sha256:${createHash('sha256')
|
||||
.update(canonicalJson(content))
|
||||
.digest('hex')}`
|
||||
}
|
||||
|
||||
function listFiles(directory) {
|
||||
const rootPath = resolve(directory)
|
||||
const output = []
|
||||
const visit = (current) => {
|
||||
const currentStat = lstatSync(current)
|
||||
if (currentStat.isSymbolicLink()) {
|
||||
throw new Error(`Agent package source contains a symlink: ${current}`)
|
||||
}
|
||||
if (currentStat.isFile()) {
|
||||
output.push(current)
|
||||
return
|
||||
}
|
||||
if (!currentStat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Agent package source contains an unsupported entry: ${current}`
|
||||
)
|
||||
}
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
visit(join(current, entry.name))
|
||||
}
|
||||
}
|
||||
visit(rootPath)
|
||||
return output.sort((left, right) =>
|
||||
Buffer.from(relative(rootPath, left).split(sep).join('/')).compare(
|
||||
Buffer.from(relative(rootPath, right).split(sep).join('/'))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function fileDigests(filePath) {
|
||||
const hash = createHash('sha256')
|
||||
const handle = openSync(filePath, 'r')
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
let checksum = 0
|
||||
try {
|
||||
while (true) {
|
||||
const bytesRead = readSync(
|
||||
handle,
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
null
|
||||
)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
const chunk = buffer.subarray(0, bytesRead)
|
||||
hash.update(chunk)
|
||||
checksum = crc32(chunk, checksum)
|
||||
}
|
||||
} finally {
|
||||
closeSync(handle)
|
||||
}
|
||||
return {
|
||||
crc32: checksum >>> 0,
|
||||
sha256: hash.digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
function copyTree(source, destination) {
|
||||
for (const filePath of listFiles(source)) {
|
||||
const name = relative(source, filePath).split(sep).join('/')
|
||||
const target = join(destination, ...name.split('/'))
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
copyFileSync(filePath, target)
|
||||
}
|
||||
}
|
||||
|
||||
function packageFileMode(path) {
|
||||
if (path.startsWith('agent/')) {
|
||||
return expectedAgentManifestMode(path.slice('agent/'.length))
|
||||
}
|
||||
return /\/bin\/opencode$/u.test(path) ? '0755' : '0644'
|
||||
}
|
||||
|
||||
function stagePackagePayload(options) {
|
||||
const staging = options.staging
|
||||
copyTree(options.agentBundle, join(staging, 'agent'))
|
||||
copyTree(
|
||||
options.runtimeBundle,
|
||||
join(
|
||||
staging,
|
||||
'runtime',
|
||||
'opencode',
|
||||
basename(options.runtimeBundle)
|
||||
)
|
||||
)
|
||||
for (const [value, destination] of [
|
||||
[
|
||||
options.registry,
|
||||
'agent-release-keys.json'
|
||||
],
|
||||
[
|
||||
options.agentLock,
|
||||
'agent-runtime-lock.json'
|
||||
],
|
||||
[
|
||||
options.runtimeLock,
|
||||
'remote-runtime-lock.json'
|
||||
]
|
||||
]) {
|
||||
writeFileSync(
|
||||
join(staging, destination),
|
||||
`${JSON.stringify(value, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function payloadFiles(staging) {
|
||||
const crc32ByPath = new Map()
|
||||
const files = listFiles(staging)
|
||||
.map((filePath) => {
|
||||
const path = relative(staging, filePath).split(sep).join('/')
|
||||
const size = statSync(filePath).size
|
||||
const digests = fileDigests(filePath)
|
||||
crc32ByPath.set(path, digests.crc32)
|
||||
return {
|
||||
path,
|
||||
size,
|
||||
sha256: digests.sha256,
|
||||
mode: packageFileMode(path)
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(file) =>
|
||||
file.path !== packageDescriptorName &&
|
||||
file.path !== packageSignatureName
|
||||
)
|
||||
return { crc32ByPath, files }
|
||||
}
|
||||
|
||||
function createPackageArchive(
|
||||
staging,
|
||||
output,
|
||||
knownCrc32 = new Map()
|
||||
) {
|
||||
mkdirSync(dirname(output), { recursive: true })
|
||||
const entries = listFiles(staging).map((filePath) => {
|
||||
const name = relative(staging, filePath).split(sep).join('/')
|
||||
const nameBytes = Buffer.from(name, 'utf8')
|
||||
const size = statSync(filePath).size
|
||||
if (
|
||||
nameBytes.length === 0 ||
|
||||
nameBytes.length > 0xffff ||
|
||||
size > 0xffffffff
|
||||
) {
|
||||
throw new Error(`Agent package ZIP entry is too large: ${name}`)
|
||||
}
|
||||
return {
|
||||
filePath,
|
||||
name,
|
||||
nameBytes,
|
||||
size,
|
||||
crc32:
|
||||
knownCrc32.get(name) ?? fileDigests(filePath).crc32,
|
||||
mode: packageFileMode(name),
|
||||
offset: 0
|
||||
}
|
||||
})
|
||||
if (entries.length === 0 || entries.length > 0xffff) {
|
||||
throw new Error('Agent package ZIP entry count is invalid')
|
||||
}
|
||||
const outputHandle = openSync(output, 'w')
|
||||
let outputOffset = 0
|
||||
const archiveHash = createHash('sha256')
|
||||
const writeBytes = (data) => {
|
||||
let offset = 0
|
||||
while (offset < data.byteLength) {
|
||||
const written = writeSync(
|
||||
outputHandle,
|
||||
data,
|
||||
offset,
|
||||
data.byteLength - offset
|
||||
)
|
||||
if (written <= 0) {
|
||||
throw new Error('Agent package archive write made no progress')
|
||||
}
|
||||
offset += written
|
||||
}
|
||||
archiveHash.update(data)
|
||||
outputOffset += data.byteLength
|
||||
}
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
entry.offset = outputOffset
|
||||
const header = Buffer.alloc(30)
|
||||
header.writeUInt32LE(zipLocalHeaderSignature, 0)
|
||||
header.writeUInt16LE(zipVersion, 4)
|
||||
header.writeUInt16LE(zipUtf8Flag, 6)
|
||||
header.writeUInt16LE(0, 8)
|
||||
header.writeUInt16LE(0, 10)
|
||||
header.writeUInt16LE(zipDosDate, 12)
|
||||
header.writeUInt32LE(entry.crc32, 14)
|
||||
header.writeUInt32LE(entry.size, 18)
|
||||
header.writeUInt32LE(entry.size, 22)
|
||||
header.writeUInt16LE(entry.nameBytes.length, 26)
|
||||
header.writeUInt16LE(0, 28)
|
||||
writeBytes(header)
|
||||
writeBytes(entry.nameBytes)
|
||||
|
||||
const inputHandle = openSync(entry.filePath, 'r')
|
||||
try {
|
||||
let position = 0
|
||||
while (position < entry.size) {
|
||||
const bytesRead = readSync(
|
||||
inputHandle,
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, entry.size - position),
|
||||
null
|
||||
)
|
||||
if (bytesRead <= 0) {
|
||||
throw new Error(
|
||||
`Agent package source read made no progress: ${entry.name}`
|
||||
)
|
||||
}
|
||||
position += bytesRead
|
||||
writeBytes(buffer.subarray(0, bytesRead))
|
||||
}
|
||||
} finally {
|
||||
closeSync(inputHandle)
|
||||
}
|
||||
}
|
||||
|
||||
const centralOffset = outputOffset
|
||||
for (const entry of entries) {
|
||||
const record = Buffer.alloc(46)
|
||||
record.writeUInt32LE(zipCentralHeaderSignature, 0)
|
||||
record.writeUInt16LE(zipMadeByUnix, 4)
|
||||
record.writeUInt16LE(zipVersion, 6)
|
||||
record.writeUInt16LE(zipUtf8Flag, 8)
|
||||
record.writeUInt16LE(0, 10)
|
||||
record.writeUInt16LE(0, 12)
|
||||
record.writeUInt16LE(zipDosDate, 14)
|
||||
record.writeUInt32LE(entry.crc32, 16)
|
||||
record.writeUInt32LE(entry.size, 20)
|
||||
record.writeUInt32LE(entry.size, 24)
|
||||
record.writeUInt16LE(entry.nameBytes.length, 28)
|
||||
record.writeUInt16LE(0, 30)
|
||||
record.writeUInt16LE(0, 32)
|
||||
record.writeUInt16LE(0, 34)
|
||||
record.writeUInt16LE(0, 36)
|
||||
const unixMode =
|
||||
0o100000 | Number.parseInt(entry.mode, 8)
|
||||
record.writeUInt32LE((unixMode << 16) >>> 0, 38)
|
||||
record.writeUInt32LE(entry.offset, 42)
|
||||
writeBytes(record)
|
||||
writeBytes(entry.nameBytes)
|
||||
}
|
||||
|
||||
const centralSize = outputOffset - centralOffset
|
||||
const end = Buffer.alloc(22)
|
||||
end.writeUInt32LE(zipEndSignature, 0)
|
||||
end.writeUInt16LE(0, 4)
|
||||
end.writeUInt16LE(0, 6)
|
||||
end.writeUInt16LE(entries.length, 8)
|
||||
end.writeUInt16LE(entries.length, 10)
|
||||
end.writeUInt32LE(centralSize, 12)
|
||||
end.writeUInt32LE(centralOffset, 16)
|
||||
end.writeUInt16LE(0, 20)
|
||||
writeBytes(end)
|
||||
fsyncSync(outputHandle)
|
||||
} finally {
|
||||
closeSync(outputHandle)
|
||||
}
|
||||
return {
|
||||
size: outputOffset,
|
||||
sha256: archiveHash.digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
function assembleAgentPackage(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const architecture = options.architecture
|
||||
const platform = options.platform ?? 'linux'
|
||||
const target = targetName(architecture, platform)
|
||||
if (
|
||||
!['x64', 'arm64'].includes(architecture) ||
|
||||
!semanticVersionPattern.test(options.minimumDesktopVersion ?? '') ||
|
||||
typeof options.output !== 'string'
|
||||
) {
|
||||
throw new Error('Agent package identity is invalid')
|
||||
}
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const agentLock =
|
||||
options.agentLock ?? readRuntimeLock(projectRoot)
|
||||
const runtimeLock =
|
||||
options.runtimeLock ?? readRemoteRuntimeLock(projectRoot)
|
||||
const signingIdentity =
|
||||
options.testSigningIdentity ??
|
||||
options.signingIdentity ??
|
||||
productionSigningKey(options.environment ?? process.env)
|
||||
const signingRecord = options.testSigningIdentity
|
||||
? registry.keys.find(
|
||||
(key) => key.keyId === signingIdentity.keyId
|
||||
)
|
||||
: preflightProductionSigningKey({
|
||||
projectRoot,
|
||||
registry,
|
||||
keyId: signingIdentity.keyId
|
||||
})
|
||||
if (!signingRecord) {
|
||||
throw new Error('Agent package signing key is not registered')
|
||||
}
|
||||
if (
|
||||
publicKeySpkiBase64(signingIdentity.privateKey) !==
|
||||
signingRecord.publicKeySpkiBase64
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package signing private key does not match the trusted registry'
|
||||
)
|
||||
}
|
||||
const agent = verifyLockedBundle(
|
||||
options.agentBundle,
|
||||
architecture,
|
||||
{
|
||||
platform,
|
||||
projectRoot,
|
||||
registry,
|
||||
lock: agentLock,
|
||||
verificationEnvironment: options.testSigningIdentity
|
||||
? 'test'
|
||||
: 'production'
|
||||
}
|
||||
)
|
||||
const runtime = verifyRuntimeBundle(options.runtimeBundle, {
|
||||
platform,
|
||||
projectRoot,
|
||||
architecture,
|
||||
registry,
|
||||
lock: runtimeLock,
|
||||
verificationEnvironment: options.testSigningIdentity
|
||||
? 'test'
|
||||
: 'production'
|
||||
})
|
||||
const expectedArchiveName =
|
||||
`goodbuddy-agent-${agent.manifest.agentVersion}` +
|
||||
`-${target}.gbagent`
|
||||
if (basename(options.output) !== expectedArchiveName) {
|
||||
throw new Error(
|
||||
`Agent package output must be named ${expectedArchiveName}`
|
||||
)
|
||||
}
|
||||
const staging = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-agent-package-${target}-`)
|
||||
)
|
||||
try {
|
||||
stagePackagePayload({
|
||||
staging,
|
||||
registry,
|
||||
agentLock,
|
||||
runtimeLock,
|
||||
agentBundle: resolve(options.agentBundle),
|
||||
runtimeBundle: runtime.bundleDirectory
|
||||
})
|
||||
const payload = payloadFiles(staging)
|
||||
const initial = {
|
||||
format: 'goodbuddy-agent-package',
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
component: 'agent',
|
||||
version: agent.manifest.agentVersion,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
platform,
|
||||
architecture,
|
||||
signingKeyId: signingIdentity.keyId,
|
||||
agentProtocol: agent.manifest.protocol,
|
||||
remoteRuntime: {
|
||||
runtimeId: runtime.manifest.runtimeId,
|
||||
provider: runtime.manifest.provider,
|
||||
version: runtime.manifest.runtimeVersion,
|
||||
bundleDigest: runtime.manifest.bundleDigest,
|
||||
protocol: runtime.manifest.protocol
|
||||
},
|
||||
contentDigest: `sha256:${'0'.repeat(64)}`,
|
||||
files: payload.files
|
||||
}
|
||||
const descriptor = {
|
||||
...initial,
|
||||
contentDigest: descriptorContentDigest(initial)
|
||||
}
|
||||
const bytes = descriptorBytes(descriptor)
|
||||
const signature = sign(
|
||||
null,
|
||||
Buffer.concat([signatureDomain, bytes]),
|
||||
signingIdentity.privateKey
|
||||
)
|
||||
writeFileSync(join(staging, packageDescriptorName), bytes)
|
||||
writeFileSync(
|
||||
join(staging, packageSignatureName),
|
||||
`${signature.toString('base64')}\n`,
|
||||
'utf8'
|
||||
)
|
||||
const archive = createPackageArchive(
|
||||
staging,
|
||||
options.output,
|
||||
payload.crc32ByPath
|
||||
)
|
||||
return {
|
||||
descriptor,
|
||||
archive: basename(options.output),
|
||||
size: archive.size,
|
||||
sha256: archive.sha256
|
||||
}
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgentPackage(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const signingIdentity =
|
||||
options.testSigningIdentity ??
|
||||
options.signingIdentity ??
|
||||
productionSigningKey(options.environment ?? process.env)
|
||||
const agentRoot = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-agent-build-${options.architecture}-`)
|
||||
)
|
||||
const runtimeRoot = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-runtime-build-${options.architecture}-`)
|
||||
)
|
||||
try {
|
||||
const agentBundle = buildAgentBundle({
|
||||
platform: options.platform ?? 'linux',
|
||||
projectRoot,
|
||||
arch: options.architecture,
|
||||
runtimeArchive: options.nodeArchive,
|
||||
outputDirectory: join(agentRoot, 'agent'),
|
||||
registry,
|
||||
...(options.agentLock ? { lock: options.agentLock } : {}),
|
||||
...(options.testSigningIdentity
|
||||
? { testSigningIdentity: options.testSigningIdentity }
|
||||
: { signingIdentity })
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
platform: options.platform ?? 'linux',
|
||||
projectRoot,
|
||||
architecture: options.architecture,
|
||||
runtimeArchive: options.runtimeArchive,
|
||||
outputRoot: runtimeRoot,
|
||||
registry,
|
||||
...(options.runtimeLock ? { lock: options.runtimeLock } : {}),
|
||||
...(options.testSigningIdentity
|
||||
? { testSigningIdentity: options.testSigningIdentity }
|
||||
: { signingIdentity })
|
||||
})
|
||||
return assembleAgentPackage({
|
||||
...options,
|
||||
projectRoot,
|
||||
registry,
|
||||
...(options.testSigningIdentity
|
||||
? {}
|
||||
: { signingIdentity }),
|
||||
agentBundle,
|
||||
runtimeBundle: runtime.bundleDirectory
|
||||
})
|
||||
} finally {
|
||||
rmSync(agentRoot, { recursive: true, force: true })
|
||||
rmSync(runtimeRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const command = argv[0]
|
||||
if (!['build', 'assemble'].includes(command)) {
|
||||
throw new Error(
|
||||
'Usage: agent-package.cjs <build|assemble> --arch <x64|arm64> --minimum-desktop-version <version> --output <archive>'
|
||||
)
|
||||
}
|
||||
const options = {}
|
||||
for (let index = 1; index < argv.length; index += 2) {
|
||||
const flag = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (
|
||||
![
|
||||
'--arch',
|
||||
'--platform',
|
||||
'--minimum-desktop-version',
|
||||
'--node-archive',
|
||||
'--runtime-archive',
|
||||
'--agent-bundle',
|
||||
'--runtime-bundle',
|
||||
'--output'
|
||||
].includes(flag) ||
|
||||
!value
|
||||
) {
|
||||
throw new Error('Invalid Agent package argument')
|
||||
}
|
||||
options[
|
||||
flag.slice(2).replace(/-([a-z])/gu, (_match, valuePart) =>
|
||||
valuePart.toUpperCase()
|
||||
)
|
||||
] = value
|
||||
}
|
||||
if (
|
||||
!['x64', 'arm64'].includes(options.arch) ||
|
||||
!semanticVersionPattern.test(
|
||||
options.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
!options.output
|
||||
) {
|
||||
throw new Error('Agent package arguments are incomplete')
|
||||
}
|
||||
const common = {
|
||||
platform: options.platform ?? 'linux',
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
output: resolve(options.output)
|
||||
}
|
||||
targetName(common.architecture, common.platform)
|
||||
if (command === 'build') {
|
||||
if (!options.nodeArchive || !options.runtimeArchive) {
|
||||
throw new Error('Agent package build inputs are incomplete')
|
||||
}
|
||||
return {
|
||||
command,
|
||||
...common,
|
||||
nodeArchive: resolve(options.nodeArchive),
|
||||
runtimeArchive: resolve(options.runtimeArchive)
|
||||
}
|
||||
}
|
||||
if (!options.agentBundle || !options.runtimeBundle) {
|
||||
throw new Error('Agent package assembly inputs are incomplete')
|
||||
}
|
||||
return {
|
||||
command,
|
||||
...common,
|
||||
agentBundle: resolve(options.agentBundle),
|
||||
runtimeBundle: resolve(options.runtimeBundle)
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArguments(argv)
|
||||
const result =
|
||||
options.command === 'build'
|
||||
? buildAgentPackage(options)
|
||||
: assembleAgentPackage(options)
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assembleAgentPackage,
|
||||
buildAgentPackage,
|
||||
canonicalJson,
|
||||
descriptorBytes,
|
||||
descriptorContentDigest,
|
||||
parseArguments,
|
||||
signatureDomain
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${redactSecrets(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)}\n`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,10 @@ const manifestName = 'release-manifest.json'
|
||||
const targetDefinitions = [
|
||||
{ platform: 'windows', arch: 'x64', formats: ['nsis', 'portable'] },
|
||||
{ platform: 'windows', arch: 'arm64', formats: ['nsis', 'portable'] },
|
||||
{ platform: 'macos', arch: 'x64', formats: ['dmg', 'zip'] },
|
||||
{ platform: 'macos', arch: 'arm64', formats: ['dmg', 'zip'] },
|
||||
{ platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb'] },
|
||||
{ platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb'] }
|
||||
{ platform: 'macos', arch: 'x64', formats: ['dmg'] },
|
||||
{ platform: 'macos', arch: 'arm64', formats: ['dmg'] },
|
||||
{ platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb', 'rpm'] },
|
||||
{ platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb', 'rpm'] }
|
||||
]
|
||||
const allowedExtensions = {
|
||||
nsis: '.exe',
|
||||
@@ -30,7 +30,8 @@ const allowedExtensions = {
|
||||
dmg: '.dmg',
|
||||
zip: '.zip',
|
||||
AppImage: '.AppImage',
|
||||
deb: '.deb'
|
||||
deb: '.deb',
|
||||
rpm: '.rpm'
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
@@ -319,6 +320,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
module.exports = {
|
||||
aggregateRelease,
|
||||
assertSafeName,
|
||||
expectedFormatForFile,
|
||||
parseArguments,
|
||||
targetDefinitions
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
function detectElfArchitecture(buffer) {
|
||||
if (
|
||||
buffer.length < 20 ||
|
||||
buffer[0] !== 0x7f ||
|
||||
buffer.toString('ascii', 1, 4) !== 'ELF' ||
|
||||
![1, 2].includes(buffer[5])
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const machine =
|
||||
buffer[5] === 2
|
||||
? buffer.readUInt16BE(18)
|
||||
: buffer.readUInt16LE(18)
|
||||
if (machine === 62) {
|
||||
return 'x64'
|
||||
}
|
||||
if (machine === 183) {
|
||||
return 'arm64'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function detectBinaryArchitecture(buffer) {
|
||||
if (
|
||||
buffer.length >= 64 &&
|
||||
buffer[0] === 0x4d &&
|
||||
buffer[1] === 0x5a
|
||||
) {
|
||||
const peOffset = buffer.readUInt32LE(0x3c)
|
||||
if (
|
||||
peOffset + 6 <= buffer.length &&
|
||||
buffer.toString('ascii', peOffset, peOffset + 4) === 'PE\0\0'
|
||||
) {
|
||||
const machine = buffer.readUInt16LE(peOffset + 4)
|
||||
if (machine === 0x8664) {
|
||||
return 'x64'
|
||||
}
|
||||
if (machine === 0xaa64) {
|
||||
return 'arm64'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elfArchitecture = detectElfArchitecture(buffer)
|
||||
if (elfArchitecture) {
|
||||
return elfArchitecture
|
||||
}
|
||||
|
||||
if (buffer.length >= 8) {
|
||||
const littleMagic = buffer.readUInt32LE(0)
|
||||
const bigMagic = buffer.readUInt32BE(0)
|
||||
const cpuType =
|
||||
littleMagic === 0xfeedfacf
|
||||
? buffer.readUInt32LE(4)
|
||||
: bigMagic === 0xfeedfacf
|
||||
? buffer.readUInt32BE(4)
|
||||
: undefined
|
||||
if (cpuType === 0x01000007) {
|
||||
return 'x64'
|
||||
}
|
||||
if (cpuType === 0x0100000c) {
|
||||
return 'arm64'
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectBinaryArchitecture,
|
||||
detectElfArchitecture
|
||||
}
|
||||
@@ -29,19 +29,6 @@ const portablePath = process.env.GOODBUDDY_OUT_DIR
|
||||
const markerName = '.goodbuddy-portable.json'
|
||||
const portableLocales = new Set(['zh-CN.pak', 'en-US.pak'])
|
||||
|
||||
if (process.platform !== 'win32' || process.arch !== 'x64') {
|
||||
throw new Error('Portable 目录当前必须在 Windows x64 上构建')
|
||||
}
|
||||
if (
|
||||
portablePath === parse(portablePath).root ||
|
||||
portablePath === root ||
|
||||
portablePath === outputRoot
|
||||
) {
|
||||
throw new Error(
|
||||
`拒绝使用不安全的输出目录:${portablePath}`
|
||||
)
|
||||
}
|
||||
|
||||
function assertOutputUnlocked(directory) {
|
||||
if (!existsSync(directory)) {
|
||||
return
|
||||
@@ -142,6 +129,9 @@ function portableRequiredPaths(directory) {
|
||||
return [
|
||||
join(directory, 'GoodBuddy.exe'),
|
||||
join(directory, 'resources', 'app.asar'),
|
||||
join(directory, 'resources', 'agent-runtime-lock.json'),
|
||||
join(directory, 'resources', 'remote-runtime-lock.json'),
|
||||
join(directory, 'resources', 'agent-release-keys.json'),
|
||||
join(directory, 'resources', 'icon.ico'),
|
||||
join(directory, 'resources', 'tray-icon.png'),
|
||||
join(
|
||||
@@ -151,6 +141,13 @@ function portableRequiredPaths(directory) {
|
||||
'opencode',
|
||||
'opencode.exe'
|
||||
),
|
||||
join(
|
||||
directory,
|
||||
'resources',
|
||||
'runtimes',
|
||||
'opencode',
|
||||
'rg.exe'
|
||||
),
|
||||
join(
|
||||
directory,
|
||||
'resources',
|
||||
@@ -219,7 +216,10 @@ function writePortableMarker(directory) {
|
||||
)
|
||||
}
|
||||
|
||||
function replacePortableOutput(source, destination) {
|
||||
function replacePortableOutput(
|
||||
source,
|
||||
destination
|
||||
) {
|
||||
const replacement = `${destination}.replacement-${process.pid}`
|
||||
const backup = `${destination}.previous-${process.pid}`
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
@@ -279,36 +279,76 @@ function replacePortableOutput(source, destination) {
|
||||
}
|
||||
}
|
||||
|
||||
const electronDist = ensureElectronRuntime()
|
||||
mkdirSync(outputRoot, { recursive: true })
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
function main() {
|
||||
if (process.platform !== 'win32' || process.arch !== 'x64') {
|
||||
throw new Error('Portable 目录当前必须在 Windows x64 上构建')
|
||||
}
|
||||
if (
|
||||
portablePath === parse(portablePath).root ||
|
||||
portablePath === root ||
|
||||
portablePath === outputRoot
|
||||
) {
|
||||
throw new Error(
|
||||
`拒绝使用不安全的输出目录:${portablePath}`
|
||||
)
|
||||
}
|
||||
const electronDist = ensureElectronRuntime()
|
||||
mkdirSync(outputRoot, { recursive: true })
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
|
||||
for (const [label, script, args] of [
|
||||
[
|
||||
'Node 类型检查',
|
||||
join(root, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
['--noEmit', '-p', 'tsconfig.node.json']
|
||||
],
|
||||
[
|
||||
'Renderer 类型检查',
|
||||
join(root, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
['--noEmit', '-p', 'tsconfig.web.json']
|
||||
],
|
||||
[
|
||||
'Production bundle',
|
||||
join(
|
||||
root,
|
||||
'node_modules',
|
||||
'electron-vite',
|
||||
'bin',
|
||||
'electron-vite.js'
|
||||
),
|
||||
['build']
|
||||
]
|
||||
]) {
|
||||
const buildResult = spawnSync(
|
||||
for (const [label, script, args] of [
|
||||
[
|
||||
'Node 类型检查',
|
||||
join(root, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
['--noEmit', '-p', 'tsconfig.node.json']
|
||||
],
|
||||
[
|
||||
'Renderer 类型检查',
|
||||
join(root, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
['--noEmit', '-p', 'tsconfig.web.json']
|
||||
],
|
||||
[
|
||||
'Production bundle',
|
||||
join(
|
||||
root,
|
||||
'node_modules',
|
||||
'electron-vite',
|
||||
'bin',
|
||||
'electron-vite.js'
|
||||
),
|
||||
['build']
|
||||
]
|
||||
]) {
|
||||
const buildResult = spawnSync(
|
||||
process.execPath,
|
||||
[script, ...args],
|
||||
{
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (buildResult.error) {
|
||||
throw buildResult.error
|
||||
}
|
||||
if (buildResult.status !== 0) {
|
||||
throw new Error(
|
||||
`${label}失败(code ${buildResult.status ?? 1})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[script, ...args],
|
||||
[
|
||||
join(root, 'build', 'run-electron-builder.cjs'),
|
||||
'--dir',
|
||||
'--x64',
|
||||
`--config.directories.output=${stagingRoot}`,
|
||||
`--config.electronDist=${electronDist}`
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
@@ -317,55 +357,40 @@ for (const [label, script, args] of [
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (buildResult.error) {
|
||||
throw buildResult.error
|
||||
|
||||
if (result.error) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw result.error
|
||||
}
|
||||
if (buildResult.status !== 0) {
|
||||
if (result.status !== 0) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw new Error(
|
||||
`${label}失败(code ${buildResult.status ?? 1})`
|
||||
`Electron Builder 构建失败(code ${result.status ?? 1})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
join(root, 'node_modules', 'electron-builder', 'cli.js'),
|
||||
'--dir',
|
||||
'--x64',
|
||||
`--config.directories.output=${stagingRoot}`,
|
||||
`--config.electronDist=${electronDist}`
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
if (!statSync(unpackedPath, {
|
||||
throwIfNoEntry: false
|
||||
})?.isDirectory()) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw new Error('Electron Builder 未生成 portable 目录')
|
||||
}
|
||||
try {
|
||||
pruneLocales(unpackedPath)
|
||||
assertPortableOutput(unpackedPath)
|
||||
replacePortableOutput(unpackedPath, portablePath)
|
||||
} finally {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
}
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw new Error(
|
||||
`Electron Builder 构建失败(code ${result.status ?? 1})`
|
||||
)
|
||||
}
|
||||
if (!statSync(unpackedPath, { throwIfNoEntry: false })?.isDirectory()) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw new Error('Electron Builder 未生成 portable 目录')
|
||||
}
|
||||
try {
|
||||
pruneLocales(unpackedPath)
|
||||
assertPortableOutput(unpackedPath)
|
||||
replacePortableOutput(unpackedPath, portablePath)
|
||||
} finally {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
console.log(`Portable 目录构建完成:${portablePath}`)
|
||||
console.log(`启动文件:${join(portablePath, 'GoodBuddy.exe')}`)
|
||||
}
|
||||
|
||||
console.log(`Portable 目录构建完成:${portablePath}`)
|
||||
console.log(`启动文件:${join(portablePath, 'GoodBuddy.exe')}`)
|
||||
module.exports = {
|
||||
assertPortableOutput,
|
||||
portableRequiredPaths
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
closeSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
@@ -35,6 +36,16 @@ const {
|
||||
} = require('@electron/asar')
|
||||
const { Zip, ZipDeflate } = require('fflate')
|
||||
const { sha256File } = require('./file-hash.cjs')
|
||||
const {
|
||||
detectBinaryArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
const {
|
||||
readZipCentralDirectory
|
||||
} = require('./zip-central-directory.cjs')
|
||||
const {
|
||||
verifyOpenCodeConfig,
|
||||
verifyHarnessBundleImports
|
||||
} = require('./verify-desktop-runtimes.cjs')
|
||||
|
||||
const root = join(__dirname, '..')
|
||||
const packageJson = JSON.parse(
|
||||
@@ -45,23 +56,34 @@ const packageLock = JSON.parse(
|
||||
)
|
||||
const productName = packageJson.build?.productName ?? packageJson.name
|
||||
const releaseRoot = join(root, 'dist', 'release')
|
||||
const releaseBuilderConfig = join(
|
||||
root,
|
||||
'build',
|
||||
'electron-builder.release.cjs'
|
||||
)
|
||||
const manifestName = 'release-manifest.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'
|
||||
'@deepseek-ai/dsh-agent':
|
||||
packageLock.packages['node_modules/@deepseek-ai/dsh-agent']
|
||||
?.version,
|
||||
'@napi-rs/canvas':
|
||||
packageLock.packages['node_modules/@napi-rs/canvas']
|
||||
?.version,
|
||||
'node-pty':
|
||||
packageLock.packages['node_modules/node-pty']?.version
|
||||
}
|
||||
const koffiVersion = '3.1.4'
|
||||
const koffiVersion =
|
||||
packageLock.packages['node_modules/koffi']?.version
|
||||
const harnessLicenseFiles = [
|
||||
'agent-client-protocol-Apache-2.0.txt',
|
||||
'deepseek-cordis-MIT.txt',
|
||||
'deepseek-harness-MIT.txt',
|
||||
'koffi-MIT.txt',
|
||||
'napi-rs-canvas-MIT.txt',
|
||||
'node-pty-MIT.txt'
|
||||
]
|
||||
const portableRequiredFiles = [
|
||||
@@ -70,8 +92,17 @@ const portableRequiredFiles = [
|
||||
'resources/release-notes.json',
|
||||
'resources/icon.ico',
|
||||
'resources/tray-icon.png',
|
||||
'resources/tool-environment/managed-python-artifacts.json',
|
||||
'resources/runtimes/opencode/opencode.exe',
|
||||
'resources/runtimes/continue/package.json'
|
||||
'resources/runtimes/opencode/rg.exe',
|
||||
'resources/licenses/vscode-ripgrep-MIT.txt',
|
||||
'resources/runtimes/opencode-config/node_modules/@opencode-ai/plugin/package.json',
|
||||
'resources/runtimes/opencode-config/node_modules/@opencode-ai/plugin/dist/index.js',
|
||||
'resources/runtimes/continue/package.json',
|
||||
'resources/runtimes/npm/bin/npm-cli.js',
|
||||
'resources/runtimes/npm/bin/npx-cli.js',
|
||||
'resources/runtimes/npm/package.json',
|
||||
'resources/runtimes/npm/node_modules/graceful-fs/package.json'
|
||||
]
|
||||
const maxPortableZipEntries = 50_000
|
||||
const maxPortableCentralDirectoryBytes = 64 * 1024 * 1024
|
||||
@@ -99,11 +130,12 @@ const platformDefinitions = {
|
||||
supportedFormats: ['nsis', 'portable'],
|
||||
unpackedPattern: /^win(?:-.+)?-unpacked$/u,
|
||||
executable: [`${productName}.exe`],
|
||||
runtimeExecutable: 'opencode.exe'
|
||||
runtimeExecutable: 'opencode.exe',
|
||||
ripgrepExecutable: 'rg.exe'
|
||||
},
|
||||
macos: {
|
||||
builderFlag: '--mac',
|
||||
defaultFormats: ['dmg', 'zip'],
|
||||
defaultFormats: ['dmg'],
|
||||
supportedFormats: ['dmg', 'zip'],
|
||||
unpackedPattern: /^mac(?:-.+)?$/u,
|
||||
executable: [
|
||||
@@ -112,15 +144,17 @@ const platformDefinitions = {
|
||||
'MacOS',
|
||||
productName
|
||||
],
|
||||
runtimeExecutable: 'opencode'
|
||||
runtimeExecutable: 'opencode',
|
||||
ripgrepExecutable: 'rg'
|
||||
},
|
||||
linux: {
|
||||
builderFlag: '--linux',
|
||||
defaultFormats: ['AppImage', 'deb'],
|
||||
supportedFormats: ['AppImage', 'deb'],
|
||||
defaultFormats: ['AppImage', 'deb', 'rpm'],
|
||||
supportedFormats: ['AppImage', 'deb', 'rpm'],
|
||||
unpackedPattern: /^linux(?:-.+)?-unpacked$/u,
|
||||
executable: [packageJson.name],
|
||||
runtimeExecutable: 'opencode'
|
||||
runtimeExecutable: 'opencode',
|
||||
ripgrepExecutable: 'rg'
|
||||
}
|
||||
}
|
||||
const formatExtensions = {
|
||||
@@ -129,7 +163,8 @@ const formatExtensions = {
|
||||
dmg: '.dmg',
|
||||
zip: '.zip',
|
||||
AppImage: '.AppImage',
|
||||
deb: '.deb'
|
||||
deb: '.deb',
|
||||
rpm: '.rpm'
|
||||
}
|
||||
|
||||
function normalizePlatform(value) {
|
||||
@@ -150,6 +185,7 @@ function parseArguments(argv, environment = process) {
|
||||
formats: [],
|
||||
skipBuild: false,
|
||||
dryRun: false,
|
||||
unsigned: false,
|
||||
help: false
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
@@ -173,6 +209,8 @@ function parseArguments(argv, environment = process) {
|
||||
options.skipBuild = true
|
||||
} else if (argument === '--dry-run') {
|
||||
options.dryRun = true
|
||||
} else if (argument === '--unsigned') {
|
||||
options.unsigned = true
|
||||
} else if (argument === '--help' || argument === '-h') {
|
||||
options.help = true
|
||||
} else {
|
||||
@@ -188,6 +226,9 @@ function parseArguments(argv, environment = process) {
|
||||
if (!supportedArchitectures.has(options.arch)) {
|
||||
throw new Error(`不支持的架构:${options.arch}`)
|
||||
}
|
||||
if (options.unsigned && options.platform !== 'macos') {
|
||||
throw new Error('--unsigned 仅支持 macOS 发布包')
|
||||
}
|
||||
const definition = platformDefinitions[options.platform]
|
||||
const requestedFormats =
|
||||
options.formats.length > 0
|
||||
@@ -318,7 +359,9 @@ function buildElectronBuilderArguments(options, outputDirectory) {
|
||||
)
|
||||
)]
|
||||
const builderArguments = [
|
||||
join(root, 'node_modules', 'electron-builder', 'cli.js'),
|
||||
join(root, 'build', 'run-electron-builder.cjs'),
|
||||
'--config',
|
||||
releaseBuilderConfig,
|
||||
definition.builderFlag,
|
||||
...builderFormats,
|
||||
`--${options.arch}`,
|
||||
@@ -334,62 +377,43 @@ function buildElectronBuilderArguments(options, outputDirectory) {
|
||||
`--config.nsis.artifactName=${productName}-\${version}-windows-\${arch}-setup.\${ext}`
|
||||
)
|
||||
}
|
||||
if (options.platform === 'macos' && options.unsigned) {
|
||||
builderArguments.push('--config.mac.notarize=false')
|
||||
}
|
||||
return builderArguments
|
||||
}
|
||||
|
||||
function detectBinaryArchitecture(buffer) {
|
||||
if (
|
||||
buffer.length >= 64 &&
|
||||
buffer[0] === 0x4d &&
|
||||
buffer[1] === 0x5a
|
||||
) {
|
||||
const peOffset = buffer.readUInt32LE(0x3c)
|
||||
if (
|
||||
peOffset + 6 <= buffer.length &&
|
||||
buffer.toString('ascii', peOffset, peOffset + 4) === 'PE\0\0'
|
||||
) {
|
||||
const machine = buffer.readUInt16LE(peOffset + 4)
|
||||
if (machine === 0x8664) {
|
||||
return 'x64'
|
||||
}
|
||||
if (machine === 0xaa64) {
|
||||
return 'arm64'
|
||||
}
|
||||
}
|
||||
function electronBuilderEnvironment(
|
||||
options,
|
||||
environment = process.env
|
||||
) {
|
||||
const builderEnvironment = {
|
||||
...environment,
|
||||
CSC_IDENTITY_AUTO_DISCOVERY:
|
||||
environment.CSC_IDENTITY_AUTO_DISCOVERY ?? 'false'
|
||||
}
|
||||
if (
|
||||
buffer.length >= 20 &&
|
||||
buffer[0] === 0x7f &&
|
||||
buffer.toString('ascii', 1, 4) === 'ELF'
|
||||
) {
|
||||
const machine =
|
||||
buffer[5] === 2
|
||||
? buffer.readUInt16BE(18)
|
||||
: buffer.readUInt16LE(18)
|
||||
if (machine === 62) {
|
||||
return 'x64'
|
||||
}
|
||||
if (machine === 183) {
|
||||
return 'arm64'
|
||||
}
|
||||
if (options.platform !== 'macos' || !options.unsigned) {
|
||||
return builderEnvironment
|
||||
}
|
||||
if (buffer.length >= 8) {
|
||||
const littleMagic = buffer.readUInt32LE(0)
|
||||
const bigMagic = buffer.readUInt32BE(0)
|
||||
const cpuType =
|
||||
littleMagic === 0xfeedfacf
|
||||
? buffer.readUInt32LE(4)
|
||||
: bigMagic === 0xfeedfacf
|
||||
? buffer.readUInt32BE(4)
|
||||
: undefined
|
||||
if (cpuType === 0x01000007) {
|
||||
return 'x64'
|
||||
}
|
||||
if (cpuType === 0x0100000c) {
|
||||
return 'arm64'
|
||||
}
|
||||
for (const name of [
|
||||
'CSC_LINK',
|
||||
'CSC_KEY_PASSWORD',
|
||||
'CSC_NAME',
|
||||
'CSC_INSTALLER_LINK',
|
||||
'CSC_INSTALLER_KEY_PASSWORD',
|
||||
'APPLE_API_KEY',
|
||||
'APPLE_API_KEY_ID',
|
||||
'APPLE_API_ISSUER',
|
||||
'APPLE_ID',
|
||||
'APPLE_APP_SPECIFIC_PASSWORD',
|
||||
'APPLE_TEAM_ID',
|
||||
'APPLE_KEYCHAIN',
|
||||
'APPLE_KEYCHAIN_PROFILE'
|
||||
]) {
|
||||
delete builderEnvironment[name]
|
||||
}
|
||||
return undefined
|
||||
builderEnvironment.CSC_IDENTITY_AUTO_DISCOVERY = 'false'
|
||||
return builderEnvironment
|
||||
}
|
||||
|
||||
function binaryArchitecture(filePath) {
|
||||
@@ -443,6 +467,20 @@ function assertFile(filePath, description) {
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonFile(filePath, description) {
|
||||
assertFile(filePath, description)
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${description}无效:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAsarEntry(filePath) {
|
||||
return filePath.split('/').join(sep)
|
||||
}
|
||||
@@ -490,34 +528,60 @@ function targetHarnessPaths(options) {
|
||||
macos: `darwin_${options.arch}/koffi.node`,
|
||||
linux: `linux_${options.arch}/koffi.node`
|
||||
}[options.platform]
|
||||
const canvasTarget = {
|
||||
windows: `win32-${options.arch}-msvc`,
|
||||
macos: `darwin-${options.arch}`,
|
||||
linux: `linux-${options.arch}-gnu`
|
||||
}[options.platform]
|
||||
return {
|
||||
canvasPackage: `@napi-rs/canvas-${canvasTarget}`,
|
||||
canvasBinary: `skia.${canvasTarget}.node`,
|
||||
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
|
||||
`prebuilds/${platformName}-${options.arch}/` +
|
||||
(options.platform === 'windows'
|
||||
? 'conpty.node'
|
||||
: 'pty.node'),
|
||||
nodePtyDirectory: `${platformName}-${options.arch}`
|
||||
}
|
||||
}
|
||||
|
||||
function targetRuntimePackageNames(options) {
|
||||
const target = targetHarnessPaths(options)
|
||||
return [
|
||||
target.koffiPackage,
|
||||
...(target.landlockPackage ? [target.landlockPackage] : [])
|
||||
]
|
||||
return [target.koffiPackage, target.canvasPackage]
|
||||
}
|
||||
|
||||
function lockedTargetRuntimePackage(packageName) {
|
||||
const expectedVersion =
|
||||
packageJson.optionalDependencies?.[packageName]
|
||||
function lockedTargetRuntimePackage(
|
||||
packageName,
|
||||
packageMetadata = packageJson,
|
||||
lockMetadata = packageLock
|
||||
) {
|
||||
let expectedVersion =
|
||||
packageMetadata.optionalDependencies?.[packageName]
|
||||
if (
|
||||
typeof expectedVersion !== 'string' &&
|
||||
packageName.startsWith('@napi-rs/canvas-')
|
||||
) {
|
||||
const canvasPackageName = '@napi-rs/canvas'
|
||||
const canvasVersion =
|
||||
packageMetadata.dependencies?.[canvasPackageName]
|
||||
const canvasLockEntry =
|
||||
lockMetadata.packages?.[`node_modules/${canvasPackageName}`]
|
||||
if (
|
||||
typeof canvasVersion !== 'string' ||
|
||||
canvasLockEntry?.version !== canvasVersion ||
|
||||
canvasLockEntry.optionalDependencies?.[packageName] !==
|
||||
canvasVersion
|
||||
) {
|
||||
throw new Error(
|
||||
`目标 Runtime 依赖未完整锁定:${packageName}`
|
||||
)
|
||||
}
|
||||
expectedVersion = canvasVersion
|
||||
}
|
||||
const lockEntry =
|
||||
packageLock.packages?.[`node_modules/${packageName}`]
|
||||
lockMetadata.packages?.[`node_modules/${packageName}`]
|
||||
if (
|
||||
typeof expectedVersion !== 'string' ||
|
||||
lockEntry?.version !== expectedVersion ||
|
||||
@@ -531,7 +595,6 @@ function lockedTargetRuntimePackage(packageName) {
|
||||
return {
|
||||
name: packageName,
|
||||
version: expectedVersion,
|
||||
resolved: lockEntry.resolved,
|
||||
integrity: lockEntry.integrity
|
||||
}
|
||||
}
|
||||
@@ -579,9 +642,13 @@ function verifyArchiveIntegrity(filePath, expectedIntegrity) {
|
||||
}
|
||||
}
|
||||
|
||||
function installedPackageMatches(packageName, expectedVersion) {
|
||||
function installedPackageMatches(
|
||||
packageName,
|
||||
expectedVersion,
|
||||
runtimeRoot = root
|
||||
) {
|
||||
const manifestPath = join(
|
||||
root,
|
||||
runtimeRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'package.json'
|
||||
@@ -601,14 +668,29 @@ function installedPackageMatches(packageName, expectedVersion) {
|
||||
return true
|
||||
}
|
||||
|
||||
async function stageTargetRuntimeDependencies(options) {
|
||||
async function stageTargetRuntimeDependencies(
|
||||
options,
|
||||
dependencies = {}
|
||||
) {
|
||||
const runtimeRoot = dependencies.root ?? root
|
||||
const runtimePackageJson =
|
||||
dependencies.packageJson ?? packageJson
|
||||
const runtimePackageLock =
|
||||
dependencies.packageLock ?? packageLock
|
||||
const missing = targetRuntimePackageNames(options)
|
||||
.map(lockedTargetRuntimePackage)
|
||||
.map((packageName) =>
|
||||
lockedTargetRuntimePackage(
|
||||
packageName,
|
||||
runtimePackageJson,
|
||||
runtimePackageLock
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
(dependency) =>
|
||||
!installedPackageMatches(
|
||||
dependency.name,
|
||||
dependency.version
|
||||
dependency.version,
|
||||
runtimeRoot
|
||||
)
|
||||
)
|
||||
if (missing.length === 0) {
|
||||
@@ -627,14 +709,27 @@ async function stageTargetRuntimeDependencies(options) {
|
||||
}
|
||||
|
||||
try {
|
||||
const npm = npmInvocation()
|
||||
const npm = dependencies.npmInvocation?.() ?? npmInvocation()
|
||||
const captureCommand =
|
||||
dependencies.runCapture ?? runCapture
|
||||
const extractArchive =
|
||||
dependencies.extractArchive ??
|
||||
((archivePath, destination) =>
|
||||
run('tar', [
|
||||
'-xzf',
|
||||
archivePath,
|
||||
'-C',
|
||||
destination,
|
||||
'--strip-components',
|
||||
'1'
|
||||
]))
|
||||
for (const [index, dependency] of missing.entries()) {
|
||||
const archiveDirectory = join(
|
||||
stagingRoot,
|
||||
`package-${index}`
|
||||
)
|
||||
mkdirSync(archiveDirectory, { recursive: true })
|
||||
const output = await runCapture(npm.command, [
|
||||
const output = await captureCommand(npm.command, [
|
||||
...npm.prefixArgs,
|
||||
'pack',
|
||||
`${dependency.name}@${dependency.version}`,
|
||||
@@ -654,7 +749,7 @@ async function stageTargetRuntimeDependencies(options) {
|
||||
verifyArchiveIntegrity(archivePath, dependency.integrity)
|
||||
|
||||
const destination = join(
|
||||
root,
|
||||
runtimeRoot,
|
||||
'node_modules',
|
||||
...dependency.name.split('/')
|
||||
)
|
||||
@@ -665,18 +760,12 @@ async function stageTargetRuntimeDependencies(options) {
|
||||
}
|
||||
mkdirSync(destination, { recursive: true })
|
||||
stagedDirectories.push(destination)
|
||||
await run('tar', [
|
||||
'-xzf',
|
||||
archivePath,
|
||||
'-C',
|
||||
destination,
|
||||
'--strip-components',
|
||||
'1'
|
||||
])
|
||||
await extractArchive(archivePath, destination)
|
||||
if (
|
||||
!installedPackageMatches(
|
||||
dependency.name,
|
||||
dependency.version
|
||||
dependency.version,
|
||||
runtimeRoot
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -742,6 +831,10 @@ function verifyHarnessPackage(
|
||||
join(unpackedRoot, ...harnessHostEntry.split('/')),
|
||||
'DeepSeek Harness 可执行 Host'
|
||||
)
|
||||
verifyHarnessBundleImports(
|
||||
readFileSync(join(unpackedRoot, ...harnessHostEntry.split('/')), 'utf8'),
|
||||
harnessHostEntry
|
||||
)
|
||||
const harnessLlmChunk = [...entries]
|
||||
.map((entry) => entry.slice(1).split(sep).join('/'))
|
||||
.find((entry) =>
|
||||
@@ -790,6 +883,10 @@ function verifyHarnessPackage(
|
||||
join(unpackedRoot, ...chunkPath.split('/')),
|
||||
'DeepSeek Harness 可执行 module chunk'
|
||||
)
|
||||
verifyHarnessBundleImports(
|
||||
readFileSync(join(unpackedRoot, ...chunkPath.split('/')), 'utf8'),
|
||||
chunkPath
|
||||
)
|
||||
}
|
||||
for (const [packageName, expectedVersion] of Object.entries(
|
||||
harnessPackageVersions
|
||||
@@ -804,6 +901,49 @@ function verifyHarnessPackage(
|
||||
)
|
||||
}
|
||||
}
|
||||
const npmRoot = join(resources, 'runtimes', 'npm')
|
||||
const npmManifest = readJsonFile(
|
||||
join(npmRoot, 'package.json'),
|
||||
'DSH 插件安装 npm 元数据'
|
||||
)
|
||||
if (npmManifest.version !== packageJson.dependencies?.npm) {
|
||||
throw new Error(
|
||||
`DSH 插件安装 npm 版本错误:期望 ${String(packageJson.dependencies?.npm)},实际 ${String(npmManifest.version)}`
|
||||
)
|
||||
}
|
||||
assertFile(
|
||||
join(npmRoot, 'bin', 'npm-cli.js'),
|
||||
'DSH 插件安装 npm CLI'
|
||||
)
|
||||
assertFile(
|
||||
join(npmRoot, 'bin', 'npx-cli.js'),
|
||||
'DSH 插件安装 npx CLI'
|
||||
)
|
||||
if (
|
||||
!Array.isArray(npmManifest.bundleDependencies) ||
|
||||
npmManifest.bundleDependencies.length === 0
|
||||
) {
|
||||
throw new Error('DSH 插件安装 npm 依赖清单无效')
|
||||
}
|
||||
for (const packageName of npmManifest.bundleDependencies) {
|
||||
if (
|
||||
typeof packageName !== 'string' ||
|
||||
!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u.test(
|
||||
packageName
|
||||
)
|
||||
) {
|
||||
throw new Error('DSH 插件安装 npm 依赖清单无效')
|
||||
}
|
||||
assertFile(
|
||||
join(
|
||||
npmRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'package.json'
|
||||
),
|
||||
`DSH 插件安装 npm 依赖 ${packageName}`
|
||||
)
|
||||
}
|
||||
const targetKoffiManifest = readJson(
|
||||
`node_modules/${target.koffiPackage}/package.json`,
|
||||
`${target.koffiPackage} 元数据`
|
||||
@@ -813,6 +953,18 @@ function verifyHarnessPackage(
|
||||
`${target.koffiPackage} 版本错误:期望 ${koffiVersion},实际 ${String(targetKoffiManifest.version)}`
|
||||
)
|
||||
}
|
||||
const targetCanvasManifest = readJson(
|
||||
`node_modules/${target.canvasPackage}/package.json`,
|
||||
`${target.canvasPackage} 元数据`
|
||||
)
|
||||
if (
|
||||
targetCanvasManifest.version !==
|
||||
harnessPackageVersions['@napi-rs/canvas']
|
||||
) {
|
||||
throw new Error(
|
||||
`${target.canvasPackage} 版本错误:期望 ${harnessPackageVersions['@napi-rs/canvas']},实际 ${String(targetCanvasManifest.version)}`
|
||||
)
|
||||
}
|
||||
|
||||
const ptyBinary = join(
|
||||
unpackedRoot,
|
||||
@@ -826,6 +978,12 @@ function verifyHarnessPackage(
|
||||
...target.koffiPackage.split('/'),
|
||||
...target.koffiBinary.split('/')
|
||||
)
|
||||
const canvasBinary = join(
|
||||
unpackedRoot,
|
||||
'node_modules',
|
||||
...target.canvasPackage.split('/'),
|
||||
target.canvasBinary
|
||||
)
|
||||
assertBinaryArchitecture(
|
||||
ptyBinary,
|
||||
options.arch,
|
||||
@@ -845,9 +1003,17 @@ function verifyHarnessPackage(
|
||||
'DeepSeek Harness Koffi 元数据',
|
||||
statAsarFile
|
||||
)
|
||||
const canvasMetadata = asarEntryMetadata(
|
||||
asarPath,
|
||||
entries,
|
||||
`node_modules/${target.canvasPackage}/${target.canvasBinary}`,
|
||||
'DeepSeek Harness Canvas 元数据',
|
||||
statAsarFile
|
||||
)
|
||||
for (const [metadata, description] of [
|
||||
[nodePtyMetadata, 'DeepSeek Harness node-pty'],
|
||||
[koffiMetadata, 'DeepSeek Harness Koffi']
|
||||
[koffiMetadata, 'DeepSeek Harness Koffi'],
|
||||
[canvasMetadata, 'DeepSeek Harness Canvas']
|
||||
]) {
|
||||
if (!('unpacked' in metadata) || !metadata.unpacked) {
|
||||
throw new Error(`${description}未从 ASAR 解包`)
|
||||
@@ -858,8 +1024,13 @@ function verifyHarnessPackage(
|
||||
options.arch,
|
||||
'DeepSeek Harness Koffi'
|
||||
)
|
||||
assertBinaryArchitecture(
|
||||
canvasBinary,
|
||||
options.arch,
|
||||
'DeepSeek Harness Canvas'
|
||||
)
|
||||
|
||||
if (options.platform === 'darwin') {
|
||||
if (options.platform === 'macos') {
|
||||
const helper = join(
|
||||
unpackedRoot,
|
||||
'node_modules',
|
||||
@@ -876,74 +1047,6 @@ function verifyHarnessPackage(
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -972,18 +1075,70 @@ function verifyUnpackedOutput(directory, options) {
|
||||
'opencode',
|
||||
definition.runtimeExecutable
|
||||
)
|
||||
const ripgrepExecutable = join(
|
||||
resources,
|
||||
'runtimes',
|
||||
'opencode',
|
||||
definition.ripgrepExecutable
|
||||
)
|
||||
assertFile(applicationExecutable, '应用主程序')
|
||||
assertFile(join(resources, 'app.asar'), '应用 ASAR')
|
||||
assertFile(join(resources, 'release-notes.json'), '版本更新说明')
|
||||
assertFile(
|
||||
join(
|
||||
resources,
|
||||
'tool-environment',
|
||||
'managed-python-artifacts.json'
|
||||
),
|
||||
'托管 Python 产物目录'
|
||||
)
|
||||
assertFile(
|
||||
join(resources, 'agent-release-keys.json'),
|
||||
'Agent 受信任签名密钥'
|
||||
)
|
||||
assertFile(
|
||||
join(resources, 'agent-runtime-lock.json'),
|
||||
'Agent Runtime 锁定清单'
|
||||
)
|
||||
assertFile(
|
||||
join(resources, 'remote-runtime-lock.json'),
|
||||
'Remote Runtime 锁定清单'
|
||||
)
|
||||
assertFile(runtimeExecutable, 'OpenCode Runtime')
|
||||
assertFile(ripgrepExecutable, 'ripgrep Runtime')
|
||||
verifyOpenCodeConfig(
|
||||
resources,
|
||||
join(root, '.runtime-resources', 'opencode-config')
|
||||
)
|
||||
assertFile(
|
||||
join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
|
||||
'Continue Runtime'
|
||||
)
|
||||
assertFile(
|
||||
join(
|
||||
resources,
|
||||
'runtimes',
|
||||
'npm',
|
||||
'bin',
|
||||
'npm-cli.js'
|
||||
),
|
||||
'DSH 插件安装 npm Runtime'
|
||||
)
|
||||
assertFile(
|
||||
join(
|
||||
resources,
|
||||
'runtimes',
|
||||
'npm',
|
||||
'bin',
|
||||
'npx-cli.js'
|
||||
),
|
||||
'DSH 插件安装 npx Runtime'
|
||||
)
|
||||
verifyHarnessPackage(resources, options)
|
||||
for (const [filePath, label] of [
|
||||
[applicationExecutable, '应用主程序'],
|
||||
[runtimeExecutable, 'OpenCode Runtime']
|
||||
[runtimeExecutable, 'OpenCode Runtime'],
|
||||
[ripgrepExecutable, 'ripgrep Runtime']
|
||||
]) {
|
||||
const actualArchitecture = binaryArchitecture(filePath)
|
||||
if (actualArchitecture !== options.arch) {
|
||||
@@ -1179,98 +1334,42 @@ async function createPortableZip(
|
||||
}
|
||||
|
||||
function readZipEntryNames(filePath) {
|
||||
const fileSize = statSync(filePath).size
|
||||
if (fileSize < 22) {
|
||||
throw new Error('Portable ZIP 缺少中央目录')
|
||||
}
|
||||
const endChunkSize = Math.min(fileSize, 65_557)
|
||||
const endChunkStart = fileSize - endChunkSize
|
||||
const endChunk = readChunk(
|
||||
filePath,
|
||||
endChunkSize,
|
||||
endChunkStart
|
||||
)
|
||||
let endOffset = -1
|
||||
for (let index = endChunk.length - 22; index >= 0; index -= 1) {
|
||||
if (
|
||||
endChunk.readUInt32LE(index) === 0x06054b50 &&
|
||||
index + 22 + endChunk.readUInt16LE(index + 20) ===
|
||||
endChunk.length
|
||||
) {
|
||||
endOffset = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if (endOffset < 0) {
|
||||
throw new Error('Portable ZIP 缺少中央目录')
|
||||
}
|
||||
const diskNumber = endChunk.readUInt16LE(endOffset + 4)
|
||||
const centralDisk = endChunk.readUInt16LE(endOffset + 6)
|
||||
const diskEntryCount = endChunk.readUInt16LE(endOffset + 8)
|
||||
const entryCount = endChunk.readUInt16LE(endOffset + 10)
|
||||
const centralSize = endChunk.readUInt32LE(endOffset + 12)
|
||||
const centralOffset = endChunk.readUInt32LE(endOffset + 16)
|
||||
const absoluteEndOffset = endChunkStart + endOffset
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
centralDisk !== 0 ||
|
||||
diskEntryCount !== entryCount ||
|
||||
entryCount === 0xffff ||
|
||||
centralSize === 0xffffffff ||
|
||||
centralOffset === 0xffffffff ||
|
||||
entryCount < portableRequiredFiles.length + 1 ||
|
||||
entryCount > maxPortableZipEntries ||
|
||||
centralSize < 46 ||
|
||||
centralSize > maxPortableCentralDirectoryBytes ||
|
||||
centralOffset + centralSize !== absoluteEndOffset
|
||||
) {
|
||||
throw new Error('Portable ZIP 中央目录无效')
|
||||
}
|
||||
const centralDirectory = readChunk(
|
||||
filePath,
|
||||
centralSize,
|
||||
centralOffset
|
||||
)
|
||||
const names = []
|
||||
let offset = 0
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (
|
||||
offset + 46 > centralDirectory.length ||
|
||||
centralDirectory.readUInt32LE(offset) !== 0x02014b50
|
||||
) {
|
||||
throw new Error('Portable ZIP 中央目录条目无效')
|
||||
}
|
||||
const nameLength = centralDirectory.readUInt16LE(offset + 28)
|
||||
const extraLength = centralDirectory.readUInt16LE(offset + 30)
|
||||
const commentLength = centralDirectory.readUInt16LE(offset + 32)
|
||||
const entryLength = 46 + nameLength + extraLength + commentLength
|
||||
if (offset + entryLength > centralDirectory.length) {
|
||||
throw new Error('Portable ZIP 中央目录条目越界')
|
||||
}
|
||||
const name = centralDirectory
|
||||
.subarray(offset + 46, offset + 46 + nameLength)
|
||||
.toString(
|
||||
centralDirectory.readUInt16LE(offset + 8) & 0x0800
|
||||
? 'utf8'
|
||||
: 'latin1'
|
||||
const handle = openSync(filePath, 'r')
|
||||
try {
|
||||
let parsed
|
||||
try {
|
||||
parsed = readZipCentralDirectory(
|
||||
handle,
|
||||
fstatSync(handle).size,
|
||||
{
|
||||
label: 'Portable ZIP',
|
||||
minimumEntries: portableRequiredFiles.length + 1,
|
||||
maximumEntries: maxPortableZipEntries,
|
||||
maximumCentralDirectoryBytes:
|
||||
maxPortableCentralDirectoryBytes
|
||||
}
|
||||
)
|
||||
.replaceAll('\\', '/')
|
||||
if (
|
||||
!name ||
|
||||
name.startsWith('/') ||
|
||||
/^[a-z]:\//iu.test(name) ||
|
||||
name.includes('\0') ||
|
||||
name.split('/').some((part) => part === '..')
|
||||
) {
|
||||
throw new Error(`Portable ZIP 包含不安全路径:${name}`)
|
||||
} catch (error) {
|
||||
throw new Error('Portable ZIP 中央目录无效', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
names.push(name)
|
||||
offset += entryLength
|
||||
return parsed.entries.map((entry) => {
|
||||
const name = entry.name.replaceAll('\\', '/')
|
||||
if (
|
||||
!name ||
|
||||
name.startsWith('/') ||
|
||||
/^[a-z]:\//iu.test(name) ||
|
||||
name.includes('\0') ||
|
||||
name.split('/').some((part) => part === '..')
|
||||
) {
|
||||
throw new Error(`Portable ZIP 包含不安全路径:${name}`)
|
||||
}
|
||||
return name
|
||||
})
|
||||
} finally {
|
||||
closeSync(handle)
|
||||
}
|
||||
if (offset !== centralDirectory.length) {
|
||||
throw new Error('Portable ZIP 中央目录数量不一致')
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function verifyPortableZip(filePath) {
|
||||
@@ -1361,6 +1460,12 @@ function verifyArtifactSignature(filePath, format, arch) {
|
||||
) {
|
||||
throw new Error('deb 产物不是有效的 ar 归档')
|
||||
}
|
||||
if (
|
||||
format === 'rpm' &&
|
||||
readChunk(filePath, 4).toString('hex') !== 'edabeedb'
|
||||
) {
|
||||
throw new Error('rpm 产物缺少 RPM lead magic')
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManifest(directory, options) {
|
||||
@@ -1489,11 +1594,12 @@ function printHelp() {
|
||||
--format <列表> 覆盖默认格式,逗号分隔
|
||||
--skip-build 复用已有 out 生产构建
|
||||
--dry-run 仅显示目标与 electron-builder 参数
|
||||
--unsigned 仅用于 macOS,明确禁用代码签名与公证
|
||||
|
||||
默认格式:
|
||||
windows: nsis, portable (ZIP)
|
||||
macos: dmg, zip
|
||||
linux: AppImage, deb`)
|
||||
macos: dmg
|
||||
linux: AppImage, deb, rpm`)
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
@@ -1527,6 +1633,11 @@ async function main(argv = process.argv.slice(2)) {
|
||||
`${options.platform} 包必须在对应系统构建,当前系统为 ${hostPlatform ?? process.platform}`
|
||||
)
|
||||
}
|
||||
if (options.unsigned) {
|
||||
console.warn(
|
||||
'警告:正在生成未签名、未公证的 macOS 包,Gatekeeper 可能阻止用户首次打开。'
|
||||
)
|
||||
}
|
||||
|
||||
rmSync(stagingDirectory, { recursive: true, force: true })
|
||||
let cleanupTargetDependencies = () => undefined
|
||||
@@ -1543,16 +1654,22 @@ async function main(argv = process.argv.slice(2)) {
|
||||
await run(
|
||||
process.execPath,
|
||||
builderArguments,
|
||||
{
|
||||
...process.env,
|
||||
CSC_IDENTITY_AUTO_DISCOVERY:
|
||||
process.env.CSC_IDENTITY_AUTO_DISCOVERY ?? 'false'
|
||||
}
|
||||
electronBuilderEnvironment(options)
|
||||
)
|
||||
const unpackedDirectory = verifyUnpackedOutput(
|
||||
stagingDirectory,
|
||||
options
|
||||
)
|
||||
if (
|
||||
process.env.GITHUB_ACTIONS === 'true' &&
|
||||
options.platform === 'windows' &&
|
||||
options.arch === 'x64'
|
||||
) {
|
||||
await run(process.execPath, [
|
||||
join(root, 'build', 'run-packaged-deepseek-harness-smoke.cjs'),
|
||||
unpackedDirectory
|
||||
])
|
||||
}
|
||||
if (
|
||||
options.platform === 'windows' &&
|
||||
options.formats.includes('portable')
|
||||
@@ -1586,13 +1703,16 @@ module.exports = {
|
||||
buildElectronBuilderArguments,
|
||||
createPortableZip,
|
||||
detectBinaryArchitecture,
|
||||
electronBuilderEnvironment,
|
||||
normalizePlatform,
|
||||
parseArguments,
|
||||
parsePackedPackageMetadata,
|
||||
platformDefinitions,
|
||||
lockedTargetRuntimePackage,
|
||||
replaceOutput,
|
||||
stageTargetRuntimeDependencies,
|
||||
targetRuntimePackageNames,
|
||||
targetHarnessPaths,
|
||||
verifyHarnessPackage,
|
||||
verifyArchiveIntegrity,
|
||||
verifyUnpackedOutput,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { resolve } = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
|
||||
const maximumInstallerBytes = 256 * 1024
|
||||
|
||||
function verifyControlPlanePackageInstaller(
|
||||
filePath = resolve(
|
||||
'out',
|
||||
'main',
|
||||
'remote-package-installer.mjs'
|
||||
)
|
||||
) {
|
||||
const bytes = readFileSync(filePath)
|
||||
if (
|
||||
bytes.byteLength === 0 ||
|
||||
bytes.byteLength > maximumInstallerBytes
|
||||
) {
|
||||
throw new Error(
|
||||
'Control-plane package installer exceeds its transfer limit'
|
||||
)
|
||||
}
|
||||
const source = new TextDecoder('utf-8', {
|
||||
fatal: true,
|
||||
ignoreBOM: true
|
||||
}).decode(bytes)
|
||||
if (
|
||||
/(?:^|\n)\s*(?:import|export)\b[^\r\n]*\bfrom\s*['"]\.{1,2}\//u.test(
|
||||
source
|
||||
) ||
|
||||
/(?:^|\n)\s*import\s*['"]\.{1,2}\//u.test(source) ||
|
||||
/\bimport\s*\(\s*['"]\.{1,2}\//u.test(source)
|
||||
) {
|
||||
throw new Error(
|
||||
'Control-plane package installer is not a standalone bundle'
|
||||
)
|
||||
}
|
||||
const syntax = spawnSync(
|
||||
process.execPath,
|
||||
['--check', filePath],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (syntax.status !== 0) {
|
||||
throw new Error(
|
||||
`Control-plane package installer is not executable: ${
|
||||
syntax.stderr.trim() || syntax.stdout.trim()
|
||||
}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
size: bytes.byteLength,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const result = verifyControlPlanePackageInstaller(
|
||||
process.argv[2]
|
||||
? resolve(process.argv[2])
|
||||
: undefined
|
||||
)
|
||||
process.stdout.write(
|
||||
`Control-plane package installer: ${result.size} bytes, sha256 ${result.sha256}\n`
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
maximumInstallerBytes,
|
||||
verifyControlPlanePackageInstaller
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
const {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { dirname, resolve } = require('node:path')
|
||||
const {
|
||||
expectedFormatForFile,
|
||||
targetDefinitions
|
||||
} = require('./aggregate-release.cjs')
|
||||
|
||||
const supportedTargets = new Map(
|
||||
targetDefinitions.map((target) => [
|
||||
`${target.platform}-${target.arch}`,
|
||||
target
|
||||
])
|
||||
)
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {}
|
||||
const optionNames = new Map([
|
||||
['--manifest', 'manifest'],
|
||||
['--base-url', 'baseUrl'],
|
||||
['--output', 'output']
|
||||
])
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
const optionName = optionNames.get(argument)
|
||||
if (optionName) {
|
||||
const value = argv[index + 1]
|
||||
if (!value) {
|
||||
throw new Error(`${argument} 缺少值`)
|
||||
}
|
||||
options[optionName] = value
|
||||
index += 1
|
||||
} else {
|
||||
throw new Error(`未知参数:${argument}`)
|
||||
}
|
||||
}
|
||||
if (!options.manifest || !options.baseUrl || !options.output) {
|
||||
throw new Error('必须指定 --manifest、--base-url 和 --output')
|
||||
}
|
||||
return {
|
||||
manifest: resolve(options.manifest),
|
||||
baseUrl: options.baseUrl,
|
||||
output: resolve(options.output)
|
||||
}
|
||||
}
|
||||
|
||||
function validateBaseUrl(value) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
throw new Error(`OSS 基础地址无效:${value}`)
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
throw new Error('OSS 基础地址必须是无凭据、查询参数和片段的 HTTPS 地址')
|
||||
}
|
||||
if (!url.pathname.endsWith('/')) {
|
||||
url.pathname += '/'
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function assertFile(file, targetKey) {
|
||||
if (
|
||||
!file ||
|
||||
typeof file.name !== 'string' ||
|
||||
!/^GoodBuddy-[A-Za-z0-9._-]+$/u.test(file.name) ||
|
||||
!Number.isSafeInteger(file.size) ||
|
||||
file.size < 1 ||
|
||||
typeof file.sha256 !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/u.test(file.sha256)
|
||||
) {
|
||||
throw new Error(`发布文件元数据无效:${targetKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
function createSiteRelease(manifest, baseUrlValue) {
|
||||
if (
|
||||
!manifest ||
|
||||
manifest.formatVersion !== 1 ||
|
||||
manifest.productName !== 'GoodBuddy' ||
|
||||
typeof manifest.version !== 'string' ||
|
||||
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(manifest.version) ||
|
||||
!Array.isArray(manifest.targets)
|
||||
) {
|
||||
throw new Error('聚合发布 manifest 元数据无效')
|
||||
}
|
||||
const baseUrl = validateBaseUrl(baseUrlValue)
|
||||
const seenTargets = new Set()
|
||||
const targets = {}
|
||||
for (const target of manifest.targets) {
|
||||
const key = `${target?.platform}-${target?.arch}`
|
||||
const expectedTarget = supportedTargets.get(key)
|
||||
const expectedFormats = expectedTarget?.formats
|
||||
if (
|
||||
!expectedFormats ||
|
||||
seenTargets.has(key) ||
|
||||
!Array.isArray(target.formats) ||
|
||||
!Array.isArray(target.files) ||
|
||||
target.formats.length !== expectedFormats.length ||
|
||||
!expectedFormats.every(
|
||||
(format, index) => target.formats[index] === format
|
||||
) ||
|
||||
target.files.length !== expectedFormats.length
|
||||
) {
|
||||
throw new Error(`发布目标元数据无效:${key}`)
|
||||
}
|
||||
const files = {}
|
||||
for (const file of target.files) {
|
||||
assertFile(file, key)
|
||||
const format = expectedFormatForFile(
|
||||
file.name,
|
||||
expectedTarget
|
||||
)
|
||||
if (!format || !expectedFormats.includes(format) || files[format]) {
|
||||
throw new Error(`发布文件格式无效:${file.name}`)
|
||||
}
|
||||
files[format] = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
url: new URL(encodeURIComponent(file.name), baseUrl).href
|
||||
}
|
||||
}
|
||||
if (expectedFormats.some((format) => !files[format])) {
|
||||
throw new Error(`发布目标文件不完整:${key}`)
|
||||
}
|
||||
targets[key] = {
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
files
|
||||
}
|
||||
seenTargets.add(key)
|
||||
}
|
||||
if (seenTargets.size !== supportedTargets.size) {
|
||||
throw new Error(
|
||||
`发布目标数量错误:期望 ${supportedTargets.size},实际 ${seenTargets.size}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
formatVersion: 1,
|
||||
productName: manifest.productName,
|
||||
version: manifest.version,
|
||||
targets,
|
||||
checksumUrl: new URL('SHA256SUMS', baseUrl).href,
|
||||
fallbackUrl: 'https://github.com/mesalogo/goodbuddy/releases/latest'
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArguments(argv)
|
||||
const manifest = JSON.parse(readFileSync(options.manifest, 'utf8'))
|
||||
const siteRelease = createSiteRelease(manifest, options.baseUrl)
|
||||
mkdirSync(dirname(options.output), { recursive: true })
|
||||
writeFileSync(
|
||||
options.output,
|
||||
`${JSON.stringify(siteRelease, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
console.log(`官网发布索引已生成:${options.output}`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSiteRelease,
|
||||
parseArguments,
|
||||
validateBaseUrl
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,9 @@ const {
|
||||
const { app, utilityProcess } = require('electron/main')
|
||||
|
||||
const protocol = 'goodbuddy.deepseek-harness.control'
|
||||
const version = 1
|
||||
const controlVersion = 3
|
||||
const byteProtocol = 'goodbuddy.deepseek-harness.byte-stream'
|
||||
const byteProtocolVersion = 1
|
||||
const configuredHostPath =
|
||||
process.env.GOODBUDDY_HARNESS_SMOKE_HOST
|
||||
const hostPath = configuredHostPath
|
||||
@@ -132,12 +133,12 @@ async function run() {
|
||||
child.on('message', (message) => {
|
||||
if (
|
||||
message?.protocol === protocol &&
|
||||
message.version === version &&
|
||||
message.version === controlVersion &&
|
||||
message.type === 'ready'
|
||||
) {
|
||||
child.postMessage({
|
||||
protocol: byteProtocol,
|
||||
version,
|
||||
version: byteProtocolVersion,
|
||||
type: 'data',
|
||||
stream: 'stdin',
|
||||
seq: 0,
|
||||
@@ -147,7 +148,7 @@ async function run() {
|
||||
}
|
||||
if (
|
||||
message?.protocol === byteProtocol &&
|
||||
message.version === version &&
|
||||
message.version === byteProtocolVersion &&
|
||||
message.type === 'ack' &&
|
||||
message.stream === 'stdin' &&
|
||||
message.seq === 0
|
||||
@@ -158,7 +159,7 @@ async function run() {
|
||||
}
|
||||
if (
|
||||
message?.protocol === protocol &&
|
||||
message.version === version &&
|
||||
message.version === controlVersion &&
|
||||
message.type === 'fatal'
|
||||
) {
|
||||
finish('fatal', String(message.code))
|
||||
@@ -172,7 +173,7 @@ async function run() {
|
||||
})
|
||||
child.postMessage({
|
||||
protocol,
|
||||
version,
|
||||
version: controlVersion,
|
||||
type: 'start',
|
||||
config: {
|
||||
workspace,
|
||||
@@ -181,20 +182,13 @@ async function run() {
|
||||
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'
|
||||
},
|
||||
supportsImageInput: false,
|
||||
requestHeaders: {},
|
||||
harnessVersion: '0.1.2-rc.1',
|
||||
credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'],
|
||||
skillPackages: [],
|
||||
maxFrameBytes: 1024 * 1024
|
||||
extensionPackages: [],
|
||||
maxFrameBytes: 8 * 1024 * 1024
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('../package.json').build
|
||||
@@ -17,6 +17,25 @@ const darkSourcePath = join(
|
||||
'ChatGPT_qPkaIrLGsm.png'
|
||||
)
|
||||
const rendererAssetRoot = join(root, 'src', 'renderer', 'src', 'assets')
|
||||
const websiteAssetRoot = join(root, 'sites', 'assets')
|
||||
|
||||
const lightTile = {
|
||||
left: 40,
|
||||
top: 32,
|
||||
right: 704,
|
||||
bottom: 696,
|
||||
radius: 126,
|
||||
edgeColor: [255, 255, 255]
|
||||
}
|
||||
|
||||
const darkTile = {
|
||||
left: 20,
|
||||
top: 16,
|
||||
right: 684,
|
||||
bottom: 680,
|
||||
radius: 126,
|
||||
edgeColor: [15, 21, 31]
|
||||
}
|
||||
|
||||
function cropSquare(source, size) {
|
||||
const output = new PNG({ width: size, height: size })
|
||||
@@ -24,6 +43,102 @@ function cropSquare(source, size) {
|
||||
return output
|
||||
}
|
||||
|
||||
function scaleTile(tile, sourceSize, targetSize) {
|
||||
const scale = targetSize / sourceSize
|
||||
return {
|
||||
left: tile.left * scale,
|
||||
top: tile.top * scale,
|
||||
right: tile.right * scale,
|
||||
bottom: tile.bottom * scale,
|
||||
radius: tile.radius * scale,
|
||||
edgeColor: tile.edgeColor
|
||||
}
|
||||
}
|
||||
|
||||
function cropTile(source, tile) {
|
||||
const width = Math.round(tile.right - tile.left)
|
||||
const height = Math.round(tile.bottom - tile.top)
|
||||
if (width !== height || width <= 0) {
|
||||
throw new Error('图标卡片裁剪区域必须是有效正方形')
|
||||
}
|
||||
const output = new PNG({ width, height })
|
||||
PNG.bitblt(
|
||||
source,
|
||||
output,
|
||||
Math.round(tile.left),
|
||||
Math.round(tile.top),
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0
|
||||
)
|
||||
return {
|
||||
image: output,
|
||||
tile: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
radius: tile.radius,
|
||||
edgeColor: tile.edgeColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function roundedRectangleDistance(x, y, tile) {
|
||||
const centerX = (tile.left + tile.right) / 2
|
||||
const centerY = (tile.top + tile.bottom) / 2
|
||||
const halfWidth = (tile.right - tile.left) / 2
|
||||
const halfHeight = (tile.bottom - tile.top) / 2
|
||||
const offsetX = Math.abs(x - centerX) - (halfWidth - tile.radius)
|
||||
const offsetY = Math.abs(y - centerY) - (halfHeight - tile.radius)
|
||||
return (
|
||||
Math.hypot(Math.max(offsetX, 0), Math.max(offsetY, 0)) +
|
||||
Math.min(Math.max(offsetX, offsetY), 0) -
|
||||
tile.radius
|
||||
)
|
||||
}
|
||||
|
||||
function applyRoundedTransparency(image, tile) {
|
||||
for (let y = 0; y < image.height; y += 1) {
|
||||
for (let x = 0; x < image.width; x += 1) {
|
||||
const offset = pixelOffset(image, x, y)
|
||||
const distance = roundedRectangleDistance(x + 0.5, y + 0.5, tile)
|
||||
const coverage = Math.min(Math.max(0.5 - distance, 0), 1)
|
||||
if (coverage >= 1) {
|
||||
if (distance > -2 && image.data[offset + 3] < 255) {
|
||||
image.data[offset] = tile.edgeColor[0]
|
||||
image.data[offset + 1] = tile.edgeColor[1]
|
||||
image.data[offset + 2] = tile.edgeColor[2]
|
||||
}
|
||||
continue
|
||||
}
|
||||
image.data[offset] = tile.edgeColor[0]
|
||||
image.data[offset + 1] = tile.edgeColor[1]
|
||||
image.data[offset + 2] = tile.edgeColor[2]
|
||||
image.data[offset + 3] = Math.round(
|
||||
image.data[offset + 3] * coverage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createRoundedIcon(source, tile, size) {
|
||||
const cropped = cropTile(source, tile)
|
||||
applyRoundedTransparency(cropped.image, cropped.tile)
|
||||
const output = resize(
|
||||
cropped.image,
|
||||
size,
|
||||
size,
|
||||
'bicubicInterpolation'
|
||||
)
|
||||
applyRoundedTransparency(
|
||||
output,
|
||||
scaleTile(cropped.tile, cropped.image.width, size)
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
function pixelOffset(image, x, y) {
|
||||
return (y * image.width + x) * 4
|
||||
}
|
||||
@@ -119,6 +234,51 @@ function assertTaskbarIcon(image) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertTransparentCorners(image, label) {
|
||||
const corners = [
|
||||
pixelOffset(image, 0, 0),
|
||||
pixelOffset(image, image.width - 1, 0),
|
||||
pixelOffset(image, 0, image.height - 1),
|
||||
pixelOffset(image, image.width - 1, image.height - 1)
|
||||
]
|
||||
if (corners.some((offset) => image.data[offset + 3] !== 0)) {
|
||||
throw new Error(`${label} 的圆角外侧必须透明`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertDarkEdgeHasNoWhiteFringe(image) {
|
||||
const edgeWidth = Math.max(4, Math.round(image.width * 0.08))
|
||||
for (let y = 0; y < image.height; y += 1) {
|
||||
for (let x = 0; x < image.width; x += 1) {
|
||||
if (
|
||||
x >= edgeWidth &&
|
||||
x < image.width - edgeWidth &&
|
||||
y >= edgeWidth &&
|
||||
y < image.height - edgeWidth
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const offset = pixelOffset(image, x, y)
|
||||
const alpha = image.data[offset + 3]
|
||||
if (
|
||||
alpha > 0 &&
|
||||
alpha < 255 &&
|
||||
Math.max(
|
||||
image.data[offset],
|
||||
image.data[offset + 1],
|
||||
image.data[offset + 2]
|
||||
) > 96
|
||||
) {
|
||||
throw new Error(
|
||||
`暗色图标的透明边缘仍包含白色像素:${x},${y} ` +
|
||||
`rgba(${image.data[offset]},${image.data[offset + 1]},` +
|
||||
`${image.data[offset + 2]},${alpha})`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function repairDarkCursor(dark, light) {
|
||||
for (let y = 570; y <= 606; y += 1) {
|
||||
const backgroundOffset = pixelOffset(dark, 640, y)
|
||||
@@ -197,22 +357,31 @@ async function main() {
|
||||
repairDarkCursor(darkSquare, lightSquare)
|
||||
assertCursorRemoved(lightSquare, darkSquare)
|
||||
|
||||
const light = resize(lightSquare, 512, 512, 'bicubicInterpolation')
|
||||
const dark = resize(darkSquare, 512, 512, 'bicubicInterpolation')
|
||||
const taskbar = createTaskbarIcon(lightSquare)
|
||||
const light = createRoundedIcon(lightSquare, lightTile, 512)
|
||||
const dark = createRoundedIcon(darkSquare, darkTile, 512)
|
||||
const rendererLight = createRoundedIcon(lightSquare, lightTile, 128)
|
||||
const rendererDark = createRoundedIcon(darkSquare, darkTile, 128)
|
||||
assertTaskbarIcon(taskbar)
|
||||
assertTransparentCorners(light, '亮色图标')
|
||||
assertTransparentCorners(dark, '暗色图标')
|
||||
assertTransparentCorners(rendererLight, '亮色界面图标')
|
||||
assertTransparentCorners(rendererDark, '暗色界面图标')
|
||||
assertTransparentCorners(taskbar, '任务栏图标')
|
||||
assertDarkEdgeHasNoWhiteFringe(dark)
|
||||
assertDarkEdgeHasNoWhiteFringe(rendererDark)
|
||||
const tray = resize(taskbar, 32, 32, 'bicubicInterpolation')
|
||||
assertTransparentCorners(tray, '托盘图标')
|
||||
const lightPng = PNG.sync.write(light)
|
||||
const darkPng = PNG.sync.write(dark)
|
||||
const taskbarPng = PNG.sync.write(taskbar)
|
||||
const trayPng = PNG.sync.write(tray)
|
||||
const rendererLightPng = PNG.sync.write(
|
||||
resize(light, 128, 128, 'bicubicInterpolation')
|
||||
)
|
||||
const rendererDarkPng = PNG.sync.write(
|
||||
resize(dark, 128, 128, 'bicubicInterpolation')
|
||||
)
|
||||
await mkdir(rendererAssetRoot, { recursive: true })
|
||||
const rendererLightPng = PNG.sync.write(rendererLight)
|
||||
const rendererDarkPng = PNG.sync.write(rendererDark)
|
||||
await Promise.all([
|
||||
mkdir(rendererAssetRoot, { recursive: true }),
|
||||
mkdir(websiteAssetRoot, { recursive: true })
|
||||
])
|
||||
|
||||
const outputs = [
|
||||
[join(root, 'build', 'icon-light.png'), lightPng],
|
||||
@@ -221,7 +390,9 @@ async function main() {
|
||||
[join(root, 'build', 'icon-taskbar.png'), taskbarPng],
|
||||
[join(root, 'build', 'icon-tray.png'), trayPng],
|
||||
[join(rendererAssetRoot, 'goodbuddy-light.png'), rendererLightPng],
|
||||
[join(rendererAssetRoot, 'goodbuddy-dark.png'), rendererDarkPng]
|
||||
[join(rendererAssetRoot, 'goodbuddy-dark.png'), rendererDarkPng],
|
||||
[join(websiteAssetRoot, 'goodbuddy-light.png'), rendererLightPng],
|
||||
[join(websiteAssetRoot, 'goodbuddy-dark.png'), rendererDarkPng]
|
||||
]
|
||||
await Promise.all(outputs.map(([path, contents]) => writeFile(path, contents)))
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 287 KiB |
@@ -0,0 +1 @@
|
||||
results
|
||||
@@ -0,0 +1,54 @@
|
||||
FROM ubuntu:26.04
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG RUST_TOOLCHAIN=1.93.1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends \
|
||||
binutils-loongarch64-linux-gnu \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
dpkg-dev \
|
||||
fakeroot \
|
||||
file \
|
||||
g++-loongarch64-linux-gnu \
|
||||
gcc-loongarch64-linux-gnu \
|
||||
libc6-dev-loong64-cross \
|
||||
make \
|
||||
ninja-build \
|
||||
node-gyp \
|
||||
nodejs \
|
||||
npm \
|
||||
pkg-config \
|
||||
python3 \
|
||||
qemu-user \
|
||||
rustup \
|
||||
tar \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN rustup set profile minimal \
|
||||
&& rustup toolchain install "${RUST_TOOLCHAIN}" \
|
||||
--target loongarch64-unknown-linux-gnu
|
||||
|
||||
ENV CC=loongarch64-linux-gnu-gcc \
|
||||
CXX=loongarch64-linux-gnu-g++ \
|
||||
AR=loongarch64-linux-gnu-ar \
|
||||
RANLIB=loongarch64-linux-gnu-ranlib \
|
||||
STRIP=loongarch64-linux-gnu-strip \
|
||||
CARGO_BUILD_TARGET=loongarch64-unknown-linux-gnu \
|
||||
CARGO_TARGET_LOONGARCH64_UNKNOWN_LINUX_GNU_LINKER=loongarch64-linux-gnu-gcc \
|
||||
RUSTUP_TOOLCHAIN=${RUST_TOOLCHAIN}
|
||||
|
||||
COPY toolchain.cmake /opt/goodbuddy/loongarch64-toolchain.cmake
|
||||
COPY probe.sh /usr/local/bin/goodbuddy-loongarch-cross-probe
|
||||
COPY preview-deb.sh /usr/local/bin/goodbuddy-loongarch-preview-deb
|
||||
|
||||
RUN chmod 0755 \
|
||||
/usr/local/bin/goodbuddy-loongarch-cross-probe \
|
||||
/usr/local/bin/goodbuddy-loongarch-preview-deb
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/goodbuddy-loongarch-cross-probe"]
|
||||
@@ -0,0 +1,78 @@
|
||||
# LoongArch cross-compilation probe
|
||||
|
||||
This directory defines an isolated Ubuntu 26.04 amd64 environment for probing
|
||||
GoodBuddy's Linux `loong64` prerequisites. It does not build Electron or
|
||||
OpenCode.
|
||||
|
||||
The image includes:
|
||||
|
||||
- GCC and G++ targeting `loongarch64-linux-gnu`
|
||||
- A LoongArch glibc development sysroot
|
||||
- QEMU user-mode execution
|
||||
- CMake and Ninja
|
||||
- Rust's `loongarch64-unknown-linux-gnu` target
|
||||
- Node.js, npm, and node-gyp
|
||||
|
||||
The probe validates:
|
||||
|
||||
- C, C++, CMake, and Rust LoongArch ELF output
|
||||
- C, C++, and Rust execution through QEMU
|
||||
- A minimal NAPI-RS Rust addon cross-build
|
||||
- A Debian package with `Architecture: loong64`
|
||||
- Koffi 3.1.4's published LoongArch glibc binding and the absence of a musl
|
||||
binding
|
||||
- The current npm availability of an `@napi-rs/canvas` LoongArch package
|
||||
- A source cross-build of node-pty 1.1.0
|
||||
|
||||
The node-pty probe uses its upstream node-gyp 11.4.2 dependency. Ubuntu's
|
||||
distribution node-gyp unconditionally links Linux addons with `-lnode`, which
|
||||
is appropriate for Ubuntu's shared Node.js package but not for Electron native
|
||||
addons.
|
||||
|
||||
Build and run it from this directory on an amd64 Docker host:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--tag goodbuddy-loongarch-cross:ubuntu-26.04 \
|
||||
.
|
||||
|
||||
mkdir -p results
|
||||
docker run --rm \
|
||||
--volume "$PWD/results:/results" \
|
||||
goodbuddy-loongarch-cross:ubuntu-26.04
|
||||
```
|
||||
|
||||
Each run writes to a new UTC timestamped directory below `results`. A passing
|
||||
run contains `summary.txt`, `SHA256SUMS`, the generated ELF files, and the
|
||||
probe DEB.
|
||||
|
||||
QEMU only checks user-mode execution against the cross sysroot. Native Electron
|
||||
loading, PTY behavior, desktop integration, GPU, audio, and packaging must
|
||||
still be validated on a real Debian `loong64` host.
|
||||
|
||||
Koffi 3.1.4 currently publishes only `linux_loong64/koffi.node`, not a
|
||||
`musl_loong64` binding. That is sufficient for a Debian desktop package but
|
||||
does not satisfy GoodBuddy Agent's current dual-glibc/musl bundle contract.
|
||||
|
||||
The NAPI-RS probe confirms that a Rust Node addon can be cross-compiled. It
|
||||
does not build Canvas itself, whose Skia dependency requires a separate port
|
||||
and native validation.
|
||||
|
||||
## Experimental desktop preview
|
||||
|
||||
`prepare-input.cjs` creates the architecture-neutral GoodBuddy input archive.
|
||||
`preview-deb.sh` combines it with the verified community Electron 42.3.0
|
||||
LoongArch shell and produces an isolated `goodbuddy-loongarch-preview` DEB.
|
||||
|
||||
The preview:
|
||||
|
||||
- uses a separate application name and user-data directory;
|
||||
- includes the direct model path and Continue runtime;
|
||||
- cross-builds the input lockfile's node-pty version against Electron 42.3.0;
|
||||
- includes Koffi's LoongArch glibc binding;
|
||||
- does not include GoodBuddy Agent, Remote Runtime, OpenCode, or native Canvas;
|
||||
- is not part of the production release matrix.
|
||||
|
||||
See
|
||||
[`docs/development/loongarch-preview-build.md`](../../docs/development/loongarch-preview-build.md)
|
||||
for the complete build and validation procedure.
|
||||
@@ -0,0 +1,108 @@
|
||||
const {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync
|
||||
} = require('node:fs')
|
||||
const { dirname, join, resolve } = require('node:path')
|
||||
const tar = require('tar')
|
||||
|
||||
const root = join(__dirname, '..', '..')
|
||||
const defaultOutput = join(
|
||||
root,
|
||||
'dist',
|
||||
'loongarch-cross',
|
||||
'goodbuddy-loongarch-cross-input.tgz'
|
||||
)
|
||||
const inputPaths = [
|
||||
'LICENSE',
|
||||
'agent-runtime-lock.json',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'remote-runtime-lock.json',
|
||||
'out',
|
||||
'resources/agent-release-keys.json',
|
||||
'resources/release-notes.json',
|
||||
'resources/skills',
|
||||
'build/icon.png',
|
||||
'build/icon-tray.png',
|
||||
'node_modules/@continuedev/cli/package.json',
|
||||
'node_modules/@continuedev/cli/dist/cn.js',
|
||||
'node_modules/@continuedev/cli/dist/index.js',
|
||||
'node_modules/@continuedev/cli/dist/xhr-sync-worker.js',
|
||||
'node_modules/npm'
|
||||
]
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = { output: defaultOutput }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
if (argument !== '--output') {
|
||||
throw new Error(`未知参数:${argument}`)
|
||||
}
|
||||
const value = argv[index + 1]
|
||||
if (!value) {
|
||||
throw new Error('--output 缺少值')
|
||||
}
|
||||
options.output = resolve(value)
|
||||
index += 1
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function verifyInputs(projectRoot = root) {
|
||||
for (const relativePath of inputPaths) {
|
||||
if (!existsSync(join(projectRoot, relativePath))) {
|
||||
throw new Error(`龙芯交叉构建输入缺失:${relativePath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createInputArchive(outputPath, projectRoot = root) {
|
||||
verifyInputs(projectRoot)
|
||||
const resolvedOutput = resolve(outputPath)
|
||||
const temporaryOutput = `${resolvedOutput}.partial-${process.pid}`
|
||||
mkdirSync(dirname(resolvedOutput), { recursive: true })
|
||||
rmSync(temporaryOutput, { force: true })
|
||||
try {
|
||||
await tar.c(
|
||||
{
|
||||
cwd: projectRoot,
|
||||
file: temporaryOutput,
|
||||
gzip: true,
|
||||
portable: true,
|
||||
noMtime: true
|
||||
},
|
||||
inputPaths
|
||||
)
|
||||
if (!statSync(temporaryOutput).isFile()) {
|
||||
throw new Error('龙芯交叉构建输入归档未生成')
|
||||
}
|
||||
rmSync(resolvedOutput, { force: true })
|
||||
renameSync(temporaryOutput, resolvedOutput)
|
||||
} finally {
|
||||
rmSync(temporaryOutput, { force: true })
|
||||
}
|
||||
return resolvedOutput
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArguments(argv)
|
||||
const output = await createInputArchive(options.output)
|
||||
console.log(`龙芯交叉构建输入已生成:${output}`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInputArchive,
|
||||
inputPaths,
|
||||
parseArguments,
|
||||
verifyInputs
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
input_archive="${1:?missing GoodBuddy input archive}"
|
||||
electron_archive="${2:?missing Electron loong64 archive}"
|
||||
output_root="${3:-/preview-output}"
|
||||
|
||||
electron_version="42.3.0"
|
||||
electron_sha256="92b0ca0c9c18ed90166918a4ac1970266c4fa967aee9277031b3b250b905526e"
|
||||
koffi_version="3.1.4"
|
||||
node_gyp_version="11.4.2"
|
||||
preview_iteration="1"
|
||||
application_directory="GoodBuddy-LoongArch-Preview"
|
||||
executable_name="goodbuddy-loongarch-preview"
|
||||
|
||||
assert_file() {
|
||||
if ! test -f "$1"; then
|
||||
echo "missing required file: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_loongarch_elf() {
|
||||
local file_path="$1"
|
||||
assert_file "${file_path}"
|
||||
file "${file_path}"
|
||||
readelf -h "${file_path}" | grep -Eq \
|
||||
'Machine:[[:space:]]+(LoongArch|Loongson Loongarch)'
|
||||
}
|
||||
|
||||
assert_file "${input_archive}"
|
||||
assert_file "${electron_archive}"
|
||||
mkdir -p "${output_root}"
|
||||
|
||||
actual_electron_sha256="$(
|
||||
sha256sum "${electron_archive}" | cut -d ' ' -f 1
|
||||
)"
|
||||
if test "${actual_electron_sha256}" != "${electron_sha256}"; then
|
||||
echo "Electron loong64 archive digest mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_id="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
work_root="${output_root}/.work-${run_id}"
|
||||
input_root="${work_root}/input"
|
||||
package_root="${work_root}/package"
|
||||
application_root="${package_root}/opt/${application_directory}"
|
||||
resources_root="${application_root}/resources"
|
||||
app_root="${resources_root}/app"
|
||||
artifact_root="${output_root}/${run_id}"
|
||||
|
||||
if test -e "${work_root}" || test -e "${artifact_root}"; then
|
||||
echo "refusing to reuse preview build directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
rm -rf "${work_root}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "${input_root}" "${app_root}" "${artifact_root}"
|
||||
tar -xzf "${input_archive}" -C "${input_root}"
|
||||
|
||||
project_version="$(
|
||||
node -p \
|
||||
"require('${input_root}/package.json').version"
|
||||
)"
|
||||
node_pty_version="$(
|
||||
node -p \
|
||||
"require('${input_root}/package-lock.json').packages['node_modules/node-pty'].version"
|
||||
)"
|
||||
preview_version="${project_version}-loong64-preview.${preview_iteration}"
|
||||
deb_version="${project_version}~loong64preview${preview_iteration}"
|
||||
artifact_name="GoodBuddy-${project_version}-linux-loong64-preview.deb"
|
||||
|
||||
echo "== extract verified Electron shell =="
|
||||
python3 -m zipfile -e "${electron_archive}" "${application_root}"
|
||||
assert_loongarch_elf "${application_root}/electron"
|
||||
mv "${application_root}/electron" \
|
||||
"${application_root}/${executable_name}"
|
||||
chmod 0755 "${application_root}/${executable_name}"
|
||||
rm -f "${resources_root}/default_app.asar"
|
||||
if test -f "${application_root}/chrome-sandbox"; then
|
||||
chmod 4755 "${application_root}/chrome-sandbox"
|
||||
fi
|
||||
|
||||
echo "== install production JavaScript dependencies =="
|
||||
cp "${input_root}/package.json" "${app_root}/package.json"
|
||||
cp "${input_root}/package-lock.json" "${app_root}/package-lock.json"
|
||||
(
|
||||
cd "${app_root}"
|
||||
npm ci \
|
||||
--ignore-scripts \
|
||||
--omit=dev \
|
||||
--cpu=loong64 \
|
||||
--os=linux
|
||||
)
|
||||
|
||||
rm -rf \
|
||||
"${app_root}/node_modules/@esbuild" \
|
||||
"${app_root}/node_modules/@img" \
|
||||
"${app_root}/node_modules/@rollup"
|
||||
find "${app_root}/node_modules/@koromix" \
|
||||
-mindepth 1 \
|
||||
-maxdepth 1 \
|
||||
-type d \
|
||||
-exec rm -rf -- {} + 2>/dev/null || true
|
||||
find "${app_root}/node_modules/@napi-rs" \
|
||||
-mindepth 1 \
|
||||
-maxdepth 1 \
|
||||
-type d \
|
||||
-name 'canvas-*' \
|
||||
-exec rm -rf -- {} + 2>/dev/null || true
|
||||
|
||||
node - "${input_root}" "${app_root}/package.json" \
|
||||
"${preview_version}" <<'NODE'
|
||||
const { readFileSync, writeFileSync } = require('node:fs')
|
||||
const [inputRoot, outputPath, version] = process.argv.slice(2)
|
||||
const metadata = JSON.parse(
|
||||
readFileSync(`${inputRoot}/package.json`, 'utf8')
|
||||
)
|
||||
metadata.name = 'goodbuddy-loongarch-preview'
|
||||
metadata.productName = 'GoodBuddy LoongArch Preview'
|
||||
metadata.desktopName = 'GoodBuddy LoongArch Preview'
|
||||
metadata.version = version
|
||||
metadata.main = './out/main/index.js'
|
||||
delete metadata.build
|
||||
delete metadata.devDependencies
|
||||
delete metadata.optionalDependencies
|
||||
writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`)
|
||||
NODE
|
||||
rm -f "${app_root}/package-lock.json"
|
||||
|
||||
echo "== stage target Koffi binding =="
|
||||
koffi_stage="${work_root}/koffi"
|
||||
mkdir -p "${koffi_stage}"
|
||||
koffi_pack_json="$(
|
||||
npm pack \
|
||||
"@koromix/koffi-linux-loong64@${koffi_version}" \
|
||||
--ignore-scripts \
|
||||
--json \
|
||||
--pack-destination "${koffi_stage}"
|
||||
)"
|
||||
koffi_archive="$(
|
||||
node -e \
|
||||
'const value=JSON.parse(process.argv[1]); process.stdout.write(value[0].filename)' \
|
||||
"${koffi_pack_json}"
|
||||
)"
|
||||
koffi_integrity="$(
|
||||
node -p \
|
||||
"require('${input_root}/package-lock.json').packages['node_modules/@koromix/koffi-linux-loong64'].integrity"
|
||||
)"
|
||||
node - "${koffi_stage}/${koffi_archive}" \
|
||||
"${koffi_integrity}" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const [filePath, integrity] = process.argv.slice(2)
|
||||
const [algorithm, expected] = integrity.split('-', 2)
|
||||
const actual = createHash(algorithm)
|
||||
.update(readFileSync(filePath))
|
||||
.digest('base64')
|
||||
if (actual !== expected) {
|
||||
throw new Error('Koffi archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
koffi_target="${app_root}/node_modules/@koromix/koffi-linux-loong64"
|
||||
mkdir -p "${koffi_target}"
|
||||
tar \
|
||||
-xzf "${koffi_stage}/${koffi_archive}" \
|
||||
-C "${koffi_target}" \
|
||||
--strip-components 1
|
||||
koffi_binding="${koffi_target}/linux_loong64/koffi.node"
|
||||
assert_loongarch_elf "${koffi_binding}"
|
||||
|
||||
echo "== cross-build node-pty ${node_pty_version} for Electron ${electron_version} =="
|
||||
node_gyp_root="${work_root}/node-gyp"
|
||||
npm install \
|
||||
--ignore-scripts \
|
||||
--prefix "${node_gyp_root}" \
|
||||
"node-gyp@${node_gyp_version}"
|
||||
node_pty_root="${app_root}/node_modules/node-pty"
|
||||
test "$(
|
||||
node -p "require('${node_pty_root}/package.json').version"
|
||||
)" = "${node_pty_version}"
|
||||
rm -rf "${node_pty_root}/build" "${node_pty_root}/prebuilds"
|
||||
(
|
||||
cd "${node_pty_root}"
|
||||
CC=loongarch64-linux-gnu-gcc \
|
||||
CXX=loongarch64-linux-gnu-g++ \
|
||||
npm_config_arch=loong64 \
|
||||
npm_config_target_arch=loong64 \
|
||||
npm_config_runtime=electron \
|
||||
npm_config_target="${electron_version}" \
|
||||
npm_config_disturl=https://electronjs.org/headers \
|
||||
"${node_gyp_root}/node_modules/.bin/node-gyp" rebuild \
|
||||
--arch=loong64
|
||||
)
|
||||
node_pty_binding="${node_pty_root}/build/Release/pty.node"
|
||||
assert_loongarch_elf "${node_pty_binding}"
|
||||
|
||||
echo "== stage GoodBuddy application and architecture-neutral runtimes =="
|
||||
cp -a "${input_root}/out" "${app_root}/out"
|
||||
mkdir -p \
|
||||
"${resources_root}/licenses" \
|
||||
"${resources_root}/runtimes" \
|
||||
"${resources_root}/skills"
|
||||
cp -a "${input_root}/resources/skills/." "${resources_root}/skills/"
|
||||
cp "${input_root}/resources/release-notes.json" \
|
||||
"${resources_root}/release-notes.json"
|
||||
cp "${input_root}/resources/agent-release-keys.json" \
|
||||
"${resources_root}/agent-release-keys.json"
|
||||
cp "${input_root}/agent-runtime-lock.json" \
|
||||
"${resources_root}/agent-runtime-lock.json"
|
||||
cp "${input_root}/remote-runtime-lock.json" \
|
||||
"${resources_root}/remote-runtime-lock.json"
|
||||
cp "${input_root}/build/icon.png" "${resources_root}/icon.png"
|
||||
cp "${input_root}/build/icon-tray.png" \
|
||||
"${resources_root}/tray-icon.png"
|
||||
cp "${input_root}/LICENSE" \
|
||||
"${resources_root}/licenses/GoodBuddy-0BSD.txt"
|
||||
mkdir -p "${resources_root}/runtimes/continue/dist"
|
||||
cp "${input_root}/node_modules/@continuedev/cli/package.json" \
|
||||
"${resources_root}/runtimes/continue/package.json"
|
||||
cp "${input_root}/node_modules/@continuedev/cli/dist/cn.js" \
|
||||
"${resources_root}/runtimes/continue/dist/cn.js"
|
||||
cp "${input_root}/node_modules/@continuedev/cli/dist/index.js" \
|
||||
"${resources_root}/runtimes/continue/dist/index.js"
|
||||
cp "${input_root}/node_modules/@continuedev/cli/dist/xhr-sync-worker.js" \
|
||||
"${resources_root}/runtimes/continue/dist/xhr-sync-worker.js"
|
||||
cp -a "${input_root}/node_modules/npm" \
|
||||
"${resources_root}/runtimes/npm"
|
||||
|
||||
echo "== reject foreign native addons =="
|
||||
while IFS= read -r -d '' native_addon; do
|
||||
assert_loongarch_elf "${native_addon}"
|
||||
done < <(
|
||||
find "${app_root}/node_modules" \
|
||||
-type f \
|
||||
-name '*.node' \
|
||||
-print0
|
||||
)
|
||||
|
||||
if test -e "${resources_root}/agents" ||
|
||||
test -e "${resources_root}/remote-runtimes" ||
|
||||
test -e "${resources_root}/runtimes/opencode"
|
||||
then
|
||||
echo "preview package unexpectedly contains Agent or OpenCode payload" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== build isolated preview DEB =="
|
||||
mkdir -p \
|
||||
"${package_root}/DEBIAN" \
|
||||
"${package_root}/usr/bin" \
|
||||
"${package_root}/usr/share/applications" \
|
||||
"${package_root}/usr/share/icons/hicolor/512x512/apps"
|
||||
ln -s \
|
||||
"/opt/${application_directory}/${executable_name}" \
|
||||
"${package_root}/usr/bin/${executable_name}"
|
||||
cp "${input_root}/build/icon.png" \
|
||||
"${package_root}/usr/share/icons/hicolor/512x512/apps/${executable_name}.png"
|
||||
cat > "${package_root}/usr/share/applications/${executable_name}.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Name=GoodBuddy LoongArch Preview
|
||||
Comment=GoodBuddy LoongArch 主体预览版
|
||||
Exec=/opt/${application_directory}/${executable_name} %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=${executable_name}
|
||||
Categories=Utility;
|
||||
StartupWMClass=GoodBuddy LoongArch Preview
|
||||
EOF
|
||||
cat > "${package_root}/DEBIAN/control" <<EOF
|
||||
Package: goodbuddy-loongarch-preview
|
||||
Version: ${deb_version}
|
||||
Architecture: loong64
|
||||
Maintainer: MesaLogo
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Depends: libasound2t64, libatspi2.0-0t64, libgbm1, libgtk-3-0t64, libnotify4, libnss3, libpulse0, libsecret-1-0, libuuid1, libxss1, libxtst6, xdg-utils
|
||||
Description: GoodBuddy LoongArch desktop preview
|
||||
Experimental LoongArch desktop build without bundled Agent, Remote Runtime,
|
||||
OpenCode, or native Canvas image decoding.
|
||||
EOF
|
||||
|
||||
artifact="${artifact_root}/${artifact_name}"
|
||||
dpkg-deb --root-owner-group --build "${package_root}" "${artifact}"
|
||||
|
||||
echo "== verify preview package =="
|
||||
test "$(dpkg-deb --field "${artifact}" Architecture)" = "loong64"
|
||||
verify_root="${work_root}/verify"
|
||||
mkdir "${verify_root}"
|
||||
dpkg-deb --extract "${artifact}" "${verify_root}"
|
||||
assert_loongarch_elf \
|
||||
"${verify_root}/opt/${application_directory}/${executable_name}"
|
||||
assert_loongarch_elf \
|
||||
"${verify_root}/opt/${application_directory}/resources/app/${node_pty_binding#${app_root}/}"
|
||||
assert_loongarch_elf \
|
||||
"${verify_root}/opt/${application_directory}/resources/app/${koffi_binding#${app_root}/}"
|
||||
|
||||
(
|
||||
cd "${artifact_root}"
|
||||
sha256sum "${artifact_name}" > SHA256SUMS
|
||||
)
|
||||
cat > "${artifact_root}/preview-manifest.json" <<EOF
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"product": "GoodBuddy LoongArch Preview",
|
||||
"goodBuddyVersion": "${project_version}",
|
||||
"previewVersion": "${preview_version}",
|
||||
"electronVersion": "${electron_version}",
|
||||
"architecture": "loong64",
|
||||
"artifact": "${artifact_name}",
|
||||
"bundledAgent": false,
|
||||
"bundledRemoteRuntime": false,
|
||||
"bundledOpenCode": false,
|
||||
"nativeCanvas": false,
|
||||
"continueRuntime": true,
|
||||
"nodePty": true,
|
||||
"koffiGlibc": true,
|
||||
"nativeLaunchVerified": false
|
||||
}
|
||||
EOF
|
||||
|
||||
cat "${artifact_root}/preview-manifest.json"
|
||||
echo "artifact=${artifact}"
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
results_root="${1:-/results}"
|
||||
run_id="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
results="${results_root}/${run_id}"
|
||||
mkdir -p "${results}"
|
||||
|
||||
log="${results}/probe.log"
|
||||
exec > >(tee "${log}") 2>&1
|
||||
|
||||
assert_loongarch_elf() {
|
||||
local file_path="$1"
|
||||
file "${file_path}"
|
||||
readelf -h "${file_path}" | grep -Eq \
|
||||
'Machine:[[:space:]]+(LoongArch|Loongson Loongarch)'
|
||||
}
|
||||
|
||||
echo "== tool versions =="
|
||||
loongarch64-linux-gnu-gcc --version | head -n 1
|
||||
loongarch64-linux-gnu-g++ --version | head -n 1
|
||||
loongarch64-linux-gnu-ld --version | head -n 1
|
||||
qemu-loongarch64 --version | head -n 1
|
||||
node --version
|
||||
npm --version
|
||||
node-gyp --version
|
||||
rustc --version
|
||||
cargo --version
|
||||
cmake --version | head -n 1
|
||||
|
||||
echo "== C probe =="
|
||||
cat > "${results}/hello.c" <<'EOF'
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
puts("goodbuddy-loongarch-c-ok");
|
||||
return 0;
|
||||
}
|
||||
EOF
|
||||
|
||||
loongarch64-linux-gnu-gcc \
|
||||
-O2 \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
-Werror \
|
||||
"${results}/hello.c" \
|
||||
-o "${results}/hello-c"
|
||||
assert_loongarch_elf "${results}/hello-c"
|
||||
test "$(
|
||||
qemu-loongarch64 \
|
||||
-L /usr/loongarch64-linux-gnu \
|
||||
"${results}/hello-c"
|
||||
)" = "goodbuddy-loongarch-c-ok"
|
||||
|
||||
echo "== C++ probe =="
|
||||
cat > "${results}/hello.cpp" <<'EOF'
|
||||
#include <iostream>
|
||||
#include <string_view>
|
||||
|
||||
int main() {
|
||||
constexpr std::string_view message{"goodbuddy-loongarch-cpp-ok"};
|
||||
std::cout << message << '\n';
|
||||
return 0;
|
||||
}
|
||||
EOF
|
||||
|
||||
loongarch64-linux-gnu-g++ \
|
||||
-std=c++20 \
|
||||
-O2 \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
-Werror \
|
||||
"${results}/hello.cpp" \
|
||||
-o "${results}/hello-cpp"
|
||||
assert_loongarch_elf "${results}/hello-cpp"
|
||||
test "$(
|
||||
qemu-loongarch64 \
|
||||
-L /usr/loongarch64-linux-gnu \
|
||||
"${results}/hello-cpp"
|
||||
)" = "goodbuddy-loongarch-cpp-ok"
|
||||
|
||||
echo "== CMake probe =="
|
||||
mkdir "${results}/cmake-source"
|
||||
cat > "${results}/cmake-source/CMakeLists.txt" <<'EOF'
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(goodbuddy_loongarch_probe LANGUAGES C)
|
||||
add_executable(goodbuddy-cmake-probe main.c)
|
||||
target_compile_options(
|
||||
goodbuddy-cmake-probe
|
||||
PRIVATE -Wall -Wextra -Werror
|
||||
)
|
||||
EOF
|
||||
cp "${results}/hello.c" "${results}/cmake-source/main.c"
|
||||
cmake \
|
||||
-S "${results}/cmake-source" \
|
||||
-B "${results}/cmake-build" \
|
||||
-G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_TOOLCHAIN_FILE=/opt/goodbuddy/loongarch64-toolchain.cmake
|
||||
cmake --build "${results}/cmake-build"
|
||||
assert_loongarch_elf \
|
||||
"${results}/cmake-build/goodbuddy-cmake-probe"
|
||||
|
||||
echo "== Rust probe =="
|
||||
mkdir -p "${results}/rust-probe/src"
|
||||
cat > "${results}/rust-probe/Cargo.toml" <<'EOF'
|
||||
[package]
|
||||
name = "goodbuddy-loongarch-rust-probe"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
EOF
|
||||
cat > "${results}/rust-probe/src/main.rs" <<'EOF'
|
||||
fn main() {
|
||||
println!("goodbuddy-loongarch-rust-ok");
|
||||
}
|
||||
EOF
|
||||
cargo build \
|
||||
--release \
|
||||
--manifest-path "${results}/rust-probe/Cargo.toml"
|
||||
rust_binary="$(
|
||||
find "${results}/rust-probe/target" \
|
||||
-type f \
|
||||
-name goodbuddy-loongarch-rust-probe \
|
||||
-print \
|
||||
-quit
|
||||
)"
|
||||
test -n "${rust_binary}"
|
||||
assert_loongarch_elf "${rust_binary}"
|
||||
test "$(
|
||||
qemu-loongarch64 \
|
||||
-L /usr/loongarch64-linux-gnu \
|
||||
"${rust_binary}"
|
||||
)" = "goodbuddy-loongarch-rust-ok"
|
||||
|
||||
echo "== NAPI-RS Rust addon probe =="
|
||||
mkdir -p "${results}/napi-rs-probe/src"
|
||||
cat > "${results}/napi-rs-probe/Cargo.toml" <<'EOF'
|
||||
[package]
|
||||
name = "goodbuddy-loongarch-napi-probe"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
napi = { version = "3", default-features = false, features = ["napi8"] }
|
||||
napi-derive = "3"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2"
|
||||
EOF
|
||||
cat > "${results}/napi-rs-probe/build.rs" <<'EOF'
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
EOF
|
||||
cat > "${results}/napi-rs-probe/src/lib.rs" <<'EOF'
|
||||
use napi_derive::napi;
|
||||
|
||||
#[napi]
|
||||
pub fn architecture_probe() -> &'static str {
|
||||
"loong64"
|
||||
}
|
||||
EOF
|
||||
cargo build \
|
||||
--release \
|
||||
--manifest-path "${results}/napi-rs-probe/Cargo.toml"
|
||||
napi_binding="$(
|
||||
find "${results}/napi-rs-probe/target" \
|
||||
-type f \
|
||||
-name 'libgoodbuddy_loongarch_napi_probe.so' \
|
||||
-print \
|
||||
-quit
|
||||
)"
|
||||
test -n "${napi_binding}"
|
||||
assert_loongarch_elf "${napi_binding}"
|
||||
|
||||
echo "== Debian package probe =="
|
||||
package_root="${results}/deb-root"
|
||||
mkdir -p \
|
||||
"${package_root}/DEBIAN" \
|
||||
"${package_root}/usr/lib/goodbuddy-loongarch-probe"
|
||||
cp "${results}/hello-c" \
|
||||
"${package_root}/usr/lib/goodbuddy-loongarch-probe/hello"
|
||||
cat > "${package_root}/DEBIAN/control" <<'EOF'
|
||||
Package: goodbuddy-loongarch-cross-probe
|
||||
Version: 1.0.0
|
||||
Architecture: loong64
|
||||
Maintainer: MesaLogo
|
||||
Description: GoodBuddy LoongArch cross-compilation probe
|
||||
EOF
|
||||
dpkg-deb --build \
|
||||
"${package_root}" \
|
||||
"${results}/goodbuddy-loongarch-cross-probe_1.0.0_loong64.deb"
|
||||
test "$(
|
||||
dpkg-deb \
|
||||
--field \
|
||||
"${results}/goodbuddy-loongarch-cross-probe_1.0.0_loong64.deb" \
|
||||
Architecture
|
||||
)" = "loong64"
|
||||
|
||||
echo "== Koffi prebuilt probe =="
|
||||
mkdir "${results}/koffi"
|
||||
npm pack \
|
||||
"@koromix/koffi-linux-loong64@3.1.4" \
|
||||
--ignore-scripts \
|
||||
--pack-destination "${results}/koffi"
|
||||
koffi_archive="$(
|
||||
find "${results}/koffi" \
|
||||
-maxdepth 1 \
|
||||
-type f \
|
||||
-name '*.tgz' \
|
||||
-print \
|
||||
-quit
|
||||
)"
|
||||
test -n "${koffi_archive}"
|
||||
mkdir "${results}/koffi/package"
|
||||
tar \
|
||||
-xzf "${koffi_archive}" \
|
||||
-C "${results}/koffi/package" \
|
||||
--strip-components 1
|
||||
koffi_binding="${results}/koffi/package/linux_loong64/koffi.node"
|
||||
assert_loongarch_elf "${koffi_binding}"
|
||||
if test -e "${results}/koffi/package/musl_loong64/koffi.node"; then
|
||||
echo "Unexpected Koffi musl binding is present"
|
||||
exit 1
|
||||
fi
|
||||
echo "Koffi loong64 currently provides a glibc binding only"
|
||||
|
||||
echo "== Canvas package availability probe =="
|
||||
canvas_package_status=unavailable
|
||||
if npm view \
|
||||
"@napi-rs/canvas-linux-loong64-gnu" \
|
||||
version \
|
||||
> "${results}/canvas-package-version.txt" \
|
||||
2> "${results}/canvas-package-error.txt"
|
||||
then
|
||||
canvas_package_status=available
|
||||
fi
|
||||
echo "@napi-rs/canvas loong64 package: ${canvas_package_status}"
|
||||
|
||||
echo "== node-pty cross-build probe =="
|
||||
mkdir "${results}/node-pty"
|
||||
npm pack \
|
||||
"node-pty@1.1.0" \
|
||||
--ignore-scripts \
|
||||
--pack-destination "${results}/node-pty"
|
||||
node_pty_archive="$(
|
||||
find "${results}/node-pty" \
|
||||
-maxdepth 1 \
|
||||
-type f \
|
||||
-name '*.tgz' \
|
||||
-print \
|
||||
-quit
|
||||
)"
|
||||
test -n "${node_pty_archive}"
|
||||
mkdir "${results}/node-pty/package"
|
||||
tar \
|
||||
-xzf "${node_pty_archive}" \
|
||||
-C "${results}/node-pty/package" \
|
||||
--strip-components 1
|
||||
(
|
||||
cd "${results}/node-pty/package"
|
||||
npm install --ignore-scripts --omit=dev
|
||||
npm install \
|
||||
--ignore-scripts \
|
||||
--no-save \
|
||||
"node-gyp@11.4.2"
|
||||
CC=loongarch64-linux-gnu-gcc \
|
||||
CXX=loongarch64-linux-gnu-g++ \
|
||||
npm_config_arch=loong64 \
|
||||
npm_config_target_arch=loong64 \
|
||||
npm_config_runtime=electron \
|
||||
npm_config_target=43.2.0 \
|
||||
npm_config_disturl=https://electronjs.org/headers \
|
||||
./node_modules/.bin/node-gyp rebuild \
|
||||
--arch=loong64
|
||||
)
|
||||
node_pty_binding="${results}/node-pty/package/build/Release/pty.node"
|
||||
assert_loongarch_elf "${node_pty_binding}"
|
||||
|
||||
echo "== summary =="
|
||||
sha256sum \
|
||||
"${results}/hello-c" \
|
||||
"${results}/hello-cpp" \
|
||||
"${rust_binary}" \
|
||||
"${napi_binding}" \
|
||||
"${results}/goodbuddy-loongarch-cross-probe_1.0.0_loong64.deb" \
|
||||
"${koffi_binding}" \
|
||||
"${node_pty_binding}" \
|
||||
> "${results}/SHA256SUMS"
|
||||
|
||||
cat > "${results}/summary.txt" <<EOF
|
||||
result=pass
|
||||
target=loong64
|
||||
c=pass
|
||||
cpp=pass
|
||||
cmake=pass
|
||||
rust=pass
|
||||
napi_rs=pass
|
||||
qemu=pass
|
||||
deb=pass
|
||||
koffi=pass
|
||||
koffi_musl=unavailable
|
||||
canvas_package=${canvas_package_status}
|
||||
node_pty=pass
|
||||
EOF
|
||||
|
||||
cat "${results}/summary.txt"
|
||||
echo "results=${results}"
|
||||
@@ -0,0 +1,14 @@
|
||||
set(CMAKE_SYSTEM_NAME Linux)
|
||||
set(CMAKE_SYSTEM_PROCESSOR loongarch64)
|
||||
|
||||
set(CMAKE_C_COMPILER loongarch64-linux-gnu-gcc)
|
||||
set(CMAKE_CXX_COMPILER loongarch64-linux-gnu-g++)
|
||||
set(CMAKE_AR loongarch64-linux-gnu-ar)
|
||||
set(CMAKE_RANLIB loongarch64-linux-gnu-ranlib)
|
||||
set(CMAKE_STRIP loongarch64-linux-gnu-strip)
|
||||
|
||||
set(CMAKE_FIND_ROOT_PATH /usr/loongarch64-linux-gnu)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
@@ -0,0 +1,33 @@
|
||||
const { existsSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
function npmInvocation(projectDir, environment = process.env) {
|
||||
const npmCli = environment.npm_execpath;
|
||||
if (npmCli) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
prefixArgs: [npmCli],
|
||||
};
|
||||
}
|
||||
|
||||
const bundledNpmCli = join(
|
||||
projectDir,
|
||||
"node_modules",
|
||||
"npm",
|
||||
"bin",
|
||||
"npm-cli.js",
|
||||
);
|
||||
if (existsSync(bundledNpmCli)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
prefixArgs: [bundledNpmCli],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: process.platform === "win32" ? "npm.cmd" : "npm",
|
||||
prefixArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { npmInvocation };
|
||||
@@ -0,0 +1,110 @@
|
||||
const { existsSync } = require("node:fs");
|
||||
const {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { npmInvocation } = require("./npm-invocation.cjs");
|
||||
|
||||
const opencodeVersion = "1.18.29";
|
||||
const opencodePluginPackage = "@opencode-ai/plugin";
|
||||
|
||||
function prepareBundledOpenCodeConfig(projectDir) {
|
||||
const targetDirectory = join(
|
||||
projectDir,
|
||||
".runtime-resources",
|
||||
"opencode-config",
|
||||
);
|
||||
try {
|
||||
const installed = JSON.parse(
|
||||
readFileSync(
|
||||
join(
|
||||
targetDirectory,
|
||||
"node_modules",
|
||||
"@opencode-ai",
|
||||
"plugin",
|
||||
"package.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
if (installed.version === opencodeVersion) {
|
||||
return targetDirectory;
|
||||
}
|
||||
} catch {
|
||||
// Rebuild an incomplete or stale offline config template.
|
||||
}
|
||||
|
||||
const stagingDirectory = `${targetDirectory}.staging-${process.pid}`;
|
||||
rmSync(stagingDirectory, { recursive: true, force: true });
|
||||
mkdirSync(stagingDirectory, { recursive: true });
|
||||
try {
|
||||
writeFileSync(
|
||||
join(stagingDirectory, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
dependencies: {
|
||||
[opencodePluginPackage]: opencodeVersion,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(join(stagingDirectory, ".gitignore"), "node_modules\n");
|
||||
const npm = npmInvocation(projectDir);
|
||||
const environment = { ...process.env };
|
||||
delete environment.NODE_TLS_REJECT_UNAUTHORIZED;
|
||||
const result = spawnSync(
|
||||
npm.command,
|
||||
[
|
||||
...npm.prefixArgs,
|
||||
"install",
|
||||
"--ignore-scripts",
|
||||
"--bin-links=false",
|
||||
"--omit=dev",
|
||||
"--omit=optional",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--save-exact",
|
||||
],
|
||||
{
|
||||
cwd: stagingDirectory,
|
||||
encoding: "utf8",
|
||||
env: environment,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Unable to prepare bundled OpenCode config: ${
|
||||
result.error?.message ||
|
||||
result.stderr ||
|
||||
result.stdout ||
|
||||
`npm exited with ${result.status}`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
rmSync(targetDirectory, { recursive: true, force: true });
|
||||
mkdirSync(join(targetDirectory, ".."), { recursive: true });
|
||||
renameSync(stagingDirectory, targetDirectory);
|
||||
return targetDirectory;
|
||||
} finally {
|
||||
if (existsSync(stagingDirectory)) {
|
||||
rmSync(stagingDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
opencodeVersion,
|
||||
prepareBundledOpenCodeConfig,
|
||||
};
|
||||
@@ -32,13 +32,21 @@ function validateItems(value, label) {
|
||||
fail(`${label} contains a non-string item`)
|
||||
}
|
||||
const normalized = item.trim()
|
||||
if (!normalized || normalized.length > 240) {
|
||||
fail(`${label} contains an empty or oversized item`)
|
||||
if (!normalized) {
|
||||
fail(`${label} contains an empty item`)
|
||||
}
|
||||
return normalized
|
||||
})
|
||||
}
|
||||
|
||||
const releaseNoteSections = [
|
||||
'highlights',
|
||||
'features',
|
||||
'fixes',
|
||||
'notices'
|
||||
]
|
||||
const legacyReleaseNoteSections = ['features', 'fixes']
|
||||
|
||||
function validateRelease(value, index) {
|
||||
const label = `releases[${index}]`
|
||||
if (!hasExactKeys(value, ['version', 'releasedAt', 'notes'])) {
|
||||
@@ -63,28 +71,50 @@ function validateRelease(value, index) {
|
||||
const notes = Object.fromEntries(
|
||||
['zh-CN', 'en-US'].map((locale) => {
|
||||
const localized = value.notes[locale]
|
||||
if (!hasExactKeys(localized, ['features', 'fixes'])) {
|
||||
const isCurrentFormat = hasExactKeys(
|
||||
localized,
|
||||
releaseNoteSections
|
||||
)
|
||||
const isLegacyFormat = hasExactKeys(
|
||||
localized,
|
||||
legacyReleaseNoteSections
|
||||
)
|
||||
if (!isCurrentFormat && !isLegacyFormat) {
|
||||
fail(`${label}.notes.${locale} has invalid fields`)
|
||||
}
|
||||
const features = validateItems(
|
||||
localized.features,
|
||||
`${label}.notes.${locale}.features`
|
||||
const normalized = Object.fromEntries(
|
||||
releaseNoteSections.map((section) => [
|
||||
section,
|
||||
section in localized
|
||||
? validateItems(
|
||||
localized[section],
|
||||
`${label}.notes.${locale}.${section}`
|
||||
)
|
||||
: []
|
||||
])
|
||||
)
|
||||
const fixes = validateItems(
|
||||
localized.fixes,
|
||||
`${label}.notes.${locale}.fixes`
|
||||
)
|
||||
if (features.length + fixes.length === 0) {
|
||||
if (normalized.highlights.length > 3) {
|
||||
fail(
|
||||
`${label}.notes.${locale}.highlights must contain no more than 3 items`
|
||||
)
|
||||
}
|
||||
if (
|
||||
releaseNoteSections.every(
|
||||
(section) => normalized[section].length === 0
|
||||
)
|
||||
) {
|
||||
fail(`${label}.notes.${locale} must not be empty`)
|
||||
}
|
||||
return [locale, { features, fixes }]
|
||||
return [locale, normalized]
|
||||
})
|
||||
)
|
||||
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`)
|
||||
for (const section of releaseNoteSections) {
|
||||
if (
|
||||
notes['zh-CN'][section].length !==
|
||||
notes['en-US'][section].length
|
||||
) {
|
||||
fail(`${label} localized ${section} counts do not match`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: value.version,
|
||||
@@ -126,14 +156,18 @@ const localizedDefinitions = [
|
||||
{
|
||||
locale: 'zh-CN',
|
||||
title: `GoodBuddy ${release.version} 更新内容`,
|
||||
highlights: '本次亮点',
|
||||
features: '功能更新',
|
||||
fixes: '问题修复'
|
||||
fixes: '问题修复',
|
||||
notices: '使用前请留意'
|
||||
},
|
||||
{
|
||||
locale: 'en-US',
|
||||
title: `What's New in GoodBuddy ${release.version}`,
|
||||
highlights: 'Highlights',
|
||||
features: 'Features',
|
||||
fixes: 'Bug Fixes'
|
||||
fixes: 'Bug Fixes',
|
||||
notices: 'Before You Start'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -151,8 +185,13 @@ const markdown = localizedDefinitions
|
||||
...(index === 0 ? [] : ['---', '']),
|
||||
`# ${definition.title}`,
|
||||
'',
|
||||
...markdownSection(
|
||||
definition.highlights,
|
||||
notes.highlights
|
||||
),
|
||||
...markdownSection(definition.features, notes.features),
|
||||
...markdownSection(definition.fixes, notes.fixes)
|
||||
...markdownSection(definition.fixes, notes.fixes),
|
||||
...markdownSection(definition.notices, notes.notices)
|
||||
]
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const { rmSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join } = require('node:path')
|
||||
const { buildSync } = require('esbuild')
|
||||
|
||||
const output = join(tmpdir(), `goodbuddy-browser-tabs-e2e-${process.pid}.cjs`)
|
||||
|
||||
try {
|
||||
buildSync({
|
||||
entryPoints: [join(process.cwd(), 'tests', 'browser-tabs-electron-e2e.ts')],
|
||||
outfile: output,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node22',
|
||||
external: ['electron']
|
||||
})
|
||||
const environment = { ...process.env }
|
||||
delete environment.ELECTRON_RUN_AS_NODE
|
||||
const result = spawnSync(require('electron'), [output], {
|
||||
cwd: process.cwd(),
|
||||
env: environment,
|
||||
stdio: 'inherit',
|
||||
timeout: 120_000
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
process.exitCode = result.status ?? 1
|
||||
} finally {
|
||||
rmSync(output, { force: true })
|
||||
}
|
||||
@@ -88,7 +88,7 @@ async function main() {
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: '@deepseek-ai/dsh-llm',
|
||||
version: '0.1.0-rc.6',
|
||||
version: '0.1.2-rc.1',
|
||||
private: true,
|
||||
type: 'module'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict'
|
||||
|
||||
function configureElectronBuilderLogLevels(
|
||||
moduleManager = require(
|
||||
'app-builder-lib/out/node-module-collector/moduleManager'
|
||||
)
|
||||
) {
|
||||
const duplicateReferenceMessage =
|
||||
moduleManager.LogMessageByKey.PKG_DUPLICATE_REF
|
||||
if (
|
||||
!duplicateReferenceMessage ||
|
||||
!moduleManager.logMessageLevelByKey[
|
||||
duplicateReferenceMessage
|
||||
]
|
||||
) {
|
||||
throw new Error(
|
||||
'当前 electron-builder 不支持依赖引用日志级别配置'
|
||||
)
|
||||
}
|
||||
moduleManager.logMessageLevelByKey[
|
||||
duplicateReferenceMessage
|
||||
] = 'debug'
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
configureElectronBuilderLogLevels()
|
||||
require('electron-builder/cli')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configureElectronBuilderLogLevels
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { spawn } = require('node:child_process')
|
||||
const {
|
||||
chmod,
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
@@ -11,7 +12,8 @@ const {
|
||||
} = require('node:fs/promises')
|
||||
const { statSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { delimiter, join, resolve } = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const unpackedPath = process.argv[2]
|
||||
? resolve(process.argv[2])
|
||||
@@ -28,20 +30,57 @@ const host = join(
|
||||
'main',
|
||||
'deepseek-harness-host-bootstrap.js'
|
||||
)
|
||||
const npmRoot = join(unpackedPath, 'resources', 'runtimes', 'npm')
|
||||
const npmCli = join(npmRoot, 'bin', 'npm-cli.js')
|
||||
const npmManifestPath = join(npmRoot, 'package.json')
|
||||
|
||||
for (const [path, description] of [
|
||||
[executable, 'packaged Electron executable'],
|
||||
[host, 'packaged DeepSeek Harness host']
|
||||
[host, 'packaged DeepSeek Harness host'],
|
||||
[npmCli, 'packaged npm CLI'],
|
||||
[npmManifestPath, 'packaged npm manifest']
|
||||
]) {
|
||||
if (!statSync(path, { throwIfNoEntry: false })?.isFile()) {
|
||||
throw new Error(`${description} is missing: ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
function run(command, args, env) {
|
||||
function quotePosixShell(value) {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
async function prepareNodeCommand(directory) {
|
||||
await mkdir(directory, { recursive: true })
|
||||
if (process.platform === 'win32') {
|
||||
await writeFile(
|
||||
join(directory, 'node.cmd'),
|
||||
[
|
||||
'@echo off',
|
||||
'set "ELECTRON_RUN_AS_NODE=1"',
|
||||
`"${executable.replaceAll('%', '%%')}" %*`,
|
||||
''
|
||||
].join('\r\n'),
|
||||
'utf8'
|
||||
)
|
||||
return
|
||||
}
|
||||
const commandPath = join(directory, 'node')
|
||||
await writeFile(
|
||||
commandPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`ELECTRON_RUN_AS_NODE=1 exec ${quotePosixShell(executable)} "$@"`,
|
||||
''
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
)
|
||||
await chmod(commandPath, 0o700)
|
||||
}
|
||||
|
||||
function run(command, args, env, cwd = resolve('.')) {
|
||||
return new Promise((resolveExit, rejectExit) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: resolve('.'),
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
@@ -67,7 +106,24 @@ async function main() {
|
||||
const project = join(root, 'app')
|
||||
const profile = join(root, 'profile')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const packageManagerBin = join(root, 'package-manager-bin')
|
||||
const npmProject = join(root, 'npm-project')
|
||||
const npmFixture = join(root, 'npm-fixture')
|
||||
await mkdir(project, { recursive: true })
|
||||
const pluginUrl = pathToFileURL(join(
|
||||
unpackedPath, 'resources', 'runtimes', 'opencode-config',
|
||||
'node_modules', '@opencode-ai', 'plugin', 'dist', 'index.js'
|
||||
)).href
|
||||
const importedPlugin = await run(
|
||||
executable,
|
||||
['--input-type=module', '--eval', `await import(${JSON.stringify(pluginUrl)})`],
|
||||
{ ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
root
|
||||
)
|
||||
if (importedPlugin.exitCode !== 0 || importedPlugin.signal) {
|
||||
throw new Error(`Packaged OpenCode plugin import failed: ${importedPlugin.output.trim()}`)
|
||||
}
|
||||
console.log('Packaged OpenCode offline plugin import: ready')
|
||||
|
||||
await copyFile(
|
||||
resolve('build/deepseek-harness-utility-smoke.cjs'),
|
||||
@@ -106,7 +162,7 @@ async function main() {
|
||||
)
|
||||
|
||||
const packageArguments = [
|
||||
resolve('node_modules/electron-builder/cli.js'),
|
||||
resolve('build/run-electron-builder.cjs'),
|
||||
'--projectDir',
|
||||
project,
|
||||
'--win',
|
||||
@@ -116,9 +172,16 @@ async function main() {
|
||||
'never',
|
||||
`--config.directories.output=${join(root, 'dist')}`
|
||||
]
|
||||
if (process.env.GOODBUDDY_ELECTRON_DIST) {
|
||||
const electronDist = process.env.GOODBUDDY_ELECTRON_DIST
|
||||
? resolve(process.env.GOODBUDDY_ELECTRON_DIST)
|
||||
: resolve('node_modules/electron/dist')
|
||||
if (
|
||||
statSync(electronDist, {
|
||||
throwIfNoEntry: false
|
||||
})?.isDirectory()
|
||||
) {
|
||||
packageArguments.push(
|
||||
`--config.electronDist=${resolve(process.env.GOODBUDDY_ELECTRON_DIST)}`
|
||||
`--config.electronDist=${electronDist}`
|
||||
)
|
||||
}
|
||||
const packaged = await run(
|
||||
@@ -153,7 +216,102 @@ async function main() {
|
||||
`Packaged DeepSeek Harness smoke failed (${executed.exitCode}, ${executed.signal ?? 'no signal'}): ${JSON.stringify(result)} ${executed.output.trim()}`
|
||||
)
|
||||
}
|
||||
|
||||
const npmManifest = JSON.parse(
|
||||
await readFile(npmManifestPath, 'utf8')
|
||||
)
|
||||
await prepareNodeCommand(packageManagerBin)
|
||||
await mkdir(npmProject, { recursive: true })
|
||||
await mkdir(npmFixture, { recursive: true })
|
||||
await writeFile(
|
||||
join(npmProject, 'package.json'),
|
||||
'{"name":"goodbuddy-packaged-npm-project","version":"1.0.0","private":true}\n',
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(
|
||||
join(npmFixture, 'package.json'),
|
||||
`${JSON.stringify({
|
||||
name: 'goodbuddy-packaged-npm-smoke',
|
||||
version: '1.0.0',
|
||||
scripts: {
|
||||
install: 'node install.cjs'
|
||||
}
|
||||
})}\n`,
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(
|
||||
join(npmFixture, 'install.cjs'),
|
||||
"require('node:fs').writeFileSync(require('node:path').join(__dirname, 'lifecycle-ran.txt'), 'ready\\n')\n",
|
||||
'utf8'
|
||||
)
|
||||
const inheritedPath =
|
||||
process.env.PATH ?? process.env.Path ?? ''
|
||||
const npmEnvironment = {
|
||||
...process.env,
|
||||
PATH: inheritedPath
|
||||
? `${packageManagerBin}${delimiter}${inheritedPath}`
|
||||
: packageManagerBin,
|
||||
Path: inheritedPath
|
||||
? `${packageManagerBin}${delimiter}${inheritedPath}`
|
||||
: packageManagerBin,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
npm_execpath: npmCli,
|
||||
npm_node_execpath: executable,
|
||||
npm_config_audit: 'false',
|
||||
npm_config_fund: 'false',
|
||||
npm_config_update_notifier: 'false'
|
||||
}
|
||||
const npmVersion = await run(
|
||||
executable,
|
||||
[npmCli, '--version'],
|
||||
npmEnvironment,
|
||||
npmProject
|
||||
)
|
||||
if (
|
||||
npmVersion.exitCode !== 0 ||
|
||||
npmVersion.signal ||
|
||||
npmVersion.output.trim() !== npmManifest.version
|
||||
) {
|
||||
throw new Error(
|
||||
`Packaged npm version smoke failed: ${npmVersion.output.trim()}`
|
||||
)
|
||||
}
|
||||
const installed = await run(
|
||||
executable,
|
||||
[
|
||||
npmCli,
|
||||
'install',
|
||||
'--save-exact',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--dangerously-allow-all-scripts',
|
||||
'--loglevel=error',
|
||||
npmFixture
|
||||
],
|
||||
npmEnvironment,
|
||||
npmProject
|
||||
)
|
||||
if (installed.exitCode !== 0 || installed.signal) {
|
||||
throw new Error(
|
||||
`Packaged npm install smoke failed: ${installed.output.trim()}`
|
||||
)
|
||||
}
|
||||
const lifecycleMarker = await readFile(
|
||||
join(
|
||||
npmProject,
|
||||
'node_modules',
|
||||
'goodbuddy-packaged-npm-smoke',
|
||||
'lifecycle-ran.txt'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
if (lifecycleMarker !== 'ready\n') {
|
||||
throw new Error('Packaged npm lifecycle smoke failed')
|
||||
}
|
||||
console.log('Packaged DeepSeek Harness utility smoke: ready')
|
||||
console.log(
|
||||
`Packaged npm install smoke: ready (${npmManifest.version})`
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
const { createHash } = require("node:crypto");
|
||||
const {
|
||||
chmod,
|
||||
mkdir,
|
||||
@@ -6,101 +6,84 @@ const {
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} = require('node:fs/promises')
|
||||
const { existsSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const tar = require('tar')
|
||||
const { sha256File } = require('./file-hash.cjs')
|
||||
writeFile,
|
||||
} = require("node:fs/promises");
|
||||
const { existsSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const tar = require("tar");
|
||||
const { sha256File } = require("./file-hash.cjs");
|
||||
const {
|
||||
opencodeVersion,
|
||||
prepareBundledOpenCodeConfig,
|
||||
} = require("./opencode-config.cjs");
|
||||
const { npmInvocation } = require("./npm-invocation.cjs");
|
||||
|
||||
const opencodeVersion = '1.18.9'
|
||||
const architectureNames = {
|
||||
1: 'x64',
|
||||
3: 'arm64'
|
||||
}
|
||||
1: "x64",
|
||||
3: "arm64",
|
||||
};
|
||||
const platformNames = {
|
||||
darwin: 'darwin',
|
||||
linux: 'linux',
|
||||
win32: 'windows'
|
||||
}
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
|
||||
function sha512Integrity(contents) {
|
||||
return `sha512-${createHash('sha512').update(contents).digest('base64')}`
|
||||
return `sha512-${createHash("sha512").update(contents).digest("base64")}`;
|
||||
}
|
||||
|
||||
async function lockedIntegrity(projectDir, packageName) {
|
||||
async function lockedIntegrity(projectDir, packageName, version) {
|
||||
const lock = JSON.parse(
|
||||
await readFile(join(projectDir, 'package-lock.json'), 'utf8')
|
||||
)
|
||||
const entry = lock.packages?.[`node_modules/${packageName}`]
|
||||
await readFile(join(projectDir, "package-lock.json"), "utf8"),
|
||||
);
|
||||
const entry = lock.packages?.[`node_modules/${packageName}`];
|
||||
if (
|
||||
entry?.version !== opencodeVersion ||
|
||||
typeof entry.integrity !== 'string'
|
||||
entry?.version !== version ||
|
||||
typeof entry.integrity !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
`Missing locked ${packageName}@${opencodeVersion} integrity`
|
||||
)
|
||||
`Missing locked ${packageName}@${version} integrity`,
|
||||
);
|
||||
}
|
||||
return entry.integrity
|
||||
return entry.integrity;
|
||||
}
|
||||
|
||||
function npmInvocation() {
|
||||
const npmCli = process.env.npm_execpath
|
||||
if (npmCli) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
prefixArgs: [npmCli]
|
||||
}
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
throw new Error('npm_execpath is required to prepare bundled runtimes')
|
||||
}
|
||||
return {
|
||||
command: 'npm',
|
||||
prefixArgs: []
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPackage(projectDir, packageName, integrity) {
|
||||
const cacheDirectory = join(
|
||||
projectDir,
|
||||
'.runtime-resources',
|
||||
'cache'
|
||||
)
|
||||
await mkdir(cacheDirectory, { recursive: true })
|
||||
async function downloadPackage(projectDir, packageName, version, integrity) {
|
||||
const cacheDirectory = join(projectDir, ".runtime-resources", "cache");
|
||||
await mkdir(cacheDirectory, { recursive: true });
|
||||
const archivePath = join(
|
||||
cacheDirectory,
|
||||
`${packageName}-${opencodeVersion}.tgz`
|
||||
)
|
||||
`${packageName.replaceAll("/", "-").replaceAll("@", "")}-${version}.tgz`,
|
||||
);
|
||||
if (existsSync(archivePath)) {
|
||||
const cached = await readFile(archivePath)
|
||||
const cached = await readFile(archivePath);
|
||||
if (sha512Integrity(cached) === integrity) {
|
||||
return archivePath
|
||||
return archivePath;
|
||||
}
|
||||
await rm(archivePath, { force: true })
|
||||
await rm(archivePath, { force: true });
|
||||
}
|
||||
|
||||
const npm = npmInvocation()
|
||||
const npm = npmInvocation(projectDir);
|
||||
const result = spawnSync(
|
||||
npm.command,
|
||||
[
|
||||
...npm.prefixArgs,
|
||||
'pack',
|
||||
`${packageName}@${opencodeVersion}`,
|
||||
'--ignore-scripts',
|
||||
'--json',
|
||||
'--pack-destination',
|
||||
cacheDirectory
|
||||
"pack",
|
||||
`${packageName}@${version}`,
|
||||
"--ignore-scripts",
|
||||
"--json",
|
||||
"--pack-destination",
|
||||
cacheDirectory,
|
||||
],
|
||||
{
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
shell: false,
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Unable to fetch ${packageName}: ${
|
||||
@@ -108,81 +91,150 @@ async function downloadPackage(projectDir, packageName, integrity) {
|
||||
result.stderr ||
|
||||
result.stdout ||
|
||||
`npm exited with ${result.status}`
|
||||
}`
|
||||
)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const output = JSON.parse(result.stdout)
|
||||
const downloadedPath = join(cacheDirectory, output[0].filename)
|
||||
const contents = await readFile(downloadedPath)
|
||||
const output = JSON.parse(result.stdout);
|
||||
const downloadedPath = join(cacheDirectory, output[0].filename);
|
||||
const contents = await readFile(downloadedPath);
|
||||
if (sha512Integrity(contents) !== integrity) {
|
||||
await rm(downloadedPath, { force: true })
|
||||
throw new Error(`Integrity verification failed for ${packageName}`)
|
||||
await rm(downloadedPath, { force: true });
|
||||
throw new Error(`Integrity verification failed for ${packageName}`);
|
||||
}
|
||||
if (downloadedPath !== archivePath) {
|
||||
await rm(archivePath, { force: true })
|
||||
await rename(downloadedPath, archivePath)
|
||||
await rm(archivePath, { force: true });
|
||||
await rename(downloadedPath, archivePath);
|
||||
}
|
||||
return archivePath
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
module.exports = async function prepareBundledRuntimes(context) {
|
||||
const platform = context.electronPlatformName
|
||||
const architecture = architectureNames[context.arch]
|
||||
const packagePlatform = platformNames[platform]
|
||||
if (!architecture || !packagePlatform) {
|
||||
throw new Error(
|
||||
`Bundled OpenCode does not support ${platform}/${context.arch}`
|
||||
)
|
||||
}
|
||||
|
||||
const suffix =
|
||||
architecture === 'x64' ? `${architecture}-baseline` : architecture
|
||||
const packageName = `opencode-${packagePlatform}-${suffix}`
|
||||
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 targetDirectory = join(
|
||||
projectDir,
|
||||
'.runtime-resources',
|
||||
architecture
|
||||
)
|
||||
const readyPath = join(targetDirectory, '.ready.json')
|
||||
const executable = platform === 'win32' ? 'opencode.exe' : 'opencode'
|
||||
const preparedPath = join(targetDirectory, executable)
|
||||
const identity = {
|
||||
packageName,
|
||||
version: opencodeVersion,
|
||||
integrity
|
||||
}
|
||||
async function prepareRipgrep(
|
||||
projectDir,
|
||||
targetDirectory,
|
||||
platform,
|
||||
architecture,
|
||||
version,
|
||||
) {
|
||||
const packageName = `@vscode/ripgrep-${platform}-${architecture}`;
|
||||
const integrity = await lockedIntegrity(projectDir, packageName, version);
|
||||
const executable = platform === "win32" ? "rg.exe" : "rg";
|
||||
const preparedPath = join(targetDirectory, executable);
|
||||
const readyPath = join(targetDirectory, ".ripgrep-ready.json");
|
||||
const identity = { packageName, version, integrity };
|
||||
try {
|
||||
const ready = JSON.parse(await readFile(readyPath, 'utf8'))
|
||||
const ready = JSON.parse(await readFile(readyPath, "utf8"));
|
||||
if (
|
||||
ready.packageName === identity.packageName &&
|
||||
ready.version === identity.version &&
|
||||
ready.integrity === identity.integrity &&
|
||||
typeof ready.executableSha256 === 'string' &&
|
||||
(platform === 'win32' ||
|
||||
typeof ready.executableSha256 === "string" &&
|
||||
(platform === "win32" || ((await stat(preparedPath)).mode & 0o111) !== 0) &&
|
||||
(await sha256File(preparedPath)) === ready.executableSha256
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Rebuild an incomplete or stale ripgrep cache.
|
||||
}
|
||||
|
||||
const archivePath = await downloadPackage(
|
||||
projectDir,
|
||||
packageName,
|
||||
version,
|
||||
integrity,
|
||||
);
|
||||
const stagingDirectory = `${targetDirectory}.ripgrep-staging-${process.pid}`;
|
||||
await rm(stagingDirectory, { recursive: true, force: true });
|
||||
await mkdir(stagingDirectory, { recursive: true });
|
||||
try {
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: stagingDirectory,
|
||||
strip: 1,
|
||||
});
|
||||
const sourcePath = join(stagingDirectory, "bin", executable);
|
||||
const executableSha256 = await sha256File(sourcePath);
|
||||
if (platform !== "win32") {
|
||||
await chmod(sourcePath, 0o755);
|
||||
}
|
||||
await mkdir(targetDirectory, { recursive: true });
|
||||
await rm(preparedPath, { force: true });
|
||||
await rename(sourcePath, preparedPath);
|
||||
await writeFile(
|
||||
readyPath,
|
||||
JSON.stringify({ ...identity, executableSha256 }),
|
||||
"utf8",
|
||||
);
|
||||
} finally {
|
||||
await rm(stagingDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = async function prepareBundledRuntimes(context) {
|
||||
const platform = context.electronPlatformName;
|
||||
const architecture = architectureNames[context.arch];
|
||||
const packagePlatform = platformNames[platform];
|
||||
if (!architecture || !packagePlatform) {
|
||||
throw new Error(
|
||||
`Bundled OpenCode does not support ${platform}/${context.arch}`,
|
||||
);
|
||||
}
|
||||
|
||||
const suffix =
|
||||
architecture === "x64" ? `${architecture}-baseline` : architecture;
|
||||
const packageName = `opencode-${packagePlatform}-${suffix}`;
|
||||
const projectDir = context.packager.projectDir;
|
||||
await prepareBundledOpenCodeConfig(projectDir);
|
||||
const projectPackage = JSON.parse(
|
||||
await readFile(join(projectDir, "package.json"), "utf8"),
|
||||
);
|
||||
const ripgrepVersion = projectPackage.dependencies["@vscode/ripgrep"];
|
||||
if (typeof ripgrepVersion !== "string" || !/^\d+\.\d+\.\d+$/u.test(ripgrepVersion)) {
|
||||
throw new Error("Missing exact @vscode/ripgrep dependency version");
|
||||
}
|
||||
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, opencodeVersion);
|
||||
const targetDirectory = join(projectDir, ".runtime-resources", architecture);
|
||||
const readyPath = join(targetDirectory, ".ready.json");
|
||||
const executable = platform === "win32" ? "opencode.exe" : "opencode";
|
||||
const preparedPath = join(targetDirectory, executable);
|
||||
const identity = {
|
||||
packageName,
|
||||
version: opencodeVersion,
|
||||
integrity,
|
||||
};
|
||||
try {
|
||||
const ready = JSON.parse(await readFile(readyPath, "utf8"));
|
||||
if (
|
||||
ready.packageName === identity.packageName &&
|
||||
ready.version === identity.version &&
|
||||
ready.integrity === identity.integrity &&
|
||||
typeof ready.executableSha256 === "string" &&
|
||||
(platform === "win32" ||
|
||||
((await stat(preparedPath)).mode & 0o111) !== 0) &&
|
||||
(await sha256File(preparedPath)) === ready.executableSha256
|
||||
) {
|
||||
return
|
||||
await prepareRipgrep(
|
||||
projectDir,
|
||||
targetDirectory,
|
||||
platform,
|
||||
architecture,
|
||||
ripgrepVersion,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Rebuild an incomplete or stale runtime cache.
|
||||
@@ -191,32 +243,40 @@ module.exports = async function prepareBundledRuntimes(context) {
|
||||
const archivePath = await downloadPackage(
|
||||
projectDir,
|
||||
packageName,
|
||||
integrity
|
||||
)
|
||||
const stagingDirectory = `${targetDirectory}.staging-${process.pid}`
|
||||
await rm(stagingDirectory, { recursive: true, force: true })
|
||||
await mkdir(stagingDirectory, { recursive: true })
|
||||
opencodeVersion,
|
||||
integrity,
|
||||
);
|
||||
const stagingDirectory = `${targetDirectory}.staging-${process.pid}`;
|
||||
await rm(stagingDirectory, { recursive: true, force: true });
|
||||
await mkdir(stagingDirectory, { recursive: true });
|
||||
try {
|
||||
await tar.x({
|
||||
file: archivePath,
|
||||
cwd: stagingDirectory,
|
||||
strip: 1
|
||||
})
|
||||
const sourcePath = join(stagingDirectory, 'bin', executable)
|
||||
const stagingExecutable = join(stagingDirectory, executable)
|
||||
await rename(sourcePath, stagingExecutable)
|
||||
if (platform !== 'win32') {
|
||||
await chmod(stagingExecutable, 0o755)
|
||||
strip: 1,
|
||||
});
|
||||
const sourcePath = join(stagingDirectory, "bin", executable);
|
||||
const stagingExecutable = join(stagingDirectory, executable);
|
||||
await rename(sourcePath, stagingExecutable);
|
||||
if (platform !== "win32") {
|
||||
await chmod(stagingExecutable, 0o755);
|
||||
}
|
||||
const executableSha256 = await sha256File(stagingExecutable)
|
||||
const executableSha256 = await sha256File(stagingExecutable);
|
||||
await writeFile(
|
||||
join(stagingDirectory, '.ready.json'),
|
||||
join(stagingDirectory, ".ready.json"),
|
||||
JSON.stringify({ ...identity, executableSha256 }),
|
||||
'utf8'
|
||||
)
|
||||
await rm(targetDirectory, { recursive: true, force: true })
|
||||
await rename(stagingDirectory, targetDirectory)
|
||||
"utf8",
|
||||
);
|
||||
await rm(targetDirectory, { recursive: true, force: true });
|
||||
await rename(stagingDirectory, targetDirectory);
|
||||
} finally {
|
||||
await rm(stagingDirectory, { recursive: true, force: true })
|
||||
await rm(stagingDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
await prepareRipgrep(
|
||||
projectDir,
|
||||
targetDirectory,
|
||||
platform,
|
||||
architecture,
|
||||
ripgrepVersion,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
function preflightRegisteredProductionKey(options) {
|
||||
const keyId = options.keyId
|
||||
if (!keyId) {
|
||||
throw new Error(options.missingKeyIdMessage)
|
||||
}
|
||||
const signingRecord = options.registry.keys.find(
|
||||
(key) => key.keyId === keyId
|
||||
)
|
||||
if (!signingRecord) {
|
||||
throw new Error(
|
||||
`Production ${options.component} public signing key ID "${keyId}" is absent from resources/agent-release-keys.json; provision the matching public key before building a release`
|
||||
)
|
||||
}
|
||||
if (signingRecord.environment !== 'production') {
|
||||
throw new Error(
|
||||
`Production ${options.component} signing key ID "${keyId}" is not registered for production`
|
||||
)
|
||||
}
|
||||
if (
|
||||
options.registry.revocations.some(
|
||||
(revocation) => revocation.keyId === keyId
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Production ${options.component} signing key ID "${keyId}" is revoked`
|
||||
)
|
||||
}
|
||||
return signingRecord
|
||||
}
|
||||
|
||||
module.exports = { preflightRegisteredProductionKey }
|
||||
@@ -0,0 +1,39 @@
|
||||
const { readdirSync, readFileSync, statSync } = require('node:fs')
|
||||
const { join, relative } = require('node:path')
|
||||
const { opencodeVersion } = require('./opencode-config.cjs')
|
||||
|
||||
function verifyOpenCodeConfig(resources, source) {
|
||||
const destination = join(resources, 'runtimes', 'opencode-config')
|
||||
const manifestPath = join(destination, 'node_modules', '@opencode-ai', 'plugin', 'package.json')
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
if (manifest.name !== '@opencode-ai/plugin' || manifest.version !== opencodeVersion) {
|
||||
throw new Error('Packaged OpenCode plugin version is invalid')
|
||||
}
|
||||
const pending = [source]
|
||||
let files = 0
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop()
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(file)
|
||||
} else if (entry.isFile()) {
|
||||
const target = join(destination, relative(source, file))
|
||||
if (!statSync(target, { throwIfNoEntry: false })?.isFile() ||
|
||||
!readFileSync(target).equals(readFileSync(file))) {
|
||||
throw new Error(`Packaged OpenCode dependency is missing or changed: ${relative(source, file)}`)
|
||||
}
|
||||
files++
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function verifyHarnessBundleImports(source, name) {
|
||||
if (/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?|\brequire\s*\(\s*)["']@deepseek-ai\//u.test(source)) {
|
||||
throw new Error(`DeepSeek Harness bundle contains an external DeepSeek dependency: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifyOpenCodeConfig, verifyHarnessBundleImports }
|
||||
@@ -0,0 +1,71 @@
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { resolve } = require('node:path')
|
||||
const {
|
||||
targetDefinitions
|
||||
} = require('./aggregate-release.cjs')
|
||||
|
||||
const expectedInstallerCount = targetDefinitions.reduce(
|
||||
(count, target) => count + target.formats.length,
|
||||
0
|
||||
)
|
||||
|
||||
function parseArguments(argv) {
|
||||
if (argv.length !== 2 || argv[0] !== '--manifest' || !argv[1]) {
|
||||
throw new Error('必须指定 --manifest')
|
||||
}
|
||||
return { manifest: resolve(argv[1]) }
|
||||
}
|
||||
|
||||
async function verifySiteRelease(manifest, request = fetch) {
|
||||
const files = Object.values(manifest?.targets ?? {}).flatMap(
|
||||
(target) => Object.values(target?.files ?? {})
|
||||
)
|
||||
if (files.length !== expectedInstallerCount) {
|
||||
throw new Error(`官网发布文件数量错误:${files.length}`)
|
||||
}
|
||||
const urls = new Set()
|
||||
for (const file of files) {
|
||||
if (
|
||||
typeof file?.url !== 'string' ||
|
||||
!Number.isSafeInteger(file.size) ||
|
||||
file.size < 1 ||
|
||||
urls.has(file.url)
|
||||
) {
|
||||
throw new Error('官网发布文件元数据无效')
|
||||
}
|
||||
urls.add(file.url)
|
||||
const response = await request(file.url, {
|
||||
method: 'HEAD',
|
||||
redirect: 'error'
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`OSS 文件不可访问:${file.url}(${response.status})`)
|
||||
}
|
||||
const contentLength = Number(response.headers.get('content-length'))
|
||||
if (contentLength !== file.size) {
|
||||
throw new Error(
|
||||
`OSS 文件大小不匹配:${file.url},期望 ${file.size},实际 ${contentLength}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return files.length
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArguments(argv)
|
||||
const manifest = JSON.parse(readFileSync(options.manifest, 'utf8'))
|
||||
const count = await verifySiteRelease(manifest)
|
||||
console.log(`OSS 发布文件验证通过:${count} 个`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseArguments,
|
||||
verifySiteRelease
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
const { readSync } = require('node:fs')
|
||||
|
||||
const localFileHeaderSignature = 0x04034b50
|
||||
const centralDirectoryHeaderSignature = 0x02014b50
|
||||
const endOfCentralDirectorySignature = 0x06054b50
|
||||
|
||||
function readExactZipBytes(handle, length, position, label) {
|
||||
const output = Buffer.allocUnsafe(length)
|
||||
let offset = 0
|
||||
while (offset < length) {
|
||||
const bytesRead = readSync(
|
||||
handle,
|
||||
output,
|
||||
offset,
|
||||
length - offset,
|
||||
position + offset
|
||||
)
|
||||
if (bytesRead <= 0) {
|
||||
throw new Error(`${label} is truncated`)
|
||||
}
|
||||
offset += bytesRead
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function readZipCentralDirectory(handle, archiveSize, limits) {
|
||||
if (archiveSize < 22) {
|
||||
throw new Error(`${limits.label} central directory is missing`)
|
||||
}
|
||||
const endSize = Math.min(archiveSize, 65_557)
|
||||
const endStart = archiveSize - endSize
|
||||
const end = readExactZipBytes(
|
||||
handle,
|
||||
endSize,
|
||||
endStart,
|
||||
`${limits.label} end record`
|
||||
)
|
||||
let endOffset = -1
|
||||
for (let index = end.length - 22; index >= 0; index -= 1) {
|
||||
if (
|
||||
end.readUInt32LE(index) === endOfCentralDirectorySignature &&
|
||||
index + 22 + end.readUInt16LE(index + 20) === end.length
|
||||
) {
|
||||
endOffset = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if (endOffset < 0) {
|
||||
throw new Error(`${limits.label} central directory is missing`)
|
||||
}
|
||||
const diskNumber = end.readUInt16LE(endOffset + 4)
|
||||
const centralDisk = end.readUInt16LE(endOffset + 6)
|
||||
const diskEntryCount = end.readUInt16LE(endOffset + 8)
|
||||
const entryCount = end.readUInt16LE(endOffset + 10)
|
||||
const centralSize = end.readUInt32LE(endOffset + 12)
|
||||
const centralOffset = end.readUInt32LE(endOffset + 16)
|
||||
const absoluteEndOffset = endStart + endOffset
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
centralDisk !== 0 ||
|
||||
diskEntryCount !== entryCount ||
|
||||
entryCount === 0 ||
|
||||
entryCount === 0xffff ||
|
||||
entryCount < limits.minimumEntries ||
|
||||
entryCount > limits.maximumEntries ||
|
||||
centralSize === 0xffffffff ||
|
||||
centralSize < 46 ||
|
||||
centralSize > limits.maximumCentralDirectoryBytes ||
|
||||
centralOffset === 0xffffffff ||
|
||||
centralOffset + centralSize !== absoluteEndOffset
|
||||
) {
|
||||
throw new Error(`${limits.label} central directory is invalid`)
|
||||
}
|
||||
const central = readExactZipBytes(
|
||||
handle,
|
||||
centralSize,
|
||||
centralOffset,
|
||||
`${limits.label} central directory`
|
||||
)
|
||||
const entries = []
|
||||
let offset = 0
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (
|
||||
offset + 46 > central.length ||
|
||||
central.readUInt32LE(offset) !==
|
||||
centralDirectoryHeaderSignature
|
||||
) {
|
||||
throw new Error(
|
||||
`${limits.label} central directory entry is invalid`
|
||||
)
|
||||
}
|
||||
const nameLength = central.readUInt16LE(offset + 28)
|
||||
const extraLength = central.readUInt16LE(offset + 30)
|
||||
const commentLength = central.readUInt16LE(offset + 32)
|
||||
const entryLength =
|
||||
46 + nameLength + extraLength + commentLength
|
||||
if (offset + entryLength > central.length) {
|
||||
throw new Error(
|
||||
`${limits.label} central directory entry exceeds its bounds`
|
||||
)
|
||||
}
|
||||
const flags = central.readUInt16LE(offset + 8)
|
||||
const nameBytes = central.subarray(
|
||||
offset + 46,
|
||||
offset + 46 + nameLength
|
||||
)
|
||||
const name = new TextDecoder(
|
||||
flags & 0x0800 ? 'utf-8' : 'latin1',
|
||||
{ fatal: true }
|
||||
).decode(nameBytes)
|
||||
entries.push({
|
||||
name,
|
||||
nameBytes: Buffer.from(nameBytes),
|
||||
flags,
|
||||
compression: central.readUInt16LE(offset + 10),
|
||||
checksum: central.readUInt32LE(offset + 16),
|
||||
compressedSize: central.readUInt32LE(offset + 20),
|
||||
size: central.readUInt32LE(offset + 24),
|
||||
diskStart: central.readUInt16LE(offset + 34),
|
||||
localOffset: central.readUInt32LE(offset + 42)
|
||||
})
|
||||
offset += entryLength
|
||||
}
|
||||
if (offset !== central.length) {
|
||||
throw new Error(
|
||||
`${limits.label} central directory count is invalid`
|
||||
)
|
||||
}
|
||||
return {
|
||||
centralOffset,
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
centralDirectoryHeaderSignature,
|
||||
endOfCentralDirectorySignature,
|
||||
localFileHeaderSignature,
|
||||
readExactZipBytes,
|
||||
readZipCentralDirectory
|
||||
}
|
||||
@@ -5,39 +5,49 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-08-13 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-08-19 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 文档角色 | 自动任务、目标、并行实验、会话监督、分区记忆与持续学习的总纲 |
|
||||
| 文档角色 | Task/Job、调度、目标、并行实验、会话监督、分区记忆与持续学习的总纲 |
|
||||
| 领域模型 | [Task 与 Job 统一领域模型](../features/task-and-job/task-and-job-model.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
GoodBuddy 当前已经具备若干长期助手能力,但它们仍是彼此分离的功能:
|
||||
|
||||
1. 定时任务支持单次、每日和每周触发,创建 Ask 任务并保存任务和成果。
|
||||
2. 智能心跳支持全局或项目范围的每日、每周回顾,读取有界会话、任务和已确认记忆,
|
||||
1. 当前 Schedule 已支持单次、每日和每周触发,并在创建时绑定稳定产品级 Task 与真实
|
||||
Conversation;重复触发复用同一身份,文本结果写回 Conversation,独立文件和图片保存为
|
||||
Artifact。到期执行与用户在回复期间继续发送的普通消息共用 Conversation 级持久队列,
|
||||
因而不会与当前回复并发写入同一时间线。IANA 时区、Cron、事件触发、租约、重试和完整
|
||||
Job/Run 抽象仍待实现。
|
||||
2. 当前智能心跳支持全局或项目范围的每日、每周回顾,读取有界会话、任务和已确认记忆,
|
||||
生成摘要、记忆建议和后续任务。
|
||||
3. 专家子任务支持有限并发和只读综合,但没有实验变量、重复运行、统一指标和结果晋升。
|
||||
3. 专家执行的 Job 支持有限并发和只读综合,但没有实验变量、重复运行、统一指标和结果晋升。
|
||||
4. 记忆已有全局、项目、会话三种作用域,以及偏好、事实、摘要、流程四种类型,
|
||||
但检索、来源、时态、冲突和运行级隔离仍不完整。
|
||||
5. 魔法笔记已经提供“内容旁持续出现 AI 评论”的交互,可作为会话监督的体验参考,
|
||||
但它只分析笔记或待办,不观察会话和任务运行。
|
||||
|
||||
如果继续把更多能力加入“智能心跳”,心跳将同时承担调度、总结、执行、监督、学习和
|
||||
记忆管理,最终无法解释一次后台行为为什么发生、读取了什么、是否越权、产生了什么影响。
|
||||
智能心跳长期方向是面向未来的分区记忆,但该模型尚未设计。近期只改善现有心跳的权威入口
|
||||
与 Global / 多 Project 范围,并保留 Task Center 作为 Task 索引。若继续把 Task、调度、
|
||||
监督、学习和执行加入“智能心跳”,将无法解释一次后台行为为什么发生、读取了什么、是否
|
||||
越权、产生了什么影响。
|
||||
|
||||
本设计将这些能力统一到一个平台模型中,同时保留不同产品的清晰边界。
|
||||
|
||||
## 2. 核心产品判断
|
||||
|
||||
### 2.1 不把心跳升级成万能后台 Agent
|
||||
### 2.1 智能心跳保持独立,分区记忆协同另行设计
|
||||
|
||||
智能心跳应继续承担周期性观察和回顾,不直接成为所有自动化的宿主。
|
||||
当前智能心跳继续承担周期回顾、报告和建议,并支持 Global 或指定 Project 范围。它不是
|
||||
Task、通用调度器或后台 Agent,也不进入统一 `AutomationPlan.kind`。按分区回顾、候选生成
|
||||
以及时间/内部事件唤起需要独立设计,不能从当前心跳行为直接推导,也不创建平行记忆类型。
|
||||
|
||||
- 定时任务解决“何时执行一个已知任务”。
|
||||
- 目标任务解决“围绕结果持续规划和推进”。
|
||||
- Scheduled Task 解决“何时在一个 Task 中执行新的 Job”。
|
||||
- Goal Task 解决“围绕结果在同一 Task 中持续规划和推进”。
|
||||
- 并行实验解决“隔离多个候选并用相同标准比较”。
|
||||
- 会话监督解决“独立观察并在必要时评论、告警或暂停”。
|
||||
- 智能心跳当前解决“在什么范围周期回顾并提出报告和建议”。
|
||||
- 记忆系统解决“哪些经验可以在什么范围内被未来运行读取”。
|
||||
- 持续学习解决“候选经验如何经过评估后改变未来行为”。
|
||||
|
||||
@@ -77,7 +87,7 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现
|
||||
- 工具权限和审批策略。
|
||||
- Electron 安全边界。
|
||||
- 项目根目录和数据访问范围。
|
||||
- Runtime 沙箱。
|
||||
- Runtime 当前用户执行权限与 Ask/Execute 边界。
|
||||
- 系统级提示词。
|
||||
- 远程消息发送或其他外部副作用策略。
|
||||
|
||||
@@ -85,8 +95,9 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现
|
||||
|
||||
### 3.1 用户目标
|
||||
|
||||
- 用统一入口创建定时、事件、目标和实验型自动任务。
|
||||
- 清楚知道自动任务的触发原因、当前目标、运行状态、预算和停止条件。
|
||||
- 在现有 Task Center 中找到 Scheduled、Event 和 Goal Task,并直接打开关联 Conversation
|
||||
和对应 Task。
|
||||
- 清楚知道 Task 的触发原因、当前目标、聚合执行状态、预算和停止条件。
|
||||
- 在一个工作台中观察多个候选运行,并追溯结论到原始证据。
|
||||
- 为重要会话启用独立监督,及时发现偏题、遗漏、矛盾、证据不足和风险。
|
||||
- 知道每条记忆属于哪个范围、从哪里产生、何时有效以及被哪些运行使用。
|
||||
@@ -95,6 +106,8 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现
|
||||
### 3.2 产品目标
|
||||
|
||||
- 复用现有 Project、Conversation、Task、Run、Artifact、Approval 和 Notification 能力。
|
||||
- 保持每个 Task 只关联一条 Conversation,同时允许一条 Conversation 承载多个 Task,不为
|
||||
同一项工作建立第二份内容载体。
|
||||
- 为所有后台工作提供统一的幂等、租约、恢复、取消、预算和审计语义。
|
||||
- 保持 Ask 只读,Execute 继续经过现有能力和审批控制。
|
||||
- 保持本地优先,应用退出后不虚假承诺后台持续执行。
|
||||
@@ -117,20 +130,28 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现
|
||||
|
||||
## 5. 统一领域模型
|
||||
|
||||
以下模型是 Scheduled Task、Goal Task 和实验共享的技术基础,不要求新增独立
|
||||
Automation Center。`AutomationPlan` 是 Task 的计划配置,`Job` 是 Task 内执行单位,
|
||||
`Run` 是执行尝试。用户主要通过 Task Center、左侧会话 Task 列表和关联 Conversation
|
||||
理解工作;当前 UI 不展示 Job/Run 层级。智能心跳
|
||||
不属于此模型。
|
||||
|
||||
### 5.1 核心实体
|
||||
|
||||
```text
|
||||
AutomationPlan
|
||||
├─ TriggerPolicy
|
||||
├─ ObjectiveSet
|
||||
├─ ExecutionProtocol
|
||||
├─ BudgetPolicy
|
||||
├─ ApprovalPolicy
|
||||
├─ SupervisorPolicy
|
||||
└─ MemoryBinding
|
||||
│
|
||||
└─ AutomationRun
|
||||
├─ Task / Child Task
|
||||
Conversation
|
||||
└─ Task 0..N
|
||||
├─ AutomationPlan(可选)
|
||||
│ ├─ TriggerPolicy
|
||||
│ ├─ ObjectiveSet
|
||||
│ ├─ ExecutionProtocol
|
||||
│ ├─ BudgetPolicy
|
||||
│ ├─ ApprovalPolicy
|
||||
│ ├─ SupervisorPolicy
|
||||
│ └─ MemoryBinding
|
||||
└─ Job
|
||||
├─ Run
|
||||
├─ Subjob
|
||||
├─ Observation
|
||||
├─ SupervisorDecision
|
||||
├─ Artifact
|
||||
@@ -140,15 +161,17 @@ AutomationPlan
|
||||
|
||||
| 实体 | 职责 |
|
||||
| --- | --- |
|
||||
| `AutomationPlan` | 用户可编辑的长期定义,描述做什么、为何做、何时做和允许做什么 |
|
||||
| `Task` | 用户可见工作单位,只关联一条 Conversation;Conversation 可以承载多个 Task |
|
||||
| `Job` | Task 内部一次步骤、触发、并行分支或委派执行 |
|
||||
| `Run` | Task/Job 的一次执行尝试和审计记录 |
|
||||
| `AutomationPlan` | Task 的可编辑计划配置,描述做什么、为何做、何时做和允许做什么 |
|
||||
| `TriggerPolicy` | 手动、时间、事件或条件触发,以及错过执行策略 |
|
||||
| `ObjectiveSet` | 成功标准、优化指标、约束和停止条件 |
|
||||
| `ExecutionProtocol` | 本次运行冻结的提示、步骤模板、变量、Runtime、工具和数据范围 |
|
||||
| `BudgetPolicy` | 最大耗时、模型调用、Token、工具次数、子任务数、成果大小和并发 |
|
||||
| `BudgetPolicy` | 最大耗时、模型调用、Token、工具次数、Job/Subjob 数、成果大小和并发 |
|
||||
| `ApprovalPolicy` | 哪些动作可自动执行、哪些等待批准、哪些禁止 |
|
||||
| `SupervisorPolicy` | 观察维度、触发频率、干预级别和确定性门禁 |
|
||||
| `MemoryBinding` | 运行可读取和可写入哪些记忆分区 |
|
||||
| `AutomationRun` | 一次触发产生的不可变运行快照和聚合状态 |
|
||||
| `Observation` | 对消息、步骤、工具、指标或系统状态的结构化观察 |
|
||||
| `SupervisorDecision` | `continue`、`comment`、`warn`、`request_review`、`pause` 或 `stop` |
|
||||
| `Metric` | 可复现的运行指标及其计算来源 |
|
||||
@@ -161,12 +184,11 @@ AutomationPlan
|
||||
| 类型 | 说明 |
|
||||
| --- | --- |
|
||||
| `scheduled_task` | 到点运行一个固定任务 |
|
||||
| `heartbeat_review` | 周期性观察会话、任务和记忆,输出回顾和建议 |
|
||||
| `goal_loop` | 围绕目标重复执行“观察、计划、行动、评估” |
|
||||
| `experiment` | 生成隔离候选 Run,按统一协议评估和比较 |
|
||||
|
||||
会话监督不是独立执行任务。它是可附着到 Conversation、Task、AutomationRun 或
|
||||
Experiment 的 `SupervisorPolicy` 和监督会话。
|
||||
会话监督不是独立 Task。用户选择 Conversation、Task 或 Experiment 作为监督对象;
|
||||
`SupervisorPolicy` 可以在内部观察所属 Job/Run 事件,但当前 UI 不把它们作为独立目标。
|
||||
|
||||
### 5.3 运行快照
|
||||
|
||||
@@ -183,7 +205,8 @@ Experiment 的 `SupervisorPolicy` 和监督会话。
|
||||
- 预算和并发限制。
|
||||
- 审批策略。
|
||||
|
||||
运行开始后的设置变化只影响下一次 Run。用户可以查看当前 Run 与最新 Plan 的差异。
|
||||
运行开始后的设置变化只影响下一次 Run。用户可以在 Task 执行记录中查看当次快照与最新
|
||||
Plan 的差异,但 Run 不作为独立导航对象。
|
||||
|
||||
## 6. 统一状态模型
|
||||
|
||||
@@ -235,34 +258,59 @@ inactive → observing → attention_required → paused → resolved
|
||||
|
||||
```text
|
||||
Trigger
|
||||
→ AutomationCoordinator 声明 Run
|
||||
→ RunQueue 按优先级和预算排队
|
||||
→ AutomationExecutor 创建 Task
|
||||
→ AutomationCoordinator 在所属 Task 内声明 Job
|
||||
→ ExecutionQueue 按优先级和预算排队
|
||||
→ AutomationExecutor 为 Job 创建或恢复 Run
|
||||
→ Runtime 执行
|
||||
→ Supervisor 观察
|
||||
→ Evaluator 计算指标
|
||||
→ 结果、证据和候选记忆入库
|
||||
→ 用户审查或后续 Run
|
||||
→ 用户审查或后续执行
|
||||
```
|
||||
|
||||
`AutomationCoordinator` 只负责触发、声明和恢复,不直接调用模型。执行仍通过任务和 Runtime
|
||||
边界完成。
|
||||
`AutomationCoordinator` 只负责触发、声明和恢复,不直接调用模型。执行仍通过 Job 和
|
||||
Runtime 边界完成,用户可见结果通过所属 Task 汇入关联 Conversation。
|
||||
|
||||
### 7.2 优先级
|
||||
|
||||
默认优先级从高到低:
|
||||
|
||||
1. 用户正在等待的前台对话。
|
||||
2. 用户手动启动的 Run。
|
||||
3. 等待批准后恢复的 Run。
|
||||
4. 到期定时任务。
|
||||
5. 目标循环和实验 Run。
|
||||
2. 用户手动启动的 Task 执行。
|
||||
3. 等待批准后恢复的 Task 执行。
|
||||
4. 到期 Scheduled Task 的执行。
|
||||
5. 目标循环和实验执行。
|
||||
6. 心跳回顾、记忆巩固和维护。
|
||||
|
||||
后台任务必须可被背压延后。延后记录为 `deferred`,不得丢失,也不得在系统恢复空闲时一次性
|
||||
释放全部积压。
|
||||
|
||||
### 7.3 幂等和租约
|
||||
### 7.3 Conversation 输入仲裁
|
||||
|
||||
当前实现以 Main 和 SQLite 中的 `conversation_queue_items` 作为每条 Conversation 的权威
|
||||
输入队列,而不是在 Renderer 分别维护聊天草稿队列和 Scheduled Task 队列:
|
||||
|
||||
- 普通消息在发送时冻结 Runtime、工作模式、专家/团队、知识范围和附件上下文,再以
|
||||
`source=user` 入队;附件内容使用有界序列化保存,应用重启后仍可恢复。
|
||||
- 到期或手动启动的 Scheduled Task 先建立 `schedule_run`,再以 `source=schedule` 进入同一
|
||||
队列,只派发任务要求和关联会话身份。执行时读取会话最新历史和配置,不保存任务上下文快照。
|
||||
- Main 对每条 Conversation 只保留一个活动请求或 Renderer 派发保留位。默认按 FIFO 认领;
|
||||
Scheduled Task 不另设执行器或并发计数。
|
||||
- 两种来源均由 Main 派发给 Renderer,建立普通用户消息和流式助手消息后调用 `agent.run`。
|
||||
Renderer 加载会话及设置后才声明队列可派发。SSH 项目同样通过该入口选择远程 Runtime,
|
||||
使用普通请求的上下文、工具范围、取消和恢复流程。
|
||||
- `schedule_run.id` 作为普通请求 ID;普通请求终态同步该次计划运行与稳定产品 Task 的状态。
|
||||
尚未接受的派发可释放重试;已经接受的运行在重启后按普通 Task 状态结算或恢复,不重新发送。
|
||||
- 当前执行到达终态后再认领下一项。删除只移除尚未执行的项;“立即中断并插入”取消当前
|
||||
请求并把所选项设为下一项,不重排其他项。
|
||||
- 每条 Conversation 最多保留 20 个用户可提交的待执行项。启动时将未完成的派发恢复为
|
||||
`pending`,但应用退出期间不会实际执行任务。
|
||||
|
||||
Renderer 只通过显式 IPC 列出、加入、删除、提升、释放和接收用户队列项;Main 在接受
|
||||
`agent.run` 时校验队列项仍处于 `dispatching` 且属于同一 Conversation,防止 Renderer
|
||||
绕过顺序仲裁。
|
||||
|
||||
### 7.4 幂等和租约
|
||||
|
||||
- 每次计划触发使用 `planId + scheduledFor + planVersion` 形成幂等键。
|
||||
- 手动触发使用调用方提供的单次幂等键。
|
||||
@@ -372,43 +420,40 @@ Trigger
|
||||
|
||||
## 12. 信息架构
|
||||
|
||||
建议将现有“智能心跳”逐步扩展为“自动化中心”,但保留心跳作为一种计划:
|
||||
当前阶段保留任务中心并适度完善,不新增独立自动化中心。智能心跳使用自己的菜单入口,
|
||||
并已在现有模型上实现 Global / 多 Project 范围与唯一配置入口:
|
||||
|
||||
```text
|
||||
自动化中心
|
||||
├─ 概览
|
||||
│ ├─ 正在运行
|
||||
│ ├─ 等待审批
|
||||
│ ├─ 需要关注
|
||||
│ └─ 最近结果
|
||||
├─ 计划
|
||||
│ ├─ 定时任务
|
||||
│ ├─ 智能心跳
|
||||
│ ├─ 目标任务
|
||||
│ └─ 实验
|
||||
├─ 运行
|
||||
│ ├─ 时间线
|
||||
│ ├─ 任务与步骤
|
||||
│ ├─ 监督记录
|
||||
│ ├─ 指标与证据
|
||||
│ └─ 成果
|
||||
├─ 建议
|
||||
│ ├─ 记忆候选
|
||||
│ ├─ 后续任务
|
||||
│ └─ 学习候选
|
||||
└─ 设置
|
||||
├─ 全局预算
|
||||
├─ 后台优先级
|
||||
├─ 通知
|
||||
└─ 数据保留
|
||||
任务中心
|
||||
├─ 需要关注
|
||||
├─ 进行中
|
||||
├─ 已暂停
|
||||
└─ 已结束
|
||||
└─ 打开任务自身
|
||||
|
||||
任务自身
|
||||
├─ 消息时间线
|
||||
├─ Run、步骤与审批活动
|
||||
├─ 监督、指标与证据
|
||||
└─ 独立成果
|
||||
|
||||
智能心跳
|
||||
├─ 运行概览
|
||||
├─ 待处理建议
|
||||
├─ 报告与记录
|
||||
└─ 心跳计划
|
||||
└─ Global / 指定 Project
|
||||
```
|
||||
|
||||
会话页面增加可折叠“监督”右栏,与任务、上下文和成果并列,或在已有右侧工作栏中新增页签。
|
||||
监督统一进入应用级助手工作栏中固定且始终可访问的“监督”栏目,不再保留“独立可折叠右栏”
|
||||
和“动态新增页签”两种实现。栏目默认跟随当前上下文,用户可以固定到其他 Conversation、
|
||||
Task 或实验对象;详细范围与交互契约见
|
||||
[通用助手工作栏与执行空间 PRD](../features/assistant-workbar/prd.md)。
|
||||
|
||||
## 13. 安全与隐私
|
||||
|
||||
1. Ask 在 Runtime 边界保持只读,而不只是提示词要求只读。
|
||||
2. Execute 继续通过现有审批、沙箱、工具和目录控制。
|
||||
2. Execute 继续通过现有审批、主机执行策略、工具和目录控制。
|
||||
3. 无人值守只允许用户显式批准的能力集合;遇到未预授权动作时进入等待审批。
|
||||
4. Supervisor、Evaluator 和 Heartbeat 都把消息、工具输出、记忆和成果视为不可信数据。
|
||||
5. 监督器不能读取隐藏推理,只能读取产品允许持久化和展示的事件。
|
||||
@@ -421,7 +466,7 @@ Trigger
|
||||
|
||||
## 14. 可观测性
|
||||
|
||||
每个 Run 至少展示:
|
||||
每次 Task 执行的内部 Job/Run 记录至少保存:
|
||||
|
||||
- 触发来源和计划版本。
|
||||
- 计划目标和当前 `goalStatus`。
|
||||
@@ -429,13 +474,14 @@ Trigger
|
||||
- 实际读取的知识库与记忆分区。
|
||||
- 实际调用的模型、Token、工具、耗时和成果大小。
|
||||
- 当前预算和剩余预算。
|
||||
- 任务、步骤和子任务状态。
|
||||
- Task、Job 和 Subjob 状态。
|
||||
- Supervisor 评论、证据、严重度和处理结果。
|
||||
- 评估器版本、指标和证据。
|
||||
- 产生的候选记忆或学习产物。
|
||||
- 重试、延后、中断和恢复原因。
|
||||
|
||||
不得只显示一个模糊的“自动化成功率”而隐藏失败 Run、跳过 Run 或无结论 Run。
|
||||
UI 在 Task 下呈现上述信息的有界摘要和活动,不提供 Job/Run 树或独立导航。不得只显示一个
|
||||
模糊的“自动化成功率”而隐藏失败、跳过或无结论的 Task 执行。
|
||||
|
||||
## 15. 建议的数据模型增量
|
||||
|
||||
@@ -462,15 +508,20 @@ experiment_runs
|
||||
```
|
||||
|
||||
现有 `schedules`、`schedule_runs`、`heartbeat_configs`、`heartbeat_runs`、
|
||||
`heartbeat_entries`、`tasks` 和 `runs` 不应一次性重写。迁移顺序应先增加统一只读视图和
|
||||
关联字段,再逐步让新计划使用统一模型。
|
||||
`heartbeat_entries`、`tasks` 和 `runs` 不应一次性重写。Schedule 可渐进建立稳定 Task 与
|
||||
Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心跳数据保持独立,不得
|
||||
静默转成 `AutomationPlan` 或顶层 Task。分区记忆协同和唤起条件完成设计前,不新增迁移目标。
|
||||
|
||||
## 16. 分阶段实施
|
||||
|
||||
### 阶段 0:统一术语和可观测性
|
||||
|
||||
- 固定 Plan、Run、Goal、Protocol、Supervisor、Observation、Memory Candidate 等概念。
|
||||
- 为现有定时任务、心跳和专家子任务建立统一活动视图。
|
||||
- 固定 Task、Conversation、Job、Subjob、Run、Plan、Goal、Protocol、Supervisor、
|
||||
Observation、Memory Candidate 等概念。
|
||||
- 明确 Task N:1 Conversation 关系、左侧行首展开按钮与 Task 子项图标,以及 Task Center
|
||||
索引边界,不复制内容。
|
||||
- 为现有 Scheduled Task 和专家执行建立按 Task 聚合的活动视图。
|
||||
- 明确当前心跳保持独立,分区记忆协同与唤起条件尚待设计。
|
||||
- 补充触发来源、运行版本、预算和读写范围展示。
|
||||
|
||||
### 阶段 1:调度与运行基础
|
||||
@@ -483,6 +534,7 @@ experiment_runs
|
||||
### 阶段 2:会话监督与分区记忆
|
||||
|
||||
- 上线评论型会话监督。
|
||||
- 智能心跳配置支持 Global 或指定一个、多个 Project。
|
||||
- 增加 Automation 和 Run 记忆分区。
|
||||
- 建立来源、证据、时态、冲突和晋升流程。
|
||||
|
||||
@@ -506,20 +558,29 @@ experiment_runs
|
||||
|
||||
## 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)
|
||||
- [Task 与 Job 统一领域模型](../features/task-and-job/task-and-job-model.md)
|
||||
- [Task Center PRD](../features/task-and-job/task-center-prd.md)
|
||||
- [Scheduled Task PRD](../features/task-and-job/scheduled-task-prd.md)
|
||||
- [Job 与 Subjob PRD](../features/task-and-job/job-and-subjob-prd.md)
|
||||
- [智能心跳 PRD](../features/smart-heartbeat/prd.md)
|
||||
- [并行实验工作台 PRD](../features/parallel-experiments/prd.md)
|
||||
- [会话监督 PRD](../features/conversation-supervision/prd.md)
|
||||
- [分区记忆 PRD](../features/memory/prd.md)
|
||||
- [持续学习与评估门 PRD](../features/continuous-learning/prd.md)
|
||||
- [GoodBuddy 长期助手功能规划](../roadmap/long-term-assistant-roadmap.md)
|
||||
- [GoodBuddy 统一界面设计系统](../../UI-DESIGN.md)
|
||||
|
||||
## 18. 总体验收标准
|
||||
|
||||
- [ ] 心跳、定时、目标和实验使用统一的 Plan 与 Run 术语。
|
||||
- [ ] 每个自动 Run 都能解释触发原因、目标、范围、预算、状态和结果。
|
||||
- [ ] 智能心跳保持独立,不作为 Task 类型;分区记忆协同与唤起条件尚未设计。
|
||||
- [ ] Task Center 只索引 Task;每个 Task 只关联一条 Conversation,一条 Conversation 可以
|
||||
关联多个 Task。
|
||||
- [ ] 当前 UI 只展示到 Task,不提供 Job/Subjob/Run 树或独立导航。
|
||||
- [ ] Scheduled Task 的重复触发和并行 Job 不创建新的顶层 Task。
|
||||
- [ ] 每次自动执行的内部 Run 都记录触发原因、目标、范围、预算、状态和结果,并在所属
|
||||
Task 下提供有界可观测信息。
|
||||
- [ ] Ask 自动化无法调用写工具或产生外部副作用。
|
||||
- [ ] Execute 自动化不能绕过现有审批、沙箱和能力控制。
|
||||
- [ ] Execute 自动化不能绕过现有审批、主机执行策略和能力控制。
|
||||
- [ ] 会话监督默认只评论,不能替用户发言或批准工具。
|
||||
- [ ] 并行 Run 的变量、会话、运行记忆、任务和成果相互隔离。
|
||||
- [ ] 失败 Run 不参与最佳结果选择,全部失败不报告成功。
|
||||
@@ -0,0 +1,497 @@
|
||||
# GoodBuddy 设备共享与 ShareServer 联邦网络总体设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-09 |
|
||||
| 适用范围 | GoodBuddy 桌面端、ShareServer、网关间联邦协议 |
|
||||
| 桌面端设计 | [设备发现与共享](../features/device-sharing/README.md) |
|
||||
| 服务端设计 | [ShareServer](../../../goodbuddy-shareserver/docs/features/share-server/README.md) |
|
||||
|
||||
## 1. 文档职责
|
||||
|
||||
本文是设备共享网络的跨端权威设计,负责定义拓扑、术语、身份、能力清单、授权计算、任务
|
||||
路由、能力分发、协议边界和共同验收标准。桌面端的产品与界面行为由“设备发现与共享”功能
|
||||
文档负责,独立 Web 服务由 ShareServer 功能文档负责。
|
||||
|
||||
本文描述完整目标系统,不以交付阶段删减领域模型,也不把尚未实现的设计表述为当前能力。
|
||||
|
||||
## 2. 核心决策
|
||||
|
||||
### 2.1 两个独立产品面
|
||||
|
||||
1. **GoodBuddy Desktop** 在设置中提供“设备发现与共享”,默认使用局域网发现与直连,也可
|
||||
由用户额外连接一个或多个 ShareServer。
|
||||
2. **ShareServer** 是独立部署、独立升级的 Web 服务,提供组织、设备、能力、权限、更新、
|
||||
联邦、中继、Office 编辑和审计管理。它不运行在 Electron Main 中,也不由桌面端代为启动。
|
||||
|
||||
### 2.2 能力优先,来源可见
|
||||
|
||||
用户日常选择的是能力,不是网络连接。设备、ShareServer 和组织作为能力来源与诊断入口。
|
||||
任何调用都必须持续显示提供者、执行位置和数据去向,不能为了统一体验隐藏跨设备或跨组织
|
||||
边界。
|
||||
|
||||
### 2.3 局域网是默认连接方式,网关是显式附加连接
|
||||
|
||||
- 桌面端没有网关配置时,局域网发现、配对、调用和本机共享仍可完整工作。
|
||||
- 添加、删除或断开 ShareServer 不改变局域网配对关系。
|
||||
- ShareServer 不接管本机局域网发现,也不能静默把局域网设备注册到组织。
|
||||
- 同一桌面端可以连接多个 ShareServer;能力来源和授权按连接分别计算。
|
||||
|
||||
### 2.4 发现、被发现和开放能力是三个独立动作
|
||||
|
||||
- **发现设备**:浏览同一局域网中主动广播的 GoodBuddy 服务。
|
||||
- **允许被发现**:广播本设备存在及配对入口。
|
||||
- **开放能力**:向指定设备、用户组或组织发布可查看、调用或拉取的能力。
|
||||
|
||||
开启其中一项不能推导另外两项已开启。新安装默认不开放能力。
|
||||
|
||||
### 2.5 ShareServer 优先作为控制面
|
||||
|
||||
ShareServer 负责身份、目录、策略、任务路由、更新和审计。数据面优先在两个已认证端点之间
|
||||
直接建立;网络条件不允许时,可按组织策略使用 ShareServer 中继。是否允许中继、正文是否
|
||||
端到端加密、是否允许跨组织,由本次任务冻结的策略共同决定。
|
||||
|
||||
### 2.6 Office 编辑是 ShareServer 托管服务
|
||||
|
||||
ShareServer 可以把 ONLYOFFICE Docs、工作副本存储、保存回调和编辑会话作为一个可选部署
|
||||
能力提供给 Desktop。Desktop 只连接用户已明确配置的 ShareServer,不直接配置 Document
|
||||
Server,也不在本机启动服务。Office 文档内容会由 ShareServer 和编辑引擎处理,因此不能沿用
|
||||
“控制面默认不读取正文”的数据面假设;创建编辑会话前必须明确显示服务端、数据去向和保留期。
|
||||
|
||||
编辑后的本地或 SSH 源文件仍由 Desktop 或远程 GoodBuddy Agent 持有和原子回写。ShareServer
|
||||
是工作副本及服务端保存检查点的权威,不是源文件最终写入成功的权威。完整设计见
|
||||
[Office 协同编辑](../features/office-document-editing/README.md)。
|
||||
|
||||
## 3. 术语
|
||||
|
||||
| 术语 | 定义 |
|
||||
| --- | --- |
|
||||
| `Device` | 安装 GoodBuddy 并拥有稳定设备身份的终端 |
|
||||
| `Peer` | 与当前设备存在局域网配对关系的另一台设备 |
|
||||
| `ShareServer` | 提供组织控制面和可选数据中继的独立服务 |
|
||||
| `Organization` | ShareServer 中隔离成员、设备、能力与策略的管理范围 |
|
||||
| `GatewayConnection` | 某台设备与一个 ShareServer 上特定组织身份的连接 |
|
||||
| `Capability` | 可被查看、调用或分发的稳定能力标识 |
|
||||
| `CapabilityProvider` | 实际持有并执行某项能力的设备或服务节点 |
|
||||
| `Publication` | 能力提供者向某一范围发布的可见性和使用声明 |
|
||||
| `Grant` | 主体对能力执行某类动作的明确授权 |
|
||||
| `Task` | 一次远程能力调用及其输入、执行、输出和终态 |
|
||||
| `Package` | 可下载到本机安装的签名能力工件 |
|
||||
| `FederationTrust` | 两个组织网关之间经管理员确认的双边信任关系 |
|
||||
| `DocumentEditSession` | ShareServer 托管的单文档短期编辑会话、工作副本和保存检查点 |
|
||||
|
||||
“网关”是 ShareServer 的网络角色;产品名称为 `ShareServer`,独立仓库为
|
||||
`goodbuddy-shareserver`。
|
||||
|
||||
## 4. 系统拓扑
|
||||
|
||||
```text
|
||||
局域网:
|
||||
GoodBuddy Desktop A <-- mDNS 发现 + 配对后 TLS 直连 --> GoodBuddy Desktop B
|
||||
|
||||
单组织:
|
||||
GoodBuddy Desktop A -- 出站连接 --> ShareServer <-- 出站连接 -- Provider Device B
|
||||
|
||||
Office 编辑:
|
||||
GoodBuddy Desktop --> ShareServer --> ONLYOFFICE Docs
|
||||
└-------> 临时工作副本与保存检查点
|
||||
|
||||
跨组织:
|
||||
Desktop A --> ShareServer A <== 联邦信任 ==> ShareServer B <-- Provider Device B
|
||||
```
|
||||
|
||||
局域网和网关连接共享能力、授权和任务的领域语义,但不共享发现方式:
|
||||
|
||||
| 维度 | 局域网 | ShareServer |
|
||||
| --- | --- | --- |
|
||||
| 发现来源 | mDNS/DNS-SD 主动广播 | 已配置 URL 或邀请链接 |
|
||||
| 初始身份 | 双端验证码确认设备指纹 | 用户登录、设备注册和组织成员身份 |
|
||||
| 能力目录 | 已配对设备直接返回 | 服务端按组织策略过滤 |
|
||||
| 连接方向 | 配对后直连 | 设备主动建立出站连接 |
|
||||
| 管理范围 | 单个设备间授权 | 用户组、组织和联邦授权 |
|
||||
| 审计权威 | 两端各自本地记录 | 组织网关记录元数据,两端保留本地记录 |
|
||||
|
||||
## 5. 组件边界
|
||||
|
||||
### 5.1 GoodBuddy Desktop
|
||||
|
||||
- 浏览和广播局域网服务。
|
||||
- 创建、确认和撤销点对点配对。
|
||||
- 连接 ShareServer,保存脱敏连接信息和本机受保护凭据。
|
||||
- 发布用户明确选择的本机能力。
|
||||
- 展示经当前身份过滤的能力目录。
|
||||
- 在调用前冻结提供者、输入范围、授权和路由。
|
||||
- 执行本机提供的任务,或消费远端任务结果。
|
||||
- 校验并安装用户明确选择的签名能力包。
|
||||
- 保存本机活动记录和可撤销授权。
|
||||
|
||||
### 5.2 ShareServer
|
||||
|
||||
- 认证用户、设备、服务节点和其他 ShareServer。
|
||||
- 管理组织、成员、用户组和设备目录。
|
||||
- 接收能力发布并生成权限过滤后的目录。
|
||||
- 计算组织强制策略,签发短期任务授权。
|
||||
- 路由任务、维护在线状态,并在允许时中继数据。
|
||||
- 管理签名包、发布通道、更新策略和撤销。
|
||||
- 可选管理 Office 编辑会话、ONLYOFFICE 接入、工作副本、保存回调、保留和清理。
|
||||
- 建立双边联邦关系并限制跨组织可见性与调用。
|
||||
- 保存有界审计元数据、用量和管理操作。
|
||||
|
||||
### 5.3 能力提供者
|
||||
|
||||
- 只执行已发布且本次授权允许的能力。
|
||||
- 在执行边界重新验证任务令牌、输入 schema、大小、期限和调用方。
|
||||
- 使用自身 Runtime、账号、工作区和数据权限。
|
||||
- 返回有界进度、结果、成果或确定终态。
|
||||
- 不因收到任务令牌而获得其他能力或任意系统接口。
|
||||
|
||||
## 6. 身份与信任
|
||||
|
||||
### 6.1 设备身份
|
||||
|
||||
每次 GoodBuddy 安装生成稳定设备密钥和自签设备证书,私钥保存在系统安全存储中。对外只
|
||||
使用随机设备 ID、公钥指纹、用户设置的设备名称和必要协议版本。操作系统用户名、绝对路径、
|
||||
软件资产和能力详情不进入未认证发现广播。
|
||||
|
||||
重装或主动重置设备身份会产生新设备。旧局域网配对和 ShareServer 设备注册不会自动迁移。
|
||||
|
||||
### 6.2 局域网配对
|
||||
|
||||
1. 发起端选择一台主动广播的设备。
|
||||
2. 双方建立临时加密通道并计算共同短验证码。
|
||||
3. 两端同时显示设备名、系统类型、公钥指纹和验证码。
|
||||
4. 双方用户确认后保存对端公钥及配对 ID。
|
||||
5. 后续使用双方设备证书认证;任一端可以撤销配对。
|
||||
|
||||
IP 地址不作为设备身份,也不单独构成授权依据。
|
||||
|
||||
### 6.3 ShareServer 注册
|
||||
|
||||
用户通过浏览器/OIDC 或服务端支持的登录方式认证后,为当前设备申请一次性注册挑战。设备
|
||||
使用自身私钥签名挑战,ShareServer 建立 `user + organization + device` 绑定并签发可轮换的
|
||||
设备凭据。设备凭据不等同于用户 Web 会话,不可用于管理控制台登录。
|
||||
|
||||
### 6.4 联邦身份
|
||||
|
||||
两个 ShareServer 由双方管理员交换组织标识、网关公钥、公开端点和指纹,并分别确认信任。
|
||||
联邦关系只有在双方状态均为 `active` 时生效。单方暂停或撤销后立即停止新目录交换和新任务,
|
||||
已执行任务保留审计终态。
|
||||
|
||||
## 7. 能力模型
|
||||
|
||||
### 7.1 能力清单
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"capabilityId": "document.convert.docx",
|
||||
"name": "文档转 Word",
|
||||
"version": "1.4.0",
|
||||
"modes": ["remote", "package"],
|
||||
"inputSchema": {},
|
||||
"outputSchema": {},
|
||||
"permissions": ["receive_text", "receive_files", "return_files"],
|
||||
"limits": {
|
||||
"maxInputBytes": 10485760,
|
||||
"maxOutputBytes": 52428800,
|
||||
"maxDurationSeconds": 900,
|
||||
"maxConcurrentTasks": 2
|
||||
},
|
||||
"dataPolicy": {
|
||||
"retention": "task_lifetime",
|
||||
"networkAccess": false,
|
||||
"delegation": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
清单只描述能力事实,不授予任何主体访问权。`inputSchema` 和 `outputSchema` 使用受限 JSON
|
||||
Schema 子集;实现必须限制 schema 深度、属性数、枚举数和总字节,不能把清单当作可执行代码。
|
||||
|
||||
### 7.2 发布声明
|
||||
|
||||
`Publication` 将一个能力版本发布到以下一种范围:
|
||||
|
||||
- 指定局域网 Peer。
|
||||
- 所有已配对 Peer。
|
||||
- ShareServer 内指定用户、用户组或设备组。
|
||||
- 指定联邦组织中的用户组。
|
||||
|
||||
发布声明同时定义允许动作:`discover`、`inspect`、`invoke`、`download`。`inspect` 允许查看
|
||||
详细清单,`discover` 只允许看到名称和可用状态。
|
||||
|
||||
### 7.3 可调用与可分发
|
||||
|
||||
- `remote`:代码与受控数据留在提供方,调用方只发送输入并接收结果。
|
||||
- `package`:签名工件下载到调用方,经校验和用户/组织策略批准后安装。
|
||||
|
||||
同一能力可以同时支持两种模式,但两种模式分别授权。远程调用授权不能推导下载权,下载权
|
||||
也不能推导安装后自动启用。
|
||||
|
||||
## 8. 授权模型
|
||||
|
||||
### 8.1 授权输入
|
||||
|
||||
一次动作的最终决定取以下约束交集:
|
||||
|
||||
```text
|
||||
调用方用户权限
|
||||
∩ 调用方设备状态
|
||||
∩ 调用方组织强制策略
|
||||
∩ 发布范围
|
||||
∩ 提供方授权
|
||||
∩ 提供方组织强制策略
|
||||
∩ 能力输入与数据策略
|
||||
∩ 联邦关系限制(跨组织时)
|
||||
= allow | require_approval | deny
|
||||
```
|
||||
|
||||
`deny` 优先于 `require_approval`,`require_approval` 优先于 `allow`。客户端偏好只能在组织强制
|
||||
策略允许的范围内收紧权限,不能放宽权限。
|
||||
|
||||
### 8.2 Grant
|
||||
|
||||
Grant 至少绑定:
|
||||
|
||||
- 主体:用户、用户组、设备、设备组或外部组织。
|
||||
- 能力 ID 和允许的版本范围。
|
||||
- 动作:查看、调用、下载、安装或发布。
|
||||
- 输入:文本、文件、数据分类、单项和累计大小。
|
||||
- 提供者和执行位置限制。
|
||||
- 次数、并发、预算、时间段和有效期。
|
||||
- 是否需要每次批准。
|
||||
- 是否允许委托及明确的下游组织。
|
||||
|
||||
“始终允许”只创建与当前能力、提供者、输入类型和范围匹配的 Grant,不创建整台设备的任意
|
||||
访问权。
|
||||
|
||||
### 8.3 转委托
|
||||
|
||||
默认 `delegation=none`。允许委托时,任务授权中必须冻结完整执行链和最大跳数。任一新组织、
|
||||
新能力或新数据接收方加入执行链,都需要重新计算授权,不能沿用原任务令牌。
|
||||
|
||||
## 9. 任务协议
|
||||
|
||||
### 9.1 任务快照
|
||||
|
||||
创建任务时冻结:
|
||||
|
||||
- 调用方用户、设备和组织。
|
||||
- 能力 ID、版本和提供者。
|
||||
- 输入 schema 版本、数据类型、数据分类和字节数。
|
||||
- 路由:局域网直连、网关直连、网关中继或联邦路径。
|
||||
- 授权决定、批准记录和策略版本。
|
||||
- 委托链、预算、期限和幂等键。
|
||||
|
||||
设置或策略后续变化不追改已接受任务;紧急撤销可以阻止尚未开始的任务,并按策略取消活动
|
||||
任务。
|
||||
|
||||
### 9.2 状态
|
||||
|
||||
```text
|
||||
created -> awaiting_approval -> queued -> connecting -> running
|
||||
running -> completed | failed | cancelled | outcome_unknown
|
||||
created/awaiting_approval/queued/connecting -> rejected | expired | cancelled
|
||||
```
|
||||
|
||||
- `completed` 只在提供者提交完整终态且调用方确认接收后成立。
|
||||
- 网络中断后无法确认副作用结果时使用 `outcome_unknown`,不得自动重放。
|
||||
- 重试创建新 attempt,并复用仅在能力声明支持时才有效的幂等键。
|
||||
- 用户取消必须传播到实际提供者;未确认取消时不能声称任务已停止。
|
||||
|
||||
### 9.3 数据传输
|
||||
|
||||
- 小型 JSON 输入使用任务通道传输。
|
||||
- 文件使用内容 SHA-256、声明大小和一次性任务对象令牌传输。
|
||||
- 接收端边读边校验大小和摘要,拒绝路径、符号链接和调用方文件名决定的落盘位置。
|
||||
- 任务正文、文件和输出不进入普通网关审计日志。
|
||||
- 中继模式下是否端到端加密由策略快照明确记录;界面必须显示网关能否读取正文。
|
||||
|
||||
## 10. 能力包与更新
|
||||
|
||||
每个 Package 必须提供不可变版本、目标平台和架构、内容大小、SHA-256、发布者签名、权限
|
||||
声明、依赖和兼容范围。安装流程统一为:
|
||||
|
||||
```text
|
||||
取得签名目录 -> 选择不可变目标 -> 下载暂存 -> 校验大小/摘要/签名
|
||||
-> 展示权限差异 -> 批准 -> 安装到 GoodBuddy-owned 目录 -> 验证 -> 启用
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 下载开始后冻结来源,不在失败时静默切换 ShareServer 或镜像。
|
||||
- 包不得包含凭据;配置只携带非敏感结构和密钥引用。
|
||||
- 新版本扩大文件、进程、网络、数据或委托权限时,旧 Grant 不自动继承。
|
||||
- 紧急撤销阻止新安装和新调用;已安装包的停用或删除遵循组织策略并向用户说明。
|
||||
- 复用现有 GoodBuddy 签名工件原则,但不同用途使用独立签名域。
|
||||
|
||||
## 11. 协议分层
|
||||
|
||||
### 11.1 局域网发现层
|
||||
|
||||
- DNS-SD 服务类型:`_goodbuddy-share._tcp.local`。
|
||||
- 广播仅包含协议 major、随机实例 ID、端口和配对状态。
|
||||
- 设备名称、证书和详细版本通过临时加密握手取得。
|
||||
- 不扫描任意端口,不枚举未主动广播的机器。
|
||||
|
||||
### 11.2 ShareServer 控制 API
|
||||
|
||||
使用 HTTPS 提供登录、设备注册、目录、管理和审计 API;使用 WebSocket 建立设备出站控制
|
||||
通道,承载心跳、任务声明、取消和短期数据通道协商。普通管理 API 与设备通道使用不同凭据
|
||||
和受众。
|
||||
|
||||
### 11.3 数据通道
|
||||
|
||||
数据通道可以是配对 TLS 直连、ShareServer WebSocket 中继或经网关协商的端到端加密流。
|
||||
协议只保证有界帧、顺序、取消和终态,不自行实现另一套拥塞控制。
|
||||
|
||||
### 11.4 版本协商
|
||||
|
||||
- 每层独立声明 `major.minor`。
|
||||
- major 不同拒绝连接;minor 使用双方共同支持的最低能力集合。
|
||||
- 未识别字段在声明允许扩展的位置忽略,安全与授权枚举中的未知值一律拒绝。
|
||||
- 联邦只交换双方共同支持的能力和策略表达式,不降低任一方策略。
|
||||
|
||||
### 11.5 Office 编辑协议
|
||||
|
||||
Office 编辑使用独立的 HTTPS 会话、文件和事件 API,不复用普通远程能力 Task 表达长生命周期
|
||||
编辑器。Desktop 通过设备凭据创建会话和传输源文件;编辑器使用一次性启动授权;ONLYOFFICE
|
||||
使用独立短期 JWT 获取工作副本并提交保存回调。三类凭据使用不同 audience,不能互换。
|
||||
|
||||
文档页签可以长期存在,但启动 URL、文件 URL、callback token 和编辑 Cookie 必须短期、单用途
|
||||
并可撤销。跨端会话、保存和冲突规则见
|
||||
[Office 协同编辑技术设计](../features/office-document-editing/technical-design.md)。
|
||||
|
||||
## 12. 数据与审计
|
||||
|
||||
### 12.1 最小审计事件
|
||||
|
||||
- 操作者、调用设备和组织。
|
||||
- 动作、能力、版本和提供者。
|
||||
- 路由与是否跨组织。
|
||||
- 输入数据类别、分类、文件数和字节数。
|
||||
- 授权结果、策略 ID 和批准者。
|
||||
- 任务状态、耗时、输出大小和资源用量。
|
||||
- 包下载、安装、更新、撤销和发布变更。
|
||||
|
||||
默认不保存提示正文、文件内容、模型输出、密钥、Cookie、认证头或完整私有路径。需要内容
|
||||
审计的组织必须单独定义保留期限和访问角色,并在调用前向用户显示。
|
||||
|
||||
### 12.2 数据权威
|
||||
|
||||
- 桌面端是本地配对、本机共享偏好和本地活动记录的权威。
|
||||
- ShareServer 是组织成员、设备注册、组织策略、发布目录和组织审计的权威。
|
||||
- ShareServer 是 Office 工作副本、服务端保存检查点、编辑会话和保留清理的权威;Desktop 或
|
||||
远程 GoodBuddy Agent 是源文件是否成功回写的权威。
|
||||
- 能力提供者是运行中任务及结果是否产生的权威。
|
||||
- 联邦两端各自保存本组织审计,不复制对方完整成员目录或内部策略。
|
||||
|
||||
## 13. 安全边界
|
||||
|
||||
ShareServer 可以部署到公网,因此公网连接、外部组织和服务端多租户是本设计中的实际信任
|
||||
边界。共同要求如下:
|
||||
|
||||
1. 所有远端清单、名称、schema、错误、文件和模型输出都作为不可信数据处理。
|
||||
2. Renderer 不接触设备私钥、网关令牌、任意 socket 或原始文件传输接口。
|
||||
3. Main/Provider 在执行边界重新验证任务,不信任 Renderer 或 ShareServer 代替本机授权。
|
||||
4. ShareServer 按组织隔离查询,搜索和计数接口不能泄露无权查看的设备或能力。
|
||||
5. 设备只建立主动配置的网关出站连接,不接受 ShareServer 发起任意系统调用。
|
||||
6. 局域网发现不等于信任,组织成员身份也不等于所有能力授权。
|
||||
7. 远程能力不能扩大本机 Ask/Execute、Workspace、Runtime、Skill 或 MCP 现有权限。
|
||||
8. 密钥只进入对应安全存储,诊断和审计使用固定字段与脱敏错误码。
|
||||
9. 限制握手、清单、帧、文件、并发、速率、任务时长和审计查询大小。
|
||||
10. 撤销设备、用户、Grant、Publication、Package 或 FederationTrust 后停止签发新令牌。
|
||||
11. Office 编辑器使用固定 origin 和隔离会话,不获得 Electron、文件系统、Shell、Runtime 或
|
||||
普通 ShareServer 管理 API;Document Server 默认禁止任意出站网络和未批准插件。
|
||||
12. Office 文件正文由服务端实际处理,组织策略必须定义允许格式、大小、保留和内容访问角色,
|
||||
Desktop 在上传前显示这些事实。
|
||||
|
||||
## 14. 建议领域实体
|
||||
|
||||
```text
|
||||
DeviceIdentity
|
||||
PeerPairing
|
||||
GatewayConnection
|
||||
Organization
|
||||
Membership
|
||||
Group
|
||||
RegisteredDevice
|
||||
Capability
|
||||
CapabilityVersion
|
||||
Publication
|
||||
Grant
|
||||
Policy
|
||||
Approval
|
||||
Task
|
||||
TaskAttempt
|
||||
ArtifactTransfer
|
||||
Package
|
||||
PackageRelease
|
||||
FederationTrust
|
||||
AuditEvent
|
||||
Revocation
|
||||
DocumentEditSession
|
||||
DocumentCheckpoint
|
||||
```
|
||||
|
||||
共享协议只定义跨端 DTO 和状态,不规定桌面 SQLite 与 ShareServer 数据库使用相同表结构。
|
||||
|
||||
## 15. 仓库边界
|
||||
|
||||
当前仓库边界(跨仓库文档链接约定两个仓库克隆到同一级目录):
|
||||
|
||||
```text
|
||||
goodbuddy/
|
||||
docs/architecture/share-network-architecture.md
|
||||
docs/features/device-sharing/
|
||||
src/ # GoodBuddy Desktop
|
||||
goodbuddy-shareserver/
|
||||
docs/features/share-server/
|
||||
server/ # 独立 Hono Web 服务
|
||||
shared/ # 当前原型 fixture,不是跨端协议
|
||||
src/ # React 管理前端
|
||||
```
|
||||
|
||||
ShareServer 源码和服务端文档已迁入独立仓库:
|
||||
`ssh://git@git.digiman.live:11022/root/goodbuddy-shareserver.git`。
|
||||
当前服务是交互原型,尚未提供真实 Desktop 集成。共享网络总体设计仍由本文统一维护。
|
||||
|
||||
ShareServer 不反向依赖桌面端 `src/shared`,构建和运行不要求桌面仓库存在。
|
||||
真实跨端协议实现时双方依赖独立 `share-protocol` 包,具体仓库归属和发布方式届时确定,
|
||||
不提前创建空包。协议包不得包含数据库模型、Electron API、服务实现或凭据。
|
||||
|
||||
## 16. 共同验收标准
|
||||
|
||||
- [ ] 不配置 ShareServer 时,两台桌面端可通过局域网发现、双端配对、查看获准能力并调用。
|
||||
- [ ] 关闭“允许被发现”不会关闭主动发现;关闭主动发现不会撤销既有配对。
|
||||
- [ ] 本机没有明确 Publication 时,任何 Peer 或组织成员都不能查看或调用本机能力。
|
||||
- [ ] 桌面端可以连接多个 ShareServer,任一连接故障不影响局域网和其他连接。
|
||||
- [ ] ShareServer 只展示当前用户、设备和组织获准查看的目录。
|
||||
- [ ] 每次远程调用都能说明提供者、执行位置、发送数据、路由、授权依据和委托链。
|
||||
- [ ] 网络中断且副作用结果不确定时进入 `outcome_unknown`,不会自动重放。
|
||||
- [ ] 能力包经大小、SHA-256、签名、兼容和权限差异校验后才能安装。
|
||||
- [ ] 跨组织调用同时满足两端策略;联邦撤销后不能创建新任务。
|
||||
- [ ] 审计默认只保存必要元数据,不保存正文、文件、密钥或认证信息。
|
||||
- [ ] ShareServer 可独立部署和升级,不由 Electron 启动,也不影响桌面端离线使用。
|
||||
- [ ] 启用 Office 服务后,Desktop 可在多实例文档页签中编辑 DOCX、XLSX 和 PPTX;保存回调、
|
||||
源文件冲突或网络故障均不会破坏原文件。
|
||||
- [ ] Office 会话的启动、文件和 callback 凭据不可互换,过期或撤销后不能重用;临时工作副本
|
||||
按策略清理,默认审计不保存文档正文或 AI 选区内容。
|
||||
|
||||
## 17. 相关设计
|
||||
|
||||
- [设备发现与共享 PRD](../features/device-sharing/prd.md)
|
||||
- [设备发现与共享功能逻辑](../features/device-sharing/logic-design.md)
|
||||
- [设备发现与共享 UI](../features/device-sharing/ui-design.md)
|
||||
- [设备发现与共享技术设计](../features/device-sharing/technical-design.md)
|
||||
- [ShareServer PRD](../../../goodbuddy-shareserver/docs/features/share-server/prd.md)
|
||||
- [ShareServer 功能逻辑](../../../goodbuddy-shareserver/docs/features/share-server/logic-design.md)
|
||||
- [ShareServer 管理界面](../../../goodbuddy-shareserver/docs/features/share-server/ui-design.md)
|
||||
- [ShareServer 技术设计](../../../goodbuddy-shareserver/docs/features/share-server/technical-design.md)
|
||||
- [Office 协同编辑](../features/office-document-editing/README.md)
|
||||
- [SSH 远程主机与 Agent](../features/remote-host/README.md)
|
||||
- [GoodBuddy 统一界面设计系统](../../UI-DESIGN.md)
|
||||
@@ -1,678 +0,0 @@
|
||||
# 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、Linux,x64 与 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。
|
||||
- 按会话取消。
|
||||
- 一次性权限选择。
|
||||
- 已提交的助手文本。
|
||||
- 官方本地沙箱支持:
|
||||
- Linux:Bubblewrap,或 Landlock 降级。
|
||||
- macOS:Seatbelt。
|
||||
- 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
|
||||
├─ RuntimeAuthorizer(Ask 拒绝 / 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 原生 Session,Runtime 重启后由 GoodBuddy 历史重建。
|
||||
- 首版不支持图片、知识库、浏览器工具和 Harness Subagent;MCP 仅支持用户分配、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,75 @@
|
||||
# 数据库迁移与大数据升级指南
|
||||
|
||||
本文约束已发布用户数据的数据库升级。具体字段、存储表示和功能行为由对应功能文档负责;
|
||||
本文不建立通用迁移框架、回滚引擎或独立状态日志。
|
||||
|
||||
## 先确认问题和数据边界
|
||||
|
||||
1. 先只读统计文件大小、WAL、页数、空闲页、表与字段的字节数,不先删除或压缩数据库。
|
||||
2. 区分有效内容、重复存储、索引和已释放但未回收的空间。空闲页少时,单独
|
||||
`VACUUM` 不能消除重复记录。
|
||||
3. 查清实际写入、读取、删除、重放与恢复入口。修改 Runtime 相关持久化时,同时检查
|
||||
本机和 GoodBuddy Agent 的桌面路径,不假设一个入口覆盖另一个入口。
|
||||
4. 明确用户必须保留的信息、稳定 ID、顺序、时间、引用与远程去重字段。
|
||||
分支内未发布、可安全重建的内部记录不需要兼容机制。
|
||||
|
||||
## 实现原则
|
||||
|
||||
- 先修新增数据路径,再修历史数据。历史转换与新写入尽量使用同一套转换函数。
|
||||
- 普通 schema 变更使用既有 `PRAGMA user_version` 和 SQLite 事务;
|
||||
已升级 schema 不允许旧客户端强行打开,不通过回写版本号支持降级。
|
||||
- 大数据转换不得同步阻塞 Electron Main 或 Renderer。放到工作线程,在业务连接
|
||||
打开前处理,并使用应用的共享字体、主题与页面组件显示进度和可执行的失败提示。
|
||||
- 根据实测设置有界读取批次与缓存,批次提交后再报告进度。保持 SQLite 事务与
|
||||
checkpoint,不靠关闭同步落盘、取消约束或一次性读入整库提速。
|
||||
- 从已提交数据直接重试和还原即可完成的迁移,不增加 durable snapshot、journal、
|
||||
receipt 或多阶段恢复状态。退出及失败至少回滚未提交批次,保留已完成部分。
|
||||
- 差量编码必须能精确还原原始事件。更新内容、删除或重排都要覆盖;涉及远程来源时,
|
||||
去重、冲突判断、消息投影和恢复序号不得失效。
|
||||
- 释放数据库页和归还文件系统空间是不同步骤。先减少实际存储内容,再执行需要的
|
||||
空间回收;保留足够临时空间,磁盘不足时明确失败并允许重试。
|
||||
- SQLite 同库写入是串行的。是否增加读取/转换并行,应由实际读取、解析、写入、
|
||||
提交与 checkpoint 的耗时决定,不把增加写线程当作默认优化。
|
||||
|
||||
## 验证与备份
|
||||
|
||||
### 合成回归
|
||||
|
||||
覆盖正常转换、无变化记录、重复打开、内容修订、失败事务和退出续跑。数据库大小问题
|
||||
必须同时验证新增写入的增长量、旧记录可还原性以及文件实际缩小,而不只检查 schema。
|
||||
对象比较必须覆盖真实生产形状和 JSON 往返,尤其是可选字段值为 `undefined` 时与字段缺省
|
||||
的差异。再次修复已发布迁移产生的数据时,提高目标 schema,并覆盖已升级用户的入口;
|
||||
不能只处理尚未迁移的旧库。
|
||||
|
||||
### 真实旧库
|
||||
|
||||
1. 关闭占用源数据库的应用,确认 WAL 状态。没有 WAL 时可复制已关闭的数据库;
|
||||
有活动 WAL 时使用 SQLite 一致性备份机制,不只复制主文件。
|
||||
2. 备份到空间足够的位置,记录字节数并校验 SHA-256。备份和测试副本分离,
|
||||
不直接在唯一原件或唯一备份上试跑。
|
||||
3. 使用当前源码或用户要求的 portable 正常启动入口执行升级,不用手工 SQL 代替
|
||||
产品升级过程。退出一次后重新启动,验证已提交数据可以继续处理。
|
||||
4. 比较升级前后消息、任务、成果等用户表的内容;对转换事件逐条还原后比较原始内容、
|
||||
ID、顺序、时间和去重来源,不只比较行数。日志只能包含统计和断言结果,不记录正文。
|
||||
5. 运行数据库完整性检查,记录主文件和 WAL 的实际字节数、回收比例、耗时、平台、
|
||||
源 schema 与目标 schema。确认原件和备份未被改写。
|
||||
6. 运行 `npm test`、`npm run typecheck`、`npm run lint`;生产构建变化按开发验证规则
|
||||
检查,发布准备则遵循[发布手册](./release-runbook.md),不重复本地打包。
|
||||
7. 用真实模型模拟正常用户任务,允许主代理与子代理按需调用工具、修改独立测试项目、
|
||||
运行测试和处理失败;只读纯文字模型调用不能证明工具事件增长正常。记录实际提供商
|
||||
请求数与统计范围,不把 Runtime 用量上报数当成全部 HTTP 请求数。
|
||||
|
||||
## 发布与维护
|
||||
|
||||
发布说明应告知升级范围、可能耗时、退出重试、额外磁盘空间和降级边界。不得把一个测试库
|
||||
的回收比例当作所有用户都能获得的保证。功能文档记录具体转换规则,进度文档记录实际
|
||||
证据和未完成项;未经验证的阶段不能标为完成。
|
||||
|
||||
### 已有实例
|
||||
|
||||
- Desktop 0.13.1:子任务累计过程重复保存导致数据库膨胀,转换到 schema 34 的差量事件
|
||||
表示,并回收空间。具体规则见
|
||||
[执行记录存储与升级回收](../features/assistant-workbar/execution-history-storage.md),
|
||||
数据保留与真实旧库验证见[工作栏进度](../features/assistant-workbar/progress.md)。
|
||||
- 当前未发布源码:schema 35 同时整理旧快照和 schema 34 的重复工具块,工具字段按
|
||||
JSON 可保存的值比较,不因 `undefined` 被省略而误判变化。
|
||||
@@ -0,0 +1,252 @@
|
||||
# GoodBuddy 龙芯(LoongArch)预览版构建
|
||||
|
||||
本文档是 GoodBuddy 龙芯预览版的权威构建与功能状态说明。当前目标是
|
||||
Debian `loong64` 桌面,不代表 GoodBuddy 已将龙芯纳入正式发布支持矩阵。
|
||||
|
||||
## 当前状态
|
||||
|
||||
当前流程可以在 AMD64 Linux 主机上交叉构建一个独立的
|
||||
`goodbuddy-loongarch-preview` DEB,并校验包内 Electron、node-pty 和 Koffi
|
||||
均为 LoongArch ELF。
|
||||
|
||||
预览版使用社区维护的 Electron 42.3.0 LoongArch 构建。GoodBuddy 正式版本
|
||||
当前使用 Electron 43.2.0,因此该预览包只用于移植和真机验证,不能作为正式
|
||||
发布基线。Electron 官方目前也不提供 Linux LoongArch 预编译包。
|
||||
|
||||
已完成的实际构建结果:
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | --- |
|
||||
| GoodBuddy 源版本 | `0.11.0` |
|
||||
| 预览版本 | `0.11.0-loong64-preview.1` |
|
||||
| Debian 架构 | `loong64` |
|
||||
| Electron | 社区版 `42.3.0` |
|
||||
| DEB 包名 | `goodbuddy-loongarch-preview` |
|
||||
| 构建产物 | `GoodBuddy-0.11.0-linux-loong64-preview.deb` |
|
||||
| 已验证构建时间(UTC) | `20260825T102937Z` |
|
||||
| 已验证产物大小 | `187053208` bytes |
|
||||
| 已验证产物 SHA-256 | `461ade49f10a20ef7b28163077a2ecd6afd2c5b1b0befc673c6d174fd58497b3` |
|
||||
| DEB 解包、架构和动态库解析 | 已通过 |
|
||||
| QEMU Electron 初始化 | 共享库解析通过;Chromium 多线程初始化在 QEMU 下异常退出 |
|
||||
| 龙芯真机启动 | 尚未验证 |
|
||||
|
||||
版本号和产物名会跟随当前 `package.json` 版本变化,上表只记录首次成功构建。
|
||||
|
||||
## 功能列表
|
||||
|
||||
“已打包”只表示所需代码或组件进入 DEB,不等同于已在龙芯桌面完成运行验证。
|
||||
|
||||
| 功能 | 预览包状态 | 当前验证 |
|
||||
| --- | --- | --- |
|
||||
| GoodBuddy Electron 桌面主体 | 已打包 | Electron 可执行文件为 LoongArch ELF;尚未完成真机 GUI 启动 |
|
||||
| React 界面和主进程业务代码 | 已打包 | 生产 bundle 构建、测试、类型检查和 lint 已通过 |
|
||||
| 直接模型调用路径 | 已打包 | JavaScript 代码与生产依赖已包含;待龙芯真机端到端验证 |
|
||||
| Continue Runtime | 已打包 | CLI JavaScript 入口及 npm Runtime 已包含;待真机执行验证 |
|
||||
| 本地终端能力 | 已打包 | 输入锁文件固定的 node-pty(当前为 1.2.0-beta.15)已针对 Electron 42.3.0 交叉编译并验证为 LoongArch ELF;待真机 PTY 验证 |
|
||||
| Koffi glibc 原生绑定 | 已打包 | Koffi 3.1.4 LoongArch glibc binding 已校验为 LoongArch ELF |
|
||||
| 内置 Skills | 已打包 | 静态资源已进入 DEB |
|
||||
| GoodBuddy Agent | **不包含** | 当前 Agent 的正式构建和双 glibc/musl 产物契约不支持 LoongArch |
|
||||
| Remote Runtime | **不包含** | 当前没有 LoongArch Remote Runtime 产物 |
|
||||
| OpenCode Runtime | **不包含** | OpenCode 所需 Bun Runtime 当前不支持 LoongArch |
|
||||
| 原生 Canvas 图像解码 | **不包含** | npm 当前没有 `@napi-rs/canvas-linux-loong64-gnu` 包 |
|
||||
| Koffi musl binding | **不包含** | Koffi 3.1.4 只发布了 `linux_loong64` glibc binding |
|
||||
| 正式自动更新和发布索引 | **不支持** | 预览包不加入正式 CI、安装包矩阵或更新索引 |
|
||||
|
||||
在真机验证完成前,不应把“已打包”功能描述为正式可用。尤其是窗口显示、GPU、
|
||||
音频、系统托盘、桌面集成、PTY 交互和模型调用都必须在真实 LoongArch 桌面上
|
||||
重新验证。
|
||||
|
||||
## 与正式 GoodBuddy 隔离
|
||||
|
||||
预览版有意使用单独身份,避免覆盖正式安装:
|
||||
|
||||
- Debian 包名:`goodbuddy-loongarch-preview`
|
||||
- 产品名:`GoodBuddy LoongArch Preview`
|
||||
- 可执行文件:`goodbuddy-loongarch-preview`
|
||||
- 安装目录:`/opt/GoodBuddy-LoongArch-Preview`
|
||||
- Electron 应用身份与正式 GoodBuddy 不同,因此使用单独的用户数据目录
|
||||
|
||||
该流程不会修改 `.github/workflows/packages.yml`,也不会把 `loong64` 加入
|
||||
正式 release manifest 或下载索引。
|
||||
|
||||
## 构建前提
|
||||
|
||||
### 本地源码主机
|
||||
|
||||
- 已完成 `npm ci`
|
||||
- Node.js 和 npm 版本满足仓库要求
|
||||
- 可以成功执行 `npm run build:bundle`
|
||||
|
||||
### AMD64 交叉构建主机
|
||||
|
||||
- Linux AMD64
|
||||
- Docker
|
||||
- 建议至少 30 GiB 内存和 50 GiB 可用磁盘
|
||||
- 可访问 Ubuntu、npm、Electron headers 和社区 Electron Release
|
||||
|
||||
### Electron LoongArch shell
|
||||
|
||||
从
|
||||
[`darkyzhou/electron-loong64`](https://github.com/darkyzhou/electron-loong64)
|
||||
的 Releases 下载:
|
||||
|
||||
```text
|
||||
electron-v42.3.0-linux-loong64.zip
|
||||
```
|
||||
|
||||
构建脚本固定校验以下 SHA-256,摘要不匹配时必须停止:
|
||||
|
||||
```text
|
||||
92b0ca0c9c18ed90166918a4ac1970266c4fa967aee9277031b3b250b905526e
|
||||
```
|
||||
|
||||
这是社区产物,不是 Electron 官方发布的 Linux LoongArch 二进制。
|
||||
|
||||
## 第一步:生成架构无关输入
|
||||
|
||||
在 GoodBuddy 仓库根目录执行:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:bundle
|
||||
node build/loongarch-cross/prepare-input.cjs
|
||||
```
|
||||
|
||||
默认输出:
|
||||
|
||||
```text
|
||||
dist/loongarch-cross/goodbuddy-loongarch-cross-input.tgz
|
||||
```
|
||||
|
||||
输入归档包含生产 `out`、锁文件、Skills、Continue Runtime、npm Runtime、
|
||||
图标和发布元数据。它不包含 Agent、Remote Runtime 或 OpenCode 二进制。
|
||||
|
||||
可通过 `--output` 指定其他输出位置:
|
||||
|
||||
```sh
|
||||
node build/loongarch-cross/prepare-input.cjs \
|
||||
--output /path/to/goodbuddy-loongarch-cross-input.tgz
|
||||
```
|
||||
|
||||
## 第二步:构建交叉工具链镜像
|
||||
|
||||
将 `build/loongarch-cross`、输入归档和 Electron zip 放到 AMD64 Docker
|
||||
主机,然后执行:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--tag goodbuddy-loongarch-cross:ubuntu-26.04 \
|
||||
build/loongarch-cross
|
||||
```
|
||||
|
||||
工具链镜像包括:
|
||||
|
||||
- `loongarch64-linux-gnu-gcc` 和 G++
|
||||
- LoongArch glibc 开发 sysroot
|
||||
- QEMU user mode
|
||||
- CMake 和 Ninja
|
||||
- Rust `loongarch64-unknown-linux-gnu` target
|
||||
- Node.js、npm 和上游 node-gyp
|
||||
|
||||
可先运行基础探针:
|
||||
|
||||
```sh
|
||||
mkdir -p build/loongarch-cross/results
|
||||
docker run --rm \
|
||||
--volume "$PWD/build/loongarch-cross/results:/results" \
|
||||
goodbuddy-loongarch-cross:ubuntu-26.04
|
||||
```
|
||||
|
||||
探针必须通过 C、C++、CMake、Rust、NAPI-RS、QEMU、DEB、Koffi 和
|
||||
node-pty 检查。Canvas 和 Koffi musl 显示为 unavailable 是当前已知限制,
|
||||
不是 Debian 桌面主体 DEB 的构建失败。
|
||||
|
||||
## 第三步:生成预览 DEB
|
||||
|
||||
假设交叉构建主机上的文件布局如下:
|
||||
|
||||
```text
|
||||
input/goodbuddy-loongarch-cross-input.tgz
|
||||
electron/electron-v42.3.0-linux-loong64.zip
|
||||
preview-output/
|
||||
```
|
||||
|
||||
运行:
|
||||
|
||||
```sh
|
||||
docker run --rm \
|
||||
--entrypoint /usr/local/bin/goodbuddy-loongarch-preview-deb \
|
||||
--volume "$PWD/input:/input:ro" \
|
||||
--volume "$PWD/electron:/electron:ro" \
|
||||
--volume "$PWD/preview-output:/preview-output" \
|
||||
goodbuddy-loongarch-cross:ubuntu-26.04 \
|
||||
/input/goodbuddy-loongarch-cross-input.tgz \
|
||||
/electron/electron-v42.3.0-linux-loong64.zip \
|
||||
/preview-output
|
||||
```
|
||||
|
||||
每次构建创建一个 UTC 时间戳目录,包含:
|
||||
|
||||
```text
|
||||
GoodBuddy-<version>-linux-loong64-preview.deb
|
||||
preview-manifest.json
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
脚本会执行以下强制检查:
|
||||
|
||||
1. 校验 Electron zip 的固定 SHA-256。
|
||||
2. 删除 npm 错误解析出的宿主架构可选原生包。
|
||||
3. 按 lockfile integrity 校验并安装 Koffi LoongArch glibc binding。
|
||||
4. 读取输入 lockfile 固定的 node-pty 版本,并使用上游 node-gyp 11.4.2
|
||||
和 Electron 42.3.0 headers 交叉编译。
|
||||
5. 拒绝 `node_modules` 中任何不是 LoongArch ELF 的 `.node` 文件。
|
||||
6. 拒绝意外进入包内的 Agent、Remote Runtime 或 OpenCode 目录。
|
||||
7. 生成 `Architecture: loong64` 的 DEB,解包后再次校验关键 ELF。
|
||||
|
||||
Ubuntu 发行版自带的 node-gyp 会在 Linux addon 链接阶段无条件添加
|
||||
`-lnode`,不适合此 Electron 交叉编译;不要用它替换脚本固定的上游
|
||||
node-gyp。
|
||||
|
||||
## 验证边界
|
||||
|
||||
### 已由交叉构建验证
|
||||
|
||||
- C、C++、CMake 和 Rust 生成 LoongArch ELF
|
||||
- C、C++ 和 Rust 程序通过 QEMU user mode 执行
|
||||
- 最小 NAPI-RS addon 交叉编译
|
||||
- node-pty 针对 Electron headers 交叉编译
|
||||
- Koffi LoongArch glibc binding 架构
|
||||
- Electron、node-pty、Koffi 和 DEB `loong64` 元数据
|
||||
- DEB 完整解包
|
||||
- Debian Forky rootfs 中 Electron 全部共享库解析;该检查发现并补充了
|
||||
`libpulse0` DEB 依赖
|
||||
|
||||
### 仍需真实龙芯桌面验证
|
||||
|
||||
1. 安装 DEB 并从桌面入口启动。
|
||||
2. 验证窗口、渲染、GPU 回退、输入法、系统托盘和通知。
|
||||
3. 配置模型并执行一次真实的直接模型对话。
|
||||
4. 验证 Continue Runtime 启动、取消和退出清理。
|
||||
5. 打开本地终端,验证 node-pty 输入、输出、窗口缩放和进程回收。
|
||||
6. 验证 Koffi 使用路径。
|
||||
7. 验证音频、文件选择器、剪贴板和外部链接。
|
||||
8. 确认 Agent、Remote Runtime、OpenCode 和 Canvas 相关入口不会被误认为
|
||||
预览版已支持。
|
||||
|
||||
QEMU 可以补充动态链接器和缺库检查,但不能替代上述 GUI、PTY、GPU、音频和
|
||||
桌面集成真机测试。当前 Electron 在 QEMU user mode 下可以完成全部共享库
|
||||
解析;继续进入 Chromium 多线程初始化后,QEMU 出现 target `SIGSEGV`,因此
|
||||
不能用该结果判断真机 Electron 是否可以启动。
|
||||
|
||||
## 已知依赖问题
|
||||
|
||||
- npm 的 `--cpu=loong64 --os=linux` 仍可能安装宿主 x64 的 Koffi 可选包。
|
||||
打包脚本会删除所有该作用域的已解析原生包,并按 lockfile integrity 手工
|
||||
安装 LoongArch 包。
|
||||
- `@napi-rs/canvas` 没有发布 LoongArch 原生包。基础 NAPI-RS 探针通过只说明
|
||||
Rust addon 工具链可用,不代表 Skia/Canvas 已完成移植。
|
||||
- Koffi 没有 LoongArch musl binding,因此当前不能满足 GoodBuddy Agent 的
|
||||
双 glibc/musl 产物契约。
|
||||
- 预览 Electron 比正式 GoodBuddy 低一个主版本。正式支持必须先获得与当前
|
||||
Electron 基线一致、可重复构建并完成安全维护的 LoongArch shell。
|
||||
@@ -0,0 +1,317 @@
|
||||
# GoodBuddy Release Runbook
|
||||
|
||||
Read this runbook only when preparing, publishing, recovering, or verifying a
|
||||
desktop or GoodBuddy Agent release.
|
||||
|
||||
This is an operator and CI procedure. It does not authorize adding application
|
||||
persistence, snapshots, journals, rollback engines, recovery protocols, or
|
||||
compatibility layers to automate release operations. Determine release recovery
|
||||
from the authoritative GitHub, OSS, workflow, and Git state, then use the
|
||||
smallest existing idempotent rerun or a new immutable version as specified
|
||||
below.
|
||||
|
||||
## Release Packaging
|
||||
|
||||
- Unless the user explicitly requests it, never run a local production build,
|
||||
desktop package, or launch probe as part of release preparation. In
|
||||
particular, do not run `npm run build`, `npm run build:bundle`,
|
||||
`npm run portable`, `npm run release:package`, direct `electron-builder`
|
||||
packaging, or launch a locally packaged app before publishing a release.
|
||||
Local tests, type checks, and lint plus the main-branch and native tag GitHub
|
||||
Actions jobs are the release authorities.
|
||||
- After a release is published, confirm only that the expected Windows assets
|
||||
exist in the public release metadata. Do not locally rebuild or launch the
|
||||
Windows package, download or hash published installers, attach browser
|
||||
automation to a packaged app, or perform exhaustive per-asset network probes
|
||||
unless the user explicitly asks for that additional validation.
|
||||
- `.github/workflows/packages.yml` is the canonical cross-platform packaging
|
||||
workflow. It validates and builds `out` once, then packages on six native
|
||||
runners: Windows, macOS, and Linux, each for x64 and arm64.
|
||||
- The experimental Linux LoongArch (`loong64`) preview is built separately
|
||||
through `build/loongarch-cross` by following
|
||||
`docs/development/loongarch-preview-build.md`. It is not part of
|
||||
`.github/workflows/packages.yml`, the six-runner GitHub Actions matrix,
|
||||
standard release manifests, GitHub Release asset expectations, or the
|
||||
production update index. Do not add it to those paths unless the user
|
||||
explicitly requests a future production-support migration.
|
||||
- Run the unified packager with
|
||||
`npm run release:package -- --platform <platform> --arch <arch>`. It only
|
||||
packages for the native host and writes to
|
||||
`dist/release/<platform>-<arch>`.
|
||||
- Default deliverables are NSIS and portable ZIP for Windows, DMG for
|
||||
macOS, and AppImage, DEB, and RPM for Linux. Every target includes
|
||||
`release-manifest.json` with SHA-256 hashes.
|
||||
- Starting with Desktop 0.12.8, macOS releases contain DMG only. Older macOS
|
||||
GitHub update readers and all older mirror readers require ZIP and cannot
|
||||
validate this new matrix; affected users must upgrade manually from the
|
||||
download page. The user approved this immediate format change. Current
|
||||
readers accept both the historical DMG/ZIP matrix and the DMG-only matrix.
|
||||
- Final Desktop package verification compares every bundled OpenCode
|
||||
configuration file with the prepared dependency tree and rejects external
|
||||
DeepSeek imports in the unpacked Host and chunks. Windows x64 CI also
|
||||
imports the packaged OpenCode plugin and starts an isolated packaged
|
||||
UtilityProcess before publishing; source-only tests do not replace it.
|
||||
- `build/build-release.cjs` verifies the unpacked application, `app.asar`,
|
||||
bundled Continue and OpenCode runtimes, executable architecture, and package
|
||||
signatures before atomically replacing a release directory.
|
||||
- All desktop build paths, including local `portable`, package the Agent and
|
||||
remote Runtime version locks plus the public signing-key registry without
|
||||
embedding installable Linux Agent or remote Runtime payloads. Managed SSH is
|
||||
optional: users explicitly download a compatible compound `.gbagent` package
|
||||
from Settings or import one offline before GoodBuddy can install or update a
|
||||
Host.
|
||||
- Each independently published Linux x64, Linux arm64, or macOS arm64 `.gbagent` contains the
|
||||
Agent daemon, pinned Node, and the desktop-maintained compatible OpenCode
|
||||
Runtime. Build all three targets on native GitHub Actions runners from one
|
||||
immutable `agent-v<agentVersion>` tag.
|
||||
- Keep the GoodBuddy Agent source, shared protocol/contracts, runtime lock,
|
||||
bundle tooling, and tests in this repository so a desktop commit identifies
|
||||
the exact Agent source it expects. Do not create a separate Agent repository
|
||||
or long-lived Agent release branch unless the Agent becomes an independently
|
||||
released product serving multiple clients.
|
||||
- `.github/workflows/agents.yml` is the branch and pull-request build
|
||||
verification workflow for GoodBuddy Agent. It must build Linux x64 and
|
||||
arm64 on native runners from the checked-out commit, acquire the locked
|
||||
official Node archive and locked OpenCode npm archive, verify every locked
|
||||
digest, use only an ephemeral in-memory test signing identity, and verify the
|
||||
resulting compound package and deterministic archive. It must not read
|
||||
production signing secrets, publish installable release artifacts, or modify
|
||||
the checked-in public key registry. The Darwin arm64 target uses `macos-15`
|
||||
and the same production locks. Native CI checks do not replace real Host
|
||||
installation, Attach, Ask/Execute, and lifecycle validation during development.
|
||||
- `.github/workflows/agent-release.yml` is the only production compound Agent
|
||||
publication path. It requires an annotated immutable Agent tag, the protected
|
||||
`agent-signing` Environment, native x64/arm64 builds, one production
|
||||
GoodBuddy signing identity for every layer of the compound package and
|
||||
catalog, a non-Latest GitHub Agent Release, and synchronized immutable
|
||||
Beijing OSS objects plus the final signed latest-catalog pointer.
|
||||
- `.github/workflows/packages.yml` and `release:package` build only desktop
|
||||
products. Missing Agent packages, remote Runtime inputs, or GoodBuddy signing
|
||||
credentials must never block an ordinary desktop package or release.
|
||||
- Keep electron-builder invocations on `--publish never`. Main-branch builds
|
||||
run validation and build the production bundle without running the native
|
||||
package matrix. Manual builds upload 30-day GitHub Actions artifacts.
|
||||
Version-tag builds verify and aggregate packages before publishing GitHub
|
||||
Release assets. The macOS jobs sign, notarize, and verify packages when all
|
||||
five Apple credentials are configured. With no Apple credentials they must
|
||||
use the explicit `--unsigned` path, warn that Gatekeeper may block the
|
||||
packages, and still complete; a partial credential set must fail rather than
|
||||
silently downgrade.
|
||||
- Keep `ELECTRON_CACHE` and `ELECTRON_BUILDER_CACHE` under
|
||||
`${{ runner.temp }}` in step-level workflow contexts. A cache beneath the
|
||||
repository inherits the root `"type": "module"` and breaks electron-builder's
|
||||
CommonJS macOS icon tool.
|
||||
- Tag builds must use `v${package.version}`. The workflow also supports manual
|
||||
dispatch and main-branch changes to release tooling.
|
||||
|
||||
### Agent Release Process
|
||||
|
||||
- Agent releases use annotated `agent-v${agent-runtime-lock.agentVersion}` tags
|
||||
and are separate from desktop `v${package.version}` releases. Confirm the
|
||||
exact Agent release commit and tag with the user before creating or pushing
|
||||
either.
|
||||
- Store approved bilingual Agent release notes in
|
||||
`resources/agent-release-notes/<agentVersion>.md`. The Agent publication job
|
||||
reads that exact file instead of generating a generic release description;
|
||||
keep it aligned with the approved version and source range.
|
||||
- The tagged commit must be reachable from protected `main`. The three native
|
||||
jobs build Linux x64, Linux arm64, and Darwin arm64 packages from the same source and locks;
|
||||
the catalog job rejects missing architectures, changed bytes for an existing
|
||||
version/platform/architecture identity, invalid prior signatures, and incompatible
|
||||
matrix metadata.
|
||||
- All three targets share the existing signed `agent-catalog.json`. Before the
|
||||
first mixed-platform catalog is published, release the Desktop reader that
|
||||
accepts Darwin entries and document the required Desktop upgrade. Older
|
||||
Desktop readers reject the entire mixed catalog and cannot check online Agent
|
||||
updates; preserve their locally verified packages and immutable historical
|
||||
releases. Do not introduce a second catalog or silently weaken package
|
||||
signature and integrity verification.
|
||||
- Agent GitHub Releases are ordinary non-draft, non-prerelease releases but
|
||||
must use `--latest=false`; they must never replace the desktop release marked
|
||||
Latest. Publish immutable packages and the versioned catalog to
|
||||
`agent-releases/v<agentVersion>/` in Beijing OSS, publish the GitHub Agent
|
||||
Release, then atomically update the single `agent-releases/latest.json`
|
||||
pointer to that version's immutable catalog and signature.
|
||||
- Do not report an Agent release complete until both public package
|
||||
architectures match the signed catalog by size and SHA-256, the GitHub and
|
||||
OSS catalog bytes/signatures are identical, the application can read the
|
||||
selected source, and GitHub's repository Latest tag remains the desktop tag.
|
||||
|
||||
### 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. Separately ask whether this
|
||||
release should also receive an experimental LoongArch preview build; never
|
||||
infer that choice from an earlier release.
|
||||
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. Write the approved notes to the single entry for the release version in
|
||||
`resources/release-notes.json`. A failed unpublished candidate whose content
|
||||
is carried forward must not retain a duplicate packaged entry.
|
||||
6. Verify that `package.json`, the root `package-lock.json` version, and
|
||||
`package-lock.json.packages[""].version` all equal the release version. Run
|
||||
`npm run release:notes:verify`, `npm test`, `npm run typecheck`, and
|
||||
`npm run lint`. Do not run a local production build, packaging command, or
|
||||
packaged-app launch probe unless the user explicitly requests one.
|
||||
If the user requested a LoongArch preview in step 1, build it separately
|
||||
from this exact candidate by following the LoongArch build document, record
|
||||
its size and SHA-256, and keep it outside the standard GitHub/OSS release
|
||||
asset set unless the user separately authorizes publishing it.
|
||||
7. Commit the approved candidate and confirm the exact release commit with the
|
||||
user. Fetch both remotes, inspect branch movement instead of overwriting or
|
||||
silently merging it, then push that commit to `main` on `origin` and
|
||||
`github`. Require the candidate commit's main-branch GitHub Actions source
|
||||
validation and production build to succeed before tagging.
|
||||
8. Fetch both remotes again immediately before tagging. Confirm the working
|
||||
tree is clean, both remote `main` refs still equal the approved commit, the
|
||||
candidate tag is unused locally and remotely, and the exact approved commit
|
||||
has not changed. Only then create the annotated `v${package.version}` tag at
|
||||
that commit, push the tag to both remotes, and verify each peeled tag SHA
|
||||
(`refs/tags/<tag>^{}`) equals the release commit.
|
||||
9. 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.
|
||||
10. Observe the tag workflow through publication and complete the public
|
||||
verification checklist below. A successful push is not a completed
|
||||
release.
|
||||
|
||||
### OSS Publication Contract
|
||||
|
||||
The tagged release job publishes through the GitHub Environment selected by
|
||||
`.github/workflows/packages.yml` (`aliyun-oss-release`) and reads the following
|
||||
effective GitHub Actions variables. Before a release or same-tag rerun can
|
||||
publish, verify that repository-, organization-, or environment-level
|
||||
resolution exposes:
|
||||
|
||||
- `ALIYUN_OSS_BUCKET=goodbuddy`
|
||||
- `ALIYUN_OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com`
|
||||
- a non-empty `ALIYUN_OIDC_PROVIDER_ARN` matching
|
||||
`acs:ram::*:oidc-provider/*`
|
||||
- a non-empty `ALIYUN_ROLE_ARN` matching `acs:ram::*:role/*`
|
||||
|
||||
For environment-scoped values, use the exact Environment name from the
|
||||
workflow; do not assume a similarly named UI environment such as `Production`
|
||||
contains the active variables.
|
||||
|
||||
The Bucket and Endpoint are a deployment contract, not interchangeable
|
||||
examples. They must stay aligned with the trusted URL checks in
|
||||
`src/main/version-checker.ts`, `sites/app.js`, website validation, and related
|
||||
tests. A host, Bucket, Region, or CDN migration must update and validate every
|
||||
surface together before a new release.
|
||||
|
||||
- Use GitHub OIDC and the RAM Role to obtain short-lived STS credentials.
|
||||
Never add long-lived AccessKeys to repository or environment secrets.
|
||||
- Keep `ossutil` pinned. Its V4 signing requires the Region; derive it from the
|
||||
canonical Endpoint, verify that the production value resolves to
|
||||
`cn-beijing`, and pass `--region` to every `ossutil cp`, including the final
|
||||
`latest.json` update.
|
||||
- Grant the RAM Role only the actions and prefixes required by the workflow.
|
||||
It must be able to write immutable version objects and the final latest
|
||||
pointer without granting unrelated administration privileges.
|
||||
- Upload release assets and `site-release.json` under the immutable
|
||||
`releases/<tag>/` prefix first. Verify all 12 installer URLs publicly before
|
||||
creating or publishing the GitHub Release. Update
|
||||
`releases/latest.json` only after the GitHub Release is public and all prior
|
||||
checks succeeded.
|
||||
- The expected GitHub Release contains 20 assets: 12 installers (two formats
|
||||
for each Windows target, DMG for each macOS target, and three formats for each Linux target), six
|
||||
renamed target manifests, one aggregate `release-manifest.json`, and one `SHA256SUMS`.
|
||||
`site-release.json` is an OSS publication artifact, not a GitHub Release
|
||||
asset.
|
||||
|
||||
### Failed Tag Recovery
|
||||
|
||||
Classify a failed tag by the external side effects that completed before
|
||||
choosing a recovery:
|
||||
|
||||
- If no source or release metadata must change, correct only the external
|
||||
configuration and use **Re-run failed jobs** for the same immutable tag. Do
|
||||
not change, move, delete, or recreate the tag.
|
||||
- If immutable OSS objects were partially uploaded but their source bytes are
|
||||
unchanged, a same-tag rerun may idempotently re-upload or verify them. Never
|
||||
point `latest.json` at a partially verified prefix.
|
||||
- If the GitHub Release is already public but the final latest-pointer step
|
||||
failed, it is a published version. Keep its packaged notes and rerun the
|
||||
failed release job for the same tag; do not classify it as an unpublished
|
||||
candidate.
|
||||
- If code or release metadata must change, keep the failed tag immutable,
|
||||
increment the patch version, obtain approval for the revised exact release
|
||||
commit and notes, and create a new tag. Do not reuse the failed version.
|
||||
|
||||
When recovering from a version tag whose workflow never published a public
|
||||
GitHub Release:
|
||||
|
||||
- If a code or metadata change requires a higher version and a new tag, carry
|
||||
the failed candidate's approved user-facing notes forward into the recovery
|
||||
version, then remove the superseded failed version's entry from
|
||||
`resources/release-notes.json`.
|
||||
- The packaged first-open modal must show that carried-forward content only
|
||||
once under the recovery version. Never retain both the failed version and
|
||||
its cumulative recovery copy, because users upgrading across them would see
|
||||
duplicate content.
|
||||
- Never remove the packaged history for a version that successfully published
|
||||
a GitHub Release. Verify the failed release state before treating an entry as
|
||||
superseded.
|
||||
|
||||
### Post-Publication Verification
|
||||
|
||||
Do not report a release complete until all of the following are verified:
|
||||
|
||||
1. The tag workflow and all six native package jobs succeeded. Verify the
|
||||
recorded macOS signing mode: signed builds must pass `codesign`, `spctl`,
|
||||
and `stapler`; unsigned builds must record the Gatekeeper caveat in the
|
||||
Actions log and job summary. In the final release job, explicitly verify
|
||||
the OSS configuration, OIDC authentication, release-index generation,
|
||||
immutable upload, public asset check, GitHub Release publication, and
|
||||
latest-pointer steps.
|
||||
2. The public GitHub Release is non-draft, non-prerelease, marked Latest, and
|
||||
uses the expected tag and title. Its body must exactly match the Markdown
|
||||
generated from the approved packaged bilingual notes.
|
||||
3. GitHub release metadata contains exactly the expected 20 uploaded asset
|
||||
names, including the expected Windows x64 and arm64 assets. Do not download,
|
||||
hash, launch, or send individual network probes to published installers
|
||||
unless the user explicitly requests that additional validation.
|
||||
4. The Beijing `releases/latest.json` returns HTTP 200, has the expected stable
|
||||
version, exact six targets and 12 installer entries, the trusted Beijing
|
||||
URLs, and the GitHub fallback URL. It must match the immutable
|
||||
`releases/<tag>/site-release.json`. Fetching and comparing these small JSON
|
||||
metadata files is sufficient; rely on the successful publication job for
|
||||
its per-asset public checks.
|
||||
5. The live website successfully fetches the index and produces the 12 correct
|
||||
platform/architecture/format links from that metadata.
|
||||
6. Both remote `main` refs and both peeled tag refs still equal the approved
|
||||
release commit, and the local working tree is clean.
|
||||
|
||||
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 release baseline on 2026-08-18: commit
|
||||
`60119a4317118fa3f077db0382664f15266a6682`, annotated tag `v0.10.4`, and
|
||||
GitHub Actions run `32038633609` attempt 2 succeeded through all six native
|
||||
packages, GitHub Release publication, Beijing OSS publication, and the final
|
||||
`latest.json` switch.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Desktop Window Recovery
|
||||
|
||||
This is recovery hardening for intermittent blank windows, not an established
|
||||
diagnosis of their cause.
|
||||
|
||||
- `src/renderer/src/main.tsx` wraps the locale provider, App and storage-upgrade
|
||||
page in `AppErrorBoundary`. A React render/lifecycle failure replaces the tree
|
||||
with a blocking recovery page and an explicit Reload button. It uses the saved
|
||||
UI language (including system preference resolution), shared theme tokens and
|
||||
primary-button styling, without depending on the failed application providers.
|
||||
- Reload refreshes the current document, retaining its URL and storage-upgrade
|
||||
query. It can lose unsaved input. There are no automatic React retries.
|
||||
- `src/main/window.ts` listens for `render-process-gone`. Abnormal exits open one
|
||||
native dialog at a time, using the OS locale because renderer language storage
|
||||
is unavailable to Main. Reload occurs only on explicit confirmation. Cancel
|
||||
leaves the window unchanged; persistent failures require quitting and reopening
|
||||
GoodBuddy. Clean exits, shutdown and destroyed windows do not trigger recovery.
|
||||
- Main-frame load failures are logged; aborted navigation and subframe failures
|
||||
are ignored. Initial load promises are caught, without automatic load retries
|
||||
or a new load-failure UI.
|
||||
- Native crashes and main-frame load failures use the existing bounded desktop
|
||||
diagnostics store (`desktop.renderer.gone`, `desktop.renderer.load-failed`,
|
||||
stage `renderer`). Persistent records retain fixed summaries rather than raw
|
||||
errors. Crash reason/exit code and React errors/component stacks go to their
|
||||
respective process consoles. There is no new diagnostic IPC.
|
||||
|
||||
The boundary does not catch module evaluation/startup failures before React
|
||||
mounts, event-handler exceptions, asynchronous rejections, a hung renderer, or
|
||||
main-process failure. Reload does not establish that a repeated underlying error
|
||||
has been fixed. Agent runtimes and remote execution are unchanged.
|
||||
|
||||
Targeted regressions are in `AppErrorBoundary.test.tsx`, `window.test.ts` and
|
||||
`desktop-diagnostics.test.ts`. Electron API mocks verify decision paths but do
|
||||
not establish native dialog appearance or full application recovery.
|
||||
|
||||
Validation on Windows, 2026-09-15:
|
||||
|
||||
- Targeted Vitest run: 20 tests passed across the three files above.
|
||||
- `npm test`: 359 files passed, 9 skipped; 4,225 tests passed, 67 skipped.
|
||||
- `npm run typecheck`, `npm run lint` and `git diff --check` passed.
|
||||
- An isolated Electron fixture bundled the current boundary, shared styles and
|
||||
production window module. Both languages and themes at requested content sizes
|
||||
1280x800, 960x720, 720x640 and 640x420 passed visibility, focus and horizontal
|
||||
overflow checks. Windows scaling rounded actual widths down by one CSS pixel.
|
||||
The 16-image overview and Chinese/light and English/dark short-window originals
|
||||
were visually reviewed; no clipping or focus-style defect was found.
|
||||
- A native Space key activated the recovery button and reloaded the document.
|
||||
Two actual renderer crashes verified confirmed reload with URL retention and
|
||||
cancellation without retry. Only native dialog responses were mocked; native
|
||||
dialog appearance, full App business-state recovery and other OSes were not
|
||||
verified. No model calls were made by this fixture.
|
||||
- The first temporary Electron driver timed out during ESM startup. Removing its
|
||||
top-level readiness await fixed the driver; the subsequent run passed. This
|
||||
was a test-driver failure, not evidence of the reported application root cause.
|
||||
@@ -0,0 +1,135 @@
|
||||
# GoodBuddy Feature Documentation Guide
|
||||
|
||||
## Scope
|
||||
|
||||
These instructions apply to all files under `docs/features/`.
|
||||
|
||||
Each direct child directory represents one user-visible product feature or one coherent feature family. Organize documentation by feature ownership first, then by document responsibility.
|
||||
|
||||
## Standard Structure
|
||||
|
||||
Use the smallest applicable subset:
|
||||
|
||||
```text
|
||||
docs/features/<feature-name>/
|
||||
README.md
|
||||
prd.md
|
||||
user-stories.md
|
||||
logic-design.md
|
||||
ui-design.md
|
||||
technical-design.md
|
||||
progress.md
|
||||
```
|
||||
|
||||
Additional documents are allowed when one standard file would mix independent responsibilities. List every additional document and its unique responsibility in the feature `README.md`.
|
||||
|
||||
Do not create empty placeholders. If a standard document is not needed yet, omit it and state the missing responsibility in `README.md` only when that absence matters to current work.
|
||||
|
||||
## Document Responsibilities
|
||||
|
||||
### `README.md`
|
||||
|
||||
- The sole entry point for the feature.
|
||||
- Summarizes feature purpose and boundary.
|
||||
- Lists all feature documents and their authoritative responsibilities.
|
||||
- Defines feature-specific terminology.
|
||||
- Points to cross-feature dependencies without copying them.
|
||||
|
||||
### `prd.md`
|
||||
|
||||
- Defines the user problem, goals, scope, non-goals, functional requirements, and product acceptance.
|
||||
- Describes what the product must do, not module layout or implementation mechanics.
|
||||
- Records explicit limitations when a workflow is intentionally unsupported.
|
||||
|
||||
### `user-stories.md`
|
||||
|
||||
- Defines user roles, scenarios, and Given/When/Then-style acceptance.
|
||||
- Covers first use, normal use, failure recovery, compatibility, permissions, and boundary cases relevant to real users.
|
||||
- Keeps implementation details out unless the detail is itself an observable contract.
|
||||
|
||||
### `logic-design.md`
|
||||
|
||||
- Defines invariants, state dimensions, state transitions, decision tables, priority rules, failure outcomes, and cross-story consistency.
|
||||
- Resolves overlap between PRD requirements and User Stories before technical implementation.
|
||||
- Includes a logic completeness assessment and identifies unresolved product decisions separately from technical selection work.
|
||||
|
||||
### `ui-design.md`
|
||||
|
||||
- Defines feature information architecture, shared component usage, states, copy, feedback, keyboard behavior, accessibility, and responsive behavior.
|
||||
- Must follow the repository root `UI-DESIGN.md`.
|
||||
- Does not create feature-local visual systems when shared tokens and primitives already exist.
|
||||
|
||||
### `technical-design.md`
|
||||
|
||||
- Defines process and module boundaries, shared contracts, persistence, IPC, lifecycle, packaging, compatibility, validation, and implementation order.
|
||||
- Implements the PRD and logic design rather than redefining their behavior.
|
||||
- Follows KISS and YAGNI. Do not add recovery or compatibility machinery without a demonstrated requirement.
|
||||
|
||||
### `progress.md`
|
||||
|
||||
- Records only verified implementation facts, remaining work, blockers, validation commands, and dated evidence.
|
||||
- Never marks an item complete based only on a design, mock, schema, or injected fake when the production path remains incomplete.
|
||||
- Does not duplicate future design. Link back to the authoritative document.
|
||||
|
||||
## Traceability
|
||||
|
||||
For non-trivial features:
|
||||
|
||||
1. PRD requirements use stable IDs such as `FR-1`.
|
||||
2. User Stories use stable IDs such as `US-A1`.
|
||||
3. Logic design maps rules and state transitions to PRD and User Story IDs.
|
||||
4. UI and technical design link to the PRD and logic design.
|
||||
5. Progress items map to implementation stages and record actual validation.
|
||||
|
||||
Do not renumber stable IDs merely for presentation after implementation begins.
|
||||
|
||||
## Source of Truth
|
||||
|
||||
- PRD owns product scope and requirements.
|
||||
- User Stories own user scenarios and scenario acceptance.
|
||||
- Logic design owns behavioral decisions and state rules.
|
||||
- Root `UI-DESIGN.md` owns global UI consistency; feature UI design owns feature-specific application.
|
||||
- Technical design owns implementation architecture.
|
||||
- Progress owns current implementation and validation status.
|
||||
- Final code and validated runtime behavior override stale documentation. Correct all affected documents when they diverge.
|
||||
|
||||
## Feature Boundaries
|
||||
|
||||
- Keep a document in a feature directory when one feature can own its behavior and acceptance.
|
||||
- Keep a document in `docs/architecture`, `docs/quality`, `docs/development`, or `docs/roadmap` only when it truly governs multiple features and has no single feature owner.
|
||||
- Reference cross-feature documents rather than copying their definitions.
|
||||
- When two features share a global domain model, keep that model in the feature family that owns the domain or in cross-feature architecture if ownership is genuinely global.
|
||||
|
||||
## Managed External Artifacts
|
||||
|
||||
When a feature downloads external artifacts managed by GoodBuddy:
|
||||
|
||||
- Define the exact setting that selects the source and the setting scope.
|
||||
- Use immutable artifact targets with expected byte size and SHA-256.
|
||||
- Freeze the selected source when an operation starts.
|
||||
- Fail explicitly when the selected source is unavailable or invalid. Never silently request another source.
|
||||
- Keep download URLs and redirect allowlists out of Renderer-controlled input.
|
||||
- State whether the setting affects models, application updates, Agent packages, managed tool runtimes, package-manager registries, or user-configured URLs. Do not imply a broader scope than implemented.
|
||||
- When two targets are presented as an upstream/original address and its OSS mirror, require byte-identical content with one shared size and SHA-256. A mirror must not rebuild, recompress, or modify the upstream artifact.
|
||||
|
||||
## Migration Rules
|
||||
|
||||
When moving existing documentation:
|
||||
|
||||
1. Preserve content and Git history where practical.
|
||||
2. Update every repository link to the new path.
|
||||
3. Add or update the destination feature `README.md`.
|
||||
4. Delete the old authoritative path; do not leave a duplicate compatibility copy.
|
||||
5. Do not rewrite unrelated document content merely because the file moved.
|
||||
6. Preserve uncommitted user edits while moving files.
|
||||
7. Verify all relative Markdown links after migration.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- The feature has one clear entry point.
|
||||
- Every document has one declared responsibility.
|
||||
- Product requirements, stories, logic, UI, technical design, and progress do not contradict each other.
|
||||
- Normal, failure, cancellation, compatibility, permission, and scope behavior are covered where applicable.
|
||||
- UI behavior follows `UI-DESIGN.md`.
|
||||
- Progress claims are backed by dated evidence.
|
||||
- All relative links resolve.
|
||||
@@ -0,0 +1,96 @@
|
||||
# 应用导航与系统工具入口
|
||||
|
||||
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,内置应用通过最底部应用中心
|
||||
访问,并可常驻主导航;系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
|
||||
商店入口、推荐位或安装流程。
|
||||
|
||||
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
|
||||
本轮更新实施计划与范围,旧版功能要求、验收和配套设计仍需按新计划同步,此项列为实现前的第一步。
|
||||
|
||||
新增计划包括 [私有化市场与 YAML 交换](./prd.md#fr-15-私有化市场与-yaml-交换后续):
|
||||
通过市场 URL 获取应用,支持 `.yml` 导入导出,未来由 ShareServer 提供私有目录与分发。
|
||||
[本机模型管理入口](./prd.md#fr-16-本机模型管理入口新增计划项)可独立于商店交付,拟作为
|
||||
可常驻左侧的内置管理应用,区分受管进程、外部服务和进程内模型。两项均为需求记录,
|
||||
未实现,相关后续验收见 [User Stories](./user-stories.md#7-后续需求场景)。
|
||||
|
||||
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与级联菜单见
|
||||
[活动界面](./ui-design.md#10-项目活动汇总),数据加载与会话跳转见
|
||||
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”指当前运行或待处理会话,
|
||||
与用于审计的“运行记录”不同;已完成未读面板属于后续范围,本次未实现。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [PRD](./prd.md) | 用户问题、产品边界、功能要求与产品验收 |
|
||||
| [User Stories](./user-stories.md) | 用户场景及 Given/When/Then 验收 |
|
||||
| [功能逻辑设计](./logic-design.md) | 分类规则、入口状态、优先级与失败结果 |
|
||||
| [UI 设计](./ui-design.md) | 侧栏信息架构、控件、文案、键盘和响应式行为 |
|
||||
| [技术设计](./technical-design.md) | 现有模块映射、状态契约、持久化和实施顺序 |
|
||||
| [实施进度](./progress.md) | 项目活动级联的生产实现与验证记录 |
|
||||
| [应用中心交互 Demo](./navigation-demo.html) | 对比六个候选方案;配套 CSS 和 JS 仅服务原型 |
|
||||
|
||||
Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加、移除、排序和商店模拟。
|
||||
六个方案共用本次页面会话内的应用配置,刷新后恢复默认。商店、资源指标和工具内容均为
|
||||
演示数据,不执行真实安装或本机操作。窄屏上下排列侧栏与输入区,桌面展示完整应用布局。
|
||||
这些方案用于讨论最底部应用中心;其中商店页、商店应用安装与 F 方案属于未来交互参考,
|
||||
不纳入首期实现或验收。首期产品只提供内置应用;Demo 暂保留远期示例以便后续讨论。
|
||||
底部输入区共用一个简化候选布局:附件、语音、Runtime、模式和发送位于主操作行,专家、
|
||||
知识范围及 Runtime 专属选项通过“选项”展开,非默认选择显示为可移除标签。可体验文件名
|
||||
附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止,
|
||||
以及上下文详情和模拟压缩。文件只读取名称,语音使用固定示例文本,不录音、不上传、不调用 AI。
|
||||
这是输入区设计讨论用的原型,尚未改变正式产品或替代现有 UI 规范。
|
||||
左上角项目选择器下方仅在有运行中或待处理会话时显示活动摘要,全部空闲时隐藏。点击摘要打开项目
|
||||
列表,悬停或点击项目向右展开会话子菜单,点击会话才切换项目与对话。子菜单与项目列表
|
||||
相接,项目行保留子菜单箭头,会话行只显示名称与状态。支持上下方向键选择、右键进入、左键或 Esc 返回,顶层 Esc 关闭;窄屏在同一浮层内
|
||||
进入会话层,并提供返回按钮。页面上方可切换“有待处理、仅运行中、仅当前项目运行、全部
|
||||
空闲”四种场景。项目与会话均为演示数据;该级联交互已接入正式产品活动入口,范围与
|
||||
验证记录见[实施进度](./progress.md)。
|
||||
按最新讨论,Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
|
||||
工作栏 Tab 中演示,不列入应用中心。上方正式文档仍保留原设计,待方案确定后同步。
|
||||
|
||||
六个方案共用“我的应用”区:通过 + 或“全部应用”选择常驻应用,编辑时可拖动或使用
|
||||
上下箭头排序、移除入口。未常驻的已安装应用仍可从“全部应用”打开。商店包含“项目管理”
|
||||
和“阅读空间”应用示例,支持仅安装或安装并常驻左侧菜单,常驻不表示后台运行。
|
||||
常驻应用在本次页面会话内跨方案保持一致;“恢复默认应用”重置安装与常驻配置。
|
||||
移除入口不会卸载应用或关闭当前页面,刷新后恢复默认。
|
||||
|
||||
魔法笔记提供应用中心“设置”、笔记页面“笔记设置”和全局设置演示入口,均打开同一设置
|
||||
面板,共用本次页面会话内的配置。面板沿用现有选项:显示未完成待办数量、AI 评论方式
|
||||
(即时/保存后自动/保存后手动)、AI 评论形式(长评 + 要点/长评/要点)。现有“显示
|
||||
魔法笔记入口”在原型中与“常驻左侧菜单”统一,不额外增加功能启停开关。侧栏待办数为
|
||||
固定的 3 项演示数据。修改即时生效于原型,不调用 AI 或写入真实应用设置。
|
||||
|
||||
| 方案 | 讨论重点 |
|
||||
| --- | --- |
|
||||
| A:紧凑入口 | 从底部向上展开应用列表 |
|
||||
| B:应用宫格 | 在展开面板中展示应用图标、名称和常驻操作 |
|
||||
| C:应用预览 | 用卡片说明应用用途 |
|
||||
| D:搜索启动台 | 搜索打开能力,支持 Ctrl / Command K、方向键与 Enter |
|
||||
| E:常驻管理 | 已常驻应用优先排列,可取消常驻或打开 |
|
||||
| F:商店发现(未来) | 默认展开模拟商店,不纳入首期 |
|
||||
|
||||
D 参考 [Raycast](https://www.raycast.com/) 的搜索启动与扩展发现方式。早期方案参考过
|
||||
Notion 收藏和 VS Code Profiles,当前版本已移除场景工具包与快捷区。
|
||||
原型支持 URL 的 `#launcher`、`#favorites`、
|
||||
`#scenes` 等片段直接打开指定方案。
|
||||
|
||||
## 术语
|
||||
|
||||
| 术语 | 定义 |
|
||||
| --- | --- |
|
||||
| 系统入口 | GoodBuddy 工作流程或审计所必需的固定入口,用户不能卸载、隐藏或改变类型 |
|
||||
| 应用 | 在主内容区打开完整一级页面的功能,可显示在“当前对话”所在的主导航区 |
|
||||
| 系统工具 | GoodBuddy 自带且不可卸载的辅助能力,包括文件、终端、浏览器和资源监控,由右侧助手工作栏 Tab 承载 |
|
||||
| 插件应用 | 未来由插件提供的一级页面入口 |
|
||||
| 插件工具 | 未来由插件提供的辅助面板或命令入口 |
|
||||
| 快捷入口 | 只负责打开或聚焦能力,不复制该能力的界面和业务状态 |
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md)
|
||||
- [通用助手工作栏与执行空间 PRD](../assistant-workbar/prd.md)
|
||||
- [助手工作栏多终端页签 PRD](../assistant-workbar/terminal-tabs-prd.md)
|
||||
- [智能心跳 PRD](../smart-heartbeat/prd.md)
|
||||
- [知识库功能文档](../knowledge-base/README.md)
|
||||
@@ -0,0 +1,196 @@
|
||||
# 应用导航与系统工具入口功能逻辑设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 关联 User Stories | [User Stories](./user-stories.md) |
|
||||
|
||||
## 1. 不变量
|
||||
|
||||
1. “当前对话”“运行记录”和“设置”始终是系统入口。
|
||||
2. 运行记录不进入工具注册表、应用显隐设置或插件市场。
|
||||
3. 文件浏览器、终端、浏览器和资源监控始终是系统工具,不能卸载或被插件替换。
|
||||
4. 左下角只提供快捷入口,工具内容仍由助手工作栏承载。
|
||||
5. 隐藏应用不等于停用功能,也不删除、迁移或清理业务数据。
|
||||
6. 快捷入口不能扩大执行空间、文件、PTY、网络或浏览器权限。
|
||||
|
||||
## 2. 入口模型
|
||||
|
||||
```ts
|
||||
type NavigationContribution =
|
||||
| {
|
||||
type: 'system-entry'
|
||||
id: 'chat' | 'activity' | 'settings'
|
||||
placement: 'primary-start' | 'primary-end' | 'footer'
|
||||
}
|
||||
| {
|
||||
type: 'application'
|
||||
id: 'magic-notes' | 'knowledge' | 'heartbeat'
|
||||
source: 'builtin'
|
||||
visible: boolean
|
||||
}
|
||||
| {
|
||||
type: 'system-tool'
|
||||
id: 'workspace' | 'terminal' | 'browser' | 'resources'
|
||||
workbarAppId: 'workspace' | 'terminal' | 'browser' | 'resources'
|
||||
}
|
||||
```
|
||||
|
||||
该模型只覆盖首期入口。插件贡献模型随插件 Host、权限和生命周期另行设计。
|
||||
|
||||
## 3. 主导航组合规则
|
||||
|
||||
主导航按以下顺序计算:
|
||||
|
||||
```text
|
||||
当前对话
|
||||
可见的内置应用
|
||||
运行记录
|
||||
```
|
||||
|
||||
当前对话和运行记录不参与排序。首期内置应用默认顺序为魔法笔记、知识库、智能心跳;显隐
|
||||
设置只从中移除对应项,不改变固定入口的位置。
|
||||
|
||||
### 3.1 应用可见性与业务状态
|
||||
|
||||
| 导航可见 | 功能启用 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 是 | 是 | 显示入口并允许进入 |
|
||||
| 否 | 是 | 隐藏入口,后台和数据保持原状 |
|
||||
| 是 | 否 | 不显示不可用入口;设置页保留启用路径 |
|
||||
| 否 | 否 | 不显示入口,保留符合该功能自身规则的数据 |
|
||||
|
||||
“功能启用”由各应用现有产品设置负责。例如魔法笔记启停仍使用原设置;导航偏好不能覆盖它。
|
||||
|
||||
## 4. 系统工具打开规则
|
||||
|
||||
### 4.1 通用流程
|
||||
|
||||
```text
|
||||
用户激活工具按钮
|
||||
-> 校验按钮仍可用
|
||||
-> 向助手工作栏发送打开意图
|
||||
-> 工作栏按应用实例策略查找或创建实例
|
||||
-> 展开工作栏
|
||||
-> 激活目标实例
|
||||
-> 把焦点移入面板标题或首个主要控件
|
||||
```
|
||||
|
||||
若创建失败,工作栏保持展开并在对应能力位置显示错误;主页面和其他工作栏实例不被清空。
|
||||
|
||||
### 4.2 决策表
|
||||
|
||||
| 工具 | 匹配实例存在 | 匹配实例不存在 | 上下文不可用 |
|
||||
| --- | --- | --- | --- |
|
||||
| 文件 | 激活不可关闭的单实例并保留其跟随或固定状态 | 仅在迁移或布局修复时补回 `workspace` | 打开面板并显示目录修复入口 |
|
||||
| 终端 | 激活当前执行空间最近的 `starting` 或 `running` 实例 | 创建一个绑定当前执行空间的实例 | 没有项目时使用本机 Home |
|
||||
| 浏览器 | 激活绑定当前 Conversation 的最近使用实例 | 创建绑定当前 Conversation 的新实例 | 激活最近使用实例;不存在时创建待绑定实例 |
|
||||
| 资源监控 | 激活 `resources` 单实例 | 创建本机 `resources` 单实例 | 部分指标不可用时显示原因并保留其余指标 |
|
||||
|
||||
文件保持单一实例原有绑定。浏览器快捷入口按当前 Conversation 匹配最近使用实例,而工作栏
|
||||
“+”始终新建浏览器实例。固定目标失效时显示该目标的修复状态,不回退到当前项目、默认
|
||||
工作区或当前会话。
|
||||
|
||||
`closing`、`exited`、`interrupted` 和 `failed` 终端不属于可复用实例。当前执行空间只有这些
|
||||
终端时,快捷按钮创建新实例并保留原 Tab 的历史状态。用户需要额外的并行终端时使用工作栏
|
||||
“+”或终端面板中的既有创建命令。
|
||||
|
||||
### 4.3 选中与状态
|
||||
|
||||
工具按钮满足以下条件时显示选中:助手工作栏已展开,且当前活动实例的 `appId` 与工具映射
|
||||
一致。只有实例存在但未激活时不显示选中。
|
||||
|
||||
徽标优先级为:等待用户处理 > 错误 > 运行中 > 无徽标。同一按钮只显示一个视觉徽标,
|
||||
可访问名称包含完整状态。
|
||||
|
||||
资源监控首期不在后台采样,因此不显示负载告警徽标。只有资源面板是工作栏当前活动实例且
|
||||
工作栏已展开时,资源按钮才显示选中状态。
|
||||
|
||||
### 4.4 资源采样状态
|
||||
|
||||
```text
|
||||
inactive -> warming -> loading -> live
|
||||
-> partial
|
||||
-> error
|
||||
live/partial/error -> loading
|
||||
warming/loading/live/partial/error -> inactive
|
||||
```
|
||||
|
||||
- `inactive`:资源面板未处于可见活动状态,没有采样定时器。
|
||||
- `warming`:首次进入或停止后恢复,建立 CPU 和磁盘 IO 区间基线;内存仍可立即显示。
|
||||
- `loading`:正在请求后续样本;请求未结束时跳过后续时钟触发。
|
||||
- `live`:所有首期指标可用,按 2 秒间隔追加样本。
|
||||
- `partial`:至少一个指标可用;不可用项携带稳定错误码,其他指标继续更新。
|
||||
- `error`:本次没有任何可用指标,保留已有历史;自动时钟或手动操作可以重试。
|
||||
|
||||
资源面板为活动实例、工作栏展开且 `document.visibilityState` 为 `visible` 时才离开 `inactive`。
|
||||
任一条件失效即停止定时器并重置区间基线。历史队列只保留最后 150 个样本;时间间隔较长或
|
||||
采样失败时保留真实时间缺口,不补零或伪造中间数据。
|
||||
|
||||
## 5. 设置保存规则
|
||||
|
||||
应用导航偏好与应用业务启停分开保存。Switch 每次切换立即提交完整目标集合;请求完成前禁用
|
||||
三个应用显隐 Switch,下一次操作基于最新确认结果提交,防止并发更新互相覆盖。
|
||||
|
||||
保存失败时:
|
||||
|
||||
- 主导航和 Switch 恢复最后一次成功保存的偏好。
|
||||
- 设置行显示可重试错误。
|
||||
- 不回滚或修改各应用的业务状态。
|
||||
|
||||
## 6. 与需求的对应关系
|
||||
|
||||
| 逻辑 | PRD | User Stories |
|
||||
| --- | --- | --- |
|
||||
| 固定入口不变量 | FR-1、FR-2 | US-A1、US-A2 |
|
||||
| 应用显隐和失败恢复 | FR-3、FR-4、FR-11、FR-12 | US-B1、US-B2、US-B4、US-B5 |
|
||||
| 工具打开决策表 | FR-5 至 FR-10 | US-C1 至 US-C7 |
|
||||
| 资源采样状态 | FR-9 | US-C6 |
|
||||
| 无障碍与窄窗口 | FR-13 | US-D1、US-D2 |
|
||||
|
||||
## 7. 逻辑完整性评估
|
||||
|
||||
首期规则已覆盖固定入口、应用显隐、保存失败、系统工具匹配与显式多实例差异、本机 Home、
|
||||
托管 SSH、无活动会话、目录失效、资源部分可用、采样停止、权限边界、键盘和窄窗口。其他
|
||||
工具面板内部的取消、超时、
|
||||
连接恢复和固定目标失效继续由助手工作栏负责,本功能不复制这些状态机。
|
||||
|
||||
## 8. 后续产品决策
|
||||
|
||||
- 插件工具上线后,快捷栏允许固定的最大数量及溢出规则。
|
||||
- 插件应用是否允许用户排序,以及固定系统入口之间允许的排序范围。
|
||||
|
||||
这些决策不影响首期四个系统工具的固定入口和内置应用显隐实现。
|
||||
|
||||
## 9. 项目活动汇总
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),按 Conversation ID 聚合,每条会话最多一行。
|
||||
数据来源为活动会话 ID、会话中的流式消息及待回答/待审批请求,以及全部可见的运行中或
|
||||
等待处理 Task;不能只取当前项目、当前搜索结果或每条会话的最新 Task。
|
||||
|
||||
| 优先级(高到低) | 条件 | 汇总类别 |
|
||||
| --- | --- | --- |
|
||||
| 待回答 | 消息有待回答问题 | 待处理 |
|
||||
| 待审批 | 消息有待审批请求 | 待处理 |
|
||||
| Task 等待处理 | Task 为 `waiting_approval`,尚无更具体的消息状态 | 待处理 |
|
||||
| 运行中 | 活动会话 ID、`streaming` 消息或运行中 Task | 运行中 |
|
||||
|
||||
同一会话只采用最高优先级状态;Task 的 `waiting_approval` 也可能对应提问,不能直接
|
||||
标成待审批。“待处理”和“运行中”数量互斥,全局总数为两者之和;项目计数使用相同结果。
|
||||
项目归属优先取会话元数据,缺少会话时取 Task 元数据;无项目归属的活动只计入全局汇总。
|
||||
|
||||
历史排队项、仅待调度的 Task、终态 Task 和未读标记本身不构成活动。活动结束且没有其他
|
||||
运行或等待条件时移除该行,不能因历史任务或未读状态继续计数。
|
||||
|
||||
级联菜单先按项目归属聚合活动行,沿用本地、SSH Host、通道的稳定项目顺序;缺少项目
|
||||
元数据的归属组及“未归属项目”组放在已知项目之后。无归属组只用于访问这些会话,不增加
|
||||
项目计数。每个项目内先列待处理会话,再列运行中会话,同类别保留来源顺序。
|
||||
|
||||
展开项目只改变菜单层级,不导航。选中项目的活动全部结束后返回项目列表;全局活动结束后
|
||||
关闭菜单并隐藏摘要入口。后台更新保留仍存在的焦点项,删除焦点项时将焦点
|
||||
移至当前层可用项。关闭菜单后,新的活动只恢复摘要,不会自动重新打开菜单。
|
||||
@@ -0,0 +1,133 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-canvas: #e8f0f5; --surface-raised: #fff; --surface-subtle: #f4f8fb;
|
||||
--text-primary: #17263a; --text-secondary: #4f6278; --border-default: #cedce7;
|
||||
--accent: #086fc7; --accent-subtle: #e8f4ff; --accent-selected: #a7cfea;
|
||||
--font-family-ui: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", system-ui, sans-serif;
|
||||
--radius-control: 10px; --radius-card: 14px;
|
||||
--warning: #865200;
|
||||
}
|
||||
[data-theme="dark"] { --warning: #f2bd66; }
|
||||
.project-lab { max-width: 1440px; margin: 0 auto 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; color: var(--text-secondary); font-size: 12px; }
|
||||
.project-lab select { font: inherit; color: var(--text-primary); background: var(--surface-raised); padding: 4px 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }
|
||||
.project-entry { position: relative; margin-bottom: 12px; }
|
||||
#project-trigger { width: 100%; display: flex; align-items: center; gap: 8px; text-align: left; border: 1px solid var(--border-default); }
|
||||
#project-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.project-waiting { color: var(--warning); font-weight: 600; }
|
||||
#activity-trigger { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: -8px 0 12px; padding: 4px 8px; color: var(--text-secondary); font-size: 11px; text-align: left; }
|
||||
#project-activity { position: fixed; inset: auto; margin: 0; padding: 0; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); max-width: calc(100vw - 32px); }
|
||||
#project-activity:popover-open { display: flex; align-items: stretch; }
|
||||
#project-activity > section { width: 264px; max-width: calc(100vw - 34px); max-height: min(480px, calc(100dvh - 32px)); overflow: auto; padding: 8px; }
|
||||
#project-activity h3 { margin: 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
#activity-project-list > p { padding: 8px; }
|
||||
#activity-project-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; gap: 4px 8px; text-align: left; }
|
||||
#activity-project-list button > span:first-child { overflow-wrap: anywhere; }
|
||||
#activity-project-list small { grid-column: 1; font-size: 11px; color: var(--text-secondary); }
|
||||
#activity-project-list button > span:last-child { grid-column: 2; grid-row: 1 / 3; align-self: center; }
|
||||
#activity-project-list [aria-expanded="true"] { background: var(--accent-subtle); color: var(--accent); }
|
||||
#activity-sessions { border-left: 1px solid var(--border-default); }
|
||||
#activity-back { display: none; font-size: 12px; color: var(--accent); }
|
||||
#project-activity.activity-left { flex-direction: row-reverse; }
|
||||
.activity-left #activity-sessions { border-left: 0; border-right: 1px solid var(--border-default); }
|
||||
.activity-compact.has-submenu #activity-projects { display: none; }
|
||||
.activity-compact #activity-back { display: block; }
|
||||
.activity-compact #activity-sessions { border: 0; }
|
||||
#project-menu { position: fixed; inset: auto; margin: 0; width: min(300px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow: auto; padding: 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); }
|
||||
#project-list h3, #activity-list h3 { margin: 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); }
|
||||
#project-list button { width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; text-align: left; }
|
||||
.project-row-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
#project-list [aria-current="true"] { background: var(--accent-subtle); color: var(--accent); }
|
||||
#project-list small { width: 100%; display: flex; gap: 12px; font-size: 11px; color: var(--text-secondary); }
|
||||
#activity-list button { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; text-align: left; padding: 12px; }
|
||||
#activity-list strong { font-weight: 500; overflow-wrap: anywhere; }
|
||||
#activity-list small { grid-column: 1; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.history button small { display: block; font-size: 11px; color: var(--text-secondary); }
|
||||
[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --accent-selected: #28668f; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px 32px; font: 13px/1.6 var(--font-family-ui); background: var(--surface-subtle); color: var(--text-primary); }
|
||||
button, input, textarea { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; border: 0; background: transparent; border-radius: var(--radius-control); padding: 8px 12px; }
|
||||
button:hover { background: var(--accent-subtle); color: var(--accent); }
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; vertical-align: middle; flex-shrink: 0; }
|
||||
p, h1, h2 { margin: 0; } h1 { font-size: 30px; font-weight: 550; margin: 12px 0; letter-spacing: -.8px; } h2 { font-size: 24px; }
|
||||
.muted, .eyebrow { color: var(--text-secondary); }.eyebrow { font-size: 11px; letter-spacing: 1px; }
|
||||
.lab-header, .design-note, .lab-footer { display: flex; align-items: center; justify-content: space-between; gap: 24px; max-width: 1440px; margin: auto; }
|
||||
.lab-header strong { font-size: 16px; }.lab-header p { color: var(--text-secondary); font-size: 12px; margin-top: 4px; }
|
||||
.variants { display: grid; grid-template-columns: repeat(3, 1fr); padding: 4px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }
|
||||
.variants button[aria-pressed="true"] { background: var(--surface-raised); color: var(--accent); box-shadow: 0 1px 3px var(--border-default); }
|
||||
.design-note { margin: 24px auto 12px; font-size: 12px; }.design-note #variant-label { color: var(--accent); font-weight: 600; white-space: nowrap; }.design-note p { flex: 1; }.design-note > span:last-child { color: var(--text-secondary); font-size: 11px; }
|
||||
.app { display: flex; max-width: 1440px; height: min(780px, calc(100dvh - 204px)); min-height: 590px; margin: auto; border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; background: var(--surface-raised); }
|
||||
.sidebar { width: 278px; flex: 0 0 278px; background: var(--surface-canvas); display: flex; flex-direction: column; padding: 20px 12px 8px; border-right: 1px solid var(--border-default); }
|
||||
.brand { display: flex; gap: 8px; align-items: center; padding: 0 8px 24px; }.brand > .muted { margin-left: auto; font-size: 11px; }.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; background: var(--text-primary); color: var(--surface-raised); border-radius: 10px; font-weight: 650; font-size: 12px; }
|
||||
.new-chat { border: 1px solid var(--border-default); background: var(--surface-raised); text-align: left; margin-bottom: 16px; }.new-chat span { margin-right: 8px; }
|
||||
.navigation { display: grid; gap: 3px; }.navigation button, .fixed-entry button { display: flex; align-items: center; gap: 10px; text-align: left; padding: 9px 10px; }.navigation .selected { background: var(--accent-subtle); color: var(--accent); }
|
||||
.history { flex: 1; min-height: 72px; overflow: auto; padding: 24px 0 8px; }.history .eyebrow { display: block; padding: 0 10px 8px; }.history button { display: block; width: 100%; text-align: left; color: var(--text-secondary); font-size: 12px; }
|
||||
.fixed-entry { border-top: 1px solid var(--border-default); padding: 8px 0; }.fixed-entry button { width: 100%; }
|
||||
.shortcuts { border-top: 1px solid var(--border-default); padding-top: 12px; max-height: 52%; overflow: auto; flex-shrink: 0; }.section-label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; padding-left: 8px; font-size: 11px; color: var(--text-secondary); }.section-label button { font-size: 11px; padding: 4px 8px; }
|
||||
.shortcut-list { display: grid; gap: 4px; }.shortcut-item { min-width: 0; }.shortcut-button { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; }.shortcut-button.active { color: var(--accent); background: var(--accent-subtle); }.shortcut-button .icon-tile { display: grid; place-items: center; }.shortcut-button .item-name { font-size: 11px; }
|
||||
[data-variant="dock"] .shortcut-list { grid-template-columns: repeat(5, minmax(0, 1fr)); background: var(--surface-raised); padding: 4px; border: 1px solid var(--border-default); border-radius: var(--radius-card); }
|
||||
[data-variant="dock"] .shortcut-button { padding: 10px 0; flex-direction: column; gap: 4px; }[data-variant="dock"] .shortcut-button .item-name { display: none; }[data-variant="dock"] .shortcut-list.editing { grid-template-columns: repeat(3, minmax(0, 1fr)); }[data-variant="dock"] .editing .item-name { display: block; }
|
||||
.store-link { margin-top: 8px; padding: 8px; display: flex; align-items: center; width: 100%; gap: 8px; text-align: left; font-size: 12px; }.store-link span:last-child { margin-left: auto; color: var(--text-secondary); }
|
||||
[data-variant="grid"] .shortcut-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }[data-variant="grid"] .shortcut-button { flex-direction: column; gap: 4px; padding: 8px 4px; }[data-variant="grid"] .icon-tile { width: 36px; height: 36px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); }
|
||||
.featured { border: 1px solid var(--border-default); border-radius: var(--radius-control); padding: 12px; margin-top: 12px; background: var(--surface-raised); position: relative; }.featured p { font-size: 11px; color: var(--text-secondary); margin: 4px 0; }.featured .dismiss { position: absolute; right: 4px; top: 4px; padding: 0 8px; }.featured .feature-open { color: var(--accent); padding: 4px 0 0; font-size: 11px; }
|
||||
[data-variant="cards"] .shortcut-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }[data-variant="cards"] .shortcut-button { background: var(--surface-raised); border: 1px solid var(--border-default); padding: 12px 8px; flex-direction: column; align-items: flex-start; }[data-variant="cards"] .shortcut-button .item-name { font-size: 12px; }.card-detail { font-size: 11px; color: var(--text-secondary); text-align: left; }.mini-meter { display: flex; gap: 3px; align-items: end; height: 24px; }.mini-meter i { display: block; width: 5px; background: var(--accent-selected); border-radius: 2px; }.mini-meter i:nth-child(3n) { background: var(--accent); }
|
||||
.edit-actions { display: flex; justify-content: center; }.edit-actions button { padding: 2px 6px; font-size: 12px; }.sidebar-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; padding-left: 8px; font-size: 11px; }
|
||||
.workspace { flex: 1; min-width: 0; display: flex; flex-direction: column; }.workspace-header { display: flex; justify-content: space-between; padding: 16px 24px; border-bottom: 1px solid var(--border-default); font-size: 12px; }.conversation { flex: 1; padding: 64px 40px 24px; max-width: 740px; width: 100%; margin: auto; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }.suggestions { display: flex; gap: 12px; margin-top: 32px; flex-wrap: wrap; }.suggestions button { border: 1px solid var(--border-default); font-size: 12px; }.suggestions span { margin-left: 24px; }
|
||||
.composer-area { margin: 16px 24px 8px; flex-shrink: 0; }
|
||||
.composer { padding: 12px; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-subtle); }
|
||||
.composer:focus-within { border-color: var(--accent-selected); }
|
||||
.composer textarea { display: block; width: 100%; height: 76px; min-height: 76px; max-height: 180px; padding: 4px; border: 0; resize: none; background: transparent; line-height: 1.8; }
|
||||
.composer textarea::placeholder { color: var(--text-secondary); font-size: 12px; }
|
||||
.composer-toolbar { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.composer-toolbar button { padding: 6px 8px; font-size: 12px; white-space: nowrap; }
|
||||
.composer-toolbar .composer-icon { width: 32px; height: 32px; padding: 4px; font-size: 20px; }
|
||||
.composer-select select { max-width: 132px; padding: 6px 2px; border: 0; background: transparent; cursor: pointer; font: inherit; font-size: 12px; color: var(--text-primary); }
|
||||
select option { background: var(--surface-raised); color: var(--text-primary); }
|
||||
select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.composer-actions { display: flex; gap: 4px; margin-left: auto; }
|
||||
#send { width: 32px; height: 32px; padding: 0; font-size: 20px; background: var(--accent); color: var(--surface-raised); }
|
||||
#voice[aria-pressed="true"] { background: var(--accent-subtle); color: var(--accent); }
|
||||
.composer-meta { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-height: 32px; color: var(--text-secondary); font-size: 10px; }
|
||||
.composer-meta button { font-size: 10px; padding: 4px; }
|
||||
#composer-tags { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 8px; max-height: 96px; overflow: auto; }
|
||||
#composer-tags button { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 8px; font-size: 11px; background: var(--accent-subtle); color: var(--accent); }
|
||||
#composer-options, #context-details { position: fixed; inset: auto; margin: 0; width: min(320px, calc(100vw - 32px)); max-height: min(460px, 70dvh); overflow: auto; padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-raised); color: var(--text-primary); box-shadow: 0 8px 32px #07131f26; font-size: 12px; }
|
||||
#composer-options header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.option-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 0; }
|
||||
.option-row select { width: 166px; padding: 6px; border: 1px solid var(--border-default); border-radius: 6px; font: inherit; background: var(--surface-subtle); color: var(--text-primary); }
|
||||
#composer-options fieldset { margin: 8px 0; padding: 12px 0; border: 0; border-top: 1px solid var(--border-default); }
|
||||
#composer-options fieldset label { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; }
|
||||
#composer-options input { accent-color: var(--accent); }
|
||||
#composer-options p { margin-top: 12px; font-size: 11px; }
|
||||
#context-details p { margin: 8px 0; color: var(--text-secondary); }
|
||||
#context-details progress { width: 100%; accent-color: var(--accent); }
|
||||
#compact { margin-top: 8px; color: var(--accent); background: var(--accent-subtle); }
|
||||
#demo-queue { max-height: 96px; overflow: auto; margin-bottom: 8px; }
|
||||
.queue-row { display: flex; align-items: center; gap: 8px; font-size: 11px; }
|
||||
.queue-row span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.queue-row button { flex-shrink: 0; padding: 4px; font-size: 11px; }
|
||||
.workbar { width: 280px; flex-shrink: 0; border-left: 1px solid var(--border-default); background: var(--surface-subtle); }.workbar header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border-default); }#tool-content { padding: 24px 16px; }.metric { margin: 20px 0; }.metric strong { float: right; }.metric progress { width: 100%; accent-color: var(--accent); }.file-row { padding: 12px 0; border-bottom: 1px solid var(--border-default); }pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
|
||||
.lab-footer { margin-top: 12px; font-size: 12px; color: var(--text-secondary); }
|
||||
dialog { width: min(580px, calc(100% - 32px)); max-height: 85dvh; border: 1px solid var(--border-default); padding: 24px; border-radius: var(--radius-card); background: var(--surface-raised); color: var(--text-primary); }dialog::backdrop { background: #07131f80; }dialog header { display: flex; justify-content: space-between; align-items: start; }dialog > p { margin: 12px 0 16px; }dialog input { width: 100%; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--border-default); border-radius: var(--radius-control); }.catalog-row { display: flex; align-items: center; gap: 12px; padding: 16px 0; border-bottom: 1px solid var(--border-default); }.catalog-row > div { flex: 1; }.catalog-row p { font-size: 11px; color: var(--text-secondary); }.catalog-row button { color: var(--accent); background: var(--accent-subtle); font-size: 12px; }dialog footer { font-size: 11px; color: var(--text-secondary); margin-top: 16px; }
|
||||
#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: var(--radius-control); background: var(--text-primary); color: var(--surface-raised); pointer-events: none; opacity: 0; }#toast.visible { opacity: 1; }
|
||||
[hidden] { display: none !important; }
|
||||
#page-actions { margin-left: auto; }#page-actions button { padding: 4px 8px; font-size: 12px; }.note-count { margin-left: auto; padding: 0 6px; background: var(--accent-subtle); border-radius: var(--radius-control); color: var(--accent); }.note-setting-row { display: flex; gap: 16px; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid var(--border-default); }.note-setting-row p, .note-setting-options p { font-size: 11px; color: var(--text-secondary); margin-top: 6px; }.demo-switch { width: 36px; height: 20px; border-radius: 20px; border: 1px solid var(--text-secondary); background: var(--surface-canvas); padding: 2px; flex-shrink: 0; }.demo-switch::after { content: ''; display: block; width: 14px; height: 14px; border-radius: 50%; background: var(--text-secondary); }.demo-switch[aria-checked="true"] { background: var(--accent); border-color: var(--accent); }.demo-switch[aria-checked="true"]::after { margin-left: 16px; background: var(--surface-raised); }.note-setting-options { border: 0; padding: 16px 0; margin: 0; min-width: 0; }.note-setting-options legend { padding-top: 16px; font-weight: 550; }.note-segments { display: flex; padding: 4px; gap: 4px; background: var(--surface-subtle); border: 1px solid var(--border-default); border-radius: var(--radius-control); }.note-segments button { flex: 1; padding: 8px 4px; font-size: 12px; }.note-segments [aria-pressed="true"] { color: var(--accent); background: var(--surface-raised); box-shadow: 0 1px 3px var(--border-default); }.note-settings-preview { padding: 16px; background: var(--accent-subtle); border-radius: var(--radius-control); }.note-settings-preview small { color: var(--text-secondary); font-size: 11px; }#back-to-apps { margin-bottom: 12px; padding-left: 0; color: var(--accent); }#note-settings-summary { margin: 16px 0; }
|
||||
.sidebar-footer { border-top: 1px solid var(--border-default); padding: 8px 0 0; gap: 4px; }#center-entry { flex: 1; min-width: 0; }#open-center { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px; text-align: left; }#open-center strong { font-size: 13px; font-weight: 550; }#open-center small { display: block; font-size: 11px; color: var(--text-secondary); }.entry-arrow { margin-left: auto; }.tool-tabs { display: flex; padding: 8px; gap: 2px; }.tool-tabs button { flex: 1; padding: 8px 4px; font-size: 12px; }.tool-tabs [aria-selected="true"] { background: var(--accent-subtle); color: var(--accent); }.center-switch { display: flex; gap: 4px; padding-bottom: 12px; }.center-switch [aria-pressed="true"] { background: var(--accent-subtle); color: var(--accent); }.center-app { display: flex; gap: 12px; align-items: start; padding: 16px 0; border-bottom: 1px solid var(--border-default); }.center-app > svg { margin-top: 4px; color: var(--accent); }.center-app-info { flex: 1; }.center-app p { color: var(--text-secondary); font-size: 11px; }.center-actions { display: flex; gap: 4px; flex-wrap: wrap; }.center-actions button { font-size: 11px; background: var(--accent-subtle); color: var(--accent); }.center-actions [aria-pressed="true"] { border: 1px solid var(--accent-selected); }
|
||||
dialog[data-variant="dock"], dialog[data-variant="grid"], dialog[data-variant="favorites"] { position: fixed; left: 48px; bottom: 100px; top: auto; right: auto; margin: 0; width: min(520px, calc(100% - 32px)); max-height: 72dvh; }
|
||||
dialog[data-variant="grid"] #catalog-items { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }dialog[data-variant="grid"] .center-app { flex-direction: column; padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }dialog[data-variant="cards"] .center-app { padding: 24px 0; }dialog[data-variant="cards"] .center-app > svg { width: 32px; height: 32px; }dialog[data-variant="cards"] .app-detail { margin-top: 8px; font-size: 13px; }dialog[data-variant="favorites"] .app-detail { display: none; }dialog[data-variant="scenes"] .center-app { background: var(--surface-subtle); border: 1px solid var(--border-default); border-radius: var(--radius-card); padding: 20px; margin-top: 16px; flex-wrap: wrap; }dialog[data-variant="scenes"] .center-actions { width: 100%; justify-content: flex-end; }
|
||||
@media (max-width: 640px) { dialog[data-variant] { left: 16px; right: 16px; bottom: 16px; top: auto; margin: 0; max-height: 85dvh; width: calc(100% - 32px); padding: 16px; }.center-app { flex-wrap: wrap; }.center-actions { width: 100%; }.app { flex-direction: column; }.app .sidebar { flex: 1; min-height: 0; }.app .workspace { display: block; flex: 0 0 auto; }.app .workspace .conversation, .app .workspace .composer { display: none; }.app .workspace-header { padding: 8px 16px; align-items: center; }.app .workbar { z-index: 2; } }
|
||||
.reference-note { max-width: 1440px; margin: 12px auto 0; display: flex; flex-wrap: wrap; gap: 8px 16px; color: var(--text-secondary); font-size: 11px; }.reference-note a { color: var(--accent); text-underline-offset: 3px; }
|
||||
.sidebar > * { flex-shrink: 0; }.sidebar .history { flex: 1 1 80px; min-height: 0; }.sidebar .shortcuts { flex-shrink: 1; min-height: 80px; }
|
||||
.sidebar .resident-apps { margin-top: 12px; min-height: 96px; flex-shrink: 1; display: flex; flex-direction: column; max-height: 240px; }.resident-apps .section-label { margin-bottom: 4px; flex-shrink: 0; }#resident-list { overflow: auto; min-height: 0; }.resident-row { display: flex; align-items: center; }.resident-row > button { display: flex; align-items: center; gap: 10px; padding: 8px 10px; flex: 1; min-width: 0; text-align: left; }.resident-row > button span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.resident-row [aria-current="page"] { background: var(--accent-subtle); color: var(--accent); }.resident-row[draggable="true"] { cursor: grab; }.resident-row .edit-actions { flex-shrink: 0; }.all-apps { display: flex; justify-content: space-between; padding: 6px 10px; font-size: 11px; color: var(--text-secondary); flex-shrink: 0; }.empty-apps { padding: 8px 10px; font-size: 11px; color: var(--text-secondary); }.app-placement { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }.app-placement button { font-size: 11px; padding: 6px 8px; }.app-placement button[aria-pressed="true"] { border: 1px solid var(--accent-selected); }.app-catalog-row { align-items: flex-start; }.app-catalog-row > svg { margin-top: 4px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
.launcher-trigger { display: flex; align-items: center; gap: 8px; width: 100%; padding: 12px 8px; border: 1px solid var(--border-default); background: var(--surface-raised); margin-bottom: 12px; font-size: 12px; }.launcher-trigger kbd { margin-left: auto; font: 10px var(--font-family-ui); color: var(--text-secondary); white-space: nowrap; }
|
||||
[data-variant="launcher"] .shortcut-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }[data-variant="launcher"] .shortcut-button { flex-direction: column; padding: 8px 0; }[data-variant="launcher"] .icon-tile { height: 24px; }[data-variant="launcher"] .store-link { border-top: 1px solid var(--border-default); border-radius: 0; }
|
||||
[data-variant="favorites"] .section-label { padding-left: 0; }[data-variant="favorites"] .shortcut-list { gap: 0; }[data-variant="favorites"] .shortcut-button { justify-content: flex-start; padding: 8px 12px; }[data-variant="favorites"] .item-name { font-size: 12px; }[data-variant="favorites"] .shortcut-item { display: flex; align-items: center; }[data-variant="favorites"] .edit-actions { margin-left: auto; }[data-variant="favorites"] .store-link { margin-top: 12px; border: 1px dashed var(--border-default); }.count { padding: 0 6px; color: var(--text-secondary); }
|
||||
.scene-banner { display: flex; align-items: center; gap: 12px; margin: 0 4px 12px; padding: 12px; background: var(--accent-subtle); border-radius: var(--radius-control); }.scene-banner > svg { color: var(--accent); width: 24px; height: 24px; }.scene-banner .eyebrow { letter-spacing: 0; font-size: 10px; }.scene-count { margin-left: auto; font-size: 11px; color: var(--text-secondary); }.scene-label { display: block; }.scene-label select { font: inherit; font-weight: 600; color: var(--text-primary); background: transparent; border: 0; padding: 0; cursor: pointer; }.scene-label option { background: var(--surface-raised); }
|
||||
[data-variant="scenes"] .shortcut-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }[data-variant="scenes"] .shortcut-button { border: 1px solid var(--border-default); background: var(--surface-raised); padding: 10px 8px; justify-content: flex-start; }.pack-preview { display: flex; gap: 8px; align-items: center; width: 100%; margin-top: 12px; padding: 12px 8px; text-align: left; background: var(--surface-raised); border: 1px solid var(--border-default); }.pack-preview strong { font-size: 11px; font-weight: 550; }.pack-preview small { display: block; color: var(--text-secondary); font-size: 10px; }.pack-preview > span:last-child { margin-left: auto; }.pack-symbol { color: var(--accent); }
|
||||
.catalog-pack { padding: 20px 0; border-bottom: 1px solid var(--border-default); }.catalog-pack h3 { margin: 8px 0; font-size: 18px; font-weight: 550; }.pack-art { display: grid; place-items: center; float: right; width: 56px; height: 56px; border: 1px solid var(--accent-selected); background: var(--accent-subtle); color: var(--accent); border-radius: var(--radius-card); }.pack-art svg { width: 28px; height: 28px; }.pack-includes { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }.pack-includes span { padding: 4px 8px; font-size: 11px; background: var(--surface-subtle); border-radius: var(--radius-control); }.pack-includes svg { width: 14px; height: 14px; }.catalog-pack > button { border: 1px solid var(--accent-selected); color: var(--accent); background: var(--accent-subtle); }
|
||||
dialog[data-mode="launch"] { width: min(640px, calc(100% - 32px)); }dialog[data-mode="launch"] .catalog-row:focus-within { background: var(--accent-subtle); }.catalog-row { padding-left: 8px; padding-right: 8px; }
|
||||
@media (max-width: 1080px) { body { padding: 16px; }.app { position: relative; }.workbar { position: absolute; right: 0; top: 52px; bottom: 0; width: min(320px, 100%); box-shadow: -8px 0 24px var(--border-default); }.lab-header { flex-wrap: wrap; gap: 12px; }.design-note > span:last-child { display: none; }.conversation { padding: 32px 24px; } }
|
||||
@media (max-width: 640px) { body { padding: 12px; }.lab-header > div:first-child { flex: 1; }.variants { order: 3; width: 100%; }.variants button { flex: 1; padding: 8px 4px; font-size: 12px; }.design-note { align-items: flex-start; gap: 12px; }.app { height: auto; min-height: 0; }.app .sidebar { width: 100%; flex: 0 0 auto; border: 0; padding-top: 12px; }.brand { padding-bottom: 12px; }.app .history { display: none; }.app .workspace { display: flex; min-height: 320px; border-top: 1px solid var(--border-default); }.app .workspace .composer { display: block; }.composer-area { margin: 12px 12px 4px; margin-top: auto; }.composer-toolbar { gap: 2px; }.composer-select select { max-width: 108px; font-size: 11px; }.composer-toolbar button { font-size: 11px; padding: 6px; }.composer-toolbar .composer-icon { width: 28px; }.composer-meta { flex-wrap: wrap; gap: 0; }.shortcut-list { max-height: 300px; overflow: auto; }.lab-footer { align-items: flex-start; }.lab-footer button { white-space: nowrap; }.workbar { top: 0; width: 100%; }.design-note p { font-size: 11px; } }
|
||||
@@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GoodBuddy · 应用中心设计实验</title>
|
||||
<link rel="stylesheet" href="navigation-demo.css">
|
||||
<script src="navigation-demo.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="lab-header">
|
||||
<div><strong>GoodBuddy <span class="muted">/ 设计实验</span></strong><p>左下角应用中心 · 六个交互方案</p></div>
|
||||
<div class="variants" role="group" aria-label="选择设计方案">
|
||||
<button data-variant="dock" aria-pressed="true">A · 紧凑入口</button>
|
||||
<button data-variant="grid" aria-pressed="false">B · 应用宫格</button>
|
||||
<button data-variant="cards" aria-pressed="false">C · 应用预览</button>
|
||||
<button data-variant="launcher" aria-pressed="false">D · 搜索启动台</button>
|
||||
<button data-variant="favorites" aria-pressed="false">E · 常驻管理</button>
|
||||
<button data-variant="scenes" aria-pressed="false">F · 商店发现</button>
|
||||
</div>
|
||||
<button id="theme">切换深色</button>
|
||||
</header>
|
||||
<div class="design-note"><span id="variant-label">A / 紧凑 Dock</span><p id="variant-description"></p><span>交互原型 · 商店与指标均为演示数据</span></div>
|
||||
<div class="project-lab"><label>项目活动演示 <select id="project-scenario"><option value="waiting">有待处理</option><option value="running">仅运行中</option><option value="current">仅当前项目运行</option><option value="idle">全部空闲</option></select></label><span>有活动时显示摘要,悬停或点击项目向右展开会话。</span></div>
|
||||
<main class="app" data-variant="dock">
|
||||
<aside class="sidebar" aria-label="主侧栏">
|
||||
<div class="brand"><span class="brand-mark">GB</span><strong>GoodBuddy</strong><span class="muted">个人空间</span></div>
|
||||
<div class="project-entry"><button id="project-trigger" popovertarget="project-menu" aria-expanded="false" aria-controls="project-menu"><span id="project-name">GoodBuddy</span><span class="muted" aria-hidden="true">⌄</span></button></div>
|
||||
<button id="activity-trigger" popovertarget="project-activity" aria-expanded="false" aria-controls="project-activity"><span id="activity-summary"></span><span aria-hidden="true">⌄</span></button>
|
||||
<div id="project-menu" popover aria-label="切换项目"><div id="project-list"></div></div>
|
||||
<div id="project-activity" popover aria-label="所有项目活动"><section id="activity-projects"><h3>所有项目活动</h3><div id="activity-project-list"></div></section><section id="activity-sessions" hidden><button id="activity-back">‹ 返回项目列表</button><h3 id="activity-project-title"></h3><div id="activity-list"></div></section></div>
|
||||
<button class="new-chat" data-page="当前对话"><span>+</span> 新对话</button>
|
||||
<nav class="navigation" aria-label="应用导航">
|
||||
<button class="selected" data-page="当前对话"><span data-icon="chat"></span>当前对话</button>
|
||||
</nav>
|
||||
<section class="resident-apps" aria-label="我的应用"><div class="section-label"><span>我的应用</span><div><button id="edit-apps">编辑</button><button data-catalog="apps" aria-label="管理常驻应用">+</button></div></div><nav id="resident-list" aria-label="常驻应用"></nav><button class="all-apps" data-catalog="apps">全部应用 <span>›</span></button></section>
|
||||
<div class="history"><span class="eyebrow">最近对话</span><button data-page="整理产品设计思路">整理产品设计思路</button><button data-page="本周工作计划">本周工作计划</button><button data-page="项目文档检索">项目文档检索</button></div>
|
||||
<div class="fixed-entry"><button data-page="运行记录"><span data-icon="history"></span>运行记录</button></div>
|
||||
<div class="sidebar-footer"><div id="center-entry"></div><button data-page="设置" aria-label="设置" title="设置"><span data-icon="settings"></span></button></div>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<header class="workspace-header"><span id="page-label">当前对话</span><div id="page-actions"></div><button id="toggle-workbar" aria-expanded="true" aria-controls="workbar">助手工作栏</button></header>
|
||||
<div class="conversation" id="page-content">
|
||||
<span class="eyebrow">LET'S MAKE SOMETHING</span>
|
||||
<h1>从一个想法开始。</h1>
|
||||
<p class="muted">对话留在这里,常用能力随手打开。</p>
|
||||
<div class="suggestions"><button data-page="项目文档检索">查找项目资料 <span>↗</span></button><button data-page="整理产品设计思路">梳理一个想法 <span>↗</span></button></div>
|
||||
</div>
|
||||
<section class="composer-area" aria-label="会话输入">
|
||||
<div id="demo-queue" aria-label="待发送消息" hidden></div>
|
||||
<div class="composer">
|
||||
<div id="composer-tags" aria-label="当前附件与选项" hidden></div>
|
||||
<textarea id="message" aria-label="演示消息输入" placeholder="向 GoodBuddy 提问… Enter 发送 · Shift+Enter 换行"></textarea>
|
||||
<div class="composer-toolbar">
|
||||
<button id="attach" class="composer-icon" aria-label="添加附件" title="添加附件">+</button>
|
||||
<input id="attachment-input" type="file" multiple hidden>
|
||||
<button id="voice" class="composer-icon" aria-label="模拟语音输入" aria-pressed="false" title="模拟语音输入"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg></button>
|
||||
<label class="composer-select"><span class="sr-only">Runtime</span><select id="composer-runtime"><option>OpenCode</option><option>Continue</option><option>直连模型</option></select></label>
|
||||
<label class="composer-select"><span class="sr-only">工作模式</span><select id="composer-mode"><option value="Execute">Execute</option><option value="Ask">Ask</option></select></label>
|
||||
<button id="composer-options-trigger" popovertarget="composer-options" aria-expanded="false">选项 <span class="muted">⌃</span></button>
|
||||
<div class="composer-actions"><button id="stop" hidden aria-label="停止演示回复">停止</button><button id="send" aria-label="发送演示消息" title="发送" disabled>↑</button></div>
|
||||
</div>
|
||||
<div id="composer-options" popover aria-label="会话选项">
|
||||
<header><strong>会话选项</strong><button popovertarget="composer-options" popovertargetaction="hide" aria-label="关闭会话选项">×</button></header>
|
||||
<label class="option-row">专家<select id="composer-expert"><option value="">默认助手</option><option>产品顾问</option><option>代码审查专家</option></select></label>
|
||||
<fieldset><legend>知识范围</legend><label><input type="checkbox" data-knowledge="产品文档"> 产品文档</label><label><input type="checkbox" data-knowledge="团队知识"> 团队知识</label></fieldset>
|
||||
<label class="option-row">检索方式<select id="composer-retrieval"><option value="auto">自动检索</option><option value="always">每次检索</option></select></label>
|
||||
<div id="runtime-options"><label class="option-row"><span id="agent-label">Agent</span><select id="composer-agent"><option value="">默认</option><option value="plan">Plan</option><option value="review">Review</option></select></label><label class="option-row">快捷操作<select id="composer-command"><option value="">无</option><option value="prompt">填入总结模板</option><option value="command">原生命令 /init</option></select></label></div>
|
||||
<p class="muted">非默认选项会显示在输入框上方。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-meta"><span id="composer-status" role="status">交互演示 · 不调用 AI</span><button id="context-trigger" popovertarget="context-details">上下文 <span id="context-value">42%</span></button></div>
|
||||
<div id="context-details" popover aria-label="上下文用量"><strong>上下文用量 · 示例</strong><p id="context-description">本次模型调用:84k / 200k tokens</p><progress id="context-progress" value="42" max="100"></progress><button id="compact">压缩上下文</button></div>
|
||||
</section>
|
||||
</section>
|
||||
<aside class="workbar" id="workbar" aria-label="助手工作栏"><header><strong>助手工作栏</strong><button id="close-tool" aria-label="关闭工作栏">×</button></header><div class="tool-tabs" role="tablist" aria-label="系统工具"><button role="tab" id="tab-files" data-tool="files" aria-selected="true" aria-controls="tool-content">文件</button><button role="tab" id="tab-terminal" data-tool="terminal" aria-selected="false" aria-controls="tool-content" tabindex="-1">终端</button><button role="tab" id="tab-browser" data-tool="browser" aria-selected="false" aria-controls="tool-content" tabindex="-1">浏览器</button><button role="tab" id="tab-resources" data-tool="resources" aria-selected="false" aria-controls="tool-content" tabindex="-1">资源</button></div><div id="tool-content" role="tabpanel" aria-labelledby="tab-files" tabindex="0"></div></aside>
|
||||
</main>
|
||||
<footer class="lab-footer"><span id="design-tradeoff"></span><button id="reset">恢复默认应用</button></footer>
|
||||
<div class="reference-note">设计参考:<a href="https://www.raycast.com/" target="_blank" rel="noreferrer">Raycast · 搜索与扩展</a><a href="https://www.notion.com/help/intro-to-workspaces" target="_blank" rel="noreferrer">Notion · 收藏与折叠分区</a><a href="https://code.visualstudio.com/docs/configure/profiles" target="_blank" rel="noreferrer">VS Code · Profiles</a><span>借鉴交互思路,保留 GoodBuddy 的视觉与入口分类。</span></div>
|
||||
<dialog id="catalog" aria-labelledby="catalog-title"><button id="back-to-apps" hidden>‹ 返回应用中心</button><header><div><span class="eyebrow">GOODBUDDY COLLECTION</span><h2 id="catalog-title">应用中心</h2></div><button id="close-catalog" aria-label="关闭">×</button></header><p class="muted" id="catalog-description"></p><div class="center-switch" role="group" aria-label="应用中心视图"><button data-catalog="apps">我的应用</button><button data-catalog="store">商店</button></div><input id="search" type="search" aria-label="搜索应用" placeholder="搜索应用…"><div id="catalog-items"></div><section id="app-settings" aria-label="魔法笔记设置" hidden></section><footer>常驻只表示菜单入口,不表示后台运行。安装与内容均为演示。</footer></dialog>
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,407 @@
|
||||
/* global document, window, history, location, matchMedia, innerWidth, Event, setTimeout, clearTimeout */
|
||||
const paths = {
|
||||
note: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
||||
book: '<path d="M12 5v16M12 5C8 2 4 3 2 4v15c4-1 7-1 10 2 3-3 6-3 10-2V4c-2-1-6-2-10 1Z"/>',
|
||||
chat: '<path d="M21 11a9 9 0 0 1-9 9H3l1-5A9 9 0 1 1 21 11Z"/>',
|
||||
history: '<path d="M3 4v5h5M3 9a9 9 0 1 1 0 6m9-8v5l3 2"/>',
|
||||
settings: '<circle cx="12" cy="12" r="4"/><path d="M12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2"/>',
|
||||
activity: '<path d="M2 12h5l3-8 4 16 3-8h5"/>',
|
||||
layers: '<path d="m12 3 10 5-10 5L2 8Zm-10 9 10 5 10-5M2 17l10 5 10-5"/>',
|
||||
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>'
|
||||
};
|
||||
const icon = name => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths[name] || paths.layers}</svg>`;
|
||||
const items = [
|
||||
{ id: 'notes', name: '魔法笔记', icon: 'note', detail: '记录想法、整理待办', summary: '把刚才的灵感留在这里', builtin: true },
|
||||
{ id: 'knowledge', name: '知识库', icon: 'book', detail: '整理资料、检索知识', summary: '下一次提问,从已有知识开始', builtin: true },
|
||||
{ id: 'heartbeat', name: '智能心跳', icon: 'activity', detail: '管理定时任务与主动提醒', summary: '让例行任务按计划发生', builtin: true },
|
||||
{ id: 'projects', name: '项目管理', icon: 'layers', detail: '跟踪项目、任务与进度', summary: '把一个想法变成可跟进的项目', builtin: false },
|
||||
{ id: 'reading', name: '阅读空间', icon: 'book', detail: '集中阅读、批注与摘录', summary: '给长文和深度阅读留一个空间', builtin: false }
|
||||
];
|
||||
const variants = {
|
||||
dock: ['A / 紧凑入口', '最底部保留一个应用中心入口,向上展开紧凑列表。', '适合希望把侧栏空间留给对话的用户。'],
|
||||
grid: ['B / 应用宫格', '底部入口展示应用数量,展开后用宫格浏览已安装应用。', '图标与名称同时可见,常驻位置由用户选择。'],
|
||||
cards: ['C / 应用预览', '入口展示内置应用图标,展开后用卡片说明每个应用的用途。', '适合通过内容理解应用,卡片不代表后台持续运行。'],
|
||||
launcher: ['D / 搜索启动台', '最底部就是搜索入口,也可以按 Ctrl / ⌘ K 打开。', '输入名称,用方向键与 Enter 打开已安装应用。'],
|
||||
favorites: ['E / 常驻管理', '从底部管理哪些应用常驻菜单,未常驻应用也可直接打开。', '常驻应用优先排列;入口显隐不影响安装和运行状态。'],
|
||||
scenes: ['F / 商店发现', '底部入口强调应用发现,展开后查看商店应用与安装位置。', '先模拟安装,再决定是否常驻;我的应用始终可切换访问。']
|
||||
};
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const demoProjects = [
|
||||
{ id: 'goodbuddy', name: 'GoodBuddy', group: '本地', conversations: ['优化项目导航', '检查构建问题'] },
|
||||
{ id: 'website', name: '官网改版', group: '本地', conversations: ['发布首页'] },
|
||||
{ id: 'remote', name: '客户方案与交付文档工作空间', group: 'SSH', conversations: ['整理报价'] },
|
||||
{ id: 'channel', name: '团队助手', group: '通道', conversations: ['本周工作计划'] }
|
||||
];
|
||||
let currentProject = 'goodbuddy';
|
||||
let projectActivity = [];
|
||||
let expandedActivityProject = '';
|
||||
function renderProjects() {
|
||||
const scenario = $('#project-scenario').value;
|
||||
const activity = demoProjects.flatMap(project => project.conversations.map((name, index) => ({ project, name, index,
|
||||
state: scenario === 'idle' || project.id === 'channel' || (scenario === 'current' && project.id !== currentProject) ? '' : scenario === 'waiting' && project.id === 'website' ? '等待审批' : '运行中'
|
||||
}))).filter(row => row.state);
|
||||
projectActivity = activity;
|
||||
const waiting = activity.filter(row => row.state !== '运行中').length;
|
||||
const running = activity.length - waiting;
|
||||
const project = demoProjects.find(entry => entry.id === currentProject);
|
||||
$('#project-name').textContent = project.name;
|
||||
$('#project-trigger').setAttribute('aria-label', `切换项目,当前项目:${project.name}`);
|
||||
$('#activity-trigger').hidden = !activity.length;
|
||||
if (!activity.length && $('#project-activity').matches(':popover-open')) $('#project-activity').hidePopover();
|
||||
$('#activity-summary').innerHTML = `${running ? `运行中 ${running}` : ''}${waiting && running ? ' · ' : ''}${waiting ? `<span class="project-waiting">待处理 ${waiting}</span>` : ''}`;
|
||||
$('#activity-trigger').setAttribute('aria-label', `所有项目活动:${$('#activity-summary').textContent}`);
|
||||
$('#project-list').innerHTML = ['本地', 'SSH', '通道'].map(group => `<section><h3>${group}</h3>${demoProjects.filter(entry => entry.group === group).map(entry => {
|
||||
const rows = activity.filter(row => row.project.id === entry.id);
|
||||
const pending = rows.filter(row => row.state !== '运行中').length;
|
||||
const active = rows.length - pending;
|
||||
return `<button data-project="${entry.id}" ${entry.id === currentProject ? 'aria-current="true"' : ''}><span class="project-row-name">${entry.name}</span>${entry.id === currentProject ? '<span aria-hidden="true">✓</span>' : ''}${rows.length ? `<small>${pending ? `<span class="project-waiting">待处理 ${pending}</span>` : ''}${active ? `<span>运行中 ${active}</span>` : ''}</small>` : ''}</button>`;
|
||||
}).join('')}</section>`).join('');
|
||||
$('#activity-project-list').innerHTML = demoProjects.map(entry => {
|
||||
const rows = activity.filter(row => row.project.id === entry.id);
|
||||
if (!rows.length) return '';
|
||||
const pending = rows.filter(row => row.state !== '运行中').length;
|
||||
return `<button data-activity-project="${entry.id}" aria-expanded="false" aria-controls="activity-sessions"><span>${entry.name}</span><small>${pending ? `<span class="project-waiting">待处理 ${pending}</span>` : `运行中 ${rows.length}`}</small><span aria-hidden="true">›</span></button>`;
|
||||
}).join('') || '<p class="muted">暂无运行中或待处理会话。</p>';
|
||||
expandActivityProject('');
|
||||
$('.history').innerHTML = `<span class="eyebrow">当前项目对话</span>${project.conversations.map((name, index) => {
|
||||
const row = activity.find(entry => entry.project.id === currentProject && entry.index === index);
|
||||
return `<button data-conversation="${currentProject}" data-conversation-index="${index}">${name}${row ? `<small>${row.state}</small>` : ''}</button>`;
|
||||
}).join('')}`;
|
||||
}
|
||||
$('#project-scenario').onchange = renderProjects;
|
||||
const projectMenu = $('#project-menu');
|
||||
function positionProjectMenu() {
|
||||
const rect = $('#project-trigger').getBoundingClientRect();
|
||||
projectMenu.style.left = `${Math.max(16, Math.min(rect.left, innerWidth - projectMenu.offsetWidth - 16))}px`;
|
||||
projectMenu.style.top = `${Math.max(16, Math.min(rect.bottom + 8, window.innerHeight - projectMenu.offsetHeight - 16))}px`;
|
||||
}
|
||||
projectMenu.addEventListener('toggle', event => {
|
||||
$('#project-trigger').setAttribute('aria-expanded', String(event.newState === 'open'));
|
||||
if (event.newState === 'open') positionProjectMenu();
|
||||
});
|
||||
window.addEventListener('resize', () => { if (projectMenu.matches(':popover-open')) positionProjectMenu(); });
|
||||
window.addEventListener('scroll', () => { if (projectMenu.matches(':popover-open')) positionProjectMenu(); }, true);
|
||||
function positionActivityMenu() {
|
||||
const panel = $('#project-activity');
|
||||
const rect = $('#activity-trigger').getBoundingClientRect();
|
||||
panel.classList.toggle('activity-compact', innerWidth < 620);
|
||||
panel.classList.toggle('activity-left', innerWidth >= 620 && rect.left + panel.offsetWidth > innerWidth - 16 && rect.right >= panel.offsetWidth + 16);
|
||||
const left = panel.classList.contains('activity-left') ? rect.right - panel.offsetWidth : rect.left;
|
||||
panel.style.left = `${Math.max(16, Math.min(left, innerWidth - panel.offsetWidth - 16))}px`;
|
||||
panel.style.top = `${Math.max(16, Math.min(rect.bottom + 4, window.innerHeight - panel.offsetHeight - 16))}px`;
|
||||
}
|
||||
function expandActivityProject(id, focus = false) {
|
||||
expandedActivityProject = id;
|
||||
$('#activity-sessions').hidden = !id;
|
||||
$('#project-activity').classList.toggle('has-submenu', Boolean(id));
|
||||
document.querySelectorAll('[data-activity-project]').forEach(button => button.setAttribute('aria-expanded', String(button.dataset.activityProject === id)));
|
||||
if (id) {
|
||||
$('#activity-project-title').textContent = demoProjects.find(project => project.id === id).name;
|
||||
$('#activity-list').innerHTML = projectActivity.filter(row => row.project.id === id).sort((a, b) => Number(a.state === '运行中') - Number(b.state === '运行中')).map(row => `<button data-conversation="${id}" data-conversation-index="${row.index}"><strong>${row.name}</strong><small>${row.state}</small></button>`).join('');
|
||||
}
|
||||
if ($('#project-activity').matches(':popover-open')) positionActivityMenu();
|
||||
if (focus) $('#activity-list button')?.focus();
|
||||
}
|
||||
$('#project-activity').addEventListener('toggle', event => {
|
||||
$('#activity-trigger').setAttribute('aria-expanded', String(event.newState === 'open'));
|
||||
if (event.newState === 'open') { expandActivityProject(''); positionActivityMenu(); }
|
||||
});
|
||||
$('#activity-project-list').addEventListener('pointerover', event => {
|
||||
const button = event.target.closest('[data-activity-project]');
|
||||
if (event.pointerType === 'mouse' && innerWidth >= 620 && button && button.dataset.activityProject !== expandedActivityProject) expandActivityProject(button.dataset.activityProject);
|
||||
});
|
||||
$('#activity-project-list').addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-activity-project]');
|
||||
if (button) expandActivityProject(button.dataset.activityProject, true);
|
||||
});
|
||||
$('#activity-back').onclick = () => {
|
||||
const id = expandedActivityProject;
|
||||
expandActivityProject('');
|
||||
$(`[data-activity-project="${id}"]`)?.focus();
|
||||
};
|
||||
$('#project-activity').addEventListener('keydown', event => {
|
||||
const button = event.target.closest('[data-activity-project]');
|
||||
if (event.key === 'ArrowRight' && button) { event.preventDefault(); expandActivityProject(button.dataset.activityProject, true); }
|
||||
if ((event.key === 'ArrowLeft' || event.key === 'Escape') && expandedActivityProject) { event.preventDefault(); event.stopPropagation(); $('#activity-back').click(); }
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
const buttons = [...document.querySelectorAll(button ? '[data-activity-project]' : '#activity-list button')];
|
||||
const index = buttons.indexOf(document.activeElement);
|
||||
if (index >= 0) { event.preventDefault(); buttons[(index + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length].focus(); }
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', () => { if ($('#project-activity').matches(':popover-open')) positionActivityMenu(); });
|
||||
window.addEventListener('scroll', () => { if ($('#project-activity').matches(':popover-open')) positionActivityMenu(); }, true);
|
||||
renderProjects();
|
||||
let variant = 'dock';
|
||||
let catalogMode = 'apps';
|
||||
let residentApps = ['notes', 'knowledge'];
|
||||
let editingApps = false;
|
||||
let draggedApp = '';
|
||||
let installed = new Set(items.filter(item => item.builtin).map(item => item.id));
|
||||
let toastTimer;
|
||||
let settingsOpen = false;
|
||||
const noteSettings = { showCount: true, mode: 'immediate', format: 'combined' };
|
||||
const noteModes = { immediate: '即时', 'after-save-auto': '保存后自动', 'after-save-manual': '保存后手动' };
|
||||
const noteFormats = { combined: '长评 + 要点', narrative: '长评', structured: '要点' };
|
||||
function renderNoteSettings() {
|
||||
$('#app-settings').innerHTML = `<div class="note-setting-row"><div><strong>常驻左侧菜单</strong><p>对应“显示魔法笔记入口”,移除后仍可从应用中心打开。</p></div><button class="demo-switch" role="switch" aria-label="常驻左侧菜单" aria-checked="${residentApps.includes('notes')}" data-note-toggle="resident"></button></div>
|
||||
<div class="note-setting-row"><div><strong>显示未完成待办数量</strong><p>在左侧入口显示数量,超过 99 项显示 99+。本 Demo 使用 3 项示例。</p></div><button class="demo-switch" role="switch" aria-label="显示未完成待办数量" aria-checked="${noteSettings.showCount}" data-note-toggle="showCount"></button></div>
|
||||
<fieldset class="note-setting-options"><legend>AI 评论方式</legend><div class="note-segments" role="group" aria-label="AI 评论方式">${Object.entries(noteModes).map(([value, label]) => `<button data-note-mode="${value}" aria-pressed="${noteSettings.mode === value}">${label}</button>`).join('')}</div><p>即时:按回车并停止输入 5 秒后评论未保存草稿。保存后自动:保存后评论。保存后手动:点击 AI 分析后评论。</p></fieldset>
|
||||
<fieldset class="note-setting-options"><legend>AI 评论形式</legend><div class="note-segments" role="group" aria-label="AI 评论形式">${Object.entries(noteFormats).map(([value, label]) => `<button data-note-format="${value}" aria-pressed="${noteSettings.format === value}">${label}</button>`).join('')}</div><p>默认同时生成流式长评和结构化要点,也可只保留其中一种。</p></fieldset>
|
||||
<div class="note-settings-preview"><span class="eyebrow">当前配置预览</span><p>${noteModes[noteSettings.mode]} · ${noteFormats[noteSettings.format]}</p><small>选择后即时保留于本次 Demo 会话,不调用 AI、不修改真实设置。</small></div>`;
|
||||
if ($('#note-settings-summary')) $('#note-settings-summary').textContent = `当前设置:${noteModes[noteSettings.mode]} · ${noteFormats[noteSettings.format]}。待办数量${noteSettings.showCount ? '显示' : '隐藏'}。`;
|
||||
}
|
||||
function showNoteSettings() {
|
||||
if (!$('#catalog').open) { catalogMode = 'apps'; $('#search').value = ''; }
|
||||
settingsOpen = true;
|
||||
$('#catalog').dataset.variant = variant;
|
||||
$('#catalog-title').textContent = '魔法笔记设置';
|
||||
$('#catalog-description').textContent = '调整入口与 AI 评论行为。应用中心和笔记页面共享这份演示配置。';
|
||||
$('#back-to-apps').hidden = false; $('#app-settings').hidden = false;
|
||||
for (const selector of ['.center-switch', '#search', '#catalog-items']) $(selector).hidden = true;
|
||||
renderNoteSettings();
|
||||
if (!$('#catalog').open) $('#catalog').showModal();
|
||||
$('#back-to-apps').focus();
|
||||
}
|
||||
function notify(text) {
|
||||
$('#toast').textContent = text; $('#toast').classList.add('visible');
|
||||
clearTimeout(toastTimer); toastTimer = setTimeout(() => $('#toast').classList.remove('visible'), 2200);
|
||||
}
|
||||
function renderApps() {
|
||||
$('#edit-apps').textContent = editingApps ? '完成' : '编辑';
|
||||
$('#resident-list').innerHTML = residentApps.map((id, index) => {
|
||||
const item = items.find(entry => entry.id === id);
|
||||
return `<div class="resident-row" data-resident="${id}" draggable="${editingApps}"><button data-open="${id}" ${$('#page-label').textContent === item.name ? 'aria-current="page"' : ''}>${icon(item.icon)}<span>${item.name}</span>${id === 'notes' && noteSettings.showCount ? '<small class="note-count" aria-label="3 项未完成待办(示例)">3</small>' : ''}</button>${editingApps ? `<div class="edit-actions"><button data-move="${id}" data-direction="-1" aria-label="上移${item.name}" ${index === 0 ? 'disabled' : ''}>↑</button><button data-pin="${id}" aria-label="移除${item.name}">×</button><button data-move="${id}" data-direction="1" aria-label="下移${item.name}" ${index === residentApps.length - 1 ? 'disabled' : ''}>↓</button></div>` : ''}</div>`;
|
||||
}).join('') || '<p class="empty-apps">点击 +,选择常驻应用。</p>';
|
||||
const subtitles = { dock: '', grid: `${installed.size} 个已安装应用`, cards: '笔记 · 知识 · 自动化', launcher: 'Ctrl / ⌘ K', favorites: `${residentApps.length} 个应用常驻菜单`, scenes: '发现你的下一个好应用' };
|
||||
$('#center-entry').innerHTML = `<button id="open-center" aria-haspopup="dialog"><span class="brand-mark">${variant === 'launcher' ? icon('search') : 'GB'}</span><span><strong>${variant === 'launcher' ? '搜索应用…' : '应用中心'}</strong>${subtitles[variant] ? `<small>${subtitles[variant]}</small>` : ''}</span><span class="entry-arrow">${variant === 'scenes' ? '↗' : '⌃'}</span></button>`;
|
||||
}
|
||||
function renderCatalog() {
|
||||
const query = $('#search').value.trim().toLowerCase();
|
||||
const matches = items.filter(item => (catalogMode === 'store' ? !item.builtin : installed.has(item.id)) && `${item.name}${item.detail}`.toLowerCase().includes(query));
|
||||
if (variant === 'favorites') matches.sort((a, b) => Number(residentApps.includes(b.id)) - Number(residentApps.includes(a.id)));
|
||||
$('#catalog-items').innerHTML = matches.map(item => {
|
||||
const ready = installed.has(item.id); const pinned = residentApps.includes(item.id);
|
||||
return `<article class="center-app">${icon(item.icon)}<div class="center-app-info"><strong>${item.name}</strong><p>${item.builtin ? '内置应用' : '商店应用 · 示例'}</p><p class="app-detail">${variant === 'cards' ? item.summary : item.detail}</p></div><div class="center-actions">${ready ? `<button data-open="${item.id}">打开</button>${item.id === 'notes' ? '<button data-note-settings aria-label="魔法笔记设置">设置</button>' : ''}<button data-pin="${item.id}" aria-pressed="${pinned}">${pinned ? '取消常驻' : '常驻左侧'}</button>` : `<button data-install="${item.id}">模拟安装</button><button data-install-pin="${item.id}">安装并常驻</button>`}</div></article>`;
|
||||
}).join('') || '<p class="empty-apps">没有匹配的应用。可以换个关键词,或切换到商店。</p>';
|
||||
document.querySelectorAll('.center-switch button').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.catalog === catalogMode)));
|
||||
}
|
||||
function openCatalog(mode = 'apps') {
|
||||
settingsOpen = false; $('#back-to-apps').hidden = true; $('#app-settings').hidden = true;
|
||||
for (const selector of ['.center-switch', '#search', '#catalog-items']) $(selector).hidden = false;
|
||||
catalogMode = mode; $('#catalog').dataset.variant = variant;
|
||||
$('#catalog-title').textContent = mode === 'store' ? '发现新应用' : '应用中心';
|
||||
$('#catalog-description').textContent = mode === 'store' ? '安装你需要的应用,自主决定是否常驻左侧菜单。' : '打开应用,或把常用应用留在左侧。系统工具请使用右侧工作栏。';
|
||||
$('#search').value = ''; renderCatalog();
|
||||
if (!$('#catalog').open) $('#catalog').showModal();
|
||||
$('#search').focus();
|
||||
}
|
||||
function openPage(name) {
|
||||
if ($('#catalog').open) $('#catalog').close();
|
||||
$('#page-label').textContent = name;
|
||||
$('#page-content').replaceChildren();
|
||||
const title = document.createElement('h1'); title.textContent = name;
|
||||
const description = document.createElement('p'); description.className = 'muted'; description.textContent = '主内容区页面预览。右侧工作栏保持当前工具状态。';
|
||||
$('#page-content').append(title, description);
|
||||
$('#page-actions').innerHTML = name === '魔法笔记' ? `<button data-note-settings>${icon('settings')} 笔记设置</button>` : '';
|
||||
if (name === '魔法笔记' || name === '设置') {
|
||||
const summary = document.createElement('p'); summary.id = 'note-settings-summary'; summary.className = 'muted'; $('#page-content').append(summary);
|
||||
if (name === '设置') { const button = document.createElement('button'); button.dataset.noteSettings = ''; button.textContent = '魔法笔记设置'; $('#page-content').append(button); }
|
||||
renderNoteSettings();
|
||||
}
|
||||
document.querySelectorAll('.navigation button').forEach(button => button.classList.toggle('selected', button.dataset.page === name));
|
||||
renderApps(); notify(`已打开${name}`);
|
||||
}
|
||||
function selectVariant(name) {
|
||||
variant = name; $('.app').dataset.variant = name;
|
||||
document.querySelectorAll('button[data-variant]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.variant === name)));
|
||||
[$('#variant-label').textContent, $('#variant-description').textContent, $('#design-tradeoff').textContent] = variants[name];
|
||||
history.replaceState(null, '', `#${name}`); renderApps();
|
||||
}
|
||||
function selectTool(id) {
|
||||
const content = {
|
||||
files: '<span class="eyebrow">本机 / goodbuddy · 示例</span><div class="file-row">src /</div><div class="file-row">docs /</div><div class="file-row">resources /</div><div class="file-row">README.md</div>',
|
||||
terminal: '<span class="eyebrow">当前项目 · 演示终端</span><pre>PS > git status\nOn branch main\nworking tree clean\n\nPS ></pre>',
|
||||
browser: '<span class="eyebrow">浏览器 · 静态示例</span><div class="file-row">https://example.com</div><h2>Example Domain</h2>',
|
||||
resources: '<span class="eyebrow">本机 · 静态演示数据</span><div class="metric">CPU <strong>18%</strong><progress value="18" max="100"></progress></div><div class="metric">内存 <strong>9.6 / 32 GB</strong><progress value="30" max="100"></progress></div><p>磁盘 IO:读 12 MB/s · 写 3 MB/s</p>'
|
||||
};
|
||||
$('#tool-content').innerHTML = content[id]; $('#tool-content').setAttribute('aria-labelledby', `tab-${id}`);
|
||||
document.querySelectorAll('[data-tool]').forEach(button => { button.setAttribute('aria-selected', String(button.dataset.tool === id)); button.tabIndex = button.dataset.tool === id ? 0 : -1; });
|
||||
}
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('button'); if (!button) return;
|
||||
const data = button.dataset;
|
||||
if (data.project || data.conversation) {
|
||||
currentProject = data.project || data.conversation;
|
||||
if (projectMenu.matches(':popover-open')) projectMenu.hidePopover();
|
||||
if ($('#project-activity').matches(':popover-open')) $('#project-activity').hidePopover();
|
||||
renderProjects();
|
||||
const project = demoProjects.find(entry => entry.id === currentProject);
|
||||
openPage(data.conversation ? project.conversations[Number(data.conversationIndex)] : '当前对话');
|
||||
$('#page-label').textContent = `${project.name} / ${data.conversation ? project.conversations[Number(data.conversationIndex)] : '当前对话'}`;
|
||||
$('#project-trigger').focus();
|
||||
}
|
||||
if (data.variant) selectVariant(data.variant);
|
||||
if (data.catalog) openCatalog(data.catalog);
|
||||
if (data.page) openPage(data.page);
|
||||
if (data.open) openPage(items.find(item => item.id === data.open).name);
|
||||
if (data.tool) selectTool(data.tool);
|
||||
if (button.hasAttribute('data-note-settings')) showNoteSettings();
|
||||
if (data.noteToggle || data.noteMode || data.noteFormat) {
|
||||
if (data.noteToggle === 'resident') residentApps = residentApps.includes('notes') ? residentApps.filter(id => id !== 'notes') : [...residentApps, 'notes'];
|
||||
if (data.noteToggle === 'showCount') noteSettings.showCount = !noteSettings.showCount;
|
||||
if (data.noteMode) noteSettings.mode = data.noteMode;
|
||||
if (data.noteFormat) noteSettings.format = data.noteFormat;
|
||||
renderApps(); renderNoteSettings();
|
||||
const selector = data.noteToggle ? `[data-note-toggle="${data.noteToggle}"]` : data.noteMode ? `[data-note-mode="${data.noteMode}"]` : `[data-note-format="${data.noteFormat}"]`;
|
||||
$(selector).focus();
|
||||
}
|
||||
if (button.id === 'open-center') openCatalog(variant === 'scenes' ? 'store' : 'apps');
|
||||
if (button.id === 'edit-apps') { editingApps = !editingApps; renderApps(); $('#edit-apps').focus(); }
|
||||
if (data.pin || data.install || data.installPin) {
|
||||
const id = data.pin || data.install || data.installPin;
|
||||
if (data.install || data.installPin) installed.add(id);
|
||||
if (data.pin || data.installPin) residentApps = residentApps.includes(id) ? residentApps.filter(entry => entry !== id) : [...residentApps, id];
|
||||
renderApps();
|
||||
if ($('#catalog').open) { renderCatalog(); ($(`#catalog [data-pin="${id}"]`) || $('#search')).focus(); }
|
||||
else $('#edit-apps').focus();
|
||||
}
|
||||
if (data.move) {
|
||||
const from = residentApps.indexOf(data.move); const to = from + Number(data.direction);
|
||||
if (to >= 0 && to < residentApps.length) [residentApps[from], residentApps[to]] = [residentApps[to], residentApps[from]];
|
||||
renderApps(); $(`#resident-list [data-pin="${data.move}"]`).focus();
|
||||
}
|
||||
});
|
||||
$('#resident-list').addEventListener('dragstart', event => { draggedApp = event.target.closest('[data-resident]')?.dataset.resident || ''; if (!editingApps || !draggedApp) { event.preventDefault(); return; } event.dataTransfer.setData('text/plain', draggedApp); });
|
||||
$('#resident-list').addEventListener('dragover', event => { if (editingApps && draggedApp) event.preventDefault(); });
|
||||
$('#resident-list').addEventListener('drop', event => {
|
||||
event.preventDefault(); const target = event.target.closest('[data-resident]')?.dataset.resident;
|
||||
if (!editingApps || !residentApps.includes(draggedApp) || !target || target === draggedApp) return;
|
||||
const to = residentApps.indexOf(target); residentApps.splice(residentApps.indexOf(draggedApp), 1); residentApps.splice(to, 0, draggedApp); draggedApp = ''; renderApps(); $('#edit-apps').focus();
|
||||
});
|
||||
$('#resident-list').addEventListener('dragend', () => { draggedApp = ''; });
|
||||
$('#search').addEventListener('input', renderCatalog);
|
||||
$('#close-catalog').onclick = () => $('#catalog').close();
|
||||
$('#back-to-apps').onclick = () => { const query = $('#search').value; openCatalog(catalogMode); $('#search').value = query; renderCatalog(); ($('#catalog [data-note-settings]') || $('#search')).focus(); };
|
||||
function toggleWorkbar(visible) { $('.workbar').hidden = !visible; $('#toggle-workbar').setAttribute('aria-expanded', String(visible)); }
|
||||
$('#toggle-workbar').onclick = () => toggleWorkbar($('.workbar').hidden);
|
||||
$('#close-tool').onclick = () => { toggleWorkbar(false); $('#toggle-workbar').focus(); };
|
||||
$('.tool-tabs').addEventListener('keydown', event => {
|
||||
const tabs = [...document.querySelectorAll('[data-tool]')]; const index = tabs.indexOf(event.target);
|
||||
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault(); const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
||||
selectTool(tabs[next].dataset.tool); tabs[next].focus();
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); openCatalog(); }
|
||||
if (!$('#catalog').open || settingsOpen || variant !== 'launcher') return;
|
||||
const actions = [...document.querySelectorAll('#catalog [data-open]')]; if (!actions.length) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); const index = actions.indexOf(document.activeElement); const next = index < 0 ? (event.key === 'ArrowDown' ? 0 : actions.length - 1) : (index + (event.key === 'ArrowDown' ? 1 : -1) + actions.length) % actions.length; actions[next].focus(); }
|
||||
if (event.key === 'Enter' && document.activeElement === $('#search')) { event.preventDefault(); actions[0].click(); }
|
||||
});
|
||||
$('#theme').onclick = () => { const dark = document.documentElement.dataset.theme !== 'dark'; document.documentElement.dataset.theme = dark ? 'dark' : 'light'; $('#theme').textContent = dark ? '切换浅色' : '切换深色'; };
|
||||
$('#reset').onclick = () => { installed = new Set(items.filter(item => item.builtin).map(item => item.id)); residentApps = ['notes', 'knowledge']; editingApps = false; renderApps(); notify('应用安装与常驻配置已恢复默认'); };
|
||||
let demoRunning = false;
|
||||
let demoRecording = false;
|
||||
let demoAttachments = [];
|
||||
let demoQueue = [];
|
||||
let demoReplyTimer;
|
||||
function renderComposer() {
|
||||
const tags = $('#composer-tags'); tags.replaceChildren();
|
||||
const addTag = (text, clear) => {
|
||||
const button = document.createElement('button'); button.textContent = `${text} ×`; button.setAttribute('aria-label', `移除${text}`);
|
||||
button.onclick = () => { clear(); renderComposer(); $('#message').focus(); }; tags.append(button);
|
||||
};
|
||||
demoAttachments.forEach((name, index) => addTag(name, () => demoAttachments.splice(index, 1)));
|
||||
for (const id of ['composer-expert', 'composer-agent', 'composer-command']) {
|
||||
const select = $(`#${id}`);
|
||||
if (select.value) addTag(select.selectedOptions[0].textContent, () => { select.value = ''; });
|
||||
}
|
||||
document.querySelectorAll('[data-knowledge]:checked').forEach(input => addTag(input.dataset.knowledge, () => { input.checked = false; }));
|
||||
if ($('#composer-retrieval').value === 'always') addTag('每次检索', () => { $('#composer-retrieval').value = 'auto'; });
|
||||
tags.hidden = !tags.childElementCount;
|
||||
$('#stop').hidden = !demoRunning;
|
||||
$('#send').disabled = !$('#message').value.trim() && $('#composer-command').value !== 'command';
|
||||
$('#send').title = demoRunning ? '加入队列' : '发送';
|
||||
$('#send').setAttribute('aria-label', demoRunning ? '加入演示队列' : '发送演示消息');
|
||||
$('#composer-status').textContent = demoRecording ? '模拟录音中 · 再次点击结束' : demoRunning ? '模拟回复中 · 可继续发送加入队列' : '交互演示 · 不调用 AI';
|
||||
for (const id of ['composer-runtime', 'composer-mode', 'composer-expert', 'composer-agent', 'composer-command']) $(`#${id}`).disabled = demoRunning;
|
||||
$('#compact').disabled = demoRunning;
|
||||
$('#runtime-options').hidden = $('#composer-runtime').value === '直连模型';
|
||||
$('#compact').hidden = $('#composer-runtime').value === '直连模型';
|
||||
$('#demo-queue').replaceChildren();
|
||||
demoQueue.forEach((text, index) => {
|
||||
const row = document.createElement('div'); row.className = 'queue-row';
|
||||
const label = document.createElement('span'); label.textContent = text; label.title = text;
|
||||
const insert = document.createElement('button'); insert.textContent = '立即插入'; insert.setAttribute('aria-label', `立即中断并插入:${text}`);
|
||||
insert.onclick = () => { demoQueue.splice(index, 1); startDemoReply(text); };
|
||||
const remove = document.createElement('button'); remove.textContent = '×'; remove.setAttribute('aria-label', `删除待发送消息:${text}`);
|
||||
remove.onclick = () => { demoQueue.splice(index, 1); renderComposer(); };
|
||||
row.append(label, insert, remove); $('#demo-queue').append(row);
|
||||
});
|
||||
$('#demo-queue').hidden = !demoQueue.length;
|
||||
}
|
||||
function startDemoReply(text) {
|
||||
clearTimeout(demoReplyTimer); demoRunning = true;
|
||||
if ($('#composer-options').matches(':popover-open')) $('#composer-options').hidePopover();
|
||||
notify(`模拟处理:${text}`); renderComposer();
|
||||
demoReplyTimer = setTimeout(() => {
|
||||
demoRunning = false;
|
||||
if (demoQueue.length) startDemoReply(demoQueue.shift());
|
||||
else { renderComposer(); notify('演示回复已完成,未调用 AI'); }
|
||||
}, 8000);
|
||||
}
|
||||
$('#send').onclick = () => {
|
||||
if ($('#send').disabled) return;
|
||||
const text = $('#composer-command').value === 'command' ? `/init ${$('#message').value.trim()}` : $('#message').value.trim();
|
||||
$('#message').value = ''; $('#message').style.height = ''; demoAttachments = [];
|
||||
if (demoRunning) { demoQueue.push(text); renderComposer(); }
|
||||
else startDemoReply(text);
|
||||
};
|
||||
$('#stop').onclick = () => {
|
||||
clearTimeout(demoReplyTimer); demoRunning = false;
|
||||
if (demoQueue.length) startDemoReply(demoQueue.shift());
|
||||
else { renderComposer(); notify('已停止演示回复'); }
|
||||
};
|
||||
$('#message').addEventListener('input', () => { $('#message').style.height = 'auto'; $('#message').style.height = `${Math.min(180, $('#message').scrollHeight)}px`; renderComposer(); });
|
||||
$('#message').addEventListener('keydown', event => { if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); $('#send').click(); } });
|
||||
$('#attach').onclick = () => $('#attachment-input').click();
|
||||
$('#attachment-input').onchange = () => { demoAttachments.push(...Array.from($('#attachment-input').files, file => file.name)); $('#attachment-input').value = ''; renderComposer(); };
|
||||
$('#voice').onclick = () => {
|
||||
demoRecording = !demoRecording; $('#voice').setAttribute('aria-pressed', String(demoRecording));
|
||||
if (!demoRecording) { $('#message').value += '请帮我整理一下今天的工作计划。'; $('#message').dispatchEvent(new Event('input')); }
|
||||
renderComposer();
|
||||
};
|
||||
$('#composer-options').addEventListener('change', event => {
|
||||
if (event.target.id === 'composer-command' && event.target.value === 'prompt') {
|
||||
$('#message').value = '请总结当前讨论,列出关键结论和下一步行动。'; event.target.value = ''; $('#message').dispatchEvent(new Event('input'));
|
||||
}
|
||||
renderComposer();
|
||||
});
|
||||
$('#composer-runtime').onchange = () => {
|
||||
$('#composer-agent').value = ''; $('#composer-command').value = '';
|
||||
$('#agent-label').textContent = $('#composer-runtime').value === 'Continue' ? '预设' : 'Agent';
|
||||
$('#composer-command').querySelector('[value="command"]').hidden = $('#composer-runtime').value !== 'OpenCode';
|
||||
renderComposer();
|
||||
};
|
||||
$('#compact').onclick = () => {
|
||||
$('#context-value').textContent = '18%'; $('#context-progress').value = 18;
|
||||
$('#context-description').textContent = '压缩后对话估算:36k / 200k tokens';
|
||||
$('#context-details').hidePopover(); notify('已模拟压缩上下文');
|
||||
};
|
||||
for (const [panelId, triggerId] of [['composer-options', 'composer-options-trigger'], ['context-details', 'context-trigger']]) {
|
||||
const panel = $(`#${panelId}`); const trigger = $(`#${triggerId}`);
|
||||
const position = () => {
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
panel.style.left = `${Math.max(16, Math.min(rect.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 16))}px`;
|
||||
panel.style.top = `${Math.max(16, rect.top - panel.offsetHeight - 8)}px`;
|
||||
};
|
||||
panel.addEventListener('toggle', event => { trigger.setAttribute('aria-expanded', String(event.newState === 'open')); if (event.newState === 'open') position(); });
|
||||
window.addEventListener('resize', () => { if (panel.matches(':popover-open')) position(); });
|
||||
window.addEventListener('scroll', () => { if (panel.matches(':popover-open')) position(); }, true);
|
||||
}
|
||||
renderComposer();
|
||||
document.querySelectorAll('[data-icon]').forEach(element => { element.innerHTML = icon(element.dataset.icon); });
|
||||
window.addEventListener('hashchange', () => { if (Object.hasOwn(variants, location.hash.slice(1))) selectVariant(location.hash.slice(1)); });
|
||||
selectVariant(Object.hasOwn(variants, location.hash.slice(1)) ? location.hash.slice(1) : 'dock'); selectTool('files');
|
||||
if (matchMedia('(max-width: 1080px)').matches) toggleWorkbar(false);
|
||||
@@ -0,0 +1,263 @@
|
||||
# 应用导航与系统工具入口 PRD
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 关联设计 | [统一界面设计系统](../../../UI-DESIGN.md)、[助手工作栏 PRD](../assistant-workbar/prd.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
计划修订说明:当前实施范围以[第 8 节](#8-分期)为准。本文其余章节仍包含 v0.2 的
|
||||
左下角系统工具方案;启动实现前须同步功能要求、验收及配套设计,旧快捷工具条不再列入实施。
|
||||
|
||||
主侧栏当前把“当前对话”“魔法笔记”“知识库”“智能心跳”和“运行记录”放在同一导航组,
|
||||
底部长期显示“GB 本地工作区”和平台架构。底部信息不能反映用户可以执行的操作,文件、终端
|
||||
和浏览器则需要先找到右侧助手工作栏后才能打开。
|
||||
|
||||
后续插件可能提供完整页面,也可能只提供辅助面板或命令。若不先区分系统入口、应用和工具,
|
||||
插件接入后会同时争用主导航、侧栏底部和助手工作栏,用户也无法判断入口会切换页面还是打开
|
||||
辅助面板。
|
||||
|
||||
## 2. 产品定义
|
||||
|
||||
### 2.1 三类入口
|
||||
|
||||
| 类型 | 首期成员 | 默认位置 | 用户控制范围 |
|
||||
| --- | --- | --- | --- |
|
||||
| 系统入口 | 当前对话、运行记录、设置 | 主导航固定位置或侧栏底部 | 不可隐藏、卸载或改变类型 |
|
||||
| 应用 | 魔法笔记、知识库、智能心跳 | 主导航应用区 | 可控制是否显示;隐藏不删除数据 |
|
||||
| 系统工具 | 文件浏览器、终端、浏览器、资源监控 | 侧栏底部快捷工具栏 | 首期固定提供,不可卸载 |
|
||||
|
||||
运行记录是系统审计页面,不属于工具。文件浏览器、终端、浏览器和资源监控是系统工具;它们
|
||||
的快捷入口位于左下角,实际界面继续由右侧助手工作栏承载。
|
||||
|
||||
### 2.2 后续插件类型
|
||||
|
||||
插件市场开放后,插件可以贡献“插件应用”或“插件工具”。插件不能注册、覆盖或替换系统入口,
|
||||
也不能冒充系统工具。内置应用继续标记为“内置”,不因采用统一注册协议而显示成待安装商品。
|
||||
|
||||
## 3. 目标
|
||||
|
||||
- 用户从侧栏底部一次点击即可打开文件、终端、浏览器或资源监控。
|
||||
- 主导航只承载完整页面,辅助工具不占用一级页面位置。
|
||||
- 运行记录始终可发现,不受应用显示设置和插件状态影响。
|
||||
- 内置应用可以从一个设置位置控制主导航可见性,隐藏后仍可恢复。
|
||||
- 为插件应用和插件工具保留明确的入口契约,不提前建设插件安装能力。
|
||||
|
||||
## 4. 非目标
|
||||
|
||||
- 首期不提供应用商店页面、商店导航、推荐位或安装按钮,也不提供第三方插件搜索、下载、
|
||||
安装、更新、签名验证或卸载。内置应用的浏览、搜索与打开属于应用中心,不依赖商店。
|
||||
- 不把文件树、终端或浏览器重新实现到左侧栏。
|
||||
- 不改变助手工作栏的面板实例、停靠、执行空间和安全边界。
|
||||
- 不允许隐藏运行记录、当前对话或设置。
|
||||
- 不因隐藏应用而删除魔法笔记、知识库或智能心跳数据。
|
||||
- 不把“显示在侧边栏”解释为停止智能心跳计划或后台任务。
|
||||
- 不提供任意本机程序启动器。
|
||||
- 首期资源监控不采集远程主机、GPU、显存或网络吞吐,不提供后台告警和历史持久化。
|
||||
|
||||
## 5. 功能要求
|
||||
|
||||
### FR-1 入口分类
|
||||
|
||||
系统必须为每个可见入口声明稳定类型:`system-entry`、`application` 或 `system-tool`。界面位置、
|
||||
设置项和点击结果均以该类型为准,不根据当前页面临时改变类型。
|
||||
|
||||
### FR-2 固定系统入口
|
||||
|
||||
“当前对话”固定在主导航首位;“运行记录”固定在应用区之后;“设置”固定在侧栏底部。
|
||||
这些入口不出现在应用显隐、工具固定或插件管理设置中。
|
||||
|
||||
### FR-3 内置应用区
|
||||
|
||||
魔法笔记、知识库和智能心跳显示在当前对话与运行记录之间。应用入口打开对应一级页面,保留
|
||||
现有徽标、范围和页面状态。用户可以在设置中控制其主导航可见性。
|
||||
|
||||
### FR-4 隐藏应用
|
||||
|
||||
隐藏应用只移除主导航入口,不删除数据、不修改业务启停状态,也不终止正在进行的操作。显隐
|
||||
Switch 更新成功后主导航立即生效;更新失败时恢复原状态并允许重试。用户可从设置恢复入口。
|
||||
|
||||
### FR-5 系统工具快捷栏
|
||||
|
||||
侧栏底部提供文件浏览器、终端、浏览器和资源监控四个图标按钮,并保留独立设置按钮。每个
|
||||
按钮具有可见工具提示和可访问名称。首期系统工具顺序固定为文件、终端、浏览器、资源监控。
|
||||
|
||||
### FR-6 文件浏览器入口
|
||||
|
||||
点击文件按钮时展开助手工作栏,并聚焦默认存在且不可关闭的“工作区”单实例。该实例保持
|
||||
“跟随当前上下文”或“固定到指定对象”状态;迁移或布局修复发现实例缺失时补回,并默认跟随
|
||||
当前项目。没有项目时使用已配置的默认工作区。跟随上下文所需的路径不可用时,面板显示修复
|
||||
入口;已有有效固定目标不受当前项目和默认工作区状态影响。
|
||||
|
||||
### FR-7 终端入口
|
||||
|
||||
点击终端按钮时展开助手工作栏。当前执行空间有正在启动或运行中的终端时聚焦最近使用的实例;
|
||||
没有时创建一个绑定当前执行空间的新终端,已退出、已中断或失败的历史 Tab 保持不变。没有
|
||||
项目时当前执行空间为本机 Home。创建额外并行终端继续使用工作栏现有的“+”目录入口。
|
||||
|
||||
### FR-8 浏览器入口
|
||||
|
||||
点击浏览器按钮时展开助手工作栏,并优先聚焦绑定当前 Conversation 的最近使用浏览器实例;
|
||||
没有匹配实例时创建一个。没有活动 Conversation 时聚焦最近使用的浏览器实例;完全不存在时
|
||||
创建待绑定空实例。该快捷入口不会因连续点击创建重复实例;用户通过工作栏“+”选择浏览器时
|
||||
则始终创建新实例。入口不打开系统默认浏览器,也不创建旁路 WebView。
|
||||
|
||||
### FR-9 资源监控入口
|
||||
|
||||
点击资源监控按钮时展开助手工作栏,并打开或聚焦单实例的本机“资源监控”面板。面板显示
|
||||
整机 CPU 使用率、内存使用量与总量、磁盘读写速率,以及 GoodBuddy 桌面进程的 CPU、内存
|
||||
和进程数摘要。指标不可用时显示具体项目和原因,不能显示为 `0`。
|
||||
|
||||
资源面板处于可见活动状态、工作栏已展开且应用窗口未隐藏或最小化时,每 2 秒采样一次,在
|
||||
Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止采样;首期不在后台持续监控,
|
||||
也不据此产生侧栏告警。首次采样及停止后恢复的首次采样只建立 CPU 和磁盘 IO 速率基线,
|
||||
不显示伪造的 `0` 值。
|
||||
|
||||
### FR-10 快捷入口状态
|
||||
|
||||
工具面板已激活时,对应按钮显示选中状态;面板存在但未激活时保持普通状态。运行中、错误或
|
||||
等待用户处理可以显示徽标,但必须有可访问文字,且不得自动抢占当前面板。
|
||||
|
||||
### FR-11 应用导航设置
|
||||
|
||||
设置页提供“应用与工具”管理入口。首期“应用”区域列出三个内置应用并提供“显示在侧边栏”
|
||||
Switch,切换后立即持久化。系统工具区域列出四项内置固定能力,不提供虚假的卸载或停用按钮。
|
||||
|
||||
### FR-12 偏好持久化
|
||||
|
||||
应用可见性按设备持久化。升级时保留当前行为:已有可用内置应用继续显示;魔法笔记原有功能
|
||||
启停设置保持原意,不由新的导航可见性覆盖。
|
||||
|
||||
### FR-13 窄窗口行为
|
||||
|
||||
窄窗口下主侧栏仍按现有抽屉方式显示。系统工具按钮保持单行且不与设置按钮重叠;空间不足时
|
||||
收起文字和非必要分隔,不隐藏四个系统工具或设置。
|
||||
|
||||
### FR-14 项目活动汇总
|
||||
|
||||
用户在任一项目中都能查看跨项目的运行中与待处理会话,并从活动列表直接进入所选会话。
|
||||
汇总入口仅在有运行中或待处理会话时显示在项目选择器下方,无活动时整行隐藏。点击后先显示有活动会话的项目,
|
||||
悬停或点击项目展开会话子菜单,点击会话进入该条对话。项目菜单保留原有分组与顺序,并显示
|
||||
各项目的活动数量;级联菜单内的项目顺序不随待处理或运行中数量变化。
|
||||
汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
验收覆盖跨项目后台运行、同会话多任务去重、提问与审批优先、较早的仍在运行或等待处理的
|
||||
会话可见,以及从带搜索条件的页面或有未保存修改的设置页跳转。结束后仅有未读标记的
|
||||
会话不保留在活动汇总中;已完成未读面板不属于本项交付。
|
||||
|
||||
## 6. 权限与安全
|
||||
|
||||
- 快捷入口不授予新的文件、命令、网络或浏览器权限。
|
||||
- 系统工具继续使用 Main、Preload、执行空间 Grant、Ask/Execute 和现有审批边界。
|
||||
- Renderer 不因新增入口获得任意文件系统、PTY、进程或 Electron API。
|
||||
- 资源指标由 Main 采集并以有界结构返回;Renderer 不接收进程命令行、环境变量或任意 PID。
|
||||
- 资源样本只保存在当前 Renderer 内存中,不写入运行记录、SQLite 或遥测服务。
|
||||
- 插件贡献类型只是界面与导航声明,不能代替权限清单、用户授权和运行隔离。
|
||||
- 运行记录继续记录由系统工具触发的受审计操作。
|
||||
|
||||
## 7. 产品验收
|
||||
|
||||
- 主导航能明确区分固定系统入口和可配置内置应用。
|
||||
- 运行记录在所有应用显隐组合下保持可见并可打开。
|
||||
- 左下角不再用“本地工作区”与系统架构占据主要空间。
|
||||
- 文件、终端、浏览器和资源监控均可从左下角打开正确的工作栏面板,且不产生第二套内容界面。
|
||||
- 连续点击系统工具不会无意创建重复的浏览器或终端实例;工作区始终复用不可关闭的固定实例。
|
||||
用户通过工作栏“+”可以显式创建额外浏览器或终端实例。
|
||||
- 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、
|
||||
最小化后停止请求新样本。
|
||||
- CPU、内存、磁盘 IO 或 GoodBuddy 指标不可用时,页面区分部分可用与整体失败。
|
||||
- 隐藏内置应用不删除数据、不停止后台计划,并能从设置恢复;保存失败时入口保持原状。
|
||||
- 键盘、屏幕阅读器、浅色主题、深色主题和窄窗口均可完成相同操作。
|
||||
|
||||
## 8. 分期
|
||||
|
||||
### 首期
|
||||
|
||||
- 将侧栏最底部改为内置应用中心入口,设置按钮保持独立;具体列表、宫格或卡片样式从
|
||||
Demo 中收敛确定。
|
||||
- 应用中心提供魔法笔记、知识库、智能心跳的浏览、搜索与打开,不要求安装。
|
||||
- 支持应用常驻主导航、取消常驻和排序;未常驻应用仍可从应用中心打开,隐藏不删除数据或停止后台任务。
|
||||
- 应用中心和应用页面就近提供已有功能设置,共享真实设置来源;已有全局设置入口定位到同一界面。
|
||||
魔法笔记优先接入待办数量、AI 评论方式和评论形式,入口显示与常驻控制统一,避免重复开关。
|
||||
- 文件、终端、浏览器保留在右侧工作栏 Tab,不在左下角复制入口,也不纳入应用中心。
|
||||
- 原计划的本机资源监控仍作为系统工具交付项,在右侧增加单实例面板;与应用中心分别实现和验证。
|
||||
- 保持运行记录为固定系统入口。
|
||||
|
||||
首期验收需覆盖:无商店入口时可以访问全部内置应用;取消常驻后仍可重新打开和配置;
|
||||
不同设置入口状态一致;常驻排序重启后保留;右侧工具切换不改变当前应用页面。
|
||||
本节是范围调整,Demo 交互通过不代表正式功能已经实现。
|
||||
|
||||
### 补充要求:魔法笔记迁移与入口联动
|
||||
|
||||
- 将现有魔法笔记设置迁入应用中心的应用设置界面,笔记页面内的设置入口访问同一份配置。
|
||||
魔法笔记可常驻主导航;原全局设置位置如保留入口,应定位到共同设置界面。
|
||||
- 魔法笔记相关布尔开关默认全部开启,包括入口显示(常驻)和未完成待办数量显示。
|
||||
AI 评论方式、评论形式是单选项,沿用现有默认值,不把“全部开启”解释为同时选中多个模式。
|
||||
新默认值适用于未配置状态,迁移时保留用户已明确保存的选择。
|
||||
- 应用关闭后的入口联动必须覆盖主导航和会话页面,不能只更新应用中心卡片或一个菜单。
|
||||
知识库关闭后,左侧知识库菜单及会话中的知识库入口按钮同步移除;重新开启后按配置恢复。
|
||||
- “取消常驻”只移除主导航捷径;“关闭应用”移除该应用在主导航、会话等业务界面的入口。
|
||||
应用中心保留管理和重新开启入口。原型中仅有常驻控制,正式设计需补齐这两种状态,避免混用。
|
||||
- 入口变化不删除已有笔记或知识数据。知识库已打开页面、会话已关联知识以及后台任务的处理,
|
||||
在详细逻辑同步时分别定义,不能仅凭隐藏按钮推断功能或后台行为已经停用。
|
||||
|
||||
补充验收:从任一配置入口关闭知识库,主导航和当前会话按钮立即同步移除,切换会话、
|
||||
重启后也不重新出现;重新开启后恢复相应入口。验证魔法笔记未配置时的默认开启,以及
|
||||
已有关闭选择在迁移后保留。设置保存失败时各入口保持原状态,不能出现互相矛盾的显示。
|
||||
|
||||
### 后续:应用商店与扩展
|
||||
|
||||
- 单独设计应用商店的目录、搜索、推荐、详情及安装管理,时间与具体范围另行确定。
|
||||
- 插件应用和插件工具注册,以及安装、更新、卸载与权限说明。
|
||||
- 插件应用可常驻主导航;插件工具按工作栏扩展设计接入,不恢复左下角重复系统工具条。
|
||||
- 用户级快捷键与命令面板接入同一贡献命令。
|
||||
|
||||
现有 Demo 的商店页、项目管理和阅读空间安装流程仅保留为未来交互参考,不纳入首期验收,
|
||||
也不作为已有商店能力的说明。首期实现不预建插件安装服务或远程目录协议。
|
||||
|
||||
### FR-15 私有化市场与 YAML 交换(后续)
|
||||
|
||||
市场面向组织私有化部署。用户配置市场 URL 后即可获取该来源的应用目录与详情,并按需获取
|
||||
应用;需要认证的来源复用其登录流程。市场不可达时说明原因并允许重试,不影响已安装应用
|
||||
和内置应用使用,也不自动切换到公共市场。市场 URL 只影响应用市场,不改变模型、桌面更新
|
||||
或 Runtime 的下载源。
|
||||
|
||||
应用支持以 `.yml` 导入、导出,兼容 `.yaml` 扩展名,用于组织内部共享、备份和迁移。
|
||||
导出内容为应用定义及可迁移配置,包含格式版本、应用标识、应用版本、入口声明和依赖说明;
|
||||
凭据、会话、笔记及知识库业务数据不随应用定义导出。导入前展示应用摘要、缺失依赖和与已有
|
||||
应用的冲突;同标识应用由用户选择更新或取消,不静默覆盖本机配置。
|
||||
|
||||
YAML 描述文件不能代替模型权重或插件程序包。有外部依赖时应明确资源引用;离线导入仅在
|
||||
所需资源已具备时可完成使用,否则列出需要补充的资源,不显示为可运行。导入应用不会自动
|
||||
启动模型或执行任务。
|
||||
|
||||
未来 ShareServer 提供私有市场目录、应用定义的导入导出、发布和版本资源分发;桌面端负责
|
||||
配置来源、获取目录、本地导入导出及应用安装管理。服务端职责归属见
|
||||
[ShareServer 总体设计](../../architecture/share-network-architecture.md)。本节记录后续需求,
|
||||
尚未确定 API、YAML Schema 或服务端交付版本,不纳入应用中心首期。
|
||||
|
||||
### FR-16 本机模型管理入口(新增计划项)
|
||||
|
||||
应用中心提供“本机模型”内置管理应用,可选择常驻左侧,打开主内容区管理页。该入口统一
|
||||
查看各功能使用的本机模型及服务状态,包括已接入的 OCR、向量、语音或推理引擎;具体支持
|
||||
清单需核对现有模型服务后确定,不因提供入口而假定全部引擎均已支持进程控制。
|
||||
|
||||
页面展示模型名称、用途、所属引擎、管理方式、运行状态、占用情况及使用它的应用或任务。
|
||||
GoodBuddy 管理的进程提供启动、停止、重启和错误查看;外部启动的模型服务标明“外部管理”,
|
||||
提供连接状态与配置入口,未接入其管理接口时不提供停止或重启。进程内运行的模型按引擎实际
|
||||
能力提供加载与释放,不伪造独立进程。
|
||||
|
||||
停止或重启前展示正在使用该服务的任务和影响,由用户决定是否继续;取消时保留运行状态,
|
||||
失败时显示实际状态和原因。多个应用共用同一模型服务时展示同一个服务及其使用方,不重复
|
||||
启动进程。隐藏管理入口、关闭管理页面或取消应用常驻均不停止模型;卸载模型文件是独立操作。
|
||||
|
||||
资源监控仍是只读系统工具,本机模型管理负责模型生命周期,两者共享可用的状态来源。
|
||||
该项可独立于商店交付;当前只纳入计划,具体分期、任务使用期间的停止策略和各引擎管理能力
|
||||
在详细设计中确定。原首期资源监控的 GoodBuddy 桌面进程统计范围不因此扩大。
|
||||
@@ -0,0 +1,64 @@
|
||||
# 项目活动级联实施进度
|
||||
|
||||
## 2026-09-14:菜单独立高度修正
|
||||
|
||||
生产菜单移除共用双栏表面,会话层独立定位到选中项目行。打开较长会话列表时,项目层的位置、
|
||||
宽度与高度保持不变;会话层独立限制窗口边界和滚动。两侧均无法容纳子菜单时使用同层返回交互。
|
||||
|
||||
`npx vitest run src/renderer/src/ProjectActivity.test.tsx` 通过 13 项,类型检查与 Lint 通过。
|
||||
`npm test` 运行 60 秒后超时,未取得完整结果。
|
||||
|
||||
最终源码的 Electron 43.2.0 / Chromium 150 组件 fixture 使用生产样式与中文 i18n、模拟活动数据,
|
||||
深浅主题共 14 个场景、108 条检查通过,覆盖短/60 条会话、两层独立滚动、底部避让、向左展开、
|
||||
375px 窄屏及 640px 两侧空间不足。桌面项目层打开前后矩形一致,短窗口会话层底部保留 16px。
|
||||
此项验证实际组件排版和交互,不代表完整 Main/Preload 端到端验证;未修改远程执行路径。
|
||||
|
||||
## 2026-09-13:生产活动入口
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e项目活动级联)。
|
||||
本页只记录项目活动入口,不表示应用中心计划已交付。
|
||||
|
||||
`App` 已接入 `ProjectActivity` 常驻摘要和锚定级联菜单。会话和项目计数继续使用现有
|
||||
`deriveConversationActivity` 结果,点击会话继续调用 `openActivityConversation`。未增加
|
||||
持久化字段、IPC 或 Runtime 状态。项目选择器分组及计数保持原有行为。
|
||||
|
||||
组件与 App 定向回归通过 32 项,覆盖空闲入口、稳定项目及 SSH Host 顺序、待处理优先、
|
||||
跨项目精确跳转、设置离开检查、实时状态清理、键盘返回、外部关闭和窄侧栏焦点。
|
||||
|
||||
Windows Electron 视觉 fixture 使用当前 `ProjectActivity`、生产样式和 i18n,活动数据为
|
||||
受控样例。浅深主题分别验证 `1280×800`、`960×720`、`720×640`、`640×420`、`375×600`;
|
||||
原生键盘验证 Enter、上下及右方向键、End、两级 Escape、Tab 退出,另测左侧回退和空闲入口。
|
||||
此项证明组件的实际 Chromium 排版与交互,不代表完整生产 Main/Preload 的端到端验证。
|
||||
|
||||
浏览器遮挡复用共享菜单相交机制,组件测试检查相交和非相交结果;本轮未单独驱动原生
|
||||
`WebContentsView` 可见性。远程 Agent 执行路径未改动,未发出模型或知识库请求。
|
||||
|
||||
提交前复核:`npm run typecheck`、`npm run lint` 通过;单独运行
|
||||
`npx vitest run src/renderer/src/ProjectActivity.test.tsx`,11 项通过。
|
||||
`npm test` 在 240 秒后超时,超时前 `tests/agent-package.test.ts` 的
|
||||
`verifies and installs offline dependency inventories larger than one MiB` 用例失败,未取得完整结果。
|
||||
|
||||
Demo 已按最新讨论隐藏空闲摘要并移除会话行箭头;生产空闲摘要在下述非驻留修正中对齐。
|
||||
后续界面修正已移除生产会话行箭头,并为活动摘要与下方新建会话增加 `8px` 间距。
|
||||
|
||||
## 2026-09-14:摘要非驻留修正
|
||||
|
||||
生产 `ProjectActivity` 在无活动时返回空内容,活动归零时关闭级联菜单;新活动只恢复摘要。
|
||||
组件回归 11 项通过,覆盖初始空闲、活动归零关闭菜单及活动恢复后不自动展开。
|
||||
App 的 `project activity integration` 定向回归 18 项通过,类型检查与 Lint 通过。
|
||||
本轮 `npm test` 在 60 秒后超时,未取得全量结果;未重新执行 Electron 视觉验证。
|
||||
|
||||
## 2026-09-14:0.13.2 候选验证
|
||||
|
||||
正式发布准备时再次复现 App 问答用例的时序失败。活动摘要改为常驻后,等待该入口出现
|
||||
不再意味着会话初始化和 Runtime 状态读取已经完成;测试有时点击仍被禁用的发送按钮,
|
||||
留下未消费的一次性问答 mock,继而影响后续用例。
|
||||
|
||||
测试现等待已加载的会话和可用发送按钮,并在每个用例前重置问答及审批响应 mock。
|
||||
答案重载用例等待提交后的界面状态,而不只等待 API 被调用。未修改生产交互、
|
||||
未增加固定延时,也未跳过失败用例。相关 20 项连续三轮通过,完整 App 与 Agent
|
||||
发布说明检查共 227 项通过。
|
||||
|
||||
最终候选 `npm test` 通过 4,155 项、跳过 66 项、失败 0 项;`npm run typecheck`、
|
||||
`npm run lint` 和 `npm run release:notes:verify` 均通过。发布准备未执行本地生产构建、
|
||||
打包或安装包启动探针,原生包验收由主分支与标签 CI 完成。
|
||||
@@ -0,0 +1,437 @@
|
||||
# 应用导航与系统工具入口技术设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| UI 设计 | [UI 设计](./ui-design.md) |
|
||||
|
||||
## 1. 现状
|
||||
|
||||
主页面没有 URL Router,由 `App.tsx` 中的 `WorkspaceView` 和 keep-alive 状态切换:
|
||||
|
||||
```ts
|
||||
type WorkspaceView =
|
||||
| 'chat'
|
||||
| 'magic-notes'
|
||||
| 'knowledge'
|
||||
| 'heartbeat'
|
||||
| 'activity'
|
||||
| 'settings'
|
||||
```
|
||||
|
||||
文件、浏览器和终端已经由 `RightAssistantSidebar`、`WorkbarShell` 与 `workbar-contracts.ts`
|
||||
承载。工作区是不可关闭的单实例应用,浏览器和终端是多实例应用。浏览器多实例的跨进程
|
||||
实现以[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md)为准。代码中尚无资源指标采集和资源监控
|
||||
面板。现有侧栏底部 `user-card` 整行打开设置,并显示“本地工作区”和平台架构。
|
||||
|
||||
本次改动增加入口编排、从主侧栏到工作栏的命令通道,以及本机资源监控的 Main/Preload/
|
||||
Renderer 最小实现。文件、终端和浏览器后端保持不变。
|
||||
|
||||
## 2. 模块边界
|
||||
|
||||
| 模块 | 责任 |
|
||||
| --- | --- |
|
||||
| `App.tsx` | 组合主导航、系统工具栏、设置入口和页面离开检查 |
|
||||
| `SidebarSystemTools`(新增) | 渲染四个系统工具按钮并发送打开意图 |
|
||||
| `RightAssistantSidebar.tsx` | 接收打开意图,调用工作栏实例创建或激活逻辑 |
|
||||
| `WorkbarShell.tsx` | 继续负责实例策略、Tab、目录和焦点行为 |
|
||||
| `workbar-contracts.ts` | 继续作为工作栏应用 ID 和实例布局的权威契约 |
|
||||
| `resource-monitor-contracts.ts`(新增) | 定义本机资源快照、可用性和错误码 |
|
||||
| `resource-monitor-service.ts`(新增) | 在 Main 中采集并规范化本机与 GoodBuddy 指标 |
|
||||
| `ResourceMonitorPanel.tsx`(新增) | 按需轮询、保留有界历史并渲染资源面板 |
|
||||
| `application-settings-contracts.ts` | 增加应用导航偏好 schema 和默认值 |
|
||||
| `application-settings-store.ts` | 迁移并原子保存应用导航偏好 |
|
||||
| `PlatformFeaturesSettingsSection` 或独立设置 Section | 编辑“显示在侧边栏”偏好 |
|
||||
|
||||
不要在 `SidebarSystemTools` 中读取文件、创建 PTY、操作浏览器或采集系统指标;该组件只发送
|
||||
有界的工具 ID。
|
||||
|
||||
## 3. Renderer 契约
|
||||
|
||||
### 3.1 系统工具 ID
|
||||
|
||||
```ts
|
||||
export const sidebarSystemToolIds = [
|
||||
'workspace',
|
||||
'terminal',
|
||||
'browser',
|
||||
'resources'
|
||||
] as const
|
||||
|
||||
export type SidebarSystemToolId = (typeof sidebarSystemToolIds)[number]
|
||||
```
|
||||
|
||||
该类型可以放在 Renderer 内共享模块。首期不暴露 IPC,因为动作只编排已有工作栏 Renderer
|
||||
状态;实际能力调用继续走现有 Preload API。
|
||||
|
||||
### 3.2 打开意图
|
||||
|
||||
`App` 向 `RightAssistantSidebar` 传入单调递增的请求或命令对象:
|
||||
|
||||
```ts
|
||||
type WorkbarOpenIntent = {
|
||||
requestId: number
|
||||
appId: 'workspace' | 'terminal' | 'browser' | 'resources'
|
||||
reason: 'sidebar-system-tool'
|
||||
context?: { conversationId?: string; projectId?: string }
|
||||
}
|
||||
```
|
||||
|
||||
`requestId` 使连续点击同一工具仍能被消费。不要仅设置 `assistantSidebarTab`,因为用户可能已
|
||||
关闭对应实例,且终端需要按执行空间寻找实例。
|
||||
|
||||
建议由 `RightAssistantSidebar` 在消费意图后通过现有 `WorkbarShell` 控制接口完成。控制器必须
|
||||
接收上下文和调用原因,不能只按 `appId` 查找第一个实例:
|
||||
|
||||
```ts
|
||||
type WorkbarController = {
|
||||
openOrActivate(intent: WorkbarOpenIntent): Promise<WorkbarTabInstance>
|
||||
}
|
||||
```
|
||||
|
||||
若当前 `WorkbarShell` 没有命令式入口,先增加窄的回调或受控请求 Props,不引入全局事件总线。
|
||||
|
||||
工作区意图始终激活注册表中的不可关闭单实例;仅在迁移或布局损坏时补回。浏览器意图按
|
||||
`conversationId` 激活最近使用的匹配实例,没有匹配实例时创建一个。工作栏“+”不走该去重
|
||||
路径,选择浏览器时始终创建新实例。
|
||||
|
||||
### 3.3 终端匹配
|
||||
|
||||
工作栏“+”目录选择终端时每次创建新实例;侧栏系统工具按钮执行“进入当前终端”,采用:
|
||||
|
||||
1. 解析当前项目对应的 `ExecutionSpaceSelection`。
|
||||
2. 在现有终端实例中按目标 identity 查找 `starting` 或 `running` 状态。
|
||||
3. 存在多个匹配实例时激活最近使用的实例。
|
||||
4. 没有匹配实例时调用现有终端创建流程。
|
||||
|
||||
`closing`、`exited`、`interrupted` 和 `failed` 实例保留为历史 Tab,不阻止快捷按钮创建新终端。
|
||||
工作栏“+”继续按原规则创建额外终端,不受快捷按钮去重影响。
|
||||
|
||||
### 3.4 资源监控工作栏应用
|
||||
|
||||
在 `workbar-contracts.ts` 中增加 `resources`:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'resources',
|
||||
instancePolicy: 'single',
|
||||
defaultContext: 'application'
|
||||
}
|
||||
```
|
||||
|
||||
该应用不加入升级后的默认打开实例。侧栏资源按钮与工作栏“+”目录都通过既有单实例规则创建
|
||||
或聚焦它。
|
||||
|
||||
## 4. 应用导航偏好
|
||||
|
||||
### 4.1 设置结构
|
||||
|
||||
在 `ApplicationSettings` 增加独立字段:
|
||||
|
||||
```ts
|
||||
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat'
|
||||
|
||||
type ApplicationNavigationSettings = {
|
||||
hiddenBuiltInApplicationIds: BuiltInApplicationId[]
|
||||
}
|
||||
```
|
||||
|
||||
默认值为空数组,保持升级前所有可用入口可见。ID 数组可以避免为每个应用增加一个顶层布尔
|
||||
字段;Zod schema 必须去重、限制数量并拒绝未知内置
|
||||
ID。
|
||||
|
||||
首期不保存排序,因为内置应用顺序固定。插件偏好在插件系统确定稳定 identity 和卸载保留
|
||||
规则后另行扩展,不提前混入当前 schema。
|
||||
|
||||
### 4.2 与现有开关组合
|
||||
|
||||
入口可见条件为:
|
||||
|
||||
```ts
|
||||
const visible = featureEnabled && !hiddenBuiltInApplicationIds.includes(id)
|
||||
```
|
||||
|
||||
`magicNotesEnabled` 继续控制功能启停。知识库和智能心跳若未来增加业务启停开关,也按同一组合
|
||||
规则处理。功能停用时设置页保留显隐偏好但禁用显隐 Switch,并提供原功能设置入口。导航设置
|
||||
不能写回现有开关。
|
||||
|
||||
### 4.3 持久化与 IPC
|
||||
|
||||
沿用现有应用设置链路:
|
||||
|
||||
```text
|
||||
Renderer
|
||||
-> window.goodbuddy.updates.updateSettings
|
||||
-> settings:application:update
|
||||
-> application-settings-store
|
||||
-> application-settings.json
|
||||
```
|
||||
|
||||
更新继续由 Main 校验可信 sender、Zod 输入和原子写入。设置版本迁移只补充默认空数组,不
|
||||
修改已有 `magicNotesEnabled` 或其他平台设置。
|
||||
|
||||
## 5. 侧栏底部重构
|
||||
|
||||
现有 `user-card` 是一个覆盖整行的设置按钮。实现时替换为容器和相邻独立按钮:
|
||||
|
||||
```tsx
|
||||
<div className="sidebar-footer">
|
||||
<SidebarSystemTools onOpenTool={openSystemTool} />
|
||||
<button aria-label={t('navigation.settings')} ...>
|
||||
<Settings aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
不能在设置按钮内部嵌套工具按钮。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
|
||||
保留设置预加载、焦点恢复和未保存离开确认。
|
||||
|
||||
系统工具按钮调用统一 `openSystemTool`:
|
||||
|
||||
1. 设置工作栏打开意图。
|
||||
2. 展开工作栏。
|
||||
3. 由工作栏创建或激活实例。
|
||||
4. 按现有策略把焦点移入目标面板。
|
||||
|
||||
工作栏仍由窗口右上方的固定开关关闭,关闭后的焦点返回该开关。
|
||||
|
||||
## 6. 工作栏状态与快捷按钮
|
||||
|
||||
`RightAssistantSidebar` 应向 `App` 暴露当前活动实例的 `appId` 和必要的有界状态摘要,或由
|
||||
现有 `onTabChange` 扩展得到。主侧栏不读取终端输出、文件内容、URL 或活动详情。
|
||||
|
||||
```ts
|
||||
type SidebarSystemToolState = {
|
||||
activeToolId?: SidebarSystemToolId
|
||||
statusByToolId?: Partial<Record<SidebarSystemToolId, 'attention' | 'error' | 'running'>>
|
||||
}
|
||||
```
|
||||
|
||||
首期可以只实现 `activeToolId`,没有可靠聚合来源时不显示状态徽标。不能根据局部事件猜测错误
|
||||
或运行状态。
|
||||
|
||||
## 7. 应用显隐更新
|
||||
|
||||
应用显隐 Switch 复用平台功能设置的即时更新模式。请求开始后禁用三个应用显隐 Switch,只
|
||||
允许一个完整目标集合处于提交中。Main 返回成功结果后更新 App 中的平台设置快照,主导航
|
||||
随之变化。失败时恢复控件原值,在对应设置行显示可重试错误;主导航继续读取最后一次成功
|
||||
保存的设置。
|
||||
|
||||
## 8. 插件兼容边界
|
||||
|
||||
首期不增加插件注册表、插件 schema 或插件持久化字段,只保留系统 ID 不向插件开放的产品边界。
|
||||
插件市场设计需要单独确定 Host、权限、签名、生命周期和稳定 identity;不得直接复用 DeepSeek
|
||||
Harness 的 npm 插件市场。
|
||||
|
||||
## 9. 本机资源监控
|
||||
|
||||
### 9.1 采集范围
|
||||
|
||||
首期在 Main 中使用经过依赖审查的 `systeminformation` 获取整机 CPU、内存和磁盘 IO,使用
|
||||
Electron `app.getAppMetrics()` 汇总 GoodBuddy 桌面进程 CPU、内存和进程数。采集函数不接收
|
||||
Renderer 提供的路径、PID、设备名或命令。
|
||||
|
||||
首期指标定义:
|
||||
|
||||
- CPU:整机忙碌时间占比,规范化为 `0` 至 `100`。
|
||||
- 内存:总字节数、可用字节数和 `总量 - 可用量` 得到的使用量。
|
||||
- 磁盘 IO:所有可用本机磁盘的每秒读取与写入字节数。
|
||||
- GoodBuddy:`app.getAppMetrics()` 返回的桌面进程聚合 CPU、工作集内存和进程数量。各进程
|
||||
CPU 先相加,再按逻辑处理器数量归一化为整机容量占比 `0` 至 `100`;不能通过截断总和处理
|
||||
多核数值。
|
||||
|
||||
受管 Runtime、MCP 和终端子进程的逐进程指标不在首期范围。磁盘 IO 在平台或权限不足时返回
|
||||
不可用错误码,不能回退为 `0`。新增依赖在合入前需要完成许可证、维护状态、打包体积和供应链
|
||||
检查;检查未通过时应选择提供同等跨平台语义的 Main 侧实现,不能改用 Renderer Shell 命令。
|
||||
|
||||
### 9.2 快照契约
|
||||
|
||||
```ts
|
||||
type ResourceMetric<T> =
|
||||
| { status: 'available'; value: T }
|
||||
| { status: 'warming-up' }
|
||||
| { status: 'unavailable'; reason: ResourceMetricUnavailableReason }
|
||||
|
||||
type LocalResourceSnapshot = {
|
||||
sampledAt: string
|
||||
host: {
|
||||
cpuPercent: ResourceMetric<number>
|
||||
memory: ResourceMetric<{
|
||||
usedBytes: number
|
||||
totalBytes: number
|
||||
}>
|
||||
diskIo: ResourceMetric<{
|
||||
readBytesPerSecond: number
|
||||
writeBytesPerSecond: number
|
||||
}>
|
||||
}
|
||||
goodbuddy: {
|
||||
cpuPercent: ResourceMetric<number>
|
||||
memoryBytes: ResourceMetric<number>
|
||||
processCount: ResourceMetric<number>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
所有数字必须有限且非负;百分比限制为 `0` 至 `100`,字节数设置跨 IPC 上限。`sampledAt` 由
|
||||
Main 生成。不可用原因使用稳定枚举,例如 `unsupported-platform`、`permission-denied`、
|
||||
`collector-timeout` 和 `collector-failed`,不向 Renderer 返回原始命令输出。`warming-up` 只用于
|
||||
依赖两个采样点的 CPU 与磁盘 IO,不能用于掩盖采集失败。
|
||||
|
||||
### 9.3 Preload 与 IPC
|
||||
|
||||
Preload 只暴露:
|
||||
|
||||
```ts
|
||||
resourceMonitor.getLocalSnapshot(input: {
|
||||
resetIntervalBaseline: boolean
|
||||
}): Promise<LocalResourceSnapshot>
|
||||
```
|
||||
|
||||
对应 IPC 为 `resource-monitor:local-snapshot:get`。Main 验证可信 sender;单次采集设置超时,
|
||||
并合并同一时刻的并发请求,防止多个 Renderer 重复启动系统采集。采集超时后向调用方返回
|
||||
有界错误,但底层 Promise 仍占用唯一单飞槽位,直到实际结束;在此之前不启动第二个采集
|
||||
任务。输入只接受布尔值,接口不提供任意进程、磁盘或远程主机查询。
|
||||
|
||||
合并规则区分普通请求与基线重置请求:
|
||||
|
||||
- 普通请求在途时,新的普通请求复用它。
|
||||
- 普通请求在途时收到重置请求,Main 最多登记一个待执行重置;当前采集结束后再执行,不能
|
||||
用普通请求结果满足重置请求。
|
||||
- 多个重置请求复用同一个在途或待执行重置。
|
||||
- 重置请求在途时,普通请求复用其 `warming-up` 结果,下一次时钟再取得区间值。
|
||||
|
||||
因此服务最多持有一个在途采集和一个待执行重置。调用方超时不取消这个顺序,也不增加队列
|
||||
长度。
|
||||
|
||||
面板每次从非活动状态恢复后的首次请求传入 `resetIntervalBaseline: true`。Main 消费本次
|
||||
`systeminformation` 与 Electron CPU 读数并重置区间基线,CPU 和磁盘 IO 返回 `warming-up`;
|
||||
内存等瞬时指标正常返回。后续请求传入 `false` 并计算当前区间值,避免把面板停用期间计入
|
||||
速率。
|
||||
|
||||
### 9.4 轮询与历史
|
||||
|
||||
`ResourceMonitorPanel` 仅在工作栏展开、`resources` 为活动实例且
|
||||
`document.visibilityState === 'visible'` 时启动轮询:
|
||||
|
||||
1. 进入活动状态后立即请求一次,并设置 `resetIntervalBaseline: true`。
|
||||
2. 此后每 2 秒请求一次;前一请求未结束时跳过本次时钟。
|
||||
3. Tab 失活、工作栏收起、页面变为隐藏或组件卸载时清理定时器,并忽略较晚返回的旧请求。
|
||||
4. Renderer 环形队列最多保留 150 个带真实时间戳的样本。
|
||||
5. 采集失败不追加伪造样本,也不持久化历史。
|
||||
|
||||
资源数据不写入应用设置、工作栏布局、运行记录或 SQLite。工作栏布局只保存 `resources` 实例
|
||||
及其活动状态。
|
||||
|
||||
## 10. 测试
|
||||
|
||||
### 10.1 Renderer 测试
|
||||
|
||||
- 固定入口顺序不受三个应用显隐组合影响。
|
||||
- 运行记录和设置没有显隐控件。
|
||||
- 四个系统工具按钮具有名称、工具提示、键盘行为和选中状态。
|
||||
- 文件复用不可关闭单实例;浏览器和终端按当前上下文聚焦匹配实例,工作栏“+”允许显式多实例。
|
||||
- 工具按钮展开工作栏但不改变当前主页面。
|
||||
- 显隐更新成功后主导航立即变化,失败时 Switch 和主导航恢复原状态。
|
||||
- 窄窗口下工具按钮不重叠,焦点恢复正确。
|
||||
- 资源面板只在可见活动时轮询,跳过重叠请求并把历史限制为 150 项。
|
||||
- 窗口隐藏、最小化和恢复时停止轮询并重新建立区间基线。
|
||||
- 资源部分可用、全部失败和重新进入活动状态均显示正确结果。
|
||||
|
||||
### 10.2 Main 与契约测试
|
||||
|
||||
- 新设置字段默认值、合法 ID、重复 ID、数量上限和未知 ID 校验。
|
||||
- 旧版本设置迁移后默认显示所有既有应用。
|
||||
- 更新导航偏好不修改魔法笔记启停等无关字段。
|
||||
- 原子写入失败时保留最后一次有效设置。
|
||||
- 资源快照拒绝 NaN、Infinity、负数字节数、越界百分比和未知错误码。
|
||||
- 采集超时、权限不足和磁盘 IO 不支持时返回有界部分结果。
|
||||
- 超时采集尚未实际结束时保持单飞,不累积新的后台采集任务。
|
||||
- 普通采集在途时收到基线重置请求,最多排队一个重置且不复用普通结果。
|
||||
- `resetIntervalBaseline: true` 时把 CPU 与磁盘 IO 标记为 `warming-up`。
|
||||
- GoodBuddy CPU 预热时,其内存和进程数仍可独立返回。
|
||||
- GoodBuddy 多进程 CPU 按逻辑处理器数量归一化,结果保持在 `0` 至 `100`。
|
||||
- GoodBuddy 摘要不包含 PID、命令行、环境变量或其他进程信息。
|
||||
|
||||
### 10.3 回归范围
|
||||
|
||||
- 工作栏布局恢复和已关闭实例重建。
|
||||
- 本地与托管 SSH 终端目标。
|
||||
- 浏览器无会话、创建中和 Agent 操作中状态。
|
||||
- 设置页未保存离开确认。
|
||||
- 魔法笔记待办数和智能心跳建议数徽标。
|
||||
- 浅色、深色、`220px` 与 `420px` 侧栏宽度。
|
||||
- Windows、macOS 和 Linux 的真实 CPU、内存与磁盘 IO 采集;不支持项显示准确原因。
|
||||
|
||||
## 11. 实施顺序
|
||||
|
||||
实施范围以 [PRD 分期](./prd.md#8-分期)为准。本节替代旧实施顺序;前文关于四个左侧
|
||||
系统工具按钮、仅持久化隐藏 ID 的契约需在实现前同步,不能直接按旧规格开发。
|
||||
|
||||
1. 同步 PRD 功能要求、User Stories、逻辑、UI 与技术契约,收敛首期应用中心样式。
|
||||
2. 接入现有内置应用页面与最底部应用中心入口,保留独立设置和固定运行记录入口。
|
||||
3. 为常驻显隐和排序设计最小持久化契约,保留已发布的相关设置与用户数据,按保存结果更新导航。
|
||||
4. 接入应用设置的共同界面与既有设置更新路径,先验证魔法笔记多入口状态一致性。
|
||||
5. 验证右侧现有文件、终端、浏览器 Tab;移除计划中的左下角工具快捷条和专用于该快捷条的意图接口工作。
|
||||
6. 单独实现资源快照契约、Main 采集服务、Preload API 与右侧 `resources` 单实例面板。
|
||||
7. 验证键盘、焦点、浅深主题、窄窗口、设置失败、重启持久化和跨平台行为。
|
||||
|
||||
首期使用内置应用清单,不依赖远程商店、插件注册或安装服务。商店目录、推荐、安装与
|
||||
更新机制在后续独立设计中确定;Demo 中的模拟商店不接入首期产品界面。
|
||||
|
||||
后续设计输入见 [FR-15](./prd.md#fr-15-私有化市场与-yaml-交换后续) 与
|
||||
[FR-16](./prd.md#fr-16-本机模型管理入口新增计划项):
|
||||
|
||||
- 私有市场的 YAML Schema 应由桌面本地导入与 ShareServer 发布共用,具体协议与字段在
|
||||
市场功能设计中确定。市场 URL 按设备保存,认证信息沿用受保护凭据存储;导出仅包含
|
||||
可迁移应用定义与非敏感配置。
|
||||
- 程序包与模型资源单独分发。受管下载声明不可变版本、预期字节数和 SHA-256,下载开始后
|
||||
固定来源,失败不静默切源;下载地址与重定向范围由 Main 验证,不接受 Renderer 任意覆盖。
|
||||
市场来源配置不覆盖现有模型、应用更新或 Runtime 下载源设置。
|
||||
- ShareServer 的市场发布与分发实现由其独立仓库承接,本仓库先记录依赖,不将桌面需求
|
||||
记录标为服务端已实现。
|
||||
- 本机模型管理实施前盘点现有 OCR、向量、语音及推理服务的生命周期接口,优先复用其
|
||||
Main 服务。只为实际支持的操作提供 typed IPC;区分受管子进程、外部服务和进程内模型,
|
||||
不把直连模型 HTTP 请求等同于独立模型进程。
|
||||
- 模型管理的详细逻辑需确定共享服务的使用方识别、忙碌时停止与重启、退出应用时的释放
|
||||
行为,再接入管理页面并验证实际引擎。该工作单独排期,不依赖市场上线。
|
||||
|
||||
## 12. 项目活动汇总
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),按[活动逻辑](./logic-design.md#9-项目活动汇总)
|
||||
派生状态,界面行为见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
- `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见
|
||||
`assistantTasks` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
|
||||
`ProjectActivity` 在有活动时渲染汇总与项目、会话级联菜单,`ProjectSwitcher` 复用项目计数。
|
||||
- `App` 向 `ProjectActivity` 传入项目元数据、活动行、侧栏可见状态与既有精确跳转回调。
|
||||
菜单按项目 ID 分组并记忆化派生集合;排序仅影响展示,不改聚合状态或持久化数据。
|
||||
- 两层菜单在同一 body Portal 内相接,桌面会话层使用独立 fixed 定位,不参与项目层高度计算。
|
||||
项目层锚定摘要,会话层锚定选中项目行并独立限制窗口边界。布局在打开、层级变化、实时数据更新、窗口缩放和
|
||||
滚动时按锚点重算;窄窗口隐藏项目面板并保留返回操作。菜单不调用 Modal 焦点隔离,
|
||||
捕获阶段消费 Escape,防止触发窄侧栏的文档级监听;Tab 关闭后交还正常焦点顺序。
|
||||
菜单关闭与焦点恢复在导航回调之前同步完成。
|
||||
- `role="menu"` 使浮层直接进入共享 `browser-viewport-occlusion.ts` 相交检测。未增加
|
||||
浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
|
||||
- 持久化快照刷新时,同一条仍在流式输出的消息保留本地待审批、待回答信息,持久化终态
|
||||
则清除这些信息。响应成功后对应 Task 立即退出等待状态;响应期间到达的新问题、审批或
|
||||
终态不能被旧响应覆盖。
|
||||
- `AssistantDatabase.listTasks()` 保留最近历史窗口(默认 100 条),额外包含窗口外当前
|
||||
运行或等待处理的可见 Task;`activeVisibleTaskSelect` 与 `toTask` 的有效状态保持一致,
|
||||
待调度运行规范化为 `queued`,不因旧 Task 状态被误纳入实时集合。
|
||||
- `listConversations()` 保留最近 100 条活动会话,同时包含上述 Task 关联的较早活动会话,
|
||||
以及含 `streaming` 消息的较早活动会话。两类列表去重后仍按原时间顺序返回,结果允许超过
|
||||
历史窗口大小,避免后台工作被最近记录挤出。
|
||||
- `openActivityConversation` 按确切 Conversation ID 导航;内存中缺失时刷新持久化会话
|
||||
列表并合并,再查找同一 ID。刷新失败或会话已不存在时显示通知,不回退到项目最新会话。
|
||||
设置离开检查通过后才提交项目、会话及界面清理状态。
|
||||
- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。
|
||||
|
||||
验证记录见[项目活动级联实施进度](./progress.md)。本次只改 Renderer 展示与交互,远程
|
||||
Runtime、Agent 协议和桌面到 Agent 的执行路径未改变。
|
||||
@@ -0,0 +1,234 @@
|
||||
# 应用导航与系统工具入口 UI 设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 全局规范 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) |
|
||||
|
||||
本文定义入口、设置和轻量资源监控界面。文件、终端和浏览器的内容布局继续遵守助手工作栏
|
||||
文档。
|
||||
|
||||
## 1. 侧栏信息架构
|
||||
|
||||
```text
|
||||
品牌
|
||||
项目选择
|
||||
项目活动汇总(仅有活动时显示)
|
||||
新对话
|
||||
搜索
|
||||
|
||||
当前对话
|
||||
魔法笔记 3
|
||||
知识库
|
||||
智能心跳 1
|
||||
运行记录
|
||||
|
||||
最近对话
|
||||
...
|
||||
|
||||
────────────────────────
|
||||
[文件] [终端] [浏览器] [资源] [设置]
|
||||
```
|
||||
|
||||
“当前对话”和“运行记录”属于固定系统入口。三个内置应用位于二者之间。侧栏底部不再显示
|
||||
“本地工作区”和 `platform · arch`;排障信息移至关于或诊断页面。
|
||||
|
||||
## 2. 底部系统工具栏
|
||||
|
||||
底部使用连续平整表面和顶部分隔线,不做成账户卡片或嵌套卡片。按钮顺序固定:
|
||||
|
||||
| 位置 | 图标 | 可见工具提示 | 点击结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `FolderOpen` | 文件 | 聚焦不可关闭的工作区面板 |
|
||||
| 2 | `SquareTerminal` | 终端 | 打开或聚焦当前执行空间终端 |
|
||||
| 3 | `Globe` | 浏览器 | 聚焦当前 Conversation 的匹配浏览器实例 |
|
||||
| 4 | `Activity` | 资源监控 | 打开或聚焦本机资源监控面板 |
|
||||
| 末位 | `Settings` | 设置 | 打开设置页面 |
|
||||
|
||||
图标使用项目现有 Lucide 库。每个按钮采用统一的 `34px × 34px` 点击区域,内部图标建议
|
||||
`16px`。工具栏使用 `8px` 内边距和 `4px` 间隔;设置按钮通过自动空白靠右,不添加第二条
|
||||
视觉分隔线。
|
||||
|
||||
### 2.1 状态
|
||||
|
||||
- 默认:透明背景,图标使用次级文字色。
|
||||
- 悬停:使用弱表面色,不能强于当前选中状态。
|
||||
- 选中:使用强调选中背景,并提高图标对比度。
|
||||
- 键盘焦点:显示统一焦点环,不能被侧栏边缘裁切。
|
||||
- 处理中:创建或激活请求未完成时暂时禁用对应按钮,保留尺寸并提供进行中名称。
|
||||
- 徽标:只用于等待处理、错误或活动状态,不显示无意义的常驻绿色圆点。
|
||||
|
||||
选中状态表达“对应工作栏面板正在显示”,不表达功能已安装或权限已授予。
|
||||
|
||||
## 3. 主导航应用区
|
||||
|
||||
应用入口沿用现有 `nav-item` 行,不使用应用卡片。图标、名称和徽标保持单行:
|
||||
|
||||
```text
|
||||
[图标] 魔法笔记 3
|
||||
[图标] 知识库
|
||||
[图标] 智能心跳 1
|
||||
```
|
||||
|
||||
隐藏某个应用后,其余应用向上补位。运行记录始终位于最后一个可见应用之后,不因应用显隐
|
||||
改变样式或归类为工具。
|
||||
|
||||
设置页打开期间,主侧栏显示最后一次成功保存的导航状态。Switch 更新成功后导航立即变化,
|
||||
失败时保持原导航。
|
||||
|
||||
## 4. 应用与工具设置
|
||||
|
||||
设置中心新增“应用与工具”分类,采用 `standard` PageShell:
|
||||
|
||||
```text
|
||||
应用与工具
|
||||
管理主导航中的应用。系统工具由 GoodBuddy 提供并固定在侧栏底部。
|
||||
|
||||
应用
|
||||
魔法笔记 笔记、待办和评论 [显示在侧边栏 开]
|
||||
知识库 管理和检索知识 [显示在侧边栏 开]
|
||||
智能心跳 查看计划、建议和运行历史 [显示在侧边栏 开]
|
||||
|
||||
系统工具
|
||||
文件 打开当前工作区 内置
|
||||
终端 进入当前执行空间 内置
|
||||
浏览器 打开 GoodBuddy 受控浏览器 内置
|
||||
资源监控 查看本机 CPU、内存和磁盘 IO 内置
|
||||
|
||||
系统入口
|
||||
当前对话、运行记录和设置位置固定,不参与上述配置。
|
||||
```
|
||||
|
||||
应用显隐使用共享 Switch,标签必须是“显示在侧边栏”,不能缩写为“启用”。切换时立即保存,
|
||||
请求期间禁用三个应用显隐 Switch;失败后恢复原值,并在对应设置行显示可重试错误。系统工具
|
||||
使用紧凑只读列表,不渲染禁用的卸载、停用或固定按钮。
|
||||
|
||||
功能已停用时,对应应用行显示“功能已停用”和设置入口,保留显隐偏好但暂时禁用 Switch。
|
||||
|
||||
若某应用自身支持业务启停,应在该应用原有设置中保留“启用功能”开关,并用说明区分:
|
||||
|
||||
- “显示在侧边栏”只控制入口。
|
||||
- “启用功能”控制该功能是否运行。
|
||||
|
||||
## 5. 助手工作栏反馈
|
||||
|
||||
点击系统工具后,右侧工作栏按现有布局回流打开。主内容页面保持不变,侧栏不渲染工具内容。
|
||||
|
||||
- 文件:激活默认存在且不可关闭的“工作区”Tab,焦点进入目录区域或错误修复入口。
|
||||
- 终端:激活匹配终端,焦点进入终端输入区;新建期间保留按钮宽度和进度状态。
|
||||
- 浏览器:激活绑定当前 Conversation 的最近使用“浏览器”Tab;没有匹配实例时创建一个,
|
||||
没有 Conversation 时按产品规则复用最近实例或进入待绑定空状态。
|
||||
- 资源监控:激活“资源监控”Tab,焦点进入指标摘要;首个样本加载后开始绘制历史。
|
||||
|
||||
后台事件可以更新徽标,不能自动展开工作栏或切换当前 Tab。由用户点击快捷按钮触发的打开
|
||||
属于显式操作,可以展开工作栏并切换 Tab。
|
||||
|
||||
### 5.1 资源监控面板
|
||||
|
||||
资源监控使用紧凑单列,不使用指标卡片墙:
|
||||
|
||||
```text
|
||||
资源监控 本机
|
||||
|
||||
CPU 18%
|
||||
[最近 5 分钟折线图]
|
||||
|
||||
内存 9.6 / 32 GB
|
||||
[使用量进度条]
|
||||
|
||||
磁盘 IO 读 12 MB/s · 写 3 MB/s
|
||||
|
||||
GoodBuddy
|
||||
CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
|
||||
更新于 14:32:08
|
||||
```
|
||||
|
||||
- CPU 折线纵轴固定为 `0%` 至 `100%`,不能根据当前低负载动态放大波动。
|
||||
- 内存同时显示使用量、总量和百分比;进度条不代替文字数值。
|
||||
- 磁盘 IO 使用每秒读写字节并按统一单位格式化,不显示累计设备字节数。
|
||||
- GoodBuddy 摘要只显示聚合值,不列 PID、命令行或环境变量。
|
||||
- 首次加载或停止后恢复时,CPU 和磁盘 IO 显示“正在建立采样基线”,不先渲染 `0%`;内存
|
||||
等瞬时指标可以立即显示。
|
||||
- 部分可用时只在对应指标行显示“不可用”和简短原因;全部失败时显示重试入口。
|
||||
- 图表不使用动效平移;`prefers-reduced-motion` 下行为不变。
|
||||
- 面板失去活动状态、工作栏收起或窗口隐藏、最小化时停止更新;返回后重新建立区间指标基线,
|
||||
不填补离开期间的历史。
|
||||
|
||||
## 6. 文案
|
||||
|
||||
固定使用以下名称:
|
||||
|
||||
| 对象 | 文案 |
|
||||
| --- | --- |
|
||||
| 设置分类 | 应用与工具 |
|
||||
| 文件系统工具 | 文件 |
|
||||
| 终端系统工具 | 终端 |
|
||||
| 浏览器系统工具 | 浏览器 |
|
||||
| 浏览器说明 | GoodBuddy 受控浏览器 |
|
||||
| 资源系统工具 | 资源监控 |
|
||||
| 资源范围 | 本机 |
|
||||
| 指标缺失 | 不可用 |
|
||||
| 应用显隐开关 | 显示在侧边栏 |
|
||||
| 内置来源 | 内置 |
|
||||
|
||||
不再使用“本地工作区”作为侧栏底部身份名称。项目和默认工作区的路径继续由项目选择器、页面
|
||||
范围或工作区面板显示。
|
||||
|
||||
## 7. 键盘和无障碍
|
||||
|
||||
- 底部按钮均为独立 `button`,不能嵌套在整行设置按钮中。
|
||||
- Tab 顺序为文件、终端、浏览器、资源监控、设置。
|
||||
- 图标按钮通过 `aria-label` 提供名称,工具提示不能作为唯一可访问名称。
|
||||
- 工作栏打开后按现有焦点策略聚焦目标面板;用户通过固定工作栏开关关闭时,焦点返回该开关。
|
||||
- 选中按钮使用 `aria-pressed` 或等价的可访问状态,不能只依靠背景色。
|
||||
- 徽标的数量或状态合并进按钮可访问名称,例如“终端,1 个等待处理”。
|
||||
- 应用显隐 Switch 支持 Space 切换,保存失败后恢复原值并保留焦点。
|
||||
|
||||
## 8. 响应式
|
||||
|
||||
- 侧栏宽度 `220px` 至 `420px` 时,底部按钮尺寸不随宽度缩放。
|
||||
- 窄窗口继续使用现有侧栏抽屉;工具栏不转为多行。
|
||||
- 空间不足时缩小设置按钮前的弹性空白,不隐藏四个系统工具和设置。
|
||||
- 工具提示不得超出窗口;靠近底边时显示在按钮上方。
|
||||
- 粗指针设备不依赖悬停,按钮的可访问名称和选中反馈保持有效。
|
||||
|
||||
## 9. 未来插件入口
|
||||
|
||||
插件市场交付后另行定义插件区域、固定数量、溢出和权限界面。系统入口与四个系统工具的位置
|
||||
保持保留,不向插件开放。
|
||||
|
||||
## 10. 项目活动汇总
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),状态与计数遵守
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
- 全局汇总按钮仅在有活动时显示在项目选择器下方,紧凑显示待处理与运行中数量,空闲时整行隐藏,
|
||||
右侧显示展开箭头,与下方新建会话保留 `8px` 间距。可访问名称明确数量属于“全项目活动”。
|
||||
- 项目菜单在项目行显示同口径数量,保留“本地项目 / 远程项目 / 远程通道”分组和 SSH Host
|
||||
层级,不因活动状态重排项目。数量为零时不显示标记,连接状态仍独立显示。
|
||||
项目名独占一行,状态数量排列在名称下方,避免窄侧栏中数量挤占名称宽度。
|
||||
- 点击汇总在按钮下方打开项目菜单,只列有活动会话的项目,每项显示项目名和同口径计数。
|
||||
桌面鼠标悬停或点击项目展开右侧会话菜单,右侧空间不足且左侧可容纳时向左展开。两个面板
|
||||
相接,各自拥有边框、圆角与阴影;会话层对齐所选项目行,底部空间不足时仅上移会话层,
|
||||
不改变项目层的位置或高度。指针离开项目行进入会话层时不收起。展开项目不切换当前会话。
|
||||
- 会话层顶部显示项目名,每行显示完整可换行的会话标题与明确状态文字,待处理会话排在
|
||||
运行中会话之前。项目行保留子菜单箭头,会话行不显示箭头。不展开 Task、Job 或 Run 列表。
|
||||
- 点击会话先关闭菜单并恢复入口焦点,再请求进入该条会话;允许离开当前页面后,
|
||||
选择所属项目、清除搜索和该会话未读标记、清除任务选择与会话操作菜单,窄窗口同时关闭侧栏。
|
||||
设置页沿用未保存修改的离开检查,取消离开时保留原页面。
|
||||
- 菜单挂载到 body,使用 `menu`、`menuitem`、`aria-haspopup`、`aria-expanded` 与控制关系,
|
||||
不隔离背景、不增加遮罩。原生浏览器相交遮挡沿用共享机制。
|
||||
- 打开后焦点进入首个项目;上下方向键循环选择当前层项目或会话,Home、End 到首尾,
|
||||
Enter、Space 激活,右方向键进入会话层。左方向键或 Escape 返回项目层并恢复对应项目
|
||||
焦点;顶层 Escape 关闭并恢复汇总焦点,不继续关闭窄侧栏。Tab 关闭菜单后继续正常焦点
|
||||
顺序;点击外部、焦点离开、再次点击入口或隐藏侧栏也关闭菜单。
|
||||
- 单面板宽度为 `264px`,窗口小于 `620px` 或项目菜单两侧均无法容纳子菜单时,就地进入会话层并显示“返回项目列表”,
|
||||
触控无需悬停。浮层距窗口边缘至少 `16px`,每层最大高度为 `480px`,短窗口进一步限制
|
||||
高度并独立滚动。活动全部结束时,关闭已打开菜单并隐藏摘要。
|
||||
@@ -0,0 +1,307 @@
|
||||
# 应用导航与系统工具入口 User Stories
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 关联逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
|
||||
## 1. 角色
|
||||
|
||||
### 1.1 普通用户
|
||||
|
||||
使用对话、笔记和知识功能,希望常用入口稳定,不需要理解工作栏实例模型。
|
||||
|
||||
### 1.2 本地工作用户
|
||||
|
||||
经常查看项目文件、打开终端、使用受控浏览器或检查电脑负载,希望从任意一级页面快速进入
|
||||
这些工具。
|
||||
|
||||
## 2. Epic A:固定系统入口
|
||||
|
||||
### US-A1 始终访问运行记录
|
||||
|
||||
作为用户,我希望运行记录保持在固定位置,以便应用入口变化后仍能检查 Agent 和工具活动。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户隐藏任意内置应用
|
||||
- When 用户查看主导航
|
||||
- Then “运行记录”仍显示在应用区之后
|
||||
- And 设置中没有隐藏、卸载或固定运行记录的选项
|
||||
|
||||
### US-A2 从固定入口进入设置
|
||||
|
||||
作为用户,我希望设置入口始终位于侧栏底部,以便快捷工具状态变化时仍能进入配置。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 任意主页面和任意侧栏宽度
|
||||
- When 用户查看侧栏底部
|
||||
- Then 设置按钮保持可见且点击区域不与工具按钮重叠
|
||||
- And 点击后继续遵守未保存内容的离开确认
|
||||
|
||||
## 3. Epic B:管理应用入口
|
||||
|
||||
### US-B1 识别完整应用
|
||||
|
||||
作为用户,我希望魔法笔记、知识库和智能心跳显示在主导航,以便知道它们会打开完整页面。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 对应功能已启用且应用设置为显示
|
||||
- When 用户打开主侧栏
|
||||
- Then 应用位于“当前对话”和“运行记录”之间
|
||||
- And 点击应用在主内容区打开对应一级页面
|
||||
- And 原有待办或建议徽标继续显示
|
||||
|
||||
### US-B2 隐藏低频应用
|
||||
|
||||
作为用户,我希望隐藏不常用的内置应用,以便缩短主导航,但保留已有数据。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 应用已有内容
|
||||
- When 用户关闭该应用的“显示在侧边栏”Switch
|
||||
- Then 主导航不再显示该应用
|
||||
- And 应用数据、计划和业务启停状态不变
|
||||
- And 用户可从同一设置位置恢复显示
|
||||
|
||||
### US-B4 保留功能启停语义
|
||||
|
||||
作为魔法笔记用户,我希望导航显隐与功能启停相互独立,以免整理导航时关闭功能。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 魔法笔记功能已启用
|
||||
- When 用户只隐藏其侧栏入口
|
||||
- Then 笔记及待办处理能力继续按原设置工作
|
||||
- And 再次显示时恢复原页面和数据
|
||||
|
||||
### US-B5 恢复显隐保存失败
|
||||
|
||||
作为用户,我希望入口设置保存失败时继续使用原导航,以免界面状态与实际设置不一致。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 应用显隐更新尚未完成
|
||||
- When 用户切换“显示在侧边栏”Switch
|
||||
- Then 三个应用显隐 Switch 在请求期间暂时不可操作
|
||||
- And 更新成功后主导航立即变化
|
||||
- And 更新失败时 Switch 和主导航恢复原状态并显示可重试错误
|
||||
|
||||
## 4. Epic C:使用系统工具
|
||||
|
||||
### US-C1 一键查看文件
|
||||
|
||||
作为本地工作用户,我希望点击左下角文件按钮后直接查看当前工作区文件。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前项目有有效根目录
|
||||
- When 用户点击文件按钮
|
||||
- Then 助手工作栏展开
|
||||
- And 聚焦默认存在且不可关闭的工作区单实例
|
||||
- And 文件按钮通过选中状态表明该面板正在显示
|
||||
|
||||
### US-C2 处理无效工作区
|
||||
|
||||
作为用户,我希望目录不可用时看到修复方法,并收到明确反馈。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 工作区面板处于跟随模式
|
||||
- And 当前项目和默认工作区均不可用
|
||||
- When 用户点击文件按钮
|
||||
- Then 工作区面板仍打开
|
||||
- And 面板说明目录不可用并提供选择或修复目录的入口
|
||||
- And 应用不静默改用其他目录
|
||||
|
||||
### US-C3 快速进入终端
|
||||
|
||||
作为本地工作用户,我希望点击终端按钮后进入当前执行空间的终端。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前执行空间已有正在启动或运行中的终端
|
||||
- When 用户点击终端按钮
|
||||
- Then 工作栏聚焦最近使用的匹配终端,不创建重复实例
|
||||
- Given 当前执行空间没有终端
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建一个绑定该执行空间的新终端
|
||||
- Given 当前没有项目
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建或聚焦使用本机 Home 的终端
|
||||
- Given 当前执行空间只有已退出、已中断或失败的终端
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建新终端并保留原终端的历史状态
|
||||
|
||||
### US-C4 使用远程项目终端
|
||||
|
||||
作为远程项目用户,我希望同一个终端按钮进入远程项目目录,以免误在本机执行命令。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前项目绑定托管 SSH 执行空间
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建或聚焦绑定该远程项目及其权威目录的终端
|
||||
- And 面板持续显示远程主机和工作目录
|
||||
- And 连接失败时保留重连与诊断信息
|
||||
|
||||
### US-C5 打开受控浏览器
|
||||
|
||||
作为用户,我希望浏览器按钮打开 GoodBuddy 受控浏览器,以便查看与 Agent 相同的会话。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 浏览器面板跟随当前上下文且当前会话已有浏览器会话
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 工作栏聚焦绑定当前 Conversation 的最近使用浏览器实例
|
||||
- And 不打开系统默认浏览器或无意创建重复实例
|
||||
- Given 现有浏览器实例都绑定到其他 Conversation
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 工作栏创建并聚焦绑定当前 Conversation 的新浏览器实例
|
||||
- Given 当前没有活动 Conversation
|
||||
- And 已存在浏览器实例
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 工作栏聚焦最近使用的浏览器实例并保持其目标
|
||||
- Given 当前没有活动 Conversation
|
||||
- And 不存在浏览器实例
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 创建待选择目标的空实例
|
||||
- Given 用户需要同一 Conversation 的另一个独立页面
|
||||
- When 用户从工作栏“+”选择浏览器
|
||||
- Then 每次创建拥有独立页面和导航状态的新实例
|
||||
- And 同一 Conversation 内共享隔离登录上下文
|
||||
|
||||
### US-C6 查看本机资源
|
||||
|
||||
作为本地工作用户,我希望快速查看 CPU、内存和磁盘 IO,以便判断任务变慢是否与本机负载
|
||||
有关。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 资源监控面板尚未打开
|
||||
- When 用户点击左下角资源监控按钮
|
||||
- Then 助手工作栏创建并激活单实例资源监控面板
|
||||
- And 页面显示整机 CPU、内存、磁盘读写及 GoodBuddy 桌面进程摘要
|
||||
- And 页面每 2 秒更新一次,并用图表保留最近 5 分钟
|
||||
- When 用户切换到其他工作栏 Tab、收起工作栏、隐藏窗口或最小化窗口
|
||||
- Then 资源监控停止请求新样本
|
||||
- When 用户再次显示窗口并返回资源监控
|
||||
- Then 首次采样建立区间指标基线,下一次采样开始显示 CPU 和磁盘 IO 速率
|
||||
- Given 某项指标在当前平台不可用
|
||||
- When 页面收到部分结果
|
||||
- Then 该指标显示“不可用”及原因,其他指标继续更新
|
||||
|
||||
### US-C7 从任意应用打开工具
|
||||
|
||||
作为用户,我希望在魔法笔记、知识库、智能心跳和运行记录页面也能打开系统工具。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户位于任意一级页面
|
||||
- When 用户点击文件、终端、浏览器或资源监控
|
||||
- Then 主页面保持不变
|
||||
- And 右侧工作栏打开对应面板
|
||||
- And 工具不会覆盖主内容或伪装成一级页面
|
||||
|
||||
## 5. Epic D:可访问性和布局
|
||||
|
||||
### US-D1 使用键盘打开系统工具
|
||||
|
||||
作为键盘用户,我希望按顺序聚焦并打开左下角工具,以便不依赖鼠标。
|
||||
|
||||
验收:
|
||||
|
||||
- 每个系统工具和设置都是独立按钮
|
||||
- Tab 顺序与视觉顺序一致
|
||||
- Enter 和 Space 均可激活按钮
|
||||
- 每个纯图标按钮都有可访问名称和工具提示
|
||||
- 焦点样式不被侧栏边界裁切
|
||||
|
||||
### US-D2 在窄窗口使用快捷栏
|
||||
|
||||
作为小屏用户,我希望侧栏抽屉中的工具按钮保持可操作。
|
||||
|
||||
验收:
|
||||
|
||||
- 工具按钮保持单行且不重叠
|
||||
- 不因宽度不足隐藏文件、终端、浏览器、资源监控或设置
|
||||
- 触控点击区域符合统一界面规范
|
||||
- 关闭侧栏抽屉后焦点返回原触发按钮
|
||||
|
||||
## 6. Epic E:项目活动级联
|
||||
|
||||
### US-E1 从摘要进入确切会话
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)。用户在当前项目工作时,可从项目选择器下方
|
||||
查看跨项目活动,先定位项目,再进入需要处理的会话。
|
||||
|
||||
- Given 多个项目存在运行中和待处理会话,当前搜索仅匹配当前项目
|
||||
- When 打开全项目活动并悬停或点击另一个项目
|
||||
- Then 项目顺序保持稳定,该项目会话按待处理优先展示,当前页面和会话保持原选择
|
||||
- When 点击会话
|
||||
- Then 进入该确切 Conversation ID,清除搜索、任务选择和会话菜单,窄窗口关闭侧栏
|
||||
- Given 设置页有未保存修改
|
||||
- Then 会话跳转仍需通过既有离开检查,取消离开保留设置草稿和原项目
|
||||
|
||||
### US-E2 键盘、窄窗口与实时结束
|
||||
|
||||
- Given 菜单已打开
|
||||
- Then 上下方向键与 Home、End 选择当前层,右方向键或 Enter 进入会话层,左方向键或
|
||||
Escape 返回;再次 Escape 关闭活动菜单并恢复摘要焦点,窄侧栏保持打开
|
||||
- Given 窗口小于 `620px`
|
||||
- Then 点击项目后仅显示会话层,可通过返回按钮回到项目列表;长列表可滚动到最后一项
|
||||
- Given 鼠标从项目行移向相接的会话面板
|
||||
- Then 子菜单保持打开,点击会话正常导航
|
||||
- Given 当前项目的活动全部结束
|
||||
- Then 返回项目层并恢复可用焦点;全局无活动时整行隐藏摘要并关闭菜单,新活动到来时只恢复摘要
|
||||
- When 点击外部、焦点离开、按 Tab 或隐藏侧栏
|
||||
- Then 菜单关闭,后台新活动不自动重新展开
|
||||
|
||||
## 7. 后续需求场景
|
||||
|
||||
本节对应新增计划,不计入应用中心首期完成条件。
|
||||
|
||||
### US-F1 连接组织应用市场
|
||||
|
||||
对应 [FR-15](./prd.md#fr-15-私有化市场与-yaml-交换后续)。
|
||||
|
||||
- Given 组织已部署提供应用市场的 ShareServer
|
||||
- When 用户配置市场 URL,并在来源要求时完成登录
|
||||
- Then 可以浏览该来源的应用目录、详情及可获取版本
|
||||
- Given 市场暂时不可达
|
||||
- Then 显示失败原因与重试入口,已安装和内置应用仍可打开,不切换公共市场
|
||||
|
||||
### US-F2 导入导出应用定义
|
||||
|
||||
对应 FR-15。
|
||||
|
||||
- Given 用户需要在私有部署间迁移应用
|
||||
- When 导出应用为 `.yml` 并在另一台设备导入
|
||||
- Then 预览应用身份、版本、可迁移配置和依赖,凭据与业务数据不包含在导出文件中
|
||||
- Given 同标识应用已存在
|
||||
- Then 用户可选择更新或取消,取消后原应用与配置保持不变
|
||||
- Given 文件格式无效、版本不支持或离线环境缺失程序包或模型资源
|
||||
- Then 显示具体问题与补充要求,不将应用误报为可运行,也不自动启动模型或任务
|
||||
|
||||
### US-F3 管理本机模型服务
|
||||
|
||||
对应 [FR-16](./prd.md#fr-16-本机模型管理入口新增计划项)。
|
||||
|
||||
- Given 用户使用依赖本机模型的应用
|
||||
- When 从应用中心或常驻菜单打开“本机模型”
|
||||
- Then 可查看已接入模型的状态、管理方式及使用方,两处入口访问同一页面
|
||||
- Given 多个应用共用一个 GoodBuddy 受管模型服务
|
||||
- Then 展示同一服务及使用方,启动操作不重复创建进程
|
||||
- When 用户停止或重启仍有任务使用的受管服务
|
||||
- Then 先展示任务影响供用户决定,取消后服务保持运行;操作失败显示原因与实际状态
|
||||
- Given 模型服务由外部程序启动,且未接入其管理接口
|
||||
- Then 展示“外部管理”与连接配置,不提供停止或重启
|
||||
- When 用户关闭管理页或取消常驻
|
||||
- Then 模型运行状态不变
|
||||
@@ -0,0 +1,25 @@
|
||||
# 助手工作栏
|
||||
|
||||
助手工作栏统一承载本机与远程执行空间、Task、成果、文件、浏览器和终端等持续工作界面。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
|
||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 工作栏应用的固定、可关闭、单实例和多实例策略,以及多终端生命周期 |
|
||||
| [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 |
|
||||
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
|
||||
| [执行记录存储与升级回收](./execution-history-storage.md) | 子任务过程差量存储、远程去重、旧库转换、空间回收及升级界面 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、并行与输出、消息请求状态及资源生命周期规则 |
|
||||
| [Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md) | 跨 Runtime 资源调研、锁定协议证据、进程与会话拆分、远端模型桥、实施顺序和性能验收 |
|
||||
| [Runtime 执行清单技术设计](./runtime-checklist-technical-design.md) | OC/CN 原生清单提取、远程 Agent 传递复用、消息恢复与真实部署验收;尚未实现 |
|
||||
|
||||
当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;产品场景与验收保留在现有 PRD,
|
||||
Runtime 资源状态规则由交互边界文档维护。浏览器多实例和 Runtime 进程复用涉及不同的
|
||||
跨进程身份与工具路由,分别使用技术设计文档维护。
|
||||
|
||||
执行清单的需求、场景和顶部布局见 [PRD](./prd.md#131-runtime-执行清单),状态规则见
|
||||
[Runtime 交互边界](./runtime-interactions.md#runtime-执行清单)。本目录拥有该功能;
|
||||
[Task 与 Job](../task-and-job/README.md)拥有产品任务领域,
|
||||
[远程主机](../remote-host/technical-design.md#执行清单接入范围)拥有远程部署与连接边界。
|
||||
@@ -0,0 +1,216 @@
|
||||
# 浏览器多实例技术设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 基础多实例已实施,Agent 多 Tab 工具待需求 |
|
||||
| 版本 | 1.0 |
|
||||
| 日期 | 2026-09-13 |
|
||||
| 关联 PRD | [通用助手工作栏与执行空间 PRD](./prd.md) |
|
||||
| 页签策略 | [助手工作栏多终端页签 PRD](./terminal-tabs-prd.md) |
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
浏览器改为可关闭的多实例工作栏应用。每个工作栏浏览器实例对应一个独立 Browser Tab,拥有
|
||||
独立页面、导航状态、操作队列、画面状态和元素引用空间。同一 Conversation 下的多个 Tab
|
||||
共享隔离存储与代理上下文,使登录状态符合常规浏览器预期。
|
||||
|
||||
本设计不把多个工作栏 Tab 实现为同一页面的重复入口,不允许用户切换 Tab 后改变正在执行的
|
||||
Agent 工具目标,也不在首期要求模型主动管理多个 Tab。
|
||||
|
||||
## 2. 改造前限制
|
||||
|
||||
改造前的链路以 `conversationId` 作为唯一浏览器身份:
|
||||
|
||||
- Renderer 只保存每个 Conversation 的一份 `BrowserLiveState`。
|
||||
- Browser IPC、Preload API 和 `BrowserService` 操作只携带 `conversationId`。
|
||||
- Main 每个 Conversation 只创建一个 Session、一个 `WebContentsView` 和一个 CDP Driver。
|
||||
- 请求级 MCP 能力只保存 `browserConversationId`。
|
||||
- snapshot 元素 `ref` 只在一个 Driver 的引用空间内有效。
|
||||
|
||||
因此只把工作栏注册策略改成 `multiple` 会导致多个 Tab 控制同一页面,并产生 viewport、清理和
|
||||
元素引用竞态,不能作为可交付实现。
|
||||
|
||||
## 3. 身份与所有权
|
||||
|
||||
```ts
|
||||
type BrowserTabId = string
|
||||
|
||||
type ConversationBrowserContext = {
|
||||
conversationId: string
|
||||
partitionId: string
|
||||
tabs: Map<BrowserTabId, BrowserTab>
|
||||
primaryTabId?: BrowserTabId
|
||||
}
|
||||
|
||||
type BrowserTab = {
|
||||
id: BrowserTabId
|
||||
conversationId: string
|
||||
view: WebContentsView
|
||||
driver: CdpBrowserDriver
|
||||
state: BrowserLiveState
|
||||
}
|
||||
```
|
||||
|
||||
- Conversation Browser Context 拥有隔离 partition、代理配置和全部 Tab。
|
||||
- Browser Tab 拥有页面、Driver、串行操作队列、snapshot 代次、idle 状态和可见 viewport。
|
||||
- `BrowserTabId` 是 Main 签发的不透明 ID。Renderer 和 Runtime 不能自行选择或复用。
|
||||
- 工作栏实例保存公开的逻辑实例 ID 和 Conversation 绑定;进程重启后不恢复旧
|
||||
`WebContentsView`,首次使用时由 Main 为该逻辑实例创建新的 Browser Tab。
|
||||
- 删除 Conversation 时关闭该 Context 下全部 Tab;关闭一个工作栏浏览器实例只关闭对应
|
||||
Browser Tab,不得调用 Conversation 级 Runtime 释放路径。
|
||||
|
||||
## 4. Renderer 与 viewport
|
||||
|
||||
Renderer 状态改为:
|
||||
|
||||
```ts
|
||||
Record<ConversationId, Record<BrowserTabId, BrowserLiveState>>
|
||||
```
|
||||
|
||||
每个浏览器工作栏实例持有自己的 `browserTabId`。只有当前可见实例可以提交 viewport。每次
|
||||
获得可见权时生成新的 UUID `leaseToken`;提交请求携带 `browserTabId` 和 token,清理请求必须
|
||||
回传同一个 token。Main 只接受当前 token 的清理,避免已隐藏实例的延迟 cleanup 隐藏新实例,
|
||||
也避免 Renderer 重载后从零开始的数字序列无法覆盖旧租约。
|
||||
|
||||
非活动 Browser Tab 保持页面和状态,但隐藏 `WebContentsView` 并停止非必要画面采集。工作栏
|
||||
收起、切换主页面和窗口隐藏时采用相同规则,不销毁 Tab。
|
||||
普通导航、快照与页面操作只同步轻量导航状态,不自动截图、等待画面或传输 JPEG;只有显式
|
||||
`browser_screenshot` 调用生成图片。
|
||||
|
||||
`BrowserViewport` 统一观察 body Portal 的插入、移除和可见属性变化,以及浮层尺寸、
|
||||
窗口尺寸、滚动和过渡结束。`browser-viewport-occlusion.ts` 按共享语义识别
|
||||
`aria-modal="true"`、dialog、alertdialog、menu 和应用通知:可见应用级 Modal 始终释放
|
||||
viewport,其余浮层只在与浏览器区域相交时释放。隐藏节点和隐藏祖先不阻塞浏览器。
|
||||
最后一个遮挡消失后,只有当前活动实例恢复同一 viewport 租约;网页、历史和页面状态不变。
|
||||
终端关闭确认复用此机制,不再维护单独的隐藏条件。页面无需逐个登记弹窗,也不能靠提高
|
||||
CSS `z-index` 覆盖原生子视图。此逻辑仅涉及 Desktop Renderer 的显示租约,不改变
|
||||
Runtime、MCP 或远端 Agent 的请求、导航与生命周期。
|
||||
|
||||
浏览器应用声明 `visibleAcrossContextSwitches: true`,打开的 Tab 跨项目、会话切换继续显示。
|
||||
切换活动 Conversation 不改变当前选中的浏览器或其 `targetRef.conversationId`;用户在会话 B
|
||||
查看会话 A 的浏览器时,viewport 和手动导航仍指向 A 的原 Tab。新建实例绑定 B。仅未绑定的
|
||||
默认或恢复实例可在首次使用时取得当前会话绑定。
|
||||
|
||||
Tab 的跨会话可见性与 MCP 授权分开处理。B 的请求不能选择属于 A 的可见 Tab;A 已取得的
|
||||
capability 在用户切到 B 后仍固定路由至 A 的原 Tab;使用租约不阻止用户关闭该 Tab。
|
||||
内部点击实例只同步应用类型,不再次按当前 Conversation 改选实例。Main 状态携带对应的
|
||||
`workbarInstanceId`,Renderer 据此绑定请求创建的同一 Tab,而不是另建空白页。收到 `stopped`
|
||||
后清除该实例的旧 Tab 绑定,下一次使用通过原逻辑实例重新创建,保留地址草稿。
|
||||
刷新可用性读取 Main 的已提交 URL,不使用地址草稿或仅凭 `sessionActive` 判断。
|
||||
侧栏操作错误以当前工作栏实例和面板展开状态为作用域;切换或关闭时清空,旧作用域的
|
||||
异步回调不再写入错误,即使用户随后返回同一实例。
|
||||
请求创建的实例也遵守现有 32 个工作栏页签上限;满额时保留 Main 页面及其状态,并就地提示
|
||||
关闭一个可关闭页签。释放位置后再绑定该页面,不写入超限布局或丢弃 Agent 页面。
|
||||
|
||||
## 5. IPC 与服务接口
|
||||
|
||||
共享契约增加有界操作:
|
||||
|
||||
```ts
|
||||
browser.createTab({ conversationId, workbarInstanceId })
|
||||
browser.listTabs({ conversationId })
|
||||
browser.closeTab({ conversationId, tabId })
|
||||
browser.setViewport({ conversationId, tabId, leaseToken, bounds })
|
||||
browser.navigate({ conversationId, tabId, url })
|
||||
browser.back({ conversationId, tabId })
|
||||
browser.reload({ conversationId, tabId })
|
||||
browser.stopLoading({ conversationId, tabId })
|
||||
```
|
||||
|
||||
Main 必须验证 `tabId` 属于同一 `conversationId` 和当前窗口。BrowserService 将现有
|
||||
Conversation 级 slot 拆为 Context 与 Tab 两层;同一 Tab 内操作继续串行,不同 Tab 可以并行,
|
||||
但仍受窗口级和 Conversation 级有界数量限制。
|
||||
|
||||
现有 Conversation 级 `stop/release` 保留为销毁完整浏览器上下文的操作,不能用作关闭单个
|
||||
Browser Tab。
|
||||
|
||||
## 6. Agent 与 MCP 路由
|
||||
|
||||
### 6.1 兼容阶段
|
||||
|
||||
第一阶段不修改模型可见的现有浏览器工具参数。模型请求开始时,Main 按以下顺序选择并冻结
|
||||
`boundBrowserTabId`:
|
||||
|
||||
1. 当前可见且绑定该 Conversation 的 Browser Tab。
|
||||
2. 该 Conversation 的 `primaryTabId`。
|
||||
3. 预留 primary Browser Tab 身份,首次实际浏览器操作时才创建页面与 Conversation Context。
|
||||
|
||||
未使用浏览器的请求只持有随请求释放的内存预留,不创建 Chromium 资源、不触发浏览器 UI、
|
||||
也不占用活动浏览器会话名额。物化后使用同一 Tab 身份和工作栏实例归属;取消、能力撤销及
|
||||
服务关闭释放未使用的预留,不保存到磁盘。编程 Subagent 继承父请求的 Browser Tab 与浏览器
|
||||
Conversation 归属,不新建租约或把页面重新归属到子级对话。
|
||||
|
||||
直连模型上下文和请求级 MCP capability 同时保存 `conversationId` 与
|
||||
`boundBrowserTabId`。请求执行期间,用户切换、创建或关闭其他工作栏 Tab 不改变工具目标;
|
||||
用户可直接关闭模型正在使用的 Tab。关闭释放该 Tab 的页面和使用租约,中止其浏览器操作,
|
||||
不取消整个模型请求、不撤销 MCP capability,也不关闭同级 Tab。系统不得把进行中的操作改投其他 Tab。
|
||||
|
||||
关闭后,快照等非导航工具返回“浏览器标签页已关闭,请调用 browser_navigate 打开新标签页”,
|
||||
不自动创建页面或使用现有 primary/sibling。只有显式且参数有效的 `browser_navigate` 才创建
|
||||
专用替代 Tab 并绑定后续工具;旧 Tab 身份与元素引用不复用。Gateway 与直连 Provider 均隔离
|
||||
单次工具取消信号、Tab 使用租约信号和请求信号,取消一次工具调用不终止后续调用。
|
||||
|
||||
该阶段 MCP transport、endpoint 和 token 生命周期不变,只扩展服务端 capability 内容和内部
|
||||
路由。工具调用使用当前请求绑定的 Tab,只有上述显式导航恢复会更换绑定。
|
||||
|
||||
### 6.2 Agent 多 Tab 阶段
|
||||
|
||||
只有明确需要 Agent 管理多个页面时,才增加:
|
||||
|
||||
- `browser_tab_list`
|
||||
- `browser_tab_open`
|
||||
- `browser_tab_close`
|
||||
|
||||
导航、snapshot、back 和 screenshot 增加可选 `tabId`;省略时使用请求绑定的默认 Tab。
|
||||
`browser_snapshot` 返回 `tabId` 和 snapshot 代次。click、type 和 select 必须校验 capability、
|
||||
`tabId`、引用代次和元素 `ref` 属于同一 Driver,禁止跨 Tab 猜测或解析引用。
|
||||
|
||||
## 7. 工作栏创建与快捷入口
|
||||
|
||||
- 从“+”选择浏览器时始终创建新工作栏实例,并默认绑定当前 Conversation。
|
||||
- 没有活动 Conversation 时创建待绑定实例,不创建无归属 Main Browser Context。
|
||||
- 主侧栏浏览器快捷按钮优先激活绑定当前 Conversation 的最近使用实例;没有匹配实例时创建
|
||||
一个。没有活动 Conversation 时激活最近使用的浏览器实例;完全不存在时创建待绑定实例。
|
||||
- New browser instances use a numbered browser label followed by the active Conversation title, when
|
||||
available. The number avoids existing titles; restoring the layout preserves each saved title.
|
||||
The address bar shows only that instance's page URL. Binding a numbered instance does not overwrite
|
||||
its title; the default unbound browser receives the Conversation label when first bound.
|
||||
- 关闭工作栏 Browser Tab 只释放对应 Browser Tab。关闭浏览器上下文是独立危险操作,需要
|
||||
明确说明会影响该 Conversation 下的全部 Tab 和 Agent 浏览器状态。
|
||||
|
||||
## 8. 持久化与恢复
|
||||
|
||||
`createTab` reuses a live tab only for the same Conversation and workbar instance ID. A new workbar
|
||||
instance can adopt the primary tab only when its request creates the Conversation context. An existing
|
||||
primary, including one opened by an Agent, is not adopted by a new instance. New sibling tabs start at
|
||||
`about:blank` with no navigation history and share the Conversation's cookie partition.
|
||||
|
||||
持久化工作栏实例 ID、顺序、自定义标题和公开 Conversation 绑定,不持久化页面进程、CDP
|
||||
引用、snapshot、viewport lease、Cookie、Token 或 Main `BrowserTabId`。应用重启后实例显示
|
||||
未启动状态,展开浏览器面板或 Agent 首次使用时按原绑定创建新 Tab。
|
||||
|
||||
绑定的 Conversation 已删除或不可见时保留实例并显示目标失效,不自动绑定当前 Conversation。
|
||||
|
||||
## 9. 限制与验收
|
||||
|
||||
- 分别限制每个 Conversation 的 Tab 数和每个窗口的总活动 Tab 数;达到上限时在创建前反馈。
|
||||
- 两个 Tab 的地址、历史、加载状态、截图和元素引用互不覆盖。
|
||||
- 同一 Conversation 的 Tab 共享登录状态,不同 Conversation 保持隔离。
|
||||
- 延迟 viewport cleanup 不能隐藏另一个已激活 Tab。
|
||||
- 一个 Tab 的元素 `ref` 不能在另一个 Tab 中执行。
|
||||
- 用户切换工作栏 Tab 不改变进行中模型请求的 MCP 浏览器目标。
|
||||
- 关闭一个 Tab 不释放 Conversation Runtime,也不关闭同一 Conversation 的其他 Tab。
|
||||
- 关闭已租用 Tab 后,同一 MCP 会话仍可列出工具;旧目标快照报错且不创建页面,显式导航创建
|
||||
不同身份的替代 Tab,同级页面、导航状态与其他 Conversation 的可见 viewport 保持不变。
|
||||
- 旧版无 `tabId` 的模型工具调用仍操作请求绑定的默认 Tab。
|
||||
|
||||
## 10. 实施顺序
|
||||
|
||||
1. 引入 Browser Context 与 Browser Tab 两层身份,拆分 partition 和页面生命周期。
|
||||
2. 扩展 Shared、Preload、IPC 与 BrowserService 的 `tabId` 和 viewport `leaseToken`。
|
||||
3. 改造 Renderer 状态与工作栏实例,开放浏览器 `multiple` 策略和单 Tab 关闭。
|
||||
4. 将直连模型与 MCP capability 绑定到请求开始时确定的 Browser Tab。
|
||||
5. 完成跨 Tab 引用隔离、并行队列、共享 Cookie、关闭和恢复测试。
|
||||
6. 有明确 Agent 多页面需求后再开放模型可见的 Tab 管理工具。
|
||||
@@ -0,0 +1,64 @@
|
||||
# 执行记录存储与升级回收
|
||||
|
||||
通用迁移、备份、性能测量和发布要求见
|
||||
[数据库迁移与大数据升级指南](../../development/database-migrations.md)。
|
||||
|
||||
## 用户行为
|
||||
|
||||
子任务仍实时显示完整的文字、思考、工具过程和最终结果。运行时间较长、工具输出较多时,
|
||||
数据库不再为每个流式更新重复保存此前的整份过程;本机和托管 SSH OpenCode 使用相同
|
||||
的桌面存储机制。此修复不关闭子任务、不截短已有过程,也不增加 Execute 权限门槛。
|
||||
|
||||
已有数据库在首次升级时自动处理。启动页铺满窗口,显示处理数量、全宽进度条和近期
|
||||
处理速度;处理时间取决于历史数据量与磁盘速度,不承诺固定几分钟。用户可以退出,
|
||||
下次启动重新检查并继续转换未完成的数据;完成后回收空闲页并进入应用。失败时留在
|
||||
该页提供重试与退出,磁盘空间不足会明确说明,不删除聊天来腾出空间。
|
||||
|
||||
## 存储规则
|
||||
|
||||
- `messages.metadata_json` 继续保存供会话显示的完整子任务过程。
|
||||
- `task_events` 的 `subagent` 内容由共享 `SubagentProgressStorage` 编码。
|
||||
`progressUpdates` 只保存文字追加、变更的块,或确有删除/重排时的完整重置。
|
||||
完全相同的过程使用空更新数组。工具字段按值比较,缺省字段与 `undefined` 等价,
|
||||
不因 JSON 省略空字段而重复写入整个工具结果;空字符串与实际输出/错误变化仍保留。
|
||||
其他事件内容不改变。
|
||||
- 每个任务内按事件 ID 顺序、按 `childTaskId` 独立还原过程。事件 ID、顺序、状态、
|
||||
最终输出、错误、时间及远程来源字段均保留,不按天数裁剪或删除旧事件。
|
||||
- Runtime、Agent transcript、Preload 和 Renderer 的公共 `SubagentEvent` 仍传完整
|
||||
`progress`;差量表示只存在于桌面数据库内部,不要求更新远端 Agent 协议。
|
||||
- 远程消息投影与事件插入仍共用 SQLite 事务。重放时还原已保存内容进行等值校验,
|
||||
相同来源重复事件不重复投影,不同内容继续报冲突。内存过程缓存通过已提交事件 ID
|
||||
核对;事务回滚或重新打开后从数据库重建,不保存独立快照或回收日志。
|
||||
- 存储层最多缓存 16 个任务;收到任务完成、错误、取消或中断的终态记录后立即移除该任务
|
||||
的缓存引用,不影响其他会话。若事务随后回滚或仍收到该任务的后续事件,按已提交记录重建。
|
||||
释放引用不等于进程 RSS 立即下降,垃圾回收和 Runtime 自身会话缓存另按生命周期回收。
|
||||
|
||||
## 旧库升级
|
||||
|
||||
桌面在打开业务数据库之前启动独立 Node 工作线程,使用同一套编码和还原函数转换旧库。
|
||||
按任务有序读取,每批最多 32 条,提交后报告进度;SQLite 保留事务原子性及正常 WAL
|
||||
自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史
|
||||
快照在过小缓存中反复读取;不以关闭事务或同步落盘换取速度。
|
||||
|
||||
转换可以混合读取旧完整记录与已经转换的记录,也会重新编码 schema 34 中重复的工具块
|
||||
更新。因此退出、磁盘写入失败后可以重新扫描并继续;已是相同 JSON 的记录不重复更新,
|
||||
不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非
|
||||
在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。
|
||||
|
||||
当前源码的业务初始化随后将数据库升级为 schema 35;schema 34 用户也会执行本次整理,
|
||||
成功后后续启动直接跳过。继续复用 schema 34 的子任务事件部分索引和同一差量表示。
|
||||
低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用
|
||||
升级前备份,不要修改 `user_version` 强行绕过版本检查。
|
||||
|
||||
## 验证要求
|
||||
|
||||
- 使用可还原的合成过程验证文字追加、内容修订、块重排、清空和多次打开。
|
||||
- 工具 fixture 必须含生产对象会携带的 `undefined` 可选字段,并经过实际 JSON 写入/重读。
|
||||
同时验证旧完整事件和 schema 34 差量事件的整理、混合重试及无变化记录不被重写。
|
||||
- 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。
|
||||
- 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、
|
||||
其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。
|
||||
- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。真实场景必须让主代理
|
||||
与子代理按正常任务需要读取、修改文件和执行测试,不能用“子代理不使用工具”的纯文字
|
||||
场景代替工具存储验收;记录实际请求数、有效变化、重复工具块及还原结果。具体证据见
|
||||
[实现与验证进度](./progress.md)。
|
||||
@@ -0,0 +1,106 @@
|
||||
# Runtime 执行清单技术设计
|
||||
|
||||
状态:设计阶段,尚未实现。需求、场景与顶部布局由 [PRD](./prd.md#131-runtime-执行清单)
|
||||
维护,替换、隔离和终态规则由 [Runtime 交互边界](./runtime-interactions.md#runtime-执行清单)
|
||||
维护。研究证据与交付状态见[进度](./progress.md#2026-09-15-runtime-执行清单设计未实现)。
|
||||
|
||||
## 1. 原生数据与提取位置
|
||||
|
||||
| Runtime / 路径 | 已确认格式与设计接入 |
|
||||
| --- | --- |
|
||||
| 本地 OC 1.18.29 | `todo.updated` 与 `session.todo` 返回完整条目列表,字段为 `content/status/priority`,无 item ID;以原生更新事件驱动本轮整份替换 |
|
||||
| 本地 CN 1.5.47 | 精确原生工具 `Checklist` 的参数 `checklist` 是 Markdown 勾选列表,`- [ ]` / `- [x]` 表示未完成/完成;按一次成功调用提交完整替换 |
|
||||
| 远程 OC/CN | 先核对实际部署版本、原生输出与 Agent 传递结果;只有格式已确认且来源归属明确的原生 plan entries 或精确清单工具参数可以映射,当前尚无本功能远程实测 |
|
||||
|
||||
`opencode-runtime.ts` 当前将普通工具 input 经 `boundedToolDetail(..., 4_000)` 转成
|
||||
展示字符串。接入应在 SDK 原始事件层消费 `todo.updated`;如需核对 `todowrite` 参数,
|
||||
必须在该截断之前处理。`session.todo` 是 Session 当前快照,读取结果必须与请求生命周期
|
||||
关联;不在每轮开始时无条件读旧快照充当新清单,也不轮询代替事件。
|
||||
|
||||
CN 的提取位置在 `continue-host-adapter.ts`:宿主产生工具事件、HTTP 状态合并及输入
|
||||
校验均存在 4,000 字符展示限制,不能等到 `continue-runtime.ts` 收到截断字符串后再解析。
|
||||
宿主应在最早的原始参数层提取 `checklist`,按工具调用 ID 保留候选,结合最终成功状态
|
||||
提交;成功帧省略参数时使用同次调用的候选,失败不得提交。普通工具事件继续沿现有路径
|
||||
流转。超出 4,000 字符的清单需要独立验证完整性,但不扩大所有普通工具输入的展示上限。
|
||||
|
||||
Markdown 解析只适用于已确认的 CN `Checklist.checklist`,不扫描助手正文或其他工具
|
||||
输出。空数组在统一事件中明确表示清除;CN 原生如何表达空清单,以及空白、大小写和
|
||||
非条目行的实际接受规则,实施时核对后固定解析规则。未确认或无效输入保留旧清单,
|
||||
不能误当清空。
|
||||
|
||||
## 2. 统一事件与消息归属
|
||||
|
||||
拟在现有 Runtime 事件联合中增加完整清单事件,承载 `requestId`、条目数组和原生来源;
|
||||
条目包含 `content`、`status` 及可选 `priority`。最终命名随实现确定,不设计条目级增删
|
||||
协议或稳定 item ID。空数组必须经过 schema、IPC、消息投影和保存链路,不能被“无内容”
|
||||
过滤丢弃。
|
||||
|
||||
Main 使用现有请求到助手消息的映射投影清单,Renderer 顶部读取该消息的最新有效状态。
|
||||
同一请求若同时观察到原生清单事件与其工具记录,只以前者更新清单,后者保留活动;不能
|
||||
提交两次替换。原生父 Session、请求开始/结束边界需共同校验,子 Session 或旧请求事件
|
||||
不得通过当前活动请求映射到新轮。
|
||||
|
||||
优先扩展现有消息 metadata、Runtime 事件和数据库投影,历史清单与所属助手消息一起保存。
|
||||
远端沿用事件提交、消息投影和 ACK 的既有事务及去重规则;相关存储边界见
|
||||
[执行记录存储](./execution-history-storage.md)。实施前核对本地会话与远程任务实际落库
|
||||
字段,选择能复用的最小表示。不新建清单数据库、独立快照系统、恢复日志或持久化服务。
|
||||
旧消息没有结构化清单时保持缺省,不从已截断工具字符串或状态文案补造历史。
|
||||
|
||||
## 3. 远程 Agent 与 ACP 复用
|
||||
|
||||
本次源码检查确认以下传递位置,属于设计依据,不是本功能远程验证结果:
|
||||
|
||||
1. `AgentAcpConnection` 按原生 `sessionId` 分派 `sessionUpdate`;
|
||||
`AgentOwnedAcpPrompt` 校验 Session 后,以 `bindingId/operationId` 将通知写入
|
||||
既有 semantic transcript。
|
||||
2. `ProtocolRemoteRuntimeChannel` 已有 transcript 读取/ACK、身份核对,以及 ACP
|
||||
channel 恢复与重放;`AcpRemoteRuntime` 消费 `session-update` 并转换桌面事件。
|
||||
3. `AcpRemoteRuntime` 当前将 `plan.entries` 及 `plan_update` 的 items 转成 `status`
|
||||
字符串;markdown/URI 也只形成状态消息。工具事件按 `toolCallId` 合并 `rawInput`、
|
||||
`status` 和结果,再有界保留。清单提取应发生在结构丢失或截断前。
|
||||
|
||||
优先复用上述通知和 transcript,不另建远端清单通道。对实际 OC/CN 部署确认过的
|
||||
`plan.entries`,或已确认等价的 `plan_update` items,校验条目字段和状态后转成统一
|
||||
清单事件。普通 `status`、plan markdown/URI、`plan_removed` 文案以及泛化工具名均不
|
||||
触发清单更新;在没有确认对应原生清单身份前,`plan_removed` 也不能清空顶部。
|
||||
|
||||
若远程原生输出走精确清单工具,须确认原始参数、调用身份与成功状态确实贯穿 Agent,
|
||||
再按同一提取规则接入。是否能直接复用原生 entries,或需补充最小 Agent 适配,取决于
|
||||
真实格式证据,不能根据 ACP 类型定义推断 OC/CN 一定会发送。ACP 是协议,Runtime 身份
|
||||
仍为 OC 或 CN。
|
||||
|
||||
当前托管 SSH 产品及受管安装、校验、创建链路只支持 OC,详见
|
||||
[远程范围](../remote-host/technical-design.md#执行清单接入范围)。远程 CN 交付需补齐
|
||||
受管工件、安装与启动、项目选择/校验及原生事件接入,确认 Main 模型桥、请求归属、取消
|
||||
和已有恢复流程能用于该部署。不能只放开选择菜单或使用本地 CN 调远程模型来宣称完成。
|
||||
|
||||
重连按[既有 ACP 与断线规则](../remote-host/technical-design.md#acp-与断线)执行。
|
||||
已接受 Prompt 使用原 binding/operation 恢复,清单从已提交消息和其后的既有事件恢复;
|
||||
重复事件不重复展示。连接丢失不表示条目完成,不为恢复清单重新发送 Prompt、模型请求或
|
||||
工具调用,也不新增独立重连机制。
|
||||
|
||||
## 4. 实施与验收
|
||||
|
||||
| 阶段 | 必须完成的工作 | 对应需求 |
|
||||
| --- | --- | --- |
|
||||
| CL-P1 | 原始层提取、严格格式校验、统一事件及请求归属;保留工具活动 | FR-CL1–FR-CL3 |
|
||||
| CL-P2 | 消息投影和既有存储恢复、顶部只读布局、空数组清除及终态展示 | FR-CL2–FR-CL4 |
|
||||
| CL-P3 | 确认远程 OC 原生格式,补齐远程 CN 真实部署与事件接入,复用 Agent 传递和恢复 | FR-CL1、FR-CL4 |
|
||||
| CL-P4 | 本地与共享 Linux 真实 Host 逐项验收,记录版本、结果、调用口径与剩余阻塞 | FR-CL1–FR-CL4 |
|
||||
|
||||
以下均为待执行验收,本次文档工作不运行模型或代码测试:
|
||||
|
||||
- 本地 OC/CN 各经桌面正常发送路径产生并连续修改清单,检查新增、修改、重排、删除、
|
||||
重复文本、有效清空及超过 4,000 字符的完整内容;CN 成功才替换,失败保留前值。
|
||||
- 同会话连续两轮、并行会话、切换 Runtime/项目/执行空间、旧轮迟到事件和子会话事件
|
||||
不串数据;完成、失败、取消不改写条目状态。默认 DS、任意 status/Markdown/list
|
||||
工具不生成伪清单,工具活动仍可查。
|
||||
- 本地会话重新打开及应用重启恢复历史;顶部折叠、键盘操作、浅深主题、窄窗口和 200%
|
||||
缩放符合 PRD,保存与重放后的空清单不会恢复旧内容。
|
||||
- 在共享 Linux x64 真实 Host 使用开发中的 Agent 与实际部署的 OC/CN,分别从远程项目
|
||||
完成原生清单更新、取消、断线续接及历史恢复。沿用
|
||||
[Agent 开发验证规则](../../../AGENTS.md#goodbuddy-agent-development-validation),
|
||||
LAN/VPN 是同一机器的两条路径,不能计作两台 Host。
|
||||
- 逐个记录 OC/CN 的安装、启动、模型请求、清单传递、顶部显示、取消和重连结果;基础
|
||||
Runtime 成功不等于清单通过。任一步未执行写“待验证”,被外部条件阻塞则记录原因,
|
||||
不以 mock、协议 fixture 或本地成功代替远程验收。
|
||||
@@ -0,0 +1,175 @@
|
||||
# Runtime 交互边界
|
||||
|
||||
本文定义当前原生交互的适配范围,不增加工作模式或第二层 Execute 审批。
|
||||
|
||||
| Runtime | 权限确认 | 业务问答 |
|
||||
| --- | --- | --- |
|
||||
| 本机 GoodBuddy 管理的 OpenCode | 内部默认配置允许权限;每个 Ask 请求仍使用 deny-all 与显式只读能力。Execute 自动回复属于当前请求的父、子孙会话权限事件,不处理其他并行请求 | 通过 `question.asked` 转交父、子孙会话问题,支持原生单选、多选、yes/no、自由文本及跳过;使用公开 ID 回传 `question.reply/reject` |
|
||||
| 托管 SSH OpenCode | Agent 按已接受 Prompt 的 Ask/Execute 模式处理 ACP 权限,规则见远程主机技术设计 | 当前 Agent 插件转交原生父、子孙会话 `question.asked`;前台使用同一问答队列,回答和跳过经认证 Agent 控制通道回传,已知 Task 关联显示子任务归属 |
|
||||
| Continue | Execute 启动传入 `--auto`,剩余原生权限请求按当前模式自动答复 | 对接 `1.5.47` QuizService 的扁平 `pendingQuestion`,其中 `question` 是字符串。选项回答与自定义文本分别回传正确的 `isCustomAnswer`;跳过发送拒绝回答的文本 |
|
||||
| DeepSeek Harness | 固定 Host 的 Execute 文件与命令工具不询问目录权限;若收到 ACP 权限请求,活跃 Execute 直接选一次性允许,缺少该选项时使用 Runtime 提供的允许选项,Ask 拒绝,不等待额外 authorizer | 固定 Host 当前未提供通用原生业务问答服务,不把普通模型正文误识别成待答协议。模型以普通回复提问时,用户通过下一条消息回答 |
|
||||
|
||||
托管 SSH OpenCode 的权限、原生问题事件与回复链路由
|
||||
[远程主机技术设计](../remote-host/technical-design.md)定义。该远端业务问答基线需要
|
||||
Desktop `0.13.0`、Agent `0.11.24` 和新启动的托管 Runtime;请先升级 Desktop,再更新
|
||||
Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包含此前问答修复,
|
||||
但不包含本文末尾尚未发布的进程复用改动。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答接口。
|
||||
|
||||
## 运行与失败
|
||||
|
||||
- 结构化 yes/no 是普通选项,不从日志、自然语言或 Shell 输出猜测用户意图。
|
||||
- 前台同一请求的待答问题按公开 `questionId` 去重,按到达顺序保存在消息的
|
||||
`pendingQuestions` 中。每次显示队首表单;有多个待答请求时显示数量。后续提问和重复
|
||||
事件不替换当前表单或清空输入,回答或跳过成功后只移除对应 ID,再显示下一张表单。
|
||||
提交失败保留队列、输入和重试入口,队列未清空时继续显示“等待你的回答”。
|
||||
- 本机与托管 SSH OpenCode 在提问到达前已收到原生 Task 的 `metadata.sessionId` 时,将精确匹配的
|
||||
子会话关联为可选 `childTaskId`。问答卡片显示该任务名称;对应 Task 卡片显示
|
||||
“等待你的回答”,展开后可点击“前往问答表单”滚动并聚焦当前队首表单,仍按顺序回答。
|
||||
没有已知关联的子孙会话问题照常排队,不猜测子任务归属,也不从工具名或进度文本构造表单。
|
||||
- 回答或跳过成功后,会话保留只读的“问题与回答”,显示原问题及对应的选项答案、
|
||||
自定义文本或“已跳过”。同一任务的多轮问答逐次保留,随本地会话保存,重新打开后仍可查看。
|
||||
提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。
|
||||
- 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时
|
||||
不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。
|
||||
- 待答队列仅保存在当前桌面会话内存中,不写入持久会话快照。刷新仍在运行的消息时保留整个
|
||||
本地队列;完成、失败、取消事件或持久化终态快照清除整个队列。迟到的提交结果可以保存
|
||||
已回答记录,但不会恢复表单或覆盖终态。本机 Runtime 不恢复重启前未提交的问题;托管 SSH
|
||||
重新附加同一存活 Agent Prompt 时,由 Agent 当前待答列表补发问题,已回答的历史事件不补发。
|
||||
- 未发布的进程复用源码在 Desktop 重启后由 Main 并行附加同项目的存活任务,将待答
|
||||
列表和 Stop 所需的请求 ID 投影到实时 `activeRequest`,不保存该字段或重新发送任务。
|
||||
回答/跳过成功由 Main 写回原远程助理消息的既有问答历史,Renderer 不覆盖远程正文或终态。
|
||||
Runtime 设置、定制和能力更新让旧请求在原配置上结束,已有问题仍可回答;新请求使用
|
||||
新配置。专家团队例外:既有专家调度器在设置替换时取消子任务,团队父请求也随之停止,
|
||||
不使用替换后的模型汇总旧结果。详细实现见
|
||||
[恢复与终态投影](./runtime-process-reuse-technical-design.md#63-恢复与终态投影)。
|
||||
- 后台任务收到结构化提问时,通过现有 Main 路径明确失败并提示改为前台对话,不永久等待。
|
||||
- 请求结束或取消后清除对应待答映射;过期回答明确报错,不转交给后来的请求。
|
||||
- 任意终端程序的密码、OAuth 登录、许可证确认或 stdin 提示不自动回复 `yes`。这类程序应
|
||||
使用其非交互参数,或由用户在终端完成;不能把权限确认和业务决策混为一谈。Shell
|
||||
工具保留其现有取消与超时,不新增日志解析或通用交互代理。
|
||||
|
||||
## 托管 Runtime 的并行与输出
|
||||
|
||||
子任务完整过程的显示与数据库差量存储相互独立,本机和托管 SSH 共用桌面存储层;
|
||||
旧库升级与空间回收规则见[执行记录存储与升级回收](./execution-history-storage.md)。
|
||||
|
||||
- 本机和 Agent 管理的 OpenCode 启动配置都显式设置 `snapshot: false`。GoodBuddy 不消费
|
||||
原生自动 Git 快照、快照差异摘要或快照撤销;关闭这条内部路径,避免大行级差异的同步
|
||||
计算阻塞同一原生进程中的其他会话。
|
||||
- 文件工具、Shell、原生子代理、工作区 Git 状态和单文件差异保持可用;不新增全局会话
|
||||
队列、请求重放或总执行截止时间。外部 OpenCode Server 的配置不由 GoodBuddy 改写,
|
||||
已运行的旧原生进程也不会热更新配置。
|
||||
- Continue 使用 HTTP 传递响应和状态;Electron UtilityProcess 启动后持续排空不用展示的
|
||||
stdout,避免控制台输出填满管道后阻塞宿主。stderr 的有界错误处理、请求取消与进程
|
||||
回收保持不变,不把诊断输出复制进聊天或持久日志。
|
||||
- 远端最终启动配置由[远程主机技术设计](../remote-host/technical-design.md)定义,
|
||||
模型桥生成最终配置时同样保留上述快照策略。
|
||||
|
||||
## 工具操作摘要
|
||||
|
||||
工具记录沿用 [UI 设计系统](../../../UI-DESIGN.md)的折叠布局。Continue、DeepSeek Harness
|
||||
和直连模型共用 `toolOperationSummary`:优先使用有效原生标题,否则从参数提取命令、
|
||||
文件路径、搜索条件或 URL,合并空白后最多显示 240 字符。无法识别时保留原有工具名
|
||||
回退规则;文件正文、整份参数 JSON 不进入标题,参数详情仍可展开查看。
|
||||
|
||||
Continue 在宿主截断输入前提取摘要,流式完成与失败事件使用合并后的输入和摘要。
|
||||
DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省略输入时仍能显示操作内容。
|
||||
直连模型从原始参数生成一次摘要,在待执行、执行中和完成时复用;审批失败、拒绝和
|
||||
执行失败、刷新重试仍保留已有提示。
|
||||
|
||||
本机 OpenCode 沿用原生操作标题。远端 ACP 独立使用原生标题作为记录名称,不经过上述
|
||||
本地转换,本次没有修改 Agent、远程桥或协议。
|
||||
|
||||
## 消息底部请求状态
|
||||
|
||||
- 状态属于当前请求,不与顶部 Runtime 的连接中、就绪、不可用混为一谈。提交后先显示
|
||||
“正在准备请求”;没有更具体的活动事件时显示“等待后续进展”,不声称模型正在思考。
|
||||
- 本机 OpenCode 只把当前父会话的 `session.status.retry` 映射为等待重试,显示原生
|
||||
`attempt`、计划重试时间和有界脱敏原因;后续 `busy` 才显示已开始重试。其他会话的
|
||||
状态不串入当前消息,不猜测总重试次数,不把 SDK 事件流重连当作模型重试。
|
||||
- 显示优先级为待回答、待确认、进行中的上下文压缩、明确的请求状态、待执行或执行中的
|
||||
工具、未完成的子任务、等待后续进展。压缩沿用已有活动块,不重复增加底部提示。
|
||||
正文、推理或工具进展会清除旧状态;子任务更新不覆盖父请求的重试提示。
|
||||
- 完成、失败、取消后保留真实终态,迟到的状态事件不能把请求恢复成处理中。底部状态点
|
||||
为静态,不凭请求尚未结束暗示仍有进展;会话列表闪点和发送、停止按钮保持原有行为。
|
||||
- Continue、DeepSeek Harness 和远端 ACP 保留其已接入的处理与工具事件,不伪造未接入
|
||||
的原生重试状态。直连模型的重试等待与请求开始文案见
|
||||
[直连模型 UI 设计](../direct-model-agent/ui-design.md#6-错误与通知);本次不改变任何
|
||||
Runtime 的重试上限、请求重放或取消策略。
|
||||
|
||||
实现与真实 Runtime 验证证据见[进度记录](./progress.md)。
|
||||
|
||||
## Runtime 执行清单
|
||||
|
||||
本节为未实现的状态设计,对应 [FR-CL1–FR-CL4、US-CL1–US-CL5](./prd.md#131-runtime-执行清单)。
|
||||
原始格式和传递边界由[技术设计](./runtime-checklist-technical-design.md)维护。
|
||||
|
||||
清单属于一次请求及其助手消息。沿用项目、执行空间、Conversation、Runtime 选择和
|
||||
原生 Session 到请求的既有映射;远端还需核对 binding/operation。不能仅以原生 Session
|
||||
或 Conversation 为键更新顶部,因为同一会话会连续执行多轮请求。
|
||||
|
||||
| 输入或操作 | 清单与顶部行为 |
|
||||
| --- | --- |
|
||||
| 本轮尚无有效清单 | 隐藏顶部清单,不复制上一轮内容 |
|
||||
| 收到本轮有效完整清单 | 按原生顺序整体替换,包括修改、删除、重排和重复文本;不按条目文本合并 |
|
||||
| 收到有效空数组 | 记录明确清空并隐藏;空数组与缺少清单字段不同 |
|
||||
| CN 调用进行中 | 候选只按该请求的调用 ID 暂存;成功后才提交完整替换 |
|
||||
| CN 调用失败、取消或格式无效 | 丢弃该候选,保留上次有效清单,工具记录保留实际结果 |
|
||||
| 请求完成、失败、取消或中断 | 冻结最新已提交清单,并显示请求真实结果;不推断条目完成,迟到输入不改写已结算清单 |
|
||||
| 同一会话开始下一轮 | 顶部转向新请求,上一轮清单留在原消息;旧轮事件不得归入新轮 |
|
||||
| 切换会话、项目、执行空间或 Runtime | 只读取目标归属的数据;原请求在原归属结算,不把原清单标为新 Runtime 的进度 |
|
||||
| 查看历史或重连 | 从原消息及既有事件恢复;按原请求和远程来源顺序去重,不能用最新原生 Session 快照覆盖历史轮次 |
|
||||
|
||||
条目状态支持 `pending`、`in_progress`、`completed`、`cancelled`。OC 原生提供的 priority 保留;
|
||||
CN 未勾选项映射为 `pending`,已勾选项为 `completed`,不推断 `in_progress` 或 priority。
|
||||
OC 无稳定 item ID,视图索引仅服务本次完整列表渲染,不生成跨更新的条目身份。
|
||||
|
||||
清单内容不创建、调度或完成产品 Task,也不改变底部请求状态。清单更新与工具活动可以
|
||||
同时保留,但顶部计数只来自已确认的结构化清单,不能反向解析普通状态文本、任意
|
||||
Markdown 或名称含 `list` 的工具。子会话清单不覆盖父请求顶部。
|
||||
|
||||
以上产品状态规则已明确;远程 CN 接入、原生格式确认及历史字段选择仍属技术待办,见
|
||||
[进度记录](./progress.md#2026-09-15-runtime-执行清单设计未实现)。
|
||||
|
||||
## Runtime 资源生命周期
|
||||
|
||||
本节对应 [FR-RR1–FR-RR6、US-RR1–US-RR4](./prd.md#141-runtime-资源复用),
|
||||
定义进程复用的行为规则。当前源码已接入本机共享 owner 和 Agent 共享 ACP;
|
||||
完整验收状态以[进度记录](./progress.md#2026-09-14-runtime-进程复用实施中)为准,不表示已发布。
|
||||
具体 owner、兼容键和协议实现见[技术设计](./runtime-process-reuse-technical-design.md)。
|
||||
|
||||
资源有三个独立维度:进程是否启动/退役,会话是否仍持有原生上下文,请求是否在途。
|
||||
“任务完成”“当前没有选中该项目”“工作栏关闭”均不能直接推导进程可以销毁。
|
||||
|
||||
| 触发 | 请求与会话行为 | 进程行为 | 对应需求/场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| 被动状态读取 | 不取得新执行会话,已有状态可读取 | 不冷启动,不延长无会话 owner 的空闲期限;不为探测替换服务中的进程 | RR2 / US-RR1 |
|
||||
| 显式测试/原生清单 | 取得临时检查引用;清单带明确工作区 | 与执行共用启动 Promise 和 owner;结束只释放检查引用 | RR1、RR2 / US-RR1 |
|
||||
| 首次或并行 Prompt | 取得对应 execution space + conversation 的绑定 | 同兼容组复用,非兼容组分别启动 | RR1、RR3 / US-RR1、RR2 |
|
||||
| 普通请求完成 | 释放请求订阅、临时 token、问题和 callback,保留可续聊 Session | 不因为一个请求完成关闭整个进程 | RR3、RR4 / US-RR3 |
|
||||
| 取消一个请求 | 取消其模型/工具调用并结算,其他会话继续 | 不以 kill 共享进程代替正常会话取消 | RR3、RR4 / US-RR2 |
|
||||
| 关闭面板/切换项目 | 停止非必要 UI 推送;不取消后台任务或抹掉会话所有权 | 不触发隐式关闭 | RR4 / US-RR3 |
|
||||
| 显式释放/删除会话 | 释放该 Session 与会话工具资源;删除历史仍只由原有明确删除操作决定 | 其他引用仍在则保留 | RR4 / US-RR3 |
|
||||
| 无会话、无请求、无检查引用 | 不存在需要保留的原生上下文 | 进入短空闲等待,再关闭;新使用者到来取消等待 | RR4 / US-RR1、RR3 |
|
||||
| 配置替换 | 已接受请求使用旧配置结算,新请求使用新配置 | 旧 owner 排空后退役;不积累只被历史 ID 持有的旧配置进程 | RR1、RR4 / US-RR4 |
|
||||
| 进程故障/不可响应 | 只给该 owner 下受影响请求明确中断;未知结果不自动重放 | 有界终止失效 owner,不伪造逐会话故障隔离 | RR3、RR4 / US-RR2、RR4 |
|
||||
| SSH detach | 保留 Agent 已接受 Prompt 及既有恢复归属 | 关闭传输不关闭 Agent 或活动 Runtime | RR5 / US-RR4 |
|
||||
|
||||
表中 RRn 是 PRD 稳定需求 ID `FR-RRn` 的简写。
|
||||
|
||||
不变量:
|
||||
|
||||
1. 一条请求始终使用接受时的 execution space、工作模式、配置和归属;不能读取后来
|
||||
切换的 UI 选择,也不以进程 cwd 表示所有会话的 cwd。
|
||||
2. 同一会话串行,跨会话不增加全局 Prompt 队列。权限、子代理、问题、用量和最终清理
|
||||
必须沿同一 Session/请求归属,不因共享进程放宽 Ask 或限制 Execute。
|
||||
3. owner 的退出条件依据所有使用者,不依据某个 adapter 的空闲状态。
|
||||
未启动对象的 dispose 不产生冷启动;一个等待者取消不取消其他等待者的初始化。
|
||||
4. 正常会话仍持有原生上下文时,不用任意 TTL 清掉它来声称“空闲内存已回收”。
|
||||
无会话 owner 的短空闲等待为 60 秒;
|
||||
不增加用户配置项。它是进程引用回收,不是聊天历史保留期限。
|
||||
5. 持久历史不因请求结束、进程 idle、配置变化或测试 cleanup 被删除。
|
||||
原生进程确实重启后的续聊沿现有历史重建规则,不新增第二套持久会话快照。
|
||||
|
||||
已明确普通并行、取消、释放、配置替换及断线行为。各项实际通过的场景及尚未完成的
|
||||
组合/性能验证以[进度记录](./progress.md#2026-09-14-runtime-进程复用实施中)为准。
|
||||
@@ -0,0 +1,386 @@
|
||||
# Runtime 进程复用技术设计
|
||||
|
||||
## 1. 状态与结论
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已接入本机与 Agent 生产源码,Windows / Linux x64 验证结果及边界已记录,未发布 |
|
||||
| 日期 | 2026-09-15 |
|
||||
| 调研源码 | `de14a6157a6c34f1e3ef22619eca374a50a83706`;相关 Runtime 与 Desktop 0.13.2 一致 |
|
||||
| 需求 | [工作栏 PRD §14.1,FR-RR1–FR-RR6](./prd.md#141-runtime-资源复用) |
|
||||
| 行为规则 | [Runtime 资源生命周期](./runtime-interactions.md#runtime-资源生命周期) |
|
||||
| 验证事实 | [实施与验证记录](./progress.md#2026-09-14-runtime-进程复用实施中),原始调研记录保留在同页 |
|
||||
|
||||
**修复对象是进程所有权过细,不是工具太多,也不是历史记录应该删除。** 本机 OpenCode
|
||||
和 DeepSeek Harness 应在兼容启动配置内共享重型进程,工作区与会话上下文仍各自独立。
|
||||
远端 OpenCode 必须同时调整 Agent 进程所有权、ACP 连接和模型桥,不能把桌面修复当作远端
|
||||
修复。Continue 的锁定版本是单会话 Host,不做未经验证的常驻进程池;直连模型没有独立
|
||||
模型进程,应检查对象、历史和工具资源的生命周期。
|
||||
|
||||
“一个进程”指一组兼容启动配置对应一个 Runtime Server/Host,不是把不同 Runtime、
|
||||
不同 SSH 账号、不同插件组合强行塞入一个进程。正常工具命令产生的短期子进程不属于重复
|
||||
Server,也不能通过关闭 Shell、子代理或 MCP 来达成进程数指标。
|
||||
|
||||
## 2. 修复前证据与缺陷
|
||||
|
||||
### 2.1 已测现象和解释边界
|
||||
|
||||
Windows 已安装 0.13.2 的只读采样发现两个属于同一 App 的 OpenCode Server:一个原生
|
||||
数据库为 0 Session / 0 Message,约 527 MiB Private Commit;另一个已有会话并执行请求。
|
||||
两个 Server 的启动工作区不同。源码能解释“清单查询也可启动按工作区缓存的 Runtime”,
|
||||
但没有取得能独立证明空 Server 最初由哪一个 UI 操作启动的调用链记录。
|
||||
|
||||
该缺陷不等于整个 App 内存的唯一来源。五分钟活跃采样的 App 相关进程 Private Commit
|
||||
约 3.15–3.78 GiB,CPU 平均占整机约 3.15%;后续一分钟包含新任务,不是空闲回收试验。
|
||||
数据库五分钟增长约 2.72 MiB,新增子代理负载约 1.49 MiB,重复工具 upsert 为 0。
|
||||
schema 35 的存储修复与本方案独立,不能回退或删历史来改善指标。
|
||||
|
||||
同次源数据检查还发现最近 100 个会话的消息 metadata 合计约 107.75 MiB,
|
||||
`listConversations()` 会读入这些会话的全部消息详情。它是 Renderer 大对象加载的明确
|
||||
检查方向,不是堆快照已经证明的泄漏。进程复用不能代替历史按需加载的独立性能工作。
|
||||
|
||||
### 2.2 调研基线的源码归属
|
||||
|
||||
| 位置 | 已确认行为 | 修复含义 |
|
||||
| --- | --- | --- |
|
||||
| [SelectedRuntimeManager](../../../src/main/agent/selected-runtime-manager.ts) | 缓存键包含原始 selection 与 execution-space identity;默认 8 个缓存、16 个保留实例;只在容量压力下淘汰 | 轻量工作区适配器和重型进程不能继续按同一键、同一数量计费 |
|
||||
| [AgentRuntimeController](../../../src/main/agent/runtime-controller.ts) | `canRetire` 同时要求无请求、无 owned conversation;普通完成不移除会话所有权 | 不能把“已完成请求”和“已释放原生会话”混为一谈 |
|
||||
| [本机 OpenCode](../../../src/main/agent/opencode-runtime.ts) | 对象私有 Server/client/初始化 Promise;已有多会话映射;多个入口使用固定 `defaultWorkspace` | 仅删除 manager 的 workspace 键会把后续操作送进错误目录 |
|
||||
| [DeepSeek Runtime](../../../src/main/agent/deepseek-harness-runtime.ts) | 每对象一个 Utility Host;`getStatus()` 调用 `getState()` 启动 Host | manager 的临时状态实例会额外启动、探测并销毁 Host |
|
||||
| [Harness Host](../../../src/main/deepseek-harness-host.ts)、[控制面](../../../src/main/agent/goodbuddy-harness-control-plane.ts) | 根 Provider 有 cwd 默认值;控制面拒绝与 Host workspace 不同的 `newSession.cwd` | 限制主要在 GoodBuddy 创建入口,不是原生工具缺少会话 cwd |
|
||||
| [Continue Host Adapter](../../../src/main/agent/continue-host-adapter.ts) | 每次 run 启动临时 Host,最终 `/exit` 与进程树回收;准备结果已在 adapter 内缓存 | 不重复添加已有缓存;研究跨 adapter 重复准备,不直接共享活动 Host |
|
||||
| [直连模型](../../../src/main/agent/model-runtime.ts) | Main 内 HTTP;历史 Map 按 50 个会话限制,其他归属及工具资源依赖 release/dispose | 没有可合并的常驻模型子进程,按对象和资源检查 |
|
||||
| [远端 ACP](../../../src/main/agent/acp-remote-runtime.ts)、[Agent backend](../../../src/agent-daemon/runtime-acp-backend.ts) | 会话取得 binding/channel;backend 按 binding 拥有进程并拒绝跨 binding 复用 process identity | 必须拆开会话绑定与进程所有权 |
|
||||
| [Agent-owned Prompt](../../../src/agent-daemon/agent-owned-acp-prompt.ts)、[模型桥](../../../src/agent-daemon/model-bridge-helper.ts) | owner 一个 ACP connection/session/当前 operation;helper 固定模型和工作模式并串行 dispatch | 共享进程还要求会话路由、请求归属和取消隔离 |
|
||||
|
||||
原始 selection 的 `auto`、默认 profile 和显式 profile 可能解析为同一有效配置。当前
|
||||
已确认“先按原始 selection 建键、后解析配置”的顺序,但尚未证明全部 UI 调用会实际生成
|
||||
等价重复实例。实施时从 [runtime-selection](../../../src/main/agent/runtime-selection.ts)
|
||||
和调用者验证,不把可能性记为另一次生产复现。
|
||||
|
||||
### 2.3 锁定依赖与协议基础
|
||||
|
||||
以仓库 lock 和本机已安装代码为准,不用最新网页替代锁定版本:
|
||||
|
||||
| 依赖 | 基线 | 本次核验 |
|
||||
| --- | --- | --- |
|
||||
| OpenCode binary / SDK / plugin | 1.18.29 | HTTP API 接受 directory;ACP 接受多个 Session;`chat.headers` 有 sessionID |
|
||||
| ACP SDK | 0.25.1 | 一条连接关联多个 request ID,session notification/permission 带 sessionId |
|
||||
| DeepSeek Harness 包族 | 0.1.2-rc.1 | Agent factory/scoped context、Session header.cwd、真实文件与 Shell 工具 |
|
||||
| Cordis | 4.0.2 | Host 根组件与 Agent scope 的生命周期 |
|
||||
| Continue CLI | 1.5.47 | 实际受管补丁入口的单 `M.session`、QuizService 和权限状态 |
|
||||
|
||||
已通过的三个隔离探针:
|
||||
|
||||
1. **OpenCode HTTP**:调用当前生产 Server launcher,只有一次 spawn;同一个 SDK client
|
||||
在两个目录创建四个 Session,读取各自的同名文件;删除一个 Session 后其余三个仍可取回。
|
||||
没有调用 `Runtime.run`、模型或活动取消。
|
||||
2. **DeepSeek 工具**:当前 Host composition 和真实 Agent factory 在同一 Node 测试进程中
|
||||
创建两个不同 cwd 的原生 Agent;并行执行真正的 `read`、`write` 和 PowerShell,
|
||||
同名相对路径各自命中正确目录。另确认生产控制面仍拒绝第二工作区。没有启动 Electron
|
||||
Utility、ACP Prompt 或模型流,不能据此声称生产多工作区已经可用。
|
||||
3. **原生 ACP 与模型归属**:Windows 上一个 1.18.29 `opencode acp` 进程、一条 ACP
|
||||
connection、两个工作区 Session;分别选模型并发出并行 Prompt。本机确定性 HTTP
|
||||
服务收到两次请求,原生 `chat.headers` 插件传来的 sessionID 分别匹配两个 Session。
|
||||
不经过已发布 Agent backend/helper,没有测试真实工具、子代理、Linux 或断线恢复。
|
||||
|
||||
三个探针真实模型调用均为 **0**,其中第三个有 **2 次本机合成提供方请求**。
|
||||
它们证明方案有原生基础,不替代后文的真实开发任务验收。
|
||||
|
||||
特别纠正:`dsh-tool-fs` 将 `exec.agent.session.header.cwd` 传给 `fs.resolve`;
|
||||
`dsh-tool-bash` / `dsh-tool-pwsh` 也从 Session cwd 补全相对 workdir。
|
||||
`dsh-fs-local` / 本地 Shell 的根 cwd 是非会话调用的默认值,不是工作区隔离边界。
|
||||
不需要为这个已具备的能力新建一套“每工作区 Filesystem/Shell Provider”。
|
||||
|
||||
官方背景资料:[OpenCode Server](https://opencode.ai/docs/server/)、
|
||||
[DeepSeek Harness](https://www.deepseek.com/harness/en/)、
|
||||
[Continue CLI](https://docs.continue.dev/cli/quickstart)。
|
||||
网页只用于核对产品入口;上述版本结论来自本地锁定代码和探针。
|
||||
|
||||
## 3. 选定的结构
|
||||
|
||||
### 3.1 两层对象,不建立通用进程池框架
|
||||
|
||||
```text
|
||||
SelectedRuntimeManager
|
||||
-> 轻量 Runtime adapter(有效 selection + execution space)
|
||||
-> OpenCode process owner / Harness Host owner(兼容启动配置)
|
||||
-> Session binding(execution space + conversationId)
|
||||
-> 当前 requestId / 原生 sessionId / 工作目录
|
||||
```
|
||||
|
||||
- 保留 Main 已解析、校验的 execution space;adapter 每次将其显式交给共享 owner。
|
||||
目录不是 Renderer 新增的任意启动参数,也不依赖 `process.chdir` 或进程级可变 cwd。
|
||||
- owner 持有唯一启动 Promise、client/connection、子进程、会话映射与关闭逻辑。两个
|
||||
工作区同时冷启动时复用同一 Promise;一个调用者取消等待不取消其他使用者的启动。
|
||||
- adapter 的 `dispose` 只释放其引用,不直接关闭同组进程。会话映射由 owner 持有,避免
|
||||
adapter 缓存淘汰丢掉原生续接。会话操作通过 binding 查目录,不从“当前选中项目”反查。
|
||||
- 设置重建沿用现有 reset/drain:新请求取得新配置,旧请求持有启动时的配置直到结算。
|
||||
旧配置不再有在途使用者后释放旧绑定和进程,不能被历史会话 ID 永久钉住。
|
||||
IPC 的 Runtime 设置、定制及能力更新不再主动取消全部请求或清空审批;controller
|
||||
退役仍允许回答其已拥有的问题,但拒绝新请求和未知问题。
|
||||
既有专家调度器仍在配置替换时取消专家子任务,团队父请求一起停止,防止用新模型
|
||||
汇总旧 generation 的部分结果;不为该单独生命周期新建通用退役框架。
|
||||
- 8/16 的旧上限不应继续限制轻量项目 adapter 数量。若保留重型 owner 的容量保护,
|
||||
应统计实际进程配置组及退役进程;不靠提高常数掩盖“一项目一进程”。
|
||||
- 只用进程内 Map、现有 controller、Promise 和普通定时器。会话历史仍以现有 SQLite /
|
||||
Agent transcript 为准,不增加数据库表、迁移、持久化池快照、恢复日志或新配额。
|
||||
|
||||
### 3.2 兼容键与上下文
|
||||
|
||||
先通过 `applyRuntimeSelection` 等现有解析取得有效配置,再生成进程键;公开的 selection
|
||||
仍按现有语义保存,不能把“跟随默认”永久改写成某个显式 profile。
|
||||
|
||||
| 范围 | 参与进程兼容性 | 留在会话/请求 |
|
||||
| --- | --- | --- |
|
||||
| 本机 OpenCode | 本机账号、二进制身份、有效模型连接/配置、启动环境、原生配置和插件组合 | 目录、conversation/session、Prompt、模式、当前权限和问答、请求级 MCP/知识 token |
|
||||
| 本机 Harness | Host/依赖版本、有效模型连接、图片能力、凭据引用、启动环境、启用插件及配置、启动 Skill 集合 | cwd、conversation/session、请求模式、Main Web/MCP 代理与用量归属 |
|
||||
| SSH OpenCode | 已有 Host/SSH 身份、Agent/Runtime installation identity、模型协议/连接、原生插件与启动配置 | Workspace、binding/session、operation、请求凭据和账本归属 |
|
||||
|
||||
第一轮只合并同一有效模型连接及等价默认选择,不顺便支持任意不同模型连接共享:
|
||||
本机 OpenCode 配置使用固定 provider ID,Harness 用量也读取 Host config,跨 profile
|
||||
混用会引入另一项真实风险。不同 profile ID 不通过比较或序列化明文 API Key 来合并;
|
||||
密钥、Header 和插件配置更新通过现有设置失效路径使旧 owner 退役。
|
||||
|
||||
调研基线的 SSH launch profile 把 workMode 写入原生启动配置。当前实现把工作模式
|
||||
放入 Session/当前 Prompt 路由,并设置原生 Session 工具规则,详见 §6.1;
|
||||
不能仅从兼容键中删除 workMode,也不向用户增加逐工具审批。
|
||||
|
||||
## 4. 本机 OpenCode
|
||||
|
||||
在 [opencode-runtime.ts](../../../src/main/agent/opencode-runtime.ts) 中抽出重型 owner,
|
||||
由 [create-runtime](../../../src/main/agent/create-runtime.ts) 的 Main composition 复用。
|
||||
保留工作区 adapter,逐项替换隐式 `options.defaultWorkspace`:
|
||||
|
||||
1. create/get/delete Session、Prompt、历史初始化和压缩。
|
||||
2. 原生能力、工具、文件、Skill、MCP 查询的 directory。
|
||||
3. SSE 订阅、父子 Session 归属、待答 question 的 directory 与原生 ID。
|
||||
4. 权限更新、abort、问题答复/拒绝、MCP 临时注册及最终清理。
|
||||
|
||||
会话绑定记录规范化绝对目录及原生 sessionId。同一 conversation 的请求继续串行,
|
||||
不同 conversation 并行,不引入覆盖整个 Server 的 Prompt 队列。
|
||||
现有 `snapshot: false` 必须保留。
|
||||
|
||||
`mcpMutationTail` 目前属于 Runtime 对象;共享后同一原生目录的变更必须由同一 owner
|
||||
串行协调。注册名称、工具可见性、knowledge token 和清理仍按请求区分,不能用一个全局
|
||||
“当前 MCP 列表”覆盖同目录或跨目录的其他请求。探针未覆盖动态 MCP,这是实施验收项。
|
||||
|
||||
第一轮保留现有按请求消费事件流和结束清理的方式,扩展目录及 Session 过滤即可。
|
||||
只有测出重复 SSE 消费是瓶颈时才另做 owner 级事件分发,不同时引入第二套事件总线。
|
||||
健康检查也读取 owner 的总 activeRequests,不能从一个空闲 adapter 判断整个 Server
|
||||
无任务后重启。异常退出使该 owner 的全部绑定失效,明确结算受影响请求,不重放已执行工具。
|
||||
|
||||
## 5. DeepSeek Harness
|
||||
|
||||
### 5.1 共享 Host 的最小改法
|
||||
|
||||
- 将 `HarnessState`、Utility 启动 Promise、ACP connection 和事件路由提升到兼容配置
|
||||
owner;Runtime adapter 保留请求工作区。
|
||||
- 每个新 Session 在 Main 解析 `realpath`,Host 校验绝对路径格式,
|
||||
不再要求它等于 Host 启动工作区;`meta.cwd` 传入已有 Agent factory。
|
||||
不依赖 Windows 大小写字符串猜测,也不取消路径存在性校验。
|
||||
- 保留一个本地 Filesystem、Subprocess 和平台 Shell Provider。Session 使用原生 cwd
|
||||
路由;非 Session 的清单查询显式传入查询工作区,不能回退到第一个项目。
|
||||
- 保留 `setup(agentCtx)` 注册 Skill 和工具视图、每 Session 的 Ask definition 身份校验、
|
||||
`sessionId + requestId` 准备及事件路由。Main 的 Web/MCP callback 按对应请求找
|
||||
ToolProvider,不挂到某个最先创建的 adapter 上。
|
||||
- 第一轮保持同 profile、图片能力和插件组合共享,`queueUsage` 的模型配置因此仍一致。
|
||||
给用量加跨会话断言,不借本次改造扩大跨模型 Host 能力。
|
||||
|
||||
第三方插件的初始化可能依赖配置中的路径,不能因为内置工具支持 cwd 就声称所有插件
|
||||
天然多工作区安全。已启用插件组合属于进程配置;对实际产品支持的插件验证它使用
|
||||
Agent scope/Session cwd。若某插件确实有项目级初始化状态,将该状态显式作为不兼容
|
||||
启动配置,而不是默认给所有工作区各起一套 Provider 或禁止 Execute 工具。
|
||||
|
||||
### 5.2 状态与取消
|
||||
|
||||
被动 `getStatus` 改为已有 owner 快照或本地配置/Host 资源检查,不调用 `getState()`。
|
||||
没有启动过时不能写“握手已验证”,可表示资源已安装、尚未连接。
|
||||
状态检查不取得执行引用,也不清除或重置无会话 owner 的空闲计时;owner 已回收时,
|
||||
临时配置检查对象用完即释放,不将其登记成新的执行 owner。
|
||||
用户显式连接测试与原生清单可启动共享 owner;已有 owner 时只做有界控制检查,最终
|
||||
释放的是检查引用,不能在 `finally` dispose 掉仍服务其他会话的 Host。
|
||||
|
||||
当前取消超时会 terminate 整个 Utility。共享后先取消并释放目标 Session 的 Agent /
|
||||
工具资源,不因一个请求结算较慢就杀掉仍健康服务其他 Session 的 Host。若真实控制面
|
||||
已失去响应,保留整个 Host 的有界终止,向受影响会话分别报告中断;不得把它说成
|
||||
“取消完全不影响其他任务”。同步插件卡死的进程级故障无法在同一 JS 进程中强行隔离,
|
||||
不为此新增通用恢复框架。普通长工具取消与整 Host 故障必须分开测试。
|
||||
|
||||
## 6. SSH OpenCode 与 Agent
|
||||
|
||||
本节定义当前源码的共享进程改造;当前已发布版本仍见
|
||||
[远程技术设计](../remote-host/technical-design.md)。不能删除或绕过其持久 binding、
|
||||
operation、transcript、ACK 和模型 dispatch 结果不确定性规则。
|
||||
|
||||
### 6.1 连接与所有权
|
||||
|
||||
将 `RuntimeAcpBackend` 的进程 owner 与 binding 分开:
|
||||
|
||||
```text
|
||||
Agent 当前 Runtime installation + 兼容配置
|
||||
-> 一个 process owner / 一条 ACP ClientSideConnection
|
||||
-> sessionId -> binding -> 当前 operation / 原始 Prompt Promise
|
||||
```
|
||||
|
||||
- 一条 stdio 流只交给一套 ACP decoder/connection,不能让几个 ClientSideConnection
|
||||
竞争读取同一 stdout。消息和 permission 按 sessionId 分派给 binding 的当前 operation。
|
||||
- `AgentAcpConnection` 统一连接级握手、stdio 解码与退出监听,Prompt
|
||||
生命周期仍留在 binding。同一 Session 串行,其他 Session 不等待它完成。
|
||||
- `closeAcpChannel`、取消和 release 只释放所属 binding/Session;最后一个 binding
|
||||
离开后空闲 60 秒关闭进程。关闭 Session 时等待原生 `closeSession` 和原始 Prompt
|
||||
结算,不等待排在 backend 控制队列后面的完成回调。只有原生控制面不响应时才有界
|
||||
终止共享进程,并向其余请求报告中断,不声称这类故障可以逐 Session 隔离。
|
||||
- terminal 与物理进程状态分开:取消/失败的 Session 也可返回
|
||||
`processTree: running`,表示进程继续服务其他 Session,不能伪报进程树为空。
|
||||
Desktop 与 Agent 须使用配套的新契约;原始字节透传 binding 仍保留独占进程规则。
|
||||
- 共享 stdio owner 不使用单 Prompt 的累积输入和墙钟期限管理整进程;单次写入、
|
||||
待写队列、输出背压继续有界,Prompt 输入与截止时间由 binding 管理。
|
||||
- SSH attach 仍不拥有活动 Prompt,断线不能取消或重发 Agent 已接受的工作。
|
||||
历史 binding 的语义和原生 sessionId 保留;内存进程索引从当前安装和已有绑定得出,
|
||||
不增加第二份持久进程恢复账本。
|
||||
- 同一 PID 的多个 binding 不代表同一权限或同一 operation。Host/installation identity
|
||||
变化仍使用已有失效和退役规则,不把不同 Host、账号或 Runtime generation 合并。
|
||||
|
||||
原生 `chat.message` 在模型请求前设置该 Session 的工具规则:Execute 允许工具;
|
||||
Ask 只开放原生只读、搜索和读取网络内容的工具,不开放 Shell/编辑/写入。
|
||||
OpenCode 1.18.29 的 ACP 不转发未注册子 Session 的权限请求,且原生子代理只继承父
|
||||
Session 的拒绝规则、不继承允许规则,因此子 Session 也按根 Prompt 设置规则,再
|
||||
保留原生子代理的显式限制。不能只把进程启动配置统一设成 `ask`,否则子代理工具会等待。
|
||||
|
||||
### 6.2 模型桥归属
|
||||
|
||||
选用锁定 OpenCode 已有的 **`chat.headers` 会话标记**,扩展当前受管
|
||||
[原生插件](../../../src/agent-daemon/opencode-subagent-plugin.ts),不靠解析正文、猜测
|
||||
“当前最新 Prompt”或重写用户模型名来路由:
|
||||
|
||||
1. 现有 Agent prepare/start 顺序在 helper 的进程内登记 Session/根 Session 与
|
||||
binding、operation;插件从原生 sessionID 及已建立的父子关系取得精确归属。
|
||||
2. 在请求生成时固定所属 operation,将有界内部归属 Header 加到 loopback 模型请求。
|
||||
归属必须覆盖原生子代理、自动/手动压缩,不能等 HTTP 到达后才绑定“目前活动的请求”。
|
||||
3. helper 检查它是否仍属于已登记的 Prompt,选择对应现有 Agent gateway/凭据上下文。
|
||||
转发真实提供方前剥离内部 Header;不将其当作密钥或新增认证体系。
|
||||
4. 当前 helper 的单 socket/全局 dispatchBusy 改成按 operation 隔离的派发状态;
|
||||
每个 operation 的现有轮次、顺序、字节和结果不确定性规则不变,取消 A 只关闭 A 的
|
||||
提供方流,不关闭 B 的 socket、response 或待处理请求。
|
||||
5. Prompt 终态撤销对应映射及凭据;迟到请求不能借用下一轮 operation。保留
|
||||
`snapshot: false`、关闭不消费的 title 请求和现有父子问题转交。
|
||||
|
||||
普通 ACP 双 Session 的 Header 路由已有探针证据;子代理、压缩和终态竞态的实际
|
||||
验证结果以进度记录为准。若锁定版本某条实际调用路径不触发 hook,先定位那条原生路径,
|
||||
不得悄悄用全局串行 Prompt 或一个共享凭据槽冒充并行复用。
|
||||
内部桥字段如需调整,按既有受管 Agent/Runtime 版本配套交付,不移动发布标签,不给
|
||||
旧 Agent 宣称具备新行为。
|
||||
|
||||
### 6.3 恢复与终态投影
|
||||
|
||||
- Agent-owned 语义 Prompt 不一定产生原始 ACP journal 行。binary replay 仍校验现有
|
||||
cursor 边界,但不存在 raw cursor 时不向 journal ACK 0;不虚建记录来满足 replay。
|
||||
- 同一 Agent 断线恢复后,保留的 Session binding 使用新 transport generation,
|
||||
Runtime 可以同时接纳新 Session。安装身份已经更换的 attach-only 请求明确失败,
|
||||
不能无限重试阻塞项目,也不能借此重发旧 Prompt。
|
||||
- Desktop 重启时,同项目的可恢复任务并行附加,不能等待一个待答 Prompt 结束才附加
|
||||
第二个。live `question`/`question-resolved` 不属于有 provenance 的 transcript,
|
||||
使用既有 pending Map,清除时只影响对应 request/question。
|
||||
- `conversations.list` 将恢复租约的 request/message ID 和待答列表投影为可选
|
||||
`activeRequest`,Renderer 合并表单并使 Stop 指向真实请求。保存 header/消息时不保存
|
||||
这个实时字段。回答/跳过成功由 Main 按 task → assistant message 归属更新既有
|
||||
`answeredQuestions`,不写另一份问题日志,不覆盖流式正文、已完成或失败状态。
|
||||
- 明确确认的取消使用现有消息/任务终态事务一并结算,避免 Task cancelled 而消息仍
|
||||
streaming。仍无法确认的 Stop 保持可恢复;连接中断本身不是已取消或已失败的证据。
|
||||
|
||||
## 7. Continue、直连模型与相邻内存问题
|
||||
|
||||
### 7.1 Continue
|
||||
|
||||
保留每请求 Host 的隔离和最终清理。1.5.47 的单 Session、QuizService/权限队列意味着
|
||||
“一个 HTTP Server”不等于可安全承载多会话;这一例外有锁定源码依据。
|
||||
|
||||
`getPreparedHost()` 已缓存本 adapter 的准备 Promise,磁盘也有按内容确认的补丁产物。
|
||||
先分别测量读取/哈希/补丁准备、Utility 启动、HTTP ready、首个提供方请求和退出耗时。
|
||||
若确认跨 adapter 重复准备明显,再将只读准备结果与启动 Promise 按已验证的 CLI
|
||||
identity/补丁版本放到 Main composition 复用;失败仍允许重试。不得复用临时 config、
|
||||
token、global dir、活动 Session 或权限/问题队列。设置替换及窗口状态刷新也纳入测量。
|
||||
|
||||
不在本次给 Continue 加多 Session 补丁或常驻池。将来只有锁定版本真正支持多 Session
|
||||
且生产桥验证通过,才替换这一实现。
|
||||
|
||||
### 7.2 直连模型及历史详情
|
||||
|
||||
- 不新增模型 Utility 或进程池。检查 `conversations`、`knownConversationIds`、
|
||||
summary、ToolProvider 与 DirectModelProcessService 的实际引用与 release 调用链。
|
||||
- 活动请求、会话可续读的有界输出和数据库历史是不同资源。请求结束回收临时闭包/
|
||||
callback;会话释放才结束其可续读输出和工具资源,不在工具刚完成时删掉用户可查看结果。
|
||||
- 当前 50 个会话的 history 限制不是字节上限。先采堆与 retained size,再决定可从
|
||||
SQLite 重建的历史缓存是否使用字节约束和 LRU,不能先增加一套持久历史副本。
|
||||
- Renderer 最近 100 个会话整包 metadata 加载另做列表摘要、当前会话分页、展开后取
|
||||
详情的性能改造;本方案只记录依赖,不将这一尚未设计完成的 UI 改造算进进程修复交付。
|
||||
必须保持完整历史、搜索、重新打开和当前活动事件可用。
|
||||
- 旧 Runtime 磁盘缓存可单独评估版本清理策略,不在本次研究或进程 idle 时删除安装、
|
||||
用户工作区、数据库、备份或历史。
|
||||
|
||||
## 8. 内部实施顺序
|
||||
|
||||
| 阶段 | 具体工作 | 必须产出的证据 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 固化本次原生探针为聚焦回归;补有效 selection/进程计数和状态启动计数 | 修复前能复现重复启动,失败原因不依赖真实用户配置 |
|
||||
| P1 | Main composition、兼容键、owner 与 adapter 生命周期;本机 OpenCode 全入口目录化 | 真实 UI 两项目四会话同一 Server,文件/工具/问答/取消不串线 |
|
||||
| P2 | DeepSeek 共享 Utility、会话 cwd、被动状态、请求级代理及取消收敛 | 实际 Utility/ACP 两项目四会话,原生工具与插件验证,状态刷新无额外 Host |
|
||||
| P3 | Agent process/session owner、ACP 单连接路由、Header 模型桥、权限和终态清理 | 开发中的真实 Linux x64 Host,含多会话工具、断线/重连和结果不确定性 |
|
||||
| P4 | 全 Runtime 资源回归;Continue 冷启动分解与有证据的准备复用;直连对象释放检查 | 相同输入的前后进程/内存/CPU/磁盘/延迟报告;没有残留测试进程 |
|
||||
|
||||
这是依赖顺序,不是发布一半实现的许可。涉及的正常生产 UI 路径、远端专用修复和下节
|
||||
验收全部完成后,才能称为 Runtime 复用修复。Continue 测量若没有显示重复准备成本,
|
||||
明确记录不改;不要为凑齐阶段增加代码。历史 UI 分页是后续独立工作。
|
||||
|
||||
## 9. 验收与性能比较
|
||||
|
||||
### 9.1 必测场景
|
||||
|
||||
| 编号 | 对应需求 | 场景与通过条件 |
|
||||
| --- | --- | --- |
|
||||
| V1 | FR-RR1、FR-RR2 | 全新 profile 仅查看状态、切换设置,OpenCode/DSH 重型启动次数为 0;已有 Host 时刷新不新建;清单与首个真实请求并发只有一次初始化 |
|
||||
| V2 | FR-RR1、FR-RR3 | 同一有效模型连接,两个独立项目各两会话同时自然开发,OpenCode/DSH 各一重型进程;各项目同名不同内容文件、命令 cwd、Git/测试结果都正确 |
|
||||
| V3 | FR-RR3 | 第二轮复用同一批会话;Ask/Execute 混合,Ask 写入被边界拒绝,Execute 不增审批;Skill、MCP、图片能力、用量和业务问答不串归属 |
|
||||
| V4 | FR-RR3、FR-RR4 | 一会话取消真实长工具/子代理时另外三会话完成并通过独立项目测试;分别检查请求 abort、SSE、问题映射和工具句柄释放 |
|
||||
| V5 | FR-RR4 | 关闭面板不取消工作;释放一个会话不关闭共享进程;无会话 owner 空闲后退出;App 退出后本机受管进程无残留,远端按已有 detach 规则保留 Agent-owned 工作 |
|
||||
| V6 | FR-RR1、FR-RR4 | 等价默认/显式选择复用;不同实际连接/插件配置不串用;配置更新期间旧请求完成,旧 generation 不永久积累;超过八个项目不因轻量 adapter 报重型容量满 |
|
||||
| V7 | FR-RR5 | 当前源码 Desktop → SSH → 当前开发 Agent → 原生 Runtime,两个项目/四会话;断线、重连、取消、重复 ACK/事件、迟到模型请求不重复调用或跨 operation 归账 |
|
||||
| V8 | FR-RR6 | Continue 并发请求仍各自正确清理;直连 HTTP 不生出额外模型进程;会话 release 后工具/分页输出资源释放;性能采样不靠删除历史或禁用工具达标 |
|
||||
|
||||
V2/V4 必须是正常开发任务,模型可自然调用读写、Shell、测试和已支持的原生子代理;
|
||||
不是要求模型“不用工具”后回复一行。验证产出应用本身可运行、独立测试通过,模型口头
|
||||
说完成不算完成。每个 Runtime 只验证它已经支持的能力,不伪造 DSH/Continue 子代理。
|
||||
|
||||
### 9.2 采样口径
|
||||
|
||||
- 使用同一源码变更前后、同一机器、隔离 profile 与等规模历史副本;不改写用户运行库。
|
||||
覆盖至少三次冷启动、五次热请求,以及三轮多项目任务和至少十分钟**确无新任务**的
|
||||
空闲观察。分别记录中位数、范围与实际样本数,不从一次采样推断泄漏已消失。
|
||||
- 记录 Main、Renderer、Runtime 和工具子进程的 Private Commit、工作集/RSS、堆、
|
||||
CPU、PID/启动次数、会话数、活动请求、SSE/ACP 订阅与残留句柄。Windows 工作集求和
|
||||
会重复计算共享页,Private Commit 也不等于独占物理 RAM。
|
||||
- 同时报首轮准备、HTTP/ACP ready、首个模型请求、首次可见输出和总耗时。降低进程数
|
||||
但把不同会话全局串行化,不能通过验收。
|
||||
- 数据库/WAL 字节与新增事件负载分开;进程 I/O 包含管道,不能称为物理磁盘读写。
|
||||
继续检查未变化工具 upsert 为 0,旧历史读取/搜索/展开正常。
|
||||
- 核心硬指标是每个兼容组一重型进程、无额外被动启动、无跨会话错误、无结束后持续
|
||||
增加的活动句柄、无重复提供方 dispatch。没有证据前不承诺“固定省 527 MiB”或
|
||||
“整个 App 降到某个 RAM 上限”;原生 Session 仍保留时必须单独说明其常驻成本。
|
||||
|
||||
### 9.3 源码与平台验证
|
||||
|
||||
定向覆盖 manager/controller、各 Runtime lifecycle、Harness Host/control plane、
|
||||
ACP backend/owner、模型桥与插件,随后执行仓库要求的 `npm test`、`npm run typecheck`、
|
||||
`npm run lint`;生产构建改动在开发阶段按仓库规则构建验证。
|
||||
|
||||
凡改变 Agent 或桌面到 Agent 路径,必须在开发期间按
|
||||
[真实 Host 验证规则](../remote-host/technical-design.md#agent-开发期间的真实-host-验证)
|
||||
使用当前源码的共享 Linux x64 Host 验证。Windows 原生 ACP 探针、旧版本 Host、mock、
|
||||
CI 和将来发布测试都不能替代它。Windows PowerShell 与 Linux Bash 的工作目录及取消
|
||||
分别验证;macOS/Linux arm64 生命周期与原生依赖由可用原生平台和 CI 补齐并明确报告。
|
||||
|
||||
当前源码的真实 Linux Host、完整生产 UI 和最终整仓门禁结果均保留在进度记录;
|
||||
各次失败、修正、模型调用数及未测平台也一并记录,不以本机单测或原生协议探针
|
||||
替代完整产品验收。完整 App 性能样本保留用户要求的按钮位置调整,属于包含该 UI
|
||||
差异的实测对照,不是纯 Runtime 单变量实验。
|
||||
@@ -0,0 +1,658 @@
|
||||
# 助手工作栏多终端页签 PRD
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已实施,跨平台验证中 |
|
||||
| 版本 | 1.2 |
|
||||
| 日期 | 2026-09-10 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 界面归属 | [通用助手工作栏与执行空间](./prd.md) |
|
||||
| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) |
|
||||
| 远程架构 | [SSH 远程主机与 GoodBuddy Agent 实现说明](../remote-host/technical-design.md)、[SSH Host 远程环境准备与控制面直连设计](../remote-host/environment-provisioning-technical-design.md) |
|
||||
|
||||
## 1. 文档职责
|
||||
|
||||
本文细化助手工作栏中的以下行为:
|
||||
|
||||
- 已打开应用以同级 Tab 呈现。
|
||||
- Tab 行始终提供“+”入口,用户按需打开应用。
|
||||
- 应用通过统一注册表分别声明单实例或多实例、默认打开、可关闭和排序策略。
|
||||
- “终端”作为首个多实例应用,自动使用当前项目的本机或托管 SSH 执行空间。
|
||||
- 本机与远程交互式终端的生命周期、状态、性能和验收要求。
|
||||
|
||||
[通用助手工作栏与执行空间 PRD](./prd.md)
|
||||
继续负责工作栏的应用级位置、稳定能力目录、执行空间、范围绑定和总体产品边界。本文不重新
|
||||
定义 Host Key、凭据、Agent 安装、Ask/Execute Runtime 或项目持久化契约。
|
||||
|
||||
## 2. 背景与当前问题
|
||||
|
||||
当前工作栏在聊天页面右侧固定显示“任务中心、工作区、浏览器、成果”四个 Tab。用户不能:
|
||||
|
||||
- 通过统一入口按需打开新的工作栏应用。
|
||||
- 按应用策略关闭并重新打开可关闭的工作栏应用。
|
||||
- 为支持多实例的应用创建第二个独立实例。
|
||||
- 在 GoodBuddy 内使用本机或远程 Host 的交互式 Shell。
|
||||
|
||||
当前代码已经具备工作栏右侧回流、宽度调整、Tab 键盘导航、SSH Host Key 固定、加密凭据和
|
||||
SSH 连接池,但尚不存在工作栏应用实例模型、终端 IPC、终端 Session Manager 或终端模拟器。
|
||||
`node-pty` 当前只作为其他 Runtime 的间接依赖被打包,不能视为已经存在用户终端能力。
|
||||
|
||||
## 3. 产品定义
|
||||
|
||||
### 3.1 工作栏应用
|
||||
|
||||
工作栏应用是可以在助手工作栏中打开的一个能力,例如任务中心、工作区、浏览器、成果或
|
||||
终端。每项应用通过统一注册表声明:
|
||||
|
||||
- 稳定应用 ID。
|
||||
- 用户可见名称、图标和说明。
|
||||
- `single` 或 `multiple` 实例策略。
|
||||
- 是否默认打开、是否必须存在、是否可关闭和是否允许排序;这些属性彼此独立。
|
||||
- 创建实例时可用的默认上下文。
|
||||
- 当前不可用时的原因和修复入口。
|
||||
|
||||
目录中的应用可发现性与实例是否已经打开相互独立。应用不可用时保留入口并说明原因,不以
|
||||
隐藏代替状态。
|
||||
|
||||
注册表字段如下:
|
||||
|
||||
| 字段 | 类型或允许值 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `id` | 稳定应用 ID | 关联图标、面板实现和持久化实例 |
|
||||
| `label` | 有界文本 | 默认 Tab 名称和目录名称 |
|
||||
| `icon` | 稳定图标 ID | 应用目录和 Tab 图标 |
|
||||
| `description` | 有界文本 | 目录说明和完整提示 |
|
||||
| `instancePolicy` | `single`、`multiple` | 同一应用允许一个还是多个实例 |
|
||||
| `defaultContext` | `application`、`current-project`、`current-conversation` | 创建实例时使用的默认范围,不代表实例随后必须跟随 |
|
||||
| `visibleAcrossContextSwitches` | boolean | 切换项目或会话后,已打开的 Tab 是否继续留在工作栏中 |
|
||||
| `defaultOpen` | boolean | 新布局是否创建默认实例 |
|
||||
| `required` | boolean | 布局加载时实例缺失是否必须补回 |
|
||||
| `closable` | boolean | 是否渲染关闭入口并接受关闭命令 |
|
||||
| `reorderable` | boolean | 是否允许用户改变 Tab 顺序 |
|
||||
| `availability` | `available` 或带原因和修复入口的 `unavailable` | 当前能力状态 |
|
||||
|
||||
`defaultOpen`、`required` 和 `closable` 不互相推导。例如浏览器默认打开但不是必需实例;任务
|
||||
中心和工作区既默认打开又必须存在。`required` 不表示锁定顺序,顺序只由 `reorderable` 控制。
|
||||
|
||||
当前五个应用的 `visibleAcrossContextSwitches` 均为 `true`。该属性记录应用目录的显示策略,
|
||||
不改变内容归属,也不授予其他会话的 MCP 访问权限;当前没有使用 `false` 的应用,尚未实现
|
||||
按上下文隐藏 Tab 的策略。
|
||||
|
||||
浏览器在创建时绑定当前 Conversation;切换会话后仍保留原 Tab、选中状态和页面,导航操作
|
||||
继续使用原 `targetRef.conversationId`。此时新建浏览器才绑定新会话。默认或旧布局中尚未绑定
|
||||
的浏览器在首次使用时绑定活动会话,此后不再随会话切换改绑。终端同样保留创建时执行目标;
|
||||
任务中心、工作区和成果的内容继续跟随当前项目。
|
||||
|
||||
### 3.2 工作栏 Tab 实例
|
||||
|
||||
工作栏 Tab 是应用的一次打开实例。每个实例具有独立 ID、标题、应用类型和状态。Tab 行只
|
||||
显示已经打开的实例,不把全部能力同时渲染为 Tab。
|
||||
|
||||
实例持久化字段只有 `id`、`appId`、`title` 和可选 `targetRef`。`targetRef` 使用公开引用:
|
||||
终端接受 `local` 或 `projectId`,浏览器接受 `conversationId`。Main 签发的 Browser Tab ID、
|
||||
Terminal Session ID、元素引用、凭据、路径和运行状态不进入工作栏布局。
|
||||
|
||||
### 3.3 终端实例
|
||||
|
||||
每个终端 Tab 对应一个独立交互式 Shell Session:
|
||||
|
||||
- 本机终端由桌面 Main 进程管理本机 PTY。
|
||||
- 远程终端通过现有、已验证的 SSH Host 打开直接 SSH PTY。
|
||||
- 不同终端拥有独立 Shell、工作目录、输入、输出、尺寸和结束状态。
|
||||
- 创建后固定到当时选择的执行位置,切换项目不会迁移已经运行的终端。
|
||||
|
||||
直接 SSH PTY 是用户操作的远程终端,不是连接期临时 GoodBuddy Agent,也不作为 Runtime
|
||||
stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或桌面重启的进程恢复。
|
||||
|
||||
## 4. 已确认的产品决策
|
||||
|
||||
1. 工作栏 Tab 行末尾始终显示“+”按钮。
|
||||
2. “+”是普通命令按钮,不是一个 `tab`。
|
||||
3. 点击“+”在工作栏内容区显示“新建工作栏应用”选择页;取消后返回原活动 Tab。
|
||||
4. 选择应用后创建或激活实例,并关闭临时选择页。
|
||||
5. 单实例应用已经打开时,再次选择只聚焦已有实例,不创建重复 Tab。
|
||||
6. 多实例应用每次选择都创建新实例。
|
||||
7. 终端从首期开始就是多实例应用。
|
||||
8. 任务中心和工作区保持单实例、默认打开且不可关闭,分别固定在第一、第二位,`reorderable` 为 `false`。恢复旧布局时也按此规则修正顺序,其余 Tab 保持相对顺序。
|
||||
9. 浏览器注册为可关闭的多实例应用,升级后保留一个默认实例;成果保持单实例、默认打开且
|
||||
可关闭。
|
||||
10. 用户关闭可关闭实例、切换或重排应用 Tab 后,“+”仍保持可见。
|
||||
11. 选择“终端”后不再显示第二层目标选择器。存在当前项目时直接绑定该项目,由 Main
|
||||
根据项目执行空间自动打开本机或托管 SSH Shell;没有当前项目时打开本机 Home Shell。
|
||||
12. 远程首期使用直接 SSH PTY。SSH 断线后终端进入“已中断”,重新连接会创建一个新的
|
||||
Shell,不伪装为恢复旧进程。
|
||||
13. 工作栏收起、切换主页面或切换 Tab 不终止终端;显式关闭终端 Tab 才终止对应 Shell。
|
||||
14. 应用重启不自动启动 Shell。恢复的终端 Tab 显示“会话已结束”,由用户显式重新连接。
|
||||
15. 终端是用户交互表面。Ask/Execute 继续约束 Agent Runtime,不把用户键盘操作误判为
|
||||
Agent 工具调用;Agent、自动化和模型不得未经明确用户操作向终端注入输入。
|
||||
16. 工作栏继续按上位 PRD 提升为应用级容器;切换聊天、知识、魔法笔记或活动记录等主页面
|
||||
不销毁 Tab 实例或终端 Session。
|
||||
|
||||
## 5. 目标
|
||||
|
||||
### 5.1 用户目标
|
||||
|
||||
- 在工作栏中随时通过“+”发现并打开应用。
|
||||
- 同时打开多个本机或远程终端,并通过 Tab 快速切换。
|
||||
- 清楚知道每个终端运行在本机还是哪个远程 Host、当前目录和连接状态。
|
||||
- 在终端断线、退出或目标失效后获得准确状态和明确恢复操作。
|
||||
- 使用键盘完成打开应用、切换 Tab、操作终端和关闭实例。
|
||||
|
||||
### 5.2 产品目标
|
||||
|
||||
- 用一个可扩展的应用注册表统一单实例与多实例行为。
|
||||
- 把现有 Tab 迁移到统一实例模型,同时保持任务中心和工作区始终存在,并允许浏览器和终端
|
||||
创建多个实例。
|
||||
- 建立真实的 Main/Preload/Renderer 终端生产链路,而不是用普通文本框模拟终端。
|
||||
- 复用现有 SSH Host Key、凭据和连接池,不建立第二套 Host 管理。
|
||||
- 保持终端输入输出有序、响应流畅、内存有界,并可靠清理本机和远程资源。
|
||||
|
||||
## 6. 非目标
|
||||
|
||||
- 不把 GoodBuddy 改造成完整 IDE。
|
||||
- 不唤起 Windows Terminal、Terminal.app 等外部终端窗口代替内嵌终端。
|
||||
- 不扫描或导入用户全部 SSH config、目录、Shell profile 或命令历史。
|
||||
- 不支持任意 `ProxyCommand`、端口转发或 SSH Agent Forwarding。
|
||||
- 不在打开远程终端时安装、更新或修复 GoodBuddy Agent。
|
||||
- 不把 Agent 工具调用、Runtime 日志或自动化输出伪装成用户终端。
|
||||
- 不允许 Renderer 直接访问 PTY、ChildProcess、SSH Client、凭据或 Node API。
|
||||
- 首期不提供终端协作、录制、云同步、Shell Session 复用、tmux 管理或跨设备恢复。
|
||||
- 首期不实现底部停靠和独立窗口;本文保留兼容模型,后续按工作栏总 PRD 扩展。
|
||||
- 首期不通过 GoodBuddy Agent 恢复远程终端,也不为终端复制 Runtime transcript、
|
||||
`outcome_unknown` 或模型调用恢复协议。
|
||||
|
||||
## 7. 信息架构与“+”交互
|
||||
|
||||
### 7.1 Tab 行
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 任务中心 │ 工作区 │ 终端 1 │ 终端 2 │ + │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ 当前活动实例内容 │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 已打开实例位于一个语义化 `tablist` 中。
|
||||
- 每个实例使用独立 `tab` 和 `tabpanel`。
|
||||
- “+”固定在 Tab 行末尾,但位于 `tablist` 之外。
|
||||
- Tab 过多时实例区域单行横向滚动,“+”保持可见;不换行、不自动隐藏实例。
|
||||
- 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、
|
||||
工作目录和 Shell。用户可以重命名终端。
|
||||
- 当前 Tab 同时使用连续内容表面、选中边框、顶部强调线、图标和较高字重,不能只靠文字颜色。
|
||||
- Tab、关闭按钮和“+”入口沿用共享控件圆角;终端面板外框和终端顶部工具栏按钮使用直角,
|
||||
保持紧凑的停靠式终端界面。
|
||||
- 只有可关闭实例渲染 Tab 关闭按钮。关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘
|
||||
焦点进入对应 Tab 时出现;
|
||||
粗指针或无悬停设备始终显示,出现与隐藏不得引发布局位移。
|
||||
|
||||
### 7.2 新建应用选择页
|
||||
|
||||
点击“+”后:
|
||||
|
||||
1. 保留当前实例及其运行状态。
|
||||
2. 在工作栏内容区显示“新建工作栏应用”。
|
||||
3. 列出稳定应用目录,每项显示名称、说明、单/多实例提示和当前可用状态。
|
||||
4. 初始焦点进入第一个可用应用;Escape 或“取消”返回原活动实例和“+”按钮。
|
||||
5. 选择单实例应用时,若实例已存在,直接聚焦该 Tab;任务中心和工作区始终走此路径。
|
||||
6. 选择浏览器时创建绑定当前 Conversation 的新 Browser Tab;没有活动 Conversation 时创建
|
||||
待选择目标的空实例。
|
||||
7. 选择终端时直接解析当前项目执行空间,创建新 Tab 并聚焦终端输入,不再要求用户
|
||||
重复选择本机或远程目标。
|
||||
|
||||
选择页是“+”触发的临时界面,不进入实例顺序,不持久化,也不占用一个普通 Tab。
|
||||
|
||||
### 7.3 首期应用注册表
|
||||
|
||||
| 应用 | 实例策略 | 默认上下文 | 默认打开 | 必须存在 | 可关闭 | 可排序 | 再次从“+”选择 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 任务中心 | `single` | 当前项目 | 是 | 是 | 否 | 否 | 聚焦已有 Tab |
|
||||
| 工作区 | `single` | 当前项目 | 是 | 是 | 否 | 否 | 聚焦已有 Tab |
|
||||
| 浏览器 | `multiple` | 当前 Conversation | 是,一个实例 | 否 | 是 | 是 | 每次创建新 Tab |
|
||||
| 成果 | `single` | 当前项目 | 是 | 否 | 是 | 是 | 聚焦已有 Tab |
|
||||
| 终端 | `multiple` | 当前项目 | 否 | 否 | 是 | 是 | 每次创建新 Tab |
|
||||
|
||||
实例策略、默认状态和可关闭策略都是应用注册数据,不能分散硬编码在多个点击处理器中。
|
||||
|
||||
新建浏览器使用独立编号标题,避开已有名称;关闭后再次新建也不产生重名。恢复布局保留已保存
|
||||
的标题。会话名称用于标识归属,不替代浏览器实例名称。新建页面为空白页,同会话 Cookie 共享
|
||||
不表示复用旧页面或导航历史。
|
||||
|
||||
### 7.4 Tab 操作
|
||||
|
||||
- 点击或使用方向键切换实例不会暂停或终止后台实例。
|
||||
- 支持左右方向键、Home、End 和 roving `tabindex`。
|
||||
- 任务中心和工作区没有关闭按钮,也不响应关闭快捷命令。
|
||||
- 关闭可关闭的非活动 Tab 后焦点保持在当前活动 Tab。
|
||||
- 关闭可关闭的活动 Tab 后激活右侧相邻实例;没有右侧实例时激活左侧实例。任务中心和工作区
|
||||
始终存在,因此正常布局不会进入零实例空状态。
|
||||
- 关闭正在运行的终端需要确认“关闭终端将结束 Shell 及其子进程”;取消是初始焦点。
|
||||
- 已退出或已中断的终端可以直接关闭,不重复确认。
|
||||
|
||||
## 8. 终端创建流程
|
||||
|
||||
### 8.1 自动目标解析
|
||||
|
||||
- 当前项目是本地项目时,创建目标为公开 Project ID,Main 从权威项目记录解析本机根目录。
|
||||
- 当前项目是托管 SSH 项目时,创建目标仍为公开 Project ID,Main 再解析项目 Host、
|
||||
固定 Host Key、凭据和远端根目录。
|
||||
- 没有当前项目时使用本机目标和当前用户 Home。
|
||||
- Host 未验证、凭据不可用或不支持交互式 Shell 时,终端 Tab 就地显示失败原因和恢复入口,
|
||||
不静默切换到本机或其他 Host。
|
||||
|
||||
### 8.2 本机终端
|
||||
|
||||
- 当前项目是本地项目时,默认工作目录为项目根目录。
|
||||
- 没有本地项目目标时,默认工作目录为当前用户 Home。
|
||||
- Windows 优先使用可用的 PowerShell 7,其次 Windows PowerShell,最后使用系统
|
||||
`COMSPEC`。
|
||||
- macOS 和 Linux 优先使用当前用户有效的 `$SHELL`,不可用时使用平台常规 Shell fallback。
|
||||
- 首期自动选择默认 Shell,不提供任意可执行文件路径输入。
|
||||
- Main 只向 Shell 传递正常交互所需的筛选环境,不注入模型 API Key、GoodBuddy 内部
|
||||
Token 或其他产品凭据。
|
||||
|
||||
### 8.3 远程终端
|
||||
|
||||
- 当前项目是托管 SSH 项目时,自动使用项目 Host 和远端项目根目录。
|
||||
- Main 复用现有 Host store、完整 Host Key 固定、认证方式和 SSH connection pool。
|
||||
- 远端通过 SSH PTY 打开账号的登录 Shell,并支持输入、ANSI 输出和窗口尺寸同步。
|
||||
- 活动终端持有专用 Channel lease,不得被连接池空闲回收。
|
||||
- 不把密码、私钥、认证头或 GoodBuddy 模型凭据发送给 Renderer 或写入终端环境。
|
||||
- Host 地址、用户、凭据或 Host Key generation 变化时,旧 Session 进入“已中断”,新建
|
||||
Session 必须使用 Host store 中的当前身份。
|
||||
|
||||
### 8.4 创建结果
|
||||
|
||||
创建成功后:
|
||||
|
||||
- 新 Tab 插入当前活动 Tab 右侧并立即激活。
|
||||
- 焦点进入终端输入区域。
|
||||
- 状态栏显示“本机”或 Host 名称、工作目录、Shell 和连接状态。
|
||||
- 终端尺寸使用内容区计算出的首个有效 `cols/rows`,不是固定默认值覆盖实际布局。
|
||||
|
||||
创建失败时在终端 Tab 保留错误上下文并提供重试;不得留下不可回收 Session 或显示为已经连接。
|
||||
|
||||
## 9. 终端交互与状态
|
||||
|
||||
### 9.1 基础交互
|
||||
|
||||
首期支持:
|
||||
|
||||
- 真实 TTY 输入与 ANSI 输出。
|
||||
- 中文输入法、组合字符和常规键盘快捷键。
|
||||
- 复制、粘贴、全选、搜索、清屏和滚动。复制与粘贴通过桌面 Main 进程访问系统文本剪贴板,
|
||||
不依赖 Renderer 的浏览器剪贴板权限。
|
||||
- 工作栏宽度或窗口尺寸变化后的 PTY resize。
|
||||
- 终端重命名。
|
||||
- 显式关闭、退出后重新打开和远程中断后重新连接。
|
||||
|
||||
浏览器原生页面快捷键与终端按键冲突时,终端聚焦期间优先交给终端;应用级保留快捷键必须
|
||||
有明确规则和可访问说明。
|
||||
|
||||
### 9.2 状态模型
|
||||
|
||||
```ts
|
||||
type TerminalSessionState =
|
||||
| 'starting'
|
||||
| 'running'
|
||||
| 'exited'
|
||||
| 'interrupted'
|
||||
| 'closing'
|
||||
| 'failed'
|
||||
```
|
||||
|
||||
- `starting`:正在创建本机 PTY 或 SSH Channel。
|
||||
- `running`:允许输入和 resize。
|
||||
- `exited`:Shell 正常或带退出码结束,显示退出码或信号。
|
||||
- `interrupted`:远程连接、Host identity 或桌面生命周期导致会话失效。
|
||||
- `closing`:已拒绝新输入,正在回收进程或 Channel。
|
||||
- `failed`:Session 未成功开始或发生不可恢复错误。
|
||||
|
||||
高频输出不逐行进入 live region。只播报连接成功、连接失败、已中断和 Shell 已退出等重要
|
||||
状态变化。
|
||||
|
||||
### 9.3 重新连接
|
||||
|
||||
- 本机或远程 Session 结束后,“重新打开”在同一 Tab 中创建全新的 Shell Session。
|
||||
- 远程断线后的按钮文案为“重新连接并新建 Shell”,不能暗示恢复旧进程。
|
||||
- 新 Session 使用 Host store 和项目中的当前有效配置,不复用旧 Host identity。
|
||||
- 重新连接前清空旧终端画面,并保留可见说明“这是新的 Shell 会话”。
|
||||
|
||||
## 10. 实例、Session 与持久化
|
||||
|
||||
工作栏实例与运行 Session 分离:
|
||||
|
||||
```ts
|
||||
type WorkbarAppDefinition = {
|
||||
id: 'tasks' | 'workspace' | 'browser' | 'results' | 'terminal'
|
||||
label: string
|
||||
icon: WorkbarAppDefinition['id']
|
||||
description: string
|
||||
instancePolicy: 'single' | 'multiple'
|
||||
defaultContext: 'application' | 'current-project' | 'current-conversation'
|
||||
defaultOpen: boolean
|
||||
required: boolean
|
||||
closable: boolean
|
||||
reorderable: boolean
|
||||
availability:
|
||||
| { state: 'available' }
|
||||
| { state: 'unavailable'; reason: string; remedy: string }
|
||||
}
|
||||
|
||||
type WorkbarTabInstance = {
|
||||
id: string
|
||||
appId: WorkbarAppDefinition['id']
|
||||
title: string
|
||||
targetRef?:
|
||||
| { type: 'local' }
|
||||
| { type: 'project'; projectId: string }
|
||||
| { type: 'conversation'; conversationId: string }
|
||||
}
|
||||
|
||||
type WorkbarLayoutPreferences = {
|
||||
instances: WorkbarTabInstance[]
|
||||
activeInstanceId: string | null
|
||||
expanded: boolean
|
||||
dock: 'right'
|
||||
widthRatio: number
|
||||
taskScope: 'current-project' | 'global' | 'all-projects'
|
||||
}
|
||||
```
|
||||
|
||||
- 持久化工作栏打开状态、Tab 顺序、活动实例、应用 ID、自定义标题、公开目标引用和宽度比例。
|
||||
- 加载布局时根据 `required` 补回缺失实例,并拒绝重复的单实例应用;应用策略不写入实例
|
||||
数据,避免持久化状态覆盖注册表。
|
||||
- `reorderable` 已进入共享契约;当前 Tab 壳层尚未提供拖放或键盘重排,不能把声明属性视为
|
||||
已完成的排序功能。
|
||||
- 不持久化终端正文、未执行输入、凭据、环境变量、PTY PID、SSH Channel 或派生 Host
|
||||
identity。
|
||||
- Main 创建终端后返回新的不透明 Session ID;Renderer 不能选择 PID 或 Channel。
|
||||
- 应用重启恢复终端 Tab 描述,但状态为“会话已结束”,不自动建立 SSH 连接或启动本机
|
||||
Shell。
|
||||
- 运行期间切换项目不会改变已经创建的终端目标。用户需要新目标时通过“+”创建新终端。
|
||||
|
||||
## 11. 生命周期与关闭语义
|
||||
|
||||
| 用户或系统动作 | Tab | Shell Session |
|
||||
| --- | --- | --- |
|
||||
| 切换 Tab | 保留 | 继续运行 |
|
||||
| 收起工作栏 | 保留 | 继续运行,停止非必要高频 UI 工作 |
|
||||
| 切换主页面 | 保留 | 继续运行 |
|
||||
| 切换项目 | 保留并显示原目标 | 继续在原目标运行 |
|
||||
| 关闭运行中的终端 Tab 并确认 | 删除 | 结束 Shell 和子进程 |
|
||||
| Shell 自行退出 | 保留 | 标记 `exited` |
|
||||
| SSH 断线 | 保留 | 标记 `interrupted` |
|
||||
| 删除或修改目标 Host | 保留 | 关闭旧 Channel 并标记 `interrupted` |
|
||||
| Renderer 销毁或应用退出 | 下次恢复描述 | 在总关闭截止时间内回收 |
|
||||
|
||||
关闭操作必须安全处理竞态。Main 停止接受新输入,关闭 stdin/Channel,先正常结束,再在既有
|
||||
应用关闭截止时间内升级清理;一个卡死终端不能无限阻塞应用退出。关闭后的迟到 ACK 视为已
|
||||
完成,input、resize 或重复 close 返回 Session 不存在,不操作其他 Session。
|
||||
|
||||
## 12. 技术生产路径
|
||||
|
||||
### 12.1 Shared
|
||||
|
||||
新增独立终端契约,至少覆盖:
|
||||
|
||||
- `create`:目标引用、初始 `cols/rows`。
|
||||
- `write`:Session ID 和有界输入。
|
||||
- `resize`:Session ID、`cols/rows`。
|
||||
- `close`:Session ID。
|
||||
- `snapshot`:目标、状态、Shell、目录和终态。
|
||||
- `event`:带 Session ID 和单调序号的输出、状态、退出与错误事件。
|
||||
|
||||
所有 Schema 使用 Zod 严格校验,并限制 ID、标题、输入、尺寸、事件块、会话数和缓冲字节。
|
||||
|
||||
### 12.2 Main
|
||||
|
||||
新增统一 `TerminalSessionManager`:
|
||||
|
||||
- Session 按所属 `webContents` 和不透明 ID 隔离。
|
||||
- 将现有间接 `node-pty` 提升为 GoodBuddy 直接生产依赖,并用于启动平台 Shell。
|
||||
- 远程使用现有 `SshConnectionPool` 打开交互式 PTY Channel。
|
||||
- 统一处理 write、resize、close、exit、连接中断和应用退出。
|
||||
- 终端输出只发送给所属窗口。
|
||||
- 复用现有子进程树终止思路,但针对 PTY 验证 Windows 与 POSIX 的实际回收行为。
|
||||
|
||||
### 12.3 Preload 与 IPC
|
||||
|
||||
- `DesktopApi` 只暴露显式 `terminal.create/write/resize/close/getSnapshot/onEvent`。
|
||||
- Main Handler 对每个调用执行可信 sender 检查和共享 Schema parse。
|
||||
- Preload 管理事件订阅释放,不暴露 `ipcRenderer`、PTY、SSH Client 或 Node API。
|
||||
|
||||
### 12.4 Renderer
|
||||
|
||||
- 使用 `@xterm/xterm` 6 和官方 fit/search addon 处理 ANSI、光标、选择、IME、搜索、
|
||||
滚动和可访问输入,不自行实现终端转义状态机。
|
||||
- 终端首次打开时按需加载;支持 WebGL2 时使用官方 WebGL renderer,初始化失败或 Context
|
||||
丢失时释放 WebGL addon 并回退到 xterm 默认 renderer。
|
||||
- xterm 使用解析后的跨平台系统等宽字体栈,不能把未解析的 CSS `var(...)` 交给 Canvas。
|
||||
- Tab 壳层只管理实例与焦点,不持有 PTY 或 SSH 对象。
|
||||
- `ResizeObserver` 只在有效非零布局后发送去重后的 `cols/rows`。
|
||||
- 组件卸载时退订画面事件;只有显式关闭终端才调用 Session close。
|
||||
- 终端样式复用全局语义颜色、边框、焦点和字体令牌,不创建另一套工作台设计系统。
|
||||
|
||||
### 12.5 输出顺序与背压
|
||||
|
||||
- 输出按 Session 分区并携带单调序号。
|
||||
- Main 合并小块输出,限制单事件大小、待发送条目和总字节。
|
||||
- Renderer 确认已经消费的序号;慢消费者达到上限时暂停 PTY/SSH stream,消费后恢复。
|
||||
- 不通过丢弃中间输出维持界面响应,因为丢失字节会破坏终端状态。
|
||||
- Renderer 只保留有界滚动历史;清屏不改变 Main Session 生命周期。
|
||||
|
||||
## 13. 权限与边界
|
||||
|
||||
- 用户终端以当前本机账号或所选 SSH 账号的正常权限运行,不增加额外虚构的 trust tier。
|
||||
- Ask/Execute 是 Agent Runtime 模式,不自动改变用户直接操作终端的 OS 权限。
|
||||
- UI 必须持续将其标注为“用户终端”,避免被理解为 Ask Runtime 的只读输出。
|
||||
- Agent、模型、监督器和自动化不能调用终端 write,也不能把指令注入已有 Session。
|
||||
- 终端输出不会自动进入对话上下文、模型 Prompt、成果或活动记录。
|
||||
- Renderer 只能引用项目和 Session 的公开 ID;Main 从权威 store 解析目录、Host、凭据和
|
||||
当前 Host identity。
|
||||
- 不接受 Renderer 提交任意 Shell 可执行路径、SSH 命令、PID、环境变量集合或原始凭据。
|
||||
- 保持 Electron `contextIsolation`、sandbox 和禁用 Renderer Node integration。
|
||||
|
||||
## 14. 错误与反馈
|
||||
|
||||
- 创建失败、断线、Shell 退出和目标失效保留在终端面板,提供就地重试。
|
||||
- 不与全局通知重复显示同一终端错误。
|
||||
- 非局部错误,例如无法保存工作栏布局,可以进入应用通知。
|
||||
- 错误文案说明发生了什么、当前 Shell 是否仍在运行、用户可以做什么。
|
||||
- Host Key 变化沿用现有 Host 管理流程;终端只显示“主机身份已变化,请重新验证”,不在
|
||||
终端内复制凭据或 Host Key 修改流程。
|
||||
- 目标不可用时保留 Tab 名称和目标,不静默切换到本机或其他 Host。
|
||||
|
||||
## 15. 性能与资源要求
|
||||
|
||||
- 工作栏切换和终端输入在正常负载下保持即时反馈,持续输出不能阻塞主界面导航。
|
||||
- 同一窗口的活动终端数量必须有明确上限;达到上限时阻止新建并说明先关闭不用的终端。
|
||||
- 每个 Session 的输出队列、Renderer 滚动历史和 IPC 单事件均有硬上限。
|
||||
- 工作栏不可见时可以降低渲染刷新频率,但不能丢失终端协议字节或暂停 Shell 本身。
|
||||
- 应用退出复用现有总清理截止时间,不因终端新增无界等待。
|
||||
- 生产构建必须实际加载本机 PTY 原生模块;不能只验证文件存在。
|
||||
- 远程终端不能使 SSH connection pool 的空闲项、Channel 或监听器泄漏。
|
||||
|
||||
具体数值在共享契约和性能测试中确定,以现有 IPC、窗口和关闭预算为上限,不在多个进程中
|
||||
维护不同常量。
|
||||
|
||||
## 16. 无障碍与响应式
|
||||
|
||||
- Tab 使用 `tablist/tab/tabpanel`;“+”使用带可访问名称“打开工作栏应用”的普通按钮。
|
||||
- 应用选择页使用可键盘操作的列表或菜单语义,不使用 `SegmentedControl`;终端没有重复的
|
||||
目标选择页。
|
||||
- 每个终端 Tab 的可访问名称包含自定义名称或位置,多个终端不能都只读作“终端”。
|
||||
- Tab 关闭后焦点移动到确定的相邻 Tab;取消创建后焦点返回“+”。
|
||||
- 工作栏收起后焦点返回唯一工作栏开关,不增加焦点陷阱或遮罩。
|
||||
- 终端高频输出不逐行播报;只播报连接、退出、失败和中断。
|
||||
- 200% 文字缩放时,目标、Shell、目录、状态和关闭操作仍可访问。
|
||||
- Tab 单行横向滚动,终端内容随工作栏尺寸变化并同步 PTY,不把 Tab 换成下拉框。
|
||||
- 深色和浅色主题都使用设计系统语义令牌,并保持终端文本、选择和光标可辨。
|
||||
|
||||
## 17. 实施顺序与多代理工作包
|
||||
|
||||
功能必须以完整的本机和远程用户流程交付。以下是可并行的开发工作包,不表示可以发布只有
|
||||
Schema、假数据终端或不可用入口的中间产品。
|
||||
|
||||
### 17.1 依赖顺序
|
||||
|
||||
```text
|
||||
契约冻结
|
||||
├─ 工作栏实例壳层
|
||||
├─ 本机 PTY 后端
|
||||
├─ SSH PTY 后端
|
||||
└─ 终端 Renderer
|
||||
↓
|
||||
单一集成负责人完成 Main/Preload/App 接线
|
||||
↓
|
||||
完整验证、真实 Host 验收与文档同步
|
||||
```
|
||||
|
||||
### 17.2 文件所有权
|
||||
|
||||
| 工作包 | 主要职责 | 建议文件所有权 | 不应修改 |
|
||||
| --- | --- | --- | --- |
|
||||
| 契约与注册表 | Workbar/Terminal Schema、状态、测试 | 新建 `src/shared/workbar-contracts.ts`、`terminal-contracts.ts` 及测试 | `App.tsx`、`ipc.ts`、Preload |
|
||||
| 工作栏壳层 | 动态实例 Tab、“+”、选择页、单/多实例和焦点 | 新建工作栏组件及其测试 | PTY、SSH、Main IPC |
|
||||
| 本机终端后端 | 本机 PTY、输入、resize、退出和进程树清理 | 新建 `src/main/terminal/` 本机文件及测试 | Renderer、远程 Agent |
|
||||
| SSH 终端后端 | SSH PTY、lease、resize、断线和 Channel 清理 | 新建 `src/main/terminal/` 远程文件及聚焦测试 | 工作栏 UI、Agent 协议 |
|
||||
| 终端 Renderer | 模拟器、IME、搜索、复制粘贴、状态栏和 resize | 新建终端组件、样式和测试 | Main、SSH、中心 IPC |
|
||||
| 集成 | 依赖、IPC channel、DesktopApi、Preload、Main 注册、`App` 接线与关闭顺序 | `package.json`、lock、`ipc-channels.ts`、`contracts.ts`、`preload/index.ts`、`main/ipc.ts`、`App.tsx` | 不重写各包已验收内部逻辑 |
|
||||
| 验收与文档 | E2E、跨平台构建检查、真实 Host、最终文档 | 测试与相关文档 | 不用假实现替换生产路径 |
|
||||
|
||||
中心文件只能由集成负责人修改,避免多个代理同时编辑 `App.tsx`、`main/ipc.ts`、
|
||||
`shared/contracts.ts`、`preload/index.ts`、`package.json` 和 lockfile。
|
||||
|
||||
### 17.3 并行开发门槛
|
||||
|
||||
契约负责人先冻结:
|
||||
|
||||
- 应用注册和实例策略。
|
||||
- Terminal request、snapshot、event 和 error code。
|
||||
- Session 所有权和关闭语义。
|
||||
- 输出序号、ACK 与容量常量。
|
||||
|
||||
其他代理基于冻结契约工作。接口偏差由集成负责人统一修正,不由多个代理分别修改共享中心
|
||||
文件。
|
||||
|
||||
## 18. 验收标准
|
||||
|
||||
### 18.1 工作栏应用与实例
|
||||
|
||||
- [ ] Tab 行末始终显示“+”;任务中心和工作区始终存在,因此正常布局不存在零实例状态。
|
||||
- [ ] 点击“+”显示稳定应用目录,取消后返回原实例并恢复焦点。
|
||||
- [ ] 选择已打开单实例应用只聚焦已有 Tab。
|
||||
- [x] 任务中心和工作区默认存在且不可关闭,不渲染关闭按钮,也不响应关闭快捷命令。
|
||||
- [x] 连续选择浏览器会创建页面、导航状态和元素引用相互独立的浏览器 Tab。
|
||||
- [x] 连续选择终端会创建多个互相独立的终端 Tab。
|
||||
- [ ] 关闭可关闭实例、切换和重排实例后,“+”仍保持可用。
|
||||
- [ ] Tab 溢出时单行横向滚动,不隐藏“+”或把 Tab 换成下拉框。
|
||||
- [x] 关闭可关闭的活动 Tab 后激活正确的相邻 Tab 并恢复焦点。
|
||||
- [x] 当前 Tab 的背景、边框、顶部指示和字重清晰可辨;关闭按钮仅在悬停、焦点或无悬停
|
||||
设备上出现,且不引发布局位移。
|
||||
- [x] 升级后任务中心、工作区、一个浏览器实例和成果按默认布局打开;布局恢复会补回缺失的
|
||||
任务中心与工作区。
|
||||
- [ ] 在所有主要页面都能打开工作栏;切换主要页面不会销毁 Tab 实例或终端 Session。
|
||||
|
||||
### 18.2 本机终端
|
||||
|
||||
- [x] 从“+ → 终端”直接打开当前本机项目的真实交互式 Shell,工作目录为项目根。
|
||||
- [ ] 没有项目时从用户 Home 打开本机 Shell。
|
||||
- [ ] `pwd` / `Get-Location`、交互输入、ANSI 颜色、中文 IME、复制和粘贴工作正常。
|
||||
- [ ] 调整工作栏和窗口尺寸会更新 PTY `cols/rows`,全屏 TTY 程序正确重绘。
|
||||
- [ ] 多个本机终端的输入、输出、目录和退出状态互不串线。
|
||||
- [ ] Shell 有活动子进程时关闭 Tab,确认后回收完整进程树;取消后 Session 继续运行。
|
||||
- [ ] 退出后的 Tab 显示退出码,并可在同一 Tab 显式创建新 Shell。
|
||||
|
||||
### 18.3 远程终端
|
||||
|
||||
- [ ] 从“+ → 终端”直接为当前托管 SSH 项目打开真实 SSH PTY。
|
||||
- [ ] 终端持续显示 Host、远端目录、Shell 和连接状态。
|
||||
- [ ] 使用现有固定 Host Key 和已存凭据,Renderer、日志和命令参数不出现凭据。
|
||||
- [ ] 输入、ANSI、中文、复制粘贴和 resize 在真实 Host 上工作正常。
|
||||
- [ ] 活动远程终端不会被 SSH connection pool 空闲回收。
|
||||
- [ ] SSH 断线立即标记“已中断”并拒绝继续输入,不显示为仍在运行。
|
||||
- [ ] “重新连接并新建 Shell”明确创建新 Session,不宣称恢复旧远端进程。
|
||||
- [ ] Host 删除、地址/用户/凭据或 Host Key generation 变化会中断旧 Session;新 Session
|
||||
使用当前 Host identity。
|
||||
|
||||
### 18.4 生命周期、性能和边界
|
||||
|
||||
- [ ] 收起工作栏、切换主页面、切换 Tab 或切换项目不会终止已有终端。
|
||||
- [ ] 终端不会随项目切换静默迁移执行位置。
|
||||
- [ ] 应用重启只恢复终端 Tab 描述,不自动连接或启动 Shell。
|
||||
- [ ] 输出顺序在持续大输出下保持正确,Main 和 Renderer 内存保持有界,主界面仍可操作。
|
||||
- [ ] 慢 Renderer 触发暂停和恢复,不丢弃中间终端字节。
|
||||
- [ ] Renderer 不获得 raw PTY、ChildProcess、SSH Client、PID、环境变量或凭据。
|
||||
- [ ] Agent、模型和自动化不能向已有用户终端注入输入。
|
||||
- [ ] 窗口销毁和应用退出在既有关闭截止时间内回收本机进程、SSH Channel 和监听器。
|
||||
- [ ] 重复 close、迟到 input/resize 和退出竞态不会操作其他 Session 或导致进程泄漏。
|
||||
|
||||
### 18.5 设计、无障碍和跨平台
|
||||
|
||||
- [ ] 仅使用键盘可以打开应用、创建浏览器和终端、切换 Tab、关闭可关闭 Tab、复制、粘贴及
|
||||
重新连接。
|
||||
- [ ] Tab、“+”、选择页和关闭确认使用正确语义并有可见焦点。
|
||||
- [ ] 终端输出不逐行进入 live region,关键状态变化可被辅助技术感知。
|
||||
- [ ] 浅色、深色、200% 文字缩放和窄窗口下可以完成核心流程。
|
||||
- [ ] Windows、macOS、Linux x64 和 Linux arm64 的正式构建实际启动本机 PTY。
|
||||
- [ ] 远程链路在共享 Linux x64 真实 Host 上完成创建、输入、resize、断线、重连、关闭和
|
||||
应用退出验证;若 Host 不可达,远程终端不得标记为完成。
|
||||
|
||||
## 19. 验证计划
|
||||
|
||||
### 19.1 自动验证
|
||||
|
||||
- 共享 Zod Schema 和边界值测试。
|
||||
- 单/多实例、默认打开、可关闭和排序策略,以及 Tab 顺序、关闭焦点和“+”选择页组件测试。
|
||||
- 任务中心与工作区缺失时的布局修复、关闭命令拒绝和焦点保持测试。
|
||||
- 浏览器多实例的页面隔离、Conversation 匹配和快捷入口去重测试。
|
||||
- 本机 PTY Session Manager 的输入、resize、退出、幂等关闭和进程树测试。
|
||||
- SSH PTY 的 Host identity、lease、断线、resize 和关闭测试。
|
||||
- Main trusted sender、Session 所有权、IPC 输入边界和事件路由测试。
|
||||
- Terminal Renderer 的 IME、键盘、ResizeObserver、订阅释放和状态测试。
|
||||
- `npm test`
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `npm run build`
|
||||
|
||||
### 19.2 真实路径验证
|
||||
|
||||
- 在当前开发平台通过产品 UI 完成本机终端全流程。
|
||||
- 在共享 Linux x64 Host 通过现有 Host 配置完成远程终端全流程。
|
||||
- 真实运行需要 TTY 的程序,验证 ANSI、光标和 resize,不只运行非交互命令。
|
||||
- 持续输出并主动减慢 Renderer 消费,验证背压和内存上限。
|
||||
- 分别在本机和远程启动带子进程的命令,关闭 Tab 和应用后核对无 GoodBuddy 所属遗留资源。
|
||||
- 生产构建在各受支持平台实际加载 PTY 原生模块,不以单元测试或包内文件存在代替。
|
||||
|
||||
### 19.3 当前开发验证记录
|
||||
|
||||
2026-09-01 已通过生产 `SshConnectionPool` 和 `createSshTerminalSession` 在共享 Linux x64
|
||||
Host 完成直接 SSH PTY 验证:使用已有固定 Host Key 和测试凭据,以远端项目目录启动登录
|
||||
Shell,验证交互输入、ANSI、UTF-8 中文、`80×24` 到 `101×37` resize、连接销毁后进入
|
||||
`interrupted`、重新连接创建新 Shell,以及 Session close 和 connection pool dispose
|
||||
回收远端 Shell。验证只创建一个
|
||||
`$HOME/.goodbuddy/terminal-smoke-<UUID>` 临时目录,结束后已删除并确认无遗留 Shell 进程。
|
||||
|
||||
同日使用最新生产构建完成工作栏视觉检查:活动 Tab 使用连续内容表面、顶部强调线和应用
|
||||
图标;关闭按钮默认不可见,悬停后以 overlay 显示且 Tab 宽度不变;横向滚动继续可用但不
|
||||
显示滚动条,“+”保持独立可见。
|
||||
|
||||
本记录不替代尚未勾选的完整产品 UI、剪贴板、macOS、Linux arm64 和各平台本机 PTY 验收。
|
||||
|
||||
本 PRD 不改变 Agent 协议,因此首期远程终端无需 Agent package 更新。若实现期间改为
|
||||
Agent-owned PTY,必须先修订本文的远程生命周期和恢复语义,并按 Agent 开发规则在共享
|
||||
Linux x64 Host 验证当前源码或候选包。
|
||||
|
||||
## 20. 相关文档职责
|
||||
|
||||
- [通用助手工作栏与执行空间 PRD](./prd.md):
|
||||
工作栏应用级位置、稳定能力目录、执行空间和总体范围。
|
||||
- 本文:持久“+”、应用实例策略、工作栏动态 Tab、自动项目目标、本机与直接 SSH 多终端。
|
||||
- [统一界面设计系统](../../../UI-DESIGN.md):Tab、菜单、布局、主题、焦点和无障碍规则。
|
||||
- [SSH 远程主机与 GoodBuddy Agent 实现说明](../remote-host/technical-design.md):
|
||||
Host Key、凭据、连接池、Agent 和远程项目现状。
|
||||
- [SSH Host 远程环境准备与控制面直连设计](../remote-host/environment-provisioning-technical-design.md):
|
||||
Host 保存、环境准备和项目使用之间的职责。
|
||||
@@ -1,399 +0,0 @@
|
||||
# 自动任务、目标与调度 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,11 @@
|
||||
# 电脑控制
|
||||
|
||||
电脑控制包含 GoodBuddy 对浏览器和客户端桌面能力的产品实现与平台适配状态。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [功能进度](./progress.md) | 当前已实现能力、缺陷修复、平台限制和验证状态 |
|
||||
|
||||
当前只有进度文档;产品需求、功能逻辑、UI 和技术设计尚未从历史实现记录中独立整理。
|
||||
@@ -6,9 +6,9 @@
|
||||
| --- | --- |
|
||||
| 文档类型 | 实施进度 |
|
||||
| 状态 | 持续更新 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-08-07 |
|
||||
| 适用能力 | 电脑控制与托管浏览器 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 适用能力 | 内置浏览器与客户端电脑控制 |
|
||||
|
||||
## 范围
|
||||
|
||||
@@ -17,19 +17,20 @@ Linux x64 和 Linux arm64。
|
||||
|
||||
## 已完成
|
||||
|
||||
### 直连模型与浏览器
|
||||
### Runtime 与浏览器
|
||||
|
||||
- 已实现宿主进程拥有的隔离浏览器服务。
|
||||
- 使用 Electron 自带 Chromium 和 CDP,不下载额外浏览器。
|
||||
- 已实现导航、可访问性快照、点击、输入、选择、返回和有界截图工具。
|
||||
- 浏览器工具只在直连模型的 Execute 模式中提供;用户选择 Execute 即授权
|
||||
本次交互运行,不再逐个弹出 GoodBuddy 工具审批。
|
||||
- 浏览器工具作为 GoodBuddy 内置能力,可分别分配给直连模型、GoodBuddy 管理的
|
||||
OpenCode 和 Continue;只在 Execute 模式中提供。用户选择 Execute 即授权本次
|
||||
运行,不再逐个弹出 GoodBuddy 工具审批。
|
||||
- 支持当前设备可连接的 HTTP、HTTPS、公网、内网、本机和元数据地址,
|
||||
导航及重定向仍经过 DNS 解析和目标地址固定。
|
||||
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
|
||||
空闲回收和应用退出清理。
|
||||
- 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和
|
||||
无进展保护。
|
||||
- 直连模型工具循环不设置工具次数或模型轮次配额;取消、请求超时、工具结果大小和
|
||||
累计输出边界继续防止失控执行。
|
||||
|
||||
### MCP
|
||||
|
||||
@@ -59,11 +60,18 @@ Linux x64 和 Linux arm64。
|
||||
|
||||
### 设置与安全边界
|
||||
|
||||
- 能力存储已迁移到版本 2。
|
||||
- 已增加电脑控制能力卡片、诊断、托管浏览器配置元数据、IPC 和 preload
|
||||
契约。
|
||||
- 电脑控制能力默认关闭;启用、配置变更和本地数据清除会替换或清理相关
|
||||
Runtime 和会话。
|
||||
- 能力存储已迁移到版本 6。版本 2 至 5 中原有的浏览器启停状态会迁移到
|
||||
`builtin-browser`,已有启用状态不丢失;默认分配给直连模型、GoodBuddy 管理的
|
||||
OpenCode 和 Continue。
|
||||
- 内置浏览器位于“内置 MCP”,与知识库、笔记和 GoodBuddy 配置一样提供独立启停、
|
||||
Runtime 分配和工具清单;DeepSeek Harness 持续显示为不支持。
|
||||
- 浏览器开关只控制 Agent 工具访问。关闭后,任何 Runtime 都不会获得浏览器工具,
|
||||
但用户仍可在浏览器工作栏中手动前往、返回、刷新、停止加载、交互和关闭浏览器。
|
||||
开启后,已分配 Runtime 可在 Execute 模式直接使用,不逐次询问。
|
||||
- 尚未实际参与浏览器执行的命名配置已从界面隐藏;底层旧数据和兼容接口继续
|
||||
保留,不删除用户已有记录。
|
||||
- “电脑控制”只显示实际操作客户端电脑的能力。能力启用、配置变更和本地
|
||||
数据清除仍会替换或清理相关 Runtime 和会话。
|
||||
|
||||
## 本轮已修复缺陷
|
||||
|
||||
@@ -84,15 +92,28 @@ Linux x64 和 Linux arm64。
|
||||
|
||||
### P0:右侧没有浏览器实时画面,已修复
|
||||
|
||||
浏览器窗口默认使用 `show: false`,但 BrowserService 现在从模型实际操作的同一
|
||||
会话捕获页面帧,并通过受限 IPC 发送状态、当前 URL 和约 220KB 的 JPEG 画面。右侧工作栏
|
||||
新增“浏览器”页签;活动对话启动浏览器时会自动打开该页签,并显示创建中、
|
||||
加载中、操作中、用户交互中、就绪、失败和已停止状态。用户可点击“交互”打开
|
||||
同一会话的子窗口辅助 Agent;交互时主窗口暂时禁用,关闭时先刷新最终画面再将
|
||||
浏览器窗口最小化,页面和会话继续保留。用户也可立即停止当前对话的浏览器会话。
|
||||
早期版本通过同一浏览器会话的 JPEG 帧提供预览。当前工作栏直接嵌入 Agent 操作的原生
|
||||
`WebContentsView`,不再自动截图或传输预览帧,也不再用独立交互窗口阻塞主界面。
|
||||
页面显示、标签页归属、失败重试和显式截图的当前规则统一见
|
||||
[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md);
|
||||
该路径的最新验证证据见[工作栏进度](../assistant-workbar/progress.md)。
|
||||
|
||||
当前实现按导航、快照、点击、输入、选择、返回和截图操作刷新画面,而不是创建
|
||||
第二个预览浏览器,因此显示内容与模型受控页面一致。
|
||||
### 已实现:浏览器工具栏
|
||||
|
||||
浏览器 Tab 顶部已增加“返回、刷新/停止加载、地址输入、前往、交互、关闭浏览器”工具栏。
|
||||
它继续操作 Agent 在当前 Conversation 中使用的同一浏览器会话;用户导航会有意改变 Agent
|
||||
下一步看到的页面。前往、返回和刷新通过 `BrowserService` 与 Agent 浏览器操作串行执行,
|
||||
不会创建第二套 Renderer 导航路径。
|
||||
|
||||
工具栏状态以浏览器提交后的权威 URL、是否加载中、能否返回和会话活动状态为准;地址输入
|
||||
在用户编辑期间保留本地草稿,不被状态刷新覆盖;提交未带 HTTP(S) 协议的裸域名时会自动添加
|
||||
`http://`。创建中、Agent 操作中和完整交互窗口期间禁用其余工具栏控件,但活动导航仍可用
|
||||
“停止加载”中断。停止加载只取消页面加载,不关闭浏览器,也不释放 Runtime 上下文;原“停止
|
||||
浏览器”会话生命周期操作已统一标为“关闭浏览器”,并在视觉和文案上与停止加载清楚区分。
|
||||
|
||||
活动 Conversation 尚无浏览器会话时,可直接从地址栏输入 URL 并“前往”,通过浏览器组件
|
||||
可用性检查和同一 URL 策略创建会话。该手动路径不读取 Agent 浏览器开关。实现复用现有每
|
||||
Conversation 串行队列和有界状态,不引入恢复、版本化或第二套会话框架。
|
||||
|
||||
### P0:百度输入时元素引用失效,已修复
|
||||
|
||||
@@ -133,23 +154,34 @@ Linux x64 和 Linux arm64。
|
||||
区分代理、分区、窗口、CDP 初始化、DNS 或页面导航失败。需要增加有界、脱敏的
|
||||
阶段错误码,并保留用户可执行的修复建议。
|
||||
|
||||
### P1:命名浏览器配置尚未用于执行
|
||||
### P1:命名浏览器配置尚未用于执行,界面已隐藏
|
||||
|
||||
设置中的命名浏览器配置当前只保存未来托管隔离所需的元数据。实际执行仍使用
|
||||
每个会话随机创建的临时分区,不复用登录状态。
|
||||
命名浏览器配置当前只保存未来托管隔离所需的元数据,实际执行仍使用每个会话
|
||||
随机创建的临时分区,不复用登录状态。为避免用户误以为该配置已经生效,设置
|
||||
界面暂不显示创建、选择、重命名或删除入口;底层数据与兼容接口继续保留。
|
||||
|
||||
## 下一步顺序
|
||||
|
||||
1. 在便携版真实模型流程中继续验证返回、取消和右侧画面刷新。
|
||||
1. 在便携版真实模型流程中继续验证 OpenCode、Continue 的导航、返回、取消和右侧画面刷新。
|
||||
2. 再设计命名隔离 Profile 的持久登录态、引用和清理策略。
|
||||
3. 分别实现并真机验证 Windows UIA、macOS AX 和 Linux 原生适配器。
|
||||
|
||||
## 最近验证
|
||||
|
||||
- `npm test`:591 项通过,6 项跳过。
|
||||
- `npm test`:3496 项通过,53 项跳过。
|
||||
- `npm run typecheck`:通过。
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过。
|
||||
- `GOODBUDDY_RUN_RUNTIME_E2E=1 ... -t "calls the assigned built-in browser
|
||||
through bundled OpenCode"`:通过,真实模型上报 2 次模型调用,OpenCode 经请求级
|
||||
GoodBuddy MCP 调用 `browser_navigate` 并保留正确 Conversation 归属。
|
||||
- `GOODBUDDY_RUN_RUNTIME_E2E=1 ... -t "calls the assigned built-in browser
|
||||
through bundled Continue"`:通过,Continue 经同一路径调用 `browser_navigate` 并
|
||||
返回预期结果;当前 Continue Host 为该完整工具轮次上报 1 条聚合模型用量事件。
|
||||
- 聚焦的能力迁移、网关、IPC、设置界面测试共 234 项通过。其中覆盖 Agent 浏览器
|
||||
开关关闭时用户手动浏览器操作仍可用。
|
||||
|
||||
以上自动化结果覆盖协议、安全边界和模拟驱动,不替代真实 Electron
|
||||
BrowserWindow/WebContentsView、目标网站和目标 Linux 桌面环境的集成验证。
|
||||
本轮曾尝试用隔离用户目录启动第二个 Electron 实例,但当前已运行的正式 GoodBuddy
|
||||
单实例锁阻止了该实例继续运行;未关闭或附着用户正在使用的正式实例。
|
||||
@@ -0,0 +1,11 @@
|
||||
# 持续学习
|
||||
|
||||
持续学习定义 GoodBuddy 如何从运行结果、评估证据和人工反馈中形成可验证的改进闭环。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [持续学习与评估门 PRD](./prd.md) | 产品目标、学习边界、评估门和验收要求 |
|
||||
|
||||
当前尚无独立 User Stories、功能逻辑、UI、技术设计和进度文档。
|
||||
@@ -5,13 +5,14 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中,远期能力 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-08-13 |
|
||||
| 依赖 | [自动化平台总体设计](./automation-platform-architecture.md)、[并行实验 PRD](./parallel-experiments-prd.md)、[分区记忆 PRD](./partitioned-memory-prd.md) |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-08-19 |
|
||||
| 依赖 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md)、[并行实验 PRD](../parallel-experiments/prd.md)、[分区记忆 PRD](../memory/prd.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
智能心跳已经可以生成摘要、后续任务和记忆候选,但这还不是完整学习:
|
||||
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。它与分区记忆的协同尚未
|
||||
实施,也不承担持续学习、模式挖掘或自动改进:
|
||||
|
||||
- 候选是否改善未来行为没有评估。
|
||||
- 一条反思是否会被检索和使用并不确定。
|
||||
@@ -82,8 +83,8 @@ Observe
|
||||
## 5. 候选来源
|
||||
|
||||
- 用户对回答、任务或 Supervisor 意见的显式反馈。
|
||||
- 智能心跳提出的重复模式。
|
||||
- 自动化 Run 的成功与失败比较。
|
||||
- 用户对心跳报告或建议的显式反馈。
|
||||
- Task 执行的成功与失败比较。
|
||||
- 并行实验结论。
|
||||
- 回放评估发现的稳定差异。
|
||||
- 用户手动创建。
|
||||
@@ -301,7 +302,8 @@ Shadow 达到配置的最小观察数且无安全退化后进入 `awaiting_appro
|
||||
|
||||
## 14. 信息架构
|
||||
|
||||
建议在自动化中心增加“学习”:
|
||||
若远期验证确有集中学习管理需求,应提供独立且可审计的“学习”视图,而不是放入智能心跳、
|
||||
任务中心或一个尚未确认的自动化中心:
|
||||
|
||||
1. **候选**:来源、作用域、预期收益和风险。
|
||||
2. **评估中**:进度、案例和预算。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 会话 HTML 渲染
|
||||
|
||||
会话 HTML 渲染让用户直接查看 Agent 回复中的网页结果,而不必先复制代码到外部文件。
|
||||
该能力默认开启,也可以在“设置 > 平台功能 > 通用设置”中关闭。
|
||||
|
||||
## 功能边界
|
||||
|
||||
- 只渲染已完成的 Agent 正文和已完成的子 Agent 输出。
|
||||
- 支持完整 HTML 文档、单一 HTML 根元素,以及 Markdown 中的 `html` / `htm` 代码块。
|
||||
- 使用无脚本、无网络、无表单提交和无窗口操作权限的静态隔离预览。
|
||||
- 每个预览提供源码切换和全屏预览两个图标操作;全屏预览沿用同一清理和隔离边界。
|
||||
- 用户消息、推理过程和流式中的回复不执行 HTML;关闭功能后继续显示 HTML 源码。
|
||||
- 本功能不把普通聊天回复复制为成果,也不替代助手工作栏中独立成果的预览流程。
|
||||
|
||||
## 文档
|
||||
|
||||
- [产品需求](./prd.md):功能范围、状态规则和验收标准。
|
||||
- [技术设计](./technical-design.md):设置持久化、渲染边界与静态隔离方案。
|
||||
- [实现进度](./progress.md):已验证实现和验证记录。
|
||||
|
||||
## 术语
|
||||
|
||||
- **HTML 源码**:Agent 回复中原始、可复制的 HTML 文本。
|
||||
- **静态预览**:在隔离 `iframe` 中显示的 HTML;脚本、联网和外部操作均被禁用。
|
||||
- **全屏预览**:在应用级 Modal 中显示同一份静态预览文档,用于查看完整页面布局。
|
||||
|
||||
全局聊天和设置界面规则由根目录的
|
||||
[统一界面设计系统](../../../UI-DESIGN.md)定义。独立成果预览仍由
|
||||
[助手工作栏](../assistant-workbar/README.md)负责。
|
||||
@@ -0,0 +1,69 @@
|
||||
# 会话 HTML 渲染产品需求
|
||||
|
||||
## 用户问题
|
||||
|
||||
Agent 可以生成网页、报告和交互原型的 HTML,但此前会话只能显示源码或忽略原始标签。
|
||||
用户必须把内容复制到外部文件后才能查看结果,打断了会话内的阅读和迭代流程。
|
||||
|
||||
## 目标
|
||||
|
||||
- 让 Agent 生成的 HTML 在回复完成后直接显示为网页预览。
|
||||
- 保留一键查看原始 HTML 的能力。
|
||||
- 允许用户通过平台功能开关恢复为只看源码。
|
||||
- 预览不取得 GoodBuddy Renderer 或外部网络能力。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不执行 Agent 提供的 JavaScript。
|
||||
- 不提供联网网页、表单、弹窗或 Electron `webview`。
|
||||
- 不在流式输出中反复加载不完整页面。
|
||||
- 不自动把普通会话回复保存为成果。
|
||||
|
||||
## 功能需求
|
||||
|
||||
### FR-1 默认设置
|
||||
|
||||
“会话 HTML 渲染”是应用级持久设置,默认开启。用户可在“设置 > 平台功能 >
|
||||
通用设置”中立即关闭或重新开启;保存失败时保留最后确认的状态并显示错误。
|
||||
|
||||
### FR-2 可识别输出
|
||||
|
||||
开启后,已完成 Agent 回复中的以下内容显示为静态预览:
|
||||
|
||||
1. 完整 HTML 文档。
|
||||
2. 由单一 HTML 根元素构成的完整回复。
|
||||
3. Markdown 中标记为 `html` 或 `htm` 的代码块。
|
||||
|
||||
普通 Markdown 继续按原有规则显示。一个回复中的多个 HTML 代码块各自保持原有位置。
|
||||
|
||||
### FR-3 回复状态和角色
|
||||
|
||||
- 已完成的主 Agent 正文和已完成的子 Agent 输出可以预览 HTML。
|
||||
- 用户消息和 Agent 推理只显示文本或源码。
|
||||
- 流式 Agent 正文在完成前显示源码,完成后原位切换为预览。
|
||||
|
||||
### FR-4 源码、全屏与关闭行为
|
||||
|
||||
每个预览持续显示“静态安全预览,脚本和网络已禁用”,并在标题右侧提供两个图标操作:
|
||||
|
||||
1. 源码按钮,就地展开或收起原始 HTML。
|
||||
2. 全屏按钮,在应用级 Modal 中查看同一份预览文档。
|
||||
|
||||
嵌入预览使用固定高度并在框内滚动,需要更大画面时使用全屏预览。全屏预览可通过关闭
|
||||
按钮、Escape 或点击遮罩退出,退出后焦点回到全屏按钮。关闭平台功能后,完整 HTML 回复
|
||||
和 HTML 代码块继续显示源码,不能变成空白。
|
||||
|
||||
### FR-5 静态隔离
|
||||
|
||||
预览必须禁用脚本、网络请求、表单提交、嵌套页面、插件对象和窗口操作。远程链接和远程
|
||||
媒体不得从预览加载;内联样式和有界的内联位图可用于还原静态视觉。
|
||||
|
||||
## 产品验收
|
||||
|
||||
- 新安装和从旧设置升级后,开关均为开启。
|
||||
- 完整 HTML 和 fenced HTML 在已完成的 Agent 回复中显示预览。
|
||||
- 关闭开关后,当前会话立即恢复显示源码,重启后选择仍保留。
|
||||
- 流式回复、推理和用户消息从不执行 HTML。
|
||||
- 预览可通过键盘访问源码和全屏按钮,并具有明确的 iframe 标题。
|
||||
- 全屏预览隔离背景、初始聚焦关闭按钮,Escape 退出后焦点回到全屏按钮。
|
||||
- 恶意脚本、事件属性、远程资源、表单和嵌套 frame 不进入可执行预览。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 会话 HTML 渲染实现进度
|
||||
|
||||
## 已实现
|
||||
|
||||
- [x] 应用级持久开关,默认开启,并提供旧设置迁移。
|
||||
- [x] 平台功能通用设置中的共享 Switch。
|
||||
- [x] 已完成 Agent 回复和子 Agent 输出中的完整 HTML 与 fenced HTML 识别。
|
||||
- [x] 隔离静态预览、持续安全说明和源码切换。
|
||||
- [x] 预览保留作者 `<style>` 与 `style` 属性,样式不再被整块丢弃。
|
||||
- [x] 与 Mermaid 一致的图标按钮组:源码切换与全屏预览 Modal。
|
||||
- [x] 嵌入预览固定 480px 高度,源码面板同值上限。
|
||||
- [x] 用户消息、推理、流式回复和关闭状态的源码边界。
|
||||
- [x] 设置变更后当前会话立即重新渲染。
|
||||
|
||||
## 验证记录
|
||||
|
||||
2026-09-05:
|
||||
|
||||
- `npm run typecheck`:通过。
|
||||
- 聚焦测试(应用设置、Markdown、会话时间线、设置中心、App 集成):306 项通过。
|
||||
- 全量 `npm test`:`3493 passed, 55 skipped`。
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:Main、Preload、Renderer 与控制面安装器均成功构建。
|
||||
|
||||
修复样式丢失后复验:
|
||||
|
||||
- 原实现只要 `<style>` 或 `style` 属性中出现 `@import` / `url()` 就整块删除,带渐变或
|
||||
背景图的回复因此完全失去样式;现改为保留作者样式,外部资源加载仍由 iframe CSP 拒绝。
|
||||
- `npx vitest run src/renderer/src/MarkdownRenderer.test.tsx`:`19 passed`。
|
||||
- 聚焦测试(会话时间线、App 集成、设置中心):`260 passed`。
|
||||
- `npm run typecheck`、`npm run lint`:通过。
|
||||
|
||||
新增全屏预览与图标化源码按钮后复验:
|
||||
|
||||
- 源码按钮改为代码图标并暴露 `aria-expanded`;新增放大图标打开全屏 Modal。
|
||||
- 嵌入预览高度由 `min-height: 320px` 改为固定 `height: 480px`,源码面板上限同步为 480px。
|
||||
- 新增回归测试覆盖全屏 Modal 的初始焦点、背景 inert、Sandbox/CSP 与 Escape 焦点恢复。
|
||||
- 聚焦测试(Markdown、会话时间线、App 集成、设置中心):`280 passed`。
|
||||
- `npm run typecheck`、`npm run lint`:通过。
|
||||
@@ -0,0 +1,68 @@
|
||||
# 会话 HTML 渲染技术设计
|
||||
|
||||
## 进程和设置边界
|
||||
|
||||
`conversationHtmlRenderingEnabled` 属于现有 `ApplicationSettings`。Main 继续通过
|
||||
版本化 JSON 文件持久化应用设置,IPC 继续复用已验证的
|
||||
`settings:application:get/update` 通道,Preload 只暴露类型化设置方法。
|
||||
|
||||
应用设置格式升级到版本 11。版本 10 及更早设置在读取时补入默认值 `true`,无需保留第二份
|
||||
状态或独立迁移文件。
|
||||
|
||||
## Renderer 数据流
|
||||
|
||||
1. `App` 启动时读取应用设置,并把当前值传给每个保活的会话视图。
|
||||
2. 平台功能 Switch 保存成功后立即更新 `App` 状态。
|
||||
3. `ChatTimeline` 只为 `role === "assistant"` 且 `state === "complete"` 的正文传入
|
||||
HTML 渲染权限;推理始终不传入该权限。
|
||||
4. `MarkdownRenderer` 识别完整 HTML 回复以及 `html` / `htm` fenced code。
|
||||
5. 关闭权限或回复仍在流式阶段时,HTML 保持代码文本。
|
||||
|
||||
## 静态预览
|
||||
|
||||
`StaticHtmlPreview` 不使用 `dangerouslySetInnerHTML`。它先用 DOMPurify 清理文档,再通过
|
||||
`DOMParser` 移除事件属性、非锚点 `href` 和非内联图片的 `src` / `poster`,最后把结果写入
|
||||
`<iframe sandbox="" srcDoc>`。
|
||||
|
||||
作者样式必须保留:`<style>` 元素和 `style` 属性原样传入预览,不按 `@import` 或 `url()`
|
||||
关键字整块删除,否则任何带渐变或背景图的回复都会退化成无样式文档。外部样式表和远程
|
||||
字体、图片的实际加载由 iframe 文档的 CSP 拒绝,不需要在清理阶段重复拦截。
|
||||
|
||||
iframe 文档注入严格 CSP:
|
||||
|
||||
- `default-src 'none'`
|
||||
- `script-src 'none'`
|
||||
- `connect-src 'none'`
|
||||
- `frame-src 'none'`
|
||||
- `object-src 'none'`
|
||||
- `form-action 'none'`
|
||||
- 只允许内联样式以及 `data:` 字体和受支持的内联位图
|
||||
|
||||
同时设置 `referrerPolicy="no-referrer"`。Sandbox 不授予 scripts、forms、popups、
|
||||
same-origin 或 navigation 权限。预览只在回复完成后创建,并使用 lazy loading。
|
||||
|
||||
## UI 与无障碍
|
||||
|
||||
预览沿用聊天阅读宽度、语义颜色和共享 `icon-button`。`figure` / `figcaption` 表达预览及其
|
||||
说明,iframe 具有本地化标题。标题右侧是一个 `role="group"` 图标按钮组,与 Mermaid 图表
|
||||
操作保持一致:源码按钮暴露 `aria-expanded` 与 `aria-controls`,全屏按钮打开 Modal。
|
||||
|
||||
嵌入预览高度固定为 480px,超出部分在框内滚动;查看源码面板使用同一数值作为最大高度。
|
||||
iframe 无法按内容自适应高度,因为 `sandbox=""` 不含 `allow-same-origin` 或 `allow-scripts`,
|
||||
主文档既读不到内部文档尺寸,也不能接收上报;全屏预览覆盖需要更多空间的场景。
|
||||
|
||||
全屏预览复用 `dialog-focus` 的 `activateModalFocus` 与 `trapTabFocus`,遵循统一 Modal 的
|
||||
背景 inert、初始焦点落在关闭按钮、Tab 循环、Escape 关闭和关闭后焦点恢复。它渲染的是与
|
||||
嵌入预览完全相同的清理后文档,不额外放宽 Sandbox 或 CSP。切换回复时,源码面板与全屏
|
||||
预览按当前 source 派生开合状态,不使用会引发级联渲染的重置 effect。
|
||||
|
||||
## 验证范围
|
||||
|
||||
- 应用设置默认值、关闭持久化和版本 10 迁移。
|
||||
- fenced HTML、完整 HTML、关闭后的源码回退。
|
||||
- 作者 `<style>` 与 `style` 属性在预览中保留。
|
||||
- 脚本、远程资源、表单和事件属性清理,以及 iframe Sandbox/CSP。
|
||||
- 源码按钮的 `aria-expanded` 切换,以及全屏预览的初始焦点、背景 inert、Escape 关闭和
|
||||
焦点恢复。
|
||||
- 主 Agent、子 Agent、用户、推理和流式状态边界。
|
||||
- 从设置 Switch 到当前会话重新渲染的应用级集成路径。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 会话媒体生成
|
||||
|
||||
实现状态:已完成。纳入 Desktop 0.13.5 / Agent 0.13.0 候选,尚未发布。日期:2026-09-15。实际验证范围单独见[进度记录](./progress.md)。
|
||||
|
||||
用户添加图片模型后,可以开启“允许 AI 在会话中调用”。支持工具调用的聊天模型通过 GoodBuddy 生成图片、编辑上传图片和历史成果。切换聊天模型或 Agent Runtime 后仍可发起生成和编辑;已提交操作固定生成模型,结果保存到原会话。
|
||||
|
||||
本目录负责普通 LLM 调用媒体模型的产品行为、调用入口和生命周期。现有直连图片协议及连续修改仍由[图片生成](../image-generation/README.md)维护;本设计不表示现有功能已改变。
|
||||
|
||||
## 范围
|
||||
|
||||
图片生成与编辑覆盖直连文本 Model Runtime、本地 OpenCode、Continue、DeepSeek Harness,以及 GoodBuddy Agent 远程路径上的适用 Runtime。复用已有图片协议和各 Runtime 的内部工具或 MCP 注入,由 GoodBuddy 自动管理;用户无需创建 Skill、搭建 MCP server 或重复配置凭据。
|
||||
|
||||
使用稳定工具 `generate_image` 完成生成与编辑。全局默认和本次指定模型足够,不新增会话级选择器或独立生成表单。无工具能力的聊天模型明确提示限制,可使用已有直连图片工作流。视频不在本设计范围;完整需求以[产品需求](./prd.md)为准。
|
||||
|
||||
图片能力在现有内置 MCP 列表持续可见,Runtime 分配以只读复选框展示,由图片模型的会话调用设置自动管理;没有开启的模型时全部取消勾选,仍保留该行和模型设置入口。分配规则见[行为规则](./logic-design.md),展示与说明见[界面设计](./ui-design.md)。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [产品需求](./prd.md) | 范围、需求编号与产品验收 |
|
||||
| [用户场景](./user-stories.md) | 用户操作及场景验收 |
|
||||
| [行为规则](./logic-design.md) | 选择优先级、切换规则、状态和失败处理 |
|
||||
| [界面设计](./ui-design.md) | 配置入口、会话操作、结果卡片和反馈 |
|
||||
| [技术设计](./technical-design.md) | 工具注入、Main 服务、存储与 Runtime 接入 |
|
||||
| [进度记录](./progress.md) | 当前代码事实、剩余工作与验证证据 |
|
||||
|
||||
## 术语与依赖
|
||||
|
||||
聊天模型负责理解请求、调用工具和组织回答;生成模型负责图片生成与编辑;Agent Runtime 承载聊天模型及工具执行。工具提供结构化调用入口,使用说明随工具提供,不要求用户管理 Skill。
|
||||
|
||||
“生成操作”指一次图片生成或编辑及其结果记录,不自动创建任务中心的 Task,也不引入新的通用 Job 调度系统。领域边界遵循 [Task 与 Job 模型](../task-and-job/task-and-job-model.md)。
|
||||
|
||||
- [模型连接](../model-connections/README.md):连接、凭据、协议及请求定制。
|
||||
- [直连文本 Agent](../direct-model-agent/README.md):文本工具循环与上下文。
|
||||
- [远程主机](../remote-host/README.md):远程 Runtime 与桌面通信。
|
||||
- [统一界面规则](../../../UI-DESIGN.md):控件、反馈和可访问性。
|
||||
@@ -0,0 +1,78 @@
|
||||
# 行为规则
|
||||
|
||||
状态:设计草案。此文是模型选择、切换与生成状态的权威定义,范围见[产品需求](./prd.md)。L-1 至 L-5 编号保留,以下规则替代原视频、会话级选择及通用查询恢复规则。
|
||||
|
||||
## 能力与调用时点
|
||||
|
||||
L-1(FR-1、FR-3、FR-7、FR-9;US-01、US-08、US-11、US-15 至 US-19):自动生成与编辑要求图片连接允许会话调用、协议支持所需操作、聊天模型支持工具调用且当前请求处于 Execute。直连文本 Model Runtime、本地 OpenCode、Continue、DeepSeek Harness 及 GoodBuddy Agent 远程适用 Runtime 均须接入同一图片能力,内部工具或 MCP 注入由 GoodBuddy 管理。
|
||||
|
||||
允许调用的模型目录取自已保存且“允许 AI 在会话中调用”开启的图片模型配置。目录非空时,图片生成与编辑能力的全部支持 Runtime 分配自动勾选;目录为空时全部取消勾选。模型配置是唯一事实来源,不保存独立可变的能力启用或分配设置,用户不能操作分配复选框。
|
||||
|
||||
| 配置或运行变化 | 分配与目录结果 |
|
||||
| --- | --- |
|
||||
| 首次开启或重新开启一个图片模型 | 自动勾选全部支持 Runtime,目录包含该模型 |
|
||||
| 关闭或删除一个模型,仍有其他开启模型 | 分配保持勾选,从目录移除该模型 |
|
||||
| 最后一个开启模型被关闭或删除,或从未开启任何模型 | 全部分配取消勾选,目录为空;能力行、说明和模型设置入口仍可见 |
|
||||
| Runtime 离线、聊天模型无工具能力、Ask、配置或执行错误 | 保持按模型开关推导的勾选和目录,另行显示调用限制或错误 |
|
||||
|
||||
配置保存成功后同步刷新分配展示和允许模型目录;保存失败时保留最后已保存配置推导的状态。分配不表示运行就绪,也不保证每个图片模型支持编辑;协议及实际提交校验仍适用。
|
||||
|
||||
请求开始和聊天模型、Runtime、配置切换后刷新可用能力;进程复用也须刷新。运行中的聊天回合保留原工具协议,Main 在实际提交前检查最新配置及工作模式,已关闭的连接不能接受新操作。无工具模型说明限制并提示已有直连图片工作流;Ask 不提交生成或编辑,不自动切换模式。Runtime 接入尚未完成属于实施缺口,不能作为减少支持范围的规则。
|
||||
|
||||
## 模型和素材选择
|
||||
|
||||
L-2(FR-2、FR-5;US-02、US-03、US-05、US-07、US-14、US-15):先确定新建或编辑,再选择图片模型。
|
||||
|
||||
| 操作 | 选择优先级,由高到低 |
|
||||
| --- | --- |
|
||||
| 新建图片 | 用户本次明确指定 > 全局默认 |
|
||||
| 编辑历史成果 | 用户本次明确指定 > 原成果模型 > 全局默认 |
|
||||
| 编辑上传图片(无来源模型) | 用户本次明确指定 > 全局默认 |
|
||||
|
||||
只有较高优先级未设置时才查看下一项。选定模型被删除、关闭或不支持编辑时提示改选,不静默跳过。未设默认时提示本次指定或去设置默认后重新发送;名称歧义先澄清,LLM 不得编造模型 ID。不持久化会话级生成模型选择。
|
||||
|
||||
显式附件或点选成果优先。“刚才那张”等指代仅在对象唯一时解析;多义指代先澄清。新建不相关图片不附带旧图。编辑能力不足或原图缺失时保留输入并提示,不把编辑自动降为文生图;用户可以明确要求按文字重新生成。已有直连图片路径的行为仍由[图片技术设计](../image-generation/technical-design.md)定义。
|
||||
|
||||
## 切换规则
|
||||
|
||||
L-3(FR-4;US-03 至 US-07、US-11、US-15):Main 接受提交时固定原会话、触发消息、实际图片模型、当次连接参数、生成参数及素材引用。未提交请求在实际提交时解析默认值并校验显式选择;提交后不重选模型。图片请求由 Main 持有,独立于聊天 Runtime 生命周期。
|
||||
|
||||
| 变化 | 未提交操作 | 已提交操作与成果 |
|
||||
| --- | --- | --- |
|
||||
| 切换聊天模型 | 新回合使用新模型及可用工具,无工具能力时说明限制 | 图片模型不变,操作独立继续 |
|
||||
| 切换本地或远程 Runtime | 新回合注入图片工具,可发起生成和编辑 | 不依赖旧进程,不向新进程补发旧工具响应,不重放生成 |
|
||||
| 更改全局默认或本次指定 | 新操作按 L-2 解析 | 保持提交时模型 |
|
||||
| 关闭或删除连接 | 拒绝新操作并提示改选 | 已发请求使用当次配置尽力完成 |
|
||||
| 更改地址、凭据或参数 | 新操作使用新配置 | 当前操作不混用新端点或凭据 |
|
||||
| 离开或重开会话 | 使用当前请求及全局配置 | 只更新原会话,历史成果仍可引用 |
|
||||
| 远程断连 | 未被 Main 接收的请求不能声称已提交 | 桌面仍运行时由 Main 继续管理已接收操作 |
|
||||
|
||||
“停止回复”只结束文字回合;“取消生成”单独处理图片操作。切到 Ask 不撤销先前 Execute 已提交的操作,也不允许新增生成。运行时切换不自动唤起新的 LLM 请求。
|
||||
|
||||
## 操作状态
|
||||
|
||||
L-4(FR-6、FR-8;US-04、US-09、US-13):复用现有操作与成果反馈,状态只表达实际知道的事实。没有上游百分比时显示状态及等待时间,不虚构进度。
|
||||
|
||||
| 状态表达 | 条件与处理 |
|
||||
| --- | --- |
|
||||
| 进行中 | 正在发送或等待响应;仅有上游确认时才说明已接受生成 |
|
||||
| 保存中 | 已取得图片,Main 正在校验并保存 |
|
||||
| 已完成 | 图片及会话引用已保存,可重新读取 |
|
||||
| 取消中 | 已请求终止,尚无明确结果 |
|
||||
| 已停止等待/已取消 | 本地中断只说明“已停止等待,上游可能继续生成”;上游确认停止才说明“生成已取消” |
|
||||
| 失败 | 有明确的请求或保存失败原因,保留输入和错误上下文 |
|
||||
| 结果未确认 | 响应丢失、超时或重启后无法确认结果,不当作上游失败或取消 |
|
||||
|
||||
同一操作的通知更新同一记录。取消后仍取得图片时,在原卡片保存并说明“取消未生效,已生成”,不恢复旧工具回合。超时不等于上游取消,不承诺退款。
|
||||
|
||||
人工重新生成创建新操作,沿用原模型与输入并重新校验;改选模型时在新卡片显示实际选择。失败、结果未确认及保存失败均不自动重发收费请求,不设置独立保存重试入口。应用退出后不承诺继续本地执行,重启时未完成记录显示结果未确认,不建立跨重启查询恢复或通用恢复框架。
|
||||
|
||||
## 历史成果
|
||||
|
||||
L-5(FR-5、FR-8;US-07、US-12、US-14、US-15):历史上下文提供成果引用、提示词及实际图片模型信息。只有具备视觉输入能力的聊天模型才接收适用的图片内容;无视觉模型可根据用户文字将原图引用交给编辑工具,但不能声称看过图片。上传图片和历史成果均使用已有可持久化素材引用。
|
||||
|
||||
删除会话沿用已有确认流程,说明活动生成的取消及无法保证上游停止的情况;删除后的晚到结果不能重建会话。
|
||||
|
||||
## 完整性与实施验证
|
||||
|
||||
规则覆盖配置、新建、上传图与历史图编辑、模型与 Runtime 切换、Ask、无工具或无视觉模型、取消、失败、重启和会话归属。没有待定产品范围;各 Runtime 工具注入、远程往返及取消传播的具体接法需在实施时核验,按[技术设计](./technical-design.md)完成真实路径验收。视频、跨设备同步和应用退出后的本地执行不在范围内。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 产品需求
|
||||
|
||||
状态:设计草案。本文描述目标行为,不作为现有能力说明。
|
||||
|
||||
## 用户问题与范围
|
||||
|
||||
用户希望在文字会话中直接提出“画一张海报”“把上传图片的背景改成蓝色”“继续修改刚才的图片”,由聊天助手调用已配置的图片模型。切换聊天模型或 Runtime 后,应能继续生成、引用和编辑图片。
|
||||
|
||||
范围包括图片模型调用开关、全局默认与本次指定、文生图、已有协议支持的上传图片和历史图片编辑、成果保存、取消及状态反馈。支持直连文本 Model Runtime、本地 OpenCode、Continue、DeepSeek Harness,以及 GoodBuddy Agent 远程路径上的适用 Runtime;各路径的生成、编辑和切换均属于验收范围。
|
||||
|
||||
GoodBuddy 自动管理内部工具或 MCP 注入,复用模型连接与凭据。用户无需创建 Skill、搭建 MCP server 或填写第二套凭据。自动调用要求聊天模型支持工具调用;无工具能力时明确说明限制,用户可使用已有直连图片工作流。
|
||||
|
||||
不包含视频生成、模型训练、自动采购服务、通用文本指令执行器、批量渲染平台或应用退出后继续本地执行的承诺。不新增图片协议、会话级生成模型选择器、独立生成表单或通用恢复框架,也不安排视频交付计划。
|
||||
|
||||
## 功能需求
|
||||
|
||||
保留 FR-1 至 FR-9 编号。原需求中的视频部分移出范围,FR-2 的会话级选择由全局默认和本次指定替代,FR-7 的新增显式入口由已有直连图片工作流替代;下表为当前定义。
|
||||
|
||||
| 编号 | 要求 | 产品验收 |
|
||||
| --- | --- | --- |
|
||||
| FR-1 | 图片模型提供“允许 AI 在会话中调用”开关;新增和已有连接默认关闭,由用户主动开启;模型配置是图片能力启用与 Runtime 分配的唯一事实来源 | 至少一个模型开启时自动勾选全部支持 Runtime;全部关闭或删除时全部取消勾选;关闭或删除其中一个后若仍有开启模型,保持勾选并更新允许调用的模型目录;被关闭模型拒绝新操作,历史成果保留 |
|
||||
| FR-2 | 支持全局默认和本次明确指定;编辑可沿用原成果模型 | 按[选择规则](./logic-design.md)解析并显示实际模型;失效或有歧义时提示,不静默换服务 |
|
||||
| FR-3 | 在支持工具调用的聊天模型下,以自然语言生成和编辑图片 | 上传图片、历史图片及文生图均可经真实服务处理,保存后在原会话展示,活动记录可见 |
|
||||
| FR-4 | 支持聊天模型、Runtime 及生成模型切换 | 切换后可发起新的生成和编辑;已提交操作固定原模型和原会话,不重复提交 |
|
||||
| FR-5 | 支持上传素材、历史成果引用与连续编辑 | 重开会话及切换 Runtime 后可引用已保存原图;缺失素材或能力不足时保留输入并提示处理方式 |
|
||||
| FR-6 | 生成操作独立显示状态,支持取消及人工重新生成 | 离开会话不取消;保存成功才完成;不虚构进度,不自动重试收费请求;重启后未完成操作显示结果未确认 |
|
||||
| FR-7 | 遵循工作模式及模型能力边界 | Ask 不提交生成或编辑;无工具模型说明限制,可使用已有直连图片工作流,无需新增表单 |
|
||||
| FR-8 | 图片作为会话成果保存 | 可预览、导出和重新打开,来源模型可追溯;无视觉聊天模型只接收文字事实及引用,不编造观察 |
|
||||
| FR-9 | 本地及 GoodBuddy Agent 远程适用 Runtime 均支持图片工具;GoodBuddy 管理的图片生成与编辑能力及 Runtime 分配持续可见 | 分别完成真实生成、编辑、切换验收;分配复选框只读,不可手动切换;无开启模型时仍显示该行、未勾选状态、自动管理说明及模型设置入口;无独立能力启停、分配、移除或凭据编辑设置,用户无需维护 Skill 或 MCP server |
|
||||
|
||||
分配表示模型配置允许的调用范围。聊天模型不支持工具、Ask、Runtime 离线、配置错误或执行失败均单独说明,不取消配置分配的勾选;不保存可独立修改的能力启用或 Runtime 分配状态。具体推导与刷新规则由 L-1 定义。
|
||||
|
||||
## 产品验收
|
||||
|
||||
以[用户场景](./user-stories.md)验收。各 Runtime 分别记录真实工具往返、生成、上传图编辑、历史图编辑及切换结果,不能用单一路径成功代替其他路径。配置检查或工具注入成功不等于生成成功,成果必须可在会话中重新打开。
|
||||
|
||||
Runtime 接入细节和真实 Host 验证属于实施工作,未验证时如实记录未完成,不据此缩减 FR-9 的支持范围。
|
||||
@@ -0,0 +1,83 @@
|
||||
# 进度记录
|
||||
|
||||
## 2026-09-15
|
||||
|
||||
### 真实图片请求与构建修复
|
||||
|
||||
已使用现有 `gpt-image2` 连接(模型 `sub-gpt-image-2`)经生产 `ImageGenerationService` 发起两次真实图片请求:生成白底蓝色圆形,再上传该成果将圆形改成红色。两次均 HTTP 200,保存并重新解码成功,导出图片目视检查符合要求。请求数为 2,自动重试为 0,用户保存的配置未修改。请求尺寸为 1024×1024,供应商实际返回两张 1254×1254 PNG;未对结果重采样。证据保存在本机临时目录 `image-bounded-oiOPRi/`。此项验证 Main 图片服务与真实供应商,尚不替代真实聊天模型经各 Runtime 自行调用工具及完整界面切换验收。
|
||||
|
||||
用户报告 Renderer 生产构建失败,定位到 `remote-image-tool-contracts.ts` 的 `node:crypto` 被共享契约间接引入浏览器。已将 MCP 名称哈希移至仅 Main 与 Agent 引用的 `remote-image-tool-node.ts`,共享消息编解码使用标准 `TextEncoder`/`TextDecoder`,无新增依赖或 polyfill。修复后 `npm run build`(含类型检查、Main、Preload、Renderer 构建)、`npm run lint` 通过,4 个相关测试文件共 89 项通过。此前仅运行类型检查和测试未发现这一构建问题,不能作为生产构建通过证据。
|
||||
|
||||
### 实施与集成复验
|
||||
|
||||
工作区已有模型会话调用开关、默认图片模型、配置派生的四种 Runtime 只读分配、Main 图片服务、上传与历史成果引用、操作状态及取消/重新生成 IPC。本地 Model、OpenCode、Continue、DeepSeek Harness 和受管远程 OpenCode 均已接入同一服务。操作复用消息 metadata 和成果存储,未新增任务表或独立分配设置。
|
||||
|
||||
本轮修复远程保留会话在图片目录清空后未刷新 MCP,以及 Continue 准备自定义 MCP 失败时未释放本次图片工具令牌的问题。图片卡片更新保持原顺序;取消提示区分请求取消、停止等待和取消未生效后的结果保存。失败卡片提供模型设置与素材重选入口,无工具能力及 Ask 有相应提示。长名称和提示词自动换行,窄窗口及 200% 缩放下恢复按钮可换行。
|
||||
|
||||
验证证据如下;此前小节中的超时和未验证记录仅代表各次检查时点:
|
||||
|
||||
| 验证 | 结果与边界 |
|
||||
| --- | --- |
|
||||
| `npm test` | 364 个文件通过、9 个跳过;4,267 项通过、67 项跳过。后续恢复入口及样式修改另有 269 项定向测试通过,未再运行全量测试 |
|
||||
| `npm run typecheck`、`npm run lint` | 最后一次 Renderer 修改后通过 |
|
||||
| 真实 Linux Host | 最终 Agent 构建通过固定主机身份连接,使用实际远程 OpenCode;Ask、Execute 各 1 次真实文本请求均 HTTP 200 并完成,真实图片请求 0 次 |
|
||||
| Host 图片工具往返 | 最终运行含 7 次受控图片请求、15 次受控文本请求;生成、上传和历史图编辑、目录刷新、Ask 隐藏、并发会话归属、停止回复和断连后 Main 继续保存均通过。图片服务返回固定内联 PNG,不构成真实生图证明 |
|
||||
| Electron 界面 | 当前生产组件、样式、字体及双语文案,使用隔离 fixture API;初轮 94 项检查,恢复入口完成后 40 项检查通过。覆盖明暗主题、四种窗口尺寸、键盘导航及 200% 缩放;没有修改用户配置 |
|
||||
|
||||
最终 Host 运行退出码为 0,残留自有进程为 0,隔离目录和上传文件已清理。此前受控复验另有 7 次图片请求和 15 次文本请求,因此本轮合计真实文本请求 2 次、真实图片请求 0 次、受控图片请求 14 次、受控文本请求 30 次。此前历史测试记录不计入此合计。
|
||||
|
||||
本机临时证据位于 `C:/Users/jiang/AppData/Local/Temp/opencode/`:`image-review-full-tests-20260915.stdout.log`、`image-host-final-real-20260915.stdout.log`、`image-host-review-20260915.stdout.log` 及 `image-visual-0915/`。这些是开发检查产物,不随产品发布。
|
||||
|
||||
当时尚未完成真实图片供应商生成与编辑;后续验证见上方记录。完整 Electron Main/Preload 路径中的自然语言触发及本地/远程双向切换验收仍待完成,FR-3、FR-9 尚未完成全部产品验收。
|
||||
|
||||
隔离的真实图片验证脚本离线校验通过后,已按用户要求执行上述两次真实请求。该连接的会话调用仅在验证内存中启用,不修改保存配置。此前 `deai-writing` 文档扫描阻断项为 0,25 个候选经语境复核保留产品边界、状态限制和历史证据;`git diff --check` 通过。
|
||||
|
||||
### Renderer 与设置复查
|
||||
|
||||
本轮在已有未提交实现上修复图片操作卡片:状态事件原位更新,保留同一消息中多次生成的卡片顺序;取消提示区分正在请求取消、已停止等待和取消未生效但图片已保存。按钮通过 `aria-describedby` 关联就地操作错误,中英文提示同步更新。补充组件回归,并在现有 App 测试中检查离开原会话、结果晚到、返回及切换 Runtime 后继续修改时的卡片顺序。
|
||||
|
||||
已检查模型设置、RuntimeSettingsStore、CapabilityService 和共享设置契约。现有实现从已保存模型开关派生四种 Runtime 的只读分配,不持久化独立分配;关闭或删除默认图片模型后保留失效默认。相关测试本轮通过,未改动这些生产契约。共享工具目录测试原先仅排除浏览器工具,现补充图片受管能力例外,确认 `generate_image` 保持写操作且不重复进入直连模型设置分组。
|
||||
|
||||
验证记录:
|
||||
|
||||
- `npx vitest run src/renderer/src/ImageOperationStatus.test.tsx src/renderer/src/App.test.tsx src/renderer/src/SettingsPanel.test.tsx src/main/runtime-settings-store.test.ts src/main/capabilities/capability-service.test.ts`:5 个文件、451 项通过。
|
||||
- 修正目录测试后,`npx vitest run src/shared/builtin-model-tools.test.ts src/renderer/src/ImageOperationStatus.test.tsx`:2 个文件、5 项通过。
|
||||
- `npm run typecheck`、`npm run lint` 通过。
|
||||
- `npm test` 在 120 秒上限被终止;期间报告的共享工具目录测试失败已单独修正并复验,未取得全量测试通过结论。
|
||||
- 未调用真实文本或图片服务,请求数均为 0;未执行 Electron 视觉验收或真实 Host 验证。本轮没有修改 `src/main/agent` 或 `src/agent-daemon`。各 Runtime 的真实生成、编辑、取消及切换验收仍待完成。
|
||||
|
||||
### 此前文档修订记录
|
||||
|
||||
本日补充:已按最新明确接受的要求修订 FR-1、FR-9、L-1、界面与技术设计,并新增 US-16 至 US-19。只读分配及模型配置派生规则见[行为规则](./logic-design.md),受管行、说明与无障碍要求见[界面设计](./ui-design.md)。已读取根目录与 features 的 AGENTS、根 UI-DESIGN.md 及本目录七份现有文档,保留此前未提交修订;本次未修改目录外文件或应用代码,相关行为仍待实现与产品验收。
|
||||
|
||||
本日更正:此前将其他 Runtime 自动调用排除在交付范围之外的记录不符合用户要求,现由本条替代。七份文档已统一为图片生成和编辑,支持范围及验收以[产品需求](./prd.md)和[用户场景](./user-stories.md)为准,不再保留分期规则。视频移出范围,US-10 保留编号并明确标注;其他被替代要求已记录编号变更。
|
||||
|
||||
本次只修改本目录设计文档,未检查或改变应用实现。2026-09-14 记录仅为当日历史证据,不表示已重新验证。图片工具、Main 请求生命周期、各本地及 GoodBuddy Agent 远程适用 Runtime 的生成、编辑和切换均仍需实施与实际验证;具体注入及桥接核验属于实施工作,不缩减支持范围。
|
||||
|
||||
本次文档验证:7 份文档的 38 个相对 Markdown 链接均可解析;一致性审阅覆盖模型配置唯一来源、只读分配、空目录保留行、运行状态独立及完整 Runtime 范围,未发现分期或缩减支持范围的现行规则。`deai-writing` 扫描阻断项为 0,25 个复核候选涉及设计状态、职责与必要行为限制,逐条按语境保留,并通读检查结构及前后规则。`git diff --check -- docs/features/conversation-media-generation` 通过;本次七份文件均已被 Git 跟踪,检查覆盖其工作区差异。该命令不覆盖未跟踪文件,不能沿用此前“目录未跟踪”的结论。未运行应用测试或真实生成请求,未声称功能完成或重新验证此前代码。
|
||||
|
||||
## 2026-09-14(历史记录)
|
||||
|
||||
以下保留当日检查与原设计记录,其中视频、保存重试等旧范围已被 2026-09-15 更正取代,不作为当前实施要求。
|
||||
|
||||
当前阶段:设计草案已落盘,功能未实现。本次只新增设计文档与相关入口链接。
|
||||
|
||||
代码与文档检查确认以下基础能力:
|
||||
|
||||
| 事实 | 证据 |
|
||||
| --- | --- |
|
||||
| 已有直连图片生成、编辑及历史成果引用 | [图片技术设计](../image-generation/technical-design.md)、`src/main/agent/model-runtime.ts` |
|
||||
| 模型连接及 Main 凭据存储已存在 | `src/shared/contracts.ts`、`src/main/runtime-settings-store.ts` |
|
||||
| 已有 Skill 发现、Runtime 分配及工具分发 | `src/main/capabilities/capability-service.ts`、`src/main/agent/model-tool-provider.ts` |
|
||||
| 会话、消息 metadata 和成果已有 SQLite 存储 | `src/main/assistant/assistant-database.ts`、`src/shared/assistant-contracts.ts` |
|
||||
| 生图尚未成为普通 LLM 的内置生成工具 | 检查 `src/shared/builtin-model-tools.ts` 及工具分发路径 |
|
||||
| 本次检查未发现第一方视频生成协议实现 | 检查模型能力契约、Runtime 及现有功能文档;不据此推断外部 MCP 的能力 |
|
||||
|
||||
剩余工作对应[技术设计实施顺序](./technical-design.md):模型调用设置与选择、图片服务及会话入口、各 Runtime 接入、视频真实服务与成果交付均待实施。视频协议选择和远程工具桥接验证待完成。
|
||||
|
||||
本次未修改应用代码,未运行应用测试或真实生成请求。文档验证记录如下:
|
||||
|
||||
- 相对 Markdown 链接检查:7 份新文档及图片功能入口共 33 个链接,失效链接为 0。
|
||||
- `deai-writing` 扫描:阻断项为 0;人工复核保留设计状态、能力边界和禁止行为等必要限定,以及真实并列的验收项。
|
||||
- 独立一致性审阅发现“重新生成”与“重试保存”的操作身份表述不一致,已在 L-4 和 US-09 中统一:前者创建新操作,后者更新原操作。
|
||||
- `git diff --check` 通过。上述检查只验证文档,不构成功能验收。
|
||||
@@ -0,0 +1,81 @@
|
||||
# 技术设计
|
||||
|
||||
状态:工作区实现已接通,真实图片服务与完整产品验收未完成,证据见[进度记录](./progress.md)。内部契约实现 [FR-1 至 FR-9](./prd.md)及[行为规则 L-1 至 L-5](./logic-design.md),不作为外部公开 API。
|
||||
|
||||
## 调用结构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Direct[直连文本 Model Runtime] --> Tool[generate_image]
|
||||
Local[本地 OpenCode / Continue / DeepSeek Harness] --> Injection[GoodBuddy 管理的内部 MCP 注入]
|
||||
Remote[GoodBuddy Agent 远程适用 Runtime] --> Bridge[现有 Agent 通信与工具桥接]
|
||||
Bridge --> Injection
|
||||
Injection --> Tool
|
||||
Tool --> Main[Main 图片服务]
|
||||
Main --> Profile[现有模型连接与凭据]
|
||||
Main --> Adapter[现有图片生成与编辑协议]
|
||||
Main --> Artifact[原会话操作记录与图片成果]
|
||||
```
|
||||
|
||||
Main 持有图片请求、取消句柄及结果保存职责,生命周期独立于聊天 Runtime controller。切换或释放聊天进程不释放已提交图片操作。Renderer 的取消等操作经明确 preload/IPC 方法进入 Main,沿用共享契约和发送方校验。凭据只在 Main 解密,不写入工具说明、参数、输出或远程 Runtime 配置。
|
||||
|
||||
`ImageGenerationService` 复用 `ModelAgentRuntime.generateImage` 的图片请求和校验,在 Main 保存操作及成果。工具路径按 L-2 明确区分新建和编辑,不自动将缺少素材或不支持的编辑降为文生图;已有直连图片路径保持原上下文规则。
|
||||
|
||||
## 工具与注入
|
||||
|
||||
稳定图片工具 `generate_image` 同时负责生成和编辑。当前输入为 `prompt`、可选 `modelProfileId`、`intent`(`create` 或 `edit`)、`sourceArtifactIds` 和可选 `quality`;类型及编辑素材约束由 `src/shared/image-generation-contracts.ts` 校验。每次请求沿用已有协议的单图生成和尺寸行为,没有新增数量或尺寸参数。原会话、触发消息和工具调用关联由运行上下文绑定,LLM 不能自由指定结果写入位置或任意文件路径。
|
||||
|
||||
每个新回合提供可调用图片模型的脱敏 ID、名称及生成、编辑能力,模型切换、配置变化或进程复用时刷新。Main 提交前重新校验工作模式、连接开关、所需能力及素材。优先由工具描述承载模型选择、图片引用和结果使用说明,不把 Skill 管理作为依赖。
|
||||
|
||||
直连文本 Model Runtime 使用内置工具契约及分发;外部 Runtime 复用内部 MCP 注入,由 GoodBuddy 自动注册、配置和管理生命周期,远程路径经现有 Agent 工具桥接调用同一服务。内置 MCP 列表中的图片行只展示这项受管能力,不代表直连 Model 也通过 MCP 调用。无需用户创建 Skill、另建 MCP server 或复制凭据,不按模型连接复制工具或 Skill。
|
||||
|
||||
Main 从已保存图片 profile 的会话调用开关推导允许模型目录,并以目录是否非空推导全部支持 Runtime 的分配。Renderer 只读取派生分配;该受管行不接入通用 MCP 的启停、分配、删除或凭据编辑写入接口。模型配置保存后刷新展示及注入目录,进程复用和下一回合重新读取;运行中的回合及已提交操作继续遵循 L-1、L-3。配置错误、Runtime 在线状态、聊天模型工具能力与执行结果独立于派生分配,不因这些状态移除允许目录中的模型或取消勾选。
|
||||
|
||||
不预建能力发现、查询或取消工具目录。只有实际 Runtime 协议要求独立发现、查询或取消交互,且现有工具描述、响应及 UI 操作不能满足时,才增加所需的最小适配,并记录协议依据;这些适配仍调用同一 Main 图片服务,不形成新的媒体平台。取消默认从会话卡片进入 Main。
|
||||
|
||||
工具等待遵循现有超时约束,返回操作 ID、实际模型、真实状态及完成后的成果 ID,不把 Base64 塞入工具历史。原回合仍有效时通过合法工具响应返回结果;回合结束或 Runtime 切换后,Main 继续写原会话,下一回合读取操作摘要及成果引用,不补发旧响应、不自动唤起 LLM 或重发图片请求。
|
||||
|
||||
## 配置与持久化
|
||||
|
||||
| 对象 | 最小数据与职责 |
|
||||
| --- | --- |
|
||||
| 图片模型连接 | 复用 settings/profile,增加会话调用开关;现有连接缺少开关时解释为关闭 |
|
||||
| 图片能力与 Runtime 分配 | 从图片模型配置派生,只读展示;不新增独立可变的 capability enabled、assignment 设置或持久化副本 |
|
||||
| 全局偏好 | 默认图片 profile ID,复用应用设置;本次指定只属于请求,不新增会话级选择 |
|
||||
| 生成操作 | 优先使用现有消息 metadata,保存操作 ID、原会话/消息、工具关联、状态、实际模型 ID 与名称、提示词、有效参数、素材引用、时间、错误及成果 ID |
|
||||
| 素材与成果 | 复用附件和 artifact 持久化,保存上传图片、生成图片及编辑来源关系,不能只存于 Runtime 内存 |
|
||||
|
||||
活动请求在 Main 内存持有提交时连接参数,持久记录不复制凭据。应用重启后未完成操作显示结果未确认,不建立跨重启查询恢复、凭据历史、通用恢复日志或自动重试。取消和晚到结果按 L-4 更新同一记录;人工重新生成使用新操作 ID。
|
||||
|
||||
不新增独立任务表、视频字段或通用调度器,操作不自动成为 Task/Job。若实施涉及已发布数据,按[迁移规范](../../development/database-migrations.md)处理实际兼容需求,不预建迁移或恢复机制。
|
||||
|
||||
Main 仅在图片和会话引用持久化成功后发布完成事件。素材必须是当前会话可访问的附件或成果,沿用已有项目和会话边界;删除会话后的晚到结果不重建会话。
|
||||
|
||||
## 图片协议
|
||||
|
||||
沿用[图片技术设计](../image-generation/technical-design.md)的已有生成、编辑协议及素材限制。接收并校验有界内联图片,不下载 Provider 返回的图片 URL。沿用现有成果存储限制,实施时核对编码开销与超限反馈,不以配置检查成功代替真实生成验收。视频协议、下载、播放及交付计划均不属于本设计。
|
||||
|
||||
## Runtime 接入
|
||||
|
||||
| 路径 | 设计接入点 | 必须完成的验证 |
|
||||
| --- | --- | --- |
|
||||
| 直连文本 Model Runtime | 现有内置工具契约及工具分发转入 Main 图片服务 | 自然语言生成、上传图编辑、历史图编辑及切换 |
|
||||
| 本地 OpenCode | 现有内部 MCP 注入转入同一服务 | 实际工具往返、能力刷新、生成编辑及切换 |
|
||||
| 本地 Continue | 现有内部 MCP 注入转入同一服务 | 实际工具往返、能力刷新、生成编辑及切换 |
|
||||
| 本地 DeepSeek Harness | 现有内部 MCP 注入转入同一服务 | 实际工具往返、能力刷新、生成编辑及切换 |
|
||||
| GoodBuddy Agent 远程适用 Runtime | 复用桌面与 Agent 通信及工具桥接,将请求绑定原会话,由 Main 调用图片模型 | 逐一验证实际支持的远程 Runtime,覆盖真实 Host 工具往返、生成编辑、本地与远程双向切换、断连及成果归属 |
|
||||
| 无工具能力的聊天模型 | 说明自动调用限制,可使用已有直连图片工作流 | 不解析普通文字伪装工具调用,不新增表单;Ask 不提交 |
|
||||
|
||||
本地 OpenCode、Continue 和 DeepSeek Harness 经内部 MCP gateway 注入;直连 Model 使用内置工具分发。远程通过 `main-image-tool-session.ts`、Agent 协议和 `image-tool-mcp.ts` 回到同一 Main 服务。现有 GoodBuddy Agent 的受管远程 Runtime 为 OpenCode,项目校验和 Runtime 创建均沿用此范围;共享契约包含其他 Runtime 不表示存在其远程执行路径。保留会话在目录清空时也刷新 MCP,Continue 准备失败时释放本次分配的令牌。工具发现成功或 Skill 注入成功不能代替真实生成与编辑。
|
||||
|
||||
Main 已接收的请求在桌面仍运行时独立于远程连接继续;未确认是否接收的请求不自动重放。聊天回合停止等待与用户取消图片是不同动作,须验证现有取消传播不会误杀 Main 图片请求。
|
||||
|
||||
## 实施与验证
|
||||
|
||||
实施工作包括配置与选择、Main 图片服务提取、各本地及远程 Runtime 工具适配、成果与状态 UI。它们共同完成本图片功能,不按 Runtime 划分交付范围。
|
||||
|
||||
针对 L-1 至 L-5 验证选择优先级、失效配置、上传与历史素材、无视觉模型、Ask、会话归属、重复通知、取消及晚到结果。检查切换聊天模型、图片默认模型、本地与远程 Runtime 后的新生成和编辑,以及旧操作不重复提交。回归已有直连图片工作流。
|
||||
|
||||
按 US-16 至 US-19 验证从零到一个开启模型、多个中关闭或删除一个、最后一个关闭或删除、重新开启,以及配置错误和 Runtime 离线时的派生分配、目录刷新、行可见性与只读交互。重开设置和应用后均从模型配置推导,不恢复独立分配值;辅助技术可读取标签、勾选状态和说明,键盘可到达模型设置导航。
|
||||
|
||||
源代码实现后运行 `npm test`、`npm run typecheck`、`npm run lint`,并逐路径执行[用户场景](./user-stories.md)的真实产品验收,记录模型、Runtime、请求数量和结果。涉及 Agent 的实现按[远程主机验证要求](../remote-host/technical-design.md)在真实测试 Host 验证。实施和集成验证状态由[进度记录](./progress.md)维护。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 界面设计
|
||||
|
||||
状态:工作区已有实现及 Electron 组件验收,完整应用验收见[进度记录](./progress.md)。遵循[统一界面设计](../../../UI-DESIGN.md),实现 [FR-1 至 FR-9](./prd.md),行为以[行为规则](./logic-design.md)为准。
|
||||
|
||||
## 模型配置
|
||||
|
||||
在现有图片模型连接表单提供“允许 AI 在会话中调用”。说明文字:“开启后,支持工具调用的聊天助手可使用此模型生成和编辑图片,编辑能力取决于图片协议。结果保存在发起请求的会话中。”复用共享 Switch,提供 `role="switch"`、键盘操作及可见焦点。
|
||||
|
||||
开启后提供“设为默认生图模型”,列表显示可调用和默认标识。关闭或删除默认连接时提示默认选择不可用,保留失效选择供改选,不自动指定其他模型。用户无需创建 Skill、搭建 MCP server 或重复填写 API 凭据;内部注入由 GoodBuddy 管理,不作为配置步骤展示。
|
||||
|
||||
## 能力与 Runtime 分配
|
||||
|
||||
复用设置中心“MCP / 内置 MCP”的列表与分配布局,增加“图片生成与编辑”行,标记“内置 · 自动管理”。这是 GoodBuddy 管理能力的展示入口,直连 Model 通过内部工具调用,外部 Runtime 通过内部 MCP 接入;不显示为用户另行添加的 MCP 或 Skill,也不混入 Runtime 原生能力清单。
|
||||
|
||||
该行按 L-1 展示直连文本 Model Runtime、本地 OpenCode、Continue、DeepSeek Harness 及 GoodBuddy Agent 远程适用 Runtime 的只读分配复选框。此图片能力采用用户已接受的自动管理规则,不沿用其他内置 MCP 的手动启停、可调分配或 DeepSeek Harness“暂不支持”状态。没有开启模型时仍保留整行及全部未勾选项,不能因目录为空隐藏。
|
||||
|
||||
持续显示说明“由图片模型的「允许 AI 在会话中调用」设置自动管理。”,并提供“前往图片模型设置”导航。该行不提供独立启用开关、保存分配、移除、编辑连接或凭据入口。运行就绪、无工具能力、配置或调用错误单独显示,不改变分配勾选。
|
||||
|
||||
分配复用共享 Checkbox 的方形视觉和原生语义,以 `checked` 表示派生值并使用 `disabled` 禁止鼠标、标签点击或键盘切换;仅在共享控件已有受支持的只读实现时采用该实现,不给原生 Checkbox 添加无效的 `readonly` 或自行添加 `aria-readonly`。每项保留 Runtime 标签和可访问名称,以 `aria-describedby` 关联自动管理说明。说明放在禁用控件之外,保持可读及辅助技术可访问;导航保持正常 Tab 顺序和可见焦点。窄窗口下分配项换行,保留标签、勾选状态、说明和导航。
|
||||
|
||||
## 会话入口
|
||||
|
||||
在现有聊天输入区以自然语言发起图片生成或编辑,本次指定模型也通过请求表达。复用上传附件和历史成果引用入口,输入区显示素材缩略图或文件名并允许移除。不新增会话级图片模型选择器、独立生成表单或视频入口。
|
||||
|
||||
未设默认时,助手提示本次指定可用模型,或到设置选择默认后重新发送;名称歧义先澄清。没有图片连接时提供现有模型设置入口。模型不支持编辑或素材缺失时保留输入,提示更换模型或重新选择素材。
|
||||
|
||||
直连文本、本地各 Runtime 和 GoodBuddy Agent 远程适用 Runtime 使用一致的图片操作与成果反馈。切换后可继续生成及编辑历史图;工具调用异常时显示具体原因,不宣称已提交。接入未完成的开发状态记录在进度文档,不能作为产品支持范围。
|
||||
|
||||
不支持工具调用时提示“当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流”。Ask 下提示“生成或编辑图片需要 Execute 模式”,由用户切换,不自动切换。无视觉能力时说明无法直接查看图片,不妨碍按用户文字调用编辑工具。
|
||||
|
||||
## 图片卡片
|
||||
|
||||
复用现有图片成果展示,卡片就近显示实际模型、操作状态、成果或错误。提示词、适用参数和来源素材放在详情中;不显示密钥、认证头或供应商私有地址。活动记录与卡片使用同一操作标识。
|
||||
|
||||
完成后可预览、下载和“继续修改”。继续修改将成果加入输入区,用户填写修改要求;切换 Runtime 或重开会话后仍可使用。离开会话不取消操作,结果只出现在原会话。
|
||||
|
||||
进行中提供“取消生成”,与“停止回复”分开。取消、晚到结果和结果未确认的文案遵循 L-4。失败提供适用的“重新生成”“重新选择模型”或“重新选择素材”;重新生成由用户明确触发,并说明会发起新的模型请求。没有独立“重试保存”按钮。
|
||||
|
||||
“重新选择模型”进入已有模型连接设置;改变模型后,用户在新请求中明确指定。“重新选择素材”聚焦输入区并打开已有附件选择器,保留草稿和已有附件;空草稿填入原提示词及模型名称。历史成果仍使用“继续修改”加入引用并聚焦输入区。上述操作不自动发送请求,“重新生成”继续使用原操作的模型和输入。
|
||||
|
||||
卡片错误不重复弹全局通知;模型设置保存等非上下文反馈使用应用通知。状态使用文字,不能只靠颜色;状态变化通过适度的 `aria-live` 提示,避免逐秒播报等待时间。窄窗口操作可换行,图片不超过消息区宽度,模型长名称截断时保留完整可访问名称。复用的菜单关闭后焦点回到触发控件。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 用户场景
|
||||
|
||||
状态:设计草案。Given、When、Then 分别为前提、操作和预期结果。范围以[产品需求](./prd.md)为准,行为以[行为规则](./logic-design.md)为准。
|
||||
|
||||
## 场景验收
|
||||
|
||||
适用的自动调用场景须在直连文本 Model Runtime、本地 OpenCode、Continue、DeepSeek Harness 和 GoodBuddy Agent 远程适用 Runtime 分别验证,前提为 Execute 且聊天模型支持工具调用。切换验收同时检查原操作归属和目标 Runtime 的新生成、编辑能力。
|
||||
|
||||
| 编号 | 需求 | Given | When | Then |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| US-01 | FR-1、FR-3、FR-9 | 已添加图片模型、开启会话调用并设为默认 | 在文字会话中要求画海报 | 自动调用并显示活动,原会话出现图片及实际模型;无需创建 Skill、MCP server 或重复凭据 |
|
||||
| US-02 | FR-2 | 有两个可调用图片模型,未选默认 | 请求生成图片 | 提示本次指定模型或到设置选择默认后重新发送;名称歧义先澄清,不按列表顺序提交 |
|
||||
| US-03 | FR-4、FR-5 | 聊天模型 A 已调用图片模型生成图片 | 切换聊天模型 B,要求修改该图 | B 获得成果引用和可用工具,编辑模型按规则选择 |
|
||||
| US-04 | FR-4、FR-6、FR-9 | 生成操作已提交 | 切换 Runtime 并打开其他会话,再请求生成或编辑 | 原操作继续且只写回原会话;目标 Runtime 可提交新操作,不重放旧调用 |
|
||||
| US-05 | FR-2、FR-4 | 当前操作使用图片模型 X | 将全局默认改为 Y,再请求新建图片 | 旧操作仍用 X,新操作用 Y;本次明确指定可覆盖默认 |
|
||||
| US-06 | FR-1、FR-2、FR-4 | 默认或本次指定模型为 X | X 被关闭或删除后请求生成 | 显示 X 不可用并提示改选,历史图仍可打开,不静默换模型 |
|
||||
| US-07 | FR-5、FR-8 | 已保存图片并重新打开会话或切换 Runtime | 引用原图,指定另一个支持编辑的模型 | 新模型接收原图;素材缺失时要求重新选择,不能假装已编辑 |
|
||||
| US-08 | FR-7 | 当前聊天模型不支持工具调用,或处于 Ask | 请求生成或编辑 | 无工具能力时说明可使用已有直连图片工作流;Ask 下不提交并提示需要 Execute,不自动切换模式 |
|
||||
| US-09 | FR-6 | 操作正在生成或保存,或已经失败 | 取消,或明确要求重新生成 | 如实显示取消结果;人工重新生成创建新操作,不自动重复收费请求,不提供独立保存重试入口 |
|
||||
| US-11 | FR-4、FR-9 | 使用 GoodBuddy Agent 远程适用 Runtime | 生成及编辑图片,切换本地后继续编辑,再切回远程生成 | 工具往返可用,成果可继续引用;活动归原会话,凭据留在 Main;远程断连不取消 Main 已接收的操作 |
|
||||
| US-12 | FR-5、FR-7、FR-8 | 聊天模型无视觉能力但支持工具 | 请求分析图片或根据文字修改图片 | 说明无法直接查看图片;可传原图给编辑工具,不编造视觉观察 |
|
||||
| US-13 | FR-6 | 图片操作尚未取得可保存结果 | 退出并重开应用 | 未完成记录显示结果未确认,不承诺后台继续,不自动查询恢复或重新提交 |
|
||||
| US-14 | FR-5 | 会话有多张图片 | 要求“修改第二张”或“新画一张无关图片” | 前者引用指定成果,后者不附带旧图;指代不清先澄清 |
|
||||
| US-15 | FR-3、FR-5、FR-9 | 上传图片并选择支持编辑的图片模型 | 要求修改背景,再切换 Runtime 继续修改成果 | 上传原图及后续成果被正确引用,编辑结果保存在各自发起会话;不降为纯文生图 |
|
||||
| US-16 | FR-1、FR-9 | 尚无开启会话调用的图片模型 | 查看内置 MCP 列表,再开启一个图片模型并保存 | 图片生成与编辑行一直可见且标记“内置 · 自动管理”;全部支持 Runtime 从未勾选变为勾选,目录包含该模型;重开设置或应用仍按模型配置显示 |
|
||||
| US-17 | FR-1、FR-9 | 至少两个图片模型开启会话调用 | 关闭或删除其中一个并保存 | 全部支持 Runtime 保持勾选,允许目录移除该模型、保留其他开启模型;新操作不能调用已关闭或删除模型 |
|
||||
| US-18 | FR-1、FR-9 | 仅剩一个开启会话调用的图片模型 | 关闭或删除该模型,再重新开启或添加并开启一个模型 | 目录为空时全部取消勾选,整行和说明、模型设置导航仍可见;再次开启后自动恢复全部勾选,无需另行分配 |
|
||||
| US-19 | FR-1、FR-7、FR-9 | 至少一个图片模型开启会话调用 | 尝试用鼠标或键盘切换分配,或切换到无工具聊天模型、Ask、离线 Runtime,或遇到配置或执行错误 | 分配不可手动修改,运行限制与错误不取消勾选;无独立启用、分配保存、移除或凭据编辑操作;自动管理说明持续可读,辅助技术可识别勾选和禁用状态,键盘可通过“前往图片模型设置”导航修改源配置 |
|
||||
|
||||
## 编号变更
|
||||
|
||||
US-10(图生视频)移出本图片设计范围,编号保留,不安排实施。US-02、US-05、US-06 的会话选择预期由全局默认和本次指定替代;US-08 使用已有直连图片工作流;US-09 取消独立保存重试要求;US-13 的视频查询恢复由图片中断状态说明替代。其余编号保持,US-15 补充上传图片编辑的显式验收。
|
||||
|
||||
US-16 至 US-19 补充自动管理分配验收,设置展示不依赖 Execute 或当前聊天模型支持工具;自动生成与编辑仍遵循本节的调用前提。
|
||||
@@ -0,0 +1,11 @@
|
||||
# 会话监督
|
||||
|
||||
会话监督定义对长会话、工具执行和异常状态的观察、判断与介入边界。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [会话监督 PRD](./prd.md) | 监督目标、证据、介入方式和产品验收 |
|
||||
|
||||
当前尚无独立 User Stories、功能逻辑、UI、技术设计和进度文档。
|
||||