Compare commits
434
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ebb4f0b79 | ||
|
|
c7f09e1e53 | ||
|
|
d9dcb65d10 | ||
|
|
1044a32b3b | ||
|
|
c6a8455f88 | ||
|
|
e07a17f79f | ||
|
|
4205ad6317 | ||
|
|
8f1b600798 | ||
|
|
dc582b7acf | ||
|
|
46a40e7fbf | ||
|
|
3556df8895 | ||
|
|
d7fb4299a3 | ||
|
|
4b4531abeb | ||
|
|
69f64df279 | ||
|
|
adb58d9200 | ||
|
|
47917094ad | ||
|
|
e2a5f83666 | ||
|
|
670b598530 | ||
|
|
1a59c2d880 | ||
|
|
13b58627c0 | ||
|
|
4266ff5bea | ||
|
|
377b14fca6 | ||
|
|
a406f5962e | ||
|
|
7f0a73e6d0 | ||
|
|
2d68d1043d | ||
|
|
4a2ea49069 | ||
|
|
277da413c6 | ||
|
|
e136026496 | ||
|
|
59a28209d3 | ||
|
|
9788b4cd8e | ||
|
|
c90ad2af6f | ||
|
|
95ece25c37 | ||
|
|
c4ab83a7e3 | ||
|
|
7eb113028f | ||
|
|
5ba127e332 | ||
|
|
3d063937c3 | ||
|
|
2a92f8f875 | ||
|
|
95f441958d | ||
|
|
384683e861 | ||
|
|
7eb51bd5ff | ||
|
|
9212fd4359 | ||
|
|
42ad8a98b4 | ||
|
|
fbfafa15e1 | ||
|
|
c1e4bbf66a | ||
|
|
3e3a021682 | ||
|
|
c465d39d58 | ||
|
|
13c29badfb | ||
|
|
e0607a153f | ||
|
|
425accf49d | ||
|
|
904bb691b8 | ||
|
|
4c25e85b08 | ||
|
|
8473189fe2 | ||
|
|
1f85d0c0e9 | ||
|
|
d80d42b032 | ||
|
|
bc677a3718 | ||
|
|
edf019aaa7 | ||
|
|
4bb7573b10 | ||
|
|
56b8b10d46 | ||
|
|
0eaaa85d4c | ||
|
|
36e671a0a2 | ||
|
|
2be57492e6 | ||
|
|
b90ff50f2a | ||
|
|
e50ca6d149 | ||
|
|
b4df493be7 | ||
|
|
bbf24c282a | ||
|
|
126dbc57e2 | ||
|
|
d0877d9212 | ||
|
|
cf598121b1 | ||
|
|
78afec7775 | ||
|
|
4e72aa0481 | ||
|
|
29af409045 | ||
|
|
245e296ad5 | ||
|
|
ea3d607fdd | ||
|
|
b9186a616c | ||
|
|
49fd0d3054 | ||
|
|
e205e90993 | ||
|
|
577210a8ae | ||
|
|
56ebd40bd8 | ||
|
|
0cd5da33f4 | ||
|
|
4d18794e74 | ||
|
|
2c2b6c31eb | ||
|
|
ee186f9f70 | ||
|
|
629e82545b | ||
|
|
658e7f100f | ||
|
|
ff61da28a0 | ||
|
|
362fe50402 | ||
|
|
273a7fa7a5 | ||
|
|
74efbcdc17 | ||
|
|
e89db96b85 | ||
|
|
ae4c6d2423 | ||
|
|
91edcf5e8b | ||
|
|
0dd4d1d642 | ||
|
|
75e50fc194 | ||
|
|
29a134b804 | ||
|
|
59d36c2258 | ||
|
|
39fc15efe0 | ||
|
|
5d0ca44b22 | ||
|
|
78beade289 | ||
|
|
bf9850a6a9 | ||
|
|
0e9c9d4927 | ||
|
|
352da66dbd | ||
|
|
6aab78b9fa | ||
|
|
2adee13638 | ||
|
|
942fe3206c | ||
|
|
6b634c5d6b | ||
|
|
739f26702b | ||
|
|
48d7526b7e | ||
|
|
492f1d208a | ||
|
|
651475d17a | ||
|
|
2e3d2e77a9 | ||
|
|
41be9f8076 | ||
|
|
010aafb6f4 | ||
|
|
68cc0792e1 | ||
|
|
b28ddd4ba8 | ||
|
|
805acc3fce | ||
|
|
a620c22676 | ||
|
|
10065da8ac | ||
|
|
b2eb86a0b4 | ||
|
|
f4458afe4b | ||
|
|
10c5162811 | ||
|
|
9d03217f99 | ||
|
|
55f554d13c | ||
|
|
9527402003 | ||
|
|
50481deaa1 | ||
|
|
baa6defc0c | ||
|
|
775763eee0 | ||
|
|
82107ad86a | ||
|
|
b6c7de5f91 | ||
|
|
663aa85e1a | ||
|
|
3c31f0b499 | ||
|
|
169941ceda | ||
|
|
fdf7d83b20 | ||
|
|
f571301764 | ||
|
|
53b9ecbcae | ||
|
|
d859cbfdeb | ||
|
|
855b941aeb | ||
|
|
a1a1d24022 | ||
|
|
9d8477b728 | ||
|
|
0fd2f7e3c8 | ||
|
|
7aad78752b | ||
|
|
6ced760c1a | ||
|
|
09c415bd41 | ||
|
|
e97263c79e | ||
|
|
02a6f5c4e7 | ||
|
|
dc635d185e | ||
|
|
4d1ce38dad | ||
|
|
c3fa262832 | ||
|
|
0ce9606c89 | ||
|
|
ee4eb737e0 | ||
|
|
1f420cd042 | ||
|
|
43be465286 | ||
|
|
5f1ff24333 | ||
|
|
d773f5e961 | ||
|
|
90ea33ef5c | ||
|
|
983cdd4e05 | ||
|
|
bf6c4a91a2 | ||
|
|
1e10935d22 | ||
|
|
5dc65457d9 | ||
|
|
3d4e9577b5 | ||
|
|
80579ee421 | ||
|
|
8280158edc | ||
|
|
5368e0ecf4 | ||
|
|
20317c21b5 | ||
|
|
b1129cedec | ||
|
|
2e5c348dce | ||
|
|
f4d8fe7f06 | ||
|
|
dd29f309f1 | ||
|
|
b02ce31886 | ||
|
|
7cb74cce78 | ||
|
|
5e77118508 | ||
|
|
227507de8f | ||
|
|
c8f327fc4e | ||
|
|
016f987026 | ||
|
|
2c5300d031 | ||
|
|
a3b41bf2b1 | ||
|
|
0a02a17cb6 | ||
|
|
d2ceff8db5 | ||
|
|
8201c9c27e | ||
|
|
cc451f698d | ||
|
|
9cc5dc1244 | ||
|
|
f7f501a8e3 | ||
|
|
e4f7b8d993 | ||
|
|
b6d24ada87 | ||
|
|
c23dca65e8 | ||
|
|
bf64181c85 | ||
|
|
3be55348a2 | ||
|
|
89fc100d4f | ||
|
|
6edfc0046b | ||
|
|
ab85e72f01 | ||
|
|
80edd15438 | ||
|
|
7fb23d9b6b | ||
|
|
925d410247 | ||
|
|
2d8e58e995 | ||
|
|
6293ef105d | ||
|
|
d30f794184 | ||
|
|
608a9a5fec | ||
|
|
a662b16f5e | ||
|
|
911eed5e67 | ||
|
|
ed47e2c2a5 | ||
|
|
9c88ca20f3 | ||
|
|
d1b92deff3 | ||
|
|
9a9170441e | ||
|
|
3c9229ac7a | ||
|
|
86347af0b1 | ||
|
|
4baf4ec142 | ||
|
|
1603871f7c | ||
|
|
393a07ccdf | ||
|
|
50e8fde64c | ||
|
|
13ff182858 | ||
|
|
594222a2b5 | ||
|
|
1c6264dd87 | ||
|
|
120076a13d | ||
|
|
d8f0ef1301 | ||
|
|
10f3b4f2c1 | ||
|
|
58e6808f1f | ||
|
|
8650907b75 | ||
|
|
c11f204b17 | ||
|
|
be23c1346b | ||
|
|
47ba54f47d | ||
|
|
76e781a0a8 | ||
|
|
0cdf04261a | ||
|
|
66f77b83c2 | ||
|
|
83a3ea261e | ||
|
|
013e72f69e | ||
|
|
61590395ef | ||
|
|
4e5239256d | ||
|
|
65a21390de | ||
|
|
706726bad6 | ||
|
|
5cdffee9ee | ||
|
|
d9e1e42288 | ||
|
|
d47f967781 | ||
|
|
f35198df01 | ||
|
|
8e607366ca | ||
|
|
ee1bf8a187 | ||
|
|
b8b6007b51 | ||
|
|
12594961fe | ||
|
|
5a6ba6c14f | ||
|
|
0f8bf405db | ||
|
|
5ecc19dfe0 | ||
|
|
4a2986ca5d | ||
|
|
2713a81bd9 | ||
|
|
11facf0392 | ||
|
|
0f0b6ac21d | ||
|
|
eb53c827c6 | ||
|
|
8b15399577 | ||
|
|
67dedc2ed2 | ||
|
|
ceb275bdb8 | ||
|
|
44cd944601 | ||
|
|
40c108da1a | ||
|
|
5177b47aeb | ||
|
|
4edf06b685 | ||
|
|
f81f342cde | ||
|
|
1094433a7d | ||
|
|
55efca3ee1 | ||
|
|
5116613615 | ||
|
|
0759d5dcff | ||
|
|
325b702934 | ||
|
|
4c7193e1ea | ||
|
|
e6ac0734de | ||
|
|
e8d93b5bb6 | ||
|
|
cd27163660 | ||
|
|
107dc974e3 | ||
|
|
d23af99708 | ||
|
|
ff746bdc1a | ||
|
|
d4bc10c0f3 | ||
|
|
db5e43d7d5 | ||
|
|
f68584b4e2 | ||
|
|
7adae97baa | ||
|
|
0ed1be1773 | ||
|
|
d5f6dc6990 | ||
|
|
c71c0adf9e | ||
|
|
344008aaec | ||
|
|
cebd513da8 | ||
|
|
2ca19ebf50 | ||
|
|
7756231b3b | ||
|
|
b16fc900bb | ||
|
|
bf8ff7f78e | ||
|
|
bb8c04af6c | ||
|
|
073aa45a30 | ||
|
|
1cf74d3d2c | ||
|
|
163e245b00 | ||
|
|
bf8ece0590 | ||
|
|
83a48a4079 | ||
|
|
cdd573778c | ||
|
|
0265cfb762 | ||
|
|
fb4b9bdd46 | ||
|
|
984f16f178 | ||
|
|
7d487f9326 | ||
|
|
785b1ee468 | ||
|
|
be84534e77 | ||
|
|
f7541203e4 | ||
|
|
24bde0c2e3 | ||
|
|
4366bdc716 | ||
|
|
598c68f955 | ||
|
|
e6a25b779c | ||
|
|
a0b35d1192 | ||
|
|
6c4c054180 | ||
|
|
38d0923d79 | ||
|
|
8dce1d7007 | ||
|
|
6739e8e902 | ||
|
|
fe093bc15f | ||
|
|
3cd6f44d7d | ||
|
|
9e8c98290e | ||
|
|
9836a4cc9d | ||
|
|
bf1d34e897 | ||
|
|
ecf09f4e95 | ||
|
|
a3b7a27045 | ||
|
|
375b2159c8 | ||
|
|
b5d8204986 | ||
|
|
c98eb69cbd | ||
|
|
df89d96803 | ||
|
|
ec08109a09 | ||
|
|
6f06fce039 | ||
|
|
b1eb5d00d5 | ||
|
|
a9b59ceb1c | ||
|
|
935a8f56bf | ||
|
|
17186b0c11 | ||
|
|
c7a93803c9 | ||
|
|
164e89253a | ||
|
|
a8d1dbc11f | ||
|
|
5aad0cf5f7 | ||
|
|
9bd0dae865 | ||
|
|
daa14d0b7e | ||
|
|
ca3c919a34 | ||
|
|
bbae893c45 | ||
|
|
6dd6297414 | ||
|
|
04d9ea9eba | ||
|
|
94852acf82 | ||
|
|
3639566e1b | ||
|
|
1698d9fd3e | ||
|
|
441f14b5f7 | ||
|
|
cc9ea7c718 | ||
|
|
7e11d1099a | ||
|
|
0a8439a855 | ||
|
|
da23810205 | ||
|
|
cb245a6402 | ||
|
|
e84d7bd185 | ||
|
|
6c045bfe2e | ||
|
|
eaf71ce16f | ||
|
|
f1d7197480 | ||
|
|
625182c3dc | ||
|
|
b16b0d10e7 | ||
|
|
a383b2d11f | ||
|
|
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 |
@@ -0,0 +1,9 @@
|
||||
from pathlib import Path
|
||||
import difflib
|
||||
files = ['src/main/assistant/assistant-database.test.ts', 'src/main/assistant/activity-history-io.test.ts', 'src/main/assistant/assistant-storage-upgrade.test.ts', 'src/main/assistant/heartbeat-database.test.ts', 'src/main/magic-notes/magic-note-storage.test.ts']
|
||||
patch = ''
|
||||
for name in files:
|
||||
old = Path(name).read_text(encoding='utf-8')
|
||||
new = old.replace('DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;', 'DROP VIEW IF EXISTS supervision_review_current;\n DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;\n DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;\n DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;')
|
||||
patch += ''.join(difflib.unified_diff(old.splitlines(True), new.splitlines(True), fromfile='a/'+name, tofile='b/'+name))
|
||||
Path('.validation-fixtures.patch').write_text(patch, encoding='utf-8')
|
||||
@@ -228,12 +228,14 @@ jobs:
|
||||
set -euo pipefail
|
||||
output="$RUNNER_TEMP/goodbuddy-agent-output"
|
||||
mkdir -p "$output"
|
||||
continue_archive="$(node build/acquire-continue-runtime.cjs "$RUNNER_TEMP/goodbuddy-continue-runtime" "$AGENT_ARCH" "$AGENT_PLATFORM")"
|
||||
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" \
|
||||
--continue-archive "$continue_archive" \
|
||||
--output "$output/goodbuddy-agent-$AGENT_VERSION-$AGENT_PLATFORM-$AGENT_ARCH.gbagent" \
|
||||
> "$output/result.json"
|
||||
node - "$output/result.json" "$AGENT_ARCH" "$AGENT_VERSION" <<'NODE'
|
||||
@@ -484,6 +486,7 @@ jobs:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
OSS_UPLOAD_ENDPOINT: https://oss-accelerate.aliyuncs.com
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
|
||||
@@ -497,7 +500,7 @@ jobs:
|
||||
name="$(basename "$file")"
|
||||
ossutil cp "$file" \
|
||||
"oss://${OSS_BUCKET}/agent-releases/v${AGENT_VERSION}/${name}" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--endpoint "$OSS_UPLOAD_ENDPOINT" \
|
||||
--region "$region" \
|
||||
--update
|
||||
done
|
||||
@@ -532,14 +535,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
tag="agent-v$AGENT_VERSION"
|
||||
title="GoodBuddy Agent $AGENT_VERSION"
|
||||
notes="$RUNNER_TEMP/agent-release-notes.md"
|
||||
cat > "$notes" <<EOF
|
||||
GoodBuddy Agent $AGENT_VERSION
|
||||
|
||||
此版本供 GoodBuddy 的“设置 > 平台功能 > 远程项目”手动下载。软件包包含 Agent、固定 Node 和 GoodBuddy 适配的远端 OpenCode Runtime。
|
||||
|
||||
This release is downloaded manually from GoodBuddy Settings > Platform Features > Remote Projects. Each package contains the Agent, pinned Node, and the GoodBuddy-adapted remote OpenCode Runtime.
|
||||
EOF
|
||||
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
|
||||
|
||||
@@ -305,11 +305,13 @@ jobs:
|
||||
rm -rf "$directory"
|
||||
mkdir -p "$directory"
|
||||
agent_version="$(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
continue_archive="$(node build/acquire-continue-runtime.cjs "$RUNNER_TEMP/goodbuddy-continue-runtime" "$AGENT_ARCH" "$AGENT_PLATFORM")"
|
||||
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" \
|
||||
--continue-archive "$continue_archive" \
|
||||
--output-directory "$directory/bundle" \
|
||||
--archive "$directory/goodbuddy-agent-$agent_version-$AGENT_PLATFORM-$AGENT_ARCH.gbagent" |
|
||||
tee "$directory/result.json"
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
validate:
|
||||
name: Validate source
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -40,9 +40,27 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Prepare sandboxed Electron tests
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends --yes xvfb xauth dbus-x11 gnome-keyring
|
||||
sandbox="node_modules/electron/dist/chrome-sandbox"
|
||||
if [[ ! -f "$sandbox" ]]; then
|
||||
rm -rf node_modules/electron/dist
|
||||
force_no_cache=true node node_modules/electron/install.js
|
||||
fi
|
||||
test -f "$sandbox"
|
||||
sudo chown root:root "$sandbox"
|
||||
sudo chmod 4755 "$sandbox"
|
||||
|
||||
- name: Run validators
|
||||
run: |
|
||||
npm test
|
||||
dbus-run-session -- bash -euo pipefail -c '
|
||||
export XDG_CURRENT_DESKTOP=GNOME
|
||||
# An isolated CI login keyring exercises real safeStorage encryption.
|
||||
head -c 32 /dev/urandom | base64 | gnome-keyring-daemon --unlock --components=secrets
|
||||
xvfb-run --auto-servernum npm test
|
||||
'
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
|
||||
@@ -248,7 +266,7 @@ jobs:
|
||||
|
||||
- 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 }} --skip-build
|
||||
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
|
||||
@@ -261,7 +279,7 @@ jobs:
|
||||
|
||||
- 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 }} --skip-build --unsigned
|
||||
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
|
||||
@@ -445,6 +463,7 @@ jobs:
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
OSS_UPLOAD_ENDPOINT: https://oss-accelerate.aliyuncs.com
|
||||
OSS_REGION: ${{ steps.oss-release.outputs.region }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -459,7 +478,7 @@ jobs:
|
||||
name="$(basename "$file")"
|
||||
ossutil cp "$file" \
|
||||
"oss://${OSS_BUCKET}/releases/${GITHUB_REF_NAME}/${name}" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--endpoint "$OSS_UPLOAD_ENDPOINT" \
|
||||
--region "$OSS_REGION" \
|
||||
--update
|
||||
done
|
||||
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'sites/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/pages.yml'
|
||||
|
||||
permissions:
|
||||
@@ -29,8 +31,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
|
||||
- name: Validate website
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npm run docs:build
|
||||
node sites/scripts/validate.mjs
|
||||
node --check sites/app.js
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ dist/
|
||||
# Local build output left by the extracted ShareServer project.
|
||||
shareserver/build-server/
|
||||
coverage/
|
||||
/temp/
|
||||
.vite/
|
||||
sites/docs-dist/
|
||||
sites/docs/.vitepress/
|
||||
.runtime-resources/
|
||||
.remote-runtime-resources/
|
||||
.agent-resources/
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
--- a/src/main/assistant/assistant-database.test.ts
|
||||
+++ b/src/main/assistant/assistant-database.test.ts
|
||||
@@ -174,7 +174,10 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
- DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
+ DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 35;`)
|
||||
legacy.close()
|
||||
try {
|
||||
@@ -325,7 +328,10 @@
|
||||
DROP VIEW supervision_review_current;
|
||||
DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches;
|
||||
DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs;
|
||||
- DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
+ DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 41;`)
|
||||
legacy.close()
|
||||
try {
|
||||
@@ -1088,6 +1094,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 3;
|
||||
`)
|
||||
@@ -1251,6 +1260,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 26;
|
||||
`)
|
||||
@@ -1367,6 +1379,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 30;
|
||||
`)
|
||||
@@ -1445,6 +1460,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 30;
|
||||
`)
|
||||
@@ -1542,6 +1560,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 24;
|
||||
`)
|
||||
@@ -1589,6 +1610,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 24;
|
||||
`)
|
||||
@@ -1637,6 +1661,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 24;
|
||||
`)
|
||||
@@ -1681,6 +1708,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 24;
|
||||
`)
|
||||
@@ -1714,6 +1744,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 24;
|
||||
`)
|
||||
@@ -1744,6 +1777,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 5;
|
||||
`)
|
||||
@@ -1871,6 +1907,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 21;
|
||||
COMMIT;
|
||||
@@ -1967,6 +2006,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 9;
|
||||
`)
|
||||
@@ -2025,6 +2067,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 16`)
|
||||
legacy.close()
|
||||
@@ -2977,6 +3022,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 23`)
|
||||
legacy.close()
|
||||
@@ -3141,6 +3189,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 18;
|
||||
`)
|
||||
@@ -3654,6 +3705,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 31;
|
||||
`)
|
||||
--- a/src/main/assistant/activity-history-io.test.ts
|
||||
+++ b/src/main/assistant/activity-history-io.test.ts
|
||||
@@ -27,6 +27,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version=43`)
|
||||
const legacySave = raw.prepare('UPDATE activity_history SET records_json = ?, legacy_history_may_be_incomplete = 1')
|
||||
--- a/src/main/assistant/assistant-storage-upgrade.test.ts
|
||||
+++ b/src/main/assistant/assistant-storage-upgrade.test.ts
|
||||
@@ -101,7 +101,10 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
- DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
+ DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 37`)
|
||||
} finally { legacy.close() }
|
||||
expect(hasPendingAssistantStorageUpgrade(path)).toBe(true)
|
||||
@@ -419,7 +422,10 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
- DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
+ DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = ${sourceVersion}; BEGIN`)
|
||||
const insert = legacy.prepare(
|
||||
`INSERT INTO task_events(
|
||||
--- a/src/main/assistant/heartbeat-database.test.ts
|
||||
+++ b/src/main/assistant/heartbeat-database.test.ts
|
||||
@@ -82,6 +82,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 2`)
|
||||
raw.close()
|
||||
@@ -141,6 +144,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 20`)
|
||||
raw.close()
|
||||
--- a/src/main/magic-notes/magic-note-storage.test.ts
|
||||
+++ b/src/main/magic-notes/magic-note-storage.test.ts
|
||||
@@ -223,6 +223,9 @@
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
+ DROP VIEW IF EXISTS supervision_review_current;
|
||||
+ DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
|
||||
+ DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
||||
PRAGMA user_version = 37`)
|
||||
rmSync(join(directory, 'notes'), { recursive: true })
|
||||
@@ -0,0 +1,5 @@
|
||||
--- a/src/renderer/src/App.test.tsx
|
||||
+++ b/src/renderer/src/App.test.tsx
|
||||
@@ -13972 +13972 @@
|
||||
- supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300 }
|
||||
+ supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 1024 }
|
||||
@@ -31,7 +31,7 @@ risk or an explicit user requirement.
|
||||
## Design Simplicity
|
||||
|
||||
Follow KISS and YAGNI. Use the smallest conventional design that completes the
|
||||
real user workflow.
|
||||
real user workflow. NO over-engineering or over-design.
|
||||
|
||||
- A demonstrated failure authorizes only the smallest proportional fix, not a
|
||||
general recovery, compatibility, or security framework.
|
||||
@@ -93,13 +93,24 @@ additional hardening beyond the actual internal-network threat model:
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
- Ask mode must remain read-only at the runtime boundary.
|
||||
- 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.
|
||||
- Normal requests use the selected Runtime, enabled capabilities, and execution
|
||||
account permissions without Ask/Execute or the product `toolApproval` policy.
|
||||
Do not restore a product work mode or general tool approval gate.
|
||||
Implementation and validation status belongs
|
||||
in `docs/features/unified-execution/progress.md`.
|
||||
- Respect explicit user instructions such as explanation-only requests. Keep
|
||||
dedicated internal text tasks tool-free. Do not add T2/T3 trust tiers,
|
||||
separate consent checklists, per-tool approvals, or a replacement work mode.
|
||||
- Preserve cancellation, timeout, bounded-output, and shutdown behavior.
|
||||
- Keep model/provider credentials in Main, but do not otherwise reduce Execute
|
||||
- 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 execution
|
||||
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
|
||||
@@ -112,6 +123,11 @@ additional hardening beyond the actual internal-network threat model:
|
||||
|
||||
## Data and Compatibility
|
||||
|
||||
- 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
|
||||
@@ -124,6 +140,16 @@ additional hardening beyond the actual internal-network threat model:
|
||||
|
||||
## Implementation Conventions
|
||||
|
||||
- Read and follow
|
||||
[`docs/architecture/performance-principles.md`](./docs/architecture/performance-principles.md)
|
||||
before designing, implementing, or reviewing a feature. Treat its performance
|
||||
budgets, process boundaries, state ownership, startup rules, and review
|
||||
checklist as implementation constraints. Include this reference in parallel
|
||||
agent assignments so all agents follow the same rules. For performance or
|
||||
hot-path changes, provide the applicable measurements and validation required
|
||||
by that document; do not claim improvements without evidence.
|
||||
- For new feature work, also follow the repository maintenance rules in
|
||||
[`docs/development/agent-feature-maintenance.md`](./docs/development/agent-feature-maintenance.md).
|
||||
- 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.
|
||||
@@ -140,6 +166,13 @@ additional hardening beyond the actual internal-network threat model:
|
||||
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
|
||||
@@ -274,7 +307,30 @@ Repository-wide release invariants:
|
||||
|
||||
## Validation
|
||||
|
||||
Run all validators after source changes:
|
||||
### Local Temporary Files
|
||||
|
||||
- Put all agent-created local temporary files under the repository-root `temp/`
|
||||
directory (`D:\my_git\goodbuddy\temp` in this checkout), in a task-specific
|
||||
subdirectory. This includes database copies, scratch scripts, test fixtures,
|
||||
logs, screenshots, and temporary build or validation artifacts. Do not put
|
||||
them in Windows Temp or `AppData/Local/Temp/opencode`.
|
||||
- Resolve this directory from the checkout root rather than hard-coding a
|
||||
machine path in scripts. Point test-tool temporary directory options and
|
||||
child-process `TEMP`, `TMP`, or `TMPDIR` here when needed. Leave production
|
||||
application data paths unchanged.
|
||||
- `temp/` is Git-ignored and disposable. Never store original user data,
|
||||
required backups, or the only copy of a deliverable there. Keep reusable
|
||||
validation scripts in `scripts/` or `tests/` and concise results in the
|
||||
owning feature documentation.
|
||||
- Clean task temporary files after validation, including database copies and
|
||||
their WAL/SHM files, on success and failure. Close owned processes and
|
||||
database connections first. Reuse a baseline copy where possible instead
|
||||
of retaining a full database for each test case. If cleanup is blocked,
|
||||
report the remaining path; do not silently accumulate copies.
|
||||
- Apply these rules to parallel agents as well. Remote Host temporary files
|
||||
continue to follow the dedicated remote-testing directory rules below.
|
||||
|
||||
Run validators after source changes when function/fix done, not on every source change:
|
||||
|
||||
```text
|
||||
npm test
|
||||
@@ -310,8 +366,8 @@ validation are not substitutes for this development-time check.
|
||||
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,
|
||||
update for installation changes; unified tool execution 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
|
||||
@@ -319,6 +375,10 @@ validation are not substitutes for this development-time check.
|
||||
- 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.
|
||||
|
||||
@@ -31,6 +31,10 @@ npm start
|
||||
|
||||
## 质量验证
|
||||
|
||||
跨功能测试方法见[质量验证索引](./docs/quality/README.md)。桌面界面的测试分层、
|
||||
原生键盘、截图与计算样式取证、隔离启动和脱敏报告遵循
|
||||
[Electron UI 自动化测试规范](./docs/quality/electron-ui-automation.md)。
|
||||
|
||||
提交或打包前运行:
|
||||
|
||||
```bash
|
||||
@@ -39,6 +43,31 @@ npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Vitest runs two ordered projects from `vitest.config.ts`. The `unit` project runs
|
||||
first with up to four workers, bounded by Node's available CPU parallelism. Once
|
||||
it finishes, the `integration` project runs with one worker. The serial group
|
||||
contains all test files under `tests/` (including `*.electron.test.ts`),
|
||||
`src/main/agent/**/*runtime*.test.ts`, and
|
||||
`src/agent-daemon/private-endpoint.test.ts`. Other tests remain in the unit group.
|
||||
Both projects inherit the React plugin, jsdom environment, renderer setup, and
|
||||
existing exclusions; test-file isolation remains enabled.
|
||||
|
||||
This preserves serialization for the process/socket tests that previously timed
|
||||
out on Linux with two workers (commit `93721d6`). `sequence.groupOrder` keeps the
|
||||
two phases from overlapping. Existing file filters still work, for example
|
||||
`npm test -- src/main/agent/opencode-runtime.test.ts`. To select a whole phase,
|
||||
use `npm test -- --project unit` or `npm test -- --project integration`. To inspect
|
||||
file selection without running tests, use `npx vitest list --filesOnly`.
|
||||
|
||||
For activity-history persistence, run
|
||||
`npx vitest run src/main/assistant/activity-history-io.test.ts`. This checks both
|
||||
WAL growth and repeated full-history reads: consecutive saves compare against
|
||||
the last committed in-memory records, invalidated on close, data clearing or
|
||||
another SQLite connection's commit. It also checks rollback and retry. The cache
|
||||
does not change the database schema or the full-snapshot IPC contract. WAL size
|
||||
and SQL scan counts do not measure physical disk throughput or conversation
|
||||
message persistence; validate those separately when investigating live I/O.
|
||||
|
||||
监听模式:
|
||||
|
||||
```bash
|
||||
@@ -56,6 +85,14 @@ OpenCode/Continue 用例默认读取 `dist/harness-package-probe/win-unpacked`
|
||||
调用。测试不会输出 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,覆盖工作区外访问、原生问答及能力范围。OpenCode 用例默认使用开发缓存
|
||||
二进制;可通过 `GOODBUDDY_TEST_OPENCODE_BINARY` 指定已经安装的候选二进制,避免把
|
||||
仍在运行的旧缓存版本当作候选版本验证。这不替代当前源码 Agent 的 Linux Host 验收。
|
||||
|
||||
## 生产构建
|
||||
|
||||
生成 Electron Main、Preload 和 Renderer 生产文件:
|
||||
@@ -66,6 +103,13 @@ npm run build
|
||||
|
||||
中间构建输出位于 `out`。该目录为生成内容,应修改源文件后重新构建,不要直接编辑。
|
||||
|
||||
Obsidian 接入的构建钩子将锁定的 npm `@bitbonsai/mcpvault@0.16.0` 及递归运行依赖、
|
||||
许可证和原生辅助资源复制到 `out/main/obsidian-mcpvault`,桌面打包时解包到
|
||||
`app.asar.unpacked`。运行时复用现有工具执行环境的 Node,不执行在线 npm 安装。
|
||||
生产组件自动化、Electron 托管 Node、Windows 设置与聊天 UI,以及受限网络下的
|
||||
Windows x64 解包应用已验证;物理断网、安装器及其他系统验收待完成。
|
||||
验证记录见 [Obsidian 进度](./docs/features/obsidian/progress.md)。
|
||||
|
||||
## 图标生成
|
||||
|
||||
应用图标源文件位于 `icons`。修改源图或图标处理逻辑后运行:
|
||||
@@ -138,13 +182,37 @@ npm run dist:linux:arm64
|
||||
已由 Vite 打包的 `@antv/g6` 归类为构建期依赖,不会再作为独立 Node 模块复制进桌面
|
||||
安装包。
|
||||
|
||||
Desktop packaging includes only `out/main`, `out/preload`, and `out/renderer`;
|
||||
validation builds elsewhere under `out` are not shipped. Renderer-only React,
|
||||
Xterm, Mermaid, Fabric, Quill, i18next, and Markdown/math dependencies are build-time
|
||||
dependencies: Vite ships their browser bundles, while the existing license
|
||||
resources remain included. Install development dependencies before building;
|
||||
do not prune them before bundling and copying license resources.
|
||||
|
||||
The desktop DS Web resource filter excludes JavaScript/TypeScript source maps,
|
||||
`.d.ts` / `.d.mts` / `.d.cts` declarations, and `.pdb` debug symbols. It preserves
|
||||
the installed dependency layout, plugin configuration, browser assets, licenses,
|
||||
native binaries, and non-code `.map` assets. This filter does not change the
|
||||
separately built remote Agent or Runtime payloads.
|
||||
|
||||
Windows x64 development validation on 2026-09-28 compared the existing 0.15.2
|
||||
portable directory (excluding `data`) with a fresh unpacked test build:
|
||||
`app.asar` decreased from 609.4 to 350.5 MiB, and DS Web decreased from 28,701
|
||||
files / 463.4 MiB to 14,984 files / 357.0 MiB. Total measured size decreased from
|
||||
1,758.3 to 1,438.4 MiB despite the test build retaining 45.6 MiB more Electron
|
||||
locales than the portable build. These are unpacked sizes, not installer download
|
||||
sizes. Builder-copy regression tests, production build/type checks, packaged
|
||||
Harness/OpenCode/npm smoke checks, DS Web authentication/page scripts/workspace
|
||||
creation, and packaged App/settings/notes-page loading passed. Full editor/canvas
|
||||
interaction and other platform packages were not revalidated in this check.
|
||||
|
||||
以上 `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`。只有用户点击“下载”或“更新到”后才传输包,
|
||||
@@ -211,6 +279,9 @@ package format v1 归档已经包含固定 Agent 和 Node,可直接用于 Host
|
||||
桌面包会携带经过版本与完整性校验的 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 许可证。
|
||||
@@ -298,14 +369,22 @@ OSS 先写 `agent-releases/v<version>/` 不可变对象,GitHub Release 公开
|
||||
`agent-catalog.json` 与 `agent-catalog.sig`,避免客户端观察到跨版本组合。Agent Release 必须使用
|
||||
`--latest=false`,不得改变桌面 Release 的 Latest 标记。
|
||||
|
||||
### 远程 OpenCode Runtime 基础
|
||||
### 远程 Runtime 工件
|
||||
|
||||
`remote-runtime-lock.json` 独立锁定首个远程 Runtime:OpenCode 1.18.9 的 Linux x64
|
||||
`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 许可证。
|
||||
|
||||
lock 同时固定 `@continuedev/cli@1.5.47` 的 npm integrity。CN 工件使用
|
||||
`lib/continue/dist/cn.js`,由 Agent 中的 HTTP-to-ACP helper 启动,不能传入原生 `acp`
|
||||
参数。`remote-runtime:build` 与 `build/agent-package.cjs build` 接受
|
||||
`--runtime-id continue`;默认 Agent 组包通过 `--continue-archive` 同时携带 OC/CN。
|
||||
CN 的 JS payload 通过文件摘要与签名校验,执行它的 Node 仍来自 Agent 固定包。
|
||||
默认 workflow 已接入联合组包;开发包与完整桌面到 Linux 的实测见
|
||||
[CN 部署记录](./docs/features/remote-host/runtime-checklist-validation.md#cn-部署与生命周期)。
|
||||
|
||||
远程 Runtime 工件由以下命令独立管理:
|
||||
|
||||
```bash
|
||||
@@ -324,11 +403,10 @@ 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 和
|
||||
manifest/Ed25519/payload/ELF/lock 校验、digest registry、runtime/acp 6、直接进程 ownership 和
|
||||
`runtime/model-bridge` v1。Agent 通过私有 Unix socket 按需 detached 启动,不依赖
|
||||
systemd、D-Bus、Linger 或 bubblewrap。Ask 与 Execute 都直接启动签名 Runtime;两者的
|
||||
差异由 OpenCode Ask 权限配置和 Agent 工具权限分发边界共同执行,Execute 不注入该
|
||||
权限覆盖。
|
||||
systemd、D-Bus、Linger 或 bubblewrap。请求直接启动签名 Runtime,使用所选 SSH 账号
|
||||
权限与已接入能力;不携带产品工作模式。旧 capability 5 不兼容,发布前须重建配套工件。
|
||||
|
||||
OpenCode 通过已签名 Agent helper 和每次 Prompt 的私有 Unix socket 使用 Main-only 模型
|
||||
网关;Provider URL、API Key 和真实 Provider 认证头不进入远端。helper 的 loopback
|
||||
@@ -336,8 +414,7 @@ HTTP 入口使用每个进程随机生成的路径 capability,Anthropic/OpenAI
|
||||
本地兼容标记由 helper 核对后丢弃。GoodBuddy 自己维护会话标题,所以生成的 OpenCode
|
||||
配置必须禁用 title Agent;工具循环只使用 build Agent 的模型轮次。
|
||||
|
||||
Ask 模式的 ACP 权限中介只可为原生 `read` 选择 `allow_once`;其他工具种类和
|
||||
`allow_always` 请求必须拒绝。Unix 模型桥
|
||||
ACP 权限中介按活动请求与工具能力回复合法允许选项,保留取消及未知请求拒绝。Unix 模型桥
|
||||
broker 必须按 socket 当前已缓冲字节增量读取长度帧,不能等待 `read(remaining)` 一次返回
|
||||
完整大响应。Agent 传输或权限实现变化后,除了普通测试,还要在原生 Linux 上运行包含
|
||||
至少 256 KiB 响应的模型桥回归。
|
||||
@@ -387,8 +464,8 @@ DeepSeek Harness Runtime、`app.asar`、目标架构和安装包签名。
|
||||
npm run release:package -- --platform macos --arch <x64|arm64> --unsigned
|
||||
```
|
||||
|
||||
默认发布产物为 Windows 的 NSIS 安装包与 portable ZIP、macOS 的 DMG 与
|
||||
ZIP,以及 Linux 的 AppImage、DEB 与 RPM。Linux 原生 Runner 必须安装
|
||||
默认发布产物为 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 的
|
||||
@@ -424,8 +501,8 @@ Desktop 正式发布必须把候选分支推送与标签推送拆成两个阶段
|
||||
全部目标成功后,才会严格校验并聚合所有平台产物,生成按平台重命名的
|
||||
manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流通过
|
||||
GitHub OIDC 获取短期 STS 凭据,将发布资产和 `site-release.json` 上传到
|
||||
北京 OSS 的不可变版本目录,并公开校验 14 个安装包。验证通过后才创建或
|
||||
更新 draft GitHub Release、上传 22 个 Release 资产并正式发布,最后原子
|
||||
北京 OSS 的不可变版本目录,并公开校验 12 个安装包。验证通过后才创建或
|
||||
更新 draft GitHub Release、上传 20 个 Release 资产并正式发布,最后原子
|
||||
切换官网 `latest.json`。任一步失败都不会提前切换官网最新版本。
|
||||
|
||||
同一标签重跑时,工作流会根据 `resources/release-notes.json` 重新生成并
|
||||
@@ -472,7 +549,7 @@ git push github "$tag"
|
||||
macOS 发布 job 会先原子判断 Apple 凭据状态:以下五项 Actions Secrets 全部
|
||||
存在时,使用 Developer ID Application 证书签名,并通过 App Store Connect
|
||||
API Key 提交 Apple notarization;五项全部缺失时,明确生成未签名、未公证的
|
||||
DMG 和 ZIP,并在 Actions 日志与摘要中警告 Gatekeeper 限制;只配置一部分时
|
||||
DMG,并在 Actions 日志与摘要中警告 Gatekeeper 限制;只配置一部分时
|
||||
任务失败,不能静默降级为未签名包。
|
||||
|
||||
- `MACOS_CERTIFICATE_BASE64`:包含证书及私钥的 `.p12` 文件经 Base64 编码后的内容。
|
||||
@@ -510,11 +587,11 @@ Windows 代码签名仍未配置。对外分发前还应配置 Windows 签名凭
|
||||
2. 中文输入法、窗口缩放和高分屏显示。
|
||||
3. 系统密钥环和模型连接。
|
||||
4. 本地知识库导入、检索和知识图谱。
|
||||
5. Ask、Execute 的权限边界与旧版 Plan 数据兼容。
|
||||
5. 无模式执行、schema 60 与 Runtime 设置 22 的历史字段清理及用户数据保留。
|
||||
6. OpenCode 与 Continue 的权限边界、取消和超时。
|
||||
7. DeepSeek Harness Ask 拒绝写入和第三方插件工具,可调用 Main 管理的 Web Search/Fetch;Execute 可调用已启用插件工具。文本模型在网络调用前拒绝图片,声明图片能力的模型可以实际接收 JPEG/PNG。
|
||||
7. DeepSeek Harness 控制协议 2 可调用已注册内置及已启用插件工具,Main 代理沿用能力分配和请求归属。文本模型在网络调用前拒绝图片,声明图片能力的模型可以实际接收 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 内容。
|
||||
9. Runtime 原生清单把 Tools 与 Commands/LSP/Formatters 分开,显示来源及描述,不提供统一逐工具开关,不混入 GoodBuddy 分配的 Skills/MCP;外部 OpenCode 只报告连接状态,Continue 明确标记原生 Tools 静态发现不支持;内置 MCP 的启停与 Runtime 分配会持久化并限制后续请求,DeepSeek Harness 保持不可分配;MCP 测试只读取有界 Prompt/Resource 元数据,不读取 Resource 内容。
|
||||
10. DSH 市场可安装、停用、重新启用和移除插件;启动失败插件不会阻止 Host,并显示为自动停用。
|
||||
11. 智能心跳的创建、暂停、恢复和历史记录。
|
||||
12. 应用退出后无残留 Runtime 子进程。
|
||||
@@ -550,7 +627,7 @@ GOODBUDDY_DSH_MARKETPLACE_E2E=1 npm test -- src/main/agent/dsh-extension-marketp
|
||||
该测试使用临时用户目录,经捆绑 npm 路径安装已审查的最小测试插件,并验证 Host
|
||||
加载和真实工具调用;测试结束后删除临时目录。
|
||||
|
||||
要让真实模型同时验证插件的 Ask 拒绝与 Execute 调用,可显式提供兼容的
|
||||
要让真实模型验证已启用插件的调用,可显式提供兼容的
|
||||
OpenAI Chat Completions 配置:
|
||||
|
||||
```bash
|
||||
|
||||
+777
-123
File diff suppressed because it is too large
Load Diff
+130
-45
@@ -6,114 +6,199 @@
|
||||
|
||||
## 状态说明
|
||||
|
||||
- [x] 已提供
|
||||
- [ ] 开发中或规划中,具体以条目标注为准
|
||||
- [x] 已完成
|
||||
- [ ] 待实现
|
||||
|
||||
状态表示功能实现情况,不等同于版本已发布或全部测试已执行。验证记录单独列出,不作为第三种功能状态。
|
||||
|
||||
## 功能总表
|
||||
|
||||
### 桌面基础、工作空间与上下文
|
||||
|
||||
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;仅在明确要求时构建。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
|
||||
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
|
||||
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
|
||||
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
|
||||
- [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异和已展开目录,超过 50 项变更可继续加载。
|
||||
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
|
||||
- [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话。锚定的“项目 → 会话”菜单支持键盘导航和精确打开对话,不再使用整块活动弹窗;空闲时隐藏摘要,长会话子菜单独立滚动,项目选择器继续显示活动数量。
|
||||
后台会话成功完成后,在本次客户端运行期间保留完成提示,直到对应聊天可见且加载完成;提示不跨重启保留。
|
||||
- [x] **会话置顶与完整历史**:常用会话可置顶,重启后保留。列表和搜索不再限于最近 100 个会话,连续发送不再截断超过 500 条的已保存历史;模型上下文限制仍有效,已丢失的历史无法恢复。
|
||||
置顶与非置顶分组按最近一条消息的创建时间排序,流式文本和工具进度更新不再反复改变位置。
|
||||
- [x] **可选桌面任务通知**:“平台功能 / 通用设置”提供默认开启、保存后立即生效的系统任务提醒开关,应用内提示独立保留;通用设置按快捷键、会话与通知、工作目录分组。
|
||||
- [x] **按需上下文帮助**:页面和设置中的补充说明使用标题或字段旁的共享帮助入口,支持鼠标与键盘;关键操作后果和错误提示保持可见。
|
||||
- [x] **紧凑会话控件**:输入选项集中在紧凑设置面板,Runtime、发送和队列操作保持可用;会话搜索提供内嵌清除按钮,清除后恢复当前范围列表并把焦点返回输入框。
|
||||
- [x] **会话历史按需加载**:列表只加载轻量摘要,不再一次读入全部会话的过程元数据;已打开和活动会话保留完整详情,搜索、复制、导出、续聊和远程待答问题保持可用。图片合并不会重新发送未变化消息,已确认保存的空闲详情随现有视图缓存释放。详见[历史读取与保留规则](./docs/features/assistant-workbar/execution-history-storage.md#会话列表读取与前端保留)。
|
||||
- [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] **HTTP OCR 与附件持久预览**:在“设置 / 文档解析”配置 HTTP PaddleOCR-VL,供聊天文档和知识库导入共用。保存原件与插图,提供正文/图片/详情预览、显式图片文字提取,以及选图加入指定会话草稿。普通图片默认走图片输入,发送与入队检查实际模型和 Runtime。Windows、直连视觉输入和共享 Linux Host 托管 OpenCode 已有真实验证;部署高级 OCR 模型及其他 Runtime 组合需分别测试。本机推理监控只管理本地服务,不新增应用中心入口,详见[范围与证据](./docs/features/document-processing/README.md)。
|
||||
- [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异、当前浏览目录和已展开目录,超过 50 项变更可继续加载。
|
||||
- [x] **工作区文件与 Git 管理**:支持文件和目录的新建、重命名、移动、删除、属性查看,以及本地/远程分支搜索与切换、新建分支、显式 Fetch、提交历史和单文件提交差异。工具按文件/Git 视图分组,分支搜索使用统一紧凑表单,子目录保留文字导航而根目录不再显示孤立图标。提交历史贴底,首次展开占 Git 内容区一半,支持调整比例、独立滚动和统一刷新;切换项目自动检测 Git,不沿用其他项目状态。移动限于工作区内,删除须确认,分支冲突不自动 stash 或丢弃改动;托管 SSH 操作需要 Agent `0.11.23`。
|
||||
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Mermaid 大图放大后可滚动到各个边缘,并可导出完整 PNG,不受当前缩放和拖动位置影响。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
|
||||
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
|
||||
- [x] **助手工作栏、多终端与可调布局**:右侧工作栏使用持久“+”能力目录和应用 Tab;用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
|
||||
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入监督、统一 Runtime、受管进程、静态安全 HTML 预览、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。
|
||||
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、能力分配、预算和超时,并支持模板复用。
|
||||
- [x] **应用中心与导航**:底部“应用”和“设置”为显示文字的等高按钮,中间使用弱竖分隔线。“应用”带向上箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”使用 `LayoutGrid` 图标,打开标题仍为“应用中心”的可搜索列表及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。五个应用行均支持上下移动和拖动排序,不受启用或常驻状态限制;列表、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。设备共享在测试阶段默认关闭,提供启用开关,不提供常驻开关。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
|
||||
- [x] **本地设备共享元数据**(源码已实现):测试阶段默认关闭,先在应用中心启用,再从应用菜单或应用中心打开;已明确保存的启用选择保留,关闭会退出页面,但不清除目录或撤销已发布记录。可保存服务地址和设备名称、注册本机、浏览服务返回的目录、发布能力或知识元数据并撤销本机 ID 的条目。默认服务为 `http://127.0.0.1:8787`。服务未认证,ID 只用于识别;服务器来源不上传或同步内容,知识搜索/读取/下载标志是独立声明,不提供正文访问或远程执行。详见[范围与验证](./docs/features/device-sharing/README.md)。
|
||||
- [ ] **私有化应用市场**(规划中):在应用管理基础上提供组织自己的目录与应用分发。当前只管理内置应用,尚无市场浏览或安装能力,详见 [FR-15](./docs/features/application-tool-navigation/prd.md#fr-15-私有化市场与-yaml-交换后续)。
|
||||
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入更完整的执行监督、统一 Runtime 监控、受管进程、可固定目标的工作区/浏览器实例、底部停靠与独立窗口。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] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
|
||||
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。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 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。已随 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] **当前会话图片工具**:为图片模型开启会话调用后,支持工具调用的聊天模型可生成图片、修改上传图片或历史成果,无需另开会话或手动切换图片模型。直连文本模型、本地 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**:本机直连文本模型可运行平台 Shell,并可按父请求模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数按当前账号权限发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并应用多文件补丁;读取和补丁 API 保留工作区相对路径合同。用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。
|
||||
- [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 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。请求使用所选 SSH 账号权限和已接入能力,无产品模式或一般工具审批。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.5`,配套 Desktop 候选为 `0.15.12`;无模式源码要求 runtime/acp 6,发布前须重建配套工件。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [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 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。
|
||||
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
|
||||
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
|
||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权。
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
|
||||
- **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 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择,设置页显示实际管理员或回退模型来源。已注册内置工具及已启用插件工具以当前用户权限运行,Main 代理工具沿用能力分配和请求归属校验。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。Windows Host 启动和 ACP 会话共用规范工作区路径,同目录不同写法可复用正常 Runtime,不再因路径不一致而创建会话失败。
|
||||
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方初始化代码使用当前用户权限。
|
||||
- [x] **统一执行源码链路**:已移除 Ask/Execute、“安全”分类和 `toolApproval` 策略;请求按所选 Runtime、已启用能力及本机或 SSH 账号权限执行。“清除本地数据”位于“通用 > 平台功能”,保留二次确认。schema 60 和 Runtime 设置 22 清理旧字段;远程要求 runtime/acp 6,本地 Harness 控制协议为 2。原生工具清单不提供统一的逐工具开关。真实 Electron 与 Linux Host 定向证据见[进度](./docs/features/unified-execution/progress.md),全量测试及配套发布包验证仍待完成。
|
||||
- **本地 Runtime 原生客户端(预览)**:输入区可按当前本地项目与模型,在工作栏终端打开 Continue/OpenCode,或在系统浏览器打开官方 DS Web。客户端使用独立会话,不导入 GoodBuddy 对话历史;关闭 DS 网页不停止服务,可在 GoodBuddy 中停止。Continue/OpenCode 使用 Main 按当前请求分配的工具,无产品工作模式。远程快捷入口未接通,托管标准 Node 交付和完整跨平台安装包验收仍有缺口;Windows 真实模型证据不代表全部会话能力等价,详见[实现与剩余工作](./docs/features/assistant-workbar/progress.md#2026-09-27-local-native-client-validation)。
|
||||
- **Runtime 遥测关闭配置**:本地 DS Web、远程 Runtime 和模型桥启动共用遥测关闭环境,不限制模型、MCP 或用户主动联网。Continue 在自动更新关闭时跳过检查;远程改动随配套 Agent 新启动的 Runtime 生效,当前源码 Linux x64 Agent/model 验证见 [0.15.0 发布准备](./docs/development/release-preparation-0.15.0.md)。
|
||||
- [x] **Runtime 原生交互转交**:OpenCode 与 Continue 的选择、yes/no、自由文本回答和跳过使用现有问答卡片,本机与托管 SSH OpenCode 同时转交属于当前请求的子会话提问。托管 SSH OpenCode 问答需要配套 Agent 和新启动的托管 Runtime,不扩展到任意 ACP 服务。回答或跳过成功后在原位置保留问题与答案,支持多轮记录和本地会话重载;旧版本已丢弃的答案不能恢复。并行问题按顺序等待回答,重复事件保留草稿,提交失败可重试;取消远程待答后可在同一会话继续发送。原生工具权限确认自动处理,不等待第二次审批;具体支持范围见[交互边界](./docs/features/assistant-workbar/runtime-interactions.md)。
|
||||
- [x] **原生执行清单**:OpenCode 和 Continue 在对话顶部更新只读执行清单,进度随会话保存。详情在可滚动的锚定浮层中展开,不挤动聊天内容;点击外部关闭,Escape 关闭后焦点返回入口。显式清空和远程重放保留请求归属,取消不把未完成条目标成完成;远程交付需要配套 Agent,详见[清单契约](./docs/features/assistant-workbar/runtime-checklist-technical-design.md)。
|
||||
- **取消后的历史**:模型可见历史保留已中断的部分回复或占位标记,避免旧问题合入下一轮;本地 OpenCode 复用会话时附带一次中断说明。
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个专家并行分析。专家继承父请求执行空间和能力范围,可使用已启用的本机直连模型工具;这些工具不是远程 OpenCode 子会话。聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
|
||||
- [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。
|
||||
- [x] **OpenCode 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。
|
||||
- [x] **直连模型命令工作目录**:Execute 命令可通过绝对路径、相对路径或符号链接使用工作区外目录;工作区仍为默认目录和相对路径基准,Ask 保持只读。
|
||||
- [x] **OpenCode 事件连接回收**:聊天与原生上下文压缩在结束事件迭代前关闭各自订阅,覆盖完成、失败、取消和消费方提前结束,不取消其他并行会话。
|
||||
- [x] **准确的消息底部状态**:区分请求准备、等待重试、开始重试、工具活动、待回答和终态;本机 OpenCode 使用原生重试次数和计划时间,直连模型显示自身退避阶段。消息底部状态点保持静态,未接入的 Runtime 重试不编造提示,会话列表闪点及发送、停止行为不变。
|
||||
- [x] **直连模型命令工作目录**:命令可通过绝对路径、相对路径或符号链接使用当前账号可访问的工作区外目录;工作区仍为默认目录和相对路径基准。
|
||||
- [x] **Runtime 与模型分层选择**:按会话、项目、全局逐字段解析 Runtime 和模型。会话双栏选择器显示来源,可覆盖或恢复项目默认;项目与通道设置共用规则,Main 在每次请求及排队派发时重新解析。已删除或不兼容的连接会回退,缺少凭据仍明确报错。schema 49 迁移已保存选择,用途模型仍为规划,详见[选择设计](./docs/features/model-connections/runtime-selection-design.md)。
|
||||
- [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] **本地直连手动压缩**:文本模型会话可在自动压缩关闭或未到阈值时手动生成摘要,保留最近轮次和完整历史,保存的摘要用于后续对话;图像连接和远程直连入口不适用。摘要模型调用可能产生费用。
|
||||
- [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] **OpenCode Runtime 定制**:GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 Resources;Tools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及描述,并隐藏 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 代理。
|
||||
- [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示来源和描述,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。
|
||||
- [ ] **Runtime 监督栏目**(规划中):在应用级助手工作栏的固定 Runtime 栏目统一承载 OpenCode、Continue 和 DeepSeek Harness 的 Task 级委派、后台执行、Workflow/Hook、长任务与原生会话监督;用户只选择 Conversation 或 Task,Job/Run 保持内部,不形成树或独立操作对象。
|
||||
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,在助手工作栏固定的 Runtime 栏目按 Task 聚合状态、取消入口和审计归属。
|
||||
- [ ] **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,动态工具仍会重新发现并经过现有执行记录与权限边界。
|
||||
- **本机工具执行环境验证记录**:六个平台/架构的 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;这些 Server 在 DeepSeek Harness 中仍不支持。Story Graph 另通过 Main 代理支持 Harness。内置 MCP 仅通过当前请求的短期本机权限提供,继续校验启用、分配和资源归属。
|
||||
- **Obsidian 接入(源码已实现,验收中)**:默认关闭,默认发现全部本机注册仓库,也可指定文件夹并在保存或启用前测试。随包提供 MCPVault 0.16.0,19 个共享工具保留全部上游能力;生产组件自动化、Electron 托管 Node、Windows 设置与聊天 UI,以及受限网络下的 Windows x64 解包应用已验证。物理断网、安装器及其他系统验收待完成。DeepSeek Harness 与远程执行保持不可用,详见[功能文档与验证记录](./docs/features/obsidian/README.md);此项不表示已发布。
|
||||
- [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent 和 DeepSeek Harness;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍经过发现、执行记录与资源范围校验。直连模型与 Harness 的健康调用复用当前请求已发现的工具清单,不在每次调用前重复发现。
|
||||
- [x] **MCP Prompts 与 Resources 元数据**:MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。
|
||||
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。知识范围按对话保存,新对话默认不选择知识库;创建与检索高级参数默认折叠。文档区分可用、处理中和失败,支持打开来源、失败重试及移除来源前确认。
|
||||
- [ ] **外部知识库接入**(规划中):在现有知识库页面管理 Dify、FastGPT 和 RAGFlow 实例;新建知识库时可读取并绑定远端知识库,保留 Provider 专属检索配置和引用。外部系统只负责知识检索,不接入其 App、Chat、Workflow 或 Agent,也不批量同步、本地索引或修改远端内容。被回答引用的有界检索片段随会话保存在本机。详见[外部知识库接入 PRD](./docs/features/knowledge-base/external-knowledge-prd.md)。
|
||||
内容来源与文档索引共用可搜索列表,单文档来源不重复占行,目录保留分组文档,失败来源保留恢复操作;任务历史和次级操作从统一入口访问。
|
||||
- [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] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
|
||||
模型抽取不再额外限制为 8192 tokens;OpenAI 使用服务商上限,Anthropic 使用模型连接的输出设置。
|
||||
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
|
||||
- [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。
|
||||
- [x] **混合检索测试台**:支持全文、中文词组、向量和图谱通道诊断,可调 Top K、阈值、权重、本地或学习型重排及上下文预算。
|
||||
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
|
||||
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
|
||||
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。
|
||||
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
|
||||
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论。笔记从自适应卡片总览打开;独立待办页提供搜索、状态筛选、分组列表和直接勾选,宽屏列表稳定在左侧,任务说明、来源记录及 AI 评论在右侧同一区域显示,窄屏按需显示详情并提供返回列表。紧凑页签放在页头新建按钮左侧,筛选放在搜索右侧。打开原笔记再返回可恢复任务上下文;切换任务不会混入上一项的来源内容,读取失败使用应用通知并提供就地重试,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。Agent / MCP 写入后,已打开的工作台自动更新,并保留当前选择与未保存草稿。
|
||||
- **会话采集与快速笔记**(已实现,真实 Electron 验收通过):单条助手回答或完整会话正文可先预览、编辑,再追加到已有笔记或连同首条记录创建新笔记。右侧笔记 Tab 支持标题与正文搜索、快速追加及打开完整工作区,记录来源可返回会话和消息;切换会话保留草稿。真实 App/preload/IPC/SQLite 验收覆盖完整 502 条历史及 214px 面板原生键盘操作,模型调用为 0。[功能验证进度](./docs/features/magic-notes/progress.md)保留聚焦通过、fixture 修复与当时的测试和类型检查失败;[0.15.0 候选验证](./docs/development/release-preparation-0.15.0.md)已通过全量测试、类型检查和 lint。
|
||||
- 待办宽屏列表与详情之间可拖动分隔条并记住宽度;来源笔记分组可独立折叠,不清除当前选中任务的详情。
|
||||
- [x] **连续记录工作区**:笔记详情连续展示全部记录,左侧索引默认 168px,可拖动右边界或用键盘在 140–320px 内调宽,并记住偏好;索引与右侧 AI 评论独立收起。每条画布记录显示真实首页缩略图、页数和时间,文字记录显示摘要;点击索引只滚动定位,保留当前编辑器和草稿。打开笔记就在标题下方显示空白输入区,保留文字/画布选择和保存操作;新增保存后清空,供下一条记录使用,旧记录通过编辑入口修改。空白输入区和保存后未再修改的记录返回时不提示;保存失败和真实修改仍受保护。窄屏索引使用固定宽度折叠抽屉,不显示调宽分隔条,AI 评论移至底部。
|
||||
- [x] **分页画布笔记**:已集成 PeopleLib Fabric + Quill 编辑器,支持跨页正文、笔迹、高亮、对象选择及变换、浮动文字、图片和纸张模板。PDF 可作为分页底版导入并提取原生文字;正文和批注可导出为栅格 PDF,导出文件不保留可搜索文字层。用户手动保存后,记录正文与二进制资源存入本地笔记文件,SQLite 保留元数据、索引、修订、待办和评论。备份须协调保存 SQLite 与笔记目录,产品无一键备份入口。撤销/重做限当前批注页或 Quill 模式,不提供全局文档撤销、无限画布或 PNG 下载按钮。草稿、已保存记录和来源为画布的待办按默认模型图像输入能力使用页面图像加文字,或明确标注仅文字降级;“发送画布页数”可选 1~8,默认 1,按当前顺序发送前 N 页,文字和图片范围一致,保存与导出仍支持 50 页。纯视觉记录/草稿需要支持图像输入的模型。即时评论模式提供手动画布分析,保存后自动分析失败不阻断或回滚保存。提取文字不变时保留文字评论;布局变化使视觉评论失效,并在保存后自动评论模式下重新分析。MCP 禁止以纯文字覆写画布记录。实现已完成,最终验证状态见[功能进展](./docs/features/magic-notes/progress.md)。
|
||||
编辑与只读画布均支持 25%–300% 视图缩放、100% 还原及随视口调整的“适应宽度”,不改变保存内容或导出尺寸。
|
||||
历史正文迁移已改用文件存储,当前 schema 48 新增会话来源元数据;旧客户端不能再打开,回退需使用升级前备份。
|
||||
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、能力范围和审计。
|
||||
- [ ] **知识/网页摘录与 AI 编辑**(规划中):在已实现的会话正文采集之外,扩展知识和网页摘录,并提供需确认的总结、改写和整理操作。
|
||||
|
||||
### 工作管理、长期协作与工作流
|
||||
|
||||
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;迁移旧缓存时若历史可能已被截断,会持续明确提示,不能把既有缺口伪装成完整。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
|
||||
- [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] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单;按分区回顾、候选生成和唤起条件仍待设计。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
|
||||
- [ ] **通用监督**(规划中):通过固定监督栏目观察用户选择的会话、任务、自动化或实验对象,提供带证据的评论与人工介入请求,但不自动发言、批准工具或切换 Execute。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
|
||||
- [x] **任务、活动与工作区文件**:集中管理任务状态和审计活动。成果页已移除,工作区按实际文件显示修改时间和可用的创建时间,支持排序及向所选本机或远程目录导入文件;远程上传需要更新后的 Agent。会话图片和附件保留在消息中,已有 artifact 数据不删除,也不自动复制到项目。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;用量按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺。独立“系统任务”页签按来源汇总后台用量,支持手动刷新和记录更新后刷新;监督回顾、图片重新生成、图谱提取和远程嵌入补充用量记录,不补算缺失的历史或供应商数据。活动按会话分组并默认收起,避免长历史占满页面。
|
||||
OpenCode 的输入量已扣除缓存,计算命中率时会加回缓存读取和写入量;查询已有记录时也按此口径重算。
|
||||
- [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。OpenCode、Continue、DeepSeek Harness 和直连模型可显示已有文件路径、命令或搜索摘要,并在工具完成和会话重载后保留。
|
||||
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,沿用 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 的每日、每周计划及建议,Task Center 和设置中心不复制表单。待处理建议不重复,忽略后有新依据才再次提出;项目与全局共用按来源版本维护的时间线进度,会话级实时主动介入仍为规划。详见[实现规则](./docs/features/conversation-supervision/logic-design.md)。
|
||||
- [x] **故事、经验与时间螺旋**:事件可组织为项目功能、子线索及可选跨项目故事,支持重命名、合并、调整归属、移除和撤销。经验保留形成及应用依据与人工调整标记,心跳可提示暂无进展故事或可能适用的经验。平铺和懒加载 WebGL 时间螺旋共用选择与来源,保留平铺回退及图形资源释放;整理与建议可能增加模型费用,自动结果须核对来源。详见[实施证据](./docs/features/conversation-supervision/progress.md)。
|
||||
- [x] **监督者回顾、故事线图谱与活动**:按范围和时间回顾工作,查看保存结果的图谱及来源,确认或修订实体,并带入相关回顾上下文继续讨论。侧栏可跟随或固定 Conversation、Task,并打开匹配结果。活动合并心跳触发与下游监督的阶段状态,保留失败和旧结果入口。自动与默认手动回顾只处理新增、修改及未处理来源,无变化时跳过模型;显式“重新整理”重读区间,但不推进或重置共享进度,自动回顾不续跑用户暂停的运行。回顾只读且禁止工具,模型调用可能产生费用,详见[实施与验证记录](./docs/features/conversation-supervision/progress.md)。
|
||||
手动开始、自动触发和继续共用一个活动回顾;活动记录可取消并保留已保存批次,清理结束前不接纳新回顾,已取消回顾不可继续。故事线事件、实体和关系按独立页签展示数量,并与画布选择同步。
|
||||
可从监督反馈或图谱来源直接继续讨论,编辑问题并明确打开目标会话;界面不再提供知识实体写回控件或重复上下文说明,智能心跳用可处理的监督建议代替报告指标和趋势。
|
||||
- 手动回顾分页处理所选历史,并按批保存事实与来源;不再按固定时长自动暂停,仍支持主动暂停和从保存进度继续。设置提供并发、单次请求超时、批次大小和响应容量;活动记录突出相连阶段、已保存批次与剩余来源,次要统计可展开查看。工作回顾采用全宽纵向布局,运行状态单独显示,空态不再挤在控件旁。
|
||||
- [ ] **监督后续能力**(规划中):逐事件完整回放、Experiment 目标、人工修改时间序列审计、事件触发及更完整的执行观察仍未完成。当前自动回顾受输入预算和滚动窗口限制,删除报告不会重置处理进度或重建图谱。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
|
||||
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
|
||||
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
|
||||
- [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、审批、取消和恢复,执行节点继续经过 Main Runtime 边界。
|
||||
- [ ] **统一 Run Graph 与回放**(规划中):关联任务、Subagent、模型、知识、工具审批、用量和成果,支持失败定位、重试和脱敏导出。
|
||||
- **Story Graph 只读工具(Desktop 0.15.9 / Agent 0.15.3)**:Agent 可搜索已保存工作、故事和经验,读取上下文并分页核对来源。监督者开关及 Runtime 分配同时控制发现和调用,旧会话也受限制;本地和远程路径共用桌面读取入口。共享来源版本事实按允许范围复用,关键词匹配和工具说明引导模型按需查询,不自动注入全图;经验是参考而非指令,查询本身不调用模型,`as_of` 仍未实现。详见[设计与验证](./docs/features/conversation-supervision/story-graph-mcp-design.md)。
|
||||
- [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、业务确认、取消和恢复,执行节点继续经过 Main Runtime 边界。
|
||||
- [ ] **统一 Run Graph 与回放**(规划中):关联任务、Subagent、模型、知识、工具活动、用量和成果,支持失败定位、重试和脱敏导出。
|
||||
|
||||
### 浏览器、通信、语音与应用维护
|
||||
|
||||
- [x] **Runtime 共享内置浏览器**:使用 GoodBuddy 内置的隔离 Chromium,不控制客户端已安装的浏览器;用户通过内置 MCP 总开关和 Runtime 分配决定是否提供给 Execute,开启后不逐次询问。关闭 Agent 浏览器能力不影响用户继续在浏览器工作栏中手动前往、返回、刷新、停止加载、交互或关闭浏览器。直连模型、GoodBuddy 管理的 OpenCode 和 Continue 通过请求级权限共享按 Conversation 归属的会话和串行操作路径,用户导航会改变 Agent 下一步看到的页面。
|
||||
- [x] **Runtime 共享内置浏览器**:使用 GoodBuddy 内置的隔离 Chromium,不控制客户端已安装的浏览器;用户通过内置 MCP 总开关和 Runtime 分配决定工具接入,开启后不逐次询问。关闭 Agent 浏览器能力不影响用户继续在浏览器工作栏中手动前往、返回、刷新、停止加载、交互或关闭浏览器。直连模型、GoodBuddy 管理的 OpenCode 和 Continue 通过请求级权限共享按 Conversation 归属的会话和串行操作路径,用户导航会改变 Agent 下一步看到的页面。
|
||||
- [x] **浏览器多页签与按需资源**:同一对话可打开多个共享登录状态、页面与导航独立的浏览器页签,模型请求固定使用开始时绑定的目标。未使用的请求预留不创建浏览器资源,已释放页面可重新打开;保留显式截图,普通操作不再触发无用的自动截图。
|
||||
- [x] **浏览器关闭与刷新**:AI 使用中的页签也可关闭,不取消整个请求;后续显式导航会创建独立替代页。空白页不能刷新,切换或关闭面板后清除旧操作错误。
|
||||
- [x] **客户端电脑控制工具**:与内置浏览器分开管理,并保留范围、取消、超时、输出边界和执行记录。
|
||||
- [x] **远程消息通道项目**:微信 ClawBot、企业微信和钉钉分别拥有系统管理的项目、独立远程会话、工作目录、处理后端、默认 Ask/Execute 模式及任务活动归属;完整回复交由各通道按平台能力控制长度与分段,不再由公共服务统一截断。
|
||||
- [x] **远程消息通道项目**:微信 ClawBot、企业微信、钉钉和 Telegram 分别拥有系统管理的项目、独立远程会话、工作目录、处理后端及任务活动归属;完整回复交由各通道按平台能力控制长度与分段,不再由公共服务统一截断。Telegram 的真实 Bot 与模型链路仍[待验收](./docs/features/telegram-channel/progress.md)。
|
||||
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
|
||||
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
|
||||
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||
- [x] **受管本地模型下载源**:在“平台功能 / 通用设置”中为后续语音输入与 OCR 模型下载全局选择 ModelScope(默认)或 Hugging Face;所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件。
|
||||
- [x] **受管本地模型下载源**:“模型连接”页头在模型类型页签同行右侧、保存操作下方显示当前来源与次按钮。弹窗为后续受管本地模型下载全局选择 ModelScope(默认)或 Hugging Face,确认才保存,取消保持已保存来源不变,错误可重试;向量、语音和 OCR 的更换下载源入口直接打开该弹窗。所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件。
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。
|
||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
|
||||
- [x] **窗口恢复入口**:渲染失败时显示本地化的重新加载页面,渲染进程崩溃时提供原生恢复确认;重新加载可能丢失未保存输入。详见[窗口恢复说明](./docs/development/window-recovery.md)。
|
||||
- [x] **应用内反馈**:在“关于与更新”中直接提交问题、建议或体验反馈,支持可选邮箱和单张截图;默认不上传诊断,用户可主动附加有界的最近桌面诊断摘要。失败后保留草稿、诊断选择和同一请求编号;不会附加对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。
|
||||
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
|
||||
- [x] **内网兼容**:允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书;反馈、微信凭据与媒体等固定平台服务仍保持严格证书校验。
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
|
||||
- 当前 Desktop 候选为 `0.16.1`,Agent 保持 `0.15.6`、最低要求 Desktop `0.16.1`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.16.1` 平滑消息与输入框的过渡:消息会延伸到输入框轮廓前再淡出,浅色和深色主题的背景与焦点边界也不再在输入框上方形成突兀的实色横带;schema 保持不变。
|
||||
- Desktop `0.16.0` 将专家和团队角色选择移入输入框,并按会话保存选择。历史消息保留请求当时使用的角色,即使角色之后被重命名或删除也不会丢失这项上下文;schema 保持不变。
|
||||
- Desktop `0.15.14` 恢复因来源变化或移除而失败的历史监督回顾的继续操作。持续变化的来源只会在有限次数内重新冻结处理,之后自动省略,使回顾能够结束并保留其余来源的进度;设置分类增加对应图标,方便快速查找;schema 仍为 62。
|
||||
- Desktop `0.15.13` / Agent `0.15.6` 将桌面业务 SQLite 的唯一 owner 移到一个类型化 storage utility process,Assistant、Knowledge、附件、运行绑定和账本保留原路径及事务语义。直连模型在工具失败后可继续,分页输出、Magic Notes 标签、监督来源别名、设置直达预加载和文件元数据同步修正;schema 仍为 62。storage/监督 focused 编排已通过,但 C01–C07 并发验收矩阵、跨平台 storage 进程安装和低端机对照仍是明确后续门槛,不作通用性能承诺。见[发布准备](./docs/development/release-preparation-0.15.13.md)。
|
||||
- Desktop `0.15.12` / Agent `0.15.5` 增加 Telegram 私聊配置(真实 Bot 与模型完整验收仍待完成)和本地日期自定义回顾。监督图谱/来源按需读取、索引 Worker 查询、摘要缓存和选择列表窗口化减少重复工作,最终发布仍可阻塞 Main;未发布回顾省略已删除会话并重算受影响批次,历史结果保留。临时模型失败最多多试一次,可能增加费用;远程启动取消、通道交付、长消息展开状态及历史缺失时长得到修正。schema 62 增加通道支持和查询索引,升级前备份完整数据目录,降级需恢复备份。两个独立开发审计阶段共记录 15 次外部文本请求,本次发布准备没有新增;精确源码 CI 与原生打包仍为门槛。见[发布准备](./docs/development/release-preparation-0.15.12.md)。
|
||||
- Desktop `0.15.11` / Agent `0.15.4` 移除 Ask/Execute 与通用工具审批,旧 Ask 会话也不再有产品级只读边界。schema 60 和 Runtime 设置 22 清理退休元数据,升级前备份完整数据目录,降级需恢复备份;远程需配套 runtime/acp 6 工件。工作区导入、文件时间排序、会话图片发现和远程专家保存随成果页移除一起提供,不删除既有成果。执行时长按运行状态计时、不补算旧历史,OpenCode 缓存率采用完整输入量。回顾历史、响应布局及螺旋实际区间着色保留未归属事件与中性空档;汇总读取和回顾初始化减少限定路径 I/O,不承诺普遍提速。开发证据含无模式执行与专家图片保存共 25 次真实文本请求,不是本次发布准备新调用或图片生成;精确候选 CI、签名包、公开验证和跨版本活动操作恢复与这些检查分开记录。见[发布准备](./docs/development/release-preparation-0.15.11.md)。
|
||||
- Desktop `0.15.10` 统一项目/会话菜单并显示十个最近会话,增加独立监督模型选择及默认开启的会话 Story Graph 开关。活动索引分页与完整汇总、文档 Worker、延迟启动、窄渲染订阅、列表窗口化、终端批次和旧 Runtime 缓存清理减少重复工作,不承诺通用提速比例。schema 58 要求升级前备份数据库及笔记;高频本地写入改用 NORMAL,断电/系统崩溃可能丢失最近部分写入,远程确认、通道投递和任务生命周期仍用 FULL。Agent 保持 0.15.3;四次真实 Host 模型请求及 1000 行 Unicode SSH 终端检查通过,精确候选 CI 与六平台打包仍为发布门槛,不宣称已修复尚未定位的远程恢复错误。见[发布准备](./docs/development/release-preparation-0.15.10.md)。
|
||||
- Desktop `0.15.9` 增加共享回顾进度、故事经验与时间螺旋,schema 56 要求升级前备份,相关模型整理可能增加费用。会话与消息窗口化、Markdown 缓存、只读 Worker、活动差量保存及远程 checkpoint 批次落库减少重复工作,不承诺所有机器相同比例提速;页内查找不覆盖未挂载行,快速滚动可能显示占位,Worker 不可用时同步回退。Execute 配置修改取消重复原生确认,Electron 为 44.5.1。当前源码四次真实 Host 模型请求通过,仍需精确候选 CI 与打包验证,见[发布准备](./docs/development/release-preparation-0.15.9.md)。
|
||||
- Desktop `0.15.8` 与 Agent `0.15.2` 支持 Execute 模式保存会话图片到本机或 Host 绝对路径,覆盖需明确指定,不支持转换为 WebP。MCP 设置列出图像工具,不代表所有模式均可用。笔记增加新记录取消、编辑焦点返回及可用内容全宽;缺少浏览器和磨砂设置时默认开启,已保存选择不变。schema 仍为 52。真实 Linux x64 Host 工具层证据与待完成的精确候选 CI、打包验证分别记录在[发布准备](./docs/development/release-preparation-0.15.8.md)。
|
||||
- Desktop `0.15.7` 复用已完成的流式 Markdown 块与会话派生值,不改默认流式节奏。运行记录进入时读取、可见时每 15 秒刷新,也可手动刷新;本地自定义 MCP 发现故障按服务隔离。受管模型下载源移至模型连接中的确认弹窗,设备共享改为显式启用的测试功能。schema 仍为 52,仍需精确候选 CI 与打包验证,组件测量不等同完整 App 结果。详见[发布准备](./docs/development/release-preparation-0.15.7.md)。
|
||||
- Desktop `0.15.6` 增加默认关闭、可保存的顶栏与清单磨砂外观,不透出系统桌面;减少聊天重复渲染,草稿与队列图片支持直接预览,设置分组及开关右对齐,笔记标题和编辑间距更紧凑。本地 OpenCode 在清理后对明确暂时性 MCP 传输错误最多重试一次,作废结果不确定的名称,不重放 Prompt 或工具调用;反馈包含脱敏连接诊断。schema 52 增加执行统计索引,升级前备份数据库和笔记。详见[发布准备](./docs/development/release-preparation-0.15.6.md)。
|
||||
- Desktop `0.15.5` 更新智能心跳与增量回顾,保留中断轮次历史,执行清单改为浮层,笔记保存成功后退出编辑;自动评论不重新打开编辑器,失败保留草稿。新增手动笔记标签、AND 筛选及重命名/合并/删除,双栏笔记与全宽概览,固定工作区文件操作栏,缓存 OpenCode 终端依赖并缩短 MCP 名称。schema 51 增加标签存储,同时迁移旧待确认记忆为待处理约定;升级前备份数据库与相邻笔记。证据见[发布准备](./docs/development/release-preparation-0.15.5.md)。
|
||||
- Desktop `0.15.4` 增加 Runtime/模型分层选择及独立系统任务用量,减少 Runtime 指令和历史重复。schema 49 迁移项目与会话选择,回退须恢复完整升级前数据库和相邻笔记备份。Agent `0.15.1` 要求 Desktop `0.15.4`,修复远程 Continue 历史重复封装,Runtime 锁版本不变。证据见[0.15.4 发布准备](./docs/development/release-preparation-0.15.4.md)。
|
||||
- Desktop `0.15.1` 已完成六平台原生打包及 GitHub/OSS 发布。Desktop `0.15.2` 新增 Story Graph 只读工具和完整可编辑的监督讨论消息,简化笔记采集并补充退出诊断;远程图谱读取需配套 Agent `0.15.0`。主机与云环境管理应用仍为待实现规划,当前候选证据见[0.15.2 发布准备](./docs/development/release-preparation-0.15.2.md)。
|
||||
- Desktop `0.15.1` 承接未发布的 `0.15.0` Obsidian 接入、会话采集到魔法笔记和预览中的本地原生客户端,并修复知识库、监督者与界面操作。schema 48 新增会话来源元数据,回退旧客户端需使用升级前完整备份。Agent `0.15.0` 要求 Desktop `0.15.2`,保留 Node `24.19.0`,补齐远程遥测关闭配置及 Continue 跳过已关闭的更新检查,不新增远程客户端快捷入口。
|
||||
- Desktop `0.13.15` 承接未发布的 `0.13.14`,改善监督者分页回顾与续跑、ripgrep 原生搜索、待办布局和远程结果未知后的显式续聊。数据库升级至 schema 47,回退旧客户端需使用升级前完整备份。
|
||||
- Desktop `0.13.16` 分离结构升级与旧历史回收,移除回顾固定时长自动暂停,并改善监督者布局。相较 `0.13.15` 不新增 schema 变更,也不要求更新 Agent。
|
||||
- Agent `0.13.3` 回收空闲 Runtime,并使被替换的 Agent 等已有工作结束后退出;复合包要求 Desktop `0.13.12`,请先升级 Desktop,再更新 Host 环境。Node 保持 `24.19.0`。
|
||||
- Agent `0.13.4` 在完成前等待模型交付确认,空闲 Runtime 回收失败不改变已完成结果,并在环境更新后清理已确认停用的旧程序及所属孤儿进程;保留历史、活动及状态不明的安装。独立包要求 Desktop `0.13.14` 或更新版本;因 `0.13.14` 未发布,请先升级至 Desktop `0.13.15`,再更新 Host。
|
||||
- 发布准备要求全量测试、发布说明校验、类型检查及 lint;候选主分支 CI、生产构建、原生打包及公开发布核验为独立验收步骤。本轮不做本地生产构建、打包或龙芯预览,当前验证状态见[发布准备记录](./docs/development/release-preparation-0.15.0.md)。
|
||||
- Desktop `0.13.12` 和 Agent `0.13.3` 已完成原生发布流程,但不替代开发期真实 Host 场景覆盖。
|
||||
- 验证记录与功能状态分开。存储已有本地及 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)。
|
||||
- 发布准备前,`9836a4c` 已通过本地 4,858 项测试(67 项跳过)、完整类型检查和 lint;真实服务商画布分析仍未验证。该提交的三个原生 Agent CI 构建通过;桌面 CI `35485936807` 为 4,875 项通过、8 项 Electron 测试失败,均因 Linux 沙箱辅助程序未配置,在页面断言前退出。候选配置该程序并在 Xvfb 下运行测试,不关闭沙箱;候选本地验证、主分支 CI 与原生标签打包分别记录,不在发布准备中运行本地生产构建或打包。
|
||||
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
|
||||
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
|
||||
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希;Linux 同时生成 AppImage、DEB 和 RPM。
|
||||
- [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] **远程 Continue 交付**:已接入 CN 1.5.47 受管包、Agent HTTP-to-ACP 适配、模型路由、项目选择和原生清单。默认包同时携带 OC/CN;Windows 桌面到 Linux x64 已通过安装、真实模型清单更新、长清单、取消及重启恢复验证。Execute 会话 MCP 已通过真实文本模型 Host 往返,图片服务使用替代接收端,不代表真实生图验证。已发布 Agent `0.13.4` 需要 Desktop `0.13.14` 或更新版本;正式发布及原生平台验收与 Linux 开发证据分开,详见[验证记录](./docs/features/remote-host/runtime-checklist-validation.md)。
|
||||
|
||||
- [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 进程,高风险控制面操作单独确认。
|
||||
- **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 传输矩阵,发布结果另行记录。
|
||||
- [ ] **主机管理与云环境管理**(规划中):主机管理迁入应用中心,提供列表与详情工作页面;云环境管理作为同级应用,复用主机连接和执行能力。首版适配计划覆盖 AWS、Azure、阿里云、腾讯云、VMware vCenter / vSphere、ZStack,分批完成已有实例发现、接入及真实执行验收;实例创建、启停、销毁和网络编排后续交付。平台 API 尚未实现,现有云主机可按 SSH 条件手工接入。详见[产品范围](./docs/features/remote-host/prd.md)。
|
||||
|
||||
## 规划原则
|
||||
|
||||
规划中的工作流、Subagent、MCP、远程 API 和沙盒能力不得绕过现有 Main Runtime、Ask/Execute、权限、取消、超时和审计边界。
|
||||
规划中的工作流、Subagent、MCP、远程 API 和沙盒能力不得绕过现有 Main Runtime、能力范围、权限、取消、超时和审计边界。
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
[](https://github.com/mesalogo/goodbuddy/releases)
|
||||
</div>
|
||||
|
||||

|
||||

|
||||
|
||||
GoodBuddy is a desktop application for model chat, coding agents, knowledge
|
||||
retrieval, notes, and scheduled tasks. No GoodBuddy account is required.
|
||||
@@ -32,8 +32,8 @@ same device, on a private network, or at a provider selected by the user.
|
||||
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,
|
||||
- **Unified execution**: Requests use the selected Runtime, enabled capabilities,
|
||||
and current account permissions without choosing a work mode. 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
|
||||
@@ -53,24 +53,25 @@ current limitations.
|
||||
| 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 |
|
||||
| Data and execution | Local SQLite and note-file storage, current-account tool execution, 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 |
|
||||
| Integrations | Local or hosted model endpoints, custom MCP servers, WeChat ClawBot, WeCom, DingTalk, and Telegram ([live Bot + model acceptance pending](./docs/features/telegram-channel/progress.md)) |
|
||||
|
||||
## Product tour
|
||||
|
||||
### Knowledge bases and graphs
|
||||
|
||||

|
||||
|
||||

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

|
||||
|
||||

|
||||
Magic Notes displays a continuous record stream, with a narrow left index and
|
||||
independent right AI comments. Both panes can collapse; thumbnails scroll to a
|
||||
record while preserving drafts. It supports rich-text entries and paged canvases with flowing text,
|
||||
handwriting, highlights, images and PDF backgrounds. Import PDF text and export
|
||||
annotated pages as a raster PDF. Canvas saves are manual; AI comments use the
|
||||
default model's image-input capability, with an explicit text-only fallback when
|
||||
images are unsupported. See the [Magic Notes documentation](./docs/features/magic-notes/README.md)
|
||||
for behavior and final validation status.
|
||||
|
||||
## Agent Runtimes
|
||||
|
||||
@@ -133,7 +134,7 @@ See [BUILD.md](./BUILD.md) for build and packaging instructions.
|
||||
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
|
||||
install scripts, initialization code, and tools run with the current
|
||||
user's permissions.
|
||||
- Remote delegation is enabled only after the user configures an endpoint and
|
||||
token.
|
||||
@@ -156,7 +157,9 @@ npm run lint
|
||||
|
||||
Scan the QR code to join the GoodBuddy WeChat group:
|
||||
|
||||
<img width="1250" height="1194" alt="9627ce196b0de42f123c33dd9dea1568" src="https://github.com/user-attachments/assets/e806543f-6d8a-4c5c-ba44-19e74dac9a78" />
|
||||
<img width="1250" height="1743" alt="8d6cd5dc3f347a0f3f1cf3844071b4d3" src="https://github.com/user-attachments/assets/1c92c1b8-d1f7-4270-be2c-b9543beadc11" />
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+13
-13
@@ -19,7 +19,7 @@
|
||||
[](https://github.com/mesalogo/goodbuddy/releases)
|
||||
</div>
|
||||
|
||||

|
||||

|
||||
|
||||
GoodBuddy 是用于模型对话、编程 Agent、知识检索、笔记和定时任务的桌面应用。使用 GoodBuddy 无需注册账号,工作数据默认保存在本机。模型服务可以运行在本机、组织内网或用户选择的云端服务。
|
||||
|
||||
@@ -27,7 +27,7 @@ GoodBuddy 是用于模型对话、编程 Agent、知识检索、笔记和定时
|
||||
|
||||
- **本地优先**:无需注册 GoodBuddy 账号,工作数据默认保存在本机,模型服务由用户选择。
|
||||
- **统一 Agent Runtime**:在一个桌面界面中使用直连模型、OpenCode、Continue 和预览版 DeepSeek Harness。
|
||||
- **执行边界明确**:`Ask` 保持只读,`Execute` 使用当前账号权限,工具调用、取消、超时和 Token 用量均有记录。
|
||||
- **统一执行**:请求按所选 Runtime、已启用能力和当前账号权限运行,无需选择工作模式;工具调用、取消、超时和 Token 用量均有记录。
|
||||
- **支持信创软硬件**:正式 Linux 版本覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境;龙芯 LoongArch 提供独立 `loong64` 实验预览版。
|
||||
- **知识与长期工作一体化**:知识库、知识图谱、魔法笔记、任务和智能心跳共享同一个桌面工作空间。
|
||||
|
||||
@@ -38,24 +38,22 @@ GoodBuddy 是用于模型对话、编程 Agent、知识检索、笔记和定时
|
||||
| 范围 | GoodBuddy 提供的能力 |
|
||||
| --- | --- |
|
||||
| Agent Runtime | 直连模型、OpenCode、Continue 和预览版 DeepSeek Harness |
|
||||
| 数据与执行 | 本地 SQLite、只读 `Ask`、使用当前账号完整权限的 `Execute`、工具调用记录 |
|
||||
| 数据与执行 | 本地 SQLite 与笔记文件存储、当前账号权限下的工具执行、工具调用记录 |
|
||||
| 知识库 | 导入文件、目录和网页,通过全文、中文词组、向量和知识图谱检索 |
|
||||
| 桌面版本 | Windows、macOS、Linux 的 `x64` 与 `arm64` 正式版本,以及龙芯 LoongArch 实验预览版 |
|
||||
| 外部连接 | 本机或内网模型服务、自定义 MCP、微信 ClawBot、企业微信和钉钉 |
|
||||
| 外部连接 | 本机或内网模型服务、自定义 MCP、微信 ClawBot、企业微信、钉钉和 Telegram([真实 Bot 与模型链路待验收](./docs/features/telegram-channel/progress.md)) |
|
||||
|
||||
## 产品界面
|
||||
|
||||
### 知识库与知识图谱
|
||||
|
||||

|
||||
|
||||

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

|
||||
|
||||

|
||||
魔法笔记连续展示全部记录,左侧窄栏索引与右侧 AI 评论可独立收起,点击缩略图滚动定位并保留草稿。
|
||||
支持富文本记录和分页画布,可混排跨页正文、手写笔迹、高亮、图片与 PDF 底版,
|
||||
导入 PDF 时提取原生文字,并将批注页面导出为栅格 PDF。画布由用户手动保存;AI 评论按
|
||||
默认模型的图像输入能力分析,不支持图像时明确提示仅文字降级。具体行为与最终验证状态见
|
||||
[魔法笔记文档](./docs/features/magic-notes/README.md)。
|
||||
|
||||
## Agent Runtime
|
||||
|
||||
@@ -110,7 +108,7 @@ npm run dev
|
||||
- 模型请求只发送到用户选择的服务。
|
||||
- 本地数据默认保存在系统应用数据目录,API Key 由系统安全存储加密。
|
||||
- Renderer 不接触原始 Electron API 或模型凭据。
|
||||
- DeepSeek Harness 插件市场默认关闭。第三方插件安装脚本、初始化代码和 Execute 工具以当前用户权限运行。
|
||||
- DeepSeek Harness 插件市场默认关闭。第三方插件安装脚本、初始化代码和工具以当前用户权限运行。
|
||||
- 远程委派只在用户配置端点和令牌后启用。
|
||||
- 内网兼容模式允许应用内 HTTP 和非标准 HTTPS 证书;微信凭据和媒体端点仍执行严格校验。
|
||||
|
||||
@@ -128,7 +126,9 @@ npm run lint
|
||||
|
||||
扫描二维码加入 GoodBuddy 微信群:
|
||||
|
||||
<img width="1250" height="1194" alt="9627ce196b0de42f123c33dd9dea1568" src="https://github.com/user-attachments/assets/ff87dfac-d72f-47b7-ac9a-c88b0b4c96d9" />
|
||||
<img width="1250" height="1743" alt="8d6cd5dc3f347a0f3f1cf3844071b4d3" src="https://github.com/user-attachments/assets/e2cd4874-0b6c-40d4-8867-0a9e843a7cb5" />
|
||||
|
||||
|
||||
|
||||
|
||||
## 开源许可
|
||||
|
||||
+232
-71
@@ -35,6 +35,7 @@
|
||||
- 加载、空内容、无结果、失败、只读和权限不足是不同状态,必须分别呈现。
|
||||
- 可撤销操作优先提供撤销,不可撤销操作必须在执行前说明影响范围。
|
||||
- 不依赖颜色单独表达状态或风险。
|
||||
- 当前模型或 Runtime 及切换进度集中显示在会话输入区,顶栏不重复显示可用性标识。切换失败、自动状态检查失败或返回不可用时,通过应用通知说明原因;发送按钮保留关联的无障碍状态说明。
|
||||
|
||||
### 2.5 默认支持浅色与深色主题
|
||||
|
||||
@@ -42,6 +43,10 @@
|
||||
|
||||
两种主题必须保持相同的信息层级,但不要求机械地反转明暗。浅色主题以白色主内容画布、冰蓝灰侧栏和轻微着色的顶栏建立空间关系;深色主题使用深海军蓝与蓝灰表面逐层提亮,避免纯黑。蓝色承担主要选择和交互,青绿色主要承担成功与可用状态,二者不得混用语义。
|
||||
|
||||
外观设置提供“透明磨砂特效”开关,默认开启,文字在左、共享 Switch 在右。新用户及缺少该字段的旧配置使用开启状态;已保存的开关选择保持不变。选择保存在应用设置中,保存成功后立即生效,重启后恢复。开启时,右侧内容框架顶栏的背景不透明度在浅色主题为 65%、深色主题为 70%,保留 18px 背景模糊与淡分隔线。聊天滚动视口向上延伸 58px,消息上滚时实际经过顶栏背后;不使用人工渐变充当背景。初始留白放在滚动内容中,包含顶栏高度和当前任务/待办条的实测高度,首条消息仍从这些控件下方开始。任务/待办条固定在顶栏下方并保持实色;展开的 Runtime 待办卡片使用两主题各自的 75% 不透明背景、18px 背景模糊和清晰的文字颜色。
|
||||
|
||||
已保存的开关状态同步到文档根节点,卸载时清理,使 body/top layer 中的待办卡片也能应用样式;其他菜单保持原有表面。输入区、右侧工作栏和非聊天页面保持原有位置,聊天加载态及无活动消息面板时不扩展视口。窗口拖拽和控制按钮保持原有行为;该效果不透出操作系统桌面。关闭后恢复顶栏下方的聊天布局,顶栏与待办卡片均恢复实色。`tests/frosted-glass.electron.test.ts` 使用真实 `ChatHistoryPane` 检查消息与顶栏的可见矩形交叠、初始留白、关闭恢复、浅深主题、跳底、历史加载锚点和会话切换。
|
||||
|
||||
## 3. 设计令牌
|
||||
|
||||
令牌以 CSS 自定义属性实现。`:root` 提供浅色值,`[data-theme="dark"]` 覆盖深色值。组件样式不得新增只服务于单个页面的颜色、阴影、圆角或间距常量。
|
||||
@@ -67,7 +72,7 @@
|
||||
| `--success`、`--success-subtle` | 成功状态 |
|
||||
| `--warning` | 警告状态 |
|
||||
| `--danger`、`--danger-strong` | 危险文本和状态 |
|
||||
| `--danger-solid` | 最终确认按钮等需要反白文字的危险背景 |
|
||||
| `--danger-solid`、`--danger-solid-hover` | 最终确认按钮等需要反白文字的危险背景及悬停背景 |
|
||||
| `--danger-border`、`--danger-subtle` | 危险入口和失败状态背景 |
|
||||
|
||||
浅色与深色具体值只在 `styles.css` 的主题根节点维护。状态组件必须同时显示文字或图标,不能仅靠颜色区分。
|
||||
@@ -115,6 +120,7 @@
|
||||
|
||||
- 业务组件通过 `--font-family-ui` 与字体尺寸令牌继承字体,不创建页面专属字体栈。
|
||||
- 表单按钮、输入框、选择框和文本域必须继承界面字体,避免回退为原生控件字体。
|
||||
- 文本表单复用 `.field` 下的共享输入规则;不适合增加字段包装层的独立输入或文本域使用 `.field-control`。文本输入规则排除 Checkbox 和 Radio,保留全局 `:focus-visible` 轮廓,不清除 Switch 焦点环。
|
||||
- 连续阅读内容使用 `14px`,持久辅助信息不得小于 `11px`。
|
||||
- 本地兜底字体资源必须随生产包交付,并同时包含 Inter 与 Noto Sans SC 的 OFL 许可证。
|
||||
- 页面内不得通过同时放大字号、加粗和使用强调色制造多个同级主标题。
|
||||
@@ -145,8 +151,9 @@
|
||||
| 变体 | 最大内容宽度 | 适用场景 | 页面映射 |
|
||||
| --- | --- | --- | --- |
|
||||
| `reading` | `820px` | 连续阅读、单列编辑、对话撰写 | 聊天正文与输入区 |
|
||||
| `standard` | `960px` | 常规列表、设置与表单 | 最近对话、设置 |
|
||||
| `dashboard` | `1040px` | 指标、卡片网格、宽表格与审计数据 | 智能心跳、活动记录 |
|
||||
| `standard` | `960px` | 常规列表与表单 | 最近对话 |
|
||||
| `dashboard` | `1040px` | 指标、卡片网格、宽表格与审计数据 | 活动记录 |
|
||||
| `supervisor` | `1440px` | 选择列表、图谱画布、来源详情三栏工作台 | 监督者(含智能心跳设置) |
|
||||
| `master-detail` | 可用空间内流式铺开 | 左侧选择、右侧编辑或预览 | 知识库 |
|
||||
|
||||
共同规则:
|
||||
@@ -156,6 +163,7 @@
|
||||
- 页面顶部和底部内边距默认为 `32px`。
|
||||
- `master-detail` 的左栏建议为 `280px` 至 `360px`,右栏占剩余空间。分隔线和滚动容器属于壳层,不由内容卡片模拟。
|
||||
- 聊天输入区可以粘附在 `reading` 壳层底部,但消息内容和输入区必须共享同一阅读宽度。
|
||||
- 输入区外层不铺整块背景、不画横向分隔线。输入区以上内边距的高度向上叠入消息区,输入框上方保留 `--space-4`(16px)的透明到页面底色渐变,渐变终点对齐输入框上缘,让滚动的消息一直延伸到输入框轮廓前再淡出;输入框上缘以下才使用实色底;渐变位于输入框之下、不拦截鼠标事件,并随主题切换。输入框上缘只保留自身 1px 描边,不加向上的阴影,避免与渐变终点叠成粗边界。
|
||||
- 宽表格可在 `dashboard` 内容区内部横向滚动,不得撑宽整个应用窗口。
|
||||
|
||||
## 5. 信息层级
|
||||
@@ -176,7 +184,8 @@
|
||||
`PageHeader` 统一接收以下内容:
|
||||
|
||||
- `title`:必填,简短名词或任务名称。
|
||||
- `description`:可选,一行说明当前页面能做什么,不重复标题。
|
||||
- `description`:可选,需要常显的简短上下文,不重复标题或帮助内容。
|
||||
- `help`:可选,补充用途和操作说明,通过标题同行的 `InlineHelp` 按需展开,规则见 6.14。
|
||||
- `scope`:涉及范围的数据页面必填,使用 `ScopeBadge`。
|
||||
- `primaryAction`:可选,页面唯一主操作。
|
||||
- `secondaryActions`:可选,最多两个直接显示,其余进入更多菜单。
|
||||
@@ -193,7 +202,7 @@
|
||||
|
||||
### 6.1 PageTabs
|
||||
|
||||
用于同一一级页面内的同级内容面板,例如智能心跳的“运行概览”和“心跳计划”。
|
||||
用于同一一级页面内的同级内容面板,例如监督者的“工作回顾”和“故事线图谱”。
|
||||
|
||||
- 使用 `tablist`、`tab` 和 `tabpanel` 语义,当前项使用 `aria-selected="true"`。
|
||||
- 一级页面之间的导航由应用主导航承担,不复用 `PageTabs`。
|
||||
@@ -210,6 +219,8 @@
|
||||
- 选项数量为 2 至 4 个。
|
||||
- 使用带 `aria-pressed` 的按钮组语义,支持方向键切换。
|
||||
- 不能用于多选筛选、页面导航或执行即时命令。
|
||||
- 与 `PageTabs` 一样,共享控件根节点必须 `flex: 0 0 auto`,不能被同级长表单或滚动区域
|
||||
压缩高度。横向空间不足时保留控件自身滚动,不把标签裁成一条窄缝。
|
||||
- 控件宽度由内容决定,除移动窄屏外不默认等分整行。
|
||||
|
||||
### 6.3 筛选工具栏
|
||||
@@ -264,6 +275,11 @@
|
||||
- `danger-solid`:确认对话框中的最终危险操作。
|
||||
- `danger-zone`:设置页中集中展示的高影响操作区域。
|
||||
|
||||
`danger-solid` 必须是全局共享类,不依赖页面祖先;通过 Portal 挂到 `body` 后仍须生效。
|
||||
尺寸、圆角、字体、间距和禁用状态与 `primary-button`、`secondary-button` 共用规则,
|
||||
键盘焦点沿用全局 `button:focus-visible`。背景及可用态悬停使用危险实心色令牌,文字使用
|
||||
`--text-on-accent`;禁用时不触发悬停变色。
|
||||
|
||||
普通页面不得用实心红色按钮与主操作并列。危险入口必须使用具体动词和对象,例如“删除知识条目”,避免只写“确定”。
|
||||
|
||||
### 6.7 数据表格与活动列表
|
||||
@@ -281,13 +297,25 @@
|
||||
|
||||
- 浅色侧栏使用冰蓝灰表面,与白色主内容画布形成明确边界;深色侧栏使用比主画布略亮的蓝灰表面。
|
||||
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
|
||||
- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。
|
||||
- 当前导航项和当前会话使用一致的淡蓝选中背景、强调色文字和中等字重,不额外描边或添加侧边标记;导航图标随选中状态使用强调色,悬停时保留选中状态。浅深主题遵循相同规则,强制颜色模式保留系统选中边框。
|
||||
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与会话区域之间、会话区域与底部入口之间的分隔线上下均各留 `8px`(`--space-2`)。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
|
||||
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
|
||||
- 最近会话列表在精细指针设备上使用细原生滚动条,滑块使用 `--border-control`、轨道透明;强制颜色与粗指针设备保留系统默认。
|
||||
- 会话区域使用 `--surface-raised` 与主导航区分,浅色为白底,深色为相邻的深蓝灰底。不显示“最近会话”标题,保留区域的无障碍名称,列表独立滚动。搜索与新对话共用主导航上方的一行,不在会话区域另设搜索头部;展开、关闭和键盘规则见[会话搜索](./docs/features/application-tool-navigation/ui-design.md#会话搜索)。搜索框使用透明背景、`--border-control` 边框与完整焦点环。
|
||||
- 窗口高度不超过 `600px` 时,侧栏允许整体纵向滚动,会话区域至少保留 `200px` 高度,避免搜索框、列表与底部入口相互覆盖。
|
||||
- 最近会话的顺序不随流式输出增量反复变化;置顶与新消息排序遵守
|
||||
[会话列表排序规则](./docs/features/application-tool-navigation/ui-design.md#会话置顶与操作菜单)。
|
||||
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
|
||||
- 项目选择器与下方默认的新建对话按钮等宽,两行右侧图标按钮均宽 `36px`,与左侧控件间距均为 `8px`;搜索展开时项目选择器宽度保持不变。
|
||||
- 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。
|
||||
- 项目选择器使用固定双行单按钮,第一行显示项目名,第二行显示跨项目活动汇总或“暂无活动”;点击任一行打开统一菜单并默认预览当前项目,全部项目范围在菜单内切换。尺寸、焦点、项目计数与菜单遵守
|
||||
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
|
||||
活动数量不改变项目菜单的稳定分组和顺序。
|
||||
- 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整,
|
||||
并记住用户选择;分隔条使用 `separator` 语义,支持方向键、Home 和 End。小于
|
||||
`900px` 时继续使用固定宽度抽屉,不显示拖拽分隔条。
|
||||
- 关联 Task 的 Conversation 在行最左侧显示独立展开按钮,父会话行不重复任务标签或数量;
|
||||
展开后的每个 Task 子项左侧显示名称,模式、计划和共享状态标记收在右侧,与父会话的
|
||||
展开后的每个 Task 子项左侧显示名称,计划和共享状态标记收在右侧,与父会话的
|
||||
更新时间位置一致;展开层级只到 Task,不展示 Job、Subjob 或 Run。
|
||||
- 会话标题保持单行且不挤压更新时间。更新时间默认靠近行右侧;低频的更多操作按钮不常驻
|
||||
占位,只在悬停会话行时替换右侧时间显示。键盘焦点进入会话行或操作菜单展开时,按钮仍
|
||||
@@ -309,7 +337,11 @@
|
||||
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||
单行截断,不能撑宽或挤压侧栏。
|
||||
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
- 侧栏底部提供“应用 / Apps”和“设置 / Settings”两个横向等高 `nav-item` 按钮,均显示图标和文字,中间使用弱竖分隔线。“应用”带向下箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”使用 `LayoutGrid` 图标,打开既有整窗管理 Modal,标题仍为“应用中心 / Application Center”,共同应用设置沿用设置 Modal。
|
||||
五个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。监督者沿用智能心跳身份,可修改启用和常驻,执行边界见[监督者逻辑](./docs/features/conversation-supervision/logic-design.md#应用启停与执行)。魔法笔记、本机推理监控同样可修改启用和常驻。设备共享在测试阶段默认关闭,标签为“可选”及启用状态,提供启用设置和排序,不提供常驻开关;启用后可从应用菜单及管理 Modal 在主内容区打开元数据页面,关闭时退出该页,规则见[设备共享 UI](./docs/features/device-sharing/ui-design.md#当前首期页面)。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
|
||||
设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。
|
||||
管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;五行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
- 本机推理监控 Modal 复用整窗 Portal、背景隔离和焦点循环;停止、重启及释放使用其上方的紧凑影响确认。Escape 只关闭最上层,操作提交期间禁止关闭及重复提交;焦点恢复与尺寸见[本机推理监控 UI 规则](./docs/features/application-tool-navigation/ui-design.md#91-本机推理管理)。
|
||||
|
||||
### 6.9 助手工作栏
|
||||
|
||||
@@ -319,20 +351,31 @@
|
||||
动态 Tab、“+”和终端细节见
|
||||
[助手工作栏多终端页签 PRD](./docs/features/assistant-workbar/terminal-tabs-prd.md)。
|
||||
|
||||
- 升级后默认打开任务中心、工作区、浏览器和成果四个单实例应用,不根据当前页面或 Runtime
|
||||
能力自动增删稳定应用目录入口。
|
||||
- 升级后默认打开任务中心、工作区和一个浏览器实例。任务中心与工作区是不可关闭的
|
||||
单实例基础应用;浏览器是可关闭的多实例应用。成果页及其导入入口已移除。不可关闭不
|
||||
等于锁定顺序。
|
||||
- 工作区以实际目录文件为准,提供向所选目录导入文件、本机复制及远程上传。文件列表按名称、
|
||||
修改时间或可用的创建时间排序,目录优先,缺失时间显示短横线且排序置后。窄栏在文件视口内
|
||||
横向滚动。创建时间只取文件系统 birthtime,不使用 Linux ctime。会话附件和图片保留在消息中。
|
||||
- 资源监控作为本机单实例应用保留在能力目录,但不加入升级后的默认打开实例。面板只在可见
|
||||
活动时采样,首期不持久化历史或提供后台负载告警。
|
||||
- Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。
|
||||
“+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。
|
||||
- 应用注册表声明单实例或多实例。单实例重复选择时聚焦已有 Tab;终端从首期开始支持
|
||||
多实例,每次选择创建独立 Tab 和 Shell Session。
|
||||
- Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task
|
||||
只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,
|
||||
也不得把 Job/Run 提升为可导航 UI 对象。
|
||||
- 各栏目读取当前会话或项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因。
|
||||
- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。
|
||||
- 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。
|
||||
- 应用注册表分别声明单实例或多实例、默认打开、必须存在、可关闭和排序策略。在工作栏“+”目录中,
|
||||
单实例重复选择时聚焦已有 Tab;浏览器和终端每次选择创建独立实例。选择已有 Tab 只激活
|
||||
该实例,保持其目标和历史;主侧栏不再提供按当前上下文匹配实例的重复工具入口。
|
||||
- Task Center 是默认跟随当前项目的单实例索引,不使用多实例模式。用户可在同一 Tab 内切换
|
||||
“当前项目”和“所有项目”;无项目任务在“所有项目”中标注“未绑定项目”。范围变化不得创建第二个 Task Center。每个 Task 只
|
||||
关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,也
|
||||
不得把 Job/Run 提升为可导航 UI 对象。
|
||||
- 各栏目按声明的范围读取内容;笔记使用应用级数据,选择和草稿不跟随项目或会话变化。当前内容不可用时仍保留栏目入口并说明原因。
|
||||
- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。笔记遵循应用中心的业务启用设置,禁用前处理草稿,禁用后隐藏目录入口并移除笔记 Tab。
|
||||
- 用户可以主动排序栏目,并可关闭可关闭实例或恢复默认布局;任务中心和工作区不能隐藏或
|
||||
关闭。应用不能用用户偏好机制实现自动能力裁剪。
|
||||
- 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。
|
||||
实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。
|
||||
实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。不可关闭实例不渲染
|
||||
关闭按钮,也不响应关闭快捷命令。
|
||||
“+”位于滚动容器外并占用固定宽度;溢出时左右滚动箭头在滚动视口两侧各占独立宽度,不能覆盖页签、关闭按钮或添加入口。箭头隐藏或禁用时保留占位,避免悬停或滚动引起布局跳动。
|
||||
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
|
||||
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
|
||||
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
|
||||
@@ -344,13 +387,14 @@
|
||||
记忆功能实现前不展示占位内容,未来的记忆与历史执行上下文归入关联 Task 的任务中心详情。
|
||||
- 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。
|
||||
- 浏览器 Tab 的内容顶部固定使用一条紧凑工具栏,按“返回、刷新/停止加载、地址输入、
|
||||
前往、交互、关闭浏览器”排列。“刷新”在当前页面加载时原位切换为“停止加载”;
|
||||
前往、交互、关闭浏览器”排列。“前往”使用纯右箭头图标按钮,保留悬停提示和可访问名称。“刷新”在当前页面加载时原位切换为“停止加载”;
|
||||
“关闭浏览器”必须使用明确文字和与页面导航控件清楚区分的危险操作视觉,不能继续标为
|
||||
含义模糊的“停止浏览器”。地址输入显示浏览器权威提交 URL,但用户编辑期间保留本地草稿,
|
||||
不被画面或状态刷新覆盖;提交裸域名时自动添加 `http://`,提交、失焦后的同步规则不得让
|
||||
尚未提交的文字改变 Agent 看到的页面。
|
||||
- 浏览器工具栏操作的是当前 Conversation 中 Agent 使用的同一隔离浏览器会话,用户导航会
|
||||
有意改变 Agent 下一步看到的页面。创建中、Agent 操作中或已进入完整交互窗口时禁用其余
|
||||
- 浏览器工具栏操作的是该实例绑定的 Conversation 和 Browser Tab。同一 Conversation 的多个
|
||||
Tab 共享隔离登录上下文,但页面、导航、操作队列和元素引用相互独立。模型请求开始时冻结
|
||||
工具目标,用户切换工作栏 Tab 不改变进行中的 Agent 操作。创建中、Agent 操作中或已进入完整交互窗口时禁用其余
|
||||
工具栏控件;仅当前导航仍在加载时保留“停止加载”,且该操作只中止页面加载,不关闭浏览器、
|
||||
不释放 Runtime 上下文。没有会话的活动 Conversation 可以直接在地址栏输入 URL 并“前往”
|
||||
创建会话,但必须复用 Agent 浏览器相同的 URL 策略与能力路径。
|
||||
@@ -358,7 +402,9 @@
|
||||
遮罩或隔离其内容。分栏计算排除最左侧主导航,主工作区与工作栏采用相同的 `300px`
|
||||
最小宽度;拖动保存两者在可用分栏宽度中的比例,任一侧到达最小值后都不得继续侵占。
|
||||
所有可容纳两侧最小值的尺寸均支持指针和键盘调整,并须保留全部栏目入口。
|
||||
- 工作栏开关固定在窗口关闭按钮正下方,同一个按钮负责展开与收起并持续暴露状态;图标中的
|
||||
- 工作栏分隔条须保留完整的 `9px` 拖拽命中区域,位于侧栏内部,不能被容器裁剪;栏目内容
|
||||
和原生浏览器视口须避开该区域。浏览器全屏时隐藏分隔条并取消这段预留空间。
|
||||
- 工作栏开关固定在顶栏 `.topbar__actions` 内、紧邻浅色/深色主题按钮之前,同一个按钮负责展开与收起并持续暴露状态;图标中的
|
||||
箭头方向必须随当前动作切换,收起状态提示展开,展开状态提示收起。按钮保持普通图标按钮
|
||||
视觉,不通过持续高亮表达展开状态。工作栏内部不再重复关闭按钮;当前会话没有 Task 时,
|
||||
不得为了放置该开关而保留一条空上下文栏。
|
||||
@@ -380,6 +426,19 @@
|
||||
`34px × 34px` 点击区域。
|
||||
- 发布说明 Modal 与其他应用级 Modal 一样使用整窗遮罩;遮罩必须覆盖主侧栏、右侧内容和
|
||||
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
|
||||
- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,避免页面层叠上下文影响;
|
||||
保留 React Context、关闭与焦点管理,表单样式不得依赖已移出的页面祖先选择器。
|
||||
- 共享锚定菜单 `AnchoredMenu`、同行帮助 `InlineHelp` 和应用通知共用 `FloatingPortal` 的非模态 `popover="manual"`,由浏览器 top layer
|
||||
绘制,避免被 Modal 或滚动容器遮挡。菜单和帮助挂在触发按钮所属 Modal 内,通知跟随当前活动
|
||||
Modal,无 Modal 时挂在 `body`;不能只提高 `z-index` 或解除背景的 `inert`。
|
||||
- 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放
|
||||
浏览器 viewport;非模态对话框、菜单、同行帮助 tooltip、局部确认或应用通知与浏览器区域相交时也释放。
|
||||
浮层关闭后只恢复当前活动实例的 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` 之类的偏移绕开标题栏。遮罩回归按 fixed/inset 声明发现,不能只按 `backdrop` 或 `modal` 类名后缀筛选;知识引用的 `-dialog` 同样适用。
|
||||
- 当前会话存在 Task 时,Task 上下文栏位于标题栏下方并承载任务摘要与详情;没有 Task 时
|
||||
整行隐藏。折叠状态保持为 `46px` 单行表面,与右侧工作栏标签栏等高,左右分隔线必须水平
|
||||
对齐。该栏不重复放置新建入口。
|
||||
@@ -390,7 +449,7 @@
|
||||
不再重复显示状态文字;完整状态详情继续通过可访问说明和工具提示提供。
|
||||
- 浅色与深色切换属于持续可用的窗口级操作,直接显示太阳或月亮图标,并通过可访问名称说明将切换到的主题。选择必须持久化,切换不得改变布局。
|
||||
- 顶栏只直接显示当前任务所需的高频操作。已有侧栏账户设置入口时,不再重复显示 Runtime/设置入口;使用帮助优先放在相关操作附近,而不是为单个帮助项创建“更多”菜单。
|
||||
- 助手工作栏开关固定在上下文栏最右侧并与窗口关闭按钮纵向对齐;展开后该开关保持原位并
|
||||
- 助手工作栏开关固定在顶栏 `.topbar__actions` 内、紧邻浅色/深色主题按钮之前;展开后该开关保持原位并
|
||||
负责收起,不在工作栏标题区域增加第二个关闭入口。
|
||||
- 只有存在至少两个无法由稳定入口承载的低频全局操作时才增加全局菜单,不为了容纳一个冗余入口而显示省略号按钮。
|
||||
- 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。
|
||||
@@ -398,27 +457,31 @@
|
||||
|
||||
### 6.11 单选选择器
|
||||
|
||||
模型、专家角色、工作模式、Runtime Agent、Runtime 预设和 Runtime 快捷操作属于同一输入上下文,其选择器必须共享结构、尺寸和菜单视觉,不能出现一个精细菜单与多个风格不一致的原生下拉框。
|
||||
模型、专家角色、Runtime Agent、Runtime 预设和 Runtime 快捷操作属于同一输入上下文,其选择器必须共享结构、尺寸和菜单视觉,不能出现一个精细菜单与多个风格不一致的原生下拉框。
|
||||
|
||||
- 只有选项能由单行短标签充分区分、不需要补充来源、位置、状态或说明时,才使用原生 `select`。
|
||||
- 当用户必须在选择前比较来源、范围、位置、连接状态或其他辅助信息时,使用与输入区选择器同类的自定义富信息单选菜单,不用 CSS 修饰原生 `select` 冒充富信息菜单。
|
||||
- 触发按钮复用统一的模型选择按钮样式,保持相同高度、圆角、边框、展开指示和焦点状态。
|
||||
- 菜单使用 `menu` 与 `menuitemradio` 语义,当前项同时显示选中标记和 `aria-checked`。选项可以包含一行简短说明,但标签和说明不得被截断到无法区分。
|
||||
- 支持上、下方向键、Home、End、Enter 或 Space、Escape;打开后焦点进入当前项,关闭后返回触发按钮。
|
||||
- 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和模式菜单互斥展开。
|
||||
- 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和 Runtime 菜单互斥展开。
|
||||
- 不可用选项保持可读并说明原因,键盘导航不得停留在不可选择项上。
|
||||
- 当某个选项需要用户在其展开区域内完成路径选择、授权或其他前置操作时,不得因为前置条件
|
||||
尚未完成而禁用该入口。选择入口只展开流程,验证成功前不提交实际值;不得自动采用第一个
|
||||
候选。取消保持原值,验证错误在对应操作附近显示。
|
||||
- 项目选择器属于富信息单选菜单。收起时保持当前项目名称紧凑可见;展开后每项至少显示项目名称,并按数据可用性补充目录、远程来源或其他能帮助辨认项目的信息。托管 SSH 项目在“远程项目”内按已保存 SSH Host 做一层轻量分组,Host 标题旁显示真实 Agent 连接状态;项目副标题只显示远端路径,不重复“托管 SSH”类型。
|
||||
- 项目菜单区分“本地项目 / 远程项目 / 远程通道”,不把消息通道命名为远程项目。“远程项目(技术预览)”关闭时完整移除远程项目分组,而不是显示不可操作的残留选项;本地项目和远程通道继续可用。Agent 连接状态使用“未连接 / 连接中 / 就绪 / 异常”等文字标签,读取状态不得发起连接,且不能只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。
|
||||
- 项目菜单区分“本地项目 / 远程项目 / 远程通道”,不把消息通道命名为远程项目。“远程项目”关闭时完整移除远程项目分组,而不是显示不可操作的残留选项;本地项目和远程通道继续可用。Agent 连接状态使用“未连接 / 连接中 / 就绪 / 异常”等文字标签,读取状态不得发起连接,且不能只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。
|
||||
|
||||
### 6.12 应用通知与就地反馈
|
||||
|
||||
应用级通知统一进入全局通知视口,页面不得自行复制通知卡片或在内容流中长期堆放短期消息。
|
||||
|
||||
- 右上角通知使用紧凑的中性表面、弱边框和轻阴影,最大宽度为 `360px`,窄窗口保留两侧各 `16px`。状态色仅用于图标,不铺满卡片;正文直接呈现,通用状态标题仅供屏幕阅读器读取。图标、正文与关闭按钮统一垂直居中,不使用各自的上下偏移。
|
||||
- 异步操作成功、无需立即处理的信息,以及不属于某个字段的异步失败,使用应用级 `success`、`info` 或 `error` 通知。
|
||||
- 成功和信息通知默认在约 4.5 秒后自动消失;错误通知保持可见,直到用户关闭或同一去重键的更新替换它。
|
||||
- 成功和信息通知默认在约 2 秒后自动消失;错误通知保持可见,直到用户关闭或同一去重键的更新替换它。
|
||||
- 通知出现不抢焦点;Modal 打开时,通知关闭按钮纳入当前 Modal 的 Tab 循环。聚焦通知时
|
||||
Escape 返回原控件或当前 Modal 的可用控件,不关闭 Modal;关闭通知后同样恢复焦点。
|
||||
打开、关闭或切换 Modal 不重建通知,不重置自动消失计时。
|
||||
- 同一语义和文案的重复通知应去重。通知正文必须有长度上限,不包含凭据、私人内容或未脱敏的提供商响应。
|
||||
- 字段校验、破坏性确认、操作进度、阻塞整个页面的状态,以及需要就地重试或修正的错误保留在相关控件附近。
|
||||
- 就地错误必须与对应字段或操作建立程序化关联;全局错误使用 `alert` 和 assertive 实时区域,成功与信息使用 `status` 和 polite 实时区域。
|
||||
@@ -433,10 +496,35 @@ Switch 用于在两个持久状态之间立即切换,例如启用能力、开
|
||||
- Checkbox 保留原生 Checkbox 语义和方形勾选视觉,不得添加 `role="switch"`。多项分配、列表选择、确认声明和“保存时清除密钥”等一次性选择均属于 Checkbox。
|
||||
- 不创建页面专属 Switch 样式。需要紧凑布局时仍复用同一轨道、滑块、焦点环、禁用状态和动效,只调整共享组件支持的布局变体。
|
||||
- Switch 支持 Tab 聚焦和 Space 切换,键盘焦点至少显示 `2px` 高对比焦点环。可见标签应描述被控制的能力,不能只显示“开 / 关”。
|
||||
- 共享开关行统一为标签在左、Switch 靠右,说明放在标签下方;不因 JSX 中输入控件与文字的先后顺序改变视觉位置。窄窗口允许文字换行,开关保持固定尺寸。Checkbox 和 Radio 保留各自的选择布局。
|
||||
- 异步切换期间禁用重复操作并保留原状态。失败时恢复或保留最后确认状态,通过应用通知或就地可恢复错误说明原因。
|
||||
- 涉及联网、上传、电脑控制或其他外部影响的 Switch,附近必须持续说明数据去向、权限范围或风险,不能只靠设置名称表达影响。
|
||||
- 自动化测试应按 `switch` 角色查询二元开关,按 `checkbox` 角色查询多选或确认项,防止视觉迁移后语义回退。
|
||||
|
||||
### 6.14 InlineHelp 同行帮助
|
||||
|
||||
页面、区块和字段的补充用途、格式及操作说明统一收进标题或标签旁的 `InlineHelp` 问号按钮。
|
||||
图谱图例使用同一组件的 `info` 图标变体,只显示 i 图标,保留可访问名称与简短 title;浮层和键盘行为相同。
|
||||
`PageHeader` 与 `SettingsSectionHeader` 通过 `help` 接收这类内容;`description` 保留需要常显的上下文,两处不重复说明。描述缺省或为空时不渲染段落,不保留空行。
|
||||
当前范围、状态、字段错误、操作进度、风险、费用和影响用户决策的限制仍就地可见。混合文案先拆分,再将补充说明移入帮助。
|
||||
|
||||
- 标题或标签与问号使用共享 `inline-help-label` 同行排列,不为帮助另占说明行;字段标签通过 `htmlFor` 关联控件,帮助按钮放在 `label` 外,避免点击帮助触发输入或开关。
|
||||
- 悬停或键盘聚焦按钮时展开;指针可移入浮层阅读。点击(含 Enter、Space 激活)固定展开,再次点击关闭;未固定时,指针和焦点均离开后关闭。
|
||||
- Escape、点击外部或焦点移到外部关闭帮助。Escape 优先关闭帮助,不同时关闭所属 Modal;展开和关闭不主动转移焦点,不建立焦点陷阱。
|
||||
- 按钮提供与标题或标签对应的可访问名称、`aria-expanded`,展开时通过 `aria-controls`、`aria-describedby` 关联 `role="tooltip"` 内容。提供稳定帮助 ID 时,关闭状态保留同 ID 的隐藏说明,展开时由浮层替换,确保字段的 `aria-describedby` 始终有唯一目标。持久标签与必要提示不得依赖浮层。
|
||||
- SSH 密码字段常显认证用途和验证成功后加密保存的说明;Main 处理及不写入命令行、日志、普通设置的细节收进同行帮助。应用常驻说明与关闭后果使用独立翻译键,关闭后果保持常显,不按标点拆分翻译文本。
|
||||
- 浮层复用 `FloatingPortal`,遵循 6.10 的 Modal 归属规则。宽度最多 `360px`,四周保留 `16px` 视口边距;空间不足时翻转或调整位置,长内容在浮层内滚动,窗口及滚动变化后重新定位。
|
||||
- 问号使用共享 `28px × 28px` 点击区域、焦点环与主题令牌。不得另做原生 `title` 长说明或页面专属帮助浮层。
|
||||
|
||||
### 6.15 表单 Modal
|
||||
|
||||
- 复用 `styles.css` 中的 `custom-task-dialog__surface`、`custom-task-dialog__header`、`custom-task-dialog__content` 和 `custom-task-dialog__actions`,与定制任务、SSH Host 等现有弹窗使用同一结构;不为单个业务另建 Modal 框架。
|
||||
- 标题放在 `header` 左侧,右侧只放共享 `icon-button` 的 X 关闭按钮,使用本地化 `aria-label` 和工具提示。取消不放在标题下方或表单顶部。
|
||||
- `footer` 使用共享操作区,按钮整体右对齐,次要取消在前、主要保存在后。放弃修改确认沿用同一操作区,继续编辑在前、危险操作在后。
|
||||
- header、滚动 body、footer 是 surface 的三个直接子区域。沿用共享 Grid 的 `auto minmax(0, 1fr) auto`,只有 body 滚动,短窗口仍可见标题、关闭和提交操作;窄窗口沿用共享外边距、内边距和主题令牌。
|
||||
- X、取消、Escape 和遮罩关闭共用业务关闭检查,不绕过未保存确认或提交锁定。确认中的 Escape 或 X 返回编辑;继续复用 `activateModalFocus`、`trapTabFocus`,保留背景隔离、初始焦点和关闭后的焦点恢复。保存失败保留草稿及可恢复错误。
|
||||
- 从已有 Modal 打开的子 Modal 同样复用 `--z-dialog` 和 `activateModalFocus`,以较后挂载的 body Portal 绘制在父层上方,并隔离父层;不能只手动聚焦或使用旧的页面层级常量。子层关闭后恢复父层的隔离状态与触发按钮焦点,Escape 不继续关闭父层。
|
||||
|
||||
## 7. 交互状态
|
||||
|
||||
所有可交互组件必须实现:
|
||||
@@ -452,6 +540,8 @@ Switch 用于在两个持久状态之间立即切换,例如启用能力、开
|
||||
|
||||
异步提交成功后更新内容并通过统一应用通知提供明确反馈。失败时保留用户输入和筛选上下文。
|
||||
|
||||
共享控件的状态规则须在容器组合中生效:菜单通用按钮样式不能覆盖危险按钮,普通图标悬停不能覆盖危险图标或作用于禁用按钮,页签悬停保留当前选中态。深色主题通过令牌提供控件颜色,避免用页面祖先下的通用输入规则替换控件边框。验证时读取最终 computed style;共享令牌对比度与源码规则存在性检查不能代替容器内、body Portal 和伪状态回归。修复与验证记录见[共享控件审查](docs/quality/shared-controls-review-2026-09-27.md)。
|
||||
|
||||
## 8. 范围与数据语义
|
||||
|
||||
### 8.1 页面范围
|
||||
@@ -576,10 +666,13 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
### 13.1 聊天
|
||||
|
||||
- 使用 `reading` 壳层,消息流与输入区共享宽度。
|
||||
- 消息头像放在正文区域外侧:助手头像居左,用户头像居右。输入框左边缘与助手正文左边缘对齐,
|
||||
右边缘与用户气泡右边缘对齐;两侧为头像保留独立安全边距,滚动条出现或工作栏缩放不得改变对齐。
|
||||
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
|
||||
- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown
|
||||
原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示
|
||||
复制入口。
|
||||
原文,不包含推理过程、工具日志或引用元数据。复制成功后,当前按钮显示使用 `--success`
|
||||
的绿色对号,可访问名称和工具提示同步表示已复制,3 秒后恢复复制图标;重复复制成功重新
|
||||
计时,不显示成功通知。失败仍通过应用错误通知反馈。流式回复完成前不显示复制入口。
|
||||
- 默认开启“会话 HTML 渲染”时,已完成 Agent 回复中的完整 HTML 与 `html` / `htm`
|
||||
代码块在原位置显示隔离静态预览;用户消息、推理过程和流式中的回复不得执行 HTML。
|
||||
每个预览持续标注脚本和网络已禁用,并在标题右侧复用与 Mermaid 图表相同的图标按钮组:
|
||||
@@ -587,27 +680,38 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
全屏预览遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape 关闭和焦点恢复规则,
|
||||
并沿用同一套清理与 Sandbox/CSP 边界。关闭平台功能后必须显示 HTML 源码,不能隐藏内容
|
||||
或保留旧预览。
|
||||
Tab 和 Shift+Tab 必须能进入及离开 iframe;焦点在预览内部时 Escape 仍关闭当前全屏层并返回入口。空 sandbox 与禁脚本 CSP 保持不变,跨文档键盘接线见[HTML 渲染技术设计](docs/features/conversation-html-rendering/technical-design.md#ui-与无障碍)。
|
||||
- Mermaid 大图支持缩放和拖动;图形超过画布时,四个边缘都必须能通过滚动到达,不能因居中而裁掉顶部或左侧。
|
||||
弹窗工具栏提供“导出 PNG”,保存完整图形,不受当前缩放和滚动位置影响;背景使用当前主题的画布底色。
|
||||
导出按原图四周留出 `16px`,以 2 倍分辨率绘制,任一边最多 `4096px`。导出期间禁用重复点击,失败在按钮旁提示重试。
|
||||
- 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行
|
||||
或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态,
|
||||
使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。
|
||||
- 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。
|
||||
- 模型、专家角色、工作模式、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。
|
||||
- 模型和能力范围属于上下文控制,不与页面导航页签混用。
|
||||
- 模型、专家角色、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。
|
||||
- Runtime 选择菜单必须反映当前执行空间的真实能力。托管 SSH 项目当前只支持 OpenCode,因此输入区只渲染已配置的 OpenCode 选项和管理入口,不显示直连模型、Continue 或 DeepSeek Harness 的不可用入口;旧会话保存的其他 Runtime 选择在项目激活时恢复为当前 OpenCode 配置。
|
||||
- 输入区第一行工具栏只承载附件、语音、知识范围、专家角色、工作模式、Runtime 选择和发送等通用操作。OpenCode Agent、Continue 预设及 Runtime 快捷操作必须放入其下方独立的 Runtime 专属功能行,通过可见分组名称、顶部边界和差异化表面与通用操作分层;该行只承载对当前消息生效的高频选择,当前 Runtime 没有可选专属功能时不保留空行。
|
||||
- 输入区使用一个完整容器,主工具栏保留附件、语音、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 定制选择器,并关闭已打开的上下文菜单;回复结束或停止后再恢复选择,避免界面状态与本次运行实际使用的上下文不一致。
|
||||
- 普通工具在主会话和子 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` 时,通用工具栏内的添加内容、知识范围、专家、模式和 Runtime 选择保持同一行,Runtime 专属功能在自己的下一行横向排列。窄输入区中两行分别换行,专属选择器以至少 `220px` 的基准宽度换行而不是被挤压;不能把专属控件重新塞回通用工具栏。
|
||||
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,图片转换为本次消息附件。文件选择由上传按钮承担,不再提供独立“读取剪贴板”按钮;默认工具栏也不提供“截取当前屏幕”和“选择应用窗口”入口,避免与系统粘贴、文件选择和后续工具执行重复。
|
||||
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
|
||||
- 不显示 Ask/Execute 选择器、模式徽标或替代权限档位;能力由所选 Runtime 和已有启用、分配设置决定。
|
||||
- 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。
|
||||
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,系统截图和从文件管理器复制的一个或多个支持文件加入本次消息附件。文件粘贴与上传按钮共用读取、解析进度和数量限制;读取期间阻止重复导入及发送,错误就地关联输入框和附件按钮。具体类型、大小及批次失败规则见[聊天附件要求](./docs/features/document-processing/prd.md#321-聊天附件入口)。默认工具栏不提供独立“读取剪贴板”“截取当前屏幕”和“选择应用窗口”入口。
|
||||
- 空输入框使用三行原生占位文案:第一行为消息提示(生图模式保留图片描述提示),第二行为“Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴”,第三行为“Ctrl+N 新建对话 · 实际已注册快捷键 快捷唤起”。新建与粘贴快捷键按平台显示,macOS 使用 Command;全局快捷键不可用时省略该项。输入文字后占位提示自然隐藏,输入框下方不再重复全局快捷键,保留能力范围和其他元信息。
|
||||
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
|
||||
- 草稿与消息中的附件操作使用紧凑图标按钮,并提供悬停和键盘焦点提示;文件名、解析状态与操作行的布局遵循[附件操作规则](./docs/features/document-processing/ui-design.md#12-附件操作菜单)。
|
||||
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
|
||||
- 消息流的滚动范围必须使用已渲染消息的真实高度。长会话可以按批次只挂载最近消息并由用户
|
||||
加载更早内容,但不得用固定的消息占位高度替代实际布局,避免推理、工具列表或长正文被
|
||||
@@ -621,7 +725,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 侧栏最近会话的更新时间在当天显示本地时间,非当天显示本地日期;非当年记录必须同时显示年份。悬停时间信息时提供完整日期和时间。
|
||||
- 侧栏中有关联 Task 的 Conversation 显示行首展开按钮,父行不显示任务标签或数量;展开后
|
||||
最多直接显示 3 个 Task,“查看全部 N 个任务”打开该 Conversation 的完整任务区。
|
||||
- Task 子项左侧显示名称,右侧显示本地化的 Ask/Execute、计划摘要和共享状态标记,与父会话
|
||||
- Task 子项左侧显示名称,右侧显示计划摘要和共享状态标记,与父会话
|
||||
的更新时间位置一致;已完成使用带勾成功点,不重复显示完成文字。点击子项打开同一
|
||||
Conversation 并定位 Task,不继续展开 Job/Run。
|
||||
- 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。
|
||||
@@ -636,12 +740,17 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
|
||||
### 13.4 智能心跳
|
||||
|
||||
- 使用 `dashboard` 壳层。
|
||||
- 顶部先呈现运行状态、实际范围和主操作,再呈现指标、建议、历史与配置。
|
||||
- 监督者入口使用 `supervisor` 壳层;智能心跳设置位于同一壳层内。
|
||||
- 顶层页签为“工作回顾 / 故事线图谱 / 智能心跳 / 活动记录 / 设置”,使用一套共享 PageTabs,窄窗口单行横向滚动。页签下不重复同名标题,也不保留空标题容器;历史结果不得标为最新。
|
||||
- 工作回顾只展示统一监督结果、历史选择和紧凑的新回顾控件;结果保留自身范围、覆盖区间和生成时间,图谱读取同一结果 ID。智能心跳集中展示心跳计划、监督建议和只读的历史心跳报告;活动记录只展示统一执行活动、进度和详情。每页保留所属数据的一处刷新入口,范围与时间选择仅作用于新回顾请求。详细布局与阅读规则见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md#应用入口)。
|
||||
- 监督者保留统一页面壳层;工作回顾与活动子面板铺满壳层可用内容宽度,与页签左右对齐。工作回顾与图谱共用稳定的回顾上下文栏,新请求字段按需展开。工作回顾正文及辅助内容按实际容器宽度排列,空态独占上下文栏下方整行并水平居中。活动筛选按内容紧邻排列,阶段使用编号圆点和连接线,次要统计收进详情;具体布局见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md)。
|
||||
- 范围明确区分 Global 与指定的一个或多个 Project;Global 与指定项目互斥,多项目选择使用
|
||||
Checkbox,不能依靠进入页面时的当前项目推断。
|
||||
- 状态卡片使用统一状态令牌,不只依赖颜色。
|
||||
- 保留“运行概览 / 待处理建议 / 报告与记录 / 心跳计划”四个同级页面。
|
||||
- 活动记录先显示运行状态、时间、范围及实际阶段,恢复操作紧邻进度。原始错误默认折叠,展开后在有界面板内滚动并可选择复制;配置归入次要详情。任务层级只展示可查询的记录,缺失阶段明确标注,不能用剩余来源为零推断发布成功。具体阶段与分页规则见[监督者活动设计](docs/features/conversation-supervision/ui-design.md#活动)。
|
||||
- 智能心跳的“心跳计划”卡片内直接排列状态说明、创建按钮和计划列表,不再嵌套第二层边框;仅保留一个创建按钮,说明文字按无计划、全部暂停、正常三种状态只显示一条。创建与编辑复用同一份表单及共享 Modal 焦点、背景隔离和遮罩样式;未保存关闭须确认,保存失败保留草稿,编辑暂停计划不恢复运行。“执行记录”进入按该计划 ID 筛选的活动页,活动页不重复显示心跳审计列表。
|
||||
- 设置只展示模型超时与并发、分页、分批和单次响应容量,不含计划、报告、审计或嵌套页签,不显示整页全局徽标或独立刷新行。
|
||||
- 监督者的设置、帮助、空态及错误文案不得向终端用户展示内部路线图、交付进度或设计与实现缺项;只说明当前行为、适用范围和可执行的下一步。不得因此夸大为提供商全局限流或完整语义识别;真实的运行失败、超时、暂停、来源失效及恢复操作必须保留。
|
||||
- 智能心跳菜单入口是完整配置的权威位置;任务中心和设置中心不得复制同一 CRUD 表单。
|
||||
- 分区记忆协同与唤起条件尚未实现,不得显示占位入口或平行的记忆类型。
|
||||
|
||||
@@ -649,16 +758,21 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
|
||||
- 保留现有助手工作栏入口,首期在窄栏内适度完善,不先扩张成新的独立一级页面。
|
||||
- 只展示 Task;普通 Conversation、Job、Run、工具步骤、Subagent 和心跳事项不独立占行。
|
||||
- 每项显示名称、关联 Conversation、Global 或 Project 范围、Ask/Execute、状态、最近进展、
|
||||
最近真实活动时间及需要关注信息。
|
||||
- 范围固定为当前项目,不提供全局或所有项目入口;切换会话不筛掉本项目其他会话的任务。
|
||||
- 顶部紧凑显示当前会话消息总数和 Agent 累计回复时间,按每轮请求区间累加,包含轮内工具和
|
||||
审批等待,不计轮间空闲。历史缺失显示不完整提示,远程缺少可靠证据时不虚构用时。
|
||||
- 每项显示名称、关联 Conversation、所属项目、状态、最近进展、
|
||||
最近真实活动时间、累计执行时间及需要关注信息。任务用时汇总历次执行请求,不按父任务生命周期计算。
|
||||
- 点击列表项打开关联 Conversation 并定位该 Task,不显示第二份内容载体。
|
||||
- 需要关注、进行中、已暂停和已结束使用共享 `SegmentedControl`;窄栏不足时单行滚动。
|
||||
- 项目任务标题右侧依次为“+”新建和筛选图标;图标按钮提供可访问名称,筛选按钮提供展开状态。
|
||||
状态筛选默认收起且为全部;展开后用共享 `SegmentedControl` 提供全部、进行中、暂停、已结束,
|
||||
窄栏不足时单行滚动。收起保留条件,非全部时图标高亮并显示条件。状态归类遵循[任务中心 PRD](docs/features/task-and-job/task-center-prd.md#5-交互)。
|
||||
- 完整消息留在 Conversation;长错误、工具、审批和成果按 Task 关联到 Runtime、活动记录和
|
||||
成果查看器,不撑高列表,也不显示 Job/Run 树。
|
||||
- “新建定制任务”使用共享 Modal,明确选择当前或新 Conversation。聊天入口默认当前
|
||||
Conversation,Task Center 入口默认新 Conversation;选择必须持续可见。
|
||||
- 创建 Modal 默认 Execute,并允许主动切换 Ask。Execute 持续显示实际 Runtime、Project、
|
||||
工作目录、工具和审批摘要;不支持工具时不得静默降级。
|
||||
会话消息或工作区文件,不撑高列表,也不显示 Job/Run 树。
|
||||
- “新建定制任务”使用共享 Modal,默认立即执行和当前 Conversation,可选择本项目其他可用
|
||||
Conversation 或新建会话,选择必须持续可见。内容必填,名称可选并从内容生成;定时提供单次、每日和每周。
|
||||
- 定时消息创建 Modal 使用关联 Conversation 的 Runtime,不提供模式开关。
|
||||
详情显示状态、日程与 Runtime,不显示模式或从旧计划字段推断权限。不支持工具时说明真实限制。
|
||||
|
||||
### 13.6 运行记录
|
||||
|
||||
@@ -666,55 +780,100 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 默认视图按“项目 → 任务或会话 → 活动详情”组织,项目范围持续可见,任务或会话详情可以折叠。
|
||||
- 任务或会话的综合状态以最近一次顶层请求对应的最终 Agent 结果为准;最终结果尚未产生时使用该请求的当前状态。中间工具或子专家的失败、取消和中断保留在活动详情中,但不得覆盖最终成功状态。
|
||||
- 活动时间线按项目分组、按任务或会话建立横向轨道,所有轨道共享同一执行顺序并按事件时间排列,以带身份名称的节点表达用户、主 Agent、子专家、工具、审批、状态和并行关系。节点内使用 `U / G / S / T / A` 拉丁字母简称,节点下显示完整身份或名称;选择节点后显示所属会话、不可变范围快照和完整详情。
|
||||
- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度,宽表格在独立容器内横向滚动。
|
||||
- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度;按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺。宽表格在独立容器内横向滚动。
|
||||
- 活动状态筛选使用 `SegmentedControl`,不与页面页签混合。清空历史遵循破坏性操作政策。
|
||||
|
||||
### 13.7 魔法笔记
|
||||
|
||||
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
|
||||
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
|
||||
- 笔记编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
|
||||
- 左侧笔记与待办列表在双栏布局中允许在 `180px` 至 `360px` 范围内通过指针或键盘调整,
|
||||
也可从页面标题区隐藏和恢复;宽度与显示状态均记住用户选择。分隔条保留 `separator`
|
||||
语义及方向键、Home、End 操作,窄窗口堆叠布局不显示拖拽分隔条。
|
||||
- 左侧笔记列表依次显示标题、最多两行内容预览,以及同一行的记录数和本地化更新时间。记录数靠左、更新时间靠右,窄宽度下均保持单行省略,不挤压标题或扩大列表栏。
|
||||
会话联动已接入:右侧提供可关闭的单实例笔记 Tab,通过工作栏“+ → 笔记”打开;左下角和聊天页头不再提供重复的“快速笔记”入口。助手消息和会话标题菜单可打开文字采集预览;保存记录在面板和完整工作区显示独立来源条。草稿由 App 保留,关闭、替换及禁用前确认。具体交互由[魔法笔记会话联动界面设计](./docs/features/magic-notes/ui-design.md)维护。真实 Electron 验收已通过,全仓验证未全绿,见[验证进度](./docs/features/magic-notes/progress.md)。下列规则描述独立工作区。
|
||||
|
||||
- 未打开笔记详情时,笔记列表与待办使用页头普通按钮切换。按钮显示目标“切换到待办”或“切换到笔记”及对应图标,按内容宽度显示,不拉伸;笔记的新建入口位于左侧列表标题行,待办页的新建入口位于页头。窄窗口操作组可换行到标题下方,保留控件高度。
|
||||
- 切换按钮不使用页签语义,切换后焦点回到同一个按钮;内容 section 持续提供当前列表的 `aria-label`。切换沿用草稿保护入口,待办状态仍使用独立的 `SegmentedControl`。
|
||||
- 文字记录编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套文字记录持久化格式或第三方 Markdown 插件;新建文字记录的操作提示收进类型选择旁的 `InlineHelp`。
|
||||
- 新记录可选择“文字 / 画布”;已保存画布提供分页阅读和“展开编辑画布”。画布使用 PeopleLib 的 Fabric 分页批注与 Quill 跨页正文,支持纸张模板、增删页、笔迹、高亮、整对象橡皮、选择及移动缩放旋转、浮动文字、图片和 PDF 底版。画布有固定页面边界;已保存记录、待办来源和编辑态默认容纳完整当前页及工具栏,高度随当前页实际显示尺寸变化,纵向由外层笔记流滚动。多页仍属于一条记录,通过页码翻页;窄窗口工具栏换行,纸张仅在自身视口内横向滚动,不撑宽外层页面。
|
||||
- 画布通过“保存记录 / 保存修改”手动持久化,离开或切换记录类型前沿用未保存草稿确认。撤销/重做作用于当前批注页或 Quill 正文模式,增删页和 PDF 导入不属于全局撤销历史。删除非空批注页或 PDF 页需要在五秒内再次点击确认。
|
||||
- 画布编辑与只读工具栏均提供缩小、当前百分比、放大和“适应宽度”。默认 100%,范围 25%–300%;加减每次调整 25 个百分点,点击百分比还原 100%。画布区域内 Ctrl/Command + 滚轮连续缩放,普通滚轮和 Shift 横滚保持原有方向与边界传递。适应宽度按实际视口计算,窗口或分栏调整后自动更新;翻页保留固定比例或适应宽度策略。缩放仅影响显示,整页高度与横向滚动范围随之变化,不新增内层纵向滚动,也不改变正文分页、保存内容、导出尺寸或未保存状态。控件沿用画布的主题、焦点环与提示,窄屏整组换行,适应宽度通过 `aria-pressed` 和选中样式表达状态。
|
||||
- “导入 PDF”保留页面并提取原生文字供搜索与 AI 使用,扫描页不自动 OCR。“导出 PDF”将底版、正文和批注逐页合成为栅格 PDF,不保留可搜索文字层;当前 UI 无 PNG 下载按钮,页面 PNG 捕获仅作为程序接口提供。
|
||||
- 画布新建与编辑态不显示常驻分析提示。操作放在编辑区右上角:新建与类型选择同排,顺序为“分析画布草稿、保存记录”;编辑放在记录头部,顺序为“分析画布草稿、取消、保存修改”。保存为最右侧主按钮,窄屏允许换行;底部不重复操作,新建不增加取消按钮。`immediate` 仍通过按钮手动分析,不随笔画自动请求;`after-save-auto` 在手动保存后分析。设置读取或页面捕获失败不阻断保存,保存成功后通过应用通知说明自动分析失败,可稍后重新分析;模型分析失败也不回滚已保存内容。
|
||||
- 魔法笔记设置提供“发送画布页数”单选框,复用设置表单 `.field` 控件样式,范围 1~8,默认 1。标题同行的问号使用 `InlineHelp`,下拉框通过稳定的帮助 ID 关联展开的说明,交互遵循 6.14。说明包含当前页序前 N 页、不足时发送全部页、数量指单个画布页数而非记录数,以及完整保存与导出不受影响。修改后沿用设置保存反馈,下次分析生效。
|
||||
- 草稿、已保存画布记录及来源为画布的待办,按默认模型的 `supportsImageInput` 使用所选前 N 页图像与文字,或只发送同一范围文字并标注“仅文字分析(未使用画布图像)”。所选页只有视觉内容的记录/草稿遇到文字模型时提示切换支持图像输入的默认模型,不声称已理解笔迹或布局。待办分析同时保留任务标题、说明和所选来源画布页的上下文。
|
||||
- 画布文字评论在提取的 `plainText` 不变时保留;图像评论随画布内容或布局变化失效,在保存后自动评论模式下重新分析。草稿内容变化清除旧草稿分析,评论区显示实际使用的输入模式。存储和判定细节见[画布技术设计](./docs/features/magic-notes/technical-design.md#canvas-analysis-and-comments)。
|
||||
- 总览是全宽的“全部笔记”页,以自适应卡片网格(最小 `260px`)显示全部笔记,不显示右侧空白详情,也不自动打开第一篇;卡片显示标题、短日期、两行预览和记录数。打开笔记后切为左侧紧凑列表与右侧详情,页头右上角显示带文字的“返回总览”,点击回到全部笔记页并恢复搜索、滚动位置与卡片焦点;“切换到待办”只在总览显示。列表按紧凑两行行项显示:第一行为标题与短日期(今天显示时间,其余显示月/日,完整时间在工具提示中),第二行为单行预览;记录数只提供给读屏。行项无边框、透明底色,悬停为次级底色,选中为淡蓝底色加左侧 `3px` 强调标记;“更多”按钮悬停或聚焦时覆盖在日期位置。保留搜索、置顶菜单和当前选中状态。打开笔记只更新右侧详情;列表独立滚动,切换保留搜索和列表位置,并在工作区挂载期间记住各篇笔记的阅读位置。明确定位某条记录时优先跳到该记录。
|
||||
- 笔记列表默认展开、宽 `280px`,支持拖动分隔线在 `240–420px` 内调宽,方向键每次调整 `16px`,Home/End 跳到边界;桌面端详情页提供列表显隐开关,记住宽度和展开偏好。工作区宽度不超过 `900px` 时切换显示列表与详情,返回恢复列表位置和入口焦点,不覆盖桌面偏好。新建成功后打开新笔记;切换、新建和删除当前笔记都保护未保存的正文与标题。
|
||||
- 待办页是独立任务视图:搜索任务标题、说明或来源笔记,搜索框右侧同一工具栏放置“全部 / 未完成 / 已完成”筛选,显示匹配数与总数;任务按来源笔记分组。列表独立滚动,页头、搜索和筛选保持可见,窄屏工具栏空间不足时换行。直接勾选完成或恢复未完成,不自动选择任务;状态变化后按当前筛选更新列表。
|
||||
- 待办内容容器宽于 `700px` 时,任务列表稳定在左侧,右侧显示当前任务说明、来源记录和 AI 评论;未选择时右侧提示选择任务。点击任务只更新右侧,不改变列表宽度、行位置或滚动位置,再次点击取消选择。AI 不增加第三栏。容器不超过 `700px` 时,按需详情替换同一内容区域,并提供“返回待办列表”;列表保持挂载,返回恢复滚动和任务行焦点,不将列表与详情上下堆叠。任务按钮提供 `aria-expanded` 和 `aria-controls`。
|
||||
- 待办左右栏分割线支持拖拽,并记住列表宽度;键盘左右方向键每次调整 `16px`,Home/End 调整至边界。列表默认 `320px`、最小 `240px`,详情至少保留 `300px`;窄屏隐藏分割线,临时缩窄窗口不覆盖已保存宽度。每个来源笔记标题可独立收起或展开待办列表,保留标题与数量,通过 `aria-expanded` 和 `aria-controls` 表明状态;收起不清除右侧当前详情。
|
||||
- 待办 AI 评论保留方向选择、流式输出、重新分析和已有结果;来源加载失败通过应用通知提示,并提供就地重试。从待办打开原笔记后,“返回待办”恢复任务选择、搜索、筛选和列表滚动;宽屏焦点返回任务行,窄屏返回待办详情中的返回按钮。
|
||||
- 笔记详情主区按新到旧连续展示全部记录。打开笔记即在标题下、历史记录上显示空白输入区,保留文字/画布类型选择和保存操作,标题栏与索引均不提供“新增记录”按钮。新增保存成功后重建空白输入区,保留所选类型;自动分析结束也不转入旧记录编辑。旧记录通过显式编辑入口修改,保存使用同一 ID 和返回的修订号。点击索引或当前笔记内的 AI 来源只按 entry ID 滚动定位,不卸载编辑器、不清草稿、不询问离开。空白输入区离开不提示,返回总览、打开其他笔记或改为编辑另一条记录时仍保护未保存修改。
|
||||
- 打开笔记后页面一级标题仍为“魔法笔记”;笔记标题只在记录流顶部出现一次,使用 `22px` 的可编辑大标题,悬停显示下划线,聚焦显示强调色下划线,保留失焦保存、Enter 与 Escape。标题上方为吸顶的细工具栏,依次放置收起笔记列表、记录索引、条数与更新时间、AI 评论开关,均为带工具提示和可访问名称的图标按钮。记录流左右留白 `24px`(窄于 `600px` 时 `12px`),正文最大宽度 `880px` 并居中;吸顶工具栏不受此限制,始终铺满记录流宽度,AI 开关贴在右边缘,底部分隔线横贯整栏。
|
||||
- 桌面笔记详情的列表标题行、正文工具栏与 AI 评论方向标签行顶部对齐,首行统一使用 `40px` 控件高度并垂直居中;AI 区域不叠加面板和表单的顶部留白。
|
||||
- 文字记录的新建与编辑区域铺满卡片内沿,格式工具栏紧接头部,输入区域紧接工具栏;编辑器外层不再增加内边距或圆角。正文保留编辑器内部的文字留白,底部操作栏独立保留内边距与顶部分隔线;画布仍保留 `12px` 内容间距。
|
||||
- 新建记录输入区空闲时收成一行虚线提示框,隐藏类型切换、格式工具栏和保存栏;聚焦、已有草稿、选择画布或出现校验错误时展开,初始约四行并随内容增高。保存成功或切换笔记后恢复为一行。此规则不作用于旧记录编辑器。
|
||||
- 已保存记录的 AI 分析、编辑、删除为 `28px` 图标按钮,精细指针设备上悬停或键盘聚焦记录时才显示;删除悬停时使用危险色,确认条保持原样。粗指针设备始终显示。笔记内部列表、记录流、AI 区域和文字编辑区在精细指针设备上使用细原生滚动条,轨道透明;强制颜色与粗指针设备保留系统默认。说明、摘要和输入提示使用 `--font-body` 与 `--text-secondary`,时间和短标签保留 `--font-caption`。
|
||||
- 左侧记录索引默认收起,只在用户展开后记住展开状态;展开宽度默认 `168px`,可拖动右边界在 `140–320px` 内调整栏宽,缩略图随列宽等比适配。开关位于笔记工具栏,提供 `aria-controls`、展开状态和动作提示;收起后完全移除索引列、分隔条、间隙、边框及轨道,不占宽度,隐藏内容不可聚焦。右侧 AI 评论独立显示、滚动和收起,不与索引共享页签。每条记录一个导航项:画布显示真实首页缩略图及总页数,文字显示摘要,两者均显示类型和时间。两侧分别记住桌面展开状态和宽度,共用原有布局偏好;宽布局分隔条共用样式及 `separator` 语义,方向键每次调整 `16px`,Home、End 跳到当前上下限。两侧宽度按可用空间协调限制,为主区保留至少 `300px`,窗口缩小时不覆盖保存的偏好。内容容器不超过 `800px` 时索引默认隐藏,由同一标题栏按钮开闭标题栏下方固定 `168px` 的抽屉,不显示调宽分隔条;标题栏自身保留控件高度,不保留轨道。选择记录或按 Escape 后关闭,Escape 将焦点返回开关。AI 移至底部,高度不超过可用布局的 40% 且最多 `280px`,不显示分隔条。
|
||||
AI 评论默认不占列:当前笔记已有评论、正在分析或存在草稿评论时自动展开;用户对该笔记手动收起后保持收起,直到发起新的分析。无评论时手动展开即视为固定,固定状态跨笔记和重启保留,再次收起即取消固定。AI 评论的唯一显隐入口位于笔记工具栏右端,使用紧凑面板图标按钮;展开时应用共享选中样式,图标随显示或隐藏动作切换,并提供对应的工具提示、可访问名称、`aria-expanded` 和 `aria-controls`。评论面板内部不再显示重复的“AI 评论”标题或隐藏按钮行,保留区域的可访问名称、评论方向选择及各记录的分析操作。
|
||||
- 笔记详情的内部记录索引使用透明底色,与主内容表面连续,仅保留细调宽分隔线。每条索引记录保留 `1px` 的 `--border-subtle` 淡边框,不加卡片底色或阴影,悬停使用轻微次级底色;选中记录使用淡蓝背景和左侧 `3px` 蓝色内侧标记,悬停时保留选中态。缩略图保留纸张边界,键盘焦点环与强制颜色模式选中边框保持可见。窄屏抽屉使用不透明的主内容表面,避免下层正文透出;深色主题沿用语义令牌。
|
||||
- 笔记详情的标题、正文和记录流不再包裹额外的大外框,记录流容器只保留上述左右留白;缩略图列表顶部与正文区顶部对齐,左边界与索引栏左边界对齐,仅在右侧和底部保留 `8px` 留白,键盘焦点环向内绘制以避免滚动容器裁切。每条已保存记录使用一层 `1px` 的 `--border-subtle` 淡边框和 `--radius-control` 圆角,保留透明底色与记录间留白,不加阴影或头部下划线。记录编辑时保留输入区边框、页面安全边距及 AI 区域分隔。此规则仅作用于笔记详情,笔记列表与待办保持独立布局。
|
||||
- 画布新建、编辑和只读态统一保留 `12px` 内容间距;详情中已保存画布去掉预览外框和工具栏底色、下划线,保留灰色画布衬底与白色纸张,编辑态保留编辑器边框。记录头部操作按内容宽度排列,空间不足时自然换行。文字新建的保存区与编辑器属于同一卡片,空白编辑区使用最小高度,不随容器百分比高度拉长。文字工具栏图标、菜单和原生滚动条须跟随主题。
|
||||
- 标签使用中性胶囊样式(`--surface-subtle` 底色、`--border-subtle` 边框、`--font-caption`),不使用颜色区分。总览搜索框下方是标签筛选行:“全部”加各标签和对应篇数,均为 `aria-pressed` 切换按钮;可以多选,多选时只显示同时带有全部所选标签的笔记。选中时使用 `--accent-subtle` 底色和强调色边框。卡片最多显示 3 个标签,其余合并为“+N”。打开笔记后,左侧列表不显示筛选行,只显示“已筛选:A · B”提示条和清除按钮。
|
||||
- 笔记标题下方是标签编辑行:已有标签后带 × 移除按钮,末尾是虚线“+ 标签”按钮。点击后展开组合框,按输入内容过滤已有标签,也可以新建。方向键切换选项,Enter、逗号或中文逗号确认,Escape 关闭并把焦点还给“+ 标签”。达到 10 个时,“+ 标签”禁用并说明上限。
|
||||
- 列表标题行右侧只放“管理标签”,不再放 `+` 新建按钮。“新建笔记”统一放在页头右上角,是主按钮,始终位于“切换到待办 / 切换到笔记”或“返回总览”的右侧。打开笔记时点击,桌面端展开左侧列表并显示标题输入,窄屏先回到列表。“管理标签”在总览显示图标加文字,打开笔记后只显示图标。点击后打开共享 `custom-task-dialog` 对话框,按使用篇数列出标签,每行提供重命名和删除图标按钮。重命名在行内编辑;新名称与已有标签相同时,提示保存后会合并。删除在行内二次确认,并说明涉及的篇数。Escape 依次退出行内编辑或确认,最后关闭对话框。
|
||||
- 整篇笔记的置顶、取消置顶和删除放在列表行的“更多”菜单,打开详情时仍可操作,标题编辑区不重复提供入口。按钮在悬停、键盘聚焦或菜单展开时显示,触屏设备常显;菜单沿用会话列表样式,通过 Portal 避免滚动区域裁切,支持方向键、Home、End 和 Escape。删除在菜单内二次确认并说明笔记标题及全部记录;删除当前笔记还须处理未保存草稿,删除其他笔记保留当前内容与草稿。打开菜单不切换笔记。
|
||||
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||
- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
|
||||
- Agent / MCP 或其他入口成功写入笔记后,已打开的工作台自动更新笔记列表、当前详情、待办及来源记录,不增加刷新按钮。保留仍存在的笔记与待办选择、搜索和筛选,以及未保存的标题、输入区和记录编辑草稿;普通后台刷新不重建编辑器。
|
||||
- 外部删除当前笔记时,无草稿则返回总览;有草稿则保留编辑区并提示先复制草稿。外部删除正在编辑的记录时,同样保留该编辑器并明确提示记录已删除;保存失败不得清空草稿。
|
||||
- 当前记录草稿非空或标题尚未保存时,返回总览必须先就地确认;编辑另一条记录等会丢弃正文草稿的操作沿用同一保护。继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
|
||||
- 文字和画布记录保存修改成功后退出编辑状态,显示已保存正文;后续自动评论成功或失败均不重新打开编辑器。再次修改须点击编辑入口,以该修订的编辑器初始化内容作为修改比较基线。编辑器归一化不算用户修改,后续输入、画布变更和保存失败仍保留离开保护;同修订的后台刷新不替换已建立的基线。
|
||||
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
|
||||
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
|
||||
|
||||
### 13.8 设置中心
|
||||
|
||||
- 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。
|
||||
- 左侧分类导航在宽屏使用 `220px`,中等窗口使用 `196px`,窄窗口转为横向滚动;纵向滚动条仅在内容溢出时占用右侧空间,不在左侧创建镜像预留,选项与左侧可见边界保持默认内距。分类标题使用正文级字号,分类说明使用辅助字号;右侧内容区在可用空间内流式伸缩,最大宽度使用 `standard` 壳层的 `960px`,不得以页面专属较窄宽度压缩表单。
|
||||
- 全页设置标题区与双栏内容使用共享 `--page-gutter`,标题、分类导航和内容区在同一页面边距基线上;标题继续使用标准 `PageHeader` 的底部分隔线与下内距。固定标题和双独立滚动区域不改变这些一级页面壳层规则;模态设置保留自身外框与标题边界。
|
||||
- 设置中心以整窗遮罩上的大 Modal 打开,保留底层工作区、会话草稿和助手工作栏,不切换一级页面。固定标题区和右上角关闭按钮,左侧分类与右侧内容各自滚动。关闭后返回原工作区并恢复入口焦点。
|
||||
- 新建项目与项目设置弹窗固定标题、关闭按钮和底部操作区,仅表单内容滚动;滚动条不进入标题区,关闭按钮保留至少 40 × 40px 的点击区域。
|
||||
- 项目弹窗通过 Portal 挂载到 `document.body`,避免侧栏内的弹窗与主窗口标题栏拖动区域重叠时,Electron 截获关闭按钮的鼠标输入。
|
||||
- Modal 最大尺寸为 `1200px × 900px`,常规外边距为 `24px`;窗口宽度不超过 `1020px` 或高度不超过 `720px` 时接近全屏,保留 `16px` 外边距。左侧分类宽度依次为 `220px`、`180px`(宽度不超过 `1020px`)、`120px`(不超过 `720px`);窄窗口仍保留左右布局,长分类名称可换行。内容区在可用空间内伸缩,最大宽度为 `960px`。
|
||||
- 设置 Modal 通过 Portal 挂载到 `document.body`,复用共享背景隔离与焦点恢复机制。打开后聚焦关闭按钮,Tab 在弹窗内循环;Escape 先交由嵌套弹窗处理,设置自身的 Escape 与关闭按钮共用未保存确认,确认出现后再按 Escape 返回编辑。点击遮罩不关闭;保存、测试和清理数据期间禁止关闭。每次打开重新挂载设置,模型和消息通道等直达入口使用本次指定分类,普通入口默认打开 Agent Runtime。
|
||||
- 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。
|
||||
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
|
||||
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与右侧标题来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
|
||||
- 左侧按“常规 / 模型与执行 / 功能与扩展 / 系统”分组,顺序与键盘导航一致。分类按钮最小高度 `40px`,只显示名称;分类说明集中在右侧页头,外层标题不再重复功能简介。组名使用辅助字号,普通项使用正文和中等字重,选中项使用强调底色与加粗文字。
|
||||
- 设置区块使用共享圆角、间距和表面令牌;标题为 `--font-section-title`,持久说明至少为 `--font-caption`。嵌套设置区块以顶部细分隔线和留白组织,不叠加卡片背景与四周边框。“能力与工具”的上级页签使用默认 `PageTabs` 外观,与 MCP 内部的分段页签区分。
|
||||
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
|
||||
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
|
||||
- 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供
|
||||
“打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。
|
||||
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
|
||||
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
|
||||
- “模型连接”页头在保存操作下方,将模型类型页签与全局模型下载源入口放在同一行;当前来源文字与“模型下载源”次按钮整体右对齐,窄窗口保持同行,空间不足时导航行可横向滚动。按钮打开挂载到 `document.body` 的 Portal 弹窗,复用共享 Modal 焦点管理;弹窗内使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片,选中状态同时依靠 Radio、边框和背景表达。Radio 只修改草稿,点击“确认”才保存;取消或关闭不改变已保存来源。读取失败提供重试并禁用确认,不以默认值伪装已保存选择;保存失败保留草稿并允许确认重试,保存期间禁止重复提交和关闭。向量、语音和 OCR 的更换下载源入口直接打开模型连接页的同一弹窗,跨分类时沿用未保存修改保护。详细规则见[模型下载源设计](docs/architecture/model-download-source-design.md#7-模型下载源界面)。
|
||||
- 设置中的连接列表与详情共用 `.model-connection-manager`,按容器可用宽度响应:不超过 `640px` 时上下排列,列表内部滚动高度最多 `180px`;更宽时恢复左右排列。LLM、向量连接和角色编辑复用此规则,不以整窗宽度代替表单宽度。接口协议的中英文当前值须完整可读。回归范围见[窄容器布局修复记录](docs/quality/responsive-controls-review-2026-09-27.md)。
|
||||
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 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 包在线下载,不显示底层服务商名称。
|
||||
- 通用设置按快捷唤起、会话与通知、工作目录分组,复用现有卡片。“桌面通知”为默认开启的持久 Switch,保存后立即控制窗口未聚焦时的任务完成、失败和取消系统通知,关闭不影响应用内通知。旧配置缺少该字段时默认开启,明确关闭后重启仍保持关闭;保存失败保留最后确认状态并显示应用内错误通知。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、同行帮助和原生单选下拉框放在同一行,用途说明收进 `InlineHelp`,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“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 Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及描述,不提供统一的原生逐工具开关;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
|
||||
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 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 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。
|
||||
- 魔法笔记设置统一从应用中心对应行进入;笔记页和全局设置不再提供重复入口。“显示未完成待办数量”持久 Switch 只控制左侧数量徽标,不改变待办数据。旧入口开关绑定业务启用值,迁为“启用应用”并保留原值,常驻使用独立配置。默认开启仅适用于未配置布尔值,只有独立显隐偏好才映射到常驻。此迁移规则见[应用导航 PRD](./docs/features/application-tool-navigation/prd.md#fr-11-应用导航设置)。
|
||||
- MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。联网搜索保留在直连模型;内置浏览器归入内置 MCP,提供持久启停和 Runtime 分配,并明确说明其操作 GoodBuddy 隔离浏览器、不控制客户端已安装的浏览器,开启后可由已分配 Runtime 直接使用,不逐次询问。该开关只控制 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 说明持续可见。离线、无工具能力或调用错误不改变勾选。图片模型默认关闭会话调用;默认生图模型独立于普通默认连接,关闭或删除后保留失效选择并提示改选,不自动回退。
|
||||
|
||||
“安全”设置分类及 `toolApproval` 策略已移除。“清除本地数据”位于“通用 > 平台功能”,使用既有危险操作按钮和二次确认;首次点击与取消均不得清除数据,确认后才调用 Main 清除入口。
|
||||
|
||||
### 13.9 文档解析设置
|
||||
|
||||
现有本地解析与本轮 HTTP 扩展的实现边界见[文档处理进度](./docs/features/document-processing/progress.md)。
|
||||
跨场景入口、附件菜单、共享预览及选图返回规则由[文档处理 UI 设计](./docs/features/document-processing/ui-design.md)维护;
|
||||
当前接线与存储行为见[生产实现说明](./docs/features/document-processing/implementation.md)。文档解析属于全局设置,不新增应用中心成员;本机推理监控只管理本地服务。
|
||||
|
||||
- 设置中心新增独立的“文档解析”分类,统一管理聊天附件、知识库导入以及后续文档审阅场景使用的提取、转换和 OCR 策略。OCR 不作为普通对话模型出现在“模型连接”中。
|
||||
- 分类页头说明文档解析的跨场景作用,右侧依次显示“测试解析”和“保存设置”;保存位于最右侧。测试必须选择真实文件并执行实际解析,不能只检查模型文件或接口连通性。
|
||||
- 页面首先显示原生解析、文档转换和 OCR 的运行状态,并明确当前可处理格式、回退能力与不可用原因。部分能力未配置时使用“部分可用”状态,不得把原生文本解析一并标记为失败。
|
||||
@@ -728,9 +887,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- PDF 先读取文本层。仅当页面无有效文本、乱码比例过高或用户选择“始终 OCR”时渲染该页并识别;不得因为单页需要 OCR 而丢弃其他页面已经提取的可靠文本。
|
||||
- DOCX、XLSX、PPTX 优先保留段落、单元格、公式、备注等原生语义。转换为 PDF 用于补充版面、页码、图表和图片理解,不作为唯一中间格式。
|
||||
- DOC、XLS、PPT 等旧格式通过受控转换 Provider 生成新式 Office 文档和 PDF。转换子进程必须禁用宏和网络,限制输入、输出、内存、超时与临时目录,并在关闭或取消时清理。
|
||||
- OCR 来源使用“本地模型 / 远程服务”互斥选择。选择本地后显示模型下载、模型下拉选择和本地运行参数;选择远程后显示 MinerU、PaddleOCR-VL 等服务连接配置。未实现的远程服务入口保持可读但禁用,不再增加与来源选择重复的“隐私与云端处理”授权区。
|
||||
- OCR 来源使用“本地模型 / 远程服务”互斥选择,远程为 HTTP PaddleOCR-VL;MinerU 不在本轮范围。来源切换保留两套配置,未保存时禁止测试。来源配置、保存和测试规则见[文档解析设置设计](./docs/features/document-processing/ui-design.md#2-文档解析设置),不另加逐文件授权区。
|
||||
- 用户配置并保存远程 OCR 服务即表示选择该处理路径,不再逐场景重复询问。界面仍须明确显示当前服务名称、处理范围和远程属性,API 密钥只保存在主进程加密设置中,未选中远程服务时不得上传文档。
|
||||
- 解析结果使用统一文档结构,至少保留文档标题、来源格式、页码或工作表定位、正文块、置信度、处理方式和警告。聊天附件对结果做有界截断,知识库使用完整结果分块和索引。
|
||||
- 解析结果使用统一文档结构,保留文档标题、来源格式、页码或工作表定位、正文块、处理方式和警告;置信度仅在解析器提供时显示,缺失不补造。聊天文档附件保留完整解析文本,超过暂存容量时报错;知识库使用完整结果分块和索引,具体要求见[文档处理 PRD](./docs/features/document-processing/prd.md#32-场景工作流)。普通图片默认走图片输入,显式 OCR、原图保存和文档选图遵循该功能的规划规则。
|
||||
- 测试结果显示文件类型、页数、实际工作流、提取字数、OCR 页数、耗时和警告。测试文件不得自动进入聊天上下文或知识库。
|
||||
|
||||
## 14. 文案规则
|
||||
@@ -772,7 +931,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- [ ] 实现 `ScopeBadge` 并覆盖全局、项目、失效和可切换状态。
|
||||
- [ ] 实现 `EmptyState` 的首次为空、无结果、失败和只读变体。
|
||||
- [ ] 实现 `danger-ghost`、`danger-solid` 和 `danger-zone`。
|
||||
- [ ] 统一模型、专家角色和工作模式的单选菜单结构、视觉与键盘行为。
|
||||
- [ ] 统一模型、专家角色和 Runtime 选项的单选菜单结构、视觉与键盘行为。
|
||||
|
||||
### 15.4 页面迁移
|
||||
|
||||
@@ -780,7 +939,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- [ ] 将输入快捷键与附件提示置于空输入框内部,输入区下方保持单行说明。
|
||||
- [ ] 最近对话迁移到 `standard`,统一搜索、范围、时间和删除行为。
|
||||
- [ ] 知识库迁移到 `master-detail`,清除内联浅色样式并补齐窄窗口单面板流程。
|
||||
- [x] 智能心跳使用 `dashboard`,保留概览、建议、轨迹和计划,并在计划中支持 Global / 多 Project 范围。
|
||||
- [x] 监督者使用 `supervisor`,工作回顾读取统一监督结果;智能心跳包含 Global / 多 Project 心跳计划、监督建议与历史心跳报告;活动记录显示统一执行,设置只保留算法参数。
|
||||
- [x] 在现有工作栏中完善任务中心,统一范围、状态、最近进展和筛选,不新建平行任务平台。
|
||||
- [ ] 活动记录迁移到 `dashboard`,统一导航、筛选和表格行为。
|
||||
- [ ] 设置中心使用共享分类定义与 `SettingsCategoryHeader`,将保存与测试操作统一放到分类页头右侧,并把成功反馈接入应用通知。
|
||||
@@ -788,6 +947,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
|
||||
### 15.5 验收
|
||||
|
||||
执行与取证方法见 [Electron UI 自动化测试规范](./docs/quality/electron-ui-automation.md),包括真实 Portal、原生键盘、计算样式与 PNG 审阅;视觉 fixture 与完整生产 App 的结果须分别标注。
|
||||
|
||||
- [ ] 在浅色与深色主题下检查所有页面、弹窗、菜单、输入框、表格和空状态。
|
||||
- [ ] 在大于等于 `1200px`、`960px`、`720px` 和小于 `720px` 的窗口宽度检查布局。
|
||||
- [ ] 仅使用键盘完成导航、筛选、创建、编辑、确认和取消。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.11.20",
|
||||
"agentVersion": "0.15.6",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
const { mkdirSync, readFileSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { readRemoteRuntimeLock, resolveLockedRuntimeInput } = require('./remote-runtime-bundle.cjs')
|
||||
|
||||
const [directory, architecture, platform = 'linux'] = process.argv.slice(2)
|
||||
if (!directory) throw new Error('Continue archive destination is required')
|
||||
const input = resolveLockedRuntimeInput(readRemoteRuntimeLock(), architecture, platform, 'continue')
|
||||
const destination = resolve(directory)
|
||||
mkdirSync(destination, { recursive: true })
|
||||
execFileSync('npm', ['pack', `${input.packageName}@${input.version}`, '--ignore-scripts',
|
||||
'--registry=https://registry.npmjs.org', '--pack-destination', destination], { stdio: 'ignore' })
|
||||
const archive = join(destination, input.archive)
|
||||
if (`sha512-${createHash('sha512').update(readFileSync(archive)).digest('base64')}` !== input.integrity) {
|
||||
throw new Error('Continue archive integrity mismatch')
|
||||
}
|
||||
process.stdout.write(`${archive}\n`)
|
||||
+27
-5
@@ -50,6 +50,7 @@ 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 =
|
||||
@@ -151,8 +152,8 @@ function assertPackageDescriptor(descriptor) {
|
||||
descriptor.files.length > 50_000 ||
|
||||
typeof descriptor.remoteRuntime !== 'object' ||
|
||||
descriptor.remoteRuntime === null ||
|
||||
descriptor.remoteRuntime.runtimeId !== 'opencode' ||
|
||||
descriptor.remoteRuntime.provider !== 'opencode' ||
|
||||
!['opencode', 'continue'].includes(descriptor.remoteRuntime.runtimeId) ||
|
||||
descriptor.remoteRuntime.provider !== descriptor.remoteRuntime.runtimeId ||
|
||||
!semanticVersionPattern.test(
|
||||
descriptor.remoteRuntime.version ?? ''
|
||||
) ||
|
||||
@@ -163,6 +164,7 @@ function assertPackageDescriptor(descriptor) {
|
||||
throw new Error('Agent package descriptor is invalid')
|
||||
}
|
||||
assertProtocol(descriptor.agentProtocol, 'Agent protocol')
|
||||
assertAdditionalRuntimes(descriptor)
|
||||
assertProtocol(
|
||||
descriptor.remoteRuntime.protocol,
|
||||
'Remote Runtime protocol'
|
||||
@@ -412,7 +414,10 @@ function streamPackageMetadata(archivePath) {
|
||||
entry.name === 'agent-package.json' ||
|
||||
entry.name === 'agent-package.sig'
|
||||
const size = entry.recordEnd - entry.dataOffset
|
||||
if (wanted && size > maximumMetadataBytes) {
|
||||
const maximum = entry.name === 'agent-package.json'
|
||||
? maximumDescriptorBytes
|
||||
: maximumMetadataBytes
|
||||
if (wanted && size > maximum) {
|
||||
throw new Error('Agent package metadata exceeds its limit')
|
||||
}
|
||||
return {
|
||||
@@ -588,8 +593,8 @@ function assertCatalog(catalog) {
|
||||
entry.formatVersion !== 1 ||
|
||||
entry.product !== 'GoodBuddy' ||
|
||||
entry.component !== 'agent' ||
|
||||
entry.remoteRuntime?.runtimeId !== 'opencode' ||
|
||||
entry.remoteRuntime?.provider !== 'opencode' ||
|
||||
!['opencode', 'continue'].includes(entry.remoteRuntime?.runtimeId) ||
|
||||
entry.remoteRuntime?.provider !== entry.remoteRuntime?.runtimeId ||
|
||||
!semanticVersionPattern.test(
|
||||
entry.remoteRuntime?.version ?? ''
|
||||
) ||
|
||||
@@ -600,6 +605,7 @@ function assertCatalog(catalog) {
|
||||
throw new Error('Agent catalog entry is invalid')
|
||||
}
|
||||
assertProtocol(entry.agentProtocol, 'Agent protocol')
|
||||
assertAdditionalRuntimes(entry)
|
||||
assertProtocol(
|
||||
entry.remoteRuntime.protocol,
|
||||
'Remote Runtime protocol'
|
||||
@@ -630,6 +636,18 @@ function readVerifiedCatalog(catalogPath, signaturePath, registry) {
|
||||
return catalog
|
||||
}
|
||||
|
||||
function assertAdditionalRuntimes(value) {
|
||||
if (value.additionalRuntimes === undefined) return
|
||||
if (!Array.isArray(value.additionalRuntimes) || value.additionalRuntimes.length > 1) throw new Error('Invalid additional Runtimes')
|
||||
for (const runtime of value.additionalRuntimes) {
|
||||
if (!runtime || !['opencode', 'continue'].includes(runtime.runtimeId) || runtime.runtimeId === value.remoteRuntime.runtimeId ||
|
||||
runtime.provider !== runtime.runtimeId || !semanticVersionPattern.test(runtime.version ?? '') || !/^sha256:[a-f0-9]{64}$/u.test(runtime.bundleDigest ?? '')) {
|
||||
throw new Error('Invalid additional Runtime identity')
|
||||
}
|
||||
assertProtocol(runtime.protocol, 'Additional Runtime protocol')
|
||||
}
|
||||
}
|
||||
|
||||
function parseVersion(value) {
|
||||
const match =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/u
|
||||
@@ -706,6 +724,10 @@ function createCatalog(options) {
|
||||
...(options.darwinArm64Package ? [readPackageMetadata(options.darwinArm64Package, registry)] : [])
|
||||
]
|
||||
const [first, second] = packages
|
||||
const additionalIdentity = descriptor => canonicalJson((descriptor.additionalRuntimes ?? []).map(({ bundleDigest: _digest, ...identity }) => { void _digest; return identity }))
|
||||
if (packages.some(value => additionalIdentity(value.descriptor) !== additionalIdentity(first.descriptor))) {
|
||||
throw new Error('Agent package matrix has inconsistent additional Runtimes')
|
||||
}
|
||||
const {
|
||||
bundleDigest: _firstRuntimeDigest,
|
||||
...firstRuntimeIdentity
|
||||
|
||||
@@ -38,6 +38,7 @@ function parseArguments(argv) {
|
||||
'--platform',
|
||||
'--node-archive',
|
||||
'--opencode-archive',
|
||||
'--continue-archive',
|
||||
'--output-directory',
|
||||
'--archive'
|
||||
].includes(argument)
|
||||
@@ -63,6 +64,7 @@ function parseArguments(argv) {
|
||||
for (const key of [
|
||||
'nodearchive',
|
||||
'opencodearchive',
|
||||
'continuearchive',
|
||||
'outputdirectory',
|
||||
'archive'
|
||||
]) {
|
||||
@@ -77,6 +79,7 @@ function parseArguments(argv) {
|
||||
platform,
|
||||
nodeArchive: resolve(options.nodearchive),
|
||||
opencodeArchive: resolve(options.opencodearchive),
|
||||
continueArchive: resolve(options.continuearchive),
|
||||
outputDirectory: resolve(options.outputdirectory),
|
||||
archive: resolve(options.archive)
|
||||
}
|
||||
@@ -160,6 +163,11 @@ async function buildCiAgentBundle(options) {
|
||||
registry: signing.registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
const continueRuntime = buildRuntimeBundle({
|
||||
platform, runtimeId: 'continue', lock: runtimeLock, projectRoot, architecture: options.arch,
|
||||
runtimeArchive: options.continueArchive, outputRoot: runtimeRoot,
|
||||
registry: signing.registry, testSigningIdentity: identity
|
||||
})
|
||||
const assemble = (archive) =>
|
||||
assembleAgentPackage({
|
||||
platform,
|
||||
@@ -170,6 +178,7 @@ async function buildCiAgentBundle(options) {
|
||||
output: archive,
|
||||
agentBundle: options.outputDirectory,
|
||||
runtimeBundle: runtime.bundleDirectory,
|
||||
additionalRuntimeBundles: [continueRuntime.bundleDirectory],
|
||||
agentLock: lock,
|
||||
runtimeLock,
|
||||
registry: signing.registry,
|
||||
|
||||
+43
-6
@@ -142,7 +142,7 @@ function packageFileMode(path) {
|
||||
if (path.startsWith('agent/')) {
|
||||
return expectedAgentManifestMode(path.slice('agent/'.length))
|
||||
}
|
||||
return /\/bin\/opencode$/u.test(path) ? '0755' : '0644'
|
||||
return /(?:\/bin\/opencode|\/lib\/continue\/dist\/cn\.js)$/u.test(path) ? '0755' : '0644'
|
||||
}
|
||||
|
||||
function stagePackagePayload(options) {
|
||||
@@ -153,10 +153,13 @@ function stagePackagePayload(options) {
|
||||
join(
|
||||
staging,
|
||||
'runtime',
|
||||
'opencode',
|
||||
options.runtimeId ?? 'opencode',
|
||||
basename(options.runtimeBundle)
|
||||
)
|
||||
)
|
||||
for (const runtime of options.additionalRuntimes ?? []) {
|
||||
copyTree(runtime.bundleDirectory, join(staging, 'runtime', runtime.manifest.runtimeId, basename(runtime.bundleDirectory)))
|
||||
}
|
||||
for (const [value, destination] of [
|
||||
[
|
||||
options.registry,
|
||||
@@ -420,13 +423,22 @@ function assembleAgentPackage(options) {
|
||||
join(tmpdir(), `goodbuddy-agent-package-${target}-`)
|
||||
)
|
||||
try {
|
||||
const additionalRuntimes = (options.additionalRuntimeBundles ?? []).map(directory => verifyRuntimeBundle(directory, {
|
||||
platform, projectRoot, architecture, registry, lock: runtimeLock,
|
||||
verificationEnvironment: options.testSigningIdentity ? 'test' : 'production'
|
||||
}))
|
||||
if (new Set([runtime, ...additionalRuntimes].map(value => value.manifest.runtimeId)).size !== additionalRuntimes.length + 1) {
|
||||
throw new Error('Duplicate packaged Runtime')
|
||||
}
|
||||
stagePackagePayload({
|
||||
staging,
|
||||
registry,
|
||||
agentLock,
|
||||
runtimeLock,
|
||||
agentBundle: resolve(options.agentBundle),
|
||||
runtimeBundle: runtime.bundleDirectory
|
||||
runtimeBundle: runtime.bundleDirectory,
|
||||
runtimeId: runtime.manifest.runtimeId,
|
||||
additionalRuntimes
|
||||
})
|
||||
const payload = payloadFiles(staging)
|
||||
const initial = {
|
||||
@@ -447,6 +459,10 @@ function assembleAgentPackage(options) {
|
||||
bundleDigest: runtime.manifest.bundleDigest,
|
||||
protocol: runtime.manifest.protocol
|
||||
},
|
||||
...(additionalRuntimes.length ? { additionalRuntimes: additionalRuntimes.map(({ manifest }) => ({
|
||||
runtimeId: manifest.runtimeId, provider: manifest.provider, version: manifest.runtimeVersion,
|
||||
bundleDigest: manifest.bundleDigest, protocol: manifest.protocol
|
||||
})) } : {}),
|
||||
contentDigest: `sha256:${'0'.repeat(64)}`,
|
||||
files: payload.files
|
||||
}
|
||||
@@ -510,6 +526,7 @@ function buildAgentPackage(options) {
|
||||
: { signingIdentity })
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
runtimeId: options.runtimeId,
|
||||
platform: options.platform ?? 'linux',
|
||||
projectRoot,
|
||||
architecture: options.architecture,
|
||||
@@ -521,6 +538,16 @@ function buildAgentPackage(options) {
|
||||
? { testSigningIdentity: options.testSigningIdentity }
|
||||
: { signingIdentity })
|
||||
})
|
||||
const additionalRuntimeBundles = []
|
||||
if (!options.runtimeId || options.runtimeId === 'opencode') {
|
||||
if (!options.continueArchive) throw new Error('Default Agent package requires the locked Continue archive')
|
||||
additionalRuntimeBundles.push(buildRuntimeBundle({
|
||||
runtimeId: 'continue', platform: options.platform ?? 'linux', projectRoot,
|
||||
architecture: options.architecture, runtimeArchive: options.continueArchive, outputRoot: runtimeRoot,
|
||||
registry, ...(options.runtimeLock ? { lock: options.runtimeLock } : {}),
|
||||
...(options.testSigningIdentity ? { testSigningIdentity: options.testSigningIdentity } : { signingIdentity })
|
||||
}).bundleDirectory)
|
||||
}
|
||||
return assembleAgentPackage({
|
||||
...options,
|
||||
projectRoot,
|
||||
@@ -529,7 +556,8 @@ function buildAgentPackage(options) {
|
||||
? {}
|
||||
: { signingIdentity }),
|
||||
agentBundle,
|
||||
runtimeBundle: runtime.bundleDirectory
|
||||
runtimeBundle: runtime.bundleDirectory,
|
||||
additionalRuntimeBundles
|
||||
})
|
||||
} finally {
|
||||
rmSync(agentRoot, { recursive: true, force: true })
|
||||
@@ -555,6 +583,9 @@ function parseArguments(argv) {
|
||||
'--minimum-desktop-version',
|
||||
'--node-archive',
|
||||
'--runtime-archive',
|
||||
'--runtime-id',
|
||||
'--continue-archive',
|
||||
'--continue-bundle',
|
||||
'--agent-bundle',
|
||||
'--runtime-bundle',
|
||||
'--output'
|
||||
@@ -579,11 +610,15 @@ function parseArguments(argv) {
|
||||
throw new Error('Agent package arguments are incomplete')
|
||||
}
|
||||
const common = {
|
||||
runtimeId: options.runtimeId ?? 'opencode',
|
||||
platform: options.platform ?? 'linux',
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
output: resolve(options.output)
|
||||
}
|
||||
if (!['opencode', 'continue'].includes(common.runtimeId)) {
|
||||
throw new Error('Unsupported Agent package Runtime')
|
||||
}
|
||||
targetName(common.architecture, common.platform)
|
||||
if (command === 'build') {
|
||||
if (!options.nodeArchive || !options.runtimeArchive) {
|
||||
@@ -593,7 +628,8 @@ function parseArguments(argv) {
|
||||
command,
|
||||
...common,
|
||||
nodeArchive: resolve(options.nodeArchive),
|
||||
runtimeArchive: resolve(options.runtimeArchive)
|
||||
runtimeArchive: resolve(options.runtimeArchive),
|
||||
continueArchive: options.continueArchive ? resolve(options.continueArchive) : undefined
|
||||
}
|
||||
}
|
||||
if (!options.agentBundle || !options.runtimeBundle) {
|
||||
@@ -603,7 +639,8 @@ function parseArguments(argv) {
|
||||
command,
|
||||
...common,
|
||||
agentBundle: resolve(options.agentBundle),
|
||||
runtimeBundle: resolve(options.runtimeBundle)
|
||||
runtimeBundle: resolve(options.runtimeBundle),
|
||||
additionalRuntimeBundles: options.continueBundle ? [resolve(options.continueBundle)] : []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ 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: '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'] }
|
||||
]
|
||||
|
||||
@@ -141,6 +141,13 @@ function portableRequiredPaths(directory) {
|
||||
'opencode',
|
||||
'opencode.exe'
|
||||
),
|
||||
join(
|
||||
directory,
|
||||
'resources',
|
||||
'runtimes',
|
||||
'opencode',
|
||||
'rg.exe'
|
||||
),
|
||||
join(
|
||||
directory,
|
||||
'resources',
|
||||
@@ -152,7 +159,10 @@ function portableRequiredPaths(directory) {
|
||||
}
|
||||
|
||||
function assertPortableOutput(directory) {
|
||||
const requiredPaths = portableRequiredPaths(directory)
|
||||
const requiredPaths = [
|
||||
...portableRequiredPaths(directory),
|
||||
join(directory, 'resources', 'runtimes', 'dsh', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
|
||||
]
|
||||
const missing = requiredPaths.filter(
|
||||
(filePath) => !statSync(filePath, { throwIfNoEntry: false })?.isFile()
|
||||
)
|
||||
|
||||
+47
-6
@@ -42,6 +42,10 @@ const {
|
||||
const {
|
||||
readZipCentralDirectory
|
||||
} = require('./zip-central-directory.cjs')
|
||||
const {
|
||||
verifyOpenCodeConfig,
|
||||
verifyHarnessBundleImports
|
||||
} = require('./verify-desktop-runtimes.cjs')
|
||||
|
||||
const root = join(__dirname, '..')
|
||||
const packageJson = JSON.parse(
|
||||
@@ -90,6 +94,10 @@ const portableRequiredFiles = [
|
||||
'resources/tray-icon.png',
|
||||
'resources/tool-environment/managed-python-artifacts.json',
|
||||
'resources/runtimes/opencode/opencode.exe',
|
||||
'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',
|
||||
@@ -122,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: [
|
||||
@@ -135,7 +144,8 @@ const platformDefinitions = {
|
||||
'MacOS',
|
||||
productName
|
||||
],
|
||||
runtimeExecutable: 'opencode'
|
||||
runtimeExecutable: 'opencode',
|
||||
ripgrepExecutable: 'rg'
|
||||
},
|
||||
linux: {
|
||||
builderFlag: '--linux',
|
||||
@@ -143,7 +153,8 @@ const platformDefinitions = {
|
||||
supportedFormats: ['AppImage', 'deb', 'rpm'],
|
||||
unpackedPattern: /^linux(?:-.+)?-unpacked$/u,
|
||||
executable: [packageJson.name],
|
||||
runtimeExecutable: 'opencode'
|
||||
runtimeExecutable: 'opencode',
|
||||
ripgrepExecutable: 'rg'
|
||||
}
|
||||
}
|
||||
const formatExtensions = {
|
||||
@@ -820,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) =>
|
||||
@@ -868,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
|
||||
@@ -1056,6 +1075,12 @@ 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'), '版本更新说明')
|
||||
@@ -1080,6 +1105,11 @@ function verifyUnpackedOutput(directory, options) {
|
||||
'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'
|
||||
@@ -1107,7 +1137,8 @@ function verifyUnpackedOutput(directory, options) {
|
||||
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) {
|
||||
@@ -1567,7 +1598,7 @@ function printHelp() {
|
||||
|
||||
默认格式:
|
||||
windows: nsis, portable (ZIP)
|
||||
macos: dmg, zip
|
||||
macos: dmg
|
||||
linux: AppImage, deb, rpm`)
|
||||
}
|
||||
|
||||
@@ -1629,6 +1660,16 @@ async function main(argv = process.argv.slice(2)) {
|
||||
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')
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// PERF-17 regression gate for the PERF-11 full-App benchmark report.
|
||||
// Usage: node build/check-perf-thresholds.cjs <result.json | report directory> [thresholds.json]
|
||||
// Exit codes: 0 all limits met, 1 a limit, scenario or status failed,
|
||||
// 2 the report cannot be judged (throttled run, unreadable input).
|
||||
const { existsSync, readFileSync, statSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const defaultThresholdsPath = join(__dirname, 'perf-thresholds.json')
|
||||
|
||||
function metricValue(scenario, path) {
|
||||
let value = scenario
|
||||
for (const key of path.split('.')) {
|
||||
if (value === null || value === undefined) return undefined
|
||||
value = value[key]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Pure: judges a parsed report against parsed thresholds.
|
||||
// Returns { ok, refused, reason?, rows: [{ scenario, metric, value, limit, pass, note? }] }.
|
||||
function evaluate(report, thresholds) {
|
||||
const rows = []
|
||||
const throttle = Number(report?.environment?.rendererCpuThrottle ?? 1)
|
||||
const maxThrottle = Number(thresholds.maxRendererCpuThrottle ?? 1)
|
||||
if (!(throttle <= maxThrottle)) {
|
||||
return { ok: false, refused: true, reason: `renderer CPU throttle ${throttle}x exceeds ${maxThrottle}x; thresholds apply to non-throttled runs only`, rows }
|
||||
}
|
||||
const requiredStatus = thresholds.requiredStatus ?? 'passed'
|
||||
rows.push({ scenario: '(report)', metric: 'status', value: report?.status ?? null, limit: `= ${requiredStatus}`, pass: report?.status === requiredStatus })
|
||||
const scenarios = Array.isArray(report?.scenarios) ? report.scenarios : []
|
||||
for (const [id, limits] of Object.entries(thresholds.scenarios ?? {})) {
|
||||
const scenario = scenarios.find(entry => entry?.id === id)
|
||||
if (!scenario) {
|
||||
const optional = limits.optional === true
|
||||
rows.push({ scenario: id, metric: '(present)', value: 'missing', limit: optional ? 'optional' : 'required', pass: optional })
|
||||
continue
|
||||
}
|
||||
for (const [metric, bound] of Object.entries(limits)) {
|
||||
if (metric === 'optional') continue
|
||||
const raw = metricValue(scenario, metric)
|
||||
const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : null
|
||||
let pass = value !== null
|
||||
const limitParts = []
|
||||
if (typeof bound.max === 'number') { limitParts.push(`<= ${bound.max}`); pass = pass && value <= bound.max }
|
||||
if (typeof bound.min === 'number') { limitParts.push(`>= ${bound.min}`); pass = pass && value >= bound.min }
|
||||
rows.push({ scenario: id, metric, value, limit: limitParts.join(', '), pass, ...(value === null ? { note: 'metric missing' } : {}) })
|
||||
}
|
||||
}
|
||||
return { ok: rows.every(row => row.pass), refused: false, rows }
|
||||
}
|
||||
|
||||
function resolveReportPath(input) {
|
||||
const path = resolve(input)
|
||||
return existsSync(path) && statSync(path).isDirectory() ? join(path, 'result.json') : path
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (typeof value !== 'number') return String(value)
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(value < 1 ? 3 : 1)
|
||||
}
|
||||
|
||||
function run(argv) {
|
||||
const [input, thresholdsArg] = argv
|
||||
if (!input) {
|
||||
console.error('usage: node build/check-perf-thresholds.cjs <result.json | report directory> [thresholds.json]')
|
||||
return 2
|
||||
}
|
||||
const reportPath = resolveReportPath(input)
|
||||
const thresholdsPath = resolve(thresholdsArg || defaultThresholdsPath)
|
||||
let report
|
||||
let thresholds
|
||||
try {
|
||||
report = JSON.parse(readFileSync(reportPath, 'utf8'))
|
||||
thresholds = JSON.parse(readFileSync(thresholdsPath, 'utf8'))
|
||||
} catch (error) {
|
||||
console.error(`cannot read report or thresholds: ${error instanceof Error ? error.message : error}`)
|
||||
return 2
|
||||
}
|
||||
const result = evaluate(report, thresholds)
|
||||
console.log(`\nperf thresholds: ${reportPath}`)
|
||||
if (result.refused) {
|
||||
console.error(`refused: ${result.reason}`)
|
||||
return 2
|
||||
}
|
||||
console.table(result.rows.map(row => ({
|
||||
scenario: row.scenario,
|
||||
metric: row.metric,
|
||||
value: formatValue(row.value),
|
||||
limit: row.limit,
|
||||
pass: row.pass ? 'ok' : `FAIL${row.note ? ` (${row.note})` : ''}`
|
||||
})))
|
||||
const failed = result.rows.filter(row => !row.pass).length
|
||||
console.log(failed ? `${failed} of ${result.rows.length} checks failed` : `all ${result.rows.length} checks passed`)
|
||||
return result.ok ? 0 : 1
|
||||
}
|
||||
|
||||
module.exports = { evaluate, run, defaultThresholdsPath }
|
||||
|
||||
if (require.main === module) process.exitCode = run(process.argv.slice(2))
|
||||
@@ -184,7 +184,7 @@ async function run() {
|
||||
model: 'qwen-plus',
|
||||
supportsImageInput: false,
|
||||
requestHeaders: {},
|
||||
harnessVersion: '0.1.0-rc.8',
|
||||
harnessVersion: '0.1.7-rc.2',
|
||||
credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'],
|
||||
skillPackages: [],
|
||||
extensionPackages: [],
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const { cp, mkdir, readFile, rm } = require('node:fs/promises');
|
||||
const { existsSync } = require('node:fs');
|
||||
const { dirname, join, relative } = require('node:path');
|
||||
const tar = require('tar');
|
||||
|
||||
// Preserve npm's installed layout (including nested versions) and every package
|
||||
// asset. The official loader discovers plugins and browser assets at runtime.
|
||||
async function prepareNativeDshResources(projectDir, platform, architecture, downloadPackage, lockedIntegrity) {
|
||||
const target = join(projectDir, '.runtime-resources', `dsh-${architecture}`);
|
||||
await rm(target, { recursive: true, force: true });
|
||||
await mkdir(target, { recursive: true });
|
||||
const visited = new Set();
|
||||
function locate(name, from) {
|
||||
for (let directory = from; ; directory = dirname(directory)) {
|
||||
const candidate = join(directory, 'node_modules', name);
|
||||
if (existsSync(join(candidate, 'package.json'))) return candidate;
|
||||
if (dirname(directory) === directory) return undefined;
|
||||
}
|
||||
}
|
||||
async function copyPackage(directory) {
|
||||
if (visited.has(directory)) return;
|
||||
visited.add(directory);
|
||||
const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'));
|
||||
await cp(directory, join(target, relative(projectDir, directory)), { recursive: true, verbatimSymlinks: true });
|
||||
const optional = manifest.optionalDependencies ?? {};
|
||||
for (const name of Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies, ...optional })) {
|
||||
const dependency = locate(name, directory);
|
||||
if (dependency) await copyPackage(dependency);
|
||||
else if (!(name in optional) && !manifest.peerDependenciesMeta?.[name]?.optional) {
|
||||
throw new Error(`DS Web bundle is missing ${name} required by ${manifest.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await copyPackage(join(projectDir, 'node_modules', '@deepseek-ai', 'dsh'));
|
||||
// Cross-architecture packaging cannot rely on the build host's npm optional
|
||||
// selection. Fetch the exact target Sharp packages from the existing lock.
|
||||
const sharp = JSON.parse(await readFile(join(projectDir, 'node_modules', 'sharp', 'package.json'), 'utf8'));
|
||||
const lock = JSON.parse(await readFile(join(projectDir, 'package-lock.json'), 'utf8'));
|
||||
const nativePackages = [`@img/sharp-${platform}-${architecture}`];
|
||||
if (platform !== 'win32') nativePackages.push(`@img/sharp-libvips-${platform}-${architecture}`);
|
||||
for (const name of nativePackages) {
|
||||
const version = sharp.optionalDependencies[name];
|
||||
if (!version) throw new Error(`DS Web Sharp does not support ${platform}/${architecture}`);
|
||||
const destination = join(target, 'node_modules', 'sharp', 'node_modules', name);
|
||||
if (existsSync(join(destination, 'package.json'))) continue;
|
||||
const locked = lock.packages[`node_modules/sharp/node_modules/${name}`] ?? lock.packages[`node_modules/${name}`];
|
||||
if (locked?.version !== version || !locked.integrity) throw new Error(`Missing locked ${name}@${version}`);
|
||||
const integrity = locked.integrity;
|
||||
const archive = await downloadPackage(projectDir, name, version, integrity);
|
||||
await mkdir(destination, { recursive: true });
|
||||
await tar.x({ file: archive, cwd: destination, strip: 1 });
|
||||
}
|
||||
const koffi = JSON.parse(await readFile(join(projectDir, 'node_modules', 'koffi', 'package.json'), 'utf8'));
|
||||
const koffiName = `@koromix/koffi-${platform}-${architecture}`;
|
||||
const koffiDestination = join(target, 'node_modules', koffiName);
|
||||
if (!existsSync(join(koffiDestination, 'package.json'))) {
|
||||
const integrity = await lockedIntegrity(projectDir, koffiName, koffi.version);
|
||||
const archive = await downloadPackage(projectDir, koffiName, koffi.version, integrity);
|
||||
await mkdir(koffiDestination, { recursive: true });
|
||||
await tar.x({ file: archive, cwd: koffiDestination, strip: 1 });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
module.exports = { prepareNativeDshResources };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"note": "PERF-17 regression ceilings for the PERF-11 full-App benchmark (npm run perf:app). They apply ONLY to non-throttled runs (environment.rendererCpuThrottle = 1, the default) with the default GB_PERF_* scale, on the reference machine of the 2026-10-02 baseline (Intel Core Ultra X7 358H x16, 32 GB, DPR 1, 120 Hz). Limits are about 2-3x the 2026-10-02 values so that run-to-run noise does not fail; they catch regressions of the size seen before PERF-12 (e.g. ~130 long tasks / 9 s while streaming). Tighten them as PERF-12..PERF-14 land; do not loosen them to make a regression pass. Every listed scenario must be present unless marked optional, and report.status must equal requiredStatus.",
|
||||
"requiredStatus": "passed",
|
||||
"maxRendererCpuThrottle": 1,
|
||||
"scenarios": {
|
||||
"idle": {
|
||||
"renderer.longTasks.count": { "max": 2 },
|
||||
"renderer.longTasks.totalMs": { "max": 200 },
|
||||
"renderer.frames.p95": { "max": 20 },
|
||||
"renderer.frames.jankRatio": { "max": 0.05 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"typing-small": {
|
||||
"renderer.longTasks.count": { "max": 3 },
|
||||
"renderer.longTasks.totalMs": { "max": 300 },
|
||||
"renderer.frames.p95": { "max": 20 },
|
||||
"renderer.frames.jankRatio": { "max": 0.05 },
|
||||
"latency.p95": { "max": 25 },
|
||||
"latency.max": { "max": 100 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"idle-seeded": {
|
||||
"renderer.longTasks.count": { "max": 2 },
|
||||
"renderer.longTasks.totalMs": { "max": 200 },
|
||||
"renderer.frames.p95": { "max": 20 },
|
||||
"renderer.frames.jankRatio": { "max": 0.05 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"typing-seeded": {
|
||||
"renderer.longTasks.count": { "max": 3 },
|
||||
"renderer.longTasks.totalMs": { "max": 300 },
|
||||
"renderer.frames.p95": { "max": 20 },
|
||||
"renderer.frames.jankRatio": { "max": 0.05 },
|
||||
"latency.p95": { "max": 25 },
|
||||
"latency.max": { "max": 100 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"switch-conversations": {
|
||||
"renderer.longTasks.count": { "max": 5 },
|
||||
"renderer.longTasks.totalMs": { "max": 500 },
|
||||
"renderer.frames.jankRatio": { "max": 0.15 },
|
||||
"latency.p95": { "max": 350 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"switch-conversations-warm": {
|
||||
"renderer.longTasks.count": { "max": 3 },
|
||||
"renderer.longTasks.totalMs": { "max": 300 },
|
||||
"renderer.frames.jankRatio": { "max": 0.15 },
|
||||
"latency.p95": { "max": 200 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"switch-conversations-hot": {
|
||||
"optional": true,
|
||||
"renderer.longTasks.count": { "max": 3 },
|
||||
"renderer.longTasks.totalMs": { "max": 200 },
|
||||
"renderer.frames.jankRatio": { "max": 0.1 },
|
||||
"latency.p95": { "max": 100 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"open-long-conversation": {
|
||||
"renderer.longTasks.count": { "max": 4 },
|
||||
"renderer.longTasks.totalMs": { "max": 400 },
|
||||
"latency.p95": { "max": 400 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"scroll-long-conversation": {
|
||||
"renderer.longTasks.count": { "max": 2 },
|
||||
"renderer.longTasks.totalMs": { "max": 200 },
|
||||
"renderer.frames.p95": { "max": 20 },
|
||||
"renderer.frames.jankRatio": { "max": 0.05 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"stream-seeded": {
|
||||
"renderer.longTasks.count": { "max": 10 },
|
||||
"renderer.longTasks.totalMs": { "max": 1500 },
|
||||
"renderer.frames.p95": { "max": 34 },
|
||||
"renderer.frames.jankRatio": { "max": 0.15 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
},
|
||||
"stream-and-type": {
|
||||
"renderer.longTasks.count": { "max": 10 },
|
||||
"renderer.longTasks.totalMs": { "max": 1500 },
|
||||
"renderer.frames.p95": { "max": 34 },
|
||||
"renderer.frames.jankRatio": { "max": 0.2 },
|
||||
"latency.p95": { "max": 50 },
|
||||
"main.eventLoopDelay.p99": { "max": 100 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+523
-555
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
// Full-App interaction benchmark supervisor (PERF-11).
|
||||
// Starts a loopback fake OpenAI-compatible model on 127.0.0.1:11434 (the
|
||||
// fresh-profile default model endpoint), launches the current production
|
||||
// build in an isolated profile through tests/support/app-perf-driver.mjs,
|
||||
// enforces a total deadline and prints a summary of the driver report.
|
||||
const { spawn } = require('node:child_process')
|
||||
const { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { createServer } = require('node:http')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const runnerRoot = resolve(__dirname, '..')
|
||||
const root = resolve(process.env.GB_PERF_ROOT || runnerRoot)
|
||||
const concurrentMode = process.env.GB_PERF_MODE?.startsWith('C')
|
||||
// 11434 is the fresh-profile default endpoint. Another GoodBuddy (or Ollama)
|
||||
// may already use it; GB_PERF_MODEL_PORT moves the fake model and points the
|
||||
// isolated profile at it through the GOODBUDDY_MODEL_* environment variables.
|
||||
const defaultModelPort = 11434
|
||||
const modelPort = Number(process.env.GB_PERF_MODEL_PORT || defaultModelPort)
|
||||
const totalTimeoutMs = Number(process.env.GB_PERF_TIMEOUT_MS || (process.env.GB_PERF_MODE === 'C06' ? 900_000 : 600_000))
|
||||
const keepProfile = process.env.GB_PERF_KEEP_PROFILE === '1'
|
||||
const streamBytes = Number(process.env.GB_PERF_STREAM_BYTES || 40_000)
|
||||
const chunkChars = Number(process.env.GB_PERF_CHUNK_CHARS || 12)
|
||||
const streamCharsPerSecond = Number(process.env.GB_PERF_STREAM_CPS || 2_000)
|
||||
const tickIntervalMs = 10
|
||||
|
||||
function buildAnswer(targetBytes) {
|
||||
const blocks = [
|
||||
'## Section {n}\n\nThis paragraph explains the {n}th step of the benchmark answer with **bold**, `inline code`, and a [link](https://example.com/{n}).\n\n',
|
||||
'- First item for block {n}\n- Second item with *emphasis*\n - Nested item {n}\n- Third item\n\n',
|
||||
'```ts\nexport function step{n}(input: number): number {\n const value = input * {n}\n return value + 1\n}\n```\n\n',
|
||||
'| Column | Value {n} | Notes |\n| --- | --- | --- |\n| alpha | {n} | first |\n| beta | {n}1 | second |\n\n',
|
||||
// Single-line display math is common in model output. GB_PERF_CORPUS=multiline-math
|
||||
// puts the delimiters on their own lines instead.
|
||||
process.env.GB_PERF_CORPUS === 'multiline-math'
|
||||
? 'Inline math $a_{n} = b^2 + c$ and display math:\n\n$$\n\\sum_{i=1}^{{n}} i = \\frac{{n}({n}+1)}{2}\n$$\n\n'
|
||||
: 'Inline math $a_{n} = b^2 + c$ and display math:\n\n$$\\sum_{i=1}^{{n}} i = \\frac{{n}({n}+1)}{2}$$\n\n',
|
||||
'> Quoted note {n}: keep the streaming renderer honest with mixed blocks.\n\n'
|
||||
]
|
||||
let text = ''
|
||||
let n = 1
|
||||
while (Buffer.byteLength(text) < targetBytes) {
|
||||
text += blocks[n % blocks.length].replaceAll('{n}', String(n))
|
||||
n += 1
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function startModelServer(ledger) {
|
||||
const answer = buildAnswer(streamBytes)
|
||||
const server = createServer((request, response) => {
|
||||
const started = Date.now()
|
||||
const entry = { method: request.method, path: request.url, startedAt: started }
|
||||
ledger.push(entry)
|
||||
if (request.method === 'GET' && request.url?.endsWith('/models')) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ object: 'list', data: [{ id: 'qwen3', object: 'model' }] }))
|
||||
return
|
||||
}
|
||||
if (request.method === 'GET' && request.url === '/c03-knowledge.txt') {
|
||||
response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
|
||||
response.end('C03 deterministic knowledge source\n'.repeat(80))
|
||||
return
|
||||
}
|
||||
if (request.method !== 'POST' || !request.url?.endsWith('/chat/completions')) {
|
||||
response.writeHead(404).end()
|
||||
return
|
||||
}
|
||||
let body = ''
|
||||
request.on('data', chunk => { body += chunk })
|
||||
request.on('end', () => {
|
||||
let parsed = {}
|
||||
try { parsed = JSON.parse(body) } catch { /* counted below */ }
|
||||
const messages = Array.isArray(parsed.messages) ? parsed.messages : []
|
||||
const last = messages.at(-1)
|
||||
const lastText = typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? '')
|
||||
if (lastText.includes('OUTPUT CONTRACT:') && lastText.includes('BOUNDED EVIDENCE:')) {
|
||||
const evidenceText = lastText.split('BOUNDED EVIDENCE:\n\n')[1]?.split('\n\nReturn only JSON.')[0]
|
||||
let sourceId = 'missing-source'
|
||||
try { sourceId = JSON.parse(evidenceText)?.[0]?.id || sourceId } catch { /* invalid fixture prompt is rejected by the production path */ }
|
||||
const navigation = lastText.includes('navigation summaries')
|
||||
const output = JSON.stringify({
|
||||
summary: 'Deterministic concurrent acceptance review', changeDigest: 'fixture', openItems: [],
|
||||
events: navigation ? [] : [{ title: 'Acceptance event', description: 'Deterministic review event', occurredAt: '2026-10-07T00:00:00.000Z', eventType: 'milestone', entityIds: ['acceptance'], sourceReferenceIds: [sourceId] }],
|
||||
entities: navigation ? [] : [{ id: 'acceptance', label: 'Acceptance', description: 'Deterministic fixture', sourceReferenceIds: [sourceId] }],
|
||||
entityChanges: [], relations: []
|
||||
})
|
||||
entry.kind = 'supervision-deterministic'
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
||||
const chunk = payload => response.write(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
chunk({ id: 'supervision', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: { role: 'assistant', content: output }, finish_reason: null }] })
|
||||
chunk({ id: 'supervision', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })
|
||||
response.end('data: [DONE]\n\n')
|
||||
entry.finishedAt = Date.now()
|
||||
return
|
||||
}
|
||||
if (concurrentMode && lastText.includes('gb-fault:hang')) {
|
||||
entry.kind = 'fault-hung'
|
||||
entry.identity = 'fault-hung'
|
||||
entry.stream = true
|
||||
entry.aborted = false
|
||||
// Keep the production request in flight until the UI cancels it.
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const timer = setTimeout(() => {
|
||||
if (!response.destroyed) response.end('data: [DONE]\n\n')
|
||||
}, 120_000)
|
||||
response.on('close', () => { clearTimeout(timer); entry.aborted = !response.writableFinished; entry.finishedAt = Date.now() })
|
||||
return
|
||||
}
|
||||
if (concurrentMode && messages.some(message => typeof message.content === 'string' && message.content.includes('gb-concurrent:'))) {
|
||||
const marker = messages.map(message => typeof message.content === 'string' ? message.content : '').join('\n').match(/gb-concurrent:(parent|child):([a-zA-Z0-9-]+)/)
|
||||
const child = marker?.[1] === 'child'
|
||||
const toolsDone = messages.some(message => message.role === 'tool')
|
||||
entry.kind = child ? 'concurrent-child' : 'concurrent-parent'
|
||||
entry.identity = marker?.[2]
|
||||
entry.stream = parsed.stream === true
|
||||
const durationMs = child ? 12_000 : toolsDone ? 1_000 : 12_000
|
||||
const count = Math.ceil(durationMs / 16)
|
||||
const calls = !child && !toolsDone ? [0, 1].map(index => ({ index, id: `child-${marker?.[2]}-${index}`, type: 'function', function: { name: 'subagent_delegate', arguments: JSON.stringify({ task: `gb-concurrent:child:${marker?.[2]}-${index} deterministic output` }) } })) : []
|
||||
entry.offeredFragments = count
|
||||
entry.offeredToolCalls = calls.length
|
||||
entry.chunks = 0
|
||||
entry.bytes = 0
|
||||
entry.latenessMs = []
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const send = (delta, finish_reason = null) => response.write(`data: ${JSON.stringify({ id: entry.identity, object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta, finish_reason }] })}\n\n`)
|
||||
const started = performance.now()
|
||||
const tick = () => {
|
||||
if (response.destroyed) { entry.aborted = true; entry.settledAt = Date.now(); return }
|
||||
while (entry.chunks < count && performance.now() >= started + entry.chunks * 16) {
|
||||
entry.latenessMs.push(performance.now() - started - entry.chunks * 16)
|
||||
const text = `[${entry.identity}:${String(entry.chunks).padStart(5, '0')}] sample\n`
|
||||
send({ content: text })
|
||||
entry.bytes += Buffer.byteLength(text)
|
||||
entry.chunks++
|
||||
}
|
||||
if (entry.chunks < count) { setTimeout(tick, 4); return }
|
||||
if (calls.length) send({ tool_calls: calls })
|
||||
send({}, calls.length ? 'tool_calls' : 'stop')
|
||||
response.end('data: [DONE]\n\n')
|
||||
entry.finishedAt = Date.now()
|
||||
}
|
||||
tick()
|
||||
return
|
||||
}
|
||||
const benchmark = lastText.includes('perf-stream')
|
||||
entry.kind = benchmark ? 'benchmark-stream' : 'auxiliary'
|
||||
entry.stream = parsed.stream === true
|
||||
const content = benchmark ? answer : 'Benchmark title'
|
||||
if (!entry.stream) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
id: 'perf', object: 'chat.completion', model: 'qwen3',
|
||||
choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }
|
||||
}))
|
||||
entry.finishedAt = Date.now()
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
||||
const send = payload => response.write(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
const delta = text => send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: { content: text }, finish_reason: null }] })
|
||||
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })
|
||||
let offset = 0
|
||||
let chunks = 0
|
||||
const finish = () => {
|
||||
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })
|
||||
send({ id: 'perf', object: 'chat.completion.chunk', model: 'qwen3', choices: [], usage: { prompt_tokens: 10, completion_tokens: Math.ceil(content.length / 4), total_tokens: 10 + Math.ceil(content.length / 4) } })
|
||||
response.write('data: [DONE]\n\n')
|
||||
response.end()
|
||||
entry.finishedAt = Date.now()
|
||||
entry.chunks = chunks
|
||||
entry.bytes = Buffer.byteLength(content)
|
||||
}
|
||||
if (!benchmark) { delta(content); finish(); return }
|
||||
// Emit at a fixed character rate, split into small SSE events like a
|
||||
// fast token stream. Pacing is time-based so coarse timers do not
|
||||
// change throughput.
|
||||
const streamStart = Date.now()
|
||||
const tick = () => {
|
||||
if (response.destroyed) { entry.aborted = true; return }
|
||||
const due = Math.min(content.length, Math.ceil(((Date.now() - streamStart) / 1000) * streamCharsPerSecond))
|
||||
while (offset < due) {
|
||||
delta(content.slice(offset, offset + chunkChars))
|
||||
offset += chunkChars
|
||||
chunks += 1
|
||||
}
|
||||
if (offset >= content.length) { finish(); return }
|
||||
setTimeout(tick, tickIntervalMs)
|
||||
}
|
||||
tick()
|
||||
})
|
||||
})
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(modelPort, '127.0.0.1', () => resolvePromise(server))
|
||||
})
|
||||
}
|
||||
|
||||
function format(value, digits = 1) {
|
||||
return value === null || value === undefined || Number.isNaN(value) ? '-' : Number(value).toFixed(digits)
|
||||
}
|
||||
|
||||
function printSummary(report) {
|
||||
console.log(`\nGoodBuddy full-App benchmark (${report.status})`)
|
||||
console.log(`source ${report.source?.commit ?? '?'}${report.source?.dirty ? ' (dirty)' : ''}, Electron ${report.environment?.electron}, DPR ${report.environment?.devicePixelRatio}, ${report.environment?.cpu}`)
|
||||
if (report.startup) {
|
||||
console.log(`startup: window ${format(report.startup.windowCreatedMs, 0)} ms, ready-to-show ${format(report.startup.readyToShowMs, 0)} ms, composer ready ${format(report.startup.composerReadyMs, 0)} ms`)
|
||||
}
|
||||
const rows = (report.scenarios ?? []).map(s => ({
|
||||
scenario: s.id,
|
||||
ms: format(s.durationMs, 0),
|
||||
longTasks: `${s.renderer?.longTasks?.count ?? '-'} / ${format(s.renderer?.longTasks?.totalMs, 0)} / ${format(s.renderer?.longTasks?.maxMs, 0)}`,
|
||||
'frame p50/p95/max': `${format(s.renderer?.frames?.p50)} / ${format(s.renderer?.frames?.p95)} / ${format(s.renderer?.frames?.max, 0)}`,
|
||||
'jank%': format(s.renderer?.frames?.jankRatio * 100),
|
||||
'latency p50/p95/max': s.latency ? `${format(s.latency.p50)} / ${format(s.latency.p95)} / ${format(s.latency.max)}` : '-',
|
||||
'mainLag p99/max': `${format(s.main?.eventLoopDelay?.p99)} / ${format(s.main?.eventLoopDelay?.max)}`,
|
||||
'cpu% main/renderer/gpu': `${format(s.processes?.cpu?.Browser, 0)} / ${format(s.processes?.cpu?.Tab, 0)} / ${format(s.processes?.cpu?.GPU, 0)}`,
|
||||
domNodes: s.renderer?.domNodes ?? '-',
|
||||
heapMB: format(s.renderer?.heapUsedMB)
|
||||
}))
|
||||
if (rows.length) console.table(rows)
|
||||
if (report.memoryGrowth) {
|
||||
console.log(`memory growth (${report.memoryGrowth.description}):`)
|
||||
console.table(report.memoryGrowth.samples)
|
||||
}
|
||||
if (report.environment?.rendererCpuThrottle > 1) console.log(`renderer CPU throttled ${report.environment.rendererCpuThrottle}x`)
|
||||
if (report.error) console.log(`error: ${report.error}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const taskDirectory = join(runnerRoot, 'temp', 'goodbuddy-concurrent-upgrade')
|
||||
mkdirSync(taskDirectory, { recursive: true })
|
||||
if (process.argv.includes('--launch')) {
|
||||
const { openSync, closeSync } = require('node:fs')
|
||||
const output = resolve(process.env.GB_PERF_OUTPUT || join(taskDirectory, `run-${Date.now()}`))
|
||||
mkdirSync(output, { recursive: true })
|
||||
const log = openSync(join(output, 'execution.log'), 'w')
|
||||
const child = spawn(process.execPath, [__filename], {
|
||||
detached: true, stdio: ['ignore', log, log],
|
||||
env: { ...process.env, GB_PERF_OUTPUT: output }
|
||||
})
|
||||
closeSync(log)
|
||||
writeFileSync(join(output, 'supervisor.json'), JSON.stringify({ pid: child.pid, startedAt: new Date().toISOString(), root, mode: process.env.GB_PERF_MODE, timeoutMs: totalTimeoutMs }))
|
||||
child.unref()
|
||||
console.log(`Supervisor ${child.pid}; logs ${output}`)
|
||||
return
|
||||
}
|
||||
const required = ['out/main/index.js', 'out/preload/index.cjs', 'out/renderer/index.html']
|
||||
const missing = required.filter(file => !existsSync(join(root, file)))
|
||||
if (missing.length) throw new Error(`Missing production build (${missing.join(', ')}); run npm run build:bundle first`)
|
||||
const runDirectory = mkdtempSync(join(taskDirectory, 'profile-'))
|
||||
const outputDirectory = resolve(process.env.GB_PERF_OUTPUT || join(runDirectory, 'artifacts'))
|
||||
mkdirSync(outputDirectory, { recursive: true })
|
||||
const ledger = []
|
||||
let server
|
||||
try {
|
||||
server = await startModelServer(ledger)
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot listen on 127.0.0.1:${modelPort} for the fake model (${error.code ?? error.message}); stop the local service using that port, or set GB_PERF_MODEL_PORT to a free port`, { cause: error })
|
||||
}
|
||||
const environment = { ...process.env, GB_PERF_DIRECTORY: runDirectory, GB_PERF_ARTIFACTS: outputDirectory, GB_PERF_ROOT: root, TEMP: runDirectory, TMP: runDirectory, TMPDIR: runDirectory }
|
||||
if (concurrentMode) environment.GB_PERF_DRIVER_TIMEOUT_MS = String(totalTimeoutMs - 30_000)
|
||||
delete environment.ELECTRON_RUN_AS_NODE
|
||||
delete environment.ELECTRON_RENDERER_URL
|
||||
for (const key of ['GOODBUDDY_MODEL_API_KEY', 'GOODBUDDY_MODEL_BASE_URL', 'GOODBUDDY_MODEL_NAME', 'GOODBUDDY_BIGTOKEN_API_KEY', 'GOODBUDDY_BIGTOKEN_BASE_URL', 'GOODBUDDY_BIGTOKEN_MODEL']) delete environment[key]
|
||||
if (modelPort !== defaultModelPort) {
|
||||
// The fake model ignores the key; it only makes the default profile use the URL.
|
||||
environment.GOODBUDDY_MODEL_API_KEY = 'perf-loopback'
|
||||
environment.GOODBUDDY_MODEL_BASE_URL = `http://127.0.0.1:${modelPort}/v1`
|
||||
environment.GOODBUDDY_MODEL_NAME = 'qwen3'
|
||||
}
|
||||
environment.GOODBUDDY_WORKSPACE = join(runDirectory, 'workspace')
|
||||
mkdirSync(environment.GOODBUDDY_WORKSPACE, { recursive: true })
|
||||
const started = Date.now()
|
||||
const child = spawn(require('electron'), [join(runnerRoot, 'tests', 'support', 'app-perf-driver.mjs')], { cwd: root, env: environment, stdio: 'inherit', windowsHide: false })
|
||||
writeFileSync(join(outputDirectory, 'owned-processes.json'), JSON.stringify({ supervisorPid: process.pid, electronPid: child.pid, root, runDirectory, startedAt: new Date().toISOString() }))
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
else child.kill('SIGKILL')
|
||||
}, totalTimeoutMs)
|
||||
const exit = await new Promise(resolvePromise => child.once('exit', (code, signal) => resolvePromise({ code, signal })))
|
||||
clearTimeout(timer)
|
||||
await new Promise(resolvePromise => server.close(() => resolvePromise()))
|
||||
const resultPath = join(outputDirectory, 'result.json')
|
||||
let report
|
||||
if (existsSync(resultPath)) report = JSON.parse(readFileSync(resultPath, 'utf8'))
|
||||
else report = { status: 'failed', error: timedOut ? 'Supervisor timeout before report' : 'Driver exited without a report' }
|
||||
report.supervisor = { elapsedMs: Date.now() - started, timeoutMs: totalTimeoutMs, timedOut, exitCode: exit.code, signal: exit.signal }
|
||||
report.corpus = { kind: process.env.GB_PERF_CORPUS || 'single-line-math', bytes: streamBytes, charsPerSecond: streamCharsPerSecond, charsPerEvent: chunkChars }
|
||||
report.modelRequests = {
|
||||
boundary: 'loopback fake model only; no external requests',
|
||||
total: ledger.length,
|
||||
benchmarkStreams: ledger.filter(entry => entry.kind === 'benchmark-stream').length,
|
||||
auxiliary: ledger.filter(entry => entry.kind === 'auxiliary').length,
|
||||
ledger: ledger.map(({ method, path, kind, stream, chunks, bytes, aborted, startedAt, finishedAt, identity, offeredFragments, offeredToolCalls, latenessMs }) => ({ method, path, kind, stream, chunks, bytes, aborted, startedAt, finishedAt, identity, offeredFragments, offeredToolCalls, latenessMs, durationMs: finishedAt ? finishedAt - startedAt : null }))
|
||||
}
|
||||
if (timedOut || exit.code !== 0) report.status = 'failed'
|
||||
writeFileSync(resultPath, JSON.stringify(report, null, 2))
|
||||
printSummary(report)
|
||||
console.log(`\nreport: ${resultPath}`)
|
||||
if (!keepProfile) rmSync(runDirectory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
process.exitCode = String(report.status).startsWith('passed') ? 0 : 1
|
||||
// PERF-17: GB_PERF_CHECK=1 gates the run on build/perf-thresholds.json
|
||||
// (exit 1 on a regression, 2 when the report cannot be judged, e.g. throttled).
|
||||
if (process.env.GB_PERF_CHECK === '1') process.exitCode = require('./check-perf-thresholds.cjs').run([resultPath])
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -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.8',
|
||||
version: '0.1.7-rc.2',
|
||||
private: true,
|
||||
type: 'module'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Document parse benchmark (PERF-15/16, P7). Bundles the production parser
|
||||
// client and worker with esbuild, then imports synthetic large documents
|
||||
// (hash -> parse -> chunk, as Knowledge import does) twice per round: inline on
|
||||
// the main thread, then through the parse worker. Records the main event-loop
|
||||
// max block with perf_hooks.monitorEventLoopDelay.
|
||||
//
|
||||
// node build/run-document-parse-perf.cjs
|
||||
// GB_DOCPERF_ROUNDS=3 GB_DOCPERF_NODE=1 (skip Electron) GB_DOCPERF_OUTPUT=file.json
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdirSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { monitorEventLoopDelay, performance } = require('node:perf_hooks')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
|
||||
// Measure on the project's Electron (the production runtime), like perf:knowledge.
|
||||
if (process.env.GB_DOCPERF_NODE !== '1') {
|
||||
let electron
|
||||
let version
|
||||
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const rounds = Number(process.env.GB_DOCPERF_ROUNDS || 3)
|
||||
// Under node_modules so the external pdfjs-dist resolves from the repository.
|
||||
const directory = join(root, 'node_modules', '.cache', `goodbuddy-document-parse-perf-${process.pid}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
const harness = join(directory, 'harness.cjs')
|
||||
const workerBundle = join(directory, 'document-parse-worker.cjs')
|
||||
const harnessSource = join(directory, 'harness.ts')
|
||||
writeFileSync(harnessSource, [
|
||||
`export * from ${JSON.stringify(join(root, 'src/main/document-parse-client.ts'))}`,
|
||||
`export * from ${JSON.stringify(join(root, 'tests/support/document-parse-worker-fixture.ts'))}`
|
||||
].join('\n'))
|
||||
for (const [entry, outfile] of [[harnessSource, harness], [join(root, 'src/main/document-parse-worker.ts'), workerBundle]]) {
|
||||
buildSync({
|
||||
entryPoints: [entry], bundle: true, platform: 'node', format: 'cjs', target: 'node22',
|
||||
outfile, logLevel: 'error', external: ['pdfjs-dist', 'electron', 'esbuild']
|
||||
})
|
||||
}
|
||||
const {
|
||||
DocumentParseWorkerClient, chunkDocumentOffMain, parseDocumentOffMain, sha256OffMain,
|
||||
createDocx, createLargePdf, sentenceGenerator
|
||||
} = require(harness)
|
||||
|
||||
const settings = {
|
||||
version: 1, mode: 'structure', targetCharacters: 1600, overlapCharacters: 100,
|
||||
parentCharacters: 1600, childCharacters: 400, contextualIndexingEnabled: false
|
||||
}
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
|
||||
async function importOnce(name, buffer, client) {
|
||||
await sha256OffMain(buffer, client)
|
||||
const parsed = await parseDocumentOffMain(name, buffer, undefined, client)
|
||||
return (await chunkDocumentOffMain(parsed, settings, client)).length
|
||||
}
|
||||
|
||||
async function measure(name, buffer, client) {
|
||||
await importOnce(name, buffer, client) // warm modules / worker
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
const histogram = monitorEventLoopDelay({ resolution: 5 })
|
||||
histogram.enable()
|
||||
// The histogram only measures from its first timer tick on; a block that
|
||||
// starts before that tick (the inline path is all microtasks) is not seen.
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
const started = performance.now()
|
||||
const chunks = await importOnce(name, buffer, client)
|
||||
const wallMs = performance.now() - started
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
histogram.disable()
|
||||
return { maxMs: round(histogram.max / 1e6), p99Ms: round(histogram.percentile(99) / 1e6), wallMs: round(wallMs), chunks }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[docperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||
const sentence = sentenceGenerator(11)
|
||||
const inputs = [
|
||||
{ name: 'large.pdf', buffer: createLargePdf(500, 60) },
|
||||
{ name: 'large.docx', buffer: createDocx(Array.from({ length: 4000 }, () => sentence(25))) },
|
||||
{ name: 'large.md', buffer: Buffer.from(Array.from({ length: 4000 }, (_, index) => `${index % 50 === 0 ? '## Section\n' : ''}${sentence(60)}.`).join('\n')) }
|
||||
]
|
||||
const client = new DocumentParseWorkerClient(workerBundle)
|
||||
const results = []
|
||||
try {
|
||||
for (const input of inputs) {
|
||||
for (let index = 0; index < rounds; index += 1) {
|
||||
// Alternate inline / worker within each round (P7).
|
||||
const inline = await measure(input.name, input.buffer, undefined)
|
||||
const worker = await measure(input.name, input.buffer, client)
|
||||
if (worker.chunks !== inline.chunks) throw new Error(`chunk count mismatch for ${input.name}`)
|
||||
results.push({ input: input.name, bytes: input.buffer.byteLength, round: index + 1, inline, worker })
|
||||
console.log(`[docperf] ${input.name} round ${index + 1}: Main loop max ${inline.maxMs} -> ${worker.maxMs} ms; wall ${inline.wallMs} / ${worker.wallMs} ms; ${inline.chunks} chunks`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
const output = process.env.GB_DOCPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-document-parse-benchmark', node: process.version, results }, null, 2))
|
||||
console.table(results.map(r => ({ input: r.input, round: r.round, 'loop max inline/worker ms': `${r.inline.maxMs} / ${r.worker.maxMs}`, 'wall inline/worker ms': `${r.inline.wallMs} / ${r.worker.wallMs}` })))
|
||||
}
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
// Owner-local knowledge/read-worker microbenchmark (PERF-05), not a Main IPC probe.
|
||||
// Bundles the production
|
||||
// KnowledgeDatabase with esbuild, seeds synthetic libraries of increasing size
|
||||
// in a temporary SQLite file and times the synchronous search calls that the
|
||||
// storage owner runs during retrieval. Synchronous duration measures this harness's
|
||||
// event loop, not the Electron Main process of the application.
|
||||
const { buildSync } = require('esbuild')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { mkdtempSync, rmSync, writeFileSync, mkdirSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { monitorEventLoopDelay, performance } = require('node:perf_hooks')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
|
||||
// Measure on the project's Electron (the production runtime). Older runtimes
|
||||
// (Node 24.18 / Electron 43) stall worker threads for hundreds of ms on many
|
||||
// small ArrayBuffer allocations (SQLite BLOB rows); GB_KPERF_NODE=1 opts out.
|
||||
if (process.env.GB_KPERF_NODE !== '1') {
|
||||
let electron
|
||||
let version
|
||||
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(status ?? 1)
|
||||
}
|
||||
}
|
||||
const sizes = (process.env.GB_KPERF_SIZES || '5000,20000,50000').split(',').map(Number)
|
||||
const dimensions = Number(process.env.GB_KPERF_DIMENSIONS || 384)
|
||||
const chunksPerDocument = 100
|
||||
const queries = Number(process.env.GB_KPERF_QUERIES || 15)
|
||||
|
||||
const temporaryRoot = join(root, 'temp', 'goodbuddy-storage-foundation')
|
||||
mkdirSync(temporaryRoot, { recursive: true })
|
||||
const directory = mkdtempSync(join(temporaryRoot, 'knowledge-perf-'))
|
||||
const bundle = join(directory, 'knowledge-database.cjs')
|
||||
const workerBundle = join(directory, 'readonly-query-worker.cjs')
|
||||
const readerBundle = join(directory, 'readonly-query-reader.cjs')
|
||||
for (const [entry, outfile] of [
|
||||
['src/main/knowledge/knowledge-database.ts', bundle],
|
||||
['src/main/readonly-query-worker.ts', workerBundle],
|
||||
['src/main/readonly-query-reader.ts', readerBundle]
|
||||
]) {
|
||||
buildSync({
|
||||
entryPoints: [join(root, entry)],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node22',
|
||||
outfile,
|
||||
logLevel: 'error',
|
||||
// The worker reuses AssistantDatabase, which imports Electron-only modules lazily.
|
||||
external: ['electron']
|
||||
})
|
||||
}
|
||||
const { KnowledgeDatabase } = require(bundle)
|
||||
const { ReadonlyQueryReader } = require(readerBundle)
|
||||
const ownedDatabases = new Set(), readers = new Set()
|
||||
|
||||
// Deterministic pseudo-random generator so runs are comparable.
|
||||
let seed = 42
|
||||
const random = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296)
|
||||
const words = 'lighthouse harbor policy engine renderer stream vector index budget latency schedule archive network storage contract invoice release window memory review'.split(' ')
|
||||
const sentence = n => Array.from({ length: n }, () => words[Math.floor(random() * words.length)]).join(' ')
|
||||
const vector = () => Array.from({ length: dimensions }, () => random() * 2 - 1)
|
||||
const checksum = value => createHash('sha256').update(value).digest('hex')
|
||||
|
||||
function percentile(values, p) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||
}
|
||||
function time(body) {
|
||||
const samples = []
|
||||
body() // warm statement cache and page cache
|
||||
for (let index = 0; index < queries; index += 1) {
|
||||
const started = performance.now()
|
||||
body()
|
||||
samples.push(performance.now() - started)
|
||||
}
|
||||
return { p50: round(percentile(samples, 50)), p95: round(percentile(samples, 95)), max: round(Math.max(...samples)) }
|
||||
}
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
|
||||
// Harness event-loop delay while `queries` searches run back to back. A sync
|
||||
// search blocks the loop for its whole duration; a worker search should not.
|
||||
async function loopDelay(body) {
|
||||
const histogram = monitorEventLoopDelay({ resolution: 5 })
|
||||
await body() // warm worker / caches outside the measurement
|
||||
// Let the warm-up's blocked interval drain before sampling.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
histogram.enable()
|
||||
const started = performance.now()
|
||||
for (let index = 0; index < queries; index += 1) {
|
||||
await body()
|
||||
// Yield like a real IPC handler, so the histogram samples between queries.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
const wallMs = performance.now() - started
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
histogram.disable()
|
||||
const ms = value => round(value / 1e6)
|
||||
return { p50: ms(histogram.percentile(50)), p99: ms(histogram.percentile(99)), max: ms(histogram.max), perQueryMs: round(wallMs / queries) }
|
||||
}
|
||||
|
||||
const results = []
|
||||
async function main() {
|
||||
console.log(`[kperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||
for (const size of sizes) {
|
||||
const path = join(directory, `knowledge-${size}.sqlite`)
|
||||
const database = new KnowledgeDatabase(path)
|
||||
ownedDatabases.add(database)
|
||||
database.initialize()
|
||||
const library = database.createKnowledgeBase({ name: `Perf ${size}`, storageMode: 'reference' })
|
||||
const sqlite = database.database
|
||||
const seedStarted = performance.now()
|
||||
for (let documentIndex = 0; documentIndex < size / chunksPerDocument; documentIndex += 1) {
|
||||
const source = database.upsertSource({ knowledgeBaseId: library.id, type: 'file', location: `C:\\perf\\doc-${documentIndex}.md`, displayName: `doc-${documentIndex}.md`, status: 'ready' })
|
||||
const chunks = Array.from({ length: chunksPerDocument }, (_, ordinal) => ({ id: `d${documentIndex}-c${ordinal}`, ordinal, content: sentence(80), location: `line ${ordinal + 1}` }))
|
||||
const document = database.upsertDocument({ knowledgeBaseId: library.id, sourceId: source.id, externalId: `doc-${documentIndex}`, title: `doc-${documentIndex}`, sourceLocation: source.location }, chunks)
|
||||
const stored = sqlite.prepare("SELECT id, index_content FROM chunks WHERE document_id = ? AND enabled = 1 AND role <> 'parent'").all(document.id)
|
||||
database.replaceDocumentEmbeddings(document.id, 'perf', 'perf-model', stored.map(row => ({ chunkId: row.id, contentChecksum: checksum(row.index_content), vector: vector() })))
|
||||
}
|
||||
const seedMs = performance.now() - seedStarted
|
||||
database.close()
|
||||
|
||||
// Reopen so timings start from a fresh connection, like an app restart.
|
||||
const reopened = new KnowledgeDatabase(path)
|
||||
ownedDatabases.add(reopened)
|
||||
reopened.initialize()
|
||||
const query = vector()
|
||||
const entry = {
|
||||
chunks: size,
|
||||
dimensions,
|
||||
seedSeconds: round(seedMs / 1000),
|
||||
ftsMs: time(() => reopened.search({ knowledgeBaseId: library.id, query: 'lighthouse budget', limit: 40 })),
|
||||
vectorMs: time(() => reopened.vectorSearch({ knowledgeBaseId: library.id, provider: 'perf', model: 'perf-model', vector: query, limit: 40 })),
|
||||
hybridMs: time(() => reopened.hybridSearchWithDiagnostics(hybridOptions(library.id, query)))
|
||||
}
|
||||
// Harness loop delay: owner-local synchronous search vs its explicit read worker.
|
||||
entry.mainLoopDelaySyncMs = await loopDelay(async () => reopened.hybridSearchWithDiagnostics(hybridOptions(library.id, query)))
|
||||
const reader = new ReadonlyQueryReader('knowledge', path, workerBundle)
|
||||
readers.add(reader)
|
||||
entry.mainLoopDelayWorkerMs = await loopDelay(() => reader.call('hybridSearchWithDiagnostics', [hybridOptions(library.id, query)]))
|
||||
await reader.close()
|
||||
reopened.close()
|
||||
results.push(entry)
|
||||
console.log(`[kperf] ${size} chunks: fts p95 ${entry.ftsMs.p95} ms, vector p95 ${entry.vectorMs.p95} ms, hybrid p95 ${entry.hybridMs.p95} ms (seed ${entry.seedSeconds} s)`)
|
||||
console.log(`[kperf] ${size} chunks: Main loop delay max sync ${entry.mainLoopDelaySyncMs.max} ms vs worker ${entry.mainLoopDelayWorkerMs.max} ms (p99 ${entry.mainLoopDelaySyncMs.p99} / ${entry.mainLoopDelayWorkerMs.p99} ms; per query ${entry.mainLoopDelaySyncMs.perQueryMs} / ${entry.mainLoopDelayWorkerMs.perQueryMs} ms)`)
|
||||
}
|
||||
const output = process.env.GB_KPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-knowledge-retrieval-benchmark', executionBoundary: 'owner-local', node: process.version, results }, null, 2))
|
||||
console.table(results.map(r => ({ chunks: r.chunks, 'fts p50/p95': `${r.ftsMs.p50} / ${r.ftsMs.p95}`, 'vector p50/p95': `${r.vectorMs.p50} / ${r.vectorMs.p95}`, 'hybrid p50/p95': `${r.hybridMs.p50} / ${r.hybridMs.p95}`, 'loop max sync/worker': `${r.mainLoopDelaySyncMs.max} / ${r.mainLoopDelayWorkerMs.max}` })))
|
||||
}
|
||||
const hybridOptions = (knowledgeBaseId, vector) => ({ knowledgeBaseId, query: 'lighthouse budget', limit: 40, provider: 'perf', model: 'perf-model', vector, graphEnabled: false, candidateMultiplier: 1 })
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(async () => {
|
||||
await Promise.all([...readers].map(reader => reader.close()))
|
||||
for (const database of ownedDatabases) database.close()
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
// Owner-local database/read-worker microbenchmark (PERF-15). Not a Main IPC latency probe.
|
||||
// Bundles the production
|
||||
// AssistantDatabase and readonly worker with esbuild, seeds a temporary
|
||||
// assistant database (default 1,000 conversations / 20,000 messages and 24,000
|
||||
// activity records) and measures, for the IPC paths the renderer hits often:
|
||||
// - per-call time
|
||||
// - harness event-loop delay while the calls run back to back
|
||||
// for owner-local SQL ("sync") and explicit read-worker dispatch ("after").
|
||||
// conversations:list-summaries sync listConversationSummaries vs worker
|
||||
// conversations:get sync getConversation vs worker
|
||||
// activity-history:replace legacy replace (zod parse + BEGIN IMMEDIATE +
|
||||
// JSON.stringify of every record) vs current
|
||||
// activity-history:update one changed record via the incremental API
|
||||
// activity startup load whole list (pre-paging get) vs first page (200)
|
||||
// + summary on the readonly worker
|
||||
// activity page / summary sync paths, for reference
|
||||
// Env: GB_MPERF_CONVERSATIONS, GB_MPERF_MESSAGES, GB_MPERF_ACTIVITY,
|
||||
// GB_MPERF_CALLS, GB_MPERF_OUTPUT (JSON), GB_MPERF_NODE=1 (skip Electron).
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdtempSync, rmSync, writeFileSync, mkdirSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { monitorEventLoopDelay, performance } = require('node:perf_hooks')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
|
||||
if (process.env.GB_MPERF_NODE !== '1') {
|
||||
let electron
|
||||
let version
|
||||
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const conversationCount = Number(process.env.GB_MPERF_CONVERSATIONS || 1000)
|
||||
const messageCount = Number(process.env.GB_MPERF_MESSAGES || 20000)
|
||||
const activityCount = Number(process.env.GB_MPERF_ACTIVITY || 24000)
|
||||
const calls = Number(process.env.GB_MPERF_CALLS || 15)
|
||||
|
||||
const temporaryRoot = join(root, 'temp', 'goodbuddy-storage-foundation')
|
||||
mkdirSync(temporaryRoot, { recursive: true })
|
||||
const directory = mkdtempSync(join(temporaryRoot, 'main-perf-'))
|
||||
const bundle = join(directory, 'assistant-database.cjs')
|
||||
const workerBundle = join(directory, 'readonly-query-worker.cjs')
|
||||
const schemaBundle = join(directory, 'assistant-contracts.cjs')
|
||||
const readerBundle = join(directory, 'readonly-query-reader.cjs')
|
||||
for (const [entry, outfile] of [
|
||||
['src/main/assistant/assistant-database.ts', bundle],
|
||||
['src/main/readonly-query-worker.ts', workerBundle],
|
||||
['src/main/readonly-query-reader.ts', readerBundle],
|
||||
['src/shared/assistant-contracts.ts', schemaBundle]
|
||||
]) {
|
||||
buildSync({
|
||||
entryPoints: [join(root, entry)], bundle: true, platform: 'node', format: 'cjs',
|
||||
target: 'node22', outfile, logLevel: 'error', external: ['electron']
|
||||
})
|
||||
}
|
||||
const { AssistantDatabase } = require(bundle)
|
||||
const { ReadonlyQueryReader } = require(readerBundle)
|
||||
let ownedDatabase, reader
|
||||
const { activityHistorySnapshotSchema } = require(schemaBundle)
|
||||
|
||||
let seed = 42
|
||||
const random = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296)
|
||||
const words = 'lighthouse harbor policy engine renderer stream vector index budget latency schedule archive network storage contract invoice release window memory review 发布 审批 流程'.split(' ')
|
||||
const sentence = n => Array.from({ length: n }, () => words[Math.floor(random() * words.length)]).join(' ')
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
const percentile = (values, p) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `body` `calls` times (after one warm-up) and reports the per-call wall
|
||||
* time and the harness event-loop delay histogram (p99/max) over the run.
|
||||
*/
|
||||
async function measure(body) {
|
||||
await body(-1)
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
// monitorEventLoopDelay is bound to the ~15 ms Windows timer granularity, so
|
||||
// the longest block is measured as the largest gap between setImmediate
|
||||
// ticks (each tick is one event-loop turn).
|
||||
const histogram = monitorEventLoopDelay({ resolution: 1 })
|
||||
histogram.enable()
|
||||
let probing = true
|
||||
let last = performance.now()
|
||||
let maxGap = 0
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
maxGap = Math.max(maxGap, now - last)
|
||||
last = now
|
||||
if (probing) setImmediate(tick)
|
||||
}
|
||||
setImmediate(tick)
|
||||
const samples = []
|
||||
for (let index = 0; index < calls; index += 1) {
|
||||
const started = performance.now()
|
||||
await body(index)
|
||||
samples.push(performance.now() - started)
|
||||
// Yield like the IPC loop does between messages.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
probing = false
|
||||
histogram.disable()
|
||||
return {
|
||||
callP50: round(percentile(samples, 50)), callP95: round(percentile(samples, 95)),
|
||||
loopP99: round(histogram.percentile(99) / 1e6), loopMax: round(maxGap)
|
||||
}
|
||||
}
|
||||
|
||||
/** The pre-PERF-15 replace implementation, kept as the "before" reference. */
|
||||
function legacyReplace(raw, cache, input) {
|
||||
const snapshot = activityHistorySnapshotSchema.parse(input)
|
||||
raw.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const { data_version: dataVersion } = raw.prepare('PRAGMA data_version').get()
|
||||
let previous = cache.value?.dataVersion === dataVersion ? cache.value.records : undefined
|
||||
if (!previous) {
|
||||
previous = new Map(raw.prepare('SELECT record_key, record_json FROM activity_history_records').all()
|
||||
.map(row => [row.record_key, row.record_json]))
|
||||
}
|
||||
const records = new Map()
|
||||
const occurrences = new Map()
|
||||
const order = []
|
||||
// order_seq only satisfies the current schema; the old code kept order in the JSON list below.
|
||||
const upsert = raw.prepare(`INSERT INTO activity_history_records (record_key, record_json, order_seq) VALUES (?, ?, ?)
|
||||
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json`)
|
||||
for (const record of snapshot.records) {
|
||||
const occurrence = occurrences.get(record.id) ?? 0
|
||||
occurrences.set(record.id, occurrence + 1)
|
||||
const key = JSON.stringify([record.id, occurrence])
|
||||
const json = JSON.stringify(record)
|
||||
order.push(key)
|
||||
if (previous.get(key) !== json) upsert.run(key, json, snapshot.records.length - order.length + 1)
|
||||
records.set(key, json)
|
||||
}
|
||||
const remove = raw.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
||||
for (const key of previous.keys()) if (!records.has(key)) remove.run(key)
|
||||
const orderJson = JSON.stringify(order)
|
||||
const incomplete = Number(snapshot.legacyHistoryMayBeIncomplete)
|
||||
raw.prepare(`UPDATE activity_history SET record_order_json = ?, legacy_history_may_be_incomplete = ?
|
||||
WHERE singleton = 1 AND (record_order_json != ? OR legacy_history_may_be_incomplete != ?)`)
|
||||
.run(orderJson, incomplete, orderJson, incomplete)
|
||||
raw.exec('COMMIT')
|
||||
cache.value = { records, dataVersion }
|
||||
} catch (error) {
|
||||
raw.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[mperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||
const workspace = join(directory, 'workspace')
|
||||
mkdirSync(workspace)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const seeding = new AssistantDatabase(path)
|
||||
ownedDatabase = seeding
|
||||
seeding.initialize(workspace)
|
||||
const seedStarted = performance.now()
|
||||
const projectId = seeding.listProjects()[0].id
|
||||
// Message counts follow a skewed distribution; the first conversation is the largest.
|
||||
const weights = Array.from({ length: conversationCount }, (_, index) => 1 / (1 + index * 0.01))
|
||||
const weightSum = weights.reduce((sum, value) => sum + value, 0)
|
||||
const counts = weights.map(weight => Math.max(1, Math.round(messageCount * weight / weightSum)))
|
||||
const ids = counts.map(() => randomUUID())
|
||||
for (let start = 0; start < conversationCount; start += 50) {
|
||||
seeding.saveLocalConversations(ids.slice(start, start + 50).map((id, offset) => {
|
||||
const index = start + offset
|
||||
return {
|
||||
header: { id, projectId, title: `Conversation ${index} ${sentence(3)}`, updatedAt: 1_700_000_000_000 + index },
|
||||
messages: Array.from({ length: counts[index] }, (_, at) => ({
|
||||
id: randomUUID(), role: at % 2 === 0 ? 'user' : 'assistant', state: 'complete',
|
||||
content: sentence(at % 2 === 0 ? 30 : 120), createdAt: 1_700_000_000_000 + index * 1000 + at
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
const records = Array.from({ length: activityCount }, (_, index) => ({
|
||||
id: randomUUID(), conversationId: ids[index % conversationCount], requestId: randomUUID(),
|
||||
...(index % 2 === 0 ? { callId: `call-${index}` } : {}),
|
||||
scope: index % 3 === 0 ? { kind: 'project', projectId, projectName: 'Perf' } : { kind: 'global' },
|
||||
kind: index % 5 === 0 ? 'request' : 'tool', title: `Tool ${index}`, detail: sentence(40),
|
||||
status: 'completed', createdAt: 1_700_000_000_000 + index
|
||||
}))
|
||||
seeding.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: false })
|
||||
const seedSeconds = round((performance.now() - seedStarted) / 1000)
|
||||
seeding.close()
|
||||
|
||||
const database = new AssistantDatabase(path)
|
||||
ownedDatabase = database
|
||||
database.initialize(workspace)
|
||||
const raw = database.database
|
||||
const totalMessages = counts.reduce((sum, value) => sum + value, 0)
|
||||
const largest = ids[0]
|
||||
const typical = ids[Math.floor(conversationCount / 2)]
|
||||
// The renderer retains the current conversation's history.
|
||||
const detailIds = [ids[1]]
|
||||
const results = { conversations: conversationCount, messages: totalMessages, activityRecords: activityCount,
|
||||
largestConversationMessages: counts[0], seedSeconds }
|
||||
|
||||
results.listSummariesSync = await measure(async () => database.listConversationSummaries(detailIds))
|
||||
results.getLargestSync = await measure(async () => database.getConversation(largest))
|
||||
results.getTypicalSync = await measure(async () => database.getConversation(typical))
|
||||
reader = new ReadonlyQueryReader('assistant', path, workerBundle)
|
||||
results.listSummariesWorker = await measure(() => reader.call('listConversationSummaries', [detailIds]))
|
||||
results.getLargestWorker = await measure(() => reader.call('getConversation', [largest]))
|
||||
results.getTypicalWorker = await measure(() => reader.call('getConversation', [typical]))
|
||||
|
||||
// The renderer sends a fresh structured clone of the whole list every save.
|
||||
const unchanged = () => structuredClone({ records, legacyHistoryMayBeIncomplete: false })
|
||||
const oneChanged = index => {
|
||||
const snapshot = unchanged()
|
||||
const record = snapshot.records[(index + 2) % 50]
|
||||
record.detail = `${record.detail} ${index}`
|
||||
return snapshot
|
||||
}
|
||||
// Inputs are cloned up front: cloning happens in the renderer / IPC layer.
|
||||
const run = async (makeInput, call) => {
|
||||
const inputs = Array.from({ length: calls + 1 }, (_, index) => makeInput(index - 1))
|
||||
return measure(async index => call(inputs[index + 1]))
|
||||
}
|
||||
const legacyCache = {}
|
||||
results.replaceUnchangedLegacy = await run(unchanged, input => legacyReplace(raw, legacyCache, input))
|
||||
results.replaceOneChangedLegacy = await run(oneChanged, input => legacyReplace(raw, legacyCache, input))
|
||||
// Restore a consistent state for the current implementation's cache.
|
||||
database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: false })
|
||||
results.replaceUnchangedAfter = await run(unchanged, input => database.replaceActivityHistory(input))
|
||||
results.replaceOneChangedAfter = await run(oneChanged, input => database.replaceActivityHistory(input))
|
||||
results.updateOneRecord = await run(index => {
|
||||
const record = { ...records[(index + 2) % 50], detail: `incremental ${index}`, status: 'running' }
|
||||
return { changes: [{ type: 'upsert', position: 'front', record }] }
|
||||
}, input => database.updateActivityHistory(input))
|
||||
|
||||
// Startup: the renderer used to load the whole list; now the first page and the summary.
|
||||
const firstPageRequest = { limit: 200 }
|
||||
const summaryRequest = () => ({ conversationIds: [...new Set(database.getActivityHistoryPage(firstPageRequest).records.map(record => record.conversationId))] })
|
||||
results.activityFullLoad = await measure(async () => database.getActivityHistory())
|
||||
results.activityFirstPageSync = await measure(async () => database.getActivityHistoryPage(firstPageRequest))
|
||||
results.activitySummarySync = await measure(async () => database.getActivityHistorySummary(summaryRequest()))
|
||||
results.activityStartupWorker = await measure(async () => {
|
||||
const page = await reader.call('activityHistoryPage', [firstPageRequest])
|
||||
await reader.call('activityHistorySummary', [{ conversationIds: [...new Set(page.records.map(record => record.conversationId))] }])
|
||||
})
|
||||
results.activityFullLoadBytes = JSON.stringify(database.getActivityHistory()).length
|
||||
results.activityFirstPageBytes = JSON.stringify(database.getActivityHistoryPage(firstPageRequest)).length
|
||||
await reader.close()
|
||||
database.close()
|
||||
|
||||
console.log(`[mperf] seeded ${results.conversations} conversations / ${results.messages} messages (largest ${results.largestConversationMessages}), ${results.activityRecords} activity records in ${seedSeconds} s`)
|
||||
const rows = [
|
||||
['conversations:list-summaries', results.listSummariesSync, results.listSummariesWorker],
|
||||
[`conversations:get (${counts[0]} msgs)`, results.getLargestSync, results.getLargestWorker],
|
||||
[`conversations:get (${counts[Math.floor(conversationCount / 2)]} msgs)`, results.getTypicalSync, results.getTypicalWorker],
|
||||
['activity replace, unchanged', results.replaceUnchangedLegacy, results.replaceUnchangedAfter],
|
||||
['activity replace, 1 changed', results.replaceOneChangedLegacy, results.replaceOneChangedAfter],
|
||||
['activity update, 1 record', results.replaceOneChangedLegacy, results.updateOneRecord],
|
||||
['activity startup: full list vs page+summary (worker)', results.activityFullLoad, results.activityStartupWorker],
|
||||
['activity first page (200), sync', results.activityFullLoad, results.activityFirstPageSync],
|
||||
['activity summary, sync', results.activityFullLoad, results.activitySummarySync]
|
||||
]
|
||||
console.table(rows.map(([name, before, after]) => ({
|
||||
path: name,
|
||||
'before call p50/p95 ms': `${before.callP50} / ${before.callP95}`,
|
||||
'before loop max ms': before.loopMax,
|
||||
'after call p50/p95 ms': `${after.callP50} / ${after.callP95}`,
|
||||
'after loop max ms': after.loopMax
|
||||
})))
|
||||
console.log(`[mperf] activity payload: full list ${results.activityFullLoadBytes} bytes, first page ${results.activityFirstPageBytes} bytes`)
|
||||
const output = process.env.GB_MPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-database-benchmark', executionBoundary: 'owner-local', node: process.version, results }, null, 2))
|
||||
}
|
||||
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(async () => {
|
||||
await reader?.close()
|
||||
ownedDatabase?.close()
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
const { dirname, join, resolve } = require('node:path')
|
||||
const { copyFileSync, mkdtempSync, mkdirSync, rmSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
if (!process.versions.electron || process.env.ELECTRON_RUN_AS_NODE === '1') {
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-obsidian-ui-'))
|
||||
const env = { ...process.env, GOODBUDDY_OBSIDIAN_ACCEPTANCE_ROOT: root }
|
||||
if (process.argv.includes('--live') || process.argv.includes('--chat')) {
|
||||
env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_LIVE = process.argv.includes('--live') ? '1' : '0'
|
||||
env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CHAT = process.argv.includes('--chat') ? '1' : '0'
|
||||
env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CONTINUE_ONLY = process.argv.includes('--continue-only') ? '1' : '0'
|
||||
require('esbuild').buildSync({ entryPoints: ['tests/obsidian-runtime-acceptance.ts'],
|
||||
outfile: join(root, 'runtime.cjs'), bundle: true, external: ['electron', 'ssh2', 'node-pty', 'koffi'], platform: 'node', format: 'cjs',
|
||||
banner: { js: `require = require('node:module').createRequire(${JSON.stringify(resolve('package.json'))});` } })
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
try {
|
||||
const result = spawnSync(require('electron'), [__filename], {
|
||||
env, stdio: 'inherit', timeout: 300_000
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
process.exitCode = result.status ?? 1
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })
|
||||
}
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const root = process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_ROOT
|
||||
assert(root)
|
||||
const profile = join(root, 'profile')
|
||||
mkdirSync(profile)
|
||||
if (process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_LIVE === '1' || process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CHAT === '1') {
|
||||
const settings = process.env.GOODBUDDY_ACCEPTANCE_SETTINGS ?? join(app.getPath('appData'), 'goodbuddy', 'runtime-settings.json')
|
||||
copyFileSync(join(dirname(settings), 'Local State'), join(profile, 'Local State'))
|
||||
}
|
||||
mkdirSync(join(root, 'Temporary Vault'))
|
||||
app.setPath('userData', profile)
|
||||
app.setAppPath(resolve('.'))
|
||||
const timer = setTimeout(() => { console.error('Acceptance timed out'); app.exit(1) }, 280_000)
|
||||
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
;(async () => {
|
||||
const chat = process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CHAT === '1'
|
||||
? await require(join(root, 'runtime.cjs')).prepareChat(root, profile) : undefined
|
||||
await import(pathToFileURL(resolve('out/main/index.js')).href)
|
||||
let window
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
window = BrowserWindow.getAllWindows().find(item => item.webContents.getURL().includes('index.html'))
|
||||
if (window && await window.webContents.executeJavaScript('Boolean(document.querySelector("button"))')) break
|
||||
await wait(250)
|
||||
}
|
||||
assert(window, 'Production main must open its BrowserWindow')
|
||||
assert.equal(app.getPath('userData'), profile)
|
||||
const preferences = window.webContents.getLastWebPreferences()
|
||||
assert.equal(preferences.contextIsolation, true)
|
||||
assert.equal(preferences.nodeIntegration, false)
|
||||
const js = code => window.webContents.executeJavaScript(code).catch(error => { throw new Error(`${code}: ${error.message}`) })
|
||||
const click = async expression => {
|
||||
window.focus()
|
||||
for (let i=0;i<100;i++) { if (await js(`Boolean(${expression})`)) break; await wait(100) }
|
||||
const point = await js(`(() => { const element = ${expression}; if (!element) throw new Error('Missing control'); element.scrollIntoView({block:'center'}); const r=element.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)} })()`)
|
||||
window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point })
|
||||
window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point })
|
||||
await wait(500)
|
||||
}
|
||||
if (chat) {
|
||||
await js(`(async()=>{const p=(await window.goodbuddy.projects.list())[0];await window.goodbuddy.projects.update(p.id,{name:p.name,description:p.description,rootPath:${JSON.stringify(join(root,'chat-workspace'))},runtimeSelection:{provider:'opencode'}})})()`)
|
||||
window.webContents.reload()
|
||||
await wait(1000)
|
||||
}
|
||||
await click(`document.querySelector('button[aria-label="\u8bbe\u7f6e"]')`)
|
||||
await click(`Array.from(document.querySelectorAll('button')).find(b=>b.innerText.startsWith('\u80fd\u529b\u4e0e\u5de5\u5177'))`)
|
||||
await click(`Array.from(document.querySelectorAll('[role="tab"]')).find(b=>b.innerText==='MCP' && b.getClientRects().length)`)
|
||||
const card = `Array.from(document.querySelectorAll('.capability-card')).find(c=>c.querySelector('strong')?.textContent==='Obsidian')`
|
||||
const poll = async (expression, attempts=100) => {
|
||||
for (let i=0;i<attempts;i++) { if (await js(expression)) return; await wait(200) }
|
||||
console.error('VISIBLE_TABS',await js(`JSON.stringify(Array.from(document.querySelectorAll('[role="tab"]')).map(e=>({text:e.innerText,selected:e.getAttribute('aria-selected')})))`))
|
||||
throw new Error(`Timed out: ${expression}`)
|
||||
}
|
||||
await poll(`Boolean(${card})`)
|
||||
assert.equal(await js(`(${card}).querySelector('[role="switch"]').checked`), false)
|
||||
await click(`(${card}).querySelector('select')`)
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'End' })
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Return' })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Return' })
|
||||
await poll(`Boolean((${card}).querySelector('input:not([type="checkbox"])'))`)
|
||||
const input = `(${card}).querySelector('input:not([type="checkbox"])')`
|
||||
const vault = join(root, 'Temporary Vault')
|
||||
await click(input)
|
||||
await window.webContents.insertText(vault)
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`)
|
||||
await poll(`Boolean((${card}).querySelector('[role="status"]'))`)
|
||||
assert.match(await js(`(${card}).querySelector('[role="status"]').textContent`), /18/)
|
||||
assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath, '')
|
||||
assert.equal(await js(`(${card}).querySelector('[role="switch"]').checked`), false)
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[2]`)
|
||||
await poll(`!(${card}).querySelector('.primary-button').disabled`)
|
||||
assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath, vault)
|
||||
await click(input)
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: ['control'] })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'A', modifiers: ['control'] })
|
||||
await window.webContents.insertText(join(root, 'missing-vault'))
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`)
|
||||
await poll(`Boolean((${card}).querySelector('[role="alert"]'))`)
|
||||
assert.equal(await js(`(${input}).value`), join(root, 'missing-vault'))
|
||||
assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath, vault)
|
||||
console.log('PASS: native UI draft test (18 tools), no implicit save/enable, save through IPC, missing-folder error preserves draft')
|
||||
if (process.platform === 'win32') {
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[0]`)
|
||||
const user32 = require('koffi').load('user32.dll')
|
||||
const getWindow = user32.func('uintptr_t __stdcall GetWindow(uintptr_t hwnd, uint32_t command)')
|
||||
const getPid = user32.func('uint32_t __stdcall GetWindowThreadProcessId(uintptr_t hwnd, _Out_ uint32_t *pid)')
|
||||
const postMessage = user32.func('bool __stdcall PostMessageW(uintptr_t hwnd, uint32_t message, uintptr_t wparam, intptr_t lparam)')
|
||||
const handle = Number(window.getNativeWindowHandle().readBigUInt64LE())
|
||||
const popup = getWindow(handle, 6)
|
||||
assert(popup && Number(popup) !== handle, 'Native folder dialog must open')
|
||||
const pid = [0]
|
||||
getPid(popup, pid)
|
||||
assert.equal(pid[0], process.pid, 'Only close this acceptance process dialog')
|
||||
assert(postMessage(popup, 0x10, 0, 0))
|
||||
await poll(`!(${card}).querySelector('.primary-button').disabled`)
|
||||
assert.equal(await js(`(${input}).value`), join(root, 'missing-vault'))
|
||||
console.log('PASS: real Windows folder dialog opened and cancelled without changing draft')
|
||||
}
|
||||
window.setMinimumSize(640, 480)
|
||||
window.setSize(760, 720)
|
||||
await wait(500)
|
||||
const geometry = await js(`(() => {const c=${card};const r=c.getBoundingClientRect();return {width:innerWidth,left:r.left,right:r.right,scroll:c.scrollWidth,client:c.clientWidth}})()`)
|
||||
assert(geometry.scroll <= geometry.client + 1 && geometry.right <= geometry.width && geometry.left >= 0, JSON.stringify(geometry))
|
||||
console.log('PASS: narrow card has no horizontal overflow', JSON.stringify(geometry))
|
||||
await click(input)
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: ['control'] })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'A', modifiers: ['control'] })
|
||||
await window.webContents.insertText(vault)
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`)
|
||||
await poll(`Boolean((${card}).querySelector('[role="status"]'))`)
|
||||
await js(`(${card}).querySelector('[role="switch"]').focus()`)
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Space' })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Space' })
|
||||
await poll(`(${card}).querySelector('[role="switch"]').checked && !(${card}).querySelector('[role="switch"]').disabled`)
|
||||
await click(`(${card}).querySelector('button[aria-expanded]')`)
|
||||
await poll(`(${card}).querySelectorAll('.mcp-server-tools li').length === 19`)
|
||||
console.log('PASS: keyboard switch enables Obsidian; expanded production UI lists 19 tools')
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await js(`document.documentElement.dataset.theme=${JSON.stringify(theme)}`)
|
||||
const bounds = await js(`(()=>{const c=${card};const r=c.getBoundingClientRect();return {right:r.right,width:innerWidth,scroll:c.scrollWidth,client:c.clientWidth,background:getComputedStyle(c).backgroundColor}})()`)
|
||||
assert(bounds.right <= bounds.width && bounds.scroll <= bounds.client + 1)
|
||||
console.log(`PASS: ${theme} narrow Obsidian bounds`, JSON.stringify(bounds))
|
||||
}
|
||||
if (process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_LIVE === '1') {
|
||||
await require(join(root, 'runtime.cjs')).run(root, await js('window.goodbuddy.capabilities.getSnapshot()'))
|
||||
}
|
||||
if (chat) {
|
||||
await click(`document.querySelector('.settings-panel__header button')`)
|
||||
window.setSize(1280, 850)
|
||||
try {
|
||||
const providers = process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CONTINUE_ONLY === '1' ? ['Continue'] : ['OpenCode', 'Continue']
|
||||
for (const provider of providers) {
|
||||
if (provider === 'Continue') {
|
||||
await poll(`!document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').disabled`)
|
||||
await js(`document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').click()`)
|
||||
await click(`Array.from(document.querySelectorAll('.runtime-picker__menu strong')).find(b=>b.innerText==='Continue Runtime')?.nextElementSibling`)
|
||||
}
|
||||
await poll(`!document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').disabled`)
|
||||
const mode = `document.querySelector('button[aria-label^="\u5de5\u4f5c\u6a21\u5f0f"]')`
|
||||
if (!(await js(`(${mode}).innerText.includes('Execute')`))) {
|
||||
await click(mode)
|
||||
await click(`Array.from(document.querySelectorAll('[role="menuitemradio"]')).find(b=>b.innerText.startsWith('Execute'))`)
|
||||
}
|
||||
const marker = `CHAT_${provider}_${require('node:crypto').randomUUID()}`
|
||||
const before = chat.observations.length
|
||||
await click(`document.querySelector('textarea')`)
|
||||
await window.webContents.insertText(`Use only Obsidian tools. Write ${provider}-chat.md containing ${marker} using obsidian_write_note, then read it using obsidian_read_note. Reply with the content read. Do not use other tools.`)
|
||||
await click(`document.querySelector('button[aria-label="\u53d1\u9001"]')`)
|
||||
await poll(`Array.from(document.querySelectorAll('.message--assistant')).some(e=>e.innerText.includes(${JSON.stringify(marker)}))`,450)
|
||||
await poll(`!document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').disabled`,450)
|
||||
assert((require('node:fs').readFileSync(join(vault,`${provider}-chat.md`),'utf8')).includes(marker))
|
||||
console.log(JSON.stringify({ provider, fullChatUi: 'passed', realModelHttpRequests: chat.observations.length-before }))
|
||||
}
|
||||
} finally {
|
||||
console.log(JSON.stringify({ chatModelHttpRequests: chat.observations.length }))
|
||||
await chat.close()
|
||||
}
|
||||
}
|
||||
await wait(1000)
|
||||
clearTimeout(timer)
|
||||
app.exit(0)
|
||||
})().catch(error => { console.error(error); clearTimeout(timer); app.exit(1) })
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { spawn } = require('node:child_process')
|
||||
const { mkdtemp, mkdir, readFile, readdir, writeFile, rm, stat } = require('node:fs/promises')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
async function main() {
|
||||
const executable = resolve(process.argv[2])
|
||||
await stat(executable)
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-packaged-'))
|
||||
const profile = join(root, 'profile')
|
||||
const vault = join(root, 'Packaged Vault')
|
||||
await mkdir(profile)
|
||||
await mkdir(vault)
|
||||
const audit = join(root, 'node-audit.jsonl')
|
||||
const guard = join(root, 'offline.cjs')
|
||||
await writeFile(guard, `
|
||||
const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const original = net.Socket.prototype.connect;
|
||||
net.Socket.prototype.connect = function(...args) {
|
||||
const options = net._normalizeArgs(args)[0];
|
||||
const host = options.host || 'localhost';
|
||||
if (!options.path && !['localhost','127.0.0.1','::1'].includes(host)) throw new Error('Offline acceptance blocked network');
|
||||
return original.apply(this,args);
|
||||
};
|
||||
globalThis.fetch = async () => { throw new Error('Offline acceptance blocked fetch'); };
|
||||
require('node:module').syncBuiltinESMExports();
|
||||
fs.appendFileSync(${JSON.stringify(audit)}, JSON.stringify({entry:process.argv[1],exe:process.execPath,electron:process.versions.electron})+'\\n');
|
||||
`)
|
||||
const env = { ...process.env, NODE_OPTIONS: `--require ${JSON.stringify(guard)}`,
|
||||
PATH: join(process.env.SystemRoot, 'System32') }
|
||||
delete env.Path
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(executable, [`--user-data-dir=${profile}`, '--remote-debugging-port=0', '--inspect=0',
|
||||
'--proxy-server=http://127.0.0.1:9', '--proxy-bypass-list=<-loopback>'], { env, stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
let diagnostic = ''
|
||||
child.stderr.on('data', chunk => { diagnostic += chunk.toString() })
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
let socket
|
||||
let mainSocket
|
||||
try {
|
||||
let port
|
||||
for (let i=0;i<100;i++) {
|
||||
try { port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; break } catch { await wait(200) }
|
||||
}
|
||||
assert(port, 'Packaged app must expose debugging only in the isolated profile')
|
||||
const mainEndpoint = diagnostic.match(/ws:\/\/127\.0\.0\.1:\d+\/[-a-f0-9]+/u)?.[0]
|
||||
assert(mainEndpoint, 'The isolated Main inspector must be available')
|
||||
mainSocket = new WebSocket(mainEndpoint)
|
||||
await new Promise((resolve, reject) => { mainSocket.onopen=resolve;mainSocket.onerror=reject })
|
||||
// Packaged Electron strips NODE_OPTIONS at startup. Restore it only for this
|
||||
// disposable Main so its real MCPVault child receives the offline guard.
|
||||
await new Promise((resolve,reject) => {
|
||||
const timer=setTimeout(()=>reject(new Error('Main inspector timed out')),10_000)
|
||||
mainSocket.onmessage=message=>{const value=JSON.parse(message.data);if(value.id===1){clearTimeout(timer);if(value.error || value.result?.exceptionDetails)reject(new Error('Unable to set child network guard'));else resolve()}}
|
||||
mainSocket.send(JSON.stringify({id:1,method:'Runtime.evaluate',params:{expression:`process.env.NODE_OPTIONS=${JSON.stringify(env.NODE_OPTIONS)}`}}))
|
||||
})
|
||||
mainSocket.close()
|
||||
let target
|
||||
for (let i=0;i<100;i++) {
|
||||
target = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).find(item => item.type === 'page' && item.url.includes('index.html'))
|
||||
if (target) break
|
||||
await wait(200)
|
||||
}
|
||||
assert(target, 'Packaged production renderer must load')
|
||||
socket = new WebSocket(target.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject })
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
socket.onmessage = message => { const value = JSON.parse(message.data); if (pending.has(value.id)) { const {resolve,reject}=pending.get(value.id); pending.delete(value.id); if (value.error) reject(new Error(value.error.message)); else resolve(value.result) } }
|
||||
const send = (method, params={}) => new Promise((resolve,reject) => {
|
||||
const key=++id
|
||||
const timer=setTimeout(()=>{pending.delete(key);reject(new Error(`CDP timed out: ${method}`))},10_000)
|
||||
pending.set(key,{
|
||||
resolve: value=>{clearTimeout(timer);resolve(value)},
|
||||
reject: error=>{clearTimeout(timer);reject(error)}
|
||||
})
|
||||
socket.send(JSON.stringify({id:key,method,params}))
|
||||
})
|
||||
const js = async expression => { const result=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});assert(!result.exceptionDetails,JSON.stringify(result.exceptionDetails));return result.result.value }
|
||||
const poll = async expression => { for(let i=0;i<100;i++){ if(await js(expression))return;await wait(200) } throw new Error(`Timeout: ${expression}`) }
|
||||
const click = async expression => {
|
||||
await js(`(()=>{const e=${expression};if(!e)throw new Error('Missing control');e.scrollIntoView({block:'center'});e.focus();e.click()})()`)
|
||||
await wait(500)
|
||||
}
|
||||
await poll('Boolean(window.goodbuddy && document.querySelector("button"))')
|
||||
await stat(join(profile,'assistant.sqlite'))
|
||||
assert.equal(await js('typeof require'), 'undefined')
|
||||
await click(`document.querySelector('button[aria-label="\u8bbe\u7f6e"]')`)
|
||||
await poll(`Array.from(document.querySelectorAll('button')).some(b=>b.innerText.startsWith('\u80fd\u529b\u4e0e\u5de5\u5177'))`)
|
||||
await click(`Array.from(document.querySelectorAll('button')).find(b=>b.innerText.startsWith('\u80fd\u529b\u4e0e\u5de5\u5177'))`)
|
||||
await click(`Array.from(document.querySelectorAll('[role="tab"]')).find(b=>b.innerText==='MCP' && b.getClientRects().length)`)
|
||||
const card=`Array.from(document.querySelectorAll('.capability-card')).find(c=>c.querySelector('strong')?.textContent==='Obsidian')`
|
||||
await poll(`Boolean(${card})`)
|
||||
await js(`{const e=(${card}).querySelector('select');e.value='folder';e.dispatchEvent(new Event('change',{bubbles:true}));}`)
|
||||
await poll(`Boolean((${card}).querySelector('input:not([type="checkbox"])'))`)
|
||||
await click(`(${card}).querySelector('input:not([type="checkbox"])')`)
|
||||
await send('Input.insertText',{text:vault})
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`)
|
||||
await poll(`Boolean((${card}).querySelector('[role="status"]'))`)
|
||||
assert.match(await js(`(${card}).querySelector('[role="status"]').textContent`),/18/)
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[2]`)
|
||||
await poll(`!(${card}).querySelector('.primary-button').disabled`)
|
||||
assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath,vault)
|
||||
const bin=join(profile,'tool-environment','bin')
|
||||
const shims=(await readdir(bin,{recursive:true})).filter(name=>name.endsWith('node.cmd'))
|
||||
assert(shims.length>0)
|
||||
assert((await readFile(join(bin,shims[0]),'utf8')).includes(executable))
|
||||
console.log('PASS: packaged app.asar UI -> real IPC -> packaged MCPVault (18 tools), save, managed packaged Electron shim, PATH=System32, Chromium dead proxy')
|
||||
const records=(await readFile(audit,'utf8')).trim().split('\n').map(line=>JSON.parse(line))
|
||||
assert(records.some(item=>item.entry?.includes('app.asar.unpacked') && item.entry.endsWith('server.js') && item.exe===executable && item.electron))
|
||||
console.log('PASS: packaged MCPVault child loaded Node network guards; audited packaged executable and unpacked server entry')
|
||||
console.log('Boundary: MCPVault Node API guards + Chromium proxy; no OS firewall, physical disconnection, or installer execution')
|
||||
} finally {
|
||||
socket?.close()
|
||||
mainSocket?.close()
|
||||
if (child.exitCode === null) {
|
||||
const killer=spawn('taskkill',['/PID',String(child.pid),'/T','/F'],{stdio:'ignore'})
|
||||
await new Promise(resolve=>killer.once('exit',resolve))
|
||||
}
|
||||
await rm(root,{recursive:true,force:true,maxRetries:10,retryDelay:200})
|
||||
}
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1})
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
const { statSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { delimiter, join, resolve } = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const unpackedPath = process.argv[2]
|
||||
? resolve(process.argv[2])
|
||||
@@ -109,6 +110,20 @@ async function main() {
|
||||
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'),
|
||||
@@ -133,7 +148,7 @@ async function main() {
|
||||
[
|
||||
'appId: live.digiman.goodbuddy.harness-smoke',
|
||||
'productName: GoodBuddyHarnessSmoke',
|
||||
'electronVersion: "43.2.0"',
|
||||
`electronVersion: "${require('electron/package.json').version}"`,
|
||||
'asar: true',
|
||||
'npmRebuild: false',
|
||||
'files:',
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// Cold-start benchmark supervisor (PERF-01).
|
||||
// Launches the current production build (out/, from `npm run build:bundle`)
|
||||
// GB_PERF_STARTUP_RUNS times (default 5) per variant through
|
||||
// tests/support/startup-perf-driver.mjs, each launch in a fresh temporary
|
||||
// user-data directory, and reports median / p95 per startup phase.
|
||||
//
|
||||
// Variants (interleaved each round, per docs P7):
|
||||
// first-run empty user-data dir (creates databases, shows release notes)
|
||||
// empty copy of a prepared profile: release notes seen, startup update
|
||||
// check off, 0 conversations
|
||||
// seeded-300 same as empty plus GB_PERF_STARTUP_SEED (default 300)
|
||||
// synthetic conversations, reusing the PERF-11 seed shape
|
||||
//
|
||||
// Isolation: temporary profile/session/logs, a loopback fake model on an
|
||||
// ephemeral port (GB_PERF_MODEL_PORT to pin one; 11434 is left alone), all
|
||||
// non-loopback requests blocked in the driver. Output has phase names, times
|
||||
// and counts only.
|
||||
//
|
||||
// Env: GB_PERF_STARTUP_RUNS, GB_PERF_STARTUP_SEED, GB_PERF_STARTUP_VARIANTS
|
||||
// (comma list), GB_PERF_STARTUP_SKIP_BUILD=1, GB_PERF_STARTUP_PROFILE=1 (one
|
||||
// extra CPU-profiled launch per variant, excluded from the table),
|
||||
// GB_PERF_OUTPUT (report directory), GB_PERF_KEEP_PROFILE=1,
|
||||
// GB_PERF_STARTUP_APP_ROOT (checkout/worktree to build and launch; default .).
|
||||
// Variant "real" (opt-in) launches on a fresh copy of GB_PERF_STARTUP_REAL_DATA.
|
||||
const { spawn, spawnSync, execFileSync } = require('node:child_process')
|
||||
const { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } = require('node:fs')
|
||||
const { createServer } = require('node:http')
|
||||
const { cpus, tmpdir, totalmem } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { npmInvocation } = require('./npm-invocation.cjs')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
// The app under test. Defaults to this checkout; point it at a temporary
|
||||
// worktree to measure a baseline or a clean HEAD while this tree is mid-edit.
|
||||
const appRoot = resolve(process.env.GB_PERF_STARTUP_APP_ROOT || root)
|
||||
const runs = Math.max(1, Number(process.env.GB_PERF_STARTUP_RUNS || 5))
|
||||
const seedCount = Number(process.env.GB_PERF_STARTUP_SEED || 300)
|
||||
const seededName = `seeded-${seedCount}`
|
||||
const variants = (process.env.GB_PERF_STARTUP_VARIANTS || `first-run,empty,${seededName}`).split(',').map(v => v.trim()).filter(Boolean)
|
||||
const requestedPort = Number(process.env.GB_PERF_MODEL_PORT || 0)
|
||||
const launchTimeoutMs = Number(process.env.GB_PERF_STARTUP_LAUNCH_TIMEOUT_MS || 150_000)
|
||||
const keepProfile = process.env.GB_PERF_KEEP_PROFILE === '1'
|
||||
const cpuProfile = process.env.GB_PERF_STARTUP_PROFILE === '1'
|
||||
|
||||
// ------------------------------------------------------------------ build
|
||||
|
||||
function newestMtime(path) {
|
||||
let newest = 0
|
||||
const stack = [path]
|
||||
while (stack.length) {
|
||||
const current = stack.pop()
|
||||
let info
|
||||
try { info = statSync(current) } catch { continue }
|
||||
if (info.isDirectory()) {
|
||||
for (const entry of readdirSync(current)) if (entry !== 'node_modules') stack.push(join(current, entry))
|
||||
} else if (!/\.test\.tsx?$/.test(current)) {
|
||||
newest = Math.max(newest, info.mtimeMs)
|
||||
}
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
function ensureBuild() {
|
||||
const outputs = ['out/main/index.js', 'out/preload/index.cjs', 'out/renderer/index.html'].map(file => join(appRoot, file))
|
||||
const missing = outputs.some(file => !existsSync(file))
|
||||
const builtAt = missing ? 0 : Math.min(...outputs.map(file => statSync(file).mtimeMs))
|
||||
const sourceAt = Math.max(...['src/main', 'src/preload', 'src/renderer', 'src/shared', 'electron.vite.config.ts', 'package.json'].map(p => newestMtime(join(appRoot, p))))
|
||||
const stale = missing || sourceAt > builtAt
|
||||
if (!stale) return { rebuilt: false }
|
||||
if (process.env.GB_PERF_STARTUP_SKIP_BUILD === '1') {
|
||||
if (missing) throw new Error('Missing production build and GB_PERF_STARTUP_SKIP_BUILD=1')
|
||||
console.log('[startup] build is older than src/, measuring it anyway (GB_PERF_STARTUP_SKIP_BUILD=1)')
|
||||
return { rebuilt: false, stale: true }
|
||||
}
|
||||
console.log(`[startup] ${missing ? 'no production build' : 'production build is older than src/'}; running npm run build:bundle`)
|
||||
const npm = npmInvocation(appRoot)
|
||||
const result = spawnSync(npm.command, [...npm.prefixArgs, 'run', 'build:bundle'], { cwd: appRoot, stdio: 'inherit', shell: npm.prefixArgs.length === 0 && process.platform === 'win32' })
|
||||
if (result.status !== 0) throw new Error(`npm run build:bundle failed (${result.status})`)
|
||||
return { rebuilt: true }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- fake model
|
||||
|
||||
function startModelServer(ledger) {
|
||||
const server = createServer((request, response) => {
|
||||
ledger.push({ method: request.method, path: request.url })
|
||||
if (request.method === 'GET' && request.url?.endsWith('/models')) {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ object: 'list', data: [{ id: 'qwen3', object: 'model' }] }))
|
||||
return
|
||||
}
|
||||
if (request.method === 'POST' && request.url?.endsWith('/chat/completions')) {
|
||||
request.resume()
|
||||
request.on('end', () => {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ id: 'perf', object: 'chat.completion', model: 'qwen3', choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'ok' } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }))
|
||||
})
|
||||
return
|
||||
}
|
||||
response.writeHead(404).end()
|
||||
})
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(requestedPort, '127.0.0.1', () => resolvePromise(server))
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ launch
|
||||
|
||||
function baseEnvironment(modelPort, workspace) {
|
||||
const environment = { ...process.env, GB_PERF_ROOT: appRoot, GB_PERF_STARTUP_MARKS: '1' }
|
||||
delete environment.ELECTRON_RUN_AS_NODE
|
||||
delete environment.ELECTRON_RENDERER_URL
|
||||
for (const key of ['GOODBUDDY_MODEL_API_KEY', 'GOODBUDDY_MODEL_BASE_URL', 'GOODBUDDY_MODEL_NAME', 'GOODBUDDY_BIGTOKEN_API_KEY', 'GOODBUDDY_BIGTOKEN_BASE_URL', 'GOODBUDDY_BIGTOKEN_MODEL']) delete environment[key]
|
||||
environment.GOODBUDDY_MODEL_API_KEY = 'perf-loopback'
|
||||
environment.GOODBUDDY_MODEL_BASE_URL = `http://127.0.0.1:${modelPort}/v1`
|
||||
environment.GOODBUDDY_MODEL_NAME = 'qwen3'
|
||||
environment.GOODBUDDY_WORKSPACE = workspace
|
||||
return environment
|
||||
}
|
||||
|
||||
async function launch({ directory, environment, mode = 'measure', seed = 0, expectConversations = 0, profilePath }) {
|
||||
const resultPath = join(directory, 'startup-result.json')
|
||||
const env = { ...environment, GB_STARTUP_DIR: directory, GB_STARTUP_RESULT: resultPath, GB_STARTUP_MODE: mode, GB_STARTUP_SEED: String(seed), GB_STARTUP_EXPECT_CONVERSATIONS: String(expectConversations) }
|
||||
if (profilePath) env.GB_STARTUP_CPU_PROFILE = profilePath
|
||||
const stderr = []
|
||||
const spawnAt = Date.now()
|
||||
const child = spawn(require('electron'), [join(root, 'tests', 'support', 'startup-perf-driver.mjs')], { cwd: root, env, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: false })
|
||||
child.stderr.on('data', chunk => { if (stderr.length < 200) stderr.push(String(chunk)) })
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
else child.kill('SIGKILL')
|
||||
}, launchTimeoutMs)
|
||||
const exit = await new Promise(resolvePromise => child.once('exit', code => resolvePromise(code)))
|
||||
const exitAt = Date.now()
|
||||
clearTimeout(timer)
|
||||
const report = existsSync(resultPath) ? JSON.parse(readFileSync(resultPath, 'utf8')) : { status: 'failed', error: timedOut ? 'launch timeout' : 'driver exited without a report' }
|
||||
if (exit !== 0 || timedOut) report.status = 'failed'
|
||||
report.spawnAt = spawnAt
|
||||
report.exitMs = exitAt - spawnAt
|
||||
// stderr is kept only for failures, and only its first line (no user data exists in these profiles).
|
||||
if (report.status !== 'passed') report.stderrHead = stderr.join('').split(/\r?\n/).filter(Boolean).slice(0, 8)
|
||||
return report
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- analysis
|
||||
|
||||
function phases(report) {
|
||||
const t = report.times
|
||||
const r = report.renderer
|
||||
const s = report.spawnAt
|
||||
const marks = Object.fromEntries((report.startupMarks ?? []).map(m => [m.name, m.at]))
|
||||
const d = (from, to) => (from === undefined || from === null || to === undefined || to === null) ? null : to - from
|
||||
const result = {
|
||||
'spawn -> driver start (Electron boot)': d(s, t.driverStart),
|
||||
'driver start -> Main import done': d(t.driverStart, t.mainImportEnd),
|
||||
'spawn -> app ready': d(s, t.appReady),
|
||||
'app ready -> BrowserWindow created': d(t.appReady, t.windowCreated),
|
||||
'window created -> loadMainWindow': d(t.windowCreated, marks['main:load-main-window']),
|
||||
'loadMainWindow -> ready-to-show': d(marks['main:load-main-window'], t.readyToShow),
|
||||
'window created -> ready-to-show': d(t.windowCreated, t.readyToShow),
|
||||
'ready-to-show -> first visible frame': d(t.readyToShow, r.firstVisibleFrame),
|
||||
'ready-to-show -> composer interactive': d(t.readyToShow, r.composerInteractive),
|
||||
'spawn -> ready-to-show': d(s, t.readyToShow),
|
||||
'spawn -> first visible frame': d(s, r.firstVisibleFrame),
|
||||
// Only reported by Chromium when the first paint happens while visible.
|
||||
'spawn -> first contentful paint': d(s, r.firstContentfulPaint),
|
||||
'spawn -> composer present': d(s, r.composerPresent),
|
||||
'spawn -> composer interactive': d(s, r.composerInteractive),
|
||||
'spawn -> sidebar populated (seeded)': d(s, r.sidebarPopulated),
|
||||
'composer interactive -> sidebar populated': d(r.composerInteractive, r.sidebarPopulated)
|
||||
}
|
||||
// Main phases (GB_PERF_STARTUP_MARKS spans; parallel spans overlap).
|
||||
const main = {
|
||||
'main: module import (index.js eval)': d(t.mainImportStart, marks['main:module-evaluated']),
|
||||
'main: whenReady -> window created': d(marks['main:when-ready'], marks['main:create-window:end']),
|
||||
'main: createMainWindow()': d(marks['main:create-window:start'], marks['main:create-window:end']),
|
||||
'main: tray': d(marks['main:create-window:end'], marks['main:tray-built'])
|
||||
}
|
||||
for (const m of report.startupMarks ?? []) {
|
||||
if (!m.name.endsWith(':start')) continue
|
||||
const base = m.name.slice(0, -':start'.length)
|
||||
if (base === 'main:create-window') continue
|
||||
main[`${base.replace(/^main:/, 'main: ')}`] = d(m.at, marks[`${base}:end`])
|
||||
// Synchronous part of the span: from the call until the first await yields.
|
||||
if (marks[`${base}:sync-end`] !== undefined) main[`${base.replace(/^main:/, 'main: ')} (sync part)`] = d(m.at, marks[`${base}:sync-end`])
|
||||
}
|
||||
main['main: whenReady -> loadMainWindow (total)'] = d(marks['main:when-ready'], marks['main:load-main-window'])
|
||||
// Critical path in Main: consecutive top-level marks (spans that run in
|
||||
// parallel inside the prerequisites are excluded), so each gap is the time
|
||||
// Main spent between two points of the sequential startup function.
|
||||
const nested = /^main:(knowledge-init|knowledge-gateway-start|runtime-hydrate|assistant-db-init)/
|
||||
const sequence = (report.startupMarks ?? []).filter(m => !nested.test(m.name) && !m.name.endsWith(':sync-end')).sort((a, b) => a.at - b.at)
|
||||
for (let index = 1; index < sequence.length; index += 1) {
|
||||
main[`gap: ${sequence[index - 1].name} -> ${sequence[index].name}`] = sequence[index].at - sequence[index - 1].at
|
||||
}
|
||||
const renderer = {
|
||||
'renderer: navigation start -> DOMContentLoaded': d(r.navigationStart, r.domContentLoaded),
|
||||
'renderer: DOMContentLoaded -> composer present': d(r.domContentLoaded, r.composerPresent),
|
||||
'renderer: navigation start -> composer present': d(r.navigationStart, r.composerPresent)
|
||||
}
|
||||
return { ...result, ...main, ...renderer }
|
||||
}
|
||||
|
||||
function percentile(values, p) {
|
||||
if (!values.length) return null
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
// Nearest rank; with 5 samples p95 is the maximum.
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||
}
|
||||
|
||||
function summarize(reportsByVariant) {
|
||||
const rows = new Map()
|
||||
for (const [variant, reports] of Object.entries(reportsByVariant)) {
|
||||
const passed = reports.filter(r => r.status === 'passed')
|
||||
const perRun = passed.map(phases)
|
||||
for (const name of new Set(perRun.flatMap(Object.keys))) {
|
||||
const values = perRun.map(p => p[name]).filter(v => typeof v === 'number' && Number.isFinite(v))
|
||||
const row = rows.get(name) ?? {}
|
||||
row[variant] = values.length ? { median: percentile(values, 50), p95: percentile(values, 95), n: values.length } : null
|
||||
rows.set(name, row)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function sourceInfo() {
|
||||
try {
|
||||
const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: appRoot, encoding: 'utf8' }).trim()
|
||||
const dirtyFiles = execFileSync('git', ['status', '--porcelain'], { cwd: appRoot, encoding: 'utf8' }).split('\n').filter(Boolean).length
|
||||
return { commit, dirty: dirtyFiles > 0, dirtyFiles, appRoot: appRoot === root ? '.' : 'GB_PERF_STARTUP_APP_ROOT' }
|
||||
} catch {
|
||||
return { commit: null }
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = v => (v === null || v === undefined ? '-' : Math.round(v).toString())
|
||||
|
||||
// ------------------------------------------------------------------- main
|
||||
|
||||
async function main() {
|
||||
const build = ensureBuild()
|
||||
const runDirectory = mkdtempSync(join(tmpdir(), 'goodbuddy-startup-perf-'))
|
||||
const outputDirectory = resolve(process.env.GB_PERF_OUTPUT || join(runDirectory, 'artifacts'))
|
||||
mkdirSync(outputDirectory, { recursive: true })
|
||||
const workspace = join(runDirectory, 'workspace')
|
||||
mkdirSync(workspace, { recursive: true })
|
||||
const ledger = []
|
||||
let server
|
||||
try {
|
||||
server = await startModelServer(ledger)
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot listen on 127.0.0.1:${requestedPort} for the fake model (${error.code ?? error.message}); set GB_PERF_MODEL_PORT to a free port or leave it unset for an ephemeral port`, { cause: error })
|
||||
}
|
||||
const modelPort = server.address().port
|
||||
const environment = baseEnvironment(modelPort, workspace)
|
||||
const templates = {}
|
||||
const prepareReports = {}
|
||||
try {
|
||||
// Templates are prepared once and copied per launch, so every measured
|
||||
// launch starts from an identical, never-used-before directory.
|
||||
for (const variant of variants) {
|
||||
if (variant === 'first-run') continue
|
||||
if (variant === 'real') {
|
||||
// A copy of a real data directory (GB_PERF_STARTUP_REAL_DATA, for
|
||||
// example the portable build's data folder). Only the stores startup
|
||||
// reads are copied; the source is never opened by the app. The driver
|
||||
// blocks every non-loopback request, so configured remote models are
|
||||
// not called.
|
||||
const source = process.env.GB_PERF_STARTUP_REAL_DATA
|
||||
if (!source || !existsSync(join(source, 'assistant.sqlite'))) throw new Error('variant "real" needs GB_PERF_STARTUP_REAL_DATA with assistant.sqlite')
|
||||
const directory = join(runDirectory, 'template-real')
|
||||
const profile = join(directory, 'profile')
|
||||
mkdirSync(profile, { recursive: true })
|
||||
for (const entry of readdirSync(source)) {
|
||||
const from = join(source, entry)
|
||||
const keep = /\.(sqlite|sqlite-wal|sqlite-shm|json)$/i.test(entry) || entry === 'notes' || entry === 'Local Storage'
|
||||
if (keep && entry !== 'DevToolsActivePort') cpSync(from, join(profile, entry), { recursive: true })
|
||||
}
|
||||
templates[variant] = directory
|
||||
continue
|
||||
}
|
||||
const seed = variant === 'empty' ? 0 : variant === seededName ? seedCount : null
|
||||
if (seed === null) throw new Error(`Unknown variant ${variant}`)
|
||||
const directory = join(runDirectory, `template-${variant}`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
console.log(`[startup] preparing ${variant} profile${seed ? ` (${seed} conversations)` : ''}`)
|
||||
const report = await launch({ directory, environment, mode: 'prepare', seed })
|
||||
prepareReports[variant] = { status: report.status, error: report.error, seeded: report.seeded, exitMs: report.exitMs, stderrHead: report.stderrHead }
|
||||
if (report.status !== 'passed') throw new Error(`Preparing ${variant} failed: ${report.error ?? 'unknown'}\n${(report.stderrHead ?? []).join('\n')}`)
|
||||
rmSync(join(directory, 'startup-result.json'), { force: true })
|
||||
templates[variant] = directory
|
||||
}
|
||||
|
||||
const fresh = (variant, label) => {
|
||||
const directory = join(runDirectory, `${label}-${variant}`)
|
||||
if (templates[variant]) {
|
||||
cpSync(templates[variant], directory, { recursive: true })
|
||||
} else {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
// The real profile's sidebar is ready once its first rows show.
|
||||
const expectedFor = variant => (variant === seededName ? seedCount : variant === 'real' ? 10 : 0)
|
||||
const results = Object.fromEntries(variants.map(v => [v, []]))
|
||||
// A discarded warm-up launch per invocation so the first measured launch
|
||||
// does not also pay for loading Electron binaries from a cold disk cache.
|
||||
console.log('[startup] warm-up launch (discarded)')
|
||||
await launch({ directory: fresh(variants.includes('empty') ? 'empty' : variants[0], 'warmup'), environment })
|
||||
for (let round = 0; round < runs; round += 1) {
|
||||
for (const variant of variants) {
|
||||
const directory = fresh(variant, `run${round + 1}`)
|
||||
const report = await launch({ directory, environment, expectConversations: expectedFor(variant) })
|
||||
report.round = round + 1
|
||||
results[variant].push(report)
|
||||
const p = report.status === 'passed' ? phases(report) : {}
|
||||
console.log(`[startup] round ${round + 1} ${variant}: ${report.status}, ready ${fmt(p['spawn -> app ready'])} ms, ready-to-show ${fmt(p['spawn -> ready-to-show'])} ms, first frame ${fmt(p['spawn -> first visible frame'])} ms, composer interactive ${fmt(p['spawn -> composer interactive'])} ms${report.error ? `, error ${report.error}` : ''}`)
|
||||
if (!keepProfile) rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
const profiles = {}
|
||||
if (cpuProfile) {
|
||||
for (const variant of variants) {
|
||||
const directory = fresh(variant, 'profiled')
|
||||
const profilePath = join(outputDirectory, `main-startup-${variant}.cpuprofile`)
|
||||
const report = await launch({ directory, environment, profilePath, expectConversations: expectedFor(variant) })
|
||||
profiles[variant] = { status: report.status, cpuProfile: report.cpuProfile, phases: report.status === 'passed' ? phases(report) : null, file: profilePath }
|
||||
console.log(`[startup] CPU-profiled ${variant}: ${report.status} -> ${profilePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
const table = summarize(results)
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
kind: 'goodbuddy-startup-benchmark',
|
||||
source: { ...sourceInfo(), build },
|
||||
environment: { platform: process.platform, arch: process.arch, electron: require('electron/package.json').version, cpu: `${cpus()[0]?.model?.trim()} x${cpus().length}`, memoryGB: Math.round(totalmem() / 1073741824) },
|
||||
config: { runs, variants, seedConversations: seedCount, percentile: 'nearest-rank' },
|
||||
prepare: prepareReports,
|
||||
phases: Object.fromEntries(table),
|
||||
runs: Object.fromEntries(Object.entries(results).map(([variant, reports]) => [variant, reports.map(r => ({
|
||||
round: r.round, status: r.status, error: r.error, stderrHead: r.stderrHead, exitMs: r.exitMs,
|
||||
phases: r.status === 'passed' ? phases(r) : null,
|
||||
renderer: r.renderer ? { domNodes: r.renderer.domNodes, conversationRows: r.renderer.conversationRows, scriptResources: r.renderer.scriptResources, scriptBytes: r.renderer.scriptBytes, jsHeapMB: r.renderer.jsHeapMB, startupDialog: r.renderer.startupDialog !== undefined, composerPresentBeforeProbe: r.renderer.alreadyAtInstall === true, longAnimationFrames: r.renderer.longAnimationFrames && { count: r.renderer.longAnimationFrames.count, totalMs: r.renderer.longAnimationFrames.totalMs, maxMs: r.renderer.longAnimationFrames.maxMs, scriptMs: r.renderer.longAnimationFrames.scriptMs } } : null,
|
||||
mainEventLoopDelay: r.mainEventLoopDelay,
|
||||
blockedRequests: r.blockedRequests && { fetch: r.blockedRequests.fetch.length, renderer: r.blockedRequests.renderer.length, hosts: [...new Set([...r.blockedRequests.fetch, ...r.blockedRequests.renderer])] }
|
||||
}))])),
|
||||
cpuProfiles: profiles,
|
||||
modelRequests: { boundary: 'loopback fake model only; non-loopback requests blocked', total: ledger.length, paths: [...new Set(ledger.map(e => `${e.method} ${e.path}`))] }
|
||||
}
|
||||
const resultPath = join(outputDirectory, 'startup-result.json')
|
||||
writeFileSync(resultPath, JSON.stringify(report, null, 2))
|
||||
|
||||
console.log(`\nGoodBuddy cold-start benchmark: ${runs} runs per variant, ms (median / p95, nearest rank)`)
|
||||
console.log(`source ${report.source.commit ?? '?'}${report.source.dirty ? ` (dirty, ${report.source.dirtyFiles} files)` : ''}, Electron ${report.environment.electron}, ${report.environment.cpu}, ${report.environment.memoryGB} GB`)
|
||||
console.table(Object.fromEntries([...table].map(([name, row]) => [name, Object.fromEntries(variants.map(v => [v, row[v] ? `${fmt(row[v].median)} / ${fmt(row[v].p95)}${row[v].n < runs ? ` (n=${row[v].n})` : ''}` : '-']))])))
|
||||
const failed = Object.values(results).flat().filter(r => r.status !== 'passed').length
|
||||
if (failed) console.log(`${failed} launch(es) failed; see report`)
|
||||
const blocked = Object.values(results).flat().flatMap(r => [...(r.blockedRequests?.fetch ?? []), ...(r.blockedRequests?.renderer ?? [])])
|
||||
console.log(`blocked non-loopback requests: ${blocked.length}${blocked.length ? ` (${[...new Set(blocked)].join(', ')})` : ''}; fake model requests: ${ledger.length}`)
|
||||
console.log(`\nreport: ${resultPath}`)
|
||||
process.exitCode = failed ? 1 : 0
|
||||
} finally {
|
||||
await new Promise(resolvePromise => server.close(() => resolvePromise()))
|
||||
if (!keepProfile) {
|
||||
for (const entry of readdirSync(runDirectory)) {
|
||||
if (entry !== 'artifacts') rmSync(join(runDirectory, entry), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// Windows logical-I/O probe against an isolated copy of a quiescent data folder.
|
||||
// GB_WPERF_DATA must contain assistant.sqlite. No source database is opened.
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdtempSync, copyFileSync, cpSync, existsSync, rmSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { performance } = require('node:perf_hooks')
|
||||
const { execFileSync, spawnSync } = require('node:child_process')
|
||||
const { deepStrictEqual } = require('node:assert')
|
||||
|
||||
if (process.platform !== 'win32') throw new Error('This probe uses Windows process I/O counters')
|
||||
if (!process.versions.electron) {
|
||||
const result = spawnSync(require('electron'), [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
const io = () => JSON.parse(execFileSync('powershell.exe', ['-NoProfile', '-Command',
|
||||
`Get-CimInstance Win32_Process -Filter 'ProcessId=${process.pid}' | Select-Object ReadTransferCount,WriteTransferCount | ConvertTo-Json -Compress`
|
||||
], { encoding: 'utf8' }))
|
||||
const source = process.env.GB_WPERF_DATA
|
||||
if (!source || !existsSync(join(source, 'assistant.sqlite'))) throw new Error('Set GB_WPERF_DATA')
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-write-io-'))
|
||||
let database
|
||||
try {
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
if (existsSync(join(source, `assistant.sqlite${suffix}`))) {
|
||||
copyFileSync(join(source, `assistant.sqlite${suffix}`), `${path}${suffix}`)
|
||||
}
|
||||
}
|
||||
if (existsSync(join(source, 'notes'))) cpSync(join(source, 'notes'), join(directory, 'notes'), { recursive: true })
|
||||
const bundle = join(directory, 'database.cjs')
|
||||
buildSync({ entryPoints: [resolve(__dirname, '../src/main/assistant/assistant-database.ts')],
|
||||
outfile: bundle, bundle: true, platform: 'node', format: 'cjs', external: ['electron'], logLevel: 'error' })
|
||||
const { AssistantDatabase } = require(bundle)
|
||||
database = new AssistantDatabase(path)
|
||||
database.initialize(directory)
|
||||
const raw = database.database
|
||||
const largest = raw.prepare(`SELECT id, conversation_id, length(metadata_json) AS bytes
|
||||
FROM messages ORDER BY length(metadata_json) DESC LIMIT 1`).get()
|
||||
if (!largest) throw new Error('No messages')
|
||||
const snapshot = database.getConversation(largest.conversation_id)
|
||||
const message = snapshot.messages.find(item => item.id === largest.id)
|
||||
// Summary reads must remain small even while a large history is streaming.
|
||||
database.saveLocalConversations([{ header: { ...snapshot, messages: undefined },
|
||||
messages: [{ ...message, state: 'streaming' }] }])
|
||||
const taskId = randomUUID()
|
||||
database.createTask({ id: taskId, projectId: snapshot.projectId,
|
||||
conversationId: snapshot.id, title: 'I/O probe', instructions: '', visible: false })
|
||||
database.startExecutionTiming(taskId)
|
||||
raw.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
console.log(JSON.stringify({ schema: raw.prepare('PRAGMA user_version').get(), metadataBytes: largest.bytes }))
|
||||
const results = []
|
||||
for (const [variant, query, count] of [
|
||||
['read-conversation', () => database.getConversation(snapshot.id), 1],
|
||||
['read-summary', () => database.listConversationSummaries([]), 1],
|
||||
['conversation-timing', () => database.getExecutionStats({ conversationId: snapshot.id }), 20],
|
||||
...(snapshot.projectId ? [['project-timing', () => database.getExecutionStats({ projectId: snapshot.projectId }), 20]] : [])
|
||||
]) {
|
||||
const before = io()
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < count; index++) query()
|
||||
const elapsedMs = Math.round(performance.now() - start)
|
||||
const after = io()
|
||||
results.push({ variant, calls: count, elapsedMs,
|
||||
readMiB: Number(((after.ReadTransferCount - before.ReadTransferCount) / 1024 / 1024).toFixed(3)),
|
||||
writeMiB: Number(((after.WriteTransferCount - before.WriteTransferCount) / 1024 / 1024).toFixed(3)) })
|
||||
}
|
||||
database.endExecutionTiming(taskId)
|
||||
deepStrictEqual(database.getConversation(snapshot.id).messages,
|
||||
snapshot.messages.map(item => item.id === message.id ? { ...item, state: 'streaming' } : item))
|
||||
console.table(results)
|
||||
} finally {
|
||||
database?.close()
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Owner-local database WRITE microbenchmark on a closed data copy (PERF-15).
|
||||
// Copies assistant.sqlite (+ -wal/-shm) from GB_WPERF_DATA (for example the
|
||||
// portable build's data folder) into a temporary directory - the source is
|
||||
// never opened - then measures, with the production AssistantDatabase, the
|
||||
// synchronous repository writes. This is not a production Main IPC latency probe:
|
||||
// task-events:subagent replay of the largest real subagent progress stream
|
||||
// (restored events, re-compacted on write, as live)
|
||||
// task-events:tool replay of real tool events
|
||||
// task-events:text 250 ms text batches (persisted buffer)
|
||||
// conversations:save the renderer's 500 ms local save (header + the
|
||||
// streaming assistant message) for a real conversation
|
||||
// activity:update one incremental activity change
|
||||
// For each: per-call time and the longest owner-local harness event-loop block.
|
||||
// Event replays are paced at GB_WPERF_RATE events/s (default 270, the busiest
|
||||
// second in real data; 0 = back to back).
|
||||
// Env: GB_WPERF_DATA (required), GB_WPERF_EVENTS (default 2000),
|
||||
// GB_WPERF_OUTPUT (JSON), GB_WPERF_KEEP=1 (keep the copy).
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdtempSync, rmSync, writeFileSync, mkdirSync, copyFileSync, cpSync, existsSync } = require('node:fs')
|
||||
const { Worker } = require('node:worker_threads')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { performance } = require('node:perf_hooks')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
|
||||
if (process.env.GB_WPERF_NODE !== '1') {
|
||||
let electron
|
||||
let version
|
||||
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
})
|
||||
process.exit(status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
const source = process.env.GB_WPERF_DATA
|
||||
if (!source || !existsSync(join(source, 'assistant.sqlite'))) {
|
||||
console.error('[wperf] set GB_WPERF_DATA to a GoodBuddy data directory containing assistant.sqlite')
|
||||
process.exit(2)
|
||||
}
|
||||
const eventLimit = Number(process.env.GB_WPERF_EVENTS || 2000)
|
||||
|
||||
const temporaryRoot = join(root, 'temp', 'goodbuddy-storage-foundation')
|
||||
mkdirSync(temporaryRoot, { recursive: true })
|
||||
const directory = mkdtempSync(join(temporaryRoot, 'write-perf-'))
|
||||
let ownedDatabase, checkpoint, checkpointExit
|
||||
const bundle = join(directory, 'assistant-database.cjs')
|
||||
const progressBundle = join(directory, 'subagent-progress.cjs')
|
||||
for (const [entry, outfile] of [
|
||||
['src/main/assistant/assistant-database.ts', bundle],
|
||||
['src/main/assistant/subagent-progress-storage.ts', progressBundle],
|
||||
['src/main/readonly-query-worker.ts', join(directory, 'readonly-query-worker.cjs')]
|
||||
]) {
|
||||
buildSync({
|
||||
entryPoints: [join(root, entry)], bundle: true, platform: 'node', format: 'cjs',
|
||||
target: 'node22', outfile, logLevel: 'error', external: ['electron']
|
||||
})
|
||||
}
|
||||
const { AssistantDatabase } = require(bundle)
|
||||
const { restoreSubagentPayload } = require(progressBundle)
|
||||
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
const percentile = (values, p) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||
}
|
||||
|
||||
/** Per-call times plus the longest gap between event-loop turns. */
|
||||
async function measure(count, body, { yieldEvery = 1, rate = 0 } = {}) {
|
||||
let probing = true
|
||||
let last = performance.now()
|
||||
let maxGap = 0
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
maxGap = Math.max(maxGap, now - last)
|
||||
last = now
|
||||
if (probing) setImmediate(tick)
|
||||
}
|
||||
setImmediate(tick)
|
||||
const samples = []
|
||||
const slow = []
|
||||
const started = performance.now()
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const at = performance.now()
|
||||
body(index)
|
||||
const elapsed = performance.now() - at
|
||||
samples.push(elapsed)
|
||||
if (elapsed > 50) slow.push([index, round(elapsed)])
|
||||
if (rate > 0) {
|
||||
// Real streams arrive at a rate; wait until this call's scheduled time.
|
||||
const due = started + ((index + 1) * 1000) / rate
|
||||
const wait = due - performance.now()
|
||||
await new Promise(resolve => (wait > 1 ? setTimeout(resolve, wait) : setImmediate(resolve)))
|
||||
} else if ((index + 1) % yieldEvery === 0) await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
const total = performance.now() - started
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
probing = false
|
||||
return {
|
||||
calls: count, totalMs: round(total), callP50: round(percentile(samples, 50)),
|
||||
callP95: round(percentile(samples, 95)), callP99: round(percentile(samples, 99)),
|
||||
callMax: round(Math.max(...samples)), loopMax: round(maxGap), slowCalls: slow.slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[wperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||
const copyStarted = performance.now()
|
||||
for (const name of ['assistant.sqlite', 'assistant.sqlite-wal', 'assistant.sqlite-shm']) {
|
||||
if (existsSync(join(source, name))) copyFileSync(join(source, name), join(directory, name))
|
||||
}
|
||||
// Initialization checks note files against the database.
|
||||
if (existsSync(join(source, 'notes'))) cpSync(join(source, 'notes'), join(directory, 'notes'), { recursive: true })
|
||||
console.log(`[wperf] copied data in ${round((performance.now() - copyStarted) / 1000)} s`)
|
||||
const workspace = join(directory, 'workspace')
|
||||
mkdirSync(workspace)
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
ownedDatabase = database
|
||||
const openStarted = performance.now()
|
||||
database.initialize(workspace)
|
||||
const raw = database.database
|
||||
const results = { openMs: round(performance.now() - openStarted) }
|
||||
// Experiment switch: override the durability level after initialization.
|
||||
if (process.env.GB_WPERF_SYNCHRONOUS) raw.exec(`PRAGMA synchronous = ${process.env.GB_WPERF_SYNCHRONOUS}`)
|
||||
results.synchronous = raw.prepare('PRAGMA synchronous').get().synchronous
|
||||
if (process.env.GB_WPERF_AUTOCHECKPOINT) raw.exec(`PRAGMA wal_autocheckpoint = ${process.env.GB_WPERF_AUTOCHECKPOINT}`)
|
||||
// As in production: checkpoints run on a worker thread (GB_WPERF_CHECKPOINT_WORKER=0 disables).
|
||||
if (process.env.GB_WPERF_CHECKPOINT_WORKER !== '0') {
|
||||
checkpoint = new Worker(join(directory, 'readonly-query-worker.cjs'), {
|
||||
workerData: { kind: 'checkpoint', databasePath: join(directory, 'assistant.sqlite'), intervalMs: 250 }
|
||||
})
|
||||
checkpointExit = new Promise(resolve => checkpoint.once('exit', resolve))
|
||||
await new Promise((resolve, reject) => { checkpoint.once('message', resolve); checkpoint.once('error', reject) })
|
||||
}
|
||||
results.walAutocheckpoint = raw.prepare('PRAGMA wal_autocheckpoint').get().wal_autocheckpoint
|
||||
const count = (sql, ...args) => Number(raw.prepare(sql).get(...args).n)
|
||||
results.taskEvents = count('SELECT COUNT(*) AS n FROM task_events')
|
||||
results.messages = count('SELECT COUNT(*) AS n FROM messages')
|
||||
results.activityRecords = count('SELECT COUNT(*) AS n FROM activity_history_records')
|
||||
|
||||
// The real task with the most subagent events, restored in order.
|
||||
const sourceTask = raw.prepare(
|
||||
`SELECT task_id FROM task_events WHERE kind = 'subagent' GROUP BY task_id ORDER BY COUNT(*) DESC LIMIT 1`
|
||||
).get()?.task_id
|
||||
const blocks = new Map()
|
||||
const subagentEvents = sourceTask ? raw.prepare(
|
||||
`SELECT payload_json FROM task_events WHERE task_id = ? AND kind = 'subagent' ORDER BY id LIMIT ?`
|
||||
).all(sourceTask, eventLimit).map(row => restoreSubagentPayload(JSON.parse(row.payload_json), blocks)) : []
|
||||
const toolEvents = raw.prepare(
|
||||
`SELECT payload_json FROM task_events WHERE kind = 'tool' ORDER BY id DESC LIMIT ?`
|
||||
).all(eventLimit).map(row => JSON.parse(row.payload_json))
|
||||
|
||||
const conversation = raw.prepare(
|
||||
`SELECT conversation_id AS id FROM messages GROUP BY conversation_id ORDER BY COUNT(*) DESC LIMIT 1`
|
||||
).get()?.id
|
||||
const taskId = randomUUID()
|
||||
database.createTask({ id: taskId, title: 'write perf', instructions: 'replay', conversationId: conversation })
|
||||
|
||||
const replay = (events, kind) => index => {
|
||||
const event = { ...events[index % events.length], requestId: taskId }
|
||||
database.appendTaskEvent(taskId, kind, event)
|
||||
}
|
||||
if (process.env.GB_WPERF_INSPECT) {
|
||||
const at = Number(process.env.GB_WPERF_INSPECT)
|
||||
const { compactSubagentPayload } = require(progressBundle)
|
||||
const event = subagentEvents[at]
|
||||
const t0 = performance.now()
|
||||
const json = JSON.stringify(event)
|
||||
const t1 = performance.now()
|
||||
const restoredBlocks = new Map()
|
||||
for (let i = 0; i < at; i += 1) restoreSubagentPayload(JSON.parse(JSON.stringify(compactSubagentPayload(subagentEvents[i], restoredBlocks))), restoredBlocks)
|
||||
const t2 = performance.now()
|
||||
const compact = JSON.stringify(compactSubagentPayload(event, restoredBlocks))
|
||||
const t3 = performance.now()
|
||||
console.log(`[wperf] event ${at}: full ${json.length} B (stringify ${round(t1 - t0)} ms), compact ${compact.length} B (compact ${round(t3 - t2)} ms)`)
|
||||
console.log(`[wperf] event ${at} progress blocks: ${(event.progress ?? []).length}, updates: ${(event.progressUpdates ?? []).length}`)
|
||||
}
|
||||
// Paced at the busiest second seen in real data (269 subagent events/s); 0 = back to back.
|
||||
const rate = Number(process.env.GB_WPERF_RATE ?? 270)
|
||||
if (subagentEvents.length) {
|
||||
results.subagentEvents = await measure(subagentEvents.length, replay(subagentEvents, 'subagent'), { rate })
|
||||
}
|
||||
if (toolEvents.length) results.toolEvents = await measure(toolEvents.length, replay(toolEvents, 'tool'), { rate })
|
||||
const delta = 'streamed text '.repeat(40)
|
||||
results.textBatches = await measure(400, index => database.appendTaskEvent(taskId, 'text',
|
||||
{ type: 'text', requestId: taskId, delta: `${delta}${index}` }))
|
||||
|
||||
// Renderer local save: the conversation header and the streaming assistant message growing.
|
||||
if (conversation) {
|
||||
const snapshot = database.getConversation(conversation)
|
||||
const header = {
|
||||
id: snapshot.id, projectId: snapshot.projectId, title: snapshot.title,
|
||||
runtimeSelection: snapshot.runtimeSelection, updatedAt: Date.now()
|
||||
}
|
||||
const messageId = randomUUID()
|
||||
let content = ''
|
||||
results.conversationSave = await measure(200, index => {
|
||||
content += 'Lorem ipsum dolor sit amet, streaming reply chunk. '.repeat(8)
|
||||
database.saveLocalConversations([{
|
||||
header: { ...header, updatedAt: Date.now() + index },
|
||||
messages: [{ id: messageId, role: 'assistant', state: 'streaming', content, createdAt: Date.now() }]
|
||||
}])
|
||||
})
|
||||
results.conversationGet = await measure(20, () => database.getConversation(conversation))
|
||||
}
|
||||
|
||||
const sample = database.getActivityHistoryPage({ limit: 50 }).records
|
||||
if (sample.length) {
|
||||
results.activityUpdate = await measure(200, index => database.updateActivityHistory({
|
||||
changes: [{ type: 'upsert', position: 'in-place', record: { ...sample[index % sample.length], detail: `perf ${index}` } }]
|
||||
}))
|
||||
}
|
||||
results.walBytes = existsSync(join(directory, 'assistant.sqlite-wal'))
|
||||
? require('node:fs').statSync(join(directory, 'assistant.sqlite-wal')).size : 0
|
||||
if (checkpoint) { checkpoint.postMessage({ type: 'close' }); await checkpointExit; checkpoint = undefined }
|
||||
database.close()
|
||||
|
||||
console.log(`[wperf] ${results.taskEvents} task events, ${results.messages} messages, ${results.activityRecords} activity records; open ${results.openMs} ms`)
|
||||
const rows = [
|
||||
['task event: subagent (real stream)', results.subagentEvents],
|
||||
['task event: tool (real)', results.toolEvents],
|
||||
['task event: text batch', results.textBatches],
|
||||
['conversation save (500 ms tick)', results.conversationSave],
|
||||
['conversation get (largest)', results.conversationGet],
|
||||
['activity update, 1 record', results.activityUpdate]
|
||||
].filter(([, value]) => value)
|
||||
console.table(rows.map(([name, r]) => ({
|
||||
path: name, calls: r.calls, 'p50 ms': r.callP50, 'p95 ms': r.callP95, 'p99 ms': r.callP99,
|
||||
'max ms': r.callMax, 'loop max ms': r.loopMax, 'calls/s': Math.round(r.calls / (r.totalMs / 1000))
|
||||
})))
|
||||
for (const [name, r] of rows) if (r.slowCalls.length) console.log(`[wperf] slow calls (index, ms) ${name}: ${JSON.stringify(r.slowCalls)}`)
|
||||
console.log(`[wperf] synchronous=${results.synchronous} wal_autocheckpoint=${results.walAutocheckpoint} wal=${results.walBytes} B`)
|
||||
const output = process.env.GB_WPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-write-benchmark', executionBoundary: 'owner-local', node: process.version, results }, null, 2))
|
||||
}
|
||||
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(async () => {
|
||||
if (checkpoint) { checkpoint.postMessage({ type: 'close' }); await checkpointExit }
|
||||
ownedDatabase?.close()
|
||||
if (process.env.GB_WPERF_KEEP === '1') console.log(`[wperf] kept ${directory}`)
|
||||
else rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
+208
-146
@@ -1,4 +1,4 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
const { createHash } = require("node:crypto");
|
||||
const {
|
||||
chmod,
|
||||
mkdir,
|
||||
@@ -6,101 +6,85 @@ 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 { prepareNativeDshResources } = require("./native-dsh-resources.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 +92,151 @@ 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 prepareNativeDshResources(projectDir, platform, architecture, downloadPackage, lockedIntegrity);
|
||||
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 +245,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,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,64 @@
|
||||
# Assistant Storage Consumers
|
||||
|
||||
The S02 domain consumers use the typed port in
|
||||
`src/main/assistant-storage-port.ts`. This implements the consumer side of
|
||||
[Desktop Storage Foundation](./desktop-storage-foundation.md). Production
|
||||
composition in `src/main/index.ts` supplies the client after storage readiness;
|
||||
the consumer services do not construct an Assistant database.
|
||||
|
||||
## Main Integration
|
||||
|
||||
```ts
|
||||
const storage = new DesktopStorageClient({
|
||||
...paths,
|
||||
onChanged: createAssistantStorageOnChanged({
|
||||
onMagicNotesChanged,
|
||||
onMagicTodosChanged,
|
||||
onModelUsageChanged,
|
||||
onExecutionStatsChanged
|
||||
})
|
||||
})
|
||||
await storage.ready
|
||||
const assistant = createAssistantStoragePort(storage)
|
||||
```
|
||||
|
||||
`createAssistantStoragePort` binds only the explicit Assistant operation allowlist
|
||||
and `supervisionContext`. Its return type is `AsyncAssistantStoragePort`: every
|
||||
operation returns a Promise. There is no SQL, arbitrary property proxy, remote
|
||||
callback or live repository API. `AssistantStoragePort` uses `Awaitable` results
|
||||
so existing synchronous database fixtures can be injected into unit tests.
|
||||
Domain change callbacks run in Main through `DesktopStorageClient.onChanged`.
|
||||
|
||||
The subagent service, native client coordinator, terminal manager, computer-control
|
||||
audit sink, channel dedup/outbox, image service and Magic Notes IPC helpers accept
|
||||
this port. Notes helpers retain sender validation, schema validation, source-message
|
||||
checks and revision checks. Note analysis awaits the model-usage callback before
|
||||
publishing its result. Channel and audit adapters return persistence completion to
|
||||
their existing asynchronous callers.
|
||||
|
||||
Image service `initialize`, `persistUploads`, `getOperation`, `cancel`,
|
||||
`cancelConversation`, `regenerate` and `dispose` return Promises. Await upload
|
||||
persistence before submitting generation. Await cancellation and shutdown writes.
|
||||
`onUsage` may return a Promise and must return the actual persistence operation.
|
||||
Notification-only `onOperation` errors are reported without changing provider
|
||||
outcomes; storage and usage-write failures reject instead of reporting durable
|
||||
success. Shutdown reports retained persistence failures.
|
||||
|
||||
Subagent event and model-usage callbacks may return Promises. The service awaits
|
||||
task creation, events and terminal status writes; shutdown also drains task
|
||||
creation and queued cancellation writes that have not entered a scheduler slot.
|
||||
Native client composition additionally forwards the storage-backed
|
||||
`openModelCallLedger` factory to both browser and terminal clients.
|
||||
|
||||
Stop producers and await service disposal before closing `DesktopStorageClient`.
|
||||
This consumer migration does not establish full desktop performance acceptance or
|
||||
replace the separate Main/IPC composition and concurrent acceptance work.
|
||||
|
||||
## Validation
|
||||
|
||||
The 11 focused suites pass with 94 tests, including asynchronous image storage,
|
||||
commit-before-notification, usage-write rejection, subagent shutdown during task
|
||||
creation, channel delivery, audit persistence and IPC sender/schema checks.
|
||||
Repository-wide type checking remains dependent on the concurrent Main/IPC,
|
||||
Knowledge and runtime-storage integrations. No latency or full concurrent-desktop
|
||||
acceptance claim is made by these unit and loopback tests.
|
||||
@@ -84,10 +84,10 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现
|
||||
|
||||
初期自动学习只允许产生可审查候选,不允许自动修改:
|
||||
|
||||
- 工具权限和审批策略。
|
||||
- 工具启用、分配与资源范围。
|
||||
- Electron 安全边界。
|
||||
- 项目根目录和数据访问范围。
|
||||
- Runtime 当前用户执行权限与 Ask/Execute 边界。
|
||||
- Runtime 当前用户执行权限与实际能力范围。
|
||||
- 系统级提示词。
|
||||
- 远程消息发送或其他外部副作用策略。
|
||||
|
||||
@@ -105,11 +105,11 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现
|
||||
|
||||
### 3.2 产品目标
|
||||
|
||||
- 复用现有 Project、Conversation、Task、Run、Artifact、Approval 和 Notification 能力。
|
||||
- 复用现有 Project、Conversation、Task、Run、Artifact 和 Notification 能力。
|
||||
- 保持每个 Task 只关联一条 Conversation,同时允许一条 Conversation 承载多个 Task,不为
|
||||
同一项工作建立第二份内容载体。
|
||||
- 为所有后台工作提供统一的幂等、租约、恢复、取消、预算和审计语义。
|
||||
- 保持 Ask 只读,Execute 继续经过现有能力和审批控制。
|
||||
- 沿用统一执行与能力配置,不引入工作模式或一般工具审批。
|
||||
- 保持本地优先,应用退出后不虚假承诺后台持续执行。
|
||||
- 保证项目、会话、自动化和实验 Run 之间的记忆隔离。
|
||||
- 先建立可观测和可评估能力,再允许任何形式的自动行为改变。
|
||||
@@ -120,7 +120,7 @@ SQLite、FTS 和可选本地向量已经足够支撑第一阶段。只有出现
|
||||
|
||||
- 将 GoodBuddy 变为需要常驻服务器、Redis 或云端控制面的多租户平台。
|
||||
- 在应用退出后依靠未安装的系统服务继续运行任务。
|
||||
- 默认允许无人值守高风险 Execute。
|
||||
- 自动扩大无人值守任务的能力或资源范围。
|
||||
- 让模型自行扩大工具、目录、知识库、记忆或网络访问范围。
|
||||
- 允许多个实验 Run 并发修改同一个用户工作区。
|
||||
- 记录键盘、持续录屏或静默监控其他应用。
|
||||
@@ -197,13 +197,12 @@ Conversation
|
||||
- Plan 版本。
|
||||
- 项目和工作目录。
|
||||
- Runtime 和模型配置引用。
|
||||
- 工作模式。
|
||||
- 提示和变量。
|
||||
- 工具、Skills、MCP 和知识库范围。
|
||||
- 可读、可写记忆分区。
|
||||
- 监督策略和评估器版本。
|
||||
- 预算和并发限制。
|
||||
- 审批策略。
|
||||
- 能力分配。
|
||||
|
||||
运行开始后的设置变化只影响下一次 Run。用户可以在 Task 执行记录中查看当次快照与最新
|
||||
Plan 的差异,但 Run 不作为独立导航对象。
|
||||
@@ -290,14 +289,17 @@ Runtime 边界完成,用户可见结果通过所属 Task 汇入关联 Conversa
|
||||
当前实现以 Main 和 SQLite 中的 `conversation_queue_items` 作为每条 Conversation 的权威
|
||||
输入队列,而不是在 Renderer 分别维护聊天草稿队列和 Scheduled Task 队列:
|
||||
|
||||
- 普通消息在发送时冻结 Runtime、工作模式、专家/团队、知识范围和附件上下文,再以
|
||||
- 普通消息在发送时冻结 Runtime、专家/团队、知识范围和附件上下文,再以
|
||||
`source=user` 入队;附件内容使用有界序列化保存,应用重启后仍可恢复。
|
||||
- 到期或手动启动的 Scheduled Task 先建立 `schedule_run`,再以 `source=schedule` 进入同一
|
||||
队列。Scheduler 不再绕过队列直接调用 Runtime。
|
||||
队列,只派发任务要求和关联会话身份。执行时读取会话最新历史和配置,不保存任务上下文快照。
|
||||
- Main 对每条 Conversation 只保留一个活动请求或 Renderer 派发保留位。默认按 FIFO 认领;
|
||||
全局 Scheduled Task 执行仍受最多 4 项并发限制。
|
||||
- 用户消息由 Main 派发给 Renderer,由 Renderer 建立用户消息和流式助手消息后调用
|
||||
`agent.run`;Scheduled Task 由 Main 直接执行。两条路径共享同一 Conversation 活动锁。
|
||||
Scheduled Task 不另设执行器或并发计数。
|
||||
- 两种来源均由 Main 派发给 Renderer,建立普通用户消息和流式助手消息后调用 `agent.run`。
|
||||
Renderer 加载会话及设置后才声明队列可派发。SSH 项目同样通过该入口选择远程 Runtime,
|
||||
使用普通请求的上下文、工具范围、取消和恢复流程。
|
||||
- `schedule_run.id` 作为普通请求 ID;普通请求终态同步该次计划运行与稳定产品 Task 的状态。
|
||||
尚未接受的派发可释放重试;已经接受的运行在重启后按普通 Task 状态结算或恢复,不重新发送。
|
||||
- 当前执行到达终态后再认领下一项。删除只移除尚未执行的项;“立即中断并插入”取消当前
|
||||
请求并把所选项设为下一项,不重排其他项。
|
||||
- 每条 Conversation 最多保留 20 个用户可提交的待执行项。启动时将未完成的派发恢复为
|
||||
@@ -422,15 +424,14 @@ Renderer 只通过显式 IPC 列出、加入、删除、提升、释放和接收
|
||||
|
||||
```text
|
||||
任务中心
|
||||
├─ 需要关注
|
||||
├─ 进行中
|
||||
├─ 已暂停
|
||||
├─ 暂停
|
||||
└─ 已结束
|
||||
└─ 打开任务自身
|
||||
|
||||
任务自身
|
||||
├─ 消息时间线
|
||||
├─ Run、步骤与审批活动
|
||||
├─ Run、步骤与工具活动
|
||||
├─ 监督、指标与证据
|
||||
└─ 独立成果
|
||||
|
||||
@@ -449,9 +450,9 @@ Task 或实验对象;详细范围与交互契约见
|
||||
|
||||
## 13. 安全与隐私
|
||||
|
||||
1. Ask 在 Runtime 边界保持只读,而不只是提示词要求只读。
|
||||
2. Execute 继续通过现有审批、主机执行策略、工具和目录控制。
|
||||
3. 无人值守只允许用户显式批准的能力集合;遇到未预授权动作时进入等待审批。
|
||||
1. 正常请求按已注册工具、启用与分配配置执行,内部文本调用保持无工具。
|
||||
2. 保留主机账号权限、参数与资源范围校验,不恢复一般工具审批策略。
|
||||
3. 无人值守不获得未启用能力;需要结构化回答时按后台入口的既有支持范围处理。
|
||||
4. Supervisor、Evaluator 和 Heartbeat 都把消息、工具输出、记忆和成果视为不可信数据。
|
||||
5. 监督器不能读取隐藏推理,只能读取产品允许持久化和展示的事件。
|
||||
6. 所有跨分区读取由 Main 根据绑定关系决定,Renderer 不能提交任意分区 ID。
|
||||
@@ -467,7 +468,7 @@ Task 或实验对象;详细范围与交互契约见
|
||||
|
||||
- 触发来源和计划版本。
|
||||
- 计划目标和当前 `goalStatus`。
|
||||
- Runtime、工作模式和工作目录。
|
||||
- Runtime 和工作目录。
|
||||
- 实际读取的知识库与记忆分区。
|
||||
- 实际调用的模型、Token、工具、耗时和成果大小。
|
||||
- 当前预算和剩余预算。
|
||||
@@ -539,7 +540,7 @@ Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心
|
||||
|
||||
- 增加目标、成功标准、约束、预算和停止条件。
|
||||
- 支持有界的观察、计划、行动、评估循环。
|
||||
- 默认 Ask 或需要逐步审批的 Execute。
|
||||
- 普通请求采用统一执行;专用内部分析采用无工具调用。
|
||||
|
||||
### 阶段 4:并行实验
|
||||
|
||||
@@ -576,8 +577,8 @@ Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心
|
||||
- [ ] Scheduled Task 的重复触发和并行 Job 不创建新的顶层 Task。
|
||||
- [ ] 每次自动执行的内部 Run 都记录触发原因、目标、范围、预算、状态和结果,并在所属
|
||||
Task 下提供有界可观测信息。
|
||||
- [ ] Ask 自动化无法调用写工具或产生外部副作用。
|
||||
- [ ] Execute 自动化不能绕过现有审批、主机执行策略和能力控制。
|
||||
- [ ] 专用内部文本调用不获得工具。
|
||||
- [ ] 自动化不能绕过主机账号权限和能力范围。
|
||||
- [ ] 会话监督默认只评论,不能替用户发言或批准工具。
|
||||
- [ ] 并行 Run 的变量、会话、运行记忆、任务和成果相互隔离。
|
||||
- [ ] 失败 Run 不参与最佳结果选择,全部失败不报告成功。
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
# Desktop Storage And Concurrent Execution
|
||||
|
||||
## Decision and delivery
|
||||
|
||||
Updated 2026-10-05 against `2be57492`. The user selected one complete architecture
|
||||
delivery for PERF-15/PERF-16 and the necessary performance guards. This document
|
||||
supersedes the earlier proposal to leave full migration for a later discussion.
|
||||
The integrated migration and acceptance are not complete. The implemented storage
|
||||
foundation, production composition and remaining acceptance work are recorded in
|
||||
[Storage Foundation Integration](./desktop-storage-foundation.md).
|
||||
Local fixes in `b90ff50f` remain the measured starting point.
|
||||
|
||||
Deliver the integrated design in one candidate, with normal product workflows and
|
||||
concurrent acceptance passing together. Internal implementation dependencies do
|
||||
not create separate partial product deliveries. A worker-only prototype, an async
|
||||
facade over Main SQL, or a dual-write transition does not meet completion criteria.
|
||||
|
||||
This document owns architecture and scope. [Performance principles](./performance-principles.md)
|
||||
own normative budgets and concurrency rules; [concurrent acceptance](../quality/concurrent-desktop-acceptance.md)
|
||||
owns reproducible workloads and evidence. The [roadmap](../roadmap/product-performance-experience-improvement-plan.md)
|
||||
tracks status, and the [local repair record](../review/kiss-local-fixes-2026-10-05.md)
|
||||
preserves earlier measurements. Migration completion requires new acceptance evidence.
|
||||
|
||||
## Requirements
|
||||
|
||||
| ID | Required outcome |
|
||||
| --- | --- |
|
||||
| SA-01 | Desktop Main does not open or query business SQLite, directly or through a helper. |
|
||||
| SA-02 | Complete domain transactions execute under one write owner per database. |
|
||||
| SA-03 | Multiple projects, conversations and subtasks progress without unrelated global waits. |
|
||||
| SA-04 | Requests, event buffers, result payloads and caches have bounded lifetimes and capacity. |
|
||||
| SA-05 | Persistence, ordering, cancellation, recovery and user interaction semantics are preserved. |
|
||||
| SA-06 | Startup, process failure and shutdown have one tested ownership path. |
|
||||
| SA-07 | Static guards and concurrent production-path tests enforce the architecture. |
|
||||
| SA-08 | GoodBuddy eliminates unnecessary intermediate disk writes and duplicate full outputs; necessary temporary resources have explicit ownership and cleanup. |
|
||||
|
||||
## Responsibilities
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
UI[Renderer: domain stores and visible views] <--> MAIN[Main: trusted IPC and lifecycle]
|
||||
MAIN <--> STORAGE[Storage utilityProcess: domain operations and write owners]
|
||||
STORAGE <--> READ[Existing bounded read workers]
|
||||
MAIN <--> RUNTIME[Runtime hosts and compute workers]
|
||||
MAIN <--> AGENT[Remote Agent]
|
||||
```
|
||||
|
||||
- Renderer retains presentation, local interaction state and disposable visible-data
|
||||
caches. Domain selectors subscribe narrowly. Hidden views retain required state,
|
||||
not continuous full rendering or whole-history refreshes.
|
||||
- Main retains windows, trusted sender/schema checks, OS APIs, credential decryption,
|
||||
network/runtime orchestration and process lifecycle. SQL, large result assembly,
|
||||
hashing and bulk attachment file work do not execute on its event loop.
|
||||
- One desktop storage utility process owns the existing business database files and
|
||||
exposes typed asynchronous domain operations. Keep synchronous SQLite inside this
|
||||
dedicated environment where it simplifies transaction correctness.
|
||||
- Reuse bounded read-only workers for expensive reads. They open only after upgrade,
|
||||
read committed WAL snapshots, and do not run initialization or recovery. Storage
|
||||
owns their lifecycle; a project or conversation does not create a new worker.
|
||||
- Parsing, OCR, embedding and Runtime execution retain their existing hosts. Network
|
||||
requests and model execution must not run inside storage transactions or hold the
|
||||
storage write queue. This work does not relocate browser-dependent OCR engines.
|
||||
- Remote Agent databases remain remote. Changes to desktop storage must preserve
|
||||
remote replay, controller ownership and commit-before-ACK.
|
||||
|
||||
One write owner means exclusive business-write authority, not necessarily one OS
|
||||
thread for all reads. Startup upgrades and existing maintenance/checkpoint workers
|
||||
must be explicitly owned and excluded from competing business writes. Keep their
|
||||
current bounded mechanisms where valid; do not create independent writers per domain.
|
||||
|
||||
## Ownership inventory
|
||||
|
||||
Inventory records current owners; destinations below are targets, not implementation facts.
|
||||
|
||||
| Surface | Current entry | Required destination |
|
||||
| --- | --- | --- |
|
||||
| `assistant.sqlite` | `src/main/assistant/assistant-database.ts`, constructed in Main | Storage service: conversations, tasks/events, activity, notes metadata, channels, supervision and timing persistence |
|
||||
| `knowledge.sqlite` | `src/main/knowledge/knowledge-service.ts` and `knowledge-database.ts` | Storage service: schema, writes and external metadata; existing workers for costly reads |
|
||||
| `conversation-attachments.sqlite` and referenced files | `src/main/conversation-attachment-storage.ts` | Storage service: references, adoption, reconciliation and bounded file IO |
|
||||
| Document-result persistent lookup and note bodies | `document-result-storage.ts`, `magic-notes` storage | Async domain access to associated files; preserve original directories and reference semantics |
|
||||
| Local remote-runtime binding DB | `src/main/remote-agent/managed-remote-execution-services.ts` | Storage service: transactional binding transitions and uniqueness |
|
||||
| Native terminal / DS Web model-call ledgers | `native-terminal-client.ts`, `native-dsh-web-client.ts` instantiate `AgentModelCallLedger` | Storage service manages desktop ledger files under the existing client lifecycle; Main retains model/credential routing |
|
||||
| Read, checkpoint and upgrade connections | `readonly-query-worker.ts`, `assistant-storage-worker.ts` | Service-owned steady-state reads/checkpoints; exclusive upgrade execution before readiness |
|
||||
| Supervision manifest writer | `readonly-query-worker.ts` supervision mode and `SupervisionReviewStore` | Reuse scanning and manifest logic, but route its business mutations through the same write owner as publication; remove independent writer authority |
|
||||
| Remote Agent journal, owners, prompts and ledger | `src/agent-daemon/daemon.ts`, `runtime-composition.ts` | Existing remote ownership, unchanged by desktop connection migration |
|
||||
| Encrypted JSON settings and transient runtime caches | Existing settings/runtime services | Keep existing appropriate owners; async settings do not move merely to match a diagram |
|
||||
|
||||
An import scan must also find `openPrivateSqliteDatabase` and transitive constructors:
|
||||
an implementation under `agent-daemon` can still open SQLite when called in desktop
|
||||
Main. Do not weaken shared Agent code or relocate remote databases as a side effect.
|
||||
|
||||
Preserve existing database names, table formats, IDs, paths, scopes and durability
|
||||
settings. Process relocation alone does not require a schema bump. Necessary schema
|
||||
changes follow the [migration guide](../development/database-migrations.md).
|
||||
|
||||
## Existing work and consolidation
|
||||
|
||||
This inventory was checked against production callers on 2026-10-05. Reuse means
|
||||
preserving working algorithms and contracts, not claiming their current transport,
|
||||
cancellation or close semantics already meet the target. Implement the replacement
|
||||
at the existing seam and remove superseded callers in the same delivery.
|
||||
|
||||
| Existing mechanism | Reuse or extend | Retire or avoid |
|
||||
| --- | --- | --- |
|
||||
| [ReadonlyQueryReader](../../src/main/readonly-query-reader.ts), [worker handlers](../../src/main/readonly-query-worker.ts) | Reuse request correlation, named operations, errors, lazy lifecycle and reader handlers; adapt the outer utility transport and strengthen typed operations, readiness, byte bounds and settlement-aware close | No second equivalent storage RPC client or admission queue; remove production infrastructure fallback through `readWithFallback`, not legitimate owner-local transaction reads |
|
||||
| [Startup storage](../../src/main/assistant-storage-startup.ts), [upgrade worker](../../src/main/assistant-storage-worker.ts) | Preserve migration algorithms, progress, retry and cancellation; move SQL preflight into the storage-owned exclusive startup path | Remove Main SQL preflight and competing upgrade launch ownership; do not add a new migration framework |
|
||||
| [Checkpoint and supervision connections](../../src/main/assistant/assistant-database.ts), [review store](../../src/main/assistant/supervision-review-store.ts) | Reuse PASSIVE checkpoint and manifest scan/revision algorithms; make checkpoint exit awaited and manifest writes owner-controlled | No second maintenance service or independent business writer hidden in a read-worker entry |
|
||||
| [EmbeddingIndexRepository](../../src/main/knowledge/embedding-index-coordinator.ts), [binding store interface](../../src/main/agent/runtime-session-binding-store.ts), [activity repository](../../src/main/assistant/activity-history-repository.ts) | Reuse domain interfaces and transaction algorithms; adapt the desktop backing implementation and async callers | Do not add remote object proxies or duplicate repositories; Promise-returning methods are not proof of off-Main execution |
|
||||
| [RemoteEventBatcher](../../src/main/remote-event-batcher.ts) | Extend existing batching with awaited persistence/flush and pending-plus-in-flight bounds; update recovery, background and interactive IPC consumers | No second durable event coalescer, receipt ledger or retry queue; no ACK or forwarding before commit |
|
||||
| [AgentModelCallLedger](../../src/agent-daemon/agent-model-gateway.ts), [terminal client](../../src/main/agent/native-terminal-client.ts), [DS Web client](../../src/main/agent/native-dsh-web-client.ts) | Preserve schema, claim/deduplication and uncertain outcomes; desktop access becomes awaited storage operations | Do not relocate remote Agent ledgers; remove desktop concrete synchronous opens and early directory deletion |
|
||||
| [Utility transport](../../src/main/knowledge/embedding-utility-transport.ts), [Harness readiness](../../src/main/agent/deepseek-harness-utility-launcher.ts), [shutdown helpers](../../src/main/shutdown.ts) | Reuse fork/entry/exit adapters and phased application shutdown; add storage readiness and drain at those seams | No new generic process supervisor, duplicate shutdown path or copied Harness streaming/version protocol |
|
||||
| [Runtime controller](../../src/main/agent/runtime-controller.ts), [selected runtimes](../../src/main/agent/selected-runtime-manager.ts), [remote backend](../../src/agent-daemon/runtime-acp-backend.ts) | Keep request ownership, session reuse and retirement; inspect remaining global open/prepare/close waits with healthy peers | No second Runtime pool or global execution manager; the prior route-cleanup fix does not prove every global wait is gone |
|
||||
| [Subagent scheduler](../../src/main/assistant/subagent-scheduler.ts), [supervision model pool](../../src/main/assistant/supervision-model-pool.ts), [review admission](../../src/main/assistant/supervisor-service.ts) | Retain task slots, review single-flight and per-model-call pool as distinct existing scopes; extend capacity/settlement tests where needed | Do not merge ordinary chat into the supervision pool or create another task state machine/provider scheduler |
|
||||
|
||||
The current ordinary reader removes a cancelled request from `pending` before worker
|
||||
settlement, and `close()` provides no exit barrier. Supervision waits longer and can
|
||||
substitute cancellation for a successful result. Extend these semantics to
|
||||
represent actual execution and committed outcomes; do not copy them unchanged into
|
||||
the storage client and call its capacity or shutdown complete.
|
||||
|
||||
Supervision's temporary scan table belongs to one SQLite connection. Retain the
|
||||
bounded scan, transfer only bounded manifest data to its write owner, and preserve
|
||||
initialization/resume invariants. Existing 200-row commits run in synchronous loops;
|
||||
they do not themselves yield an execution turn. Yield at safe existing batch boundaries
|
||||
without dividing final atomic publication or introducing a general scheduler.
|
||||
|
||||
Renderer consolidation stays within existing domains:
|
||||
|
||||
- [Conversation store](../../src/renderer/src/conversation-store.ts) and
|
||||
[persistence](../../src/renderer/src/conversation-persistence.ts) already own ordered
|
||||
deltas, save serialization, history requests and quit flushing. Extend those paths,
|
||||
not a second `conversation-sync` queue. Its current React-aware integration is not
|
||||
a reason to introduce another store library.
|
||||
- [Activity sync](../../src/renderer/src/activity-sync.ts) owns paging, pending writes
|
||||
and retry; [task sync](../../src/renderer/src/task-sync.ts) owns task/artifact refresh.
|
||||
Tighten projections and retention there. A resolved flush that caught an error and
|
||||
requeued data is not a durable success barrier; extend its explicit completion
|
||||
semantics where dependent reads or shutdown require persistence.
|
||||
- [Live messages](../../src/renderer/src/live-message-store.ts),
|
||||
[public event buffering](../../src/main/agent-event-buffer.ts), and
|
||||
[terminal batching](../../src/renderer/src/terminal-output-batcher.ts) serve distinct
|
||||
display stages. Keep them separate from durable remote batching and avoid an
|
||||
additional universal event bus or a second coalescing delay.
|
||||
- Retain domain cache owners: [keep-alive](../../src/renderer/src/keep-alive-cache.ts),
|
||||
[Markdown](../../src/renderer/src/markdown-render-cache.ts),
|
||||
[channel status](../../src/renderer/src/channel-status-store.ts), and
|
||||
[Supervisor scope reads](../../src/renderer/src/SupervisionStories.tsx).
|
||||
Preserve pinned-pane exceptions and scope/revision guards; strengthen bounds at
|
||||
their existing owners.
|
||||
|
||||
Runtime session behavior remains authoritative in the
|
||||
[reuse design](../features/assistant-workbar/runtime-process-reuse-technical-design.md).
|
||||
[Task timing](../features/task-and-job/technical-design.md) and
|
||||
[supervision model scheduling](../features/conversation-supervision/technical-design.md)
|
||||
retain their existing semantics. Ending an elapsed-time segment on abort does not
|
||||
release an execution slot before cleanup; do not unify these clocks or restore
|
||||
historical timing scans/statistics workers during this migration.
|
||||
|
||||
Completion includes a removal inventory tied to the table above: old Main opens,
|
||||
external live-store factories, production sync fallbacks, obsolete transport callers
|
||||
and independent business writers have no production references. Remove only the
|
||||
superseded implementations, not valid in-owner synchronous algorithms, remote stores,
|
||||
released-data compatibility or historical test evidence. No permanent dual path or
|
||||
unused compatibility wrapper is part of the delivery.
|
||||
|
||||
## Operation boundary
|
||||
|
||||
Reuse repository algorithms behind domain operations: save conversation changes,
|
||||
commit remote events, publish a review, adopt attachment references, or transition
|
||||
a runtime binding. Public renderer IPC can keep its current shape while Main awaits
|
||||
the storage client. No generic SQL endpoint or function serialization is introduced.
|
||||
|
||||
Existing transaction callbacks and returned live repository objects stay inside the
|
||||
owner. Replace their external use with typed inputs/results. In particular,
|
||||
`readSnapshot`, Knowledge publication callbacks and Supervisor store factories must
|
||||
not be transported or accept an asynchronous callback that outlives a sync transaction.
|
||||
|
||||
The channel extends the request/handler and utility-adapter seams identified above,
|
||||
correlates requests/results and carries named operations,
|
||||
serializable arguments, cancellation and readiness/failure. Reuse installed Electron
|
||||
transport patterns; do not add a general RPC dependency, persisted transport sequence,
|
||||
protocol negotiation or a service discovery layer for this bundled child process.
|
||||
Validate actual transport behavior for buffers, Maps and errors; use DTOs. Live classes,
|
||||
subscriptions and handles remain with their owners. Errors retain usable codes and causes
|
||||
without credentials or user content in diagnostic logs.
|
||||
|
||||
Queries return the requested projection. Lists use summaries/pages; source bodies,
|
||||
tool outputs and attachments load through detail paths. Large results must not be
|
||||
JSON-assembled in Main or copied through several caches. Existing source records are
|
||||
not truncated to satisfy a latency check. A read transaction ends before posting its
|
||||
reply so a slow consumer cannot keep a WAL snapshot open.
|
||||
|
||||
## Concurrency and ordering
|
||||
|
||||
Keep ordering at the smallest required scope. A conversation's persisted sequence
|
||||
and a database's commits remain ordered; independent model waits, sessions and
|
||||
read requests do not share a global long-running lock. Runtime process reuse retains
|
||||
per-binding/session/operation identity; cancelling one request must not stop its peers.
|
||||
|
||||
Use existing finite concurrency and batch mechanisms before adding scheduling code.
|
||||
Apply capacity to queued bytes as well as operation count where payload size varies.
|
||||
When a queue fills, wait or report the existing actionable busy/error state; never
|
||||
silently discard user messages, tool results or acknowledged events. Obsolete read
|
||||
refreshes and display progress can coalesce; persisted evidence cannot. Preserve one
|
||||
bounded batch of work before yielding so other admitted producers can progress;
|
||||
change scheduling only where concurrent measurements demonstrate starvation.
|
||||
|
||||
Heavy reads use the read workers and leave necessary writes able to progress. UI/control
|
||||
requests must not wait behind model networking, import parsing or route cleanup.
|
||||
Control processing must not depend on acquiring another execution slot. This is a
|
||||
resource-boundary rule, not a mandate for a new global priority scheduler.
|
||||
|
||||
For long publications, compute immutable data before the write transaction when
|
||||
possible, then validate current database preconditions inside the transaction and
|
||||
commit the complete result/checkpoint atomically. Do not await models, filesystem
|
||||
callbacks or IPC while holding a write transaction. An atomic publication cannot be
|
||||
split into externally visible partial commits to improve timing. Worker relocation
|
||||
does not eliminate the single-writer bottleneck: acceptance measures queue wait and
|
||||
commit time, as well as Main responsiveness.
|
||||
|
||||
## Persistence and cancellation
|
||||
|
||||
| Operation | Required completion meaning |
|
||||
| --- | --- |
|
||||
| Cached UI preference/read | Synchronous memory access is allowed; cache ownership and invalidation are explicit |
|
||||
| Existing coalesced local saves | Preserve current flush semantics and expose the existing flush barrier to dependent reads/exit |
|
||||
| Remote event batch | Persist event/checkpoint under existing durability, then notify/ACK; enqueueing is not commit |
|
||||
| Channel outbox / runtime ledger | Preserve duplicate-dispatch prevention and uncertain-outcome handling; no blind retry after lost replies |
|
||||
| Review publication | Facts, result status and checkpoints commit together or roll back together |
|
||||
|
||||
Abort removes queued work before dispatch. In-flight work reaches a defined safe
|
||||
point and reports its actual outcome; capacity is released only after settlement or
|
||||
confirmed host exit. Caller cancellation, operation termination and resource release
|
||||
are distinct observations, not three new persistent product states.
|
||||
|
||||
`worker_threads` SharedArrayBuffer cancellation must not be assumed to cross
|
||||
`utilityProcess` IPC. Reuse it only within verified thread boundaries. A message
|
||||
cannot interrupt synchronous SQL while the receiver is blocked. Keep SQL/transaction
|
||||
units bounded, check cancellation between safe units, and report a completed commit
|
||||
as committed even if cancellation arrived too late. Do not terminate a shared storage
|
||||
process to cancel one request or promise instantaneous rollback of a committed write.
|
||||
|
||||
Filesystem adoption and SQL references are not automatically one atomic transaction.
|
||||
Preserve existing staging, adoption and reconciliation order with async file APIs.
|
||||
Do not delete originals or release protected in-flight files early; do not introduce
|
||||
a cross-database transaction engine or recovery journal.
|
||||
|
||||
## Lifecycle and failure
|
||||
|
||||
Main starts and supervises one storage process using existing utility-process
|
||||
patterns. Process spawn is not readiness: required migrations, connection opening
|
||||
and startup recovery must finish before storage-dependent operations are accepted.
|
||||
Show the existing startup/upgrade progress and retry UI. Render the application shell
|
||||
when safe without marking unavailable data as an empty successful result.
|
||||
|
||||
After process loss, reject affected pending requests with bounded diagnostics, stop
|
||||
admitting writes, confirm exit and close transport resources before replacement.
|
||||
The existing user retry/reopen lifecycle can re-establish storage readiness. Read-only
|
||||
work can be repeated when requested. For writes without a response, consult existing
|
||||
task/event/result identities and deduplication before claiming failure or retrying;
|
||||
model dispatch marked uncertain must not be sent again automatically. No Main SQL
|
||||
fallback, silent empty database, second durable receipt ledger or unbounded restart loop.
|
||||
|
||||
Shutdown stops producers and new admission, cancels unnecessary reads, drains required
|
||||
writes, closes readers/checkpoints/connections, and observes storage process exit.
|
||||
Extend the existing phased shutdown and deadline, including explicit storage failure
|
||||
reporting; existing settled-error helpers alone do not prove a successful drain. Native client ledger
|
||||
directories are removed only after their connection is closed. Upgrade/maintenance
|
||||
requires exclusive ownership before changing files, following the existing maintenance
|
||||
path; it cannot open an extra Main business connection.
|
||||
|
||||
## Output and temporary resource ownership
|
||||
|
||||
This work is included in the same architecture delivery. The [source investigation](./runtime-storage.md#output-and-temporary-file-investigation)
|
||||
records current producers and persistence gaps. Scope is GoodBuddy-created material;
|
||||
third-party MCP/Runtime internals and user workspaces are not cleanup targets.
|
||||
|
||||
Decide content ownership before choosing a temporary directory. Ordinary requests,
|
||||
responses, concatenation and short outputs stay in bounded memory. Where an existing
|
||||
record or artifact already contains complete content, pass its reference and read pages
|
||||
from that owner. Do not write a temporary copy merely to cross IPC or call MCP.
|
||||
|
||||
Current tool events often contain only previews. Preserve full paging access by adapting
|
||||
the existing `PagedOutputStore` interface to the storage/attachment owner: output that
|
||||
must outlive bounded in-memory capture has one backing content object, with events
|
||||
carrying previews and its reference. Write directly to that backing object; do not spool
|
||||
to Temp and later copy the same bytes to another full-output store. Reuse existing file
|
||||
adoption/reference/release operations and paged reads, without a second output database,
|
||||
generic blob service or durable transport journal. Final answers, previews and full
|
||||
stdout/stderr remain distinct data.
|
||||
|
||||
The storage object's retention follows its consumer. Call-only output is released when
|
||||
its existing owner no longer needs paging; content referenced by persisted history is
|
||||
released through that history's existing reference cleanup. Being stored by the storage
|
||||
service does not make every intermediate output permanent. Keep small call-only output
|
||||
in memory and preserve existing model limits. Avoid adding whole-output copies to events
|
||||
or retaining all concurrent streams in an unbounded buffer. Size alone does not justify
|
||||
an additional temporary copy.
|
||||
|
||||
The replacement must specify the complete-content reference and byte cursor through
|
||||
process, search, subagent and `output_read` paths before removing the old file backend.
|
||||
Inspect remote mapping separately: it does not use the local process spool and cannot
|
||||
recover omitted bytes from already-pruned Agent events. Preserve supported remote
|
||||
content/ACK semantics; this work does not promise new full-history capture from third-party
|
||||
runtimes. Remove the old spool creation and synchronous Main paging only once the shared
|
||||
replacement preserves the existing full-output contract.
|
||||
|
||||
### Necessary temporary material
|
||||
|
||||
External CLI configuration, plugin modules and atomic replacement/install staging may
|
||||
still require files. Reuse environment/in-memory configuration when already supported;
|
||||
otherwise keep the actual external contract. Atomic sibling files and installer staging
|
||||
stay on their required filesystem under their existing owner, not a forced central root.
|
||||
|
||||
For disposable runtime launch material, pass a root from application initialization
|
||||
through existing constructors; create it lazily:
|
||||
|
||||
```text
|
||||
<userData>/temp/goodbuddy-runtime/<appVersion>/<runId>/
|
||||
goodbuddy-runtime-launch/
|
||||
```
|
||||
|
||||
Add purpose directories only for evidenced necessary files. Do not precreate process,
|
||||
search or subagent output trees. User-visible disposable roots retain `goodbuddy-`
|
||||
prefixes. Tests use `<repository>/temp/goodbuddy-<task>-<runId>/`; remote resources use
|
||||
GoodBuddy-owned prefixed roots on the Host. Do not modify global `TEMP` or relocate
|
||||
third-party histories/caches through an indiscriminate environment override.
|
||||
|
||||
The creator removes partial creations on failure. Existing service shutdown closes
|
||||
children/handles and then releases its resources. Startup, including the next upgrade,
|
||||
reclaims obsolete owned run directories after the existing single-instance check and
|
||||
verification that no owning child remains active. Version/run IDs identify candidates;
|
||||
they do not establish inactivity. Reuse process ownership checks, with only the minimal
|
||||
owner information needed to identify surviving children; do not create a lease service.
|
||||
Cleanup runs in bounded background work and cannot delay the first screen with a full
|
||||
filesystem scan. Failed deletion leaves the known directory available to retry.
|
||||
|
||||
Legacy OS-Temp prefixes require an explicit list of layouts, creator and inactivity
|
||||
evidence. Reclaim confirmed obsolete instances, preserve ambiguous/live directories,
|
||||
and report the remaining scope. Never delete arbitrary `goodbuddy-*`, `.tmp` or `.partial`
|
||||
paths by age. Existing installer/reference reconciliation retains its own roots and
|
||||
rules; no global garbage collector, cleanup ledger or user-data migration is introduced.
|
||||
|
||||
## KISS constraints
|
||||
|
||||
- Keep Electron, TypeScript, SQLite and existing provider/runtime integrations.
|
||||
- Add the one storage utility process, not processes per project/session/subtask.
|
||||
- Reuse reader, checkpoint, parser, batching and lifecycle mechanisms where appropriate.
|
||||
- Keep credentials/OS integration in Main; storage receives only required business data.
|
||||
- Do not add general schedulers, distributed transactions, durable RPC journals,
|
||||
compatibility readers for unshipped intermediates, new approval gates or user modes.
|
||||
- Synchronous cache reads and pure calculations need not become RPC calls.
|
||||
- The runtime host, database driver and UI framework are not replaced as incidental work.
|
||||
|
||||
## VS Code reference
|
||||
|
||||
VS Code demonstrates service separation, asynchronous execution, lazy activation,
|
||||
cached state and coordinated close. Its application/profile/workspace storage is
|
||||
owned by Electron Main, uses async `@vscode/sqlite3`, and batches cached key/value
|
||||
writes. GoodBuddy retains `DatabaseSync` in a dedicated execution environment because
|
||||
its relational history, graph queries and durable event acknowledgements differ.
|
||||
Do not copy full-state memory loading or empty-store recovery into business history.
|
||||
|
||||
Sources reviewed on 2026-10-05, upstream main; no same-machine benchmark is claimed:
|
||||
|
||||
- [Connection ownership](https://github.com/microsoft/vscode/blob/main/src/vs/platform/storage/electron-main/storageMain.ts)
|
||||
- [SQLite backend](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/storage/node/storage.ts)
|
||||
- [Cache and flush lifecycle](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/storage/common/storage.ts)
|
||||
- [Extension hosts](https://code.visualstudio.com/api/advanced-topics/extension-host)
|
||||
|
||||
## One-delivery completion
|
||||
|
||||
SA-01 through SA-08 must pass together under the [acceptance matrix](../quality/concurrent-desktop-acceptance.md).
|
||||
All listed desktop owners, normal/background/error/cancel/exit callers, and native
|
||||
client ledger paths must use the final boundary. Remove the superseded Main openings,
|
||||
direct storage access and synchronous fallbacks, including helper-based bypasses.
|
||||
No feature-disable workaround or loss of user interaction counts as completion.
|
||||
|
||||
Run production UI-to-storage tests, existing full validators, old-data integrity and
|
||||
concurrent before/after measurements; validate affected desktop-to-Agent behavior on
|
||||
the shared Linux x64 Host. Native platform CI verifies process startup, SQLite and
|
||||
packaged entry/resource paths. Follow the release runbook for production builds and
|
||||
packaged-app checks, including measurement runs.
|
||||
|
||||
The acceptance record must report latency, throughput, backlog and resource cleanup,
|
||||
including failures. A responsive UI with stalled background commits is not success.
|
||||
No target implementation, new performance baseline or migration completion is claimed
|
||||
by this documentation update.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Desktop File Storage Integration (S04)
|
||||
|
||||
Implemented domain operations, async consumers and production composition on
|
||||
2026-10-05. The storage owner is registered by the foundation and the current Main
|
||||
composition passes its file facades to ContextManager, document parsing and model
|
||||
output. This does not claim that the full application has passed concurrent or
|
||||
packaged acceptance.
|
||||
The governing constraints are [performance principles](./performance-principles.md)
|
||||
and [storage ownership](./desktop-storage-direction.md).
|
||||
|
||||
## Foundation API
|
||||
|
||||
`src/main/desktop-storage-files.ts` exports the complete integration surface:
|
||||
|
||||
- `DesktopFileStorageDomains`: intersect into `DesktopStorageDomains`. The domains
|
||||
are `attachments` and `documentResults`.
|
||||
- `attachmentStorageMethods` and `documentResultStorageMethods`: explicit allowed
|
||||
operations. `attachments.reconcile` is an additional owner composite, with
|
||||
arguments `[startup?, protectedIds?]`; its callback never crosses IPC.
|
||||
- `openDesktopStorageFiles(userDataRoot, hasOwner, persistentResultLookup?)`:
|
||||
construct once inside the existing utility process, after assistant and knowledge
|
||||
databases open. Pass the existing userData root, not the assistant DB directory.
|
||||
Add that root to foundation options and forward it in the client's open message.
|
||||
- `DesktopStorageFilesOwner.dispatch(domain, method, args, signal)`: dispatch the
|
||||
two domains here from the existing owner's dispatcher. It normalizes transported
|
||||
original bytes, checks operation allowlists, and supplies cancellation to result
|
||||
saves. Keep the existing transport admission and result limits.
|
||||
- Before readiness, run `files.attachments.reconcile(hasOwner, true)`. The callback
|
||||
uses the already-open assistant owner. It checks released references, marks
|
||||
interrupted parsing, removes stale call-only output refs, and reclaims owned
|
||||
orphan UUID directories. Do not run it concurrently with file producers.
|
||||
- After draining admitted requests, `await files.close()` before closing assistant
|
||||
and knowledge. Close drops call-only output refs, preserves history refs, removes
|
||||
temporary document results, and closes the existing attachment database.
|
||||
|
||||
No new database, RPC transport, SQL endpoint, or process is introduced. Existing
|
||||
attachment reference transactions and schema 1 remain in place. New relative paths
|
||||
use slash separators; reads and reconciliation also accept released Windows paths.
|
||||
|
||||
## Main And Knowledge
|
||||
|
||||
After `storage.ready`, production composition uses `createDesktopStorageFiles(storage)` to obtain
|
||||
`{ attachments, results, backingStore }`. Pass attachments to `ContextManager` and
|
||||
results to `DocumentParsingService` and Knowledge's result-access dependency.
|
||||
Do not also construct `ConversationAttachmentStorage` or `DocumentResultStorage`
|
||||
in Main. Facades only call the existing storage client; importing them does not open
|
||||
SQLite. Their result `close()` is intentionally inert: shared result shutdown belongs
|
||||
to `DesktopStorageFilesOwner.close()`, after all producers drain.
|
||||
|
||||
Context operations that now require await: `storePastedImage`, `enrichRequest`,
|
||||
`remove`, `serializeForQueue`, `restoreFromQueue`, `getDraft`, `hasImageInputs`,
|
||||
`validateForSend`, `saveDraft`, `sendOriginal`, and `cancelUnavailableImport`.
|
||||
Previously async import/reparse/image operations now await every storage operation.
|
||||
Conversation/target validators may return Promises. Memory-only `clear` and
|
||||
`activeContextIds` remain synchronous. `ContextManager.dispose()` cancels and drains
|
||||
the active import before collecting unreferenced assets. Stop other context producers
|
||||
and await their operations before disposing. Parsing-service disposal drains all
|
||||
diagnostics, including those without an operation ID.
|
||||
|
||||
IPC must await attachment/result calls, including `results.original(id)` before
|
||||
`shell.openPath`, and array predicates/maps after `get`, `draft`, `pendingParsing`,
|
||||
and `getDraft`. Replace callback-based Main reconciliation with
|
||||
`await attachments.reconcile(false, contextManager.activeContextIds())`.
|
||||
|
||||
Knowledge must accept `DocumentResultStorageAccess`, await `detach`, `original`,
|
||||
and `isTemporaryResult`, and remove Main `setPersistentLookup` registration. Supply
|
||||
that lookup to `openDesktopStorageFiles` instead: use owner-local
|
||||
`knowledge.getDocumentByParsedResultId(id)` and the existing
|
||||
`knowledge-assets/<libraryId>/<documentId>/<resultId>` directory and source location.
|
||||
Keep Knowledge's publish/move/detach/release order. Result release never deletes an
|
||||
external source original. No user-data directory migration is needed.
|
||||
|
||||
Project deletion supplies the project's conversation IDs captured before assistant
|
||||
deletion to `attachments.deleteProject(conversationIds, protectedIds?)`. It removes
|
||||
their refs in one attachment transaction, then collects only unreferenced assets.
|
||||
Shared assets remain available to other conversations. Call this after the assistant
|
||||
deletion commits; startup reconciliation covers an interrupted cleanup. Use
|
||||
`deleteConversation` for single conversations and reconciliation for full clearing.
|
||||
|
||||
## Full Output
|
||||
|
||||
Pass the returned `backingStore` to each existing `PagedOutputStore`. It implements
|
||||
`create/append/finish/read/release` directly in the existing attachment owner.
|
||||
An output handle's UUID identifies its attachment row throughout capture, promotion,
|
||||
history retention, restart, and deletion. Capture writes `original.txt` directly in
|
||||
the owned attachment directory. Promotion renames that directory; no temporary full
|
||||
copy or second backing is created. `request.json` contains only the descriptor.
|
||||
Appends are bounded to 64 KiB; backing reads allow 32 KiB plus UTF-8 lookahead.
|
||||
Small outputs remain in the existing bounded in-memory capture.
|
||||
|
||||
For history retention, await
|
||||
`attachments.outputAdopt(callOwnerId, handle, conversationId, kind, referenceId)`
|
||||
before the runtime releases its call reference. It returns the resource ID and adds
|
||||
the reference without replacing the owner's existing attachments. Persist the
|
||||
complete-output handle/reference in the owning event/message path; a preview alone
|
||||
is not full output. Retain the call until required history adoption/persistence has
|
||||
settled. Failed history publication uses existing release/reconciliation, not a
|
||||
second durable operation log. Incomplete captures cannot be adopted.
|
||||
|
||||
`release` drops only the matching `output-call` ref. Persisted history can page after
|
||||
call disposal or restart with
|
||||
`attachments.outputRead(conversationId, handle, cursor, length)`, reading the same
|
||||
bytes; it must use the owning conversation ID. History deletion uses the existing
|
||||
message/queue/conversation reference cleanup. Runtime cancellation must await writer
|
||||
abort/release. Startup removes stale call refs while retaining valid history refs.
|
||||
The remote Agent path does not use this desktop backing and is unchanged.
|
||||
|
||||
## Validation Boundary
|
||||
|
||||
The focused suites exercise real SQLite/files through owner operations and async
|
||||
facades, using structured-cloned arguments/results to cover Buffer-to-Uint8Array
|
||||
conversion. They cover complete UTF-8 paging, one backing file, adoption, release,
|
||||
abort, restart, project deletion with shared history, existing path separators,
|
||||
temporary-result cleanup, failed adoption retry, async restore, and shutdown drain.
|
||||
New result writes and copies omit `parsed.md`; old manifests and directories remain
|
||||
readable. Result image writes settle sequentially before failure cleanup.
|
||||
|
||||
Local Windows validation: six focused suites passed (83 tests), covering this owner,
|
||||
attachment references, ContextManager, parsing service, paged output, and HTTP OCR
|
||||
integration. Scoped ESLint and diff whitespace checks passed. Node type checking
|
||||
reported no diagnostics in S04 source/tests; the complete checkout still has pending
|
||||
integration errors in Main/IPC and concurrent domain migrations.
|
||||
|
||||
These tests do not replace the foundation's utility-process tests or UI acceptance.
|
||||
The real utility-process coverage for these domains is present in the focused
|
||||
storage fixtures. Concurrent desktop acceptance and packaged validation remain
|
||||
outstanding. No latency or throughput improvement is claimed from these focused
|
||||
tests. Test resources live under `temp/goodbuddy-files-upgrade` and individual
|
||||
fixtures remove their files.
|
||||
@@ -0,0 +1,308 @@
|
||||
# Desktop Storage Foundation Integration
|
||||
|
||||
Implemented 2026-10-05. This is the storage foundation for
|
||||
[the desktop storage direction](./desktop-storage-direction.md). The production Main
|
||||
composition now opens one `DesktopStorageClient` and supplies its typed facades to
|
||||
the migrated domains. Full application acceptance is still incomplete. Do not open
|
||||
the existing Main databases alongside the owner.
|
||||
|
||||
## Client API
|
||||
|
||||
Import `DesktopStorageClient` from `src/main/desktop-storage-client.ts` in Main.
|
||||
Construct it after Electron readiness and the application's single-instance check.
|
||||
The existing database directories must exist. Preserve the current database paths.
|
||||
|
||||
```ts
|
||||
const storage = new DesktopStorageClient({
|
||||
assistantPath,
|
||||
knowledgePath,
|
||||
defaultRootPath,
|
||||
userDataPath,
|
||||
onProgress: progress => publishExistingUpgradeProgress(progress),
|
||||
onChanged: domain => publishExistingDomainNotification(domain),
|
||||
onFailure: error => reportStorageFailure(error)
|
||||
})
|
||||
await storage.ready
|
||||
|
||||
await storage.call('assistant', 'saveLocalConversations', [changes])
|
||||
const conversation = await storage.call(
|
||||
'assistant', 'getConversation', [conversationId], { signal }
|
||||
)
|
||||
const results = await storage.call('knowledge', 'search', [searchOptions], { signal })
|
||||
|
||||
// After stopping producers and awaiting their persistence flushes:
|
||||
await storage.close()
|
||||
```
|
||||
|
||||
`call(domain, method, argumentTuple, { signal? })` infers argument and result types
|
||||
from the explicit allowlists in `desktop-storage-contracts.ts`. All results are
|
||||
Promises. Pass cancellation as the transport option, not inside a DTO. Functions,
|
||||
signals and live class instances are rejected before posting. Maps and Uint8Arrays
|
||||
are supported; Node Buffers arrive as Uint8Arrays across Electron IPC.
|
||||
|
||||
`userDataPath` is the existing application user-data root, not a database directory
|
||||
inferred from a filename. `ready` means upgrade worker exit, database initialization,
|
||||
attachment startup reconciliation and runtime-binding owner initialization completed.
|
||||
Calls before readiness fail without queueing. `retry()` retains the same public
|
||||
client and facades. A live failed startup retries in that process; a failed/exited
|
||||
utility is replaced only after exit confirmation and old-request settlement. The
|
||||
replacement gets fresh request correlation, never the old writes. The confirmed
|
||||
upgrade decision is acknowledged into Main memory before upgrade execution, retained
|
||||
across startup loss, and cleared when that upgrade completes. Wire `retry()` to the
|
||||
existing retry UI. There is no automatic restart or replay of unconfirmed writes.
|
||||
|
||||
The defaults locate `desktop-storage-entry.js`, `readonly-query-worker.js` and
|
||||
`assistant-storage-worker.js` as packaged siblings. `entryPath`, `readerWorkerPath`
|
||||
and `upgradeWorkerPath` overrides support isolated tests and explicit composition.
|
||||
The new entry is included in `electron.vite.config.ts`.
|
||||
|
||||
## Owner-Side Operations
|
||||
|
||||
| Existing call shape | Storage operation |
|
||||
| --- | --- |
|
||||
| Assistant/Knowledge ordinary database methods | `call('assistant' / 'knowledge', method, args)` using the explicit allowlists |
|
||||
| SSH project create/update | `assistant.createSshProject` / `assistant.updateSshProject` accept `Omit<SshProjectWrite, 'assertCurrent'>`; Main checks the live host/runtime before dispatch, and the owner retains cancellation and project-revision checks inside the existing transaction |
|
||||
| `activityHistory()` live repository | Existing `getActivityHistoryPage`, `getActivityHistorySummary`, `updateActivityHistory`, `replaceActivityHistory`, `clearActivityHistory`, `reconcileActivityHistory` operations |
|
||||
| `supervisionReviewStore()` | `call('review', method, args)`; initialization/resume execute on the write owner, not the old independent writer |
|
||||
| `supervisionStories()` | `call('stories', method, args)` |
|
||||
| `supervisionExperiences()` | `call('experiences', method, args)` |
|
||||
| `supervisionSuggestions()` | `call('suggestions', method, args)`; resolution uses `assistant.resolveSupervisionSuggestion` |
|
||||
| Knowledge `externalStore` | `call('external', method, args)` |
|
||||
| `readSnapshot(callback)` for review context | `call('assistant', 'supervisionContext', [request])`; summary/background share one reader snapshot |
|
||||
| Knowledge `publishDocument(..., { afterChunksInserted })` | `call('knowledge', 'publishDocument', [input, chunks, { graph, embeddingReplacement, embeddingError }])` |
|
||||
| Knowledge graph-only replacement | `call('knowledge', 'replaceDocumentGraph', [library, document, graph])`; reuses the existing evidence replacement transaction |
|
||||
| Attachment references and full-output backing | `call('attachments', method, args)`; includes owner-local `reconcile(startup?, protectedIds?)` |
|
||||
| Temporary and persisted document results | `call('documentResults', method, args)` |
|
||||
| Runtime bindings and native model-call ledgers | `call('runtime', method, args)` |
|
||||
|
||||
Repository instances never leave the owner. The publication graph DTO is the
|
||||
existing `GraphExtractionResult`. The owner creates the graph callback and performs
|
||||
entity identity resolution, evidence insertion, chunk/embedding publication and
|
||||
pruning within `KnowledgeDatabase.publishDocument`'s existing transaction. Replace
|
||||
the consumer's publication callback with this DTO; remove its duplicate graph
|
||||
publication path when migrating that consumer. Extraction/model work remains outside
|
||||
storage. Publication and replacement share `storeExtractedGraph` from
|
||||
`desktop-storage-knowledge-operations.ts`. Callback-bearing `replaceEvidenceForDocument`
|
||||
is excluded from the transport allowlist. File adoption/release and task-status
|
||||
orchestration remain outside this SQLite transaction.
|
||||
|
||||
After readiness, `createDesktopStorageFiles(storage)` supplies attachments, results
|
||||
and output backing; `createDesktopRuntimeStorageAdapters((domain, method, args) =>
|
||||
storage.call(domain, method, args))` supplies runtime bindings and ledger access.
|
||||
The facade caller types now share the core DTO types, so these calls need no casts.
|
||||
`StorageData` preserves the existing recursive `JsonValue` as-is rather than
|
||||
recursively rebuilding it; runtime validation still rejects callbacks and handles.
|
||||
|
||||
The owner supplies both file callbacks. Attachment reconciliation checks
|
||||
`assistant.hasAttachmentOwner`. Persistent document lookup reads
|
||||
`knowledge.getDocumentByParsedResultId` and resolves the existing
|
||||
`<userDataPath>/knowledge-assets/<libraryId>/<documentId>/<resultId>` directory and
|
||||
source location. Main must not register another persistent lookup on the results
|
||||
facade. The existing `onProgress`, `onChanged` and `onFailure` APIs are unchanged,
|
||||
including `createAssistantStorageOnChanged`'s four notification mappings.
|
||||
|
||||
Keep these composites intact during integration:
|
||||
|
||||
- Remote task/conversation batches use the existing `appendRemote*Batch` or
|
||||
`appendRemote*Once` methods. Await commit before forwarding or ACK. Never translate
|
||||
a batch into independently acknowledged per-event writes.
|
||||
- Review publication uses `assistant.saveSupervisionResult`, which owns result facts
|
||||
and checkpoint commits. `commitCheckpoints` is deliberately not an external review
|
||||
operation. No callback-based transaction API is exposed.
|
||||
- Story/experience apply and review batch save retain their existing repository
|
||||
transactions. A model request must finish before calling these operations.
|
||||
- Additional multi-statement snapshots need a named owner/reader handler; do not
|
||||
recreate `readSnapshot` as several awaited Main calls.
|
||||
|
||||
## Bounds And Lifecycle
|
||||
|
||||
The utility adapter retains `ReadonlyQueryReader` request correlation, errors and
|
||||
admission. Its existing request limit is 32, with 32 MiB of argument admission
|
||||
credit. This is no longer a content-size restriction: one oversized complete input
|
||||
uses the entire credit budget exclusively. Smaller concurrent inputs still share
|
||||
the budget; saturation returns `STORAGE_CAPACITY` without dropping work.
|
||||
|
||||
`desktop-storage-wire.ts` transfers complete values on the same channel in frames
|
||||
of at most 256 KiB accounted data. Each direction sends one frame and waits for its
|
||||
acknowledgement before constructing the next. Strings are split without assembling
|
||||
a whole JSON/serialized copy; binary frames copy only their slice and fill the
|
||||
receiver's final buffer. The receiver exposes a value only after its last frame.
|
||||
These frame acknowledgements provide transport backpressure, not durable write ACKs.
|
||||
Cancellation bypasses the frame queue and is retained for an incompletely received
|
||||
request until dispatch can reject it safely.
|
||||
|
||||
Known complete-note/history/result reads share one materialization/transfer slot,
|
||||
so queued requests do not build many huge reply objects while another reply drains.
|
||||
Writes and bounded queries retain ordinary admission. A complete user value itself
|
||||
still needs memory proportional to its size; the frame budget is not a claim that
|
||||
total process memory or every supported history fits in 32 MiB. There is no result
|
||||
size rejection or truncation, and no `STORAGE_RESULT_CAPACITY` outcome after a commit.
|
||||
|
||||
Renderer conversation persistence now targets 8 MiB batches as well as the existing
|
||||
100-conversation bound. A single oversized conversation stays complete and uses the
|
||||
framed transport. Quit flushing drains all remaining batches and propagates a failed
|
||||
write. Oversized single histories no longer fail solely because of the transport cap.
|
||||
`STORAGE_UNCONFIRMED` means the process exited without confirming a pending operation;
|
||||
reconcile using existing domain identities, not blind retries.
|
||||
|
||||
Queued cancellation is checked before dispatch. In-flight reader cancellation uses
|
||||
the existing thread SharedArrayBuffer inside the owner; that buffer is never sent
|
||||
over utility-process IPC. Synchronous commits cannot be interrupted by an incoming
|
||||
message, so a completed commit returns success even when cancellation was requested
|
||||
too late. Request capacity remains occupied until settlement.
|
||||
|
||||
`close()` stops client admission, drains admitted requests, awaits file-owner cleanup,
|
||||
closes runtime ledgers/bindings and read workers, awaits the existing PASSIVE
|
||||
checkpoint worker's exit, closes both database owners
|
||||
and observes utility-process exit. An exit without the close acknowledgement rejects
|
||||
close. Apply the application's existing shutdown deadline to the whole operation;
|
||||
the transport separately reports an unconfirmed exit after its 10-second exit wait.
|
||||
Startup failure follows the same release barriers. A failed close attempts the
|
||||
remaining independent closes, reports `STORAGE_CLOSE_FAILED`, and exits unsuccessfully;
|
||||
it does not acknowledge a clean drain or permit a second owner to open in that host.
|
||||
|
||||
## Remaining Integration And Acceptance
|
||||
|
||||
### Supervision And Heartbeat Callers
|
||||
|
||||
`createSupervisionDomainPorts(storage)` in
|
||||
`src/main/assistant/supervision-domain-ports.ts` supplies Main-side RPC facades:
|
||||
|
||||
```ts
|
||||
const ports = createSupervisionDomainPorts(storage)
|
||||
const supervisor = createProductionSupervisorService(ports.supervision, getSettings, resolveRuntime, pool, persistUsage)
|
||||
const phrase = createProductionSuggestionPhraser(ports.supervision, getSettings, resolveRuntime, pool, persistUsage)
|
||||
const heartbeat = new HeartbeatService(ports.heartbeat, actions)
|
||||
await deriveSuggestions(ports.suggestions, phrase, input)
|
||||
```
|
||||
|
||||
The production factory's other arguments are unchanged. If provided, `persistUsage`
|
||||
must return the persistence Promise. All heartbeat methods, including configuration
|
||||
CRUD and history, now return Promises. Review repository factories may return a
|
||||
Promise of the typed facade. The facades contain closures only; no database or live
|
||||
repository is transported. `initializeSupervisionReview` and `resumeSupervisionReview`
|
||||
route to `review.initialize` / `review.resume`, with signals in transport options.
|
||||
The caller does not invoke the old independent supervision writer.
|
||||
|
||||
`story-assignment-service.ts` and `experience-extraction-service.ts` are the sole
|
||||
story/experience model orchestrators, including their prompts, output schemas and
|
||||
model-facing types. Production, tests and live scripts import those services.
|
||||
The repository files retain their SQL methods and transactions, with type-only
|
||||
imports for validated model output. `supervision-suggester.ts` awaits candidate
|
||||
selection and suggestion publication. The heartbeat live script also awaits plan
|
||||
creation before using its ID.
|
||||
|
||||
Callers await leaf/navigation writes, final publication, task usage/status,
|
||||
heartbeat projections, story/experience results and error bookkeeping. Cancellation
|
||||
aborts model work immediately, but review admission remains occupied until the
|
||||
cancel write and active execution settle. A late rejected cancel does not overturn
|
||||
a confirmed publication. Failure to persist a suggestion failure is propagated;
|
||||
a model phrasing failure whose status was saved preserves the completed review.
|
||||
|
||||
Validation includes unchanged behavioral assertions in the existing review/story/
|
||||
experience suites, delayed-commit tests, and `supervision-storage.test.ts`: a real
|
||||
Electron storage utility process runs heartbeat, two review leaves, navigation,
|
||||
stories, experiences, suggestion publication, usage, a no-change heartbeat and
|
||||
cancellation using a local deterministic Runtime. This does not validate final
|
||||
Main/IPC composition or concurrent latency budgets.
|
||||
|
||||
On 2026-10-05, after removing the duplicate orchestrators, the targeted supervision/
|
||||
heartbeat run passed 215 tests across 21 files, including the real utility-process
|
||||
test. Scoped ESLint and Node type checking passed.
|
||||
|
||||
The bounded `scripts/supervision-storage-live.cjs <runtime-settings.json>` probe
|
||||
used the current supervisor profile (`gpt-6-astra`, OpenAI Responses) and production
|
||||
utility storage with three synthetic calibration messages. Exactly four actual
|
||||
HTTP requests completed, all HTTP 200: review, story assignment, experience
|
||||
extraction and suggestion phrasing. It committed one leaf covering all three
|
||||
sources (589 characters), assigned three events to one story, created one experience
|
||||
and published two suggestions. The unchanged review made zero additional requests.
|
||||
Persisted usage was four calls, 3,495 input and 1,251 output tokens. Two initial
|
||||
preflight attempts sent zero requests; the probe was corrected to install the
|
||||
isolated Electron encryption state before readiness. Current settings remained
|
||||
byte-identical; credentials were not logged, and isolated databases, encryption
|
||||
state and bundles were removed. This is functional evidence, not concurrent
|
||||
performance acceptance.
|
||||
|
||||
### Integrated Owner
|
||||
|
||||
The foundation registers assistant, knowledge, external metadata, supervision,
|
||||
attachments, document results and runtime operations without Main SQL fallback. The
|
||||
production composition in `src/main/index.ts` constructs the Assistant, Knowledge,
|
||||
file and Runtime facades after storage readiness and passes them to the relevant
|
||||
Main services. Remote Agent database ownership is unchanged.
|
||||
|
||||
The remaining work is product-level acceptance: complete C01-C07/A01-A10 coverage,
|
||||
packaged cross-platform checks, and removal or verification of any caller that is
|
||||
outside the migrated desktop domains. Focused domain tests do not establish those
|
||||
results.
|
||||
|
||||
The S05 runtime-domain owner, Main adapters, required composition wiring and focused
|
||||
validation are documented in [Runtime Storage Integration](./desktop-storage-runtime-integration.md).
|
||||
|
||||
Supervision now uses `ReadonlyQueryReader.reviewManifest(runId)`. The existing
|
||||
read-only assistant worker scans once under a read snapshot into connection-local
|
||||
temporary metadata, ends the snapshot, and returns at most 200 metadata rows per
|
||||
page. It holds at most one live scan manifest, rejects a competing scan explicitly,
|
||||
and releases that temporary data in the operation's settlement path. It never
|
||||
opens a business-write connection.
|
||||
|
||||
The sole write owner clears/rebuilds disposable manifest rows in bounded batches,
|
||||
commits each insertion batch atomically, and yields between commits. Cancellation
|
||||
and persisted pause/cancel status are checked between batches. `initializing`
|
||||
remains set after interruption; clearing it and deleted-source cleanup share the
|
||||
final transaction. Resume rebuilds partial manifests, or validates existing frozen
|
||||
revisions in read-worker pages of at most 200 rows, yielding between pages. A failed
|
||||
scan never falls back to synchronous owner scanning. Final result/checkpoint
|
||||
publication remains the existing atomic operation.
|
||||
|
||||
The old writable `supervision` worker mode, connection opener and separate reader
|
||||
field were removed. `AssistantDatabase`'s direct initialization/resume methods use
|
||||
the same bounded implementation; in-memory owner-local use retains its synchronous
|
||||
algorithm because a separate read connection cannot share that database. Other
|
||||
non-worker operations still execute synchronously inside storage; no latency
|
||||
improvement or full concurrent performance acceptance is claimed.
|
||||
|
||||
The current focused validation passes 70 tests across eleven suites: core storage,
|
||||
file/runtime domains, assistant facade, read workers, bounded review-store algorithms,
|
||||
existing supervision worker regressions, startup, wire backpressure, renderer batching
|
||||
and real supervision/storage orchestration.
|
||||
Real Electron coverage includes graph replacement rollback, SSH revision conflicts,
|
||||
owner-side persistent document lookup after reopen, full-output retention and crash
|
||||
reconciliation, ledger recovery, runtime startup failure/retry and pending file-save
|
||||
drain before cleanup. Review tests cover 200-row bounds, concurrent owner writes,
|
||||
frozen scan revisions, cancellation, failed-batch rollback, reader loss, marker
|
||||
recovery, deleted sources and final marker/cleanup atomicity.
|
||||
|
||||
Real Electron large-value tests save an explicitly typed 16 MiB MP4 payload, read a
|
||||
complete two-entry note, and round-trip all 25,600,230 characters of an 80-message
|
||||
history. Hashes verify complete video and message bodies. The measured accounted
|
||||
frame high-water marks were 248,332 bytes in Main and 252,578 bytes in storage, below
|
||||
262,144 bytes; argument credit peaked at 33,554,432 bytes. Main RSS peaked at
|
||||
426,049,536 bytes including fixture inputs, complete results and application memory.
|
||||
This is queue-bound evidence, not a whole-process memory or latency budget claim.
|
||||
The real process tests also kill an in-progress write transfer and confirm no replay
|
||||
after retry, and verify startup decision retention/clearing across process loss.
|
||||
|
||||
Full Node, Agent and renderer typechecking passes, as does scoped ESLint. Temporary checks and databases use
|
||||
`temp/goodbuddy-storage-foundation`; tests remove their generated bundles/data.
|
||||
The earlier foundation production-build check predates this domain integration.
|
||||
Packaged cross-platform and full UI-to-storage concurrent acceptance remain outstanding.
|
||||
|
||||
### Reader Ownership Cleanup
|
||||
|
||||
The obsolete `readWithFallback` export, database-owned readonly-reader fields and
|
||||
checkpoint lifecycle methods, and the database async wrappers were removed. Heavy
|
||||
reads are dispatched by `DesktopStorageOwner`; owner-local synchronous reads remain
|
||||
available inside transactions and focused database tests. Read-worker and checkpoint
|
||||
tests now create explicit `ReadonlyQueryReader`/`Worker` owners and await their close.
|
||||
The three perf harnesses create explicit owner-local reader/checkpoint resources under
|
||||
the repository temporary root, report `executionBoundary: owner-local`, and close
|
||||
those resources before deleting their copies. No production or test source reference
|
||||
remains to the removed compatibility APIs.
|
||||
|
||||
The cleanup validation adds termination barriers and failed-frame poisoning: a reader
|
||||
cannot reopen capacity while its prior worker termination is pending, a transport
|
||||
posting failure cannot feed later values into a partial decoder, and close waits for
|
||||
wire drain and owner settlement. The focused cleanup run passes 122 tests; Node and
|
||||
renderer type checks and scoped lint pass.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Knowledge Main Storage Migration (S03)
|
||||
|
||||
Knowledge Main now receives `KnowledgeServiceOptions.database`, a
|
||||
`KnowledgeStoragePort`. Production composition constructs it with
|
||||
`createKnowledgeStoragePort(storageClient)` after `storageClient.ready`.
|
||||
`KnowledgeService` no longer opens, initializes, closes, or starts readers for
|
||||
the business database. The application storage owner retains those duties.
|
||||
|
||||
The adapter sends explicit `knowledge` and `external` domain calls. Search
|
||||
signals travel as transport options, not serialized arguments. The port's
|
||||
`Awaitable` return type permits synchronous owner-local test implementations;
|
||||
the production adapter always returns the storage client's promises.
|
||||
|
||||
Main awaits source, document, task, graph, embedding and external metadata
|
||||
operations. IPC mutations await their writes before returning or refreshing a
|
||||
snapshot. Parsing, models, credentials and source watchers remain in their
|
||||
existing Main services. Embedding batches retain their existing sizes and
|
||||
staging/publication sequence.
|
||||
|
||||
Publication passes the extracted graph through foundation
|
||||
`publishDocument(..., { graph })`. Graph-only replacement calls
|
||||
`replaceDocumentGraph(library, document, result)`. Its implementation in
|
||||
`src/main/desktop-storage-knowledge-operations.ts` runs the existing synchronous
|
||||
graph algorithm inside `replaceEvidenceForDocument`'s transaction. No callback
|
||||
or SQL crosses the storage boundary.
|
||||
|
||||
Shutdown aborts producers immediately, drains tracked work and observes the
|
||||
task-interruption write and embedding status persistence. The service does not
|
||||
close the shared storage client. Embedding allocation returns its replacement
|
||||
ID even if cancellation arrives with the reply, allowing cleanup; a committed
|
||||
finish is not reported as rolled back. Rebuild admission remains occupied
|
||||
until its final status write settles, and status-write failures reach the
|
||||
completion barrier.
|
||||
|
||||
## Current Production Boundary
|
||||
|
||||
The following boundary is implemented in the current production composition:
|
||||
|
||||
- `replaceDocumentGraph(library: KnowledgeBase, document: Document,
|
||||
result: GraphExtractionResult): void` to the knowledge domain contract and
|
||||
dispatch it to the exported owner-local operation, passing the owned database
|
||||
as its first argument. It is not part of the concrete database method
|
||||
allowlist because it is a composite owner operation.
|
||||
- Foundation publication reuses the exported `storeExtractedGraph` helper.
|
||||
- `StorageData` preserves the existing recursive JSON type without rebuilding it
|
||||
in the adapter.
|
||||
- Persistent document lookup is resolved by the storage owner through the
|
||||
Knowledge domain; Main does not register a second lookup.
|
||||
- Application composition injects the port, awaits asynchronous gateway and
|
||||
tool-provider calls, and closes shared storage after Knowledge disposal.
|
||||
|
||||
## Validation
|
||||
|
||||
The focused Knowledge service, external service, embedding coordinator and
|
||||
embedding repository suites use a Promise-backed adapter over a test-owned
|
||||
SQLite connection. Coverage includes publication rollback, embedding batch
|
||||
ordering, late cancellation, replacement cleanup, external request cancellation,
|
||||
credential persistence and restart. The production gateway now awaits its library
|
||||
and search operations. Remaining evidence is limited to the focused suites and
|
||||
does not establish full concurrent desktop acceptance.
|
||||
|
||||
Owned production files pass ESLint. The current worktree typecheck and focused
|
||||
storage validation cover the injected production port and owner dispatch. Global
|
||||
architecture acceptance, packaged checks and concurrent performance acceptance
|
||||
remain with the parent delivery; these tests do not establish a new performance
|
||||
baseline.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Desktop Runtime Storage Integration
|
||||
|
||||
S05 domain implementation and production composition, 2026-10-05. This supplies
|
||||
the runtime domain for the [desktop storage foundation](./desktop-storage-foundation.md).
|
||||
The current composition passes the storage-owned binding store and model-call
|
||||
ledger factory to Main runtime services. Full desktop migration and performance
|
||||
acceptance remain incomplete.
|
||||
|
||||
## Integration API
|
||||
|
||||
`src/main/desktop-storage-runtime-operations.ts` exports:
|
||||
|
||||
- `DesktopStorageRuntimeOperations`: the existing `RuntimeSessionBindingStore`
|
||||
interface plus named ledger lifecycle and transition operations.
|
||||
- `runtimeStorageMethods`: the explicit host dispatch allowlist.
|
||||
- `DesktopStorageRuntimeOwner.open(userDataPath)`: creates the sole binding owner
|
||||
at `remote-runtime-bindings.sqlite`. Await it before publishing storage readiness.
|
||||
Native ledgers open lazily at their existing paths through `openLedger(id, path)`.
|
||||
- `createDesktopRuntimeStorageAdapters(call)`: Main adapters returning
|
||||
`bindingStore` and `openModelCallLedger`. `call` has the same domain/method/tuple
|
||||
shape as `DesktopStorageClient.call`, restricted to the `runtime` domain.
|
||||
- `DesktopStorageRuntimeOwner.close()`: closes remaining native ledgers and the
|
||||
binding database. The outer storage owner must first drain admitted operations.
|
||||
This method is not part of the external domain allowlist.
|
||||
|
||||
The storage-core integration adds `runtime: DesktopStorageRuntimeOperations` to
|
||||
`DesktopStorageDomains`, initializes one runtime owner from the existing user-data
|
||||
directory, and dispatches allowed runtime methods against that owner. Include its
|
||||
close in the storage owner's release path, including startup failure. No new RPC
|
||||
client, SQL endpoint, admission queue or process is needed.
|
||||
|
||||
After registering the domain, Main composition is:
|
||||
|
||||
```ts
|
||||
await storage.ready
|
||||
const runtimeStorage = createDesktopRuntimeStorageAdapters(
|
||||
(domain, method, args) => storage.call(domain, method, args)
|
||||
)
|
||||
|
||||
// ManagedRemoteExecutionServices options:
|
||||
// bindingStore: runtimeStorage.bindingStore
|
||||
// The former userDataPath option is removed; the host owns that path.
|
||||
|
||||
// NativeClientCoordinator, NativeTerminalClient and NativeDshWebClientService options:
|
||||
// openModelCallLedger: runtimeStorage.openModelCallLedger
|
||||
```
|
||||
|
||||
`ManagedRemoteExecutionServices` borrows the binding interface and does not close
|
||||
it on disposal. Native clients stop their producers and proxy, await their ledger
|
||||
close, then delete launch material. DS Web retains native history. A failed or
|
||||
unconfirmed close retains files and propagates the error. A lost open reply also
|
||||
retains files because the host may still own the connection. Shutdown must stop
|
||||
native/remote producers before closing the shared storage client.
|
||||
|
||||
## Ordering And Ownership
|
||||
|
||||
The shared `ModelCallLedger` type allows synchronous or Promise-returning methods;
|
||||
the concrete `AgentModelCallLedger` stays synchronous and remains the remote
|
||||
Agent's owner. Schema, database names, call identities, retention and transaction
|
||||
algorithms are unchanged.
|
||||
|
||||
`AgentModelGateway` awaits claim before provider dispatch, complete before returning
|
||||
the response, delivered before resolving the delivery acknowledgement, and
|
||||
outcome-unknown before reporting provider failure. Cancellation while claim is
|
||||
pending does not dispatch after claim completes. Persistence rejection propagates;
|
||||
the gateway does not replay a provider request after an unconfirmed write.
|
||||
|
||||
## Validation
|
||||
|
||||
The final focused run passed 113 tests across eight files, with 11 existing opt-in
|
||||
cases skipped. It included the official DS Web process using standard Node,
|
||||
binding uniqueness/rotation/cursor persistence, concrete ledger reopen and uncertain
|
||||
outcomes, delayed/rejected persistence, and both native clients' close ordering.
|
||||
|
||||
`desktop-storage-runtime-operations.test.ts` also builds real Electron fixtures.
|
||||
The production native terminal loopback gateway uses Main adapters and the real
|
||||
`DesktopStorageClient`/transport to reach a utility-process runtime owner, then
|
||||
closes its ledger before removing launch files. Production core registration is
|
||||
covered by `src/main/index.ts`; the fixture remains focused on the runtime domain.
|
||||
Its model response is local and deterministic, with no external provider call.
|
||||
|
||||
The shared gateway was separately validated on the pinned Linux x64 test Host via
|
||||
the existing lifecycle harness rebuilt from current source. OpenCode ran a bounded
|
||||
tool command in a dedicated `/root/tmp/gb-lifecycle-*` workspace. Desktop detached,
|
||||
the test Agent was promoted, the controller resumed, and the prompt completed.
|
||||
There were exactly **2 real text-provider calls**, both HTTP 200, **0 image calls**,
|
||||
33 persisted event entries, 17 checkpoints, and successful replay deduplication.
|
||||
The old test daemon retired; final owned process count was zero. The harness removed
|
||||
its remote directories. Local artifacts were isolated in
|
||||
`temp/goodbuddy-ledger-upgrade` and cleaned after validation.
|
||||
|
||||
Scoped ESLint and `git diff --check` passed. The current composition supplies
|
||||
`bindingStore` to `ManagedRemoteExecutionServices` and `openModelCallLedger` to
|
||||
the native coordinator. Whole-application acceptance, packaged cross-platform
|
||||
checks and concurrent performance measurements are not established by these
|
||||
focused tests.
|
||||
@@ -0,0 +1,101 @@
|
||||
# 本机专用推理架构与任务控制评估
|
||||
|
||||
## 记录范围与状态
|
||||
|
||||
评估日期:2026-09-18。本文记录 ASR、OCR、内置 embedding 的执行边界、任务控制缺口和后续优化依据,供语音输入、文档解析、知识检索与索引共同使用。
|
||||
|
||||
现状依据本次工作区源码核对,包含尚未提交的变更,不等同于已发布版本或实机验收结论。本次仅整理文档,未运行引擎性能测试、复现故障或实施迁移。下文“建议”是评估方向,“待办”均未在本次实施,也没有约定交付日期。
|
||||
|
||||
产品 Task、Job、Run 的定义沿用 [Task 与 Job 领域模型](../features/task-and-job/task-and-job-model.md)。本文的“推理任务”指一次引擎请求及其执行记录,不自动等同于产品 Task,不据此新增产品对象、持久化日志或全局调度器。本机推理管理的现有界面和接入清单见[应用导航技术设计](../features/application-tool-navigation/technical-design.md#111-本机推理任务与执行服务);本文负责跨功能架构评估及优化待办。
|
||||
|
||||
## 当前执行边界
|
||||
|
||||
| 能力 | 源码现状 | 所有权与释放 | 评估建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| ASR | 录音结果就绪后提交完整 PCM;Main 创建 `node:worker_threads` Worker,以 sherpa-onnx WASM 执行离线识别;服务一次只接受一个请求 | 每请求创建识别器;超时或 abort 调用并等待 `worker.terminate()`;正常结果路径的清理确认存在缺口,见 TC-4 | 保留请求级 Worker,适合间歇语音输入;常驻化需流式需求或测量支持 |
|
||||
| OCR | Main 排队后发往主窗口 Renderer;Renderer 复用 Web Worker,运行 `ppu-paddle-ocr/web` 与 `onnxruntime-web` WASM;文档串行处理,PDF 逐页处理 | Renderer 模块持有 Worker;活动请求取消或单页超时会终止 Worker;无活动和排队请求时空闲 60 秒释放 | 当前保留 Web Worker;独立于 UI 的持续任务可评估专用后台 Renderer 加 Worker |
|
||||
| 内置 embedding | Main 按模型目录共享引用计数 Broker;transport 使用 `utilityProcess.fork`,Granite tokenizer 和 ONNX session 首次请求懒加载并复用 | 多使用方共享进程;最后一个 provider 释放时 shutdown;也支持显式启停,停止等待 child exit | 保留共享 utilityProcess;优先核对取消后的容量回收与加载失败重试 |
|
||||
|
||||
### ASR 的证据与边界
|
||||
|
||||
[App.tsx](../../src/renderer/src/App.tsx) 在 `recording.result` 完成后调用 `speech.transcribe`;[录音模块](../../src/renderer/src/speech-recognition.ts)负责采样与重采样。[SpeechTranscriptionService](../../src/main/speech/speech-transcription-service.ts) 的 `active.size` 拒绝并发识别,`runSherpaTranscription` 为每次请求创建 Worker,转移音频缓冲区,使用 `createOfflineRecognizer` 同步 decode。Worker 创建后的识别超时为 120 秒,不包含此前模型解析的等待时间。
|
||||
|
||||
[package.json](../../package.json) 固定 `sherpa-onnx` 为 1.13.4;本次同时核对已安装包的 `index.js`,其入口加载 `sherpa-onnx-wasm-nodejs.js`。因此这里记录的是 Node Worker 内的 WASM 路径。Node Worker 与 Main 共享 OS 进程,线程隔离不能视为独立进程故障边界。
|
||||
|
||||
Worker 先 `postMessage`,再在 `finally` 中调用 `stream.free()` 和 `recognizer.free()`。Main 收到结果后以 `finish(..., false)` 结算、移除监听和超时,不等待 Worker exit;随后 `active` 删除请求,推理记录转为终态。这证明结果完成与资源清理确认之间存在记账间隙,但尚未证明实际泄漏或清理耗时。
|
||||
|
||||
### OCR 的浏览器依赖
|
||||
|
||||
[OCR Worker](../../src/renderer/src/document-ocr-worker.ts) 显式选择 WASM provider、`canvas-native` 图像处理,当前 `ort.env.wasm.numThreads = 1`。PDF 路径使用 `pdfjs-dist`、`OffscreenCanvas` 和 `convertToBlob`。本次核对的依赖链为 `ppu-paddle-ocr/web/platform.web.js` 到 `ppu-ocv/canvas-web`;后者的 `platform/web.js` 使用 `createImageBitmap` 解码图片。包依赖以 [package-lock.json](../../package-lock.json) 为准,依赖升级后需重新核对此结论。
|
||||
|
||||
这些能力与浏览器执行环境有关。直接迁到 Node utilityProcess,需要另行验证图像解码、Canvas、PDF 渲染及打包资源路径,不能只替换进程创建 API 就宣称等价。
|
||||
|
||||
[DocumentOcrBroker](../../src/main/document-ocr-broker.ts) 最多保留 4 个 pending 请求,一次派发一个;总超时从派发时开始,不覆盖 Main 排队等待。其预算为 `min(10 分钟, max(单页预算, 60 秒 + 单页预算 × 页数))`。[Renderer bridge](../../src/renderer/src/document-ocr-bridge.ts) 另有串行 Promise 队列,Worker 就绪超时为 120 秒,就绪后按单页预算计时,并在进度消息到达时重置单页计时器。Main 的 60 秒启动预算与 Renderer 的 120 秒就绪等待未对齐,短文档冷启动时 Main 有机会先判超时。
|
||||
|
||||
[Main 初始化](../../src/main/index.ts) 将 Broker 绑定到 `mainWindow`;[Renderer 入口](../../src/renderer/src/main.tsx) 安装 bridge。Broker 派发前仅检查窗口销毁等条件,没有等待 bridge 就绪的握手,也没有在该派发链路中处理 bridge 就绪丢失。Renderer 内的 Worker `ready` 只确认模型初始化,不能证明 Main 发请求时 Renderer 已安装 IPC 监听。窗口重载、Renderer 崩溃及启动时监听尚未安装的影响应按 TC-3 复现。
|
||||
|
||||
来源请求可以取消 OCR;已派发请求取消时 Main 发送取消消息并立即结算,然后派发下一项,未等待 Renderer 清理确认。Renderer 对当前活动请求的取消会终止共享 Worker。管理页因此没有将该操作作为安全的单任务取消能力;手动释放也会拒绝活动或排队请求。
|
||||
|
||||
### Embedding 的共享执行与取消
|
||||
|
||||
[Main provider 工厂](../../src/main/index.ts) 按模型目录维护 `sharedEmbeddingBrokers` 及使用计数;[transport](../../src/main/knowledge/embedding-utility-transport.ts) 以 spawn 确认进程启动。[Granite 引擎](../../src/main/knowledge/granite-embedding-engine.ts) 缓存 `loadTask`,在首次请求时创建 tokenizer 和 WASM ONNX session。进程已启动不等于模型已加载;当前没有为模型加载完成提供独立服务状态。
|
||||
|
||||
[Broker](../../src/main/knowledge/embedding-inference-broker.ts) 默认请求超时为 120 秒,计时在 transport 就绪后建立 pending 时开始。调用方取消或超时会先返回错误,同时发送 cancel,保留 pending 和推理执行记录,等待结果、`CANCELLED` 或进程退出。保留容量避免把仍在计算的任务当作已释放。
|
||||
|
||||
[执行端](../../src/main/knowledge/embedding-inference-worker.ts) 收到 cancel 后设置 AbortSignal;Granite 在加载、输入构造和 `session.run` 前后检查取消,但没有将可抢占的取消句柄传入 `session.run`。若该调用一直不返回,取消检查和确认就无法完成。Broker 的 `cancelPending` 会清除原计时器,当前没有针对该 pending 的后续有界清理升级,因此持续挂起可一直占用在途容量。此为源码可达的条件风险,真实引擎挂起尚未复现。
|
||||
|
||||
`loadTask ??= this.loadFiles(...)` 也会缓存被拒绝的加载 Promise;同一引擎实例后续请求复用同一个失败,不能通过再次请求重新加载。重建进程会建立新引擎;是否增加实例内重试及其清理方式,按 TC-2 验证后确定。
|
||||
|
||||
## 架构建议与触发条件
|
||||
|
||||
ASR 继续按录音请求创建 Worker。对低频、间歇输入,每次释放模型能避免长期占用;本次没有冷启动与常驻成本数据,不能量化收益。只有连续流式识别成为真实需求,或高频请求测出重复加载已影响目标延迟,才评估常驻 utilityProcess。届时需比较加载成本、暖请求延迟、空闲工作集和取消时延,并重新设计音频流与识别器复用;单纯常驻不会把当前离线 API 变成流式识别。
|
||||
|
||||
OCR 保留 Web Worker。如果文档任务需要在主 UI 重载或不可用时继续执行,优先评估由 Main 持有的专用后台 Renderer 加 Worker,以保留浏览器图像/PDF 能力。该方案仍是候选,需要完成以下设计与实机验证后才可决定迁移:
|
||||
|
||||
- 保持现有 sandbox、context isolation、禁用 Node integration 的边界;通过专用 preload 暴露必要方法,并沿用可信 sender 与输入校验。当前 IPC 若限定主窗口 sender,需要明确新增宿主的受信身份及响应归属。
|
||||
- 分别确认后台 Renderer 的 IPC 监听就绪与 Worker 模型就绪;定义重载、崩溃、启动失败及应用退出时 pending、队列和模型的处置,防止静默丢请求或自动重放。
|
||||
- Main 持有后台宿主生命周期;管理 UI 关闭不销毁执行宿主,服务释放和应用退出才按约定清理。验证隐藏 Renderer 的调度、计时器和 Worker 执行,不根据窗口不可见推断运行不受影响。
|
||||
- 用实际 OS PID、Electron 进程指标和崩溃影响验证隔离程度。单独的 BrowserWindow/WebContents 不能直接作为独立 OS 进程或独立服务资源统计的证据;若与其他 Renderer 共享 PID,必须继续报告共享范围。
|
||||
|
||||
Embedding 保留共享 utilityProcess。查询与索引共享模型和进程的现有方式具备复用基础,优化先处理取消回收和失败重试。单请求取消继续尽量只影响该请求;进程终止属于服务级操作,必须说明其他使用方的结果。不得把每次请求超时直接等同于可以无条件杀死共享进程。
|
||||
|
||||
## 任务控制覆盖与缺口
|
||||
|
||||
[共享契约](../../src/shared/local-inference-contracts.ts)将服务状态与任务状态分开;[LocalInferenceService](../../src/main/local-inference-service.ts) 在 Main 内存登记请求,保存活动项及最近 100 条终态记录。任务的 `running` 当前包含排队、模型加载与执行,未提供统一细分阶段。该登记器是观测和控制入口,各引擎仍负责实际队列与清理。
|
||||
|
||||
| 控制维度 | 当前覆盖 | 后续评估和验收要求 |
|
||||
| --- | --- | --- |
|
||||
| 状态 | 任务有 `running/cancelling/completed/cancelled/failed`;服务另有启动、运行、停止、未知和错误状态;ASR 模型可用时服务仍为 unknown | 区分调用方已返回、执行仍活动、资源清理已确认;进程 spawn、模型 ready、请求完成不能互相替代。先增加必要证据,不预建完整阶段状态机 |
|
||||
| 所有权 | ASR 请求持有 Worker;OCR Renderer 持有共享 Worker;embedding 按目录共享 Broker 并引用计数 | 控制记录注明真实来源与宿主;缺失项目、会话关联时不猜测归属。产品 Task 的取消不能凭名称去停止整个推理服务 |
|
||||
| 取消 | ASR terminate;OCR 来源取消会终止当前 Worker;embedding 协作取消并保留执行记录 | 验证取消与自然完成竞态、重复取消及清理结果。单请求取消与共享服务停止分别定义影响,不用 UI 隐藏代替取消 |
|
||||
| 超时与容量 | ASR Worker 120 秒;OCR 两端预算与排队语义不同;embedding 默认请求 120 秒且保留取消后容量 | 分别说明排队、启动、推理、清理的计时起点和上限;调用方超时后仍在执行的工作需要有界处置,不得提前释放容量制造超额并发 |
|
||||
| 停止与清理 | 服务操作按 serviceId 串行,期间拒绝新增请求;停止校验当前活动 taskId 确认清单;向量停止等待 exit,重启不重放请求 | 清理失败保持可观察的活动/错误信息;窗口或宿主丢失需使受影响请求得到明确结果;ASR 和 OCR 的结果/清理间隙分别核对 |
|
||||
| 可观测性 | 有来源、状态、时间、可选模型和错误;向量有 PID 资源数据;管理 Modal 不显示任务历史 | 测量排队、加载、执行、取消请求到执行结束、清理确认的耗时与在途数;日志不记录音频、图片或向量原文,未知指标不补零 |
|
||||
|
||||
本文的覆盖要求用于后续问题复现和最小修复,不构成引入统一常驻服务框架、持久化执行副本或自动恢复协议的决定。范围为桌面本机引擎;远程 Agent、外部向量端点和未接入的 TTS 不因此获得同样控制能力。
|
||||
|
||||
## 资源观测口径
|
||||
|
||||
[LocalInferencePage](../../src/renderer/src/LocalInferencePage.tsx) 在启用、文档可见且 Modal 内容未被 hidden/inert 隔离时每 5 秒读取快照,跳过重叠请求,关闭时清理轮询。该刷新间隔不能用于证明短请求的峰值或清理完成时刻。
|
||||
|
||||
[向量资源采样器](../../src/main/inference-process-resources.ts) 按 transport 当前 PID 匹配 `app.getAppMetrics()` 的 Utility 条目,用累计 CPU 秒差除以单调时间间隔。100% 表示一个逻辑核心,可超过 100%;首次采样、PID/创建时间变化、间隔超过 15 秒或读数异常时重建基线。工作集从 KiB 转为字节,UI 显示 MiB。
|
||||
|
||||
向量工作集包含运行时、模型、临时缓冲区及并发使用方,不能当作模型权重大小或单任务内存。ASR Node Worker 和 OCR Web Worker 没有当前可独立归属的 OS 进程工作集;宿主进程总量不能直接填入某个服务行。未来 OCR 后台宿主也必须先核实 PID 与共享情况,再决定指标范围。显存当前未采集。
|
||||
|
||||
## 优化待办与验收
|
||||
|
||||
优先级表示建议处理顺序:P1 为执行正确性和回收问题,P2 为测量及有条件的架构选型。全部待办待开展;源码核对已完成,故障复现、修复和实机验收分别记录,不以文档完成替代实现完成。
|
||||
|
||||
| ID | 优先级与事项 | 依赖及验收条件 |
|
||||
| --- | --- | --- |
|
||||
| TC-1 | P1:embedding 取消/超时后的有界回收 | 先覆盖 `session.run` 延迟返回及不返回,观察 caller、pending、执行记录与容量。定义取消后的清理等待上限及升级条件,数值依据测量确定。普通取消不终止其他共享任务;确需服务级终止时明确受影响使用方及失败结果,等实际退出再释放容量,且不重放请求。模拟用于稳定触发条件,最终以真实引擎取消和停止路径补验 |
|
||||
| TC-2 | P1:embedding 加载失败重试 | 覆盖首次读模型或建 session 失败、随后恢复依赖的场景;选择清除失败缓存或显式重建引擎的最小方案。验收后续请求能够重新加载,并发请求不重复加载,失败中已创建的资源被清理,销毁后不再复活;不能把一次成功创建进程当作重试通过 |
|
||||
| TC-3 | P1:OCR 宿主就绪、丢失与超时预算 | 保留当前 Web Worker 即可先验证,无须等待迁移。覆盖监听安装前派发、主 Renderer 重载/崩溃、冷启动超过 Main 启动预算及排队取消。明确 bridge 就绪与 Worker ready 的区别;受影响请求有明确结果,队列能继续使用,没有静默丢失或重放;两端预算、起算点和取消后下一项派发与清理顺序一致 |
|
||||
| TC-4 | P1:ASR 结果与清理记账 | 覆盖结果先到、延迟退出、清理异常以及取消/超时与结果竞争。选择延后请求结算或单独保留清理记录的最小方案;验收资源仍活动时不会被报告为已释放,也不提前放开实际单请求容量。正常完成及异常路径均验证 Worker 最终退出 |
|
||||
| TC-5 | P2:统一测量记录与资源归属核验 | 可与 P1 复现并行,为 TC-6/TC-7 提供依据。记录冷/暖加载时间、端到端延迟、排队时长、取消至执行结束/资源回收时长、空闲与峰值工作集、CPU 时间和样本数;按 PID 对照 OS 工具,分别报告引擎、宿主与应用总量。重启后不沿用旧 PID 基线,不以 5 秒 UI 轮询代替专门测量 |
|
||||
| TC-6 | P2、条件待定:OCR 后台 Renderer 加 Worker | 依赖明确的 UI 独立执行需求、TC-3 生命周期结论及 TC-5 数据。候选验证需包含图片、多页 PDF、UI 重载时执行、后台宿主崩溃、空闲释放、应用退出、preload/IPC sender 边界和各支持平台打包资源。证实任务连续性及实际 PID 隔离后,再记录采用或保留现状的结论 |
|
||||
| TC-7 | P2、条件待定:ASR 常驻 utilityProcess | 依赖流式/高频场景成立,或 TC-5 证明重复加载达到需优化程度,并完成 TC-4。对比请求级 Worker 与候选的冷启动、暖请求、空闲内存和取消成本;若为流式,验证连续音频、结果提交和停止语义。没有测量或实际需求时继续保留请求级 Worker |
|
||||
|
||||
测量记录应包含 OS/架构、Electron 与依赖版本、模型版本、硬件、输入时长/页数/token 与批量大小、冷暖条件、请求频率和并发使用方。使用固定可重复输入,报告样本数、分布及异常,不填入未经测量的性能收益。后续验收覆盖受影响的 Windows、macOS、Linux 构建;尚未测的平台明确保留为待验。
|
||||
|
||||
本次证据为源码和已安装依赖入口检查。后续执行待办时,在所属功能进度文档记录命令、环境、真实路径结果及遗留项,并引用 TC 编号;若迁移条件未成立,记录保留现状的原因即可。
|
||||
@@ -12,8 +12,8 @@
|
||||
| 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
|
||||
| 相关基线 | [统一界面设计系统](../../UI-DESIGN.md)、[文档解析与本地 OCR](../features/document-processing/prd.md)、[本地文本向量模型与连接设计](../features/knowledge-base/local-embedding-technical-design.md)、[全双工实时语音交互设计](../features/full-duplex-voice/technical-design.md) |
|
||||
|
||||
本文定义“设置 → 平台功能”的二级页签结构,以及所有 GoodBuddy 托管本地模型共同使用的
|
||||
“模型下载源”设置。首期下载源为 ModelScope 和 Hugging Face,默认使用 ModelScope。
|
||||
本文定义“设置 → 平台功能”的二级页签结构,以及位于“设置 → 模型连接”、所有 GoodBuddy
|
||||
托管本地模型共同使用的“模型下载源”设置。首期下载源为 ModelScope 和 Hugging Face,默认使用 ModelScope。
|
||||
|
||||
模型下载源是用户明确选择的供应链路径。当前来源不可用或没有所选模型时,GoodBuddy
|
||||
必须明确失败并提供设置入口,**不能静默切换到另一个下载源。**
|
||||
@@ -24,8 +24,9 @@
|
||||
|
||||
1. “平台功能”保留现有一级设置分类,在分类内部增加共享 `PageTabs`:
|
||||
- **通用设置**
|
||||
- **魔法笔记**
|
||||
2. “通用设置”首个功能为“模型下载源”。
|
||||
- **远程项目**
|
||||
2. “模型下载源”位于“模型连接”页头,与模型类型页签同一行右对齐,在保存操作下方;
|
||||
显示当前来源和次按钮,点击后在弹窗中选择并确认保存。
|
||||
3. 首期提供两个互斥选项:
|
||||
- `modelscope`,用户文案为 **ModelScope**,默认值。
|
||||
- `hugging-face`,用户文案为 **Hugging Face**。
|
||||
@@ -47,9 +48,9 @@
|
||||
同一模型包的部分文件从 ModelScope 下载、部分文件从 Hugging Face 下载。
|
||||
8. 下载任务启动时冻结来源。任务运行期间切换全局设置,不改变、重启或迁移该任务;新的
|
||||
下载使用新来源。
|
||||
9. 当前来源失败时,不自动重试另一个来源。错误可以提供“前往通用设置更换下载源”,由
|
||||
用户明确选择。
|
||||
10. 现有魔法笔记设置只移动到“魔法笔记”页签,不改变保存、启用或评论行为。
|
||||
9. 当前来源失败时,不自动重试另一个来源。向量、语音和 OCR 的更换下载源入口直接打开
|
||||
“模型连接”页的下载源弹窗,由用户明确选择并确认。
|
||||
10. 魔法笔记设置由应用中心与笔记页共享,不在全局设置中重复提供入口。
|
||||
|
||||
---
|
||||
|
||||
@@ -59,10 +60,11 @@
|
||||
|
||||
“平台功能”现已使用两个二级页签:
|
||||
|
||||
- “通用设置”承载全局模型下载源。
|
||||
- “魔法笔记”承载显示入口、AI 评论方式和 AI 评论形式。
|
||||
- “通用设置”承载快捷唤起、会话与通知、工作目录设置。
|
||||
- “远程项目”承载远程项目开关与 Agent 包管理。
|
||||
|
||||
页签复用共享 `PageTabs`,默认打开“通用设置”,不会把当前页签写入应用设置。
|
||||
全局模型下载源由“模型连接”页头提供入口,布局和弹窗行为见第 7 节。
|
||||
|
||||
### 2.2 受管模型下载
|
||||
|
||||
@@ -99,7 +101,7 @@
|
||||
- 切换来源时不影响已安装、已选择或正在使用的模型。
|
||||
- 下载失败时知道实际使用了哪个来源。
|
||||
- 所选来源没有模型时得到明确说明,并可以主动前往设置切换。
|
||||
- 在“平台功能”中快速区分通用设置与魔法笔记。
|
||||
- 在“模型连接”页头打开模型下载源弹窗,选择后确认保存。
|
||||
|
||||
### 3.2 产品目标
|
||||
|
||||
@@ -163,13 +165,15 @@
|
||||
|
||||
```text
|
||||
设置
|
||||
├─ 模型连接
|
||||
│ └─ 页头:模型类型页签同行右侧的当前来源与“模型下载源”按钮
|
||||
│ └─ 模型下载源弹窗
|
||||
└─ 平台功能
|
||||
├─ 通用设置
|
||||
│ └─ 模型下载源
|
||||
└─ 魔法笔记
|
||||
├─ 显示魔法笔记入口
|
||||
├─ AI 评论方式
|
||||
└─ AI 评论形式
|
||||
│ └─ 快捷唤起、会话与通知、工作目录
|
||||
└─ 远程项目
|
||||
├─ 远程项目开关
|
||||
└─ Agent 包管理
|
||||
```
|
||||
|
||||
分类页只有一个一级标题“平台功能”。页签标题不重复渲染为第二个页面标题;每个面板内部
|
||||
@@ -186,7 +190,7 @@
|
||||
onChange={setActiveSection}
|
||||
tabs={[
|
||||
{ id: 'general', label: '通用设置' },
|
||||
{ id: 'magic-notes', label: '魔法笔记' }
|
||||
{ id: 'remote-projects', label: '远程项目' }
|
||||
]}
|
||||
value={activeSection}
|
||||
variant="segmented"
|
||||
@@ -214,7 +218,7 @@
|
||||
|
||||
未来新增页签时:
|
||||
|
||||
- 必须与“通用设置”和“魔法笔记”处于同级。
|
||||
- 必须与“通用设置”和“远程项目”处于同级。
|
||||
- 页签表示完整、独立的功能设置域。
|
||||
- 通用设置只放跨功能的全局行为,不成为任意设置的杂物区。
|
||||
- 页签超过可读数量时重新组织信息架构,不允许多行堆叠。
|
||||
@@ -222,17 +226,20 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. 通用设置界面
|
||||
## 7. 模型下载源界面
|
||||
|
||||
### 7.1 布局
|
||||
|
||||
“模型连接”页头在保存操作下方,将模型类型页签与下载源控件放在同一行:页签居左,
|
||||
“当前来源:ModelScope”和“模型下载源”次按钮整体右对齐。窄窗口保持同行,空间不足时
|
||||
导航行可横向滚动。点击按钮打开弹窗;通用设置的工作目录卡片不再提供下载源控件。共享布局规则见
|
||||
[UI 设计规范](../../UI-DESIGN.md#138-设置中心)。
|
||||
|
||||
```text
|
||||
平台功能
|
||||
管理通用平台行为与可选工作区能力
|
||||
模型连接 [保存]
|
||||
[文本] [向量] [重排] [语音] 当前来源:ModelScope [模型下载源]
|
||||
|
||||
[ 通用设置 ] [ 魔法笔记 ]
|
||||
|
||||
┌ 模型下载源 ────────────────────────────────────────────┐
|
||||
┌ 模型下载源(弹窗)───────────────────────────────── [×]┐
|
||||
│ 选择 GoodBuddy 托管本地模型后续下载使用的平台。 │
|
||||
│ 已安装模型和 ZIP 导入不受影响。 │
|
||||
│ │
|
||||
@@ -241,19 +248,19 @@
|
||||
│ │
|
||||
│ ○ Hugging Face │
|
||||
│ 适合可以稳定访问 Hugging Face 的网络环境 │
|
||||
│ │
|
||||
│ [取消] [确认] │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
页面只保留一个视觉上最突出的任务。模型下载源保存属于即时设置,不增加与来源选项竞争的
|
||||
大型“保存全部”按钮。
|
||||
弹窗通过 Portal 挂载到 `document.body`,复用共享 Modal 样式、背景隔离和焦点管理。
|
||||
模型连接页头的保存操作与下载源弹窗的“确认”各自保存对应设置。
|
||||
|
||||
### 7.2 控件
|
||||
|
||||
来源选项包含说明和未来可能的不可用原因,因此使用语义化 Radio Group,而不是原生
|
||||
`select` 或 `SegmentedControl`:
|
||||
|
||||
- `fieldset` + `legend` 表达“模型下载源”。
|
||||
- 弹窗内使用 `fieldset` + 持久的屏幕阅读器 `legend` 表达“模型下载源”。
|
||||
- 每个选项使用 Radio 和整行可点击卡片。
|
||||
- 当前项同时显示选中 Radio、强调边框和选中背景。
|
||||
- 不能只用站点 Logo 或颜色表达选择。
|
||||
@@ -282,12 +289,16 @@
|
||||
|
||||
### 7.4 保存交互
|
||||
|
||||
- 用户选择另一个 Radio 后立即调用应用设置更新。
|
||||
- 保存期间禁用两个选项,保留最后确认值。
|
||||
- 成功后更新当前选择,并通过应用通知提示:
|
||||
- 打开弹窗时以已保存来源初始化草稿;选择 Radio 只修改草稿,不调用应用设置更新。
|
||||
- 点击“确认”才保存;草稿与已保存来源相同时直接关闭,不重复写入。
|
||||
- 取消、关闭按钮、Escape 或点击遮罩均丢弃草稿,已保存来源不变。
|
||||
- 保存期间禁用选项、确认、取消和关闭,阻止重复提交及遮罩、Escape 关闭。
|
||||
- 成功后更新页头当前来源并关闭弹窗,通过应用通知提示:
|
||||
`模型下载源已切换为 Hugging Face。`
|
||||
- 失败时恢复最后确认值,在 Radio Group 附近显示可重试错误。
|
||||
- 保存失败时保留弹窗和草稿,已保存来源不变;就地显示错误,允许再次点击“确认”重试。
|
||||
- 读取失败时显示错误和“重试”,禁用确认,不以默认值伪装为已保存来源。
|
||||
- 同一个失败不再同时显示页面横幅和全局错误通知。
|
||||
- 弹窗打开后聚焦关闭按钮,Tab 在弹窗内循环;关闭后焦点回到页头“模型下载源”按钮。
|
||||
|
||||
来源变化不触发:
|
||||
|
||||
@@ -304,28 +315,11 @@
|
||||
|
||||
---
|
||||
|
||||
## 8. 魔法笔记页签
|
||||
## 8. 魔法笔记设置边界
|
||||
|
||||
现有魔法笔记卡完整移动到“魔法笔记”面板:
|
||||
|
||||
- 显示魔法笔记入口。
|
||||
- AI 评论方式。
|
||||
- AI 评论形式。
|
||||
|
||||
保持以下行为不变:
|
||||
|
||||
- 开关继续使用共享 Switch 和 `role="switch"`。
|
||||
- 评论方式和形式继续使用 `SegmentedControl`。
|
||||
- 每项继续即时保存。
|
||||
- 保存失败保留最后确认设置。
|
||||
- `onMagicNotesEnabledChange` 继续更新应用导航入口。
|
||||
|
||||
页签重构不能:
|
||||
|
||||
- 重置现有设置。
|
||||
- 在切换页签时保存或改变值。
|
||||
- 因魔法笔记入口关闭而隐藏“魔法笔记”设置页签。
|
||||
- 把页签选择误当成启用开关。
|
||||
全局设置不再提供魔法笔记页签或跳转入口。应用中心与笔记页继续共享同一表单和配置,
|
||||
保留已有启用、待办数量显示、评论方式、评论形式及业务数据。共同设置的交互与同步规则见
|
||||
[应用导航 UI 设计](../features/application-tool-navigation/ui-design.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -645,7 +639,7 @@ resolveModelDownloadPackage(files, source)
|
||||
|
||||
### 12.1 来源状态
|
||||
|
||||
语音和 OCR 的当前模型详情各保留一个全局下载源标签。向量内置连接不重复显示平台功能中
|
||||
语音和 OCR 的当前模型详情各保留一个全局下载源标签。向量内置连接不重复显示模型连接页头中
|
||||
已经明确的全局来源,也不显示固定本地安装路径;下载仍使用已保存的全局来源。
|
||||
|
||||
所有模型管理界面都显示当前模型是否可下载、活动操作的进度与取消入口,以及已安装模型
|
||||
@@ -663,10 +657,12 @@ resolveModelDownloadPackage(files, source)
|
||||
|
||||
```text
|
||||
Hugging Face 暂不提供此模型的完整已验证文件。
|
||||
[前往通用设置]
|
||||
[模型下载源]
|
||||
```
|
||||
|
||||
不能显示可点击“下载”后才告诉用户缺少某个文件。
|
||||
向量、语音和 OCR 的“模型下载源”入口均导航到“模型连接”并直接打开同一弹窗。
|
||||
跨分类导航沿用设置中心的未保存修改确认,用户取消离开时保留原表单。
|
||||
|
||||
### 12.2 已安装模型
|
||||
|
||||
@@ -711,7 +707,7 @@ ZIP 是来源无关的离线迁移方式:
|
||||
- `正在从 {{source}} 下载`
|
||||
- `打开 {{source}} 模型仓库`
|
||||
- `当前下载源暂不提供此模型`
|
||||
- `前往通用设置更换下载源`
|
||||
- `模型下载源`(打开下载源弹窗)
|
||||
|
||||
不再写死:
|
||||
|
||||
@@ -757,7 +753,7 @@ ZIP 是来源无关的离线迁移方式:
|
||||
失败状态提供:
|
||||
|
||||
1. 重试当前来源。
|
||||
2. 前往“平台功能 → 通用设置”。
|
||||
2. 打开“模型连接”的模型下载源弹窗,选择并确认保存。
|
||||
3. 取消。
|
||||
|
||||
用户切换来源后需要重新点击下载。不能在设置保存后自动恢复上一失败任务。
|
||||
@@ -824,7 +820,7 @@ ModelScope 和 Hugging Face 都可能跳转到文件存储或 CDN。每个来源
|
||||
|
||||
| 设置 | 位置 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| 模型下载源 | 平台功能 → 通用设置 | GoodBuddy 托管本地模型 |
|
||||
| 模型下载源 | 模型连接页头 → 模型下载源弹窗 | GoodBuddy 托管本地模型 |
|
||||
| 检查更新源 | 关于与更新 | GoodBuddy 版本检查和下载页 |
|
||||
|
||||
切换任一设置不得修改另一个设置。用户选择 ModelScope 不表示应用更新使用镜像节点;选择
|
||||
@@ -864,9 +860,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 读取设置失败 | 显示平台功能阻塞错误,不猜测或写入来源 |
|
||||
| 保存来源失败 | 保留上一个确认值,显示就地错误 |
|
||||
| 当前来源无完整模型包 | 下载按钮禁用,显示前往通用设置 |
|
||||
| 读取设置失败 | 页头显示读取失败,弹窗提供就地重试并禁用确认,不猜测或写入来源 |
|
||||
| 保存来源失败 | 已保存来源不变,保留弹窗和草稿,显示就地错误并允许确认重试 |
|
||||
| 当前来源无完整模型包 | 下载按钮禁用,提供直接打开模型连接页下载源弹窗的入口 |
|
||||
| 当前来源网络不可用 | 当前下载失败或允许同来源重试,不请求另一来源 |
|
||||
| 当前来源返回 404 | 显示来源缺少固定文件,不请求另一来源 |
|
||||
| 重定向 Host 不受信任 | 立即失败并删除本次暂存 |
|
||||
@@ -894,15 +890,19 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
- 更高未知版本拒绝,不覆盖文件。
|
||||
- 并发设置更新保持完整 JSON。
|
||||
|
||||
### 17.2 平台功能界面
|
||||
### 17.2 设置界面
|
||||
|
||||
- 默认选中“通用设置”Tab。
|
||||
- Tab 使用 `tablist`、`tab`、`tabpanel` 和 `aria-selected`。
|
||||
- 方向键、Home、End 和焦点恢复正确。
|
||||
- 切换到魔法笔记不改变任何设置。
|
||||
- 切换平台功能页签不改变任何设置,全局设置不显示魔法笔记入口。
|
||||
- 模型连接页头的当前来源与次按钮位于模型类型页签同一行右侧、保存操作下方;通用设置不再显示下载源控件。
|
||||
- Radio Group 具有持久 Label。
|
||||
- 选择来源时保存一次,保存期间防止重复提交。
|
||||
- 保存失败恢复原值并保留可重试错误。
|
||||
- 选择 Radio 只更新草稿,确认后保存一次;未改变来源时确认不写入。
|
||||
- 取消、关闭、Escape 和遮罩关闭不改变已保存来源;保存期间禁止关闭及重复提交。
|
||||
- 保存失败保留草稿并允许确认重试;读取失败提供重试且禁用确认。
|
||||
- 弹窗使用 body Portal、焦点循环和关闭后焦点恢复。
|
||||
- 向量、语音和 OCR 的更换下载源入口直接打开模型连接页弹窗,跨分类时保留离开保护。
|
||||
- 窄窗口不隐藏页签或来源名称。
|
||||
|
||||
### 17.3 目录
|
||||
@@ -953,8 +953,8 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
- 新增 `ModelDownloadSource` Schema 和默认值。
|
||||
- 将应用设置版本升级并迁移旧版本。
|
||||
- 在“平台功能”中增加 `PageTabs`。
|
||||
- 增加“通用设置”与模型下载源 Radio Group。
|
||||
- 把现有魔法笔记卡移动到“魔法笔记”面板。
|
||||
- “通用设置”保留平台行为;模型下载源已移至模型连接页头,通过弹窗 Radio 草稿和确认保存。
|
||||
- 魔法笔记卡已迁入应用共同设置,全局页签已移除,见第 8 节。
|
||||
|
||||
### 阶段 2:共享目录和来源解析(已完成)
|
||||
|
||||
@@ -983,14 +983,16 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
### 19.1 产品
|
||||
|
||||
- “平台功能”显示“通用设置”和“魔法笔记”两个可访问页签。
|
||||
- “平台功能”显示“通用设置”和“远程项目”两个可访问页签。
|
||||
- 默认打开“通用设置”。
|
||||
- “通用设置”提供 ModelScope 和 Hugging Face,默认 ModelScope。
|
||||
- “模型连接”页头在模型类型页签同行右侧、保存操作下方显示当前来源及“模型下载源”次按钮。
|
||||
- 下载源弹窗提供 ModelScope 和 Hugging Face,默认 ModelScope;Radio 只编辑草稿,确认才保存,取消不改变已保存来源。
|
||||
- 读取与保存失败均可就地重试;向量、语音和 OCR 的更换下载源入口直接打开该弹窗。
|
||||
- 来源选择说明影响范围,并明确已安装模型、ZIP、Ollama 和应用更新不受影响。
|
||||
- 切换来源不会下载、删除、切换或重新验证任何已安装模型。
|
||||
- 当前来源没有模型时,在点击下载前即可看到不可用原因。
|
||||
- 下载失败不会自动请求另一来源。
|
||||
- 魔法笔记的现有设置和入口行为保持不变。
|
||||
- 魔法笔记设置由应用中心与笔记页共享,全局设置不再提供笔记入口;已有配置值和业务数据保持不变。
|
||||
|
||||
### 19.2 数据与下载
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# GoodBuddy 性能原则
|
||||
|
||||
## 文档信息
|
||||
|
||||
- **适用范围:** Desktop(Electron Main、Preload、Renderer)及其后台进程和线程。
|
||||
- **用途:** 新功能设计和代码评审的约束。多个 agent 并行修改代码时,以本文为统一标准。
|
||||
- **关联文档:** `docs/roadmap/product-performance-experience-improvement-plan.md`(测量记录与实施进度)。
|
||||
- **架构与验收:** [桌面存储与并发执行](./desktop-storage-direction.md)定义一次完整交付的职责和范围;[桌面并发验收](../quality/concurrent-desktop-acceptance.md)定义多项目、多会话、多子任务负载及证据。本文件是性能规范的唯一数值来源。
|
||||
- **参照:** VS Code 的进程分工和渲染做法。我们没有在同一台机器上实测过 VS Code,下面的预算按常见的流畅标准设定。
|
||||
|
||||
## 1. 性能预算
|
||||
|
||||
“流畅”用下面的数字判断,不靠感觉。“标准机”指记录了硬件环境、不降速运行的参考机;Renderer 降速场景使用 `GB_PERF_CPU_THROTTLE=4`。该降速仅作用于 Renderer,不等于真实低配整机,也不模拟慢 Main 或磁盘。预算适用于单场景及规定的并发负载。
|
||||
|
||||
| 指标 | 标准机 | 低端机 |
|
||||
| --- | --- | --- |
|
||||
| 按键到下一帧,p95 | ≤ 16 ms | ≤ 50 ms |
|
||||
| 点击到界面响应(切换会话、打开面板),p95 | ≤ 100 ms | ≤ 300 ms |
|
||||
| 稳定交互期间(打字、滚动、流式输出)超过 50 ms 的长任务 | 0 | 尽量为 0,总时长不超过场景时长的 5% |
|
||||
| 流式输出、滚动时的帧间隔,p95 | ≤ 17 ms | ≤ 33 ms |
|
||||
| Main 事件循环单次阻塞 | ≤ 16 ms | — |
|
||||
| Main 事件循环延迟,p99 | ≤ 30 ms | — |
|
||||
| DOM 节点数 | 不随会话数、消息数增长 | 同左 |
|
||||
|
||||
当前 `npm run perf:check` 使用历史回归上限,部分数值宽于本表,也未覆盖全部并发入口。检查通过不能宣称本表达标。目标交付须在验收报告中分别给出规范预算和回归上限的结果;低端机降速目前只做记录,真实低配和新增自动检查的完成状态另行记录。
|
||||
|
||||
点击反馈、所需内容可用和后台工作完成分别计时。显示加载状态只能证明及时反馈;不能用它替代结果就绪。每个进程分别报告长任务/事件循环和排队耗时,禁止把多个独立探针相加为端到端延迟。
|
||||
|
||||
## 2. 原则
|
||||
|
||||
### P1 Main 只做调度
|
||||
|
||||
Main 负责窗口、生命周期、权限和消息转发。数据库查询、文档解析、哈希、向量计算、批量文件读写,都放到 worker 线程或 `utilityProcess` 里执行。
|
||||
|
||||
- 新代码不能在 Main 里使用 `DatabaseSync` 和同步文件 API。lint 已有白名单,名单只能减少。
|
||||
- 暂时必须留在 Main 的同步操作,单次耗时要能证明在 16 ms 以内,并在代码里注明理由。
|
||||
- 耗时可能超过 16 ms 的操作,必须支持取消和进度上报,不能一直占着 Main。
|
||||
- 目标交付后 Main 不再直接或通过 helper 打开桌面业务 SQLite;只读、失败回退、维护及退出均在检查范围内。当前迁移例外不是新增同步工作的许可。存储宿主可继续使用同步 SQLite,事务和计算仍需有界。
|
||||
|
||||
### P2 界面状态局部订阅
|
||||
|
||||
状态按领域放进外部 store,组件通过 selector 只订阅自己用到的那一部分。
|
||||
|
||||
- 不再往 `App.tsx` 里加 `useState`、`useEffect` 或领域逻辑。`App.tsx` 有行数上限,只能往下调。
|
||||
- IPC 订阅放在 store 模块里,组件不直接调用 `window.goodbuddy.*.on*`。
|
||||
- 一个按键、一个流式片段,只能让显示它的那一小块界面重新渲染。
|
||||
- 传给 memo 组件的回调和 context 值,引用必须稳定。
|
||||
|
||||
**模块约定(便于维护,所有拆分都按这个结构做):**
|
||||
|
||||
| 层 | 文件命名 | 内容 | 不允许 |
|
||||
| --- | --- | --- | --- |
|
||||
| Store | `<domain>-store.ts` | `create<Domain>Store()` 返回 `getState / subscribe / 领域动作`;纯 TS,不依赖 React;可单测 | 访问 DOM、调用 React hook |
|
||||
| 选择器 / Hook | `<domain>-selectors.ts` 或 `use-<domain>.ts` | 基于 `useSyncExternalStore` 的 selector,返回值引用稳定 | 在 selector 里新建对象却不做相等比较 |
|
||||
| 同步层 | `<domain>-sync.ts` | 与 Main 的 IPC:首次加载、订阅推送、增量保存队列、失败重试、退出前刷盘 | 放在组件里;整份数据回写 |
|
||||
| 视图 | `<Domain>*.tsx` | `memo` 组件,只从 selector 和稳定的动作读数据 | 接收 App 的大对象或内联回调 |
|
||||
|
||||
- `App.tsx` 只负责布局外壳、路由和把 store 实例通过 context 提供给子树。新增领域状态一律走上表四层。
|
||||
- 每个 store 的单元测试和 store 文件放在一起(`<domain>-store.test.ts`),覆盖顺序、失败重试和边界情况。
|
||||
- 领域数据库访问集中在 repository,目标架构中由存储宿主执行;Main 的 IPC handler 只做校验并等待领域操作。受管只读 Worker 与远端 Agent 按各自明确的连接归属执行,不能按源码目录把它们误判为 Main 访问。
|
||||
- 多项目/多会话流式更新只通知相关领域与实体;后台子任务不得让全部项目视图重渲染。隐藏视图保留草稿、选择和必要执行订阅,停止无关绘制与全量重读,切回时按已有摘要及增量恢复。
|
||||
|
||||
### P3 高频数据走专门通道,按帧合并
|
||||
|
||||
流式文本、终端输出、进度、滚动这类高频数据,不进全局状态。
|
||||
|
||||
- 多条更新合并后,每帧最多提交一次;同步到全局状态的频率要更低,目前是 250 ms。
|
||||
- 高频路径上不能读取布局(`getBoundingClientRect`、`scrollHeight` 等),布局读取要集中到每帧一次。
|
||||
- 合并更新不能丢失、重复或打乱顺序。每条数据带序号,并有测试覆盖。
|
||||
- 显示合并与持久化分开。可以合并进度或替换过时刷新,不能静默丢弃消息、工具结果或需确认的事件;多生产者缓冲只有在用户设置、外部协议、数据安全或实测故障证据要求时才设置容量,否则将负载交给所属 Host。
|
||||
|
||||
### P4 数据只有一份来源,只传变化部分
|
||||
|
||||
- 每类数据只有一个权威来源,由数据所在进程负责持久化。界面不把整份数据写回。
|
||||
- 跨进程的通知直接带上变化的内容,接收方不再“收到通知后重新查全量”。
|
||||
- 写入合并成批次和事务,不按每个事件单独开一个事务。
|
||||
- 一个数据库有一个业务写入所有者,完整事务不跨 IPC 拆成单条 SQL。Main 不保存第二份可写业务状态;依赖写入的读取等待对应提交,远程 ACK 等待所需事件与 checkpoint 提交成功。
|
||||
- 缓存必须说明归属、失效和释放条件,不按浏览过的项目/会话无限积累。查询返回摘要、分页或所需详情,保持完整信息可访问,不用截断历史满足预算。
|
||||
- GoodBuddy 自身的中转数据优先使用有界内存;已有完整记录或文件时复用引用,避免重复落盘。预览、最终回答与工具全文分别核实,不能以“已保存消息”推断全文已保存。需要超出内存保留的内容直接使用所属存储的唯一正文,分页读取同一内容,避免临时全文再复制到正式存储。必要文件及清理范围遵守[输出与临时资源设计](./desktop-storage-direction.md#output-and-temporary-resource-ownership)。
|
||||
|
||||
### P5 工作量和可见内容成正比
|
||||
|
||||
- 长列表只渲染可见部分,包括消息、会话、笔记、文件树和知识库列表。
|
||||
- 解析和渲染的结果要缓存,比如 Markdown 语法树。内容没变,就不重新计算。
|
||||
- 增量处理:只解析变化的部分。没有把握时,退回到慢但正确的完整处理,并记录在测试里。
|
||||
|
||||
### P6 启动只做首屏需要的工作
|
||||
|
||||
- 首屏之前只做显示应用壳和恢复上次界面所需的工作。
|
||||
- 其他初始化(Provider、Runtime、知识库、通知渠道等)按需执行,或等首屏显示后再空闲执行。
|
||||
- 后台模块在第一次使用时才加载。
|
||||
- 冷启动、首次进入功能和暖返回分别验收;预加载只使用有界空闲工作,不能为每个项目预建进程、连接或整份历史缓存。进程 spawn 不代表服务就绪,首次可用反馈与数据实际可用分开记录。
|
||||
|
||||
### P7 先测量,再修改
|
||||
|
||||
- 先跑测量,拿到数据再改代码;每项改动都要有前后对比,基线用临时 worktree 构建。
|
||||
- 每个版本至少跑 3 轮,交替运行;结果差异小于同一版本多次运行之间的波动时,不能说成“有提升”。
|
||||
- 测量用隔离的用户目录和本地假模型,不发出外部模型请求,结果里不含用户内容。
|
||||
- 必测多项目、多会话、多子任务及导入/检索/回顾发布重叠场景,负载和采样按[并发验收](../quality/concurrent-desktop-acceptance.md)。记录 offered load、实际吞吐、排队和排空、资源高水位;不得降低输入速率、串行化夹具或丢事件来取得流畅结果。
|
||||
- 基准使用固定版本、数据和环境。性能测量与真实模型功能验收分开;受发布构建限制时遵守发布手册,不以性能原则授权额外生产打包。
|
||||
|
||||
### P8 正确性优先于速度
|
||||
|
||||
- 事件顺序、持久化、取消、崩溃恢复和显示结果都不能因为性能而改变。
|
||||
- 异步改造要先确认“先写入、后读取”的顺序,没有测试覆盖就不合并。
|
||||
- 风险高、收益小,或者会改变现有功能的优化先不做,在计划文档里记录理由。
|
||||
- 取消请求、执行结算和容量释放不是同一时刻;调用方停止等待不能提前释放仍运行的工作。取消一个会话或子任务不得杀死仍服务其他任务的共享宿主。已提交事务如实报告成功,不伪装成回滚;未确认写入不盲目重放。
|
||||
|
||||
### P10 体验不倒退
|
||||
|
||||
性能优化必须保留数据、结果和用户交互语义,不能用体验换速度:
|
||||
|
||||
- **动画和过渡保持原样。** 平滑滚动(如“到底部”按钮)、展开收起、悬停、淡入淡出都不能因为优化而消失。任何瞬间写入 `scrollTop`/`scrollTo` 的代码都会打断正在进行的平滑滚动,必须在平滑滚动期间暂停。
|
||||
- **只渲染可见部分(P5)时,用户状态不能丢。** 行被卸载后,它的局部状态也会消失。以下行必须保持挂载,或者把状态存到行外:有焦点的行、用户展开过 `<details>` 或输入过内容的行、选中文本的两端、待回答的问题和待审批项、打开菜单或正在重命名的行。
|
||||
- **只渲染可见部分时,基本操作要和完整列表一致:** 跨行选择和复制、滚动位置不因上方插入或删除而跳动(关闭原生 `overflow-anchor` 时要自己做锚定)、快速拖动滚动条时没有空白帧、渲染结果缓存让重新挂载的行不出现“加载中”。
|
||||
- **合并和批处理不能增加交互延迟。** 第一条数据立即处理(leading edge),只合并之后同一帧内的数据。打字回显、终端回显不能多等一帧。
|
||||
- **低频同步不能让用户看到旧数据。** 复制、导出、保存这类读取全局状态的操作,要先把待同步的数据刷进去。
|
||||
- **体验要有测试。** 涉及动画、滚动、焦点的改动,需要真实 Electron 测试(逐帧采样)或单元测试,并确认测试在旧代码上会失败。
|
||||
|
||||
### P9 用工具守住规则
|
||||
|
||||
- lint 规则和性能阈值是合并前的硬性条件,不靠人工记得。
|
||||
- 规则只能收紧,白名单和上限只能往下调。
|
||||
- 每个 agent 只提交自己改的文件和片段,不把别人未提交的改动带进自己的提交。
|
||||
- 检查直接 SQL 导入及已知间接打开函数/构造点,按实际宿主区分测试、存储与远端代码。白名单和上限需要与已提交基线比较,不能只靠注释约定;全局同步回退同样要被发现。
|
||||
- 普通 CI 与性能执行环境分别提供功能和预算证据;新增并发与首次进入场景纳入候选必需检查,缺少运行环境或结果时保留验收缺口,不由单测通过推断达标。
|
||||
|
||||
### P11 并发隔离与有界工作
|
||||
|
||||
同一会话的事件顺序和同一数据库的提交顺序必须保持;不同项目、会话、子任务的模型等待、重查询与清理不因此共用一把长时间全局锁。
|
||||
|
||||
- Main 全局控制队列不得等待模型、网络、原生启动或耗时清理。必要登记和状态提交可短暂串行,等待在所属操作范围内完成。
|
||||
- 重查询复用有界 Worker;必要写入不排在整个导入解析或模型请求之后。长事务先减少事务内无关计算,再测写入排队,不把阻塞简单搬进唯一存储队列。
|
||||
- 复用现有池、批处理和队列,记录在途数、排队数及实际资源使用。只有有明确依据时才设置容量;没有依据时将负载交给所属 Host,不因内部数字拒绝用户工作。有实测饥饿才引入最小调度调整,不预建通用优先级框架。
|
||||
- 控制和取消不等待新的执行槽位;如果外部或用户设置要求排队,按该语义执行。禁止静默丢失、无限重启或自动扩增进程;不能把未经证明的队列上限包装成稳定性。
|
||||
- `ReadonlyQueryReader` 的生产 storage 模式不传 `maxPending`,因此请求直接透传给 storage host;其等待队列只服务显式有界的调用方和测试,不构成第二个 storage owner。
|
||||
- 每个异步响应携带已有项目/会话/任务/操作归属;迟到响应不得覆盖新选择或新操作。归属检查先于改变共享请求代次、缓存或队列状态。
|
||||
|
||||
### P12 架构完整性与 KISS
|
||||
|
||||
一次性交付按最终职责边界验收,不以全面 Promise 化或局部热点迁出代替。目标范围内正常、后台、失败、重试、取消和退出路径均使用同一权威实现,清理原直接入口和回退。
|
||||
|
||||
保持现有技术栈、数据库文件及业务合同,复用已有资源机制。不得顺带新增分布式事务、持久 RPC receipt、通用恢复框架、每项目独立服务或产品审批模式。同步内存缓存读取可以保留,不把纯函数和每个 getter 远程化。设计新增机制必须指出当前失败、现有机制的不足与对应验证,不以“将来可能并发”代替证据。
|
||||
|
||||
## 3. 评审清单
|
||||
|
||||
提交性能相关改动,或者会影响热点路径的功能改动时,逐项确认:
|
||||
|
||||
1. Main 里有没有新增同步数据库调用、同步文件 IO,或者耗时可能超过 16 ms 的计算?
|
||||
2. 有没有往 `App.tsx` 里加状态?一次按键或一个流式片段,会让多大范围的界面重新渲染?
|
||||
3. 高频路径上有没有读取布局,有没有每次事件都提交状态?
|
||||
4. 有没有整份数据回写,或者“收到通知后重新查全量”?
|
||||
5. 列表的 DOM 数量会不会随数据量增长?
|
||||
6. 有没有在启动阶段新增同步初始化?
|
||||
7. 是否附上了 `perf:app`、`perf:main` 或 `perf:knowledge` 的前后对比?
|
||||
8. 顺序、取消、持久化和恢复,有没有测试覆盖?
|
||||
9. 动画、平滑滚动、过渡还在吗?有没有新增会打断平滑滚动的瞬间滚动写入?
|
||||
10. 只渲染可见部分的列表,滚走再滚回来,展开状态、输入内容、选区、焦点还在吗?
|
||||
11. 合并或延迟处理后,第一次响应(打字回显、点击反馈)有没有变慢?复制、导出读到的是不是屏幕上的最新内容?
|
||||
12. 多项目/多会话/多子任务同时运行时,取消或清理一个请求是否拖住其他任务?队列和缓存是否持续增长?
|
||||
13. 存储提交、远程 ACK、调用方返回和资源释放的顺序是否有真实路径及故障注入验证?
|
||||
14. 是否删除目标范围内的旧直接访问和同步回退?新增进程或调度机制是否复用现有设施并有实际必要性?
|
||||
15. 请求或输出是否产生非必要临时文件、重复全文?是否核实完整内容与预览的区别,并验证分页、引用释放、必要文件的创建失败清理及升级后的残留回收?
|
||||
|
||||
## 4. 当前差距
|
||||
|
||||
截至 2026-10-05,以下状态结合[存储升级实施记录](../quality/storage-upgrade-implementation-2026-10-05.md)和[并发验收规范](../quality/concurrent-desktop-acceptance.md)核对。表中“已实现”只表示代码或定向测试已有证据,不表示 PERF-15、PERF-16 或 C01-C07 已完成。
|
||||
|
||||
| 原则 | 差距 |
|
||||
| --- | --- |
|
||||
| P1 | Assistant、Knowledge、文件和 Runtime 生产域已由 `DesktopStorageClient` 接入 utility process;剩余缺口是全量间接访问扫描、异常/维护/退出路径核验,以及 Main 阻塞和存储排队的并发测量。文档解析、分块和大文件哈希仍在各自 worker。 |
|
||||
| P2 | `App.tsx` 约 7,900 行。输入区、隐藏路由、任务和结果事件、切换会话已不再整体重渲染 App;当前会话的流式吸收(每 250 ms)、队列和活动记录仍经过 App |
|
||||
| P3 | 流式输出和终端输出已有按帧合并;C01/C02/C06 的长期负载仍需验证是否出现排队增长、长帧或内容丢失。 |
|
||||
| P4 | 存储域已统一到各自 owner,输出 backing 和附件引用已接入;部分 `*Changed` 通知仍会重新读取全量,重复缓存和跨域通知的并发影响待测。 |
|
||||
| P5 | 消息、会话、笔记、文件树、知识库文档表格已有窗口化;C07 仍需记录冷/暖内容可用时刻和切换后的状态保留。 |
|
||||
| P6 | 空配置和真实数据启动有历史测量;当前 C07 尚未完成监督概览、图谱、历史选择和来源正文的完整冷/暖验收。 |
|
||||
| P9 | 已有 lint 和历史回归上限;间接 SQLite 打开、白名单单调收紧、并发指标缺失和 CI/性能环境接入仍待补齐。 |
|
||||
| P10 | 已有局部输入、刷新和项目切换修复;需要在 C01/C02/C06 中继续验证动画、焦点、滚动、草稿和取消语义没有因降载或合并而退化。 |
|
||||
| P11 | 存储请求已移除产品侧 admission 上限并透传给 storage host;运行时仍有若干并发与消息容量数字,需逐项证明属于用户设置、外部协议、数据安全或实测故障约束,否则应删除。C02、C04、C05、C06 尚未证明健康 peer 隔离、故障恢复和队列排空。 |
|
||||
| P12 | 目标边界和 KISS 约束已写入架构文档;S01-S07 已有生产组合,但删除旧直接入口、异常路径和全候选统一验收仍未完成。 |
|
||||
|
||||
2026-10-04 补充:监督回顾的来源初始化和继续前版本校验已移入现有 Worker 入口的独立连接,扫描不持有写锁,清单按 200 条提交,生产失败不回退 Main 同步扫描。最终发布事务仍在 Main;实现与实测边界见[监督者进度](../features/conversation-supervision/progress.md#2026-10-04-回顾初始化阻塞修复)。
|
||||
|
||||
2026-10-05 局部修复记录:监督者 UI 的概览、图谱、故事读取也已迁到现有只读 Worker;概览按需加载、列表窗口化及摘要缓存见[KISS 局部修复](../review/kiss-local-fixes-2026-10-05.md)。这与工具侧 Story Graph 读取是不同入口。Main 最终发布及部分大响应仍有超预算样本,不能把局部完成视为全项达标。
|
||||
|
||||
随后用户确定按[完整存储与并发架构](./desktop-storage-direction.md)一次性交付。当前已完成存储 owner、主要生产组合和部分输出迁移,但没有完成全面架构验收。P11/P12 与[并发验收矩阵](../quality/concurrent-desktop-acceptance.md)约束当前候选的剩余验证,不能作为已有实现或实测结果;上述第一期历史状态也不替代当前验收。
|
||||
@@ -0,0 +1,247 @@
|
||||
# Runtime Storage And Background Reads
|
||||
|
||||
## Current Implementation
|
||||
|
||||
This file records current runtime timing, reader behavior and temporary-resource
|
||||
ownership. The production runtime receives the storage-owned output backing store and
|
||||
output adoption function during Main startup. Main also computes one per-version,
|
||||
per-run temporary root and passes it to the local runtime cache paths and native
|
||||
client coordinator.
|
||||
|
||||
SQLite owns persisted task, event and conversation evidence. The desktop business
|
||||
connection is long-lived. WAL permits readers alongside a writer; synchronous SQL
|
||||
still blocks the thread that executes it. Memory caches are disposable derived data.
|
||||
Persisted evidence is not necessarily full tool output; the inventory below records
|
||||
the current distinction. The target ownership rules are in the architecture document.
|
||||
|
||||
Startup storage upgrades run in `assistant-storage-worker` before the business
|
||||
connection opens. Runtime readers must not invoke initialization, migrations or
|
||||
recovery. `SubagentProgressStorage` caches event encoding state, not task statistics.
|
||||
|
||||
## Runtime Read Design
|
||||
|
||||
Execution duration is persisted on running-state transitions in Main. Snapshots
|
||||
read small indexed timing rows; they require no historical evidence query, worker
|
||||
or result cache. Main owns current segments independently of renderer visibility.
|
||||
The [duration design](../features/task-and-job/technical-design.md) defines the
|
||||
state boundaries, restart handling and incomplete history.
|
||||
|
||||
## Planned Architecture
|
||||
|
||||
The [complete desktop architecture](./desktop-storage-direction.md) defines the
|
||||
remaining target, including desktop binding databases and native-client model ledgers.
|
||||
Its migration and [concurrent acceptance](../quality/concurrent-desktop-acceptance.md)
|
||||
are one delivery; remote Agent database ownership remains unchanged. Main will still
|
||||
observe runtime transitions while the storage service persists them in order; the
|
||||
indexed timing representation and semantics remain.
|
||||
|
||||
## Runtime Read Lifecycle
|
||||
|
||||
Read transactions end before replies are posted. Long-lived read transactions
|
||||
would prevent WAL checkpoints from reclaiming old pages. Each reader has its own
|
||||
SQLite page cache; adding workers increases memory usage and requires evidence of
|
||||
parallel query demand. New consumers should reuse the same lifecycle and read-only
|
||||
rules, without introducing a general query protocol before a second use case exists.
|
||||
|
||||
## Renderer Scheduling
|
||||
|
||||
Hidden task panels use React `Activity` to preserve state while scheduling hidden
|
||||
render work below visible updates. Panel factories run inside that boundary.
|
||||
Terminal and browser panels retain their existing mounted effects and sessions.
|
||||
Duration snapshots refresh on Main change notifications while the task center is
|
||||
open. The renderer ticks locally for open segments and fetches fresh snapshots on
|
||||
reopening. Results remain scoped; superseded responses are discarded.
|
||||
|
||||
The [task statistics design](../features/task-and-job/technical-design.md) owns the
|
||||
snapshot contract and timing rules. Tests and timings demonstrate only their fixture
|
||||
conditions; they do not establish a latency guarantee for arbitrary retained history.
|
||||
|
||||
## Output And Temporary File Investigation
|
||||
|
||||
Source review on 2026-10-05. The inventory records the current production flow. No
|
||||
user Temp directory was scanned or deleted.
|
||||
|
||||
| Producer | Current content flow | Retention and consequence |
|
||||
| --- | --- | --- |
|
||||
| Local process and ripgrep | [Process capture](../../src/main/agent/direct-model-process-service.ts) writes stdout/stderr through [PagedOutputStore](../../src/main/agent/paged-output-store.ts); model receives a JSON-bounded preview, up to 96 KiB per stream, and a reference when truncated | Every stream opens a file, including empty stderr. Small files are deleted on finish; retained files and their in-memory handles belong to the conversation/service |
|
||||
| Direct-model subagent | [Subagent service](../../src/main/assistant/direct-model-subagent-service.ts) captures child text through the same store, returning up to 192 KiB and an optional reference | [Provider event bridge](../../src/main/agent/model-tool-provider.ts) forwards preview/status but drops the output reference from the subagent event; child text is not an independent full parent-history record |
|
||||
| Tool events and conversation history | [Model runtime](../../src/main/agent/model-runtime.ts) emits at most 16,000 characters of serialized tool result as the event preview; [IPC](../../src/main/ipc.ts) and [database](../../src/main/assistant/assistant-database.ts) persist the supplied event | Tool events still carry bounded previews, while output history adopts finished handles through attachment storage. Even `output_read` responses are previewed in tool events. Persisted event history does not by itself guarantee a complete stdout/stderr copy |
|
||||
| Remote ACP | [Agent transcript](../../src/agent-daemon/semantic-prompt-store.ts) retains received notifications pending ACK; [desktop adapter](../../src/main/agent/acp-remote-runtime.ts) bounds mapped events before persistence | ACK prunes Agent transcript rows. Notifications themselves may already be bounded by the Runtime. Do not treat this journal as a permanent full-output source or remove commit-before-ACK |
|
||||
| MCP calls | [MCP transport](../../src/main/capabilities/mcp-client-transport.ts), provider and [gateway](../../src/main/agent/knowledge-mcp-gateway.ts) pass objects through stdio/HTTP/SSE | No per-call request/response scratch-file creation was found in these paths. Calling a tool over MCP does not itself require a payload file; third-party server internals are outside this inventory |
|
||||
|
||||
`PagedOutputStore` keeps short and empty output in memory and delegates larger
|
||||
or JSON-preview-truncated content to an injected `PagedOutputBackingStore`. Capture
|
||||
has a shared 8 MiB budget, at most 1,024 pending chunks per writer and 4,096 active or
|
||||
retained handles per store; backing writes are at most 64 KiB. Producers must await
|
||||
append, or receive an explicit capacity error. Local process pipes pause until each
|
||||
append settles. The store does not create a temporary file backend or another DB.
|
||||
UTF-8 byte cursors, JSON-escaped preview bounds, owner checks and failed/cancelled
|
||||
output remain covered by focused tests using real async files and local children.
|
||||
|
||||
The production owner exposes output create, append, finish, read, adopt and release
|
||||
through the existing storage transport. Main passes its `backingStore` to the runtime
|
||||
and passes `outputAdopt` to the tool provider. When output history is enabled, finished
|
||||
stdout/stderr references are adopted into the conversation attachment reference for
|
||||
the target message; the bounded preview and output reference are returned together.
|
||||
Missing backing configuration still explicitly fails capture that requires full
|
||||
storage. A model's final answer is not an equivalent copy of its tool output.
|
||||
|
||||
### Planned Architecture And Remaining Acceptance
|
||||
|
||||
The planned architecture still requires full end-to-end history and restart coverage,
|
||||
resource reconciliation and the remaining storage-owner migration. The file-backed
|
||||
test fixture alone does not establish that architecture or its acceptance.
|
||||
|
||||
Tool previews can also appear in both conversation `tools` and `blocks`. This is a
|
||||
separate persisted-preview duplication issue, not evidence that full paged output is
|
||||
already stored. Existing [subagent progress compaction](../../src/main/assistant/subagent-progress-storage.ts)
|
||||
already handles repeated progress snapshots; retain that implementation.
|
||||
|
||||
### Other Owned Files
|
||||
|
||||
| Existing owner | Source finding | Disposition for the upgrade |
|
||||
| --- | --- | --- |
|
||||
| [DocumentResultStorage](../../src/main/document-result-storage.ts) and [attachment storage](../../src/main/conversation-attachment-storage.ts) | `parsed.md` duplicates `manifest.json.content`; normal reads use the manifest and `copyResult` copies the extra file | Remove the redundant new write/copy. Preserve original files, extracted images, manifests and ownership transfer; old directories follow existing reference cleanup |
|
||||
| [OpenCode runtime](../../src/main/agent/opencode-runtime.ts) | Configuration already uses `OPENCODE_CONFIG_CONTENT`; scratch supplies isolated XDG state and skill registration | Keep required isolation, reuse immutable skill caches. Do not invent a config-file optimization for a path already using memory/environment |
|
||||
| [Continue adapter](../../src/main/agent/continue-host-adapter.ts) | CLI uses `--config` plus an isolated global directory; creation can fail before the outer owner receives the path | Retain the external-file interface and clean partial creation at the creating function, including skill-staging failure; preserve pre-existing files on `EEXIST` |
|
||||
| [Continue helper](../../src/agent-daemon/continue-acp-helper.ts) and [model bridge helper](../../src/agent-daemon/model-bridge-helper.ts) | Temporary config/plugin resources use helper finalization; setup before the protected scope and termination need lifecycle coverage | Extend existing ownership and shutdown, verify child exit before deletion. Plugin file URLs and Unix sockets remain necessary interfaces under current contracts |
|
||||
| [Managed Python](../../src/main/local-tool-environment/managed-python-install.ts), [model packages](../../src/main/model-package-utils.ts), [Agent packages](../../src/main/remote-agent/agent-package-manager.ts) | Install staging, partial downloads and backups support validation/publication; existing cleanup has owner-specific patterns | Extend those cleanup functions for demonstrated leftovers; preserve active staging and verified reusable installations. No global installer sweep |
|
||||
| [Magic note storage](../../src/main/magic-notes/magic-note-storage.ts) | Sibling atomic-write `.tmp` files protect replacement; memory-database fixtures have a separate temporary root | Keep atomic replacement and normal failure cleanup. Test scratch follows repository rules; persistent note directories are user data |
|
||||
|
||||
Document results may move from temporary ownership into attachment references, including
|
||||
shared branch references. A directory under `temp/document-parsing` can still be owned
|
||||
by persisted data. Its location alone does not authorize deletion. Native client history,
|
||||
model caches, database WAL/SHM and exported user files are likewise not disposable scratch.
|
||||
|
||||
Some tests/build helpers share production-looking prefixes, including `goodbuddy-process-*`
|
||||
and `goodbuddy-opencode-*`. Prefix and age alone cannot establish who owns a legacy directory.
|
||||
The [architecture rules](./desktop-storage-direction.md#output-and-temporary-resource-ownership)
|
||||
define the replacement and cleanup scope; the [acceptance matrix](../quality/concurrent-desktop-acceptance.md#输出与临时资源验收)
|
||||
defines evidence required before claiming improvement.
|
||||
|
||||
### Creator Cleanup Validation (2026-10-05)
|
||||
|
||||
Continue now removes its partially written config and failed skill-staging directory
|
||||
at the creator, before returning ownership to the run. Exclusive creation failures
|
||||
leave existing files/directories intact. Run finalization preserves prepared caches
|
||||
and external configuration. Both Agent helpers protect setup with finalization and
|
||||
handle SIGTERM/SIGINT; Continue stops prompt admission, aborts and drains active
|
||||
runs, while the OpenCode helper waits for child exit before deleting its plugin.
|
||||
Managed Python's existing pre-install cleanup also removes regular download files
|
||||
with the exact UUID layout, preserving directories, unknown names, caches and history.
|
||||
|
||||
Validation in the concurrent working tree:
|
||||
|
||||
- Five focused suites (`continue-host-adapter`, `continue-acp-helper`, `model-bridge`,
|
||||
`managed-python-install`, `local-tool-environment-service`): 81 passed, 10 Unix-only
|
||||
cases skipped on Windows. Failure injection covers partial creation, EEXIST,
|
||||
failed skill staging, setup-time signals and shutdown cleanup.
|
||||
- Final `npm run typecheck` passed; ESLint passed for all eight owned source/test files.
|
||||
Repository lint passed with `npm run lint -- --ignore-pattern temp`; unfiltered
|
||||
lint encountered generated validation bundles in ignored task/release directories.
|
||||
Task-owned `temp/goodbuddy-scratch-upgrade` was subsequently removed and its absence
|
||||
verified; unrelated release scratch was preserved.
|
||||
- Full Vitest run: 517 suites passed, 7 failed, 15 skipped; 6,244 tests passed,
|
||||
6 failed, 94 skipped. Failures outside this cleanup scope included OpenCode config
|
||||
preparation (`spawnSync npm.cmd EINVAL`), the MCPVault Electron fixture, and
|
||||
message-expansion and workspace-import Electron assertions. These results do not
|
||||
establish full architecture acceptance.
|
||||
- Reused the existing `a01-runtime-host-build.cjs`/`a01-runtime-host-run.cjs` harness
|
||||
logic, rebuilding Agent, daemon and Desktop driver from current source under
|
||||
`temp/goodbuddy-scratch-upgrade`. Connected to Linux x64 over the LAN using
|
||||
GoodBuddy encrypted credentials and the pinned Host identity. Both routes were
|
||||
reachable; only the LAN route was used for execution.
|
||||
- Both OpenCode and Continue passed real tool execution, startup cancellation,
|
||||
disconnect/reconnect during execution, checkpoint persistence/replay deduplication,
|
||||
and retirement. Final-source cancellation took 217 ms and 233 ms respectively;
|
||||
each run reported zero owned processes after retirement and shutdown. There were
|
||||
eight real model requests across the initial and final runs, four per runtime.
|
||||
Remote artifacts stayed in dedicated `/root/tmp/gb-lifecycle-*` directories and
|
||||
followed the harness cleanup. These timings are scenario observations, not a
|
||||
comparative performance claim.
|
||||
|
||||
### Version And Run Scratch Follow-up (2026-10-05)
|
||||
|
||||
`runtimeTemporaryRoot(userDataPath, appVersion)` computes the lazy
|
||||
`temp/goodbuddy-runtime/<version>/<runId>/goodbuddy-runtime-launch` path.
|
||||
Main passes that same value through existing `continueHostCacheRoot` and
|
||||
`opencodeSharedCacheRoot` options. The native client coordinator receives it as
|
||||
`temporaryRoot` and passes it to both native clients. The existing constructors need
|
||||
no new parameter.
|
||||
OpenCode creates its isolated launch directory beneath the supplied shared root;
|
||||
Continue puts generated configuration and isolated globals in one per-call directory.
|
||||
Prepared bundles and immutable Skill/config caches remain reusable within the run.
|
||||
The helpers on a remote Host use `goodbuddy-runtime-launch` beside their existing
|
||||
model-bridge socket, under that process owner's scratch directory. Global `TEMP`
|
||||
is unchanged.
|
||||
|
||||
Each local launch has a sibling owner file containing creator and child PIDs. Keeping
|
||||
metadata outside the deleted tree preserves retry evidence after partial deletion.
|
||||
The launch root also records its creator for cache-only reclamation. After the
|
||||
single-instance check and first screen, call `cleanupRuntimeTemporaryDirectories`
|
||||
in the existing delayed background startup work. It examines at most 256 directory
|
||||
entries per invocation, sequentially, and accepts the startup shutdown signal.
|
||||
Service disposal still stops children before deleting its own launch material;
|
||||
an unconfirmed child exit retains the material. Startup preserves live/reused PIDs,
|
||||
permission-denied process checks, malformed ownership and the ambiguous spawn-to-PID
|
||||
recording window. Empty version/run parents are removed after owned material.
|
||||
This is not a new process owner or an installer collector.
|
||||
|
||||
Legacy candidates are limited to the known OS-Temp layouts `goodbuddy-opencode-*`
|
||||
(desktop isolated state), `goodbuddy-continue-*` (Continue helper), and
|
||||
`goodbuddy-opencode-plugin-*` (model-bridge plugin). Deletion requires valid owner
|
||||
metadata and confirmed inactive creator/child PIDs. Releases that wrote no such
|
||||
metadata cannot be identified as obsolete from their names, contents or age alone;
|
||||
those directories are retained and reported. A bounded pass can leave candidates
|
||||
unvisited. Native-client histories, existing version-only caches and unrelated
|
||||
OS-Temp files are outside this reclamation contract. The old version/layout-only
|
||||
cache sweep is not evidence that a surviving child has exited.
|
||||
|
||||
Remote environment preparation now retains its existing pending-operation record
|
||||
when connection acquisition or staging cleanup fails. Because that store has one
|
||||
record per Host, a replacement prepare retries cleanup before overwriting it;
|
||||
confirmed cleanup or `operation-unavailable` clears it. Already adopted environments
|
||||
remain usable. Agent package delayed removals retain their pending entry until
|
||||
deletion succeeds, retry on inventory access, and rediscover verified obsolete
|
||||
versions through the existing package scan after restart. Active archive leases
|
||||
still prevent deletion.
|
||||
|
||||
Continue bundle staging now waits for all parallel writes to settle before failure
|
||||
cleanup. Existing settings atomic replacement and selected-runtime diagnostic
|
||||
teardown already use `finally`; no global `.tmp` sweep or new diagnostic owner was
|
||||
added. Native clients now accept an optional `temporaryRoot` for the same application
|
||||
version/run launch root. `NativeTerminalClient` keeps histories and reusable caches
|
||||
under its existing `rootDirectory`; only per-launch material moves to scratch.
|
||||
DS Web keeps `DSH_HOME`, sessions and user preferences in place and supplies its
|
||||
generated configuration through `web --patch <scratch-file>`. Its ledger also lives
|
||||
in scratch. Both reuse the existing sibling PID metadata and await ledger close
|
||||
before releasing material; unconfirmed child exit retains it. ConPTY registration
|
||||
waits for the actual child PID, which can be unavailable immediately after spawn.
|
||||
The collector recognizes `goodbuddy-native-dsh-*` alongside the existing prefixes.
|
||||
The production wiring propagates `temporaryRoot` through the native coordinator to
|
||||
both constructors without relocating the retained-history root.
|
||||
Focused verification passed 60 tests, including a real Windows PTY, official DS Web
|
||||
session persistence across restart, and delayed ledger-close cleanup. Typechecking
|
||||
and scoped lint passed; this native follow-up made zero real model calls.
|
||||
|
||||
Final-source verification for this follow-up is separate from the earlier eight
|
||||
real requests above: two real provider requests total, one per runtime. Current-source
|
||||
Continue/OpenCode helpers ran on Linux x64 over pinned SSH, each executed its native
|
||||
command tool, returned the real model answer, and removed launch files after exit.
|
||||
A separate active-request shutdown probe passed for both helpers with zero real
|
||||
provider requests. Four successful scenarios used deterministic command responses;
|
||||
probe setup retries before these results did not reach a provider. A previous-process
|
||||
version/run directory was reclaimed using actual dead PIDs. After the final
|
||||
enumeration-error guard, a separate Host probe confirmed that the current collector
|
||||
retains a real live child and reclaims that same run after its exit, with zero model
|
||||
requests. The timed-out probe's ledger was audited and contained zero calls; all
|
||||
task probe artifacts were removed locally and remotely. These probes cover the
|
||||
changed helper and scratch lifecycle, not full Desktop UI or installation acceptance.
|
||||
|
||||
The seven primary suites passed 267 tests with 10 platform skips; related runtime,
|
||||
native-terminal and diagnostic-owner suites also passed. Agent and web typechecks
|
||||
passed. Full desktop typecheck and repository lint remain blocked by concurrent
|
||||
`portable-storage-upgrade-fixture.ts` typing and `portable-storage-upgrade.mjs` lint
|
||||
errors; unfiltered lint also visits generated files under `temp`. Focused lint for
|
||||
the edited source/tests passed. The C01-C07 concurrent acceptance matrix remains
|
||||
incomplete; these results do not establish full storage-architecture acceptance.
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-09 |
|
||||
| 适用范围 | GoodBuddy 桌面端、ShareServer、网关间联邦协议 |
|
||||
| 桌面端设计 | [设备发现与共享](../features/device-sharing/README.md) |
|
||||
| 服务端设计 | [ShareServer](../../../goodbuddy-shareserver/docs/features/share-server/README.md) |
|
||||
@@ -26,7 +26,7 @@
|
||||
1. **GoodBuddy Desktop** 在设置中提供“设备发现与共享”,默认使用局域网发现与直连,也可
|
||||
由用户额外连接一个或多个 ShareServer。
|
||||
2. **ShareServer** 是独立部署、独立升级的 Web 服务,提供组织、设备、能力、权限、更新、
|
||||
联邦、中继和审计管理。它不运行在 Electron Main 中,也不由桌面端代为启动。
|
||||
联邦、中继、Office 编辑和审计管理。它不运行在 Electron Main 中,也不由桌面端代为启动。
|
||||
|
||||
### 2.2 能力优先,来源可见
|
||||
|
||||
@@ -55,6 +55,17 @@ 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. 术语
|
||||
|
||||
| 术语 | 定义 |
|
||||
@@ -71,6 +82,7 @@ ShareServer 负责身份、目录、策略、任务路由、更新和审计。
|
||||
| `Task` | 一次远程能力调用及其输入、执行、输出和终态 |
|
||||
| `Package` | 可下载到本机安装的签名能力工件 |
|
||||
| `FederationTrust` | 两个组织网关之间经管理员确认的双边信任关系 |
|
||||
| `DocumentEditSession` | ShareServer 托管的单文档短期编辑会话、工作副本和保存检查点 |
|
||||
|
||||
“网关”是 ShareServer 的网络角色;产品名称为 `ShareServer`,独立仓库为
|
||||
`goodbuddy-shareserver`。
|
||||
@@ -84,6 +96,10 @@ GoodBuddy Desktop A <-- mDNS 发现 + 配对后 TLS 直连 --> GoodBuddy Desktop
|
||||
单组织:
|
||||
GoodBuddy Desktop A -- 出站连接 --> ShareServer <-- 出站连接 -- Provider Device B
|
||||
|
||||
Office 编辑:
|
||||
GoodBuddy Desktop --> ShareServer --> ONLYOFFICE Docs
|
||||
└-------> 临时工作副本与保存检查点
|
||||
|
||||
跨组织:
|
||||
Desktop A --> ShareServer A <== 联邦信任 ==> ShareServer B <-- Provider Device B
|
||||
```
|
||||
@@ -121,6 +137,7 @@ Desktop A --> ShareServer A <== 联邦信任 ==> ShareServer B <-- Provider Devi
|
||||
- 计算组织强制策略,签发短期任务授权。
|
||||
- 路由任务、维护在线状态,并在允许时中继数据。
|
||||
- 管理签名包、发布通道、更新策略和撤销。
|
||||
- 可选管理 Office 编辑会话、ONLYOFFICE 接入、工作副本、保存回调、保留和清理。
|
||||
- 建立双边联邦关系并限制跨组织可见性与调用。
|
||||
- 保存有界审计元数据、用量和管理操作。
|
||||
|
||||
@@ -339,6 +356,16 @@ created/awaiting_approval/queued/connecting -> rejected | expired | cancelled
|
||||
- 未识别字段在声明允许扩展的位置忽略,安全与授权枚举中的未知值一律拒绝。
|
||||
- 联邦只交换双方共同支持的能力和策略表达式,不降低任一方策略。
|
||||
|
||||
### 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 最小审计事件
|
||||
@@ -358,6 +385,8 @@ created/awaiting_approval/queued/connecting -> rejected | expired | cancelled
|
||||
|
||||
- 桌面端是本地配对、本机共享偏好和本地活动记录的权威。
|
||||
- ShareServer 是组织成员、设备注册、组织策略、发布目录和组织审计的权威。
|
||||
- ShareServer 是 Office 工作副本、服务端保存检查点、编辑会话和保留清理的权威;Desktop 或
|
||||
远程 GoodBuddy Agent 是源文件是否成功回写的权威。
|
||||
- 能力提供者是运行中任务及结果是否产生的权威。
|
||||
- 联邦两端各自保存本组织审计,不复制对方完整成员目录或内部策略。
|
||||
|
||||
@@ -372,10 +401,14 @@ ShareServer 可以部署到公网,因此公网连接、外部组织和服务
|
||||
4. ShareServer 按组织隔离查询,搜索和计数接口不能泄露无权查看的设备或能力。
|
||||
5. 设备只建立主动配置的网关出站连接,不接受 ShareServer 发起任意系统调用。
|
||||
6. 局域网发现不等于信任,组织成员身份也不等于所有能力授权。
|
||||
7. 远程能力不能扩大本机 Ask/Execute、Workspace、Runtime、Skill 或 MCP 现有权限。
|
||||
7. 远程能力不能扩大本机 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. 建议领域实体
|
||||
|
||||
@@ -401,6 +434,8 @@ PackageRelease
|
||||
FederationTrust
|
||||
AuditEvent
|
||||
Revocation
|
||||
DocumentEditSession
|
||||
DocumentCheckpoint
|
||||
```
|
||||
|
||||
共享协议只定义跨端 DTO 和状态,不规定桌面 SQLite 与 ShareServer 数据库使用相同表结构。
|
||||
@@ -442,6 +477,10 @@ ShareServer 不反向依赖桌面端 `src/shared`,构建和运行不要求桌
|
||||
- [ ] 跨组织调用同时满足两端策略;联邦撤销后不能创建新任务。
|
||||
- [ ] 审计默认只保存必要元数据,不保存正文、文件、密钥或认证信息。
|
||||
- [ ] ShareServer 可独立部署和升级,不由 Electron 启动,也不影响桌面端离线使用。
|
||||
- [ ] 启用 Office 服务后,Desktop 可在多实例文档页签中编辑 DOCX、XLSX 和 PPTX;保存回调、
|
||||
源文件冲突或网络故障均不会破坏原文件。
|
||||
- [ ] Office 会话的启动、文件和 callback 凭据不可互换,过期或撤销后不能重用;临时工作副本
|
||||
按策略清理,默认审计不保存文档正文或 AI 选区内容。
|
||||
|
||||
## 17. 相关设计
|
||||
|
||||
@@ -453,5 +492,6 @@ ShareServer 不反向依赖桌面端 `src/shared`,构建和运行不要求桌
|
||||
- [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)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# Agent 功能维护约束
|
||||
|
||||
本文面向在 GoodBuddy 中新增功能的 Agent。它只记录做决定时必须遵守的维护规则,
|
||||
不替代架构设计、性能规范或功能文档。若本文与代码或权威文档不一致,以已验证的生产
|
||||
代码和下列权威文档为准,并修正文档链接或内容:
|
||||
|
||||
- [桌面存储与并发执行](../architecture/desktop-storage-direction.md):职责边界、存储所有权和操作边界。
|
||||
- [桌面存储基础集成](../architecture/desktop-storage-foundation.md):`DesktopStorageClient`、domain operation 和生命周期。
|
||||
- [桌面 Runtime 存储集成](../architecture/desktop-storage-runtime-integration.md):binding、model-call ledger 和关闭顺序。
|
||||
- [性能原则](../architecture/performance-principles.md):唯一的性能数值、并发规则和评审清单。
|
||||
- [桌面并发架构验收](../quality/concurrent-desktop-acceptance.md):真实生产路径、负载和证据要求。
|
||||
- [运行时存储与后台读取](../architecture/runtime-storage.md):输出正文、预览、临时资源和当前迁移状态。
|
||||
- [数据库迁移与大数据升级指南](./database-migrations.md):已发布用户数据的迁移边界。
|
||||
- [AGENTS.md](../../AGENTS.md):临时文件、真实 Host 验证、测试和文档维护规则。
|
||||
|
||||
## 决策检查
|
||||
|
||||
开始编码前,逐项回答下面的问题。任一项没有答案时,先补充设计或查找现有机制,
|
||||
不要直接增加新的 owner、队列、缓存或恢复记录。
|
||||
|
||||
- 数据的唯一权威来源和写入 owner 是什么?它是否已经存在?
|
||||
- 该功能是否需要数据库事务、跨表快照、文件引用或运行时 ledger?若需要,操作是否落在对应 owner 内?
|
||||
- Main 是否只负责 IPC 校验、凭据、生命周期和调度?是否把 SQL、大文件读写、哈希或模型工作留在了 Main?
|
||||
- 新操作能否使用现有 domain port 和显式 allowlist?是否误把 SQL、回调、实例、函数或 handle 放进跨进程 DTO?
|
||||
- 依赖链是否按“先提交,再通知、ACK、读取或返回成功”的顺序实现?提交未确认时是否禁止盲目重放?
|
||||
- 排队取消、在途取消、执行结算和资源释放分别是什么时刻?容量是否一直占用到实际结算或确认的 Host 退出?
|
||||
- 输出是预览还是完整正文?完整正文是否只有一个所属存储,引用是否随历史、分支和重启继续有效?
|
||||
- 是否创建了临时文件?创建者、使用者、删除条件、子进程退出确认和删除失败后的重试位置是否明确?
|
||||
- 新的操作数、排队数、字节数、句柄数和缓存是否有界?是否复用了已有预算并避免无限缓冲?
|
||||
- 测试是否覆盖真实 UI 到生产实现的路径、失败、取消、重试、关闭和迟到响应?是否需要真实数据、模型或 Host?
|
||||
- 受影响的架构、功能、质量、构建或运维文档由谁维护?是否已有同一事实的权威定义,新增内容能否只链接它?
|
||||
|
||||
## 存储所有权
|
||||
|
||||
桌面业务 SQLite 由一个 storage utility process 持有。Main 通过
|
||||
`DesktopStorageClient.call(domain, method, args, { signal })` 使用显式操作;
|
||||
`src/main/desktop-storage-contracts.ts` 的 allowlist 是可调用面的边界。Main 不得再
|
||||
打开同一业务数据库,也不得通过 helper、同步回退或隐藏的 repository 构造绕过 owner。
|
||||
远程 Agent 的数据库仍由远程 Agent 持有,不能因桌面存储改造而迁移。
|
||||
|
||||
新增持久化时,优先扩展已有 domain repository 和 owner operation。完整事务、跨表快照、
|
||||
去重、checkpoint 与发布规则留在 owner 内;模型、网络、解析和文件准备在事务外完成,
|
||||
进入事务后只校验当前前提并提交完整结果。需要多个语句的读快照,应增加一个有名字的
|
||||
owner/reader operation,不要在 Main 中拆成多次 `await`。
|
||||
|
||||
Main 侧服务只持有异步 port 和普通 DTO。live repository、数据库连接、事务 callback、
|
||||
订阅、函数、AbortSignal 和运行时 handle 不跨 utility transport。`StorageData` 允许的
|
||||
值以当前契约为准;新增类型先确认序列化、大小计算和错误传播,再加入 allowlist。
|
||||
|
||||
文件和输出也有 owner。附件引用、document result、paged output backing、Runtime
|
||||
binding 和 native model-call ledger 应通过既有 storage facade 访问。文件所在目录不等于
|
||||
可删除的临时目录;持久化引用、WAL/SHM、native history、用户导出文件和仍被分支引用的
|
||||
正文都必须按 owner 释放。
|
||||
|
||||
## 增加领域操作
|
||||
|
||||
按以下顺序定位扩展点:
|
||||
|
||||
1. 找到已有 repository、port 或 facade,以及它实际打开的数据库和文件。
|
||||
2. 在 owner 内保留原有事务和 ID、顺序、去重、耐久性语义,必要时抽出可复用的内部函数。
|
||||
3. 为外部调用增加一个有明确名称的 domain operation,更新类型 allowlist 和 transport handler。
|
||||
4. 在 Main 组合处注入已有 typed port;调用者等待 Promise,不保存第二份可写事实。
|
||||
5. 为提交前、提交后响应前、取消、失败、重试、关闭和重启增加针对性断言。
|
||||
|
||||
操作应表达业务动作,例如 `appendRemoteTaskEventsBatch`、`publishDocument` 或
|
||||
`transition`。不要暴露 `executeSql`、任意方法代理、通用对象代理或把 repository
|
||||
整个返回给调用者。一个已有操作能表达需求时,扩展其参数或返回值并更新权威文档,
|
||||
不要平行创建同义 operation。
|
||||
|
||||
通知不是提交确认。持久化成功后才能发送依赖该事实的 change notification、远程 ACK、
|
||||
模型下一轮输入或成功响应;通知失败要按现有语义处理,不能把已提交事实伪装成未提交。
|
||||
|
||||
## Main 与 utility 的边界
|
||||
|
||||
Main 保留窗口、可信 sender 和 schema 校验、凭据解密、网络与 Runtime 编排、OS API、
|
||||
请求归属和进程生命周期。utility storage 保留业务 SQLite、owner-local 事务、升级后
|
||||
的读 worker 生命周期和必要的同步 SQL。Parser、OCR、embedding、模型请求和远程 Agent
|
||||
继续使用各自宿主,不因“改成异步”而塞入 storage 事务。
|
||||
|
||||
Main 里的同步数据库调用、同步文件 IO 和可能超过 16 ms 的计算都要按性能原则处理。
|
||||
Promise 返回本身不能证明工作已离开 Main。启动顺序是先完成 storage readiness,再发布
|
||||
依赖它的服务;关闭顺序是先停止生产者并等待必要的持久化,再关闭 ledgers、bindings、
|
||||
读 worker、数据库 owner 和 utility process。失败或未确认退出时保留可能仍被 child
|
||||
持有的文件,并将错误传播给调用方。
|
||||
|
||||
## 异步顺序与取消
|
||||
|
||||
为每个操作写清四个时点:入队、开始执行、提交或执行结算、资源释放。最小必要范围内
|
||||
保持顺序:同一会话事件按序提交,同一数据库事务按序落盘;不同项目、会话和子任务
|
||||
不得共用长时间的全局等待。
|
||||
|
||||
取消规则如下:
|
||||
|
||||
- 排队中的请求在 dispatch 前检查 abort,取消后不得执行。
|
||||
- 在途请求先向实际 owner 或 Host 发送取消,调用方可以先得到取消结果,但容量只能在执行结算或确认 Host 退出后释放。
|
||||
- 同步 SQL 无法被 IPC 消息打断;安全批次之间检查取消,已经提交的事务必须报告为已提交。
|
||||
- 取消一个请求不得杀掉仍被其他请求使用的共享 Runtime、utility process 或 Worker。
|
||||
- 迟到结果必须携带已有项目、会话、任务或 operation 归属,先通过归属检查,再改变代次、缓存或队列。
|
||||
- 存储进程替换前等待旧进程退出并结算旧请求;未确认写入按 domain identity reconciliation 处理,不能自动重放写入。
|
||||
|
||||
`AbortSignal` 是 transport option,不是业务 DTO 字段。不要用 UI 隐藏、组件卸载或清空
|
||||
按钮代替服务层取消。控制和取消不得等待新的执行槽位。
|
||||
|
||||
## 输出引用与临时文件
|
||||
|
||||
短输出可以保留有界预览;超过预览上限的输出必须由所属 backing store 保存完整正文,
|
||||
并在后续模型 `output_read`、历史和分支路径中复用同一 handle。分别核对模型收到的内容、
|
||||
工具事件预览和历史正文,不能用最终回答代替工具全文,也不能把正文再次复制成临时全文。
|
||||
输出 writer 的 append 必须被生产者等待;容量错误要显式传播,不能丢块或无限排队。
|
||||
|
||||
创建临时配置、插件、socket 或 launch 目录的函数承担创建失败清理。删除前关闭拥有
|
||||
文件或目录的 child、ledger 和句柄,并确认 child 已退出;无法确认时保留材料和 owner
|
||||
metadata,等待后续有界回收。Agent 和测试产生的验证文件使用仓库根目录 `temp/<task>/`;
|
||||
产品运行时临时资源继续使用各自 owner 管理的运行时路径,不能因为位于仓库 `temp/` 就获得删除权。
|
||||
远程 Host 使用专用 `/root/tmp` 子目录。不要扫描或删除用户 Temp、未知归属目录或仅凭名称和年龄
|
||||
判断为过期的资源。
|
||||
|
||||
持久化目录可以位于 `temp/` 下,位置本身不能授权删除。删除前先检查 attachment、
|
||||
document result、history、branch 或其他 durable reference;释放一个调用引用不能删除
|
||||
仍被历史引用的正文。旧数据清理沿用所属 owner,不增加全局 Temp sweep。
|
||||
|
||||
## 并发预算
|
||||
|
||||
数值只引用[性能原则](../architecture/performance-principles.md),负载和证据只引用
|
||||
[并发验收](../quality/concurrent-desktop-acceptance.md)。当前 storage client 的请求、
|
||||
读写 pending 不设置产品侧 admission 上限,负载透传给 storage host;paged output 的 8 MiB capture、
|
||||
每 writer 1,024 个 pending chunks、4,096 个 active/retained handles 和 64 KiB backing
|
||||
write 也必须服从当前实现与测试。修改前先确认代码中的常量和适用范围,不在本文件复制
|
||||
一套新数字。
|
||||
|
||||
只有用户明确配置、外部协议要求、数据结构安全,或已有实测证明不限制会导致崩溃、
|
||||
数据损坏或不可恢复失败时,才设置上限。读写请求默认透传给所属 Host;不能用内部
|
||||
并发数、队列长度或容量数字替用户决定吞吐。仍禁止静默丢事件、无限重启、按项目预建
|
||||
服务、为每个调用新增 scheduler,或用串行夹具掩盖真实负载。
|
||||
|
||||
Worker 发生故障时可以进入已有退避和恢复流程;这不应被伪装成负载上限。正常排队的
|
||||
解析、存储和 Host 工作不能因为一个未经测量的队列数字直接返回 busy。
|
||||
|
||||
`ReadonlyQueryReader` 同时服务只读 Worker 和桌面 storage client。只有调用方显式传入
|
||||
`maxPending` 时,`waiting` 队列才承担有界等待语义;生产 storage client 不传该选项,
|
||||
请求直接交给 storage host。这个共享类不是第二个 storage owner,不能因为看到等待队列
|
||||
就给生产路径恢复默认 admission 上限。若未来实测证明某个边界需要容量策略,应先说明
|
||||
资源、故障和测量依据,再拆出最小的显式 wrapper 或增加调用方配置。
|
||||
|
||||
## 测试与真实验证
|
||||
|
||||
单元测试用于确定字节边界、事务回滚、顺序、归属、取消竞态、失败清理和重试。行为改动
|
||||
还必须覆盖生产组合:正常 UI action 经过 Preload、Main、storage/Runtime 宿主并产生
|
||||
实际结果。mock bridge、独立 SQL、只测试握手或只测试 schema 都不能替代这条路径。
|
||||
|
||||
需要持久化时使用真实 SQLite owner 和隔离数据库;需要文件时使用真实异步文件;需要
|
||||
Runtime 时使用本地确定性模型验证工具、事件、取消和关闭。不要把真实提供商网络速度
|
||||
混入性能基准。需要模型能力证明时,配置检查不够,必须发起实际生成或工具请求,并
|
||||
记录实际请求数和范围。
|
||||
|
||||
涉及桌面到 Agent 的 Runtime、安装、workspace、模型 bridge 或生命周期时,按
|
||||
`AGENTS.md` 在共享 Linux x64 Host 使用当前源码验证。覆盖面应对应改动,包括断开、重连、
|
||||
取消或重启;远程临时目录必须专用,验证后清理。未能连接 Host 时记录为验证阻塞,不能
|
||||
用单测或旧发布包替代当前源码证据。
|
||||
|
||||
性能改动先取得基线,至少按权威验收矩阵记录 offered load、排队、吞吐、排空、资源高水位、
|
||||
Main/Renderer 指标和样本条件。功能测试通过不等于并发预算通过;未测平台和缺失指标保留
|
||||
为缺口。
|
||||
|
||||
## 文档与重复设计
|
||||
|
||||
代码行为改变后,检查受影响的架构、功能、质量、构建和运维文档,更新真正的 owner、
|
||||
顺序、限制和验证证据。每项事实只保留一个权威定义:本文件写维护决策,架构文档写
|
||||
职责与边界,性能原则写数值,验收文档写场景和证据,功能 README 或 progress 写功能
|
||||
行为和状态。其他地方使用链接,不复制完整表格、状态机、预算或验收结果。
|
||||
|
||||
新增机制前先搜索同类名称、队列、owner、transport、shutdown 和测试。若现有机制只缺
|
||||
一个操作、一个容量检查或一个清理分支,扩展它并补测试;只有现有 owner 无法表达真实
|
||||
边界时才提出新设计,并在对应架构文档中记录问题、取舍和验证条件。未提交的实验代码、
|
||||
测试夹具或接口存在,不等于生产路径已经接入。
|
||||
|
||||
## 允许与禁止的模式
|
||||
|
||||
| 允许 | 禁止 |
|
||||
| --- | --- |
|
||||
| 在已有 storage domain 增加命名 operation,并由 owner 完成事务 | 在 Main 新建同一 SQLite 的第二个连接、同步回退或隐藏 writer |
|
||||
| 先在 Main 完成模型/解析,再把不可变 DTO 交给 owner 原子发布 | 在 SQLite 事务内 `await` 模型、网络、IPC 或文件回调 |
|
||||
| `storage.call('assistant', 'appendRemoteTaskEventsBatch', [batch], { signal })`,提交后再 ACK | 暴露 `executeSql`、任意 repository proxy,或把 live repository/callback 放进 DTO |
|
||||
| 使用同一 output handle 进行预览、分页、历史引用和 release | 每个工具结果写一个临时全文,再复制到 history 或用最终回答代替全文 |
|
||||
| child 退出确认后删除创建者拥有的 launch material | 按目录前缀、年龄或路径位置删除未知归属的用户文件 |
|
||||
| 取消在途请求并等待 settlement,再归还容量 | 调用方停止等待后立即释放槽位,或取消一个请求时杀共享宿主 |
|
||||
| 用真实 owner、隔离数据、确定性模型和生产 UI 路径验证 | 只验证 schema、mock bridge、旧包或 utility ready 就宣称功能完成 |
|
||||
| 更新一个权威文档,其他文档链接它 | 在多个文档复制同一预算、状态机、所有权表并分别维护 |
|
||||
@@ -0,0 +1,83 @@
|
||||
# 数据库迁移与大数据升级指南
|
||||
|
||||
本文约束已发布用户数据的数据库升级。具体字段、存储表示和功能行为由对应功能文档负责;
|
||||
本文不建立通用迁移框架、回滚引擎或独立状态日志。
|
||||
|
||||
## 先确认问题和数据边界
|
||||
|
||||
1. 先只读统计文件大小、WAL、页数、空闲页、表与字段的字节数,不先删除或压缩数据库。
|
||||
2. 区分有效内容、重复存储、索引和已释放但未回收的空间。空闲页少时,单独
|
||||
`VACUUM` 不能消除重复记录。
|
||||
3. 查清实际写入、读取、删除、重放与恢复入口。修改 Runtime 相关持久化时,同时检查
|
||||
本机和 GoodBuddy Agent 的桌面路径,不假设一个入口覆盖另一个入口。
|
||||
4. 明确用户必须保留的信息、稳定 ID、顺序、时间、引用与远程去重字段。
|
||||
分支内未发布、可安全重建的内部记录不需要兼容机制。
|
||||
|
||||
## 实现原则
|
||||
|
||||
- 先修新增数据路径,再修历史数据。历史转换与新写入尽量使用同一套转换函数。
|
||||
- 普通 schema 变更使用既有 `PRAGMA user_version` 和 SQLite 事务;
|
||||
已升级 schema 不允许旧客户端强行打开,不通过回写版本号支持降级。
|
||||
- 将结构升级、旧内容转换和空间回收分别判断。结构版本落后不代表需要整理历史;
|
||||
普通更新产生的空闲页也不是旧数据证据。只有明确的旧内容转换才允许检查空闲页并
|
||||
执行 `VACUUM`,后续新增结构迁移不自动继承全库回收。启动页按实际操作显示状态。
|
||||
- 同一次启动的重试可在内存中保留已确认的转换/回收决定;退出后重新根据数据库
|
||||
schema 和未转换内容判断,不为仅剩空闲页增加持久化标记或重复启动回收。
|
||||
- 大数据转换不得同步阻塞 Electron Main 或 Renderer。放到工作线程,在业务连接
|
||||
打开前处理,并使用应用的共享字体、主题与页面组件显示进度和可执行的失败提示。
|
||||
- 根据实测设置有界读取批次与缓存,批次提交后再报告进度。保持 SQLite 事务与
|
||||
checkpoint,不靠关闭同步落盘、取消约束或一次性读入整库提速。
|
||||
- 从已提交数据直接重试和还原即可完成的迁移,不增加 durable snapshot、journal、
|
||||
receipt 或多阶段恢复状态。退出及失败至少回滚未提交批次,保留已完成部分。
|
||||
- 差量编码必须能精确还原原始事件。更新内容、删除或重排都要覆盖;涉及远程来源时,
|
||||
去重、冲突判断、消息投影和恢复序号不得失效。
|
||||
- 释放数据库页和归还文件系统空间是不同步骤。先减少实际存储内容,再执行需要的
|
||||
空间回收;保留足够临时空间,磁盘不足时明确失败并允许重试。
|
||||
- SQLite 同库写入是串行的。是否增加读取/转换并行,应由实际读取、解析、写入、
|
||||
提交与 checkpoint 的耗时决定,不把增加写线程当作默认优化。
|
||||
|
||||
## 验证与备份
|
||||
|
||||
### 合成回归
|
||||
|
||||
覆盖正常转换、无变化记录、重复打开、内容修订、失败事务和退出续跑。数据库大小问题
|
||||
必须同时验证新增写入的增长量、旧记录可还原性以及文件实际缩小,而不只检查 schema。
|
||||
对象比较必须覆盖真实生产形状和 JSON 往返,尤其是可选字段值为 `undefined` 时与字段缺省
|
||||
的差异。再次修复已发布迁移产生的数据时,提高目标 schema,并覆盖已升级用户的入口;
|
||||
不能只处理尚未迁移的旧库。
|
||||
仅修改升级调度、不改变存储格式时不增加 schema。回归必须覆盖已完成历史转换的库
|
||||
在后续结构升级时仍有空闲页,确认不重新转换或 `VACUUM`,并验证真实 Worker、
|
||||
同次启动重试和下一次启动的区别。
|
||||
|
||||
### 真实旧库
|
||||
|
||||
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)。
|
||||
- Desktop 0.13.2:schema 35 同时整理旧快照和 schema 34 的重复工具块,工具字段按
|
||||
JSON 可保存的值比较,不因 `undefined` 被省略而误判变化。
|
||||
@@ -10,7 +10,7 @@ Debian `loong64` 桌面,不代表 GoodBuddy 已将龙芯纳入正式发布支
|
||||
均为 LoongArch ELF。
|
||||
|
||||
预览版使用社区维护的 Electron 42.3.0 LoongArch 构建。GoodBuddy 正式版本
|
||||
当前使用 Electron 43.2.0,因此该预览包只用于移植和真机验证,不能作为正式
|
||||
当前使用 Electron 44.5.1,因此该预览包只用于移植和真机验证,不能作为正式
|
||||
发布基线。Electron 官方目前也不提供 Linux LoongArch 预编译包。
|
||||
|
||||
已完成的实际构建结果:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Desktop 0.13.12 and Agent 0.13.3 Preparation
|
||||
|
||||
Prepared on 2026-09-23 on `main`. This record describes a local candidate, not a
|
||||
published release. Desktop notes are in `resources/release-notes.json`; Agent
|
||||
notes are in `resources/agent-release-notes/0.13.3.md`. Both are bilingual drafts
|
||||
awaiting approval with the exact candidate commit before tagging.
|
||||
|
||||
## Scope
|
||||
|
||||
- Desktop baseline: `v0.13.11`, including all nine subsequent commits through
|
||||
`83a48a4` and the reviewed working-tree changes.
|
||||
- Agent baseline: `agent-v0.13.2`. The Agent changes reclaim idle Runtimes and
|
||||
drain displaced Agents; the Desktop Continue path restores conversation
|
||||
history when reopening a Runtime.
|
||||
- Desktop package and both root lockfile versions are `0.13.12`.
|
||||
`agent-runtime-lock.json` is `0.13.3`. Agent package tooling derives the minimum
|
||||
Desktop version from `package.json`. Node, OpenCode, and Continue locks are
|
||||
unchanged at `24.19.0`, `1.18.29`, and `1.5.47`.
|
||||
- Both feature matrices were reviewed and updated for versions, Supervisor
|
||||
default-off behavior, implemented reviews/graphs/activity/incremental input,
|
||||
remaining supervision work, and release acceptance limits.
|
||||
|
||||
## Local Validation
|
||||
|
||||
`npm run release:notes:verify`, `npm run typecheck`, and `npm run lint` passed
|
||||
after the final source changes. Focused runs cover 35 distinct test files and
|
||||
690 passing tests; 403 tests were excluded by the IPC/App name filter.
|
||||
|
||||
| Group | Files | Passing Tests |
|
||||
| --- | ---: | ---: |
|
||||
| Supervisor, settings, bridge, and release-note suites passing in the initial run | 14 | 157 |
|
||||
| Database, migration, activity I/O, note storage, menu, and sidebar suites after fixes | 6 | 209 |
|
||||
| IPC/App filtered by `supervision\|Supervisor\|heartbeat\|application enable\|graph navigation` | 2 | 16 |
|
||||
| Agent CLI, daemon, Runtime backend, Desktop ACP, transport, and connection manager | 7 | 207 |
|
||||
| Usage, duration, workspace, Markdown, and Supervisor Electron layout | 6 | 101 |
|
||||
|
||||
The initial focused run had 25 failures: 22 synthetic legacy database fixtures
|
||||
retained schema-46 objects, two menu assertions assumed Supervisor was enabled,
|
||||
and one browser close retry left its earlier error visible. Fixtures now remove
|
||||
the new objects before simulating older versions, menu tests follow the saved
|
||||
enabled preference, and a successful browser close clears its scoped error.
|
||||
All affected suites passed on rerun. Production migration behavior was not
|
||||
changed to accommodate synthetic fixtures.
|
||||
|
||||
The Electron layout test uses production components with isolated fixtures,
|
||||
including 1440, 1024, and 390 pixel widths and light/dark themes. It is not a
|
||||
packaged-app probe or a real-user database acceptance test.
|
||||
|
||||
No real model requests were made during this preparation. Existing development
|
||||
evidence records eight requests on the shared Linux x64 Host for remote lifecycle
|
||||
checks and three for incremental review. The remote evidence does not repeat
|
||||
Continue real-Host validation, the complete signed installation flow, or the
|
||||
native architecture matrix. See
|
||||
[remote lifecycle evidence](../features/remote-host/technical-design.md#生命周期验证2026-09-23)
|
||||
and [incremental review evidence](../features/conversation-supervision/progress.md#2026-09-23-自动增量验证).
|
||||
|
||||
## Pending Release Work
|
||||
|
||||
The user requested no further full local suite. Full main-branch CI validation
|
||||
and its production build remain required before tagging; native Desktop and
|
||||
Agent packaging and publication verification remain pending. No local production
|
||||
build or package was run. No push, tag, remote synchronization, or publication
|
||||
was performed during preparation. Approval must identify the exact candidate
|
||||
commit and both bilingual note files before either release tag is created.
|
||||
|
||||
## Approved Candidate CI Attempt
|
||||
|
||||
The user approved `bf8ece05902509411454b27b742ebfec930005ad` and both bilingual
|
||||
note files. On 2026-09-23 that exact commit was fast-forwarded to `main` on
|
||||
`origin` and `github`; both remote refs were verified. GitHub SSH stalled, so
|
||||
the GitHub push used HTTPS with the existing GitHub CLI keyring credentials.
|
||||
No persistent Git configuration was changed. GitHub reported the account's
|
||||
existing pull-request-rule bypass for this requested direct push.
|
||||
|
||||
- [Desktop main CI](https://github.com/mesalogo/goodbuddy/actions/runs/35849362694)
|
||||
failed in the full suite: 4 files failed, 418 passed, 13 skipped; 10 tests
|
||||
failed, 4,997 passed, 43 skipped. Type checks, lint, and the production build
|
||||
did not execute after the test failure.
|
||||
- [Agent main CI](https://github.com/mesalogo/goodbuddy/actions/runs/35849362799)
|
||||
passed source validation and native Linux x64, Linux arm64, and Darwin arm64
|
||||
deterministic compound-package verification with ephemeral test signatures.
|
||||
- Neither release tag was created or pushed. No Desktop or Agent publication
|
||||
workflow was started, and no production release assets were verified.
|
||||
|
||||
The follow-up changes are limited to tests. Two IPC tests clear the startup
|
||||
settings-read count before measuring interactive request lookups. The settings
|
||||
failure UI test now expects Supervisor to remain disabled. OpenCode isolation
|
||||
tests prepare their real configuration dependencies in a dedicated temporary
|
||||
directory instead of relying on a prior local package preparation; connection
|
||||
failures now include their diagnostic detail. The large activity-list and
|
||||
multi-page navigation and external knowledge binding tests have explicit
|
||||
15-second budgets; the binding test also waits up to five seconds for its
|
||||
resulting heading.
|
||||
Its snapshot fixture now selects by library ID, and its instance list remains
|
||||
available during polling instead of assuming a fixed number of asynchronous
|
||||
reads. Both mocks reset between tests; the binding test waits for the
|
||||
selected-library read.
|
||||
These budgets address timeouts observed in the full CI run without removing
|
||||
assertions or changing product behavior.
|
||||
|
||||
The approved source and bilingual notes remain unchanged. These test changes
|
||||
need a revised candidate commit and approval before another release attempt;
|
||||
the failed commit must not be tagged. No local production build, package,
|
||||
external model request, or LoongArch build was performed.
|
||||
|
||||
Follow-up validation: the seven selected IPC/UI regression cases and all seven
|
||||
OpenCode isolation cases passed locally. Type checks and lint passed. Full
|
||||
remote CI and the production build still require a new approved commit; these
|
||||
focused results do not satisfy that release gate.
|
||||
|
||||
## Linux Executable Path Follow-up
|
||||
|
||||
On 2026-09-23, local `main` was fast-forwarded from `163e245` to
|
||||
`073aa45a30efe99fab7a92f8aee2a36087db9c2a` after inspecting the four intervening
|
||||
commits. They update only the community QR codes in the two README files.
|
||||
The user authorized retaining these changes, fixing CI, pushing both remotes,
|
||||
and publishing Desktop `v0.13.12` and Agent `agent-v0.13.3` after successful
|
||||
candidate CI without another approval round. No LoongArch preview is requested.
|
||||
|
||||
[Desktop CI 35854792610](https://github.com/mesalogo/goodbuddy/actions/runs/35854792610)
|
||||
reported 5,004 passing tests, three failures, and 43 skipped tests. All three
|
||||
failures were in the real OpenCode isolation test. It constructed
|
||||
`opencode-ai/bin/opencode` on Linux, but OpenCode 1.18.29 installs its npm
|
||||
executable as `bin/opencode.exe` on every platform. The test now reuses
|
||||
`resolveBundledRuntimePaths`, the existing production resolver, instead of
|
||||
constructing a platform-specific filename. Production Runtime code and both
|
||||
release-note files are unchanged.
|
||||
|
||||
Local follow-up validation passed all nine tests in
|
||||
`opencode-selection-isolation.test.ts` and `bundled-runtimes.test.ts`, including
|
||||
the existing Linux development-path assertion, plus full type checks and lint.
|
||||
Release-note and package/lockfile version validation passed. The isolation
|
||||
tests made two requests to their local mock endpoint and zero real model
|
||||
requests. No full local suite, production build, package, or real-Host check
|
||||
was run for this test-only fix; it changes neither deployed Agent code nor
|
||||
the Desktop-to-Agent production path. Successful Linux main-branch CI and
|
||||
its production build remain required before tagging.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Desktop 0.13.13 Preparation
|
||||
|
||||
Prepared on 2026-09-23 from `b16fc90`, relative to published Desktop `v0.13.12`.
|
||||
This is a Desktop-only release; Agent remains at published `0.13.3`.
|
||||
No LoongArch preview is requested.
|
||||
|
||||
## Scope and isolation
|
||||
|
||||
- `bf8ff7f` reduces repeated activity-history reads during streaming saves.
|
||||
- `b16fc90` restores compact supervision feedback controls and target names.
|
||||
- The candidate is prepared in a separate worktree. The user's uncommitted
|
||||
supervision timeout tests in the original worktree are explicitly excluded.
|
||||
- Desktop and both root lockfile versions are `0.13.13`. Both feature matrices
|
||||
and bilingual release notes were reviewed against this range.
|
||||
- Database schema and Agent/Runtime contracts are unchanged. The activity
|
||||
comparison cache belongs to Desktop persistence; it does not modify the
|
||||
deployed Agent, model bridge, or remote Runtime lifecycle.
|
||||
|
||||
## Validation
|
||||
|
||||
- Six focused files passed 192 tests: activity-history I/O, AssistantDatabase,
|
||||
storage upgrades, sidebar interactions, SupervisionCard, and the Supervisor
|
||||
Electron layout fixture.
|
||||
- Re-running `tests/supervisor-layout.electron.test.ts` with
|
||||
`GOODBUDDY_SUPERVISOR_SIDEBAR=1` passed its additional sidebar scenario:
|
||||
480, 300, and 200 pixel widths in both themes, no horizontal overflow,
|
||||
compact 34px buttons, visible keyboard focus, and no raw target ID in body text.
|
||||
- `npm run release:notes:verify`, `npm run typecheck`, and `npm run lint` passed.
|
||||
- Ten consecutive activity saves performed one full-history scan. WAL and SQL
|
||||
counters are not installed-application physical disk throughput measurements.
|
||||
- No real model requests, local full-suite rerun, production build, packaging,
|
||||
or packaged-app probe were performed. UI validation uses production
|
||||
components with isolated fixtures, not a real user database.
|
||||
|
||||
The candidate must pass complete main-branch CI and its production build before
|
||||
an unused annotated `v0.13.13` tag is pushed to both remotes. Native six-platform
|
||||
packaging and public GitHub/OSS verification remain subsequent release steps.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Desktop 0.13.14 / Agent 0.13.4 发布准备
|
||||
|
||||
后续状态:Desktop 标签流程 `36165805814` 在测试阶段失败,未进入构建或发布;
|
||||
Agent 流程 `36165805884` 成功且 0.13.4 已公开。两个历史标签均保留,
|
||||
桌面恢复版见 [0.13.15 准备记录](./release-preparation-0.13.15.md)。
|
||||
以下为原候选准备时的范围与证据,不代表其桌面发布成功。
|
||||
|
||||
## 范围与审批
|
||||
|
||||
- 用户确认从 0.13.13 升至 0.13.14,现有七个测试修改及三个验证辅助文件一起提交。
|
||||
- 不构建实验性 LoongArch 预览,不运行本地生产构建、打包或安装包启动探针。
|
||||
- 准备起点为 `ff746bdc1add6a12227ccb61a7794f654aaaa8ba`。
|
||||
- 最近可达稳定标签为 `v0.13.12`。已公开发布的 `v0.13.13` 指向
|
||||
`7756231`,其发布准备提交不在当前 main 祖先链上;两项产品修复已包含,
|
||||
0.13.13 的包内发布说明也已保留。需同时核对两个范围,不重复宣传已发布修复,
|
||||
不移动标签或重写历史。
|
||||
- 用户随后确认 Agent 也末位加一,目标为 0.13.4。已批准联合发布的双语说明,
|
||||
以及验证通过后一起提交本地候选;最终候选 SHA 仍须另行确认。
|
||||
- Agent 范围为 `agent-v0.13.3..HEAD`,生产改动来自 `7adae97`。
|
||||
- 用户批准提交并推送两个远端、等待 CI;标签仍待精确提交确认。
|
||||
- Fetch 确认两个远端 main 均为 `7756231b3bdfa148f1b2702e4ff3fc3bd0cb057e`。
|
||||
用户批准以普通合并保留双方历史,保留本次版本和已批准说明,
|
||||
补回历史发布文档;不强推、不改动已有标签。
|
||||
- 本地候选提交为 `d23af99`。普通合并解决后,仅新增或修改文档;
|
||||
产品源码、测试、依赖、版本文件及两份已批准说明与该候选完全一致。
|
||||
合并后 `v0.13.13` 恢复为可达发布基线,先前祖先链差异仅记录准备起点状态。
|
||||
|
||||
## 交付与数据边界
|
||||
|
||||
- 本轮联合准备 Desktop 0.13.14 和独立 Agent 0.13.4,不覆盖历史 Agent 包。
|
||||
- 桌面包含远程结果未知后显式发送新消息的恢复修复;Agent 完成确认、
|
||||
空闲回收失败处理和旧安装清理随 0.13.4 交付。
|
||||
- Agent 发布流程读取本次 package.json 作为最低 Desktop 版本,因此需先升级
|
||||
Desktop 至 0.13.14,再下载并更新 Host。Node、OpenCode、Continue 锁定版本不变。
|
||||
- 两个候选分别使用 `v0.13.14`、`agent-v0.13.4`,Agent 不替代 Desktop Latest。
|
||||
- 数据库从 schema 46 升至 47。升级前需完整备份用户数据,包括数据库和
|
||||
相邻 notes/ 目录;旧客户端不能打开升级后的数据库。
|
||||
- 已检查中英文功能矩阵,更新当前版本、监督回顾、待办能力及独立 Agent
|
||||
交付边界。其他平台和功能的既有未验收事项继续保留。
|
||||
|
||||
## 验证
|
||||
|
||||
- 首次 `npm test`:5,125 项通过、74 项跳过、2 项失败。失败均为版本已更新而
|
||||
说明尚未获批写入导致的当前版本缺失,未修改断言规避。
|
||||
- 联合说明写入后的 `npm test`:435 个文件通过、10 个文件跳过;
|
||||
**5,127 项通过、74 项跳过,0 失败**,耗时 685.49 秒。
|
||||
- `npm run typecheck`、`npm run lint`:首轮与联合候选复验均通过。
|
||||
- `npm run release:notes:verify`:联合说明写入后通过;Desktop 三处版本一致,
|
||||
当前说明仅一项,Agent lock 与独立双语标题一致。
|
||||
- 合并后复验:发布说明校验、9 项发布说明测试、完整 typecheck、lint、
|
||||
`git diff --check` 通过。因合并仅恢复文档,未重复运行完整测试和 Host 探针。
|
||||
- 当前源码 Linux x64 完成/历史恢复探针通过:两轮 Ask 均完成,Runtime 重建后
|
||||
保留会话历史,每轮结束及最终退出时所属进程数均为 0。
|
||||
- 隔离旧安装清理探针通过:真实 CLI 回收所属孤儿进程、删除对应旧 payload,
|
||||
保留存活旧 daemon、活动工作、历史及 current/candidate,重复清理无重复删除。
|
||||
- 本轮新增真实模型调用 **2 次**,模拟模型调用 0 次;清理探针模型调用 0 次。
|
||||
- 两个探针从当前源码重新生成临时测试脚本,仅操作共享 Linux x64 Host 的独立
|
||||
`/root/tmp/gb-lifecycle-*` 与 `/root/tmp/gb-upgrade-*` 测试目录,不替换共享安装。
|
||||
探针沿用历史测试身份版本,不是正式签名 0.13.4 包安装验收,也不代替
|
||||
Linux arm64、macOS arm64 原生 CI 或完整 Host 更新界面验证。
|
||||
- 复现命令沿用[远程 Host 技术设计](../features/remote-host/technical-design.md)
|
||||
中 lifecycle-host 和 upgrade-cleanup 脚本;本轮生命周期参数为
|
||||
`driver --completion-only`,使用真实模型而非 `--fake-model`。
|
||||
- 候选主分支 CI、生产构建、六平台原生打包及公开发布验证:尚未执行。
|
||||
@@ -0,0 +1,40 @@
|
||||
# Desktop 0.13.15 恢复版准备
|
||||
|
||||
## 发布范围
|
||||
|
||||
- 以已发布的 `v0.13.13` 为用户更新基线,完整承接未发布 `v0.13.14` 的产品变更。
|
||||
历史准备及 Host 验证见 [0.13.14 记录](./release-preparation-0.13.14.md)。
|
||||
- 0.13.14 标签流程 `36165805814` 在测试阶段失败,生产构建、打包与发布均未执行;
|
||||
GitHub Release API 返回 404。不可变标签保留,包内条目改为 0.13.15,
|
||||
不保留重复的未发布说明。
|
||||
- Agent 0.13.4 流程 `36165805884` 成功,三个包已公开,最低 Desktop 要求仍为
|
||||
0.13.14。恢复版满足该最低要求;不重发 Agent、不更改其签名目录或标签。
|
||||
- 用户批准 0.13.15 标题及升级提示调整,其余双语正文沿用已批准内容;
|
||||
要求先推送候选,等 CI 全量验证与生产构建成功后再打标签。
|
||||
- 不构建 LoongArch,不运行本地生产构建、打包或安装包启动探针。
|
||||
- 中英文功能矩阵已复核,同步恢复版本和 Agent 已发布状态,保留其他未验收边界。
|
||||
|
||||
## CI 修复
|
||||
|
||||
`cd27163` 仅改测试和质量规范,不改变产品、Agent、数据库或外部契约:
|
||||
|
||||
- 原生能力清单测试控制第二次响应,等待重试已开始再展开控件,并分别校验响应前后状态。
|
||||
- 浏览器关闭测试在同一个 `waitFor` 中检查两次调用、页签移除和错误清除。
|
||||
- 笔记布局测试显式扫描 fixture 依赖,使用可见的隔离 Electron 窗口执行原生输入,
|
||||
保留 sandbox/contextIsolation,明确检查超时、信号和退出码后才读取结果。
|
||||
|
||||
对应 CI 证据:标签流程有 1 项失败(原生能力按钮未找到);
|
||||
main 流程 `36164698647` 有 2 项失败(关闭错误残留、布局测试约 90 秒后缺少结果文件)。
|
||||
两次失败点不同,不能仅凭本地通过认定 CI 已修复。
|
||||
|
||||
## 验证
|
||||
|
||||
- 本地三个定向用例通过,Electron 布局约 3.46 秒,全部几何和交互断言保留。
|
||||
- 修复提交完整 typecheck、lint 通过。
|
||||
- 本轮没有重复本地全量测试,以新候选 Linux CI 的完整测试结果为准;
|
||||
先前 5,127 项本地通过仅为历史记录。
|
||||
- 无新真实模型调用,未改变 Agent 或 Runtime 生产源码,无需重复 Host 开发探针。
|
||||
- 恢复候选的说明校验、9 项发布说明测试和 `git diff --check` 通过;
|
||||
三处桌面版本一致。逐项比较确认只替换版本及已批准升级提示,
|
||||
0.13.14 无重复条目,Agent lock 仍为 0.13.4。
|
||||
- 主分支 CI、生产构建和标签发布结果待后续核对。
|
||||
@@ -0,0 +1,43 @@
|
||||
# Desktop 0.13.16 发布准备
|
||||
|
||||
日期:2026-09-26。当前为候选,不代表已发布。
|
||||
|
||||
## 范围与批准
|
||||
|
||||
- 用户确认 Desktop 0.13.16、Agent 保持 0.13.4、不构建 LoongArch 预览。
|
||||
- 基线为 `325b702934ba0f0c076f27e926a897267262ab55`,用户明确批准纳入新提交的
|
||||
Supervisor 界面修改。相对已发布 `v0.13.15` 包含:
|
||||
- `e6ac073`:监督回顾持续分批处理,保留主动暂停、单次模型超时和并发。
|
||||
- `4c7193e`:结构升级不自动回收正常空闲页,准确显示操作状态及保留同启动重试。
|
||||
- `325b702`:监督回顾空态、运行状态与活动阶段布局修复。
|
||||
- 用户已批准中英文三项问题修复及两项升级提示,写入 `resources/release-notes.json`;
|
||||
已发布版本说明保留。最终候选 SHA 需另行确认,主分支 CI 通过后才创建新标签。
|
||||
- `FEATURES.md` 和 `FEATURES.zh-CN.md` 已核对并更新当前版本、持续回顾、
|
||||
结构升级及界面行为,删除过时的执行预算暂停描述。
|
||||
|
||||
## 数据与 Agent 边界
|
||||
|
||||
相对 0.13.15 不提高 schema,仍为 47。不新增持久化迁移标记,不改写用户数据库。
|
||||
旧格式仍执行必要转换;普通结构升级不因空闲页执行全库 VACUUM。
|
||||
监督回顾仍只读、无工具,模型调用可能收费;取消固定时长暂停后可连续处理更多批次。
|
||||
|
||||
本轮没有 Agent、Runtime、模型桥、部署锁或远程协议修改;本机与远程历史共用桌面升级
|
||||
实现。监督通过桌面 Ask 编排,不改变 Host 运行路径,不要求 Agent 更新或新 Host 部署。
|
||||
本轮实际模型请求数为 0。
|
||||
|
||||
## 验证
|
||||
|
||||
在独立工作树准备,避免其他工作修改影响候选,复用相同锁定依赖,未复制本机私有环境文件。
|
||||
候选第一轮全量发现 `HeartbeatCenter.test.tsx` 的两种语言仍要求已移除的重复说明存在;
|
||||
改为断言说明不存在,保留范围、操作、标题及页面内容校验,专项 **42 项通过**。
|
||||
|
||||
最终候选完整测试:**5,128 通过、84 跳过、1 失败**。唯一失败为未修改的
|
||||
`mermaid-viewer.electron.test.ts` 子进程退出码 null;首次单独复跑被工具的 150 秒
|
||||
上限中断,随后按用例原有时间窗口复跑 **1 项通过**,覆盖九种显示场景和十次实际 PNG
|
||||
导出。未修改该用例、提高其自身超时或弱化断言,独立复跑不替代全量结果。
|
||||
`npm run typecheck`、`npm run lint`、`npm run release:notes:verify` 均通过。
|
||||
|
||||
先前迁移定向 47 项通过;最终开发期全量为 5,137 通过、74 跳过、两项 Electron
|
||||
失败,失败用例独立复跑两项通过。该记录不是本候选的全量或 CI 通过证明。
|
||||
|
||||
未运行本地生产构建、打包或安装包启动。主分支 CI、六目标原生打包和公开发布核验待完成。
|
||||
@@ -0,0 +1,238 @@
|
||||
# Desktop 0.15.1 与 Agent 0.15.0 发布准备及恢复
|
||||
|
||||
日期:2026-09-27 至 2026-09-28。原 Desktop 0.15.0 标签构建失败,
|
||||
用户已选择 Desktop 0.15.1 与 Agent 0.15.0 恢复发布;验证通过不代表已发布。
|
||||
|
||||
## 范围与批准
|
||||
|
||||
- 用户要求 Desktop 与 GoodBuddy Agent 同步升级至 `0.15.0`,明确不构建 LoongArch。
|
||||
- 源码基线:`0f8bf405db7ca21a85cc70009ad083eb837e1e03`。
|
||||
- Desktop 差异基线为已发布的 `v0.13.16`,Agent 为 `agent-v0.13.4`。
|
||||
- `v0.15.0` 已创建并推送,保持不可变;恢复使用 `v0.15.1`,
|
||||
`agent-v0.15.0` 尚未创建或推送。
|
||||
- 用户已批准两份完整双语说明,正式正文仅维护在
|
||||
[Desktop 发布资源](../../resources/release-notes.json) 的 `0.15.1` 条目与
|
||||
[Agent 发布资源](../../resources/agent-release-notes/0.15.0.md)。
|
||||
已逐条核对写入文本与批准草案一致,本记录不再复制正文。
|
||||
- `FEATURES.md` 与 `FEATURES.zh-CN.md` 已核对本轮能力、限制和候选版本。
|
||||
- 用户要求两张未跟踪宣传截图保留原位、不提交,打标签前另行处理;
|
||||
不移动、删除、暂存或更改忽略规则。
|
||||
- 2026-09-28 用户更新授权:当前工作区的宣传文档与素材可以一起提交、推送,
|
||||
后续候选由代理自行审核确认,不再逐次等待用户批准,继续完成双产品正式发布。
|
||||
三张新增候选图已读取检查,保留原始草稿属性,不冒充正式发布截图或对外媒体发文。
|
||||
发布说明正文保持不变,`releasedAt` 更新为实际准备发布日期 2026-09-28。
|
||||
|
||||
## 本地验证
|
||||
|
||||
- `npm run typecheck`:通过。
|
||||
- `npm run lint`:通过。
|
||||
- `npm run release:notes:verify`:通过。
|
||||
- 发布说明相关三个文件定向测试:16 项通过。
|
||||
- 首轮 `npm test`:5,313 通过、79 跳过、4 失败,耗时 697.61 秒。
|
||||
三项失败来自当时尚未批准写入的 0.15.0 Desktop/Agent 发布说明;
|
||||
另一项为 `magic-notes-layout.electron.test.ts` 的布尔断言。
|
||||
该布局用例单独复验 1 项通过,未修改源码或放宽断言。
|
||||
单独通过不替代全量结果。
|
||||
- 正式说明写入后的最终 `npm test`:**5,317 通过、79 跳过、0 失败**,
|
||||
453 个测试文件通过、11 个跳过,耗时 645.49 秒。随后再次执行完整
|
||||
`npm run typecheck` 与 `npm run lint`,均通过。
|
||||
- 最终验证期间出现并行宣传文档编辑及新增 `storygraph.png`,均不属于本次
|
||||
发布准备改动,不暂存或覆盖。打标签前仍须处理工作区洁净条件。
|
||||
- 修改文档的相对文件链接、版本一致性和 `git diff --check` 通过。
|
||||
- 不运行本地生产构建、打包或安装包启动。准确候选提交须经用户批准,
|
||||
然后双远端推送、主分支 CI、生产构建、原生发布与公开核验分别进行。
|
||||
|
||||
## Agent 当前源码实机验证
|
||||
|
||||
共享 Linux x64 Host 的 LAN/VPN SSH 端口均可达,实际通过 LAN 与既有固定
|
||||
Host Key、加密凭据连接。重新捆绑当前源码的 Agent CLI、daemon、model bridge
|
||||
与 Desktop 适配器,仅上传独立 `/root/tmp/gb-lifecycle-*` 测试目录,复用已安装且
|
||||
版本锁未变的 Node/OpenCode/Continue,不替换原安装,不构建正式包。
|
||||
|
||||
最终 OpenCode、Continue 各通过一次 Ask 和一次 Execute,无工具请求,
|
||||
验证回答、后续历史、空闲 Runtime 回收及测试停止后所属进程为 0。
|
||||
模型请求时读取所属进程的固定遥测字段,确认 `DO_NOT_TRACK=1`、
|
||||
`OTEL_SDK_DISABLED=true` 及三个 exporter 均为 `none`,不输出整个环境。
|
||||
这些结果补齐 `eb53c82` 的 Agent/model bridge 真实链路验证,
|
||||
但不证明后台没有其他网络流量,也不替代三目标正式包验收。
|
||||
|
||||
首轮 Continue 探针沿用了 OpenCode 原生历史持久化假设,未像 Desktop 一样传入
|
||||
`request.history`,第二轮历史断言失败;当前 Continue 冷启动源码明确要求传入
|
||||
保存历史。修正测试输入及会话 ID 断言后通过,未修改产品实现。
|
||||
|
||||
有完整计数的实机运行共 **8 次**真实模型 HTTP 请求:首次 OpenCode 2 次、
|
||||
首次 Continue 2 次、最终 Continue 2 次、最终 OpenCode 2 次。
|
||||
此前一次直接启动 Electron 的探针未捕获子进程输出,不能确认是否实际发出请求,
|
||||
该次上限 2 次,不纳入通过证据;因此不能把 8 次写成本轮精确总数,总量为 8–10 次。
|
||||
后续均使用等待退出并记录脱敏输出的启动器;本轮探针 Electron 进程已退出。
|
||||
本地诊断证据在临时 `opencode/release-015-host/`,不提交脚本、日志或凭据。
|
||||
|
||||
## 首次候选 CI 与测试修正
|
||||
|
||||
用户批准的首次候选 `5a6ba6c14f5c58f3da5e9afe281b18e0cbdb1ae8`
|
||||
已推送至 `origin/main` 和 `github/main`,未创建两个发布标签。
|
||||
|
||||
- [Agent CI 36329271991](https://github.com/mesalogo/goodbuddy/actions/runs/36329271991)
|
||||
源码验证及 Linux x64、Linux arm64、Darwin arm64 原生构建全部通过。
|
||||
- [Desktop CI 36329271967](https://github.com/mesalogo/goodbuddy/actions/runs/36329271967)
|
||||
为 5,342 通过、51 跳过、3 失败。两个 App 长历史场景超过默认 5 秒单用例时限;
|
||||
会话采集 Electron 场景等待 Escape 返回笔记列表超时。生产构建未运行。
|
||||
- 测试修正仅让原生键盘辅助函数显示窗口、获取焦点并等待 `document.hasFocus()`,
|
||||
然后发送真实输入;两个长历史场景采用仓库既有的 15 秒单用例时限。
|
||||
不修改产品实现、不跳过 Linux、不关闭沙箱、不删除或放宽数据和键盘断言。
|
||||
- Windows 聚焦复验三个失败场景通过,其中真实 Electron 覆盖完整 502 条历史、
|
||||
214px 窄栏键盘操作、浅深主题确认按钮、真实 IPC/SQLite 和重开保留。
|
||||
修正后首次全量复验被中断,不计为通过;随后完整重跑为 **5,317 通过、
|
||||
79 跳过、0 失败**,453 个文件通过、11 个跳过,耗时 646.48 秒。
|
||||
类型检查、lint 和发布说明校验均通过,新候选 Linux CI 仍需独立通过。
|
||||
- 本次只改测试和验证文档,不改变 Agent、远程 Runtime 或模型桥;
|
||||
沿用上方当前源码 Host 验证,不新增真实模型请求。
|
||||
- 新提交必须重新取得准确 SHA 批准,原发布说明正文保持不变。
|
||||
|
||||
## Linux 实机复现与最终同步修正
|
||||
|
||||
2026-09-28:第二次批准候选 `12594961fe1e814e89f55de1fbfe245310505115`
|
||||
的 [Desktop CI 36331853137](https://github.com/mesalogo/goodbuddy/actions/runs/36331853137)
|
||||
为 5,343 通过、51 跳过、2 失败。两个 App 长历史场景已通过;
|
||||
Escape 返回列表仍失败,活动历史迁移这次超过默认 5 秒时限,生产构建仍未执行。
|
||||
|
||||
按用户要求直接在共享 Linux x64 Host 测试准确候选源码。使用独立磁盘测试目录、
|
||||
锁定 Node 24.19.0 和 `npm ci`,补齐 Xvfb 与匹配的 X server 系统依赖;
|
||||
没有创建新账号,使用既有非 root 账号运行测试,保留 Electron 沙箱及其标准 helper 权限。
|
||||
不替换既有 Agent,不修改其安装或工作区,不执行生产构建或打包。
|
||||
|
||||
原测试在 Host 上稳定复现:缩窄窗口会按产品既有行为收起工作栏,隐藏面板仍有非零宽度。
|
||||
诊断显示 Escape 实际发给 `aria-expanded=false` 的工作栏开关,而不是笔记输入框。
|
||||
因此仅获取窗口焦点不够;测试改为等待窄视口及响应式渲染完成,按真实按钮打开工作栏,
|
||||
确认展开状态和目标输入框焦点后再发送键盘输入。临时诊断代码已移除。
|
||||
|
||||
迁移测试在 Host 上以原默认时限通过,保留 10,000 条记录、完整迁移、WAL 字节比、
|
||||
扫描次数与无变化写入断言;仅为这一个大数据场景采用已有的 15 秒单用例时限,
|
||||
不改数据库实现或全局时限。
|
||||
|
||||
- 最终 Linux Host 完整 `npm test`:**5,345 通过、51 跳过、0 失败**,
|
||||
449 个文件通过、15 个跳过,耗时 751.09 秒。
|
||||
- Windows 聚焦回归:3 项通过,包含真实 Electron 和迁移。
|
||||
- Windows 完整类型检查、lint、发布说明校验:全部通过。
|
||||
- Linux Electron 实测覆盖 502 条历史、218px 窄栏、Escape/Tab/Enter、
|
||||
浅深主题确认按钮、真实 IPC/SQLite 与重开保留;Windows 对应窄栏为约 214px。
|
||||
- 本阶段不新增真实模型请求,不改变产品代码或双语说明。
|
||||
新候选仍须重新批准 SHA 并通过自己的主分支 CI/生产构建,两个标签仍未创建。
|
||||
|
||||
## 后续 CI 焦点前提与确定性取消验证
|
||||
|
||||
`b8b6007` 的 [CI 36335076841](https://github.com/mesalogo/goodbuddy/actions/runs/36335076841)
|
||||
两次执行均为 5,344 通过、51 跳过、1 失败,失败点改为 `new title focus`,
|
||||
生产构建没有运行。该次失败未在 Host 的 10 次带诊断复验中重现,其中 5 次限制单 CPU;
|
||||
移除临时诊断后再测也通过。这些独立结果不替代 CI。
|
||||
|
||||
测试原先直到键盘阶段才显示窗口,前面的自动聚焦检查仍在隐藏窗口执行。
|
||||
现让测试窗口从开始即显示并确认焦点,保留标题自动聚焦原断言;
|
||||
超时增加实际焦点、标题禁用/可见/隔离状态和保存状态,避免只报告阶段名称。
|
||||
可见窗口版本在 Host 连续五次聚焦复验通过。
|
||||
|
||||
随后 Host 全量出现一个独立的 Continue 取消测试竞争:
|
||||
`setTimeout(0)` 没有保证取消发生在子进程启动前,空 mock 已被调用。
|
||||
用例改在既有启动环境回调处精确取消,仍验证回调执行、子进程未启动和临时配置删除,
|
||||
不更改 Continue 产品实现或取消机制。
|
||||
|
||||
- 最终 Host 完整测试:**5,345 通过、51 跳过、0 失败**,449 个文件通过、
|
||||
15 个跳过,耗时 743.34 秒。
|
||||
- Windows 可见窗口 Electron 回归及确定性取消回归通过。
|
||||
- 完整类型检查、lint 通过;双语说明正文不变,日期调整后另行验证发布资源。
|
||||
- 上述均为测试或验证文档调整,不新增真实模型调用,不改变 Agent 发布输入。
|
||||
正式发布仍要求当前候选自己的 CI/生产构建通过。
|
||||
|
||||
## 完整分支快照重新核对
|
||||
|
||||
`d9e1e42` 的 [Desktop CI 36340682594](https://github.com/mesalogo/goodbuddy/actions/runs/36340682594)
|
||||
已通过原生会话采集焦点测试,但为 5,367 通过、51 跳过、4 失败。
|
||||
核对祖先后确认准备期间另有四个并行提交进入分支:
|
||||
`ee1bf8a` 输入区控件样式、`8e60736` 流式会话稳定排序、
|
||||
`f35198d` 桌面通知开关、`d47f967` 监督结果继续讨论。
|
||||
它们已随用户授权的当前分支推送;前述 Host 的 5,345 项通过使用较早源码快照,
|
||||
不能作为包含这四项的新候选验证结果。
|
||||
|
||||
本轮已重新核对四个提交的实现和文档,补充两份功能矩阵与双语发布说明:
|
||||
通知开关默认开启且不影响应用内提示;流式更新不改变排序;
|
||||
监督结果可直接编辑问题并继续讨论,界面不再提供知识实体写回控件。
|
||||
Agent 产品源码及 Runtime 锁未因这四项改变,Agent
|
||||
[CI 36340682607](https://github.com/mesalogo/goodbuddy/actions/runs/36340682607)
|
||||
源码校验和三个原生构建全部通过。
|
||||
|
||||
补齐通知默认值的契约 fixture;Obsidian 多次真实子进程调用使用单用例 15 秒时限,
|
||||
避免前项超时后的在途调用污染下一项取消断言;笔记来源用例按面板查询控件,
|
||||
并等前次来源操作释放禁用状态后再发起下一次。
|
||||
这些修正不改变产品实现。Windows 聚焦回归 19 项通过,类型、lint 和说明校验通过。
|
||||
从 Git tree `1758ff58a6ff33fcf7ddaa3d35dd2fcc57cd1265` 导出完整快照,
|
||||
覆盖 Host 测试源码,而非只同步早期快照中的少数文件。
|
||||
该完整快照 `npm test` 为 **5,371 通过、51 跳过、0 失败**,
|
||||
451 个文件通过、15 个跳过,耗时 751.42 秒,包含全部四个并行提交。
|
||||
最终提交前再次核对源码、测试、依赖和发布资源与该已验证 tree 一致,
|
||||
仅追加本段验证结果;完整候选仍需自己的主分支 CI 与生产构建通过。
|
||||
|
||||
### 完整候选后的焦点同步修正
|
||||
|
||||
`5cdffee` 的 Agent [CI 36343184917](https://github.com/mesalogo/goodbuddy/actions/runs/36343184917)
|
||||
三个原生构建和源码校验通过;桌面
|
||||
[CI 36343184916](https://github.com/mesalogo/goodbuddy/actions/runs/36343184916)
|
||||
为 5,369 通过、51 跳过、2 失败,生产构建未执行。
|
||||
会话采集的焦点等待可被 `body` 后代文字误满足,菜单随后被真正的消息聚焦关闭;
|
||||
另一个原生 Runtime 控件用例在启动完成前打开失焦关闭的菜单。
|
||||
本轮收紧消息焦点的元素断言,并等待项目能力请求及启动渲染后再打开 Runtime 选项。
|
||||
不重试菜单、不跳过场景、不改产品实现。
|
||||
|
||||
完整冻结 tree `2ddc0da69b6bcb42d8272c44dd11e3e6904d0438` 在 Host 全量
|
||||
**5,371 通过、51 跳过、0 失败**,耗时 748.21 秒。
|
||||
Windows 原生 Runtime 聚焦用例、类型检查和 lint 通过。
|
||||
提交仅追加本段记录;仍须等待修正候选的主分支 CI 和生产构建。
|
||||
|
||||
`706726b` 的桌面 [CI 36345672365](https://github.com/mesalogo/goodbuddy/actions/runs/36345672365)
|
||||
已通过上述采集与 Runtime 用例,但另一个外部知识库测试失败:
|
||||
5,370 通过、51 跳过、1 失败,生产构建未执行。
|
||||
该 fixture 只在查询新库 ID 时返回包含已创建库的快照,普通轮询会返回创建前数据;
|
||||
同时错误地要求最后一次请求必须属于新库,忽略后台轮询。
|
||||
改为模拟已持久化的库列表,等待新库请求发生,保留原库与新增库选中、被排除库未选中的
|
||||
全部界面断言,不修改产品代码。Windows 完整 App 300 项通过,类型、lint 和说明校验通过。
|
||||
冻结 tree `42a92450ad7d9ebc50167c7946bff53cff2889e6` 的 Linux 全量为
|
||||
**5,371 通过、51 跳过、0 失败**,耗时 748.67 秒。
|
||||
|
||||
## Desktop 0.15.1 恢复
|
||||
|
||||
`65a21390debe0dd04871417a363a9d87a29339e6` 的主分支
|
||||
[CI 36347785076](https://github.com/mesalogo/goodbuddy/actions/runs/36347785076)
|
||||
验证与生产构建全部通过后,两个远端创建了不可变 `v0.15.0`。
|
||||
其正式 [CI 36348938269](https://github.com/mesalogo/goodbuddy/actions/runs/36348938269)
|
||||
两个 macOS 目标在 `codesign --verify --deep --strict` 失败,
|
||||
报 `invalid destination for symbolic link in bundle`;GitHub Release 未发布。
|
||||
|
||||
Host 隔离 fixture 经实际 DS 资源准备和 electron-builder 文件复制,
|
||||
复现相对 npm CLI 链接变成原构建目录的绝对链接。不是签名凭据或测试超时问题。
|
||||
用户明确选择 **Desktop 0.15.1 + Agent 0.15.0** 恢复,不移动或复用旧标签。
|
||||
修正仅让复制保留相对链接;新增回归移除原安装与暂存目录后验证最终链接仍可读。
|
||||
该路径不用于 Agent,远程 Runtime 与 Agent 源码未改,不增加真实模型调用。
|
||||
未发布 Desktop 0.15.0 的双语条目迁移到 0.15.1,保留全部功能说明;
|
||||
Agent 版本不变,其包元数据从新版 Desktop manifest 派生最低版本 0.15.1,
|
||||
两份说明和功能矩阵同步这个升级顺序。
|
||||
|
||||
原标签最终 Windows/Linux 四个目标成功,两个 macOS 失败,发布任务跳过,
|
||||
公开 Latest 仍为 `v0.13.16`。
|
||||
Windows 打包相关测试 45 通过、1 个 POSIX 链接用例跳过,类型、lint、
|
||||
发布说明校验通过。冻结 tree `99d3271174a68922fbf0a1a4cfbadbb85a6067c8`
|
||||
在 Linux Host 全量 **5,372 通过、51 跳过、0 失败**,耗时 745.88 秒;
|
||||
新增符号链接回归实际执行通过。提交仅追加验证记录,恢复候选 CI 与 macOS 签名待验证。
|
||||
|
||||
## 数据、兼容与验收边界
|
||||
|
||||
Desktop schema 48 新增魔法笔记会话来源元数据。升级前应备份完整用户数据,
|
||||
包括数据库及相邻 `notes/` 目录;旧客户端不能打开升级后的数据库。
|
||||
本次发布准备不修改数据库迁移实现,也不直接改写用户数据库。
|
||||
|
||||
Agent 发布工作流以候选 `package.json` 设置最低 Desktop 版本,因此
|
||||
Agent 0.15.0 包要求 Desktop 0.15.1;Node、OpenCode、Continue 锁不变。
|
||||
Agent 与 Desktop 独立发布,Agent 不替代 Desktop Latest。
|
||||
|
||||
原生客户端的远程 coordinator、托管标准 Node 交付整合、Continue/OpenCode
|
||||
Ask 内置 MCP 映射及完整跨平台安装包验收仍有缺口,见
|
||||
[原生客户端进度](../features/assistant-workbar/progress.md#2026-09-27-local-native-client-validation)。
|
||||
本次说明明确标为本地预览,不将这些未完成部分宣称为已提供能力。
|
||||
@@ -0,0 +1,83 @@
|
||||
# Desktop 0.15.10 发布准备
|
||||
|
||||
日期:2026-10-04。基线 `v0.15.9`,冻结已提交源码
|
||||
`74efbcdc1703e6893764dbb2c48e3d1c47b21fce`,35 个提交。
|
||||
仅纳入已提交实现和发布准备,保留并排除工作区中的导航、文档处理等并行修改。
|
||||
标准六平台 Desktop,不含 LoongArch;Agent 保持已发布的 0.15.3。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 统一项目与会话菜单、当前/全部范围、活动及十个最近会话、悬停预览。
|
||||
- 监督者独立文本模型选择及默认开启的会话 Story Graph 开关。
|
||||
- 活动差量保存、索引分页与完整汇总,schema 57 保留历史并迁移排序;
|
||||
schema 58 增加启动恢复候选消息索引。升级前关闭应用并备份完整数据库
|
||||
和相邻 notes 目录,旧客户端回退须恢复升级前备份。
|
||||
- 文档解析 Worker、依赖延迟加载、本地工具环境后台准备、Composer 和
|
||||
路由等渲染边界、笔记/文件树/知识文档窗口化、滚动和焦点保留。
|
||||
- 终端 Main 合并与 xterm 帧批次,远程事件 16ms 定时 flush;
|
||||
checkpoint 前落库、事务顺序和回放去重保留。
|
||||
- 旧 Runtime 缓存按明确版本和布局命名清理,跳过符号链接;
|
||||
保留当前布局、运行记录、会话和配置。工作栏间距及操作修复。
|
||||
- 高频本地写入临时切换 NORMAL 并恢复原 PRAGMA;嵌套事务由外层决定。
|
||||
保留应用崩溃原子性,但断电/系统崩溃可能丢失最近部分本地写入。
|
||||
远程确认、通道投递和任务生命周期继续 FULL,后台 PASSIVE checkpoint
|
||||
不代表已经验证破坏性断电恢复。
|
||||
- Agent 守护进程只有测试变更,Continue host-v7 仅提取常量,协议和部署
|
||||
行为未变,因此不发布新 Agent。Electron/Node/OpenCode/Continue 锁不变。
|
||||
|
||||
不承诺通用提速比例。窗口化未挂载行不支持页内查找,快速滚动可显示占位;
|
||||
只读 Worker 失效可同步回退。开发报告中的 stable controller generation resume
|
||||
及 Runtime 未到终态错误仍是调查项,本轮不声称修复。
|
||||
|
||||
## 本轮真实 Host 验证
|
||||
|
||||
中断前已针对 `e89db96` 源码使用固定 Host 身份与既有凭据存储,
|
||||
在隔离测试目录经生产 Agent、模型桥、远程 ACP、RemoteEventBatcher
|
||||
和 SQLite 持久化入口执行。后续 `74efbcd` 仅修改工作栏 CSS 间距,
|
||||
不改变已验证的远程实现。OpenCode 探针显式使用已有 Continue 模型配置,
|
||||
不是产品静默回退;未构建生产安装包。
|
||||
|
||||
**4 次真实模型请求,4 次 HTTP 200**:
|
||||
|
||||
| Runtime / 模式 | 批次 | 事件 | checkpoint |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenCode Ask | 3 | 4 | 2 |
|
||||
| OpenCode Execute | 22 | 42 | 21 |
|
||||
| Continue Ask | 3 | 4 | 2 |
|
||||
| Continue Execute | 21 | 40 | 20 |
|
||||
|
||||
checkpoint 前落库、完整已保存流再次写入返回已存在、Runtime 重建后历史恢复,
|
||||
本轮拥有进程清零。生产 SSH 连接池与终端会话另输出 1000 行 Unicode,
|
||||
13 个输出事件;CRLF 归一化后内容完全一致、序号递增、输出先于退出。
|
||||
最初终端探针选错 Host 路由,发生在模型请求前,真实调用数为零;
|
||||
随后复用成功固定的路由完成。临时 Electron 缓存警告非致命,不算产品修复。
|
||||
|
||||
这些证据不是完整 Renderer、已安装 Agent 升级或全部断线恢复矩阵,
|
||||
也没有验证所有消息投影恢复分支。开发阶段其他模型调用不计入本轮四次。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
按用户持续授权,直接提交、推送并跟进 CI 和发布,不逐步询问。
|
||||
不运行本地测试套件、类型检查、lint 或生产构建;完整验证和构建由精确候选
|
||||
main CI 执行,通过后才创建新的不可变 Desktop 标签。双语说明与 FEATURES
|
||||
同时更新;Agent 不打新标签。开发文档记载的未通过测试不能视为已解决,
|
||||
由此次精确候选 CI 决定,失败须定位修复。
|
||||
|
||||
最终需核验六平台打包、macOS 签名与公证、GitHub Latest 和 20 项资产、
|
||||
北京 OSS 索引一致性与网站 12 种下载选择。不下载或启动安装包,
|
||||
不将候选或推送成功称为发布完成。
|
||||
|
||||
### 首轮候选 CI
|
||||
|
||||
`273a7fa` 的 Desktop CI `37174433765`:5953 通过、59 跳过、3 失败,
|
||||
生产构建未执行;Agent CI `37174433777` 通过。
|
||||
监督设置测试需要先进入“回顾整理”页签再操作其可见保存按钮,
|
||||
Electron 布局测试更新为真实标题“模型与运行限制”,截图锚点限定可见设置面板。
|
||||
保留保存值、再次打开恢复、布局及溢出断言。
|
||||
1200 条活动历史测试会实际显示至 1000 行,专用超时从 30 秒改为 60 秒;
|
||||
不减少数据、不移除分页、清空总数或离页裁剪断言,等待下一次 CI 验证。
|
||||
|
||||
`362fe50` 的 CI `37175597089`:上述三个测试均通过,5955 通过、59 跳过;
|
||||
另一个工作区 KeepAlive 测试在默认一秒内未找到魔法笔记标题。
|
||||
仅将该轮换测试的异步标题等待设为五秒,保留聊天 DOM 身份、旧工作区淘汰
|
||||
及缓存数量断言。CI 观察命令曾出现 GitHub API EOF,最终失败以工作流结果为准。
|
||||
@@ -0,0 +1,93 @@
|
||||
# Desktop 0.15.11 / Agent 0.15.4 发布准备
|
||||
|
||||
日期:2026-10-04。基线为已发布的 `v0.15.10`,冻结源码
|
||||
`ea3d607fdd16fe24bc4be6a172884ef4d14ef244`。中断后按用户最新要求纳入
|
||||
全部已提交改动,开始准备时工作区干净;不纳入随后出现的未提交工作。
|
||||
标准六平台 Desktop、三平台 Agent,不含 LoongArch。
|
||||
|
||||
## 范围与升级边界
|
||||
|
||||
- 无模式执行:删除 Ask/Execute、安全设置分类及通用 toolApproval,
|
||||
普通请求使用启用能力和执行账号权限,内部文本任务保持无工具。
|
||||
旧 Ask 会话不再有产品只读隔离,必须在发布说明中明确。
|
||||
- schema 59 增加状态计时和会话汇总索引,不扫描旧历史补算时长;
|
||||
schema 60 按已知 JSON 路径分批清理工作模式,并删除三列。
|
||||
保留正文、图片、稳定引用、远程去重身份;损坏元数据可见失败并保留已提交批次。
|
||||
Runtime 设置 22 清理顶层 toolApproval,保持加密凭据字节,不递归删除同名用户字段。
|
||||
- 升级前关闭应用,备份完整数据目录(数据库、相邻 notes 与设置),
|
||||
大库可能耗时,回退须恢复升级前备份。0.15.10 的 NORMAL/FULL 边界不变。
|
||||
- 远程 runtime/acp 能力从 5 升到 6,新增工作区 importFile 动作及时间字段;
|
||||
Continue helper 采用原生 agent。Agent 必须升级 0.15.4,最低 Desktop 0.15.11;
|
||||
wire major/minor 保持 2.0,Node/OpenCode/Continue 锁不变。
|
||||
更新前处理活动任务,不声称覆盖旧操作跨版本恢复。
|
||||
- 工作区分块导入二进制与空文件,同名不覆盖,失败项独立反馈;按名称和时间排序。
|
||||
每目录最多读取 500 条,排序只覆盖已读条目,无传输中取消和百分比。
|
||||
移除成果页不删除数据库成果,不自动把会话附件或图片复制到项目目录。
|
||||
- 图片仅消息引用也可发现,生成关闭时仍可保存;远程专家保持父请求执行空间。
|
||||
- 项目菜单默认全部项目、归档恢复和明确进入,新本地项目必选目录、
|
||||
远程目录符号链接、OCR 提供方与 PPTX 图片位置诊断、完整输入缓存率。
|
||||
- 回顾来源初始化和续跑移到 Worker;不宣称最后发布大事务已移出 Main。
|
||||
历史选择与布局统一,真实区间按主故事着色,空档中性、无归属事件保留;
|
||||
消息密度来自当前消息/小时桶,非历史快照,不补造故事归属。
|
||||
|
||||
## 证据与限制
|
||||
|
||||
审阅开发记录和临时原始验证说明:
|
||||
|
||||
- `docs/features/unified-execution/progress.md` 与临时
|
||||
`opencode/unified-execution-host/validation.md`:13 次真实文本请求。
|
||||
OpenCode/Continue 无模式、原生工具、detach、controller 恢复、重放、
|
||||
取消和续发检查;Continue 首次原生工具尝试暴露 readonly helper,
|
||||
改为 agent 后工具及取消场景通过,每轮所属残留进程为零。
|
||||
- 临时 `opencode/expert-image-validation.md`:12 次真实文本请求,
|
||||
包含两次预算不足的准备尝试各 2 次及最终两个 Runtime 各 4 次。
|
||||
Host 保存、冲突、显式覆盖及替换 PNG 字节一致,桌面保存次数为零。
|
||||
这些是已有开发证据,累计 25 次文本请求、0 次真实图片生成;
|
||||
本次发布准备未新增提供方请求。
|
||||
- 工作区 Host 探针:当前源码 Desktop 访问层、协议方法和真实文件系统,
|
||||
700001 字节二进制及空文件、同名冲突、时间与归属通过,模型调用零。
|
||||
不等于已安装签名 Agent 完整 attach/传输矩阵。
|
||||
- 图片保存 Host 探针验证 SQLite 重开和仅 artifactIds 引用图片的分块保存;
|
||||
专家验证使用测试 PNG,不是提供方图片生成。
|
||||
- 监督图谱定向测试、真实只读备份和 Electron 几何验证见监督进度;
|
||||
真实样本是点事件,不冒充真实持续区间着色证据。新增样式不影响 Agent。
|
||||
- schema 60 合成迁移及真实 Worker 夹具通过,但用户生产旧库完整迁移取样、
|
||||
外部委派服务兼容性和跨版本旧远程操作恢复尚未覆盖。
|
||||
各性能数值仅限原报告路径、数据和机器,不作为普遍速度承诺。
|
||||
|
||||
## 已有 CI 与准备状态
|
||||
|
||||
`e205e909` 的 Desktop CI `37202987733`:6032 通过、56 跳过、3 失败;
|
||||
Agent CI `37202987727` 通过。失败为监督旧 inline-error 断言、
|
||||
窄栏 Escape 后状态未就绪、文件导入默认五秒超时及其清理竞态。
|
||||
这些定向修正已经包含在 `ea3d607f`:改查通知回调的语气和消息,
|
||||
等侧栏 inert 后再改变尺寸,导入多块真实文件测试专用 15 秒。
|
||||
不跳过场景,不减少数据,不删除保存、冲突、键盘或布局断言。
|
||||
最新开发验证不是本次精确候选全量 CI,不记为全绿。
|
||||
|
||||
按用户要求,不跑本地测试套件、类型检查、lint 或生产构建;
|
||||
只校验发布元数据后推送,由 main CI 验证和构建。
|
||||
通过后创建新不可变双版本标签,跟进原生打包和独立发布;
|
||||
不下载、哈希或启动 Desktop 安装包,不发布宣传文章。
|
||||
双语 FEATURES 与发布说明同步权限、迁移、配套升级和验收边界。
|
||||
|
||||
### 精确候选首轮 CI
|
||||
|
||||
`245e296a`:Desktop `37211139053` 为 6038 通过、56 跳过、2 失败,
|
||||
Agent `37211139043` 通过;生产构建未执行。
|
||||
App 旧测试仍假定回顾表单默认展开,改为点击“新回顾”后保留原按钮断言。
|
||||
覆盖层堆栈通过仅编译测试 driver(不运行应用或生产构建)定位到窄转宽后的
|
||||
侧栏按钮,先前 Escape 等待已通过;补齐宽高与 CSS 过渡结束后再原生点击。
|
||||
保留 hit-test、侧栏 inert 和后续归档/弹层操作断言,等待下一轮 CI 验证。
|
||||
|
||||
`29af4090` 的 CI `37212673749`:上轮两项通过,6039 通过、56 跳过,
|
||||
仅工作区导入 Electron 探针失败(Renderer 脚本执行异常)。
|
||||
探针取消选择后按旧名称查询按钮并直接读 disabled,导入中的临时名称会使
|
||||
查询为空;改为等待匹配按钮存在且可用,点击也使用同一就绪条件,并在脚本
|
||||
异常中保留执行表达式和原始 cause。字节核对、同名保护、取消和旧图片保留
|
||||
断言均保留;此为探针时序修正,不宣称修复产品导入故障。
|
||||
|
||||
`4e72aa04` 的 CI `37213768876`:导入及此前修正项通过,6039 通过、
|
||||
56 跳过,唯一失败为活动分页等价测试默认五秒超时。该测试四个种子、
|
||||
三种筛选分别渲染新旧组件共 24 次;仅将此项设为 15 秒,完整快照、
|
||||
分组、数量、标题、状态和清空计数对比不变。
|
||||
@@ -0,0 +1,74 @@
|
||||
# Desktop 0.15.12 / Agent 0.15.5 发布准备
|
||||
|
||||
日期:2026-10-05。基线 `v0.15.11`,冻结源码
|
||||
`2be57492e6de2d639597d0fde740a18b917d7dbb`,8 个提交、144 个文件。
|
||||
检查时工作区干净,只纳入已提交实现和发布准备;保留后来出现的并行工作。
|
||||
标准六平台 Desktop、三平台 Agent,不含 LoongArch。
|
||||
|
||||
## 范围
|
||||
|
||||
- Telegram 私聊配置、数字白名单、whoami 设置顺序、系统代理路径、
|
||||
轮询恢复、待发回复、业务错误与跨层 retry_after;通道图片结果合同及钉钉错误修复。
|
||||
- 自定义回顾天数和本地日期,包含结束日、今天截止提交时刻,
|
||||
保留增量及显式重新整理规则。
|
||||
- 回顾项目隔离、暂停后发布校验、失效故事目标拒绝;已删除会话从未发布
|
||||
清单省略、混合批次和依赖导航重算,历史结果不变,已发模型输入不可撤回。
|
||||
- 监督临时错误最多重试一次,认证/配置/取消/超时/容量不重试,
|
||||
保留失败流用量,额外尝试可能增加费用。超容量中止保留已收到用量,
|
||||
正文清空,最多再消费一秒用量事件,仍返回容量错误。
|
||||
- 概览不预载图谱、来源详情按需加载、现有 Worker 重读取、索引与批量查询、
|
||||
摘要缓存、选择列表窗口化及键盘焦点;发布快照只构造一次,仍在原 Main 事务。
|
||||
- 长消息展开状态仅存用户覆盖项,删除来源清理;历史计时缺失不显示为零。
|
||||
- 本地 OpenCode 提交失败及时终止;Agent 原生启动和路由清理不占全局队列,
|
||||
迟到 Session/路由清理,保持取消、peer、回放去重。
|
||||
独立守护进程生产实现改变,必须发 Agent 0.15.5,最低 Desktop 0.15.12;
|
||||
wire 2.0/runtime-acp 6 和 Runtime/Node/Electron 锁不变。
|
||||
|
||||
## 数据与性能边界
|
||||
|
||||
schema 61 定向扩展 projects/conversations 通道 CHECK,事务中校验并恢复
|
||||
writable_schema/defensive 设置,保留行、rowid 和索引;schema 62 仅新增监督索引。
|
||||
升级前关闭应用备份完整数据目录(数据库、相邻 notes、设置),旧客户端回退需恢复备份。
|
||||
没有进行独立存储服务迁移,不把架构方向文档写成已实现。
|
||||
|
||||
已审阅[性能原则](../architecture/performance-principles.md)与
|
||||
[阶段审计](../review/stage-audit-fixes-2026-10-05.md)、
|
||||
[KISS 实测](../review/kiss-local-fixes-2026-10-05.md)。
|
||||
三轮交替测量仅限指定合成库、设备和源码路径。schema 61→62 的 55 表、
|
||||
32947 行含 rowid 哈希一致及完整性检查通过,不代表全部用户库。
|
||||
大批发布仍约 271~303ms、部分大响应和冷模块存在超预算样本,不宣称全部达标。
|
||||
最终删除会话改动另有四文件 95 项回归,未做完整 UI 或大库删除延迟测量。
|
||||
|
||||
## 开发证据与发布验证分开
|
||||
|
||||
- 第一阶段审计:7 次外部文本请求(Host OpenCode 2、Continue 2、
|
||||
本机 OpenCode 1、监督服务 2),0 次图片;6136 测试通过、87 跳过,
|
||||
typecheck/lint 通过。Host 工具、断开恢复、Agent 提升、回放和启动挂起取消
|
||||
经生产路径验证,结束后所属进程清零。
|
||||
- K07/K08:8 次外部文本 HTTP 请求、均 200、0 次图片,含首次预算
|
||||
不足尝试的 2 次;当前源码两 Runtime 的路由清理、peer、取消、工具、
|
||||
恢复及去重通过,自有进程和目录清零。隔离暂存源码全量 6212 通过、
|
||||
87 跳过,typecheck/lint 通过,不含之后的删除会话改动。
|
||||
- 上述两阶段共 15 次外部文本调用属于既有开发记录,本次发布准备新增调用零。
|
||||
- Telegram 内部链路有真实 Electron/IPC/SQLite 与 loopback API/模型证据,
|
||||
真实 Bot 加模型完整收发仍待验证,旧独立 Node API 成功不替代该验收。
|
||||
本次不使用凭据向外部收件人发送消息,不把内部测试当作真实平台通过。
|
||||
- 最终精确候选仍需 CI 全量验证及生产构建;按用户要求不执行本地
|
||||
测试套件、lint、typecheck、生产构建或安装包探针,只校验发布元数据。
|
||||
|
||||
## 发布检查
|
||||
|
||||
同步审阅并更新双语 FEATURES、Desktop 和 Agent 发布说明。
|
||||
推送双远端、等待精确源码验证后创建不可变双版本标签;
|
||||
公开核验六平台 Desktop 20 项资产/12 安装包、macOS 签名公证、双源索引、
|
||||
网站 12 种标准选择,以及 Agent 三平台目录/签名和生产读取器。
|
||||
不下载 Desktop 安装包,不发布已提交宣传稿。
|
||||
|
||||
## 精确候选 CI
|
||||
|
||||
`36e671a0` 的 Desktop CI `37273689682`:6254 通过、57 跳过、1 失败,
|
||||
生产构建未执行;Agent CI `37273689788` 通过。
|
||||
唯一失败为 Telegram Electron 探针启动时真实 safeStorage 不可用。
|
||||
Ubuntu 验证环境增加 gnome-keyring/D-Bus,在独立会话中以随机临时密码解锁
|
||||
CI keyring 后运行 sandboxed Electron 测试;不使用明文回退、不替换加密接口,
|
||||
保留生产配置加密保存断言。密码仅经管道交给临时 keyring,不写源码或日志。
|
||||
@@ -0,0 +1,51 @@
|
||||
# Desktop 0.15.13 / Agent 0.15.6 发布准备
|
||||
|
||||
日期:2026-10-07。基线 `v0.15.12`,冻结源码
|
||||
`c1e4bbf66a418dbd4e0f99a36ff0522dbf610553`。只纳入已提交实现和发布准备,
|
||||
保留开始准备后出现的并行工作。标准六平台 Desktop、三平台 Agent,不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 一个 Desktop storage utility process 统一拥有 Assistant、Knowledge、附件、
|
||||
Runtime binding/ledger 等业务 SQLite;Main 通过类型化异步操作访问。
|
||||
保留数据库路径、表、ID、事务、提交顺序和远程 Agent 数据库。
|
||||
- storage utility 使用现有读取 Worker 和受界定 IPC,覆盖 readiness、取消、
|
||||
容量、进程退出、关闭排空和错误传播;移除生产 Main 业务 SQLite owner。
|
||||
- schema 仍为 62,无数据迁移;升级前仍备份完整数据目录、notes 和设置。
|
||||
- 直连模型工具失败后可继续下一轮,分页输出保持有界;Magic Notes 标签、
|
||||
监督来源别名、设置直达预加载、文件元数据和 Composer 过渡同步修复。
|
||||
- Agent 生产 Runtime/模型桥有变化,需 0.15.6;runtime/acp 6、wire 2.0、
|
||||
Node/Electron/OpenCode/Continue 锁不变。
|
||||
|
||||
## 验证边界
|
||||
|
||||
storage foundation focused 结果:utility process 真实 Electron 编排、监督
|
||||
heartbeat/回顾叶子/导航、异步 Worker、存储帧高水位约 248,332 bytes Main、
|
||||
252,578 bytes storage;focused 70 项跨 11 suites 通过,Node/Agent/Renderer
|
||||
typecheck 和 scoped lint 通过。工具失败继续、分页输出、Magic Notes 和监督
|
||||
别名另有定向回归。
|
||||
|
||||
并发验收文档明确的 C01–C07 和 A01–A10 完整矩阵、跨平台 storage process
|
||||
安装、低端机对照及完整 UI→Preload→Main→storage→SQLite 验收仍不是这份文档
|
||||
中的已完成事实。不得把 focused 结果或架构文档目标写成全部 PERF-15/PERF-16
|
||||
已验收。Agent 远程路径需在当前源码 CI/Host 规则下重新验证。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
按用户约定不运行本地测试套件、typecheck、lint、生产构建或安装包探针;
|
||||
先校验发布元数据,推送双远端,要求精确候选 main CI 和生产构建通过后再创建
|
||||
Desktop/Agent 不可变标签。通过后核验六平台 20 项 Desktop 资产、macOS 签名公证、
|
||||
Agent 三平台目录、OSS/GitHub 元数据和网站 12 种标准下载选择。
|
||||
|
||||
## 精确候选首轮 CI
|
||||
|
||||
`fbfafa15` 的 Agent CI 已通过。Desktop CI `37636800633` 为 6430 通过、
|
||||
58 跳过、3 失败,生产构建未执行:
|
||||
|
||||
- C05 storage 进程故障 fixture 没有稳定观察到拒绝;
|
||||
- utility-process fixture 的临时数据库父目录权限在 Linux runner 上未满足;
|
||||
- readonly reader 测试把快速完成的 worker 请求固定断言为拒绝。
|
||||
|
||||
修正只涉及测试稳定性:显式把隔离目录设为 `0700`,允许快速 worker 在终止
|
||||
竞争中完成(专用 crash 测试仍覆盖拒绝),并保留 storage 故障恢复和已提交
|
||||
数据断言。下一候选 CI 仍必须重新验证这些场景;不跳过 storage owner 测试。
|
||||
@@ -0,0 +1,25 @@
|
||||
# Desktop 0.15.14 发布准备
|
||||
|
||||
日期:2026-10-09。基线 `v0.15.13`,候选源码 `8f1b6007986f31fb5e2a80e80021602a13fa20a2`。
|
||||
本候选只更新 Desktop;Agent 保持 `0.15.6`,不创建新的 Agent 标签。标准六平台 Desktop,
|
||||
不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 恢复旧版因监督来源变化或移除而失败的历史回顾的继续操作。
|
||||
- 来源在回顾期间变化时,在有限次数内重新冻结处理;持续变化的来源之后省略,
|
||||
已删除、归档或不可用来源沿用既有省略规则,其他来源继续保留进度。
|
||||
- 保留暂停、失败、取消和已保存批次的监督活动状态及项目范围、来源处理规则。
|
||||
- 设置分类显示对应图标,帮助用户更快找到各类设置。
|
||||
- 不新增数据库 schema,继续使用 schema 62;Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 验证边界
|
||||
|
||||
本候选包含监督存储、Worker、服务和 Renderer 回归测试,以及历史失败回顾继续操作的
|
||||
界面覆盖。完整并发验收矩阵、跨平台存储进程安装和其他未完成验收不在本候选中宣称通过。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再由用户确认后创建不可变的 `v0.15.14` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -0,0 +1,48 @@
|
||||
# Desktop 0.15.2 与 Agent 0.15.0 发布准备
|
||||
|
||||
日期:2026-09-28。用户要求包含当前本地工作、推送双远端,等待候选 CI 和生产构建通过后打标签;
|
||||
确认同时发布 Agent 0.15.0,不构建 LoongArch。沿用此前自行审核候选和双语说明的授权。
|
||||
|
||||
## 发布范围
|
||||
|
||||
- Desktop 基线为已发布的 `v0.15.1`(`4e5239256d42c856bb1420d87c4a338f17867b80`)。
|
||||
[发布 CI 36368878429](https://github.com/mesalogo/goodbuddy/actions/runs/36368878429)
|
||||
六个原生目标和 GitHub/OSS 发布全部成功,0.15.1 的历史说明保留。
|
||||
- 新源码截止 `66f77b83c2b6ebe0018a6c5c2bc72e1b4c1e43b1`:
|
||||
renderer 退出诊断、笔记采集界面、双语网站及 Story Graph 只读工具与完整讨论草稿。
|
||||
- Agent 0.15.0 尚未发布,基线仍为 `agent-v0.13.4`。
|
||||
既有遥测和 Continue 更新检查修复保留,补充远程 Story Graph 工具;
|
||||
最低 Desktop 版本由当前 manifest 派生为 0.15.2。三个原生目标和 Runtime 锁不变。
|
||||
- 当前未提交的功能矩阵、主机/云环境设计和宣传文稿一并审核纳入。
|
||||
云平台适配与主机入口迁移明确为待实现规划,不进入发布功能说明。
|
||||
宣传稿保持草稿和原核查截止点,不代表已对外发布。
|
||||
- 正式双语正文仅维护在 `resources/release-notes.json` 的 0.15.2 条目和
|
||||
`resources/agent-release-notes/0.15.0.md`。两份 FEATURES 已对照实现与规划核对。
|
||||
|
||||
## 验证边界
|
||||
|
||||
Story Graph 已有开发期证据见
|
||||
[监督者验证进度](../features/conversation-supervision/progress.md#2026-09-28-story-graph-只读工具):
|
||||
真实 Linux Host 上的 OpenCode/Continue Ask、Execute 调用与禁用检查通过;
|
||||
推理为本机确定性响应,10 次请求含 2 次标题生成,外部付费请求 0 次。
|
||||
不将这些证据称为真实模型建议质量验证,也不宣称历史重建、跨范围事实合并或大库性能已验收。
|
||||
|
||||
本轮类型、lint、双语说明及网站静态校验通过。按当前源码重建隔离 Agent 探针,
|
||||
在共享 Linux x64 Host 经固定身份完成 OpenCode/Continue Ask、Execute、空闲进程回收、
|
||||
冷启动历史恢复和遥测关闭环境检查;每个 Runtime 2 次真实模型请求,共 **4 次**,
|
||||
没有重试或假模型调用,结束时归属进程均为 0。该补充检查不等于真实模型图谱建议质量验收。
|
||||
|
||||
准备期间另有 `0cdf042` 宣传稿提交,已核对只有三份文档变化,产品源码仍截止 `66f77b8`,
|
||||
一并纳入完整候选。用户随后中断本地全量步骤,明确要求直接推送等待 CI;
|
||||
本地全量不记为通过,由精确候选的主分支 CI 执行完整测试和生产构建,通过后才打标签。
|
||||
不运行本地生产构建、安装包打包或安装包启动;使用既有隔离 Host 目录,不更改生产 Agent。
|
||||
schema 保持 48;从旧版升级应备份完整数据库和相邻 `notes/`,不能用旧客户端打开升级后的库。
|
||||
|
||||
## 首次候选 CI
|
||||
|
||||
`76e781a` 已同步双远端。Agent [CI 36379713606](https://github.com/mesalogo/goodbuddy/actions/runs/36379713606)
|
||||
通过;Desktop [CI 36379713607](https://github.com/mesalogo/goodbuddy/actions/runs/36379713607)
|
||||
5,389 通过、51 跳过、1 失败,生产构建未执行,未打标签。
|
||||
失败的设置测试用固定五个服务器名称过滤按钮,却用新增 Story Graph 后的六项目录断言数量。
|
||||
同步把旧模拟列表与按钮身份断言改为复用正式目录,保留数量和原交互断言,并逐项验证目录按钮存在。
|
||||
最终完整 SettingsPanel 文件、类型、lint 和发布说明检查通过;全量继续交由新提交 CI。
|
||||
@@ -0,0 +1,36 @@
|
||||
# Desktop 0.15.3 发布准备
|
||||
|
||||
日期:2026-09-28。用户确认沿用双远端推送、精确提交 CI 和生产构建通过后自动打标签的流程;
|
||||
本次仅发布 Desktop 0.15.3,已发布 Agent 0.15.0 保持不变,不构建 LoongArch。
|
||||
|
||||
## 范围
|
||||
|
||||
- 已发布基线:`v0.15.2`,提交 `47ba54f47db4f22f69375310925eedab020b3cf0`。
|
||||
- 本轮已核对 15 个提交,截止 `86347af0b1cc8726c1166b3f9fa3d7f12a0cc5e3`。
|
||||
包含手动压缩、请求级工具授权、取消/失败状态、聊天/笔记重复工作、
|
||||
侧栏与工作栏布局、打包依赖精简。起始工作区干净。
|
||||
- Office 同会话实时编辑仅更新需求与验收设计,不计入交付能力。
|
||||
- `package.json`、lockfile 两处 Desktop 版本统一为 0.15.3。
|
||||
已发布 0.15.2 与 Agent 0.15.0 的正文保持不变;新双语说明单独追加。
|
||||
- 两份 FEATURES 对照实现更新手动压缩、状态反馈、搜索、箭头方向及发布状态。
|
||||
不用局部 Markdown 基准宣称整体渲染性能提升,不提前承诺跨平台安装包缩小比例。
|
||||
|
||||
## Agent 与验证边界
|
||||
|
||||
`src/agent-daemon`、`src/main/remote-agent`、Agent 和 Runtime 锁在本轮未变。
|
||||
授权修复属于 Desktop 的 `KnowledgeMcpGateway`:请求有效期间不再按固定十分钟撤销,
|
||||
取消和清理继续撤销;不修改远程工具通道或部署包,不需要重发 Agent 0.15.0。
|
||||
手动直连压缩仅用于本地文本会话,UI 与 IPC 均排除 SSH/远程直连入口。
|
||||
|
||||
开发期证据分别见[直连模型进度](../features/direct-model-agent/progress.md)、
|
||||
[导航进度](../features/application-tool-navigation/progress.md)和
|
||||
[性能基准](../roadmap/product-performance-experience-improvement-plan.md)。
|
||||
开发期全量曾报告侧栏和笔记 Electron 用例失败,不把这些历史结果计为本候选通过。
|
||||
本轮 `npm run typecheck`、`npm run lint`、`npm run release:notes:verify` 通过。
|
||||
网关、Obsidian 授权、桌面资源与 DS 打包、手动压缩真实 Electron 生产链路共 6 文件:
|
||||
**85 通过、1 跳过**。跳过的是 Windows 不适用的 POSIX 符号链接用例。
|
||||
压缩测试使用本机 HTTP fixture,不冒充外部模型调用;本轮没有新增付费模型请求。
|
||||
沿用用户确认的直接推送等待 CI 流程,完整测试及生产构建以精确候选 CI 为发布门槛。
|
||||
不进行本地生产构建、打包或安装包启动。
|
||||
|
||||
schema 保持 48;从更早版本升级前备份完整数据库及相邻 `notes/` 目录。
|
||||
@@ -0,0 +1,55 @@
|
||||
# Desktop 0.15.4 与 Agent 0.15.1 发布准备
|
||||
|
||||
日期:2026-09-29。用户确认两个补丁版本,提交并推送两个远端;
|
||||
候选 CI 和生产构建成功后自动打标签发布,不构建 LoongArch。
|
||||
|
||||
## 范围与说明
|
||||
|
||||
- Desktop 已发布基线为 `v0.15.3`(`3c9229ac7a57071fc955adb1095c499ffdfd006a`)。
|
||||
本轮源码截止 `9c88ca20f3c62ca29b9aca179dc191b460db37d8`,开始时工作区干净。
|
||||
- 三个提交包含系统任务 Token 统计、Runtime/模型分层和宣传稿归档。
|
||||
正式功能说明以实现为准;用途模型和旧全局 provider 移除仍为规划。
|
||||
- Agent 基线为已发布 `agent-v0.15.0`。本轮 Continue helper 识别首次历史封装、
|
||||
去除保存消息中的工作模式前缀,因此发布新 Agent 0.15.1,不覆盖旧产物。
|
||||
Node 24.19.0、OpenCode 1.18.29、Continue 1.5.47 和协议锁保持不变。
|
||||
- 正文仅维护在 Desktop 0.15.4 JSON 条目及 Agent 0.15.1 Markdown;
|
||||
已发布版本正文保留。FEATURES 中修正旧的简化选择描述,并同步用量、版本和限制。
|
||||
|
||||
## 数据与兼容
|
||||
|
||||
schema 49 在既有 SQLite 事务中迁移项目与会话选择,不引入额外恢复系统。
|
||||
已读迁移指南并核对合成 schema 48 数据迁移回归;本轮不改写用户原库。
|
||||
关闭应用后备份完整数据库及相邻 `notes/`,旧客户端不能打开升级后的库,
|
||||
降级需要恢复完整升级前备份。远程先升级 Desktop 0.15.4,再更新 Agent 0.15.1。
|
||||
统计无法补算历史未记录的调用或缺失的供应商 usage。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm run typecheck`、`npm run lint`、`npm run release:notes:verify` 通过。
|
||||
- 10 个聚焦文件、221 项通过:包含数据库迁移/用量分类、选择解析、选择器、
|
||||
Activity UI、后台用量记录和 Continue helper。
|
||||
- 当前源码隔离 Agent 在共享 Linux x64 Host 验证 OpenCode 与 Continue Ask、Execute、
|
||||
空闲进程回收及冷启动历史恢复。成功场景每个 Runtime 两次真实模型请求。
|
||||
OpenCode 原模型连接前三次尝试未完成,第三次脱敏诊断明确为上游 `ECONNRESET`;
|
||||
随后测试显式使用 Continue 已配置且成功的模型连接,未修改用户设置或产品回退规则。
|
||||
本轮共 7 次上游请求分发尝试,4 次成功、3 次未完成;不推断失败尝试的计费情况。
|
||||
所有探针结束时归属进程为 0,用户设置只在临时加密副本中读取,测试副本已清理。
|
||||
- 既有开发记录缺少完整 UI 目测及全仓通过结果,保留边界,不将局部通过冒充全量。
|
||||
完整测试与生产构建交由精确提交 CI;不执行本地生产构建、打包或安装包启动。
|
||||
|
||||
## 首轮候选 CI
|
||||
|
||||
`ed47e2c` 已同步两个远端。Agent `36580699297` 三平台验证通过。
|
||||
Desktop `36580699255` 为 5,472 通过、52 跳过、1 失败,生产构建未执行,
|
||||
尚未创建任何本轮发布标签。问答测试在 Runtime 就绪前点击仍禁用的发送按钮,
|
||||
没有产生首个请求;按现有异步同步机制等待按钮可用,保留全部问答与重载断言。
|
||||
该修正仅影响测试和发布记录,运行时源码、锁文件及双语发布说明不变。
|
||||
修正后完整 `App.test.tsx` 314 项通过,类型检查与 lint 再次通过。
|
||||
|
||||
## 发布前界面修正
|
||||
|
||||
用户要求移除会话 Runtime 按钮图标与名称间的圆点,并确认继续提交、推送和发布。
|
||||
删除专用覆盖标记 class 及其伪元素样式,保留菜单内来源提示和恢复默认操作,
|
||||
同步双语说明及选择设计。完整 App 与样式回归共 351 项通过,类型检查和 lint 通过。
|
||||
该改动仅在 Renderer,不影响 Agent 或桌面至 Agent 协议,无需新增真实模型请求。
|
||||
此前中断的测试不计为通过;本条结果来自重新执行。最终新提交仍须等待独立 CI。
|
||||
@@ -0,0 +1,74 @@
|
||||
# Desktop 0.15.5 发布准备
|
||||
|
||||
日期:2026-09-30。用户授权按新增改动确定版本、提交推送两个远端,
|
||||
CI 与生产构建通过后自动打标签发布;本轮不构建 LoongArch。
|
||||
|
||||
## 范围
|
||||
|
||||
已发布基线 `v0.15.4`:`a662b16f5e076f67788457653d29c0d2fbacdab0`。
|
||||
初始工作区干净,产品源码截止 `2d8e58e995c4ac2e3d885b028e588d49d06f05cf`。
|
||||
四个提交分别为取消轮次历史、执行清单浮层、智能心跳增量回顾与监督建议、
|
||||
笔记保存后退出编辑。双语正文维护在 `resources/release-notes.json`;
|
||||
已发布正文保留,两个 FEATURES 同步当前能力和剩余边界。
|
||||
|
||||
Agent 保持 0.15.1,不发布新标签或修改其历史说明。Agent daemon、协议实现和
|
||||
Runtime 锁没有变化;共享合同新增的是桌面监督配置/API,历史构建在 Renderer/Main
|
||||
进行,远程继续通过现有 history 字段传输。因桌面至 Agent 历史输入改变,
|
||||
仍对当前源码完成真实 Host 取消后续聊验证。
|
||||
|
||||
## 数据与行为边界
|
||||
|
||||
schema 50 在既有事务中增加心跳介入方式、建议状态和建议表;旧报告中仍 proposed
|
||||
的记忆迁为 pending 约定,不确认或删除原记录。已读迁移指南并核对 49→50 回归;
|
||||
没有读取或升级用户原库,也不声明大规模真实旧库性能已验证。
|
||||
升级前关闭应用,备份完整数据库及相邻 `notes/`;降级需恢复完整升级前备份。
|
||||
|
||||
手动回顾默认增量,显式重新整理不推进或重置共享进度。建议以本次结果中的依据为准,
|
||||
有候选才进行额外模型调用;项目与全局分别维护进度,会话级实时介入仍未实现。
|
||||
本地 OpenCode 的取消会话标记不等同于 Agent 实现;远程使用共享桌面历史输入与
|
||||
原有取消/会话恢复路径,不声称复用了本地 OpenCode 类。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 类型检查和 lint 通过。
|
||||
- 首轮定向 34 文件:1092 通过、1 跳过、1 失败。会话合并用例只等待 Runtime
|
||||
mock 调用便触发新会话,单独复跑通过;补充等待已发送消息可见,保留两次同批
|
||||
新建请求和最终会话数量断言。最终 34 文件全部通过,1093 项通过、1 项既有条件跳过;
|
||||
类型检查、lint、双语发布说明及差异检查再次通过。
|
||||
- 当前源码在共享 Linux x64 Host:OpenCode、Continue 各在 Ask 输出后主动取消,
|
||||
用生产 `buildRuntimeHistory` 生成中断历史,再以 Execute 发送无工具的新问题。
|
||||
两条路径均只回答新问题,未续写原列表;归属进程最终为 0。
|
||||
本轮恰好 4 次真实上游请求,全部 HTTP 200,无重试或假模型;用户设置只读取
|
||||
临时加密副本,未修改正式 Agent 安装和工作目录。
|
||||
- 智能心跳开发记录中的 4 次 DeepSeek 请求是该功能开发证据,不计入本轮新增调用。
|
||||
参见[实施记录](../features/conversation-supervision/progress.md)。
|
||||
- 不执行本地生产构建、打包或安装包启动。全仓验证和生产构建以最终提交的 CI
|
||||
为准,标签只能在该门槛通过后创建。
|
||||
|
||||
## 中断后扩展候选
|
||||
|
||||
用户新增四个提交并授权再次推送继续发布;源码范围扩至
|
||||
`6edfc0046b83150f2027167352000f161fb0dd0a`,恢复时工作区干净。
|
||||
新增 OpenCode 终端依赖缓存、短 MCP 名称与并行占用、工作区固定工具栏、
|
||||
笔记双栏布局及手动标签。说明和功能矩阵已同步,目标 schema 改为 51。
|
||||
标签支持多选 AND 筛选、重命名/合并/删除及桌面笔记工具字段,非自动分类;
|
||||
Agent daemon 和协议不变,Agent 仍为 0.15.1。
|
||||
|
||||
上一候选 `925d410` 的 Agent CI 通过,Desktop `36664224641` 为 5481 通过、
|
||||
55 跳过、3 失败,生产构建未执行,未创建标签。两个笔记分析用例的旧编辑按钮
|
||||
假设已由新提交修正;新源码真实 Electron 复验已通过。监督布局仍查找旧记忆建议
|
||||
标题及已移除的心跳运行审计,修正为监督建议区域和两条建议断言、审计不重复展示,
|
||||
保留页签、宽度、历史报告和导航检查。
|
||||
|
||||
扩展候选复验:15 文件、502 项通过,包含上述三个失败场景、笔记标签与布局、
|
||||
工作区滚动布局、schema 51 数据库、MCP 网关/名称及原生终端缓存测试;
|
||||
类型检查、lint、双语说明校验和差异检查通过。该轮测试使用隔离 fixture,
|
||||
没有新增付费模型调用;先前 4 次真实 Host 请求仅对应取消后续聊范围,
|
||||
不作为新增标签工具的完整远程 UI 实测声明。
|
||||
|
||||
`89fc100` 的 Agent CI 通过;Desktop `36707706849` 为 5507 通过、55 跳过、
|
||||
2 失败,前述三个失败场景均已通过。笔记滚动在请求完成回调中早于 DOM 提交,
|
||||
以立即执行动画帧的回归稳定复现后,改在布局 Effect 中定位已挂载记录。
|
||||
手动压缩独立复跑通过,夹具同步当前分层选择格式并等待目标会话历史显示;
|
||||
尚不将独立通过视为 Linux 全仓通过。修正后完整 App/笔记组件及真实 Electron
|
||||
导航/压缩共 450 项通过,类型检查和 lint 通过,无新增外部模型调用。
|
||||
@@ -0,0 +1,70 @@
|
||||
# Desktop 0.15.6 发布准备
|
||||
|
||||
日期:2026-10-01。用户授权提交当前未提交改动并继续双远端推送与发布,
|
||||
如 Agent 实际受影响可升补丁版本;另明确本轮仅标准六平台,不含 LoongArch。
|
||||
|
||||
## 候选范围
|
||||
|
||||
基线 `v0.15.5` 为 `3be55348a215f4343326b1950a69529247cbae6a`,
|
||||
六个新增提交截止 `9cc5dc124464a67ed93014c53cbc93f96764caa2`。
|
||||
初始未提交改动仅 `application-center.css` 一行选择器:
|
||||
侧栏底部仅对第二个直接子元素文字 span 禁止伸缩,避免影响其他 span。
|
||||
已核对并纳入本轮,未包含凭据、日志或临时产物。
|
||||
|
||||
新增内容包括磨砂外观、输入/缓存会话切换渲染优化、直接图片预览、设置与笔记布局、
|
||||
本地 MCP 恢复及不确定名称退役、诊断和执行统计索引。
|
||||
双语说明和功能矩阵按实现更新,不将已有开发记录中的全量失败记为成功。
|
||||
|
||||
Agent 保持 0.15.1:相对其发布标签,daemon 与锁文件无变化。
|
||||
本地 OpenCode 的 HTTP 注册恢复属于 `OpenCodeRuntime`,远端使用
|
||||
`RuntimeAcpBackend`/`AgentImageToolMcp`,不经过这条 HTTP 注册路径。
|
||||
没有据此声称远程恢复得到同样改进,也没有新发布 Agent 包。
|
||||
|
||||
## 数据与验证
|
||||
|
||||
已读数据库迁移指南。schema 52 在事务内增加任务事件部分索引,不转换消息正文,
|
||||
不执行 VACUUM;建立索引的耗时受历史规模影响,本轮未测真实大库升级。
|
||||
升级前关闭应用并备份数据库和相邻 `notes/`,降级需要完整升级前备份。
|
||||
|
||||
- 当前源码 17 个定向文件、886 项通过,包含真实 OpenCode + HTTP 网关合成服务的
|
||||
MCP 故障恢复和晚到初始化回归,数据库/统计、诊断、设置、App、图片附件和真实
|
||||
Electron 磨砂、笔记布局、工作栏设置布局。
|
||||
- `npm run typecheck`、`npm run lint` 通过;本轮新增付费模型调用 0 次。
|
||||
- 完整测试和生产构建等待精确候选 CI,不运行本地生产构建、打包或安装包探针。
|
||||
|
||||
## 首轮 CI
|
||||
|
||||
候选 `cc451f6` 已含用户未提交样式修正并同步两个远端。Agent `36743915140`
|
||||
通过,Desktop `36743915207` 为 5573 通过、57 跳过、1 失败,生产构建未执行。
|
||||
磨砂测试切换主题后立即读取样式,窗口按钮 color 正在过渡,导致深色关闭态与
|
||||
先前基线不同。测试改为等待当前 CSS Transition 完成,保留完整样式、几何、
|
||||
拖拽、对比度及原生交互断言,不修改产品外观或全局超时。
|
||||
修正后完整磨砂 Electron 回归、类型检查及 lint 通过;双语说明与产品源码不变。
|
||||
|
||||
`8201c9c` 的 CI `36746918608` 中磨砂回归通过,唯一失败为 161 条历史的多次
|
||||
热切换 App 用例超过默认 5 秒(5573 通过、57 跳过)。只将该用例预算设为 15 秒,
|
||||
保留消息数量、DOM 复用、零额外渲染、草稿、附件、展开状态和滚动位置的全部断言;
|
||||
不修改全局超时或将运行时性能要求替换为等待通过。
|
||||
调整后完整 App 319 项、类型检查及 lint 通过。
|
||||
|
||||
## 再次扩展候选
|
||||
|
||||
用户要求将当前未提交改动一起提交。新增 `0a02a17`(新建笔记入口)和
|
||||
`a3b41bf`(设备共享元数据),并纳入五份应用导航文档修改。
|
||||
设备共享需另行运行 ShareServer,首期面向本地未认证服务,仅注册设备及管理元数据;
|
||||
不执行远程能力,不传输或检索知识正文,设备 ID 不构成所有权认证。
|
||||
Agent 实现和锁不变,仍不升版。
|
||||
|
||||
扩展源码类型检查和 lint 已通过。用户随后明确要求停止本地测试、直接推送等 CI,
|
||||
进行中的定向测试已中断,不计为通过,也不沿用旧候选的全量结果。
|
||||
最终发布取决于包含新增提交和文档的精确候选 CI 与生产构建。
|
||||
|
||||
## 扩展候选焦点回归
|
||||
|
||||
`2c5300d` 的 Desktop CI `36753498640`:5580 通过、58 跳过、1 失败,
|
||||
生产构建未执行;Agent CI `36753498643` 通过。
|
||||
笔记导航用例在打开并取消带 `autoFocus` 标题输入框的新建表单后,
|
||||
才断言此前打开详情时的返回按钮焦点,混合了两个不同交互阶段。
|
||||
将原焦点断言移至打开新建表单之前,并断言新建标题输入框取得焦点;
|
||||
保留返回列表、滚动位置、搜索内容和原卡片焦点断言,不改产品代码或延长超时。
|
||||
按用户要求不运行本地测试,由新候选 CI 验证。
|
||||
@@ -0,0 +1,53 @@
|
||||
# Desktop 0.15.7 发布准备
|
||||
|
||||
日期:2026-10-01。基线为 `v0.15.6`,源码候选 `c8f327f`。
|
||||
检查时工作区干净,用户要求纳入的先前改动已由该提交包含,共 75 文件。
|
||||
本轮 Desktop 升至 0.15.7,Agent 保持 0.15.1,仅标准六平台,不含 LoongArch。
|
||||
|
||||
## 范围
|
||||
|
||||
- 流式 Markdown 分段复用、会话行与派生值缓存。默认 16ms 节奏不变。
|
||||
- 运行记录进入时读取、可见时 15 秒刷新及页头手动刷新,不是逐事件实时视图。
|
||||
- 本地自定义 MCP 发现与动态目录故障按服务隔离,取消和撤销仍生效。
|
||||
- 模型连接中的受管模型下载源弹窗,确认保存、取消不变及相关入口统一。
|
||||
- 设备共享默认关闭的测试入口,显式启用,关闭不撤销服务端记录。
|
||||
|
||||
网关修改只涉及本地自定义 MCP 发现方法;远程 Agent 的独立 MCP 实现与
|
||||
Story Graph 绑定不经过这些方法,Agent 源码、锁及包未变。边界与开发验证见
|
||||
[工作栏进度](../features/assistant-workbar/progress.md)。
|
||||
数据库仍为 schema 52,无新增迁移。
|
||||
|
||||
## 证据与发布门槛
|
||||
|
||||
本轮遵照用户要求,不运行本地测试、类型检查、lint 或生产构建,由新候选 CI 验证。
|
||||
已有开发会话记录不等于本候选全仓通过;网关会话的历史全仓失败也不掩盖。
|
||||
流式输入测量见 [组件测量记录](../../tests/support/streaming-input-results.md),
|
||||
不据此声称完整 App 或所有机器的相同比例提速。本轮外部模型调用为 0。
|
||||
|
||||
中英文 FEATURES 已核对设备共享默认值、下载源位置及实现边界,并补充本轮性能和活动行为。
|
||||
发布说明保留 schema 52 备份提醒,不修改历史已发布条目。
|
||||
双远端推送精确候选后,必须通过主分支全量验证与生产构建,并确认候选及双语说明后,
|
||||
才能创建不可变 `v0.15.7` 标签。
|
||||
|
||||
## 首轮 CI
|
||||
|
||||
`227507d` 的 Desktop CI `36872224138`:5613 通过、59 跳过、2 失败,
|
||||
生产构建未执行;Agent CI `36872224140` 通过。
|
||||
英文设置布局 fixture 在 640×420 窗口将控件居中到包含固定页头的整个滚动区,
|
||||
点击检测被较高的英文页头遮挡。测试共用滚动辅助函数改为扣除实际页头高度的
|
||||
可见区域,仍检查命中、溢出、文字宽度和原生键盘交互,不改产品布局。
|
||||
旧 outbox fixture 先建当前数据库,再降为 schema 23 并执行完整迁移,
|
||||
超过默认 5 秒;仅该用例设 15 秒,保留终态、尝试次数及附件清理断言。
|
||||
修复不修改发布说明或运行时代码,按用户要求继续交新候选 CI 验证。
|
||||
|
||||
## 第二轮 CI
|
||||
|
||||
`7cb74cc` 的 CI `36874700588`:首轮两项失败已通过,但仍有 5613 通过、
|
||||
59 跳过、2 失败,生产构建未执行。检查提交链确认先前提交期间并行加入了
|
||||
`5e77118`,移除文字输入框占位提示的问候语,App 单元测试已同步为两行,
|
||||
Electron 侧栏探针却仍要求三行,导致断言失败。探针同步为两行并检查首行键盘提示,
|
||||
保留平台快捷键、输入、清空、焦点、布局和搜索检查。
|
||||
Markdown 用例逐一比较超过 1500 个流式前缀,超过默认 5 秒;
|
||||
仅该用例改为 15 秒,保留全部 DOM 等价、前缀数及多段覆盖断言。
|
||||
用户明确授权连续修复、双远端推送和 CI 通过后的标签发布,不再中途确认。
|
||||
本轮仍不运行本地测试,以上修复须由下一次精确候选 CI 验证。
|
||||
@@ -0,0 +1,57 @@
|
||||
# Desktop 0.15.8 / Agent 0.15.2 发布准备
|
||||
|
||||
日期:2026-10-02。Desktop 基线 `v0.15.7`,Agent 基线 `agent-v0.15.1`;
|
||||
已提交源码固定为 `b1129cedec5e4890d82fcddc186b13467f466a6f`。
|
||||
仅纳入四个已提交功能修正及本次必要发布准备,不提交已有 Story Graph、
|
||||
会话监督设计或宣传文章等未提交文件。
|
||||
|
||||
## 范围
|
||||
|
||||
- `dd29f30`:保存会话图片到本机或 Host、设置中列出图像工具。
|
||||
- `f4d8fe7`:新记录取消、编辑焦点返回、笔记正文使用可用宽度。
|
||||
- `2e5c348`:缺失内置浏览器设置时默认启用,保留已保存配置。
|
||||
- `b1129ce`:缺失磨砂外观字段时默认开启,保留已保存配置。
|
||||
- 数据库仍为 schema 52;Desktop 标准六平台,无 LoongArch。
|
||||
- Agent 保存路径涉及守护进程、工具声明与桌面图像二进制回复,升至 0.15.2,
|
||||
最低 Desktop 0.15.8;协议主版本及 Runtime 锁版本不变。
|
||||
|
||||
## 当前源码共享 Host 验证
|
||||
|
||||
共享 Linux x64 Host 的现有 SSH 固定身份可达。在独立 `/root/tmp` 目录运行
|
||||
当前源码 AgentImageToolMcp,通过真实 SSH 传递工具消息,与 Desktop 的
|
||||
MainImageToolSession、ImageGenerationService、真实 SQLite 会话协作。
|
||||
图像为本轮生成的合法随机 PNG,196992 字节,超过单个 128 KiB 分块。
|
||||
|
||||
保存后的 Host 文件 SHA-256 与 Desktop 原始内容相同;默认覆盖拒绝、明确覆盖成功、
|
||||
其他会话图片 ID 拒绝、Ask 写入拒绝且无文件落盘。共 8 个回复帧,
|
||||
最大 174892 字节,低于 256 KiB 上限。外部模型调用 0 次。
|
||||
这是生产保存服务与 Agent MCP 的真实 Host 工具层验证,不冒充完整 App、
|
||||
模型自动选工具、安装包或三个 Host 架构验证。使用 SSH 流作为该探针传输;
|
||||
未改变用户工作区、产品配置或 Host 已安装 Agent。
|
||||
|
||||
临时探针最初因打包 Zod 初始化顺序、混入仅 Desktop 依赖而无法启动,
|
||||
修正探针依赖边界后验证通过;这些失败不计为产品模型调用或产品修复。
|
||||
探针和包不进入仓库。用户要求不跑本地测试套件,精确候选由 CI
|
||||
执行全量验证和生产构建后,才允许打 Desktop 和 Agent 不可变标签。
|
||||
|
||||
## 发布核对
|
||||
|
||||
中英文 FEATURES、Desktop 与 Agent 双语说明必须与上述实现边界一致。
|
||||
只暂存必要发布文件,保留工作区用户文件;双远端核验候选和标签一致。
|
||||
公开验证包括六平台 Desktop、三平台 Agent 签名目录、GitHub/OSS 同步及 Latest 分离。
|
||||
|
||||
## 首轮 CI 修正
|
||||
|
||||
`20317c2`:Agent CI `36960575058` 成功;Desktop `36960575157` 为
|
||||
5620 通过、59 跳过、4 失败,构建未执行。
|
||||
Obsidian 专项 IPC fixture 未安装浏览器服务,显式关闭无关浏览器能力;
|
||||
MCP 列表断言纳入新增图像卡片的数量和名称;覆盖层原生点击先将控件居中,
|
||||
避免磨砂顶栏覆盖 `nearest` 保留在顶部的控件,并补充命中遮挡诊断。
|
||||
保留所有业务、覆盖层和原生交互断言,不改变产品默认值、关闭沙箱或跳过测试。
|
||||
这些修正由新候选 CI 验证。
|
||||
|
||||
`5368e0e` 的 CI `36962139369` 中上述四项通过,余下新记录取消焦点用例失败,
|
||||
5623 通过、59 跳过、1 失败。测试用 `fireEvent.click` 点击取消不会像原生点击
|
||||
那样移动焦点,确认框记住的仍是先前的返回总览按钮。用例先等打开详情的焦点完成,
|
||||
明确聚焦取消按钮后再点击,并等继续编辑实际聚焦编辑器后进行下一步;
|
||||
保留放弃草稿后回到记录与未保存新记录的断言,不改产品焦点规则或延长超时。
|
||||
@@ -0,0 +1,56 @@
|
||||
# Desktop 0.15.9 / Agent 0.15.3 发布准备
|
||||
|
||||
日期:2026-10-03。Desktop 基线 `v0.15.8`,源码
|
||||
`50481deaa1f7d17184500bb6f5abb3e1a0c8bbd5`,37 个提交、163 个文件。
|
||||
检查时工作区干净,范围仅为已提交实现与必要发布准备。
|
||||
标准六平台 Desktop、三平台 Agent,不含 LoongArch。
|
||||
|
||||
## 范围与边界
|
||||
|
||||
- 监督共享时间线、故事归属与人工调整、经验抽取、故事回顾与心跳建议;
|
||||
平铺与时间螺旋、紧凑图谱控件及 WebGL 生命周期修复。
|
||||
- schema 53~56:合并既有进度,增加故事、经验及建议类型,保留历史结果。
|
||||
旧数据库副本验证和模型质量抽查见[监督进度](../features/conversation-supervision/progress.md),
|
||||
不将 schema 53 的单库耗时当作本轮完整迁移保证。
|
||||
- 会话外部存储、侧栏和消息窗口化、Markdown 缓存、只读 Worker、活动差量写入、
|
||||
远程事件 checkpoint 批次持久化;保留丢失焦点、滚动、去重与提交顺序检查。
|
||||
- Execute 配置工具不再重复弹原生确认,Ask 只读边界不变。
|
||||
- Electron 44.5.1;Node、OpenCode、Continue 锁不变。
|
||||
- Agent 虽无守护进程文件变更,但打包的 Story Graph 工具 schema 增加 story/experience,
|
||||
旧 Agent 不支持这些输入,因此升级 0.15.3,最低 Desktop 0.15.9。
|
||||
|
||||
性能范围及暂缓项见[性能计划](../roadmap/product-performance-experience-improvement-plan.md)。
|
||||
不把组件或特定机器的指标作为普遍提速保证;向量仍可全表扫描,Worker 失效会同步回退,
|
||||
窗口化的未挂载行不支持页内查找,快速滚动可能短暂显示占位。
|
||||
故事、经验、建议与输出重试有模型费用,自动结果须人工核对。
|
||||
|
||||
## 本轮真实 Host 验证
|
||||
|
||||
当前源码临时打包到隔离测试目录,不构建生产安装包。使用现有固定 Host 身份及
|
||||
凭据存储,通过生产 Agent Attach、模型桥及远程 ACP 路径执行 OpenCode 与 Continue
|
||||
各一次 Ask、一次 Execute。OpenCode 探针显式选择既有 Continue 模型配置,
|
||||
不修改产品设置,也不是产品静默降级。
|
||||
|
||||
共 **4 次真实提供方请求,4 次 HTTP 200**,无模拟推理、无自动请求重试。
|
||||
验证 Runtime 释放后重建、历史恢复和本轮进程清零。
|
||||
每次消费真实远程语义流,通过生产 `RemoteEventBatcher` 与
|
||||
`AssistantDatabase.appendRemoteTaskEventsOnce` 在 checkpoint 前落库;
|
||||
对完整已提交事件再次写入均返回已存在。四次请求分别记录:
|
||||
|
||||
| Runtime / 模式 | 批次 | 事件 | checkpoint |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenCode Ask | 3 | 4 | 2 |
|
||||
| OpenCode Execute | 21 | 40 | 20 |
|
||||
| Continue Ask | 3 | 4 | 2 |
|
||||
| Continue Execute | 23 | 44 | 22 |
|
||||
|
||||
验证没有启动完整 Renderer,也不声称覆盖会话消息投影的所有恢复分支。
|
||||
Story Graph 新对象与来源语义由本轮源码测试及开发记录覆盖,以上四次请求不调用图谱工具。
|
||||
开发文档中的模型调用不计入本轮 4 次。临时探针的两次编译钩子定位失败均发生在请求之前。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
按用户要求,不执行本地测试套件、lint、类型检查或生产打包;
|
||||
精确候选推送双远端后由 CI 做全量验证和生产构建,再打不可变双版本标签。
|
||||
中英文 FEATURES 需同步共享进度、已实现故事经验、成本和窗口化限制。
|
||||
公开核验 Desktop Latest、标准产物、Agent 签名目录、两个源及网站,保留任何并行未提交工作。
|
||||
@@ -0,0 +1,23 @@
|
||||
# Desktop 0.16.0 发布准备
|
||||
|
||||
日期:2026-10-09。基线 `v0.15.14`,候选源码为本次发布准备提交。
|
||||
本候选只更新 Desktop;Agent 保持 `0.15.6`,不创建新的 Agent 标签。标准六平台 Desktop,
|
||||
不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 专家和团队角色选择移动到 Composer 输入框顶部,发送前更容易发现。
|
||||
- 角色选择按会话保存,切换会话不会带入其他会话的选择。
|
||||
- 历史消息保留请求发送时使用的角色,即使角色后来重命名或删除,历史上下文仍可识别。
|
||||
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 验证边界
|
||||
|
||||
本候选包含 Composer、会话选择状态、历史消息元数据和角色选择的回归测试。
|
||||
完整跨平台安装、远程 Host 和未覆盖的整包验收仍以 CI 与发布工作流结果为准。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再由用户确认后创建不可变的 `v0.16.0` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -0,0 +1,18 @@
|
||||
# Desktop 0.16.1 发布准备
|
||||
|
||||
日期:2026-10-10。基线 `v0.16.0`,候选源码包含 `d9dcb65d` 和 `c7f09e1e`
|
||||
两个 Composer 视觉修复提交。本候选只更新 Desktop;Agent 保持 `0.15.6`,
|
||||
不创建新的 Agent 标签。标准六平台 Desktop,不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 消息内容延伸到 Composer 输入框轮廓前再淡出,不再被输入区上方的实色区域提前截断。
|
||||
- 浅色和深色主题使用匹配的消息到输入框渐变,输入框焦点边界不再形成粗边。
|
||||
- 滚动到底部按钮保持与新的输入区过渡相配的可见位置。
|
||||
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再创建不可变的 `v0.16.1` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -10,6 +10,23 @@ from the authoritative GitHub, OSS, workflow, and Git state, then use the
|
||||
smallest existing idempotent rerun or a new immutable version as specified
|
||||
below.
|
||||
|
||||
## Optional PR Article
|
||||
|
||||
Product publicity is an optional companion to a release, not a release blocker
|
||||
or a replacement for the approved bilingual release notes. When requested,
|
||||
follow the [PR article workflow](../pr-articles/README.md) and choose either the
|
||||
[featured article](../pr-articles/README.md#重点稿模板) or
|
||||
[regular update](../pr-articles/README.md#常规与综合稿模板) template based on user value,
|
||||
not version size or commit count.
|
||||
|
||||
Prepare the draft and real screenshots against the candidate commit using the
|
||||
[isolated screenshot procedure](../pr-articles/README.md#截图执行步骤). This does
|
||||
not waive the build, packaging, or launch restrictions below. Keep missing
|
||||
materials in the article's review record without delaying the software release.
|
||||
Before external publication, verify the final version, download links, upgrade
|
||||
notes, and screenshot accuracy. Preparing materials does not authorize posting
|
||||
to a public channel; follow the user's explicit publication instruction.
|
||||
|
||||
## Release Packaging
|
||||
|
||||
- Unless the user explicitly requests it, never run a local production build,
|
||||
@@ -27,6 +44,11 @@ below.
|
||||
- `.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.
|
||||
- Linux source validation configures the installed Electron `chrome-sandbox`
|
||||
helper with root ownership and mode `4755`, rehydrates the Electron
|
||||
distribution without cache when the helper is missing, then runs the full
|
||||
test suite under Xvfb. Keep Electron sandboxing enabled; do not skip UI tests
|
||||
or add `--no-sandbox` to work around runner setup failures.
|
||||
- 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
|
||||
@@ -38,9 +60,19 @@ below.
|
||||
`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
|
||||
- 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.
|
||||
@@ -52,7 +84,7 @@ below.
|
||||
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
|
||||
and Continue Runtimes. 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
|
||||
@@ -62,13 +94,13 @@ below.
|
||||
- `.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
|
||||
official Node archive and locked OpenCode and Continue npm archives, 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.
|
||||
installation, Attach, mode-free tool execution, 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
|
||||
@@ -100,6 +132,10 @@ below.
|
||||
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
|
||||
@@ -211,12 +247,12 @@ surface together before a new release.
|
||||
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 14 installer URLs publicly before
|
||||
`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 22 assets: 14 installers (two formats
|
||||
for each Windows/macOS target and three formats for each Linux target), six
|
||||
- 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.
|
||||
@@ -269,17 +305,17 @@ Do not report a release complete until all of the following are verified:
|
||||
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 22 uploaded asset
|
||||
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 14 installer entries, the trusted Beijing
|
||||
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 14 correct
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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. Renderer exit records also retain Electron's enumerated `reason` and
|
||||
numeric `exitCode` when written, read back and exported; older records without
|
||||
these fields remain readable. The exit details still go to the Main console.
|
||||
React errors/component stacks remain console-only. 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,145 @@
|
||||
# 应用导航与系统工具入口
|
||||
|
||||
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,五个内置应用共用可调整的保存顺序,知识库始终启用并常驻;
|
||||
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部“应用”向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有“应用中心”管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
|
||||
商店入口、推荐位或安装流程。
|
||||
|
||||
设备共享作为第五项追加到原有应用顺序,测试阶段默认关闭,启用后可从菜单和管理 Modal 打开,不提供常驻
|
||||
开关;服务地址与设备名称在其页面保存。首期仅支持本地服务的设备注册与元数据目录,详见
|
||||
[设备共享](../device-sharing/README.md)。
|
||||
|
||||
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
|
||||
本轮 A–D 已接入生产源码:应用菜单、应用管理 Modal、共享应用设置、持久化偏好及本机推理监控。菜单不按打开历史或常驻状态筛选,无模态遮罩;点击应用行关闭菜单,知识库、智能心跳和魔法笔记打开主内容区页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。管理列表、菜单和侧栏按同一保存顺序排列,侧栏按启用和常驻过滤。知识库在管理 Modal 中提供“打开”和排序,不提供启用、常驻开关或通用空设置;原工作区配置及心跳单条计划 enabled 保持原义。验证范围及集成缺口见[实施进度](./progress.md)。
|
||||
|
||||
新增计划包括 [私有化市场与 YAML 交换](./prd.md#fr-15-私有化市场与-yaml-交换后续):
|
||||
通过市场 URL 获取应用,支持 `.yml` 导入导出,未来由 ShareServer 提供私有目录与分发。
|
||||
[本机模型管理入口](./prd.md#fr-16-本机模型管理入口新增计划项)已独立于商店接入,作为
|
||||
可常驻左侧的“本机推理监控”(英文 Local Inference Monitor)内置应用,直接查看语音、OCR、向量等本机服务状态与可用资源指标,不显示任务历史。向量服务显示独立进程的 CPU 和工作集内存;ASR、OCR 与宿主共享进程,逐行说明无法独立统计的原因。
|
||||
本轮已接入 `local-inference` 管理 Modal、类型化 IPC、真实请求登记及引擎管理;实际支持清单见
|
||||
[技术设计](./technical-design.md#111-本机推理任务与执行服务),市场仍为后续需求。
|
||||
相关验收见 [User Stories](./user-stories.md#7-后续需求场景)。
|
||||
|
||||
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
|
||||
生产项目入口是一个固定双行按钮:第一行显示项目名,第二行显示全局活动或“暂无活动”,点击任一行都打开统一工作空间菜单并默认预览当前项目。项目分类依次为“全部项目、本地、远程、消息通道”,点击“全部项目”切换全局范围,悬停预览会话,点击项目直接进入。生产最近会话上限为 10 条,验证状态见实施进度。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,统一菜单见
|
||||
[活动界面](./ui-design.md#10-项目活动汇总),数据加载与会话跳转见
|
||||
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”包括当前运行、待处理及本次客户端
|
||||
会话内已完成但未查看的会话;完成提示在实际进入聊天后清除,不累计历史完成总数。
|
||||
|
||||
## 文档导航
|
||||
|
||||
待实现的应用扩展见 [FR-17](./prd.md#fr-17-主机管理与云环境管理待实现):
|
||||
主机管理从设置中心迁入独立工作页面,云环境管理作为同级应用;业务定义由
|
||||
[远程主机功能文档](../remote-host/README.md)维护。该计划不包含已接入的设备共享元数据页面。
|
||||
|
||||
现有 `heartbeat` 入口承载监督者工作回顾与故事线图谱,可管理启用、常驻和排序。应用默认关闭;完整执行边界见[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)。未来市场复用同一应用身份,不增加平级重复入口,目标定义见[监督者产品设计](../conversation-supervision/supervisor-prd.md)。
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [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](./workspace-demo.html) | 左上角开始菜单式项目与并发会话候选方案;配套 `workspace-demo.css` 与 `workspace-demo.js` 仅服务原型 |
|
||||
|
||||
以下保留工作空间 Demo 的历史原型说明,包括最近 5 条的旧上限。生产实现已接入独立的 `WorkspaceMenu`,以功能设计和实施进度为准;Demo 的分类布局、排序、快捷键及模拟设置不作为生产契约。
|
||||
|
||||
工作空间 Demo 可直接打开,展示项目工作空间、会话窗口、全局活动、状态筛选、待办处理与完成待查看。
|
||||
左上角项目和活动摘要打开同一个锚定下拉菜单,没有独立总览页面或全屏遮罩。左栏筛选项目,右栏按状态分组显示紧凑会话列表,列表在菜单内滚动。
|
||||
菜单桌面宽度扩至 920px、高度至 640px,并受视口约束;左栏按本地项目、远程项目、消息通道分组。远程项目按主机归属展示,主机连接状态与会话活动计数分开;分组、主机和通道均使用演示数据。
|
||||
左侧通过本地、远程、消息通道 Tab 点击切换分类,项目悬停预览;演示包含 20 个本地项目、20 个远程项目和 3 个消息通道。搜索跨分类匹配项目名、主机及类型,远程主机分组支持折叠。“全部项目”、搜索和分类固定,项目列表独立滚动。
|
||||
项目行最小高度 52px,无活动时显示名称、工作目录两行;有活动时显示名称、活动状态、工作目录三行,高度随内容增长。当前项目以勾选区分于悬停预览。设置按钮独立,悬停或聚焦显示,触屏常显。设置弹窗可修改演示名称、说明、目录、默认模式及 Runtime;通道名称只读,执行空间不可修改。所有目录均为示例路径,不访问文件系统;原型未模拟真实目录选择、归档删除或远程恢复流程。
|
||||
点击项目入口默认筛选当前项目,点击活动摘要查看全部项目;悬停左侧项目只预览右侧会话。点击项目直接进入并恢复上次打开的会话,尚无会话则创建新会话;点击右侧会话直接进入指定会话。设置按钮独立,不触发项目切换,会话草稿保留。
|
||||
支持浅深主题、窄屏、点击外部关闭、Escape 关闭及 `Ctrl/Command+Shift+K` 切换菜单。
|
||||
菜单底部“新建项目”展开名称表单,创建后进入新项目的首个会话;“新建会话”与状态筛选同排,在所选项目创建会话,全部项目视图下使用当前项目。窄屏状态筛选横向滚动,创建按钮保持可见。均只修改演示数据,重置后清除。
|
||||
页面顶部可模拟后台完成,进入会话后清除该会话的完成提醒;已查看会话仍保留在最近会话中,不额外标记状态。
|
||||
状态沿用正式活动入口:待处理、运行中、已完成;待处理会话区分“等待你的回答”和“等待审批”。不提供“可继续”状态或筛选。“已完成”仍表示完成未查看提醒,不是全部历史完成会话。
|
||||
默认列表将待处理、运行中置顶,其余会话归入“最近会话”,按最近进入、发送或状态更新排序,最多显示 5 条;完成未查看在会话行标注。待处理和运行中不受此上限限制,也不重复列入最近会话。“已完成”筛选展示全部未查看结果,不受最近 5 条限制。没有活动时仍显示最近会话。
|
||||
所有执行、审批和消息均为内存中的模拟,不访问真实项目、不调用 AI;该候选方案尚未接入正式产品。
|
||||
|
||||
Demo 可直接在支持原生 dialog、popover 的现代浏览器中打开,无需依赖或构建。
|
||||
六个方案共用本次页面会话内的配置,刷新后全部恢复默认,不持久化或修改真实设置。
|
||||
A–E 仅展示内置应用,没有商店入口、推荐或安装操作;F 单独演示未来私有市场。
|
||||
模型服务、资源指标和工具内容均为演示数据,不执行真实安装、模型加载或进程操作。
|
||||
窄屏上下排列侧栏与主内容,应用页面隐藏输入区,对话页面显示输入区。
|
||||
Demo 的模型管理和 F 方案均使用模拟数据;正式本机推理监控 Modal 的交付范围以技术清单和实施进度为准,旧页面原型不作为 Modal 验收依据。
|
||||
底部输入区共用一个简化候选布局:附件、语音、Runtime、模式和发送位于主操作行,专家、
|
||||
知识范围及 Runtime 专属选项通过“选项”展开,非默认选择显示为可移除标签。可体验文件名
|
||||
附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止,
|
||||
以及上下文详情和模拟压缩。文件只读取名称,语音使用固定示例文本,不录音、不上传、不调用 AI。
|
||||
这是输入区设计讨论用的原型,尚未改变正式产品或替代现有 UI 规范。
|
||||
左上角项目选择器下方仅在有运行中或待处理会话时显示活动摘要,全部空闲时隐藏。点击摘要打开项目
|
||||
列表,悬停或点击项目向右展开会话子菜单,点击会话才切换项目与对话。子菜单与项目列表
|
||||
相接,项目行保留子菜单箭头,会话行只显示名称与状态。支持上下方向键选择、右键进入、左键或 Esc 返回,顶层 Esc 关闭;窄屏在同一浮层内
|
||||
进入会话层,并提供返回按钮。页面上方可切换“有待处理、仅运行中、仅当前项目运行、全部
|
||||
空闲”四种场景。项目与会话均为演示数据;该旧级联交互已由生产统一工作空间菜单替代,范围与
|
||||
验证记录见[实施进度](./progress.md)。
|
||||
Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
|
||||
工作栏 Tab 中演示,不列入应用中心。旧原型仍直接打开整窗 Modal,其中使用可搜索应用列表。
|
||||
|
||||
Demo 待同步更正:本节仅描述旧原型,不是生产行为或验收依据。原型直接打开 Modal、知识库的启停设置,以及心跳默认启用和关闭仅隐藏入口的说明均已过时;原型的常驻排序也不代表生产中四个应用的共享顺序。生产规则见上述说明及 UI 设计。Demo 已同步“本机推理监控”名称及其默认不常驻状态。
|
||||
|
||||
六个方案共用“我的应用”区:通过 + 或“全部应用”选择常驻应用,编辑时可拖动或使用
|
||||
上下箭头排序、移除入口。内置应用默认全部启用;魔法笔记、知识库、智能心跳默认常驻,计划预览“本机推理监控”默认不常驻。
|
||||
取消常驻仅移除导航捷径,仍可从应用中心打开;关闭应用隐藏业务入口,应用中心保留设置与
|
||||
重新开启入口,并记住常驻偏好。当前应用页关闭后显示不可用状态,重新启用从应用中心卡片设置进入。
|
||||
知识库关闭时同步隐藏侧栏入口、会话选项中的知识范围和检索方式、已选知识标签;重新开启
|
||||
恢复原来的选择。以上操作不删除数据,也不表示停止后台任务或模型服务。
|
||||
“恢复默认应用”重置应用安装、启用、常驻和笔记设置,返回对话页;模型服务模拟状态保留至刷新。
|
||||
|
||||
通用应用设置统一从应用中心卡片进入,应用页面和全局设置不提供重复入口,分别控制
|
||||
启用与常驻。魔法笔记面板另含显示未完成待办数量、AI 评论方式
|
||||
(即时/保存后自动/保存后手动)、AI 评论形式(长评 + 要点/长评/要点)。现有“显示
|
||||
魔法笔记入口”不在原型中重复提供。正式迁移保留其业务启用值,常驻使用独立配置。启用、常驻、待办数量显示默认开启;
|
||||
评论方式默认即时,评论形式默认长评 + 要点。侧栏待办数为固定的 3 项演示数据。
|
||||
修改即时生效于原型,不调用 AI 或写入真实应用设置。
|
||||
|
||||
此 Demo 中的任务、进度、使用方与服务内存为旧方案模拟内容;正式 Modal 已收窄为服务状态列表,见 [FR-16](./prd.md#fr-16-本机模型管理入口新增计划项)。
|
||||
引擎和管理方式收进详情;受管进程模拟启动、停止、重启,进程内模型模拟加载与释放。
|
||||
服务停止或重启后任务标为中断,重新启动不自动恢复任务;任务级取消仍待实际引擎接入。
|
||||
影响使用方的操作先显示就地确认,取消保留原状态;隐藏或关闭管理应用不改变服务状态。
|
||||
|
||||
F 提供私有市场 URL、模拟获取目录、模拟不可达与重试,不发起网络请求或切换公共来源。
|
||||
目录包含项目管理和阅读空间示例,可模拟安装或安装并常驻。YAML 文本支持导入预览和导出
|
||||
预览,明确标为 mock schema,仅识别示例中的七个扁平字段,尚非通用 YAML 解析器。
|
||||
导入列出依赖、外部资源和同标识冲突:缺少依赖或资源时不允许安装;内置应用不允许覆盖;
|
||||
已安装扩展须显式确认更新定义,保留启用与常驻偏好。导入新应用在目录中以标识显示,原始名称
|
||||
保留在定义及导出预览中。修改输入会使旧预览失效,取消不修改安装状态。
|
||||
导出仅显示定义文本,不写文件,不包含凭据、会话、笔记、知识数据、程序包或模型权重。
|
||||
|
||||
| 方案 | 讨论重点 |
|
||||
| --- | --- |
|
||||
| A:紧凑入口 | 从底部向上展开应用列表 |
|
||||
| B:应用宫格 | 在展开面板中展示应用图标、名称和常驻操作 |
|
||||
| C:应用预览 | 用卡片说明应用用途 |
|
||||
| D:搜索启动台 | 搜索打开能力,支持 Ctrl / Command K、方向键与 Enter |
|
||||
| E:常驻管理 | 已常驻应用优先排列,可取消常驻或打开 |
|
||||
| F:私有市场预览(未来) | URL 模拟获取、YAML 导入导出与显式模拟安装,不纳入首期 |
|
||||
|
||||
D 参考 [Raycast](https://www.raycast.com/) 的搜索启动与扩展发现方式。早期方案参考过
|
||||
Notion 收藏和 VS Code Profiles,当前版本已移除场景工具包与快捷区。
|
||||
原型支持 URL 的 `#launcher`、`#favorites`、
|
||||
`#scenes` 等片段直接打开指定方案。
|
||||
|
||||
## 术语
|
||||
|
||||
| 术语 | 定义 |
|
||||
| --- | --- |
|
||||
| 系统入口 | GoodBuddy 工作流程或审计所必需的固定入口,用户不能卸载、隐藏或改变类型 |
|
||||
| 应用 | 可显示在主导航区的功能;知识库、智能心跳和魔法笔记打开一级页面,本机推理监控打开保留底层工作区的 Modal |
|
||||
| 系统工具 | 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)
|
||||
- [文档解析入口与职责](../document-processing/ui-design.md#11-入口与职责):全局 OCR 配置、附件与知识文档操作的目标分工;不新增应用中心成员,本机推理监控不管理 HTTP OCR 服务。
|
||||
@@ -0,0 +1,280 @@
|
||||
# 应用导航与系统工具入口功能逻辑设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 应用中心与本机推理监控已接入源码,整体验收待完成 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 关联 User Stories | [User Stories](./user-stories.md) |
|
||||
|
||||
## 1. 不变量
|
||||
|
||||
1. “当前对话”“运行记录”和“设置”始终是系统入口。
|
||||
2. 运行记录不进入工具注册表、应用显隐设置或插件市场。
|
||||
3. 文件浏览器、终端、浏览器和资源监控始终是系统工具,不能卸载或被插件替换。
|
||||
4. 左下角提供应用中心和独立固定设置入口;文件、终端、浏览器、资源监控只通过右侧工作栏 Tab 和“+”访问,不进入应用中心。
|
||||
5. 五个内置应用共享保存顺序。知识库始终启用并常驻,位置可调整;智能心跳、魔法笔记、本机推理监控可独立设置启用和常驻。设备共享在测试阶段默认关闭,提供启用开关,启用后可从应用菜单打开,不常驻侧栏。关闭设备共享时退出其页面,不清除目录或撤销已发布记录。取消常驻不关闭功能,关闭不删除数据或导航偏好。
|
||||
6. 入口不能扩大执行空间、文件、PTY、网络或浏览器权限。
|
||||
7. 应用中心始终可达,点击向上展开无模态遮罩的锚定菜单,只列已启用应用,不使用打开历史或常驻过滤。菜单底部“管理应用”打开既有 Modal,关闭的可选应用在其中仍可搜索、配置和重新开启。
|
||||
8. 所有入口和新请求的功能判断读取同一份保存成功的权威设置;保存中的目标值不提前生效。
|
||||
|
||||
## 2. 入口模型
|
||||
|
||||
```ts
|
||||
type NavigationContribution =
|
||||
| { type: 'application'; id: 'device-sharing'; source: 'builtin'; enabled: true; pinned: false }
|
||||
| {
|
||||
type: 'system-entry'
|
||||
id: 'chat' | 'activity' | 'application-center' | 'settings'
|
||||
placement: 'primary-start' | 'primary-end' | 'footer'
|
||||
}
|
||||
| {
|
||||
type: 'application'
|
||||
id: 'knowledge'
|
||||
source: 'builtin'
|
||||
alwaysShown: true
|
||||
}
|
||||
| {
|
||||
type: 'application'
|
||||
id: 'magic-notes' | 'heartbeat' | 'local-inference'
|
||||
source: 'builtin'
|
||||
enabled: boolean
|
||||
pinned: boolean
|
||||
}
|
||||
| {
|
||||
type: 'system-tool'
|
||||
id: 'workspace' | 'terminal' | 'browser' | 'resources'
|
||||
workbarAppId: 'workspace' | 'terminal' | 'browser' | 'resources'
|
||||
}
|
||||
```
|
||||
|
||||
该模型是首期的逻辑投影,启用值来自各应用的唯一业务配置,常驻及顺序来自导航偏好。
|
||||
应用清单为魔法笔记、知识库、智能心跳、本机推理监控,打开无需安装。市场不加入本轮清单。
|
||||
|
||||
## 3. 主导航组合规则
|
||||
|
||||
主导航按以下顺序计算:
|
||||
|
||||
```text
|
||||
当前对话
|
||||
应用(按保存顺序;知识库始终显示,可选应用仅显示启用且常驻项)
|
||||
运行记录
|
||||
```
|
||||
|
||||
当前对话、运行记录和设置不参与应用排序。默认应用顺序为知识库、智能心跳、魔法笔记、本机推理监控;魔法笔记默认常驻,本机推理监控默认不常驻。过滤可选项不留下空白。
|
||||
常驻、取消常驻和排序按设备保存,重启后恢复。排序只发生在应用区,不移动固定系统入口。
|
||||
管理列表、应用菜单和主导航共用完整应用顺序,分别按搜索、启用、启用且常驻过滤;过滤不删除顺序记录,重新开启或常驻时按保存顺序恢复。
|
||||
每个管理行均可排序,包括关闭或未常驻的应用。上下移动以完整保存顺序中的相邻应用为目标,拖动使用同一移动规则。搜索时禁用拖动,箭头仍按完整顺序移动,保留未命中应用及其相对顺序。
|
||||
|
||||
### 3.1 应用可见性与业务状态
|
||||
|
||||
下表适用于智能心跳、魔法笔记和本机推理监控。知识库始终可用,管理 Modal 中提供“打开”和排序,保留原工作区实际配置,不增加启用、常驻开关或通用空设置页。心跳应用的默认值与执行条件见[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)。
|
||||
|
||||
| 常驻偏好 | 功能启用 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 是 | 是 | 主导航显示,可从主导航或应用中心打开 |
|
||||
| 否 | 是 | 主导航隐藏,仍可从应用中心打开,后台和数据保持原状 |
|
||||
| 是 | 否 | 主导航、应用菜单和业务入口隐藏,管理 Modal 保留设置,记住常驻及顺序 |
|
||||
| 否 | 否 | 主导航、应用菜单和业务入口隐藏,管理 Modal 保留设置,记住未常驻状态 |
|
||||
|
||||
管理 Modal 浏览和搜索包含关闭的可选应用,搜索不修改启用或常驻。轻量菜单仅显示已启用项,点击行先关闭菜单;本机推理监控打开独立 Modal,其余应用请求主内容区导航。管理 Modal 的“打开”和常驻入口遵守同一规则。打开已启用应用不自动常驻;
|
||||
取消当前应用的常驻不关闭当前页面或本机推理监控 Modal。关闭应用后不能从旧入口继续执行新的业务操作。
|
||||
停用时仍允许管理常驻偏好,界面说明重新开启后才会显示入口。
|
||||
|
||||
### 3.2 关闭应用与恢复
|
||||
|
||||
关闭与恢复适用于可选应用,知识库页面、会话控件及关联不受影响。智能心跳关闭后的调度、手动请求、侧栏与在途运行按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)处理,保留单条计划 enabled。
|
||||
|
||||
| 对象 | 关闭后的结果 | 重新开启后的结果 |
|
||||
| --- | --- | --- |
|
||||
| 当前已打开应用页或本机推理监控 Modal | 原位置显示“应用已关闭”的不可用状态;保留内容状态及草稿,不强制跳转。重新启用从应用中心对应行的设置进入,监控 Modal 需先关闭 | 恢复可用性;仍打开时恢复内容,已关闭或离开则不自动打开 |
|
||||
| 已在执行的请求及后台操作 | 沿用功能自身的取消、完成和清理生命周期;本次应用开关不新增取消策略 | 不自动恢复、中断后重跑或补发任务 |
|
||||
| 数据、常驻及顺序 | 保留原值,业务入口按启用状态过滤 | 按保存的常驻及顺序恢复入口,不把未常驻应用自动常驻 |
|
||||
|
||||
知识请求及单个知识库实例停用、配置修改或删除继续遵守知识库自身规则,没有应用级禁用判断。
|
||||
|
||||
### 3.3 魔法笔记设置迁移
|
||||
|
||||
魔法笔记设置迁入应用中心对应行的设置页;笔记页面和全局设置均不提供重复入口,
|
||||
编辑同一配置。启用、常驻、显示未完成待办数量分别控制功能、导航和徽标,不保留重复的
|
||||
“显示魔法笔记入口”开关。
|
||||
|
||||
未配置的魔法笔记、本机推理监控启用及魔法笔记相关布尔项默认开启;心跳应用默认关闭。魔法笔记和心跳默认常驻,本机推理监控默认不常驻;已启用应用仍可从应用菜单及管理 Modal 打开。已有保存的 `true` 和 `false` 均保留。
|
||||
AI 评论方式和评论形式继续为单选,沿用现有默认值“即时”和“长评 + 要点”,不能解释为
|
||||
全部模式同时启用。迁移保留既有单选值及业务数据。关闭数量显示只隐藏徽标,不修改待办。
|
||||
|
||||
## 4. 系统工具打开规则
|
||||
|
||||
### 4.1 通用流程
|
||||
|
||||
```text
|
||||
用户展开右侧工作栏
|
||||
-> 选择已有 Tab,或从“+”目录选择工具
|
||||
-> 已有 Tab 按实例 ID 激活;目录按单实例或多实例策略创建或激活
|
||||
-> 把焦点移入面板标题或首个主要控件
|
||||
```
|
||||
|
||||
若创建失败,工作栏保持展开并在对应能力位置显示错误;主页面和其他工作栏实例不被清空。
|
||||
|
||||
### 4.2 决策表
|
||||
|
||||
| 工具 | 选择已有 Tab | 从“+”目录选择 | 上下文不可用 |
|
||||
| --- | --- | --- | --- |
|
||||
| 文件 | 激活不可关闭单实例并保留跟随或固定状态 | 聚焦 `workspace`;仅迁移或布局修复时补回 | 跟随模式无有效项目目录或默认工作区时显示修复入口 |
|
||||
| 终端 | 激活选中的确切实例,保留执行空间和历史状态 | 每次创建绑定当前执行空间的额外实例 | 新建时无项目使用本机 Home;SSH 失败保留远端目标与诊断 |
|
||||
| 浏览器 | 激活选中的确切实例,保留绑定目标和页面 | 每次创建绑定当前 Conversation 的额外实例 | 新建时无 Conversation 创建待绑定空实例 |
|
||||
| 资源监控 | 激活本机 `resources` 单实例 | 已存在则聚焦,否则创建 | 部分指标不可用时显示原因并保留其余指标 |
|
||||
|
||||
选择已有 Tab 不按当前项目或 Conversation 查找替代实例,也不重绑目标。文件的跟随模式
|
||||
仍按工作栏自身规则更新上下文;固定目标失效时显示该目标的修复状态,不静默回退。
|
||||
终端 `closing`、`exited`、`interrupted`、`failed` Tab 保留原状态,选择它们不自动重建。
|
||||
终端和浏览器通过“+”新建时不按目标去重,不恢复旧快捷按钮的最近匹配实例复用行为。
|
||||
|
||||
### 4.3 选中与状态
|
||||
|
||||
工作栏按确切实例 ID 表达 Tab 选中状态,同类型其他实例不能同时显示为选中。“+”是普通
|
||||
按钮,不是 Tab。主侧栏和应用中心不显示工具选中状态或工具徽标。
|
||||
|
||||
徽标优先级为:等待用户处理 > 错误 > 运行中 > 无徽标。同一 Tab 只显示一个视觉徽标,
|
||||
可访问名称包含完整状态。
|
||||
|
||||
资源监控首期不在后台采样,因此不显示负载告警徽标。后台事件不自动展开工作栏或切换 Tab。
|
||||
|
||||
### 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. 设置保存规则
|
||||
|
||||
应用导航偏好与业务启停使用独立字段,通过同一应用设置保存链路提交。常驻、排序、启用和
|
||||
魔法笔记设置更新串行执行;前一请求确认前,所有入口中会修改这组设置的控件暂不可操作。
|
||||
每次只提交本次变更,Main 在最新已保存设置上合并并原子写入;顺序提交完整目标顺序。
|
||||
持久化成功后由 Store 发布完整权威快照,应用中心、主导航、当前应用页、会话和所有设置入口同步采用,工具配置写入也走同一通知链路。每次重开中心先刷新并锁定修改;较新的设置事件使在途旧读取及旧保存响应失效,不能覆盖新快照。
|
||||
|
||||
保存失败时:
|
||||
|
||||
- 所有界面保留最后一次确认的配置,包括启用、常驻、顺序、徽标和会话入口;不提前运行关闭或恢复副作用。
|
||||
- 发起操作的设置行或排序区域显示就地可重试错误,保留焦点及编辑上下文,不重复显示同一错误通知。
|
||||
- 若响应中断导致保存结果未知,先重读权威设置再开放修改;不能把本地旧值当成回滚成功,也不自动重发变更。
|
||||
|
||||
## 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-3、FR-4、FR-5、FR-11、FR-12;PRD 第 8 节 | US-B6、US-B7 |
|
||||
| 可选应用启用恢复、知识库固定可用 | FR-3、FR-4、FR-12;PRD 第 8 节补充要求 | US-B8 |
|
||||
| 魔法笔记迁移及共同配置 | FR-11、FR-12;PRD 第 8 节补充要求 | US-B9 |
|
||||
| 工具打开决策表 | FR-6 至 FR-10 | US-C1 至 US-C7 |
|
||||
| 资源采样状态 | FR-9 | US-C6 |
|
||||
| 无障碍与窄窗口 | FR-13 | US-D1、US-D2 |
|
||||
| 活动聚合、完成通知、查看清除与归档恢复 | FR-14 | US-E1、US-E2、US-E3、US-E4 |
|
||||
| 后续私有市场与 YAML | FR-15 | US-F1、US-F2 |
|
||||
| 服务状态、资源可用性、服务生命周期 | FR-16 | US-F3、US-F5;US-F4 入口已移除 |
|
||||
|
||||
稳定 ID 保留原编号。US-B6 为应用中心,US-B7 为顺序,US-B8 为启用联动,US-B9 为笔记迁移;
|
||||
US-F3 为服务概览,US-F4 为已移除的任务取消入口,US-F5 为服务生命周期。PRD 旧 FR-5 至 FR-10 和
|
||||
User Stories 已同步上述入口规则,工具实例行为沿用右侧工作栏契约。
|
||||
|
||||
## 7. 逻辑完整性评估
|
||||
|
||||
首期覆盖应用中心访问、启用与常驻组合、顺序恢复、默认值与旧值迁移、共同设置、保存失败、
|
||||
知识库固定入口、心跳应用启停、可选应用已打开页面恢复,以及右侧实例选择、本机 Home、托管 SSH、无会话、
|
||||
目录失效、资源部分可用与采样停止。工具内部取消、超时、连接恢复及业务后台生命周期由
|
||||
相应功能负责,本功能不复制状态机。底部入口打开轻量菜单,“管理应用”才进入与设置相同的整窗 Modal。FR-14 覆盖统一菜单的入口与范围、预览与进入、最近条数、状态筛选、通道创建限制及会话期恢复,规则见第 9 节。实现与验证的剩余范围见[实施进度](./progress.md)。
|
||||
|
||||
## 8. 后续产品决策
|
||||
|
||||
后续市场 URL、YAML 交换、ShareServer 目录与分发、插件注册及安装不进入首期。其协议、
|
||||
扩展身份与安装生命周期由市场功能另行确定;内置应用常驻及排序不依赖这些决策。
|
||||
|
||||
### 8.1 本机推理管理
|
||||
|
||||
FR-16 独立于市场排期,作为可常驻的管理 Modal。所有打开入口只改变 Modal 打开状态,不改变底层工作区、项目、会话或草稿;关闭时卸载 Modal,服务生命周期独立。US-F3 只有服务列表,展示名称、状态、引擎、模型及错误。共享服务只展示一次;资源按独立服务进程归属,CPU 预热与指标不可用分别给出原因,不填 `0`,不将宿主进程占用分摊给共享线程。采样与单位见[技术设计](./technical-design.md)。ASR 没有 Worker 就绪观测,显示按需运行且状态未知;活动请求不覆盖服务状态。OCR 状态来自 Renderer Worker,向量状态来自 Broker。
|
||||
|
||||
US-F4 的 Modal 单任务取消入口已移除。后端取消行为保留:Worker 确认或退出前仍计入活动任务和停止影响,即使调用方已收到取消或超时错误;任务先完成则保留真实完成结果,不通过终止共享服务模拟单任务取消。
|
||||
|
||||
| 服务管理方式 | 可提供操作 | 结果要求(US-F5) |
|
||||
| --- | --- | --- |
|
||||
| GoodBuddy 受管子进程 | 引擎已支持的启动、停止、重启 | 共用同一服务实例;操作成功由真实进程和服务就绪状态确认 |
|
||||
| 外部服务 | 在模型与连接设置中管理 | 不列入本机服务,不推断本机归属 |
|
||||
| 进程内模型 | 引擎实际支持的加载、释放 | 不显示虚构 PID 或进程启动、停止状态 |
|
||||
|
||||
停止、重启或释放均先打开紧凑的嵌套影响确认,列出任务和影响;无已登记活动任务时说明执行前再次核对。取消确认不改变运行状态。
|
||||
确认存在时禁止关闭底层管理 Modal;提交期间两层均不能关闭,重复操作被拒绝。未提交时 Escape 只关闭确认,确认关闭后才可用 Escape 关闭管理 Modal。焦点规则由 [UI 设计](./ui-design.md#91-本机推理管理)定义。
|
||||
实际操作失败关闭旧确认、显示错误并刷新快照;刷新期间或失败时禁用服务操作。再次操作重新取得当前任务清单并要求确认,避免重复提交旧清单。
|
||||
再次启动不重放已中断任务;关闭管理 Modal、关闭管理应用或取消常驻不停止服务,卸载模型文件为独立操作。
|
||||
当前向量服务允许确认影响后停止,并拒绝确认清单未包含的新任务;OCR 仅空闲时允许释放。
|
||||
ASR 按请求加载与释放,支持独立取消;TTS 尚无执行服务。具体适配边界以
|
||||
[技术清单](./technical-design.md#111-本机推理任务与执行服务)为准。活动请求仅在影响确认中展示,主列表不展示历史。应用退出清理沿用各引擎原有路径。
|
||||
|
||||
## 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 和普通未读标记本身不构成活动。完成通知规则如下:
|
||||
|
||||
- 本地运行收到成功 `done`,或当前活动运行从持久化快照确认成功完成时,记录会话完成通知。
|
||||
- 后台顶层 Task 只有在本次应用会话中已观察到该任务,随后状态转为 `completed` 时才记录通知;
|
||||
初次载入或首次出现的已完成历史任务只建立基线,子任务不单独产生通知。
|
||||
- 失败、取消不产生完成通知。实时待处理或运行中状态优先;开始新运行时清除旧完成通知,
|
||||
已观察 Task 转为 `running` 或 `waiting_approval` 时也清除对应会话的旧通知。
|
||||
- 通知只保存在 Renderer 内存,不跨重载或重启恢复,也不从普通未读状态推导。
|
||||
- 仅当目标会话实际成为当前聊天、设置未打开且文档未隐藏时清除通知;普通侧栏导航和活动菜单
|
||||
导航采用同一查看条件。已在查看的会话完成后不保留通知,恢复可见聊天时也清除通知。
|
||||
- 预览项目、查看菜单、设置覆盖下保留挂载的聊天、隐藏文档以及取消设置离开都不算查看。
|
||||
|
||||
没有实时状态且没有已完成未查看通知时才移除活动行。
|
||||
|
||||
统一菜单的分类、预览范围、状态筛选和项目搜索相互独立。项目名与活动摘要属于同一个按钮,点击任一行打开时分类为“全部项目”,预览范围为当前项目;点击菜单内“全部项目”分类将范围重置为全部。悬停或聚焦项目只更新预览范围,不导航、不读取会话详情。分类按全部项目、本地、远程、消息通道排列;搜索跨分类匹配名称、目录、主机名/地址及通道,搜索期间展开命中的主机组。远程能力关闭时隐藏 SSH 用户项目及其活动,消息通道仍可见。
|
||||
|
||||
左栏沿用本地、SSH Host、消息通道的项目顺序,主机支持折叠,不按活动数量排序。右栏合并内存会话摘要与活动行,按 Conversation ID 去重;摘要尚未到达的活动仍可显示。无项目归属的会话只在全部项目范围显示,不增加项目计数;已不存在或不可见项目的会话排除,通道项目只显示通道会话。
|
||||
|
||||
默认筛选依次列待处理、运行中、最近会话。前两组不限条数且不重复列入最近;组内排序时间取最新消息创建时间与本次应用会话内访问/状态变化时间的最大值,无消息时用会话更新时间参与比较,同时间按 ID 排序,不按置顶优先排序。最近组最多 10 条,已完成未查看会话归入最近组并标注状态;“已完成”筛选列出全部完成未查看会话,不受 10 条限制。
|
||||
|
||||
行内显示时间只取会话 `updatedAt`,不使用上述排序时间、访问时间或任务状态变化时间。只有活动而没有会话摘要时不显示时间,也不构造日期或读取详情补齐。流式输出可以更新行内时间,但不因此改变原有排序规则。
|
||||
|
||||
首次活动状态快照只建立基线,不更新最近时间;后续状态变化更新时间,唯独从已完成变为无活动的提醒清除不更新时间。实际打开会话仍记录访问时间;悬停预览不记录。访问与状态时间仅保留在当前 Store 内存中,不跨重载或重启恢复。
|
||||
|
||||
点击项目经离开检查进入聊天,优先恢复本次应用会话内为该项目记住的有效 Conversation ID,否则按最新消息时间从候选会话中选最近一条,无消息时取会话更新时间。普通项目无会话时创建;通道项目只选通道会话,无会话时显示空态。记忆不跨重载或重启保存。点击会话仍按确切 ID 导航;设置按钮独立,不触发项目切换。“新建会话”使用预览项目,全部项目范围使用当前项目;目标缺失或为消息通道时禁用。
|
||||
|
||||
全局无活动时第二行显示“暂无活动”,按钮仍可打开菜单并预览当前项目的最近会话或空态,也可在菜单内切换全部项目范围。活动归零不改变控件高度,也不关闭已打开的菜单。点击外部、焦点离开、Escape 或隐藏侧栏关闭菜单,后台活动不自动打开。查看预览不清除完成通知,实际进入可见聊天才按上述规则清除。
|
||||
|
||||
归档视图是左栏列表的临时模式,不新增顶部分类,不改变右栏预览范围。它只列已归档用户项目,保留本地/SSH Host 分组、搜索和远程能力过滤;归档行只有恢复操作,不作为可进入或可预览项目。恢复先同步会话摘要,再让项目回到活动项目集合,保持当前项目、会话和页面。成功后从归档列表移除;失败保留操作入口,可重复提交同一恢复动作。恢复不修改 Task 状态,不重放任务或启动远程连接。
|
||||
|
||||
右下角“进入项目”与点击普通项目行共用导航规则,目标为预览项目,全部项目范围回退到当前项目;目标不可用时禁用。归档视图中按钮仍对应右栏原预览,不能直接进入未恢复项目。
|
||||
@@ -0,0 +1,154 @@
|
||||
: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%); }
|
||||
.workspace { min-height: 0; }
|
||||
.workspace-header { flex: 0 0 auto; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
#page-label { overflow-wrap: anywhere; }
|
||||
.conversation { min-height: 0; overflow: auto; }
|
||||
.conversation.app-page { justify-content: flex-start; padding: 24px; max-width: 960px; gap: 12px; }
|
||||
.app-page h1 { font-size: 24px; }
|
||||
.model-list { width: 100%; }
|
||||
.model-service { padding: 16px 0; border-bottom: 1px solid var(--border-default); }
|
||||
.model-service p, .model-service details { color: var(--text-secondary); font-size: 12px; }
|
||||
.model-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
|
||||
.model-actions button, .inline-confirm button { border: 1px solid var(--border-default); }
|
||||
.inline-confirm { padding: 12px; margin: 8px 0; background: var(--accent-subtle); border: 1px solid var(--accent-selected); border-radius: var(--radius-control); }
|
||||
#market-preview { padding: 12px 0; border-bottom: 1px solid var(--border-default); }
|
||||
#market-preview label { display: block; margin: 8px 0; }
|
||||
#market-preview textarea, #market-preview select { width: 100%; padding: 8px; font: inherit; background: var(--surface-subtle); color: var(--text-primary); border: 1px solid var(--border-default); border-radius: var(--radius-control); }
|
||||
#market-preview textarea { resize: vertical; }
|
||||
#market-preview p, #import-preview { font-size: 12px; overflow-wrap: anywhere; }
|
||||
#market-preview summary, .model-service summary { cursor: pointer; }
|
||||
.note-segments, .center-switch, .tool-tabs { flex: 0 0 auto; }
|
||||
.center-app-info { min-width: 0; overflow-wrap: anywhere; }
|
||||
dialog, dialog::backdrop { -webkit-app-region: no-drag; }
|
||||
.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 .conversation { display: flex; overflow: visible; padding: 16px; }.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,92 @@
|
||||
<!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 data-knowledge-control><legend>知识范围</legend><label><input type="checkbox" data-knowledge="产品文档"> 产品文档</label><label><input type="checkbox" data-knowledge="团队知识"> 团队知识</label></fieldset>
|
||||
<label class="option-row" data-knowledge-control>检索方式<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="应用中心视图" hidden><button data-catalog="apps">我的应用</button><button data-catalog="store">未来私有市场</button></div><section id="market-preview" hidden>
|
||||
<label>私有市场 URL<input id="market-url" type="url" value="https://market.example.invalid" aria-describedby="market-status"></label><button id="market-fetch">模拟获取目录</button><button id="market-fail">模拟不可达</button><p id="market-status" role="status">未来预览,不发送网络请求;来源仅用于市场。</p>
|
||||
<details><summary>YAML 导入 / 导出预览(mock schema)</summary><p class="muted">仅支持下列扁平示例字段,非正式 Schema。支持 .yml / .yaml 的文本预览;不包含凭据或业务数据,不替代程序包或模型权重。</p><label>应用定义<textarea id="yaml-input" rows="9" spellcheck="false">schema: mock-v1
|
||||
id: reading
|
||||
name: 阅读空间
|
||||
version: 1.0
|
||||
entry: main
|
||||
dependencies: builtin:knowledge
|
||||
resources: bundled</textarea></label><button id="yaml-preview">预览导入</button><label>导出应用<select id="yaml-app"></select></label><button id="yaml-export">预览导出 YAML</button><pre id="yaml-output" tabindex="0" hidden></pre><div id="import-preview" hidden></div></details>
|
||||
</section><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,548 @@
|
||||
/* 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: 'models', name: '本机推理监控', icon: 'layers', detail: '语音、OCR、向量任务统一管理 · 计划预览', 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 / 私有市场预览', '未来实验:私有市场 URL、模拟目录获取与 YAML 交换。', '不联网、不写入文件;mock schema 不代表正式协议。']
|
||||
};
|
||||
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 = items.filter(item => item.builtin && item.id !== 'models').map(item => item.id);
|
||||
let enabledApps = new Set(items.filter(item => item.builtin).map(item => item.id));
|
||||
let currentPage = '当前对话';
|
||||
let settingsApp = 'notes';
|
||||
let marketReady = false;
|
||||
let importDraft = null;
|
||||
const definitions = new Map();
|
||||
const modelServices = [
|
||||
{ id: 'ocr', name: 'OCR 文字识别', purpose: '文档入库 #12 · 正在识别第 8 / 24 页', engine: 'PaddleOCR worker', management: '受管进程', running: true, usage: '420 MB', users: '知识库 / 文档入库任务 #12' },
|
||||
{ id: 'speech', name: '语音识别', purpose: '语音输入 #18 · 正在转写', engine: '本机语音识别引擎', management: '受管进程', running: true, usage: '310 MB', users: '当前会话 / 语音输入 #18' },
|
||||
{ id: 'tts', name: '语音合成', purpose: '回复朗读 #19 · 正在合成', engine: '本机语音合成引擎', management: '进程内', running: true, usage: '180 MB', users: '当前会话 / 回复朗读 #19' },
|
||||
{ id: 'embedding', name: '向量生成', purpose: '文档向量化 #7 · 已处理 32 / 80 个片段', engine: 'BGE-small / ONNX Runtime', management: '进程内', running: true, usage: '128 MB', users: '知识库 / 文档向量化任务 #7' }
|
||||
];
|
||||
let modelConfirmation = null;
|
||||
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 renderAppSettings() {
|
||||
const item = items.find(entry => entry.id === settingsApp);
|
||||
$('#app-settings').innerHTML = `<div class="note-setting-row"><div><strong>启用应用</strong><p>关闭后隐藏业务入口,保留常驻偏好、数据和后台状态。可在此重新开启。</p></div><button class="demo-switch" role="switch" aria-label="启用${item.name}" aria-checked="${enabledApps.has(item.id)}" data-app-toggle="enabled"></button></div>
|
||||
<div class="note-setting-row"><div><strong>常驻左侧菜单</strong><p>仅控制主导航捷径;应用关闭时记住此偏好。</p></div><button class="demo-switch" role="switch" aria-label="常驻左侧菜单" aria-checked="${residentApps.includes(item.id)}" data-app-toggle="pinned"></button></div>`;
|
||||
if (settingsApp === 'notes') $('#app-settings').innerHTML += `
|
||||
<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 showAppSettings(id = 'notes') {
|
||||
settingsApp = id;
|
||||
const item = items.find(entry => entry.id === id);
|
||||
if (!$('#catalog').open) { catalogMode = 'apps'; $('#search').value = ''; }
|
||||
settingsOpen = true;
|
||||
$('#catalog').dataset.variant = variant;
|
||||
$('#catalog-title').textContent = `${item.name}设置`;
|
||||
$('#catalog-description').textContent = '应用中心、应用页面和全局设置共用本次演示配置。';
|
||||
$('#back-to-apps').hidden = false; $('#app-settings').hidden = false;
|
||||
for (const selector of ['.center-switch', '#search', '#catalog-items', '#market-preview']) $(selector).hidden = true;
|
||||
renderAppSettings();
|
||||
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.filter(id => enabledApps.has(id) && (variant === 'scenes' || items.find(item => item.id === id).builtin)).map(id => {
|
||||
const index = residentApps.indexOf(id);
|
||||
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: '内置应用', cards: '笔记 · 知识 · 自动化 · 模型', launcher: 'Ctrl / ⌘ K', favorites: '管理常驻偏好', 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' ? marketReady && !item.builtin : installed.has(item.id) && (variant === 'scenes' || item.builtin)) && `${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 ? '内置应用' : '私有市场 · 示例'}${item.id === 'models' ? ' · 计划预览' : ''}${ready ? enabledApps.has(item.id) ? ' · 已启用' : ' · 已关闭' : ''}</p><p class="app-detail">${variant === 'cards' ? item.summary : item.detail}</p></div><div class="center-actions">${ready ? `${enabledApps.has(item.id) ? `<button data-open="${item.id}">打开</button>` : ''}<button data-app-settings="${item.id}">${enabledApps.has(item.id) ? '设置' : '设置 / 重新开启'}</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">${catalogMode === 'store' && !marketReady ? '请先模拟获取私有市场目录。' : '没有匹配的应用,请换个关键词。'}</p>`;
|
||||
$('#yaml-app').innerHTML = items.filter(item => installed.has(item.id)).map(item => `<option value="${item.id}">${item.name}</option>`).join('');
|
||||
document.querySelectorAll('.center-switch button').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.catalog === catalogMode)));
|
||||
}
|
||||
function openCatalog(mode = 'apps') {
|
||||
importDraft = null; $('#import-preview').hidden = true;
|
||||
if (variant !== 'scenes') 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;
|
||||
$('.center-switch').hidden = variant !== 'scenes';
|
||||
$('#market-preview').hidden = mode !== 'store';
|
||||
catalogMode = mode; $('#catalog').dataset.variant = variant;
|
||||
$('#catalog-title').textContent = mode === 'store' ? '私有市场 · 未来预览' : '应用中心';
|
||||
$('#catalog-description').textContent = mode === 'store' ? '目录和安装均为模拟,未接入 ShareServer。导入不会启动模型或任务。' : '内置应用无需安装;启用与常驻分别设置。系统工具请使用右侧工作栏。';
|
||||
$('#search').value = ''; renderCatalog();
|
||||
if (!$('#catalog').open) $('#catalog').showModal();
|
||||
$('#search').focus();
|
||||
}
|
||||
function openPage(name, keepSettings = false) {
|
||||
currentPage = name;
|
||||
modelConfirmation = null;
|
||||
if ($('#catalog').open && !keepSettings) $('#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);
|
||||
const app = items.find(item => item.name === name);
|
||||
$('.composer-area').hidden = Boolean(app) || name === '设置' || name === '运行记录';
|
||||
$('#page-content').classList.toggle('app-page', $('.composer-area').hidden);
|
||||
for (const panel of ['#composer-options', '#context-details']) if ($(panel).matches(':popover-open')) $(panel).hidePopover();
|
||||
$('#page-actions').replaceChildren();
|
||||
if (app && !enabledApps.has(app.id)) {
|
||||
description.textContent = '此应用已关闭,当前页面不可用。已有数据和选择保留;此操作不停止后台任务或模型服务。';
|
||||
renderApps(); return;
|
||||
}
|
||||
if (app?.id === 'models') renderModels();
|
||||
if (name === '魔法笔记' || name === '设置') {
|
||||
const summary = document.createElement('p'); summary.id = 'note-settings-summary'; summary.className = 'muted'; $('#page-content').append(summary);
|
||||
renderAppSettings();
|
||||
}
|
||||
document.querySelectorAll('.navigation button').forEach(button => button.classList.toggle('selected', button.dataset.page === name));
|
||||
renderApps(); if (!keepSettings) 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();
|
||||
if ($('#catalog').open) openCatalog();
|
||||
const pageApp = items.find(item => item.name === currentPage);
|
||||
if (name !== 'scenes' && pageApp && !pageApp.builtin) openPage('当前对话');
|
||||
}
|
||||
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; });
|
||||
}
|
||||
function renderModels() {
|
||||
$('#page-content').innerHTML = `<h1>本机推理监控</h1><p class="muted">统一查看语音、OCR、向量等本机推理任务与执行服务。任务进度、引擎及占用均为模拟数据。</p><h2>推理任务与服务</h2><p class="muted">下方操作作用于执行服务;启动或加载服务不会自动恢复已中断的任务。</p><div class="model-list">${modelServices.map(service => {
|
||||
const external = service.management === '外部管理';
|
||||
const active = service.management === '进程内' ? '已加载' : '运行中';
|
||||
const actions = external ? ['config'] : service.management === '进程内' ? [service.running ? 'release' : 'load'] : service.running ? ['stop', 'restart'] : ['start'];
|
||||
return `<article class="model-service"><strong>${service.name}</strong><p>${service.interrupted ? '任务已中断,需由原应用重新发起' : service.purpose}</p><p>执行服务:${service.running ? active : '未运行'} · 内存:${service.running ? service.usage : '已释放(模拟)'}</p><p>使用方:${service.interrupted ? '暂无活动使用方' : service.users}</p><div class="model-actions">${actions.map(action => `<button data-model="${service.id}" data-model-action="${action}">${modelActionLabels[action]}</button>`).join('')}</div>${modelConfirmation?.id === service.id ? `<div class="inline-confirm" role="group" aria-label="推理服务操作确认"><p>${modelActionLabels[modelConfirmation.action]} ${service.name} 将影响 ${service.interrupted ? '该执行服务,当前没有活动任务' : service.users}。仅模拟状态变化。</p><button data-model-cancel>取消</button><button data-model-confirm>确认${modelActionLabels[modelConfirmation.action]}</button></div>` : ''}<details><summary>引擎与错误详情</summary><p>${service.engine} · ${service.management}</p><p>最近错误:无(示例)。操作仅更新本页内存状态。</p></details></article>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
const modelActionLabels = { start: '启动', stop: '停止', restart: '重启', load: '加载', release: '释放', config: '连接配置' };
|
||||
function applyModelAction(id, action) {
|
||||
const service = modelServices.find(entry => entry.id === id);
|
||||
service.running = !['stop', 'release'].includes(action);
|
||||
if (['stop', 'release', 'restart'].includes(action)) service.interrupted = true;
|
||||
modelConfirmation = null;
|
||||
renderModels();
|
||||
$(`[data-model="${id}"]`)?.focus();
|
||||
notify(`已模拟${modelActionLabels[action]} ${service.name}`);
|
||||
}
|
||||
function previewImport() {
|
||||
importDraft = null;
|
||||
const preview = $('#import-preview'); preview.hidden = false; preview.replaceChildren();
|
||||
const values = {};
|
||||
const fields = ['schema', 'id', 'name', 'version', 'entry', 'dependencies', 'resources'];
|
||||
for (const line of $('#yaml-input').value.split(/\r?\n/).filter(line => line.trim() && !line.trim().startsWith('#'))) {
|
||||
const match = line.match(/^([a-z]+):\s*(.+)$/);
|
||||
if (!match || !fields.includes(match[1]) || Object.hasOwn(values, match[1])) { preview.textContent = '仅支持示例中的七个扁平字段,不接受重复或未知字段;请修正后重新预览。'; return; }
|
||||
values[match[1]] = match[2].trim();
|
||||
}
|
||||
if (fields.some(field => !values[field]) || values.schema !== 'mock-v1' || !/^[a-z][a-z0-9-]*$/.test(values.id) || values.entry !== 'main') { preview.textContent = '请填写完整 mock-v1 示例,id 使用小写字母、数字或连字符,entry 为 main。'; return; }
|
||||
const existing = items.find(item => item.id === values.id);
|
||||
const dependencies = values.dependencies === 'none' ? [] : values.dependencies.split(',').map(value => value.trim());
|
||||
const missing = dependencies.filter(value => !value.startsWith('builtin:') || !installed.has(value.slice(8)) || !enabledApps.has(value.slice(8)));
|
||||
const blocked = existing?.builtin || missing.length || values.resources !== 'bundled';
|
||||
const conflict = installed.has(values.id);
|
||||
const text = document.createElement('p');
|
||||
text.textContent = `${values.name} · ${values.id} · v${values.version} · 主内容区入口。依赖:${values.dependencies}。${missing.length ? `缺失或关闭:${missing.join('、')}。` : '依赖已具备(模拟)。'}资源:${values.resources}。${values.resources !== 'bundled' ? '需补充外部资源,不能安装为可用。' : ''}${existing?.builtin ? '与内置应用冲突,不允许覆盖。' : conflict ? '同标识冲突:需显式更新定义,保留启用与常驻偏好。' : '没有同标识冲突。'}`;
|
||||
preview.append(text);
|
||||
const cancel = document.createElement('button'); cancel.textContent = '取消'; cancel.onclick = () => { importDraft = null; preview.hidden = true; $('#yaml-preview').focus(); }; preview.append(cancel);
|
||||
if (!blocked) {
|
||||
importDraft = values;
|
||||
const confirm = document.createElement('button'); confirm.id = 'import-confirm'; confirm.textContent = conflict ? '确认模拟更新定义' : '确认模拟安装';
|
||||
confirm.onclick = () => {
|
||||
if (!importDraft) return;
|
||||
const draft = importDraft;
|
||||
const item = items.find(entry => entry.id === draft.id);
|
||||
if (!item) items.push({ id: draft.id, name: draft.id, icon: 'layers', detail: 'YAML 导入 · 未来示例', summary: '导入的应用定义预览', builtin: false });
|
||||
// Imported text stays in textContent/textarea; catalog markup uses the validated identifier.
|
||||
definitions.set(draft.id, { ...draft });
|
||||
if (!installed.has(draft.id)) { installed.add(draft.id); enabledApps.add(draft.id); }
|
||||
importDraft = null; preview.hidden = true; renderCatalog(); renderApps(); $('#yaml-preview').focus(); notify('已模拟安装 / 更新定义,未启动模型或任务');
|
||||
};
|
||||
preview.append(confirm);
|
||||
}
|
||||
}
|
||||
$('#market-fetch').onclick = () => {
|
||||
const valid = /^https?:\/\/[^\s/]+(?:\/[^\s]*)?$/.test($('#market-url').value.trim());
|
||||
marketReady = valid;
|
||||
$('#market-status').textContent = valid ? `已模拟获取目录:${$('#market-url').value.trim()},无网络请求。` : '请输入 http(s) 市场 URL 后重试。';
|
||||
renderCatalog();
|
||||
};
|
||||
$('#market-fail').onclick = () => { marketReady = false; $('#market-status').textContent = '模拟来源不可达,请点击模拟获取目录重试。未切换公共市场,已安装应用仍可使用。'; renderCatalog(); };
|
||||
$('#market-url').oninput = () => { marketReady = false; $('#market-status').textContent = '来源已修改,请重新模拟获取目录。'; renderCatalog(); };
|
||||
$('#yaml-preview').onclick = previewImport;
|
||||
$('#yaml-input').oninput = () => { importDraft = null; $('#import-preview').hidden = true; };
|
||||
$('#yaml-export').onclick = () => {
|
||||
const item = items.find(entry => entry.id === $('#yaml-app').value);
|
||||
const definition = definitions.get(item.id) || { schema: 'mock-v1', id: item.id, name: item.name, version: '1.0', entry: 'main', dependencies: 'none', resources: 'bundled' };
|
||||
$('#yaml-output').hidden = false;
|
||||
$('#yaml-output').textContent = '# Mock schema only; no credentials or business data\n' + Object.entries(definition).map(([key, value]) => `${key}: ${value}`).join('\n');
|
||||
};
|
||||
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 (data.appSettings) showAppSettings(data.appSettings);
|
||||
if (data.appToggle) {
|
||||
const id = settingsApp;
|
||||
if (data.appToggle === 'enabled') enabledApps.has(id) ? enabledApps.delete(id) : enabledApps.add(id);
|
||||
else residentApps = residentApps.includes(id) ? residentApps.filter(entry => entry !== id) : [...residentApps, id];
|
||||
renderApps(); renderComposer();
|
||||
if (currentPage === items.find(item => item.id === id).name) {
|
||||
openPage(currentPage, true);
|
||||
}
|
||||
renderAppSettings(); $(`[data-app-toggle="${data.appToggle}"]`).focus();
|
||||
}
|
||||
if (data.modelAction) {
|
||||
if (data.modelAction === 'config') {
|
||||
const row = button.closest('.model-service');
|
||||
let config = row.querySelector('.model-config');
|
||||
if (!config) { config = document.createElement('p'); config.className = 'model-config'; config.textContent = '连接配置预览:Ollama · http://localhost:11434 · 外部管理。仅展示示例,不连接或保存配置。'; row.append(config); }
|
||||
} else {
|
||||
const service = modelServices.find(entry => entry.id === data.model);
|
||||
if (service.running && service.users) { modelConfirmation = { id: data.model, action: data.modelAction }; renderModels(); $('[data-model-cancel]').focus(); }
|
||||
else applyModelAction(data.model, data.modelAction);
|
||||
}
|
||||
}
|
||||
if (button.hasAttribute('data-model-cancel')) { const id = modelConfirmation.id; modelConfirmation = null; renderModels(); $(`[data-model="${id}"]`).focus(); }
|
||||
if (button.hasAttribute('data-model-confirm') && modelConfirmation) applyModelAction(modelConfirmation.id, modelConfirmation.action);
|
||||
if (data.noteToggle || data.noteMode || data.noteFormat) {
|
||||
if (data.noteToggle === 'showCount') noteSettings.showCount = !noteSettings.showCount;
|
||||
if (data.noteMode) noteSettings.mode = data.noteMode;
|
||||
if (data.noteFormat) noteSettings.format = data.noteFormat;
|
||||
renderApps(); renderAppSettings();
|
||||
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) { importDraft = null; $('#import-preview').hidden = true; installed.add(id); enabledApps.add(id); notify('已模拟安装,未启动任务或模型'); }
|
||||
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-app-settings="${settingsApp}"]`) || $('#search')).focus(); };
|
||||
$('#app-settings').addEventListener('keydown', event => {
|
||||
const group = event.target.closest('.note-segments');
|
||||
if (!group || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const buttons = [...group.querySelectorAll('button')];
|
||||
const index = buttons.indexOf(event.target);
|
||||
buttons[event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length].click();
|
||||
});
|
||||
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 = [...installed].filter(id => id !== 'models'); enabledApps = new Set(installed); Object.assign(noteSettings, { showCount: true, mode: 'immediate', format: 'combined' }); editingApps = false; definitions.clear(); importDraft = null; $('#import-preview').hidden = true; renderApps(); renderComposer(); openPage('当前对话'); 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-control]').forEach(element => { element.hidden = !enabledApps.has('knowledge'); });
|
||||
if (enabledApps.has('knowledge')) {
|
||||
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,284 @@
|
||||
# 应用导航与系统工具入口 PRD
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 应用中心与本机推理监控已接入源码,整体验收待完成 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 关联设计 | [统一界面设计系统](../../../UI-DESIGN.md)、[助手工作栏 PRD](../assistant-workbar/prd.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
本版已同步应用中心方案,分期见[第 8 节](#8-分期)。左下角用于访问和管理应用,系统工具保留在右侧工作栏。
|
||||
|
||||
原主侧栏把“当前对话”“魔法笔记”“知识库”“智能心跳”和“运行记录”放在同一导航组,
|
||||
底部显示“GB 本地工作区”和平台架构。当前已将底部改为应用中心及独立设置入口,文件、终端
|
||||
和浏览器继续从右侧助手工作栏打开。
|
||||
|
||||
后续插件可能提供完整页面,也可能只提供辅助面板或命令。若不先区分系统入口、应用和工具,
|
||||
插件接入后会同时争用主导航、侧栏底部和助手工作栏,用户也无法判断入口会切换页面还是打开
|
||||
辅助面板。
|
||||
|
||||
## 2. 产品定义
|
||||
|
||||
### 2.1 入口分类
|
||||
|
||||
| 类型 | 首期成员 | 默认位置 | 用户控制范围 |
|
||||
| --- | --- | --- | --- |
|
||||
| 系统入口 | 当前对话、运行记录、设置 | 主导航固定位置或侧栏底部 | 不可隐藏、卸载或改变类型 |
|
||||
| 始终显示的内置应用 | 知识库 | 主导航应用区及应用中心 | 始终启用并常驻,可排序,不提供启用或常驻开关;保留工作区实际配置 |
|
||||
| 可选内置应用 | 智能心跳、魔法笔记、本机推理监控 | 应用中心及主导航常驻区 | 独立控制启用、常驻与排序;保留数据 |
|
||||
| 系统工具 | 文件浏览器、终端、浏览器、资源监控 | 右侧助手工作栏 Tab 和“+”目录 | 自带且不可卸载,按工作栏规则管理实例 |
|
||||
|
||||
运行记录是系统审计页面,不属于工具。文件浏览器、终端、浏览器和资源监控是系统工具;它们
|
||||
的入口与实际界面均由右侧助手工作栏承载,不列入应用中心。
|
||||
|
||||
### 2.2 后续插件类型
|
||||
|
||||
插件市场开放后,插件可以贡献“插件应用”或“插件工具”。插件不能注册、覆盖或替换系统入口,
|
||||
也不能冒充系统工具。内置应用继续标记为“内置”,不因采用统一注册协议而显示成待安装商品。
|
||||
|
||||
## 3. 目标
|
||||
|
||||
- 用户从侧栏底部进入应用中心,查找、打开和配置内置应用。
|
||||
- 主导航承载应用入口,本机推理监控打开 Modal,其余内置应用打开完整页面;辅助工具由工作栏承载。
|
||||
- 运行记录始终可发现,不受应用显示设置和插件状态影响。
|
||||
- 五个内置应用均支持排序;智能心跳、魔法笔记和本机推理监控可独立设置启用与常驻,知识库始终显示,设备共享在测试阶段默认关闭,可启用但不常驻侧栏。未常驻的已启用应用仍可从应用菜单打开。
|
||||
- 为插件应用和插件工具保留明确的入口契约,不提前建设插件安装能力。
|
||||
|
||||
## 4. 非目标
|
||||
|
||||
- 首期不提供应用商店页面、商店导航、推荐位或安装按钮,也不提供第三方插件搜索、下载、
|
||||
安装、更新、签名验证或卸载。内置应用的浏览、搜索与打开属于应用中心,不依赖商店。
|
||||
- 不把文件树、终端或浏览器重新实现到左侧栏。
|
||||
- 不改变助手工作栏的面板实例、停靠、执行空间和安全边界。
|
||||
- 不允许隐藏运行记录、当前对话或设置。
|
||||
- 不因隐藏可选应用而删除业务数据。
|
||||
- 不把“显示在侧边栏”解释为停止智能心跳计划或后台任务。
|
||||
- 不提供任意本机程序启动器。
|
||||
- 首期资源监控不采集远程主机、GPU、显存或网络吞吐,不提供后台告警和历史持久化。
|
||||
|
||||
## 5. 功能要求
|
||||
|
||||
### FR-1 入口分类
|
||||
|
||||
系统必须为每个可见入口声明稳定类型:`system-entry`、`application` 或 `system-tool`。界面位置、
|
||||
设置项和点击结果均以该类型为准,不根据当前页面临时改变类型。
|
||||
|
||||
### FR-2 固定系统入口
|
||||
|
||||
“当前对话”固定在主导航首位;“运行记录”固定在应用区之后;“设置”固定在侧栏底部。
|
||||
这些入口不出现在应用显隐、工具固定或插件管理设置中。
|
||||
|
||||
### FR-3 内置应用区
|
||||
|
||||
主导航在当前对话之后按共享保存顺序显示应用,知识库始终显示,可选应用仅显示已启用且常驻项;运行记录位于应用区之后。五个应用均可排序。本机推理监控入口打开独立 Modal,其余应用打开对应一级页面,保留
|
||||
现有徽标、范围和页面状态。管理 Modal 列出全部内置应用,知识库只提供“打开”和排序。
|
||||
|
||||
### FR-4 隐藏应用
|
||||
|
||||
取消常驻只移除主导航捷径,不删除数据、不修改启用状态,也不终止正在进行的操作。
|
||||
关闭可选应用则同步移除业务入口,保留常驻偏好;知识库不受这些偏好影响。心跳关闭后的执行边界见[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)。
|
||||
配置保存成功后各入口使用同一已确认状态,失败时保持原状态并允许重试。
|
||||
|
||||
### FR-5 应用中心入口
|
||||
|
||||
侧栏底部提供“应用”(英文 `Apps`)和独立“设置”入口,两个按钮均显示文字。点击“应用”向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理监控打开 Modal,其余应用在主内容区打开页面。
|
||||
菜单底部“管理应用”关闭菜单并打开既有管理 Modal,在其中浏览、搜索全部内置应用,为已关闭的可选应用提供设置与重新开启入口。无搜索结果时显示空状态并可清除搜索。
|
||||
首期不展示商店、安装、推荐或系统工具清单。入口具有可访问名称,Escape 关闭后恢复触发位置的焦点。
|
||||
|
||||
### FR-6 文件浏览器入口
|
||||
|
||||
在右侧选择“工作区”Tab 时聚焦默认存在且不可关闭的单实例。该实例保持
|
||||
“跟随当前上下文”或“固定到指定对象”状态;迁移或布局修复发现实例缺失时补回,并默认跟随
|
||||
当前项目。没有项目时使用已配置的默认工作区。跟随上下文所需的路径不可用时,面板显示修复
|
||||
入口;已有有效固定目标不受当前项目和默认工作区状态影响。固定目标失效时修复该目标,不静默回退到其他目录。
|
||||
|
||||
### FR-7 终端入口
|
||||
|
||||
选择已有终端 Tab 仅激活该实例,保持目标、会话和历史状态。通过工作栏“+”选择终端时
|
||||
创建绑定当前执行空间的新实例,不按目标复用已有实例;无项目时使用本机 Home。
|
||||
远程项目的新实例绑定其托管 SSH 执行空间和权威目录。创建失败保留当前主页面与其他实例。
|
||||
|
||||
### FR-8 浏览器入口
|
||||
|
||||
选择已有浏览器 Tab 保留其绑定目标、页面和导航状态;通过工作栏“+”选择浏览器时创建新实例,
|
||||
有活动 Conversation 时绑定该会话,否则创建待绑定空实例。同一 Conversation 的实例共享隔离
|
||||
登录上下文,页面与导航状态独立。入口不打开系统默认浏览器,也不创建旁路 WebView。
|
||||
|
||||
### FR-9 资源监控入口
|
||||
|
||||
通过右侧工作栏“+”打开或聚焦单实例的本机“资源监控”,已有面板直接选择其 Tab。面板显示
|
||||
整机 CPU 使用率、内存使用量与总量、磁盘读写速率,以及 GoodBuddy 桌面进程的 CPU、内存
|
||||
和进程数摘要。指标不可用时显示具体项目和原因,不能显示为 `0`。
|
||||
|
||||
资源面板处于可见活动状态、工作栏已展开且应用窗口未隐藏或最小化时,每 2 秒采样一次,在
|
||||
Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止采样;首期不在后台持续监控,
|
||||
也不据此产生侧栏告警。首次采样及停止后恢复的首次采样只建立 CPU 和磁盘 IO 速率基线,
|
||||
不显示伪造的 `0` 值。
|
||||
|
||||
### FR-10 工作栏入口状态
|
||||
|
||||
工具面板已激活时,对应 Tab 显示选中状态;面板存在但未激活时保持普通状态。运行中、错误或
|
||||
等待用户处理可以显示徽标,但必须有可访问文字,且不得自动抢占当前面板。
|
||||
|
||||
### FR-11 应用导航设置
|
||||
|
||||
管理 Modal 的五个应用行均提供上下移动和拖动排序,不受启用或常驻状态限制;管理列表、菜单和侧栏共用一份顺序。箭头边界按完整顺序计算,搜索过滤不丢失隐藏项。智能心跳、魔法笔记和本机推理监控另提供应用设置、常驻管理;知识库提供“打开”和排序,不添加通用空设置页或开关。设备共享提供启用设置、打开和排序,不提供常驻。原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存,
|
||||
修改即时提交;请求期间串行处理并禁用会修改同一配置的控件,失败就地反馈。
|
||||
魔法笔记设置统一从应用中心对应行进入,笔记页与全局设置不再提供重复入口。持久化成功后的设置变更同步覆盖工具配置写入;每次重开中心先刷新并锁定修改,较新的确认快照优先于迟到的读取结果。
|
||||
|
||||
### FR-12 偏好持久化
|
||||
|
||||
五个应用的顺序及可选应用启用、常驻按设备持久化,重启后恢复。默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享。心跳和测试阶段的设备共享默认关闭,其余应用默认启用;心跳和魔法笔记默认常驻,本机推理监控默认不常驻,设备共享不提供常驻开关;
|
||||
魔法笔记待办数量默认显示,评论方式与形式沿用现有默认值。迁移保留已保存的启用、常驻和排序选择,
|
||||
旧魔法笔记入口开关绑定业务启用值,迁为“启用应用”并保留原值;常驻使用独立配置,
|
||||
只有已发布的独立显隐偏好才映射到常驻,不能由常驻推导业务启停。旧可选应用顺序补入知识库、智能心跳默认位置并保留已有相对顺序,具体补齐规则见[技术设计](./technical-design.md#43-持久化与-ipc)。`heartbeatEnabled` 的缺失值与保存值按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)处理;不读取未发布草案中的 `knowledgeEnabled`。
|
||||
|
||||
### FR-13 窄窗口行为
|
||||
|
||||
窄窗口下主侧栏仍按现有抽屉方式显示,应用中心和设置保持可操作且不重叠。应用列表可滚动,
|
||||
搜索、设置及键盘排序不依赖拖拽;工具沿用右侧工作栏响应式规则,不搬到左侧。
|
||||
|
||||
### FR-14 项目活动汇总
|
||||
|
||||
用户在任一项目中都能查看跨项目的待处理、运行中与已完成未查看会话,并从活动列表直接进入所选会话。
|
||||
左上角使用一个固定两行的按钮:第一行显示项目名,第二行显示全局活动摘要,无活动时显示“暂无活动”,按钮高度不随活动变化。点击任一行打开同一个锚定工作空间菜单,默认预览当前项目,全部项目范围从菜单内切换;无活动时仍可查看最近会话。
|
||||
左栏依次提供“全部项目、本地、远程、消息通道”分类,远程能力关闭时隐藏远程分类及其项目;支持跨分类搜索、远程主机折叠及独立项目设置。项目行按名称、活动数量、目录分行显示,无活动时省略数量行,项目顺序不随活动数量变化。
|
||||
悬停或键盘聚焦项目只预览右栏,点击项目直接进入,优先恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目无会话时创建,消息通道无会话时保持空态。点击右栏会话进入确切会话。
|
||||
右栏默认依次列待处理、运行中、最近会话,最近会话最多 10 条;已完成未查看在行内标注。“全部、待处理、运行中、已完成”筛选与“新建会话”同排,新建使用预览项目,全部项目范围使用当前项目,消息通道禁用手动新建。左栏底部“新建项目”进入真实项目创建流程。
|
||||
|
||||
新建本地项目必须填写名称并选择根目录,空目录或纯空白目录不能创建。此要求仅适用于创建;历史空目录项目仍可编辑和保存,内置默认项目与消息通道项目的初始化规则保持不变。
|
||||
活动计数、待处理、运行中及已完成筛选不受最近 10 条限制;菜单不继承侧栏会话搜索条件。计数与排除规则见
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
右栏会话显示与侧栏一致的实际更新时间;缺少会话摘要的任务活动不显示时间。菜单从完整入口下方展开,短窗口内缩小高度并保持内部可滚动,入口不被菜单覆盖。
|
||||
|
||||
左栏底部“新建项目”旁提供低强调的“已归档”入口,支持搜索和逐项恢复。恢复保留项目数据与原本地/远程分类,不自动进入项目;通道项目及关闭远程能力时的 SSH 项目不提供恢复操作。右下角固定“进入项目”按钮,进入预览项目;全部项目范围使用当前项目,与“新建会话”的目标规则一致。进入仍经过未保存修改检查。
|
||||
|
||||
验收覆盖跨项目后台运行、同会话多任务去重、提问与审批优先、较早的仍在运行或等待处理的
|
||||
会话可见,以及从带搜索条件的页面或有未保存修改的设置页跳转。成功完成后保留“已完成”
|
||||
通知,直到用户实际查看该会话;从活动菜单或普通侧栏进入均可清除。设置覆盖下的聊天及
|
||||
隐藏文档不算查看。完成通知仅保留在当前应用会话内,初始任务历史不补发通知,失败和取消
|
||||
不产生完成通知;普通未读标记本身不构成活动。
|
||||
|
||||
## 6. 权限与安全
|
||||
|
||||
- 快捷入口不授予新的文件、命令、网络或浏览器权限。
|
||||
- 系统工具继续使用 Main、Preload、执行空间 Grant 和既有能力范围,不恢复产品工作模式或一般工具审批。
|
||||
- Renderer 不因新增入口获得任意文件系统、PTY、进程或 Electron API。
|
||||
- 资源指标由 Main 采集并以有界结构返回;Renderer 不接收进程命令行、环境变量或任意 PID。
|
||||
- 资源样本只保存在当前 Renderer 内存中,不写入运行记录、SQLite 或遥测服务。
|
||||
- 插件贡献类型只是界面与导航声明,不能代替权限清单、用户授权和运行隔离。
|
||||
- 运行记录继续记录由系统工具触发的受审计操作。
|
||||
|
||||
## 7. 产品验收
|
||||
|
||||
- 主导航能明确区分固定系统入口和可配置内置应用。
|
||||
- 运行记录在所有应用显隐组合下保持可见并可打开。
|
||||
- 左下角不再用“本地工作区”与系统架构占据主要空间。
|
||||
- 应用菜单只列已启用应用,点击行关闭菜单;本机推理监控打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。五个应用均可排序,知识库始终可用,四个可选应用可管理启用,其中设备共享不提供常驻,首期无商店入口。
|
||||
- 系统工具仅从右侧 Tab 与“+”访问;选择 Tab 不创建或改绑实例,“+”可显式创建额外浏览器或终端。
|
||||
- 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、
|
||||
最小化后停止请求新样本。
|
||||
- CPU、内存、磁盘 IO 或 GoodBuddy 指标不可用时,页面区分部分可用与整体失败。
|
||||
- 取消常驻不删除数据、不停止后台计划;关闭应用按统一规则联动业务入口,保存失败时各处保持原状。
|
||||
- 魔法笔记设置迁移保留已有选择,应用中心与笔记页共用同一配置。
|
||||
- 键盘、屏幕阅读器、浅色主题、深色主题和窄窗口均可完成相同操作。
|
||||
|
||||
## 8. 分期
|
||||
|
||||
### 首期
|
||||
|
||||
- 侧栏最底部应用中心打开向上锚定的轻量菜单,无模态遮罩;底部“管理应用”进入与设置相同的整窗 Modal 和可搜索应用列表,设置按钮保持独立。
|
||||
- 应用中心提供魔法笔记、知识库、智能心跳及本轮追加的本机推理监控入口,不要求安装。本机推理监控 Modal 与服务单独实现和验证。
|
||||
- 五个应用共享排序;支持智能心跳、魔法笔记、本机推理监控常驻主导航及取消常驻,知识库始终显示。设备共享仅从应用菜单和管理 Modal 进入,业务定义见[设备共享](../device-sharing/README.md)。已启用未常驻应用仍可从菜单打开,取消常驻不删除数据或停止后台任务。
|
||||
- 通用应用设置统一从应用中心对应行进入;应用页面保留实际业务配置,本机推理监控保留“模型与连接设置”。
|
||||
魔法笔记优先接入待办数量、AI 评论方式和评论形式,入口显示与常驻控制统一,避免重复开关。
|
||||
- 文件、终端、浏览器保留在右侧工作栏 Tab,不在左下角复制入口,也不纳入应用中心。
|
||||
- 原计划的本机资源监控仍作为系统工具交付项,在右侧增加单实例面板;与应用中心分别实现和验证。
|
||||
- 保持运行记录为固定系统入口。
|
||||
|
||||
首期验收需覆盖:无商店入口时可从菜单访问已启用应用,并从管理 Modal 恢复关闭的可选应用;取消常驻后仍可重新打开和配置;
|
||||
不同设置入口状态一致;常驻排序重启后保留;右侧工具切换不改变当前应用页面。
|
||||
本节是范围调整,Demo 交互通过不代表正式功能已经实现。
|
||||
|
||||
### 补充要求:魔法笔记迁移与入口联动
|
||||
|
||||
- 将现有魔法笔记设置迁入应用中心对应行的设置界面,移除笔记页面内的重复入口。
|
||||
魔法笔记可常驻主导航;移除原全局设置入口,保留已有配置值和业务数据。
|
||||
- 魔法笔记相关布尔开关默认全部开启,包括入口显示(常驻)和未完成待办数量显示。
|
||||
AI 评论方式、评论形式是单选项,沿用现有默认值,不把“全部开启”解释为同时选中多个模式。
|
||||
新默认值适用于未配置状态,迁移时保留用户已明确保存的选择。
|
||||
- 知识库侧栏入口始终存在且可调整位置;会话知识范围、检索方式、标签及请求行为继续遵守知识库自身规则。心跳入口按启用和常驻显示,单条计划 enabled 仍由心跳页面管理。
|
||||
- 可选应用“取消常驻”只移除主导航捷径;“关闭应用”移除该应用在业务界面的入口。
|
||||
应用中心保留管理和重新开启入口,重新开启沿用常驻偏好,不自动导航。
|
||||
- 入口变化不删除已有数据。关闭后已打开的可选应用页显示不可用状态及恢复设置入口。
|
||||
已发出的请求和后台任务沿用各功能的生命周期规则,关闭入口不新增强制取消或重放行为。
|
||||
|
||||
补充验收:关闭全部可选应用后,知识库侧栏入口仍可用,知识会话入口及心跳单条计划状态不变;管理 Modal 的知识库行提供“打开”和排序,不能启停或取消常驻。心跳执行边界按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收。验证魔法笔记未配置时的默认开启,以及
|
||||
已有关闭选择在迁移后保留。设置保存失败时各入口保持原状态,不能出现互相矛盾的显示。
|
||||
|
||||
### 后续:应用商店与扩展
|
||||
|
||||
- 单独设计应用商店的目录、搜索、推荐、详情及安装管理,时间与具体范围另行确定。
|
||||
- 插件应用和插件工具注册,以及安装、更新、卸载与权限说明。
|
||||
- 插件应用可常驻主导航;插件工具按工作栏扩展设计接入,不恢复左下角重复系统工具条。
|
||||
- 用户级快捷键与命令面板接入同一贡献命令。
|
||||
|
||||
现有 Demo 的商店页、项目管理和阅读空间安装流程仅保留为未来交互参考,不纳入首期验收,
|
||||
也不作为已有商店能力的说明。首期实现不预建插件安装服务或远程目录协议。
|
||||
|
||||
### FR-15 私有化市场与 YAML 交换(后续)
|
||||
|
||||
市场面向组织私有化部署。用户配置市场 URL 后即可获取该来源的应用目录与详情,并按需获取
|
||||
应用;需要认证的来源复用其登录流程。市场不可达时说明原因并允许重试,不影响已安装应用
|
||||
和内置应用使用,也不自动切换到公共市场。市场 URL 只影响应用市场,不改变模型、桌面更新
|
||||
或 Runtime 的下载源。市场 URL 按设备保存,重启后保留;认证失效时提示重新认证。
|
||||
|
||||
应用支持以 `.yml` 导入、导出,兼容 `.yaml` 扩展名,用于组织内部共享、备份和迁移。
|
||||
导出内容为应用定义及可迁移配置,包含格式版本、应用标识、应用版本、入口声明和依赖说明;
|
||||
凭据、会话、笔记及知识库业务数据不随应用定义导出。导入前展示应用摘要、缺失依赖和与已有
|
||||
应用的冲突;同标识应用由用户选择更新或取消,不静默覆盖本机配置。成功后展示实际版本与可用状态;
|
||||
获取、校验或更新失败保留原已安装版本与配置,并显示可重试原因。
|
||||
|
||||
YAML 描述文件不能代替模型权重或插件程序包。有外部依赖时应明确资源引用;离线导入仅在
|
||||
所需资源已具备时可完成使用,否则列出需要补充的资源,不显示为可运行。导入应用不会自动
|
||||
启动模型或执行任务。
|
||||
|
||||
未来 ShareServer 提供私有市场目录、应用定义的导入导出、发布和版本资源分发;桌面端负责
|
||||
配置来源、获取目录、本地导入导出及应用安装管理。服务端职责归属见
|
||||
[ShareServer 总体设计](../../architecture/share-network-architecture.md)。本节记录后续需求,
|
||||
尚未确定 API、YAML Schema 或服务端交付版本,不纳入应用中心首期。
|
||||
|
||||
### FR-16 本机模型管理入口(新增计划项)
|
||||
|
||||
应用中心提供“本机推理监控”内置管理应用,可选择常驻左侧。从常驻入口、应用菜单或管理 Modal 的“打开”均进入同一独立管理 Modal;来源菜单或管理 Modal 关闭,底层工作区、当前项目、会话及草稿保持不变。该入口统一
|
||||
查看本机语音识别、OCR、向量生成等服务的运行状态与可用资源指标。
|
||||
|
||||
Modal 只有服务列表,显示名称、简短状态、引擎、模型、资源和错误,不显示任务历史、任务页签、单任务取消或外部连接列表。独立向量进程显示 CPU 与工作集内存,并标明进程范围;CPU 预热、采样失败和共享进程 worker 无法独立统计时说明原因,不能用零或整个应用进程占用代替服务指标。活动请求可能仍在排队或加载,不能据此宣称服务正在运行。ASR 当前仅显示按需运行且状态未知;OCR 和向量服务使用各自执行模块的状态。
|
||||
|
||||
服务行保留实际支持的启动、停止、重启或模型加载、释放,工具栏保留刷新和模型设置入口。停止后重新启动不自动重放已中断任务。
|
||||
|
||||
停止、重启或释放前,在管理 Modal 上方打开紧凑影响确认,展示正在使用该服务的任务和影响;没有已登记活动任务时也说明执行前会再次核对。取消时保留运行状态,
|
||||
失败时关闭旧确认、显示原因并刷新状态,再次操作须重新确认当前影响。多个应用共用同一模型服务时只展示同一个服务,不重复
|
||||
启动进程。隐藏管理入口、关闭管理 Modal 或取消应用常驻均不停止模型;卸载模型文件是独立操作。
|
||||
确认打开时不能关闭底层管理 Modal;Escape 只关闭最上层,提交期间禁止关闭及重复执行。关闭后恢复可用入口焦点,具体规则见 [UI 设计](./ui-design.md#91-本机推理管理)。
|
||||
|
||||
资源监控仍是只读系统工具,本机推理监控聚焦本地服务状态。该项独立于商店接入生产源码,支持 OCR 空闲加载与释放、内置向量进程启动/停止/重启;TTS 显示不可用。后端保留请求登记及原有取消能力,供执行路径和服务影响校验使用。各引擎边界见
|
||||
[技术清单](./technical-design.md#111-本机推理任务与执行服务),验证范围见[实施进度](./progress.md)。
|
||||
原首期资源监控的 GoodBuddy 桌面进程统计范围不因此扩大。
|
||||
|
||||
### FR-17 主机管理与云环境管理(待实现)
|
||||
|
||||
2026-09-28 确认新增两个同级应用:“主机管理”和“云环境管理”。主机管理从设置中心迁入,
|
||||
两个应用均从应用菜单、管理应用和常驻入口打开主内容区独立页面,不嵌入管理 Modal。
|
||||
应用中心负责通用应用配置,业务操作在各自页面完成。
|
||||
|
||||
导航扩展须保留已有应用排序、常驻偏好和远程能力启用值;取消主机常驻不影响云环境使用
|
||||
主机能力。两个应用的能力依赖、迁移范围及产品验收由
|
||||
[主机与云环境 PRD](../remote-host/prd.md)和[功能逻辑](../remote-host/logic-design.md)定义,
|
||||
业务页面布局见[界面设计](../remote-host/ui-design.md)。该项尚未实现,不改变上文五应用的当前状态描述。
|
||||
@@ -0,0 +1,596 @@
|
||||
# 应用导航实施进度
|
||||
|
||||
## 2026-10-05:展开状态缓存复查
|
||||
|
||||
独立复查发现,首次 A10 修复在每个折叠项挂载时保存默认值;无引用的消息也会因引用 Hook 留下一条记录。下节首次验收只检查了 DOM 上限,没有覆盖这部分缓存。新增回归在该实现上失败:分批挂载 10,000 个未操作的消息作用域后,预期缓存为 0,实际为 10,000;恢复默认状态后也没有释放条目。
|
||||
|
||||
现改为只保存偏离自动默认值的用户选择,恢复默认时立即删除覆盖项。消息更新后,清理逻辑只检查已保存的覆盖项,借用历史面板已有的消息索引定位来源,清除已删除消息、工具/输入/推理块/子代理/引用及已过期的流式默认值对应的记录。清理不在滚动或渲染期执行。正常的虚拟行卸载、子代理收起继续保留有效选择,行卸载期间完成的推理仍按原规则自动收起。
|
||||
|
||||
本次执行结果:
|
||||
|
||||
- `npm test -- --project unit src/renderer/src/message-expansion.test.tsx src/renderer/src/ChatHistoryPane.test.tsx src/renderer/src/ChatTimeline.test.tsx src/renderer/src/SupervisorWorkspace.test.tsx`:4 个文件、112 项通过。其中新增 4 项覆盖 10,000 个未操作作用域零缓存、用户选择跨卸载保留、恢复默认释放、消息及折叠项删除、未挂载推理完成。
|
||||
- `npm test -- --project unit src/renderer/src/App.test.tsx -t 'keeps each conversation history window|isolates history and task strips'`:2 项通过,346 项未选中。
|
||||
- `npm test -- --project integration tests/message-expansion.electron.test.ts`:三轮交替比较后,最终测量探针调整也单独复跑通过。`npm test -- --project integration tests/frosted-glass.electron.test.ts`:1 项通过,继续覆盖浅深主题、原生点击、跳底、锚点和缓存切换。
|
||||
- `npm run typecheck`、`npm run lint` 通过。该组定向验收后,完整集成测试为 6,136 项通过、0 失败、87 跳过,见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查);本组模型调用 0 次。
|
||||
|
||||
Electron 环境与合成消息结构同下节。此次比较对象为首次 A10 的默认值缓存实现和本次稀疏覆盖实现,不是 `126dbc57`。测试通过 `GB_EXPANSION_DENSE_SOURCE` 加载修改前保留的模块副本,其余生产 Renderer 源码相同。fixture 临时观察首次展开状态写入,捕获该 Map 后恢复原 `Map.set`;产品未增加诊断接口。每个数据量先进行 200 次未操作导航,实际见到 1,397 个不同消息行(一轮旧实现为 1,401),再原生展开 13 条消息的推理、工具结果和输入,返回原行确认恢复,用 Space 收起一项,最后删除消息。
|
||||
|
||||
| 历史消息数 | 旧实现:未操作导航后条目 | 新实现:未操作导航后条目 | 用户操作后:旧/新条目 | 删除消息后:旧/新条目 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2,000 | 5,600 | 0 | 5,708 / 38 | 5,708 / 0 |
|
||||
| 10,000 | 5,604 | 0 | 5,904 / 38 | 5,904 / 0 |
|
||||
|
||||
以上计数在三轮比较中一致。交互阶段两版均最多采样到 15 个消息行、926 个 DOM 元素,原消息的三个展开项均恢复;A09 迟到来源响应检查也继续通过。计数针对缓存条目,不是浏览器堆内存字节数。
|
||||
|
||||
| 消息数 | 旧实现每轮导航中位数(ms) | 新实现每轮导航中位数(ms) | 旧实现每轮 p95(ms) | 新实现每轮 p95(ms) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2,000 | 38.8 / 38.8 / 38.4 | 38.8 / 39.1 / 38.5 | 40.4 / 39.6 / 46.9 | 39.2 / 46.7 / 40.1 |
|
||||
| 10,000 | 38.7 / 39.1 / 38.9 | 38.8 / 38.9 / 38.6 | 39.7 / 39.5 / 46.9 | 39.4 / 39.3 / 46.6 |
|
||||
|
||||
导航计时仍为目标聚焦后再等待两个动画帧,每轮 14 个样本;此次先扫过历史,不能与下节首次进入的计时直接比较。三轮比较中,200 次未操作导航的总耗时,旧实现为 4,753.5–5,080.5 ms,新实现为 4,820.3–5,228.8 ms。数据证明默认条目和删除后的条目不再积累,未证明提速;未测堆内存峰值、低端机节流或完整 App/生产 IPC。
|
||||
|
||||
## 2026-10-05:虚拟消息行的展开状态
|
||||
|
||||
聊天历史面板通过 `message-expansion.tsx` 按消息、子代理及折叠项的稳定标识保留展开状态。虚拟行卸载后只保留状态值,不保留 DOM;仍沿用最多 8 个近期交互行及焦点、选区、问题的挂载保护。阅读行为见[聊天阅读状态](./ui-design.md#聊天阅读状态)。本次没有修改 Runtime 执行或消息持久化。
|
||||
|
||||
旧代码上的新增组件回归在返回原消息时失败:预期展开,实际收起。最终三文件 108 项通过,覆盖嵌套工具、父子同名调用隔离、手动收起、行卸载期间流式完成、既有焦点/选区及平滑滚动。命令:`npm test -- --project unit src/renderer/src/ChatHistoryPane.test.tsx src/renderer/src/ChatTimeline.test.tsx src/renderer/src/SupervisorWorkspace.test.tsx`。另有会话存储、同步、流式缓冲、窗口模型和侧栏回归 48 项,以及 App 历史加载、重试、热切换、流式隔离和超过 500 条消息的定向回归 5 项通过。
|
||||
|
||||
真实 Electron 使用当前 `ChatHistoryPane`、样式及 Markdown 渲染,数据为 2,000/10,000 条合成助手消息,每条含正文、推理、工具结果与输入。Windows x64、Electron 44.5.1、1280×800、缩放 1;原生鼠标展开 13 条相距较远的消息,确认原行卸载后再返回,并用原生 Space 操作。基线为临时 worktree 中的 `126dbc57`。三轮交替运行 `npm test -- --project integration tests/message-expansion.electron.test.ts`;基线运行另设 `GB_HISTORY_SOURCE` 指向该 worktree。每轮、每种数据量采样 14 次导航;计时从请求消息定位到目标 article 获得焦点后的两个动画帧,含这两个帧的等待,不等同于纯渲染耗时。
|
||||
|
||||
| 消息数 | 基线每轮中位数(ms) | 修复后每轮中位数(ms) | 基线每轮 p95(ms) | 修复后每轮 p95(ms) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2,000 | 47.0 / 39.2 / 39.3 | 39.4 / 39.0 / 39.1 | 177.6 / 136.4 / 137.4 | 129.7 / 128.8 / 138.7 |
|
||||
| 10,000 | 47.2 / 39.4 / 39.2 | 38.8 / 39.2 / 39.5 | 56.0 / 40.2 / 43.7 | 41.4 / 39.7 / 40.0 |
|
||||
|
||||
两版在两种数据量下的采样最大值均为 15 个消息行、926 个 DOM 元素;基线三轮均丢失原消息的三个展开状态,修复后三轮均恢复。数字只证明本场景的 DOM 数量有界,计时存在波动,不据此宣称提速或整体性能预算达标。该 Electron fixture 替换了数据来源,不是完整 App、生产 IPC 或真实模型验收;模型调用 0 次。
|
||||
|
||||
既有 `npm test -- --project integration tests/frosted-glass.electron.test.ts` 单独复跑通过,覆盖浅深主题、原生点击、跳底、阅读锚点、会话缓存、加载更早消息及 960×540 短窗口。首次与监督者布局测试合并运行在工具的 60 秒期限内未返回结果,不计为通过;`tests/supervisor-layout.electron.test.ts` 随后单独后台运行通过,总耗时 65.26 秒,stderr 为空。最终完整 typecheck、全仓 lint 和修改范围差异检查通过。首次 lint 曾受其他并行修改中的三个 Runtime 错误阻断,后续复跑已通过。未运行完整测试集或低端机节流测量。
|
||||
|
||||
## 2026-10-04:新建本地项目必选目录
|
||||
|
||||
对应 FR-14 的项目创建入口,规则见 [PRD](./prd.md#fr-14-项目活动汇总)及[界面设计](./ui-design.md#10-项目活动汇总)。创建表单已增加中英文必填提示、无障碍关联及空目录提交检查;共享创建 schema 拒绝空白路径,更新 IPC 使用独立 schema,保留历史空目录项目的保存能力。
|
||||
|
||||
- `npx vitest run --project unit src/renderer/src/ProjectSwitcher.test.tsx src/shared/assistant-contracts.test.ts`:57 项通过,覆盖空白目录、目录选择与取消、正常创建、中英文提示、历史空目录设置保存及既有 SSH 界面回归。
|
||||
- `npx vitest run src/main/ipc.test.ts -t "requires a directory through project creation IPC"`:1 项通过,172 项未选中。调用生产 IPC handler 和真实内存 SQLite,验证空目录创建不写库、有效目录创建落库,以及历史空目录更新成功。
|
||||
- `npm run typecheck` 和本次修改的 8 个源码/测试文件的定向 ESLint 检查通过。
|
||||
|
||||
本次未运行完整测试集或原生 Electron 目录选择器验收,未修改远程主机文档、Agent 或远程连接流程;模型调用 0 次。
|
||||
|
||||
## 2026-10-04:归档恢复与右下角进入项目
|
||||
|
||||
对应 FR-14、US-E4。生产菜单左栏底部已接入按需归档列表、搜索和逐项恢复,右下角已接入“进入项目”。恢复复用既有归档 IPC,并在项目重新可选前合并目标项目会话摘要;不自动切换当前项目,不改变 Task 状态或启动远程连接。进入按钮复用带离开检查的选择回调。规则见[活动逻辑](./logic-design.md#9-项目活动汇总)和[界面设计](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
本次验证:
|
||||
|
||||
- `npx vitest run --project unit src/renderer/src/WorkspaceMenu.test.tsx src/renderer/src/ProjectSwitcher.test.tsx src/renderer/src/workspace-menu-selectors.test.ts src/renderer/src/WorkspacePrimitives.test.tsx`:90 项通过。包含按需 `list(true)`、分类及远程开关、搜索、加载/恢复失败重试、重复提交锁定、预览不导航及全部项目进入目标。
|
||||
- App 项目/通道相关筛选首次运行 83 项通过,1 条旧断言未包含此前新增的更新时间。修正该断言后运行 `npx vitest run --project unit src/renderer/src/App.test.tsx -t "keeps cross-project counts independent|restores archived|single guarded Magic Notes discard"`,7 项通过,覆盖本地/远程恢复、真实 `setArchived(id, false)` 接线、摘要读取失败重试、原会话恢复和未保存草稿离开检查。
|
||||
- `npx vitest run tests/overlay-layering.electron.test.ts -t "keeps nested previews"`:真实 Electron App 用例通过。生产 Preload、IPC、SQLite 验证归档恢复、保留历史正文、恢复不切换及进入不新建替代会话;浅深主题分别覆盖 `1280×800`、`1280×480`、`560×640`、`375×480` 的右下角位置、边界和内部滚动,并保留原生浏览器相交遮挡及同页面恢复检查。模型调用 0 次。
|
||||
- `npm run typecheck`、`npm run lint`、`git diff --check` 通过。未运行全量测试或实机 SSH 连接;本次没有修改 Agent、SSH 协议或 Runtime 路径。Demo 未修改,生产菜单为本次交付对象。
|
||||
|
||||
## 2026-10-04:会话更新时间与菜单下方定位
|
||||
|
||||
对应 FR-14、US-E1、US-E2。右栏时间按会话 ID 局部订阅,使用实际 `updatedAt` 及侧栏共用格式;仅有活动而无摘要时省略时间。显示文字与提示未变的流式更新不触发 React 提交,跨显示精度更新只提交时间子组件。菜单按完整按钮底边定位,剩余视口高度限制菜单高度,保留内部滚动。规则见[活动界面](./ui-design.md#10-项目活动汇总)及[技术设计](./technical-design.md#12-项目活动汇总)。
|
||||
|
||||
本次实际验证:
|
||||
|
||||
- `npx vitest run --project unit src/renderer/src/WorkspaceMenu.test.tsx src/renderer/src/workspace-menu-selectors.test.ts src/renderer/src/ProjectSwitcher.test.tsx src/renderer/src/time-format.test.ts`:4 个文件、52 项通过。覆盖中英文当天/当年/跨年格式、提示和 ISO 时间、访问时间不替代更新时间、缺少摘要不读取详情、局部提交与未变化时零提交。
|
||||
- `npm run typecheck`:Node、Agent、Web 全部通过。`npx eslint src/renderer/src/WorkspaceMenu.tsx src/renderer/src/workspace-menu-selectors.ts src/renderer/src/WorkspaceMenu.test.tsx tests/support/overlay-paths-main.ts` 通过。
|
||||
- `npx vitest run --project integration tests/overlay-layering.electron.test.ts -t 'keeps nested previews'`:选中的真实 Electron App 用例通过,其余 3 项未选中。浅深主题分别覆盖 `1280×800`、`1280×480`、`560×640`、`375×480`,验证 `8px` 间隔、按钮两行命中、视口边界、时间与状态位置、原生滚轮内部滚动、End 到末行和 Escape 焦点恢复;连续 12 帧几何一致,并检查菜单打开时缩短及恢复窗口。
|
||||
- 原生浏览器与菜单相交时隐藏、不相交时保持显示;菜单关闭后恢复同一页面及标记。模型调用 0 次。截图和 JSON 保存在临时 `workspace-menu-validation` 目录,已查看桌面、短窗口深色和窄窗口浅色截图。
|
||||
- 临时换回旧的按菜单高度上移公式后,Electron 用例在首个桌面场景的间隔断言失败(实际 `6px`,要求 `8px`);恢复最终代码后复跑通过。未运行全量测试,未修改监督者代码。
|
||||
|
||||
## 2026-10-04:双行入口合并为单个按钮
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。项目名和活动摘要现在属于同一个 `60px` 按钮,共用键盘焦点、展开状态和菜单关联。点击任一行默认预览当前项目,全部项目范围在菜单内切换;活动描述和按钮外的恢复反馈通过 `aria-describedby` 关联。移除了独立摘要触发引用与入口范围状态,保留最近 10 条及现有活动计数,没有新增轮询。
|
||||
|
||||
本次验证:`ProjectSwitcher`、`WorkspaceMenu`、`WorkspacePrimitives` 共 80 项通过;App 活动、审批、问题队列和流式侧栏相关测试 37 项通过;`npm run typecheck` 与 `npm run lint` 通过。真实 Electron overlay harness 在 `1280×800`、`1280×480`、`560×640` 的浅深主题下通过,验证单按钮、空闲与活动状态的 `60px` 高度、第二行点击、菜单布局和 Escape 焦点恢复,模型调用 0 次。
|
||||
|
||||
## 2026-10-04:双行入口与菜单布局调整
|
||||
|
||||
本节保留此次单按钮更正前的实现与验证记录;现行入口规则见上节。
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。已核对 `ProjectSwitcher.tsx`、`WorkspaceMenu.tsx` 和 `workspace-menu.css`:左上角控件固定高 `60px`,项目名和全局活动为同级按钮,第二行无活动时显示“暂无活动”;恢复反馈位于控件下方并关联可访问描述。左栏搜索框使用 `border-box` 限制边界,两栏之间使用 `--border-control` 分隔线,全部项目及搜索结果补充分组标题,右栏状态工具栏收紧间距。菜单项目行继续按名称、活动、目录分行显示,高度随内容增长。具体规则见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
以下结果由实现方提供,本次文档更新未重跑产品测试:
|
||||
|
||||
- 79 项定向组件测试、68 项项目相关 App 测试通过。
|
||||
- 完整类型检查与修改范围 lint 通过。
|
||||
- 真实 Electron 在 `1280×800`、`1280×480`、`560×640` 的浅深主题下验证搜索框边界、分隔线、紧凑工具栏、项目三行信息,以及左上角控件在空闲和活动状态下的固定高度与焦点。
|
||||
- 模型调用 0 次。
|
||||
|
||||
最近会话上限已由 5 条调整为 10 条,生产设计与验收条目已同步。最终运行 `npx vitest run --project unit src/renderer/src/workspace-menu-selectors.test.ts src/renderer/src/WorkspaceMenu.test.tsx src/renderer/src/ProjectSwitcher.test.tsx`,3 个文件共 44 项通过,覆盖最近 10 条、活动不占最近名额、已完成筛选超过 10 条,以及完成后保留历史。Demo 文件未修改,仍保留原型的最近 5 条行为。
|
||||
|
||||
## 2026-10-04:生产统一工作空间菜单
|
||||
|
||||
本节保留首次接入时的历史实现与验证范围,其中最近 5 条为当时上限,现行调整见上节。
|
||||
|
||||
对应 FR-14、US-E1 至 US-E3。已核对生产 `WorkspaceMenu.tsx`、`workspace-menu-selectors.ts`、`ProjectSwitcher.tsx` 与 `App.tsx` 差异:项目入口与活动摘要共用菜单,替代独立 `ProjectActivity` 级联。已接入全部项目优先的分类 PageTabs、悬停预览与点击进入、最近 5 条、同排状态筛选与新建会话、真实项目设置和创建、项目三行信息,以及会话期内上次打开记录。预览只用内存数据,项目和会话列表复用共享窗口化;规则见[活动逻辑](./logic-design.md#9-项目活动汇总)与[技术设计](./technical-design.md#12-项目活动汇总)。
|
||||
|
||||
最终源码补充:菜单最近时间取最新消息时间与 Store 会话期访问/状态时间的最大值;`recordActivityStatuses` 首次只建立基线,清除完成提醒不更新时间。项目上次打开 ID 仍只保留在内存,获准后的项目进入及新建回调使用 `commitView`,避免重复离开检查。
|
||||
|
||||
以下为实现代理提供的验证结果,本次仅更新文档,未重跑产品测试:
|
||||
|
||||
- 82 项定向测试通过。
|
||||
- 31 项选定 App 测试通过。
|
||||
- 1 项真实 Electron overlay 测试通过,最终场景也在全量测试运行中通过。
|
||||
- 最终完整 `npm run typecheck`、`npm run lint` 通过。
|
||||
- 全量 `npm test` 在 600 秒超时前未完成。已结束的 App 文件共 347 项,346 项通过、1 项监督者设置测试失败;`tests/supervisor-layout.electron.test.ts` 也失败。尚无证据将这些失败归为既有问题,全量测试未通过。
|
||||
- 首次 200 秒尝试报告 `activity-history-repository` worker 失败;第二次截至超时未报告该处失败。同期有并行性能修改,未确定失败原因,也不据此认定已修复。
|
||||
|
||||
本次没有性能基准结论。README 保留未提交的工作空间 Demo 描述,并区分生产菜单与旧演示;下方级联菜单记录保留当时实现和验证范围,不再定义当前生产交互。
|
||||
|
||||
## 2026-10-01:设备共享入口
|
||||
|
||||
新增 `device-sharing`,启用后应用菜单及管理 Modal 均进入主内容区页面;管理行可排序。
|
||||
首期初始实现没有启用开关,同日后续改为测试阶段默认关闭、可手动启用,不提供常驻开关。
|
||||
旧顺序通过现有存储归一化追加新项,保留原设置;权威写入要求五项完整顺序。
|
||||
ApplicationCenter、ApplicationMenu、设置迁移以及 App 入口/排序回归已通过,真实 Electron
|
||||
从应用中心进入页面并完成后端流程。精确命令、完整范围及全仓测试记录见
|
||||
[设备共享实施进度](../device-sharing/progress.md)。下方历史四应用记录保留原验证时的范围。
|
||||
|
||||
## 2026-09-28: Composer shortcut initialization follow-up
|
||||
|
||||
The composer now retains successful shortcut-settings snapshots independently of
|
||||
the startup app-info request. A failed or pending app-info read no longer drops a
|
||||
successful shortcut save, and a late startup response cannot restore a disabled
|
||||
hint. Registration remains authoritative; no default shortcut is advertised when
|
||||
registration is unavailable. This preserves the existing [composer rule](../../../UI-DESIGN.md#131-聊天).
|
||||
|
||||
The failed/pending read regressions reproduced the missing hint before the fix.
|
||||
The reported screenshot's actual registration status and IPC result were not
|
||||
captured, so its specific runtime cause remains unconfirmed.
|
||||
|
||||
Validation: App, sidebar layout, main IPC, and shortcut-service suites passed
|
||||
(500 tests, one skipped); `npm run typecheck`, ESLint on the four changed source
|
||||
and test files, and `git diff --check` passed. The first broader run hit the
|
||||
60-second command limit; the rerun completed in 95 seconds. No full-repository
|
||||
suite, native Electron screenshot reproduction, packaging, or OS registration
|
||||
probe was run for this follow-up.
|
||||
|
||||
## 2026-09-28:新建对话与搜索共用一行
|
||||
|
||||
已按[会话搜索](./ui-design.md#会话搜索)接入收起与展开状态,移除列表上方的常驻搜索头部。
|
||||
沿用会话创建、延迟搜索和项目过滤接口。后续按用户反馈恢复默认按钮的聊天加号图标、原有
|
||||
字号字重和整体左对齐;搜索展开后保留居中的同一图标。快捷键说明移至聊天输入框原生占位文案,
|
||||
具体规则见[聊天 UI 规范](../../../UI-DESIGN.md#131-聊天)。
|
||||
|
||||
本次调整验证:`App.test.tsx` 与 `AppSidebarLayout.test.ts` 合计 316 项通过;最终长标签换行
|
||||
调整后,布局 5 项与 `magic-notes-capture.electron.test.ts` 1 项复验通过。Windows Electron
|
||||
在上述 6 种侧栏组合检查原字号字重、左对齐、图标居中、文字无溢出及搜索交互,并验证
|
||||
原生输入后占位提示隐藏、清空后恢复。App 回归覆盖中英文、生图提示、macOS 快捷键格式、
|
||||
全局快捷键缺省以及设置保存后的更新和停用。类型检查、修改文件 ESLint 与差异空白检查通过。
|
||||
未运行全仓测试、打包或 macOS/Linux 原生窗口验证。模型调用 0 次。
|
||||
|
||||
此前搜索展开改动的验证记录:
|
||||
|
||||
```text
|
||||
npm test -- src/renderer/src/App.test.tsx src/renderer/src/AppSidebarLayout.test.ts tests/magic-notes-capture.electron.test.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
三文件合计 311 项通过。最终动画边界与文字宽度调整后,布局及 Electron 共 5 项、App 搜索
|
||||
交互 1 项复跑通过,typecheck 通过。Electron 复用现有 App、生产 Preload、IPC 和临时 SQLite
|
||||
测试环境,以英文界面检查浅深主题下 220px、420px 侧栏和短窗口抽屉,共 6 种组合;检查
|
||||
展开过程及结束后的右边缘、列表位置、完整按钮文字、原生鼠标与 Escape、自动聚焦、关闭
|
||||
焦点恢复、点击列表保留关键词及减少动态效果。中文 App 回归同时覆盖标题、正文、项目
|
||||
过滤和加号创建。模型调用 0 次。
|
||||
|
||||
修改文件的 ESLint 与 `git diff --check` 通过。全仓 ESLint 有 10 个错误,均来自本任务未修改
|
||||
的未跟踪 `sidebar-final-probe.ts` 和 `sidebar-final-run.mjs`。未跑全仓测试、打包程序或
|
||||
macOS/Linux 窗口验证。中文新增规则经 deai-writing 扫描无阻断项,并已人工核对。
|
||||
|
||||
## 2026-10-02:补齐五秒排序检查
|
||||
|
||||
会话列表已接入 `useConversationListOrder`,保留实时行数据,定时调整展示顺序。
|
||||
交互暂停与即时操作规则见[会话置顶与操作菜单](./ui-design.md#会话置顶与操作菜单)。
|
||||
本地和消息通道共用此 Renderer 路径,未修改 Agent 执行、同步或持久化。
|
||||
|
||||
验证命令:
|
||||
|
||||
```text
|
||||
npx vitest run src/renderer/src/use-conversation-list-order.test.tsx src/renderer/src/chat-conversation.test.ts src/renderer/src/App.test.tsx -t "useConversationListOrder|conversation display order|parallel streaming conversations|summary and detail order|sorts pinned conversations|merges persisted pin metadata|dedicated pin update"
|
||||
```
|
||||
|
||||
3 个文件、23 项通过,覆盖五秒边界、连续增量、交互暂停与恢复、立即增删与置顶、
|
||||
项目/搜索范围切换、计时器清理,以及 App 中本地/通道摘要更新的实际列表接线。
|
||||
新增 hook 与相关测试的定向 ESLint、`git diff --check` 通过。未运行 Electron 窗口验证。
|
||||
全仓检查未通过:typecheck 报并行修改中的 `App.tsx:5436` 缺少 `startTransition`;
|
||||
ESLint 报打包文件及现有会话视图缓存的 refs 错误;`npm test` 在 120 秒后超时。
|
||||
额外运行的草稿置顶失败测试未通过,实际显示持久化错误而非预期的置顶错误。
|
||||
|
||||
## 2026-09-28:并行会话输出时保持列表顺序
|
||||
|
||||
修复多个会话交替输出时,左侧列表随每个增量反复换位的问题。展示顺序统一使用消息创建
|
||||
时间,摘要查询补充只读 `latestMessageAt`。交互规则见[会话置顶与操作菜单](./ui-design.md#会话置顶与操作菜单),
|
||||
时间字段与合并边界见[会话列表读取与前端保留](../assistant-workbar/execution-history-storage.md#会话列表读取与前端保留)。
|
||||
本地及远程事件的更新时间写入、快照合并判定和增量保存保持原实现。
|
||||
|
||||
聚焦验证命令:
|
||||
|
||||
```text
|
||||
npx vitest run src/renderer/src/chat-conversation.test.ts src/renderer/src/App.test.tsx src/main/assistant/assistant-database.test.ts -t "conversation display order|parallel streaming conversations|summary and detail order|sorts pinned conversations|merges persisted pin metadata|lists lightweight history|persists only the changed conversation|preserves live .* across snapshot|loads only the opened history"
|
||||
```
|
||||
|
||||
结果:3 个文件、13 项通过。覆盖并行文字增量与完成后顺序、保存时间继续推进、本地和消息
|
||||
通道的摘要/详情刷新、新消息前移、置顶合并、同时间固定顺序、空会话回退、摘要附带实时
|
||||
消息及真实 SQLite 摘要时间。历史加载回归继续验证刷新不重复保存未变化的消息。
|
||||
修改过的 7 个 TypeScript 文件定向 ESLint 通过。中文文档按 deai-writing 自查清单复核。
|
||||
|
||||
本轮验证为 App 组件事件回归与真实 SQLite 测试,未进行 Electron 窗口或真实模型调用。
|
||||
改动仅涉及桌面展示及桌面 Main 的只读摘要,未改变 Agent 协议或桌面到 Agent 的执行路径,
|
||||
无需部署 Host。全仓 typecheck、ESLint 和 `git diff --check` 通过;全量测试运行十分钟后
|
||||
超时,期间报告配置摘要和 Agent 离线安装测试失败,未取得全仓测试通过结果。
|
||||
|
||||
2026-09-23 文档校正:当前 `heartbeat` 已作为可选应用管理,默认值与执行边界见[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行),设置与导航契约见[技术设计](./technical-design.md#4-应用导航偏好)。下方日期记录保留当时实现与验证结果;其中“心跳始终显示”“无应用级开关”和旧字段删除说明不再代表当前行为。本次仅核对源码并更新文档,未重新执行功能测试。
|
||||
|
||||
## 2026-09-18:窄幅单列应用列表
|
||||
|
||||
对应 FR-11、US-B6、US-B7、US-D1。管理概览改为紧凑单列横向列表,概览与详情共用 `640px` 最大宽度;左侧显示图标、名称、状态与可换行用途,右侧放置打开、可用的设置及成组排序箭头。行间使用弱分隔线,窄窗口操作换行。保留四应用保存顺序、箭头和拖动规则、知识库与智能心跳始终显示、本机推理监控默认不常驻。现行尺寸与交互见 [UI 设计](./ui-design.md#4-应用中心与共同设置),下方历史记录保留当时布局。
|
||||
|
||||
验证:ApplicationCenter、ApplicationMenu、WorkspacePrimitives 共 54 项测试通过;`npm run typecheck`、`npm run lint`、`git diff --check` 通过。全量 `npm test` 超过 60 秒未取得汇总,不记为通过。
|
||||
|
||||
Windows Electron fixture 重新编译当前组件、生产 CSS 与中文翻译,使用真实 ApplicationSettingsStore 及临时 IPC;浅深主题分别检查 1280、960、720、640、375px 目标窗口,实际 CSS 视口因缩放约多 1px。计算布局均为单列且无横向溢出,桌面 Modal 宽度为 640px,四行共八个箭头且均可拖动。原生键盘排序、磁盘顺序读取、详情返回及关闭后的焦点恢复通过。证据为临时目录 `application-rows-Hl3X55/result.json`。截图捕获持续报 `UnknownVizError`,禁用硬件加速后仍失败,因此未完成截图审阅;此 fixture 不作为完整 App、安装包或原生指针拖放验收。
|
||||
|
||||
中文改动按 deai-writing 自查清单复核。未修改模型、OCR、任务中心或 Agent 执行逻辑,未调用模型,未创建提交。
|
||||
|
||||
## 2026-09-18:收拢通用应用设置入口
|
||||
|
||||
对应 FR-11、US-B9。移除魔法笔记页面及本机推理监控停用状态的通用“应用设置”按钮,启用、常驻和笔记评论配置统一从应用中心卡片进入。保留管理应用、设置详情返回导航、模型与连接设置及原业务控件。删除仅用于页面快捷入口的 Context、跳转回调和初始详情参数;停用时仍保留页面挂载状态。现行入口规则见 [UI 设计](./ui-design.md#41-共同应用设置页),Demo 已移除页面、停用恢复及全局设置中的重复快捷入口。
|
||||
|
||||
验证:App 入口与状态同步定向测试 14 项通过;ApplicationCenter、ApplicationMenu、LocalInferencePage、SettingsPanel 共 128 项通过。首次包含完整 App 文件的运行出现两项既有任务审批失败,以及新增笔记测试未启用 fixture 的失败;修正 fixture 后相关定向测试通过。两项审批失败与上次记录一致,本轮未修改。类型检查、全仓 lint、Demo 语法及 diff 检查通过。本轮未运行全量测试或 Electron 窗口验收,未调用模型,未创建提交;保留已有四应用排序、监控名称和默认不常驻改动。
|
||||
|
||||
## 2026-09-18:四应用共享顺序
|
||||
|
||||
对应 FR-3、FR-11、FR-12、US-B7。四张管理卡片均提供上下移动和拖动,包含关闭、未常驻以及始终显示的应用;卡片、菜单、侧栏共用 `applicationNavigation.order`。知识库和智能心跳保留无开关的始终启用、常驻行为,位置可调整,标签改为“始终显示”/`Always shown`。当前对话、运行记录、设置仍保持系统位置。搜索中箭头按完整顺序移动,拖动禁用。保留此前 `260px` 自适应网格、本机推理监控名称及默认不常驻。
|
||||
|
||||
权威设置返回及写入 schema 接受四个 ID 各一次,拒绝重复、未知和缺失项。仅存储读取补齐旧可选应用顺序及部分默认值,保留已有顺序、显式布尔值和无关设置;规则见[持久化与 IPC](./technical-design.md#43-持久化与-ipc)。没有新增存储版本、应用启用字段或第二份导航状态。
|
||||
|
||||
验证:`npm test -- src/main/application-settings-store.test.ts src/shared/goodbuddy-config-contracts.test.ts src/main/goodbuddy-config-service.test.ts src/renderer/src/ApplicationCenter.test.tsx src/renderer/src/ApplicationMenu.test.tsx src/renderer/src/LocalInferencePage.test.tsx src/renderer/src/WorkspacePrimitives.test.tsx` 共 127 项通过;App 排序保存、菜单与侧栏同步、外部事件、重开及入口行为的 9 项定向测试通过。包含完整 `App.test.tsx` 的八文件运行共 390 项通过、2 项失败,失败项是任务中心审批测试在折叠列表中找不到任务行(`App.test.tsx` 的 scheduled approvals 场景),本次没有修改该行为。`npm run typecheck`、`npm run lint` 和 `git diff --check` 通过。全量 `npm test` 运行约七分钟仍未得到汇总,已停止本次测试进程;日志复现上述两项失败,不能宣称全量通过。
|
||||
|
||||
Windows Electron fixture 使用当前 ApplicationCenter、生产 CSS/中文翻译、真实 ApplicationSettingsStore 和临时 IPC,从旧两项顺序读取后,移动知识库以及关闭且未常驻的本机推理监控,并用新 Store 实例确认磁盘结果。浅深主题各检查 1280、960、640、375px 目标宽度,实际 CSS 视口因缩放多约 1px;均显示八个排序箭头、四个可拖动卡片,无横向溢出,列数分别为三、三、二、一。详情不含排序,返回搜索焦点和重开顺序通过。最终证据目录为临时 `all-app-order-t0hDya`;已查看桌面浅色及窄屏深色截图。此项是组件与存储验证,未验收完整生产 Main/Preload 启动或安装包,原生指针拖放由组件事件测试之外另行验收。
|
||||
|
||||
中文文档运行 deai-writing 扫描,无阻断项;对本次改动的复核候选人工检查,保留明确的产品状态与验收限制。未调用模型,未修改模型管理、GPU 指标或 Agent 执行路径,未创建提交。下方记录保留各次实施时的行为,旧“固定位置”“仅常驻项排序”规则已由本条及现行设计替代。
|
||||
|
||||
## 2026-09-18:卡片排序与自适应列数
|
||||
|
||||
对应 FR-11、US-B7、US-D1、US-D2。上下移动从设置详情移到常驻可选应用卡片,箭头名称及提示包含应用名;首尾、唯一常驻项和保存锁定时禁用对应操作。拖动也仅允许常驻可选应用,固定应用及未常驻项不参与排序。卡片改用 `auto-fit/minmax`,最小宽度 `260px`,Modal 最大宽度仍为 `960px`;启用、常驻及笔记设置继续使用原详情表单。保留本机推理监控名称及默认不常驻改动。
|
||||
|
||||
Windows Electron 组件 fixture 使用当前 ApplicationCenter、生产 CSS/中文 i18n、真实 ApplicationSettingsStore 及临时 IPC。浅深主题各检查 1280、960、640、375px 目标窗口宽度,实际 CSS 视口受缩放约多 1px;分别为三、三、二、一列,均无横向溢出。检查排序持久化、详情无排序及返回搜索焦点,并人工查看桌面浅色、窄屏深色截图。证据位于临时目录 `cards-order-fixture-tucKjv`。完整 App 配合已有 Main/Preload 的两次启动尝试未产生窗口,此次 fixture 不作为完整 App 或安装包验收。
|
||||
|
||||
`npm run typecheck`、`npm run lint`、`git diff --check` 通过;`npm test -- src/renderer/src/ApplicationCenter.test.tsx src/renderer/src/ApplicationMenu.test.tsx src/renderer/src/LocalInferencePage.test.tsx src/main/application-settings-store.test.ts` 共 66 项通过,覆盖固定/未常驻项、边界、保存锁定、搜索排序及设置入口。全量 `npm test` 在 60 秒后超时,未取得完整结果。中文改动按 deai-writing 自查清单人工复核。未调用模型、修改模型管理或 GPU 指标,未创建提交。
|
||||
|
||||
## 2026-09-18:本机推理监控默认不常驻
|
||||
|
||||
对应 FR-11、US-B 的默认值与迁移场景。共享导航默认值改为本机推理监控不常驻;新安装和旧设置缺失整个导航配置时采用此值,应用仍默认启用,可从应用菜单及管理 Modal 打开。默认可选顺序及知识库、智能心跳的固定入口规则保持不变。沿用存储版本 12,已有常驻 `true`/`false` 均保留;旧默认值与主动固定没有来源标记,无法区分,不强制取消已有常驻。Demo 初始状态和恢复默认同步。
|
||||
|
||||
验证:设置存储、应用中心、菜单、推理 Modal 共 62 项测试通过,App 入口及设置同步 8 项定向测试通过;`npm run typecheck`、`npm run lint`、Demo 的 `node --check` 和 `git diff --check` 通过。全量 `npm test` 在 60 秒后超时,未取得完整结果。中文改动按 deai-writing 自查清单人工复核。本次未启动 Electron 或调用模型,不涉及 GoodBuddy Agent 或远程执行路径;未创建提交,保留此前未提交的名称调整。
|
||||
|
||||
## 2026-09-18:统一名称为本机推理监控
|
||||
|
||||
对应 FR-16、US-F3。导航、应用中心、应用设置、Modal 标题及关闭按钮统一使用“本机推理监控”,英文应用名为 Local Inference Monitor。现行功能文档与 Demo 名称同步;`local-inference`、API、组件名及服务行为保持不变,下方历史记录保留当时称谓。
|
||||
|
||||
验证:应用菜单、应用中心、Modal 的 27 项测试及 App 入口、设置同步相关的 8 项测试通过;`npm run typecheck`、修改源码 ESLint、Demo 的 `node --check` 和 `git diff --check` 通过。Demo 被 ESLint 既有规则排除。中文改动按 deai-writing 自查清单人工复核,仅同步名称及本条验证事实。本次未运行全量测试或启动 Electron 验收窗口排版。
|
||||
|
||||
## 2026-09-18:独立推理进程资源采样
|
||||
|
||||
对应 FR-16、US-F3。向量 utility process 已按 PID 接入 Electron 真实 CPU 和工作集内存,服务行显示进程范围、PID、百分比及 MiB。ASR 为主进程线程,OCR 为渲染进程 Web Worker,分别显示不能独立统计的原因。采样口径及基线规则见[技术设计](./technical-design.md)。服务 Modal 继续保持单一列表。
|
||||
|
||||
本轮验证:资源采样器、Main 服务、向量 Broker、Modal 共 39 项定向测试通过;IPC 与 OCR Bridge 另有 130 项通过,共 169 项。类型检查、修改范围 ESLint、`git diff --check` 通过。全仓 lint 仅报告未修改的 story-graph Demo 的 22 个 `document` 错误;`npm test` 超过 60 秒未完成,不记为通过。中文功能文档 deai-writing 扫描阻断项为 0,复核项中的采样限制和验证边界按事实保留。
|
||||
|
||||
真实 Windows Electron 验证直接调用生产 `createEmbeddingUtilityTransport` 和采样器,使用临时 CPU 工作负载:PID 5892 的工作集从 57,769,984 字节变为 63,090,688 字节,区间 CPU 为 98.71%;退出后返回不可用,重启得到 PID 20532 并重新预热。进程刚 spawn 时指标可能尚未登记,返回等待数据。此验证没有加载推理模型,没有调用模型 API,也没有重新验收完整 App 窗口;macOS、Linux 尚未实机验证。改动仅涉及桌面本机服务监视,不影响 GoodBuddy Agent 或远程执行。
|
||||
|
||||
## 2026-09-18:本机推理服务监视简化
|
||||
|
||||
对应 FR-16、US-F3、US-F5;US-F4 的 Modal 单任务取消入口退役。`LocalInferencePage` 直接显示服务列表,移除任务页签、历史、外部连接和长说明,保留刷新、模型设置及受支持的服务操作。现行定义见 [UI 设计](./ui-design.md#91-本机推理管理)。
|
||||
|
||||
当时没有服务级 CPU/内存采样,统一显示“暂不可用”;这一限制已由上方资源采样实现修正。ASR 模型可用时返回 `unknown`,登记活动请求不再将状态覆盖为 `running`。OCR/向量仍使用各自执行模块的状态。操作失败关闭旧确认并刷新,重试时重新确认当前影响;后端任务清单校验和 OCR 忙碌保护保留。
|
||||
|
||||
验证结果:
|
||||
|
||||
- 定向运行 LocalInferencePage、Main 服务、向量 Broker、OCR Bridge 和 IPC,5 个文件共 165 项通过;App 的本机推理入口和停用恢复场景 4 项通过。
|
||||
- `npm run typecheck` 和修改范围 ESLint 通过。`npm run lint` 仍报告无关 `docs/features/story-graph/demo.js` 的 22 个 `document` 错误。
|
||||
- `npm test` 首次超过 120 秒,再次超过 600 秒,均未完成。第二次报告的 App 失败来自旧页签断言,已改为服务列表并定向复验通过;不将全量测试记为通过。
|
||||
- 中文功能文档按 deai-writing 扫描,阻断项为 0;复核项中的状态限制和操作清单按产品事实保留。
|
||||
|
||||
本轮未启动真实 Electron 或调用模型,未重新验证原生窗口排版。改动仅影响桌面本机服务监视,不涉及 GoodBuddy Agent 或远程执行路径。未创建提交,下方历史记录保留当时的 UI 与验证边界。
|
||||
|
||||
## 2026-09-17:应用卡片与紧凑详情
|
||||
|
||||
对应 US-B6 至 US-B9、US-F3 至 US-F5。已核对 `ApplicationCenter`、`LocalInferencePage`
|
||||
及其样式:管理改为桌面双列、窄窗口单列卡片,固定应用仅有打开;可选应用提供打开和设置,
|
||||
关闭时禁用打开。启用、常驻及上下移动位于最大 `640px` 的详情,卡片拖动排序保留。
|
||||
详情使用紧凑返回标题栏,进入及返回时滚动归顶。本机推理使用内容高度 Modal,列表状态与操作
|
||||
分区并响应式排列。现行规则见 [UI 设计](./ui-design.md#4-应用中心与共同设置)。
|
||||
|
||||
本轮实现验证结果由实现方提供,本次仅更新文档,未重跑产品测试:
|
||||
|
||||
- 59 项组件测试及 8 项选定 App 测试通过;`npm run typecheck`、修改范围 ESLint 和
|
||||
`git diff --check` 通过。
|
||||
- 真实 Electron 使用当前 Renderer、已有 Main/Preload、真实 IPC 和隔离配置,四种尺寸、浅深
|
||||
主题共 32 个视图无横向溢出;导航、焦点、Escape、背景隔离及详情滚动重置已检查。
|
||||
- 实机任务列表为空态;有任务及长名称由组件测试覆盖。模型调用为 0,未重跑全量测试,
|
||||
未创建提交;此前全量测试与全仓 lint 的失败记录仍保留,不将整体验收标为完成。
|
||||
|
||||
实机报告已核对:`C:/Users/jiang/AppData/Local/Temp/opencode/application-cards-review-cUiVAc/result.json`,
|
||||
截图位于同目录。下方记录保留各轮当时的布局及验证范围,旧单列管理行和旧 Modal 尺寸不作为现行定义。
|
||||
|
||||
## 2026-09-17:本机推理改为独立 Modal
|
||||
|
||||
对应 FR-3、FR-5、FR-16、US-B1、US-B6、US-F3 至 US-F5。已核对当前 `App.tsx`、
|
||||
`LocalInferencePage.tsx`、`local-inference.css` 和 `dialog-focus.ts`:常驻入口、应用菜单及管理
|
||||
Modal 的“打开”均打开独立本机推理 Modal,保留底层工作区;其他内置应用仍打开主内容区页面。
|
||||
停止、重启和释放使用嵌套紧凑影响确认,复用背景隔离、焦点循环与恢复,按当前层处理 Escape,
|
||||
操作提交期间禁止关闭及重复执行。具体行为见 [UI 设计](./ui-design.md#91-本机推理管理)。
|
||||
|
||||
以下结果由实现代理提供,本次文档更新未重新运行代码测试:
|
||||
|
||||
- 180 项定向测试通过。
|
||||
- `npm run typecheck` 与修改范围的 ESLint 通过。
|
||||
- 完整 App 测试运行超时,未取得完整通过结果。
|
||||
- 全仓 lint 仍有 `docs/features/story-graph/demo.js` 的既有 22 个 `document` 全局声明错误。
|
||||
|
||||
随后完成真实 Electron 验证:当前 Renderer 源码配合当日已有 Main/Preload 构建,使用隔离配置,
|
||||
未模拟 IPC。三个入口均保留同一知识库工作区节点;背景隔离、Escape、焦点恢复及窄窗口侧栏
|
||||
开关回退通过。使用已有本机资产完成 OCR 模型加载与确认释放,推理请求和模型下载均为零。
|
||||
浅深主题及窄窗口未出现横向溢出,释放确认框约为 480 × 182 CSS 像素,内边距 16px、按钮高 36px。
|
||||
修正窄窗口关闭按钮换行后再次通过实机检查和 43 项相关测试。证据位于临时目录
|
||||
`inference-modal-ui-8KJFgQ/result.json` 及同目录截图;未验证重新打包后的应用。
|
||||
|
||||
`npm test` 在 240 秒边界停止,未取得全量通过结果;独立复跑心跳数据库测试为 6 项通过、
|
||||
2 项失败,均为已有的 `duplicate column name: pinned`。下方旧页面截图不作为 Modal 验收证据。
|
||||
本轮已同步源码与文档,未修改 Demo,未创建提交。
|
||||
|
||||
下方保留各轮当时的实现与验证记录,旧主内容区页面及无 props 描述不作为当前 Modal 的行为定义。
|
||||
|
||||
## 2026-09-17:生产界面密度与响应式复核
|
||||
|
||||
对应 US-B5 至 US-B9、US-F3。已审阅 `abcd-production-08rUQA` 中 A、B、D 的原生产截图。
|
||||
应用管理从全宽纵向块改为最大 `960px` 的内容高度 Modal:名称旁标明固定/可选状态,桌面操作
|
||||
同行,窄屏自然换行且排序箭头成组。共同设置统一标签、说明和开关对齐;本机推理改为弱分隔列表。
|
||||
菜单只调整共享字体和图标层级。展示规则见 [UI 设计](./ui-design.md#4-应用中心与共同设置)。
|
||||
|
||||
最终源码验证:
|
||||
|
||||
- 应用中心、应用菜单、本机推理、设置面板和共享 UI 的 5 个定向文件共 148 项通过。
|
||||
- `npm run typecheck`、改动 Renderer 文件的定向 ESLint、`git diff --check` 通过。
|
||||
- `npm run build:bundle` 成功;最后一处布局修正后,`npx electron-vite build --logLevel error` 再次成功。
|
||||
- 全仓 `npm run lint` 仍有 `docs/features/story-graph/demo.js` 的 22 个 `document` 全局声明错误,未改动该文件。
|
||||
- 完整 `npm test` 用时 `648.80s`:4,454 项通过、67 项跳过、2 项失败。失败仍为心跳数据库
|
||||
迁移的 `duplicate column name: pinned`,位于未改动的 `assistant-database.ts:9988`;全量测试
|
||||
未通过。完整日志位于临时目录 `abcd-polish-tests-VxXZ34/test.log`。
|
||||
|
||||
真实 Electron 使用仓库 package 入口、当前生产 Main/Preload/App 和独立用户目录,未替换 IPC。
|
||||
最终截图与测量位于临时目录 `abcd-production-m9I4Vy`:`1200×775` 下四行约 `75px` 高,管理
|
||||
内容区 `scrollHeight` 与 `clientHeight` 均为 `420px`,操作同行;`520×740`、`375×667` 下
|
||||
管理、共同设置和推理内容无横向溢出。已审阅桌面与窄屏浅深色截图、375px 管理和设置截图。
|
||||
管理行按钮为 `34px`/`36px`,Switch 标签点击区高 `36px`,评论选项保留共享紧凑分段控件。
|
||||
生产验证覆盖菜单 End/Escape、Modal 焦点保持、页签方向键,以及 Space 切换常驻后重载 Renderer
|
||||
确认保存。运行报告为同目录 `result.json`,截图为 `A-menu*.png`、`B-manage*.png`、
|
||||
`C-settings*.png`、`D-inference*.png` 和 `D-tasks.png`。
|
||||
|
||||
此次为界面调整,未改后端生命周期;隔离目录没有安装模型,模型请求为 0,仅检查真实服务快照与任务空态。
|
||||
日志仍有既有字体 CSP 拦截,未捕获 JavaScript 异常。未验证模型启停、安装包或整个项目的功能验收。
|
||||
工作区原有 Runtime 与 story-graph 修改保留,未创建提交。
|
||||
|
||||
## 2026-09-17:轻量应用菜单与两个固定应用
|
||||
|
||||
对应 FR-3、FR-5、FR-11、FR-12、US-B5 至 US-B8、US-D1/US-D2。当前生产源码的底部应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选。点击应用行关闭菜单并打开主内容区;底部“管理应用”打开既有管理 Modal。
|
||||
|
||||
[ApplicationMenu](../../../src/renderer/src/ApplicationMenu.tsx) 与 [AnchoredMenu](../../../src/renderer/src/AnchoredMenu.tsx) 承担菜单及键盘、定位;[App](../../../src/renderer/src/App.tsx) 固定依次显示知识库、智能心跳,再显示已启用且常驻的可选应用。[管理 Modal](../../../src/renderer/src/ApplicationCenter.tsx) 中两个固定应用仅提供“打开”,不提供启用、常驻、排序或通用空设置;原工作区配置及心跳单条计划 enabled 不变。
|
||||
|
||||
[设置契约](../../../src/shared/application-settings-contracts.ts) 的可选偏好仅包含 `magic-notes`、`local-inference`。未发布草案中的 `knowledgeEnabled`、`heartbeatEnabled` 已删除,不增加迁移或兼容读取。[Store](../../../src/main/application-settings-store.ts) 持久化后发布权威快照的链路保留,包含配置工具写入;重开刷新期间锁定修改,较新快照使迟到的旧读取和保存响应失效。
|
||||
|
||||
本节收录本轮提供的最新验证结果;此次文档编辑未重新运行产品测试或模型请求:
|
||||
|
||||
- `npm run typecheck` 通过,`npx eslint src` 通过。
|
||||
- 完整 `npm test` 已结束:4,448 项通过、67 项跳过、2 项失败。两项仍为心跳数据库迁移的 `duplicate column name: pinned`,相关迁移未改动;全量测试未通过。
|
||||
- 全仓 `npm run lint` 仍有 `docs/features/story-graph/demo.js` 的 22 个 `document` 全局声明错误,属于无关文档 Demo;全仓 lint 未通过。
|
||||
- Electron fixture 共 8 个主题/尺寸场景通过,覆盖原生键盘、焦点、菜单几何、原生视图遮挡及实际 `ApplicationSettingsStore` 持久化。此证据验证组件 fixture 和真实设置存储,不是完整生产 App 的端到端验收,也不是安装包验收。
|
||||
|
||||
完整生产 App 补充验证:`npm run build:bundle` 成功后,以隔离用户目录启动仓库 package 入口,
|
||||
使用实际 `out/main/index.js`、生产 Preload 和 App,未替换 IPC handlers。已验证上拉菜单、
|
||||
“管理应用”Modal、两个固定应用仅有“打开”、笔记常驻切换,以及刷新 Renderer 和磁盘配置的
|
||||
持久化结果;本机推理页通过真实注册 IPC 取得 ASR、OCR、向量和 TTS 状态。已审阅菜单与管理截图。
|
||||
证据位于临时目录 `abcd-production-08rUQA` 的 `result.json`、`A-menu.png`、`B-manage.png`、
|
||||
`D-inference.png` 和 `runtime.log`。生产启动日志有字体被 CSP 阻止的消息,未捕获 JavaScript 异常。
|
||||
此轮未重启整个应用验证持久化;隔离目录没有安装模型,未执行模型启停或模型请求。
|
||||
|
||||
A–D 已接入源码并通过上述生产 App 冒烟检查;不将全量测试、安装包及整个计划标为通过。
|
||||
D 的真实 ASR、OCR、向量引擎证据及最小 Main 接线限制保留在下方原记录中,本次状态读取不扩大
|
||||
其模型操作验证范围。本机资源监控仍待实现。Demo 的直接 Modal 和知识库/心跳可选设置已过时,本轮未编辑 Demo 文件。
|
||||
|
||||
下方为历史记录,保留当时实现、测试结果及限制,旧应用级开关和旧入口描述不作为现行行为定义。
|
||||
|
||||
## 2026-09-16:知识库固定入口与设置同步更正
|
||||
|
||||
本节更正下方早期记录的现行行为,历史实现和验证记录原文保留,不作为最新修订已重新通过验证的证据。
|
||||
A–D 的应用中心 Modal、可选应用共同设置、常驻排序及本机推理均已接入生产源码,整体验收边界仍见各次记录。
|
||||
|
||||
本次文档核对的源码证据:
|
||||
|
||||
- [共享设置契约](../../../src/shared/application-settings-contracts.ts) 将可编辑 ID 限为魔法笔记、智能心跳、本机推理;知识库不参与启用、常驻或排序。版本 12 为未发布分支格式,`knowledgeEnabled` 已移除,无兼容旧草案要求。
|
||||
- [应用中心](../../../src/renderer/src/ApplicationCenter.tsx) 的知识库行只有“打开”;[App](../../../src/renderer/src/App.tsx) 在首个可选槽位后插入固定知识库,再过滤可选应用。知识库始终显示,原工作区实际配置保留;旧的禁用知识库请求守卫说明已从现行设计中删除。
|
||||
- [设置 Store](../../../src/main/application-settings-store.ts) 持久化成功后发布 `onChanged`;[IPC](../../../src/main/ipc.ts) 转发完整快照,覆盖[配置工具服务](../../../src/main/goodbuddy-config-service.ts) 的写入。App 重开中心时刷新并锁定修改,较新的事件使在途旧读取和保存响应失效。
|
||||
- [向量 Broker](../../../src/main/knowledge/embedding-inference-broker.ts) 区分调用方返回与 Worker 执行结束;取消或超时后,执行记录在 Worker 确认或退出前仍占用在途名额并计入活动任务和服务停止影响。
|
||||
|
||||
本次仅更新文档并核对源码、链接和文本一致性,未运行产品测试或实机验证,也未发起模型请求。
|
||||
README 已标明 Demo 中知识库启停、常驻及通用设置描述待独立同步;本次未修改 Demo 文件。
|
||||
|
||||
## 2026-09-16:D 本机推理生产接入
|
||||
|
||||
对应 FR-16、US-F3 至 US-F5。`LocalInferencePage` 默认导出、无 props,已与中心代理的
|
||||
`local-inference` 页面入口对接,未修改 App 生产入口或应用启用契约。真实支持范围见
|
||||
[技术清单](./technical-design.md#111-本机推理任务与执行服务)。ASR、OCR、向量生产请求均登记
|
||||
生命周期,服务操作和任务取消独立。内置向量 Broker 按模型目录共享;OCR 直接控制持有模型的
|
||||
Renderer Worker。没有新增任务持久化、虚构进度、资源值或 PID。
|
||||
|
||||
验证证据:
|
||||
|
||||
- 原始 Vitest 配置运行 App、IPC、应用中心、设置存储、推理服务、ASR、OCR、向量 Broker、
|
||||
Worker 和引擎,共 12 个文件、452 项通过。完整 App 测试使用真实页面模块,没有 resolve 替身。
|
||||
- 收尾新增 OCR 加载失败恢复用例后,11 个定向文件 202 项通过;加上已通过的完整 App 251 项,
|
||||
当前覆盖合计 453 项。最终 `npm run typecheck`、D 源码及测试定向 ESLint、`git diff --check`
|
||||
均通过;diff 检查仅有工作区已有换行符提示。验证生成的临时 bundle 已清理。
|
||||
- Windows Electron 隔离用户目录,加载当前页面与生产 CSS、生产 Preload、实际引擎模块,
|
||||
通过临时最小 Main IPC 接线运行已安装的 SenseVoice、PaddleOCR、Granite 模型,未修改模型配置。
|
||||
最终一轮:3 次向量请求(含完成/取消竞态)、1 次 ASR 静音采样请求、1 次 OCR 图片请求。
|
||||
向量返回 384 维;UI 停止、启动后再次推理成功;隐藏窗口保持服务运行;OCR 识别 `HELLO`,
|
||||
UI 加载与释放成功;1080px 和 520px 窗口无横向溢出。未调用外部付费模型。
|
||||
- 实机取消竞态中执行方自然完成,管理页正确保留完成结果;取消确认和共享进程不被终止的分支
|
||||
另由 Broker 回归测试验证。停止失败、启动失败重试、新任务影响清单变化由定向测试覆盖。
|
||||
- 真实向量验证发现浏览器 ONNX `/wasm` 入口无法 fetch 本地资源;Main 改为 Node 条件入口后
|
||||
实机推理通过,具体执行错误可回传任务列表。
|
||||
|
||||
限制与阻塞:本轮不是打包安装包验收,也未驱动完整生产 App 的所有入口与原生浏览器视图。
|
||||
实机测试 Main 使用最小接线,完整生产 IPC 注册和 App 接入由上述原始测试及类型检查覆盖。
|
||||
全量 `npm test` 在 200 秒预算内未结束;已复现心跳迁移两项 `duplicate column name: pinned`,
|
||||
独立重跑仍失败,和应用设置 v12 期望值无关,未修改该数据库迁移。全仓 lint 仍被并行工作目录
|
||||
`docs/features/story-graph/demo.js` 的 22 个 `document` 全局声明错误阻塞。不将全量测试或 lint
|
||||
标为通过。D 不修改 GoodBuddy Agent 或远程执行链路,无远程主机验证要求;没有创建提交。
|
||||
|
||||
本节取代下方早期记录中“缺少 LocalInferencePage”的当前阻塞描述,早期验证范围仍保留供追溯。
|
||||
|
||||
## 2026-09-16:应用中心、共同设置与知识联动
|
||||
|
||||
对应 US-A2、US-B5 至 US-B9。当前源码接入左下角应用中心和独立设置按钮;中心使用设置 Modal
|
||||
壳层,包含搜索、打开、常驻、拖动及上下移动。四个稳定 ID 为 `magic-notes`、`knowledge`、
|
||||
`heartbeat`、`local-inference`。应用启用、常驻和顺序独立持久化,固定入口及右侧工具不参与排序。
|
||||
|
||||
`ApplicationSettingsStore` 升为版本 12,保留已有 `false` 和评论单选值,为缺失项补默认值。
|
||||
笔记页与中心使用 `ApplicationSettingsView`;全局笔记入口已移除。保存期间共用锁,失败后读取
|
||||
确认值,无法确认时锁定修改并允许重新读取。关闭当前应用保留页面挂载及草稿,显示恢复入口。
|
||||
|
||||
知识关闭后移除会话知识控件与摘要,保留各会话关联;Main 交互请求入口在授予知识能力及
|
||||
检索前裁剪新请求范围,不取消已开始的请求。队列派发重新经过同一入口,本机和托管 SSH
|
||||
分支消费同一个请求对象。未修改 Skills、MCP、专家或本机推理页面与后端实现。
|
||||
|
||||
验证记录:
|
||||
|
||||
- 合并定向运行 App、IPC、配置与 Renderer 共 10 个 suite,599 项全部通过;随后新增本机推理
|
||||
主内容区入口用例,完整 App suite 251 项通过,合计覆盖 600 项;
|
||||
App 仍使用下述临时页面契约替身,其余生产代码未替换。
|
||||
- `ApplicationCenter.test.tsx`、设置、笔记、更新设置、共享界面与浏览器遮挡及配置契约的
|
||||
定向回归共 227 项通过;随后增加的保存失败、缺失笔记配置回归与配置契约共 39 项通过。
|
||||
- `ipc.test.ts` 和中心回归共 127 项通过,包含新请求禁用、在飞请求继续及重新开启的范围恢复。
|
||||
- `App.test.tsx` 共 251 项通过;因独立负责的 `LocalInferencePage.tsx` 尚未到位,使用临时
|
||||
Vitest resolve 插件提供无 props 空组件,仅用于验证 App 壳层,未替代或写入生产页面。
|
||||
- Windows Electron 使用当前中心组件、生产 CSS、真实 `ApplicationSettingsStore` 与临时
|
||||
最小 IPC 桥接验证常驻、排序、启用及重新读取持久化结果。浅深主题各验证 `1200 × 850` 和
|
||||
`680 × 560` 窗口,无水平溢出;搜索初始焦点、Escape、关闭焦点恢复及背景隔离通过。
|
||||
已审阅窄浅色和宽深色截图;证据位于本轮临时目录的 `application-center-*.png`。
|
||||
- 全量 `npm test` 的 JSON 报告为 4,153 项通过、4 项失败、67 项跳过,另有 suite 加载失败。
|
||||
其中应用配置契约旧快照已修正并定向通过;其余失败包括心跳旧库迁移两项、工作栏终端标题一项。
|
||||
终端标题用例随后单独复跑通过,未修改其实现。
|
||||
App suite 因缺少 `LocalInferencePage` 无法在原始配置下加载,不能将全量检查记为通过。
|
||||
- `npm run typecheck` 当前仅报告缺少 `./LocalInferencePage`;`npm run lint` 当前仅报告
|
||||
并行工作目录 `docs/features/story-graph/demo.js` 的 22 个 `document` 全局声明错误。
|
||||
本轮修改的生产源文件定向 Lint 通过。
|
||||
|
||||
剩余集成验证:接入另一实现负责的默认导出 `LocalInferencePage` 后重跑原始 App 测试与类型
|
||||
检查;完整应用的原生浏览器遮挡和共享 Linux Host 真实请求尚未实测。本轮真实模型调用为 0。
|
||||
本节不宣称本机推理后端、管理页或整个计划已交付,也没有生成提交。
|
||||
|
||||
## 2026-09-16:设置改为大 Modal
|
||||
|
||||
对应 [US-A2](./user-stories.md#us-a2-从固定入口进入设置)。设置入口保留底层工作区,关闭后卸载
|
||||
设置内容;布局、关闭与分类直达规则见 [设置中心 UI 规范](../../../UI-DESIGN.md#138-设置中心)。
|
||||
|
||||
`App.test.tsx`、`SettingsPanel.test.tsx`、`WorkspacePrimitives.test.tsx` 和
|
||||
`browser-viewport-occlusion.test.ts` 定向回归共 389 项通过。`npm run lint` 通过。
|
||||
全量 `npm test` 完成时为 4,354 项通过、9 项失败、67 项跳过;失败涉及本地 Runtime 复用超时、
|
||||
心跳旧库迁移、远程恢复及 ProjectSwitcher。共享工作区存在并行修改,最终 `npm run typecheck`
|
||||
报错位于非本次修改的 `use-unviewed-completions.test.tsx`,未将全量检查记为通过。
|
||||
|
||||
Windows Electron 使用临时用户目录、当前 Renderer 源码和已有 Main/Preload 输出,验证窗口
|
||||
尺寸 `1440 × 960`、`1024 × 700`、`680 × 560`(系统缩放使实际 CSS 高宽略有偏差)。
|
||||
浅色与深色检查通过:整窗背景隔离、左右分栏、无内容横向溢出、遮罩点击不关闭、原生 Tab 与
|
||||
Escape、关闭后保留原知识库组件。截图保存在本轮临时验证目录;没有运行打包构建,也未单独
|
||||
驱动原生 `WebContentsView`,其遮挡规则通过共享检测测试验证。本次未修改 GoodBuddy Agent
|
||||
或桌面到 Agent 的执行路径,未额外发起真实模型请求。
|
||||
|
||||
## 2026-09-16:已完成保留至查看
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E3](./user-stories.md#us-e3-完成后保留至查看)。
|
||||
已核对当前工作区 `use-unviewed-completions.ts`、`conversation-activity.ts` 和 `App.tsx` 的
|
||||
完成通知接入:Renderer 会话内集合、后台 Task 转换、本地及持久化活动运行成功标记、
|
||||
三类计数,以及聊天、设置和文档可见性条件。行为定义见[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
活动汇总、完成通知 Hook、项目活动组件、项目选择器及 App 活动集成定向回归共 77 项通过。
|
||||
覆盖前台完成、后台完成、持久化完成补齐、失败和取消、普通会话入口及级联菜单进入、
|
||||
设置覆盖聊天、窗口隐藏、历史完成不提醒、查看后刷新不重复提示和下一轮完成。
|
||||
实际聊天详情加载完成后才清除提示。最终 `npm run typecheck` 与 `npm run lint` 通过。
|
||||
|
||||
全量 `npm test` 用 900 秒预算运行,762.20 秒结束:4,365 项通过、3 项失败、67 项跳过。
|
||||
失败为 `agent-package.test.ts` 离线依赖清单安装内部 60 秒超时,以及
|
||||
`heartbeat-database.test.ts` 两项迁移的 `duplicate column name: pinned`。
|
||||
共享工作区存在并行修改,此项未改动这些文件,不将全量检查记为通过。
|
||||
|
||||
Windows Electron 43.2.0 / Chromium 150 的当前组件 fixture 验证浅深主题、1280×800 和
|
||||
375×667、混合状态及仅完成状态,共 8 个场景、112 项检查通过,8 张 PNG 已审阅。
|
||||
检查包含完成图标、三类计数、原生方向键与 Space、进入回调及移除后的空闲隐藏。
|
||||
证据为临时目录 `activity-completion-0916-results.json` 和 `activity-completion-0916-review.md`。
|
||||
此项使用受控活动数据,未验证完整 Main/Preload 真实模型端到端;模型及知识库请求为 0。
|
||||
本次只修改 Renderer 展示与状态跟踪,本地和远程均消费已有事件/Task 更新,不改变 Agent 执行路径。
|
||||
|
||||
## 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,510 @@
|
||||
# 应用导航与系统工具入口技术设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 应用中心与本机推理监控已接入源码,整体验收待完成 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| UI 设计 | [UI 设计](./ui-design.md) |
|
||||
|
||||
## 1. 现状
|
||||
|
||||
主页面没有 URL Router,由 `App.tsx` 中的 `WorkspaceView` 和 keep-alive 状态切换;该类型也包含由独立状态打开 Modal 的入口:
|
||||
|
||||
```ts
|
||||
type WorkspaceView =
|
||||
| 'chat'
|
||||
| 'magic-notes'
|
||||
| 'knowledge'
|
||||
| 'heartbeat'
|
||||
| 'local-inference'
|
||||
| 'device-sharing'
|
||||
| 'activity'
|
||||
| 'settings'
|
||||
```
|
||||
|
||||
文件、浏览器和终端已经由 `RightAssistantSidebar`、`WorkbarShell` 与 `workbar-contracts.ts`
|
||||
承载。工作区是不可关闭的单实例应用,浏览器和终端是多实例应用。浏览器多实例的跨进程
|
||||
实现以[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md)为准。代码中尚无资源指标采集和资源监控
|
||||
面板。侧栏底部已替换为应用中心与独立设置按钮。
|
||||
|
||||
应用中心、五应用共享排序、可选应用常驻及启用联动、共同设置页和本机推理监控已接入生产源码;本机资源监控的
|
||||
Main/Preload/Renderer 实现仍待交付。系统工具只使用右侧工作栏已有 Tab 和“+”链路。
|
||||
行为由[功能逻辑](./logic-design.md)定义,验证范围见[实施进度](./progress.md)。
|
||||
|
||||
## 2. 模块边界
|
||||
|
||||
| 模块 | 责任 |
|
||||
| --- | --- |
|
||||
| `App.tsx` | 组合主导航、应用中心、固定设置,持有确认配置及页面离开检查 |
|
||||
| `ApplicationMenu.tsx` / `AnchoredMenu.tsx` | 底部向上锚定的已启用应用菜单;关闭后导航,或打开既有管理 Modal;共享几何定位与键盘行为 |
|
||||
| `ApplicationCenter.tsx` | 浏览、搜索内置清单,调用既有页面导航及共同设置入口,编辑常驻和顺序;复用设置 Modal 样式和焦点工具 |
|
||||
| `ApplicationSettingsView` | 位于 `ApplicationCenter.tsx`,由应用中心对应行进入的应用配置表单 |
|
||||
| `RightAssistantSidebar.tsx` | 保留右侧工具 Tab 和“+”目录调用链路 |
|
||||
| `WorkbarShell.tsx` | 继续负责实例策略、Tab、目录和焦点行为 |
|
||||
| `workbar-contracts.ts` | 继续作为工作栏应用 ID 和实例布局的权威契约 |
|
||||
| `resource-monitor-contracts.ts`(新增) | 定义本机资源快照、可用性和错误码 |
|
||||
| `resource-monitor-service.ts`(新增) | 在 Main 中采集并规范化本机与 GoodBuddy 指标 |
|
||||
| `ResourceMonitorPanel.tsx`(新增) | 按需轮询、保留有界历史并渲染资源面板 |
|
||||
| `application-settings-contracts.ts` | 定义常驻、顺序及应用启用字段;保留笔记既有配置键 |
|
||||
| `application-settings-store.ts` | 迁移默认值,串行合并更新并原子保存,返回完整确认快照 |
|
||||
| `PlatformFeaturesSettingsSection` | 移除全局笔记设置页签、跳转入口及专用回调,保留通用设置和远程项目 |
|
||||
|
||||
应用中心只编排应用导航和配置,不读取文件、创建 PTY、操作浏览器或采集系统指标。
|
||||
不增加左侧工具组件、左侧工具状态镜像或主侧栏到工作栏的专用命令通道。
|
||||
|
||||
## 3. Renderer 契约
|
||||
|
||||
### 3.1 内置应用清单
|
||||
|
||||
```ts
|
||||
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat' | 'local-inference' | 'device-sharing'
|
||||
|
||||
type BuiltInApplicationDefinition = {
|
||||
id: BuiltInApplicationId
|
||||
source: 'builtin'
|
||||
titleKey: string
|
||||
descriptionKey: string
|
||||
view: BuiltInApplicationId
|
||||
}
|
||||
```
|
||||
|
||||
全部应用 ID 在 `application-settings-contracts.ts` 的 `BuiltInApplicationId` 定义;`EditableApplicationId` 包含 `magic-notes`、`heartbeat`、`local-inference`。Renderer 的 `applicationDefinitions` 映射图标、国际化键和可选应用启用键,知识库始终可用。
|
||||
`App.tsx` 默认导入 `LocalInferencePage`,传入 `onClose`、`enabled` 和 `restoreFocus`。`local-inference` 保留为入口 ID,`setView` 在提交工作区切换前将其转为 `localInferenceOpen = true` 并返回,不更新 `view` 或 KeepAlive 缓存;组件按该独立状态挂载及卸载。
|
||||
静态清单复用既有应用页面和国际化键。管理 Modal 的搜索只过滤该清单,包含未常驻和关闭的应用,不请求
|
||||
远程目录。启用、常驻和排序从确认配置派生,不在清单中持久化第二份状态。
|
||||
|
||||
### 3.2 应用与设置导航
|
||||
|
||||
应用中心调用既有 `navigateFromSidebar` 或同一页面导航函数,沿用未保存离开检查和焦点
|
||||
策略。打开前检查确认的启用值;未常驻但已启用的应用不附带常驻写入。本机推理监控从常驻入口、菜单及管理 Modal 均经 `setView('local-inference')` 打开独立 Modal,保留底层工作区与设置草稿;其他应用继续页面导航。
|
||||
`ApplicationMenu` 按五个应用共用的持久化顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。设备共享使用默认 `false` 的 `deviceSharingEnabled`,启用后进入主内容区页面,无常驻字段;迁移与关闭规则见[设备共享技术设计](../device-sharing/technical-design.md#当前首期实现)。
|
||||
应用中心对应行的设置操作仅接受 `EditableApplicationId`;应用页和全局设置不提供重复入口。知识库的管理行提供“打开”和排序,原工作区实际配置保留,不添加通用空设置页。
|
||||
共同页复用设置 Modal 容器、配置读取及更新方法,不建立独立配置存储或重复表单。
|
||||
|
||||
### 3.3 工作栏选择与创建
|
||||
|
||||
已有 Tab 仅按实例 ID 激活,保留目标和历史状态。不得按当前项目、执行空间或 Conversation
|
||||
查找最近匹配实例替代用户选择,也不在选中时自动重建退出或失败终端。
|
||||
|
||||
“+”选择终端每次调用既有创建流程,解析当前 `ExecutionSpaceSelection`,绑定本机或托管
|
||||
SSH 项目的权威目录;无项目时使用本机 Home。选择浏览器每次创建额外实例,绑定当前
|
||||
Conversation,无 Conversation 时创建待绑定空实例。同一 Conversation 的浏览器页面独立,
|
||||
登录上下文共享规则继续由工作栏负责。文件按单实例聚焦,缺失时仅由布局修复补回。
|
||||
选择现有文件 Tab 保留跟随或固定模式,固定目标失效时不切换目标。
|
||||
|
||||
### 3.4 资源监控工作栏应用
|
||||
|
||||
在 `workbar-contracts.ts` 中增加 `resources`:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'resources',
|
||||
instancePolicy: 'single',
|
||||
defaultContext: 'application'
|
||||
}
|
||||
```
|
||||
|
||||
该应用不加入升级后的默认打开实例,仅通过右侧已有 Tab 或“+”目录访问,使用既有单实例
|
||||
规则创建或聚焦。左侧应用清单不包含 `resources`。
|
||||
|
||||
## 4. 应用导航偏好
|
||||
|
||||
### 4.1 设置结构
|
||||
|
||||
在 `ApplicationSettings` 增加独立字段:
|
||||
|
||||
```ts
|
||||
type ApplicationNavigationSettings = {
|
||||
order: BuiltInApplicationId[]
|
||||
pinned: Record<EditableApplicationId, boolean>
|
||||
}
|
||||
```
|
||||
|
||||
`applicationNavigation` 保存五个应用的完整顺序和三个可选应用的常驻布尔值。默认顺序为
|
||||
`knowledge`、`heartbeat`、`magic-notes`、`local-inference`、`device-sharing`;心跳和魔法笔记常驻默认 `true`,本机推理监控默认 `false`,设备共享无常驻开关。
|
||||
权威返回 schema 和更新 schema 均要求五个 ID 各出现一次,拒绝重复、未知和缺失 ID;`pinned` 包含三个可选应用,缺失 `heartbeat` 时补 `true`,显式布尔值保留。Renderer 直接读取该顺序,再按各入口的可见性过滤。知识库始终启用、常驻,无可写开关键。
|
||||
按[排序规则](./logic-design.md#3-主导航组合规则)提交完整顺序;不持久化可推导的可见 ID 列表。
|
||||
首期不预建插件字段,也不为未发布的旧隐藏 ID 草案增加迁移读取器。
|
||||
|
||||
### 4.2 与现有开关组合
|
||||
|
||||
可选应用侧栏入口可见条件如下,知识库按保存位置始终显示:
|
||||
|
||||
```ts
|
||||
const visible = featureEnabled && applicationNavigation.pinned[id]
|
||||
```
|
||||
|
||||
`magicNotesEnabled` 保留为笔记启用的唯一权威键,`localInferenceEnabled` 为本机推理监控应用启用键,默认 `true`,不在导航配置再复制 enabled map。`heartbeatEnabled` 控制心跳与监督者应用;Renderer 仅将 `settings?.heartbeatEnabled === true` 视为启用,设置未加载时也不开放入口。默认值及运行边界见[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行),Main 与侧栏接入见[监督者运行路径](../conversation-supervision/technical-design.md#2-运行路径)。关闭可选应用时保留常驻控件和恢复入口。
|
||||
|
||||
笔记配置使用 `magicNotesShowIncompleteTodoCount`、`magicNoteCommentMode`、
|
||||
`magicNoteCommentFormat` 和 `magicNoteCanvasPageCount`,应用中心与笔记页使用同一 schema 和更新链路。
|
||||
画布发送页数默认 1、范围 1~8,具体选择规则见[画布分析契约](../magic-notes/technical-design.md#canvas-analysis-and-comments)。未配置的笔记
|
||||
布尔值(含 `magicNotesEnabled`)默认 `true`;迁移必须用字段是否存在区分缺失与显式 `false`,
|
||||
不能用 `value || true`。已有单选值保留,缺失时分别使用 `immediate` 和 `combined`。
|
||||
若旧“显示入口”实际绑定 `magicNotesEnabled`,保留其启用含义和原值;独立常驻使用新字段,
|
||||
不得直接把旧业务启用键改名为 pinned。只为确认已发布的独立显隐偏好增加对应迁移。
|
||||
|
||||
### 4.3 持久化与 IPC
|
||||
|
||||
沿用现有应用设置链路:
|
||||
|
||||
```text
|
||||
Renderer
|
||||
-> window.goodbuddy.updates.updateSettings
|
||||
-> settings:application:update
|
||||
-> application-settings-store
|
||||
-> application-settings.json
|
||||
```
|
||||
|
||||
Main 继续校验可信 sender、Zod 输入,复用 `ApplicationSettingsStore` 的更新队列,读取最新
|
||||
确认值后合并本次 patch、原子写入并返回完整 `ApplicationSettings`。写入失败不发布目标值。
|
||||
存储版本 12 补充可选应用导航和启用字段,为缺失字段补默认值;保留已有
|
||||
`true`、`false`、单选值和无关设置,不迁移或清理笔记、知识及会话业务数据。
|
||||
新安装和升级时缺失整个 `applicationNavigation` 均使用共享默认值,本机推理监控启用但不常驻;本次不增加存储版本。已保存的常驻值均保留,无法区分旧默认 `true` 与用户主动固定,不强制改为 `false`。
|
||||
|
||||
仅存储读取允许补齐旧数据:缺失的 `knowledge`、`heartbeat` 按此顺序前置,再保留已有 ID 的相对顺序,最后按默认顺序追加缺失项。旧 `[local-inference, magic-notes]` 因此变为 `[knowledge, heartbeat, local-inference, magic-notes, device-sharing]`;旧完整四项顺序保留并在末尾追加 `device-sharing`。缺失或空顺序采用默认顺序,部分 `pinned` 映射仅补缺失键,显式 `false` 保留。重复或未知 ID 仍按现有损坏文件处理规则处理,不静默去重或丢弃。版本 11 迁移时写入归一化结果;版本 12 读取时归一化,下一次设置保存时写入完整结果。IPC 和配置工具写入不接受这些部分数据。
|
||||
|
||||
版本 12 属于未发布的分支格式,原草案 `knowledgeEnabled` 已移除,不增加兼容读取器。`heartbeatEnabled` 是当前应用启用字段,由 Store 归一化缺失值并保留显式保存值。
|
||||
|
||||
## 5. 侧栏底部重构
|
||||
|
||||
原 `user-card` 整行设置按钮已替换为容器和相邻独立按钮:
|
||||
|
||||
```tsx
|
||||
<div className="sidebar-footer sidebar-footer--applications">
|
||||
<button className="nav-item" onClick={toggleApplicationMenu} aria-haspopup="menu" aria-expanded={applicationMenuOpen} ...>
|
||||
<Grid2X2 aria-hidden="true" />
|
||||
<span>{t('applications.menuLabel')}</span>
|
||||
<ChevronUp aria-hidden="true" />
|
||||
</button>
|
||||
<div className="sidebar-footer__divider" aria-hidden="true" />
|
||||
<button className="nav-item" aria-label={t('navigation.settings')} ...>
|
||||
<Settings aria-hidden="true" />
|
||||
<span>{t('navigation.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
“应用”(`applications.menuLabel`:中文“应用”、英文 `Apps`)和设置为横向等高独立按钮,使用 `nav-item` 样式和弱竖分隔线;“管理应用”菜单项使用 `LayoutGrid` 图标,管理 Modal 标题保持“应用中心” / `Application Center`。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
|
||||
保留设置预加载、焦点恢复和未保存离开确认。该调用只打开设置 Modal,不更新当前工作区或
|
||||
KeepAlive 缓存;`settingsOpen` 独立管理打开状态。关闭后卸载设置内容,下次按入口指定的分类
|
||||
和消息通道重新挂载,避免缓存上次分类覆盖本次直达目标。底层工作区继续挂载,普通关闭不跳回聊天。
|
||||
|
||||
应用菜单使用 `AnchoredMenu` 的 body Portal,向上锚定,无模态遮罩或背景隔离;`menu` 语义纳入共享浏览器 viewport 相交遮挡。管理 Modal 及共同设置沿用原 Portal、焦点隔离、恢复及 Escape 机制。
|
||||
不在主侧栏复制工具入口。工作栏仍由窗口右上方固定开关展开和关闭,关闭后焦点返回该开关。
|
||||
|
||||
## 6. 应用可用性与知识请求
|
||||
|
||||
Renderer 从同一确认快照派生可选应用主导航、中心状态和页面可用性,依赖只包含相应
|
||||
字段。keep-alive 页挂载不代表应用仍启用;禁用后在原位置渲染不可用状态,
|
||||
保留页面草稿及业务数据,阻止新的页面操作。重新启用不触发导航或业务重放。
|
||||
|
||||
`ApplicationAvailability` 隐藏并设置业务内容为 inert,保留挂载的页面实例;重新启用从
|
||||
应用中心对应行的设置进入。笔记页面直接消费确认的评论配置,
|
||||
不因修改评论方式而重建编辑器。
|
||||
|
||||
知识库不经过 `ApplicationAvailability`;会话知识按钮、范围、检索方式和标签继续遵守原知识库规则。心跳页面受应用可用性控制,单条计划 enabled 保持不变。
|
||||
请求构建没有应用级禁用知识库的裁剪或守卫;本机、托管 SSH 和排队请求沿用原知识上下文链路。
|
||||
单个知识库实例的配置、停用、删除及取消清理仍由知识库功能负责。
|
||||
|
||||
## 7. 串行保存与确认快照
|
||||
|
||||
中心与应用页的启用、常驻、排序及笔记配置共用一个 Renderer 更新入口及 pending
|
||||
状态。任一提交未完成时,所有入口中修改这组设置的控件暂时禁用;下一次操作基于最新确认
|
||||
快照生成 patch,不提交整个过期 `ApplicationSettings` 覆盖无关字段。Main 队列仍负责串行合并。
|
||||
|
||||
`ApplicationSettingsStore.update` 在持久化成功后通过 `onChanged` 发布完整快照;IPC 转发
|
||||
`applicationSettingsChanged`,Preload 提供 `updates.onSettingsChanged`。工具配置服务调用同一 Store,
|
||||
因此配置工具写入也同步界面,保存失败不发事件。`App` 收到事件后推进读取修订号并替换快照,
|
||||
迟到的旧读取及旧保存响应不能覆盖新状态。每次重开中心都刷新配置,刷新期间锁定修改,失败后
|
||||
保留锁和重试入口;控件不先乐观生效。
|
||||
明确保存失败时保留最后确认值,就地显示可重试错误与编辑上下文。若 IPC 响应中断而写入
|
||||
结果未知,通过既有设置读取重新取得权威快照再解锁;读取失败继续显示错误并允许重试读取,
|
||||
不自动重发变更或写回旧配置。无须引入持久化回滚日志或独立导航快照文件。
|
||||
|
||||
## 8. 插件兼容边界
|
||||
|
||||
首期不增加市场 URL、YAML 导入导出、ShareServer API、插件注册表或安装服务。FR-15 的
|
||||
私有市场协议、YAML Schema、资源分发与扩展生命周期另行设计;系统 ID 不向插件开放。
|
||||
不得直接复用 DeepSeek Harness 的 npm 插件市场。后续依赖见第 11 节,首期不预建占位界面。
|
||||
|
||||
## 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 测试
|
||||
|
||||
- 固定入口不受应用启用、常驻和顺序组合影响,应用中心及设置始终可达;左侧没有重复系统工具。
|
||||
- US-B6:底部菜单向上锚定且无遮罩,只列已启用应用,不受常驻或打开历史影响;点击行关闭菜单并导航,“管理应用”进入既有可搜索 Modal,包含关闭及未常驻应用。
|
||||
- US-F3/US-F5:三个入口均打开本机推理监控 Modal 并保留底层工作区;覆盖嵌套确认的焦点循环、逐层 Escape、提交期间禁止关闭与重复执行、关闭后的入口及后备焦点恢复,以及停用内容后的键盘焦点。
|
||||
- US-B7:常驻、取消常驻、键盘及拖动排序成功后同步,重新开启及重启保留原偏好和顺序。
|
||||
- US-B8:知识库始终启用且常驻,对应行提供打开与排序,不提供启用或常驻开关;心跳应用启停按监督者逻辑验收,单条计划 enabled 不变;可选应用关闭后的不可用页保留草稿,重新开启不自动导航或重放。
|
||||
- US-B9:应用中心对应行进入笔记表单,管理启用、常驻、待办数量、评论方式及形式;笔记页和全局设置无重复入口。
|
||||
- US-B5:所有入口共享 pending,保存失败保留相同确认值;重开刷新期间锁定修改,较新事件使旧读取失效;结果未知时重读,不自动重发。
|
||||
- 文件复用不可关闭单实例;选择任一已有终端或浏览器 Tab 保留确切目标,“+”每次创建额外实例。
|
||||
- 已退出、失败的终端 Tab 不因选中而重建;工具切换不改变主页面,后台状态不抢占 Tab。
|
||||
- 窄窗口、键盘排序、共享 Switch、Tab 语义及浮层焦点恢复正确;中心和设置不被原生浏览器遮挡。
|
||||
- 资源面板只在可见活动时轮询,跳过重叠请求并把历史限制为 150 项。
|
||||
- 窗口隐藏、最小化和恢复时停止轮询并重新建立区间基线。
|
||||
- 资源部分可用、全部失败和重新进入活动状态均显示正确结果。
|
||||
|
||||
### 10.2 Main 与契约测试
|
||||
|
||||
- 顺序包含五个应用 ID 各一次;公开写入拒绝重复、缺失、未知 ID 及无效常驻字段;旧存储顺序按迁移规则补齐,不为未发布的 `knowledgeEnabled` 添加迁移。
|
||||
- 未配置的心跳应用默认关闭,其余应用启用及笔记布尔项默认开启,本机推理监控默认不常驻;已保存的启用、常驻值和评论单选值经迁移及重启保留。
|
||||
- 更新常驻或顺序不修改启用、计划或业务数据;笔记既有配置键保持语义。
|
||||
- 串行 patch 在 Main 最新快照上合并,原子写入失败不发布目标状态;响应丢失后重读实际结果。
|
||||
- Store 持久化后才发布变更,工具配置写入使用同一通知链路;知识库不受可选应用偏好影响。
|
||||
- 保存启用失败不会改变业务判断;会话关联和历史知识引用不因入口隐藏被清空。
|
||||
- 资源快照拒绝 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 采集;不支持项显示准确原因。
|
||||
|
||||
FR-16 单独覆盖 US-F3 概览去重、未知指标、US-F4 单任务取消与完成竞态、US-F5 服务
|
||||
实际启动/停止/重启或加载/释放、使用方确认、外部管理限制及失败真实状态;用接入的真实引擎
|
||||
验证共享服务不被单任务取消终止,重启不重放。设计文档检查或模拟服务不算生产路径验证。
|
||||
|
||||
## 11. 实施顺序
|
||||
|
||||
实施范围以 [PRD 分期](./prd.md#8-分期)及最新评审决定为准,逻辑映射见
|
||||
[需求对应](./logic-design.md#6-与需求的对应关系)。既有 FR/US 编号保持稳定;新增故事使用
|
||||
US-B6 应用中心、US-B7 顺序、US-B8 启用联动、US-B9 笔记迁移,以及 US-F3 概览、
|
||||
US-F4 取消、US-F5 服务生命周期。PRD、User Stories、配套设计与根 UI 规范已同步入口及持久化规则。
|
||||
|
||||
以下为实施阶段划分,应用中心及本机推理监控已接入源码,验证状态以进度记录为准。
|
||||
|
||||
1. 底部入口使用向上锚定的轻量菜单,通过“管理应用”进入既有整窗 Modal;核对已发布配置的迁移来源。
|
||||
2. 接入现有内置应用页面与最底部应用中心入口,保留独立设置和固定运行记录入口。
|
||||
3. 实现常驻、完整顺序及启用契约,迁移时保留明确旧值,通过串行保存与确认快照同步所有界面。
|
||||
4. 迁移魔法笔记共同设置,保留知识库始终可用及原工作区配置、心跳单条计划 enabled,验证可选应用关闭和重新开启。
|
||||
5. 验证右侧现有文件、终端、浏览器 Tab 的确切目标与“+”额外实例行为,不实现左侧重复工具条。
|
||||
6. 单独实现资源快照契约、Main 采集服务、Preload API 与右侧 `resources` 单实例面板。
|
||||
7. 验证键盘、焦点、浅深主题、窄窗口、设置失败、重启持久化和跨平台行为。
|
||||
|
||||
首期使用内置应用清单,不依赖远程商店、插件注册或安装服务。商店目录、推荐、安装与
|
||||
更新机制在后续独立设计中确定;Demo 中的模拟商店不接入首期产品界面。
|
||||
|
||||
本轮 `local-inference` 已接入管理 Modal、类型化 IPC 与真实引擎管理,支持范围见 11.1;
|
||||
实机和自动化验证范围见[实施进度](./progress.md)。
|
||||
|
||||
后续设计输入见 [FR-15](./prd.md#fr-15-私有化市场与-yaml-交换后续) 与
|
||||
[FR-16](./prd.md#fr-16-本机模型管理入口新增计划项):
|
||||
|
||||
- 私有市场的 YAML Schema 应由桌面本地导入与 ShareServer 发布共用,具体协议与字段在
|
||||
市场功能设计中确定。市场 URL 按设备保存,认证信息沿用受保护凭据存储;导出仅包含
|
||||
可迁移应用定义与非敏感配置。
|
||||
- 程序包与模型资源单独分发。受管下载声明不可变版本、预期字节数和 SHA-256,下载开始后
|
||||
固定来源,失败不静默切源;下载地址与重定向范围由 Main 验证,不接受 Renderer 任意覆盖。
|
||||
市场来源配置不覆盖现有模型、应用更新或 Runtime 下载源设置。
|
||||
- ShareServer 的市场发布与分发实现由其独立仓库承接,本仓库先记录依赖,不将桌面需求
|
||||
记录标为服务端已实现。
|
||||
|
||||
### 11.1 本机推理任务与执行服务
|
||||
|
||||
ASR、OCR 与内置向量的跨功能执行边界、任务控制缺口及有条件的优化方向,见[本机专用推理架构与任务控制评估](../../architecture/local-inference-task-control.md)。该评估区分源码现状、建议与待办,未将常驻化或后台宿主迁移列为已采用实现。
|
||||
|
||||
`LocalInferencePage` 默认导出,接收必需的 `onClose` 及可选的 `enabled`、`restoreFocus`,通过 `createPortal(..., document.body)` 渲染管理 Modal,复用 `PageHeader` 和共享
|
||||
`activateModalFocus`/`trapTabFocus`。`ImpactConfirmation` 使用独立 Portal 和紧凑确认样式;共享焦点工具隔离下层 Modal,并在关闭时恢复可用触发元素或指定后备焦点。`operation` ref 防止重复执行,`busy` 和 `control` 控制关闭与 Escape,交互细节见 [UI 设计](./ui-design.md#91-本机推理管理)。`local-inference-contracts.ts` 定义快照、操作与取消输入;Main 注册四个
|
||||
`local-inference:*` 通道,复用可信 sender 校验和 Zod 输入校验。Preload 只暴露读取、服务操作、
|
||||
任务取消及打开既有设置的方法,不接收 PID、命令或模型路径。
|
||||
|
||||
| 引擎 | 生产接入与可用操作 | 明确限制 |
|
||||
| --- | --- | --- |
|
||||
| ASR / sherpa-onnx | `SpeechTranscriptionService` 登记真实请求;执行路径保留独立 Worker 请求取消 | 每次请求加载、结束释放;未提供 Worker 就绪观测,模型可用时状态为 unknown,不从活动请求推断 running |
|
||||
| OCR / PaddleOCR | `DocumentOcrBroker` 登记文档请求;`document-ocr-bridge` 提供实际 Worker 状态、加载及空闲释放 | 有活动或排队请求时拒绝释放;不把终止共享 Worker 暴露为单任务取消 |
|
||||
| 内置向量 / Granite | 按模型目录复用引用计数 Broker;启动、停止、重启;取消消息等待 Worker 结果或取消确认 | 启动确认进程 spawn,模型在首个请求时加载;不把进程启动称为模型已加载 |
|
||||
| TTS | 显示当前没有执行服务 | 不提供虚假任务或控制 |
|
||||
| 外部向量连接 | 快照契约保留配置,Modal 不显示;通过既有模型设置管理 | 本页不主动探测,不推断本机归属,不控制外部进程 |
|
||||
|
||||
`LocalInferenceService` 在 Main 内存中登记真实 ASR、OCR、向量请求,保留活动请求和最近
|
||||
100 条终态记录。只保存能力来源、时间、模型(执行路径有提供时)、状态和错误,不保存音频、
|
||||
图片或向量输入文本。运行阶段包括排队、模型加载及执行,没有统一阶段进度;来源未提供项目或
|
||||
会话对象时不补造对象关联。Modal 仅使用活动请求生成影响确认,不展示任务历史或单任务取消。
|
||||
|
||||
向量执行端通过 `utilityProcess.fork` 创建独立进程。每个 transport 持有自己的资源采样器,按当前 `child.pid` 从 `app.getAppMetrics()` 匹配 Utility 条目。服务快照的 `resources.scope` 为 `service-process`,携带 PID、采样时间及可用指标;无进程或无法采集时为 `unavailable` 并给出原因。Broker 清除退出的 transport,重启使用新采样器,不复用旧进程指标。
|
||||
|
||||
Modal 可见且启用时每 5 秒读取一次快照,跳过重叠请求。CPU 使用累计 CPU 秒数之差除以单调时钟间隔,100% 表示一个逻辑核心,可超过 100%;首次读取、PID 或创建时间变化、采样间隔超过 15 秒、计数回退及采样失败后重新建立基线。15 秒阈值为定时器延迟和跳过重叠请求留出余量。工作集由 Electron 的 KiB 转为契约中的字节,界面显示 MiB。缺少 CPU 时间或内存读数时只标记该指标不可用,不补零。数值为整个向量服务进程的占用,包含运行时和并发使用方,不是单任务或模型权重大小。
|
||||
|
||||
ASR 使用主进程中的 `node:worker_threads`;OCR 使用渲染进程中的 Web Worker,两者均没有独立进程工作集,逐行标明无法独立统计 CPU/内存。没有将主进程或整个 Renderer 的读数归属给服务。显存未采集,本机资源监控工作栏应用仍未实现。
|
||||
|
||||
服务操作按 serviceId 串行,操作期间拒绝新增该服务请求;停止前校验所有当前活动 taskId
|
||||
都在用户确认清单内,新增使用方要求刷新后重新确认。向量停止等待实际 child exit;失败保留
|
||||
可观测状态和错误。Renderer 操作失败时关闭旧确认并重新读取快照,刷新期间或失败时禁用服务操作;再次操作须重新确认当前活动请求。重启不重放中断请求。任务取消与服务停止分开,完成竞态保留执行方结果。
|
||||
|
||||
向量 Broker 将调用方 Promise 与 Worker 执行记录分开。调用方取消或超时可先返回错误,
|
||||
执行记录在 Worker 确认、返回结果或进程退出前仍为活动项,保留在途名额并计入服务停止影响;
|
||||
停止等待超时也不能提前移除记录。请求取消与自然完成竞争时采用执行方结果。
|
||||
|
||||
OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Renderer 模块提供。手动加载
|
||||
同样复用既有 Worker,加载超时恢复为可重试错误;空闲 60 秒自动释放。Modal 启用、文档可见且内容未被 hidden/inert 隔离时每 5 秒刷新,跳过未结束的请求;嵌套确认期间暂停新快照读取。
|
||||
关闭 Modal 时卸载组件并清理轮询,窗口隐藏、关闭管理应用或取消常驻不会触发服务停止。退出整个进程继续
|
||||
沿用原有应用清理路径。模型文件卸载仍在模型设置中独立进行。
|
||||
|
||||
向量 Main 引擎使用 `onnxruntime-web` 的 Node 条件入口,避免浏览器 `/wasm` 入口通过 fetch
|
||||
加载本地文件失败。执行方的具体错误随任务返回;Renderer 不把失败展示为零指标或恢复成功。
|
||||
|
||||
## 12. 项目活动汇总
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),按[活动逻辑](./logic-design.md#9-项目活动汇总)
|
||||
派生状态,界面行为见[活动界面](./ui-design.md#10-项目活动汇总)。
|
||||
|
||||
- `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见
|
||||
`assistantTasks`、`completedConversationIds` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
|
||||
`ProjectSwitcher` 使用单个固定双行按钮显示项目名与全局活动,活动为空时第二行显示空闲文案,统一打开 `WorkspaceMenu` 并默认预览当前项目;生产 `App` 不再挂载独立 `ProjectActivity` 级联菜单。按钮通过 `aria-describedby` 关联活动描述及控件外的恢复反馈,尺寸与样式见活动界面。
|
||||
- `use-unviewed-completions.ts` 在 Renderer 内存维护完成集合和上一轮 Task 状态;已观察到的
|
||||
顶层 Task 转为 `completed` 时加入集合,初始历史不生成通知。`App` 的本地 `done` 事件和
|
||||
活动运行持久化快照的成功终态调用 `markConversationCompleted`,失败、取消不调用。
|
||||
新运行及 Task 恢复运行或等待处理时清除旧通知。具体优先级和通知生命周期以活动逻辑为准。
|
||||
- `App` 仅在 `view === 'chat' && !settingsOpen && !applicationCenterOpen` 时向 Hook 传入当前会话 ID;Hook 监听
|
||||
`visibilitychange`,文档不为 `hidden` 时才按当前会话清除通知。这使普通侧栏、活动菜单
|
||||
导航和可见性恢复共用查看规则,KeepAlive 挂载本身不代表查看。集合不写入 SQLite 或设置。
|
||||
- 聚合状态新增 `completed`,全局和项目计数包含 `attention`、`running`、`completed`,
|
||||
同一 Conversation ID 只按最高优先级计数;活动行为空时以“暂无活动”替换计数,保留全局入口。
|
||||
- `App` 向 `ProjectSwitcher` 传入 `conversationStore`、活动行、侧栏可见状态和确切会话跳转、新建回调。`WorkspaceMenu` 持有分类、预览范围、搜索及状态筛选;分类复用 `PageTabs`,状态筛选复用 `SegmentedControl`,项目行与真实设置/创建表单继续由 `ProjectSwitcher` 提供。
|
||||
- 本地创建使用共享 `projectCreateSchema`,`rootPath` 经 trim 后要求 1 至 4096 个字符,`projectsCreate` IPC 在写库前校验。`projectUpdateSchema` 保留原有空目录兼容性,`projectsUpdate` 请求显式使用该 schema;持久化执行空间、默认项目和通道初始化不增加目录限制。`ProjectSwitcher` 在创建按钮和保存入口检查空目录,设置保存沿用原规则。
|
||||
- `workspace-menu-selectors.ts` 订阅会话 Store,投影 ID、项目、标题、排序时间及通道标记,以字段相等比较保留未变化结果;排序时间为 `Math.max(conversationActivityTime(conversation), activityTimes.get(conversation.id) ?? 0)`。与活动行按 ID 合并后派生分组和最近 10 条;仅有活动、尚无摘要的行使用 Store 时间,缺失时取 0。悬停只改变 Renderer 预览范围,不发 IPC、不加载详情或请求模型。打开菜单所需的既有主机快照读取与悬停预览分开。
|
||||
- Store 的 `activityTimes` 保存本次应用会话内的访问/状态变化时间,更新时间为 `Math.max(Date.now(), lastActivityTime + 1)`。`ProjectSwitcher` 调用 `recordActivityStatuses`:首次快照只建立基线,后续状态变化触发更新,但 `completed` 变为无状态的完成提醒清除不触发更新。`WorkspaceMenu` 经 `useSyncExternalStore` 订阅该时间映射;时间不持久化,悬停不更新,普通侧栏原有消息排序规则不变。
|
||||
- 可见会话行的 `WorkspaceConversationTime` 通过 `useWorkspaceConversationTime` 按 ID 订阅 Store,使用 `formatConversationListTime(updatedAt, locale)` 和 `formatMediumDateTime`,`time.dateTime` 保留对应快照的实际 ISO 时间。选择器按显示文字及提示相等复用结果,忽略显示精度以下的流式变化;时间变化只重渲染该子组件,不向整菜单投影加入原始 `updatedAt`。缺少摘要时返回空值,不读取详情。
|
||||
- 项目、会话列表均使用共享 `useListWindow`,分别估算 `64px`、`56px` 行高并测量实际高度,保留键盘目标行;范围改变时重置滚动。窗口化仅限制挂载行数,不截断活动集合,不代表已有性能基准结果。
|
||||
- 菜单通过 `FloatingPortal` 显示同一双栏表面,定位和焦点恢复共用项目按钮的引用;按钮尺寸、窗口和滚动变化时重算位置。顶部由按钮底边加 `8px` 决定,最大高度为视口底边减顶部及 `16px` 边距,不读取菜单高度参与定位。`ResizeObserver` 只观察按钮,避免菜单收缩后反复改变自身位置。非模态 `dialog` 不调用焦点隔离,Escape 关闭整个菜单,Tab 可访问内部控件,焦点离开后关闭。窄窗口仍为双栏,侧栏隐藏时卸载菜单。
|
||||
- 浮层沿用共享 `browser-viewport-occlusion.ts` 相交检测,不增加浏览器实例的特殊隐藏条件,也不关闭或重建浏览器会话。
|
||||
- `conversationStore.rememberOpened` 在 `App.setActiveId` 时为存在的会话记录访问时间,并记住其项目最后打开的 ID。`projectConversation` 优先返回仍在候选集合中的记忆 ID,否则按 `conversationActivityTime` 选择最近会话,不使用菜单的访问/状态时间;通道项目只选带 `remote` 的会话。记忆仅存在当前 Store 的 Map 中,不持久化、不跨重载恢复。普通项目无会话时创建,通道无会话时清空活动 ID。
|
||||
- 项目进入、新建会话及创建项目后的导航均经过 `requestWorkspaceLeave`。获准后的 `commitProjectSelection` 与新建会话 `ready` 回调直接调用 `commitView('chat')`,避免经 `setView` 再次触发离开检查;新建会话就绪后才提交项目、会话及输入框焦点。
|
||||
- `WorkspaceMenu.onEnterProject` 接到 `ProjectSwitcher.onSelect`,与项目行共用 App 的离开检查及会话恢复。归档入口首次点击才调用 `projects.list(true)` 并筛选 `status === 'archived'`,当前菜单内缓存结果,重新打开菜单后按需重读;不新增启动读取、轮询或逐项目请求。
|
||||
- `restore-project.ts` 调用既有 `projects.setArchived(id, false)`,成功后并行读取活动项目和会话摘要,只合并目标项目摘要,以 `mergePersistedConversations` 保留本地未保存正文及需要保留的详情,再发布 App 项目列表。恢复不调用选择、新建或任务执行路径;摘要读取失败时保留重试入口,重复设置活动状态可安全重试。归档预览不读会话详情,正常进入才按原路径加载历史。后端通道限制、远程功能检查及删除保护不变。
|
||||
- 持久化快照刷新时,同一条仍在流式输出的消息保留本地待审批、待回答信息,持久化终态
|
||||
则清除这些信息。响应成功后对应 Task 立即退出等待状态;响应期间到达的新问题、审批或
|
||||
终态不能被旧响应覆盖。
|
||||
- `AssistantDatabase.listTasks()` 保留最近历史窗口(默认 100 条),额外包含窗口外当前
|
||||
运行或等待处理的可见 Task;`activeVisibleTaskSelect` 与 `toTask` 的有效状态保持一致,
|
||||
待调度运行规范化为 `queued`,不因旧 Task 状态被误纳入实时集合。
|
||||
- `listConversations()` 的范围与排序遵守
|
||||
[会话列表读取与前端保留](../assistant-workbar/execution-history-storage.md#会话列表读取与前端保留),
|
||||
包含上述 Task 关联及含 `streaming` 消息的较早活动会话,避免后台工作被最近记录挤出。
|
||||
`listTasks()` 仍按原时间顺序返回;列表结果允许超过历史窗口大小。
|
||||
- `openActivityConversation` 按确切 Conversation ID 导航;内存中缺失时刷新持久化会话
|
||||
列表并合并,再查找同一 ID。刷新失败或会话已不存在时显示通知,不回退到项目最新会话。
|
||||
设置离开检查通过后才提交项目、会话及界面清理状态。
|
||||
- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。
|
||||
|
||||
完成通知的定向验证应覆盖本地成功、持久化活动运行成功、后台 Task 状态转换、初始历史、
|
||||
失败和取消、实时状态优先、三类计数、普通侧栏查看、设置覆盖、隐藏文档与恢复可见、无活动时的空闲占位。
|
||||
统一菜单还应覆盖固定双行单按钮及焦点、当前项目默认范围和菜单内全部项目切换、分类与跨分类搜索、搜索框边界与两栏分隔、紧凑工具栏、预览无 IPC、最近 10 条与不限量活动筛选、真实设置/创建入口、会话期恢复及窗口化键盘导航。验证记录见[实施进度](./progress.md);源码核对不能替代真实 Electron 路径验证。
|
||||
@@ -0,0 +1,342 @@
|
||||
# 应用导航与系统工具入口 UI 设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 应用中心与本机推理监控已接入源码,整体验收待完成 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 全局规范 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) |
|
||||
|
||||
本文定义已接入的应用中心、共同设置和本机推理监控界面,以及待实现的右侧资源监控界面。
|
||||
行为以[功能逻辑](./logic-design.md)为准,需求保留 FR-1 至 FR-16 与既有 US 编号。
|
||||
根 UI 规范已同步应用中心与工具入口规则;共享控件、主题、设置 Modal 与工作栏布局遵守根规范。
|
||||
|
||||
## 1. 侧栏信息架构
|
||||
|
||||
```text
|
||||
品牌
|
||||
项目名称(固定双行控件的第一行)
|
||||
全局活动汇总/暂无活动(第二行)
|
||||
[新对话] [搜索图标]
|
||||
|
||||
当前对话
|
||||
应用(按保存顺序;知识库始终显示,可选应用仅显示启用且常驻项)
|
||||
运行记录
|
||||
|
||||
最近对话
|
||||
...
|
||||
|
||||
────────────────────────
|
||||
[应用 v] | [设置]
|
||||
```
|
||||
|
||||
“当前对话”和“运行记录”属于固定系统入口,常驻应用位于二者之间。首期应用清单为魔法笔记、
|
||||
知识库、智能心跳及本轮追加的本机推理监控,应用中心与设置始终可见。侧栏底部不再显示“本地工作区”和 `platform · arch`;
|
||||
排障信息移至关于或诊断页面。左侧不再提供文件、终端、浏览器、资源监控的重复入口。
|
||||
|
||||
### 会话搜索
|
||||
|
||||
新建对话与搜索共用原新建对话位置的一行。默认显示 `MessageSquarePlus` 图标与完整“新建对话”文字,整体左对齐,沿用原有 13px、600 字重和 12px 左内边距,右侧为搜索图标;
|
||||
点击搜索后,输入框从右向左展开,新建对话只显示居中的同一图标,保留“新建对话”可访问名称与悬停提示。
|
||||
最窄侧栏中的长标签允许在固定行高内换行,保留字号和图标。搜索右边缘和整行高度保持不变,列表不因展开或关闭而上下移动。宽度动画使用
|
||||
`--motion-normal`,减少动态效果时立即切换;沿用桌面侧栏调宽和窄窗口抽屉尺寸。
|
||||
通道项目继续不提供新对话按钮,搜索展开后使用整行宽度。
|
||||
控件行与新建对话按钮高 `44px`;搜索框本体高 `36px`,上下各留 `4px`,垂直居中。保留 `2px` 焦点外框与 `2px` 外间距,聚焦后外沿与新建对话按钮上下对齐。
|
||||
|
||||
展开时聚焦输入框。X 始终可用,X 或控件行内的 Escape 清空关键词并收起输入框,
|
||||
焦点返回搜索图标。失焦、点击会话列表保持搜索状态和关键词;新建对话图标继续调用原新对话操作。
|
||||
无结果区域的“清除搜索”只清空关键词,保留展开状态。项目活动跳转仍沿用既有清空搜索规则。
|
||||
|
||||
## 2. 底部应用中心入口
|
||||
|
||||
底部使用连续平整表面和顶部分隔线,“应用”在前,“设置”靠右,二者为横向排列的等宽、等高 `nav-item` 独立按钮,内容各自居中,竖分隔线位于底部入口区域的正中间:
|
||||
|
||||
| 位置 | 图标 | 可见文字 | 点击结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `Grid2X2` + `ChevronUp` | 应用 / Apps | 向上展开已启用应用菜单,底部“管理应用”进入管理 Modal |
|
||||
| 末位 | `Settings` | 设置 / Settings | 在当前工作区上打开设置 Modal |
|
||||
|
||||
图标使用项目现有 Lucide 库。两个按钮均保留可见文字,沿用主导航的高度、间距和焦点样式;
|
||||
“应用”文字后显示向上箭头,与菜单展开方向一致。竖分隔线宽 1px、高 `--space-6`,使用 `--border-default`,与上方横分隔线颜色一致。不得把整个容器做成设置按钮。
|
||||
|
||||
### 2.1 状态
|
||||
|
||||
- 默认:透明背景,图标使用次级文字色。
|
||||
- 悬停:使用弱表面色,不能强于当前选中状态。
|
||||
- 展开:应用中心按钮显示展开反馈,不能当作当前应用的选中状态。
|
||||
- 键盘焦点:显示统一焦点环,不能被侧栏边缘裁切。
|
||||
- 加载:读取应用配置时保留入口,中心内显示加载状态;失败提供重试,不用默认值冒充已保存配置。
|
||||
|
||||
应用中心入口不承载工具运行徽标。全部可选应用关闭时菜单仍显示知识库及“管理应用”,可进入 Modal 重新开启可选应用。
|
||||
|
||||
## 3. 主导航应用区
|
||||
|
||||
应用入口沿用现有 `nav-item` 行,不使用应用卡片。图标、名称和徽标保持单行:
|
||||
|
||||
```text
|
||||
[图标] 知识库
|
||||
[图标] 智能心跳 1
|
||||
[图标] 魔法笔记 3
|
||||
[图标] 本机推理监控
|
||||
```
|
||||
|
||||
取消常驻或关闭可选应用后不留下空白。知识库始终启用并显示;心跳按启用和常驻显示,位置遵守共享应用顺序。运行记录始终位于应用区之后。
|
||||
五个应用均在管理行上通过拖动及“上移”“下移”按钮排序;首尾无目标时禁用对应按钮。两种方式提交完整应用顺序,保存成功后更新,重启保留。当前对话、运行记录和底部系统入口不参与排序。
|
||||
|
||||
取消常驻不关闭当前页面或本机推理监控 Modal。重新常驻按已保存顺序恢复,关闭应用保留常驻与顺序偏好。
|
||||
主侧栏始终读取最后确认配置,不展示尚未保存成功的排序或开关目标值。
|
||||
|
||||
## 4. 应用中心与共同设置
|
||||
|
||||
设备共享追加到默认顺序末尾,测试阶段默认关闭。管理行显示“可选”及启用状态,提供打开、
|
||||
设置与排序按钮;设置内提供启用 Switch,不提供常驻开关。关闭时禁用“打开”,应用菜单只在
|
||||
启用时显示该项,点击后在主内容区打开[设备共享页面](../device-sharing/ui-design.md#当前首期页面)。
|
||||
|
||||
底部点击打开轻量菜单,锚定按钮向上展开,无模态遮罩、背景隔离或焦点循环。菜单按共享保存顺序列出已启用应用,不按打开历史或常驻状态过滤。点击应用行关闭菜单,本机推理监控打开独立 Modal,知识库、智能心跳和魔法笔记在主内容区打开页面。分隔线下的“管理应用”关闭菜单并打开既有管理 Modal。
|
||||
|
||||
菜单通过共享 `AnchoredMenu` 挂载到 body,宽度最多 `280px`,距窗口边缘至少 `16px`,与按钮上缘间隔 `8px`;短窗口限制高度并内部滚动,滚动、窗口和内容尺寸变化时重算位置。原生浏览器相交遮挡复用共享机制。
|
||||
|
||||
“管理应用”菜单项使用 `LayoutGrid` 图标。管理 Modal 标题仍为“应用中心”(英文 `Application Center`),沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置:
|
||||
|
||||
应用管理始终采用紧凑单列横向列表,从上到下对应保存顺序;行间使用 `--border-subtle` 弱分隔线,不加卡片外框。概览与设置详情共用 `640px` 最大宽度,窄窗口保留两侧各 `16px` 外边距,高度随内容增长并受窗口限制。
|
||||
每行左侧显示图标、名称、状态和简短用途,右侧对齐打开、可用的设置及上下移动操作;空间不足时操作组换到下一行并保持右对齐,组内也可换行,上下箭头保持成组。名称旁区分
|
||||
“始终显示”(英文 `Always shown`)和“可选 · 已启用/已关闭”。名称与用途可自然换行,不截断用途说明;正文与控件均不可横向溢出。
|
||||
|
||||
```text
|
||||
应用中心
|
||||
[搜索内置应用]
|
||||
知识库 始终显示 [打开] [上移] [下移]
|
||||
智能心跳 可选 · 已关闭 [打开(禁用)] [设置] [上移] [下移]
|
||||
魔法笔记 可选 · 已启用 [打开] [设置] [上移] [下移]
|
||||
本机推理监控 可选 · 已启用 [打开] [设置] [上移] [下移]
|
||||
```
|
||||
|
||||
已启用但未常驻的应用可以直接打开,打开不自动常驻。关闭的应用仍能被搜索和配置,
|
||||
保留禁用的“打开”按钮,可通过“设置”重新开启。启用、常驻放在设置详情中;每行右侧提供成组的上下箭头,与打开、设置操作按空间换行。箭头使用共享图标按钮,名称和提示包含方向及应用名;目标取完整保存顺序中的相邻应用,仅首尾无目标或修改锁定时禁用对应箭头。无搜索词且未锁定修改时,所有行均支持拖动排序,包括关闭或未常驻的应用;搜索时禁用拖动,箭头仍按完整保存顺序操作,未命中的应用保留在顺序中。无搜索结果显示清除搜索入口。
|
||||
首期不展示商店页签、推荐、安装按钮、市场 URL 或 YAML 操作,系统工具也不在此列出。
|
||||
|
||||
### 4.1 共同应用设置页
|
||||
|
||||
智能心跳、魔法笔记、本机推理监控的通用应用设置统一从应用中心对应行的“设置”进入;应用页面不再提供重复快捷入口。本机推理监控保留“模型与连接设置”。知识库在管理 Modal 中提供“打开”和排序,不提供启用、常驻开关或通用空设置;原工作区实际配置入口保留。
|
||||
共同页遵守[设置中心 UI 规范](../../../UI-DESIGN.md#138-设置中心),不为每个入口建立一套设置。
|
||||
全局设置移除魔法笔记页签及跳转入口。旧“显示魔法笔记入口”绑定业务启用值,迁为“启用应用”并保留原值;
|
||||
“常驻左侧菜单”使用独立配置,只有已发布的独立显隐偏好才映射到常驻。
|
||||
|
||||
| 控件 | 表达含义 |
|
||||
| --- | --- |
|
||||
| 启用应用 | 控制业务可用性及主导航、会话等业务入口 |
|
||||
| 常驻左侧菜单 | 仅控制主导航捷径,关闭应用时保留该偏好,并说明开启后才显示 |
|
||||
| 显示未完成待办数量 | 仅控制魔法笔记导航徽标 |
|
||||
| AI 评论方式 | 即时/保存后自动/保存后手动,单选 |
|
||||
| AI 评论形式 | 长评 + 要点/长评/要点,单选 |
|
||||
|
||||
二元配置使用共享 Switch 和 `role="switch"`;评论选项使用共享单选控件。本机推理监控
|
||||
默认启用但不常驻;心跳应用默认关闭,常驻偏好默认开启,其余未配置布尔项默认开启。评论默认值沿用既有值,已有保存的选择保留。启用与常驻不可合并。
|
||||
设置详情最大宽度 `640px`,紧凑标题栏包含返回图标、应用名称、用途及关闭按钮。进入详情和
|
||||
返回列表时正文滚动位置重置到顶部;进入详情聚焦关闭按钮,返回后聚焦搜索框。
|
||||
共同设置表单使用共享 Switch,相关字段分组,组间使用弱分隔线;说明沿用正文字号。
|
||||
评论分段控件按内容占宽,窄窗口中标签与选项上下排列;详情不提供排序控件。
|
||||
智能心跳单条计划 CRUD 及 enabled 仍由其业务页面负责,应用级开关及执行条件见[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)。
|
||||
|
||||
所有入口共享保存中状态。串行保存期间禁用相关启用、常驻、排序及笔记配置控件,保留
|
||||
最后确认值;持久化成功后的设置事件同步中心、侧栏、会话、应用页及其他设置入口,包括工具配置写入。重开中心刷新期间锁定修改,迟到的旧读取不能覆盖更新快照。失败在发起操作
|
||||
附近显示可重试错误并保留焦点;结果未知时显示“正在确认保存结果”,重读成功后再开放修改。
|
||||
|
||||
### 4.2 关闭后的页面与会话
|
||||
|
||||
关闭当前可选应用成功后,原页面或本机推理监控 Modal 显示“应用已关闭”,重新启用通过应用中心对应行的设置完成;本机推理监控需先关闭 Modal。保留草稿和内容状态,
|
||||
暂停该页面的新业务操作。重新开启后恢复可用性,不自动跳到已离开的应用页或重放任务。
|
||||
|
||||
知识库不显示应用关闭状态;会话知识控件、历史引用和工作区配置遵守原功能规则。心跳关闭时的侧栏行为见[监督者 UI 设计](../conversation-supervision/ui-design.md#会话侧栏)。可选应用的已有请求和后台任务继续按功能生命周期展示,详细边界见[关闭应用与恢复](./logic-design.md#32-关闭应用与恢复)。
|
||||
|
||||
## 5. 助手工作栏反馈
|
||||
|
||||
系统工具仅从右侧工作栏已有 Tab 或“+”目录进入,工作栏按现有布局回流,主内容页面保持不变。
|
||||
|
||||
- 文件:激活默认存在且不可关闭的“工作区”Tab,焦点进入目录区域或错误修复入口。
|
||||
- 终端:选择已有 Tab 激活该确切终端并保留目标与历史状态;“+”每次创建额外终端,按当前
|
||||
项目选择本机或托管 SSH,无项目时使用本机 Home。新建期间显示进度,失败保留诊断。
|
||||
- 浏览器:选择已有 Tab 保留其 Conversation、页面及导航状态;“+”每次创建额外浏览器,
|
||||
无活动 Conversation 时新建待绑定空实例。不会按当前会话改选其他 Tab 或重绑已有实例。
|
||||
- 资源监控:激活“资源监控”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. 文案
|
||||
|
||||
固定使用以下名称:
|
||||
|
||||
| 对象 | 文案 |
|
||||
| --- | --- |
|
||||
| 底部入口 | 应用 / Apps |
|
||||
| 管理 Modal 标题 | 应用中心 / Application Center |
|
||||
| 菜单底部管理入口 | 管理应用 |
|
||||
| 应用设置开关 | 启用应用 |
|
||||
| 文件系统工具 | 文件 |
|
||||
| 终端系统工具 | 终端 |
|
||||
| 浏览器系统工具 | 浏览器 |
|
||||
| 浏览器说明 | GoodBuddy 受控浏览器 |
|
||||
| 资源系统工具 | 资源监控 |
|
||||
| 资源范围 | 本机 |
|
||||
| 指标缺失 | 不可用 |
|
||||
| 应用常驻开关 | 常驻左侧菜单 |
|
||||
| 关闭的应用页 | 应用已关闭 |
|
||||
| 内置来源 | 内置 |
|
||||
|
||||
不再使用“本地工作区”作为侧栏底部身份名称。项目和默认工作区的路径继续由项目选择器、页面
|
||||
范围或工作区面板显示。
|
||||
|
||||
## 7. 键盘和无障碍
|
||||
|
||||
- 底部应用中心和设置均为独立 `button`,Tab 顺序与视觉顺序一致,不嵌套按钮。
|
||||
- 图标按钮通过 `aria-label` 提供名称,工具提示不能作为唯一可访问名称。
|
||||
- 工作栏打开后按现有焦点策略聚焦目标面板;用户通过固定工作栏开关关闭时,焦点返回该开关。
|
||||
- 应用中心按钮提供 `aria-haspopup="menu"`、`aria-expanded` 和控制关系;菜单使用 `menu`/`menuitem`,打开后聚焦首个可用项,上下方向键循环选择,Home、End 到首尾,Enter、Space 激活。
|
||||
- Escape 关闭菜单并恢复入口焦点;Tab 关闭后继续正常焦点顺序,点击外部、焦点离开或再次点击入口也关闭。菜单不隔离背景,不增加遮罩。
|
||||
- “管理应用”打开的 Modal 使用共享 Portal、整窗遮罩、原生浏览器遮挡、背景隔离和焦点循环,初始焦点进入搜索。行操作与详情控件按正常 Tab 顺序访问,保留共享可见焦点环,不伪装成单个菜单项;详情切换焦点见共同应用设置页。
|
||||
- 工作栏使用 `tablist`、`tab`、`tabpanel`、`aria-selected`、游标焦点和方向键、Home、End。
|
||||
“+”使用普通按钮语义,同类型 Tab 仍按实例独立标记。
|
||||
- 徽标数量或状态合并进可访问名称;Switch 支持 Space,失败保留焦点。排序按钮名称包含
|
||||
应用名,操作结果通过 polite 状态区播报;拖动不是唯一排序方式。
|
||||
|
||||
## 8. 响应式
|
||||
|
||||
- 侧栏宽度 `220px` 至 `420px` 时,应用中心与设置均可操作,不随宽度缩小点击区域。
|
||||
- 窄窗口继续使用现有侧栏抽屉,底部入口不重叠;应用菜单向上展开并限制窗口边界、内部滚动。管理 Modal 的长名称可换行,搜索、设置与重新开启入口始终可达。
|
||||
- 右侧 Tab 单行横向滚动,“+”持续可见;工作栏保持停靠回流,不遮罩主页面。
|
||||
- 工具提示不得超出窗口;靠近底边时显示在按钮上方。
|
||||
- 粗指针设备不依赖悬停,按钮的可访问名称和选中反馈保持有效。
|
||||
|
||||
### 会话置顶与操作菜单
|
||||
|
||||
会话行的“更多会话操作”提供“置顶会话/取消置顶”,本地与消息通道会话均可使用。
|
||||
置顶会话显示带“已置顶”可访问名称的图标;当前项目和搜索过滤仍生效,过滤后的列表先显示
|
||||
置顶会话,两组内各按最新消息的创建时间倒序;时间相同按会话 ID 固定顺序,无消息时使用
|
||||
会话更新时间。新增消息在每 5 秒的排序检查时可以前移会话,仅顺序变化时调整位置;同一消息的文字、推理、工具进度和完成状态更新不
|
||||
改变排序。有消息的会话重命名或修改设置也不前移。摘要刷新、打开详情和重新启动沿用同一
|
||||
规则,行右侧仍显示实际更新时间。取消置顶恢复消息时间排序,不改变会话更新时间。
|
||||
置顶状态重启后保留;保存成功后更新图标和顺序,失败保留原状态并显示应用错误通知。
|
||||
存储尚未就绪或置顶请求进行中时禁用置顶操作。
|
||||
|
||||
消息内容、标题与运行状态实时更新。鼠标停留在列表内、焦点在列表内、滚动尚未停止
|
||||
200ms 或会话操作菜单打开时,跳过自动排序;操作结束后在下一次 5 秒检查时调整。
|
||||
新建、删除、置顶和取消置顶立即反映到列表中,不提前应用其他会话待处理的位置变化。
|
||||
切换项目或搜索范围立即显示对应列表;排序计时器不因消息增量而重新计时。
|
||||
|
||||
操作菜单通过 body Portal 浮在会话列表上方,不撑开会话行。菜单锚定更多按钮,默认位于
|
||||
按钮下方,随滚动、窗口及菜单尺寸变化重新定位,距窗口边缘至少 `8px`;空间不足时限制
|
||||
宽高并允许菜单内部滚动。使用共享表面色、边框、圆角与浮层阴影。
|
||||
|
||||
入口提供 `aria-haspopup="menu"`、展开状态和控制关系;菜单使用 `menu`/`menuitem`。
|
||||
打开后聚焦首个可用按钮,上下方向键循环移动,Home/End 到首尾;Escape 关闭并返回
|
||||
触发按钮,点击外部或焦点离开时关闭。本地会话继续提供分支、重命名和删除确认,复制与
|
||||
导出也适用于消息通道会话。持久化与刷新规则见
|
||||
[会话置顶存储](../assistant-workbar/execution-history-storage.md#会话置顶)。
|
||||
|
||||
### 聊天阅读状态
|
||||
|
||||
在同一历史面板内,滚动到别处再返回时,消息的推理、工具结果、工具输入、引用和子代理详情保留用户的展开或收起选择。子代理收起后重新展开,其内部工具详情也保留选择;不同消息及父子工具分别记忆。状态保留在历史面板中,不依赖把所有已展开的消息留在 DOM 中;面板被缓存淘汰或应用重启后不恢复这些临时阅读选择。
|
||||
|
||||
未操作的折叠项不留下默认状态记录;用户恢复默认选择、消息或对应折叠项被删除后,释放相应记录。正常滚动卸载不视为删除。
|
||||
|
||||
推理仍在流式输出时默认展开、完成时自动收起,流式期间手动收起不因滚动重挂载而重新展开。有焦点、文本选区端点、待回答问题和待审批项的消息继续按既有规则保留挂载;平滑跳到底部及滚动锚定行为不变。
|
||||
|
||||
## 9. 未来插件入口
|
||||
|
||||
FR-15、US-F1/US-F2 的市场 URL、YAML 导入导出及 ShareServer 分发在后续设计中定义。
|
||||
首期中心只浏览本地内置清单,不显示未来市场的空壳界面。
|
||||
|
||||
### 9.1 本机推理管理
|
||||
|
||||
FR-16 的 `local-inference` 入口已纳入本轮应用清单,可常驻主导航。从常驻入口、应用菜单和管理 Modal 的“打开”均进入同一本机推理监控 Modal,关闭来源菜单或管理 Modal,保留底层工作区、项目、会话和草稿。其他内置应用仍打开主内容区页面。
|
||||
Modal 与真实执行模块已接入;支持清单及验证范围分别见技术设计和实施进度。
|
||||
复用 `custom-task-dialog` 的整窗 body Portal、遮罩、背景隔离及浏览器遮挡机制。主 Modal 最大宽度 `960px`,高度随内容增长,最大高度为 `min(720px, 100dvh - 2 × --space-6)`;紧凑标题栏和关闭按钮固定在顶部,正文独立滚动。直接显示服务列表,没有页签或任务历史。工具栏显示“每 5 秒刷新 · CPU 100% = 一个逻辑核心”、模型与连接设置、刷新状态,窄屏可换行。服务行显示“服务进程 PID … · CPU …% · 工作集 … MiB”,预热时显示“正在建立采样基线”;共享进程的 worker 在该行显示无法独立统计的原因。
|
||||
初始焦点进入“关闭本机推理监控”,Tab/Shift+Tab 在当前 Modal 内循环。没有影响确认且没有操作提交时,关闭按钮或 Escape 关闭 Modal;点击遮罩不关闭。关闭后优先恢复仍可用的原触发元素,入口已移除时回到应用中心,窄侧栏已隐藏时回到侧栏开关。
|
||||
服务采用弱分隔列表;名称使用区块标题字号,状态及引擎元数据使用
|
||||
辅助字号,状态与名称同组并与操作区分开,原因与操作说明使用正文字号。桌面操作独立位于右侧;
|
||||
窗口宽度不超过 `720px` 时移到正文下方并左对齐,保留完整状态和原因。
|
||||
|
||||
- US-F3 每行显示服务名、简短状态、引擎、模型及错误。ASR 可用时显示“按需运行(状态未知)”;OCR 和向量使用执行模块的状态。TTS 显示不可用,外部连接不列入本机服务。
|
||||
- US-F4 不再提供单任务取消入口;活动任务仅在服务操作的影响确认中出现。
|
||||
- US-F5 服务行右侧保留实际支持的启动、停止、重启或模型加载、释放。
|
||||
- 停止、重启、释放前打开最大宽度 `480px` 的紧凑嵌套确认,列出正在使用的任务与影响;无已登记任务时说明执行前再次核对。初始焦点进入“保留运行状态”,最终危险按钮写明“确认停止服务/重启服务/释放模型”;取消保留运行状态。
|
||||
确认使用独立 body Portal,隔离底层管理 Modal,Tab/Shift+Tab 只在确认中循环;Escape 只关闭确认,关闭后恢复原服务操作按钮,按钮不可用时回到管理 Modal。确认存在时禁用主 Modal 关闭按钮;提交期间禁用确认和取消按钮,两层 Escape 均不能关闭,也不能重复执行。失败关闭旧确认,在服务列表显示错误并刷新;再次操作须重新确认当前影响。成功只关闭确认。
|
||||
操作失败呈现实际状态和可重试错误,无法确认则显示未知。服务恢复不自动重放任务。
|
||||
关闭 Modal、关闭管理应用或取消常驻不停止服务,模型文件卸载另行操作。
|
||||
|
||||
服务列表提供首次加载、无服务、加载失败重试及保留旧快照的刷新失败状态;刷新期间或失败时禁用服务操作。
|
||||
OCR 忙碌或模型不可用时禁用加载与释放,并在执行模块再次检查。
|
||||
文档解析与 HTTP OCR 的配置归属见[文档处理入口设计](../document-processing/ui-design.md#11-入口与职责),
|
||||
不因接入 HTTP 而扩展本机服务列表。“模型与连接设置”现直达 `model`,OCR 服务行的
|
||||
“文档解析设置”直达 `document-parsing`,关闭设置返回原工作区;实施与验证见
|
||||
[文档处理进度](../document-processing/progress.md#2026-09-19-实现与验证)。
|
||||
页面不显示无来源的进度条或资源数值。US-B6 至 US-B9 的应用中心、顺序、启用联动、笔记共同设置映射见
|
||||
[需求对应](./logic-design.md#6-与需求的对应关系)。
|
||||
|
||||
## 10. 项目活动汇总
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),状态与计数遵守
|
||||
[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
左上角是固定高 `60px` 的单个按钮,第一行显示项目名称,第二行显示全局待处理、运行中、已完成数量,无活动时显示“暂无活动”。点击任一行都打开锚定整个按钮的工作空间菜单,默认预览当前项目;菜单内点击“全部项目”切换至全局范围。按钮只有一个键盘焦点、焦点环及展开状态,`aria-controls` 关联菜单,`aria-describedby` 关联活动描述和控件下方的远程恢复反馈。活动变化不改变按钮高度,也不关闭已打开的菜单;长内容单行截断。
|
||||
|
||||
左栏固定搜索、分类及底部“新建项目”,项目列表独立滚动。分类使用共享 `PageTabs` 的 `segmented` 变体,依次为“全部项目、本地、远程、消息通道”,保留 `tablist`/`tab`/`tabpanel`、游标焦点及方向键语义;远程能力关闭时隐藏远程分类。搜索跨分类匹配,SSH Host 分组可折叠,连接状态独立于会话数量。
|
||||
|
||||
新建本地项目的根目录字段常显“根目录为必填项,请选择目录后创建项目。”,英文为“Required: select a root folder to create the project.”。只读路径框声明 `aria-required`,通过 `aria-describedby` 关联提示;沿用目录选择按钮。名称或目录去除首尾空白后为空时禁用“创建”。取消目录选择保留原选择和其他草稿;项目设置不显示创建专用提示,也不要求历史空目录项目补选目录。
|
||||
|
||||
底部“新建项目”旁放低强调的“已归档”按钮。点击后左栏保留搜索框,以“已归档”和“返回”替代顶部分类,列表显示名称、目录与独立“恢复”按钮,继续按本地和 SSH Host 分组。加载、空列表、搜索无结果和加载失败重试分别呈现;异步错误与恢复成功使用共享应用通知。恢复期间禁用重复操作,完成后移除该行,键盘焦点仍在该行时移到“已归档”按钮;“返回”恢复选中分类的焦点。
|
||||
|
||||
左栏使用 `--surface-subtle`,与右栏之间以 `1px` 的 `--border-control` 竖线分隔。搜索框使用 `border-box`,宽度包含边框和内边距,并扣除左右外边距,避免越过分隔线。全部项目及搜索结果按实际存在的本地、远程、消息通道显示分组标题,组间用横线和留白区分;远程组内保留 Host 标题。
|
||||
|
||||
项目行显示名称、活动数量、目录三行,无活动时省略数量行,高度随内容增长;恢复状态继续显示在目录下方。当前项目以勾选和 `aria-checked` 标识,预览使用单独背景。设置按钮独立,桌面悬停或聚焦显示,窄窗口及粗指针设备可直接访问;打开既有真实项目设置,保留目录选择、Runtime、模式、删除及远程恢复等适用操作,不触发项目切换。悬停或聚焦项目只预览右栏,点击项目直接进入;通道无会话时保留空态。
|
||||
|
||||
右栏顶部用共享 `SegmentedControl` 显示“全部、待处理、运行中、已完成”,采用辅助字号和紧凑内边距,与靠右的“新建会话”同排。新建使用预览项目,全部项目范围使用当前项目;消息通道禁用并显示自动创建说明。默认会话列表按待处理、运行中、最近会话分组,最近最多 10 条,完成未查看在行内标注;“已完成”筛选不受此上限限制。会话标题单行截断,全部项目范围补充项目名,不展开 Task、Job 或 Run。无活动仍显示最近会话,无匹配会话时显示空态。
|
||||
|
||||
会话行右侧显示实际更新时间,格式与普通会话侧栏一致:当天显示时分,当年显示月日,跨年带年份,随界面中英文切换;悬停提供完整日期与时分。时间使用弱文本色,状态放在其下方并右对齐。窄窗口优先截断标题,保留时间;仅有任务活动、尚无会话摘要的行省略时间,预览不为补时间读取详情。显示时间与最近排序时间的区别见[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
右栏底部固定右对齐的主按钮“进入项目”,英文为“Enter project”,工具提示和可访问名称包含目标项目名。目标与顶部“新建会话”一致,但消息通道允许进入;目标缺失时禁用。打开归档视图保持右栏原预览及该目标。按钮不随会话列表滚动,短窗口仍保留底部操作区。
|
||||
|
||||
点击会话关闭菜单并请求确切 ID 导航;通过既有未保存离开检查后,选择所属项目、清除侧栏搜索、未读标记、任务选择和会话菜单,窄窗口关闭侧栏。取消离开保留原页面和完成通知。完成通知仅在目标聊天实际可见后清除,预览、设置覆盖或隐藏文档不触发清除。
|
||||
|
||||
菜单使用共享 `FloatingPortal`,外层为非模态 `dialog`,入口提供 `aria-haspopup="dialog"`、展开状态及控制关系,不增加遮罩或背景隔离。项目列表使用 `menu`/`menuitemradio`,独立操作使用 `menuitem`;会话列表使用 `list`/`listitem` 和按钮。原生浏览器相交遮挡沿用共享机制。
|
||||
|
||||
打开后聚焦选中分类。列表内上下方向键循环移动,Home、End 到首尾;右方向键进入会话列表,左方向键返回预览项目,项目不可见时回到选中分类。Enter、Space 激活按钮;Tab 在控件间正常移动,焦点离开后关闭。Escape 关闭整个菜单并恢复仍可用的触发入口焦点,触发入口已卸载时回到项目入口,不继续关闭窄侧栏。点击外部、再次点击入口或隐藏侧栏也关闭。
|
||||
|
||||
菜单最大宽度 `920px`,目标高度 `640px`,顶部始终位于完整双行按钮底边下方 `8px`。最大高度按该位置到视口底部的剩余空间计算,并留出 `16px` 底边距;短窗口缩小菜单、让列表内部滚动,不向上移动覆盖入口或翻转到上方。左右距窗口边缘至少 `16px`。桌面左栏为 `240px` 至 `360px`,窄窗口仍保留双栏并各自滚动,不使用旧级联返回页。状态筛选可横向滚动,新建按钮保持可见。项目和会话列表均采用共享窗口化渲染,键盘可到达完整列表首尾。
|
||||
@@ -0,0 +1,535 @@
|
||||
# 应用导航与系统工具入口 User Stories
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 应用中心与本机推理监控已接入源码,整体验收待完成 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 关联 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:管理应用入口
|
||||
|
||||
本节的启用、常驻和共同设置适用于智能心跳、魔法笔记、本机推理监控;五个应用均可排序,知识库始终启用并常驻。设备共享在测试阶段默认关闭,提供启用开关,不提供常驻开关;启用后可从应用菜单和管理 Modal 打开,关闭后退出当前页面并保留目录及发布记录,旧设置缺失时默认关闭,明确启用值在重启后保留。业务验收见[设备共享首期需求](../device-sharing/prd.md#当前首期本地元数据)。心跳的默认关闭及执行边界按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收,保留既有故事 ID。
|
||||
|
||||
### US-B1 识别完整应用
|
||||
|
||||
作为用户,我希望魔法笔记、知识库和智能心跳显示在主导航,以便知道它们会打开完整页面。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 始终显示的应用,或已启用且常驻的可选应用
|
||||
- When 用户打开主侧栏
|
||||
- Then 应用位于“当前对话”和“运行记录”之间
|
||||
- And 点击知识库、智能心跳或魔法笔记在主内容区打开对应一级页面;本机推理监控打开保留底层工作区的 Modal,验收见 US-F3
|
||||
- And 原有待办或建议徽标按已保存的显示设置继续显示
|
||||
- And 当前对话之后按保存顺序显示应用;知识库始终显示,可调整位置,无启用或常驻开关;心跳按启用及常驻过滤
|
||||
|
||||
### US-B2 取消低频应用常驻
|
||||
|
||||
作为用户,我希望隐藏不常用的内置应用,以便缩短主导航,但保留已有数据。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 应用已启用且已有内容
|
||||
- When 用户关闭该应用的“常驻左侧菜单”Switch 并保存成功
|
||||
- Then 主导航不再显示该应用,当前已打开页面保持不变
|
||||
- And 应用数据、计划和业务启停状态不变
|
||||
- And 用户可从应用中心打开该应用,或从同一设置位置恢复常驻
|
||||
|
||||
### US-B4 保留功能启停语义
|
||||
|
||||
作为魔法笔记用户,我希望导航显隐与功能启停相互独立,以免整理导航时关闭功能。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 魔法笔记功能已启用
|
||||
- When 用户只取消其侧栏常驻并保存成功
|
||||
- Then 笔记及待办处理能力继续按原设置工作
|
||||
- And 重新常驻只恢复入口,不自动导航;再次打开时保留原页面状态和数据
|
||||
|
||||
### US-B5 保持设置保存一致
|
||||
|
||||
作为用户,我希望应用设置保存期间和失败后各入口保持一致,以免界面与实际配置不符。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 配置已加载且没有待确认的更新
|
||||
- When 用户修改启用、常驻、顺序或魔法笔记配置
|
||||
- Then 更新串行保存,在请求确认前,应用中心、应用页及全局设置中修改这组配置的所有控件均暂不可操作
|
||||
- And 各处保留最后确认值,不提前改变导航、业务可用性、徽标或会话入口
|
||||
- When 保存成功
|
||||
- Then 各入口同时采用确认配置,后续修改基于该配置提交
|
||||
- And 工具配置写入持久化成功后也同步各入口;较新的确认快照不被迟到的读取或保存响应覆盖
|
||||
- When 关闭后重新打开应用中心
|
||||
- Then 刷新最新配置期间锁定修改,读取失败保留重试入口,不允许用旧快照提交
|
||||
- When 保存明确失败
|
||||
- Then 各入口仍使用相同的最后确认配置,操作处显示可重试错误,保留焦点和编辑上下文,不重复通知
|
||||
- Given 响应中断,无法确认保存结果
|
||||
- When 用户查看设置
|
||||
- Then 显示正在确认保存结果,重读权威配置成功后才恢复修改;读取失败可重试读取,不自动重发变更或写回旧值
|
||||
|
||||
### US-B6 从应用中心访问内置应用
|
||||
|
||||
作为用户,我希望从固定应用中心找到应用,以便未常驻或已关闭的应用仍有访问和恢复入口。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户位于任意一级页面,包括全部可选应用均已关闭的情况
|
||||
- When 点击侧栏底部应用中心
|
||||
- Then 向上打开轻量锚定菜单,无模态遮罩;按保存顺序列出知识库和已启用的可选应用,不按打开历史或常驻筛选
|
||||
- When 点击菜单中的应用行
|
||||
- Then 菜单关闭;知识库、智能心跳和魔法笔记经既有离开检查在主内容区打开,本机推理监控打开保留底层工作区的 Modal,不自动常驻
|
||||
- When 点击菜单底部“管理应用”
|
||||
- Then 菜单关闭并打开既有管理 Modal,可按名称或用途搜索全部内置应用,包含未常驻及已关闭的可选应用
|
||||
- And 知识库行提供“打开”和排序,没有通用设置、启用或常驻开关;心跳行可管理启用和常驻,单条计划 enabled 不变
|
||||
- And 首期没有商店、推荐、安装、市场 URL、YAML 操作或系统工具入口
|
||||
- Given 应用已启用但未常驻
|
||||
- When 用户选择打开
|
||||
- Then 本机推理监控打开 Modal,底层内容无需离开;其余应用经既有未保存内容离开检查进入页面,不自动常驻
|
||||
- Given 应用已关闭
|
||||
- When 用户在管理 Modal 中找到该应用
|
||||
- Then 显示关闭状态,保留禁用的“打开”按钮,可通过“设置”进入共同设置重新开启
|
||||
- Given 搜索无匹配结果
|
||||
- Then 显示无结果及清除搜索入口,搜索不修改启用和常驻
|
||||
- Given 配置正在加载或读取失败
|
||||
- Then 中心保留加载状态或重试入口,不用默认值冒充已保存配置
|
||||
|
||||
### US-B7 保存常驻及应用顺序
|
||||
|
||||
作为用户,我希望常驻和顺序按设备保存,以便重启后仍使用熟悉的导航。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 首次使用且未配置导航偏好
|
||||
- Then 默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享;心跳默认关闭且保留默认常驻偏好,魔法笔记默认常驻,本机推理监控默认启用但不常驻,仍可从应用菜单打开;设备共享默认关闭,启用后只在应用菜单和管理 Modal 提供入口;侧栏只按启用与常驻过滤可选项
|
||||
- When 用户修改常驻,或通过拖动、键盘可操作的上移和下移按钮排序并保存成功
|
||||
- Then 管理列表从上到下、菜单和侧栏按同一保存顺序显示,知识库和智能心跳可移动;当前对话、运行记录及底部系统入口位置不变,重启后恢复保存结果
|
||||
- And 每行均提供上下移动,以完整保存顺序中的相邻应用为目标;未常驻及关闭应用仍可排序,设置详情不提供排序控件
|
||||
- And 搜索时箭头仍按完整顺序移动,保留未命中应用,拖动禁用;清除搜索后显示完整保存结果
|
||||
- Given 旧设置仅保存本机推理监控、魔法笔记的顺序
|
||||
- When 升级后读取设置
|
||||
- Then 知识库、智能心跳补在前面,两个可选应用保留原有相对顺序和常驻值,设备共享追加到末尾
|
||||
- Given 应用已关闭或取消常驻
|
||||
- When 用户重新开启或常驻
|
||||
- Then 按保存顺序恢复入口;关闭或取消常驻不删除顺序,已关闭应用仍可管理常驻偏好
|
||||
- Given 保存失败
|
||||
- Then 按 US-B5 保留原常驻和顺序,在操作处提供重试并保留键盘焦点
|
||||
- Given 没有可见的可选应用或仅有一个
|
||||
- Then 固定入口和应用中心仍可用,没有移动目标的排序按钮禁用,不产生空白占位项
|
||||
|
||||
### US-B8 独立控制启用与常驻
|
||||
|
||||
作为用户,我希望关闭应用时保留数据和导航偏好,并能明确知道哪些业务操作已不可用。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 应用已启用
|
||||
- When 仅取消常驻并保存成功
|
||||
- Then 业务能力和后台操作保持原状,仍可从应用中心打开
|
||||
- Given 知识库已有会话关联,智能心跳已有启用或暂停的计划
|
||||
- When 用户关闭全部可选应用或修改其常驻与顺序
|
||||
- Then 知识库始终启用,侧栏位置稳定,页面可打开;知识控件及关联保持可用,请求遵守知识库自身规则,心跳单条计划 enabled 原值不变
|
||||
- And 心跳应用关闭后显示不可用状态,可从应用中心设置重新开启;新运行、在途运行及侧栏查询按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收
|
||||
- Given 关闭的应用页面或本机推理监控 Modal 仍打开
|
||||
- Then 原位置显示“应用已关闭”,保留草稿及页面状态,阻止新业务操作而不强制跳转;用户从应用中心对应行的设置重新启用,监控 Modal 需先关闭
|
||||
- When 用户重新开启并保存成功
|
||||
- Then 仍停留的页面恢复可用;已离开时不自动导航,不重放或补发旧请求和任务
|
||||
- And 保留原常驻及顺序,未常驻应用不自动常驻
|
||||
- Given 启用变更保存失败
|
||||
- Then 按 US-B5 保留原业务状态和入口,不执行关闭或恢复副作用
|
||||
|
||||
### US-B9 迁移魔法笔记共同设置
|
||||
|
||||
作为魔法笔记用户,我希望在应用中心统一管理笔记设置,并在升级后保留已有选择。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户从应用中心的魔法笔记行进入设置
|
||||
- Then 显示笔记设置表单;笔记页面不提供重复入口,也不保留“显示魔法笔记入口”开关
|
||||
- And 全局设置不再显示魔法笔记页签或跳转入口,已有配置值和业务数据保持不变
|
||||
- And 启用、常驻、显示未完成待办数量分别控制业务、导航和徽标;隐藏数量不修改待办
|
||||
- Given 升级前已有明确保存的布尔值和评论选择
|
||||
- When 迁移配置并重启
|
||||
- Then 保留已有 `true`、`false`、评论单选值及业务数据;缺失导航配置时,本机推理监控默认不常驻,魔法笔记默认常驻;未配置的应用启用和笔记相关布尔项默认 `true`
|
||||
- And 旧入口开关若实际控制笔记启用,保留其启用含义和原值,不将其改作常驻
|
||||
- Given AI 评论方式或评论形式未配置
|
||||
- Then 分别默认“即时”和“长评 + 要点”,均为单选,不同时启用所有模式
|
||||
- When 任一入口修改配置并保存成功
|
||||
- Then 应用中心、主导航、笔记页、会话及其他设置入口同步采用确认值;保存中和失败均遵守 US-B5
|
||||
|
||||
## 4. Epic C:使用系统工具
|
||||
|
||||
### US-C1 一键查看文件
|
||||
|
||||
作为本地工作用户,我希望从右侧工作栏查看文件,并保留工作区面板的目标模式。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前项目有有效根目录
|
||||
- When 用户展开右侧工作栏并选择已有工作区 Tab,或从“+”选择文件
|
||||
- Then 聚焦默认存在且不可关闭的工作区单实例,不创建重复实例;仅迁移或布局修复时补回缺失实例
|
||||
- And 保留跟随或固定模式,跟随模式继续按工作栏规则更新上下文
|
||||
- And 仅该实例 Tab 显示选中状态,焦点进入目录区域
|
||||
|
||||
### US-C2 处理无效工作区
|
||||
|
||||
作为用户,我希望目录不可用时看到修复方法,并收到明确反馈。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 工作区面板处于跟随模式
|
||||
- And 当前项目和默认工作区均不可用
|
||||
- When 用户在右侧工作栏选择工作区 Tab 或从“+”选择文件
|
||||
- Then 工作区面板仍打开
|
||||
- And 面板说明目录不可用并提供选择或修复目录的入口
|
||||
- And 应用不静默改用其他目录
|
||||
- Given 工作区面板固定的目标已失效,即使当前项目或默认目录可用
|
||||
- When 用户选择工作区 Tab
|
||||
- Then 保留固定目标并显示该目标的修复状态,不回退到其他目录或切换为跟随模式
|
||||
|
||||
### US-C3 快速进入终端
|
||||
|
||||
作为本地工作用户,我希望选择确切终端继续查看,并能通过“+”创建额外终端。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 已有终端 Tab,包括绑定其他执行空间的终端
|
||||
- When 用户选择其中一个 Tab
|
||||
- Then 仅激活该确切实例,保留执行空间、目录及历史,不按当前项目查找最近匹配实例或重新绑定
|
||||
- Given 已选 Tab 正在关闭、已退出、已中断或失败
|
||||
- Then 保留其真实状态,不因选中而重建
|
||||
- Given 当前执行空间已有正在启动或运行中的终端
|
||||
- When 用户从右侧工作栏“+”选择终端
|
||||
- Then 每次创建绑定当前执行空间的额外实例,不复用或按目标去重
|
||||
- Given 当前没有项目
|
||||
- When 用户从“+”选择终端
|
||||
- Then 创建使用本机 Home 的新终端,已有终端保持原目标和历史
|
||||
|
||||
### US-C4 使用远程项目终端
|
||||
|
||||
作为远程项目用户,我希望新建终端绑定远程项目目录,以免误在本机执行命令。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前项目绑定托管 SSH 执行空间
|
||||
- When 用户从右侧工作栏“+”选择终端
|
||||
- Then 创建绑定该远程项目及其权威目录的额外终端
|
||||
- And 面板持续显示远程主机和工作目录
|
||||
- And 连接失败时保留远端目标、重连与诊断信息,不回退为本机终端
|
||||
- When 切换当前项目后重新选择该终端 Tab
|
||||
- Then 仍激活原远程实例,不重绑到新项目
|
||||
|
||||
### US-C5 打开受控浏览器
|
||||
|
||||
作为用户,我希望从右侧工作栏使用 GoodBuddy 受控浏览器,并按需创建独立页面。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 已有浏览器 Tab,当前 Conversation 与其目标不同或没有活动 Conversation
|
||||
- When 用户选择该 Tab
|
||||
- Then 激活该确切实例,保留绑定目标、页面及导航状态,不重绑、重建或改选最近匹配实例
|
||||
- Given 当前有活动 Conversation,无论是否已有匹配实例
|
||||
- When 用户从工作栏“+”选择浏览器
|
||||
- Then 每次创建绑定当前 Conversation 的额外实例,页面和导航状态独立,同一 Conversation 内共享隔离登录上下文
|
||||
- Given 当前没有活动 Conversation,无论是否已有浏览器实例
|
||||
- When 用户从“+”选择浏览器
|
||||
- Then 创建待选择目标的空实例,不复用已有实例
|
||||
- And 以上操作均使用 GoodBuddy 受控浏览器,不打开系统默认浏览器
|
||||
|
||||
### US-C6 查看本机资源
|
||||
|
||||
作为本地工作用户,我希望快速查看 CPU、内存和磁盘 IO,以便判断任务变慢是否与本机负载
|
||||
有关。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 资源监控面板尚未打开
|
||||
- When 用户从右侧工作栏“+”选择资源监控
|
||||
- Then 助手工作栏创建并激活单实例资源监控面板
|
||||
- And 页面显示整机 CPU、内存、磁盘读写及 GoodBuddy 桌面进程摘要
|
||||
- And 可见活动期间每 2 秒采样一次,前次请求未结束时跳过新请求,图表最多保留 150 个真实时间样本,连续采样约 5 分钟
|
||||
- Given 资源监控实例已存在
|
||||
- When 用户选择其 Tab 或再次从“+”选择资源监控
|
||||
- Then 激活同一本机单实例,不新建重复面板
|
||||
- When 用户切换到其他工作栏 Tab、收起工作栏、隐藏窗口或最小化窗口
|
||||
- Then 资源监控停止请求新样本
|
||||
- When 用户再次显示窗口并返回资源监控
|
||||
- Then 首次采样建立区间指标基线,下一次采样开始显示 CPU 和磁盘 IO 速率
|
||||
- And 内存等瞬时指标可立即显示,基线未建立时不显示虚构零值
|
||||
- Given 某项指标在当前平台不可用
|
||||
- When 页面收到部分结果
|
||||
- Then 该指标显示“不可用”及原因,其他指标继续更新
|
||||
- Given 本次采样全部失败或超时
|
||||
- Then 显示失败原因和重试入口,保留已有历史并标明最后更新时间,不把旧值当作实时数据
|
||||
- And 失败或暂停采样期间保留真实时间缺口,不补零或伪造中间样本
|
||||
- When 可见活动期间自动重试或用户手动重试成功
|
||||
- Then 按真实采样结果恢复更新,较晚返回的失活前旧请求不覆盖当前结果
|
||||
|
||||
### US-C7 从任意应用打开工具
|
||||
|
||||
作为用户,我希望在魔法笔记、知识库、智能心跳和运行记录页面也能打开系统工具。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户位于任意一级页面
|
||||
- When 用户展开右侧工作栏,选择已有工具 Tab 或通过“+”选择文件、终端、浏览器或资源监控
|
||||
- Then 主页面保持不变
|
||||
- And 右侧工作栏打开对应面板
|
||||
- And 工具不会覆盖主内容或伪装成一级页面
|
||||
- And 创建失败时工作栏保持展开并显示对应错误,主页面和其他实例不被清空
|
||||
- And 后台事件不自动展开工作栏或切换当前 Tab,左侧和应用中心不复制工具入口或选中状态
|
||||
|
||||
## 5. Epic D:可访问性和布局
|
||||
|
||||
### US-D1 使用键盘管理应用与访问工具
|
||||
|
||||
作为键盘用户,我希望浏览和管理应用,并通过右侧工作栏访问工具,以便不依赖鼠标。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户使用键盘访问侧栏底部
|
||||
- Then 应用中心和设置是独立按钮,Tab 顺序与视觉顺序一致,Enter 和 Space 可激活,图标按钮有可访问名称和工具提示
|
||||
- When 打开应用中心
|
||||
- Then 入口提供菜单展开状态和控制关系,焦点进入首个可用菜单项;上下方向键循环移动,Home、End 到首尾,Enter、Space 激活
|
||||
- When 按 Escape 关闭菜单
|
||||
- Then 焦点返回应用中心入口;Tab 关闭菜单后继续正常焦点顺序,点击外部或焦点离开也关闭
|
||||
- When 选择“管理应用”
|
||||
- Then 关闭菜单并进入既有 Modal,焦点进入搜索;每行的打开、设置、排序及详情中的启用、常驻控件按正常 Tab 顺序可达,不伪装成单个菜单项;进入详情及返回时滚动归顶,返回后聚焦搜索
|
||||
- When 通过键盘修改常驻或顺序
|
||||
- Then Switch 使用共享开关语义并支持 Space,上移和下移按钮名称包含应用名,结果通过 polite 状态区播报,失败保留焦点
|
||||
- Given 用户进入右侧工作栏
|
||||
- Then Tab 选择、游标焦点、方向键及 Home、End 由工作栏负责,使用 `tablist`、`tab`、`tabpanel` 和 `aria-selected`,按确切实例表达选中状态
|
||||
- And “+”为普通按钮,工具状态纳入 Tab 可访问名称;激活后焦点进入面板,关闭工作栏后返回固定工作栏开关
|
||||
- And 焦点环不被边界裁切,应用中心及设置沿用共享浮层和原生浏览器遮挡处理
|
||||
|
||||
### US-D2 在窄窗口访问应用中心与工作栏
|
||||
|
||||
作为小屏用户,我希望应用管理和工具实例在窄窗口中仍可操作。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 侧栏宽度为 `220px` 至 `420px`,或窄窗口使用侧栏抽屉
|
||||
- Then 底部应用中心和设置不重叠,不因宽度不足隐藏入口或缩小点击区域
|
||||
- When 打开应用菜单或通过“管理应用”进入 Modal
|
||||
- Then 菜单向上锚定且限制在窗口内,可内部滚动;Modal 中长名称可换行,搜索、常驻、排序、设置及重新开启入口均可达
|
||||
- And 触控不依赖悬停,工具提示保持在窗口内,点击区域符合统一规范
|
||||
- When 关闭侧栏抽屉
|
||||
- Then 焦点返回原触发按钮
|
||||
- Given 右侧工作栏有多个工具实例
|
||||
- Then Tab 单行横向滚动且“+”持续可见,工作栏停靠回流,不遮罩主页面;布局和 Tab 交互仍由工作栏负责
|
||||
|
||||
## 6. Epic E:统一工作空间菜单
|
||||
|
||||
### US-E1 从摘要进入确切会话
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总)。用户在当前项目工作时,可从项目选择器下方
|
||||
查看跨项目活动,先定位项目,再进入需要处理的会话。
|
||||
|
||||
- Given 多个项目存在运行中和待处理会话,当前搜索仅匹配当前项目
|
||||
- When 打开全项目活动并悬停或键盘聚焦另一个项目
|
||||
- Then 同一菜单右栏预览该项目会话,待处理、运行中优先,最近会话最多 10 条,当前页面和会话保持原选择
|
||||
- And 分类依次为全部项目、本地、远程、消息通道;远程能力关闭时隐藏远程分类及项目,搜索跨分类,远程主机可折叠
|
||||
- When 点击项目
|
||||
- Then 进入该项目,恢复本次应用会话内上次打开的有效会话,否则选择最近会话;普通项目没有会话时新建,消息通道保持空态
|
||||
- When 点击会话
|
||||
- Then 进入该确切 Conversation ID,清除搜索、任务选择和会话菜单,窄窗口关闭侧栏
|
||||
- Given 设置页有未保存修改
|
||||
- Then 会话跳转仍需通过既有离开检查,取消离开保留设置草稿和原项目
|
||||
- Given 用户选择的确切 Conversation ID 未在内存中或已被删除
|
||||
- When 请求进入该会话
|
||||
- Then 刷新持久化会话列表后仍只查找同一 ID;刷新失败或会话不存在时显示通知并保留原页面和选择,不回退到项目最新会话或其他会话
|
||||
- Given 从项目入口打开菜单
|
||||
- Then 默认预览当前项目;点击“全部项目”恢复全局范围,“已完成”筛选列出全部完成未查看会话,不受最近 10 条限制
|
||||
- When 实际打开会话、产生新消息或活动状态变化
|
||||
- Then 菜单按最新消息与本次应用会话内访问/状态变化时间中的较晚者排序;首次状态载入和仅清除完成提醒不更新时间,悬停不改变排序
|
||||
- And 行右侧显示会话实际更新时间,中英文格式与会话侧栏一致;访问或任务状态变化不冒充会话更新时间,没有会话摘要时省略时间且不请求详情
|
||||
- When 点击与状态筛选同排的“新建会话”
|
||||
- Then 在预览项目创建,全部项目范围使用当前项目;消息通道禁用手动新建并说明自动创建规则
|
||||
- When 点击项目行独立设置按钮或左栏底部“新建项目”
|
||||
- Then 打开真实设置或创建表单;设置不触发项目切换,创建成功后经离开检查进入新项目
|
||||
- And 项目进入或新建会话已获准离开后,提交聊天视图不再次触发同一离开检查
|
||||
|
||||
### US-E2 键盘、窄窗口与实时结束
|
||||
|
||||
- Given 菜单已打开
|
||||
- Then 初始焦点进入选中分类,上下方向键与 Home、End 移动列表焦点,右方向键进入会话列表,左方向键返回预览项目或选中分类;Enter、Space 激活按钮
|
||||
- And 菜单顶部距完整入口底边 `8px`,短窗口内保留底部 `16px` 边距,列表内部滚动;入口两行仍可点击,时间与标题不重叠
|
||||
- When 按 Escape
|
||||
- Then 关闭整个工作空间菜单并恢复可用触发入口焦点,窄侧栏保持打开
|
||||
- Given 窄窗口或长列表
|
||||
- Then 两栏有清晰分隔并独立滚动,搜索框不越过左栏边界,全部项目及搜索结果按本地、远程、消息通道显示分组标题;紧凑状态筛选可横向滚动,新建按钮保持可见,触控点击项目直接进入
|
||||
- Given 当前项目已无待处理、运行中或已完成未查看会话
|
||||
- Then 显示最近会话或空态;全局无活动时第二行显示“暂无活动”,仍可打开菜单并在其中切换全部项目范围,已打开的菜单仍可使用,新活动不自动打开菜单
|
||||
- And 左上角是一个固定两行高度的按钮,项目名和全局活动共用一个键盘焦点;点击任一行都默认预览当前项目,恢复提示显示在按钮下方并关联可访问描述
|
||||
- When 点击外部、焦点离开或隐藏侧栏
|
||||
- Then 菜单关闭,后台新活动不自动重新展开
|
||||
- And Tab 可在菜单内访问搜索、分类、项目设置、创建及会话按钮,焦点离开后关闭
|
||||
|
||||
### US-E3 完成后保留至查看
|
||||
|
||||
对应 [FR-14](./prd.md#fr-14-项目活动汇总),具体状态规则见[活动逻辑](./logic-design.md#9-项目活动汇总)。
|
||||
|
||||
- Given 用户正在其他会话或应用中工作
|
||||
- When 本次应用会话内的运行成功完成,或已观察到的后台任务转为完成
|
||||
- Then 活动会话保留为“已完成”,全局与项目的已完成计数同步更新
|
||||
- When 从活动菜单或普通侧栏实际进入该会话的可见聊天
|
||||
- Then 清除其完成通知;没有其他活动行时第二行恢复“暂无活动”,已查看会话仍可出现在最近会话中
|
||||
- Given 聊天被设置覆盖或文档隐藏
|
||||
- When 对应运行完成
|
||||
- Then 完成通知保留,直到设置关闭且目标聊天在可见文档中显示;取消设置离开不清除通知
|
||||
- Given 目标聊天已可见且未被设置覆盖
|
||||
- When 运行成功完成
|
||||
- Then 不保留该会话的完成通知
|
||||
- Given 初始加载已完成任务历史,或任务失败、取消
|
||||
- Then 不产生新的完成通知;重载或重启也不恢复上一应用会话的通知
|
||||
- Given 同一会话仍有待处理或运行中工作
|
||||
- Then 按实时状态优先展示,只计入一个类别
|
||||
|
||||
### US-E4 恢复归档项目并显式进入
|
||||
|
||||
对应 FR-14。
|
||||
|
||||
- Given 用户打开项目菜单
|
||||
- When 点击左下角“已归档”
|
||||
- Then 在左栏查看和搜索已归档用户项目,右栏保持原预览;首次加载显示进度,失败提供重试及应用通知
|
||||
- When 点击某个项目的“恢复”
|
||||
- Then 项目回到原本地/远程分类,当前项目和聊天保持不变;失败保留该行并允许重试,提交期间禁止重复恢复
|
||||
- Given 恢复后的项目已有会话
|
||||
- When 返回项目列表,预览该项目并点击右下角“进入项目”
|
||||
- Then 通过离开检查后按既有规则恢复会话,不因摘要尚未刷新创建替代会话
|
||||
- Given 范围为全部项目
|
||||
- Then “进入项目”目标为当前项目,按钮提示及可访问名称包含项目名;悬停和键盘预览不导航
|
||||
- Given 远程项目功能关闭,或项目属于系统消息通道
|
||||
- Then 归档列表不提供对应恢复操作,正常消息通道分类继续可用
|
||||
|
||||
## 7. 后续需求场景
|
||||
|
||||
US-F1、US-F2 为后续市场计划;US-F3 至 US-F5 的本机推理监控已接入源码,当前支持边界见技术设计,验证范围见实施进度。
|
||||
|
||||
### US-F1 连接组织应用市场
|
||||
|
||||
对应 [FR-15](./prd.md#fr-15-私有化市场与-yaml-交换后续)。
|
||||
|
||||
- Given 组织已部署提供应用市场的 ShareServer
|
||||
- When 用户配置市场 URL,并在来源要求时完成登录
|
||||
- Then 可以浏览该来源的应用目录、详情及可获取版本
|
||||
- And 市场 URL 按设备保存,重启后恢复;认证信息使用受保护凭据存储,不作为可导出配置
|
||||
- Given 市场不可达、URL 无效、登录过期或认证失败
|
||||
- When 用户访问该来源
|
||||
- Then 显示具体原因及修改地址、重新登录或重试入口,已安装和内置应用仍可打开,不静默切换公共市场或其他来源
|
||||
- Given 用户修改市场来源或开始获取应用资源
|
||||
- Then 市场配置不改变模型、应用更新及 Runtime 等独立下载源;已开始的下载保持原来源,失败不静默切源
|
||||
|
||||
### US-F2 导入导出应用定义
|
||||
|
||||
对应 FR-15。
|
||||
|
||||
- Given 用户需要在私有部署间迁移应用
|
||||
- When 导出应用为 YAML,并在另一台设备选择 `.yml` 或 `.yaml` 文件导入
|
||||
- Then 预览应用身份、版本、可迁移配置和依赖,凭据与业务数据不包含在导出文件中
|
||||
- When 用户确认有效定义且所需依赖已满足,导入成功
|
||||
- Then 应用目录显示导入的身份、版本和配置,可按其启用状态打开或配置,不自动启动模型或任务
|
||||
- Given 同标识应用已存在
|
||||
- When 用户查看导入预览
|
||||
- Then 可选择更新或取消,取消后原应用与配置保持不变
|
||||
- When 更新成功
|
||||
- Then 显示已确认的新定义和配置
|
||||
- When 更新失败
|
||||
- Then 显示具体原因及重试入口,保留原应用定义、版本和配置,不留下部分更新
|
||||
- Given 文件格式无效、版本不支持或离线环境缺失程序包或模型资源
|
||||
- Then 显示具体问题与补充要求,不将应用误报为可运行,也不自动启动模型或任务
|
||||
|
||||
### US-F3 查看本机推理服务与资源
|
||||
|
||||
对应 [FR-16](./prd.md#fr-16-本机模型管理入口新增计划项)。
|
||||
|
||||
- Given 用户使用依赖本机模型的应用
|
||||
- When 从常驻入口、应用菜单或管理 Modal 的“打开”进入“本机推理监控”
|
||||
- Then 均打开同一管理 Modal,来源菜单或管理 Modal 关闭,底层工作区、当前项目、会话和未提交草稿保持不变
|
||||
- And 直接显示服务列表,不出现任务页签、任务历史或外部连接;知识库、智能心跳和魔法笔记仍打开主内容区页面
|
||||
- And 初始焦点进入关闭按钮,背景不可交互,Tab/Shift+Tab 在 Modal 内循环;标题与关闭按钮保持可见,正文独立滚动
|
||||
- Given 没有影响确认且没有操作提交
|
||||
- When 点击关闭按钮或按 Escape
|
||||
- Then 关闭并卸载管理 Modal,底层工作区继续显示,模型服务不停止;点击遮罩不关闭
|
||||
- And 焦点恢复到仍可用的原触发元素;原入口已移除时回到应用中心,窄侧栏已隐藏时回到侧栏开关
|
||||
- When 查看服务列表
|
||||
- Then 显示服务名、模型、引擎、简短状态及错误;共享服务只展示一次
|
||||
- Given 向量服务独立进程正在运行
|
||||
- Then 显示带 PID 和进程范围的 CPU、工作集内存;CPU 首次采样显示预热状态
|
||||
- Given 服务 worker 与宿主共享进程,或进程已退出、采样失败
|
||||
- Then 在服务行显示不能独立统计或当前不可用的原因,不展示虚构零值或借用整个应用进程指标
|
||||
- Given ASR 有活动请求但没有 Worker 就绪观测
|
||||
- Then 显示“按需运行(状态未知)”,不推断服务正在运行
|
||||
|
||||
### US-F4 单任务取消入口(已移除)
|
||||
|
||||
保留故事 ID。FR-16 已收窄为服务监视,Modal 不再提供任务取消按钮。执行路径的原有取消行为和活动请求登记继续用于服务影响校验,见[逻辑设计](./logic-design.md#81-本机推理管理)。
|
||||
|
||||
### US-F5 管理实际执行服务生命周期
|
||||
|
||||
对应 FR-16;当前引擎支持清单见[技术设计](./technical-design.md#111-本机推理任务与执行服务)。
|
||||
ASR 取消独立 Worker,向量停止核对所有活动请求,OCR 有活动或排队请求时拒绝释放。
|
||||
运行阶段包括排队、加载和执行;当前未接入统一进度及来源项目对象关联。资源指标按服务进程统计,见 US-F3。
|
||||
|
||||
- Given 多个应用共用一个由 GoodBuddy 受管子进程承载的模型服务
|
||||
- When 用户启动、停止或重启服务
|
||||
- Then 仅提供引擎实际支持的操作,按同一服务串行处理;重复请求不重复创建进程或并发执行同一服务的重启
|
||||
- And 以真实进程退出和服务就绪确认结果,不将命令已发送当作操作成功
|
||||
- Given 用户准备停止、重启或释放服务或模型
|
||||
- When 用户发起操作
|
||||
- Then 在管理 Modal 上方打开紧凑影响确认,列出使用方、任务及影响;无已登记活动任务时说明执行前再次核对,取消后不发送操作指令且运行状态不变
|
||||
- And 初始焦点进入“保留运行状态”,Tab/Shift+Tab 在确认内循环,底层管理 Modal 不可交互;Escape 只关闭确认并恢复服务操作按钮焦点,按钮不可用时回到管理 Modal
|
||||
- When 确认提交尚未完成
|
||||
- Then 禁止重复执行,确认和取消按钮均禁用,两层 Modal 都不能通过关闭按钮或 Escape 关闭
|
||||
- When 操作成功
|
||||
- Then 只关闭影响确认,管理 Modal 保持打开
|
||||
- Given 执行前出现确认清单未包含的新任务
|
||||
- Then 拒绝执行,关闭旧确认并显示错误,自动刷新状态;刷新期间或失败时禁用服务操作,再次发起操作须确认新的影响清单
|
||||
- Given 停止失败,或重启时停止成功但启动失败
|
||||
- Then 显示实际状态、原因和可重试错误,不假报已停止或已恢复;无法确认时显示未知
|
||||
- When 服务再次启动或重启成功
|
||||
- Then 已中断任务不自动重放,由来源应用显式重新发起
|
||||
- Given 模型连接由外部程序管理
|
||||
- Then 在既有模型与连接设置中查看,不将其列入本机服务或提供停止、重启
|
||||
- Given 模型在进程内运行
|
||||
- Then 仅提供引擎实际支持的加载、释放,不显示虚构 PID、进程数或进程启动停止状态
|
||||
- When 用户关闭管理 Modal、关闭管理应用或取消常驻
|
||||
- Then 模型服务运行状态不变,取消常驻不关闭当前 Modal,卸载模型文件为独立操作
|
||||
@@ -0,0 +1,69 @@
|
||||
: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;
|
||||
--success: #147566; --success-subtle: #e5f4ee; --warning: #865200;
|
||||
--radius-control: 10px; --radius-card: 14px;
|
||||
--shadow: 0 24px 80px #17263a26;
|
||||
font-family: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif;
|
||||
}
|
||||
[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; --success: #72d8be; --success-subtle: #123d37; --warning: #f2bd66; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px 32px; background: var(--surface-subtle); color: var(--text-primary); font-size: 13px; line-height: 1.6; }
|
||||
button, textarea, input { font: inherit; color: inherit; }
|
||||
button { border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; padding: 8px 12px; cursor: pointer; }
|
||||
button:hover { background: var(--accent-subtle); }
|
||||
button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
h1, h2, h3, p { margin: 0; } .muted, .eyebrow, .section-label { color: var(--text-secondary); }
|
||||
.eyebrow, .section-label { font-size: 11px; letter-spacing: 1px; }
|
||||
.lab, .lab-foot { max-width: 1440px; margin: auto; display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
.lab { margin-bottom: 24px; }.lab strong { font-size: 15px; }.lab span { margin-left: 24px; color: var(--text-secondary); }.tools { display: flex; gap: 4px; }.tools button { border-color: var(--border-default); }
|
||||
.shell { max-width: 1440px; margin: auto; height: calc(100dvh - 160px); min-height: 580px; display: grid; grid-template-columns: 256px minmax(0, 1fr); border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; background: var(--surface-raised); }
|
||||
.sidebar { background: var(--surface-canvas); padding: 24px 12px 12px; display: flex; flex-direction: column; border-right: 1px solid var(--border-default); }.brand { display: flex; align-items: center; gap: 8px; margin: 0 8px 32px; }.brand .muted { font-size: 11px; margin-left: auto; }.logo { background: var(--text-primary); color: var(--surface-raised); border-radius: var(--radius-control); padding: 4px 10px; }
|
||||
.workspace-control { background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; }#workspace-trigger { position: relative; display: grid; text-align: left; width: 100%; padding: 12px; }#project-name { font-size: 16px; }.expand { position: absolute; right: 12px; top: 20px; font-size: 22px; color: var(--accent); }#activity-trigger { border-top: 1px solid var(--border-default); border-radius: 0; width: 100%; font-size: 10px; padding: 8px; display: flex; gap: 4px; justify-content: space-between; }
|
||||
.new { margin: 16px 0 24px; text-align: left; border-color: var(--border-default); }.section-label { margin: 0 12px 8px; }#sessions { overflow: auto; }#sessions button { width: 100%; text-align: left; display: flex; gap: 8px; align-items: center; }#sessions button span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }[aria-current="true"], button[aria-pressed="true"] { background: var(--accent-subtle); border-color: var(--accent-selected); color: var(--accent); }.sidebar-bottom { margin-top: auto; padding-top: 24px; }.sidebar-bottom > span { display: block; margin: 8px 12px; font-size: 11px; }.sidebar-bottom button { width: 100%; text-align: left; }kbd { font: 10px/1.5 inherit; border: 1px solid var(--border-default); border-radius: 4px; padding: 2px 4px; color: var(--text-secondary); margin-left: 8px; }
|
||||
.workspace { display: flex; flex-direction: column; min-width: 0; overflow: hidden; }.titlebar { padding: 16px 24px; border-bottom: 1px solid var(--border-default); background: var(--surface-subtle); display: flex; justify-content: space-between; gap: 12px; }#conversation { flex: 1; overflow: auto; padding: 48px max(32px, calc((100% - 740px) / 2)); }#conversation h1 { font-size: 28px; font-weight: 550; letter-spacing: -1px; margin: 12px 0 32px; }.user-message { margin: 0 0 32px 48px; padding: 16px 24px; background: var(--surface-subtle); border-radius: var(--radius-card); }.assistant-label { color: var(--accent); font-size: 12px; margin-bottom: 12px; }.answer { font-size: 15px; line-height: 1.9; white-space: pre-wrap; }.execution { margin-top: 24px; padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }.execution p { margin: 8px 0 16px; color: var(--text-secondary); }.primary { background: var(--accent); color: var(--surface-raised); }.primary:hover { background: var(--text-primary); color: var(--surface-raised); }#composer { margin: 16px 32px 24px; border: 1px solid var(--border-default); border-radius: var(--radius-card); padding: 12px 16px; }textarea { display: block; width: 100%; resize: vertical; border: 0; background: transparent; padding: 8px 0; }#composer > div { display: flex; align-items: center; justify-content: space-between; }.lab-foot { margin-top: 16px; color: var(--text-secondary); font-size: 11px; }
|
||||
.waiting { color: var(--warning); }.running { color: var(--accent); }.done { color: var(--success); }.idle { color: var(--text-secondary); }.empty { text-align: center; padding: 48px 16px; color: var(--text-secondary); }#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 12px 24px; border: 1px solid var(--accent-selected); background: var(--surface-raised); color: var(--text-primary); border-radius: var(--radius-card); box-shadow: var(--shadow); z-index: 5; }
|
||||
@media (max-width: 800px) { body { padding: 12px; }.lab { flex-wrap: wrap; margin-bottom: 12px; }.lab span { display: none; }.shell { grid-template-columns: 208px minmax(0, 1fr); }.brand .muted { display: none; }#conversation { padding: 24px; }.lab-foot span:last-child { display: none; } }
|
||||
@media (max-width: 560px) { .shell { display: flex; flex-direction: column; height: auto; min-height: calc(100dvh - 150px); }.sidebar { border-right: 0; border-bottom: 1px solid var(--border-default); padding: 12px; }.brand, .new, .section-label, #sessions, .sidebar-bottom { display: none; }.workspace-control { display: flex; }#workspace-trigger { width: 48%; }#activity-trigger { width: 52%; flex-wrap: wrap; align-content: center; border-top: 0; border-left: 1px solid var(--border-default); }.workspace { min-height: 540px; }.titlebar { padding: 12px; }.titlebar > .muted { display: none; }#conversation { padding: 24px 16px; }#composer { margin: 12px; }.user-message { margin-left: 16px; } }
|
||||
#overview { position: fixed; inset: auto; margin: 0; padding: 0; width: min(920px, calc(100vw - 16px)); height: 640px; border: 1px solid var(--border-default); border-radius: var(--radius-card); box-shadow: var(--shadow); background: var(--surface-raised); color: var(--text-primary); overflow: hidden; }
|
||||
#overview:popover-open { display: flex; flex-direction: column; }
|
||||
#activity-trigger[hidden] { display: none; }
|
||||
.menu-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; border-bottom: 1px solid var(--border-default); flex-shrink: 0; }.menu-header button { font-size: 11px; }
|
||||
.menu-layout { display: grid; grid-template-columns: 360px minmax(0, 1fr); flex: 1; min-height: 0; }
|
||||
.project-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; background: var(--surface-subtle); border-right: 1px solid var(--border-default); }
|
||||
#project-search { margin: 12px 8px 8px; width: calc(100% - 16px); min-width: 0; border: 1px solid var(--border-default); background: var(--surface-raised); border-radius: 6px; padding: 8px; font-size: 12px; }
|
||||
#all-projects { text-align: left; margin: 0 8px 8px; padding: 8px; }
|
||||
#project-tabs { display: flex; gap: 4px; padding: 0 8px 8px; border-bottom: 1px solid var(--border-default); flex-shrink: 0; }#project-tabs[hidden] { display: none; }#project-tabs button { flex: 1; white-space: nowrap; font-size: 11px; padding: 6px 4px; }#project-tabs [aria-selected="true"] { color: var(--accent); background: var(--accent-subtle); border-color: var(--accent-selected); }
|
||||
#desktops { display: block; padding: 8px; overflow-y: auto; flex: 1; min-height: 0; overscroll-behavior: contain; }
|
||||
.search-result > small { display: block; padding: 0 8px 8px 32px; font-size: 10px; color: var(--text-secondary); }.search-result .desktop { margin-bottom: 0; }
|
||||
.host-heading { cursor: pointer; }.host-heading > span:first-child::before { content: '▸'; margin-right: 4px; }details[open] > .host-heading > span:first-child::before { content: '▾'; }
|
||||
.project-row { position: relative; margin-bottom: 4px; }
|
||||
.project-row .desktop { display: grid; grid-template-columns: 16px minmax(0, 1fr) 14px; min-height: 52px; padding: 8px 44px 8px 8px; gap: 8px; margin: 0; }
|
||||
.project-copy { min-width: 0; display: grid; gap: 2px; }.project-heading { display: flex; align-items: center; gap: 8px; min-width: 0; }.project-heading strong { font-size: 13px; font-weight: 650; }.project-path { display: block; font-size: 11px; line-height: 16px; color: var(--text-secondary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }.project-row .project-counts { width: auto; padding: 0; flex-wrap: nowrap; }.current-project { color: var(--accent); font-size: 13px; }
|
||||
.project-settings-button { position: absolute; top: 50%; right: 4px; transform: translateY(-50%); width: 32px; height: 32px; padding: 8px; opacity: 0; pointer-events: none; color: var(--text-secondary); }.project-row:hover .project-settings-button, .project-row:focus-within .project-settings-button { opacity: 1; pointer-events: auto; }
|
||||
.project-copy > .project-counts { font-size: 11px; line-height: 16px; flex-wrap: wrap; gap: 2px 8px; }.project-copy > .project-counts > span { white-space: nowrap; }
|
||||
@media (hover: none) { .project-settings-button { opacity: 1; pointer-events: auto; } }
|
||||
#project-settings { width: min(480px, calc(100vw - 24px)); max-height: calc(100dvh - 32px); overflow-y: auto; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-raised); color: var(--text-primary); padding: 24px; box-shadow: var(--shadow); }#project-settings::backdrop { background: color-mix(in srgb, var(--text-primary) 25%, transparent); }
|
||||
#settings-form { display: grid; gap: 16px; }#settings-form header, #settings-form footer { display: flex; align-items: center; gap: 8px; }#settings-form header strong, #settings-form footer small { flex: 1; }#settings-form label { display: grid; gap: 4px; font-size: 12px; }#settings-form input, #settings-form select, #settings-form textarea { width: 100%; min-width: 0; border: 1px solid var(--border-default); border-radius: 6px; background: var(--surface-subtle); color: var(--text-primary); padding: 8px; font: inherit; }
|
||||
.desktop { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px; margin-bottom: 4px; text-align: left; border-color: transparent; background: transparent; }.desktop strong { display: block; margin: 0; min-width: 0; flex: 1; font-size: 12px; font-weight: 500; }.project-counts { display: flex; gap: 8px; font-size: 10px; color: var(--text-secondary); flex-shrink: 0; }
|
||||
#desktops svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
|
||||
.project-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-default); }.project-section h2 { display: flex; align-items: center; gap: 8px; margin: 0 8px 8px; font-size: 11px; font-weight: 500; color: var(--text-secondary); }.host-group + .host-group { margin-top: 12px; }.host-heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 4px 8px; font-size: 11px; }.host-status { border: 1px solid var(--border-default); border-radius: 4px; padding: 0 4px; font-size: 10px; }.host-group .desktop { padding-left: 16px; }
|
||||
@media (min-width: 561px) and (max-width: 800px) { .menu-layout { grid-template-columns: 300px minmax(0, 1fr); } }
|
||||
.activity-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
#menu-new-chat { color: var(--accent); font-size: 11px; padding: 4px 8px; white-space: nowrap; flex-shrink: 0; }
|
||||
#project-form:not([hidden]) { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px; border-top: 1px solid var(--border-default); flex-shrink: 0; }#project-form input { min-width: 80px; width: 120px; flex: 1; background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: 6px; padding: 8px; }.desktop strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.overview-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-top: 0; border-bottom: 1px solid var(--border-default); }
|
||||
#filters { display: flex; gap: 4px; overflow-x: auto; min-width: 0; }#filters button { font-size: 11px; padding: 4px 8px; white-space: nowrap; flex-shrink: 0; }
|
||||
#windows { overflow-y: auto; padding: 8px; flex: 1; min-height: 0; overscroll-behavior: contain; }
|
||||
.window-group { margin: 0 0 16px; }.group-title { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 500; margin: 8px; }
|
||||
.session-row { display: flex; align-items: center; gap: 12px; text-align: left; width: 100%; padding: 12px 8px; border-radius: 6px; }.session-copy { flex: 1; min-width: 0; }.session-copy strong { display: block; font-weight: 500; }.session-copy small { display: block; font-size: 11px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.row-action { font-size: 10px; flex-shrink: 0; }
|
||||
.overview-footer { padding: 8px 16px; background: var(--surface-subtle); border-top: 1px solid var(--border-default); min-height: 48px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--text-secondary); font-size: 11px; }
|
||||
@media (max-width: 560px) {
|
||||
.project-row .desktop { grid-template-columns: minmax(0, 1fr) 12px; padding: 8px 36px 8px 4px; gap: 4px; }.project-row .desktop > svg { display: none; }.project-heading { flex-wrap: wrap; gap: 0; }.project-heading strong { flex-basis: 100%; }.project-settings-button { right: 0; }.project-row .project-counts { font-size: 9px; }.project-row .project-path { font-size: 10px; }
|
||||
#project-tabs { overflow-x: auto; gap: 0; padding-inline: 4px; }#project-tabs button { flex: 0 0 auto; }#project-search { font-size: 11px; padding: 6px; }.search-result > small { padding-left: 8px; }
|
||||
.desktop { flex-wrap: wrap; gap: 4px; }.desktop strong { flex-basis: calc(100% - 24px); }.project-counts { width: 100%; padding-left: 19px; }.host-heading { flex-wrap: wrap; gap: 4px; }.host-group .desktop { padding-left: 8px; }
|
||||
.menu-layout { grid-template-columns: 176px minmax(0, 1fr); }#desktops { padding: 4px; }.desktop { padding: 8px; }.desktop strong { font-size: 12px; }.project-counts { gap: 4px; flex-wrap: wrap; }.overview-toolbar { padding: 8px; }#filters { gap: 0; }#filters button { padding: 4px; }.session-row { align-items: flex-start; gap: 4px; flex-direction: column; }.session-copy { width: 100%; }.overview-footer { padding: 8px; gap: 4px; }.overview-footer span { max-width: 55%; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<!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="workspace-demo.css">
|
||||
<script src="workspace-demo.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="lab"><strong>GoodBuddy / 工作空间实验</strong><div class="tools"><button id="simulate">模拟后台完成</button><button id="theme">切换深色</button><button id="reset">重置</button></div></header>
|
||||
<main class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><b class="logo">G</b><strong>GoodBuddy</strong><span class="muted">个人空间</span></div>
|
||||
<div class="workspace-control"><button id="workspace-trigger" aria-haspopup="dialog" aria-controls="overview" aria-expanded="false"><span class="eyebrow">工作空间</span><strong id="project-name"></strong><span class="expand">▾</span></button><button id="activity-trigger" aria-haspopup="dialog" aria-controls="overview" aria-expanded="false" aria-label="查看全部项目活动"></button></div>
|
||||
<button class="new" id="new-chat">+ 新会话</button>
|
||||
<div class="section-label">会话</div><nav id="sessions" aria-label="会话窗口"></nav>
|
||||
</aside>
|
||||
<section class="workspace">
|
||||
<header class="titlebar"><span id="breadcrumb"></span><span class="muted">会话窗口</span></header>
|
||||
<article id="conversation" aria-live="polite"></article>
|
||||
<form id="composer"><label for="message" class="eyebrow">继续这项工作</label><textarea id="message" placeholder="输入消息,继续与 GoodBuddy 协作…" rows="2" required></textarea><div><span class="muted">OpenCode · Execute</span><button class="primary" type="submit">发送 ↑</button></div></form>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="lab-foot"><span>Demo · 模拟数据</span></footer>
|
||||
<section id="overview" popover="auto" role="dialog" aria-labelledby="overview-title">
|
||||
<header class="menu-header"><strong id="overview-title">项目与活动</strong><button id="close-overview" aria-label="关闭项目与活动菜单">×</button></header>
|
||||
<div class="menu-layout">
|
||||
<aside class="project-pane">
|
||||
<input id="project-search" type="search" aria-label="搜索项目" placeholder="搜索项目…" autocomplete="off">
|
||||
<button id="all-projects" data-scope="all" aria-pressed="false">全部项目</button>
|
||||
<div id="project-tabs" role="tablist" aria-label="项目类型"></div>
|
||||
<nav id="desktops" role="tabpanel" aria-label="项目列表"></nav>
|
||||
</aside>
|
||||
<section class="activity-pane">
|
||||
<div class="overview-toolbar"><div id="filters" role="group" aria-label="筛选会话状态"></div><button id="menu-new-chat">+ 新建会话</button></div>
|
||||
<div id="windows"></div>
|
||||
</section>
|
||||
</div>
|
||||
<form id="project-form" hidden><label for="project-input">项目名称</label><input id="project-input" maxlength="40" required autocomplete="off"><button type="submit" class="primary">创建</button><button type="button" id="cancel-project">取消</button></form>
|
||||
<footer class="overview-footer"><button id="new-project" aria-expanded="false" aria-controls="project-form">+ 新建项目</button></footer>
|
||||
</section>
|
||||
<dialog id="project-settings" aria-labelledby="settings-title">
|
||||
<form id="settings-form">
|
||||
<header><strong id="settings-title">项目设置</strong><button type="button" id="close-settings" aria-label="关闭项目设置">×</button></header>
|
||||
<label>名称<input id="settings-name" maxlength="120" required></label>
|
||||
<label>说明<textarea id="settings-description" rows="2"></textarea></label>
|
||||
<label>执行空间<input id="settings-space" readonly></label>
|
||||
<label>工作目录<input id="settings-path" required></label>
|
||||
<label>默认模式<select id="settings-mode"><option value="ask">Ask · 只读问答</option><option value="execute">Execute · 完全权限</option></select></label>
|
||||
<label>新对话默认 Runtime<select id="settings-runtime"><option>OpenCode</option><option>Continue</option><option>直连模型</option></select></label>
|
||||
<footer><small class="muted">仅修改 Demo 数据</small><button type="button" id="cancel-settings">取消</button><button type="submit" class="primary">保存</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,355 @@
|
||||
const projects = [
|
||||
{ id: 'buddy', name: 'GoodBuddy', kind: 'local', location: '本地工作空间' },
|
||||
{ id: 'web', name: '品牌官网', kind: 'local', location: '本地工作空间' },
|
||||
{ id: 'data', name: '数据分析', kind: 'remote', host: 'dev-server', location: '远程 · dev-server' },
|
||||
{ id: 'api', name: '后端服务', kind: 'remote', host: 'dev-server', location: '远程 · dev-server' },
|
||||
{ id: 'lab', name: '模型实验', kind: 'remote', host: 'gpu-lab', location: '远程 · gpu-lab' },
|
||||
{ id: 'channel', name: '产品讨论群', kind: 'channel', location: '消息通道' }
|
||||
];
|
||||
const hosts = [{ name: 'dev-server', status: '就绪' }, { name: 'gpu-lab', status: '未连接' }];
|
||||
for (let index = 3; index <= 20; index++) {
|
||||
projects.push({ id: `local-${index}`, name: `本地项目 ${String(index).padStart(2, '0')}`, kind: 'local', location: '本地工作空间' });
|
||||
}
|
||||
for (let index = 4; index <= 20; index++) {
|
||||
const host = index % 2 ? 'gpu-lab' : 'dev-server';
|
||||
projects.push({ id: `remote-${index}`, name: `远程项目 ${String(index).padStart(2, '0')}`, kind: 'remote', host, location: `远程 · ${host}` });
|
||||
}
|
||||
projects.push(
|
||||
{ id: 'channel-2', name: '研发协作群', kind: 'channel', location: '消息通道' },
|
||||
{ id: 'channel-3', name: '运维通知群', kind: 'channel', location: '消息通道' }
|
||||
);
|
||||
const icons = {
|
||||
all: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||||
local: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>',
|
||||
remote: '<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6h.01M7 17h.01"/>',
|
||||
channel: '<path d="M21 11a8 8 0 0 1-8 8H6l-4 3V11a8 8 0 0 1 8-8h3a8 8 0 0 1 8 8Z"/><path d="M7 9h9M7 13h6"/>',
|
||||
folder: '<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>'
|
||||
};
|
||||
const icon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${icons[name]}</svg>`;
|
||||
for (const project of projects) {
|
||||
project.rootPath = project.kind === 'remote' ? `/srv/projects/${project.id}` : `D:/workspace/${project.id}`;
|
||||
project.description = '';
|
||||
project.defaultMode = 'ask';
|
||||
project.runtime = 'OpenCode';
|
||||
}
|
||||
const initialProjects = structuredClone(projects);
|
||||
const initial = [
|
||||
{ id: 1, project: 'buddy', title: '工作空间交互设计', state: 'idle', detail: '设计讨论已保存,随时可以继续。', prompt: '让项目与活动会话的关系更直观,做出操作系统的感觉。', answer: '左上角就是工作入口。\n\n展开项目与活动菜单,可以查看多个项目正在进行的会话,也可以直接处理等待你的工作。选择项目只筛选列表,进入会话才切换工作现场。\n\n所有状态都在这个下拉菜单中,不需要再打开独立页面。' },
|
||||
{ id: 2, project: 'buddy', title: '登录流程调整', state: 'waiting', detail: '需要你选择:使用单点登录还是邮箱登录?', prompt: '帮我完善内部用户的登录流程。', answer: '已梳理现有认证入口。需要确定首选登录方式,然后继续调整界面。', question: true },
|
||||
{ id: 3, project: 'buddy', title: '导航组件测试', state: 'running', detail: '正在检查键盘导航与焦点恢复。', prompt: '检查导航组件的交互和测试覆盖。', answer: '已经完成组件检查,正在验证键盘导航与焦点恢复。' },
|
||||
{ id: 4, project: 'web', title: '发布新版首页', state: 'waiting', detail: '预览构建已就绪,等待你批准部署。', prompt: '构建新版首页,发布前让我确认。', answer: '预览构建已完成,页面检查通过。请确认是否继续部署。' },
|
||||
{ id: 5, project: 'web', title: '首页文案精修', state: 'done', detail: '完成 3 处文案调整,结果等待查看。', prompt: '把首页介绍改得更简洁、自然。', answer: '首页文案调整已完成。\n\n首屏标题:把想法,变成已完成的工作。\n产品介绍:查资料、写方案、改代码。在一个工作空间里,与 AI 一起推进。\n行动按钮:开始你的第一项工作。' },
|
||||
{ id: 6, project: 'data', title: '本周使用情况分析', state: 'running', detail: '正在汇总各项目的使用趋势。', prompt: '整理本周各项目的使用情况。', answer: '已完成示例数据整理,正在汇总各项目的使用趋势。' },
|
||||
{ id: 7, project: 'api', title: '接口变更记录', state: 'idle', detail: '昨天的会话', prompt: '整理本次接口变更。', answer: '接口变更记录已整理。' },
|
||||
{ id: 8, project: 'lab', title: '推理参数对比', state: 'idle', detail: '上次会话', prompt: '对比两组推理参数。', answer: '上次对比结果已保存。' },
|
||||
{ id: 9, project: 'channel', title: '版本发布讨论', state: 'idle', detail: '今天的会话', prompt: '整理群里的版本讨论。', answer: '本轮发布讨论已整理。' }
|
||||
];
|
||||
for (const project of projects) {
|
||||
if (!initial.some((session) => session.project === project.id)) {
|
||||
initial.push({ id: initial.length + 1, project: project.id, title: '最近工作记录', state: 'idle', detail: '上次会话', prompt: '整理当前工作。', answer: '工作记录已保存。' });
|
||||
}
|
||||
}
|
||||
const labels = { waiting: '待处理', running: '运行中', done: '已完成' };
|
||||
const symbols = { waiting: '!', running: '◌', done: '✓' };
|
||||
const statusLabel = (session) => session.state === 'waiting'
|
||||
? session.question ? '等待你的回答' : '等待审批'
|
||||
: labels[session.state] || '';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
let sessions, current, scope = 'all', filter = 'all', projectTab = 'local', toastTimer, menuTrigger, activityOrder, settingsProjectId;
|
||||
const remembered = {};
|
||||
const drafts = {};
|
||||
const overview = $('overview');
|
||||
const projectOf = (session) => projects.find((p) => p.id === session.project);
|
||||
const badge = (state) => `<span class="${state}">${symbols[state]} ${labels[state]}</span>`;
|
||||
function notify(text) {
|
||||
$('toast').textContent = text;
|
||||
$('toast').hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { $('toast').hidden = true; }, 3500);
|
||||
}
|
||||
function render() {
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
const project = projectOf(session);
|
||||
$('project-name').textContent = project.name;
|
||||
$('breadcrumb').textContent = `${project.name} / ${session.title}`;
|
||||
$('activity-trigger').innerHTML = '<span class="muted">全部</span>' + ['waiting', 'running', 'done'].filter((state) => sessions.some((s) => s.state === state)).map((state) => `<span class="${state}">${symbols[state]} ${sessions.filter((s) => s.state === state).length} ${labels[state]}</span>`).join('');
|
||||
$('activity-trigger').hidden = !sessions.some((s) => labels[s.state]);
|
||||
$('sessions').innerHTML = sessions.filter((s) => s.project === session.project).map((s) => `<button data-open="${s.id}" aria-current="${s.id === current}">${statusLabel(s) ? `<span class="${s.state}" aria-label="${statusLabel(s)}">${symbols[s.state]}</span>` : ''}<span>${escapeHtml(s.title)}</span></button>`).join('');
|
||||
$('conversation').innerHTML = `<span class="eyebrow">${escapeHtml(project.location)}${statusLabel(session) ? ` / ${statusLabel(session)}` : ''}</span><h1>${escapeHtml(session.title)}</h1><div class="user-message">${escapeHtml(session.prompt)}</div><div class="assistant-label">G / GoodBuddy</div><div class="answer">${escapeHtml(session.answer)}</div>${session.state === 'waiting' ? `<section class="execution"><span class="waiting">! ${statusLabel(session)}</span><p>${escapeHtml(session.detail)}</p>${session.question ? '<button class="primary" data-resolve="单点登录">使用单点登录</button> <button data-resolve="邮箱登录">使用邮箱登录</button>' : '<button class="primary" data-resolve="批准部署">批准并继续</button> <button data-resolve="暂不部署">暂不部署</button>'}</section>` : session.state === 'running' ? `<section class="execution">${badge('running')}<p>${escapeHtml(session.detail)} 切换到其他工作空间后仍会继续。</p></section>` : ''}`;
|
||||
$('simulate').disabled = !sessions.some((s) => s.state === 'running');
|
||||
renderOverview();
|
||||
}
|
||||
function renderOverview() {
|
||||
const focused = document.activeElement;
|
||||
const focusScope = focused?.dataset.scope;
|
||||
const focusFilter = focused?.dataset.filter;
|
||||
const projectButton = (p) => {
|
||||
const list = sessions.filter((s) => p.id === 'all' || s.project === p.id);
|
||||
const counts = ['waiting', 'running', 'done'].filter((state) => list.some((s) => s.state === state)).map((state) => `<span class="${state}">${symbols[state]} ${list.filter((s) => s.state === state).length} 个${labels[state]}</span>`).join(' ');
|
||||
const active = sessions.find((s) => s.id === current).project === p.id;
|
||||
const detail = p.kind === 'local' ? `本地目录 · ${p.rootPath}` : p.kind === 'channel' ? `消息通道 · ${p.rootPath}` : p.rootPath;
|
||||
return `<div class="project-row"><button class="desktop" data-scope="${p.id}" title="${escapeHtml(p.name)}\n${escapeHtml(detail)}">${icon(p.kind === 'channel' ? 'channel' : 'folder')}<span class="project-copy"><span class="project-heading"><strong>${escapeHtml(p.name)}</strong></span>${counts ? `<span class="project-counts">${counts}</span>` : ''}<small class="project-path">${escapeHtml(detail)}</small></span><span class="current-project" ${active ? 'aria-label="当前项目"' : 'aria-hidden="true"'}>${active ? '✓' : ''}</span></button><button class="project-settings-button" data-settings="${p.id}" aria-label="管理项目 ${escapeHtml(p.name)}" title="项目设置"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 3-.7 3-2 .9-2.7-.8-2 3.4 2 2.2v2.6l-2 2.2 2 3.4 2.7-.8 2 .9L9 23h4l.7-3 2-.9 2.7.8 2-3.4-2-2.2v-2.6l2-2.2-2-3.4-2.7.8-2-.9L13 3Z" transform="translate(1 -1) scale(.95)"/><circle cx="12" cy="12" r="3"/></svg></button></div>`;
|
||||
};
|
||||
const query = $('project-search').value.trim().toLocaleLowerCase();
|
||||
const kinds = { local: '本地', remote: '远程', channel: '消息通道' };
|
||||
if (!$('project-tabs').children.length) {
|
||||
$('project-tabs').innerHTML = Object.entries(kinds).map(([kind, label]) => `<button id="tab-${kind}" role="tab" data-tab="${kind}" aria-controls="desktops">${label} <small></small></button>`).join('');
|
||||
}
|
||||
$('project-tabs').hidden = Boolean(query);
|
||||
$('project-tabs').querySelectorAll('[data-tab]').forEach((button) => {
|
||||
const selected = button.dataset.tab === projectTab;
|
||||
button.setAttribute('aria-selected', String(selected));
|
||||
button.tabIndex = selected ? 0 : -1;
|
||||
button.querySelector('small').textContent = projects.filter((p) => p.kind === button.dataset.tab).length;
|
||||
});
|
||||
$('desktops').setAttribute('aria-label', query ? '搜索结果' : kinds[projectTab]);
|
||||
$('all-projects').setAttribute('aria-pressed', String(scope === 'all'));
|
||||
const shown = projects.filter((p) => query
|
||||
? `${p.name} ${p.rootPath} ${p.host || ''} ${kinds[p.kind]}`.toLocaleLowerCase().includes(query)
|
||||
: p.kind === projectTab);
|
||||
const projectMarkup = query
|
||||
? shown.map((p) => `<div class="search-result">${projectButton(p)}<small>${kinds[p.kind]}${p.host ? ` · ${escapeHtml(p.host)}` : ''}</small></div>`).join('')
|
||||
: projectTab === 'remote'
|
||||
? hosts.map((host) => `<section class="host-group" aria-label="${host.name}"><details open><summary class="host-heading"><span>${host.name}</span><span class="host-status ${host.status === '就绪' ? 'done' : 'muted'}">${host.status}</span></summary>${shown.filter((p) => p.host === host.name).map(projectButton).join('')}</details></section>`).join('')
|
||||
: shown.map(projectButton).join('');
|
||||
// Keep hovered project nodes stable while the right-hand list changes.
|
||||
if ($('desktops').dataset.markup !== projectMarkup) {
|
||||
$('desktops').innerHTML = projectMarkup || '<div class="empty">无匹配项目</div>';
|
||||
$('desktops').dataset.markup = projectMarkup;
|
||||
}
|
||||
$('desktops').querySelectorAll('[data-scope]').forEach((button) => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.scope === scope));
|
||||
});
|
||||
const list = sessions.filter((s) => scope === 'all' || s.project === scope);
|
||||
$('filters').innerHTML = ['all', 'waiting', 'running', 'done'].map((state) => `<button data-filter="${state}" aria-pressed="${filter === state}">${state === 'all' ? '全部' : labels[state]} <small>${list.filter((s) => state === 'all' || s.state === state).length}</small></button>`).join('');
|
||||
const targetProject = projects.find((p) => p.id === (scope === 'all' ? sessions.find((s) => s.id === current).project : scope));
|
||||
$('menu-new-chat').title = `在 ${targetProject.name} 中新建会话`;
|
||||
$('menu-new-chat').setAttribute('aria-label', $('menu-new-chat').title);
|
||||
const groups = filter === 'all' ? ['waiting', 'running', 'recent'] : [filter];
|
||||
$('windows').innerHTML = groups.map((state) => {
|
||||
const sorted = list.filter((s) => state === 'recent' ? s.state === 'done' || s.state === 'idle' : s.state === state)
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity);
|
||||
const group = state === 'recent' ? sorted.slice(0, 5) : sorted;
|
||||
if (!group.length) return '';
|
||||
return `<section class="window-group"><h2 class="group-title">${state === 'recent' ? '最近会话' : badge(state)} <span class="muted">${group.length}</span></h2>${group.map((s) => `<button class="session-row" data-open="${s.id}" title="${escapeHtml(s.detail)}"><span class="session-copy"><strong>${escapeHtml(s.title)}</strong>${scope === 'all' ? `<small>${escapeHtml(projectOf(s).name)}</small>` : ''}</span><span class="row-action ${s.state}">${s.state === 'done' ? '✓ 已完成' : s.state === 'waiting' ? statusLabel(s) : s.id === current ? '当前' : '↗'}</span></button>`).join('')}</section>`;
|
||||
}).join('') || '<div class="empty">暂无会话</div>';
|
||||
if (focusScope) overview.querySelector(`[data-scope="${focusScope}"]`)?.focus();
|
||||
if (focusFilter) overview.querySelector(`[data-filter="${focusFilter}"]`)?.focus();
|
||||
}
|
||||
function openSession(id) {
|
||||
const session = sessions.find((s) => s.id === id);
|
||||
drafts[current] = $('message').value;
|
||||
current = id;
|
||||
session.lastActivity = ++activityOrder;
|
||||
remembered[session.project] = id;
|
||||
if (session.state === 'done') { session.state = 'idle'; session.detail = '结果已查看,可以继续这项工作。'; }
|
||||
closeMenu();
|
||||
$('message').value = drafts[id] || '';
|
||||
render();
|
||||
$('conversation').scrollTop = 0;
|
||||
}
|
||||
function positionMenu() {
|
||||
const anchor = document.querySelector('.workspace-control').getBoundingClientRect();
|
||||
const top = Math.max(8, Math.min(anchor.bottom + 8, window.innerHeight - 240));
|
||||
overview.style.left = `${Math.max(8, Math.min(anchor.left, window.innerWidth - overview.offsetWidth - 8))}px`;
|
||||
overview.style.top = `${top}px`;
|
||||
overview.style.maxHeight = `${window.innerHeight - top - 8}px`;
|
||||
}
|
||||
function closeMenu() {
|
||||
if (overview.matches(':popover-open')) overview.hidePopover();
|
||||
}
|
||||
function showOverview(project = 'all', trigger = $('workspace-trigger')) {
|
||||
if (overview.matches(':popover-open')) { closeMenu(); return; }
|
||||
menuTrigger = trigger;
|
||||
scope = project;
|
||||
projectTab = projects.find((p) => p.id === (project === 'all' ? sessions.find((s) => s.id === current).project : project)).kind;
|
||||
$('project-search').value = '';
|
||||
filter = 'all';
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
renderOverview();
|
||||
overview.showPopover();
|
||||
positionMenu();
|
||||
overview.querySelector('[data-scope][aria-pressed="true"]').focus();
|
||||
}
|
||||
document.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button');
|
||||
if (!button) return;
|
||||
if (button.dataset.settings) {
|
||||
settingsProjectId = button.dataset.settings;
|
||||
const project = projects.find((p) => p.id === settingsProjectId);
|
||||
$('settings-name').value = project.name;
|
||||
$('settings-name').readOnly = project.kind === 'channel';
|
||||
$('settings-description').value = project.description || '';
|
||||
$('settings-space').value = project.location;
|
||||
$('settings-path').value = project.rootPath;
|
||||
$('settings-mode').value = project.defaultMode || 'ask';
|
||||
$('settings-runtime').value = project.runtime || 'OpenCode';
|
||||
$('settings-runtime').querySelector('option:last-child').disabled = project.kind === 'remote';
|
||||
closeMenu();
|
||||
$('project-settings').showModal();
|
||||
}
|
||||
if (button.dataset.tab) {
|
||||
projectTab = button.dataset.tab;
|
||||
renderOverview();
|
||||
$('desktops').scrollTop = 0;
|
||||
}
|
||||
if (button.dataset.open) openSession(Number(button.dataset.open));
|
||||
if (button.dataset.scope === 'all') { scope = 'all'; renderOverview(); }
|
||||
else if (button.dataset.scope) {
|
||||
const project = button.dataset.scope;
|
||||
const session = sessions.find((s) => s.id === remembered[project]) ||
|
||||
sessions.filter((s) => s.project === project).sort((a, b) => b.lastActivity - a.lastActivity)[0];
|
||||
if (session) openSession(session.id); else createSession(project);
|
||||
}
|
||||
if (button.dataset.filter) { filter = button.dataset.filter; renderOverview(); }
|
||||
if (button.dataset.resolve) {
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
const declined = button.dataset.resolve === '暂不部署';
|
||||
session.state = declined ? 'idle' : 'running';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = declined ? '已暂缓部署,等待后续安排。' : `已确认${button.dataset.resolve},正在继续执行。`;
|
||||
session.answer = session.detail;
|
||||
render();
|
||||
notify(declined ? '已暂缓本次部署' : '已处理,工作继续进行');
|
||||
}
|
||||
});
|
||||
$('desktops').addEventListener('pointerover', (event) => {
|
||||
if (event.pointerType === 'touch') return;
|
||||
const button = event.target.closest('[data-scope]');
|
||||
if (!button || button.dataset.scope === scope) return;
|
||||
scope = button.dataset.scope;
|
||||
renderOverview();
|
||||
});
|
||||
$('project-search').addEventListener('input', () => { renderOverview(); $('desktops').scrollTop = 0; });
|
||||
$('project-tabs').addEventListener('keydown', (event) => {
|
||||
const tabs = [...$('project-tabs').querySelectorAll('[data-tab]')];
|
||||
const index = tabs.indexOf(document.activeElement);
|
||||
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;
|
||||
tabs[next].focus();
|
||||
tabs[next].click();
|
||||
});
|
||||
$('settings-form').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const project = projects.find((p) => p.id === settingsProjectId);
|
||||
const name = $('settings-name').value.trim();
|
||||
const path = $('settings-path').value.trim();
|
||||
if (!name || !path) return;
|
||||
if (project.kind !== 'channel') project.name = name;
|
||||
project.rootPath = path;
|
||||
project.description = $('settings-description').value;
|
||||
project.defaultMode = $('settings-mode').value;
|
||||
project.runtime = $('settings-runtime').value;
|
||||
render();
|
||||
$('project-settings').close();
|
||||
};
|
||||
$('close-settings').onclick = $('cancel-settings').onclick = () => $('project-settings').close();
|
||||
$('project-settings').addEventListener('close', () => {
|
||||
showOverview(settingsProjectId);
|
||||
overview.querySelector(`[data-settings="${settingsProjectId}"]`)?.focus();
|
||||
});
|
||||
$('workspace-trigger').onclick = () => showOverview(sessions.find((s) => s.id === current).project);
|
||||
$('activity-trigger').onclick = () => showOverview('all', $('activity-trigger'));
|
||||
$('close-overview').onclick = () => { closeMenu(); menuTrigger?.focus(); };
|
||||
$('simulate').onclick = () => {
|
||||
const session = sessions.find((s) => s.state === 'running' && s.id !== current) || sessions.find((s) => s.state === 'running');
|
||||
if (!session) return;
|
||||
const visible = session.id === current && !overview.matches(':popover-open') && !document.hidden;
|
||||
session.state = visible ? 'idle' : 'done';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = visible ? '结果已查看,可以继续这项工作。' : '工作已完成,结果等待查看。';
|
||||
session.answer = '本轮工作已完成。\n\n示例结果:检查项已通过,相关说明已整理。你可以继续追问,或者切换到下一项工作。';
|
||||
render();
|
||||
notify(`${projectOf(session).name} / ${session.title} 已完成`);
|
||||
};
|
||||
overview.addEventListener('toggle', (event) => {
|
||||
const open = event.newState === 'open';
|
||||
$('workspace-trigger').setAttribute('aria-expanded', String(open));
|
||||
$('activity-trigger').setAttribute('aria-expanded', String(open));
|
||||
if (open) return;
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
if (session.state === 'done') { session.state = 'idle'; session.detail = '结果已查看,可以继续这项工作。'; render(); }
|
||||
});
|
||||
$('theme').onclick = () => {
|
||||
const dark = document.documentElement.dataset.theme !== 'dark';
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||
$('theme').textContent = dark ? '切换浅色' : '切换深色';
|
||||
};
|
||||
function createSession(project) {
|
||||
const id = Math.max(...sessions.map((s) => s.id)) + 1;
|
||||
sessions.push({ id, project, title: '新的工作', state: 'idle', detail: '等待你的第一条消息。', prompt: '开始一项新的工作。', answer: '在下方输入你的想法。' });
|
||||
openSession(id);
|
||||
$('message').focus();
|
||||
}
|
||||
$('new-chat').onclick = () => createSession(sessions.find((s) => s.id === current).project);
|
||||
$('menu-new-chat').onclick = () => createSession(scope === 'all' ? sessions.find((s) => s.id === current).project : scope);
|
||||
$('new-project').onclick = () => {
|
||||
$('project-form').hidden = !$('project-form').hidden;
|
||||
$('new-project').setAttribute('aria-expanded', String(!$('project-form').hidden));
|
||||
if (!$('project-form').hidden) $('project-input').focus();
|
||||
};
|
||||
$('cancel-project').onclick = () => {
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
$('new-project').focus();
|
||||
};
|
||||
$('project-form').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const name = $('project-input').value.trim();
|
||||
if (!name) { $('project-input').focus(); return; }
|
||||
const id = `project-${projects.length + 1}`;
|
||||
projects.push({ id, name, kind: 'local', location: '本地工作空间', rootPath: `D:/workspace/${id}`, description: '', defaultMode: 'ask', runtime: 'OpenCode' });
|
||||
scope = id;
|
||||
$('project-form').reset();
|
||||
$('project-form').hidden = true;
|
||||
$('new-project').setAttribute('aria-expanded', 'false');
|
||||
createSession(id);
|
||||
};
|
||||
$('composer').onsubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const text = $('message').value.trim();
|
||||
if (!text) return;
|
||||
const session = sessions.find((s) => s.id === current);
|
||||
if (session.state === 'running' || session.state === 'waiting') { notify('请先完成当前模拟执行或处理待办,再发送消息。'); return; }
|
||||
session.prompt = text;
|
||||
session.state = 'running';
|
||||
session.lastActivity = ++activityOrder;
|
||||
session.detail = '正在处理你的消息。';
|
||||
session.answer = '已开始模拟执行。你可以切换工作空间,然后点击页面上方“模拟后台完成”查看通知效果。';
|
||||
$('message').value = '';
|
||||
render();
|
||||
};
|
||||
function reset() {
|
||||
projects.splice(0, projects.length, ...structuredClone(initialProjects));
|
||||
scope = 'all';
|
||||
projectTab = 'local';
|
||||
$('project-search').value = '';
|
||||
filter = 'all';
|
||||
$('project-form').reset();
|
||||
sessions = structuredClone(initial);
|
||||
sessions.forEach((session, index) => { session.lastActivity = initial.length - index; });
|
||||
activityOrder = initial.length;
|
||||
current = 1;
|
||||
Object.keys(remembered).forEach((key) => delete remembered[key]);
|
||||
Object.keys(drafts).forEach((key) => delete drafts[key]);
|
||||
remembered.buddy = 1;
|
||||
closeMenu();
|
||||
$('message').value = '';
|
||||
render();
|
||||
}
|
||||
$('reset').onclick = reset;
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if ($('project-settings').open) return;
|
||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.code === 'KeyK') {
|
||||
event.preventDefault();
|
||||
if (overview.matches(':popover-open')) { closeMenu(); menuTrigger?.focus(); } else showOverview();
|
||||
}
|
||||
if (event.key === 'Escape' && overview.matches(':popover-open')) {
|
||||
event.preventDefault(); closeMenu(); menuTrigger?.focus();
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', () => { if (overview.matches(':popover-open')) positionMenu(); });
|
||||
reset();
|
||||
@@ -1,14 +1,34 @@
|
||||
# 助手工作栏
|
||||
|
||||
助手工作栏统一承载本机与远程执行空间、Task、成果、文件、浏览器和终端等持续工作界面。
|
||||
助手工作栏统一承载本机与远程执行空间、Task、文件、浏览器、终端和笔记等持续工作界面。
|
||||
成果页已移除;项目文件统一在工作区查看和导入,会话图片继续由消息引用读取。
|
||||
|
||||
“笔记”为应用级、可关闭的单实例 Tab,新布局默认不打开。它提供搜索、浏览和快速追加文字,
|
||||
与主页面魔法笔记共用存储;会话采集、来源跳转与草稿规则由
|
||||
[魔法笔记文档](../magic-notes/README.md)维护。该应用已接入并通过真实 Electron 验收;
|
||||
全仓验证未全绿,聚焦复跑与执行时失败见[笔记验证进度](../magic-notes/progress.md)。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
|
||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 |
|
||||
| [实现与验证进度](./progress.md) | 工作区改动的验证证据与剩余阻塞 |
|
||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 工作栏应用的固定、可关闭、单实例和多实例策略,以及多终端生命周期 |
|
||||
| [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 |
|
||||
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
|
||||
| [执行记录存储与升级回收](./execution-history-storage.md) | 子任务差量存储、远程去重、旧库转换、会话置顶、摘要与完整历史按需读取、内存引用释放 |
|
||||
| [工作区文件导入与时间](./workspace-files.md) | 真实文件来源、导入操作、排序、图片存储边界及本机和远程实现 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、并行与输出、消息请求状态及资源生命周期规则 |
|
||||
| [Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md) | 跨 Runtime 资源调研、锁定协议证据、进程与会话拆分、远端模型桥、实施顺序和性能验收 |
|
||||
| [Runtime 原生客户端启动方案](./runtime-native-client-proposal.md) | Local terminal/DS Web shortcut requirements, startup rules, current integration, validation boundaries and remaining remote/resource work |
|
||||
| [Runtime 执行清单技术设计](./runtime-checklist-technical-design.md) | OC/CN 原生清单提取、远程 Agent 传递复用、消息恢复与真实部署验收;尚未实现 |
|
||||
| [移除 Ask / Execute 工作模式](../unified-execution/README.md) | 跨入口统一执行、历史字段迁移及清除数据入口;主要源码已落地,定向 UI/Host 通过,完整验收待完成 |
|
||||
|
||||
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
|
||||
两份现有 PRD 中,后续实际维护该功能时按职责拆分。
|
||||
当前尚未拆出独立 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,247 @@
|
||||
# 浏览器多实例技术设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 基础多实例已实施,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、tooltip 和应用通知:可见应用级 Modal 始终释放
|
||||
viewport,其余浮层只在与浏览器区域相交时释放。隐藏节点和隐藏祖先不阻塞浏览器。
|
||||
最后一个遮挡消失后,只有当前活动实例恢复同一 viewport 租约;网页、历史和页面状态不变。
|
||||
终端关闭确认复用此机制,不再维护单独的隐藏条件。页面无需逐个登记弹窗,也不能靠提高
|
||||
CSS `z-index` 覆盖原生子视图。此逻辑仅涉及 Desktop Renderer 的显示租约,不改变
|
||||
Runtime、MCP 或远端 Agent 的请求、导航与生命周期。
|
||||
同行帮助仍使用 `role="tooltip"`;相交判定使用其内容矩形,不使用铺满窗口的
|
||||
`FloatingPortal` 宿主。真实 App 的相交与非相交验证见[浮层审查修复记录](../../quality/overlay-review-2026-09-27.md)。
|
||||
|
||||
浏览器应用声明 `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 Page-created tabs
|
||||
|
||||
`target="_blank"` and `window.open` create managed `WebContentsView` tabs through Electron's
|
||||
`setWindowOpenHandler.createWindow`. Chromium retains the original request and opener relationship,
|
||||
including a blank window followed by a script-assigned location. These tabs share the Conversation
|
||||
partition, proxy and permission restrictions. HTTP(S) URL checks remain in the session and filtering
|
||||
proxy; `about:blank` is permitted only as the initial empty popup. Unsupported URL schemes remain denied.
|
||||
|
||||
BrowserService reserves capacity before creating the native view, assigns a new Tab/workbar identity,
|
||||
and owns initialization, the Driver and cleanup. Initialization failure or Conversation disposal releases
|
||||
the popup. Closing the opener leaves an already created popup alive. A popup's `window.close()` releases
|
||||
its service slot and usage leases; Renderer removes its workbar instance and returns to the opener when
|
||||
it is still available. No separate unmanaged BrowserWindow is created.
|
||||
|
||||
Live state includes `openerTabId`. A popup from the visible browser is selected even when the active chat
|
||||
has switched to a different Conversation. Other workbar panels and viewport occlusion rules retain their
|
||||
existing behavior. The existing workbar capacity notification still applies when all 32 slots are occupied.
|
||||
|
||||
During `browser_click`, the service collects popup registrations from that Tab and waits for their
|
||||
initialization. The result identifies the last created popup. BrowserModelTools binds subsequent tools to
|
||||
that Tab and asks the model to obtain new references with `browser_snapshot`; the MCP gateway also replaces
|
||||
its capability binding and usage lease. Switching visible tabs does not alter this binding. Closing the
|
||||
popup retains the existing explicit `browser_navigate` recovery behavior. A page may still be loading
|
||||
when the click returns. Popups requested asynchronously after the click has completed are displayed as
|
||||
managed tabs, but are not attributed to that completed tool call.
|
||||
|
||||
The remote ACP Runtime exposes its separately scoped image MCP server, not this desktop browser gateway;
|
||||
this change does not add browser access to the deployed GoodBuddy Agent.
|
||||
|
||||
### 6.3 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,57 @@
|
||||
{
|
||||
"date": "2026-09-17",
|
||||
"status": "passed-with-counting-limitation",
|
||||
"sourceBase": "3639566e1b52296c6a9bc409bcfcecb86c905e30",
|
||||
"source": "Working tree including consecutive-request cancellation and terminal snapshot fixes",
|
||||
"environment": {
|
||||
"platform": "Windows x64",
|
||||
"runtime": "OpenCode",
|
||||
"mode": "ask",
|
||||
"model": "Existing configured default text model",
|
||||
"upstreamProtocol": "http",
|
||||
"fullProductionApp": true,
|
||||
"isolatedProfile": true,
|
||||
"nativeInput": true
|
||||
},
|
||||
"checks": {
|
||||
"twoQueuedRequestsCompleteAndPersist": true,
|
||||
"cancelAfterExternalDispatch": true,
|
||||
"cancelToResendMs": 25,
|
||||
"resendCompletesAndPersists": true,
|
||||
"cancellationPersists": true,
|
||||
"stopAbsentAfterBothScenarios": true,
|
||||
"sendEnabledWithUnsentProbe": true,
|
||||
"terminalScreenshotsReviewed": true
|
||||
},
|
||||
"requests": {
|
||||
"instrumentedExternalDispatches": 4,
|
||||
"http200": 3,
|
||||
"cancelledBeforeResponseHeaders": 1,
|
||||
"observedRetries": 0,
|
||||
"toolExecutions": 0,
|
||||
"initialUninstrumentedCompletedGenerations": 2,
|
||||
"totalUiSubmissions": 6,
|
||||
"totalExternalDispatches": null
|
||||
},
|
||||
"limitations": [
|
||||
"Initial Main counting hook missed child-process HTTP traffic; total dispatches across attempts are unknown.",
|
||||
"Successful rerun used a transparent Main relay to the unchanged configured HTTP upstream for dispatch counting.",
|
||||
"Seven tool definitions remained exposed by production Ask, with zero observed tool executions.",
|
||||
"Upstream proxy retries and cancelled request outcome at the upstream are not observable.",
|
||||
"No deterministic stale-event ordering, remote Host, or restart verification in this live run."
|
||||
],
|
||||
"validation": {
|
||||
"focusedTestsPassed": 362,
|
||||
"typecheck": "passed",
|
||||
"productionBundle": "passed"
|
||||
},
|
||||
"cleanup": {
|
||||
"ownedProcessesStopped": true,
|
||||
"isolatedProfileRemoved": true,
|
||||
"temporaryBuildRemoved": true,
|
||||
"sensitiveCopiesRemoved": true,
|
||||
"originalSettingsUnchanged": true,
|
||||
"targetSourceHashesUnchanged": true
|
||||
},
|
||||
"localArtifacts": "consecutive-ui-0917/{final-evidence.json,scenarios.json,report.json,normal-terminal.png,cancel-resend-terminal.png} under the approved temporary directory"
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
# 执行记录存储与升级回收
|
||||
|
||||
通用迁移、备份、性能测量和发布要求见
|
||||
[数据库迁移与大数据升级指南](../../development/database-migrations.md)。
|
||||
|
||||
## 用户行为
|
||||
|
||||
子任务仍实时显示完整的文字、思考、工具过程和最终结果。运行时间较长、工具输出较多时,
|
||||
数据库不再为每个流式更新重复保存此前的整份过程;本机和托管 SSH OpenCode 使用相同
|
||||
的桌面存储机制。此修复不关闭子任务、不截短已有过程,也不增加工具审批。
|
||||
|
||||
已有数据库在首次升级时自动处理。启动页铺满窗口,显示处理数量、全宽进度条和近期
|
||||
处理速度;处理时间取决于历史数据量与磁盘速度,不承诺固定几分钟。用户可以退出,
|
||||
下次启动重新检查并继续转换未完成的数据;完成后回收空闲页并进入应用。失败时留在
|
||||
该页提供重试与退出,磁盘空间不足会明确说明,不删除聊天来腾出空间。
|
||||
以上转换和回收只针对仍需转换的旧格式数据;普通结构升级使用独立状态,不宣称正在
|
||||
清理子任务历史,也不会仅因数据库有空闲页而执行全库回收。
|
||||
|
||||
## 存储规则
|
||||
|
||||
- `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 自身会话缓存另按生命周期回收。
|
||||
|
||||
## 活动历史连续保存
|
||||
|
||||
活动历史在桌面 `AssistantDatabase` 中按变化记录写入,保留记录顺序和重复 ID 的独立位置。
|
||||
连续保存使用最近一次成功提交的记录作为比较基准,不再逐次读取整份历史。关闭数据库、
|
||||
清空数据或检测到其他 SQLite 连接提交后重新读取;事务失败不更新比较缓存,重试仍基于已提交内容。
|
||||
本次优化不改变数据库 schema 或完整快照 IPC,也不改变 Agent、Runtime 和远程模型桥。
|
||||
本机与远程任务的桌面活动记录共用此保存路径,Host 不需要更新。
|
||||
|
||||
WAL 增长与 SQL 全历史扫描次数的定向验证见 [BUILD.md](../../../BUILD.md);
|
||||
这些指标不等于安装后应用的物理磁盘吞吐测量。
|
||||
|
||||
## 会话列表读取与前端保留
|
||||
|
||||
列表读取与数据库存储是两个不同层次。`messages.metadata_json` 继续保留完整过程,
|
||||
本次读取优化不修改 schema、迁移或用户数据:
|
||||
|
||||
- Renderer 启动及焦点、会话、队列刷新使用 `conversations.listSummaries(detailIds)`。
|
||||
普通未打开会话只返回标题、项目、置顶状态、上下文设置和消息数量/首条角色/最新消息创建时间,不解析或跨 IPC
|
||||
传输正文、工具、子任务及图片操作元数据。
|
||||
- 当前会话、现有 KeepAlive 视图,以及 Main 仍持有请求、数据库中仍有活动 Task 或
|
||||
streaming 消息的会话返回完整消息。列表包含全部 active 会话,不限制数量,
|
||||
Main 按置顶优先、组内更新时间倒序返回;Renderer 使用下述展示排序。仅置顶不会加载完整历史。
|
||||
- 打开历史通过 `conversations.get(id)` 读取全部消息,不使用会截断历史的分页上限。
|
||||
加载时明确显示进度;失败保留会话与输入,提供原地重试。发送、队列派发和压缩上下文
|
||||
必须先确认完整历史,发送时不按消息条数截断;复制与导出临时读取完整内容,不把未打开会话留入视图缓存。
|
||||
- 搜索由 Main 在 SQLite 中读取标题与正文,不读取过程元数据,保持原来的 Unicode
|
||||
大小写匹配和字面子串规则。范围包含全部 active 会话;前端同时匹配尚未
|
||||
落库的消息。搜索状态只保留命中的 ID,不额外持有整份历史对象。
|
||||
- KeepAlive 淘汰后,在现有保存周期中释放已经确认落库且空闲的详情及持久化比较引用。
|
||||
未保存消息、正在执行的会话、待答/待审批内容不被淘汰。读取中的请求按会话合并;
|
||||
晚到摘要不能覆盖刚打开的历史或新到的后台审批,摘要附带的实时新增消息仍增量保存。
|
||||
- `messageSummary` 仅为只读列表标记,不进入持久化 header。保存继续使用已有逐消息
|
||||
增量接口,空消息数组不代表删除历史。原有完整 `list()` 与数据库 `getConversation()`
|
||||
接口保留供其他调用方使用。
|
||||
- 图片状态通过同一合并函数处理。本地更新与 Main 图片操作按原有时间优先级合并,
|
||||
未变化消息返回原对象,避免每次列表刷新后把无变化的整个历史当作脏消息再次保存。
|
||||
- 展示顺序由 Renderer 的 `sortConversationsForDisplay` 统一派生,规则见
|
||||
[会话列表排序](../application-tool-navigation/ui-design.md#会话置顶与操作菜单)。完整消息取
|
||||
`createdAt` 最大值;摘要的 `latestMessageAt` 在已有聚合查询中取 `MAX(created_at)`,
|
||||
与摘要上附带的实时消息取最大值。该字段仅用于桌面只读投影,不增加数据库列或持久化副本。
|
||||
`updatedAt`/`updated_at` 继续随流式事件更新,原有快照合并、终态优先与逐消息保存规则不变。
|
||||
`useConversationListOrder` 在内存中保留展示 ID 顺序,以单个 5 秒计时器应用自动排序,
|
||||
并按上述交互规则暂缓重排;行数据始终使用最新会话对象,不延迟保存或同步。
|
||||
- 本机和托管 SSH 的 Renderer/Main 读取、持久化与恢复投影共用以上实现。Agent 的
|
||||
transcript、原生 Session、权限及模型桥协议未改变;完整列表、摘要和详情都附带
|
||||
Main 当前恢复请求的问题投影。
|
||||
|
||||
此优化不等于工具折叠内容的延迟渲染,也不保证操作系统立即归还全部 Private Commit。
|
||||
实际测量与适用边界见[实现与验证进度](./progress.md)。
|
||||
|
||||
## 会话置顶
|
||||
|
||||
界面行为见[会话置顶与操作菜单](../application-tool-navigation/ui-design.md#会话置顶与操作菜单)。
|
||||
`conversations.pinned` 是桌面数据库维护的会话属性,本地和消息通道会话共用,默认未置顶。
|
||||
完整快照与摘要均返回 `pinned`,旧快照缺省按未置顶处理。写入只通过
|
||||
`conversations.setPinned({ conversationId, pinned })`,经 Preload、可信发送方校验和严格
|
||||
Schema 校验后更新活动会话,再发布会话变更通知;不存在的会话报错。该写入不修改时间戳、
|
||||
消息或其他会话字段,普通自动保存 header 排除 `pinned`,不会覆盖置顶状态。
|
||||
|
||||
尚未落库的本地草稿先等待保存队列并保存,再设置置顶;消息通道会话直接调用专用接口。
|
||||
Renderer 合并时独立接收 Main 的置顶属性,即使本地正文更新时间更晚也不忽略它。
|
||||
请求修订号和进行中标记防止较早发出的列表/详情响应覆盖刚更新的置顶状态。
|
||||
此属性不修改 Runtime 或远端 Agent 协议。
|
||||
|
||||
## 旧库升级
|
||||
|
||||
启动检查只根据旧 schema 或仍未转换的笔记正文决定是否进入升级流程;正常写入、更新
|
||||
与删除产生的 SQLite 空闲页不是旧格式证据。迁移后新增数据使用当前格式,不因正常使用
|
||||
再次迁移。进入升级流程后仍分别判断结构、笔记转换与空间回收,不把旧 schema 当作
|
||||
回收许可。回收只用于实际存在旧子任务事件(schema < 35)、旧活动记录(schema < 44)
|
||||
或旧笔记正文的转换;例如 schema 45/46 升到当前结构不会自动转换历史或 `VACUUM`。
|
||||
同一次启动在内存中保留这一决定,只有原本需要回收的流程可重试压缩;若正文已转换、仅压缩中断,退出后
|
||||
重开不单凭空闲页重新进入迁移,剩余页由 SQLite 后续写入复用。不新增持久化迁移标记。
|
||||
|
||||
桌面在打开业务数据库之前启动独立 Node 工作线程,使用同一套编码和还原函数转换旧库。
|
||||
按任务有序读取,每批最多 32 条,提交后报告进度;SQLite 保留事务原子性及正常 WAL
|
||||
自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史
|
||||
快照在过小缓存中反复读取;不以关闭事务或同步落盘换取速度。
|
||||
|
||||
转换可以混合读取旧完整记录与已经转换的记录,也会重新编码 schema 34 中重复的工具块
|
||||
更新。因此退出、磁盘写入失败后可以重新扫描并继续;已是相同 JSON 的记录不重复更新,
|
||||
不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非
|
||||
在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。
|
||||
|
||||
子任务整理对应 schema 35;schema 34 用户也会执行该整理,成功后后续启动直接跳过。
|
||||
继续复用 schema 34 的子任务事件部分索引和同一差量表示。业务初始化随后在事务中
|
||||
执行后续结构迁移,其中 schema 36 新增 `pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0, 1))`,
|
||||
已有会话默认未置顶,保留原消息与时间戳;失败回滚本次字段迁移。
|
||||
低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用
|
||||
升级前备份,不要修改 `user_version` 强行绕过版本检查。
|
||||
|
||||
## 验证要求
|
||||
|
||||
- 使用可还原的合成过程验证文字追加、内容修订、块重排、清空和多次打开。
|
||||
- 工具 fixture 必须含生产对象会携带的 `undefined` 可选字段,并经过实际 JSON 写入/重读。
|
||||
同时验证旧完整事件和 schema 34 差量事件的整理、混合重试及无变化记录不被重写。
|
||||
- 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。
|
||||
- 覆盖已完成历史转换、但正常聊天更新产生空闲页的后续结构升级:无转换进度、无
|
||||
`VACUUM`,会话、笔记、任务事件与完整性不变;验证真实 Worker 和同次启动重试。
|
||||
- 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、
|
||||
其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。
|
||||
- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。真实场景必须让主代理
|
||||
与子代理按正常任务需要读取、修改文件和执行测试,不能用“子代理不使用工具”的纯文字
|
||||
场景代替工具存储验收;记录实际请求数、有效变化、重复工具块及还原结果。具体证据见
|
||||
[实现与验证进度](./progress.md)。
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
## 1. 背景
|
||||
|
||||
GoodBuddy 已经在聊天右侧提供任务中心、工作区、浏览器和成果面板,也已经具备
|
||||
GoodBuddy 已经在聊天右侧提供任务中心、工作区和浏览器面板,也已经具备
|
||||
Runtime 事件、Git 变更、文件预览、成果存储、受控浏览器和专家执行等基础能力。后续还
|
||||
计划增加:
|
||||
|
||||
@@ -36,15 +36,15 @@ Runtime 统一抽象为“执行空间”。
|
||||
### 2.1 助手工作栏
|
||||
|
||||
助手工作栏是 GoodBuddy 中始终可访问的应用级工具容器。它提供稳定能力目录,用户从中
|
||||
查看任务中心,并按需打开一个或多个监督、Runtime、终端、进程、工作区、浏览器和成果
|
||||
面板实例。稳定的是能力的可发现性,不是八个同时占据界面的固定面板。
|
||||
查看任务中心,并按需打开一个或多个监督、Runtime、终端、进程、工作区、浏览器、资源监控、
|
||||
文档面板实例。成果页已移除,项目文件由工作区统一展示;会话图片保留在消息中。
|
||||
|
||||
工作栏不是:
|
||||
|
||||
- 只在编程项目中出现的 IDE 面板。
|
||||
- 当前聊天消息的附属详情框。
|
||||
- 根据能力探测结果自动增删入口的动态菜单。
|
||||
- 绕过 Main、Preload、Ask/Execute 或审批边界的控制台。
|
||||
- 绕过 Main、Preload、能力范围或用户数据边界的控制台。
|
||||
- 全系统进程管理器、任意文件浏览器或无边界远程管理工具。
|
||||
|
||||
### 2.2 执行空间
|
||||
@@ -83,31 +83,39 @@ Runtime
|
||||
进程
|
||||
工作区
|
||||
浏览器
|
||||
成果
|
||||
资源监控
|
||||
文档
|
||||
笔记
|
||||
```
|
||||
|
||||
- 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。
|
||||
- 用户主动打开、关闭、排序和停靠面板实例;应用不默认同时挂载全部能力。
|
||||
- Task Center 是 Task 的单例应用级索引。每个 Task 只关联一条 Conversation,一条
|
||||
Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通 Conversation、
|
||||
Job、Run 或心跳事项。
|
||||
- 用户主动打开、关闭、排序和停靠可关闭的面板实例;任务中心和工作区作为基础工作入口默认
|
||||
存在且不可关闭,分别固定在第一、第二位,排序属性为不可重排。
|
||||
- Task Center 是默认跟随当前项目的单实例 Task 索引。每个 Task 只关联一条 Conversation,
|
||||
一条 Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通
|
||||
Conversation、Job、Run 或心跳事项。
|
||||
- 当前能力、连接、数据和空状态可以动态变化。
|
||||
- 能力不可用时,目录项或已打开面板显示原因、影响和可执行的配置或切换入口,不能只通过隐藏表示。
|
||||
- 笔记遵循魔法笔记的业务启用设置,启用时提供应用级单实例 Tab,禁用前处理草稿,禁用后移除入口和实例;取消主侧栏常驻不影响工作栏可用性。采集与来源规则见[魔法笔记逻辑设计](../magic-notes/logic-design.md)。
|
||||
- 用户可以在设置中调整目录顺序;恢复默认布局恢复标准目录与默认打开面板,不强制打开全部能力。
|
||||
- 同一能力需要并排比较不同目标时可以创建多个实例,每个实例拥有独立身份和范围绑定。
|
||||
- 浏览器、终端及其他声明为多实例的能力可以创建多个实例,每个实例拥有独立身份和范围绑定。
|
||||
|
||||
### 3.2 当前上下文只提供默认值
|
||||
|
||||
任务中心作为全局索引不跟随当前会话,也不支持为同一列表打开多个目标实例。其他可绑定
|
||||
任务中心只有一个工作栏实例,任务列表固定跟随当前项目,不随当前 Conversation 筛选;顶部
|
||||
统计跟随当前 Conversation。不提供全局或所有项目入口。其他可绑定
|
||||
目标的能力使用当前会话、项目、Runtime 和主机帮助新面板实例初始定位,但这些上下文不是
|
||||
使用门槛:
|
||||
|
||||
- 监督默认选择当前会话,用户可以改选其他 Conversation、Task 或实验对象。
|
||||
|
||||
当前监督卡片已支持 Conversation/Task 跟随与固定,绑定保存在 tasks 实例的 `targetRef`;绑定只影响监督卡片,任务列表继续跟随当前项目。实现范围与验证见[监督者进度](../conversation-supervision/progress.md)。实验对象仍属后续工作。
|
||||
- Runtime 默认跟随当前会话或 Task;执行事件可以查看,但 Job/Run 不作为独立选择对象。
|
||||
- 终端创建时固定到当前项目执行空间,由项目配置自动决定本机或托管 SSH;没有当前项目时
|
||||
使用本机 Home。
|
||||
- 工作区默认显示当前项目目录,用户可以打开其他本机目录或远程目录。
|
||||
- 成果默认使用当前范围,用户可以切换到项目、全局或其他允许范围。
|
||||
- 项目文件统一使用工作区范围,会话图片按原消息引用读取。
|
||||
- 文档创建时固定到用户明确打开的文件和 ShareServer 编辑会话,不提供跟随模式。
|
||||
- 下一次请求的附件和知识库由当前 Composer 展示与管理,不作为工作栏能力。历史执行上下文
|
||||
随关联 Task 查看;记忆功能实现后也归入任务中心,而不是新增独立上下文面板。
|
||||
|
||||
@@ -133,7 +141,7 @@ Runtime
|
||||
稳定能力目录和已打开面板必须准确呈现能力差异:
|
||||
|
||||
- 当前 Runtime 不支持后台 Job 时,Runtime 能力仍可发现;打开后说明当前可监督的内容。
|
||||
- 当前执行空间没有 Git 仓库时,工作区文件功能仍可使用,Git 区域显示不可用原因。
|
||||
- 当前执行空间没有 Git 仓库时,工作区文件功能仍可使用,仅确认是 Git 仓库后显示 Git 工作区入口。
|
||||
- 没有活动进程时,进程面板提供创建终端或启动 Runtime 的入口。
|
||||
- 没有项目时,终端使用当前用户本机 Home;工作区等允许选择路径的能力仍必须先取得有界
|
||||
临时 Execution Space Grant,不能把可选路径直接传给文件、Git、Process 或 Runtime。
|
||||
@@ -168,7 +176,7 @@ Runtime
|
||||
- 建立统一范围、执行空间、生命周期、成果和控制契约。
|
||||
- 复用现有 Project、Conversation、Task、Artifact、Activity 和 Approval 数据。
|
||||
- 保持 Renderer 无任意文件、进程、PTY、SSH 或 Electron API 能力。
|
||||
- 保持 Ask 只读、Execute 审批、取消、超时、输出边界和活动审计。
|
||||
- 使用当前账号权限,保留能力范围、取消、超时、输出边界和活动审计。
|
||||
- 为本机与远程能力提供一致 UI,同时准确表达能力差异。
|
||||
|
||||
## 5. 非目标
|
||||
@@ -202,24 +210,30 @@ Runtime
|
||||
|
||||
### 6.2 能力目录与面板实例
|
||||
|
||||
能力目录不等于同时打开八个面板。当前产品采用“已打开实例 Tab + 持久新增按钮”的工作栏
|
||||
能力目录不等于同时打开十个面板。当前产品采用“已打开实例 Tab + 持久新增按钮”的工作栏
|
||||
模型:Tab 行只显示已打开实例,行末“+”始终可见;点击后在工作栏内容区打开稳定能力目录,
|
||||
用户再创建或激活应用实例。详细交互以
|
||||
[助手工作栏多终端页签 PRD](./terminal-tabs-prd.md) 为准。
|
||||
|
||||
- 每项始终显示稳定图标,并提供可见标签或可持续查看的工具提示。
|
||||
- 已打开实例使用 `tablist`、`tab`、`tabpanel`;“+”是位于 `tablist` 外的普通命令按钮。
|
||||
- 应用注册表声明 `single` 或 `multiple`。重复选择单实例应用时聚焦已有实例;选择多实例
|
||||
应用时每次创建新实例。
|
||||
- 支持方向键、Home、End、Enter、Space、关闭面板和正确焦点恢复。
|
||||
- 应用注册表分别声明实例策略、默认打开、必须存在、是否可关闭和是否允许排序。`single` 或
|
||||
`multiple` 只控制实例数量;`required` 控制布局修复,不能兼作关闭或排序策略。
|
||||
- 在工作栏“+”目录中重复选择单实例应用时聚焦已有实例;选择浏览器、终端等多实例应用时
|
||||
每次创建新实例。选择已有 Tab 只激活该实例,保留其目标与历史。主侧栏应用中心不重复
|
||||
提供文件、终端、浏览器或资源监控入口。
|
||||
- 支持方向键、Home、End、Enter、Space、可关闭面板的关闭命令和正确焦点恢复。不可关闭
|
||||
实例不渲染关闭按钮,也不响应关闭快捷命令。
|
||||
- 徽标显示未解决数量、等待审批或失败状态,并同时提供文字或可访问名称。
|
||||
- 用户调整目录顺序、打开实例、停靠位置和尺寸后持久化;关闭实例后能力仍可从目录重新打开。
|
||||
- 用户调整目录顺序、打开实例、停靠位置和尺寸后持久化;关闭可关闭实例后能力仍可从目录
|
||||
重新打开。布局加载和恢复默认布局必须补回缺失的任务中心与工作区实例。
|
||||
- 默认布局只恢复经过产品确认的少量常用面板,不自动打开全部标准能力。
|
||||
- 每个实例显示稳定实例 ID、能力名称、跟随或固定状态与当前目标;同能力多实例不能只靠位置区分。
|
||||
|
||||
从当前聊天右栏迁移时,Task Center、工作区、浏览器和成果作为默认打开的单实例应用保留;
|
||||
从当前聊天右栏迁移时,Task Center 和工作区作为默认打开、单实例且不可关闭的基础应用保留;
|
||||
浏览器保留一个默认打开实例,但注册为可关闭的多实例应用;成果入口移除;
|
||||
终端不默认打开,由用户通过“+”创建,并从首期开始支持多实例。Tab 必须单行横向滚动,
|
||||
不能自动隐藏或缩写到不可辨认,“+”不能随滚动消失。Task Center 继续作为 Task 的现有
|
||||
不能自动隐藏或缩写到不可辨认。“+”固定在滚动区外的行末,溢出时页签区两端提供固定滚动箭头,箭头不覆盖“+”。Task Center 继续作为 Task 的现有
|
||||
入口;审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和
|
||||
完整配置统一归属“智能心跳”菜单入口。当前阶段不新增独立自动化中心。
|
||||
|
||||
@@ -234,7 +248,7 @@ Runtime
|
||||
项目切换器和导航整体向下推移。
|
||||
- 对话内容与输入区始终保留对称的左右安全边距,工作栏边界不得覆盖或裁切输入框、发送
|
||||
按钮及主工作区右侧边距。
|
||||
- 唯一展开与收起开关固定在窗口关闭按钮正下方。工作栏内部不重复关闭入口。开关图标中的
|
||||
- 唯一展开与收起开关固定在顶栏操作区、紧邻浅色/深色主题按钮之前。工作栏内部不重复关闭入口。开关图标中的
|
||||
箭头方向随当前动作切换,按钮不以持续高亮表达展开状态;当前会话没有 Task 时不渲染空
|
||||
Task 上下文栏。
|
||||
- “新建任务”入口位于工作栏“任务索引”标题后方,不在会话 Task 上下文栏重复出现;索引
|
||||
@@ -249,20 +263,26 @@ Runtime
|
||||
Task Center 保留为工作栏中的稳定入口,并在现有基础上适度完善:
|
||||
|
||||
- 只索引 Task;每个 Task 只关联一条 Conversation,一条 Conversation 可以关联多个 Task。
|
||||
- 显示名称、关联 Conversation、Global 或 Project 范围、Ask/Execute、状态、最近进展、
|
||||
真实活动时间和需要关注信息。
|
||||
- 顶部显示当前会话消息总数和 Agent 累计回复时间;列表显示本项目任务的名称、关联 Conversation、
|
||||
所属项目、状态、最近进展、真实活动时间、累计执行时间和需要关注信息。
|
||||
- 点击 Task 打开其关联 Conversation 并定位 Task。
|
||||
- 完整消息留在 Conversation;工具、Subagent、审批、错误和成果按 Task 关联到 Runtime、
|
||||
活动记录和成果查看器中,不在窄栏复制,也不呈现 Job/Run 树。
|
||||
活动记录、会话消息和工作区中,不在窄栏复制,也不呈现 Job/Run 树。
|
||||
- 智能心跳的报告、建议、历史和配置不进入任务中心。
|
||||
|
||||
任务中心是单例索引,不使用其他能力的“跟随 / 固定目标”多实例模型。后台状态可以更新
|
||||
徽标和排序,但不能自动打开面板或抢占用户当前工作。
|
||||
任务中心是应用级单例,默认存在且不可关闭。当前项目是唯一范围,标题明确显示项目;切换
|
||||
项目刷新任务和审批,同项目切换会话只更新顶部统计。无项目时提示选择项目。后台状态
|
||||
可以更新徽标和排序,但不能抢占用户当前工作。
|
||||
|
||||
回复时间按每轮请求区间累加,包含轮内工具和审批等待、不计轮间空闲;任务用时汇总历次执行
|
||||
请求,不按父任务生命周期计算。缺失历史有提示,远程缺少可靠证据时不虚构时间。
|
||||
项目任务标题右侧先显示“+”新建按钮,再显示筛选图标;状态筛选默认收起、默认全部。
|
||||
|
||||
主侧栏最近会话为关联 Task 提供轻量入口:父会话行只显示行首展开按钮,Task 子项使用
|
||||
任务图标和本地化摘要,展开层级只到 Task;点击 Task 子项打开同一 Conversation 并定位。
|
||||
新建定制任务使用 Modal 选择当前或新
|
||||
Conversation,默认 Execute,并持续显示 Runtime、Project、工作目录、工具和审批摘要。
|
||||
新建定制任务使用共享 Modal,默认立即执行和当前 Conversation,可选本项目其他可用会话或
|
||||
新会话,以及单次定时、每日或每周。内容必填,名称可留空从内容生成;使用关联
|
||||
会话的执行时配置,不在项目、会话或创建表单保存工作模式。
|
||||
|
||||
详细产品边界以 [Task Center PRD](../task-and-job/task-center-prd.md) 和
|
||||
[Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 为准。
|
||||
@@ -287,7 +307,7 @@ Conversation,默认 Execute,并持续显示 Runtime、Project、工作目录
|
||||
- 未解决、已查看、已解决、忽略和误报状态。
|
||||
- “带入输入框”“查看证据”“追问”“停止当前回复”等用户介入操作。
|
||||
|
||||
“采纳”只生成可编辑草稿或显式会话操作,不自动发送、执行、切换 Execute 或批准工具。
|
||||
“采纳”只生成可编辑草稿或显式会话操作,不自动发送、执行或批准工具。
|
||||
|
||||
### 7.3 Runtime
|
||||
|
||||
@@ -352,6 +372,10 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
|
||||
### 7.6 工作区
|
||||
|
||||
工作区是默认存在、单实例且不可关闭的基础应用。它默认跟随当前项目;项目或目录不可用时
|
||||
保留同一 Tab 并显示修复入口,不能通过关闭 Tab 隐藏范围错误。任务中心固定在第一位,工作区
|
||||
固定在第二位,恢复布局时保持此顺序。
|
||||
|
||||
工作区面板允许用户选择:
|
||||
|
||||
- 当前项目目录。
|
||||
@@ -368,29 +392,92 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
|
||||
当前项目目录的文件浏览与 Git 交互遵循以下规则:
|
||||
|
||||
- 页签下不重复显示“项目工作区”标题,保留刷新按钮。
|
||||
- FR-W1:顶部同一行放置“文件 / Git 工作区”视图切换和右侧刷新;非 Git 目录仍显示选中的“文件”入口和刷新,仅隐藏“Git 工作区”。
|
||||
下一行只提供当前视图的工具:文件视图提供新建文件、新建目录,Git 视图提供分支、Fetch 和更改显示方式,
|
||||
不在 Git 视图重复目录导航或新建文件操作。操作使用带名称和提示的紧凑图标按钮。
|
||||
文件视图在根目录不显示孤立的文件夹图标或空路径行;进入子目录后提供“项目目录名 / 子目录”
|
||||
文字路径导航和返回父目录按钮。根目录文字可点击返回,提示显示完整根路径;返回根目录后焦点移至顶部刷新。
|
||||
长目录名单行省略,完整路径通过提示查看;多级路径在导航内部横向滚动,不撑宽工作栏。
|
||||
选中目录时在该目录创建,选中文件时在其父目录创建,未选择时在当前浏览目录创建。
|
||||
无可用目录时隐藏无关操作。
|
||||
文件列表独立纵向滚动;顶部视图切换、刷新、新建操作和子目录路径导航保持可见,不随列表滚走。
|
||||
- 使用共享 `SegmentedControl` 切换“文件”和“Git 工作区”,默认显示文件;
|
||||
选择只在当前面板挂载期间保存在内存中,不持久化。未提交更改包含当前项目的全部
|
||||
Git 更改,不按 AI、用户或其他工具的修改来源筛选。
|
||||
- FR-W2:只有已确认的 Git 仓库显示 Git 工作区入口。临时读取或连接错误保留同一项目
|
||||
已确认的 Git 可用性和内容,提供重试;切换项目不能沿用上一项目的确认结果。
|
||||
- FR-W3:文件和目录行的更多按钮与左侧会话行一致,在悬停、键盘聚焦或菜单打开时显示。
|
||||
菜单复用会话操作的图标、间距和悬停样式,浮层限制在窗口内,不受文件列表滚动边界裁切;
|
||||
支持方向键、Home、End 和 Escape,Escape 或选定操作后焦点返回触发按钮,删除入口独立分隔。
|
||||
菜单提供重命名、移动、删除、属性、复制路径和本机默认应用打开;删除须明确确认对象
|
||||
与不可恢复性。操作失败保留输入与上下文。本机和远程均支持文件管理,系统默认打开仅本机可用。
|
||||
重命名允许仅修改同一目录项的名称大小写,不得覆盖另一个已存在的文件、目录或硬链接目录项。
|
||||
移动通过与创建项目相同的目录选择流程指定目标:本机打开系统目录选择器,远程浏览当前
|
||||
SSH Host 的目录。选择目录后保留原名,确认后执行;取消不移动文件。目标限于当前工作区,
|
||||
选择外部目录时在弹窗内说明限制,不切换项目或工作区范围。取消按钮使用共享次按钮样式,
|
||||
更多菜单中的删除图标和文字与其他菜单项一样左对齐。
|
||||
- FR-W4:Git 分支标签打开可搜索的本地与远程分支选择器,支持创建和切换分支,以及显式
|
||||
Fetch。切换使用普通 Git switch,保留可携带的未提交修改;冲突时显示 Git 错误。
|
||||
不自动 force、reset、discard 或 stash,不因浏览分支而自动 Fetch。
|
||||
分支触发器复用共享模型选择按钮,带分支图标和展开箭头;Fetch 使用共享次按钮,二者在
|
||||
紧凑工作栏内保持同一行,长分支名省略并提供完整名称提示。
|
||||
分支选择面板浮在内容上方,展开时不挤占变更列表和提交历史;宽度不超过 360px,并限制在窗口内。
|
||||
搜索使用共享表单标签、输入框与主题样式,分支列表独立限高滚动,创建按钮以紧凑次操作放在列表下方。
|
||||
点击面板外或焦点移出时收起;Escape 收起面板并将焦点返回分支触发器。
|
||||
- FR-W5:未提交更改支持列表和目录树切换;两种视图均可打开单文件 Diff。
|
||||
- FR-W6:Git 工作区底部提供默认收起的提交历史区,独立滚动,可调整高度。
|
||||
历史标题默认停在面板底部,首次展开向上占用当前 Git 内容区的一半高度;收起后标题回到底部。
|
||||
上方变更列表和下方提交历史独立滚动;分隔条支持指针及键盘调整比例,底部位置保持不变。
|
||||
顶部刷新入口同时更新已展开的提交历史,历史区内不重复提供刷新按钮。
|
||||
按当前分支分页读取提交,展示摘要、作者、时间、短 OID 和 refs。选中提交显示该次修改文件,
|
||||
再选文件进入内联提交 Diff;逐层返回保留分支、分页、选择、滚动位置与焦点。
|
||||
提交 Diff 保留重命名前后路径和实际内容修改;后台工作区刷新不得使正在加载的历史或详情
|
||||
永久停留在忙碌状态。
|
||||
历史展开入口使用带方向箭头的紧凑列表行;提交行沿用文件列表的表面、悬停和焦点样式,
|
||||
摘要与元信息左对齐分行展示,长内容省略并提供完整提示,不撑宽侧栏。
|
||||
- Git 错误和无更改提示只在 Git 工作区显示,不阻断文件浏览。
|
||||
切换项目时自动检测 Git 状态,无需手动刷新;检测结果和失败时保留的数据仅用于所属项目。
|
||||
- 未提交更改以彩色 Git 字母标识状态:M 修改、A 新增、D 删除、R 重命名、C 复制、
|
||||
U 未跟踪或冲突、T 类型变化;冲突用危险色区别于未跟踪文件,同时保留原始状态。
|
||||
- 点击更改项进入该文件 Diff,分别显示已暂存和未暂存差异,增删行使用对应状态色;
|
||||
删除文件也可查看。目录树中的文件仍打开内容预览。不再提供底部全仓库 Diff 入口。
|
||||
- 手动刷新或任务结束更新 Git 状态时,同步重读根目录和已展开目录,保留目录展开状态,
|
||||
清除未展开目录的旧缓存;正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。
|
||||
- 文件预览保留在右侧工作区内;预览期间隐藏列表但保持目录树挂载,返回后保留已展开
|
||||
目录、滚动位置和选中行,并恢复触发行焦点。Diff 返回未提交更改列表时同样恢复
|
||||
滚动位置、选中行和焦点。
|
||||
- 文件预览提供复制项目相对路径、刷新,以及仅本机项目可用的“使用默认应用打开”。
|
||||
刷新失败保留此前内容并显示错误;Markdown 默认渲染预览,可切换源码。
|
||||
- Diff 提供刷新、复制项目相对路径和“查看当前文件”;删除项不显示查看当前文件入口。
|
||||
文件路径与操作按钮位于分隔线上方,空间不足时换行;只有未暂存差异时省略“工作树更改”标题,同时存在已暂存差异时保留分区标题。
|
||||
- 手动刷新或任务结束更新 Git 状态时,同步重读根目录、当前浏览目录和已展开目录并去重,保留目录展开状态,
|
||||
刷新失败保留已有目录列表;重新展开目录时重读。目录读取错误在对应目录旁显示,
|
||||
可就地重试。正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。
|
||||
无首次提交的仓库也应显示新增和暂存文件。
|
||||
- 未提交更改首批显示 50 项,可继续分批加载其余变更,不能使后续文件尤其是已删除
|
||||
文件失去 Diff 入口。
|
||||
- 系统默认应用和资源管理器打开按钮仅用于本机项目,SSH 项目不显示这两个按钮。
|
||||
|
||||
目录切换、下载副本、终端打开及 HTML 安全预览仍属于后续能力,不能据此视为已实现。
|
||||
项目内目录导航属于 FR-W1;项目外目录选择、下载副本、终端打开及 HTML 安全预览仍属于后续能力。
|
||||
本轮工作区交互不包含项目文件搜索,也不调整其他工作栏页签。
|
||||
|
||||
本机和远程访问都必须由 Main 或远程 GoodBuddy Agent 在对应文件系统上执行路径规范化、相对路径和
|
||||
符号链接边界检查。Renderer 只能提交受约束的相对路径和已授权范围 ID。
|
||||
|
||||
### 7.7 浏览器
|
||||
|
||||
Page requests made with `target="_blank"` or `window.open` open managed browser tabs in the same
|
||||
Conversation. A popup from the visible page becomes visible in the workbar; closing it from page script
|
||||
removes that popup tab. When an Agent click opens a new tab, subsequent browser tools follow that page
|
||||
and obtain fresh element references. Detailed lifecycle and routing rules are defined in
|
||||
[Page-created tabs](./browser-tabs-technical-design.md#62-page-created-tabs).
|
||||
|
||||
浏览器是可关闭的多实例应用。升级后的默认布局保留一个浏览器实例;用户从“+”目录选择
|
||||
浏览器时每次创建新的 Browser Tab。每个实例必须显示可区分的标题和绑定 Conversation,
|
||||
不能让多个工作栏 Tab 只是同一页面的重复控制入口。
|
||||
|
||||
浏览器能力在目录中稳定可发现,打开面板后允许用户:
|
||||
|
||||
- 创建新的 GoodBuddy 隔离浏览器会话。
|
||||
- 选择当前会话或其他受控浏览器会话。
|
||||
- 在当前 Conversation 的隔离浏览器上下文中创建独立 Browser Tab。
|
||||
- 选择当前 Conversation 或其他允许访问的 Conversation 作为实例目标。
|
||||
- 查看状态、当前 URL、有界画面和错误。
|
||||
- 通过顶部浏览器工具栏返回、刷新或停止加载、编辑地址并前往。
|
||||
- 进入明确的交互模式或关闭浏览器。
|
||||
@@ -399,10 +486,19 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
页面加载期间原位切换为“停止加载”;“关闭浏览器”是清楚独立于导航的会话生命周期操作,
|
||||
必须与“停止加载”在文案和视觉上明确区分。现有“停止浏览器”语义统一改名为“关闭浏览器”。
|
||||
|
||||
浏览器面板和 Agent 操作同一条按 Conversation 归属的浏览器会话,不创建用户预览专用的
|
||||
第二个页面。用户执行前往、返回或刷新会有意改变 Agent 下一步读取和操作的页面。所有
|
||||
前往、返回和刷新请求都进入 `BrowserService`,并与 Agent 发起的浏览器操作串行执行;不在
|
||||
Renderer 建立第二套导航队列、抢占规则或恢复状态机。
|
||||
新建或重新创建的空白标签页尚无已提交 URL 时禁用刷新;只输入地址草稿不会启用刷新。
|
||||
首次导航期间仍可停止加载。操作错误仅在当前面板本次打开期间显示,切换面板、关闭当前
|
||||
页签或收起工作栏后清除;关闭失败后重试成功也清除原错误,已离开面板的异步失败不得重新显示旧提示。
|
||||
|
||||
同一 Conversation 下的 Browser Tab 共享隔离存储和代理上下文,但每个 Tab 拥有独立页面、
|
||||
导航状态、操作队列和元素引用空间。模型请求开始时绑定一个确定的 Browser Tab;用户切换
|
||||
工作栏实例不能在请求途中改变 Agent 的工具目标。用户在该实例执行前往、返回或刷新会有意
|
||||
改变绑定到同一 Tab 的 Agent 后续操作。所有请求都进入 `BrowserService`;Renderer 不建立
|
||||
第二套导航队列、抢占规则或恢复状态机。
|
||||
|
||||
用户可关闭 Agent 正在使用的标签页,无需先取消整轮请求。关闭只中止该页的浏览器操作,
|
||||
其他标签页和请求的其他工具继续可用。后续快照等操作应提示标签页已关闭;Agent 显式导航
|
||||
时打开专用新标签页并继续操作,不能自动接管用户的其他页面。
|
||||
|
||||
浏览器工具栏读取的权威状态至少包含:
|
||||
|
||||
@@ -430,39 +526,43 @@ type BrowserToolbarState = {
|
||||
没有浏览器会话时仍显示工具栏。活动 Conversation 可以直接在地址栏输入非空 URL 并“前往”,
|
||||
由 `BrowserService` 通过 Agent 浏览器相同的能力检查和 URL 策略创建会话并导航;不得为这一
|
||||
空状态增加旁路 WebView 或宽松 URL 入口。没有活动 Conversation 时说明需要先选择或创建会话。
|
||||
模型或后台浏览器活动可以更新徽标,但不得无条件打开面板或切换用户当前面板。
|
||||
模型或后台浏览器活动可以更新对应实例的徽标,但不得无条件打开面板或切换用户当前面板。
|
||||
|
||||
浏览器多实例的跨进程身份、可见 viewport 所有权、关闭语义和 MCP 兼容方案以
|
||||
[浏览器多实例技术设计](./browser-tabs-technical-design.md)为准。
|
||||
|
||||
浏览器面板只管理 GoodBuddy 受控浏览器,不表示可以控制用户已安装的浏览器。
|
||||
|
||||
### 7.8 成果
|
||||
### 7.8 资源监控
|
||||
|
||||
成果面板统一显示全局、项目、Conversation、Task 执行和监督显式产生的独立成果:
|
||||
资源监控是本机单实例面板,显示整机 CPU、内存、磁盘 IO 和 GoodBuddy 桌面进程摘要。面板
|
||||
只在自身可见并处于活动状态时采样,数据保留在 Renderer 有界内存中,不进入运行记录或长期
|
||||
存储。首期不提供远程主机、GPU、显存、网络吞吐、后台告警或任意进程查看。
|
||||
|
||||
- Markdown、纯文本和 JSON。
|
||||
- 图片和图表。
|
||||
- HTML 安全预览。
|
||||
- 后续的 PDF、Office、表格和其他受支持格式。
|
||||
指标缺失时保留面板入口并显示不可用原因。资源监控入口、采样频率、数据契约与验收由
|
||||
[应用导航与系统工具入口](../application-tool-navigation/README.md)定义。
|
||||
|
||||
普通聊天回复只保留在会话消息流中,不自动复制为成果。只有 Runtime 或受管工具显式声明的
|
||||
Artifact、自动化和监督生成的独立输出,以及用户手动导入或明确保存的内容进入成果面板。
|
||||
升级前已经自动保存的普通对话 Markdown 可以从成果列表中隐藏,但不应通过升级迁移物理
|
||||
删除用户数据库内容。
|
||||
### 7.9 工作区文件与会话图片
|
||||
|
||||
用户可以切换范围、搜索、预览、查看来源、导出或打开关联对象。成果必须保留项目、
|
||||
Conversation、Task、内部 Run、创建者、MIME、大小、校验值和时间等可用归属;界面按
|
||||
Task 呈现来源,不把 Run 作为导航对象。
|
||||
成果页面已移除。FR-WF1~FR-WF3、导入与排序场景以及图片保留边界统一见
|
||||
[工作区文件导入与时间](./workspace-files.md)。实际文件使用项目目录作为唯一来源;会话图片
|
||||
继续使用已有消息引用和 artifact 存储。HTML 会话预览由
|
||||
[会话 HTML 渲染](../conversation-html-rendering/README.md)维护。
|
||||
|
||||
#### HTML 即时预览
|
||||
### 7.10 文档
|
||||
|
||||
- Runtime 或受管工具通过显式 Artifact 事件声明成果,不能让 Renderer 猜测任意路径。
|
||||
- Main 验证成果属于当前授权执行空间,限制大小、类型和读取范围后再持久化。
|
||||
- HTML 使用 `iframe sandbox=""` 和严格 CSP 进行脚本关闭、网络关闭的静态预览。
|
||||
- 清理脚本、事件属性、嵌套 frame、object、embed、base、link、meta refresh、表单和活动 URL。
|
||||
- 提供“预览 / 源码”切换,并持续标注“静态安全预览,脚本和网络已禁用”。
|
||||
- 不使用 `dangerouslySetInnerHTML`,不启用 Electron `webviewTag`。
|
||||
- 外部打开是明确的用户操作,并说明外部浏览器可能执行脚本或联网。
|
||||
文档是可创建多个实例的工作栏应用。用户从工作区或会话文件引用打开受支持的 Office
|
||||
文件时新增文档 Tab;每个实例固定到一个 ShareServer 编辑会话,不随当前 Conversation 或
|
||||
Project 切换。Tab 使用文档文件名,同名实例增加序号,完整来源通过 Tooltip 和页签内信息
|
||||
显示。编辑器、保存、冲突、AI 选区修改和关闭行为以
|
||||
[Office 协同编辑](../office-document-editing/README.md)为准。
|
||||
|
||||
### 7.9 Composer 与任务上下文
|
||||
完整编辑器需要至少 `720px` 可用宽度;低于 `480px` 时不把第三方工具栏压缩到重叠,而显示
|
||||
摘要和“展开编辑”命令。展开到主内容区、底部或独立窗口继续使用同一页签实例和编辑会话。
|
||||
文档编辑依赖用户明确配置且声明健康能力的 ShareServer;不可用时保留入口和原因,不回退为
|
||||
未经说明的远程服务。本地默认应用打开仍是独立用户操作。
|
||||
|
||||
### 7.11 Composer 与任务上下文
|
||||
|
||||
工作栏不提供独立“上下文”能力。用户准备下一次模型请求时:
|
||||
|
||||
@@ -486,7 +586,9 @@ type WorkbarCapabilityId =
|
||||
| 'processes'
|
||||
| 'workspace'
|
||||
| 'browser'
|
||||
| 'resources'
|
||||
| 'results'
|
||||
| 'document'
|
||||
|
||||
type WorkbarTargetRef =
|
||||
| { type: 'conversation'; id: string }
|
||||
@@ -499,6 +601,7 @@ type WorkbarTargetRef =
|
||||
| { type: 'managed-process'; id: string }
|
||||
| { type: 'browser-session'; id: string }
|
||||
| { type: 'artifact'; id: string }
|
||||
| { type: 'document-session'; id: string }
|
||||
```
|
||||
|
||||
Renderer 选择目标后,Main 必须重新验证对象存在、归属范围和当前用户可见性。不能把目标 ID
|
||||
@@ -533,7 +636,7 @@ type WorkbarPanelInstance = {
|
||||
|
||||
### 9.1 设置入口
|
||||
|
||||
设置中心“平台功能”提供独立“远程项目(技术预览)”页签及开关,默认关闭。关闭时不显示
|
||||
设置中心“平台功能”提供独立“远程项目”页签及开关,默认关闭。关闭时不显示
|
||||
“主机与远程执行”分类、托管 SSH 项目创建选项和已保存远程项目,Main IPC 同时拒绝新的
|
||||
SSH/远程项目操作;已有 Host、项目和凭据保持不变。若当前项目已经是远程项目,则切回
|
||||
项目列表中的第一个普通
|
||||
@@ -541,6 +644,10 @@ SSH/远程项目操作;已有 Host、项目和凭据保持不变。若当前
|
||||
|
||||
启用后,设置中心显示“主机与远程执行”分类,管理:
|
||||
|
||||
> 上述入口为当前实现。2026-09-28 已确认后续迁入应用中心的“主机管理”,并新增同级
|
||||
> “云环境管理”;范围、能力依赖及验收见[主机与云环境 PRD](../remote-host/prd.md)。
|
||||
> 本节保留现有行为,入口迁移尚未实现。
|
||||
|
||||
- 主机名称、地址、端口和用户名。
|
||||
- 认证方式和凭据配置状态。
|
||||
- Host Key 算法与 SHA-256 指纹。
|
||||
@@ -557,7 +664,7 @@ SSH/远程项目操作;已有 Host、项目和凭据保持不变。若当前
|
||||
连接信息、认证前 Host Key 核对、密码/系统 SSH Agent 认证和平台/架构/Shell/Home 有界
|
||||
探针,全部成功后才原子保存主机、加密凭据和完整 Host Key。认证失败可在弹窗内修改凭据
|
||||
重试;取消或失败不保留新主机。Key 变化显示旧/新指纹并要求显式高风险确认。项目创建和
|
||||
设置现可选择托管 SSH、已保存 Host、远端工作目录、OpenCode 和 Ask/Execute,不要求
|
||||
设置现可选择托管 SSH、已保存 Host、远端工作目录和 Runtime,不要求
|
||||
额外 trust tier 或 consent checklist。候选完成签名 Agent/Runtime、Workspace、ACP、模型桥
|
||||
和文本模型 profile 验证后原子提交。私钥导入仍未开放。旧流程已保存但
|
||||
没有成功探针记录的主机继续读取,但在完成新向导前显示为“需要重新验证”。
|
||||
@@ -598,7 +705,7 @@ digest。当前 registry、连接或 capability 无效时项目保持未切换
|
||||
匹配 manifest 元数据,不重新验签或哈希完整 Agent payload。项目切换器立即提交本地选择,
|
||||
不展示远程激活进度;完整进度和取消只属于新建或显式保存项目。
|
||||
|
||||
项目只持久化 Host ID、规范远端路径、Runtime 选择和默认工作模式。新建和保存验证当前
|
||||
项目只持久化 Host ID、规范远端路径和 Runtime 选择。新建和保存验证当前
|
||||
Workspace/Runtime,但不写入组件 identity;普通切换不做远程验证。未就绪或版本过旧时,
|
||||
实际 Workspace/Runtime 操作显示错误。桌面更新造成组件版本变化时,用户先在 Host 管理中
|
||||
准备环境;项目切换本身不连接、不下载、不扫描、不安装,也不改写项目。
|
||||
@@ -673,8 +780,8 @@ Runtime 会话失效。产品不维护 T1/T2/T3 等附加信任层级。
|
||||
- Main 保持可信控制面,远程 GoodBuddy Agent 只接受有范围、有期限的请求。
|
||||
- GoodBuddy 控制协议负责 Agent、Workspace、Git、Process、Blob 和 Runtime supervision;
|
||||
Runtime Session、stream、tool、permission、usage 和取消优先复用 ACP。
|
||||
- Ask 的只读限制在远程 GoodBuddy Agent 和 Runtime 适配层共同强制。
|
||||
- Execute 是用户对所选 SSH 账号完整权限的授权,直接以该账号可用的文件、进程、网络和
|
||||
- GoodBuddy Agent 与 Runtime 适配层均不保留产品模式字段或只读名单。
|
||||
- 请求直接以所选 SSH 账号可用的文件、进程、网络和
|
||||
工具能力运行,不要求逐工具审批或第二个“受控执行”模式。取消、超时、输出上限和活动
|
||||
记录继续保留。
|
||||
- GoodBuddy 模型凭据和 Provider URL 必须留在 Main。远程 Runtime 只获得不含凭据的模型
|
||||
@@ -767,11 +874,12 @@ remote_operations
|
||||
其中:
|
||||
|
||||
- 工作栏偏好只保存能力目录顺序、面板实例、停靠布局、尺寸和目标引用。
|
||||
- 资源监控只保存面板实例,不保存采样结果或历史曲线。
|
||||
- 主机表只保存非敏感元数据和加密凭据引用。
|
||||
- 应用重启后先通过远程 Daemon 的 journal、lease、boot identity 和 event cursor 核对活动
|
||||
终端、进程与 Runtime;无法确认时标记 `interrupted` 或 `outcome_unknown`。
|
||||
- 日志使用有界环形缓冲或分页持久化,不能无限写入 SQLite。
|
||||
- Artifact 继续作为成果的权威实体,不把完整成果复制进工作栏状态。
|
||||
- 工作区目录是文件的权威来源;会话图片继续使用 Artifact 引用,不复制进工作栏状态。
|
||||
- 运行记录由 Main SQLite 持久化,Renderer 只保留当前内存视图。升级时迁移旧
|
||||
`localStorage` 记录;若旧缓存达到原数量、详情或总大小边界,页面持续说明更早记录或
|
||||
部分详情可能已经丢失。新记录不再按固定活动数或详情字符数丢弃,长列表只做可继续展开的
|
||||
@@ -786,7 +894,7 @@ Renderer 只通过显式方法访问:
|
||||
- 终端创建、输入、调整大小、关闭和有界输出订阅。
|
||||
- 受管进程列表、日志和允许的控制动作。
|
||||
- Runtime Inspector 快照、事件和允许的控制动作。
|
||||
- 工作区、成果、浏览器和监督的既有或扩展服务。
|
||||
- 工作区、会话图片、浏览器和监督的既有或扩展服务,以及有界的本机资源快照。
|
||||
|
||||
每个 Main Handler 都必须验证可信发送者、Zod 输入、对象归属和当前状态。不得暴露 raw
|
||||
Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
@@ -794,8 +902,8 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
## 12. 安全边界
|
||||
|
||||
1. 工作栏能力目录项和面板实例不授予任何能力;权限只由 Main 中的范围和控制契约产生。
|
||||
2. Ask 在本机和远程 Runtime 边界保持只读。
|
||||
3. Execute 继续经过现有 Runtime 和审批控制,工作栏不能直接放宽。
|
||||
2. 本机和远程 Runtime 按实际注册目录、启用与分配设置提供能力。
|
||||
3. 使用当前执行账号权限,不另加目录或逐工具审批;工作栏不能绕过 Main、Preload 与用户数据边界。
|
||||
4. 用户终端和 Agent 工具执行使用不同身份和事件来源。
|
||||
5. 进程面板只控制 GoodBuddy 受管对象,不接受任意 PID。
|
||||
6. 本机和远程路径分别在对应文件系统上 canonicalize 并验证符号链接边界。
|
||||
@@ -825,6 +933,57 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
应用通知;预览失败、终端断线、Host Key 变化、监督证据失效等需要本地恢复的错误留在面板
|
||||
内。同一事件不得同时重复显示为面板警告和应用通知。
|
||||
|
||||
### 13.1 Runtime 执行清单
|
||||
|
||||
本节为待实施设计。用户需要在对话顶部查看 Agent 本轮列出的工作项及完成情况,避免在
|
||||
工具记录中反复查找。这里的“执行清单”由 Runtime 更新,术语边界见
|
||||
[Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md#17-runtime-执行清单)。
|
||||
|
||||
- **FR-CL1**:接入 OpenCode(OC)的 `todowrite` 与 Continue(CN)的 `Checklist`,
|
||||
范围包括本地及远程 GoodBuddy Agent 上运行这两个 Runtime。远程 CN 的部署和正常请求
|
||||
路径也属于交付范围,当前可用程度见[进度](./progress.md#2026-09-15-runtime-执行清单设计未实现)。
|
||||
- **FR-CL2**:顶部展示本轮最新有效清单,更新按整份替换处理,支持明确清空。每轮请求、
|
||||
会话、项目、执行空间与 Runtime 的清单分别归属;普通聊天续轮不能沿用旧清单冒充本轮进度。
|
||||
- **FR-CL3**:清单只读,用户不能手动勾选。请求完成、失败或取消保留清单实际状态,
|
||||
不自动完成工作项;原生工具调用继续进入消息与活动记录。
|
||||
- **FR-CL4**:重新打开会话可恢复已保存的清单及请求结果,远程重连遵循已有 Agent 恢复
|
||||
流程,不重新发送模型或工具请求。没有原生清单的请求不显示空占位。
|
||||
|
||||
ACP 仅是传输及远端协议,不分类为 Runtime。DeepSeek Harness(DS)真实默认环境无
|
||||
todo/plan,本次不为其增加工具。也不增加 plan 工作模式、计划文档、计划编辑器或定时任务
|
||||
能力;只有格式已确认的原生 plan entries 可以适配清单。
|
||||
|
||||
#### 顶部布局
|
||||
|
||||
执行清单位于聊天内容区顶部、已有 Task 上下文栏下方、消息流上方;没有 Task 时仍可独立
|
||||
显示。它不占用窗口标题栏操作区,也不替换 Task 摘要、任务索引或右侧 Runtime 栏目。
|
||||
布局遵循 [UI-DESIGN](../../../UI-DESIGN.md#610-应用顶栏与全局操作)。
|
||||
|
||||
默认折叠为一行“执行清单 · 已完成 m/n”,有进行中条目时附简短摘要;展开后按原生顺序
|
||||
在摘要按钮旁的浮层显示内容、状态及原生提供的优先级,不推移或压缩下方消息流。
|
||||
行内状态使用图标和“待处理 / 进行中 / 已完成 / 已取消”文字,
|
||||
不用可勾选 Checkbox,也不根据位置推测优先级或进行中状态。请求结束后的摘要附真实
|
||||
请求结果,例如“已取消”,不改变条目状态。后台更新不强制展开、不抢焦点。
|
||||
|
||||
展开按钮提供 `aria-expanded` 和 `aria-controls`,支持 Enter/Space,展开后焦点进入清单。
|
||||
Escape 收起并返回按钮;点击外部或焦点移出也收起。实时区域只播报摘要变化。
|
||||
复用全局颜色、间距和字体令牌,浅深主题均可读。窄窗口
|
||||
摘要截短、展开内容换行并有界滚动,保留完成计数、请求结果与输入区;200% 缩放时仍可
|
||||
操作。没有清单或收到有效空清单时整行隐藏,历史内容仍按消息归属查看。
|
||||
|
||||
#### 场景验收
|
||||
|
||||
| 场景 | Given / When / Then | 需求 |
|
||||
| --- | --- | --- |
|
||||
| US-CL1,连续更新 | OC/CN 本轮产生清单并修改、重排或删除条目;顶部显示最新完整内容,工具活动仍可查 | FR-CL1–FR-CL3 |
|
||||
| US-CL2,请求隔离 | 同会话开始下一请求、切换 Runtime 或并行使用另一会话;旧轮迟到事件不覆盖当前清单,历史按原归属保留 | FR-CL2、FR-CL4 |
|
||||
| US-CL3,失败与取消 | CN 更新失败或请求被取消;失败候选不替换上次有效清单,取消不把未完成项勾完;有效空清单清除顶部 | FR-CL2、FR-CL3 |
|
||||
| US-CL4,本地与远程恢复 | 重新打开本地会话或重连共享 Linux Host 上的 OC/CN 请求;已有记录按原请求恢复,重复事件不重复展示,不重新调用模型或工具 | FR-CL1、FR-CL4 |
|
||||
| US-CL5,无清单与无障碍 | DS 默认请求或普通 Markdown/list 工具输出没有已确认清单;顶部不出现伪清单;有清单时键盘、窄窗口及浅深主题可用 | FR-CL1–FR-CL3 |
|
||||
|
||||
行为规则见 [Runtime 交互边界](./runtime-interactions.md#runtime-执行清单),接入与验证见
|
||||
[技术设计](./runtime-checklist-technical-design.md)。
|
||||
|
||||
## 14. 性能与资源边界
|
||||
|
||||
- 工作栏关闭或面板实例关闭时停止对应非必要画面和高频日志推送,但保留 Main 中的受管运行。
|
||||
@@ -837,6 +996,41 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
用户级 GoodBuddy Agent Daemon。依赖 Main 模型网关、审批或本机工具的 Run 不能被描述为
|
||||
可以无人值守继续。
|
||||
|
||||
### 14.1 Runtime 资源复用
|
||||
|
||||
2026-09-14 调研确认:多个项目可能各自保留一个重型 Runtime,状态或原生清单读取也会
|
||||
影响其启动与保留。用户需要多项目/多会话自然并行工作,而不是为避免内存增长关闭工具、
|
||||
子代理或删除历史。以下为新需求,**不表示 Desktop 0.13.2 / Agent 0.11.25 已实现**。
|
||||
|
||||
- **FR-RR1**:支持原生多会话的 Runtime,在同一账号和兼容启动配置下跨项目复用重型
|
||||
进程;不得仅因项目目录或会话不同重复启动。等价的默认和显式选择应得到相同复用结果。
|
||||
不兼容的 Runtime、Host、模型连接或插件配置可以分开,不要求全产品只有一个进程。
|
||||
- **FR-RR2**:仅查看被动状态不启动重型 Runtime。显式连接测试、原生清单和执行请求
|
||||
复用兼容的已启动实例;尚未握手时不能显示为已经验证连接。
|
||||
- **FR-RR3**:共享进程仍保持各会话的目录、历史、模型用量、工具、权限和业务问答归属。
|
||||
同一会话顺序执行,不同会话并行;统一执行不增加一般工具审批。
|
||||
- **FR-RR4**:请求结束、会话释放与进程退出分别处理。取消一个普通请求、关闭工作栏
|
||||
或释放一个会话不得误停其他健康会话;无会话的空闲重型实例及时释放。正常续聊保留
|
||||
原生上下文,应用退出、配置替换和真实进程故障仍有明确的终态与有界回收。
|
||||
- **FR-RR5**:托管 SSH 独立实现并验证复用,保留 Agent 对活动 Prompt 的所有权、
|
||||
断线续接、事件去重和模型调用结果不确定性规则。不能以本机通过代替远端交付。
|
||||
- **FR-RR6**:对不具备原生多会话 Host 的 Continue 保留正确的请求隔离;直连模型不
|
||||
新增独立模型进程。优化应有前后性能证据,不以删历史、禁用工具、全局串行化或
|
||||
推迟正常用户操作来换取进程/内存指标。
|
||||
|
||||
产品场景验收:
|
||||
|
||||
| 场景 | Given / When / Then |
|
||||
| --- | --- |
|
||||
| US-RR1,首次与热启动 | 同一有效配置的两个项目,分别查看状态、打开清单并开始四个对话;状态不冷启动,后续共用一个可多会话的重型 Runtime |
|
||||
| US-RR2,并行与取消 | 两个项目有同名不同内容文件,四个会话进行真实开发;目录及成果正确,取消其中一个后其余三个继续完成 |
|
||||
| US-RR3,续聊与释放 | 一轮任务完成后继续原会话,再释放其中一个;原生上下文可续用、其他会话不受影响,数据库完整历史仍可查看 |
|
||||
| US-RR4,边界与远程 | 切换不兼容配置、查看 Continue/直连状态或中断 SSH;不串配置、不伪造多会话能力,Agent 已接受工作按现有归属恢复 |
|
||||
|
||||
资源状态规则由 [Runtime 交互边界](./runtime-interactions.md#runtime-资源生命周期)
|
||||
定义;锁定版本研究、实现选择与逐项验收由
|
||||
[进程复用技术设计](./runtime-process-reuse-technical-design.md)定义。
|
||||
|
||||
## 15. 无障碍与响应式
|
||||
|
||||
- 能力目录、所有面板实例、目标选择器、终端控制和进程操作可用键盘完成。
|
||||
@@ -851,7 +1045,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
## 16. 分阶段实施
|
||||
|
||||
本节只表示内部依赖顺序,不表示可以发布临时架构。SSH 远程执行只使用用户级 Daemon,
|
||||
不发布连接期 stdio Agent。OpenCode Ask、OpenCode Execute 和后续 Runtime 分别通过自身的
|
||||
不发布连接期 stdio Agent。OpenCode、Continue 和后续 Runtime 分别通过自身的
|
||||
安装、权限、取消和重连门槛后开放,未完成的能力继续显示不可用。
|
||||
|
||||
### 阶段 0:应用级工作栏壳层
|
||||
@@ -859,15 +1053,17 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
- 将当前聊天专属右栏提升为应用级壳层。
|
||||
- 建立稳定能力目录和用户打开、关闭、排序、停靠的面板实例模型。
|
||||
- 建立实例级跟随、固定和失效目标语义。
|
||||
- 保留现有任务中心、工作区、浏览器和成果行为;附件与知识库继续由 Composer 管理。
|
||||
- 保留现有任务中心、工作区和浏览器行为;成果页移除,附件与知识库继续由 Composer 管理。
|
||||
- 将 Task Center 明确为 Task 的单例索引,并补齐范围、状态、最近进展、需要关注和直接打开 Conversation。
|
||||
- 审批在所属任务或 Runtime 中持续可见,不新增独立审批面板。
|
||||
- 审批在所属任务或 Runtime 中持续可见;任务中心只为未关联任务的审批保留按需显示的处理
|
||||
区域,具体规则见 [Task Center 交互](../task-and-job/task-center-prd.md#5-交互)。
|
||||
- 智能心跳菜单入口承接完整配置和范围后,再从任务中心移除重复表单;不得移除任务中心本身。
|
||||
|
||||
### 阶段 1:成果与 Runtime 可观测性
|
||||
|
||||
- 本机 CPU、内存、磁盘 IO 和 GoodBuddy 桌面进程摘要的按需资源监控。
|
||||
- 通用 Artifact Event。
|
||||
- HTML 工作区和成果的静态即时预览。
|
||||
- HTML 工作区和会话内容的静态即时预览。
|
||||
- Runtime Inspector Snapshot 与事件。
|
||||
- OpenCode 会话、子会话、Todo、工具、用量和取消;本地与托管 SSH 的原生 Task 调用在
|
||||
Conversation 中统一显示为可追踪的 Subagent 状态,而不是普通工具参数卡。
|
||||
@@ -897,8 +1093,9 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
- 签名、side-by-side GoodBuddy Agent 安装和按需 detached Daemon。
|
||||
- SSH connection pool、固定 attach relay、Agent 控制协议、Agent 本地 ACP 日志和断线重连同步。
|
||||
- 远程工作区、Git、终端和受管进程。
|
||||
- 先完成签名 OpenCode bundle、Ask bubblewrap、Execute 直接进程、官方 ACP channel、
|
||||
Main 模型网关、取消、超时和活动记录。
|
||||
- 当前源码直接启动 OpenCode bundle,使用账号权限和已接入能力;配套签名包须按 runtime/acp 6 重建。
|
||||
官方 ACP channel、Agent 本地模型网关、取消、超时和活动记录
|
||||
以[远程主机技术设计](../remote-host/technical-design.md)为准。
|
||||
- OpenCode 闭环稳定后,Continue 与 DeepSeek Harness 复用相同面板和通道契约逐个接入。
|
||||
- 新增或重新验证 Host 时只保存并探测 Agent/Runtime,不自动安装。用户在 Host 卡片用
|
||||
单一主按钮启动,并用次级控件选择自动、Host 下载或 GoodBuddy 传输;Host 保存与环境
|
||||
@@ -913,7 +1110,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
Workspace/Runtime 时直接使用新的 current 环境,无需刷新项目绑定。应用启动和项目切换
|
||||
本身都不连接 Host,也不下载或安装组件。
|
||||
- SSH、Main、Daemon、Runtime 和远端重启后的核对、恢复与 `outcome_unknown`。
|
||||
- 支持平台的安装、Ask、Execute、取消和重连冒烟测试通过后开放对应 OpenCode 能力。
|
||||
- 支持平台的安装、无模式工具执行、取消和重连冒烟测试通过后开放对应 OpenCode 能力。
|
||||
|
||||
### 阶段 5:开放后的扩展
|
||||
|
||||
@@ -926,7 +1123,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
|
||||
### 17.1 稳定能力目录与用户控制
|
||||
|
||||
- [ ] 八个标准能力在所有主要页面的目录中始终可发现,但不会默认同时打开。
|
||||
- [ ] 十个标准能力在所有主要页面的目录中始终可发现,但不会默认同时打开。
|
||||
- [ ] Task Center 继续作为 Task 的单例索引,不删除入口、不复制会话,也不混入 Job、Run 或心跳事项。
|
||||
- [ ] 项目、会话、Runtime 或主机变化不会无提示地增删能力目录项。
|
||||
- [ ] 用户可以按需打开、关闭、排序和停靠面板实例。
|
||||
@@ -937,19 +1134,21 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
|
||||
### 17.2 通用使用
|
||||
|
||||
- [ ] 没有项目时仍可创建终端、选择工作区、打开浏览器和查看成果。
|
||||
- [ ] 没有项目时仍可创建终端、选择工作区和打开浏览器。
|
||||
- [ ] 监督可以作用于普通 Conversation、Task 和后续实验对象,不假设编程语境。
|
||||
- [ ] 工作区不是 Git 仓库时仍可浏览文件。
|
||||
- [ ] Runtime 不支持某项原生能力时仍可从目录打开面板并获得准确说明。
|
||||
- [ ] 文档能力可以打开多个以文件名命名的固定实例;未配置健康 ShareServer 时显示原因,
|
||||
不把系统默认应用或静态预览冒充内置编辑。
|
||||
|
||||
### 17.3 安全与控制
|
||||
|
||||
- [ ] Renderer 没有任意文件、Shell、进程、PTY、SSH 或 Electron API。
|
||||
- [ ] Agent 不能未经授权向用户终端注入命令。
|
||||
- [ ] 进程面板不能枚举或终止任意系统进程。
|
||||
- [ ] Ask 在本机和远程执行空间均无法调用写入或外部副作用工具。
|
||||
- [ ] 本机和远程按已启用能力执行,不保留模式限制或一般工具审批。
|
||||
- [ ] HTML 预览无法执行脚本、联网、打开窗口、提交表单或访问 Electron API。
|
||||
- [ ] Supervisor 不能自动发送消息、批准工具、切换工作模式或扩大范围。
|
||||
- [ ] Supervisor 不能自动发送消息、批准工具或扩大范围。
|
||||
- [ ] SSH 首次连接和 Host Key 变化均经过明确验证流程。
|
||||
- [ ] 凭据不进入 Renderer、日志、SQLite 明文或命令参数。
|
||||
- [ ] Host 地址、用户或 Host Key 变化会关闭旧连接,并使旧 Workspace 和 Runtime 会话失效。
|
||||
@@ -975,7 +1174,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
|
||||
- [ ] 宽、中、窄窗口均可访问完整能力目录和用户打开的面板实例。
|
||||
- [ ] 工作栏在宽、中、窄窗口展开时都推动主工作区回流,不覆盖、遮罩或隔离主内容;唯一
|
||||
开关始终固定在窗口关闭按钮正下方。
|
||||
开关始终固定在顶栏操作区、紧邻浅色/深色主题按钮之前。
|
||||
- [ ] 仅使用键盘可以选择能力、面板实例、目标、执行空间和控制动作。
|
||||
- [ ] 状态不只依赖颜色,徽标具有文字或可访问名称。
|
||||
- [ ] 终端、HTML、监督证据和高频日志具有可访问替代或降噪行为。
|
||||
@@ -984,6 +1183,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
- [ ] 创建中、Agent 操作中和完整交互期间禁用浏览器其余工具栏控件;活动加载仍可单独停止,
|
||||
且停止加载不会关闭会话或释放 Runtime 上下文。
|
||||
- [ ] 活动 Conversation 没有浏览器会话时,可从同一地址栏按 Agent 相同 URL 策略启动会话。
|
||||
- [ ] 文档编辑器在右侧宽度不足时不发生工具栏或正文重叠,并可使用同一会话展开到可编辑布局。
|
||||
|
||||
## 18. 相关文档的职责
|
||||
|
||||
@@ -992,7 +1192,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
动态 Tab 以及本机与直接 SSH 多终端。
|
||||
- [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 定义 Task、Conversation、
|
||||
Job、Subjob、Run 与 Subagent。
|
||||
- [Task Center PRD](../task-and-job/task-center-prd.md) 定义应用级 Task 索引。
|
||||
- [Task Center PRD](../task-and-job/task-center-prd.md) 定义单实例、项目跟随和显式范围切换。
|
||||
- [智能心跳 PRD](../smart-heartbeat/prd.md) 定义心跳入口、范围和长期边界。
|
||||
- [会话监督 PRD](../conversation-supervision/prd.md) 定义监督判断、证据、预算和介入边界。
|
||||
- [自动化平台总体设计](../../architecture/automation-platform-architecture.md) 定义 Plan、Job、Run、监督、预算和记忆。
|
||||
@@ -1000,8 +1200,10 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
- [DeepSeek Harness Runtime 设计](../deepseek-harness/technical-design.md) 定义该 Runtime 的具体适配边界。
|
||||
- [SSH Host 远程环境准备与直连下载设计](../remote-host/environment-provisioning-technical-design.md)
|
||||
定义 Host 级组件获取、首次准备、更新、回退和项目只验证边界。
|
||||
- [Office 协同编辑](../office-document-editing/README.md) 定义文档多实例、ShareServer 编辑会话、
|
||||
保存、冲突和 AI 选区修改。
|
||||
- [统一界面设计系统](../../../UI-DESIGN.md) 定义视觉、语义、响应式和无障碍规则。
|
||||
|
||||
若其他文档把工作栏描述为八个同时固定显示的栏目、根据项目或 Runtime 自动裁剪的动态入口,
|
||||
若其他文档把工作栏描述为十个同时固定显示的栏目、根据项目或 Runtime 自动裁剪的动态入口,
|
||||
或仅属于当前聊天的附属区域,以本文“能力目录稳定、面板实例由用户打开、当前上下文只提供
|
||||
默认值”的产品决策为准。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user