Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
237b8a8cee | ||
|
|
d08ba33c3e | ||
|
|
b03c25685c | ||
|
|
7003141e56 | ||
|
|
e692362414 | ||
|
|
ee804edcae | ||
|
|
85a4a374fa | ||
|
|
89a81dd07c | ||
|
|
0eb664c0b6 | ||
|
|
9c6d1611e2 | ||
|
|
1f01c26e04 | ||
|
|
dd8289fdef | ||
|
|
d5fa0ec67c | ||
|
|
6eb73657e0 | ||
|
|
4041edefa8 | ||
|
|
482cd0c467 | ||
|
|
5a1afb00f7 | ||
|
|
a4b64a1641 | ||
|
|
9588c4b901 | ||
|
|
d180f63abd | ||
|
|
670171ce5d | ||
|
|
4490e5fd15 | ||
|
|
374968d50b | ||
|
|
4e60a4ca61 | ||
|
|
3765a937bd | ||
|
|
feaebdf221 | ||
|
|
70ec2bf792 | ||
|
|
8b9dbf55df | ||
|
|
b1e3101857 | ||
|
|
7369c13bcf | ||
|
|
fba5e1ad30 | ||
|
|
468cf0483f | ||
|
|
6586192222 | ||
|
|
987a08ad78 | ||
|
|
35a93e61cd | ||
|
|
0cba3855ea | ||
|
|
74c3ec19b4 | ||
|
|
cdc8f6f57a | ||
|
|
b8c1d33e7b | ||
|
|
f18e4abb3c |
@@ -45,17 +45,22 @@ jobs:
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build Linux ${{ matrix.arch }} compound package
|
||||
name: Build ${{ matrix.platform }} ${{ matrix.arch }} compound package
|
||||
needs: authorize
|
||||
if: needs.authorize.outputs.authorized == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
- platform: linux
|
||||
arch: x64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
- platform: linux
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
- platform: darwin
|
||||
arch: arm64
|
||||
runner: macos-15
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 25
|
||||
environment:
|
||||
@@ -76,11 +81,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify native build target
|
||||
run: node -e "if(process.platform !== '${{ matrix.platform }}' || process.arch !== '${{ matrix.arch }}') throw new Error('Native Agent target mismatch')"
|
||||
|
||||
- name: Resolve locked native inputs
|
||||
id: inputs
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
@@ -88,9 +97,11 @@ jobs:
|
||||
const { createHash } = require('node:crypto')
|
||||
const agent = require('./agent-runtime-lock.json')
|
||||
const remote = require('./remote-runtime-lock.json')
|
||||
const node = agent.node?.targets?.[`linux-${process.env.AGENT_ARCH}`]
|
||||
const node = agent.node?.targets?.[`${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`]
|
||||
const runtime = remote.runtimes?.opencode
|
||||
const opencode = runtime?.targets?.[process.env.AGENT_ARCH]
|
||||
const runtimeKey = process.env.AGENT_PLATFORM === 'linux'
|
||||
? process.env.AGENT_ARCH : `${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`
|
||||
const opencode = runtime?.targets?.[runtimeKey]
|
||||
if (
|
||||
!node ||
|
||||
!opencode ||
|
||||
@@ -136,7 +147,7 @@ jobs:
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-release-inputs
|
||||
key: goodbuddy-agent-release-${{ matrix.arch }}-${{ steps.inputs.outputs.cache-key }}
|
||||
key: goodbuddy-agent-release-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.inputs.outputs.cache-key }}
|
||||
|
||||
- name: Acquire and verify native inputs
|
||||
shell: bash
|
||||
@@ -162,8 +173,14 @@ jobs:
|
||||
"$NODE_URL" --output "$node_archive.partial"
|
||||
mv "$node_archive.partial" "$node_archive"
|
||||
fi
|
||||
printf '%s %s\n' "$NODE_SHA256" "$node_archive" |
|
||||
sha256sum --check --strict -
|
||||
node - "$node_archive" "$NODE_SHA256" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const [archive, expected] = process.argv.slice(2)
|
||||
if (createHash('sha256').update(readFileSync(archive)).digest('hex') !== expected) {
|
||||
throw new Error('Node runtime archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
|
||||
opencode_archive="$directory/$OPENCODE_ARCHIVE"
|
||||
if [[ ! -f "$opencode_archive" ]]; then
|
||||
@@ -201,6 +218,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
GOODBUDDY_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_SIGNING_KEY_ID }}
|
||||
GOODBUDDY_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_SIGNING_PRIVATE_KEY }}
|
||||
@@ -211,11 +229,12 @@ jobs:
|
||||
output="$RUNNER_TEMP/goodbuddy-agent-output"
|
||||
mkdir -p "$output"
|
||||
node build/agent-package.cjs build \
|
||||
--platform "$AGENT_PLATFORM" \
|
||||
--arch "$AGENT_ARCH" \
|
||||
--minimum-desktop-version "$(node -p "require('./package.json').version")" \
|
||||
--node-archive "$RUNNER_TEMP/goodbuddy-agent-release-inputs/$NODE_ARCHIVE" \
|
||||
--runtime-archive "$RUNNER_TEMP/goodbuddy-agent-release-inputs/$OPENCODE_ARCHIVE" \
|
||||
--output "$output/goodbuddy-agent-$AGENT_VERSION-linux-$AGENT_ARCH.gbagent" \
|
||||
--output "$output/goodbuddy-agent-$AGENT_VERSION-$AGENT_PLATFORM-$AGENT_ARCH.gbagent" \
|
||||
> "$output/result.json"
|
||||
node - "$output/result.json" "$AGENT_ARCH" "$AGENT_VERSION" <<'NODE'
|
||||
const { readFileSync } = require('node:fs')
|
||||
@@ -223,9 +242,10 @@ jobs:
|
||||
const result = JSON.parse(readFileSync(resultPath, 'utf8'))
|
||||
if (
|
||||
result.descriptor?.architecture !== architecture ||
|
||||
result.descriptor?.platform !== process.env.AGENT_PLATFORM ||
|
||||
result.descriptor?.version !== version ||
|
||||
result.archive !==
|
||||
`goodbuddy-agent-${version}-linux-${architecture}.gbagent` ||
|
||||
`goodbuddy-agent-${version}-${process.env.AGENT_PLATFORM}-${architecture}.gbagent` ||
|
||||
!/^[a-f0-9]{64}$/u.test(result.sha256)
|
||||
) {
|
||||
throw new Error('Production Agent package result is invalid')
|
||||
@@ -235,7 +255,7 @@ jobs:
|
||||
- name: Upload verified compound package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: goodbuddy-agent-package-linux-${{ matrix.arch }}
|
||||
name: goodbuddy-agent-package-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-output/goodbuddy-agent-*.gbagent
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
@@ -269,7 +289,7 @@ jobs:
|
||||
- name: Download compound packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: goodbuddy-agent-package-linux-*
|
||||
pattern: goodbuddy-agent-package-*
|
||||
path: dist/agent-release
|
||||
merge-multiple: true
|
||||
|
||||
@@ -353,6 +373,7 @@ jobs:
|
||||
node build/agent-catalog.cjs create \
|
||||
--x64-package "dist/agent-release/goodbuddy-agent-$AGENT_VERSION-linux-x64.gbagent" \
|
||||
--arm64-package "dist/agent-release/goodbuddy-agent-$AGENT_VERSION-linux-arm64.gbagent" \
|
||||
--darwin-arm64-package "dist/agent-release/goodbuddy-agent-$AGENT_VERSION-darwin-arm64.gbagent" \
|
||||
"${previous[@]}" \
|
||||
--catalog dist/agent-release/agent-catalog.json \
|
||||
--signature dist/agent-release/agent-catalog.sig \
|
||||
@@ -489,8 +510,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/v$AGENT_VERSION"
|
||||
for architecture in x64 arm64; do
|
||||
name="goodbuddy-agent-$AGENT_VERSION-linux-$architecture.gbagent"
|
||||
for target in linux-x64 linux-arm64 darwin-arm64; do
|
||||
name="goodbuddy-agent-$AGENT_VERSION-$target.gbagent"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$root/$name" --output "$RUNNER_TEMP/$name"
|
||||
cmp "dist/agent-release/$name" "$RUNNER_TEMP/$name"
|
||||
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- 'build/agent-bundle.cjs'
|
||||
- 'build/agent-package.cjs'
|
||||
- 'build/agent-ci-bundle.cjs'
|
||||
- 'build/agent-build-target.cjs'
|
||||
- 'build/binary-architecture.cjs'
|
||||
- 'build/file-hash.cjs'
|
||||
- 'build/remote-runtime-bundle.cjs'
|
||||
- 'build/signing-key-preflight.cjs'
|
||||
@@ -19,6 +21,7 @@ on:
|
||||
- 'src/agent-daemon/**'
|
||||
- 'src/shared/**'
|
||||
- 'tests/agent-bundle.test.ts'
|
||||
- 'tests/agent-darwin-build.test.ts'
|
||||
- 'tests/agent-package.test.ts'
|
||||
- 'tests/agents-workflow.test.ts'
|
||||
- 'tests/remote-runtime-bundle.test.ts'
|
||||
@@ -31,6 +34,8 @@ on:
|
||||
- 'build/agent-bundle.cjs'
|
||||
- 'build/agent-package.cjs'
|
||||
- 'build/agent-ci-bundle.cjs'
|
||||
- 'build/agent-build-target.cjs'
|
||||
- 'build/binary-architecture.cjs'
|
||||
- 'build/file-hash.cjs'
|
||||
- 'build/remote-runtime-bundle.cjs'
|
||||
- 'build/signing-key-preflight.cjs'
|
||||
@@ -41,6 +46,7 @@ on:
|
||||
- 'src/agent-daemon/**'
|
||||
- 'src/shared/**'
|
||||
- 'tests/agent-bundle.test.ts'
|
||||
- 'tests/agent-darwin-build.test.ts'
|
||||
- 'tests/agent-package.test.ts'
|
||||
- 'tests/agents-workflow.test.ts'
|
||||
- 'tests/remote-runtime-bundle.test.ts'
|
||||
@@ -73,22 +79,28 @@ jobs:
|
||||
run: |
|
||||
npm test -- \
|
||||
tests/agent-bundle.test.ts \
|
||||
tests/agent-darwin-build.test.ts \
|
||||
tests/agent-package.test.ts \
|
||||
tests/remote-runtime-bundle.test.ts \
|
||||
tests/agents-workflow.test.ts
|
||||
npm run typecheck
|
||||
|
||||
build:
|
||||
name: Build Linux ${{ matrix.arch }} Agent
|
||||
name: Build ${{ matrix.platform }} ${{ matrix.arch }} Agent
|
||||
needs: validate
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
- platform: linux
|
||||
arch: x64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
- platform: linux
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
- platform: darwin
|
||||
arch: arm64
|
||||
runner: macos-15
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -103,17 +115,23 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify native runner target
|
||||
run: node -e "if (process.platform !== '${{ matrix.platform }}' || process.arch !== '${{ matrix.arch }}') throw new Error('Agent CI requires the native target')"
|
||||
|
||||
- name: Resolve locked official Node runtime
|
||||
id: runtime
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
const { appendFileSync } = require('node:fs')
|
||||
const lock = require('./agent-runtime-lock.json')
|
||||
const target = lock.node?.targets?.[`linux-${process.env.AGENT_ARCH}`]
|
||||
const { lock } = require('./build/agent-ci-bundle.cjs').readCiLocks(
|
||||
process.env.AGENT_PLATFORM, process.env.AGENT_ARCH
|
||||
)
|
||||
const target = lock.node?.targets?.[`${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`]
|
||||
if (
|
||||
!target ||
|
||||
!/^[A-Za-z0-9._-]+\.tar\.gz$/u.test(target.archive) ||
|
||||
@@ -147,7 +165,7 @@ jobs:
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-node
|
||||
key: goodbuddy-agent-ci-node-${{ matrix.arch }}-${{ steps.runtime.outputs.sha256 }}
|
||||
key: goodbuddy-agent-ci-node-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.runtime.outputs.sha256 }}
|
||||
|
||||
- name: Acquire and verify locked Node runtime
|
||||
shell: bash
|
||||
@@ -170,22 +188,34 @@ jobs:
|
||||
"$NODE_URL" --output "$archive.partial"
|
||||
mv "$archive.partial" "$archive"
|
||||
fi
|
||||
printf '%s %s\n' "$NODE_SHA256" "$archive" |
|
||||
sha256sum --check --strict -
|
||||
node - "$archive" "$NODE_SHA256" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const [archive, expected] = process.argv.slice(2)
|
||||
if (createHash('sha256').update(readFileSync(archive)).digest('hex') !== expected) {
|
||||
throw new Error('Node runtime archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Resolve locked OpenCode Runtime
|
||||
id: opencode
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
const { appendFileSync } = require('node:fs')
|
||||
const { createHash: hash } = require('node:crypto')
|
||||
const lock = require('./remote-runtime-lock.json')
|
||||
const runtime = lock.runtimes?.opencode
|
||||
const target = runtime?.targets?.[process.env.AGENT_ARCH]
|
||||
const { runtimeLock } = require('./build/agent-ci-bundle.cjs').readCiLocks(
|
||||
process.env.AGENT_PLATFORM, process.env.AGENT_ARCH
|
||||
)
|
||||
const runtime = runtimeLock.runtimes?.opencode
|
||||
const key = process.env.AGENT_PLATFORM === 'linux'
|
||||
? process.env.AGENT_ARCH
|
||||
: `${process.env.AGENT_PLATFORM}-${process.env.AGENT_ARCH}`
|
||||
const target = runtime?.targets?.[key]
|
||||
if (
|
||||
!runtime ||
|
||||
!target ||
|
||||
@@ -213,7 +243,7 @@ jobs:
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-opencode-runtime
|
||||
key: goodbuddy-agent-ci-opencode-${{ matrix.arch }}-${{ steps.opencode.outputs.cache-key }}
|
||||
key: goodbuddy-agent-ci-opencode-${{ matrix.platform }}-${{ matrix.arch }}-${{ steps.opencode.outputs.cache-key }}
|
||||
|
||||
- name: Acquire and verify locked OpenCode Runtime
|
||||
shell: bash
|
||||
@@ -251,16 +281,22 @@ jobs:
|
||||
NODE
|
||||
|
||||
- name: Run native Agent regressions
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
npm test -- \
|
||||
src/agent-daemon/model-bridge.test.ts \
|
||||
src/agent-daemon/lifecycle.test.ts
|
||||
|
||||
- name: Run native macOS process regressions
|
||||
if: matrix.platform == 'darwin'
|
||||
run: npm test -- src/agent-daemon/darwin-process-identity.test.ts
|
||||
|
||||
- name: Build and verify ephemeral signed compound Agent package twice
|
||||
id: agent
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
NODE_ARCHIVE: ${{ steps.runtime.outputs.archive }}
|
||||
OPENCODE_ARCHIVE: ${{ steps.opencode.outputs.archive }}
|
||||
run: |
|
||||
@@ -270,11 +306,12 @@ jobs:
|
||||
mkdir -p "$directory"
|
||||
agent_version="$(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
node build/agent-ci-bundle.cjs \
|
||||
--platform "$AGENT_PLATFORM" \
|
||||
--arch "$AGENT_ARCH" \
|
||||
--node-archive "$RUNNER_TEMP/goodbuddy-agent-node/$NODE_ARCHIVE" \
|
||||
--opencode-archive "$RUNNER_TEMP/goodbuddy-opencode-runtime/$OPENCODE_ARCHIVE" \
|
||||
--output-directory "$directory/bundle" \
|
||||
--archive "$directory/goodbuddy-agent-$agent_version-linux-$AGENT_ARCH.gbagent" |
|
||||
--archive "$directory/goodbuddy-agent-$agent_version-$AGENT_PLATFORM-$AGENT_ARCH.gbagent" |
|
||||
tee "$directory/result.json"
|
||||
node - <<'NODE'
|
||||
const { appendFileSync, readFileSync } = require('node:fs')
|
||||
@@ -286,6 +323,7 @@ jobs:
|
||||
)
|
||||
if (
|
||||
result.architecture !== process.env.AGENT_ARCH ||
|
||||
result.platform !== process.env.AGENT_PLATFORM ||
|
||||
!/^[a-f0-9]{64}$/u.test(result.archiveSha256)
|
||||
) {
|
||||
throw new Error('Agent CI result is invalid')
|
||||
@@ -300,14 +338,16 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_PLATFORM: ${{ matrix.platform }}
|
||||
AGENT_SHA256: ${{ steps.agent.outputs.sha256 }}
|
||||
run: |
|
||||
{
|
||||
echo "### Linux $AGENT_ARCH Agent"
|
||||
echo "### $AGENT_PLATFORM $AGENT_ARCH Agent build"
|
||||
echo
|
||||
echo "Native compound Agent, Node, and OpenCode Runtime build, test-signature verification, launch smoke, and deterministic archive comparison passed."
|
||||
echo
|
||||
echo "Archive SHA-256: \`$AGENT_SHA256\`"
|
||||
echo
|
||||
echo "The ephemeral CI key and bundle are not published."
|
||||
echo "Native build checks do not replace real Host installation, Attach, and Ask/Execute acceptance."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
# Local build output left by the extracted ShareServer project.
|
||||
shareserver/build-server/
|
||||
coverage/
|
||||
.vite/
|
||||
.runtime-resources/
|
||||
@@ -8,6 +10,8 @@ coverage/
|
||||
.agent-resources/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -145,7 +145,7 @@ npm run dist:linux:arm64
|
||||
组件,缺少远端包也不能阻塞普通桌面打包或发布。
|
||||
|
||||
“设置 > 平台功能 > 远程项目(技术预览)”列出用户数据目录中已经下载或导入的
|
||||
Linux x64/arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
|
||||
Linux x64、Linux arm64 和 macOS arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
|
||||
小型签名目录,显示本地版本、在线最新兼容版本以及 `有更新 / 已是最新 / 未下载 /
|
||||
校验失败`;此检查不会下载 `.gbagent`。只有用户点击“下载”或“更新到”后才传输包,
|
||||
用户也可导入或导出 `.gbagent` 离线包。
|
||||
@@ -220,6 +220,9 @@ package format v1 归档已经包含固定 Agent 和 Node,可直接用于 Host
|
||||
|
||||
GoodBuddy 管理的本机 OpenCode 启动后会轮询已认证的 loopback health 接口,最多等待
|
||||
30 秒。该窗口用于兼容 Linux ARM 等较慢环境;不要改回依赖 stdout 文案或 10 秒固定上限。
|
||||
聊天中的 Runtime 切换只执行路径和模型凭据就绪检查,不为状态显示启动临时 Server。
|
||||
设置中的显式连接测试、原生能力清单读取和首次实际请求仍会启动 OpenCode,并继续使用
|
||||
上述完整健康检查。
|
||||
|
||||
## GoodBuddy Agent 工件
|
||||
|
||||
@@ -251,16 +254,32 @@ node build/agent-package.cjs build \
|
||||
Agent 源码、共享协议、lock、bundle 工具和测试与桌面应用保持在同一仓库、同一 commit;
|
||||
不维护长期分叉的 Agent 源码分支。`.github/workflows/agents.yml` 在相关 Pull Request、
|
||||
`main` push 和手工触发时,分别使用 `ubuntu-24.04` 与 `ubuntu-24.04-arm` 原生构建
|
||||
Linux x64/arm64 复合包。每个 job 从 `agent-runtime-lock.json` 解析官方 Node HTTPS
|
||||
Linux x64/arm64 复合包,并使用 `macos-15` 原生构建 Darwin arm64
|
||||
复合包。所有 job 从 `agent-runtime-lock.json` 解析官方 Node HTTPS
|
||||
地址与 digest,并从 `remote-runtime-lock.json` 解析固定 OpenCode npm 包与 integrity;
|
||||
下载后再次校验,随后使用仅存在于进程内的临时 Ed25519 测试 key 构建两次,完成内外层
|
||||
测试签名验证、Agent 原生启动 smoke 和确定性 `.gbagent` 对比。
|
||||
测试签名验证、Agent CLI 加载 smoke 和确定性 `.gbagent` 对比。所有目标还验证
|
||||
Koffi 原生模块加载与 OpenCode `--version`;Linux job 继续运行 Agent 原生回归。
|
||||
|
||||
Darwin arm64 的 Node 和 OpenCode 固定输入分别由同一份
|
||||
`agent-runtime-lock.json` 与 `remote-runtime-lock.json` 提供,不再维护 CI 专用副本。
|
||||
组包器区分 ELF 与 Mach-O,Darwin CI 产物命名为
|
||||
`goodbuddy-agent-<version>-darwin-arm64.gbagent`,不构建 Intel Mac 目标。
|
||||
macOS Host 继续通过 SSH 按需启动 detached Agent,不注册 launchd 或开机服务。
|
||||
进程身份使用 macOS 原生接口,Unix Socket 使用 `getpeereid`;Host 下载兼容系统自带
|
||||
`shasum -a 256`,不要求安装 GNU coreutils。真实 Host 验证记录见
|
||||
[远程主机技术设计](./docs/features/remote-host/technical-design.md)。
|
||||
|
||||
三个目标共用 `agent-catalog.json` 和现有签名格式,条目按平台与架构区分。旧版 Desktop
|
||||
只接受 Linux catalog 条目;首次发布包含 Darwin 的累计 catalog 前必须先发布新版
|
||||
Desktop,并在发布说明中要求用户升级 Desktop。旧版的在线 Agent 目录检查会失败,
|
||||
本地已验证包不被删除。不得为此改写历史包或复用发布标签。
|
||||
|
||||
该验证 workflow 不读取 production signing secret,不修改公开 key registry,也不上传
|
||||
可安装 Agent 工件。`.github/workflows/agent-release.yml` 是唯一 production Agent
|
||||
发布路径:只接受指向受保护 `main` 历史的 annotated
|
||||
`agent-v${agent-runtime-lock.agentVersion}` 标签,在 `agent-signing` Environment 中
|
||||
原生构建两种架构,生成并签名累计目录,然后同步到非 Latest 的 GitHub Agent Release
|
||||
原生构建 Linux x64、Linux arm64、macOS arm64 三个目标,生成并签名累计目录,然后同步到非 Latest 的 GitHub Agent Release
|
||||
与北京 OSS。该 Environment 只保护一组 GoodBuddy 通用发布变量与 Secret:
|
||||
|
||||
- `GOODBUDDY_SIGNING_KEY_ID` /
|
||||
@@ -305,10 +324,11 @@ 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、
|
||||
Ask 的固定 `bwrap` 只读 profile 和 `runtime/model-bridge` v1。Agent 通过私有 Unix socket
|
||||
按需 detached 启动,不依赖 systemd、D-Bus 或 Linger。Execute 直接以所选 SSH 账号权限
|
||||
启动签名 Runtime;Ask 才使用只读 bubblewrap。
|
||||
manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership 和
|
||||
`runtime/model-bridge` v1。Agent 通过私有 Unix socket 按需 detached 启动,不依赖
|
||||
systemd、D-Bus、Linger 或 bubblewrap。Ask 与 Execute 都直接启动签名 Runtime;两者的
|
||||
差异由 OpenCode Ask 权限配置和 Agent 工具权限分发边界共同执行,Execute 不注入该
|
||||
权限覆盖。
|
||||
|
||||
OpenCode 通过已签名 Agent helper 和每次 Prompt 的私有 Unix socket 使用 Main-only 模型
|
||||
网关;Provider URL、API Key 和真实 Provider 认证头不进入远端。helper 的 loopback
|
||||
@@ -317,7 +337,7 @@ HTTP 入口使用每个进程随机生成的路径 capability,Anthropic/OpenAI
|
||||
配置必须禁用 title Agent;工具循环只使用 build Agent 的模型轮次。
|
||||
|
||||
Ask 模式的 ACP 权限中介只可为原生 `read` 选择 `allow_once`;其他工具种类和
|
||||
`allow_always` 请求必须拒绝,Workspace 的真正只读边界仍由 `bwrap` 执行。Unix 模型桥
|
||||
`allow_always` 请求必须拒绝。Unix 模型桥
|
||||
broker 必须按 socket 当前已缓冲字节增量读取长度帧,不能等待 `read(remaining)` 一次返回
|
||||
完整大响应。Agent 传输或权限实现变化后,除了普通测试,还要在原生 Linux 上运行包含
|
||||
至少 256 KiB 响应的模型桥回归。
|
||||
|
||||
+117
-50
@@ -32,9 +32,17 @@ otherwise.
|
||||
Host heading and only the remote path on each project row.
|
||||
- [x] **File, screenshot, window, and clipboard context**: Added to model
|
||||
context only after explicit user selection.
|
||||
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math, and
|
||||
constrained Mermaid diagrams. Large diagrams can be zoomed, panned, or
|
||||
viewed as source, and failed renders preserve the original diagram code.
|
||||
- [x] **Per-file workspace diffs**: Changed files expose separate staged and
|
||||
unstaged diffs, including deleted, renamed, and untracked files. Refresh
|
||||
reloads the selected diff and expanded directories; more than 50 changes
|
||||
remain accessible through incremental loading.
|
||||
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math,
|
||||
constrained Mermaid diagrams, and static in-conversation HTML previews.
|
||||
Complete HTML and HTML code blocks can be previewed in place after an Agent
|
||||
reply finishes, with icon actions to reveal the source or open a full-screen
|
||||
preview. The feature is on by default and can be disabled under Platform
|
||||
Features / General; previews cannot run scripts, access the network, submit
|
||||
forms, or open windows.
|
||||
- [x] **AI response and full-conversation copy**: Completed AI responses expose
|
||||
a bottom action that copies Markdown source without reasoning, tool logs, or
|
||||
citation metadata. Full-conversation copy uses the same validated
|
||||
@@ -56,8 +64,8 @@ otherwise.
|
||||
supervision, unified Runtime monitoring, managed processes, safe static HTML
|
||||
preview, target-pinnable workspace/browser/artifact instances, bottom
|
||||
docking, and separate windows. Task Center remains the singleton Task index;
|
||||
attachments and knowledge remain in the conversation composer, while future
|
||||
memory and historical execution context belong to the associated Task. See
|
||||
attachments and knowledge remain in the conversation composer, while memory,
|
||||
when implemented, and historical execution context belong to the associated Task. See
|
||||
the [Feature PRD](./docs/features/assistant-workbar/prd.md).
|
||||
|
||||
### Agent Runtimes and model connections
|
||||
@@ -70,18 +78,31 @@ otherwise.
|
||||
and capability scope. OpenCode, Continue, DeepSeek Harness, and managed SSH
|
||||
do not receive duplicate copies of these tools. The Windows local command
|
||||
path and a real-model edit, test, fix, and review loop have passed; native
|
||||
macOS and Linux command validation remains.
|
||||
macOS and Linux command validation remains. Execute commands can select
|
||||
absolute, relative, or symbolic-link directories outside the workspace;
|
||||
the workspace stays the default and relative-path base. Ask remains read-only.
|
||||
- [x] **OpenCode and Continue**: Use isolated child processes, an environment
|
||||
variable allowlist, unified configuration, cancellation, total execution
|
||||
limits, bounded streaming output, and activity records. Shared process
|
||||
cleanup preserves complete Windows process-tree termination and terminates
|
||||
POSIX process groups when a child uses an independent process group. Chat
|
||||
status checks use a one-shot Runtime probe that is immediately cleaned up
|
||||
rather than entering the model-and-project execution cache. Execution
|
||||
Runtimes remain reusable per project, so different projects can run in
|
||||
parallel. Interactive questions are answered only by foreground
|
||||
conversations; scheduled tasks, remote channels, delegated work, and other
|
||||
background runs fail immediately with guidance to rerun in the foreground
|
||||
rather than entering the model-and-project execution cache. For managed
|
||||
local OpenCode, this passive check validates the selected path and model
|
||||
credential without starting a throwaway server; the explicit settings test,
|
||||
native inventory still performs full startup and health checks, then shares
|
||||
that execution Runtime with the first real request instead of cold-starting
|
||||
another server. Execution Runtimes remain reusable per project. GoodBuddy-managed local OpenCode keeps
|
||||
requests in the same conversation ordered, while different conversations in
|
||||
the same project and different projects can run in parallel. Request-scoped
|
||||
dynamic MCP tools remain isolated through default wildcard disablement and
|
||||
explicit current-request enablement. OpenCode and Continue runs do not stop
|
||||
at a fixed tool-call or activity count, and every observed tool and Subagent
|
||||
activity remains in the local conversation instead of being discarded while
|
||||
the native task continues.
|
||||
Interactive questions are answered only by foreground conversations; scheduled tasks, remote channels,
|
||||
delegated work, and other background runs fail immediately with guidance to
|
||||
rerun in the foreground
|
||||
instead of waiting indefinitely. Local OpenCode startup uses the selected
|
||||
absolute path directly without checking the file or running `--version`
|
||||
first; invalid paths fail at actual launch. On managed Linux ARM Hosts,
|
||||
@@ -92,11 +113,13 @@ otherwise.
|
||||
Features and disabled by default. Disabling it does not affect local
|
||||
projects, ordinary desktop capabilities, or desktop releases. When enabled,
|
||||
users can manage SSH Hosts with pinned Host Keys, browse bounded remote
|
||||
directories, and create Ask or Execute projects. Ask keeps the Workspace
|
||||
read-only at the Runtime boundary through bubblewrap; Execute has all
|
||||
permissions of the selected SSH account. The Agent owns accepted Prompts,
|
||||
directories, and create Ask or Execute projects. Both modes start the
|
||||
signed Runtime directly without bubblewrap. Ask applies OpenCode's Ask
|
||||
permission configuration and permits only native read approvals at the
|
||||
Agent tool-dispatch boundary, while Execute receives no such override and
|
||||
retains all permissions of the selected SSH account. The Agent owns accepted Prompts,
|
||||
provider/tool rounds, Runtime processes, a stable model ledger, and a bounded
|
||||
semantic transcript over a private Unix socket and ACP v4. Work continues on
|
||||
semantic transcript over a private Unix socket and ACP v5. Work continues on
|
||||
the Host after Desktop exit, network loss, or local-process termination.
|
||||
Reconnection attaches only to the original controller, binding, and
|
||||
operation, then atomically merges provenance into the original conversation
|
||||
@@ -106,12 +129,16 @@ otherwise.
|
||||
in the desktop package; switching projects updates only local selection and
|
||||
does not connect or download. Settings reads a small signed catalog and
|
||||
reports the local version, latest compatible online version, and update
|
||||
state for Linux x64/arm64. Downloads occur only after a user action, and
|
||||
state for Linux x64, Linux arm64, and macOS arm64. Intel Macs are not supported.
|
||||
Downloads occur only after a user action, and
|
||||
packages can also be imported or exported offline. Each compound `.gbagent`
|
||||
package contains the Agent, fixed Node, and the compatible OpenCode Runtime
|
||||
maintained by the desktop source. Online sources follow the GitHub/Beijing
|
||||
OSS choice in About and Updates. The cumulative signed catalog binds minimum
|
||||
Desktop version, Agent protocol, architecture, size, SHA-256, and fixed URL.
|
||||
Desktop version, Agent protocol, platform, architecture, size, SHA-256, and fixed URL.
|
||||
All three targets share one catalog. Users must upgrade Desktop before the
|
||||
first mixed-platform catalog is published because older Linux-only readers
|
||||
reject catalogs containing Darwin entries.
|
||||
The public-key registry accepts equivalent JSON whitespace and line endings
|
||||
while still strictly validating schema, Ed25519 keys, environment, and
|
||||
revocation; catalog, package, manifest, payload signatures, and streaming
|
||||
@@ -121,16 +148,21 @@ otherwise.
|
||||
Workspace/Runtime is first used, then reused by other projects in the same
|
||||
process. Managed SSH conversations expose only supported OpenCode choices.
|
||||
Multiple projects and conversations can run concurrently on one Host;
|
||||
Agent-owned prompts are not stopped at a fixed model-call count or
|
||||
prompt-wide output-token total, and Runtime output uses transport
|
||||
backpressure instead of cancellation when GoodBuddy buffers fill;
|
||||
recovery separately reports network, Agent, Runtime, committed-event cursor,
|
||||
completion, or failure/retry and blocks only the affected project. A real
|
||||
Linux x64 Host has covered short and long detach, forced local harness exit,
|
||||
concurrency, SSH relay loss, cancellation, definite and uncertain provider
|
||||
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
|
||||
database. Successful tool START/END events appear exactly once, with no
|
||||
Prompt, provider, or tool replay observed. The current source lock and
|
||||
Agent source lock is `0.11.15`, while the current Desktop release candidate
|
||||
is `0.12.2`; formal publication status follows the separate Agent and Desktop
|
||||
release channels.
|
||||
Prompt, provider, or tool replay observed. The current Agent source lock is
|
||||
`0.11.20`, while the current Desktop release candidate is `0.12.6`; formal
|
||||
publication status follows the separate Agent and Desktop
|
||||
release channels. Current macOS source has passed native package installation,
|
||||
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
|
||||
separate process groups on a real Host; this does not imply publication.
|
||||
- [x] **Manual SSH Host environment provisioning source path**: After Host Key,
|
||||
authentication, and system probes succeed, GoodBuddy saves the Host and
|
||||
read-only probes the shared Agent/Runtime. Saving a Host or opening a project
|
||||
@@ -187,6 +219,12 @@ otherwise.
|
||||
up to three read-only experts running in parallel. Chat shows each
|
||||
expandable full expert response first and the main Agent's synthesis below,
|
||||
and persists both with the conversation.
|
||||
- [x] **OpenCode child progress and final results**: Child cards show ordered
|
||||
text, reasoning, and tool progress separately from the final result. Remote
|
||||
live progress requires Agent `0.11.20`; older packages still expose final
|
||||
results without reconstructing missing progress.
|
||||
- [x] **OpenCode event-stream cleanup**: Each completed or cancelled request
|
||||
closes its own subscription without cancelling parallel conversations.
|
||||
- [x] **Role-bound model connections**: Each role can inherit the default model
|
||||
or select an independent text-model connection. Invalid connections safely
|
||||
fall back to the default; the synthesis role always inherits the default.
|
||||
@@ -203,6 +241,14 @@ otherwise.
|
||||
a bounded real text or image-generation request and validates the generated
|
||||
result instead of testing HTTP reachability alone, so it may incur a small
|
||||
provider usage charge.
|
||||
- [x] **Custom model request fields**: Each LLM connection accepts bounded
|
||||
JSON objects for additional request headers and top-level body fields.
|
||||
Direct requests and Continue support both; local OpenCode and DeepSeek
|
||||
Harness use only their natively supported headers, while the managed SSH
|
||||
OpenCode model gateway supports both. Runtime, protocol, and authentication
|
||||
fields take precedence, and these ordinary connection settings must not be
|
||||
used to store API keys or other secrets. See
|
||||
[model request customization](./docs/features/model-connections/README.md).
|
||||
- [x] **Context usage and automatic compaction**: Direct models update usage
|
||||
from each successful provider call. Images and tool rounds use the same
|
||||
accounting, with estimation only when the provider omits usage. The UI
|
||||
@@ -300,13 +346,14 @@ otherwise.
|
||||
install, SSL, pip, and venv probe before packaging. The immutable `v0.12.0`
|
||||
attempt passed Windows and macOS but exposed Linux TAR case handling before
|
||||
publication. The immutable `v0.12.1` attempt was cancelled before native
|
||||
packaging while synchronizing the Agent release; the corrected `0.12.2`
|
||||
candidate must pass all six targets. Real Skill/MCP execution, custom interpreter
|
||||
execution, and coordination with affected running processes remain
|
||||
packaging while synchronizing the Agent release. Published `v0.12.2`
|
||||
subsequently passed all six native package jobs and their managed-Python
|
||||
install probes. Real Skill/MCP execution, custom interpreter execution, and
|
||||
coordination with affected running processes remain
|
||||
acceptance work. Six-platform release acceptance must not be claimed before
|
||||
the applicable checks pass.
|
||||
- [x] **On-demand built-in MCP**: Knowledge, Magic Notes, and GoodBuddy
|
||||
configuration MCP can be enabled independently and assigned to direct
|
||||
- [x] **On-demand built-in MCP**: Knowledge, Magic Notes, GoodBuddy
|
||||
configuration, and built-in browser MCP can be enabled independently and assigned to direct
|
||||
models, GoodBuddy-managed OpenCode, and Continue. Settings explicitly marks
|
||||
DeepSeek Harness unsupported. Built-in MCP uses short-lived local authority
|
||||
for the current request, and user configuration cannot loosen Ask/Execute
|
||||
@@ -324,7 +371,19 @@ otherwise.
|
||||
report an experimental Resource catalog; the current Continue version
|
||||
explicitly does not support Resources.
|
||||
- [x] **Local knowledge bases**: Supports file, directory, and web imports,
|
||||
SQLite FTS5 retrieval, and source tracing.
|
||||
SQLite FTS5 retrieval, and source tracing. Knowledge selection persists per
|
||||
conversation and starts empty for new conversations. Advanced creation and
|
||||
retrieval parameters are collapsed by default. Documents distinguish ready,
|
||||
processing, and failed states, with source opening, retry, and confirmation
|
||||
before source removal.
|
||||
- [ ] **External knowledge-base connections** (planned): Manage Dify, FastGPT,
|
||||
and RAGFlow instances from the existing Knowledge page, then discover and
|
||||
bind remote knowledge bases with provider-specific retrieval settings and
|
||||
citations. External systems provide retrieval only: GoodBuddy does not use
|
||||
their App, Chat, Workflow, or Agent APIs, and does not bulk-sync, locally
|
||||
index, or modify remote content. Bounded cited snippets are retained locally
|
||||
with their conversations. See the
|
||||
[external knowledge-base PRD](./docs/features/knowledge-base/external-knowledge-prd.md).
|
||||
- [x] **Knowledge graph**: Supports rule-based, model-based, and hybrid
|
||||
extraction plus entity, relationship, alias, and evidence maintenance.
|
||||
- [x] **Embedding configuration and retrieval**: Configures compatible
|
||||
@@ -362,10 +421,14 @@ otherwise.
|
||||
activity, and independent artifact files. Ordinary chat responses remain
|
||||
only in the conversation and are no longer copied into Artifacts; existing
|
||||
duplicate chat Markdown is hidden from the artifact list but not physically
|
||||
deleted. Token usage is grouped by Runtime and model and normalizes the
|
||||
different OpenAI-compatible and Anthropic Messages cache-reporting semantics
|
||||
when showing cache hit rate. Activity is grouped by conversation and
|
||||
collapsed by default so long histories do not fill the page.
|
||||
deleted. Run history is stored in Main SQLite without the previous 500-item,
|
||||
4,000-character, or 2 MB Renderer limits; the page renders it in batches,
|
||||
and a persistent warning remains when a migrated legacy cache may already
|
||||
have lost older records or detail. Token usage is grouped by Runtime and
|
||||
model and normalizes the different OpenAI-compatible and Anthropic Messages
|
||||
cache-reporting semantics when showing cache hit rate. Activity is grouped
|
||||
by conversation and collapsed by default so long histories do not fill the
|
||||
page.
|
||||
- [x] **Task and custom-task experience**: Each product-level Task belongs to
|
||||
one Conversation, while one Conversation can contain multiple Tasks. The
|
||||
left conversation list exposes Task children through a leading expand
|
||||
@@ -391,8 +454,8 @@ otherwise.
|
||||
created and edited for Global or one or more selected Projects. Legacy
|
||||
single-project settings migrate without loss, and project-level memory and
|
||||
action output must explicitly target a Project in scope. Task Center and
|
||||
Settings no longer duplicate the form. Future partitioned memory remains an
|
||||
independently designed long-term direction. See the
|
||||
Settings no longer duplicate the form. Partition-aware review, candidate
|
||||
generation, and recall triggers remain to be designed. See the
|
||||
[Smart Heartbeat PRD](./docs/features/smart-heartbeat/prd.md).
|
||||
- [ ] **General supervision** (planned): Uses a fixed supervision section to
|
||||
observe user-selected conversations, Tasks, automations, or experiments and
|
||||
@@ -415,14 +478,15 @@ otherwise.
|
||||
|
||||
### Browser, communication, voice, and application maintenance
|
||||
|
||||
- [x] **Built-in browser for direct models**: Uses GoodBuddy's isolated
|
||||
- [x] **Shared built-in browser for runtimes**: Uses GoodBuddy's isolated
|
||||
Chromium and never controls a browser installed by the user. A separate
|
||||
master switch decides whether Execute receives the capability, with no
|
||||
per-use prompt after enablement. The Browser workbar and Agent share the same
|
||||
Conversation-owned session and serialized operation path, with Back,
|
||||
Refresh/Stop Loading, address entry, Go, Interaction, and Close actions.
|
||||
Stop Loading does not close the session, and user navigation changes the
|
||||
page the Agent sees next.
|
||||
master switch decides whether assigned runtimes receive the capability in
|
||||
Execute, with no per-use prompt after enablement. Turning Agent access off
|
||||
does not disable manual browser workbar actions. The Browser workbar and
|
||||
Agent share the same Conversation-owned session and serialized operation
|
||||
path, with Back, Refresh/Stop Loading, address entry, Go, Interaction, and
|
||||
Close actions. Stop Loading does not close the session, and user navigation
|
||||
changes the page the Agent sees next.
|
||||
- [x] **Client-computer control tools**: Managed separately from the built-in
|
||||
browser with scope, cancellation, timeout, output, and activity boundaries.
|
||||
- [x] **Remote messaging-channel projects**: WeChat ClawBot, WeCom, and
|
||||
@@ -490,21 +554,24 @@ otherwise.
|
||||
- [ ] **GoodBuddy Team Hub** (planned): An optional service for organizations,
|
||||
RBAC, project sharing, remote Agents, policy distribution, and tenant audit.
|
||||
- [ ] **SSH Host and remote execution-space release acceptance**: Host CRUD,
|
||||
Host Key, encrypted credentials, Project UI, Workspace, OpenCode ACP v4,
|
||||
Host Key, encrypted credentials, Project UI, Workspace, OpenCode ACP v5,
|
||||
Agent-owned Prompt/gateway/transcript, read-only Ask, full-account Execute,
|
||||
cancellation, exact detached-Agent reconnection, and release-only
|
||||
dual-architecture resource verification are wired. Current Linux x64 source
|
||||
has passed a real-model and tool matrix covering detach, process exit, relay
|
||||
loss, concurrency, cancellation, provider failures, Agent restart, and
|
||||
Desktop SQLite recovery. The final `0.11.14` test-signed package again
|
||||
verified two delivered model rounds, one tool, one terminal state,
|
||||
`latest=ACK`, and no owner/journal residue. The public signing-key registry is
|
||||
provisioned. Direct control-plane source does not wait for a new
|
||||
installer-bearing package, but the current formally signed Linux x64/arm64
|
||||
artifact matrix still requires public verification, along with
|
||||
GitHub/Beijing mirror, dual-architecture, and offline GoodBuddy transfer
|
||||
release acceptance. Until these gates pass, this path must not be described
|
||||
as published or as having passed formal release acceptance.
|
||||
Desktop SQLite recovery. A current-source, test-signed Agent `0.11.17`
|
||||
package on Linux x64 additionally passed two concurrent Ask reads, an
|
||||
Execute write/read, and six real managed-gateway model requests with the
|
||||
configured custom headers and body fields. No provider request was replayed,
|
||||
and scanning the isolated Agent state found no API key or custom request
|
||||
value. The public signing-key registry is provisioned. Direct control-plane
|
||||
source does not wait for a new installer-bearing package, but the current
|
||||
formally signed Linux x64/arm64 artifact matrix still requires public
|
||||
verification, along with GitHub/Beijing mirror, dual-architecture, and
|
||||
offline GoodBuddy transfer release acceptance. Until these gates pass, this
|
||||
path must not be described as published or as having passed formal release
|
||||
acceptance.
|
||||
- [ ] **Multi-cloud remote sandbox Agents** (planned): Manages dedicated Linux
|
||||
sandboxes through cloud-provider APIs and SSH Agents. Credentials remain in
|
||||
Main, and high-risk control-plane operations receive separate confirmation.
|
||||
|
||||
+17
-10
@@ -14,10 +14,12 @@
|
||||
### 桌面基础、工作空间与上下文
|
||||
|
||||
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
|
||||
- [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] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。
|
||||
- [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异和已展开目录,超过 50 项变更可继续加载。
|
||||
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
|
||||
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
|
||||
- [x] **助手工作栏、多终端与可调布局**:右侧工作栏使用持久“+”能力目录和应用 Tab;用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
|
||||
@@ -27,16 +29,20 @@
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
|
||||
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;执行 Runtime 仍按项目复用,因此不同项目可并行。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v4 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.15`,Desktop 发布候选为 `0.12.2`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
|
||||
- [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 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 的综合结果,并随会话保存。
|
||||
- [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。
|
||||
- [x] **OpenCode 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。
|
||||
- [x] **直连模型命令工作目录**:Execute 命令可通过绝对路径、相对路径或符号链接使用工作区外目录;工作区仍为默认目录和相对路径基准,Ask 保持只读。
|
||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
|
||||
- [x] **模型请求定制**:每个 LLM 连接可填写有界 JSON 自定义请求头和顶层请求体。直连与 Continue 支持两者;本地 OpenCode 和 DeepSeek Harness 只使用各自原生支持的请求头,受管 SSH OpenCode 模型网关支持两者。Runtime、协议和认证字段始终优先,这些普通连接设置不能用于保存 API Key 或其他密钥。详见[模型连接请求定制](./docs/features/model-connections/README.md)。
|
||||
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
||||
- [x] **持久凭据保护**:API Key 由 Main 使用系统安全存储加密且不暴露给 Renderer。托管 SSH accepted Prompt 只把当前 profile 与密钥放入 Agent 内存,不写入 SSH 参数、远端环境或磁盘;其他路径仍在 Main 内使用。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。
|
||||
- [x] **有界故障诊断**:Desktop 在用户数据目录中轮转保存启动、Runtime 和远程连接的固定阶段失败,最多 4 个 256 KiB 文件;GoodBuddy Agent 在各 installation 的私有 state 目录中轮转保存 daemon、连接、恢复和 Runtime 生命周期,最多 3 个 64 KiB 文件,并可通过固定 `diagnostics --installation-id` 命令读取。两端都只记录白名单阶段、稳定错误码/类型和固定短消息,不保存 Prompt、凭据、文件内容、路径、环境、SSH 参数或 Provider 原始响应;诊断写入失败不改变正常运行。
|
||||
@@ -50,11 +56,12 @@
|
||||
|
||||
- [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 发布时于原生打包前取消。修正后的 `0.12.2` 候选仍须通过全部六个目标。真实 Skill/MCP、自定义解释器和运行中进程协调仍是待完成验收;完成适用检查前不宣称六平台发布验收通过。
|
||||
- [x] **内置 MCP 按需接入**:知识库、魔法笔记与 GoodBuddy 配置 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;DeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。
|
||||
- [ ] **本机工具执行环境发布验收**:六个平台/架构的 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,动态工具仍会重新发现并经过现有执行记录与权限边界。
|
||||
- [x] **MCP Prompts 与 Resources 元数据**:MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。
|
||||
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。
|
||||
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。知识范围按对话保存,新对话默认不选择知识库;创建与检索高级参数默认折叠。文档区分可用、处理中和失败,支持打开来源、失败重试及移除来源前确认。
|
||||
- [ ] **外部知识库接入**(规划中):在现有知识库页面管理 Dify、FastGPT 和 RAGFlow 实例;新建知识库时可读取并绑定远端知识库,保留 Provider 专属检索配置和引用。外部系统只负责知识检索,不接入其 App、Chat、Workflow 或 Agent,也不批量同步、本地索引或修改远端内容。被回答引用的有界检索片段随会话保存在本机。详见[外部知识库接入 PRD](./docs/features/knowledge-base/external-knowledge-prd.md)。
|
||||
- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
|
||||
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
|
||||
- [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。
|
||||
@@ -68,10 +75,10 @@
|
||||
|
||||
### 工作管理、长期协作与工作流
|
||||
|
||||
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
|
||||
- [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)。
|
||||
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单;按分区回顾、候选生成和唤起条件仍待设计。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
|
||||
- [ ] **通用监督**(规划中):通过固定监督栏目观察用户选择的会话、任务、自动化或实验对象,提供带证据的评论与人工介入请求,但不自动发言、批准工具或切换 Execute。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
|
||||
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
|
||||
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
|
||||
@@ -80,7 +87,7 @@
|
||||
|
||||
### 浏览器、通信、语音与应用维护
|
||||
|
||||
- [x] **直连模型内置浏览器**:使用 GoodBuddy 内置的隔离 Chromium,不控制客户端已安装的浏览器;用户通过独立总开关决定是否提供给 Execute,开启后不逐次询问。浏览器工作栏与 Agent 共用同一条按 Conversation 归属的会话和串行操作路径,支持返回、刷新/停止加载、地址输入、前往、交互和关闭;停止加载不会关闭会话,用户导航会改变 Agent 下一步看到的页面。
|
||||
- [x] **Runtime 共享内置浏览器**:使用 GoodBuddy 内置的隔离 Chromium,不控制客户端已安装的浏览器;用户通过内置 MCP 总开关和 Runtime 分配决定是否提供给 Execute,开启后不逐次询问。关闭 Agent 浏览器能力不影响用户继续在浏览器工作栏中手动前往、返回、刷新、停止加载、交互或关闭浏览器。直连模型、GoodBuddy 管理的 OpenCode 和 Continue 通过请求级权限共享按 Conversation 归属的会话和串行操作路径,用户导航会改变 Agent 下一步看到的页面。
|
||||
- [x] **客户端电脑控制工具**:与内置浏览器分开管理,并保留范围、取消、超时、输出边界和执行记录。
|
||||
- [x] **远程消息通道项目**:微信 ClawBot、企业微信和钉钉分别拥有系统管理的项目、独立远程会话、工作目录、处理后端、默认 Ask/Execute 模式及任务活动归属;完整回复交由各通道按平台能力控制长度与分段,不再由公共服务统一截断。
|
||||
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
|
||||
@@ -104,7 +111,7 @@
|
||||
- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。
|
||||
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
|
||||
- [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
|
||||
- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v4、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验已经接线。Linux x64 当前源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;最终 `0.11.14` 测试签名包再次验证两个已交付模型轮次、一个工具、一个终态、`latest=ACK` 与零 owner/journal 残留。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包,但仍需公开核对当前 Linux x64/arm64 正式签名工件矩阵,并完成 GitHub/北京镜像、双架构和离线 GoodBuddy 传输的发布验收;门槛未满足时不宣称该路径已发布或通过正式发布验收。
|
||||
- [ ] **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 进程,高风险控制面操作单独确认。
|
||||
|
||||
## 规划原则
|
||||
|
||||
@@ -26,6 +26,28 @@ retrieval, notes, and scheduled tasks. No GoodBuddy account is required.
|
||||
Workspace data is stored locally by default. Model endpoints can run on the
|
||||
same device, on a private network, or at a provider selected by the user.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Local-first workspace**: No GoodBuddy account is required. Workspace data
|
||||
stays local by default, and users choose where model services run.
|
||||
- **Unified Agent Runtimes**: Use direct models, OpenCode, Continue, and the
|
||||
preview DeepSeek Harness from one desktop interface.
|
||||
- **Explicit execution boundaries**: `Ask` remains read-only, while `Execute`
|
||||
uses the current account's permissions. Tool activity, cancellation,
|
||||
timeouts, and token usage are recorded.
|
||||
- **Broad platform coverage**: Official Linux releases cover `x64` and `arm64`,
|
||||
including compatible distributions used in China's domestic computing
|
||||
ecosystem. A separate
|
||||
experimental `loong64` preview is available for LoongArch systems.
|
||||
- **Knowledge and long-running work**: Knowledge bases, graphs, Magic Notes,
|
||||
tasks, and Smart Heartbeat share one desktop workspace.
|
||||
|
||||
Platform coverage describes operating-system, instruction-set, and package
|
||||
compatibility, not certification for every hardware vendor or distribution
|
||||
release. LoongArch is outside the official release matrix; see the
|
||||
[LoongArch preview notes](./docs/development/loongarch-preview-build.md) for its
|
||||
current limitations.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Area | Available in GoodBuddy |
|
||||
@@ -33,7 +55,7 @@ same device, on a private network, or at a provider selected by the user.
|
||||
| Agent Runtimes | Direct model connections, OpenCode, Continue, and the preview DeepSeek Harness |
|
||||
| Data and execution | Local SQLite storage, read-only `Ask`, full-account `Execute`, and recorded tool activity |
|
||||
| Knowledge | File, folder, and web imports with full-text, Chinese phrase, vector, and knowledge graph retrieval |
|
||||
| Desktop releases | Windows, macOS, and Linux builds for `x64` and `arm64` |
|
||||
| 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 |
|
||||
|
||||
## Product tour
|
||||
@@ -73,6 +95,9 @@ Download a build from
|
||||
| macOS | `x64`, `arm64` | DMG, ZIP |
|
||||
| Linux | `x64`, `arm64` | AppImage, DEB, RPM |
|
||||
|
||||
The experimental LoongArch `loong64` build is distributed through a separate
|
||||
preview channel on the [product website](https://mesalogo.github.io/goodbuddy/#download).
|
||||
|
||||
Desktop installers do not contain remote Agent payloads. Local projects do not
|
||||
need an Agent package; managed SSH users download or import one explicitly in
|
||||
Settings.
|
||||
@@ -127,6 +152,14 @@ npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Community communication
|
||||
|
||||
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" />
|
||||
|
||||
|
||||
|
||||
## License
|
||||
|
||||
Original GoodBuddy code is released under the [0BSD License](./LICENSE). You may
|
||||
|
||||
+12
-1
@@ -23,6 +23,16 @@
|
||||
|
||||
GoodBuddy 是用于模型对话、编程 Agent、知识检索、笔记和定时任务的桌面应用。使用 GoodBuddy 无需注册账号,工作数据默认保存在本机。模型服务可以运行在本机、组织内网或用户选择的云端服务。
|
||||
|
||||
## 产品亮点
|
||||
|
||||
- **本地优先**:无需注册 GoodBuddy 账号,工作数据默认保存在本机,模型服务由用户选择。
|
||||
- **统一 Agent Runtime**:在一个桌面界面中使用直连模型、OpenCode、Continue 和预览版 DeepSeek Harness。
|
||||
- **执行边界明确**:`Ask` 保持只读,`Execute` 使用当前账号权限,工具调用、取消、超时和 Token 用量均有记录。
|
||||
- **支持信创软硬件**:正式 Linux 版本覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境;龙芯 LoongArch 提供独立 `loong64` 实验预览版。
|
||||
- **知识与长期工作一体化**:知识库、知识图谱、魔法笔记、任务和智能心跳共享同一个桌面工作空间。
|
||||
|
||||
信创支持按操作系统、指令集和安装包兼容性描述,不代表已完成所有厂商、整机和系统版本的兼容认证。龙芯版本尚未进入正式发布矩阵,具体限制见[龙芯预览版说明](./docs/development/loongarch-preview-build.md)。
|
||||
|
||||
## 主要能力
|
||||
|
||||
| 范围 | GoodBuddy 提供的能力 |
|
||||
@@ -118,7 +128,8 @@ npm run lint
|
||||
|
||||
扫描二维码加入 GoodBuddy 微信群:
|
||||
|
||||
<img width="360" alt="GoodBuddy 微信社区群二维码" src="https://github.com/user-attachments/assets/10bb7ac2-0bc1-41a9-978d-c2862923fcd6" />
|
||||
<img width="1250" height="1194" alt="9627ce196b0de42f123c33dd9dea1568" src="https://github.com/user-attachments/assets/ff87dfac-d72f-47b7-ac9a-c88b0b4c96d9" />
|
||||
|
||||
|
||||
## 开源许可
|
||||
|
||||
|
||||
+21
-4
@@ -336,6 +336,8 @@
|
||||
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
|
||||
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
|
||||
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
|
||||
- 工作栏 Tab、Tab 关闭按钮和“+”入口沿用共享控件圆角;终端面板外框及终端顶部工具栏按钮
|
||||
使用直角,形成紧凑的停靠式终端界面。
|
||||
- 选择“终端”后直接绑定当前项目,由项目执行空间自动决定本机或托管 SSH;不再显示第二层
|
||||
目标选择器。终端面板内部不重复提供关闭会话按钮,关闭统一由 Tab 入口完成。
|
||||
- 下一次请求使用的附件和知识库由会话输入区展示与管理,不在工作栏重复提供“上下文”栏目;
|
||||
@@ -383,7 +385,9 @@
|
||||
对齐。该栏不重复放置新建入口。
|
||||
- “新建任务”只位于工作栏“任务索引”标题后方;任务状态筛选占满工作栏可用宽度,四个选项
|
||||
等分,不能保留内容宽度的短控件。
|
||||
- Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。
|
||||
- Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。顶栏 Runtime
|
||||
状态由状态点和 Runtime/模型名称紧凑表达:就绪使用绿色,连接中使用黄色,不可用使用灰色,
|
||||
不再重复显示状态文字;完整状态详情继续通过可访问说明和工具提示提供。
|
||||
- 浅色与深色切换属于持续可用的窗口级操作,直接显示太阳或月亮图标,并通过可访问名称说明将切换到的主题。选择必须持久化,切换不得改变布局。
|
||||
- 顶栏只直接显示当前任务所需的高频操作。已有侧栏账户设置入口时,不再重复显示 Runtime/设置入口;使用帮助优先放在相关操作附近,而不是为单个帮助项创建“更多”菜单。
|
||||
- 助手工作栏开关固定在上下文栏最右侧并与窗口关闭按钮纵向对齐;展开后该开关保持原位并
|
||||
@@ -576,6 +580,13 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown
|
||||
原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示
|
||||
复制入口。
|
||||
- 默认开启“会话 HTML 渲染”时,已完成 Agent 回复中的完整 HTML 与 `html` / `htm`
|
||||
代码块在原位置显示隔离静态预览;用户消息、推理过程和流式中的回复不得执行 HTML。
|
||||
每个预览持续标注脚本和网络已禁用,并在标题右侧复用与 Mermaid 图表相同的图标按钮组:
|
||||
源码按钮使用代码图标并暴露 `aria-expanded`,全屏按钮使用放大图标打开统一 Modal。
|
||||
全屏预览遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape 关闭和焦点恢复规则,
|
||||
并沿用同一套清理与 Sandbox/CSP 边界。关闭平台功能后必须显示 HTML 源码,不能隐藏内容
|
||||
或保留旧预览。
|
||||
- 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行
|
||||
或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态,
|
||||
使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。
|
||||
@@ -598,6 +609,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
|
||||
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
|
||||
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
|
||||
- 消息流的滚动范围必须使用已渲染消息的真实高度。长会话可以按批次只挂载最近消息并由用户
|
||||
加载更早内容,但不得用固定的消息占位高度替代实际布局,避免推理、工具列表或长正文被
|
||||
排除在可滚动范围之外。
|
||||
|
||||
### 13.2 最近对话
|
||||
|
||||
@@ -629,7 +643,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 状态卡片使用统一状态令牌,不只依赖颜色。
|
||||
- 保留“运行概览 / 待处理建议 / 报告与记录 / 心跳计划”四个同级页面。
|
||||
- 智能心跳菜单入口是完整配置的权威位置;任务中心和设置中心不得复制同一 CRUD 表单。
|
||||
- “未来分区记忆”仅为长期方向,数据、状态和页面尚未设计,不得显示占位入口。
|
||||
- 分区记忆协同与唤起条件尚未实现,不得显示占位入口或平行的记忆类型。
|
||||
|
||||
### 13.5 Task Center
|
||||
|
||||
@@ -686,14 +700,17 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
|
||||
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
|
||||
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
|
||||
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
|
||||
更新,保存失败时保留最后确认状态。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
|
||||
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
|
||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
|
||||
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
|
||||
- OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。
|
||||
- MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。直连模型中的联网搜索与内置浏览器使用一致的折叠卡片和独立总开关;内置浏览器必须明确说明其操作 GoodBuddy 隔离浏览器,不控制客户端已安装的浏览器,开启后可由 Execute 直接使用,不逐次询问。尚未生效的命名浏览器配置不得显示在界面中,“电脑控制”只显示实际操作客户端电脑的能力。内置 MCP 卡片与 Skills 一样提供持久启停和 Runtime 分配;直连模型、GoodBuddy 管理的 OpenCode 与 Continue 默认选中且可调整,DeepSeek Harness 必须以置灰、未选择和“暂不支持”文案持续显示,不能呈现为可保存的分配。魔法笔记 MCP 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。
|
||||
- MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。联网搜索保留在直连模型;内置浏览器归入内置 MCP,提供持久启停和 Runtime 分配,并明确说明其操作 GoodBuddy 隔离浏览器、不控制客户端已安装的浏览器,开启后可由已分配 Runtime 的 Execute 直接使用,不逐次询问。该开关只控制 Agent 工具访问,关闭时不得禁用浏览器工作栏中的用户手动导航、返回、刷新、停止加载、交互或关闭操作。尚未生效的命名浏览器配置不得显示在界面中,“电脑控制”只显示实际操作客户端电脑的能力。内置 MCP 卡片与 Skills 一样提供持久启停和 Runtime 分配;直连模型、GoodBuddy 管理的 OpenCode 与 Continue 默认选中且可调整,DeepSeek Harness 必须以置灰、未选择和“暂不支持”文案持续显示,不能呈现为可保存的分配。魔法笔记 MCP 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。
|
||||
- MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。
|
||||
|
||||
### 13.9 文档解析设置
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.11.15",
|
||||
"agentVersion": "0.11.20",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
@@ -15,6 +15,12 @@
|
||||
"binaryPath": "node-v24.19.0-linux-x64/bin/node",
|
||||
"licensePath": "node-v24.19.0-linux-x64/LICENSE"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"archive": "node-v24.19.0-darwin-arm64.tar.gz",
|
||||
"sha256": "8294b7aa9b03997481c06babf1e8b270c859358f27da57a11509afe537ac381d",
|
||||
"binaryPath": "node-v24.19.0-darwin-arm64/bin/node",
|
||||
"licensePath": "node-v24.19.0-darwin-arm64/LICENSE"
|
||||
},
|
||||
"linux-arm64": {
|
||||
"archive": "node-v24.19.0-linux-arm64.tar.gz",
|
||||
"sha256": "d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const { readFileSync } = require('node:fs')
|
||||
const {
|
||||
detectBinaryArchitecture,
|
||||
detectElfArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
|
||||
function targetName(architecture, platform = 'linux') {
|
||||
if (
|
||||
!(platform === 'linux' && ['x64', 'arm64'].includes(architecture)) &&
|
||||
!(platform === 'darwin' && architecture === 'arm64')
|
||||
) {
|
||||
throw new Error(`Unsupported Agent build target: ${platform}-${architecture}`)
|
||||
}
|
||||
return `${platform}-${architecture}`
|
||||
}
|
||||
|
||||
function assertTargetBinary(filePath, architecture, platform, label) {
|
||||
targetName(architecture, platform)
|
||||
const header = readFileSync(filePath).subarray(0, 64)
|
||||
const isMachO = header.length >= 8 &&
|
||||
(header.readUInt32LE(0) === 0xfeedfacf ||
|
||||
header.readUInt32BE(0) === 0xfeedfacf)
|
||||
const actual = platform === 'linux'
|
||||
? detectElfArchitecture(header)
|
||||
: isMachO ? detectBinaryArchitecture(header) : undefined
|
||||
if (actual !== architecture) {
|
||||
throw new Error(
|
||||
`${label} architecture mismatch: expected ${platform}-${architecture}, received ${actual ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { targetName, assertTargetBinary }
|
||||
+55
-48
@@ -29,9 +29,11 @@ const {
|
||||
sep
|
||||
} = require('node:path')
|
||||
const tar = require('tar')
|
||||
const { detectElfArchitecture } = require('./binary-architecture.cjs')
|
||||
const {
|
||||
detectElfArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
targetName,
|
||||
assertTargetBinary
|
||||
} = require('./agent-build-target.cjs')
|
||||
const {
|
||||
preflightRegisteredProductionKey
|
||||
} = require('./signing-key-preflight.cjs')
|
||||
@@ -132,15 +134,8 @@ function readTrustedKeyRegistry(projectRoot = root) {
|
||||
return registry
|
||||
}
|
||||
|
||||
function targetName(arch) {
|
||||
if (!supportedArchitectures.includes(arch)) {
|
||||
throw new Error(`Unsupported Agent architecture: ${arch}`)
|
||||
}
|
||||
return `linux-${arch}`
|
||||
}
|
||||
|
||||
function lockedRuntimeInput(lock, arch, archivePath) {
|
||||
const target = targetName(arch)
|
||||
function lockedRuntimeInput(lock, arch, archivePath, platform = 'linux') {
|
||||
const target = targetName(arch, platform)
|
||||
const input = lock.node?.targets?.[target]
|
||||
if (
|
||||
!input ||
|
||||
@@ -177,24 +172,23 @@ function lockedRuntimeInput(lock, arch, archivePath) {
|
||||
function assertElfArchitecture(
|
||||
filePath,
|
||||
expectedArchitecture,
|
||||
description = 'Agent Node runtime'
|
||||
description = 'Agent Node runtime',
|
||||
platform = 'linux'
|
||||
) {
|
||||
const actual = detectElfArchitecture(readFileSync(filePath).subarray(0, 64))
|
||||
if (actual !== expectedArchitecture) {
|
||||
throw new Error(
|
||||
`${description} architecture mismatch: expected ${expectedArchitecture}, received ${actual ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
assertTargetBinary(filePath, expectedArchitecture, platform, description)
|
||||
}
|
||||
|
||||
function koffiNativePackageName(arch) {
|
||||
return `@koromix/koffi-linux-${arch}`
|
||||
function koffiNativePackageName(arch, platform = 'linux') {
|
||||
return `@koromix/koffi-${targetName(arch, platform)}`
|
||||
}
|
||||
|
||||
function koffiPayloadPaths(arch) {
|
||||
function koffiPayloadPaths(arch, platform = 'linux') {
|
||||
const packageRoot = `lib/node_modules/${koffiPackageName}`
|
||||
const nativeRoot =
|
||||
`lib/node_modules/${koffiNativePackageName(arch)}`
|
||||
`lib/node_modules/${koffiNativePackageName(arch, platform)}`
|
||||
const native = platform === 'linux'
|
||||
? [`${nativeRoot}/linux_${arch}/koffi.node`, `${nativeRoot}/musl_${arch}/koffi.node`]
|
||||
: [`${nativeRoot}/darwin_${arch}/koffi.node`]
|
||||
return {
|
||||
packageRoot,
|
||||
nativeRoot,
|
||||
@@ -205,13 +199,9 @@ function koffiPayloadPaths(arch) {
|
||||
`${packageRoot}/src/koffi/src/static.js`,
|
||||
`${nativeRoot}/package.json`,
|
||||
`${nativeRoot}/index.js`,
|
||||
`${nativeRoot}/linux_${arch}/koffi.node`,
|
||||
`${nativeRoot}/musl_${arch}/koffi.node`
|
||||
...native
|
||||
],
|
||||
native: [
|
||||
`${nativeRoot}/linux_${arch}/koffi.node`,
|
||||
`${nativeRoot}/musl_${arch}/koffi.node`
|
||||
]
|
||||
native
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +349,7 @@ function createManifest(bundleDirectory, metadata) {
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
agentVersion: metadata.agentVersion,
|
||||
platform: 'linux',
|
||||
platform: metadata.platform ?? 'linux',
|
||||
arch: metadata.arch,
|
||||
protocol: metadata.protocol,
|
||||
signingKeyId: metadata.signingKeyId,
|
||||
@@ -452,7 +442,10 @@ function validateManifestShape(manifest, expected) {
|
||||
manifest.product !== 'GoodBuddy' ||
|
||||
typeof manifest.agentVersion !== 'string' ||
|
||||
!semanticVersionPattern.test(manifest.agentVersion) ||
|
||||
manifest.platform !== 'linux' ||
|
||||
!(
|
||||
manifest.platform === 'linux' ||
|
||||
(manifest.platform === 'darwin' && manifest.arch === 'arm64')
|
||||
) ||
|
||||
!supportedArchitectures.includes(manifest.arch) ||
|
||||
!Number.isSafeInteger(manifest.protocol?.major) ||
|
||||
!Number.isSafeInteger(manifest.protocol?.minor) ||
|
||||
@@ -617,7 +610,7 @@ function verifyBundleDirectory(bundleDirectory, options = {}) {
|
||||
throw new Error(`Agent entrypoint payload is missing: ${required}`)
|
||||
}
|
||||
}
|
||||
const koffiPaths = koffiPayloadPaths(manifest.arch)
|
||||
const koffiPaths = koffiPayloadPaths(manifest.arch, manifest.platform)
|
||||
for (const required of koffiPaths.required) {
|
||||
if (!declaredPaths.has(required)) {
|
||||
throw new Error(`Agent Koffi payload is missing: ${required}`)
|
||||
@@ -626,7 +619,7 @@ function verifyBundleDirectory(bundleDirectory, options = {}) {
|
||||
const koffiLicense = manifest.licenses.find(
|
||||
(license) => license.package === koffiPackageName
|
||||
)
|
||||
const nativePackage = koffiNativePackageName(manifest.arch)
|
||||
const nativePackage = koffiNativePackageName(manifest.arch, manifest.platform)
|
||||
const nativeLicense = manifest.licenses.find(
|
||||
(license) => license.package === nativePackage
|
||||
)
|
||||
@@ -641,13 +634,16 @@ function verifyBundleDirectory(bundleDirectory, options = {}) {
|
||||
}
|
||||
assertElfArchitecture(
|
||||
join(bundleDirectory, manifest.entrypoint.runtimePath),
|
||||
manifest.arch
|
||||
manifest.arch,
|
||||
'Agent Node runtime',
|
||||
manifest.platform
|
||||
)
|
||||
for (const nativePath of koffiPaths.native) {
|
||||
assertElfArchitecture(
|
||||
join(bundleDirectory, ...nativePath.split('/')),
|
||||
manifest.arch,
|
||||
'Agent Koffi native binding'
|
||||
'Agent Koffi native binding',
|
||||
manifest.platform
|
||||
)
|
||||
}
|
||||
return {
|
||||
@@ -668,7 +664,8 @@ function assertReplaceableBundle(directory) {
|
||||
if (
|
||||
manifest.formatVersion === 1 &&
|
||||
manifest.product === 'GoodBuddy' &&
|
||||
manifest.platform === 'linux'
|
||||
(manifest.platform === 'linux' ||
|
||||
(manifest.platform === 'darwin' && manifest.arch === 'arm64'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -717,13 +714,13 @@ function copyRegularFile(source, destination) {
|
||||
chmodSync(destination, 0o644)
|
||||
}
|
||||
|
||||
function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion, platform = 'linux') {
|
||||
const sourceRoot = join(projectRoot, 'node_modules', koffiPackageName)
|
||||
const packageMetadata = readJson(
|
||||
join(sourceRoot, 'package.json'),
|
||||
'Koffi package metadata'
|
||||
)
|
||||
const nativePackage = koffiNativePackageName(arch)
|
||||
const nativePackage = koffiNativePackageName(arch, platform)
|
||||
const nativeSourceRoot = join(
|
||||
projectRoot,
|
||||
'node_modules',
|
||||
@@ -739,7 +736,7 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
nativeMetadata.name !== nativePackage ||
|
||||
nativeMetadata.version !== lockedVersion ||
|
||||
!Array.isArray(nativeMetadata.os) ||
|
||||
!nativeMetadata.os.includes('linux') ||
|
||||
!nativeMetadata.os.includes(platform) ||
|
||||
!Array.isArray(nativeMetadata.cpu) ||
|
||||
!nativeMetadata.cpu.includes(arch)
|
||||
) {
|
||||
@@ -748,10 +745,13 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
)
|
||||
}
|
||||
|
||||
const paths = koffiPayloadPaths(arch)
|
||||
const paths = koffiPayloadPaths(arch, platform)
|
||||
const files = [
|
||||
['package.json', `${paths.packageRoot}/package.json`],
|
||||
['index.js', `${paths.packageRoot}/index.js`],
|
||||
['index.cjs', `${paths.packageRoot}/index.cjs`],
|
||||
['src/koffi/index.cjs', `${paths.packageRoot}/src/koffi/index.cjs`],
|
||||
['src/koffi/src/static.cjs', `${paths.packageRoot}/src/koffi/src/static.cjs`],
|
||||
[
|
||||
'src/koffi/index.js',
|
||||
`${paths.packageRoot}/src/koffi/index.js`
|
||||
@@ -770,8 +770,7 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
for (const source of [
|
||||
'package.json',
|
||||
'index.js',
|
||||
`linux_${arch}/koffi.node`,
|
||||
`musl_${arch}/koffi.node`
|
||||
...paths.native.map((path) => path.slice(paths.nativeRoot.length + 1))
|
||||
]) {
|
||||
const destination = `${paths.nativeRoot}/${source}`
|
||||
copyRegularFile(
|
||||
@@ -783,7 +782,8 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
assertElfArchitecture(
|
||||
join(staging, ...nativePath.split('/')),
|
||||
arch,
|
||||
'Agent Koffi native binding'
|
||||
'Agent Koffi native binding',
|
||||
platform
|
||||
)
|
||||
}
|
||||
return {
|
||||
@@ -795,11 +795,13 @@ function copyKoffiRuntime(projectRoot, staging, arch, lockedVersion) {
|
||||
function buildAgentBundle(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const arch = options.arch
|
||||
const platform = options.platform ?? 'linux'
|
||||
const lock = options.lock ?? readRuntimeLock(projectRoot)
|
||||
const runtimeInput = lockedRuntimeInput(
|
||||
lock,
|
||||
arch,
|
||||
options.runtimeArchive
|
||||
options.runtimeArchive,
|
||||
platform
|
||||
)
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
@@ -843,13 +845,13 @@ function buildAgentBundle(options) {
|
||||
|
||||
const destination =
|
||||
options.outputDirectory ??
|
||||
join(projectRoot, '.agent-resources', targetName(arch))
|
||||
join(projectRoot, '.agent-resources', targetName(arch, platform))
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
const staging = mkdtempSync(
|
||||
join(dirname(destination), `.stage-${targetName(arch)}-`)
|
||||
join(dirname(destination), `.stage-${targetName(arch, platform)}-`)
|
||||
)
|
||||
const runtimeStaging = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-node-${targetName(arch)}-`)
|
||||
join(tmpdir(), `goodbuddy-node-${targetName(arch, platform)}-`)
|
||||
)
|
||||
try {
|
||||
tar.x({
|
||||
@@ -872,7 +874,7 @@ function buildAgentBundle(options) {
|
||||
runtimeStaging,
|
||||
...runtimeInput.licensePath.split('/')
|
||||
)
|
||||
assertElfArchitecture(runtimeBinary, arch)
|
||||
assertElfArchitecture(runtimeBinary, arch, 'Agent Node runtime', platform)
|
||||
copyFileSync(runtimeBinary, join(staging, 'node'))
|
||||
chmodSync(join(staging, 'node'), 0o755)
|
||||
|
||||
@@ -912,7 +914,8 @@ function buildAgentBundle(options) {
|
||||
projectRoot,
|
||||
staging,
|
||||
arch,
|
||||
lock.koffi.version
|
||||
lock.koffi.version,
|
||||
platform
|
||||
)
|
||||
const launcher = [
|
||||
'#!/bin/sh',
|
||||
@@ -980,6 +983,7 @@ function buildAgentBundle(options) {
|
||||
acpSdkVersion: acpSdkPackage.version,
|
||||
koffiVersion: koffi.version,
|
||||
koffiNativePackage: koffi.nativePackage,
|
||||
platform,
|
||||
arch,
|
||||
protocol: lock.protocol,
|
||||
signingKeyId: signingIdentity.keyId
|
||||
@@ -1004,6 +1008,7 @@ function buildAgentBundle(options) {
|
||||
? 'test'
|
||||
: 'production',
|
||||
expected: {
|
||||
platform,
|
||||
agentVersion: lock.agentVersion,
|
||||
arch,
|
||||
protocol: lock.protocol
|
||||
@@ -1073,6 +1078,7 @@ function verifyLockedBundle(bundleDirectory, arch, options = {}) {
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot),
|
||||
verificationEnvironment: options.verificationEnvironment ?? 'production',
|
||||
expected: {
|
||||
platform: options.platform ?? 'linux',
|
||||
agentVersion: lock.agentVersion,
|
||||
arch,
|
||||
protocol: lock.protocol
|
||||
@@ -1203,6 +1209,7 @@ function main(argv = process.argv.slice(2)) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
koffiPayloadPaths,
|
||||
assertElfArchitecture,
|
||||
assertSafeManifestPath,
|
||||
buildAgentBundle,
|
||||
|
||||
+33
-9
@@ -32,6 +32,10 @@ const {
|
||||
} = require('./zip-central-directory.cjs')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
function supportedTarget(platform, architecture) {
|
||||
return (platform === 'linux' && ['x64', 'arm64'].includes(architecture)) ||
|
||||
(platform === 'darwin' && architecture === 'arm64')
|
||||
}
|
||||
const catalogName = 'agent-catalog.json'
|
||||
const catalogSignatureName = 'agent-catalog.sig'
|
||||
const catalogSignatureDomain = Buffer.from(
|
||||
@@ -136,7 +140,7 @@ function assertPackageDescriptor(descriptor) {
|
||||
!semanticVersionPattern.test(
|
||||
descriptor.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
descriptor.platform !== 'linux' ||
|
||||
!supportedTarget(descriptor.platform, descriptor.architecture) ||
|
||||
!['x64', 'arm64'].includes(descriptor.architecture) ||
|
||||
typeof descriptor.signingKeyId !== 'string' ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(
|
||||
@@ -214,7 +218,7 @@ function readPackageMetadata(archivePath, registry) {
|
||||
)
|
||||
const expectedArchive =
|
||||
`goodbuddy-agent-${descriptor.version}` +
|
||||
`-linux-${descriptor.architecture}.gbagent`
|
||||
`-${descriptor.platform}-${descriptor.architecture}.gbagent`
|
||||
if (basename(archive) !== expectedArchive) {
|
||||
throw new Error(
|
||||
`Agent package archive must be named ${expectedArchive}`
|
||||
@@ -566,11 +570,11 @@ function assertCatalog(catalog) {
|
||||
!semanticVersionPattern.test(
|
||||
entry.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
entry.platform !== 'linux' ||
|
||||
!supportedTarget(entry.platform, entry.architecture) ||
|
||||
!['x64', 'arm64'].includes(entry.architecture) ||
|
||||
entry.archive !==
|
||||
`goodbuddy-agent-${entry.version}` +
|
||||
`-linux-${entry.architecture}.gbagent` ||
|
||||
`-${entry.platform}-${entry.architecture}.gbagent` ||
|
||||
!Number.isSafeInteger(entry.size) ||
|
||||
entry.size <= 0 ||
|
||||
!sha256Pattern.test(entry.sha256 ?? '') ||
|
||||
@@ -600,7 +604,7 @@ function assertCatalog(catalog) {
|
||||
entry.remoteRuntime.protocol,
|
||||
'Remote Runtime protocol'
|
||||
)
|
||||
const identity = `${entry.version}:${entry.architecture}`
|
||||
const identity = `${entry.version}:${entry.platform}:${entry.architecture}`
|
||||
if (identities.has(identity)) {
|
||||
throw new Error('Agent catalog contains duplicate entries')
|
||||
}
|
||||
@@ -698,7 +702,8 @@ function createCatalog(options) {
|
||||
}
|
||||
const packages = [
|
||||
readPackageMetadata(options.x64Package, registry),
|
||||
readPackageMetadata(options.arm64Package, registry)
|
||||
readPackageMetadata(options.arm64Package, registry),
|
||||
...(options.darwinArm64Package ? [readPackageMetadata(options.darwinArm64Package, registry)] : [])
|
||||
]
|
||||
const [first, second] = packages
|
||||
const {
|
||||
@@ -721,12 +726,26 @@ function createCatalog(options) {
|
||||
canonicalJson(secondRuntimeIdentity) ||
|
||||
new Set(packages.map(
|
||||
(metadata) => metadata.descriptor.architecture
|
||||
)).size !== 2
|
||||
).slice(0, 2)).size !== 2 ||
|
||||
first.descriptor.platform !== 'linux' ||
|
||||
second.descriptor.platform !== 'linux'
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package matrix does not describe one coherent release'
|
||||
)
|
||||
}
|
||||
if (packages[2]) {
|
||||
const mac = packages[2].descriptor
|
||||
const { bundleDigest: _digest, ...runtimeIdentity } = mac.remoteRuntime
|
||||
void _digest
|
||||
if (
|
||||
mac.platform !== 'darwin' || mac.architecture !== 'arm64' ||
|
||||
mac.version !== first.descriptor.version ||
|
||||
mac.minimumDesktopVersion !== first.descriptor.minimumDesktopVersion ||
|
||||
canonicalJson(mac.agentProtocol) !== canonicalJson(first.descriptor.agentProtocol) ||
|
||||
canonicalJson(runtimeIdentity) !== canonicalJson(firstRuntimeIdentity)
|
||||
) throw new Error('Agent package matrix does not describe one coherent release')
|
||||
}
|
||||
const previous =
|
||||
options.previousCatalog && options.previousSignature
|
||||
? readVerifiedCatalog(
|
||||
@@ -745,11 +764,11 @@ function createCatalog(options) {
|
||||
}
|
||||
const entries = new Map()
|
||||
for (const entry of previous?.entries ?? []) {
|
||||
entries.set(`${entry.version}:${entry.architecture}`, entry)
|
||||
entries.set(`${entry.version}:${entry.platform}:${entry.architecture}`, entry)
|
||||
}
|
||||
for (const metadata of packages) {
|
||||
const entry = catalogEntry(metadata)
|
||||
const identity = `${entry.version}:${entry.architecture}`
|
||||
const identity = `${entry.version}:${entry.platform}:${entry.architecture}`
|
||||
const existing = entries.get(identity)
|
||||
if (
|
||||
existing &&
|
||||
@@ -764,6 +783,7 @@ function createCatalog(options) {
|
||||
const sortedEntries = [...entries.values()].sort(
|
||||
(left, right) =>
|
||||
compareVersions(right.version, left.version) ||
|
||||
left.platform.localeCompare(right.platform, 'en') ||
|
||||
left.architecture.localeCompare(right.architecture, 'en')
|
||||
)
|
||||
if (sortedEntries.length > maximumCatalogEntries) {
|
||||
@@ -813,6 +833,7 @@ function parseArguments(argv) {
|
||||
![
|
||||
'--x64-package',
|
||||
'--arm64-package',
|
||||
'--darwin-arm64-package',
|
||||
'--previous-catalog',
|
||||
'--previous-signature',
|
||||
'--catalog',
|
||||
@@ -852,6 +873,9 @@ function parseArguments(argv) {
|
||||
command,
|
||||
x64Package: resolve(options['--x64-package']),
|
||||
arm64Package: resolve(options['--arm64-package']),
|
||||
...(options['--darwin-arm64-package'] ? {
|
||||
darwinArm64Package: resolve(options['--darwin-arm64-package'])
|
||||
} : {}),
|
||||
...(options['--previous-catalog']
|
||||
? {
|
||||
previousCatalog: resolve(
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
assembleAgentPackage
|
||||
} = require('./agent-package.cjs')
|
||||
const { sha256File } = require('./file-hash.cjs')
|
||||
const { targetName } = require('./agent-build-target.cjs')
|
||||
|
||||
const ciSigningKeyId = 'goodbuddy-agent-ci-ephemeral'
|
||||
|
||||
@@ -34,6 +35,7 @@ function parseArguments(argv) {
|
||||
if (
|
||||
![
|
||||
'--arch',
|
||||
'--platform',
|
||||
'--node-archive',
|
||||
'--opencode-archive',
|
||||
'--output-directory',
|
||||
@@ -56,6 +58,8 @@ function parseArguments(argv) {
|
||||
if (!supportedArchitectures.includes(options.arch)) {
|
||||
throw new Error('--arch must be x64 or arm64')
|
||||
}
|
||||
const platform = options.platform ?? 'linux'
|
||||
targetName(options.arch, platform)
|
||||
for (const key of [
|
||||
'nodearchive',
|
||||
'opencodearchive',
|
||||
@@ -70,6 +74,7 @@ function parseArguments(argv) {
|
||||
}
|
||||
return {
|
||||
arch: options.arch,
|
||||
platform,
|
||||
nodeArchive: resolve(options.nodearchive),
|
||||
opencodeArchive: resolve(options.opencodearchive),
|
||||
outputDirectory: resolve(options.outputdirectory),
|
||||
@@ -77,6 +82,14 @@ function parseArguments(argv) {
|
||||
}
|
||||
}
|
||||
|
||||
function readCiLocks(platform = 'linux', architecture = 'x64') {
|
||||
const projectRoot = resolve(__dirname, '..')
|
||||
targetName(architecture, platform)
|
||||
const lock = readRuntimeLock(projectRoot)
|
||||
const runtimeLock = readRemoteRuntimeLock(projectRoot)
|
||||
return { lock, runtimeLock }
|
||||
}
|
||||
|
||||
function ephemeralSigningIdentity() {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
return {
|
||||
@@ -97,8 +110,12 @@ function ephemeralSigningIdentity() {
|
||||
|
||||
async function buildCiAgentBundle(options) {
|
||||
const projectRoot = resolve(__dirname, '..')
|
||||
const lock = readRuntimeLock(projectRoot)
|
||||
const runtimeLock = readRemoteRuntimeLock(projectRoot)
|
||||
const platform = options.platform ?? 'linux'
|
||||
const target = targetName(options.arch, platform)
|
||||
if (process.platform !== platform || process.arch !== options.arch) {
|
||||
throw new Error(`Agent CI build requires a native ${target} host`)
|
||||
}
|
||||
const { lock, runtimeLock } = readCiLocks(platform, options.arch)
|
||||
const signing = ephemeralSigningIdentity()
|
||||
const firstArchiveRoot = resolve(
|
||||
join(
|
||||
@@ -124,6 +141,8 @@ async function buildCiAgentBundle(options) {
|
||||
force: true
|
||||
})
|
||||
buildAgentBundle({
|
||||
platform,
|
||||
lock,
|
||||
projectRoot,
|
||||
arch: options.arch,
|
||||
runtimeArchive: options.nodeArchive,
|
||||
@@ -132,6 +151,8 @@ async function buildCiAgentBundle(options) {
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
platform,
|
||||
lock: runtimeLock,
|
||||
projectRoot,
|
||||
architecture: options.arch,
|
||||
runtimeArchive: options.opencodeArchive,
|
||||
@@ -141,6 +162,7 @@ async function buildCiAgentBundle(options) {
|
||||
})
|
||||
const assemble = (archive) =>
|
||||
assembleAgentPackage({
|
||||
platform,
|
||||
projectRoot,
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion:
|
||||
@@ -181,7 +203,31 @@ async function buildCiAgentBundle(options) {
|
||||
throw new Error('Built Agent native launch smoke failed')
|
||||
}
|
||||
|
||||
const nativeChecks = [
|
||||
[
|
||||
resolve(options.outputDirectory, 'node'),
|
||||
['-e', `const k = require(${JSON.stringify(resolve(options.outputDirectory, 'lib/node_modules/koffi'))}); if (k.sizeof('int') !== 4) process.exit(1)`]
|
||||
],
|
||||
[join(runtime.bundleDirectory, 'bin', 'opencode'), ['--version']]
|
||||
]
|
||||
for (const [executable, args] of nativeChecks) {
|
||||
const result = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
windowsHide: true
|
||||
})
|
||||
if (
|
||||
result.error ||
|
||||
result.status !== 0 ||
|
||||
(args[0] === '--version' &&
|
||||
result.stdout.trim() !== runtimeLock.runtimes.opencode.version)
|
||||
) {
|
||||
throw new Error(`Built native dependency smoke failed: ${basename(executable)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
platform,
|
||||
agentVersion: lock.agentVersion,
|
||||
remoteRuntimeVersion:
|
||||
runtimeLock.runtimes.opencode.version,
|
||||
@@ -214,5 +260,6 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
buildCiAgentBundle,
|
||||
parseArguments
|
||||
parseArguments,
|
||||
readCiLocks
|
||||
}
|
||||
|
||||
+13
-3
@@ -19,6 +19,7 @@ const {
|
||||
} = require('node:fs')
|
||||
const { basename, dirname, join, relative, resolve, sep } = require('node:path')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { targetName } = require('./agent-build-target.cjs')
|
||||
const { crc32 } = require('node:zlib')
|
||||
const {
|
||||
buildAgentBundle,
|
||||
@@ -345,6 +346,8 @@ function createPackageArchive(
|
||||
function assembleAgentPackage(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const architecture = options.architecture
|
||||
const platform = options.platform ?? 'linux'
|
||||
const target = targetName(architecture, platform)
|
||||
if (
|
||||
!['x64', 'arm64'].includes(architecture) ||
|
||||
!semanticVersionPattern.test(options.minimumDesktopVersion ?? '') ||
|
||||
@@ -386,6 +389,7 @@ function assembleAgentPackage(options) {
|
||||
options.agentBundle,
|
||||
architecture,
|
||||
{
|
||||
platform,
|
||||
projectRoot,
|
||||
registry,
|
||||
lock: agentLock,
|
||||
@@ -395,6 +399,7 @@ function assembleAgentPackage(options) {
|
||||
}
|
||||
)
|
||||
const runtime = verifyRuntimeBundle(options.runtimeBundle, {
|
||||
platform,
|
||||
projectRoot,
|
||||
architecture,
|
||||
registry,
|
||||
@@ -405,14 +410,14 @@ function assembleAgentPackage(options) {
|
||||
})
|
||||
const expectedArchiveName =
|
||||
`goodbuddy-agent-${agent.manifest.agentVersion}` +
|
||||
`-linux-${architecture}.gbagent`
|
||||
`-${target}.gbagent`
|
||||
if (basename(options.output) !== expectedArchiveName) {
|
||||
throw new Error(
|
||||
`Agent package output must be named ${expectedArchiveName}`
|
||||
)
|
||||
}
|
||||
const staging = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-agent-package-linux-${architecture}-`)
|
||||
join(tmpdir(), `goodbuddy-agent-package-${target}-`)
|
||||
)
|
||||
try {
|
||||
stagePackagePayload({
|
||||
@@ -431,7 +436,7 @@ function assembleAgentPackage(options) {
|
||||
component: 'agent',
|
||||
version: agent.manifest.agentVersion,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
platform: 'linux',
|
||||
platform,
|
||||
architecture,
|
||||
signingKeyId: signingIdentity.keyId,
|
||||
agentProtocol: agent.manifest.protocol,
|
||||
@@ -493,6 +498,7 @@ function buildAgentPackage(options) {
|
||||
)
|
||||
try {
|
||||
const agentBundle = buildAgentBundle({
|
||||
platform: options.platform ?? 'linux',
|
||||
projectRoot,
|
||||
arch: options.architecture,
|
||||
runtimeArchive: options.nodeArchive,
|
||||
@@ -504,6 +510,7 @@ function buildAgentPackage(options) {
|
||||
: { signingIdentity })
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
platform: options.platform ?? 'linux',
|
||||
projectRoot,
|
||||
architecture: options.architecture,
|
||||
runtimeArchive: options.runtimeArchive,
|
||||
@@ -544,6 +551,7 @@ function parseArguments(argv) {
|
||||
if (
|
||||
![
|
||||
'--arch',
|
||||
'--platform',
|
||||
'--minimum-desktop-version',
|
||||
'--node-archive',
|
||||
'--runtime-archive',
|
||||
@@ -571,10 +579,12 @@ function parseArguments(argv) {
|
||||
throw new Error('Agent package arguments are incomplete')
|
||||
}
|
||||
const common = {
|
||||
platform: options.platform ?? 'linux',
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
output: resolve(options.output)
|
||||
}
|
||||
targetName(common.architecture, common.platform)
|
||||
if (command === 'build') {
|
||||
if (!options.nodeArchive || !options.runtimeArchive) {
|
||||
throw new Error('Agent package build inputs are incomplete')
|
||||
|
||||
@@ -17,7 +17,7 @@ const {
|
||||
const { app, utilityProcess } = require('electron/main')
|
||||
|
||||
const protocol = 'goodbuddy.deepseek-harness.control'
|
||||
const controlVersion = 2
|
||||
const controlVersion = 3
|
||||
const byteProtocol = 'goodbuddy.deepseek-harness.byte-stream'
|
||||
const byteProtocolVersion = 1
|
||||
const configuredHostPath =
|
||||
@@ -183,6 +183,7 @@ async function run() {
|
||||
provider: 'goodbuddy',
|
||||
model: 'qwen-plus',
|
||||
supportsImageInput: false,
|
||||
requestHeaders: {},
|
||||
harnessVersion: '0.1.0-rc.8',
|
||||
credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'],
|
||||
skillPackages: [],
|
||||
|
||||
@@ -33,8 +33,9 @@ const {
|
||||
} = require('node:path')
|
||||
const tar = require('tar')
|
||||
const {
|
||||
detectElfArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
targetName,
|
||||
assertTargetBinary
|
||||
} = require('./agent-build-target.cjs')
|
||||
const {
|
||||
preflightRegisteredProductionKey
|
||||
} = require('./signing-key-preflight.cjs')
|
||||
@@ -145,13 +146,6 @@ function readTrustedKeyRegistry(projectRoot = root) {
|
||||
return registry
|
||||
}
|
||||
|
||||
function targetName(architecture) {
|
||||
if (!supportedArchitectures.includes(architecture)) {
|
||||
throw new Error(`Unsupported Runtime architecture: ${architecture}`)
|
||||
}
|
||||
return `linux-${architecture}`
|
||||
}
|
||||
|
||||
function sha256(contents) {
|
||||
return createHash('sha256').update(contents).digest('hex')
|
||||
}
|
||||
@@ -390,15 +384,8 @@ function modeString(filePath) {
|
||||
.padStart(4, '0')
|
||||
}
|
||||
|
||||
function assertElfArchitecture(filePath, expectedArchitecture) {
|
||||
const actual = detectElfArchitecture(
|
||||
readFileSync(filePath).subarray(0, 64)
|
||||
)
|
||||
if (actual !== expectedArchitecture) {
|
||||
throw new Error(
|
||||
`Runtime architecture mismatch: expected ${expectedArchitecture}, received ${actual ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
function assertElfArchitecture(filePath, expectedArchitecture, platform = 'linux') {
|
||||
assertTargetBinary(filePath, expectedArchitecture, platform, 'Runtime')
|
||||
}
|
||||
|
||||
function validateManifestShape(manifest) {
|
||||
@@ -408,7 +395,10 @@ function validateManifestShape(manifest) {
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.runtimeVersion !== '1.18.9' ||
|
||||
manifest.provider !== 'opencode' ||
|
||||
manifest.platform !== 'linux' ||
|
||||
!(
|
||||
manifest.platform === 'linux' ||
|
||||
(manifest.platform === 'darwin' && manifest.architecture === 'arm64')
|
||||
) ||
|
||||
!supportedArchitectures.includes(manifest.architecture) ||
|
||||
typeof manifest.signingKeyId !== 'string' ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(manifest.bundleDigest) ||
|
||||
@@ -478,13 +468,14 @@ function trustedKeyForManifest(
|
||||
return key
|
||||
}
|
||||
|
||||
function assertManifestMatchesLock(manifest, lock, architecture) {
|
||||
function assertManifestMatchesLock(manifest, lock, architecture, platform = 'linux') {
|
||||
const expected = lock.runtimes.opencode
|
||||
const target = expected.targets[architecture]
|
||||
const target = expected.targets[platform === 'linux' ? architecture : targetName(architecture, platform)]
|
||||
if (
|
||||
manifest.runtimeId !== 'opencode' ||
|
||||
manifest.runtimeVersion !== expected.version ||
|
||||
manifest.provider !== expected.provider ||
|
||||
manifest.platform !== platform ||
|
||||
manifest.architecture !== architecture ||
|
||||
manifest.sourcePackage?.name !== target.package ||
|
||||
manifest.sourcePackage.integrity !== target.integrity ||
|
||||
@@ -512,7 +503,8 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const architecture = options.architecture
|
||||
targetName(architecture)
|
||||
const platform = options.platform ?? 'linux'
|
||||
targetName(architecture, platform)
|
||||
const manifestBytes = readFileSync(
|
||||
join(bundleDirectory, manifestFileName)
|
||||
)
|
||||
@@ -530,7 +522,7 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
|
||||
'Runtime manifest is not in canonical deterministic form'
|
||||
)
|
||||
}
|
||||
assertManifestMatchesLock(manifest, lock, architecture)
|
||||
assertManifestMatchesLock(manifest, lock, architecture, platform)
|
||||
const environment =
|
||||
options.verificationEnvironment ?? 'production'
|
||||
const key = trustedKeyForManifest(
|
||||
@@ -635,7 +627,8 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
|
||||
}
|
||||
assertElfArchitecture(
|
||||
join(bundleDirectory, ...manifest.entrypoint.path.split('/')),
|
||||
architecture
|
||||
architecture,
|
||||
platform
|
||||
)
|
||||
return {
|
||||
bundleDirectory,
|
||||
@@ -644,8 +637,8 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function lockedArchive(lock, architecture, archivePath) {
|
||||
const input = resolveLockedRuntimeInput(lock, architecture)
|
||||
function lockedArchive(lock, architecture, archivePath, platform = 'linux') {
|
||||
const input = resolveLockedRuntimeInput(lock, architecture, platform)
|
||||
if (!archivePath || !existsSync(archivePath)) {
|
||||
throw new Error(
|
||||
`Locked OpenCode archive is required: ${input.archive}`
|
||||
@@ -665,13 +658,13 @@ function lockedArchive(lock, architecture, archivePath) {
|
||||
`Runtime archive integrity mismatch for ${targetName(architecture)}`
|
||||
)
|
||||
}
|
||||
return lock.runtimes.opencode.targets[architecture]
|
||||
return lock.runtimes.opencode.targets[platform === 'linux' ? architecture : targetName(architecture, platform)]
|
||||
}
|
||||
|
||||
function resolveLockedRuntimeInput(lock, architecture) {
|
||||
targetName(architecture)
|
||||
function resolveLockedRuntimeInput(lock, architecture, platform = 'linux') {
|
||||
targetName(architecture, platform)
|
||||
const runtime = lock.runtimes.opencode
|
||||
const target = runtime.targets[architecture]
|
||||
const target = runtime.targets[platform === 'linux' ? architecture : targetName(architecture, platform)]
|
||||
return {
|
||||
packageName: target.package,
|
||||
version: runtime.version,
|
||||
@@ -708,7 +701,7 @@ function createManifest(bundleDirectory, metadata) {
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: metadata.runtime.version,
|
||||
provider: metadata.runtime.provider,
|
||||
platform: 'linux',
|
||||
platform: metadata.platform ?? 'linux',
|
||||
architecture: metadata.architecture,
|
||||
signingKeyId: metadata.signingKeyId,
|
||||
bundleDigest: `sha256:${'0'.repeat(64)}`,
|
||||
@@ -745,10 +738,10 @@ function defaultOutputRoot(projectRoot = root) {
|
||||
return join(projectRoot, '.remote-runtime-resources')
|
||||
}
|
||||
|
||||
function bundleDirectory(outputRoot, architecture, digest) {
|
||||
function bundleDirectory(outputRoot, architecture, digest, platform = 'linux') {
|
||||
return join(
|
||||
outputRoot,
|
||||
targetName(architecture),
|
||||
targetName(architecture, platform),
|
||||
'opencode',
|
||||
digest.slice('sha256:'.length)
|
||||
)
|
||||
@@ -757,12 +750,14 @@ function bundleDirectory(outputRoot, architecture, digest) {
|
||||
function buildRuntimeBundle(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const architecture = options.architecture
|
||||
const platform = options.platform ?? 'linux'
|
||||
const lock = options.lock ?? readRemoteRuntimeLock(projectRoot)
|
||||
const runtime = lock.runtimes.opencode
|
||||
const sourcePackage = lockedArchive(
|
||||
lock,
|
||||
architecture,
|
||||
options.runtimeArchive
|
||||
options.runtimeArchive,
|
||||
platform
|
||||
)
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
@@ -810,7 +805,7 @@ function buildRuntimeBundle(options) {
|
||||
// The lock target itself carries the canonical package name.
|
||||
if (
|
||||
sourceMetadata.name !==
|
||||
runtime.targets[architecture].package
|
||||
sourcePackage.package
|
||||
) {
|
||||
throw new Error('OpenCode source package name does not match the lock')
|
||||
}
|
||||
@@ -828,7 +823,8 @@ function buildRuntimeBundle(options) {
|
||||
chmodSync(join(staging, 'bin', 'opencode'), 0o755)
|
||||
assertElfArchitecture(
|
||||
join(staging, 'bin', 'opencode'),
|
||||
architecture
|
||||
architecture,
|
||||
platform
|
||||
)
|
||||
mkdirSync(join(staging, 'licenses'), { mode: 0o700 })
|
||||
copyFileSync(
|
||||
@@ -840,11 +836,12 @@ function buildRuntimeBundle(options) {
|
||||
0o644
|
||||
)
|
||||
const manifest = createManifest(staging, {
|
||||
platform,
|
||||
architecture,
|
||||
runtime,
|
||||
sourcePackage: {
|
||||
name: runtime.targets[architecture].package,
|
||||
integrity: runtime.targets[architecture].integrity
|
||||
name: sourcePackage.package,
|
||||
integrity: sourcePackage.integrity
|
||||
},
|
||||
signingKeyId: signingIdentity.keyId
|
||||
})
|
||||
@@ -869,11 +866,13 @@ function buildRuntimeBundle(options) {
|
||||
const destination = bundleDirectory(
|
||||
outputRoot,
|
||||
architecture,
|
||||
manifest.bundleDigest
|
||||
manifest.bundleDigest,
|
||||
platform
|
||||
)
|
||||
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 })
|
||||
if (existsSync(destination)) {
|
||||
const existing = verifyBundleDirectory(destination, {
|
||||
platform,
|
||||
projectRoot,
|
||||
architecture,
|
||||
lock,
|
||||
@@ -893,6 +892,7 @@ function buildRuntimeBundle(options) {
|
||||
chmodSync(staging, 0o700)
|
||||
renameSync(staging, destination)
|
||||
return verifyBundleDirectory(destination, {
|
||||
platform,
|
||||
projectRoot,
|
||||
architecture,
|
||||
lock,
|
||||
|
||||
@@ -37,11 +37,11 @@ GoodBuddy 当前已经具备若干长期助手能力,但它们仍是彼此分
|
||||
|
||||
## 2. 核心产品判断
|
||||
|
||||
### 2.1 智能心跳保持独立,未来分区记忆另行设计
|
||||
### 2.1 智能心跳保持独立,分区记忆协同另行设计
|
||||
|
||||
当前智能心跳继续承担周期回顾、报告和建议,并支持 Global 或指定 Project 范围。它不是
|
||||
Task、通用调度器或后台 Agent,也不进入统一 `AutomationPlan.kind`。未来分区记忆的
|
||||
数据、状态、唤起和页面需要独立设计,不能从当前方向直接推导。
|
||||
Task、通用调度器或后台 Agent,也不进入统一 `AutomationPlan.kind`。按分区回顾、候选生成
|
||||
以及时间/内部事件唤起需要独立设计,不能从当前心跳行为直接推导,也不创建平行记忆类型。
|
||||
|
||||
- Scheduled Task 解决“何时在一个 Task 中执行新的 Job”。
|
||||
- Goal Task 解决“围绕结果在同一 Task 中持续规划和推进”。
|
||||
@@ -507,7 +507,7 @@ experiment_runs
|
||||
现有 `schedules`、`schedule_runs`、`heartbeat_configs`、`heartbeat_runs`、
|
||||
`heartbeat_entries`、`tasks` 和 `runs` 不应一次性重写。Schedule 可渐进建立稳定 Task 与
|
||||
Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心跳数据保持独立,不得
|
||||
静默转成 `AutomationPlan` 或顶层 Task。未来分区记忆完成设计前,不新增迁移目标。
|
||||
静默转成 `AutomationPlan` 或顶层 Task。分区记忆协同和唤起条件完成设计前,不新增迁移目标。
|
||||
|
||||
## 16. 分阶段实施
|
||||
|
||||
@@ -518,7 +518,7 @@ Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心
|
||||
- 明确 Task N:1 Conversation 关系、左侧行首展开按钮与 Task 子项图标,以及 Task Center
|
||||
索引边界,不复制内容。
|
||||
- 为现有 Scheduled Task 和专家执行建立按 Task 聚合的活动视图。
|
||||
- 明确当前心跳保持独立,未来分区记忆尚待设计。
|
||||
- 明确当前心跳保持独立,分区记忆协同与唤起条件尚待设计。
|
||||
- 补充触发来源、运行版本、预算和读写范围展示。
|
||||
|
||||
### 阶段 1:调度与运行基础
|
||||
@@ -569,7 +569,7 @@ Conversation 关联,旧 child-task 字段可兼容映射到 Job/Subjob;心
|
||||
|
||||
## 18. 总体验收标准
|
||||
|
||||
- [ ] 智能心跳保持独立,不作为 Task 类型;未来分区记忆尚未设计。
|
||||
- [ ] 智能心跳保持独立,不作为 Task 类型;分区记忆协同与唤起条件尚未设计。
|
||||
- [ ] Task Center 只索引 Task;每个 Task 只关联一条 Conversation,一条 Conversation 可以
|
||||
关联多个 Task。
|
||||
- [ ] 当前 UI 只展示到 Task,不提供 Job/Subjob/Run 树或独立导航。
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
# GoodBuddy 设备共享与 ShareServer 联邦网络总体设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 适用范围 | GoodBuddy 桌面端、ShareServer、网关间联邦协议 |
|
||||
| 桌面端设计 | [设备发现与共享](../features/device-sharing/README.md) |
|
||||
| 服务端设计 | [ShareServer](../../../goodbuddy-shareserver/docs/features/share-server/README.md) |
|
||||
|
||||
## 1. 文档职责
|
||||
|
||||
本文是设备共享网络的跨端权威设计,负责定义拓扑、术语、身份、能力清单、授权计算、任务
|
||||
路由、能力分发、协议边界和共同验收标准。桌面端的产品与界面行为由“设备发现与共享”功能
|
||||
文档负责,独立 Web 服务由 ShareServer 功能文档负责。
|
||||
|
||||
本文描述完整目标系统,不以交付阶段删减领域模型,也不把尚未实现的设计表述为当前能力。
|
||||
|
||||
## 2. 核心决策
|
||||
|
||||
### 2.1 两个独立产品面
|
||||
|
||||
1. **GoodBuddy Desktop** 在设置中提供“设备发现与共享”,默认使用局域网发现与直连,也可
|
||||
由用户额外连接一个或多个 ShareServer。
|
||||
2. **ShareServer** 是独立部署、独立升级的 Web 服务,提供组织、设备、能力、权限、更新、
|
||||
联邦、中继和审计管理。它不运行在 Electron Main 中,也不由桌面端代为启动。
|
||||
|
||||
### 2.2 能力优先,来源可见
|
||||
|
||||
用户日常选择的是能力,不是网络连接。设备、ShareServer 和组织作为能力来源与诊断入口。
|
||||
任何调用都必须持续显示提供者、执行位置和数据去向,不能为了统一体验隐藏跨设备或跨组织
|
||||
边界。
|
||||
|
||||
### 2.3 局域网是默认连接方式,网关是显式附加连接
|
||||
|
||||
- 桌面端没有网关配置时,局域网发现、配对、调用和本机共享仍可完整工作。
|
||||
- 添加、删除或断开 ShareServer 不改变局域网配对关系。
|
||||
- ShareServer 不接管本机局域网发现,也不能静默把局域网设备注册到组织。
|
||||
- 同一桌面端可以连接多个 ShareServer;能力来源和授权按连接分别计算。
|
||||
|
||||
### 2.4 发现、被发现和开放能力是三个独立动作
|
||||
|
||||
- **发现设备**:浏览同一局域网中主动广播的 GoodBuddy 服务。
|
||||
- **允许被发现**:广播本设备存在及配对入口。
|
||||
- **开放能力**:向指定设备、用户组或组织发布可查看、调用或拉取的能力。
|
||||
|
||||
开启其中一项不能推导另外两项已开启。新安装默认不开放能力。
|
||||
|
||||
### 2.5 ShareServer 优先作为控制面
|
||||
|
||||
ShareServer 负责身份、目录、策略、任务路由、更新和审计。数据面优先在两个已认证端点之间
|
||||
直接建立;网络条件不允许时,可按组织策略使用 ShareServer 中继。是否允许中继、正文是否
|
||||
端到端加密、是否允许跨组织,由本次任务冻结的策略共同决定。
|
||||
|
||||
## 3. 术语
|
||||
|
||||
| 术语 | 定义 |
|
||||
| --- | --- |
|
||||
| `Device` | 安装 GoodBuddy 并拥有稳定设备身份的终端 |
|
||||
| `Peer` | 与当前设备存在局域网配对关系的另一台设备 |
|
||||
| `ShareServer` | 提供组织控制面和可选数据中继的独立服务 |
|
||||
| `Organization` | ShareServer 中隔离成员、设备、能力与策略的管理范围 |
|
||||
| `GatewayConnection` | 某台设备与一个 ShareServer 上特定组织身份的连接 |
|
||||
| `Capability` | 可被查看、调用或分发的稳定能力标识 |
|
||||
| `CapabilityProvider` | 实际持有并执行某项能力的设备或服务节点 |
|
||||
| `Publication` | 能力提供者向某一范围发布的可见性和使用声明 |
|
||||
| `Grant` | 主体对能力执行某类动作的明确授权 |
|
||||
| `Task` | 一次远程能力调用及其输入、执行、输出和终态 |
|
||||
| `Package` | 可下载到本机安装的签名能力工件 |
|
||||
| `FederationTrust` | 两个组织网关之间经管理员确认的双边信任关系 |
|
||||
|
||||
“网关”是 ShareServer 的网络角色;产品名称为 `ShareServer`,独立仓库为
|
||||
`goodbuddy-shareserver`。
|
||||
|
||||
## 4. 系统拓扑
|
||||
|
||||
```text
|
||||
局域网:
|
||||
GoodBuddy Desktop A <-- mDNS 发现 + 配对后 TLS 直连 --> GoodBuddy Desktop B
|
||||
|
||||
单组织:
|
||||
GoodBuddy Desktop A -- 出站连接 --> ShareServer <-- 出站连接 -- Provider Device B
|
||||
|
||||
跨组织:
|
||||
Desktop A --> ShareServer A <== 联邦信任 ==> ShareServer B <-- Provider Device B
|
||||
```
|
||||
|
||||
局域网和网关连接共享能力、授权和任务的领域语义,但不共享发现方式:
|
||||
|
||||
| 维度 | 局域网 | ShareServer |
|
||||
| --- | --- | --- |
|
||||
| 发现来源 | mDNS/DNS-SD 主动广播 | 已配置 URL 或邀请链接 |
|
||||
| 初始身份 | 双端验证码确认设备指纹 | 用户登录、设备注册和组织成员身份 |
|
||||
| 能力目录 | 已配对设备直接返回 | 服务端按组织策略过滤 |
|
||||
| 连接方向 | 配对后直连 | 设备主动建立出站连接 |
|
||||
| 管理范围 | 单个设备间授权 | 用户组、组织和联邦授权 |
|
||||
| 审计权威 | 两端各自本地记录 | 组织网关记录元数据,两端保留本地记录 |
|
||||
|
||||
## 5. 组件边界
|
||||
|
||||
### 5.1 GoodBuddy Desktop
|
||||
|
||||
- 浏览和广播局域网服务。
|
||||
- 创建、确认和撤销点对点配对。
|
||||
- 连接 ShareServer,保存脱敏连接信息和本机受保护凭据。
|
||||
- 发布用户明确选择的本机能力。
|
||||
- 展示经当前身份过滤的能力目录。
|
||||
- 在调用前冻结提供者、输入范围、授权和路由。
|
||||
- 执行本机提供的任务,或消费远端任务结果。
|
||||
- 校验并安装用户明确选择的签名能力包。
|
||||
- 保存本机活动记录和可撤销授权。
|
||||
|
||||
### 5.2 ShareServer
|
||||
|
||||
- 认证用户、设备、服务节点和其他 ShareServer。
|
||||
- 管理组织、成员、用户组和设备目录。
|
||||
- 接收能力发布并生成权限过滤后的目录。
|
||||
- 计算组织强制策略,签发短期任务授权。
|
||||
- 路由任务、维护在线状态,并在允许时中继数据。
|
||||
- 管理签名包、发布通道、更新策略和撤销。
|
||||
- 建立双边联邦关系并限制跨组织可见性与调用。
|
||||
- 保存有界审计元数据、用量和管理操作。
|
||||
|
||||
### 5.3 能力提供者
|
||||
|
||||
- 只执行已发布且本次授权允许的能力。
|
||||
- 在执行边界重新验证任务令牌、输入 schema、大小、期限和调用方。
|
||||
- 使用自身 Runtime、账号、工作区和数据权限。
|
||||
- 返回有界进度、结果、成果或确定终态。
|
||||
- 不因收到任务令牌而获得其他能力或任意系统接口。
|
||||
|
||||
## 6. 身份与信任
|
||||
|
||||
### 6.1 设备身份
|
||||
|
||||
每次 GoodBuddy 安装生成稳定设备密钥和自签设备证书,私钥保存在系统安全存储中。对外只
|
||||
使用随机设备 ID、公钥指纹、用户设置的设备名称和必要协议版本。操作系统用户名、绝对路径、
|
||||
软件资产和能力详情不进入未认证发现广播。
|
||||
|
||||
重装或主动重置设备身份会产生新设备。旧局域网配对和 ShareServer 设备注册不会自动迁移。
|
||||
|
||||
### 6.2 局域网配对
|
||||
|
||||
1. 发起端选择一台主动广播的设备。
|
||||
2. 双方建立临时加密通道并计算共同短验证码。
|
||||
3. 两端同时显示设备名、系统类型、公钥指纹和验证码。
|
||||
4. 双方用户确认后保存对端公钥及配对 ID。
|
||||
5. 后续使用双方设备证书认证;任一端可以撤销配对。
|
||||
|
||||
IP 地址不作为设备身份,也不单独构成授权依据。
|
||||
|
||||
### 6.3 ShareServer 注册
|
||||
|
||||
用户通过浏览器/OIDC 或服务端支持的登录方式认证后,为当前设备申请一次性注册挑战。设备
|
||||
使用自身私钥签名挑战,ShareServer 建立 `user + organization + device` 绑定并签发可轮换的
|
||||
设备凭据。设备凭据不等同于用户 Web 会话,不可用于管理控制台登录。
|
||||
|
||||
### 6.4 联邦身份
|
||||
|
||||
两个 ShareServer 由双方管理员交换组织标识、网关公钥、公开端点和指纹,并分别确认信任。
|
||||
联邦关系只有在双方状态均为 `active` 时生效。单方暂停或撤销后立即停止新目录交换和新任务,
|
||||
已执行任务保留审计终态。
|
||||
|
||||
## 7. 能力模型
|
||||
|
||||
### 7.1 能力清单
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"capabilityId": "document.convert.docx",
|
||||
"name": "文档转 Word",
|
||||
"version": "1.4.0",
|
||||
"modes": ["remote", "package"],
|
||||
"inputSchema": {},
|
||||
"outputSchema": {},
|
||||
"permissions": ["receive_text", "receive_files", "return_files"],
|
||||
"limits": {
|
||||
"maxInputBytes": 10485760,
|
||||
"maxOutputBytes": 52428800,
|
||||
"maxDurationSeconds": 900,
|
||||
"maxConcurrentTasks": 2
|
||||
},
|
||||
"dataPolicy": {
|
||||
"retention": "task_lifetime",
|
||||
"networkAccess": false,
|
||||
"delegation": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
清单只描述能力事实,不授予任何主体访问权。`inputSchema` 和 `outputSchema` 使用受限 JSON
|
||||
Schema 子集;实现必须限制 schema 深度、属性数、枚举数和总字节,不能把清单当作可执行代码。
|
||||
|
||||
### 7.2 发布声明
|
||||
|
||||
`Publication` 将一个能力版本发布到以下一种范围:
|
||||
|
||||
- 指定局域网 Peer。
|
||||
- 所有已配对 Peer。
|
||||
- ShareServer 内指定用户、用户组或设备组。
|
||||
- 指定联邦组织中的用户组。
|
||||
|
||||
发布声明同时定义允许动作:`discover`、`inspect`、`invoke`、`download`。`inspect` 允许查看
|
||||
详细清单,`discover` 只允许看到名称和可用状态。
|
||||
|
||||
### 7.3 可调用与可分发
|
||||
|
||||
- `remote`:代码与受控数据留在提供方,调用方只发送输入并接收结果。
|
||||
- `package`:签名工件下载到调用方,经校验和用户/组织策略批准后安装。
|
||||
|
||||
同一能力可以同时支持两种模式,但两种模式分别授权。远程调用授权不能推导下载权,下载权
|
||||
也不能推导安装后自动启用。
|
||||
|
||||
## 8. 授权模型
|
||||
|
||||
### 8.1 授权输入
|
||||
|
||||
一次动作的最终决定取以下约束交集:
|
||||
|
||||
```text
|
||||
调用方用户权限
|
||||
∩ 调用方设备状态
|
||||
∩ 调用方组织强制策略
|
||||
∩ 发布范围
|
||||
∩ 提供方授权
|
||||
∩ 提供方组织强制策略
|
||||
∩ 能力输入与数据策略
|
||||
∩ 联邦关系限制(跨组织时)
|
||||
= allow | require_approval | deny
|
||||
```
|
||||
|
||||
`deny` 优先于 `require_approval`,`require_approval` 优先于 `allow`。客户端偏好只能在组织强制
|
||||
策略允许的范围内收紧权限,不能放宽权限。
|
||||
|
||||
### 8.2 Grant
|
||||
|
||||
Grant 至少绑定:
|
||||
|
||||
- 主体:用户、用户组、设备、设备组或外部组织。
|
||||
- 能力 ID 和允许的版本范围。
|
||||
- 动作:查看、调用、下载、安装或发布。
|
||||
- 输入:文本、文件、数据分类、单项和累计大小。
|
||||
- 提供者和执行位置限制。
|
||||
- 次数、并发、预算、时间段和有效期。
|
||||
- 是否需要每次批准。
|
||||
- 是否允许委托及明确的下游组织。
|
||||
|
||||
“始终允许”只创建与当前能力、提供者、输入类型和范围匹配的 Grant,不创建整台设备的任意
|
||||
访问权。
|
||||
|
||||
### 8.3 转委托
|
||||
|
||||
默认 `delegation=none`。允许委托时,任务授权中必须冻结完整执行链和最大跳数。任一新组织、
|
||||
新能力或新数据接收方加入执行链,都需要重新计算授权,不能沿用原任务令牌。
|
||||
|
||||
## 9. 任务协议
|
||||
|
||||
### 9.1 任务快照
|
||||
|
||||
创建任务时冻结:
|
||||
|
||||
- 调用方用户、设备和组织。
|
||||
- 能力 ID、版本和提供者。
|
||||
- 输入 schema 版本、数据类型、数据分类和字节数。
|
||||
- 路由:局域网直连、网关直连、网关中继或联邦路径。
|
||||
- 授权决定、批准记录和策略版本。
|
||||
- 委托链、预算、期限和幂等键。
|
||||
|
||||
设置或策略后续变化不追改已接受任务;紧急撤销可以阻止尚未开始的任务,并按策略取消活动
|
||||
任务。
|
||||
|
||||
### 9.2 状态
|
||||
|
||||
```text
|
||||
created -> awaiting_approval -> queued -> connecting -> running
|
||||
running -> completed | failed | cancelled | outcome_unknown
|
||||
created/awaiting_approval/queued/connecting -> rejected | expired | cancelled
|
||||
```
|
||||
|
||||
- `completed` 只在提供者提交完整终态且调用方确认接收后成立。
|
||||
- 网络中断后无法确认副作用结果时使用 `outcome_unknown`,不得自动重放。
|
||||
- 重试创建新 attempt,并复用仅在能力声明支持时才有效的幂等键。
|
||||
- 用户取消必须传播到实际提供者;未确认取消时不能声称任务已停止。
|
||||
|
||||
### 9.3 数据传输
|
||||
|
||||
- 小型 JSON 输入使用任务通道传输。
|
||||
- 文件使用内容 SHA-256、声明大小和一次性任务对象令牌传输。
|
||||
- 接收端边读边校验大小和摘要,拒绝路径、符号链接和调用方文件名决定的落盘位置。
|
||||
- 任务正文、文件和输出不进入普通网关审计日志。
|
||||
- 中继模式下是否端到端加密由策略快照明确记录;界面必须显示网关能否读取正文。
|
||||
|
||||
## 10. 能力包与更新
|
||||
|
||||
每个 Package 必须提供不可变版本、目标平台和架构、内容大小、SHA-256、发布者签名、权限
|
||||
声明、依赖和兼容范围。安装流程统一为:
|
||||
|
||||
```text
|
||||
取得签名目录 -> 选择不可变目标 -> 下载暂存 -> 校验大小/摘要/签名
|
||||
-> 展示权限差异 -> 批准 -> 安装到 GoodBuddy-owned 目录 -> 验证 -> 启用
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 下载开始后冻结来源,不在失败时静默切换 ShareServer 或镜像。
|
||||
- 包不得包含凭据;配置只携带非敏感结构和密钥引用。
|
||||
- 新版本扩大文件、进程、网络、数据或委托权限时,旧 Grant 不自动继承。
|
||||
- 紧急撤销阻止新安装和新调用;已安装包的停用或删除遵循组织策略并向用户说明。
|
||||
- 复用现有 GoodBuddy 签名工件原则,但不同用途使用独立签名域。
|
||||
|
||||
## 11. 协议分层
|
||||
|
||||
### 11.1 局域网发现层
|
||||
|
||||
- DNS-SD 服务类型:`_goodbuddy-share._tcp.local`。
|
||||
- 广播仅包含协议 major、随机实例 ID、端口和配对状态。
|
||||
- 设备名称、证书和详细版本通过临时加密握手取得。
|
||||
- 不扫描任意端口,不枚举未主动广播的机器。
|
||||
|
||||
### 11.2 ShareServer 控制 API
|
||||
|
||||
使用 HTTPS 提供登录、设备注册、目录、管理和审计 API;使用 WebSocket 建立设备出站控制
|
||||
通道,承载心跳、任务声明、取消和短期数据通道协商。普通管理 API 与设备通道使用不同凭据
|
||||
和受众。
|
||||
|
||||
### 11.3 数据通道
|
||||
|
||||
数据通道可以是配对 TLS 直连、ShareServer WebSocket 中继或经网关协商的端到端加密流。
|
||||
协议只保证有界帧、顺序、取消和终态,不自行实现另一套拥塞控制。
|
||||
|
||||
### 11.4 版本协商
|
||||
|
||||
- 每层独立声明 `major.minor`。
|
||||
- major 不同拒绝连接;minor 使用双方共同支持的最低能力集合。
|
||||
- 未识别字段在声明允许扩展的位置忽略,安全与授权枚举中的未知值一律拒绝。
|
||||
- 联邦只交换双方共同支持的能力和策略表达式,不降低任一方策略。
|
||||
|
||||
## 12. 数据与审计
|
||||
|
||||
### 12.1 最小审计事件
|
||||
|
||||
- 操作者、调用设备和组织。
|
||||
- 动作、能力、版本和提供者。
|
||||
- 路由与是否跨组织。
|
||||
- 输入数据类别、分类、文件数和字节数。
|
||||
- 授权结果、策略 ID 和批准者。
|
||||
- 任务状态、耗时、输出大小和资源用量。
|
||||
- 包下载、安装、更新、撤销和发布变更。
|
||||
|
||||
默认不保存提示正文、文件内容、模型输出、密钥、Cookie、认证头或完整私有路径。需要内容
|
||||
审计的组织必须单独定义保留期限和访问角色,并在调用前向用户显示。
|
||||
|
||||
### 12.2 数据权威
|
||||
|
||||
- 桌面端是本地配对、本机共享偏好和本地活动记录的权威。
|
||||
- ShareServer 是组织成员、设备注册、组织策略、发布目录和组织审计的权威。
|
||||
- 能力提供者是运行中任务及结果是否产生的权威。
|
||||
- 联邦两端各自保存本组织审计,不复制对方完整成员目录或内部策略。
|
||||
|
||||
## 13. 安全边界
|
||||
|
||||
ShareServer 可以部署到公网,因此公网连接、外部组织和服务端多租户是本设计中的实际信任
|
||||
边界。共同要求如下:
|
||||
|
||||
1. 所有远端清单、名称、schema、错误、文件和模型输出都作为不可信数据处理。
|
||||
2. Renderer 不接触设备私钥、网关令牌、任意 socket 或原始文件传输接口。
|
||||
3. Main/Provider 在执行边界重新验证任务,不信任 Renderer 或 ShareServer 代替本机授权。
|
||||
4. ShareServer 按组织隔离查询,搜索和计数接口不能泄露无权查看的设备或能力。
|
||||
5. 设备只建立主动配置的网关出站连接,不接受 ShareServer 发起任意系统调用。
|
||||
6. 局域网发现不等于信任,组织成员身份也不等于所有能力授权。
|
||||
7. 远程能力不能扩大本机 Ask/Execute、Workspace、Runtime、Skill 或 MCP 现有权限。
|
||||
8. 密钥只进入对应安全存储,诊断和审计使用固定字段与脱敏错误码。
|
||||
9. 限制握手、清单、帧、文件、并发、速率、任务时长和审计查询大小。
|
||||
10. 撤销设备、用户、Grant、Publication、Package 或 FederationTrust 后停止签发新令牌。
|
||||
|
||||
## 14. 建议领域实体
|
||||
|
||||
```text
|
||||
DeviceIdentity
|
||||
PeerPairing
|
||||
GatewayConnection
|
||||
Organization
|
||||
Membership
|
||||
Group
|
||||
RegisteredDevice
|
||||
Capability
|
||||
CapabilityVersion
|
||||
Publication
|
||||
Grant
|
||||
Policy
|
||||
Approval
|
||||
Task
|
||||
TaskAttempt
|
||||
ArtifactTransfer
|
||||
Package
|
||||
PackageRelease
|
||||
FederationTrust
|
||||
AuditEvent
|
||||
Revocation
|
||||
```
|
||||
|
||||
共享协议只定义跨端 DTO 和状态,不规定桌面 SQLite 与 ShareServer 数据库使用相同表结构。
|
||||
|
||||
## 15. 仓库边界
|
||||
|
||||
当前仓库边界(跨仓库文档链接约定两个仓库克隆到同一级目录):
|
||||
|
||||
```text
|
||||
goodbuddy/
|
||||
docs/architecture/share-network-architecture.md
|
||||
docs/features/device-sharing/
|
||||
src/ # GoodBuddy Desktop
|
||||
goodbuddy-shareserver/
|
||||
docs/features/share-server/
|
||||
server/ # 独立 Hono Web 服务
|
||||
shared/ # 当前原型 fixture,不是跨端协议
|
||||
src/ # React 管理前端
|
||||
```
|
||||
|
||||
ShareServer 源码和服务端文档已迁入独立仓库:
|
||||
`ssh://git@git.digiman.live:11022/root/goodbuddy-shareserver.git`。
|
||||
当前服务是交互原型,尚未提供真实 Desktop 集成。共享网络总体设计仍由本文统一维护。
|
||||
|
||||
ShareServer 不反向依赖桌面端 `src/shared`,构建和运行不要求桌面仓库存在。
|
||||
真实跨端协议实现时双方依赖独立 `share-protocol` 包,具体仓库归属和发布方式届时确定,
|
||||
不提前创建空包。协议包不得包含数据库模型、Electron API、服务实现或凭据。
|
||||
|
||||
## 16. 共同验收标准
|
||||
|
||||
- [ ] 不配置 ShareServer 时,两台桌面端可通过局域网发现、双端配对、查看获准能力并调用。
|
||||
- [ ] 关闭“允许被发现”不会关闭主动发现;关闭主动发现不会撤销既有配对。
|
||||
- [ ] 本机没有明确 Publication 时,任何 Peer 或组织成员都不能查看或调用本机能力。
|
||||
- [ ] 桌面端可以连接多个 ShareServer,任一连接故障不影响局域网和其他连接。
|
||||
- [ ] ShareServer 只展示当前用户、设备和组织获准查看的目录。
|
||||
- [ ] 每次远程调用都能说明提供者、执行位置、发送数据、路由、授权依据和委托链。
|
||||
- [ ] 网络中断且副作用结果不确定时进入 `outcome_unknown`,不会自动重放。
|
||||
- [ ] 能力包经大小、SHA-256、签名、兼容和权限差异校验后才能安装。
|
||||
- [ ] 跨组织调用同时满足两端策略;联邦撤销后不能创建新任务。
|
||||
- [ ] 审计默认只保存必要元数据,不保存正文、文件、密钥或认证信息。
|
||||
- [ ] ShareServer 可独立部署和升级,不由 Electron 启动,也不影响桌面端离线使用。
|
||||
|
||||
## 17. 相关设计
|
||||
|
||||
- [设备发现与共享 PRD](../features/device-sharing/prd.md)
|
||||
- [设备发现与共享功能逻辑](../features/device-sharing/logic-design.md)
|
||||
- [设备发现与共享 UI](../features/device-sharing/ui-design.md)
|
||||
- [设备发现与共享技术设计](../features/device-sharing/technical-design.md)
|
||||
- [ShareServer PRD](../../../goodbuddy-shareserver/docs/features/share-server/prd.md)
|
||||
- [ShareServer 功能逻辑](../../../goodbuddy-shareserver/docs/features/share-server/logic-design.md)
|
||||
- [ShareServer 管理界面](../../../goodbuddy-shareserver/docs/features/share-server/ui-design.md)
|
||||
- [ShareServer 技术设计](../../../goodbuddy-shareserver/docs/features/share-server/technical-design.md)
|
||||
- [SSH 远程主机与 Agent](../features/remote-host/README.md)
|
||||
- [GoodBuddy 统一界面设计系统](../../UI-DESIGN.md)
|
||||
@@ -50,9 +50,9 @@ below.
|
||||
optional: users explicitly download a compatible compound `.gbagent` package
|
||||
from Settings or import one offline before GoodBuddy can install or update a
|
||||
Host.
|
||||
- Each independently published Linux x64 or arm64 `.gbagent` contains the
|
||||
- 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 both architectures on native GitHub Actions runners from one
|
||||
Runtime. Build all three targets on native GitHub Actions runners from one
|
||||
immutable `agent-v<agentVersion>` tag.
|
||||
- Keep the GoodBuddy Agent source, shared protocol/contracts, runtime lock,
|
||||
bundle tooling, and tests in this repository so a desktop commit identifies
|
||||
@@ -66,7 +66,9 @@ below.
|
||||
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 checked-in public key registry. The Darwin arm64 target uses `macos-15`
|
||||
and the same production locks. Native CI checks do not replace real Host
|
||||
installation, Attach, Ask/Execute, and lifecycle validation during development.
|
||||
- `.github/workflows/agent-release.yml` is the only production compound Agent
|
||||
publication path. It requires an annotated immutable Agent tag, the protected
|
||||
`agent-signing` Environment, native x64/arm64 builds, one production
|
||||
@@ -98,11 +100,18 @@ 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.
|
||||
- The tagged commit must be reachable from protected `main`. The two native
|
||||
jobs build the compound x64/arm64 packages from the same source and locks;
|
||||
- The tagged commit must be reachable from protected `main`. The three native
|
||||
jobs build Linux x64, Linux arm64, and Darwin arm64 packages from the same source and locks;
|
||||
the catalog job rejects missing architectures, changed bytes for an existing
|
||||
version/architecture identity, invalid prior signatures, and incompatible
|
||||
version/platform/architecture identity, invalid prior signatures, and incompatible
|
||||
matrix metadata.
|
||||
- All three targets share the existing signed `agent-catalog.json`. Before the
|
||||
first mixed-platform catalog is published, release the Desktop reader that
|
||||
accepts Darwin entries and document the required Desktop upgrade. Older
|
||||
Desktop readers reject the entire mixed catalog and cannot check online Agent
|
||||
updates; preserve their locally verified packages and immutable historical
|
||||
releases. Do not introduce a second catalog or silently weaken package
|
||||
signature and integrity verification.
|
||||
- Agent GitHub Releases are ordinary non-draft, non-prerelease releases but
|
||||
must use `--latest=false`; they must never replace the desktop release marked
|
||||
Latest. Publish immutable packages and the versioned catalog to
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
| --- | --- |
|
||||
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
|
||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 |
|
||||
| [实现与验证进度](./progress.md) | 工作区改动的验证证据与剩余阻塞 |
|
||||
|
||||
当前尚未拆出独立 User Stories、功能逻辑、UI 设计、技术设计和进度文档;相关内容仍保留在
|
||||
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
|
||||
两份现有 PRD 中,后续实际维护该功能时按职责拆分。
|
||||
|
||||
@@ -360,12 +360,28 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
|
||||
面板提供:
|
||||
|
||||
- 有界目录树和文本文件预览。
|
||||
- 有界目录树和分页文本文件预览;文件超过单页传输大小时先显示已读取内容,并允许用户继续加载后续内容,不因文件总大小超过单页上限而拒绝预览。
|
||||
- 当前选择、规范化路径和执行空间。
|
||||
- 可选 Git 状态、Diff 和仓库信息。
|
||||
- 显式打开、下载副本或在终端中打开。
|
||||
- HTML 文件的源码与安全预览。
|
||||
|
||||
当前项目目录的文件浏览与 Git 交互遵循以下规则:
|
||||
|
||||
- 页签下不重复显示“项目工作区”标题,保留刷新按钮。
|
||||
- 未提交更改以彩色 Git 字母标识状态:M 修改、A 新增、D 删除、R 重命名、C 复制、
|
||||
U 未跟踪或冲突、T 类型变化;冲突用危险色区别于未跟踪文件,同时保留原始状态。
|
||||
- 点击更改项进入该文件 Diff,分别显示已暂存和未暂存差异,增删行使用对应状态色;
|
||||
删除文件也可查看。目录树中的文件仍打开内容预览。不再提供底部全仓库 Diff 入口。
|
||||
- 手动刷新或任务结束更新 Git 状态时,同步重读根目录和已展开目录,保留目录展开状态,
|
||||
清除未展开目录的旧缓存;正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。
|
||||
无首次提交的仓库也应显示新增和暂存文件。
|
||||
- 未提交更改首批显示 50 项,可继续分批加载其余变更,不能使后续文件尤其是已删除
|
||||
文件失去 Diff 入口。
|
||||
- 系统默认应用和资源管理器打开按钮仅用于本机项目,SSH 项目不显示这两个按钮。
|
||||
|
||||
目录切换、下载副本、终端打开及 HTML 安全预览仍属于后续能力,不能据此视为已实现。
|
||||
|
||||
本机和远程访问都必须由 Main 或远程 GoodBuddy Agent 在对应文件系统上执行路径规范化、相对路径和
|
||||
符号链接边界检查。Renderer 只能提交受约束的相对路径和已授权范围 ID。
|
||||
|
||||
@@ -756,7 +772,10 @@ remote_operations
|
||||
终端、进程与 Runtime;无法确认时标记 `interrupted` 或 `outcome_unknown`。
|
||||
- 日志使用有界环形缓冲或分页持久化,不能无限写入 SQLite。
|
||||
- Artifact 继续作为成果的权威实体,不把完整成果复制进工作栏状态。
|
||||
- 当前 Renderer `localStorage` 活动记录不能作为 Runtime、监督或进程的权威来源。
|
||||
- 运行记录由 Main SQLite 持久化,Renderer 只保留当前内存视图。升级时迁移旧
|
||||
`localStorage` 记录;若旧缓存达到原数量、详情或总大小边界,页面持续说明更早记录或
|
||||
部分详情可能已经丢失。新记录不再按固定活动数或详情字符数丢弃,长列表只做可继续展开的
|
||||
分批渲染。
|
||||
|
||||
### 11.3 IPC 与 Preload
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-06 项目工作区
|
||||
|
||||
范围见 [PRD 工作区](./prd.md#76-工作区)。
|
||||
|
||||
当前源码已移除重复标题与底部完整 Diff 入口,使用彩色 Git 状态字母,点击更改项读取
|
||||
单文件 Diff。已暂存与未暂存差异分开展示;本机 Git 测试覆盖无首次提交、未跟踪、
|
||||
暂存后继续修改、重命名、删除及包含方括号的路径。Git 刷新驱动目录树重新读取,保留
|
||||
展开状态;SSH 项目不提供本机系统打开按钮。审查后补齐了当前 Diff 随刷新重读、
|
||||
旧请求结果失效,以及超过 50 项变更的继续加载入口。
|
||||
|
||||
验证记录:
|
||||
|
||||
- 最终 `npm run typecheck`、`npm run lint`、`npm run build` 均通过。
|
||||
- 工作区专项 7 个测试文件复跑:317 项通过,3 项 Linux 专用测试在 Windows 跳过。
|
||||
任务完成测试同时检查目录树出现新文件。
|
||||
- 最终 `npm test`:326 个测试文件、3552 项测试通过,9 个文件、58 项测试按既有条件
|
||||
跳过。复核首轮曾有一项浏览器地址草稿测试超时,单独复跑及最终全量复跑均通过;
|
||||
更早的超时/失败记录不再作为最终验收结果。
|
||||
- 后续复核已通过既有凭据和固定 Host Key 经 VPN 连接共享 Linux x64 Host,使用当前
|
||||
源码构建的隔离 Agent 包安装并启动。真实 `RemoteWorkspaceAccess → Agent git/status`
|
||||
与 `git/diff` 路径覆盖暂存后继续修改、删除、重命名、未跟踪和方括号路径;均通过。
|
||||
Git 验证未调用模型。
|
||||
- Windows 隔离桌面实例已通过真实点击验收:当前项目单文件暂存/工作树 Diff 分组与
|
||||
增删颜色正确,修改测试文件后点击刷新可更新正在查看的 Diff,53 项变更经加载更多
|
||||
全部可访问。截图留在本地验证目录,不纳入产品包。
|
||||
|
||||
目录分页、任意目录切换和 HTML 预览不在本轮改动范围内。
|
||||
@@ -147,6 +147,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
|
||||
- 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、
|
||||
工作目录和 Shell。用户可以重命名终端。
|
||||
- 当前 Tab 同时使用连续内容表面、选中边框、顶部强调线、图标和较高字重,不能只靠文字颜色。
|
||||
- Tab、关闭按钮和“+”入口沿用共享控件圆角;终端面板外框和终端顶部工具栏按钮使用直角,
|
||||
保持紧凑的停靠式终端界面。
|
||||
- Tab 关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘焦点进入对应 Tab 时出现;
|
||||
粗指针或无悬停设备始终显示,出现与隐藏不得引发布局位移。
|
||||
|
||||
@@ -237,7 +239,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
|
||||
|
||||
- 真实 TTY 输入与 ANSI 输出。
|
||||
- 中文输入法、组合字符和常规键盘快捷键。
|
||||
- 复制、粘贴、全选、搜索、清屏和滚动。
|
||||
- 复制、粘贴、全选、搜索、清屏和滚动。复制与粘贴通过桌面 Main 进程访问系统文本剪贴板,
|
||||
不依赖 Renderer 的浏览器剪贴板权限。
|
||||
- 工作栏宽度或窗口尺寸变化后的 PTY resize。
|
||||
- 终端重命名。
|
||||
- 显式关闭、退出后重新打开和远程中断后重新连接。
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
| --- | --- |
|
||||
| 文档类型 | 实施进度 |
|
||||
| 状态 | 持续更新 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-08-18 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 适用能力 | 内置浏览器与客户端电脑控制 |
|
||||
|
||||
## 范围
|
||||
@@ -17,13 +17,14 @@ Linux x64 和 Linux arm64。
|
||||
|
||||
## 已完成
|
||||
|
||||
### 直连模型与浏览器
|
||||
### Runtime 与浏览器
|
||||
|
||||
- 已实现宿主进程拥有的隔离浏览器服务。
|
||||
- 使用 Electron 自带 Chromium 和 CDP,不下载额外浏览器。
|
||||
- 已实现导航、可访问性快照、点击、输入、选择、返回和有界截图工具。
|
||||
- 浏览器工具只在直连模型的 Execute 模式中提供;用户选择 Execute 即授权
|
||||
本次交互运行,不再逐个弹出 GoodBuddy 工具审批。
|
||||
- 浏览器工具作为 GoodBuddy 内置能力,可分别分配给直连模型、GoodBuddy 管理的
|
||||
OpenCode 和 Continue;只在 Execute 模式中提供。用户选择 Execute 即授权本次
|
||||
运行,不再逐个弹出 GoodBuddy 工具审批。
|
||||
- 支持当前设备可连接的 HTTP、HTTPS、公网、内网、本机和元数据地址,
|
||||
导航及重定向仍经过 DNS 解析和目标地址固定。
|
||||
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
|
||||
@@ -59,11 +60,14 @@ Linux x64 和 Linux arm64。
|
||||
|
||||
### 设置与安全边界
|
||||
|
||||
- 能力存储已迁移到版本 2。
|
||||
- 内置浏览器已归入“直连模型”工具,与联网搜索并列,并明确说明不会控制
|
||||
客户端已安装的浏览器;原有开关和配置 ID 保持不变,用户状态无需迁移。
|
||||
- 用户通过内置浏览器总开关决定是否向直连模型提供工具;开启后可在 Execute
|
||||
模式直接使用,不逐次询问。诊断位于同一分类。
|
||||
- 能力存储已迁移到版本 6。版本 2 至 5 中原有的浏览器启停状态会迁移到
|
||||
`builtin-browser`,已有启用状态不丢失;默认分配给直连模型、GoodBuddy 管理的
|
||||
OpenCode 和 Continue。
|
||||
- 内置浏览器位于“内置 MCP”,与知识库、笔记和 GoodBuddy 配置一样提供独立启停、
|
||||
Runtime 分配和工具清单;DeepSeek Harness 持续显示为不支持。
|
||||
- 浏览器开关只控制 Agent 工具访问。关闭后,任何 Runtime 都不会获得浏览器工具,
|
||||
但用户仍可在浏览器工作栏中手动前往、返回、刷新、停止加载、交互和关闭浏览器。
|
||||
开启后,已分配 Runtime 可在 Execute 模式直接使用,不逐次询问。
|
||||
- 尚未实际参与浏览器执行的命名配置已从界面隐藏;底层旧数据和兼容接口继续
|
||||
保留,不删除用户已有记录。
|
||||
- “电脑控制”只显示实际操作客户端电脑的能力。能力启用、配置变更和本地
|
||||
@@ -110,9 +114,9 @@ Linux x64 和 Linux arm64。
|
||||
“停止加载”中断。停止加载只取消页面加载,不关闭浏览器,也不释放 Runtime 上下文;原“停止
|
||||
浏览器”会话生命周期操作已统一标为“关闭浏览器”,并在视觉和文案上与停止加载清楚区分。
|
||||
|
||||
活动 Conversation 尚无浏览器会话时,可直接从地址栏输入 URL 并“前往”,通过现有浏览器
|
||||
能力检查和同一 URL 策略创建会话。实现复用现有每 Conversation 串行队列和有界状态,不引入
|
||||
恢复、版本化或第二套会话框架。
|
||||
活动 Conversation 尚无浏览器会话时,可直接从地址栏输入 URL 并“前往”,通过浏览器组件
|
||||
可用性检查和同一 URL 策略创建会话。该手动路径不读取 Agent 浏览器开关。实现复用现有每
|
||||
Conversation 串行队列和有界状态,不引入恢复、版本化或第二套会话框架。
|
||||
|
||||
### P0:百度输入时元素引用失效,已修复
|
||||
|
||||
@@ -161,16 +165,26 @@ Linux x64 和 Linux arm64。
|
||||
|
||||
## 下一步顺序
|
||||
|
||||
1. 在便携版真实模型流程中继续验证返回、取消和右侧画面刷新。
|
||||
1. 在便携版真实模型流程中继续验证 OpenCode、Continue 的导航、返回、取消和右侧画面刷新。
|
||||
2. 再设计命名隔离 Profile 的持久登录态、引用和清理策略。
|
||||
3. 分别实现并真机验证 Windows UIA、macOS AX 和 Linux 原生适配器。
|
||||
|
||||
## 最近验证
|
||||
|
||||
- `npm test`:591 项通过,6 项跳过。
|
||||
- `npm test`:3496 项通过,53 项跳过。
|
||||
- `npm run typecheck`:通过。
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过。
|
||||
- `GOODBUDDY_RUN_RUNTIME_E2E=1 ... -t "calls the assigned built-in browser
|
||||
through bundled OpenCode"`:通过,真实模型上报 2 次模型调用,OpenCode 经请求级
|
||||
GoodBuddy MCP 调用 `browser_navigate` 并保留正确 Conversation 归属。
|
||||
- `GOODBUDDY_RUN_RUNTIME_E2E=1 ... -t "calls the assigned built-in browser
|
||||
through bundled Continue"`:通过,Continue 经同一路径调用 `browser_navigate` 并
|
||||
返回预期结果;当前 Continue Host 为该完整工具轮次上报 1 条聚合模型用量事件。
|
||||
- 聚焦的能力迁移、网关、IPC、设置界面测试共 234 项通过。其中覆盖 Agent 浏览器
|
||||
开关关闭时用户手动浏览器操作仍可用。
|
||||
|
||||
以上自动化结果覆盖协议、安全边界和模拟驱动,不替代真实 Electron
|
||||
BrowserWindow/WebContentsView、目标网站和目标 Linux 桌面环境的集成验证。
|
||||
本轮曾尝试用隔离用户目录启动第二个 Electron 实例,但当前已运行的正式 GoodBuddy
|
||||
单实例锁阻止了该实例继续运行;未关闭或附着用户正在使用的正式实例。
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
## 1. 背景
|
||||
|
||||
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。其长期“未来分区记忆”
|
||||
方向尚未设计,也不承担持续学习、模式挖掘或自动改进:
|
||||
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。它与分区记忆的协同尚未
|
||||
实施,也不承担持续学习、模式挖掘或自动改进:
|
||||
|
||||
- 候选是否改善未来行为没有评估。
|
||||
- 一条反思是否会被检索和使用并不确定。
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 会话 HTML 渲染
|
||||
|
||||
会话 HTML 渲染让用户直接查看 Agent 回复中的网页结果,而不必先复制代码到外部文件。
|
||||
该能力默认开启,也可以在“设置 > 平台功能 > 通用设置”中关闭。
|
||||
|
||||
## 功能边界
|
||||
|
||||
- 只渲染已完成的 Agent 正文和已完成的子 Agent 输出。
|
||||
- 支持完整 HTML 文档、单一 HTML 根元素,以及 Markdown 中的 `html` / `htm` 代码块。
|
||||
- 使用无脚本、无网络、无表单提交和无窗口操作权限的静态隔离预览。
|
||||
- 每个预览提供源码切换和全屏预览两个图标操作;全屏预览沿用同一清理和隔离边界。
|
||||
- 用户消息、推理过程和流式中的回复不执行 HTML;关闭功能后继续显示 HTML 源码。
|
||||
- 本功能不把普通聊天回复复制为成果,也不替代助手工作栏中独立成果的预览流程。
|
||||
|
||||
## 文档
|
||||
|
||||
- [产品需求](./prd.md):功能范围、状态规则和验收标准。
|
||||
- [技术设计](./technical-design.md):设置持久化、渲染边界与静态隔离方案。
|
||||
- [实现进度](./progress.md):已验证实现和验证记录。
|
||||
|
||||
## 术语
|
||||
|
||||
- **HTML 源码**:Agent 回复中原始、可复制的 HTML 文本。
|
||||
- **静态预览**:在隔离 `iframe` 中显示的 HTML;脚本、联网和外部操作均被禁用。
|
||||
- **全屏预览**:在应用级 Modal 中显示同一份静态预览文档,用于查看完整页面布局。
|
||||
|
||||
全局聊天和设置界面规则由根目录的
|
||||
[统一界面设计系统](../../../UI-DESIGN.md)定义。独立成果预览仍由
|
||||
[助手工作栏](../assistant-workbar/README.md)负责。
|
||||
@@ -0,0 +1,69 @@
|
||||
# 会话 HTML 渲染产品需求
|
||||
|
||||
## 用户问题
|
||||
|
||||
Agent 可以生成网页、报告和交互原型的 HTML,但此前会话只能显示源码或忽略原始标签。
|
||||
用户必须把内容复制到外部文件后才能查看结果,打断了会话内的阅读和迭代流程。
|
||||
|
||||
## 目标
|
||||
|
||||
- 让 Agent 生成的 HTML 在回复完成后直接显示为网页预览。
|
||||
- 保留一键查看原始 HTML 的能力。
|
||||
- 允许用户通过平台功能开关恢复为只看源码。
|
||||
- 预览不取得 GoodBuddy Renderer 或外部网络能力。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不执行 Agent 提供的 JavaScript。
|
||||
- 不提供联网网页、表单、弹窗或 Electron `webview`。
|
||||
- 不在流式输出中反复加载不完整页面。
|
||||
- 不自动把普通会话回复保存为成果。
|
||||
|
||||
## 功能需求
|
||||
|
||||
### FR-1 默认设置
|
||||
|
||||
“会话 HTML 渲染”是应用级持久设置,默认开启。用户可在“设置 > 平台功能 >
|
||||
通用设置”中立即关闭或重新开启;保存失败时保留最后确认的状态并显示错误。
|
||||
|
||||
### FR-2 可识别输出
|
||||
|
||||
开启后,已完成 Agent 回复中的以下内容显示为静态预览:
|
||||
|
||||
1. 完整 HTML 文档。
|
||||
2. 由单一 HTML 根元素构成的完整回复。
|
||||
3. Markdown 中标记为 `html` 或 `htm` 的代码块。
|
||||
|
||||
普通 Markdown 继续按原有规则显示。一个回复中的多个 HTML 代码块各自保持原有位置。
|
||||
|
||||
### FR-3 回复状态和角色
|
||||
|
||||
- 已完成的主 Agent 正文和已完成的子 Agent 输出可以预览 HTML。
|
||||
- 用户消息和 Agent 推理只显示文本或源码。
|
||||
- 流式 Agent 正文在完成前显示源码,完成后原位切换为预览。
|
||||
|
||||
### FR-4 源码、全屏与关闭行为
|
||||
|
||||
每个预览持续显示“静态安全预览,脚本和网络已禁用”,并在标题右侧提供两个图标操作:
|
||||
|
||||
1. 源码按钮,就地展开或收起原始 HTML。
|
||||
2. 全屏按钮,在应用级 Modal 中查看同一份预览文档。
|
||||
|
||||
嵌入预览使用固定高度并在框内滚动,需要更大画面时使用全屏预览。全屏预览可通过关闭
|
||||
按钮、Escape 或点击遮罩退出,退出后焦点回到全屏按钮。关闭平台功能后,完整 HTML 回复
|
||||
和 HTML 代码块继续显示源码,不能变成空白。
|
||||
|
||||
### FR-5 静态隔离
|
||||
|
||||
预览必须禁用脚本、网络请求、表单提交、嵌套页面、插件对象和窗口操作。远程链接和远程
|
||||
媒体不得从预览加载;内联样式和有界的内联位图可用于还原静态视觉。
|
||||
|
||||
## 产品验收
|
||||
|
||||
- 新安装和从旧设置升级后,开关均为开启。
|
||||
- 完整 HTML 和 fenced HTML 在已完成的 Agent 回复中显示预览。
|
||||
- 关闭开关后,当前会话立即恢复显示源码,重启后选择仍保留。
|
||||
- 流式回复、推理和用户消息从不执行 HTML。
|
||||
- 预览可通过键盘访问源码和全屏按钮,并具有明确的 iframe 标题。
|
||||
- 全屏预览隔离背景、初始聚焦关闭按钮,Escape 退出后焦点回到全屏按钮。
|
||||
- 恶意脚本、事件属性、远程资源、表单和嵌套 frame 不进入可执行预览。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 会话 HTML 渲染实现进度
|
||||
|
||||
## 已实现
|
||||
|
||||
- [x] 应用级持久开关,默认开启,并提供旧设置迁移。
|
||||
- [x] 平台功能通用设置中的共享 Switch。
|
||||
- [x] 已完成 Agent 回复和子 Agent 输出中的完整 HTML 与 fenced HTML 识别。
|
||||
- [x] 隔离静态预览、持续安全说明和源码切换。
|
||||
- [x] 预览保留作者 `<style>` 与 `style` 属性,样式不再被整块丢弃。
|
||||
- [x] 与 Mermaid 一致的图标按钮组:源码切换与全屏预览 Modal。
|
||||
- [x] 嵌入预览固定 480px 高度,源码面板同值上限。
|
||||
- [x] 用户消息、推理、流式回复和关闭状态的源码边界。
|
||||
- [x] 设置变更后当前会话立即重新渲染。
|
||||
|
||||
## 验证记录
|
||||
|
||||
2026-09-05:
|
||||
|
||||
- `npm run typecheck`:通过。
|
||||
- 聚焦测试(应用设置、Markdown、会话时间线、设置中心、App 集成):306 项通过。
|
||||
- 全量 `npm test`:`3493 passed, 55 skipped`。
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:Main、Preload、Renderer 与控制面安装器均成功构建。
|
||||
|
||||
修复样式丢失后复验:
|
||||
|
||||
- 原实现只要 `<style>` 或 `style` 属性中出现 `@import` / `url()` 就整块删除,带渐变或
|
||||
背景图的回复因此完全失去样式;现改为保留作者样式,外部资源加载仍由 iframe CSP 拒绝。
|
||||
- `npx vitest run src/renderer/src/MarkdownRenderer.test.tsx`:`19 passed`。
|
||||
- 聚焦测试(会话时间线、App 集成、设置中心):`260 passed`。
|
||||
- `npm run typecheck`、`npm run lint`:通过。
|
||||
|
||||
新增全屏预览与图标化源码按钮后复验:
|
||||
|
||||
- 源码按钮改为代码图标并暴露 `aria-expanded`;新增放大图标打开全屏 Modal。
|
||||
- 嵌入预览高度由 `min-height: 320px` 改为固定 `height: 480px`,源码面板上限同步为 480px。
|
||||
- 新增回归测试覆盖全屏 Modal 的初始焦点、背景 inert、Sandbox/CSP 与 Escape 焦点恢复。
|
||||
- 聚焦测试(Markdown、会话时间线、App 集成、设置中心):`280 passed`。
|
||||
- `npm run typecheck`、`npm run lint`:通过。
|
||||
@@ -0,0 +1,68 @@
|
||||
# 会话 HTML 渲染技术设计
|
||||
|
||||
## 进程和设置边界
|
||||
|
||||
`conversationHtmlRenderingEnabled` 属于现有 `ApplicationSettings`。Main 继续通过
|
||||
版本化 JSON 文件持久化应用设置,IPC 继续复用已验证的
|
||||
`settings:application:get/update` 通道,Preload 只暴露类型化设置方法。
|
||||
|
||||
应用设置格式升级到版本 11。版本 10 及更早设置在读取时补入默认值 `true`,无需保留第二份
|
||||
状态或独立迁移文件。
|
||||
|
||||
## Renderer 数据流
|
||||
|
||||
1. `App` 启动时读取应用设置,并把当前值传给每个保活的会话视图。
|
||||
2. 平台功能 Switch 保存成功后立即更新 `App` 状态。
|
||||
3. `ChatTimeline` 只为 `role === "assistant"` 且 `state === "complete"` 的正文传入
|
||||
HTML 渲染权限;推理始终不传入该权限。
|
||||
4. `MarkdownRenderer` 识别完整 HTML 回复以及 `html` / `htm` fenced code。
|
||||
5. 关闭权限或回复仍在流式阶段时,HTML 保持代码文本。
|
||||
|
||||
## 静态预览
|
||||
|
||||
`StaticHtmlPreview` 不使用 `dangerouslySetInnerHTML`。它先用 DOMPurify 清理文档,再通过
|
||||
`DOMParser` 移除事件属性、非锚点 `href` 和非内联图片的 `src` / `poster`,最后把结果写入
|
||||
`<iframe sandbox="" srcDoc>`。
|
||||
|
||||
作者样式必须保留:`<style>` 元素和 `style` 属性原样传入预览,不按 `@import` 或 `url()`
|
||||
关键字整块删除,否则任何带渐变或背景图的回复都会退化成无样式文档。外部样式表和远程
|
||||
字体、图片的实际加载由 iframe 文档的 CSP 拒绝,不需要在清理阶段重复拦截。
|
||||
|
||||
iframe 文档注入严格 CSP:
|
||||
|
||||
- `default-src 'none'`
|
||||
- `script-src 'none'`
|
||||
- `connect-src 'none'`
|
||||
- `frame-src 'none'`
|
||||
- `object-src 'none'`
|
||||
- `form-action 'none'`
|
||||
- 只允许内联样式以及 `data:` 字体和受支持的内联位图
|
||||
|
||||
同时设置 `referrerPolicy="no-referrer"`。Sandbox 不授予 scripts、forms、popups、
|
||||
same-origin 或 navigation 权限。预览只在回复完成后创建,并使用 lazy loading。
|
||||
|
||||
## UI 与无障碍
|
||||
|
||||
预览沿用聊天阅读宽度、语义颜色和共享 `icon-button`。`figure` / `figcaption` 表达预览及其
|
||||
说明,iframe 具有本地化标题。标题右侧是一个 `role="group"` 图标按钮组,与 Mermaid 图表
|
||||
操作保持一致:源码按钮暴露 `aria-expanded` 与 `aria-controls`,全屏按钮打开 Modal。
|
||||
|
||||
嵌入预览高度固定为 480px,超出部分在框内滚动;查看源码面板使用同一数值作为最大高度。
|
||||
iframe 无法按内容自适应高度,因为 `sandbox=""` 不含 `allow-same-origin` 或 `allow-scripts`,
|
||||
主文档既读不到内部文档尺寸,也不能接收上报;全屏预览覆盖需要更多空间的场景。
|
||||
|
||||
全屏预览复用 `dialog-focus` 的 `activateModalFocus` 与 `trapTabFocus`,遵循统一 Modal 的
|
||||
背景 inert、初始焦点落在关闭按钮、Tab 循环、Escape 关闭和关闭后焦点恢复。它渲染的是与
|
||||
嵌入预览完全相同的清理后文档,不额外放宽 Sandbox 或 CSP。切换回复时,源码面板与全屏
|
||||
预览按当前 source 派生开合状态,不使用会引发级联渲染的重置 effect。
|
||||
|
||||
## 验证范围
|
||||
|
||||
- 应用设置默认值、关闭持久化和版本 10 迁移。
|
||||
- fenced HTML、完整 HTML、关闭后的源码回退。
|
||||
- 作者 `<style>` 与 `style` 属性在预览中保留。
|
||||
- 脚本、远程资源、表单和事件属性清理,以及 iframe Sandbox/CSP。
|
||||
- 源码按钮的 `aria-expanded` 切换,以及全屏预览的初始焦点、背景 inert、Escape 关闭和
|
||||
焦点恢复。
|
||||
- 主 Agent、子 Agent、用户、推理和流式状态边界。
|
||||
- 从设置 Switch 到当前会话重新渲染的应用级集成路径。
|
||||
@@ -254,7 +254,9 @@ Store 是否已经提交为准,确定性完成新目录或恢复旧目录,
|
||||
| `goodbuddy/native/snapshot` | Main → Control Plane | 从无 Agent scope 的 Host Registry 读取有界的原生 Tool/Skill 元数据,排除 GoodBuddy 分配项与请求级代理 |
|
||||
| `goodbuddy/shutdown` | Main → Control Plane | 停止接收新请求并有序清理 |
|
||||
|
||||
Utility 启动控制协议使用版本 2,严格携带 `supportsImageInput` 与固定 8 MiB 帧上限;版本 1 或缺少该字段的启动消息失败关闭,不能让 Host 自行猜测模型能力。扩展版本独立于 ACP 版本。握手响应至少包含:
|
||||
Utility 启动控制协议使用版本 3,严格携带 `supportsImageInput`、模型连接的有界自定义
|
||||
Header 与固定 8 MiB 帧上限;旧版本或缺少必需字段的启动消息失败关闭,不能让 Host
|
||||
自行猜测模型能力。扩展版本独立于 ACP 版本。握手响应至少包含:
|
||||
|
||||
```ts
|
||||
type GoodBuddyHarnessCapabilities = {
|
||||
@@ -336,7 +338,7 @@ GoodBuddy conversationId -> Harness sessionId + process generation
|
||||
|
||||
### 9.4 释放与退出
|
||||
|
||||
- 原生能力清单通过一次性 Runtime 探测,取得有界快照后立即 dispose,不得因浏览设置或切换项目把 Host 缓存在执行 Runtime 池中。
|
||||
- 原生能力清单由 `SelectedRuntimeManager` 缓存的执行 Runtime 提供,与随后的对话请求共用同一实例,不再为清单单独启动并销毁一次 Host;该实例遵循执行缓存的空闲淘汰、设置变更 reset 和退出 dispose 规则。
|
||||
- 删除或释放对话时调用 `goodbuddy/session/release`。
|
||||
- Runtime dispose 时先拒绝新请求,再取消所有 Session。
|
||||
- Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。
|
||||
@@ -431,6 +433,9 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
|
||||
- 模型名称不限制为 DeepSeek 品牌,由所选 OpenAI 兼容服务决定。
|
||||
- 模型名称和服务地址由 Main 传入受控 Host。
|
||||
- 图片能力只读取所选 GoodBuddy 模型连接的 `supportsImageInput`;Main、Utility 启动配置、ACP 能力和 Pi-AI 模型输入模态必须使用同一个布尔值。
|
||||
- 模型连接的自定义 Header 通过 Utility 启动配置交给 Pi-AI Provider;Pi-AI 没有受支持的
|
||||
任意 Body 配置入口,因此 DeepSeek Harness 不接收自定义 Body。完整支持矩阵和合并规则
|
||||
见[模型连接请求定制](../model-connections/technical-design.md)。
|
||||
- API Key 继续保存在 GoodBuddy 加密设置中。
|
||||
- 启动环境提供的部署连接只由 Main 自动解析,不在 Renderer 中显示为可选来源。
|
||||
- 未显式指定 Harness 模型且没有可用的管理员预置连接时,Main 优先使用当前默认的兼容 GoodBuddy 模型连接;默认连接不兼容时使用首个兼容连接。只有不存在任何兼容连接时才提示用户前往模型或 Runtime 设置。
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 桌面宠物
|
||||
|
||||
桌面宠物是 GoodBuddy 桌面端一个可选的常驻悬浮入口,用于随时发起提问、暂存文件或文字,
|
||||
并直观查看当前任务状态。桌面宠物不建立新的对话、任务或执行体系:它只是现有 Conversation、
|
||||
Task 和 Runtime 能力的一个轻量触发面和状态镜面。
|
||||
|
||||
## 核心边界
|
||||
|
||||
- 桌面宠物**不**新增 Ask/Execute 执行通道,也不在自身窗口内直接展示模型回复。快速发起
|
||||
和拖放暂存的内容始终交给主窗口现有输入区确认后发送,从不自动发送或自动执行。
|
||||
- 桌面宠物**不**持续读取屏幕内容、剪贴板或全局选中文本。
|
||||
- 桌面宠物显示的任务状态、通知和历史记录均来自现有 Task/Conversation 数据,不建立第二套
|
||||
存储或索引。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [PRD](./prd.md) | 用户问题、范围、功能需求和产品验收 |
|
||||
| [User Stories](./user-stories.md) | 首次启用、正常使用、失败恢复、兼容和边界场景 |
|
||||
| [功能逻辑](./logic-design.md) | 不变量、状态维度、决策表和失败结果 |
|
||||
| [UI 设计](./ui-design.md) | 窗口交互、设置入口、状态呈现、无障碍和响应式行为 |
|
||||
| [技术设计](./technical-design.md) | 窗口与进程边界、IPC 边界、持久化、生命周期和验证策略 |
|
||||
|
||||
功能尚未实施,因此不创建 `progress.md`;实施开始后按 [`docs/features/AGENTS.md`](../AGENTS.md)
|
||||
的证据要求补充。
|
||||
|
||||
## 术语
|
||||
|
||||
- **桌面宠物窗口**:应用启用该功能后创建的常驻、无边框、可置顶悬浮窗口。
|
||||
- **快速发起**:点击宠物打开的小型输入框,用于输入文字或附加单个文件,提交后转入主窗口。
|
||||
- **暂存**:快速发起或拖放产生的文字和附件在提交前的中间状态,不代表已发送或已执行。
|
||||
- **状态镜面**:宠物窗口对当前任务状态(等待处理、执行中、已完成、失败、空闲)的展示,
|
||||
数据权威来自 Task 与 Conversation,不是独立状态源。
|
||||
- **安静模式**:抑制宠物完成/失败气泡提示的设置,可访问的状态查询不受影响。
|
||||
|
||||
## 相关功能
|
||||
|
||||
- [Task 与 Job 文档集](../task-and-job/README.md):任务状态、状态优先级和 Task Center
|
||||
导航的权威定义,宠物的状态镜面必须与其保持一致,不重新定义任务生命周期。
|
||||
- [助手工作栏](../assistant-workbar/README.md):主窗口执行空间和输入区的权威定义,宠物的
|
||||
快速发起和拖放暂存最终落地到该输入区,不复制其行为。
|
||||
- [直连模型 Agent 能力](../direct-model-agent/README.md)、[远程主机与远程执行](../remote-host/README.md):
|
||||
Ask/Execute 授权边界的权威定义,宠物不扩大或绕过该边界。
|
||||
- [文档处理](../document-processing/README.md):拖放文件解析和格式支持的权威定义,宠物拖放
|
||||
暂存复用同一处理管线,不重新定义支持类型。
|
||||
- 根 [`UI-DESIGN.md`](../../../UI-DESIGN.md):全局视觉、通知视口、Switch/Checkbox 语义和
|
||||
无障碍规则的权威定义。
|
||||
- [`docs/development/release-runbook.md`](../../development/release-runbook.md):跨平台打包
|
||||
矩阵和交付物的权威定义。
|
||||
@@ -0,0 +1,203 @@
|
||||
# 桌面宠物功能逻辑设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [桌面宠物 PRD](./prd.md) |
|
||||
| User Stories | [桌面宠物 User Stories](./user-stories.md) |
|
||||
| 任务模型 | [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
|
||||
|
||||
本文负责桌面宠物自身的状态、不变量和决策规则,不重新定义 Task、Conversation、Ask/
|
||||
Execute 或附件的既有语义;这些语义以各自权威文档为准。
|
||||
|
||||
## 1. 核心不变量
|
||||
|
||||
### INV-1 宠物不是第二条执行通道
|
||||
|
||||
快速起草和拖放暂存只产生主窗口会话输入区的草稿数据(文本、附件引用),不产生消息、
|
||||
不产生 Task、不触发模型调用。发送、模式选择和 Runtime 选择继续完全由主窗口既有流程
|
||||
拥有。
|
||||
|
||||
### INV-2 状态镜面是既有状态的投影
|
||||
|
||||
宠物状态镜面的取值只能从当前 Task 集合的既有状态推导,不允许引入宠物专属的任务状态
|
||||
字段或独立于 Task Center 的状态存储。宠物状态与 Task Center 展示的状态出现分歧即视为
|
||||
缺陷。
|
||||
|
||||
### INV-3 总开关是唯一的存在性开关
|
||||
|
||||
“启用桌面宠物”关闭时,宠物窗口、气泡和托盘条目文案必须整体消失;除该开关外,任何
|
||||
子设置(外观、位置、安静模式)的变化都不得单独创建或销毁宠物窗口。
|
||||
|
||||
### INV-4 暂时隐藏不影响启用状态
|
||||
|
||||
“暂时隐藏”只影响窗口可见性,不修改总开关持久化值,也不清除位置、外观或安静模式设置。
|
||||
|
||||
### INV-5 提示与原生通知互斥
|
||||
|
||||
同一个任务终态事件(完成或失败)只能产生宠物气泡或现有原生系统通知中的一种,不产生
|
||||
两种同时可见的独立提示。
|
||||
|
||||
### INV-6 位置始终落在可用显示器内
|
||||
|
||||
宠物窗口的持久化位置在应用于当前显示器拓扑之前必须被校验;不能得出宠物窗口完全位于
|
||||
任何当前可见显示器之外的最终坐标。
|
||||
|
||||
### INV-7 拖放与起草共享暂存规则
|
||||
|
||||
拖放产生的文本和文件必须满足与快速起草附件相同的类型、大小、数量限制;两者不允许存在
|
||||
两套互不一致的限制值。
|
||||
|
||||
## 2. 状态维度
|
||||
|
||||
### 2.1 总开关与可见性
|
||||
|
||||
```text
|
||||
disabled -> enabled_visible
|
||||
enabled_visible <-> enabled_hidden (暂时隐藏 / 重新显示)
|
||||
enabled_visible|enabled_hidden -> disabled (关闭总开关)
|
||||
```
|
||||
|
||||
`disabled` 是唯一没有宠物窗口存在的状态;`enabled_hidden` 窗口不可见但设置全部保留。
|
||||
|
||||
### 2.2 快速起草弹出框
|
||||
|
||||
```text
|
||||
closed -> drafting -> submitted | discarded
|
||||
drafting -> closed (Escape / 点击外部 / 关闭按钮)
|
||||
```
|
||||
|
||||
`submitted` 是终态转换动作,不是持续状态:提交后弹出框立即回到 `closed`,草稿数据已经
|
||||
转交给主窗口。
|
||||
|
||||
### 2.3 拖放交互
|
||||
|
||||
```text
|
||||
idle -> drag_over -> dropped_accepted | dropped_rejected | drag_cancelled
|
||||
```
|
||||
|
||||
`drag_over` 只影响视觉反馈,不产生任何持久数据;只有 `dropped_accepted` 才产生暂存
|
||||
数据。`dropped_rejected` 用于文件夹或全部文件超限的场景,不产生暂存也不打开主窗口。
|
||||
|
||||
### 2.4 状态镜面
|
||||
|
||||
```text
|
||||
unavailable | idle | completed_recent | running | waiting_approval | failed_recent
|
||||
```
|
||||
|
||||
取值由当前活跃与最近终态 Task 集合计算得出,属于派生状态,不单独持久化。
|
||||
|
||||
### 2.5 提示气泡
|
||||
|
||||
```text
|
||||
none -> pending -> shown -> dismissed
|
||||
```
|
||||
|
||||
`pending` 表示事件已发生但由于主窗口可见或安静模式而被抑制,不进入 `shown`。
|
||||
|
||||
## 3. 状态镜面优先级
|
||||
|
||||
宠物在任意时刻只呈现一个状态镜面取值。存在多个活跃 Task 时按以下优先级取最高项:
|
||||
|
||||
| 优先级 | 状态 | 条件 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `unavailable` | 用于确认状态的 Runtime 或网络当前不可达 |
|
||||
| 2 | `waiting_approval` | 至少一个 Task 处于等待批准 |
|
||||
| 3 | `failed_recent` | 至少一个 Task 在最近的“最近失败”窗口内失败,且用户尚未查看 |
|
||||
| 4 | `running` | 至少一个 Task 处于执行中 |
|
||||
| 5 | `completed_recent` | 至少一个 Task 在最近的“最近完成”窗口内完成,且用户尚未查看 |
|
||||
| 6 | `idle` | 以上条件都不满足 |
|
||||
|
||||
“最近失败”“最近完成”窗口的具体时长和“已查看”判定属于技术选择,由
|
||||
[技术设计](./technical-design.md)基于既有 Task 已读/未读语义确定,不在本文固定具体
|
||||
数值。
|
||||
|
||||
## 4. 提示归并决策表
|
||||
|
||||
| 事件 | 主窗口状态 | 安静模式 | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 任务完成 | 不可见或未聚焦 | 关闭 | 显示宠物气泡;不触发原生通知 |
|
||||
| 任务完成 | 不可见或未聚焦 | 开启 | 不显示气泡;不触发原生通知;仅更新状态镜面 |
|
||||
| 任务完成 | 可见并聚焦 | 任意 | 不显示气泡;反馈通过主窗口既有机制呈现 |
|
||||
| 任务失败 | 不可见或未聚焦 | 关闭 | 显示宠物气泡并保持可见直到用户查看或关闭 |
|
||||
| 任务失败 | 不可见或未聚焦 | 开启 | 不显示气泡;仅更新状态镜面为 `failed_recent` |
|
||||
| 任务失败 | 可见并聚焦 | 任意 | 不显示气泡;反馈通过主窗口既有机制呈现 |
|
||||
|
||||
“主窗口可见并聚焦”与现有 [`desktop-notification.ts`](../../../src/main/desktop-notification.ts)
|
||||
判定原生通知是否触发所使用的条件保持一致,避免宠物和原生通知各自使用不同的可见性
|
||||
判定导致同一事件在两处呈现或同时缺失。
|
||||
|
||||
## 5. 快速起草与拖放的目标会话决策表
|
||||
|
||||
| 场景 | 目标会话 |
|
||||
| --- | --- |
|
||||
| 主窗口存在当前活跃会话,用户未在弹出框内更改目标 | 该活跃会话 |
|
||||
| 用户在弹出框内选择“新建对话” | 提交时新建并激活一个会话 |
|
||||
| 主窗口不存在任何会话 | 提交时新建并激活一个会话 |
|
||||
| 目标会话在提交前被用户在主窗口中删除 | 视为“不存在任何会话”,回退到新建会话 |
|
||||
|
||||
拖放没有目标切换界面,始终采用与快速起草相同的默认目标会话规则。
|
||||
|
||||
## 6. 附件与文本限制
|
||||
|
||||
快速起草和拖放暂存复用现有聊天附件规则,不定义新的限制数值:
|
||||
|
||||
- 单条草稿的附件数量上限、单个附件大小上限、支持的文本与图片类型,与主窗口聊天附件
|
||||
的现有限制一致。
|
||||
- 拖放同时包含多个文件时,逐个按上述规则校验;不合规文件被拒绝,不影响合规文件继续
|
||||
暂存。
|
||||
- 拖放文件夹被整体拒绝,不做递归展开或部分接受。
|
||||
|
||||
## 7. 位置与显示器校验
|
||||
|
||||
1. 加载持久化位置后,检查该坐标是否位于当前任一可见显示器的可用区域内。
|
||||
2. 位于范围内:直接使用。
|
||||
3. 不在范围内(例如记忆的显示器已断开):改用主显示器上一个默认可见位置,并在下一次
|
||||
用户拖动宠物后覆盖持久化位置。
|
||||
4. 开启“贴边”时,拖动释放位置与最近边缘的距离小于吸附阈值即吸附到该边缘;阈值和默认
|
||||
位置的具体数值属于技术选择。
|
||||
|
||||
## 8. 失败与恢复
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 主窗口进程尚未创建或已被销毁 | 提交快速起草或拖放时先创建/恢复主窗口,再写入草稿 |
|
||||
| 写入草稿时主窗口窗口对象不可用 | 提交失败,弹出框保留草稿内容供用户重试,不静默丢弃 |
|
||||
| 状态查询所需的 Runtime/网络不可达 | 状态镜面显示 `unavailable`,不回退显示 `idle` |
|
||||
| 显示器拓扑变化导致位置越界 | 按第 7 节回退到默认可见位置,不隐藏窗口 |
|
||||
| 用户在气泡展示期间关闭总开关 | 气泡与宠物窗口一并立即消失 |
|
||||
| 应用启动时总开关为开启但窗口创建失败 | 记录失败原因,不阻塞主窗口正常启动,托盘菜单提供重试入口 |
|
||||
|
||||
## 9. 需求追踪
|
||||
|
||||
| 需求 | 主要逻辑 |
|
||||
| --- | --- |
|
||||
| `FR-1` | `INV-3`、总开关与可见性状态 |
|
||||
| `FR-2` | 位置与显示器校验、`INV-6` |
|
||||
| `FR-3` | `INV-1`、快速起草状态、目标会话决策表 |
|
||||
| `FR-4` | `INV-1`、`INV-7`、拖放交互状态、附件与文本限制 |
|
||||
| `FR-5` | `INV-2`、状态镜面优先级 |
|
||||
| `FR-6` | `INV-5`、提示归并决策表 |
|
||||
| `FR-7` | 提示归并决策表中安静模式列 |
|
||||
| `FR-8` | 位置与显示器校验之外的技术选择,逻辑仅约束持久化立即生效 |
|
||||
| `FR-9` | `INV-4`、总开关与可见性状态 |
|
||||
| `FR-10` | 快速起草状态中的键盘转换,具体按键映射见 UI 设计 |
|
||||
| `FR-11` | `INV-1`、`INV-2`、`INV-7` |
|
||||
|
||||
## 10. 逻辑完整性评估
|
||||
|
||||
本设计覆盖了启用/关闭、暂时隐藏、快速起草、拖放(含超限和文件夹)、状态镜面优先级、
|
||||
提示与原生通知的归并、位置越界恢复、以及主窗口不可用时的失败路径。
|
||||
|
||||
未在本文固定、留给技术设计选择的实现细节:
|
||||
|
||||
- “最近完成/最近失败”窗口的具体时长和“已查看”判定的确切事件来源。
|
||||
- 贴边吸附阈值和默认回退位置的具体像素值。
|
||||
- 状态查询的具体轮询或事件驱动机制。
|
||||
|
||||
这些是技术选择而非未解决的产品决策:本文的优先级和决策表已经足以在任何取值下保持
|
||||
行为一致。
|
||||
@@ -0,0 +1,158 @@
|
||||
# 桌面宠物 PRD
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 适用产品 | GoodBuddy Desktop |
|
||||
| 关联文档 | [功能逻辑](./logic-design.md)、[UI 设计](./ui-design.md)、[技术设计](./technical-design.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
GoodBuddy 当前只能通过主窗口发起提问、查看任务和管理执行空间。用户想要快速提问、丢一个
|
||||
文件进去,或者只是看一眼任务有没有跑完时,仍必须先唤出并聚焦整个主窗口。这在多任务、多
|
||||
显示器场景下打断当前工作流。
|
||||
|
||||
桌面宠物提供一个常驻、体积小、不抢焦点的悬浮入口,承担三类高频、轻量的动作:快速起草
|
||||
一个问题、拖放文件或文字暂存、以及查看当前任务状态。它不是新的执行通道,只是现有
|
||||
Conversation、Task 和 Runtime 能力上的一层轻量触发面和状态镜面。
|
||||
|
||||
## 2. 产品目标
|
||||
|
||||
1. 用户无需先打开或聚焦主窗口即可起草一条消息或暂存一个文件。
|
||||
2. 用户可以在不打开 Task Center 的情况下,一眼看到当前是否有任务在跑、等待处理或失败。
|
||||
3. 桌面宠物默认不产生新的干扰:不主动弹窗抢焦点,不重复现有原生通知。
|
||||
4. 桌面宠物的开关、位置、外观和提示强度完全由用户控制,且可随时完全关闭并恢复到没有该
|
||||
功能之前的界面状态。
|
||||
5. 桌面宠物不新增或绕过 Ask/Execute 授权边界,也不建立第二条消息发送或任务执行路径。
|
||||
|
||||
## 3. 功能范围
|
||||
|
||||
### 3.1 包含
|
||||
|
||||
- 设置中的“桌面宠物”一级分类,包含总开关、外观、位置和提示设置。
|
||||
- 常驻悬浮窗口:可拖动、跨显示器记忆位置、贴边、置顶开关。
|
||||
- 点击宠物打开的“快速起草”弹出框:输入文字、附加至多一个本机文件,提交后转入主窗口
|
||||
对应会话的输入区草稿,不自动发送。
|
||||
- 拖放文字或文件到宠物窗口,效果与快速起草的附件路径一致。
|
||||
- 状态镜面:显示当前活跃任务的汇总状态,点击后打开 Task Center 中对应任务。
|
||||
- 完成/失败气泡提示,及独立于原生通知的安静模式开关。
|
||||
- 从有限的内置角色/外观中选择,调整尺寸、透明度和动画强度。
|
||||
- 全局显示/隐藏开关,托盘菜单入口,键盘可达的等效操作路径。
|
||||
|
||||
### 3.2 不包含
|
||||
|
||||
- 在宠物窗口内直接展示模型回复、执行工具调用或建立第二条 Ask/Execute 通道。
|
||||
- 未经用户确认自动发送消息或自动执行任务。
|
||||
- 持续读取屏幕内容、剪贴板或系统级选中文本。
|
||||
- 用户上传任意自定义头像图片、养成机制、货币或每日签到。
|
||||
- 多个宠物实例、宠物之间互动或联网社交功能。
|
||||
- 管理员远程强制启用、配置或监控用户的桌面宠物。
|
||||
- 替代 Task Center、助手工作栏或现有通知系统的权威数据来源。
|
||||
|
||||
## 4. 功能需求
|
||||
|
||||
### FR-1 总开关与默认状态
|
||||
|
||||
- 设置新增“桌面宠物”一级分类,包含“启用桌面宠物”总开关。
|
||||
- 全新安装和现有用户升级后默认关闭,不在未经用户操作时出现在桌面上。
|
||||
- 关闭总开关立即隐藏宠物窗口,保留用户此前的位置、外观和提示设置以便重新开启。
|
||||
|
||||
### FR-2 常驻悬浮窗口
|
||||
|
||||
- 启用后创建一个无边框、默认置顶、不出现在任务栏的小型窗口。
|
||||
- 用户可拖动到任意位置;位置按显示器分别记忆,显示器断开重连后回退到最近的可用显示器。
|
||||
- 提供“贴边”选项,拖动到屏幕边缘附近时自动吸附。
|
||||
- 提供独立的“始终置顶”开关;关闭后宠物仍可见,但可被其他窗口遮挡。
|
||||
- 宠物窗口不接受系统级最大化、最小化或调整大小操作。
|
||||
|
||||
### FR-3 快速起草
|
||||
|
||||
- 点击宠物打开一个紧邻宠物的小型弹出框,包含文本输入和“附加文件”按钮。
|
||||
- 文本输入支持多行、粘贴图片;附加文件复用现有附件类型、数量和大小限制。
|
||||
- 弹出框显示将要进入的目标:当前活跃会话,或“新建对话”。用户可在弹出框内切换目标,
|
||||
但不能在弹出框内选择模型、Runtime 或 Ask/Execute 模式。
|
||||
- 提交后主窗口被显示或聚焦,目标会话被激活,文字和附件作为草稿填入该会话的输入区,
|
||||
输入焦点落在该输入区。提交动作本身不发送消息、不创建任务。
|
||||
- 取消或关闭弹出框放弃当前草稿,不影响主窗口任何已有草稿。
|
||||
|
||||
### FR-4 拖放暂存
|
||||
|
||||
- 用户可以把本机文件或选中文字拖到宠物窗口上。
|
||||
- 拖入时宠物给出可放置的视觉反馈;释放后遵循与快速起草提交相同的行为:显示主窗口、
|
||||
激活目标会话、把内容放入该会话输入区草稿。
|
||||
- 拖放文件的类型、数量、大小限制与现有聊天附件限制一致;超出限制时明确提示被拒绝的
|
||||
文件,其余合法文件仍正常暂存。
|
||||
- 拖放不支持文件夹;文件夹被拖入时提示不支持,不递归展开。
|
||||
|
||||
### FR-5 状态镜面
|
||||
|
||||
- 宠物在空闲、执行中、等待处理、已完成、失败之间切换外观,反映当前所有活跃 Task 的
|
||||
汇总状态;多个状态并存时按“等待处理 > 失败 > 执行中 > 已完成 > 空闲”的优先级显示。
|
||||
- 点击状态镜面打开或聚焦主窗口的 Task Center,并定位到触发当前状态的任务。
|
||||
- Runtime 或网络不可用导致任务状态无法确认时,状态镜面显示“状态不可用”而不是回退成
|
||||
“空闲”。
|
||||
|
||||
### FR-6 完成与失败提示
|
||||
|
||||
- 任务从执行中或等待处理转为已完成或失败,且主窗口不可见或未聚焦时,宠物旁显示一条
|
||||
气泡,说明任务标题和结果。
|
||||
- 气泡在用户不操作时于一段时间后自动消失;失败气泡保持可见直到用户查看或手动关闭。
|
||||
- 点击气泡等价于点击状态镜面,打开对应任务详情。
|
||||
- 同一事件只能触发桌面宠物气泡或现有原生系统通知中的一种,不同时出现两条提示;具体
|
||||
归并规则由[功能逻辑](./logic-design.md)定义。
|
||||
|
||||
### FR-7 安静模式
|
||||
|
||||
- 独立于总开关的“安静模式”开关,开启后不再显示完成/失败气泡。
|
||||
- 安静模式不影响状态镜面本身的外观切换,也不影响原生系统通知的现有行为。
|
||||
|
||||
### FR-8 外观与个性化
|
||||
|
||||
- 提供数量有限的内置角色供选择,不支持上传自定义图片。
|
||||
- 提供尺寸、透明度和动画强度调整;调整立即生效并持久化。
|
||||
- 提供“恢复默认外观”操作。
|
||||
|
||||
### FR-9 显示、隐藏与退出
|
||||
|
||||
- 提供独立于总开关的“暂时隐藏”动作:隐藏宠物窗口但保留启用状态,可通过托盘菜单或
|
||||
设置重新显示。
|
||||
- 托盘菜单新增“显示/隐藏桌面宠物”入口,随当前可见性变化文案。
|
||||
- 应用退出时宠物窗口一并关闭,不残留独立进程。
|
||||
|
||||
### FR-10 键盘可达性
|
||||
|
||||
- 宠物窗口本身可通过系统级窗口切换(如 Alt+Tab)取得焦点,取得焦点后可用键盘打开快速
|
||||
起草弹出框、在状态镜面和提示之间移动,并可用 Escape 关闭弹出框。
|
||||
- 所有交互提供可访问名称和状态说明,不仅依赖动画或颜色表达状态。
|
||||
|
||||
### FR-11 与现有能力的边界
|
||||
|
||||
- 快速起草和拖放暂存最终使用主窗口现有的会话、附件、模型和 Ask/Execute 选择逻辑,不
|
||||
新增第二套发送或授权路径。
|
||||
- 状态镜面和气泡的任务状态数据来自现有 Task 与 Conversation 记录,不建立独立存储、不
|
||||
与 Task Center 的状态定义产生分歧。
|
||||
- 桌面宠物不改变现有全局快捷键、托盘菜单既有条目或主窗口默认行为。
|
||||
|
||||
## 5. 产品验收
|
||||
|
||||
- 全新安装和已升级用户默认看不到桌面宠物;显式启用后立即出现在预期位置。
|
||||
- 关闭总开关后桌面上不再有任何宠物窗口或残留提示,重新开启后恢复此前的位置和外观。
|
||||
- 通过快速起草或拖放提交的内容,在主窗口对应会话的输入区中可见为未发送草稿,用户需要
|
||||
自行确认发送;关闭 GoodBuddy 或取消草稿不会产生任何任务或消息记录。
|
||||
- 状态镜面呈现的状态与 Task Center 中对应任务的状态一致;两者出现分歧即视为缺陷。
|
||||
- 同一任务完成或失败事件不会同时产生宠物气泡和原生系统通知。
|
||||
- 安静模式开启时不出现气泡,但状态镜面外观仍随任务状态变化。
|
||||
- 键盘用户无需鼠标即可打开快速起草、查看状态并关闭弹出框。
|
||||
- 中英文文案、深浅主题、多显示器和 DPI 缩放下宠物窗口位置和内容均正确显示。
|
||||
|
||||
## 6. 明确限制
|
||||
|
||||
- 桌面宠物不提供在其自身窗口内完成整段对话或查看历史消息的能力;查看完整回复必须进入
|
||||
主窗口。
|
||||
- 桌面宠物不支持多实例、多角色同屏或角色之间的模拟互动。
|
||||
- 透明背景效果依赖操作系统合成器;在不支持透明合成的 Linux 桌面环境下,宠物窗口可能
|
||||
退化为不透明的小型窗口,具体行为见[技术设计](./technical-design.md)。
|
||||
@@ -0,0 +1,335 @@
|
||||
# 桌面宠物技术设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [桌面宠物 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| UI 设计 | [UI 设计](./ui-design.md) |
|
||||
|
||||
## 1. 进程与窗口边界
|
||||
|
||||
```text
|
||||
Main
|
||||
-> createMainWindow() (既有,src/main/window.ts)
|
||||
-> createPetWindow() (新增)
|
||||
-> 独立 BrowserWindow:无边框、透明、置顶、不入任务栏
|
||||
-> 独立 preload:src/preload/pet.ts(新增,窄 API)
|
||||
-> 加载独立渲染入口:src/renderer/pet.html + src/renderer/src/pet/
|
||||
Renderer(主窗口,既有)
|
||||
-> window.goodbuddy(既有 DesktopApi,不变)
|
||||
Renderer(宠物窗口,新增)
|
||||
-> window.goodbuddyPet(新增窄 API,与既有 window.goodbuddy 完全隔离)
|
||||
```
|
||||
|
||||
宠物窗口是与主窗口平级的第二个 `BrowserWindow`,不是主窗口的子窗口、不是 `WebContentsView`
|
||||
叠加层。理由:
|
||||
|
||||
- 主窗口现有 `setWindowOpenHandler` 拒绝一切子窗口(`src/main/window.ts`),沿用该策略
|
||||
比引入例外更简单。
|
||||
- 宠物窗口需要独立于主窗口的显示/隐藏、位置和置顶状态,作为子窗口会继承父窗口的显示
|
||||
状态,不满足“主窗口隐藏时宠物仍可见”的产品要求。
|
||||
|
||||
不复用主窗口的 preload 和渲染入口:主窗口 preload 暴露的 `DesktopApi` 面积很大(对话、
|
||||
知识库、设备共享等)。给宠物窗口加载同一 preload 会让一个新增的、长期运行、暴露给
|
||||
远程可控网页内容风险更低但攻击面更广的窗口拥有不必要的能力。新增窄 preload 遵循仓库
|
||||
现有的“只暴露显式方法”约束。
|
||||
|
||||
## 2. 模块职责
|
||||
|
||||
| 模块 | 位置 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| `PetWindowService` | `src/main/pet/pet-window-service.ts`(新增) | 创建/销毁宠物窗口、位置持久化与越界校验、置顶与贴边、显示/隐藏 |
|
||||
| `PetSettingsStore` | `src/main/pet/pet-settings-store.ts`(新增) | 总开关、外观、位置、安静模式的持久化,遵循既有 settings-file-utils 模式 |
|
||||
| `PetStatusProjector` | `src/main/pet/pet-status-projector.ts`(新增) | 从既有 Task 查询结果计算状态镜面取值,不引入新状态源 |
|
||||
| `PetDraftBridge` | `src/main/pet/pet-draft-bridge.ts`(新增) | 把宠物提交的文本/附件转交主窗口渲染进程,驱动会话激活与草稿写入 |
|
||||
| `PetNotificationCoordinator` | `src/main/pet/pet-notification-coordinator.ts`(新增) | 与既有 `desktop-notification.ts` 协商同一事件只走一条提示路径 |
|
||||
| 既有 `context-manager.ts` | 不变 | 宠物附件校验复用其 `selectFiles`/校验规则,不新增第二套限制 |
|
||||
| 既有 `assistant-database.ts` / Task 查询 | 不变 | 状态镜面查询的唯一数据来源 |
|
||||
|
||||
`PetStatusProjector` 只读取既有 Task 查询结果并按
|
||||
[功能逻辑第 3 节](./logic-design.md#3-状态镜面优先级)的优先级归并,不写入、不缓存跨进程
|
||||
重启的状态;重启后状态镜面从当前 Task 集合重新计算。
|
||||
|
||||
## 3. 宠物窗口创建
|
||||
|
||||
```ts
|
||||
// src/main/pet/pet-window-service.ts(示意)
|
||||
function createPetWindow(): BrowserWindow {
|
||||
return new BrowserWindow({
|
||||
width: PET_SIZE.width,
|
||||
height: PET_SIZE.height,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true, // 由“始终置顶”设置决定实际值
|
||||
hasShadow: false,
|
||||
backgroundColor: '#00000000',
|
||||
webPreferences: {
|
||||
preload: join(currentDirectory, '../preload/pet.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- `transparent: true` 依赖操作系统合成器。按 PRD 6 节明确限制,在不支持透明合成的
|
||||
Linux 桌面环境(例如未运行合成器的部分窗口管理器)下,Electron/Chromium 会把窗口
|
||||
渲染为不透明;`PetWindowService` 不检测合成器能力,只在渲染进程内把背景色设置为
|
||||
当前主题的画布色作为不透明回退,保证内容仍然可读,不尝试探测或声明支持特定合成器。
|
||||
- `alwaysOnTop` 的实际值由“始终置顶”Switch 驱动,通过 `window.setAlwaysOnTop(bool)`
|
||||
在设置变更时更新,不需要重建窗口。
|
||||
- 复用既有 `resolveWindowIcon`/任务栏排除逻辑作为参考,但宠物窗口 `skipTaskbar: true`
|
||||
固定为真,不提供用户设置。
|
||||
- 宠物窗口不设置 `parent`,与主窗口是同级独立窗口;应用单实例锁和 `second-instance`
|
||||
处理(`src/main/index.ts`)不需要改动,因为宠物窗口的存在与否由 `PetWindowService`
|
||||
内部状态决定,而不是第二个 Electron 实例。
|
||||
|
||||
## 4. 拖放实现
|
||||
|
||||
Electron 渲染进程原生支持 HTML5 拖放;宠物渲染进程监听 `dragenter`/`dragover`/`drop`
|
||||
即可获得视觉反馈和释放事件,不需要 Main 参与拖放事件本身。文件路径解析复用既有
|
||||
`webUtils.getPathForFile`(已用于 `src/preload/index.ts` 的知识库拖放导入):
|
||||
|
||||
```ts
|
||||
// src/preload/pet.ts(示意)
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('goodbuddyPet', {
|
||||
submitDraft: (input: PetDraftSubmission) =>
|
||||
ipcRenderer.invoke(petIpcChannels.submitDraft, input),
|
||||
resolveDroppedPaths: (files: File[]) =>
|
||||
files.map((file) => webUtils.getPathForFile(file)).filter(Boolean),
|
||||
// ...状态订阅、设置读写等方法
|
||||
})
|
||||
```
|
||||
|
||||
拖入的文本(非文件)通过 `dataTransfer.getData('text/plain')` 直接读取,随普通文本
|
||||
字段一起提交,不经过路径解析。
|
||||
|
||||
宠物渲染进程把解析出的路径和文本打包为 `PetDraftSubmission`,通过 IPC 交给 Main;Main
|
||||
侧的路径校验、类型判断、大小限制和读取,全部复用既有 `ContextManager` 的规则(`src/main/context-manager.ts`
|
||||
中的 `supportedExtensions`、`supportedImageExtensions`、`supportedDocumentExtensions`、
|
||||
`maximumContextBytes`、`maximumAttachmentsPerMessage`),不新增校验逻辑。`ContextManager`
|
||||
目前的公开入口是围绕原生对话框(`selectFiles`)设计的;实现时需要新增一个接受任意
|
||||
canonical 路径数组的方法(例如 `stageFromPaths`),内部复用现有扩展名/大小/文档解析
|
||||
分支,而不是复制一份判断逻辑。文件夹路径(`fs.stat` 判定为目录)在该方法入口即被拒绝,
|
||||
不递归遍历。
|
||||
|
||||
## 5. 快速起草提交与主窗口联动
|
||||
|
||||
```text
|
||||
宠物渲染进程 submitDraft(input)
|
||||
-> Main: PetDraftBridge.handleSubmit(input)
|
||||
-> ContextManager.stageFromPaths(input.filePaths) (复用既有校验)
|
||||
-> 若主窗口不存在:createMainWindow() + loadMainWindow()(复用既有函数)
|
||||
-> showWindow(mainWindow)(既有函数)
|
||||
-> mainWindow.webContents.send(petIpcChannels.draftReceived, {
|
||||
targetConversationId, text, stagedContextIds
|
||||
})
|
||||
-> 主窗口渲染进程(App.tsx 新增一个事件监听,与既有 conversation:new 监听模式一致)
|
||||
-> 若 targetConversationId 无效或已删除:回退为新建会话(既有新建会话路径)
|
||||
-> 激活目标会话、把 text 写入该会话输入框状态、把 stagedContextIds 追加到该
|
||||
会话的附件状态(既有 App.tsx 附件状态更新路径)
|
||||
-> 聚焦输入框
|
||||
```
|
||||
|
||||
- 草稿只在主窗口渲染进程内变成“输入框状态”和“待发送附件列表”,与用户手动打字/手动
|
||||
点击附件按钮产生的状态是同一份 React state,不建立并行的草稿存储。这直接满足
|
||||
`INV-1`:宠物不产生消息、不产生 Task。
|
||||
- `stagedContextIds` 复用现有 `context:*` IPC 产生的 Context ID;主窗口把这些 ID 加入
|
||||
附件列表的方式,与用户通过“附加文件”按钮选择文件后的状态更新完全一致。
|
||||
- 若提交发生时主窗口曾被销毁(用户从未打开过,或者提前研究表明当前架构不销毁主窗口
|
||||
只隐藏),`PetDraftBridge` 调用既有 `createMainWindow`/`loadMainWindow`,等待
|
||||
`ready-to-show` 完成后再发送 `draftReceived`,避免竞态。
|
||||
|
||||
## 6. IPC 边界
|
||||
|
||||
新增独立 IPC 通道命名空间 `pet:*`,与既有 `ipcChannels` 并列定义在
|
||||
`src/shared/ipc-channels.ts`:
|
||||
|
||||
```text
|
||||
pet:get-snapshot (设置 + 当前状态镜面取值 + 可见性)
|
||||
pet:update-settings (总开关、外观、贴边、置顶、安静模式)
|
||||
pet:submit-draft (快速起草/拖放提交)
|
||||
pet:dismiss-bubble (关闭完成/失败气泡)
|
||||
pet:open-task (点击状态镜面/气泡,打开 Task Center 对应任务)
|
||||
pet:toggle-visibility (托盘“隐藏/显示桌面宠物”)
|
||||
```
|
||||
|
||||
- 所有输入使用 `src/shared/pet-contracts.ts`(新增)中的 Zod schema 校验,与既有约定
|
||||
一致。
|
||||
- `trusted-ipc-sender.ts` 的 `assertTrustedSender` 按窗口实例参数化使用;宠物窗口的
|
||||
IPC handler 校验 `event.sender === petWindow.webContents`,不能用主窗口的 trusted
|
||||
sender 校验函数验证宠物窗口的请求,也不能反过来。
|
||||
- 宠物 preload 不暴露任何既有 `DesktopApi` 方法;需要的能力(提交草稿、读取/写入宠物
|
||||
设置、订阅状态镜面)都通过 `pet:*` 通道单独暴露为 `window.goodbuddyPet` 的具体方法。
|
||||
- Main 侧广播状态镜面变化时,只向宠物窗口发送派生后的枚举值(第 3 节的六种状态之一)
|
||||
和最小必要的任务标题/ID,不发送完整 Task 或 Conversation 对象。
|
||||
|
||||
## 7. 持久化
|
||||
|
||||
新增设置文件,遵循既有 `settings-file-utils.ts` 的原子写入、版本校验和损坏隔离模式,
|
||||
不复用 `ApplicationSettingsStore` 或 `ShortcutSettingsStore` 的 schema:
|
||||
|
||||
```text
|
||||
pet-settings.json
|
||||
{
|
||||
version: 1,
|
||||
enabled: boolean,
|
||||
quietMode: boolean,
|
||||
alwaysOnTop: boolean,
|
||||
snapToEdge: boolean,
|
||||
appearance: { character: string, size: 'small'|'medium'|'large',
|
||||
opacity: number, motion: 'off'|'reduced'|'full' },
|
||||
position: { displayId: number, x: number, y: number } | null
|
||||
}
|
||||
```
|
||||
|
||||
- `position` 为 `null` 表示尚未被用户拖动过,使用默认位置计算规则(第 8 节)。
|
||||
- `displayId` 使用 Electron `screen.getAllDisplays()` 返回的 `id`;加载时按第 8 节校验,
|
||||
不假设跨重启保持稳定的显示器 id 一定仍然存在。
|
||||
- 独立 schema 版本号(从 1 开始),与主设置版本号互不影响;未来变更只需要迁移这一个
|
||||
文件。
|
||||
- 是否需要独立数据库表:不需要。宠物没有需要查询、筛选或跨设备同步的历史数据,一个
|
||||
JSON 文件足够,符合仓库 KISS/YAGNI 约束。
|
||||
|
||||
## 8. 位置与多显示器校验
|
||||
|
||||
```ts
|
||||
function resolvePetPosition(
|
||||
saved: PetPosition | null,
|
||||
displays: Display[]
|
||||
): { x: number; y: number } {
|
||||
if (saved) {
|
||||
const display = displays.find((d) => d.id === saved.displayId)
|
||||
if (display && pointWithinWorkArea(saved, display.workArea)) {
|
||||
return saved
|
||||
}
|
||||
}
|
||||
const primary = screen.getPrimaryDisplay()
|
||||
return defaultPositionWithin(primary.workArea) // 例如右下角,留出安全边距
|
||||
}
|
||||
```
|
||||
|
||||
- 该函数在宠物窗口创建时以及 `display-added`/`display-removed`/`display-metrics-changed`
|
||||
事件触发时调用;越界立即调用 `window.setPosition` 校正,不等待用户下一次拖动。
|
||||
- 用户拖动结束(`moved` 事件)时把当前 `getBounds()` 和 `screen.getDisplayNearestPoint`
|
||||
得到的 `displayId` 写入 `PetSettingsStore`,去抖后原子写入,避免拖动过程中高频写盘。
|
||||
- 贴边吸附在渲染进程拖动过程中做视觉预判(不移动实际窗口,只显示吸附提示),在
|
||||
`moved` 事件的最终位置上由 Main 一次性计算是否落入吸附阈值并调用 `setPosition`
|
||||
吸附到边缘,阈值和默认位置的具体像素值作为实现细节调优,不在此固定。
|
||||
|
||||
## 9. 状态镜面查询
|
||||
|
||||
`PetStatusProjector` 不新增事件源,而是订阅既有 Task 变化通知(主窗口渲染进程已经
|
||||
用于更新 Task Center 和会话内任务卡片的同一套 Main → Renderer 事件)在 Main 侧的
|
||||
对应版本,或者在 Main 侧直接查询 `assistant-database.ts` 暴露的 Task 读取方法。具体
|
||||
选择两者之一属于实现阶段的技术选择,约束条件是:
|
||||
|
||||
- 不引入宠物专属的任务状态表或缓存;查询/订阅的结果必须与 Task Center 在同一时刻
|
||||
展示的状态一致。
|
||||
- 查询开销必须是有界的(只统计活跃与最近终态 Task 的数量和状态,不拉取消息正文)。
|
||||
- Runtime/网络不可达导致查询失败时返回 `unavailable`,不静默返回 `idle`。
|
||||
|
||||
“最近完成”“最近失败”窗口时长和“已查看”判定:复用 Task 已有的已读/查看时间戳字段
|
||||
(若既有模型已提供);若当前模型没有这类字段,实现时以“该 Task 终态发生后,用户是否
|
||||
已经点击过对应的宠物气泡或状态镜面,或已经在 Task Center 中打开过该 Task”作为已查看
|
||||
判定,窗口时长建议与现有原生通知的展示逻辑保持同量级(分钟级),具体数值在实现时
|
||||
根据既有 Task 事件粒度确定。
|
||||
|
||||
## 10. 提示与原生通知协商
|
||||
|
||||
`PetNotificationCoordinator` 与 `desktop-notification.ts` 的 `showDesktopNotificationWhenUnfocused`
|
||||
共享同一个“主窗口是否可见并聚焦”判定:
|
||||
|
||||
```ts
|
||||
function shouldSuppressBecauseMainWindowVisible(mainWindow: BrowserWindow): boolean {
|
||||
return !mainWindow.isDestroyed() && mainWindow.isFocused()
|
||||
}
|
||||
```
|
||||
|
||||
- 现有 `showDesktopNotificationWhenUnfocused` 已经使用等价条件(`window.isFocused()`)
|
||||
决定是否弹出原生通知;`PetNotificationCoordinator` 复用同一判定函数(如需要,提取为
|
||||
共享工具),保证两者的“可见性”定义永远一致,不出现原生通知判定为可见但宠物判定为
|
||||
不可见(或反之)导致同一事件同时出现两条提示或完全没有提示的情况。
|
||||
- 实现层面:调用触发通知的代码路径在决定“原生通知 vs 宠物气泡”时二选一,而不是
|
||||
两者分别独立判断后都可能触发;例如由现有触发点先判断安静模式和宠物启用状态,
|
||||
为真则调用 `PetNotificationCoordinator`,否则调用现有 `showDesktopNotificationWhenUnfocused`。
|
||||
- 安静模式开启时,两者都不触发;状态镜面仍照常更新(状态镜面更新走独立的
|
||||
`pet:get-snapshot`/推送通道,不经过通知协商)。
|
||||
|
||||
## 11. 与既有模块集成
|
||||
|
||||
- 快速起草和拖放最终只调用主窗口渲染进程已有的“设置输入框文本”“追加附件”“激活
|
||||
会话”“创建新会话”状态更新函数,不新建与 Ask/Execute、模型选择、Runtime 选择相关
|
||||
的任何路径。
|
||||
- 点击状态镜面/气泡打开 Task Center 复用现有 Task Center 导航(既有 IPC/路由,与托盘
|
||||
“新建对话”发送 `conversation:new` 事件属于同一类模式:Main 发送一个事件,主窗口
|
||||
渲染进程消费并完成导航)。
|
||||
- 托盘菜单新增项复用现有 `Menu.buildFromTemplate` 结构(`src/main/index.ts` 的
|
||||
`buildTray`),插入一个受当前可见性状态驱动的条目,不重写整个菜单构建函数。
|
||||
- 全局快捷键(`src/shared/shortcut.ts`、`shortcut-settings-service.ts`)不受影响,
|
||||
桌面宠物不注册新的全局快捷键,因为激活方式是点击窗口本身或系统级窗口切换,不需要
|
||||
额外抢占全局按键空间。
|
||||
|
||||
## 12. 生命周期
|
||||
|
||||
- 应用启动读取 `PetSettingsStore`;`enabled: true` 时在既有服务和 IPC 注册完成后创建
|
||||
宠物窗口(与主窗口创建顺序一致,不阻塞主窗口 `ready-to-show`)。
|
||||
- 宠物窗口创建失败(例如极端情况下操作系统拒绝创建透明窗口):记录原因,不阻塞主窗口
|
||||
启动,托盘“显示桌面宠物”下次点击时重试创建。
|
||||
- 应用退出(`before-quit`/既有 `isQuitting` 标记)时,宠物窗口与主窗口一并销毁,不需要
|
||||
独立的关闭确认或延迟清理,因为宠物窗口没有正在进行的网络连接或未保存状态:草稿已经
|
||||
转交主窗口的那一刻起就属于主窗口生命周期。
|
||||
- “暂时隐藏”只调用 `window.hide()`,不销毁窗口实例,重新显示直接 `showWindow` 复用
|
||||
既有实例,避免频繁创建/销毁透明窗口带来的闪烁。
|
||||
|
||||
## 13. 打包
|
||||
|
||||
- 宠物渲染入口(HTML、内置角色素材)加入 `electron.vite.config.ts` 的 renderer 构建
|
||||
输入(现有配置已有 `input` 多入口结构,参考现有主渲染入口的声明方式新增一项)。
|
||||
- 内置角色素材(数量有限的静态图片/精灵图)作为打包资源加入 `package.json` 的 `files`/
|
||||
`extraResources`,不通过运行时网络下载,符合仓库“不新增远程资源请求”的字体/资源
|
||||
约束的同一精神。
|
||||
- 新增 preload 产物(`pet.cjs`)需要加入 `electron.vite.config.ts` 的 main/preload 构建
|
||||
`entryFileNames` 规则,与现有主 preload 产物并列。
|
||||
- 六个标准打包目标(Windows/macOS/Linux × x64/arm64)均需验证宠物窗口可创建、透明或
|
||||
回退不透明背景符合第 3 节预期;LoongArch 预览目标不单独验证,遵循仓库现有策略。
|
||||
|
||||
## 14. 验证策略
|
||||
|
||||
### 14.1 自动化
|
||||
|
||||
- `PetSettingsStore` 的默认值、原子写入、损坏隔离和版本校验(沿用
|
||||
`settings-file-utils.ts` 现有测试模式)。
|
||||
- `resolvePetPosition` 在显示器存在/不存在/坐标越界三种输入下的返回值。
|
||||
- `PetStatusProjector` 按第 3 节优先级表在各种 Task 组合输入下的输出。
|
||||
- `PetNotificationCoordinator` 与 `desktop-notification.ts` 共享判定函数在“可见并聚焦”
|
||||
“不可见”“安静模式开启”组合下只触发一条路径或都不触发。
|
||||
- `ContextManager.stageFromPaths`(新增方法)对合法文件、超限文件、不支持类型、文件夹
|
||||
路径的分支覆盖,复用现有 `context-manager` 测试基础设施。
|
||||
- `pet:*` IPC handler 的 trusted sender、schema 校验和错误码。
|
||||
- 快速起草提交后主窗口渲染进程状态更新的单元测试:目标会话激活、文本写入、附件追加、
|
||||
草稿被丢弃时不产生 Task 或消息记录。
|
||||
|
||||
### 14.2 真实环境
|
||||
|
||||
- Windows、macOS、Linux 至少各一次验证宠物窗口创建、拖动、贴边吸附、透明或回退背景的
|
||||
实际渲染效果。
|
||||
- 真实拖放一个合法文件、一个超限文件、一个文件夹到宠物窗口,确认 UI 设计第 4.3 节的
|
||||
反馈和最终主窗口状态符合预期。
|
||||
- 真实触发一次任务完成和一次任务失败,在“主窗口隐藏”“主窗口可见”“安静模式开启”
|
||||
三种组合下确认气泡和原生通知的出现符合第 10 节的互斥规则。
|
||||
- 多显示器插拔场景下确认位置回退逻辑(断开记忆显示器后宠物仍可见)。
|
||||
- 打包后的应用在六个标准目标上完成启用、拖动、快速起草、退出流程的一次冒烟验证。
|
||||
|
||||
设计文档完成不代表功能实施或上述验证通过。
|
||||
@@ -0,0 +1,207 @@
|
||||
# 桌面宠物 UI 设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [桌面宠物 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 全局规范 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) |
|
||||
|
||||
本文定义设置中的“桌面宠物”页面和独立宠物窗口的界面。所有颜色、间距、字体、通知、
|
||||
Switch/Checkbox、焦点和响应式规则以全局设计系统为准;宠物窗口是应用内唯一不使用标准
|
||||
`PageShell` 的持久界面,其例外范围在第 4 节明确限定。
|
||||
|
||||
## 1. 设置页面信息架构
|
||||
|
||||
设置中心新增“桌面宠物”一级分类,使用 `standard` PageShell,不使用页签,按以下顺序
|
||||
纵向排列:
|
||||
|
||||
```text
|
||||
桌面宠物
|
||||
在桌面上保留一个轻量入口,随时起草消息、拖入文件或查看任务状态
|
||||
|
||||
启用桌面宠物 [Switch 关]
|
||||
关闭后不在桌面上显示任何宠物窗口或提示
|
||||
|
||||
—— 以下设置仅在启用后可编辑,保持可见但降低强调度 ——
|
||||
|
||||
外观
|
||||
角色 [分段选择:三到四个内置角色缩略图]
|
||||
尺寸 [滑块 小 · 中 · 大]
|
||||
透明度 [滑块]
|
||||
动画强度 [滑块 关闭 · 轻量 · 完整]
|
||||
[恢复默认外观]
|
||||
|
||||
位置与窗口
|
||||
始终置顶 [Switch 开]
|
||||
贴边吸附 [Switch 开]
|
||||
|
||||
提示
|
||||
安静模式(不显示完成 / 失败气泡) [Switch 关]
|
||||
```
|
||||
|
||||
- 总开关是本页唯一决定宠物窗口存在与否的控件,符合 `INV-3`;其余控件在总开关关闭时
|
||||
保持可见但不可交互,说明文字保持“关闭后不在桌面上显示任何宠物窗口或提示”。
|
||||
- 二元持久状态一律使用共享 Switch;角色选择使用共享 `SegmentedControl`(3-4 个内置
|
||||
角色,符合数量上限);尺寸、透明度和动画强度使用标准滑块控件并显示当前取值。
|
||||
- “恢复默认外观”只重置本区域三项外观设置,不改变总开关、位置或安静模式,与
|
||||
[功能逻辑](./logic-design.md) 中“恢复默认外观”的范围一致。
|
||||
- 页面不提供“立即显示 / 隐藏”按钮:暂时隐藏的入口固定放在托盘菜单,避免在设置页和
|
||||
托盘中出现两个含义相近但状态不同步的控件。
|
||||
|
||||
## 2. 首次启用与关闭反馈
|
||||
|
||||
- 开启总开关后宠物窗口在默认位置出现,不产生任何应用通知或原生通知,因为这是用户刚
|
||||
完成的显式操作,结果已经可见。
|
||||
- 关闭总开关后宠物窗口和任何气泡立即消失;设置页本身不产生额外通知。
|
||||
- 外观、位置相关 Switch 和滑块的变更立即生效并持久化,失败时通过应用通知报告,不静默
|
||||
丢弃用户输入。
|
||||
|
||||
## 3. 托盘入口
|
||||
|
||||
- 托盘菜单在“新建对话”与“设置”之间新增一项,文案随当前可见性变化:
|
||||
- 已启用且可见:“隐藏桌面宠物”。
|
||||
- 已启用但隐藏:“显示桌面宠物”。
|
||||
- 未启用:“显示桌面宠物”,点击后等同于在设置中开启总开关并显示窗口。
|
||||
- 该菜单项不复制设置页的开关语义,只切换可见性;未启用状态下点击才会连带开启总开关,
|
||||
行为需要在可访问名称或菜单说明中明确,避免用户误以为它只是显示/隐藏。
|
||||
|
||||
## 4. 宠物窗口
|
||||
|
||||
宠物窗口是无边框、透明背景、不可缩放的持久悬浮窗口,不使用 `PageShell`、不属于标准
|
||||
一级页面,因此以下规则专属于该窗口:
|
||||
|
||||
```text
|
||||
┌───────────┐
|
||||
│ 角色 │ ← 状态镜面:角色外观随任务状态切换
|
||||
│ (idle) │
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
- 窗口本身没有标题栏、边框或系统窗口控件;关闭宠物只能通过设置总开关、托盘“隐藏桌面
|
||||
宠物”或退出应用完成,窗口自身不提供关闭按钮,避免与“暂时隐藏”“关闭功能”两种含义
|
||||
混淆。
|
||||
- 窗口整体可拖动;拖动过程中显示当前吸附意图(开启贴边时,接近边缘会有轻微吸附反馈)。
|
||||
- 窗口右键菜单提供“快速起草”“暂时隐藏”“打开设置”“退出 GoodBuddy”,与托盘菜单保持
|
||||
同一套文案。
|
||||
|
||||
### 4.1 状态镜面外观
|
||||
|
||||
宠物角色在以下六种取值间切换,取值定义见[功能逻辑](./logic-design.md) 第 3 节:
|
||||
|
||||
| 状态 | 视觉表达 | 可访问名称 |
|
||||
| --- | --- | --- |
|
||||
| `unavailable` | 角色显示为暗淡的问号叠加 | “任务状态当前不可用” |
|
||||
| `idle` | 角色静止或轻微待机动作 | “没有进行中的任务” |
|
||||
| `running` | 角色显示持续的工作动作 | “有任务正在执行” |
|
||||
| `waiting_approval` | 角色举牌或举手动作,带醒目但非警示色的强调 | “有任务等待你的确认” |
|
||||
| `completed_recent` | 角色短暂展示完成动作后回到待机 | “有任务刚刚完成” |
|
||||
| `failed_recent` | 角色显示明显区别于完成的姿态,并叠加警示色边缘 | “有任务失败,需要查看” |
|
||||
|
||||
- 状态表达必须同时使用动作/姿态和可访问名称两种信号,不能只依赖颜色区分
|
||||
`completed_recent` 与 `failed_recent`。
|
||||
- `prefers-reduced-motion` 或用户将动画强度设为“关闭”时,状态切换只改变静态姿态和颜色
|
||||
强调,不播放位移或缩放动画。
|
||||
- 点击状态镜面区域(角色本体)触发 US-D4:显示并聚焦主窗口,Task Center 定位到触发
|
||||
当前状态的任务;`idle` 和 `unavailable` 状态下点击等同于显示并聚焦主窗口,不做定位。
|
||||
|
||||
### 4.2 快速起草弹出框
|
||||
|
||||
点击宠物打开的弹出框锚定在宠物窗口旁(自动避开屏幕边缘),结构如下:
|
||||
|
||||
```text
|
||||
发送到:[当前会话名称 v]
|
||||
|
||||
[多行文本输入,占位文案“输入消息,或拖入文件…”]
|
||||
|
||||
[📎 附加文件] [已附加:contract.docx ×]
|
||||
|
||||
[取消] [提交]
|
||||
```
|
||||
|
||||
- “发送到”是一个富信息单选菜单,选项为当前活跃会话与“新建对话”,遵循全局设计系统
|
||||
第 6.11 节的单选选择器规则;默认选中当前活跃会话。
|
||||
- 文本输入支持多行、`Ctrl+V` 粘贴图片和文字,与主窗口聊天输入框的粘贴行为一致。
|
||||
- 附加文件按钮打开原生文件选择;已附加文件以行内标签形式展示,可单独移除。
|
||||
- 拒绝的文件(超限或不支持)在附件区下方以就地错误文字展示,不使用应用级通知,因为
|
||||
这是与当前操作直接绑定的字段级反馈。
|
||||
- “提交”把文本和附件转交目标会话的输入区草稿,随后关闭弹出框;提交过程中禁用重复
|
||||
点击并显示进行中状态,成功后不额外弹出通知(主窗口的显现和草稿本身就是反馈)。
|
||||
- “取消”或点击弹出框外部或按 Escape 关闭弹出框并丢弃草稿,焦点返回宠物窗口。
|
||||
- 弹出框打开时焦点进入文本输入;Tab 顺序为文本输入 → 附加文件 → 已附加文件的移除
|
||||
按钮 → 取消 → 提交;关闭后焦点回到宠物窗口本体。
|
||||
|
||||
### 4.3 拖放反馈
|
||||
|
||||
- 文件或文字拖入宠物窗口范围时,窗口整体出现可放置的高亮边框和轻微放大反馈。
|
||||
- 释放后立即按第 4.2 节“提交”的相同路径处理,不额外弹出确认框;这保持“拖动=起草并
|
||||
发送到会话输入区(而非立即发送消息)”的一致语义。
|
||||
- 拖入文件夹或全部文件超限时,宠物短暂显示拒绝反馈(边框变为警示色并伴随简短文字
|
||||
提示,例如“不支持文件夹”),不打开主窗口、不产生暂存。
|
||||
- 部分文件超限、部分合法时,合法文件正常处理,超限文件的名称通过一条会在数秒后自动
|
||||
消失的就地提示列出,不使用应用级通知(拖放是即时操作,反馈应贴近操作发生的位置)。
|
||||
|
||||
### 4.4 完成 / 失败气泡
|
||||
|
||||
```text
|
||||
┌─────────────────────────────┐
|
||||
│ ✓ 已完成 │
|
||||
│ 合同风险检查 │
|
||||
│ [×] │
|
||||
└─────────────────────────────┘
|
||||
▲
|
||||
(角色)
|
||||
```
|
||||
|
||||
- 气泡锚定在宠物窗口旁,方向随宠物在屏幕上的位置自动避开边缘。
|
||||
- 完成气泡在约 4.5 秒后自动消失,与全局应用通知的成功/信息时长一致;失败气泡保持可见
|
||||
直到用户点击查看或点击关闭按钮。
|
||||
- 气泡本体可点击,等同于点击状态镜面:显示并聚焦主窗口并定位到对应任务。
|
||||
- 关闭按钮 `[×]` 有独立可访问名称(“关闭提示”),不与气泡主体的点击目标冲突:点击
|
||||
关闭按钮只关闭气泡,不导航到主窗口。
|
||||
- 气泡使用 `status`/polite 实时区域播报完成,失败气泡使用 `alert`/assertive,与全局
|
||||
设计系统的通知语义保持一致,即使气泡本身不是应用通知视口的一部分。
|
||||
|
||||
## 5. 与应用通知视口的关系
|
||||
|
||||
- 宠物气泡是应用通知视口的窗口外等价物,只在主窗口不可见/未聚焦且安静模式关闭时出现;
|
||||
同一事件不会同时进入应用通知视口和宠物气泡,具体归并规则见
|
||||
[功能逻辑](./logic-design.md) 第 4 节。
|
||||
- 快速起草和拖放产生的字段级错误(附件超限、不支持类型)始终就地展示在宠物窗口或弹出
|
||||
框内,不进入应用通知视口,保持“就地错误”与“全局通知”分工一致。
|
||||
|
||||
## 6. 无障碍
|
||||
|
||||
- 状态镜面、气泡和弹出框内的所有交互元素都有持久可访问名称,不依赖占位文案代替标签。
|
||||
- 宠物窗口可通过系统级窗口切换获得焦点;获得焦点后:
|
||||
- 预期的激活键(例如 Enter 或 Space)打开快速起草弹出框。
|
||||
- 弹出框内 Tab 顺序遵循第 4.2 节。
|
||||
- Escape 关闭当前打开的弹出框或气泡,焦点回到宠物窗口本体。
|
||||
- 状态镜面的状态变化通过 `status`/polite 实时区域播报简短说明,避免因动画本身无法被
|
||||
屏幕阅读器感知而丢失信息;不对每次动画帧重复播报。
|
||||
- 焦点环、危险色使用和触控目标尺寸遵循全局设计系统第 11 节的通用要求。
|
||||
|
||||
## 7. 响应式与多显示器
|
||||
|
||||
- 宠物窗口尺寸不随主窗口大小变化;用户通过设置中的“尺寸”滑块单独控制。
|
||||
- 宠物窗口位置按显示器分别记忆;显示器不可用时的回退规则见
|
||||
[功能逻辑](./logic-design.md) 第 7 节,界面表现为宠物出现在主显示器的默认可见位置,
|
||||
不出现在屏幕之外或不可见状态。
|
||||
- 弹出框和气泡的锚定方向根据宠物当前位置与屏幕边缘的距离自动选择展开方向(上/下/左/
|
||||
右),确保始终完整可见,不被裁切到屏幕外。
|
||||
|
||||
## 8. 深浅主题
|
||||
|
||||
- 弹出框、气泡和设置页控件复用全局语义令牌,不新增页面专属颜色。
|
||||
- 宠物角色素材本身分别提供适配浅色和深色桌面背景的版本,避免透明背景下角色轮廓在深色
|
||||
桌面壁纸上难以辨认;具体资源交付方式属于技术设计范畴。
|
||||
|
||||
## 9. 固定词汇
|
||||
|
||||
使用“桌面宠物”“启用桌面宠物”“暂时隐藏”“快速起草”“安静模式”“状态镜面”,定义见
|
||||
[README](./README.md) 术语表。不使用“挂件”“桌宠助手”或其他未在术语表中定义的别名,
|
||||
保持中文文案统一。
|
||||
@@ -0,0 +1,255 @@
|
||||
# 桌面宠物 User Stories
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [桌面宠物 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
|
||||
## 角色
|
||||
|
||||
- **日常用户**:在多任务、多显示器场景下使用 GoodBuddy 处理日常工作。
|
||||
- **首次体验用户**:刚升级或安装 GoodBuddy,尚未接触过桌面宠物功能。
|
||||
- **键盘用户**:主要通过键盘操作,可能同时使用屏幕阅读器。
|
||||
- **多显示器用户**:使用两个或更多显示器,可能随时插拔外接显示器。
|
||||
|
||||
## US-A 首次启用
|
||||
|
||||
### US-A1 默认不出现
|
||||
|
||||
**Given** 用户全新安装或刚升级 GoodBuddy
|
||||
**When** 用户尚未进入“桌面宠物”设置
|
||||
**Then** 桌面上不出现任何宠物窗口,托盘菜单显示“显示桌面宠物”而非“隐藏”
|
||||
|
||||
### US-A2 首次启用
|
||||
|
||||
**Given** 用户在设置中打开“启用桌面宠物”总开关
|
||||
**When** 开关切换为开启
|
||||
**Then** 宠物窗口在默认位置出现,使用默认外观,且不产生任何应用级或原生通知
|
||||
|
||||
### US-A3 关闭后完全消失
|
||||
|
||||
**Given** 桌面宠物已启用并可见
|
||||
**When** 用户关闭“启用桌面宠物”总开关
|
||||
**Then** 宠物窗口立即消失,未完成的气泡提示一并消失,托盘菜单条目恢复为“显示桌面宠物”,
|
||||
且用户之前设置的位置、外观和提示偏好被保留供下次开启时使用
|
||||
|
||||
## US-B 快速起草
|
||||
|
||||
### US-B1 起草文字并转入主窗口
|
||||
|
||||
**Given** 桌面宠物已启用,主窗口当前隐藏
|
||||
**When** 用户点击宠物并输入一段文字后点击提交
|
||||
**Then** 主窗口被显示并聚焦,目标会话的输入区出现刚输入的文字作为草稿,输入焦点落在
|
||||
该输入区,且没有任何消息被发送、没有任何任务被创建
|
||||
|
||||
### US-B2 起草时附加文件
|
||||
|
||||
**Given** 快速起草弹出框已打开
|
||||
**When** 用户点击“附加文件”并选择一个受支持类型和大小内的文件
|
||||
**Then** 该文件在弹出框内显示为待提交附件;提交后该附件出现在主窗口对应会话输入区的
|
||||
待发送附件列表中
|
||||
|
||||
### US-B3 附加不受支持的文件
|
||||
|
||||
**Given** 快速起草弹出框已打开
|
||||
**When** 用户选择一个超出大小限制或类型不受支持的文件
|
||||
**Then** 弹出框就地显示明确的拒绝原因,不添加该文件,且不影响已经添加的其他文字或附件
|
||||
|
||||
### US-B4 取消起草
|
||||
|
||||
**Given** 用户已在快速起草弹出框中输入文字或添加附件
|
||||
**When** 用户点击取消或按 Escape 关闭弹出框
|
||||
**Then** 弹出框关闭且草稿被丢弃,主窗口任何会话的现有草稿不受影响,且不创建任何草稿或
|
||||
任务记录
|
||||
|
||||
### US-B5 选择目标会话
|
||||
|
||||
**Given** 主窗口当前存在多个会话,且用户在快速起草弹出框中看到默认目标会话
|
||||
**When** 用户在弹出框内切换目标为“新建对话”
|
||||
**Then** 提交后主窗口创建并激活一个新会话,草稿内容进入该新会话的输入区,不影响原有
|
||||
会话的草稿
|
||||
|
||||
## US-C 拖放暂存
|
||||
|
||||
### US-C1 拖放单个文件
|
||||
|
||||
**Given** 桌面宠物已启用并可见
|
||||
**When** 用户从文件管理器拖动一个受支持类型的文件到宠物窗口上并释放
|
||||
**Then** 宠物在拖入过程中显示可放置的视觉反馈,释放后主窗口被显示并聚焦,该文件出现在
|
||||
目标会话输入区的待发送附件列表中
|
||||
|
||||
### US-C2 拖放多个文件,部分超限
|
||||
|
||||
**Given** 用户同时拖动数量或大小超出限制的多个文件到宠物窗口
|
||||
**When** 用户释放拖放
|
||||
**Then** 合法文件正常进入目标会话的待发送附件列表,超限或不支持的文件被单独列出拒绝
|
||||
原因,不阻止合法文件被暂存
|
||||
|
||||
### US-C3 拖放文件夹
|
||||
|
||||
**Given** 用户拖动一个文件夹到宠物窗口
|
||||
**When** 用户释放拖放
|
||||
**Then** 宠物提示不支持文件夹,不递归展开文件夹内容,也不产生任何暂存
|
||||
|
||||
### US-C4 拖放选中文字
|
||||
|
||||
**Given** 用户在其他应用中选中一段文字并拖动到宠物窗口
|
||||
**When** 用户释放拖放
|
||||
**Then** 该文字进入目标会话输入区的草稿文本,行为与快速起草输入文字一致
|
||||
|
||||
## US-D 状态镜面
|
||||
|
||||
### US-D1 查看空闲状态
|
||||
|
||||
**Given** 当前没有任何活跃 Task
|
||||
**When** 用户查看宠物外观
|
||||
**Then** 宠物显示空闲状态,且可访问名称明确说明当前没有进行中的任务
|
||||
|
||||
### US-D2 查看执行中状态
|
||||
|
||||
**Given** 有一个 Task 处于执行中
|
||||
**When** 用户查看宠物外观
|
||||
**Then** 宠物切换为执行中状态,且该状态与 Task Center 中该任务的状态一致
|
||||
|
||||
### US-D3 多任务状态优先级
|
||||
|
||||
**Given** 同时存在一个等待处理的任务和一个执行中的任务
|
||||
**When** 用户查看宠物外观
|
||||
**Then** 宠物按照“等待处理优先于执行中”的规则显示等待处理状态
|
||||
|
||||
### US-D4 点击状态镜面打开任务详情
|
||||
|
||||
**Given** 宠物当前显示执行中或失败状态
|
||||
**When** 用户点击宠物的状态镜面区域
|
||||
**Then** 主窗口被显示并聚焦,Task Center 定位到触发当前状态的具体任务
|
||||
|
||||
### US-D5 状态不可用
|
||||
|
||||
**Given** 用于确认任务状态的 Runtime 或网络当前不可用
|
||||
**When** 用户查看宠物外观
|
||||
**Then** 宠物显示“状态不可用”,不回退显示为空闲或执行中
|
||||
|
||||
## US-E 完成与失败提示
|
||||
|
||||
### US-E1 主窗口不可见时收到完成提示
|
||||
|
||||
**Given** 主窗口当前隐藏或未聚焦,安静模式未开启
|
||||
**When** 一个任务从执行中变为已完成
|
||||
**Then** 宠物旁出现说明任务标题和结果的气泡,且该事件不重复触发原生系统通知
|
||||
|
||||
### US-E2 失败提示保持可见
|
||||
|
||||
**Given** 主窗口当前隐藏,安静模式未开启
|
||||
**When** 一个任务失败
|
||||
**Then** 宠物旁出现失败气泡并保持可见,直到用户点击查看或手动关闭,不自动消失
|
||||
|
||||
### US-E3 主窗口可见时不重复提示
|
||||
|
||||
**Given** 主窗口当前可见并聚焦
|
||||
**When** 一个任务完成或失败
|
||||
**Then** 不出现宠物气泡;反馈通过主窗口现有机制呈现,避免同一事件重复出现在两个位置
|
||||
|
||||
### US-E4 开启安静模式
|
||||
|
||||
**Given** 用户在设置中开启“安静模式”
|
||||
**When** 之后任意任务完成或失败,且主窗口不可见
|
||||
**Then** 不出现任何宠物气泡,但状态镜面外观仍随任务状态正常变化
|
||||
|
||||
## US-F 位置、外观与多显示器
|
||||
|
||||
### US-F1 记忆位置
|
||||
|
||||
**Given** 用户把宠物拖动到某个显示器的特定位置
|
||||
**When** 用户重启 GoodBuddy
|
||||
**Then** 宠物出现在同一显示器的同一位置
|
||||
|
||||
### US-F2 显示器断开后回退
|
||||
|
||||
**Given** 宠物位置记忆在一个已插入的外接显示器上
|
||||
**When** 该外接显示器被移除后重新启动 GoodBuddy
|
||||
**Then** 宠物出现在当前可用的主显示器上一个可见、不越界的位置,而不是不可见或位于屏幕
|
||||
之外
|
||||
|
||||
### US-F3 贴边吸附
|
||||
|
||||
**Given** 用户开启“贴边”选项
|
||||
**When** 用户把宠物拖动到接近屏幕边缘的位置并释放
|
||||
**Then** 宠物自动吸附到屏幕边缘,且吸附后仍可被再次拖动离开
|
||||
|
||||
### US-F4 调整外观
|
||||
|
||||
**Given** 用户在设置中调整宠物尺寸、透明度或动画强度
|
||||
**When** 用户改变任意一项取值
|
||||
**Then** 宠物外观立即按新取值更新,且设置持久化,重启后保持
|
||||
|
||||
### US-F5 恢复默认外观
|
||||
|
||||
**Given** 用户已经修改过角色、尺寸、透明度或动画强度
|
||||
**When** 用户点击“恢复默认外观”
|
||||
**Then** 上述设置全部恢复为默认值,宠物位置和总开关状态不受影响
|
||||
|
||||
## US-G 暂时隐藏与托盘
|
||||
|
||||
### US-G1 暂时隐藏
|
||||
|
||||
**Given** 桌面宠物已启用并可见
|
||||
**When** 用户从托盘菜单选择“隐藏桌面宠物”
|
||||
**Then** 宠物窗口消失,总开关状态和其他设置不变,托盘菜单条目变为“显示桌面宠物”
|
||||
|
||||
### US-G2 重新显示
|
||||
|
||||
**Given** 桌面宠物已启用但当前隐藏
|
||||
**When** 用户从托盘菜单选择“显示桌面宠物”
|
||||
**Then** 宠物窗口在此前记忆的位置重新出现
|
||||
|
||||
### US-G3 应用退出
|
||||
|
||||
**Given** 桌面宠物已启用并可见
|
||||
**When** 用户退出 GoodBuddy
|
||||
**Then** 宠物窗口一并关闭,不留下任何独立运行的进程或残留窗口
|
||||
|
||||
## US-H 键盘与无障碍
|
||||
|
||||
### US-H1 键盘打开快速起草
|
||||
|
||||
**Given** 桌面宠物窗口通过系统级窗口切换取得焦点
|
||||
**When** 键盘用户按下预期的激活键
|
||||
**Then** 快速起草弹出框打开,焦点进入输入框
|
||||
|
||||
### US-H2 键盘关闭弹出框
|
||||
|
||||
**Given** 快速起草弹出框已打开且有键盘焦点
|
||||
**When** 用户按下 Escape
|
||||
**Then** 弹出框关闭,焦点回到宠物窗口本身
|
||||
|
||||
### US-H3 屏幕阅读器状态说明
|
||||
|
||||
**Given** 用户使用屏幕阅读器
|
||||
**When** 焦点移动到宠物的状态镜面
|
||||
**Then** 屏幕阅读器播报当前状态的文字说明,不仅依赖颜色或动画表达状态
|
||||
|
||||
## US-I 兼容与边界
|
||||
|
||||
### US-I1 升级用户保持现状
|
||||
|
||||
**Given** 已在使用 GoodBuddy 且从未接触过桌面宠物功能的用户升级到包含该功能的版本
|
||||
**When** 用户正常启动应用
|
||||
**Then** 桌面宠物保持关闭,界面和行为与升级前一致
|
||||
|
||||
### US-I2 不支持透明合成的环境
|
||||
|
||||
**Given** 用户的操作系统或桌面环境不支持窗口透明合成
|
||||
**When** 用户启用桌面宠物
|
||||
**Then** 宠物窗口退化为不透明的小型窗口而不是渲染异常或无法显示,具体规则见
|
||||
[技术设计](./technical-design.md)
|
||||
|
||||
### US-I3 拖放到非目标区域
|
||||
|
||||
**Given** 用户尝试把文件拖到宠物窗口之外的桌面区域
|
||||
**When** 用户释放拖放
|
||||
**Then** GoodBuddy 不接收该拖放,不产生任何暂存或错误提示
|
||||
@@ -0,0 +1,28 @@
|
||||
# 设备发现与共享
|
||||
|
||||
本功能是 GoodBuddy 桌面端管理附近设备、本机共享和 ShareServer 连接的唯一入口。默认连接
|
||||
方式是局域网;网关由用户额外添加。功能不包含 ShareServer 的组织管理 Web 界面。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [PRD](./prd.md) | 用户问题、范围、功能需求和产品验收 |
|
||||
| [功能逻辑](./logic-design.md) | 不变量、状态、授权优先级和失败结果 |
|
||||
| [UI 设计](./ui-design.md) | 设置入口、四个页签、配对与调用交互 |
|
||||
| [技术设计](./technical-design.md) | Desktop 进程边界、持久化、IPC、网络和验证 |
|
||||
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
|
||||
|
||||
当前没有单独的 User Stories:正常、失败、撤销、权限和兼容场景已直接进入 PRD 验收与逻辑
|
||||
决策表。功能尚未实施,因此不创建 `progress.md`。
|
||||
|
||||
## 术语
|
||||
|
||||
- **附近设备**:同一局域网内主动广播 GoodBuddy 共享服务的设备。
|
||||
- **已配对设备**:双方确认设备指纹后建立点对点信任的设备。
|
||||
- **网关连接**:本机与某个 ShareServer 中特定组织身份的连接。
|
||||
- **本机共享**:由本机作为提供者发布给 Peer 或组织的能力。
|
||||
- **远端能力**:由其他设备或组织提供、本机可以查看或调用的能力。
|
||||
|
||||
SSH 远程项目是用户明确配置 Host 后操作远端工作区的独立功能,不自动进入设备发现与共享,
|
||||
详见[远程主机与远程执行](../remote-host/README.md)。
|
||||
@@ -0,0 +1,202 @@
|
||||
# 设备发现与共享功能逻辑设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
|
||||
| 共享规则 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
|
||||
本文负责桌面端状态、不变量和决策规则,不重复跨端协议定义。
|
||||
|
||||
## 1. 核心不变量
|
||||
|
||||
### INV-1 连接方式独立
|
||||
|
||||
局域网、每个 GatewayConnection 和本机能力互不拥有生命周期。启停、失败或删除其中一项不
|
||||
改写其他项。
|
||||
|
||||
### INV-2 三项操作独立
|
||||
|
||||
`discovering`、`advertising` 和 `publishing` 是三个独立状态。任何组合均合法,界面不得用
|
||||
一个“设备共享”总开关代替。
|
||||
|
||||
### INV-3 默认不共享
|
||||
|
||||
新安装没有 Publication。允许被发现只公开配对入口,不公开能力清单。
|
||||
|
||||
### INV-4 身份先于能力
|
||||
|
||||
未配对局域网设备不能取得详细能力;未注册网关设备不能取得组织目录。缓存不能绕过当前
|
||||
身份和撤销状态继续调用。
|
||||
|
||||
### INV-5 提供者在调用前冻结
|
||||
|
||||
自动选择只生成候选决定。用户确认或无需确认的请求创建后,provider、route 和授权快照不可
|
||||
改变。失败后切换提供者属于新的请求。
|
||||
|
||||
### INV-6 最小发送上下文
|
||||
|
||||
只发送能力 schema 要求且用户当前选择的输入。当前 Conversation、Project 和 Workspace 不
|
||||
因能力被调用而整体成为输入。
|
||||
|
||||
### INV-7 结果未知不重放
|
||||
|
||||
已经被提供者接受但终态无法确认的副作用任务进入 `outcome_unknown`。重新执行必须由用户
|
||||
发起,并显示可能重复产生副作用。
|
||||
|
||||
### INV-8 撤销只影响未来授权
|
||||
|
||||
撤销配对、网关连接、Grant 或 Publication 后不再创建新任务。历史记录保留;活动任务按
|
||||
冻结策略取消或完成,不能追改为从未发生。
|
||||
|
||||
### INV-9 设置导航无业务副作用
|
||||
|
||||
四个 PageTabs 只切换视图,不自动开始广播、连接网关、发布能力或保存草稿。
|
||||
|
||||
## 2. 状态维度
|
||||
|
||||
### 2.1 局域网服务
|
||||
|
||||
```text
|
||||
discover: off | starting | active | unavailable | error
|
||||
advertise: off | starting | active | unavailable | error
|
||||
```
|
||||
|
||||
操作系统或网络不支持时使用 `unavailable`;运行失败使用 `error`。两者分别重试。
|
||||
|
||||
### 2.2 附近设备
|
||||
|
||||
```text
|
||||
visible_unpaired -> pairing -> paired_online
|
||||
pairing -> rejected | expired | failed
|
||||
paired_online <-> paired_offline
|
||||
paired_* -> identity_changed | revoked
|
||||
```
|
||||
|
||||
`identity_changed` 不自动接受新证书。用户必须撤销旧配对后重新配对。
|
||||
|
||||
### 2.3 网关连接
|
||||
|
||||
```text
|
||||
draft -> authenticating -> connecting -> connected
|
||||
authenticating -> cancelled | auth_failed
|
||||
connecting/connected -> offline | auth_expired | policy_blocked
|
||||
offline -> connecting
|
||||
任意已保存状态 -> removed
|
||||
```
|
||||
|
||||
`offline` 保留连接和缓存目录供只读说明,但不能创建新任务;`auth_expired` 要求重新登录;
|
||||
`policy_blocked` 显示服务端给出的稳定原因和管理员联系路径。
|
||||
|
||||
### 2.4 本机发布
|
||||
|
||||
```text
|
||||
not_published -> draft -> validating -> published
|
||||
validating -> invalid
|
||||
published -> paused -> published
|
||||
published/paused -> removed
|
||||
```
|
||||
|
||||
草稿和无效发布不对外可见。能力实现消失时发布进入 `paused`,不删除用户配置。
|
||||
|
||||
### 2.5 远端任务
|
||||
|
||||
采用共享架构定义的状态。本机 UI 归并为:
|
||||
|
||||
| 协议状态 | 用户状态 |
|
||||
| --- | --- |
|
||||
| `created/awaiting_approval` | 等待确认 |
|
||||
| `queued/connecting` | 正在连接 |
|
||||
| `running` | 正在执行 |
|
||||
| `completed` | 已完成 |
|
||||
| `failed/rejected/expired` | 未完成,并显示具体原因 |
|
||||
| `cancelled` | 已取消 |
|
||||
| `outcome_unknown` | 结果未知 |
|
||||
|
||||
## 3. 默认状态
|
||||
|
||||
| 设置 | 全新安装默认值 |
|
||||
| --- | --- |
|
||||
| 发现附近设备 | 开启 |
|
||||
| 允许其他设备发现本机 | 关闭 |
|
||||
| 自动接受配对 | 不支持 |
|
||||
| 本机共享能力 | 无 |
|
||||
| 网关连接 | 无 |
|
||||
| 永久调用 Grant | 无 |
|
||||
|
||||
主动发现默认开启只浏览 GoodBuddy 主动广播,不执行端口扫描。操作系统权限尚未授予时,首次
|
||||
进入页面再触发对应系统说明和授权,不在应用启动时抢占焦点。
|
||||
|
||||
## 4. 提供者选择
|
||||
|
||||
自动选择按以下顺序比较满足当前输入和授权的在线候选:
|
||||
|
||||
1. 本机提供者。
|
||||
2. 已配对局域网提供者。
|
||||
3. 当前用户所属组织提供者。
|
||||
4. 联邦外部组织提供者。
|
||||
5. 同级中按服务端预计排队时间和稳定能力版本排序。
|
||||
|
||||
自动选择不跨越 `require_approval` 与 `allow`:若排名第一的候选需要批准,界面显示该候选及
|
||||
批准要求,不静默换成权限更宽但来源更远的候选。用户可以明确选择其他候选。
|
||||
|
||||
## 5. 调用决策表
|
||||
|
||||
| 身份与能力状态 | 授权结果 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| 身份有效、输入匹配 | allow | 创建任务并展示执行位置 |
|
||||
| 身份有效、输入匹配 | require_approval | 显示完整确认后创建 |
|
||||
| 身份有效 | deny | 不创建任务,显示策略原因 |
|
||||
| 提供者离线 | 任意 | 不创建任务,允许重新选提供者 |
|
||||
| 清单过期或版本变化 | 任意 | 刷新后重新计算,不沿用确认 |
|
||||
| 文件超过能力或策略上限 | 任意 | 就地拒绝并显示上限 |
|
||||
| 委托链与确认不一致 | 任意 | 拒绝并要求以新执行链重新确认 |
|
||||
|
||||
## 6. 撤销与删除
|
||||
|
||||
| 操作 | 删除内容 | 保留内容 |
|
||||
| --- | --- | --- |
|
||||
| 撤销 Peer | 配对凭据、该 Peer 的本地 Grant 和目录缓存 | 活动记录、已安装包 |
|
||||
| 移除网关 | 本机设备凭据、连接配置、目录缓存 | 服务端账号、活动记录、已安装包 |
|
||||
| 停止发布 | 对外 Publication | 能力实现、发布草稿、历史任务 |
|
||||
| 删除发布 | Publication 和本地发布设置 | 能力实现、历史任务 |
|
||||
| 删除已安装包 | GoodBuddy-owned 安装内容和分配 | 来源审计与任务历史 |
|
||||
|
||||
存在活动任务时,撤销操作明确询问是否同时发送取消;无论取消是否确认,立即禁止新任务。
|
||||
|
||||
## 7. 失败与恢复
|
||||
|
||||
- mDNS 不可用:显示手动地址配对,不关闭已配对直连。
|
||||
- 广播失败:保留用户开关意图,状态为错误并允许重试。
|
||||
- 网关离线:指数退避重连,用户手动“重新连接”可立即尝试一次。
|
||||
- 登录取消:不保存连接。
|
||||
- 目录刷新失败:保留最后缓存并标记时间,不允许基于过期授权启动新任务。
|
||||
- 调用前连接失败:任务失败,可安全重新执行。
|
||||
- 提供者接受后断线:恢复查询同一任务;仍无法确认时进入结果未知。
|
||||
- 下载或签名失败:删除候选暂存,保留现有安装,不切换来源。
|
||||
|
||||
## 8. 需求追踪
|
||||
|
||||
| 需求 | 主要逻辑 |
|
||||
| --- | --- |
|
||||
| `FR-1`、`FR-2` | `INV-1`、`INV-2`、`INV-9` 和默认状态 |
|
||||
| `FR-3`、`FR-4` | 身份不变量和附近设备状态机 |
|
||||
| `FR-5` | 连接独立不变量、网关连接状态和删除规则 |
|
||||
| `FR-6` | provider 冻结不变量和提供者选择顺序 |
|
||||
| `FR-7` | 默认不共享不变量、本机发布状态和撤销规则 |
|
||||
| `FR-8` | 最小发送上下文和调用决策表 |
|
||||
| `FR-9` | 远端任务状态、结果未知不重放和失败恢复 |
|
||||
| `FR-10` | 撤销与删除、下载或签名失败规则;包协议由共享架构定义 |
|
||||
| `FR-11` | 撤销后历史保留和任务状态;展示行为由 UI 设计定义 |
|
||||
| `FR-12`、`FR-13` | 独立状态维度和失败恢复 |
|
||||
| `FR-14` | 身份先于能力、最小发送上下文和调用决策表 |
|
||||
|
||||
## 9. 逻辑完整性
|
||||
|
||||
本设计已覆盖发现、配对、连接、发布、调用、取消、撤销、离线、身份变化、权限变化和能力
|
||||
更新。实现前仍需在共享协议中固定受限 JSON Schema 子集、端到端中继加密套件和桌面端可
|
||||
注册为共享能力的首批实际类型;这些是技术选择,不改变本文状态和产品规则。
|
||||
@@ -0,0 +1,160 @@
|
||||
# 设备发现与共享 PRD
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 适用产品 | GoodBuddy Desktop |
|
||||
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
| 关联文档 | [功能逻辑](./logic-design.md)、[UI 设计](./ui-design.md)、[技术设计](./technical-design.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
GoodBuddy 的能力当前主要在本机执行,SSH 远程项目则要求用户预先配置具体 Host。个人和组织
|
||||
需要在不复制完整环境的情况下使用附近工作站、其他成员设备或组织节点上的模型、知识、硬件
|
||||
和工具,也需要在经过确认后把可分发能力安装到本机。
|
||||
|
||||
该能力不能以“扫描局域网机器”的方式实现。产品只发现主动开放 GoodBuddy 服务的设备,
|
||||
配对后才查看详细能力,授权后才发送数据或执行任务。
|
||||
|
||||
## 2. 产品目标
|
||||
|
||||
1. 用户无需配置服务器即可发现并配对同一局域网中的 GoodBuddy 设备。
|
||||
2. 用户可以额外连接一个或多个 ShareServer,使用组织或合作组织提供的能力。
|
||||
3. 用户能够逐项决定本机向哪些 Peer、用户组或组织开放什么能力。
|
||||
4. 远程调用前、中、后都能确认提供者、执行位置、数据去向和授权依据。
|
||||
5. 局域网、各网关连接和本机能力彼此独立,单点故障不阻塞其他来源。
|
||||
6. 可分发能力必须在明确校验和批准后安装,不迁移对方凭据或机器配置。
|
||||
|
||||
## 3. 功能范围
|
||||
|
||||
### 3.1 包含
|
||||
|
||||
- 设置中的“设备发现与共享”一级分类。
|
||||
- 局域网主动发现、选择、双端配对、重命名和撤销。
|
||||
- 本机被发现开关和配对请求处理。
|
||||
- ShareServer 添加、登录、连接状态、组织身份和移除。
|
||||
- 远端能力目录、提供者选择、调用确认、进度、取消和结果。
|
||||
- 本机能力逐项发布、范围、允许动作和确认策略。
|
||||
- 签名能力包查看、下载、校验、安装和权限变化确认。
|
||||
- 局域网与网关活动记录。
|
||||
|
||||
### 3.2 不包含
|
||||
|
||||
- 扫描未主动广播 GoodBuddy 服务的机器或端口。
|
||||
- 在桌面端管理 ShareServer 成员、组织策略、联邦或服务器运维。
|
||||
- 自动公开本机 Skill、MCP、知识库、模型、文件或 SSH Host。
|
||||
- 同步 API Key、Cookie、系统环境变量、绝对路径或登录状态。
|
||||
- 把现有 SSH Host 自动转换为共享设备。
|
||||
- 无界远程 Shell、桌面控制或任意文件浏览接口。
|
||||
- 在网关断开时把组织授权自动降级为局域网授权。
|
||||
|
||||
## 4. 功能需求
|
||||
|
||||
### FR-1 设置入口
|
||||
|
||||
- 设置中心增加“设备发现与共享”一级分类。
|
||||
- 页面提供“局域网”“网关”“本机共享”“活动记录”四个同级页签。
|
||||
- 普通入口默认打开“局域网”;定向入口可打开指定页签。
|
||||
|
||||
### FR-2 独立开关
|
||||
|
||||
- “发现附近设备”和“允许其他设备发现本机”使用两个独立 Switch。
|
||||
- 任一开关变化不撤销既有配对、不改变网关连接、不开放能力。
|
||||
- 新安装默认允许主动发现;默认不允许本机被发现,默认无共享能力。
|
||||
|
||||
### FR-3 附近设备
|
||||
|
||||
- 只展示当前收到有效主动广播的 GoodBuddy 设备。
|
||||
- 未配对设备只展示配对所需的有限信息,不展示能力和资产详情。
|
||||
- 列表区分附近未配对、已配对在线、已配对离线和身份变化。
|
||||
- 用户可手动刷新,但持续发现不得因刷新造成列表闪烁或重复项。
|
||||
|
||||
### FR-4 双端配对
|
||||
|
||||
- 双方显示相同验证码、设备名称、系统类型和证书指纹。
|
||||
- 只有双方在时限内确认才建立配对。
|
||||
- 任一端拒绝、超时、身份变化或连接中断均不保存半配对关系。
|
||||
- 任一端可以撤销;撤销不删除历史活动记录。
|
||||
|
||||
### FR-5 网关连接
|
||||
|
||||
- 用户可输入 HTTPS ShareServer 地址或打开受支持的邀请链接。
|
||||
- 登录在系统浏览器中完成,设备凭据回到 Main 的安全存储。
|
||||
- 连接条目显示服务名、组织、当前成员身份、状态和最近策略同步时间。
|
||||
- 同一设备可连接多个网关;移除连接只删除本机凭据和缓存,不删除服务端账号。
|
||||
|
||||
### FR-6 能力目录
|
||||
|
||||
- 已配对 Peer 和已连接网关分别返回当前身份获准查看的能力。
|
||||
- 同一能力有多个提供者时合并展示,并允许“自动选择”或明确选择提供者。
|
||||
- 自动选择仅比较满足授权和输入要求的在线候选,依次选择本机、已配对局域网 Peer、
|
||||
当前用户所属组织和联邦外部组织;同级候选再按预计等待时间和稳定能力版本排序。
|
||||
- 选择结果必须在执行前冻结并展示,不能执行中静默更换提供者。
|
||||
|
||||
### FR-7 本机共享
|
||||
|
||||
- 只有显式注册为可共享的能力才能出现在本机共享列表。
|
||||
- 每项能力分别设置目标范围、`discover/inspect/invoke/download` 动作、输入类型、大小、
|
||||
并发、有效期和是否逐次确认。
|
||||
- 关闭能力或目标范围后停止接受新任务,不伪装为删除能力本身。
|
||||
- 知识库原文、工作区写入、进程执行和硬件控制需要独立的明确能力声明。
|
||||
|
||||
### FR-8 调用确认
|
||||
|
||||
- 首次或策略要求时,确认界面显示提供者、执行位置、接收方、输入内容类别、文件名和大小、
|
||||
路由、中继可读性及下游委托。
|
||||
- 用户可取消、仅本次允许,或在组织策略允许时按当前范围保存授权。
|
||||
- 调用上下文不隐式包含其他会话消息、整个工作区或未选择的知识库内容。
|
||||
|
||||
### FR-9 执行状态
|
||||
|
||||
- 会话或任务活动持续显示远端能力名称、来源、状态、发送字节和停止入口。
|
||||
- 支持排队、连接、执行、完成、失败、取消、过期和结果未知。
|
||||
- 网络中断且副作用结果无法确认时显示“结果未知”,不自动重试。
|
||||
|
||||
### FR-10 能力包
|
||||
|
||||
- 展示来源、版本、发布者、平台、大小、签名、权限和依赖。
|
||||
- 下载和安装经过共享架构规定的校验流程。
|
||||
- 新版本扩大权限时必须重新批准。
|
||||
- 安装到本机后按现有 Skill/MCP/Runtime 分配规则启用,不因来自可信组织自动取得全部 Runtime。
|
||||
|
||||
### FR-11 活动记录
|
||||
|
||||
- 统一记录本机发出、远端发入、局域网、网关和跨组织活动。
|
||||
- 支持按方向、来源、能力、结果和时间筛选。
|
||||
- 详情显示授权依据、执行链、数据类别、大小、版本和终态,不默认保存正文。
|
||||
|
||||
### FR-12 错误隔离
|
||||
|
||||
- 局域网发现失败不影响网关和本机能力。
|
||||
- 一个网关认证或连接失败不影响其他网关。
|
||||
- 一个提供者离线时不自动改发给另一个提供者;用户重新执行时再选择。
|
||||
- 页面保留可修复配置和准确错误,不以空状态代替失败状态。
|
||||
|
||||
### FR-13 平台与网络
|
||||
|
||||
- 支持 Windows、macOS、Linux 的当前桌面目标架构。
|
||||
- mDNS 被网络阻断时允许通过已知局域网地址发起配对,但仍执行相同身份确认。
|
||||
- 所有公网 ShareServer 使用 HTTPS;不提供“忽略证书错误”。
|
||||
|
||||
### FR-14 与现有能力的边界
|
||||
|
||||
- 远端调用不能扩大 Ask/Execute、Workspace、MCP、Skill 和知识库现有权限。
|
||||
- 本机提供任务时使用能力声明对应的 Runtime 边界。
|
||||
- SSH 远程项目、本机工具环境和模型配置默认不向 Peer 或网关传播。
|
||||
|
||||
## 5. 产品验收
|
||||
|
||||
- 两台新设备能完成发现、双端配对、授权、调用、取消和撤销。
|
||||
- 三个概念“主动发现、允许被发现、开放能力”在状态和界面上互不混淆。
|
||||
- 不配置网关时局域网工作完整;任一网关故障不阻塞其他来源。
|
||||
- 未配对或未授权主体无法查看详细能力。
|
||||
- 远端调用前能准确列出实际发送的数据,执行中和历史中可追溯提供者。
|
||||
- 结果未知不会被显示为失败后自动重试或成功。
|
||||
- 权限扩大的能力更新不会沿用旧授权静默安装。
|
||||
- 中英文、键盘、焦点、窄窗口和深浅主题符合统一设计系统。
|
||||
@@ -0,0 +1,213 @@
|
||||
# 设备发现与共享技术设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
|
||||
## 1. 进程边界
|
||||
|
||||
```text
|
||||
Renderer
|
||||
-> 窄 Preload API
|
||||
Main
|
||||
-> DeviceSharingService
|
||||
-> LanDiscoveryService
|
||||
-> PeerPairingService
|
||||
-> GatewayConnectionService
|
||||
-> CapabilityCatalogService
|
||||
-> CapabilityPublicationService
|
||||
-> RemoteTaskService
|
||||
-> PackageAcquisitionService
|
||||
-> SharingAuditStore
|
||||
-> 既有 Capability / Runtime / Skill / MCP 服务
|
||||
```
|
||||
|
||||
Renderer 只取得脱敏快照和调用显式动作。设备私钥、Peer 证书、网关凭据、网络 socket、原始
|
||||
任务令牌、任意文件读取和包缓存路径只存在于 Main。
|
||||
|
||||
## 2. 模块职责
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `LanDiscoveryService` | mDNS browse/advertise、手动地址握手、接口变化 |
|
||||
| `PeerPairingService` | 临时握手、验证码、双端确认、配对凭据与撤销 |
|
||||
| `GatewayConnectionService` | 浏览器登录、设备注册、WebSocket、重连和策略同步 |
|
||||
| `CapabilityCatalogService` | 合并本机、Peer 和网关目录,保留来源身份 |
|
||||
| `CapabilityPublicationService` | 校验本机可共享能力并向目标范围发布 |
|
||||
| `RemoteTaskService` | 授权、确认、provider 冻结、传输、取消和终态 |
|
||||
| `PackageAcquisitionService` | 固定来源下载、校验、安装协调和权限差异 |
|
||||
| `SharingAuditStore` | 本机有界审计查询、筛选和清理 |
|
||||
|
||||
目录合并只生成 UI 视图,不能把不同 provider 的授权、版本和执行位置压成一个身份。
|
||||
|
||||
## 3. 共享契约
|
||||
|
||||
实现前建立独立 `packages/share-protocol`。Desktop 只从该包导入 Zod schema 和 TypeScript
|
||||
类型。协议输入在网络边界和 IPC 边界分别验证,不能因已经过网络校验而跳过 Renderer 输入
|
||||
校验。
|
||||
|
||||
建议导出:
|
||||
|
||||
```text
|
||||
DeviceSummary
|
||||
PairingChallenge
|
||||
GatewayDescriptor
|
||||
CapabilityManifest
|
||||
Publication
|
||||
GrantConstraint
|
||||
AuthorizationDecision
|
||||
RemoteTaskSnapshot
|
||||
RemoteTaskEvent
|
||||
PackageDescriptor
|
||||
AuditEventSummary
|
||||
ProtocolError
|
||||
```
|
||||
|
||||
错误只向 Renderer 返回稳定 code、可本地化参数和可恢复动作,不转发远端堆栈、响应正文或
|
||||
内部地址。
|
||||
|
||||
## 4. 持久化
|
||||
|
||||
建议在现有 Desktop SQLite 中增加:
|
||||
|
||||
```text
|
||||
device_identity_metadata
|
||||
peer_pairings
|
||||
gateway_connections
|
||||
capability_publications
|
||||
local_grants
|
||||
remote_tasks
|
||||
sharing_audit_events
|
||||
installed_shared_packages
|
||||
```
|
||||
|
||||
- 私钥和 refresh credential 存系统安全存储;SQLite 只存引用和脱敏元数据。
|
||||
- 附近广播、在线状态、配对验证码、短期任务令牌和目录候选只存在于内存。
|
||||
- 网关能力目录可有界缓存,用于离线说明,不作为离线调用授权。
|
||||
- 审计正文不落库,字段和保留上限在实现前固定。
|
||||
- 数据库迁移只覆盖已发布 schema;未发布设计不预建兼容读取器。
|
||||
|
||||
## 5. 局域网网络实现
|
||||
|
||||
- 使用系统支持的 mDNS/DNS-SD 库 browse `_goodbuddy-share._tcp.local`。
|
||||
- 广播绑定用户允许的网络接口;接口切换时重建监听,不保留旧地址为身份。
|
||||
- 服务端口由 Main 持有,使用设备证书建立 TLS。
|
||||
- 未配对握手仅暴露配对所需字段,并限制并发、频率、消息大小和有效时间。
|
||||
- 手动地址只替代发现,不替代证书指纹与双端确认。
|
||||
- 停止广播时关闭未完成配对监听;已配对活动连接按任务生命周期处理。
|
||||
|
||||
## 6. 网关连接
|
||||
|
||||
1. Main 校验 HTTPS URL,并取得服务公开描述。
|
||||
2. 生成带 PKCE、随机 state 和设备挑战的系统浏览器登录请求。
|
||||
3. 本机 loopback 或已注册应用链接只接收一次性回调 code。
|
||||
4. Main 交换设备凭据,展示组织身份,用户确认后持久化。
|
||||
5. Main 建立带设备身份的 WebSocket 出站连接。
|
||||
6. 心跳更新在线状态;断线按有界指数退避重连。
|
||||
|
||||
服务器返回的跳转 URL 必须符合已取得服务描述和 HTTPS 限制。Renderer 不能提交认证头、设备
|
||||
证书、WebSocket URL 或任意回调地址。
|
||||
|
||||
## 7. 本机能力适配
|
||||
|
||||
不是所有现有 Capability 都自动可共享。每种可共享类型需要显式 adapter:
|
||||
|
||||
```ts
|
||||
interface ShareableCapabilityAdapter {
|
||||
describe(): CapabilityManifest
|
||||
validatePublication(input: PublicationDraft): ValidationResult
|
||||
invoke(input: unknown, context: ProviderTaskContext): AsyncIterable<TaskEvent>
|
||||
cancel(taskId: string): Promise<CancelResult>
|
||||
}
|
||||
```
|
||||
|
||||
adapter 负责把共享输入映射到既有生产服务,并保留原权限边界。首批类型在实现前根据真实
|
||||
产品需求确定;未有 adapter 的 Skill、MCP、知识库和 Runtime 不出现在共享列表。
|
||||
|
||||
## 8. 远端调用生命周期
|
||||
|
||||
```text
|
||||
Renderer 选择能力和输入
|
||||
-> Main 解析候选并取得当前授权
|
||||
-> 必要时返回确认描述
|
||||
-> 用户批准
|
||||
-> Main 冻结 task/provider/route/policy
|
||||
-> 建立数据通道并流式发送
|
||||
-> Provider 再校验并接受
|
||||
-> Main 转发有界进度和结果
|
||||
-> 终态与审计事务保存
|
||||
```
|
||||
|
||||
每次任务使用稳定 `taskId` 和 attempt ID。Provider 未接受前的连接失败可以明确失败;已接受
|
||||
后的断线先查询同一任务,不新建 attempt。无法恢复权威终态时保存 `outcome_unknown`。
|
||||
|
||||
附件从 Main 已授权的用户选择结果读取,按声明大小流式发送,不把任意本机路径交给远端。
|
||||
接收成果先写 GoodBuddy-owned 暂存并校验,再通过现有 Artifact 边界进入产品。
|
||||
|
||||
## 9. IPC
|
||||
|
||||
Preload 只暴露按产品动作命名的方法,例如:
|
||||
|
||||
```text
|
||||
sharing.getSnapshot
|
||||
sharing.updateLanSettings
|
||||
sharing.startPairing / confirmPairing / cancelPairing
|
||||
sharing.revokePeer
|
||||
sharing.addGateway / completeGatewayLogin / removeGateway
|
||||
sharing.listCapabilities
|
||||
sharing.savePublication / pausePublication / deletePublication
|
||||
sharing.prepareInvocation / confirmInvocation / cancelTask
|
||||
sharing.listAuditEvents / getAuditEvent
|
||||
```
|
||||
|
||||
所有变更方法校验 trusted sender、严格 schema、对象当前 revision 和归属关系。不得暴露通用
|
||||
HTTP、WebSocket、mDNS、证书、文件传输或数据库查询 IPC。
|
||||
|
||||
## 10. 生命周期与资源边界
|
||||
|
||||
- 应用启动按保存设置启动主动发现;不因保存了网关而阻塞窗口创建。
|
||||
- 广播只有在用户开启后启动。
|
||||
- 网关连接在后台恢复,认证过期只影响对应连接。
|
||||
- 应用退出停止接收新任务,取消未接受任务,并有界通知活动 Provider 后关闭连接。
|
||||
- 单个 Peer/网关/任务设置连接数、帧、字节、并发和重连上限。
|
||||
- 大文件全程流式处理,不进入单个 Main `Buffer` 或 Renderer state。
|
||||
|
||||
## 11. 与现有模块集成
|
||||
|
||||
- `capability-service` 提供本机能力事实;共享 adapter 明确选择可公开子集。
|
||||
- 已安装共享 Skill/MCP 继续使用现有配置、启停和 Runtime 分配,不建立第二套执行器。
|
||||
- SSH Host 保持独立;除非未来提供专门 adapter 和用户发布,否则不进入目录。
|
||||
- 远端 Task 的公开活动复用现有工具/任务展示语义,但保留 provider 和 route 字段。
|
||||
- 应用更新源、模型下载源、Agent 下载源和工具下载源不因添加 ShareServer 自动改变。
|
||||
|
||||
## 12. 验证策略
|
||||
|
||||
### 12.1 自动化
|
||||
|
||||
- schema 边界、未知枚举和协议版本协商。
|
||||
- 三个独立开关及默认值。
|
||||
- mDNS 去重、接口变化、广播停止和手动地址。
|
||||
- 配对双方确认、拒绝、超时、身份变化和撤销。
|
||||
- 多网关隔离、认证过期、重连和移除。
|
||||
- provider 排序、冻结、授权变化和失败不切换。
|
||||
- 调用确认最小输入、附件上限、取消和结果未知。
|
||||
- Publication 校验、暂停、撤销和 adapter 消失。
|
||||
- 包签名、权限差异、来源冻结和失败保留旧安装。
|
||||
- IPC sender、revision、归属和无通用网络/文件接口。
|
||||
|
||||
### 12.2 真实环境
|
||||
|
||||
- Windows、macOS、Linux 至少两种组合完成同网段发现和配对。
|
||||
- mDNS 可用、被防火墙阻断、网络接口切换和设备离线场景。
|
||||
- 真实 ShareServer 登录、设备出站连接、目录、调用和认证过期。
|
||||
- 小型 JSON、文件流、取消、Provider 进程失败和网络中断。
|
||||
- 确认日志、Renderer、诊断和审计中没有凭据或文件正文。
|
||||
|
||||
设计文档完成不代表功能实施或上述验证通过。
|
||||
@@ -0,0 +1,211 @@
|
||||
# 设备发现与共享 UI 设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 全局规范 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) |
|
||||
|
||||
本文只定义桌面端界面。所有颜色、间距、字体、通知、Switch、PageTabs、焦点和响应式行为以
|
||||
全局设计系统为准。
|
||||
|
||||
## 1. 信息架构
|
||||
|
||||
设置中心新增一级分类“设备发现与共享”,使用 `standard` PageShell:
|
||||
|
||||
```text
|
||||
设备发现与共享
|
||||
发现附近的 GoodBuddy,或连接组织网关使用和开放能力
|
||||
|
||||
[ 局域网 ] [ 网关 ] [ 本机共享 ] [ 活动记录 ]
|
||||
```
|
||||
|
||||
四项使用共享 `PageTabs` 的 `segmented` 视觉变体。普通入口默认“局域网”;来自能力来源、
|
||||
连接错误或审计入口的定向导航打开对应页签。页签选择不持久化为产品设置。
|
||||
|
||||
## 2. 局域网页签
|
||||
|
||||
页面顺序固定为本机发现状态、已配对设备、附近设备。
|
||||
|
||||
```text
|
||||
局域网
|
||||
|
||||
发现附近设备 [Switch 开]
|
||||
查看同一网络中主动开放的 GoodBuddy 设备
|
||||
|
||||
允许其他设备发现本机 [Switch 关]
|
||||
设备名称:Jiang-PC [编辑]
|
||||
|
||||
已配对设备 [刷新]
|
||||
名称 状态 能力 最近连接 操作
|
||||
GPU-Workstation 在线 4 项 刚刚 ...
|
||||
|
||||
附近设备
|
||||
Design-Mac 等待配对 [配对]
|
||||
```
|
||||
|
||||
- 两个 Switch 必须独立成行,不能使用一个总开关。
|
||||
- “允许被发现”开启后只显示广播状态和设备名,不暗示已经共享能力。
|
||||
- 设备行采用紧凑列表,不使用大卡片墙。
|
||||
- 已配对设备行可展开简要能力;完整信息进入设备详情抽屉。
|
||||
- 离线设备保留在“已配对设备”,附近广播消失不删除配对。
|
||||
|
||||
### 2.1 配对对话框
|
||||
|
||||
```text
|
||||
与 Design-Mac 配对
|
||||
|
||||
验证码 824 193
|
||||
设备 Design-Mac · macOS
|
||||
设备指纹 7A:31:...:D9
|
||||
有效时间 01:42
|
||||
|
||||
请确认另一台设备显示相同信息。
|
||||
[取消] [信息一致,确认配对]
|
||||
```
|
||||
|
||||
双方尚未确认时显示独立进度。拒绝、超时和连接失败保留设备行并给出重新配对,不显示成功
|
||||
通知。成功使用应用通知,焦点回到对应设备行。
|
||||
|
||||
### 2.2 设备详情
|
||||
|
||||
详情使用右侧抽屉,包含“概览”“能力”“授权”“活动”四个内部页签。未配对设备不能打开
|
||||
能力和授权。撤销配对放在抽屉底部 danger zone,并说明活动任务和历史记录影响。
|
||||
|
||||
## 3. 网关页签
|
||||
|
||||
```text
|
||||
网关 [添加网关]
|
||||
|
||||
研发组织网关
|
||||
研发中心 · 成员
|
||||
已连接 · 策略同步于 14:32
|
||||
可用能力 18 项 · 本机发布 2 项 [...]
|
||||
```
|
||||
|
||||
空状态说明网关是附加连接,局域网无需网关即可使用。主操作为“添加网关”。
|
||||
|
||||
### 3.1 添加网关
|
||||
|
||||
对话框只要求 HTTPS 地址,也可由邀请链接预填:
|
||||
|
||||
```text
|
||||
网关地址
|
||||
[https://share.example.com]
|
||||
|
||||
[取消] [继续登录]
|
||||
```
|
||||
|
||||
地址通过 Main 验证后打开系统浏览器。应用内显示“等待浏览器登录”和取消入口,不嵌入第三方
|
||||
登录页。回调成功后显示服务名、组织和账号,用户确认才保存连接。
|
||||
|
||||
网关详情抽屉包含连接、组织身份、可用能力、本机发布和诊断。移除连接明确说明只移除本机
|
||||
连接,不删除组织账号或服务端设备记录。
|
||||
|
||||
## 4. 本机共享页签
|
||||
|
||||
页首状态摘要不能替代逐项开关:
|
||||
|
||||
```text
|
||||
本机共享
|
||||
已开放 2 项能力 · 局域网 1 项 · 网关 2 项
|
||||
|
||||
搜索能力 [范围筛选] [状态筛选]
|
||||
|
||||
文档转 Word [Switch 开]
|
||||
范围:研发组织、GPU-Workstation
|
||||
允许:查看、远程调用
|
||||
确认:每次调用 [编辑]
|
||||
```
|
||||
|
||||
能力关闭时保留配置摘要并标为“已暂停”。点击“编辑”打开共享设置对话框:
|
||||
|
||||
- 目标范围使用分组多选,不混合 Peer、网关组织和用户组。
|
||||
- 允许动作使用 Checkbox。
|
||||
- “每次确认”使用 Switch。
|
||||
- 输入类型、大小、并发、时间段和有效期使用对应表单控件。
|
||||
- 权限较高的能力在提交区显示具体影响,不用泛化风险等级。
|
||||
- 提交按钮附近再次列出目标范围和允许动作。
|
||||
|
||||
能力尚未注册为可共享时不展示虚假的禁用行。列表为空时说明当前没有可共享能力,并提供前往
|
||||
“能力与工具”的次操作。
|
||||
|
||||
## 5. 活动记录页签
|
||||
|
||||
使用 `dashboard` 所需的表格能力,但保持在设置 PageShell 内横向滚动:
|
||||
|
||||
```text
|
||||
方向 能力 对端/来源 状态 时间
|
||||
发出 图像生成 研发组织网关 已完成 14:36
|
||||
收到 文档转 Word GPU-Workstation 正在执行 14:31
|
||||
```
|
||||
|
||||
工具栏提供方向、来源、状态、时间和搜索。详情抽屉显示任务 ID、能力版本、授权依据、路由、
|
||||
数据类型和大小、执行链、耗时、结果及取消/撤销入口。正文和文件内容默认不展示。
|
||||
|
||||
## 6. 能力选择与调用确认
|
||||
|
||||
远端能力进入现有能力目录时,在名称旁持续显示来源标签。多个提供者使用菜单选择:
|
||||
|
||||
```text
|
||||
图像生成
|
||||
提供者 [自动选择:GPU-Workstation v]
|
||||
```
|
||||
|
||||
调用需要确认时使用对话框:
|
||||
|
||||
```text
|
||||
调用“合同风险检查”
|
||||
|
||||
提供者 法务服务商
|
||||
执行位置 外部组织
|
||||
将发送 当前输入;contract.docx(1.8 MB)
|
||||
连接方式 ShareServer 中继
|
||||
网关可见正文 否
|
||||
下游调用 无
|
||||
|
||||
[取消] [仅本次允许] [按此范围始终允许]
|
||||
```
|
||||
|
||||
组织策略不允许保存 Grant 时不显示“始终允许”。按钮文字必须表达作用域,不能使用“确定”。
|
||||
|
||||
## 7. 执行活动
|
||||
|
||||
会话和任务中的远端工具活动至少显示:
|
||||
|
||||
```text
|
||||
合同风险检查 · 法务服务商
|
||||
正在执行 · 已发送 1 个文件(1.8 MB) [停止]
|
||||
```
|
||||
|
||||
展开后显示路由、能力版本和执行链。跨组织来源使用文字“外部组织”,不能只靠颜色。进入结果
|
||||
未知时显示持久警告和“查看活动详情”,不提供普通的自动重试按钮。
|
||||
|
||||
## 8. 状态与错误
|
||||
|
||||
- 首次为空、筛选无结果、加载失败、无权限和离线分别使用不同状态。
|
||||
- 缓存目录必须显示“上次更新于”,且离线时不能显示为当前可用。
|
||||
- 身份变化使用阻塞状态,唯一修复路径是撤销后重新配对。
|
||||
- 网关认证过期在对应网关行提供“重新登录”。
|
||||
- 发布字段错误就地显示;异步连接错误显示在对象行或详情中。
|
||||
- 配对成功、网关保存和共享发布成功使用应用通知,不重复显示页内成功横幅。
|
||||
|
||||
## 9. 无障碍与响应式
|
||||
|
||||
- 四个页签使用 `tablist/tab/tabpanel`、方向键、Home、End 和可见焦点。
|
||||
- 持久二元状态使用 `role="switch"`;范围和动作多选使用 Checkbox。
|
||||
- 表格在窄窗口切换为保留同样字段的列表,不隐藏提供者、状态或执行位置。
|
||||
- 指纹和长网关地址允许换行并提供复制按钮,不依靠水平裁切。
|
||||
- 对话框关闭后焦点回到触发控件;浏览器登录返回后恢复到等待登录区域。
|
||||
- 状态包含文字和图标,不以颜色作为唯一信号。
|
||||
|
||||
## 10. 固定词汇
|
||||
|
||||
使用“设备发现与共享”“局域网”“网关”“本机共享”“附近设备”“已配对设备”“允许其他
|
||||
设备发现本机”“结果未知”“外部组织”。不使用“扫描机器”“公网共享”“永久信任整台设备”
|
||||
或把 ShareServer 简称为“云端”。
|
||||
@@ -33,6 +33,8 @@ Task、Job、Subjob、Run 和 Subagent 的对象关系以
|
||||
## 功能边界
|
||||
|
||||
- `process_execute` 和 `subagent_delegate` 是 GoodBuddy 直连模型内置工具,不是外部 MCP。
|
||||
- 直连文本模型的普通问答、工具轮次和上下文摘要会在尚无可见输出时,对瞬时网络错误或
|
||||
单次请求超时最多自动重试 3 次。
|
||||
- OpenCode、Continue 和 DeepSeek Harness 继续使用各自原生执行与委派能力。
|
||||
- Ask 不允许进程执行;Subagent 若在 Ask 中使用,只能继承 Ask 的只读能力。
|
||||
- Execute 表示用户授权当前执行空间账号的完整能力,不增加第二套工具审批或权限档位。
|
||||
@@ -42,6 +44,7 @@ Task、Job、Subjob、Run 和 Subagent 的对象关系以
|
||||
|
||||
## 相关功能
|
||||
|
||||
- [模型连接请求定制](../model-connections/README.md):定义连接级 Header/Body、合并优先级和 Runtime 支持矩阵。
|
||||
- [本机工具环境](../local-tool-environment/README.md):为新进程提供 Node、Python 和 PATH。
|
||||
- [助手工作栏与执行空间](../assistant-workbar/prd.md):定义本机/SSH 执行位置和活动归属。
|
||||
- [远程主机与远程执行](../remote-host/README.md):定义当前托管 SSH Runtime 边界。
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已实施 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-01 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [PRD](./prd.md) |
|
||||
| 关联故事 | [User Stories](./user-stories.md) |
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
8. 子任务是父请求内部 Job/Subjob,不创建顶层 Task 或独立 Conversation。
|
||||
9. 父请求取消、Runtime 释放或应用退出会传播到全部活动子级和进程。
|
||||
10. OpenCode、Continue 和 DeepSeek Harness 的原生能力不被本功能替换或包装。
|
||||
11. 直连模型只在尚未显示文本或推理内容时自动重试瞬时网络错误或请求超时,最多重试
|
||||
3 次;用户取消和已显示的部分输出禁止重放。
|
||||
|
||||
## 2. 状态维度
|
||||
|
||||
@@ -33,6 +35,7 @@
|
||||
| 本机 Shell | `available`、`unavailable` |
|
||||
| Subagent 深度 | `0`、`1` |
|
||||
| 父请求 | `active`、`cancelling`、`terminal` |
|
||||
| 模型请求重试 | `initial`、`retry 1..3`、`terminal` |
|
||||
|
||||
## 3. 工具可见性决策表
|
||||
|
||||
@@ -75,15 +78,14 @@ validating
|
||||
| 输入 | 结果 |
|
||||
| --- | --- |
|
||||
| 未提供 | 当前工作区根目录 |
|
||||
| `.` 或合法相对子目录 | 解析到工作区内目录 |
|
||||
| 绝对路径 | 拒绝 |
|
||||
| `..` 越界、符号链接越界 | 拒绝 |
|
||||
| `.` 或相对路径 | 以当前工作区为基准解析,可指向工作区外目录 |
|
||||
| 绝对路径 | 使用指定目录 |
|
||||
| `..` 或指向工作区外的符号链接 | 解析到实际目标目录,按当前用户权限执行 |
|
||||
| 不存在或不是目录 | 拒绝 |
|
||||
| 当前工作区身份在验证期间变化 | 拒绝本次调用 |
|
||||
|
||||
首版进程工具比 Execute 的 OS 权限范围更窄地固定工作目录输入,只是为了保持请求与项目
|
||||
一致;命令本身仍以当前用户权限运行,可以显式访问该账号有权访问的其他绝对路径。该规则
|
||||
不是 Runtime OS 沙箱,也不能宣称文件系统 containment。
|
||||
工作区提供默认目录和相对路径基准,不是 Execute 命令的文件系统边界。单次命令选择其他
|
||||
工作目录不会修改父请求绑定的项目或工作区,也不会影响后续未指定 `cwd` 的命令。
|
||||
|
||||
## 6. Shell 选择
|
||||
|
||||
@@ -158,7 +160,26 @@ type SubagentDelegateResult = {
|
||||
已经提交的正常终态不被稍后到达的取消覆盖。父请求进入终态后,迟到的子级文本和工具事件
|
||||
只用于资源清理,不再写入会话。
|
||||
|
||||
## 11. 逻辑完整性评估
|
||||
## 11. 模型请求重试
|
||||
|
||||
```text
|
||||
attempt
|
||||
├─ success → completed
|
||||
├─ cancel → cancelled
|
||||
├─ non-retryable failure → failed
|
||||
├─ transient failure after visible output → failed_with_partial_output
|
||||
└─ transient failure before visible output
|
||||
├─ retry count < 3 → backoff → attempt
|
||||
└─ retry count = 3 → failed
|
||||
```
|
||||
|
||||
- 瞬时失败包括常见连接重置、连接/网络不可达、DNS 临时失败、连接管道中断和请求超时。
|
||||
- 三次重试分别等待 500 ms、1 s 和 2 s;每次尝试重新建立请求并使用独立请求超时。
|
||||
- 普通问答、工具模型轮次和上下文摘要使用同一规则。
|
||||
- 重试不重复已经执行的工具;工具只会在模型完整返回一轮工具调用后开始执行。
|
||||
- 退避等待绑定父请求取消信号,取消优先于启动下一次尝试。
|
||||
|
||||
## 12. 逻辑完整性评估
|
||||
|
||||
| 领域 | 结论 |
|
||||
| --- | --- |
|
||||
@@ -167,6 +188,7 @@ type SubagentDelegateResult = {
|
||||
| 本机三平台 | 完整,统一契约和平台 Shell 决策已定义 |
|
||||
| Subagent 递归 | 完整,深度固定为 1 |
|
||||
| 取消与失败 | 完整,父子和进程传播及优先级已定义 |
|
||||
| 模型网络恢复 | 完整,有限重试、输出重放和取消边界已定义 |
|
||||
| 产品对象 | 完整,遵循 Task/Job 权威模型 |
|
||||
| SSH 远端执行 | 明确不在首版;未来必须增加 Agent 后端后才能开放 |
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已实施,跨平台 CI 待验证 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-01 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
|
||||
| 关联领域 | [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
|
||||
@@ -65,7 +65,8 @@ Subagent 使用父请求的项目、执行空间、工作模式、直连模型
|
||||
|
||||
- Ask 父请求只能创建 Ask Subagent。
|
||||
- Execute 父请求创建 Execute Subagent。
|
||||
- 子级不能切换项目、目录、Runtime、模型连接或工作模式。
|
||||
- 子级不能切换父请求绑定的项目、工作区、Runtime、模型连接或工作模式;Execute 命令的
|
||||
单次工作目录选择遵循 FR-3。
|
||||
- 子级不再看到 `subagent_delegate`,首版最大委派深度固定为 1。
|
||||
- 子任务不是新的顶层 Task 或 Conversation,结果先返回父模型。
|
||||
|
||||
@@ -79,7 +80,7 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
|
||||
### 4.1 包含
|
||||
|
||||
- 本机 Windows、macOS、Linux 的前台 Shell 命令执行。
|
||||
- 当前工作区默认工作目录及合法子目录选择。
|
||||
- 当前工作区默认工作目录,以及当前用户可访问的本机工作目录选择。
|
||||
- Exit Code、标准输出、标准错误、Shell、目录、耗时和截断状态。
|
||||
- 命令超时、用户取消、进程树停止和应用退出清理。
|
||||
- 新进程使用当前本机工具环境提供的 Node、Python 和 PATH。
|
||||
@@ -118,7 +119,8 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
|
||||
### FR-3 工作目录和执行位置
|
||||
|
||||
- 默认目录是父请求当前工作区。
|
||||
- 可选工作目录必须解析为当前工作区本身或其子目录。
|
||||
- 可选工作目录支持绝对路径、相对工作区路径及指向工作区外目录的路径或符号链接,
|
||||
不额外限制在工作区内;目标必须是实际存在且当前用户可访问的目录。
|
||||
- 本机执行只能发生在当前桌面执行空间。
|
||||
- 当前请求绑定 SSH 执行空间且没有远端进程后端时不注册工具,绝不在桌面目录回退执行。
|
||||
|
||||
@@ -169,6 +171,14 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
|
||||
- 不改变现有 Agent Runtime 的工具清单或权限语义。
|
||||
- 不修改系统 PATH、Shell Profile 或用户终端会话。
|
||||
|
||||
### FR-11 模型请求重试
|
||||
|
||||
- 直连文本模型的普通问答、工具轮次和上下文摘要遇到瞬时网络错误或单次请求超时时,
|
||||
在尚未显示文本或推理内容的前提下最多自动重试 3 次。
|
||||
- 重试使用递增等待时间并显示当前次数;用户取消必须立即终止等待和后续请求。
|
||||
- 一旦本次模型调用已经显示文本或推理内容,不自动重放该调用,避免重复回答。
|
||||
- 非网络类 Provider 错误、协议错误和用户取消不进入自动重试。
|
||||
|
||||
## 6. 产品验收
|
||||
|
||||
- [ ] Windows 直连模型 Execute 使用 PowerShell 创建文件、运行测试并读取退出码。
|
||||
@@ -182,4 +192,6 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
|
||||
- [ ] 取消父请求和退出应用后没有残留 GoodBuddy 命令进程。
|
||||
- [ ] 本机工具环境选择对新命令生效,Main 持有的模型凭据不进入子进程。
|
||||
- [ ] 子任务不进入 Task Center,不创建独立可导航 Conversation。
|
||||
- [ ] TLS 建连重置和请求超时在未输出内容时最多自动重试 3 次,成功后继续原请求。
|
||||
- [ ] 重试等待可取消,已显示部分内容的失败不自动重放。
|
||||
- [ ] 全量测试、类型检查、Lint 和生产构建通过。
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
| --- | --- |
|
||||
| 文档类型 | 功能进度 |
|
||||
| 状态 | 已实施,本机验收完成 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-02 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 关联入口 | [直连模型 Agent 能力](./README.md) |
|
||||
|
||||
## 当前结论
|
||||
@@ -55,6 +55,8 @@
|
||||
- [x] Ask/Execute、Runtime target 和本机执行空间双重检查。
|
||||
- [x] DeepSeek Harness、OpenCode 和 Continue 隔离。
|
||||
- [x] 通用工具活动和“设置 > MCP > 直连模型”编程能力目录。
|
||||
- [x] 普通问答、工具轮次和上下文摘要的瞬时网络错误及请求超时最多自动重试 3 次。
|
||||
- [x] 递增退避、重试状态、取消传播和已显示输出不重放。
|
||||
|
||||
### 阶段 3:编程 Subagent
|
||||
|
||||
@@ -82,6 +84,26 @@
|
||||
GoodBuddy 托管 Node/Python 已可用。
|
||||
- 远端直连模型进程执行不在首版,后续需要独立增加 Agent 进程协议和真机验证。
|
||||
|
||||
## 2026-09-06 命令工作目录限制修复
|
||||
|
||||
- FR-3 / US-A3:移除命令工作目录的工作区范围限制。绝对路径、`..` 和指向工作区外
|
||||
的符号链接均按本机文件系统解析,工作区只作为默认目录和相对路径基准。
|
||||
- 保留目录存在性、本机执行空间绑定、Ask 拒绝命令、取消、超时和进程树回收。
|
||||
本次没有改变工作区文件读写工具或远端 Agent 路径。
|
||||
- `direct-model-process-service.test.ts` 覆盖绝对路径、相对路径、外部目录、符号链接、
|
||||
不存在的目录和普通文件;当前 Windows 环境通过。
|
||||
- `model-runtime.test.ts` 新增两个生产工具链回归:模型协议响应提供绝对或相对外部
|
||||
`cwd`,经 `ModelAgentRuntime → ModelToolProvider → LocalDirectModelProcessService`
|
||||
启动真实 PowerShell,在专用工作区外测试目录复制文件并读回验证。模型响应为本机
|
||||
测试替身,没有外部模型调用;不将此记录为真实模型或 macOS/Linux 真机验收。
|
||||
- 最终聚焦验证:`opencode-runtime`、`opencode-runtime-lifecycle`、
|
||||
`direct-model-process-service`、`model-tool-provider`、`model-runtime` 五个测试文件
|
||||
`182 passed, 1 skipped`;`npm run typecheck` 和 `npm run lint` 通过。
|
||||
- 本次共享工作区的全量 `npm test` 为 `3542 passed, 57 skipped, 1 failed`。唯一失败是
|
||||
`App.test.tsx` 的 `opens the global assistant sidebar and switches work tabs`,
|
||||
仍期望“项目工作区”标题,单独复测同样失败;该侧栏处于其他会话的修改范围,本次未改动。
|
||||
因此本次全量验证不能记录为全部通过。
|
||||
|
||||
## 2026-09-01 至 2026-09-02 实施与验证证据
|
||||
|
||||
- 新增 `direct-model-process-service.ts`:PowerShell/Bash/Sh 探测、工作区目录校验、96 KiB
|
||||
@@ -104,7 +126,19 @@
|
||||
0、委派编程 Subagent 再次运行最终程序并综合结果;最终源码成功验收运行包含 8 次真实
|
||||
模型调用。
|
||||
- BigToken 连接的首次验收在 TLS 建立前被 `ECONNRESET` 中断,未进入模型工具路径;切换
|
||||
到仓库已有的 DeepSeek 连接后完成验证。
|
||||
到仓库已有的 DeepSeek 连接后完成验证。2026-09-03 已针对该故障补充统一模型请求重试。
|
||||
|
||||
## 2026-09-03 模型请求重试验证证据
|
||||
|
||||
- `ModelAgentRuntime` 对普通问答、工具轮次和上下文摘要统一处理瞬时 Node/Undici 网络错误
|
||||
与请求超时;首次失败后最多重试 3 次,退避为 500 ms、1 s、2 s。
|
||||
- 聚焦测试覆盖嵌套 `fetch failed` → `ECONNRESET` 恢复、工具轮次恢复、四次尝试后终止、
|
||||
退避取消,以及已显示部分流式输出时不重放。
|
||||
- `npm test -- --run src/main/agent/model-runtime.test.ts`:
|
||||
`72 passed, 1 skipped`。
|
||||
- 全量 `npm test`:`3484 passed, 51 skipped`。
|
||||
- `npm run typecheck`、`npm run lint` 和 `npm run build`:通过。
|
||||
- 最小真实直连文本模型验证:`1 passed, 13 skipped`,实际模型调用 1 次。
|
||||
|
||||
- 全量 `npm test`:`3410 passed, 50 skipped`。
|
||||
- `npm run typecheck`:通过。
|
||||
@@ -112,6 +146,18 @@
|
||||
- `npm run build`:Main、Preload、Renderer 和控制面安装器均成功构建。2026-09-02 按产品
|
||||
决策移除未对应实际性能问题的 Renderer bundle 基线门禁后复验通过。
|
||||
|
||||
## 2026-09-05 移除直连模型响应字节上限
|
||||
|
||||
- 直连模型不再对流式响应总量、单个流式块、非流式回复、模型工具参数、工具请求上下文、
|
||||
聚合工具结果和累计回答长度设置字节上限;长回复和大工具结果由请求超时、取消和上下文
|
||||
压缩控制。用户不再看到“模型接口流式响应超过安全限制”。
|
||||
- 图像生成响应仍按可解码图片大小校验,Provider 错误响应仍读取有界文本。
|
||||
- 回归测试改为验证超过旧上限的长流式回复可以完整返回,以及超过 1 MB 的图片工具结果会
|
||||
进入下一轮模型请求。
|
||||
- `npx vitest run src/main/agent/model-runtime.test.ts`:`74 passed, 1 skipped`。
|
||||
- 全量 `npm test`:`3493 passed, 55 skipped`。
|
||||
- `npm run typecheck`、`npm run lint` 和 `npm run build`:通过。
|
||||
|
||||
## 进度维护要求
|
||||
|
||||
- 只有真实生产路径完成并验证后才能勾选实施项。
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已实施 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-01 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 相关架构 | [助手工作栏与执行空间](../assistant-workbar/prd.md)、[本机工具环境](../local-tool-environment/technical-design.md) |
|
||||
@@ -89,7 +89,8 @@ const processExecuteInputSchema = z.object({
|
||||
}).strict()
|
||||
```
|
||||
|
||||
- `cwd` 只接受相对工作区路径。
|
||||
- `cwd` 接受绝对路径或相对工作区路径,省略时使用工作区根目录;按本机路径语义解析
|
||||
`..` 和符号链接,不限制目标位于工作区内。工具 schema 向模型明确这一规则。
|
||||
- 首版不接受 `env`、`stdin`、`background`、`pty`、`shell` 或任意 executable 参数。
|
||||
- Shell 选择由执行后端决定,防止模型绕过平台契约启动另一套受管接口。
|
||||
- 命令字符串仍可以调用当前账号本来有权运行的程序;Execute 不增加命令白名单。
|
||||
@@ -114,8 +115,8 @@ type ProcessExecuteResult = {
|
||||
}
|
||||
```
|
||||
|
||||
`cwd` 返回工作区相对显示值,不把不必要的绝对用户路径送入模型上下文。Shell 在模型结果
|
||||
中只返回稳定标签;绝对可执行路径只进入 Main 诊断状态。
|
||||
`cwd` 在工作区内返回相对显示值,根目录为 `.`;工作区外返回规范化绝对路径,准确说明
|
||||
命令实际执行位置。Shell 在模型结果中只返回稳定标签;绝对可执行路径只进入 Main 诊断状态。
|
||||
|
||||
### 4.3 输出边界
|
||||
|
||||
@@ -293,7 +294,7 @@ DeepSeek Harness 的 Main 工具代理必须使用仅包含分配 MCP 和 Web
|
||||
- `failed`:启动或契约失败。
|
||||
- `cancelled`:父请求取消。
|
||||
|
||||
结果摘要包含 Shell、相对目录、退出码、耗时和截断标记。完整有界 stdout/stderr 进入工具
|
||||
结果摘要包含 Shell、工作目录、退出码、耗时和截断标记。完整有界 stdout/stderr 进入工具
|
||||
输出字段,不写应用诊断日志。
|
||||
|
||||
### 9.2 Subagent 事件
|
||||
@@ -319,12 +320,23 @@ type SubagentActor =
|
||||
| Shell 无法启动 | 工具失败 |
|
||||
| cwd 无效或越界 | 工具调用前失败 |
|
||||
| 超时/取消 | 终止进程树,返回已有输出和原因 |
|
||||
| 直连模型瞬时网络错误/请求超时,尚无可见输出 | 500 ms、1 s、2 s 退避,最多自动重试 3 次 |
|
||||
| 直连模型失败,已有文本或推理输出 | 保留部分输出并失败,不自动重放 |
|
||||
| 子级模型失败 | 返回 `failed`、部分输出和错误 |
|
||||
| 子级工具失败 | 由子模型处理;最终无法完成时返回子级失败 |
|
||||
| 父请求已终止 | 拒绝新调用,丢弃迟到内容并清理 |
|
||||
|
||||
不要用宽泛 catch 抹掉取消、超时、Provider HTTP 状态或进程退出上下文。
|
||||
|
||||
模型请求重试由 `ModelAgentRuntime` 的统一流式请求处理,覆盖普通问答、工具轮次和复用
|
||||
同一 Runtime 的上下文摘要。重试分类检查错误及有界 `cause` 链中的 Node/Undici 网络错误
|
||||
码;每次尝试创建独立超时信号。退避等待监听父请求取消。只有完整模型轮次返回后才执行
|
||||
工具,因此未产生可见文本或推理内容的重试不会重复执行工具。
|
||||
|
||||
模型回复本身不设固定字节上限:流式响应、非流式回复、模型工具参数、请求上下文和工具
|
||||
结果总量都由请求超时、取消和上下文压缩控制,长回复和大工具结果不会被中断。图像生成
|
||||
响应仍按可解码图片大小校验。
|
||||
|
||||
## 11. 性能
|
||||
|
||||
- Shell 探测按执行服务实例缓存,不在每轮模型调用重复探测。
|
||||
@@ -340,13 +352,15 @@ type SubagentActor =
|
||||
- 工具可见性矩阵和 Runtime target 过滤。
|
||||
- Ask 伪造进程调用拒绝。
|
||||
- Windows PowerShell、POSIX Bash/Sh 选择和参数。
|
||||
- cwd 默认、合法子目录、越界、符号链接越界和不存在。
|
||||
- cwd 默认、相对子目录、绝对路径、工作区外目录、指向外部的符号链接和不存在的目录。
|
||||
- exit 0、非零退出、spawn 失败、超时、取消和输出截断。
|
||||
- 环境筛选、本机工具 PATH 优先且模型凭据不进入进程。
|
||||
- 子级模式、模型、项目、执行空间和能力继承。
|
||||
- 深度 1 过滤、并发 3、队列 20、取消传播和部分输出。
|
||||
- 编程 Subagent 不创建 Task/Conversation。
|
||||
- DeepSeek Harness/OpenCode/Continue 不获得本功能工具。
|
||||
- TLS 建连 `ECONNRESET`、Undici 连接超时、单次请求超时、三次重试上限和退避取消。
|
||||
- 已产生文本或推理增量后发生网络错误时不自动重放。
|
||||
|
||||
### 12.2 本机集成测试
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已实施 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-01 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 设计系统 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) |
|
||||
| 关联 PRD | [PRD](./prd.md) |
|
||||
| 关联逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
@@ -28,7 +28,7 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由
|
||||
```text
|
||||
编程能力
|
||||
进程执行
|
||||
使用当前工作区和当前用户权限运行 PowerShell/Bash 命令。
|
||||
使用当前用户权限运行 PowerShell、Bash 或 Sh 命令,默认在工作区执行,也可指定其他工作目录。
|
||||
编程 Subagent
|
||||
将聚焦任务委派给同一模型连接;继承当前工作模式和工作区。
|
||||
```
|
||||
@@ -54,7 +54,7 @@ PowerShell · packages/app · npm test
|
||||
```
|
||||
|
||||
- 命令只显示单行有界摘要,完整参数进入可展开详情。
|
||||
- 目录显示工作区相对路径;工作区根显示 `.`。
|
||||
- 工作区内目录显示相对路径,工作区根显示 `.`;工作区外目录显示规范化绝对路径。
|
||||
- 不在摘要中显示完整环境或凭据。
|
||||
|
||||
### 3.2 状态
|
||||
@@ -117,6 +117,8 @@ GoodBuddy 服务崩溃。
|
||||
- Runtime 初始化、Shell 探测或全局服务失败可通过应用通知提示,并在设置状态卡提供重试。
|
||||
- 同一错误不同时显示活动块副本和通知副本。
|
||||
- 失败后保留用户 Prompt、命令摘要、工作目录和已有输出。
|
||||
- 直连模型自动重试通过现有聊天状态显示“模型网络请求失败,正在重试(n/3)”,不增加
|
||||
独立按钮、弹窗或通知副本;用户仍可使用当前停止操作取消请求。
|
||||
|
||||
## 7. 键盘与无障碍
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 已实施,跨平台 CI 待验证 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-01 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [直连模型 Agent 能力 PRD](./prd.md) |
|
||||
| 关联逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
|
||||
@@ -42,12 +42,13 @@ Shell、目录、退出码、输出和耗时。
|
||||
Given 当前项目位于 macOS 或 Linux 本机,When 模型运行同一工具,Then GoodBuddy 使用
|
||||
Bash,Bash 不可用时使用 Sh,工具契约保持不变且实际 Shell 明确可见。
|
||||
|
||||
### US-A3 使用工作区子目录
|
||||
### US-A3 选择命令工作目录
|
||||
|
||||
关联:FR-3。
|
||||
|
||||
Given 项目包含多个包,When 模型传入工作区内相对目录,Then 命令在该目录执行;When
|
||||
路径越出工作区、不是目录或不存在,Then 命令不启动并返回准确错误。
|
||||
Given 当前请求为本机 Execute,When 模型传入绝对路径或相对工作区路径,Then 命令在
|
||||
解析后的目录执行,包括工作区外目录和符号链接目标,And 后续省略 `cwd` 时仍使用原工作区。
|
||||
When 目标不是目录或不存在,Then 命令不启动并返回准确错误。
|
||||
|
||||
### US-A4 根据非零退出继续修正
|
||||
|
||||
@@ -70,6 +71,14 @@ Given 命令仍在运行,When 用户取消父请求,Then 当前 Shell 及其
|
||||
Given 命令输出超过上限,When 工具返回结果,Then 保留有用的开头和结尾并明确
|
||||
`truncated: true`,And 模型与用户都不会把结果理解为完整日志。
|
||||
|
||||
### US-A7 从瞬时模型网络故障恢复
|
||||
|
||||
关联:FR-7、FR-9、FR-11。
|
||||
|
||||
Given 直连模型在 TLS 建连、网络传输或单次请求超时时失败且尚未显示输出,When 请求仍未
|
||||
取消,Then GoodBuddy 显示当前重试次数并使用递增等待时间最多重试 3 次,And 成功后继续
|
||||
原问答或工具轮次。Given 已经显示文本或推理内容,Then 不自动重放该调用。
|
||||
|
||||
## 3. Epic B:编程闭环
|
||||
|
||||
### US-B1 修改后运行验证
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 知识库
|
||||
|
||||
知识库负责资料导入、解析、分块、索引、检索、图谱和带来源的知识问答。
|
||||
知识库已提供本地资料导入、解析、分块、索引、检索、图谱和带来源的知识问答。
|
||||
将 Dify、FastGPT 和 RAGFlow 中已有知识库接入同一检索范围仍处于规划阶段,不属于
|
||||
当前已提供能力。
|
||||
|
||||
## 文档导航
|
||||
|
||||
@@ -8,7 +10,25 @@
|
||||
| --- | --- |
|
||||
| [知识库检索与分块增强 PRD](./prd.md) | 检索、分块、诊断和产品验收 |
|
||||
| [User Stories](./user-stories.md) | 知识使用者、维护者和调试者场景 |
|
||||
| [UI 设计](./ui-design.md) | 当前本地知识库的信息架构、首次使用、状态与高级功能分层 |
|
||||
| [实施进度](./progress.md) | 已验证实现事实、验证命令与剩余外部知识库工作 |
|
||||
| [本地文本向量技术设计](./local-embedding-technical-design.md) | 本地与兼容向量连接、模型包、索引和进程边界 |
|
||||
| [知识检索评估](./retrieval-evaluation.md) | 检索质量评估方法、数据集、指标和门槛 |
|
||||
| [外部知识库接入 PRD](./external-knowledge-prd.md) | 外部实例、知识库绑定、检索范围和产品验收 |
|
||||
| [外部知识库 User Stories](./external-knowledge-user-stories.md) | 实例配置、添加、使用和故障恢复场景 |
|
||||
| [外部知识库逻辑设计](./external-knowledge-logic-design.md) | 状态、不变量、配置优先级和失败规则 |
|
||||
| [外部知识库 UI 设计](./external-knowledge-ui-design.md) | 知识库页面、外部实例管理和创建向导交互 |
|
||||
| [外部知识库技术设计](./external-knowledge-technical-design.md) | Provider Adapter、凭据、持久化、IPC 和检索标准化 |
|
||||
|
||||
当前尚未拆出统一功能逻辑、UI 设计和功能进度文档。
|
||||
## 术语
|
||||
|
||||
- **外部实例**:一个由用户配置的 Dify、FastGPT 或 RAGFlow 服务地址及其认证信息。
|
||||
- **外部知识库绑定**:GoodBuddy 中的知识库记录与外部实例内一个远端知识库 ID 的只读关联。
|
||||
- **Provider 配置**:某一 Provider 检索接口独有的设置,例如 RAGFlow 图谱检索。
|
||||
|
||||
外部知识库只提供检索结果。GoodBuddy 不接入外部 App、Chat、Assistant、Workflow
|
||||
或 Agent,也不在外部实例中创建、修改、上传或删除知识库内容。远端资料不会批量同步
|
||||
或进入本地索引;实际用于回答的有界片段会随引用保存在本地会话中。
|
||||
|
||||
本目录尚未拆出统一逻辑设计;当前本地知识库状态规则由 PRD、User Stories 和实现契约
|
||||
共同约束。外部知识库继续使用独立逻辑设计,直至进入实施阶段。
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
# 外部知识库逻辑设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计稿 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) |
|
||||
| 关联场景 | [外部知识库 User Stories](./external-knowledge-user-stories.md) |
|
||||
|
||||
## 1. 不变量
|
||||
|
||||
### EK-I-01 单一知识库身份
|
||||
|
||||
本地知识库和外部知识库都使用 GoodBuddy `knowledgeBaseId` 参与聊天范围、检索模式、
|
||||
工具授权和活动记录。Provider、实例 ID 和远端知识库 ID 不能替代该授权单位。
|
||||
|
||||
对应:`EK-FR-10`、`EK-FR-11`、`EK-US-C1`。
|
||||
|
||||
### EK-I-02 外部绑定只读
|
||||
|
||||
GoodBuddy 对外部实例只执行连接测试、目录读取、详情读取和检索。添加、编辑、移除
|
||||
均只修改本机记录,不调用远端创建、更新、上传或删除接口。
|
||||
|
||||
对应:`EK-FR-15`、`EK-US-A3`。
|
||||
|
||||
### EK-I-03 凭据不离开 Main
|
||||
|
||||
API Key 明文只在 Main 内完成解密并短暂进入请求头。Renderer、Preload 返回值、Runtime、
|
||||
Provider 配置、知识库元数据、诊断、日志和错误只能获得凭据状态。
|
||||
|
||||
对应:`EK-FR-02`、`EK-FR-11`。
|
||||
|
||||
### EK-I-04 Provider 不能定义界面
|
||||
|
||||
远端响应只提供数据和能力证据。可显示字段、字段类型、范围、默认值和请求映射均来自
|
||||
GoodBuddy 内置、版本化的 Provider Adapter。
|
||||
|
||||
对应:`EK-FR-09`、`EK-US-D1`。
|
||||
|
||||
### EK-I-05 无静默切换
|
||||
|
||||
请求固定使用绑定保存的实例、远端知识库和 Provider 配置。失败时不切换实例、公共云
|
||||
地址、认证方式或其他知识库。
|
||||
|
||||
对应:`EK-FR-14`、`EK-FR-16`、`EK-US-C4`。
|
||||
|
||||
### EK-I-06 零结果与失败分离
|
||||
|
||||
成功响应经过过滤后没有片段才是零结果。超时、认证、权限、限流、目标不存在、服务错误
|
||||
和响应无效均为失败,不能折叠成零结果。
|
||||
|
||||
对应:`EK-FR-14`、`EK-US-D4`。
|
||||
|
||||
### EK-I-07 外部证据不可信
|
||||
|
||||
Provider 返回的名称、内容、元数据、URL 和错误均视为不可信输入,经过长度、数量、类型
|
||||
和 URL 校验后才能显示或进入 Runtime。内容中的指令不能改变系统或工具权限。
|
||||
|
||||
对应:`EK-FR-12`。
|
||||
|
||||
### EK-I-08 Provider 配置不跨类型复用
|
||||
|
||||
Dify、FastGPT 和 RAGFlow 使用各自的判别配置。切换知识库类型或实例 Provider 时,旧类型
|
||||
配置不参与新请求,也不能按同名字段自动迁移。
|
||||
|
||||
对应:`EK-FR-06`、`EK-FR-09`。
|
||||
|
||||
## 2. 对象关系
|
||||
|
||||
```text
|
||||
ExternalKnowledgeInstance 1 ─── n ExternalKnowledgeBinding
|
||||
│
|
||||
│ 1:1
|
||||
▼
|
||||
KnowledgeBase
|
||||
```
|
||||
|
||||
- 实例可以没有绑定。
|
||||
- 外部知识库只能绑定一个实例和一个远端知识库 ID。
|
||||
- 同一实例的同一远端知识库默认只允许存在一个绑定,避免重复选择和重复检索。
|
||||
- 允许不同实例绑定同名或相同远端 ID,因为实例边界不同。
|
||||
- 本地知识库不存在 `ExternalKnowledgeBinding`。
|
||||
|
||||
## 3. 实例状态
|
||||
|
||||
实例状态由启停、凭据和最近探测三个维度组成,不能用一个布尔值代替。
|
||||
|
||||
### 3.1 持久状态
|
||||
|
||||
| 维度 | 值 |
|
||||
| --- | --- |
|
||||
| 启停 | `enabled`、`disabled` |
|
||||
| 凭据 | `configured`、`missing`、`unavailable` |
|
||||
| 探测 | `untested`、`catalog-ready`、`healthy`、`list-restricted`、`auth-failed`、`unreachable`、`incompatible`、`failed` |
|
||||
|
||||
`unavailable` 表示系统安全存储当前无法解密或保存凭据,与用户尚未配置的 `missing` 分开。
|
||||
|
||||
### 3.2 派生可用性
|
||||
|
||||
| 条件 | 目录读取 | 已绑定知识库检索 | 页面状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| disabled | 禁止 | 禁止 | 已停用 |
|
||||
| missing / unavailable | 禁止 | 禁止 | 缺少凭据 / 凭据不可用 |
|
||||
| auth-failed | 禁止 | 禁止 | 认证失效 |
|
||||
| unreachable | 可重试 | 可重试 | 无法连接 |
|
||||
| incompatible | 禁止 | 禁止 | 接口不兼容 |
|
||||
| list-restricted | 禁止 | 允许按 ID 验证与检索 | 需手工指定知识库 |
|
||||
| catalog-ready | 允许 | 创建绑定时验证 | 目录可用,检索待验证 |
|
||||
| healthy | 允许 | 允许 | 正常 |
|
||||
| untested | 允许测试 | 已有绑定可尝试检索 | 未测试 |
|
||||
|
||||
保存新实例要求至少完成服务可达和认证验证。目录受限可以保存;检索接口尚未通过时,
|
||||
实例不能用于创建绑定。
|
||||
|
||||
## 4. 连接测试状态机
|
||||
|
||||
```text
|
||||
idle
|
||||
└─ test → connecting
|
||||
├─ network failure → unreachable
|
||||
└─ connected → authenticating
|
||||
├─ 401 → auth-failed
|
||||
├─ incompatible response → incompatible
|
||||
└─ authenticated → listing
|
||||
├─ 403 / unsupported → list-restricted
|
||||
└─ success
|
||||
├─ no target → catalog-ready
|
||||
└─ target selected → probing-retrieval
|
||||
├─ success / empty → healthy
|
||||
└─ failure → failed
|
||||
```
|
||||
|
||||
检索探测需要一个目标知识库。新增实例尚未选择目标时,目录成功即可把实例保存为“目录可用,
|
||||
检索待绑定验证”;创建绑定时补做真实检索。手工模式必须先填写目标 ID,再执行检索探测。
|
||||
检索请求正常完成但返回零条结果时,说明接口和目标可访问,可继续创建绑定;失败状态按错误
|
||||
分类处理。
|
||||
|
||||
每次测试拥有 request ID 和取消信号。用户修改 Provider、地址、凭据或实例后,旧 request ID
|
||||
的结果作废。
|
||||
|
||||
## 5. 目录读取规则
|
||||
|
||||
1. 只有选定、启用且凭据可用的实例可以读取目录。
|
||||
2. Provider 列表接口按适配器定义分页,Main 最多向 Renderer 返回 500 个目录项。
|
||||
3. FastGPT 列表中的文件夹和知识库必须按类型区分;文件夹不可作为绑定目标。
|
||||
4. 列表搜索优先使用 Provider 支持的服务端过滤,不支持时只过滤已经有界加载的当前结果。
|
||||
5. 下拉列表以远端 ID 为值,以远端名称为标签;重名时追加短 ID 或父级名称。
|
||||
6. 已经绑定的目标显示“已添加”并禁止重复添加。
|
||||
7. 远端列表项名称变化不自动修改 GoodBuddy 显示名称,只更新远端名称快照。
|
||||
8. 刷新失败时可以保留上次成功目录供查看,但不得用旧目录完成新绑定,直到目标重新验证。
|
||||
|
||||
## 6. 创建向导状态
|
||||
|
||||
### 6.1 类型
|
||||
|
||||
```text
|
||||
local | dify | fastgpt | ragflow
|
||||
```
|
||||
|
||||
`local` 使用现有字段和验证。外部类型使用以下步骤状态:
|
||||
|
||||
```text
|
||||
provider selected
|
||||
→ instance selected
|
||||
→ catalog loading / manual ID
|
||||
→ remote target selected
|
||||
→ configuration valid
|
||||
→ retrieval verified
|
||||
→ binding created
|
||||
```
|
||||
|
||||
前置字段变化时,后续状态按下表失效:
|
||||
|
||||
| 变化 | 必须清除或重新验证 |
|
||||
| --- | --- |
|
||||
| 类型 | 实例、远端目标、全部 Provider 配置、测试结果 |
|
||||
| 实例 | 远端目标、能力快照、Provider 配置、测试结果 |
|
||||
| 远端目标 | 远端详情、能力适用性、测试结果 |
|
||||
| 通用或 Provider 配置 | 测试结果 |
|
||||
| 显示名称 | 不清除测试结果 |
|
||||
|
||||
## 7. 配置模型
|
||||
|
||||
### 7.1 通用配置
|
||||
|
||||
| 字段 | 范围 | 默认值 | 语义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `resultLimit` | 1 至 20 | 6 | Provider 结果标准化后最多保留的片段数 |
|
||||
| `requestTimeoutMs` | 1,000 至 60,000 | 15,000 | 单次远端请求总时限 |
|
||||
| `maxSnippetCharacters` | 500 至 8,000 | 4,000 | 单片段进入 IPC 和 Runtime 前的字符上限 |
|
||||
|
||||
Provider 请求仍使用自己的候选数量或 Token 预算。`resultLimit` 只定义 GoodBuddy 最终保留
|
||||
上限,不改写 Provider 字段的单位。
|
||||
|
||||
### 7.2 Dify 配置
|
||||
|
||||
| 字段 | 初始范围 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `useDatasetDefaults` | boolean,默认 true | 开启时不发送 `retrieval_model` 覆盖 |
|
||||
| `searchMethod` | keyword / semantic / full-text / hybrid | 对应 Dataset Retrieve 检索方式 |
|
||||
| `providerTopK` | 1 至适配器上限 | Dify 返回候选数量 |
|
||||
| `scoreThresholdEnabled` | boolean | 是否向 Dify 发送分数阈值 |
|
||||
| `scoreThreshold` | 0 至 1 | 开启阈值后生效 |
|
||||
| `rerankingMode` | weighted-score / reranking-model | 覆盖检索配置时必填 |
|
||||
| `rerankingProvider`、`rerankingModel` | 受支持的远端标识 | 选择模型重排时必填 |
|
||||
| `keywordWeight`、`vectorWeight` | 0 至 1,总和为 1 | 加权混合模式时生效 |
|
||||
| `embeddingProvider`、`embeddingModel` | 受支持的远端标识 | Dify 要求时从知识库详情选择或填写 |
|
||||
|
||||
`useDatasetDefaults` 开启时,其余覆盖字段隐藏且不发送。关闭后必须收集当前 Dify 接口
|
||||
构造 `retrieval_model` 所需的完整字段,缺少 Provider 或模型标识时不能保存。
|
||||
|
||||
### 7.3 FastGPT 配置
|
||||
|
||||
| 字段 | 初始范围 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `searchMode` | embedding / fullTextRecall / mixedRecall | 搜索方式 |
|
||||
| `tokenLimit` | 适配器限制,最高 20,000 | Provider 返回内容预算,不等同结果条数 |
|
||||
| `similarity` | 0 至 1 | 最低相关度 |
|
||||
| `usingRerank` | boolean | 使用 FastGPT 重排 |
|
||||
|
||||
初始版本不开放 FastGPT 查询优化字段,避免把查询发送给 FastGPT 配置的 LLM。检索和重排
|
||||
仍可能产生远端 Embedding、Rerank 用量及 API 审计记录,测试与保存配置时必须说明。
|
||||
|
||||
### 7.4 RAGFlow 配置
|
||||
|
||||
| 字段 | 初始范围 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `similarityThreshold` | 0 至 1 | 最低综合相似度 |
|
||||
| `vectorSimilarityWeight` | 0 至 1 | 向量分数权重 |
|
||||
| `knnTopK` | 1 至适配器上限 | KNN 候选数量,不使用已弃用 `top_k` |
|
||||
| `rerankCandidatesCount` | 0 或正整数 | 重排候选数量,实例支持时显示 |
|
||||
| `rerankId` | 可选受限字符串 | 远端重排模型 ID |
|
||||
| `useKg` | boolean,默认 false | 仅在已有图谱证据成立时允许开启 |
|
||||
| `includeKnowledgeCompilation` | boolean,默认 false | 实例支持时显式发送,禁止依赖服务端默认 true |
|
||||
| `metadataCondition` | 后续受控编辑器 | 初始版本不接受任意 JSON 文本 |
|
||||
|
||||
`parser_config.graphrag.use_graphrag` 描述构建状态,不是检索参数。只有列表、详情或受控
|
||||
探测提供已有图谱的证据时,界面才允许开启 `useKg`。Knowledge Compilation 是另一项
|
||||
检索能力,必须与旧 GraphRAG 分别显示和保存。
|
||||
|
||||
## 8. 配置优先级
|
||||
|
||||
生效请求由以下信息组成:
|
||||
|
||||
```text
|
||||
GoodBuddy 固定安全上限
|
||||
∩ 当前 Adapter 支持矩阵
|
||||
∩ 当前绑定保存的通用配置和 Provider 配置
|
||||
= 本次请求配置
|
||||
```
|
||||
|
||||
- 固定安全上限不可被实例或用户扩大。
|
||||
- 远端知识库自身默认值只用于创建表单的建议值,不在每次请求时覆盖本地已保存值。
|
||||
- Provider 增加新字段不会自动启用。
|
||||
- 当前实例版本离开 Adapter 支持矩阵,或 Adapter 新版本无法解析已保存配置时,绑定标记
|
||||
配置失效;用户确认前不重写配置。
|
||||
- 服务端忽略未知字段不能视为成功。Adapter 只发送已知且受支持的字段。
|
||||
|
||||
## 9. 绑定状态
|
||||
|
||||
| 状态 | 条件 | 可进入聊天范围 |
|
||||
| --- | --- | --- |
|
||||
| `ready` | 实例可用,目标最近验证成功,配置有效 | 是 |
|
||||
| `untested` | 迁移或新增后尚未完成真实检索 | 否 |
|
||||
| `instance-disabled` | 实例停用 | 否 |
|
||||
| `credential-error` | 凭据缺失、不可用或认证失败 | 否 |
|
||||
| `remote-missing` | 远端目标返回不存在 | 否 |
|
||||
| `config-invalid` | 已保存配置不再受支持 | 否 |
|
||||
| `temporarily-unavailable` | 超时、限流或服务错误 | 是,但本次显示失败并允许重试 |
|
||||
|
||||
短暂故障不立即永久禁用绑定;认证失败、目标不存在和配置失效会改变持久状态。
|
||||
|
||||
## 10. 多知识库检索
|
||||
|
||||
1. 读取请求冻结的 GoodBuddy 知识库 ID 集合。
|
||||
2. 本地库进入本地检索,外部库按实例和绑定构造独立请求。
|
||||
3. 同一请求最多选择现有契约允许的 20 个知识库,并遵循全局并发上限。
|
||||
4. 每个库单独记录成功、零结果、失败、耗时和截断。
|
||||
5. 成功结果按知识库保留来源,不比较不同 Provider 的原始分数。
|
||||
6. 上下文拼装使用现有总字符预算,在各成功知识库间采用确定性配额。
|
||||
7. 全部失败时返回检索失败;至少一个成功时返回证据和部分失败诊断。
|
||||
|
||||
初始版本不对跨 Provider 结果运行统一学习型重排。后续如增加,必须保留原始 Provider
|
||||
分数和重排来源,且单独定义评估数据。
|
||||
|
||||
## 11. 错误分类
|
||||
|
||||
| 稳定错误码 | 用户状态 | 持久影响 |
|
||||
| --- | --- | --- |
|
||||
| `EXTERNAL_KB_NETWORK` | 无法连接实例 | 无 |
|
||||
| `EXTERNAL_KB_TIMEOUT` | 检索超时 | 无 |
|
||||
| `EXTERNAL_KB_AUTH` | 认证失效 | 实例标记认证失败 |
|
||||
| `EXTERNAL_KB_FORBIDDEN` | 没有访问权限 | 目录或绑定按操作标记受限 |
|
||||
| `EXTERNAL_KB_NOT_FOUND` | 远端知识库不存在 | 绑定标记目标不存在 |
|
||||
| `EXTERNAL_KB_RATE_LIMITED` | 请求过于频繁 | 无,可显示建议等待 |
|
||||
| `EXTERNAL_KB_INCOMPATIBLE` | 接口或配置不兼容 | 实例或绑定标记不兼容 |
|
||||
| `EXTERNAL_KB_INVALID_RESPONSE` | 返回内容无法读取 | 无,记录脱敏诊断 |
|
||||
| `EXTERNAL_KB_SERVER` | 外部服务处理失败 | 无 |
|
||||
| `EXTERNAL_KB_CANCELLED` | 已取消 | 无 |
|
||||
|
||||
Provider 原始正文不能直接成为用户错误。Adapter 将其映射为稳定错误码和有界短消息。
|
||||
|
||||
## 12. 删除规则
|
||||
|
||||
| 操作 | 无绑定 | 有绑定 |
|
||||
| --- | --- | --- |
|
||||
| 停用实例 | 直接停用 | 允许;绑定变为不可用 |
|
||||
| 清除凭据 | 直接清除 | 需要确认;绑定保留但不可用 |
|
||||
| 删除实例 | 直接删除 | 阻止直接删除,要求同时移除本地绑定 |
|
||||
| 移除绑定 | 删除本地绑定 | 不影响实例和远端知识库 |
|
||||
|
||||
删除确认不得使用“删除远端知识库”等文案。
|
||||
|
||||
## 13. 需求追踪
|
||||
|
||||
| 需求 | 主要逻辑 | User Story |
|
||||
| --- | --- | --- |
|
||||
| `EK-FR-01` 至 `EK-FR-03` | 实例状态、连接测试状态机 | `EK-US-A1`、`EK-US-A2` |
|
||||
| `EK-FR-04`、`EK-FR-05` | 目录读取规则、手工目标验证 | `EK-US-B1`、`EK-US-B2` |
|
||||
| `EK-FR-06`、`EK-FR-07`、`EK-FR-08`、`EK-FR-09` | 创建向导状态、配置模型和优先级 | `EK-US-B3`、`EK-US-B4`、`EK-US-D1`、`EK-US-D3` |
|
||||
| `EK-FR-10` 至 `EK-FR-13` | 单一身份、多知识库检索和引用规则 | `EK-US-C1` 至 `EK-US-C3`、`EK-US-D2` |
|
||||
| `EK-FR-14` | 绑定状态、错误分类和部分失败 | `EK-US-C4`、`EK-US-D4` |
|
||||
| `EK-FR-15` | 对象关系和删除规则 | `EK-US-A3` |
|
||||
| `EK-FR-16` | 无静默切换、请求冻结和传输风险确认 | `EK-US-C2` |
|
||||
|
||||
## 14. 逻辑完整性
|
||||
|
||||
已确定:
|
||||
|
||||
- 产品入口、对象关系、只读边界和凭据边界。
|
||||
- 目录读取与手工 ID 的切换条件。
|
||||
- 三种 Provider 的初始配置范围和 RAGFlow 图谱语义。
|
||||
- 多知识库部分失败、删除和版本变化规则。
|
||||
|
||||
实施前仍需技术验证:
|
||||
|
||||
- 选定支持的 Dify、FastGPT、RAGFlow 最低版本,形成固定支持矩阵并保存真实响应 fixture。
|
||||
- 验证各版本检索探测的 API 审计、用量和内容修改行为。
|
||||
- 确定 Provider 返回 URL 的安全打开策略;未验证前只显示文本定位和远端 ID。
|
||||
- 依据真实延迟确定默认并发上限和跨知识库上下文配额。
|
||||
@@ -0,0 +1,254 @@
|
||||
# 外部知识库接入 PRD
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计稿 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 关联逻辑 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) |
|
||||
| 关联界面 | [外部知识库 UI 设计](./external-knowledge-ui-design.md) |
|
||||
| 技术设计 | [外部知识库技术设计](./external-knowledge-technical-design.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
不少企业已经在内网部署 Dify、FastGPT 或 RAGFlow,并在其中维护制度、产品、研发
|
||||
和客服知识。继续要求用户把相同资料导入 GoodBuddy,会产生重复存储、版本分叉和
|
||||
额外维护成本。
|
||||
|
||||
GoodBuddy 需要把这些已有知识库加入当前知识库列表和聊天检索范围。外部系统只
|
||||
承担知识存储与检索,问题理解、上下文组织、模型回答、Agent 和工具执行仍由
|
||||
GoodBuddy 负责。
|
||||
|
||||
## 2. 产品决策
|
||||
|
||||
1. 外部知识库属于现有“知识库”功能,不新增一级页面,也不放入全局设置中心。
|
||||
2. 知识库页面标题区增加“管理外部实例”;现有“新建知识库”向导增加知识库类型。
|
||||
3. 一个外部实例代表一个 Provider 服务地址和一组凭据,可绑定多个远端知识库。
|
||||
4. 一个外部知识库绑定在 GoodBuddy 中仍是一个 `KnowledgeBase`,可以进入现有聊天
|
||||
范围、每次先检索、模型按需检索和检索测试流程。
|
||||
5. 外部知识库保持远端检索,不批量同步或本地索引远端文档、分块、向量和图谱,也不
|
||||
显示“已同步”。实际进入回答的有界片段和引用随会话保存在本机,并遵循会话删除规则。
|
||||
6. Dify、FastGPT 和 RAGFlow 只使用知识库列表、详情和检索接口。App、Chat、
|
||||
Assistant、Workflow 和 Agent API 均不接入。
|
||||
7. 用户点击“添加”只创建本地绑定,不在外部实例中创建知识库;移除绑定也不删除
|
||||
远端知识库。
|
||||
8. 通用配置只包含跨 Provider 语义一致的选项。检索算法、阈值、重排和图谱开关
|
||||
由 Provider 配置承担。
|
||||
9. Provider 专属字段由 GoodBuddy 内置适配器定义。外部服务器不能下发任意表单、
|
||||
脚本或字段 Schema。
|
||||
10. API Key 在用户输入表单和受限 IPC 中短暂存在,由 Main 进程加密保存并用于请求;
|
||||
保存后不回传 Renderer,也不进入 Runtime、日志、普通元数据和检索引用。
|
||||
|
||||
## 3. 目标
|
||||
|
||||
### 3.1 用户目标
|
||||
|
||||
- 在知识库页面配置企业现有的 Dify、FastGPT 或 RAGFlow 实例。
|
||||
- 新建知识库时选择 Provider、实例和远端知识库,不手工复制资料。
|
||||
- 在同一知识库列表和聊天选择器中使用本地与外部知识库。
|
||||
- 按 Provider 能力调整检索参数,例如 RAGFlow 图谱检索。
|
||||
- 明确看到查询发送到哪个实例、检索是否成功以及引用来自哪里。
|
||||
- 连接失效后能更新凭据、重试或更换远端知识库,不丢失其他知识库配置。
|
||||
|
||||
### 3.2 产品目标
|
||||
|
||||
- 保持现有知识库 ID 作为聊天和 Runtime 的统一授权单位。
|
||||
- 让三种 Provider 的结果进入同一有界证据与引用契约。
|
||||
- 保留每种 Provider 的有效检索能力,不用最低公共字段覆盖全部差异。
|
||||
- 将实例认证与知识库绑定分离,使一个实例可以服务多个绑定。
|
||||
- 对 Provider 版本差异、列表权限关闭和部分检索失败提供可诊断状态。
|
||||
|
||||
## 4. 非目标
|
||||
|
||||
本功能不包含:
|
||||
|
||||
- 调用 Dify App、FastGPT 应用、RAGFlow Chat 或任何外部 Agent。
|
||||
- 使用外部系统生成最终回答或维护外部会话。
|
||||
- 在外部实例中创建、编辑、删除知识库、文档或分块。
|
||||
- 向外部实例上传文件、网页、剪贴板、完整会话或本地知识库内容。
|
||||
- 批量同步或本地索引外部文档、分块、Embedding、图谱或模型配置。回答实际引用的
|
||||
有界片段仍会随会话保存在本机。
|
||||
- 跨实例复制、迁移或统一管理外部知识内容。
|
||||
- 企业 SSO、组织级凭据下发、文档级 ACL 映射或 ShareServer 管理策略。
|
||||
- 自定义脚本适配器和任意 HTTP 请求模板。兼容检索 API 需要独立设计。
|
||||
- 把不同 Provider 的原始分数解释成可直接比较的统一质量分数。
|
||||
|
||||
## 5. 核心对象
|
||||
|
||||
| 对象 | 定义 | 用户可执行操作 |
|
||||
| --- | --- | --- |
|
||||
| 外部实例 | Provider、服务地址、凭据和已探测能力 | 添加、测试、编辑、停用、删除 |
|
||||
| 外部知识库目录项 | 实例列表接口返回的远端知识库摘要 | 刷新、选择 |
|
||||
| 外部知识库绑定 | GoodBuddy 知识库与远端知识库的只读关联 | 添加、改名、配置、测试、移除 |
|
||||
| Provider 配置 | 适配器支持的检索参数 | 查看、修改、恢复默认 |
|
||||
| 标准化检索结果 | Provider 结果转换后的片段、来源和诊断 | 查看、引用、提供给 Runtime |
|
||||
|
||||
## 6. 主要流程
|
||||
|
||||
### 6.1 管理外部实例
|
||||
|
||||
1. 用户在知识库页面点击“管理外部实例”。
|
||||
2. 页面列出实例名称、Provider、服务地址、状态和已绑定知识库数量。
|
||||
3. 用户添加实例,填写名称、Provider、服务地址和 API Key。
|
||||
4. “测试连接”分别验证服务可达、认证、知识库列表和检索能力。
|
||||
5. 用户保存后,API Key 被加密保存,列表只显示凭据已配置。
|
||||
|
||||
列表接口不可用但已知远端知识库 ID 可检索时,实例可以保存为“需手工指定知识库”。
|
||||
服务不可达或认证失败时不得保存为可用实例;用户可以保留表单并修正。
|
||||
|
||||
### 6.2 添加外部知识库
|
||||
|
||||
1. 用户点击现有“新建知识库”。
|
||||
2. 在“知识库类型”中选择“本地”、Dify、FastGPT 或 RAGFlow。
|
||||
3. 选择外部类型后,左侧选择同 Provider 的外部实例。
|
||||
4. GoodBuddy 自动读取远端知识库列表,并填入可搜索下拉框。
|
||||
5. 列表权限不可用时,用户可以手工填写远端知识库 ID。
|
||||
6. 用户选择一个远端知识库,填写 GoodBuddy 显示名称。
|
||||
7. 右侧展示通用配置和当前 Provider 的专属配置。
|
||||
8. 用户填写测试查询并执行一次有界检索;请求成功后创建本地绑定,零结果不算失败。
|
||||
|
||||
添加完成后沿用现有知识库规则,把新绑定加入当前聊天选择,但不会重新启用用户此前
|
||||
取消的其他知识库。
|
||||
|
||||
### 6.3 使用外部知识库
|
||||
|
||||
外部绑定出现在现有知识库列表和聊天知识范围中,持续显示 Provider 与实例名称。
|
||||
检索请求只发送本次查询文本和该绑定已保存的检索配置。返回片段经过 Main 校验、
|
||||
截断和标准化后,才作为不可信证据提供给 Runtime。
|
||||
|
||||
一次请求选择多个知识库时,本地与外部检索可以并行。单个外部知识库失败不扩大
|
||||
检索范围,也不切换其他实例;其他知识库成功时可继续回答,并显示部分失败。
|
||||
|
||||
### 6.4 编辑和移除
|
||||
|
||||
- 修改显示名称只影响 GoodBuddy 本地名称。
|
||||
- 修改 Provider 配置只影响后续检索,不写回远端知识库。
|
||||
- 更换远端知识库属于重新绑定,保存前必须重新测试。
|
||||
- 删除外部实例前必须处理其所有绑定:取消,或明确移除这些本地绑定。
|
||||
- 移除绑定只删除 GoodBuddy 中的绑定和检索配置,不调用远端删除接口。已经保存在历史
|
||||
会话中的引用继续可读,但不再提供依赖当前实例的刷新操作。
|
||||
|
||||
## 7. 功能需求
|
||||
|
||||
### EK-FR-01 知识库页面入口
|
||||
|
||||
知识库页面必须同时提供“管理外部实例”和“新建知识库”。外部实例管理不得迁移到
|
||||
全局设置中心或创建新的一级导航。
|
||||
|
||||
### EK-FR-02 实例配置
|
||||
|
||||
实例必须包含唯一 ID、显示名称、Provider、规范化服务地址、凭据状态、启停状态、
|
||||
最近测试结果和适配器能力快照。用户可以使用 `keep`、`replace`、`clear` 语义更新凭据。
|
||||
|
||||
### EK-FR-03 分项连接测试
|
||||
|
||||
连接测试必须区分服务可达、认证、列表读取和测试检索。新增实例尚未选择知识库时,
|
||||
测试检索显示“绑定知识库时验证”,不能伪装成已通过。测试错误应包含可处理原因,
|
||||
不得返回凭据、Authorization、完整响应正文或内部堆栈。
|
||||
|
||||
### EK-FR-04 远端目录读取
|
||||
|
||||
选择实例后自动读取有界、可分页的远端知识库列表。列表支持加载、刷新、搜索、空结果、
|
||||
权限不足和失败状态,且异步旧响应不得覆盖当前实例结果。
|
||||
|
||||
### EK-FR-05 手工指定远端 ID
|
||||
|
||||
列表接口不受支持或没有权限时,允许手工填写远端知识库 ID 和显示名称。保存前必须通过
|
||||
该 ID 的详情或检索验证;不能只验证字符串非空。
|
||||
|
||||
### EK-FR-06 知识库类型
|
||||
|
||||
新建向导必须提供“本地”、Dify、FastGPT、RAGFlow 四种类型。本地类型保持现有创建、
|
||||
存储模式、图谱和导入行为;外部类型不得显示本地分块、导入或索引设置。
|
||||
|
||||
### EK-FR-07 两栏配置
|
||||
|
||||
外部类型的创建向导在常规宽度使用左右两栏。左侧负责 Provider、实例、远端知识库和
|
||||
显示名称;右侧负责通用配置与 Provider 专属配置。窄窗口按来源、通用配置、专属配置
|
||||
顺序堆叠。
|
||||
|
||||
### EK-FR-08 通用配置
|
||||
|
||||
通用配置至少包含最终返回片段数、请求超时和单片段最大字符数。数值必须有固定范围,
|
||||
由 Main 再次校验。Provider 的 Top K、Token 预算或 Page Size 不得冒充同一字段。
|
||||
|
||||
### EK-FR-09 Provider 专属配置
|
||||
|
||||
Provider 配置使用受版本控制的判别联合。界面只展示当前适配器和已确认实例版本支持的字段;
|
||||
不支持的已保存字段必须明确显示为失效配置并阻止静默提交。
|
||||
|
||||
### EK-FR-10 统一知识库列表
|
||||
|
||||
外部绑定与本地知识库出现在同一列表,但必须显示 Provider、实例和“远端检索”状态。
|
||||
外部绑定不得显示本地文档数、同步、分块、向量覆盖率或本地图谱状态。
|
||||
|
||||
### EK-FR-11 统一检索范围
|
||||
|
||||
现有聊天知识范围、多选上限、`auto` 和 `always` 模式继续以 GoodBuddy 知识库 ID 工作。
|
||||
Runtime 只能获得标准化后的有界证据,不能获得实例地址、API Key 或 Provider 原始响应。
|
||||
|
||||
### EK-FR-12 引用来源
|
||||
|
||||
每条外部结果必须保留 Provider、实例 ID、远端知识库 ID、文档名称和片段。远端文档 ID、
|
||||
远端片段 ID、页码、URL、分数及其他定位只在 Provider 实际返回时保存,不得补造。
|
||||
|
||||
### EK-FR-13 检索测试
|
||||
|
||||
现有检索测试入口支持外部绑定。页面显示生效配置、总耗时、Provider 耗时、返回数量、
|
||||
截断状态和脱敏错误。外部测试不创建聊天、调用 GoodBuddy LLM 或修改远端内容。
|
||||
|
||||
### EK-FR-14 故障隔离
|
||||
|
||||
认证失败、超时、限流、目标删除、版本不兼容和响应无效必须分别报告。多知识库检索中,
|
||||
单项失败不得改写为零结果,也不得阻止已成功证据进入回答。
|
||||
|
||||
### EK-FR-15 生命周期边界
|
||||
|
||||
删除实例、移除绑定、清除凭据和更换远端目标必须经过明确确认。任何操作都不得调用
|
||||
Provider 的远端创建、更新、上传或删除接口。
|
||||
|
||||
### EK-FR-16 网络与数据说明
|
||||
|
||||
界面必须说明查询会发送到所选实例,并列出不会发送的内容。请求遵循用户配置的服务地址
|
||||
和当前内网兼容模式,不自动切换地址、Provider 或公共云端点。HTTP 或放宽证书校验会
|
||||
传输 API Key,测试和保存实例前必须显示风险并取得针对当前地址的明确确认。
|
||||
|
||||
## 8. Provider 初始范围
|
||||
|
||||
| Provider | 目录能力 | 检索能力 | 首批专属配置 |
|
||||
| --- | --- | --- | --- |
|
||||
| Dify | 知识库列表与详情 | Dataset Retrieve | 使用知识库默认配置,或完整覆盖检索方式、Top K、Score 阈值及 Rerank 模式 |
|
||||
| FastGPT | 知识库层级列表与详情 | Dataset Search Test | 搜索模式、Token 上限、相似度、Rerank |
|
||||
| RAGFlow | Dataset 列表与详情 | Retrieval | 相似度、向量权重、KNN Top K、Rerank、图谱检索、Knowledge Compilation |
|
||||
|
||||
“首批专属配置”表示适配器允许表达的字段,不保证每个实例版本都支持。具体显隐由能力
|
||||
探测和[逻辑设计](./external-knowledge-logic-design.md)共同决定。
|
||||
|
||||
## 9. 产品验收
|
||||
|
||||
1. 用户可以在知识库页面完成三种 Provider 的实例添加、连接测试、编辑和删除。
|
||||
2. 每种 Provider 至少通过一个真实自部署实例完成列表读取、绑定和检索。
|
||||
3. 列表权限关闭时,可以通过手工 ID 添加并验证一个可检索知识库。
|
||||
4. 本地与外部知识库可以同时进入一次聊天检索,引用来源可区分。
|
||||
5. RAGFlow 支持时可以开启图谱检索;不支持时不发送该字段。
|
||||
6. 外部知识库详情不出现本地导入、同步、分块和索引操作。
|
||||
7. 删除绑定或实例不会改变远端知识库和文档。
|
||||
8. 保存完成后的 Renderer 状态、Runtime、SQLite 普通元数据、日志和错误中均找不到明文
|
||||
API Key。
|
||||
9. 超时、401/403、404、429、5xx 和响应格式错误都有独立测试并显示准确状态。
|
||||
10. 中断、切换实例或关闭向导后,过期列表与测试响应不会写入当前表单。
|
||||
11. FastGPT 页面说明检索会记录远端 API 使用情况,并可能产生 Embedding 或 Rerank 用量。
|
||||
12. HTTP 或放宽证书校验的实例未经明确确认不能保存或发送 API Key。
|
||||
|
||||
## 10. 外部资料
|
||||
|
||||
以下链接用于核对初始适配范围,实施时仍须用受支持版本的真实实例和响应 fixture 验证:
|
||||
|
||||
- [Dify Knowledge API](https://docs.dify.ai/en/guides/knowledge-base/knowledge-and-documents-maintenance/maintain-dataset-via-api)
|
||||
- [Dify Retrieve Chunks](https://docs.dify.ai/en/api-reference/knowledge-bases/retrieve-chunks-from-a-knowledge-base-test-retrieval)
|
||||
- [FastGPT 知识库接口](https://doc.fastgpt.io/zh-CN/openapi/dataset)
|
||||
- [RAGFlow HTTP API](https://ragflow.io/docs/dev/http_api_reference)
|
||||
- [RAGFlow Release Notes](https://ragflow.io/docs/dev/release_notes)
|
||||
@@ -0,0 +1,580 @@
|
||||
# 外部知识库技术设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计稿 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 产品需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) |
|
||||
| 逻辑设计 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) |
|
||||
| UI 设计 | [外部知识库 UI 设计](./external-knowledge-ui-design.md) |
|
||||
|
||||
## 1. 设计约束
|
||||
|
||||
1. 外部知识库扩展现有知识库领域,不建立独立检索工具、聊天范围或 Runtime 权限模型。
|
||||
2. 外部绑定使用远端检索,不落地远端文档、分块、向量和图谱。
|
||||
3. 所有网络、凭据、Provider 响应校验和结果标准化都在 Main 完成。
|
||||
4. Renderer 只能通过受限 IPC 管理实例、读取目录、保存绑定和发起测试。
|
||||
5. Dify、FastGPT、RAGFlow 使用独立 Adapter 和判别配置,不通过任意 JSON 请求模板实现。
|
||||
6. 首次实现只调用无内容修改语义的列表、详情和检索接口。
|
||||
7. Provider API 变化通过真实 fixture、Adapter 版本和能力快照处理,不通过猜测字段兼容。
|
||||
|
||||
## 2. 现有代码边界
|
||||
|
||||
当前实现中的主要扩展点:
|
||||
|
||||
| 边界 | 现有位置 | 需要的变化 |
|
||||
| --- | --- | --- |
|
||||
| 共享知识契约 | `src/shared/knowledge-contracts.ts`、`src/shared/contracts.ts` | 增加实例、绑定、Provider 配置和外部引用判别联合 |
|
||||
| IPC 名称 | `src/shared/ipc-channels.ts` | 增加实例和目录操作通道 |
|
||||
| Preload | `src/preload/index.ts` | 暴露受限、类型化的外部知识库 API |
|
||||
| Main IPC | `src/main/ipc.ts` | 校验输入、调用服务、投影脱敏结果 |
|
||||
| 知识服务 | `src/main/knowledge/knowledge-service.ts` | 按知识库来源分派本地或外部检索 |
|
||||
| 数据库 | `src/main/knowledge/knowledge-database.ts` | 增加实例、绑定和迁移 |
|
||||
| 内部类型 | `src/main/knowledge/types.ts` | 增加 Provider、实例、绑定和标准化结果类型 |
|
||||
| 知识页面 | `src/renderer/src/KnowledgeWorkspace.tsx` | 实例管理、创建向导类型和外部详情 |
|
||||
| 检索测试 | `src/renderer/src/KnowledgeRetrievalWorkbench.tsx` | 支持本地与外部诊断判别联合 |
|
||||
| 聊天预检索 | `src/main/ipc.ts` | 统一调用 Provider-aware 检索编排 |
|
||||
| Agent 知识工具 | `src/main/agent/knowledge-mcp-gateway.ts` | 与预检索复用同一编排和引用标准化 |
|
||||
|
||||
外部 Provider 不是 `KnowledgeSourceType`。现有 `file | directory | url` 表示会进入本地
|
||||
文档、分块和索引的资料来源;把远端绑定塞入该枚举会错误触发同步、分块和来源打开逻辑。
|
||||
|
||||
## 3. 模块设计
|
||||
|
||||
建议增加:
|
||||
|
||||
```text
|
||||
src/main/knowledge/external/
|
||||
├─ external-knowledge-service.ts
|
||||
├─ external-knowledge-store.ts
|
||||
├─ external-knowledge-errors.ts
|
||||
├─ external-knowledge-http.ts
|
||||
├─ external-knowledge-registry.ts
|
||||
├─ external-knowledge-normalizer.ts
|
||||
└─ providers/
|
||||
├─ dify-adapter.ts
|
||||
├─ fastgpt-adapter.ts
|
||||
└─ ragflow-adapter.ts
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
- `ExternalKnowledgeService`:实例、目录、绑定、测试和检索编排。
|
||||
- `ExternalKnowledgeStore`:实例和绑定持久化,凭据加解密。
|
||||
- `ExternalKnowledgeHttp`:超时、取消、大小限制、地址和响应读取。
|
||||
- `ExternalKnowledgeRegistry`:按 Provider 返回内置 Adapter,不接受运行时插件注册。
|
||||
- `ExternalKnowledgeNormalizer`:统一结果边界、截断和引用。
|
||||
- Adapter:构造固定端点与请求,解析 Provider 响应,声明静态配置和能力条件。
|
||||
|
||||
`KnowledgeService` 继续作为聊天、检索测试和 MCP 网关的统一入口。它根据
|
||||
`KnowledgeBase.kind` 调用现有本地检索或 `ExternalKnowledgeService.retrieve`。
|
||||
|
||||
## 4. Provider Adapter
|
||||
|
||||
```ts
|
||||
type ExternalKnowledgeProvider = 'dify' | 'fastgpt' | 'ragflow'
|
||||
|
||||
interface ExternalKnowledgeAdapter<PersistedConfig, CapabilitySnapshot> {
|
||||
readonly provider: ExternalKnowledgeProvider
|
||||
readonly configVersion: number
|
||||
|
||||
normalizeBaseUrl(input: string): URL
|
||||
testConnection(context: AdapterContext): Promise<ConnectionTestResult>
|
||||
listKnowledgeBases(
|
||||
context: AdapterContext,
|
||||
input: CatalogPageInput
|
||||
): Promise<CatalogPage>
|
||||
getKnowledgeBase(
|
||||
context: AdapterContext,
|
||||
remoteKnowledgeBaseId: string
|
||||
): Promise<RemoteKnowledgeBaseSummary>
|
||||
probeRetrieval(
|
||||
context: AdapterContext,
|
||||
input: ProviderRetrieveInput<PersistedConfig>
|
||||
): Promise<ProviderRetrieveResult>
|
||||
retrieve(
|
||||
context: AdapterContext,
|
||||
input: ProviderRetrieveInput<PersistedConfig>
|
||||
): Promise<ProviderRetrieveResult>
|
||||
parseCapabilities(input: unknown): CapabilitySnapshot
|
||||
validateConfig(
|
||||
input: unknown,
|
||||
capabilities: CapabilitySnapshot
|
||||
): PersistedConfig
|
||||
}
|
||||
```
|
||||
|
||||
`probeRetrieval` 可以复用 `retrieve`,但必须使用用户在创建向导中明确输入的测试查询。
|
||||
不得生成会泄露组织信息的默认问题,不得调用 Provider 的 App 或模型问答接口,也不得
|
||||
修改远端知识内容。Provider 自身记录 API 审计或计算检索用量不属于内容修改,必须由界面
|
||||
提前说明。
|
||||
|
||||
Adapter 返回数据对象,不返回 React 组件、HTML 或远端 Schema。Renderer 中的配置定义
|
||||
由共享的 Provider 判别联合和本地字段描述生成。
|
||||
|
||||
## 5. 共享契约
|
||||
|
||||
### 5.1 实例
|
||||
|
||||
```ts
|
||||
type CredentialMutation =
|
||||
| { action: 'keep' }
|
||||
| { action: 'replace'; value: string }
|
||||
| { action: 'clear' }
|
||||
|
||||
interface ExternalKnowledgeInstanceSummary {
|
||||
id: string
|
||||
name: string
|
||||
provider: 'dify' | 'fastgpt' | 'ragflow'
|
||||
baseUrl: string
|
||||
enabled: boolean
|
||||
credentialStatus: 'configured' | 'missing' | 'unavailable'
|
||||
probeStatus:
|
||||
| 'untested'
|
||||
| 'catalog-ready'
|
||||
| 'healthy'
|
||||
| 'list-restricted'
|
||||
| 'auth-failed'
|
||||
| 'unreachable'
|
||||
| 'incompatible'
|
||||
| 'failed'
|
||||
detectedVersion?: string
|
||||
bindingCount: number
|
||||
lastTestedAt?: string
|
||||
lastErrorCode?: string
|
||||
}
|
||||
```
|
||||
|
||||
Renderer 输入使用 `CredentialMutation`,因此新密钥会在输入控件和该次 IPC 中短暂存在。
|
||||
Main 返回值永远没有 `credential`、`apiKey`、`authorization` 或加密 envelope。
|
||||
|
||||
### 5.2 绑定
|
||||
|
||||
```ts
|
||||
interface ExternalKnowledgeBinding {
|
||||
knowledgeBaseId: string
|
||||
instanceId: string
|
||||
remoteKnowledgeBaseId: string
|
||||
remoteName: string
|
||||
remoteDescription?: string
|
||||
commonConfig: ExternalKnowledgeCommonConfig
|
||||
providerConfig: DifyConfig | FastGptConfig | RagflowConfig
|
||||
providerConfigVersion: number
|
||||
adapterVersion: number
|
||||
lastVerifiedAt?: string
|
||||
}
|
||||
```
|
||||
|
||||
`providerConfig` 必须带 `provider` 判别字段,且与实例 Provider 一致。`remoteName` 是用于
|
||||
显示和变化检测的快照,不是授权标识。
|
||||
|
||||
### 5.3 标准化结果
|
||||
|
||||
现有 `KnowledgeRetrievalResult` 假定结果映射到本地 `documentId/sourceId/chunkId`。需要改为
|
||||
引用定位的判别联合,同时保持现有调用方可按统一字段读取片段:
|
||||
|
||||
```ts
|
||||
type KnowledgeReferenceLocator =
|
||||
| {
|
||||
kind: 'local'
|
||||
documentId: string
|
||||
sourceId: string
|
||||
chunkId: string
|
||||
parentChunkId?: string
|
||||
}
|
||||
| {
|
||||
kind: 'external'
|
||||
provider: ExternalKnowledgeProvider
|
||||
instanceId: string
|
||||
remoteKnowledgeBaseId: string
|
||||
remoteDocumentId?: string
|
||||
remoteChunkId?: string
|
||||
providerScore?: number
|
||||
location?: string
|
||||
sourceUrl?: string
|
||||
metadata?: Record<string, string | number | boolean | null>
|
||||
}
|
||||
|
||||
interface UnifiedKnowledgeRetrievalResult {
|
||||
knowledgeBaseId: string
|
||||
documentTitle: string
|
||||
sourceDisplayName: string
|
||||
snippet: string
|
||||
relevance?: number
|
||||
rank: number
|
||||
locator: KnowledgeReferenceLocator
|
||||
}
|
||||
```
|
||||
|
||||
`remoteDocumentId` 和 `remoteChunkId` 只保存 Provider 实际返回的标识,缺失时不得从标题、
|
||||
序号或内容哈希推导。`providerScore` 保留原始值,但不改名为统一 `relevance`。只有经过
|
||||
单独定义和验证的归一化算法才能填写跨 Provider `relevance`。初始跨库拼装按结果配额和
|
||||
稳定 Provider 顺序工作,不按原始分数混排。
|
||||
|
||||
所有共享 Schema 使用严格对象并限制:
|
||||
|
||||
- 查询不超过 4,000 字符;同时遵守 Provider 更小限制,例如 Dify 当前公开接口的
|
||||
250 字符限制,超出时在发送前明确失败,不静默截断查询。
|
||||
- 单次目录页不超过 100 项,总目录最多 500 项。
|
||||
- 标准化结果最多 20 条,单片段最多 8,000 字符。
|
||||
- 标题、ID、定位、URL 和元数据键值均设独立上限。
|
||||
- Provider 原始响应总字节数设固定上限,超限立即取消读取。
|
||||
|
||||
## 6. 持久化
|
||||
|
||||
### 6.1 数据库变化
|
||||
|
||||
保留现有 `knowledge_bases` 表,增加:
|
||||
|
||||
```sql
|
||||
ALTER TABLE knowledge_bases
|
||||
ADD COLUMN kind TEXT NOT NULL DEFAULT 'local'
|
||||
CHECK (kind IN ('local', 'external'));
|
||||
```
|
||||
|
||||
增加实例表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE external_knowledge_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK (provider IN ('dify', 'fastgpt', 'ragflow')),
|
||||
base_url TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||
credential_envelope_json TEXT,
|
||||
probe_status TEXT NOT NULL,
|
||||
detected_version TEXT,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
transport_security TEXT NOT NULL,
|
||||
transport_risk_accepted_url TEXT,
|
||||
transport_risk_accepted_at TEXT,
|
||||
last_tested_at TEXT,
|
||||
last_error_code TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
增加绑定表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE external_knowledge_bindings (
|
||||
knowledge_base_id TEXT PRIMARY KEY
|
||||
REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||
instance_id TEXT NOT NULL
|
||||
REFERENCES external_knowledge_instances(id) ON DELETE RESTRICT,
|
||||
remote_knowledge_base_id TEXT NOT NULL,
|
||||
remote_name TEXT NOT NULL,
|
||||
remote_description TEXT,
|
||||
common_config_json TEXT NOT NULL,
|
||||
provider_config_json TEXT NOT NULL,
|
||||
provider_config_version INTEGER NOT NULL,
|
||||
adapter_version INTEGER NOT NULL,
|
||||
binding_status TEXT NOT NULL,
|
||||
last_verified_at TEXT,
|
||||
last_success_at TEXT,
|
||||
last_error_code TEXT,
|
||||
UNIQUE(instance_id, remote_knowledge_base_id)
|
||||
);
|
||||
```
|
||||
|
||||
已有知识库迁移为 `kind = 'local'`。外部知识库仍保留现有名称、描述和创建时间,但本地
|
||||
`storageMode`、图谱、分块和本地检索设置不进入外部请求;后续可以在数据库重构时再移除
|
||||
这些历史必填默认值,本功能不为此扩大迁移范围。
|
||||
|
||||
### 6.2 凭据
|
||||
|
||||
复用 `SettingsCredentialCipher` 和 Electron `safeStorage`:
|
||||
|
||||
- `replace`:Main 加密新值后在一个事务中替换 envelope。
|
||||
- `keep`:不读取、不重写现有 envelope。
|
||||
- `clear`:确认后删除 envelope,并把实例凭据状态设为缺失。
|
||||
- 安全存储不可用时拒绝新增或替换,不降级为明文。
|
||||
- 解密只发生在发起请求前;请求结束后不缓存明文。
|
||||
|
||||
实例导出、诊断和数据库普通 JSON 视图均排除 `credential_envelope_json`。
|
||||
|
||||
## 7. IPC 设计
|
||||
|
||||
建议增加独立通道:
|
||||
|
||||
```text
|
||||
knowledge:external-instances:list
|
||||
knowledge:external-instances:test
|
||||
knowledge:external-instances:save
|
||||
knowledge:external-instances:set-enabled
|
||||
knowledge:external-instances:delete
|
||||
knowledge:external-catalog:list
|
||||
knowledge:external-catalog:get
|
||||
knowledge:external-bindings:create
|
||||
knowledge:external-bindings:update
|
||||
knowledge:external-bindings:remove
|
||||
knowledge:external-retrieval:test
|
||||
```
|
||||
|
||||
所有 Handler 必须:
|
||||
|
||||
1. `assertTrustedSender`。
|
||||
2. 用共享 Zod Schema 解析严格输入。
|
||||
3. 根据实例 ID 在 Main 读取地址和凭据,不接受 Renderer 在检索请求中重复传入。
|
||||
4. 使用稳定错误码返回脱敏错误。
|
||||
5. 对网络操作支持 AbortSignal、总超时和响应大小限制。
|
||||
|
||||
目录和测试请求返回 `requestId`。Renderer 仍需校验当前类型、实例和目标与响应关联,避免
|
||||
旧响应覆盖新表单。
|
||||
|
||||
## 8. HTTP 边界
|
||||
|
||||
### 8.1 服务地址
|
||||
|
||||
- 只接受 `http:` 和 `https:`。
|
||||
- 拒绝用户名、密码、查询和片段。
|
||||
- Adapter 负责规范化 Provider 所需 API 根路径,避免用户填写完整操作端点。
|
||||
- 请求只能拼接 Adapter 内置相对路径,远端目录项或错误不能改变目标 URL。
|
||||
- 重定向默认拒绝;若某 Provider 的受支持部署确需重定向,只允许同源且不得跨跳转发送
|
||||
Authorization。
|
||||
- 当前全局“内网兼容模式”决定是否允许 HTTP 和非标准 HTTPS 证书;操作开始后冻结该值。
|
||||
- HTTP 或实际使用证书例外时,Main 返回 `transportSecurity` 风险状态。用户必须针对规范化
|
||||
后的当前地址明确确认,才能保存实例或发送 API Key;地址变化立即使旧确认失效。
|
||||
- 不进行公共云回退,不把自部署地址替换成 Provider 官方地址。
|
||||
|
||||
用户明确配置内网地址属于产品用途,因此不能采用禁止私网地址的通用 SSRF 规则。安全边界
|
||||
依靠显式实例配置、Main-only 请求、固定端点、无任意方法和无远端 URL 跟随。
|
||||
|
||||
### 8.2 请求限制
|
||||
|
||||
- 默认超时 15 秒,用户可在 1 至 60 秒内调整。
|
||||
- 目录、详情和检索分别设置响应字节上限。
|
||||
- JSON 解析后仍执行深度、数组数量、字符串长度和未知字段策略。
|
||||
- 默认不自动重试。用户重试或上层下一次检索会产生新请求。
|
||||
- 取消、应用关闭和实例删除立即终止相关请求。
|
||||
- 日志只记录 Provider、实例 ID、操作、状态码、稳定错误码、耗时和字节数。
|
||||
|
||||
## 9. Provider 映射
|
||||
|
||||
本节记录 2026-09-03 核对的初始公开接口。实施时应把每个受支持版本的真实响应保存为
|
||||
去敏 fixture,并以 fixture 和真实实例测试作为发布依据。
|
||||
|
||||
### 9.1 Dify
|
||||
|
||||
| 操作 | 接口 |
|
||||
| --- | --- |
|
||||
| 列表 | `GET /v1/datasets` |
|
||||
| 检索 | `POST /v1/datasets/{dataset_id}/retrieve` |
|
||||
| 认证 | `Authorization: Bearer <knowledge-api-key>` |
|
||||
|
||||
Adapter 只接受 Knowledge Service API Key,不调用应用 `/info`、Chat 或 Workflow。默认不
|
||||
发送 `retrieval_model`,沿用远端知识库配置。用户选择覆盖时,Adapter 必须一次收集并验证
|
||||
当前接口要求的搜索方式、Top K、阈值、`reranking_mode`,以及相应的 Rerank Provider/模型
|
||||
或关键词/向量权重与 Embedding Provider/模型;缺少任何必填标识时不构造请求。响应从
|
||||
`records[].segment`、`score`、文档名称和元数据提取标准化引用。
|
||||
|
||||
Dify 当前文档限制查询不超过 250 字符。GoodBuddy 不静默截断;超出时检索测试和聊天
|
||||
诊断返回 Provider 限制,并允许后续单独设计查询压缩策略。
|
||||
|
||||
### 9.2 FastGPT
|
||||
|
||||
| 操作 | 接口 |
|
||||
| --- | --- |
|
||||
| 列表 | `POST /api/core/dataset/list` |
|
||||
| 详情 | `GET /api/core/dataset/detail?id=...` |
|
||||
| 检索 | `POST /api/core/dataset/searchTest` |
|
||||
| 认证 | `Authorization: Bearer <api-key>` |
|
||||
|
||||
列表响应同时包含文件夹和知识库,只有 `type = dataset` 可以绑定。检索映射 `datasetId`、
|
||||
`text`、Token `limit`、`similarity`、`searchMode` 和 `usingReRank`。初始版本不发送查询优化
|
||||
字段,避免调用 FastGPT 配置的 LLM。
|
||||
响应从 `q`、`a`、`sourceName`、`sourceId`、`collectionId` 和 `score` 构造片段与引用。
|
||||
|
||||
FastGPT 4.15.0 起部署实例的 `/apidoc/devapi` 是接口事实来源,手工文档可能落后。支持矩阵
|
||||
必须按真实版本测试;不能因为健康页可访问就假定列表和检索 Schema 相同。`searchTest`
|
||||
可能记录 `SEARCH_TEST` 等远端审计并更新 API Key 用量;Embedding 和 Rerank 也可能产生
|
||||
费用。GoodBuddy 将这些行为作为检索成本显示,不宣称该接口没有副作用。
|
||||
|
||||
### 9.3 RAGFlow
|
||||
|
||||
| 操作 | 接口 |
|
||||
| --- | --- |
|
||||
| 列表/详情 | `GET /api/v1/datasets` |
|
||||
| 检索 | `POST /api/v1/retrieval` |
|
||||
| 认证 | `Authorization: Bearer <api-key>` |
|
||||
|
||||
检索映射 `question`、`dataset_ids`、`page_size`、`similarity_threshold`、
|
||||
`vector_similarity_weight`、`knn_top_k`、重排、`use_kg` 和
|
||||
`include_knowledge_compilation`。不发送已弃用的 `top_k`。响应从 `data.chunks` 和
|
||||
`data.doc_aggs` 提取片段、文档、位置和原始相似度。
|
||||
|
||||
图谱规则:
|
||||
|
||||
- `parser_config.graphrag` 或等价详情字段作为已有图谱的证据,不写回。
|
||||
- `use_kg` 是检索时开关,只在已有图谱和受支持版本均确认后发送。
|
||||
- Knowledge Compilation 与旧 GraphRAG 分别建模。
|
||||
- `include_knowledge_compilation` 使用本地保存的显式布尔值,初始默认 false;即使服务端
|
||||
默认 true,也不依赖字段省略获得隐式行为。
|
||||
- Provider 没有返回图节点或路径证据时,GoodBuddy 不生成图谱引用。
|
||||
|
||||
## 10. 检索编排
|
||||
|
||||
```text
|
||||
retrieveMany(knowledgeBaseIds, query, requestContext)
|
||||
├─ load and authorize GoodBuddy knowledge bases
|
||||
├─ partition local and external bindings
|
||||
├─ local: existing retrieval path
|
||||
├─ external: bounded concurrent adapter requests
|
||||
├─ validate and normalize each response
|
||||
├─ allocate deterministic context budget
|
||||
└─ return evidence plus per-library diagnostics
|
||||
```
|
||||
|
||||
预检索和 `knowledge_search` 工具必须调用同一个 `retrieveMany`。如果只修改聊天预检索而
|
||||
未修改 `KnowledgeMcpGateway`,`auto` 和 `always` 会产生不同能力,这是阻断发布的问题。
|
||||
|
||||
### 10.1 上下文预算
|
||||
|
||||
- 每个知识库先保留不超过其 `resultLimit` 的结果。
|
||||
- 先给每个成功知识库一个最小有界配额,再按稳定顺序分配剩余字符。
|
||||
- 不用不同 Provider 原始分数决定跨库优先级。
|
||||
- 片段截断在 Main 完成并在引用中标记。
|
||||
- 相同实例的多个绑定仍按绑定分别请求,除非 Provider 明确支持且 Adapter 实现等价的多
|
||||
Dataset 请求;合并不得改变每个绑定的配置和诊断。
|
||||
|
||||
### 10.2 失败结果
|
||||
|
||||
返回结构包含每个知识库的:
|
||||
|
||||
```ts
|
||||
interface KnowledgeRetrievalOutcome {
|
||||
knowledgeBaseId: string
|
||||
status: 'success' | 'empty' | 'failed' | 'cancelled'
|
||||
durationMs: number
|
||||
resultCount: number
|
||||
errorCode?: ExternalKnowledgeErrorCode
|
||||
truncated: boolean
|
||||
}
|
||||
```
|
||||
|
||||
至少一个 `success` 或 `empty` 表示检索编排完成;全部 `failed/cancelled` 时上层显示整体失败。
|
||||
|
||||
## 11. 引用与来源打开
|
||||
|
||||
外部结果不能进入依赖本地数据库的 `referenceContext` 和 `openSource` 路径。
|
||||
|
||||
- 引用详情直接使用请求时保存的标准化片段和定位。
|
||||
- Main 可以按远端片段 ID 提供一次只读重新读取,但初始版本不要求。
|
||||
- Provider 返回 URL 只作为不可信元数据保存。没有独立 URL 规则和真实实例验证前,
|
||||
Renderer 不显示打开链接操作。
|
||||
- 活动和会话持久化保存标准化引用,其中包含实际用于回答的有界远端片段;这属于引用
|
||||
留存,不是批量同步或本地索引。删除会话或对应活动时按现有生命周期删除该副本。
|
||||
- 实例删除后,历史引用仍显示 Provider、知识库、文档名称和已保存片段,但标记连接已移除。
|
||||
|
||||
## 12. 支持矩阵与版本变化
|
||||
|
||||
每个 Adapter 维护明确的最低版本、已验证版本和 `adapterVersion`。连接测试优先读取可靠的
|
||||
版本接口或响应头;无法识别版本时,只允许经真实请求验证过的基础列表和检索字段,高级字段
|
||||
保持不可用。
|
||||
|
||||
Adapter 可以保存少量受控事实,例如“目录接口可用”“详情表明已构建图谱”,但不尝试从
|
||||
远端拼出完整 capability manifest,也不计算 capability fingerprint。
|
||||
|
||||
- `adapterVersion` 相同且实例仍在支持矩阵内:解析已保存配置并执行。
|
||||
- Adapter 升级:使用明确的配置迁移或把绑定标记为 `config-invalid`,不猜测字段迁移。
|
||||
- 实例版本离开支持矩阵:停止发送高级字段,并要求重新测试和确认。
|
||||
- 新能力出现:保持默认关闭,用户主动配置后才发送。
|
||||
- RAGFlow `use_kg` 还要求当前远端知识库存在已构建图谱的证据。
|
||||
|
||||
## 13. 安全与隐私
|
||||
|
||||
- API Key 在用户输入和保存 IPC 中短暂存在,随后使用系统安全存储加密;保存后不返回
|
||||
Renderer,也不进入 Runtime、日志和普通元数据。
|
||||
- 请求不附带完整会话、附件、本地知识库证据、系统提示、模型配置或其他 Provider 结果。
|
||||
- Provider 结果使用与本地知识证据相同的不可信上下文标记。
|
||||
- Adapter 拒绝响应中的原型污染键、过深对象、超长数组和非有限数值。
|
||||
- 远端错误正文只用于 Main 内映射,日志与 UI 使用稳定错误码和短消息。
|
||||
- 外部知识库不能扩大当前对话显式选择的 GoodBuddy 知识库 ID。
|
||||
- Ask 与 Execute 对知识检索均为只读,Execute 不获得额外 Provider 权限。
|
||||
- Provider 配置中的模型 ID、背景文本和 Metadata 条件按独立长度限制验证,不能携带凭据。
|
||||
- HTTP 或证书例外需要按当前规范化地址确认凭据传输风险,地址变化后重新确认。
|
||||
|
||||
## 14. 测试
|
||||
|
||||
### 14.1 契约测试
|
||||
|
||||
- 每个 Provider 的列表、详情、检索成功和错误 fixture。
|
||||
- 未知字段、缺字段、错误类型、超长内容、非有限分数和超大响应。
|
||||
- Dify 查询长度、FastGPT 文件夹过滤、RAGFlow 图谱和 Knowledge Compilation 字段。
|
||||
- Provider 配置判别联合拒绝类型错配和未知字段。
|
||||
|
||||
### 14.2 安全测试
|
||||
|
||||
- 保存完成后的 Renderer 快照、IPC 返回值、日志、错误、活动和导出中不存在明文 API Key;
|
||||
`replace` 输入只在当前表单和单次 IPC 请求中短暂存在。
|
||||
- `keep/replace/clear` 凭据语义和安全存储不可用路径。
|
||||
- 地址中的凭据、查询、片段、危险 scheme、跨源重定向和远端 URL 注入被拒绝。
|
||||
- 取消、超时、响应大小上限和应用关闭释放请求。
|
||||
|
||||
### 14.3 集成测试
|
||||
|
||||
- 实例保存、目录读取、手工 ID、绑定创建、编辑和本地移除。
|
||||
- 同一远端目标重复绑定被拒绝。
|
||||
- 删除实例的引用保护和级联本地移除确认。
|
||||
- 本地与外部 `retrieveMany` 部分成功、全部失败和确定性上下文预算。
|
||||
- 聊天 `always` 与 Agent `knowledge_search` 使用同一 Provider-aware 编排。
|
||||
- 历史引用在实例或绑定移除后仍可读。
|
||||
|
||||
### 14.4 UI 测试
|
||||
|
||||
- 类型切换、异步旧响应、目录搜索、刷新和手工模式。
|
||||
- 两栏与窄窗口堆叠、键盘、焦点恢复和离开保护。
|
||||
- Provider 字段显隐、失效配置、RAGFlow 图谱说明和恢复默认。
|
||||
- 外部详情不出现导入、同步、分块和本地图谱操作。
|
||||
- 错误、零结果、部分失败和 Provider 引用显示。
|
||||
|
||||
### 14.5 真实实例验收
|
||||
|
||||
每个 Provider 至少选择一个明确版本的自部署实例,记录:
|
||||
|
||||
- 版本、部署方式和认证范围。
|
||||
- 列表与检索的请求/响应 fixture,移除凭据和业务正文。
|
||||
- 默认与专属配置是否真实生效。
|
||||
- 401、403、404、429、超时和取消行为。
|
||||
- 查询、片段、引用和诊断的实际上限。
|
||||
|
||||
Mock 或公开文档通过不能代替真实实例验收。
|
||||
|
||||
## 15. 实施顺序
|
||||
|
||||
这里的顺序只用于控制代码依赖,不裁剪产品设计:
|
||||
|
||||
1. 建立共享判别契约、数据库迁移和凭据存储。
|
||||
2. 实现 HTTP 边界、错误映射和 Adapter Registry。
|
||||
3. 逐个实现三种 Provider,并用 fixture 完成契约测试。
|
||||
4. 实现实例管理和目录 IPC。
|
||||
5. 扩展创建向导、统一列表和外部详情。
|
||||
6. 扩展检索测试、引用和 `retrieveMany`。
|
||||
7. 接入聊天预检索与 `KnowledgeMcpGateway`。
|
||||
8. 完成真实实例、安全、响应式和跨平台验收。
|
||||
|
||||
## 16. 发布条件
|
||||
|
||||
- 三种 Provider 的受支持版本和限制已在产品界面或发布说明中可查。
|
||||
- 真实实例列表、绑定、检索和错误矩阵均通过。
|
||||
- 没有写入远端知识内容的代码路径。
|
||||
- API Key 泄漏测试、地址边界和响应大小测试通过。
|
||||
- 本地知识库创建、同步、检索、图谱和引用回归测试通过。
|
||||
- `auto`、`always`、检索测试和 MCP 知识工具得到一致的外部知识能力。
|
||||
|
||||
## 17. 需求追踪
|
||||
|
||||
| 需求 | 技术落点 | 验证 |
|
||||
| --- | --- | --- |
|
||||
| `EK-FR-01` 至 `EK-FR-03` | 实例契约、Store、实例 IPC、凭据和测试状态 | 实例集成测试、安全测试 |
|
||||
| `EK-FR-04`、`EK-FR-05` | Adapter 目录与详情接口、目录 IPC | Provider fixture、手工 ID 集成测试 |
|
||||
| `EK-FR-06` 至 `EK-FR-09` | Provider 判别联合、Adapter 配置版本、Renderer 本地字段定义 | 契约测试、UI 字段显隐测试 |
|
||||
| `EK-FR-10`、`EK-FR-11` | `KnowledgeBase.kind`、绑定表、统一 `retrieveMany` | 本地与外部混合检索测试 |
|
||||
| `EK-FR-12`、`EK-FR-13` | 引用定位判别联合、结果标准化、检索测试 IPC | 引用持久化与 UI 测试 |
|
||||
| `EK-FR-14` | 稳定错误码、逐库 Outcome、取消和超时 | 错误 fixture、部分失败测试 |
|
||||
| `EK-FR-15` | 外键限制、绑定本地删除、无远端写接口 | 删除生命周期测试 |
|
||||
| `EK-FR-16` | Main-only HTTP、固定端点、传输风险确认 | 地址、证书和凭据泄漏测试 |
|
||||
@@ -0,0 +1,401 @@
|
||||
# 外部知识库 UI 设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计稿 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 产品需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) |
|
||||
| 行为规则 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) |
|
||||
| 全局规范 | [GoodBuddy UI 设计系统](../../../UI-DESIGN.md) |
|
||||
|
||||
## 1. 信息架构
|
||||
|
||||
外部知识库不增加左侧主导航和知识库一级页签。现有知识库页面结构保持:
|
||||
|
||||
```text
|
||||
知识库
|
||||
├─ 页面标题区
|
||||
│ ├─ 管理外部实例
|
||||
│ └─ 新建知识库
|
||||
├─ 知识库列表
|
||||
└─ 当前知识库详情
|
||||
```
|
||||
|
||||
“管理外部实例”管理可复用连接;“新建知识库”负责创建本地知识库或添加一个远端绑定。
|
||||
|
||||
## 2. 页面标题区
|
||||
|
||||
常规宽度:
|
||||
|
||||
```text
|
||||
知识库 [管理外部实例] [新建知识库]
|
||||
导入、检索并维护本地和企业现有知识
|
||||
范围:全局
|
||||
```
|
||||
|
||||
- “新建知识库”保持主按钮。
|
||||
- “管理外部实例”是带服务器图标的次按钮。
|
||||
- 沿用知识库页面的全局 `ScopeBadge`,创建和管理外部连接时持续可见。
|
||||
- 按钮使用文字和图标,不增加只含缩写的厂商快捷入口。
|
||||
- 窄窗口下两个按钮换到标题下方,保持同一行;空间不足时各占一半宽度。
|
||||
|
||||
对应:`EK-FR-01`。
|
||||
|
||||
## 3. 知识库列表
|
||||
|
||||
本地和外部知识库保留在同一主列表。条目采用现有列表密度,不增加卡片墙。
|
||||
|
||||
```text
|
||||
研发规范
|
||||
RAGFlow · 研发知识平台 · 远端可检索
|
||||
|
||||
产品资料
|
||||
本地 · 12 个来源 · 全文与向量可用
|
||||
```
|
||||
|
||||
### 3.1 外部条目字段
|
||||
|
||||
- GoodBuddy 显示名称。
|
||||
- Provider 图标或文字徽标。
|
||||
- 外部实例名称。
|
||||
- “远端可检索”、认证失效、目标不存在等准确状态。
|
||||
- 最近一次成功检索时间;从未检索时显示“尚未检索”。
|
||||
|
||||
外部条目不显示来源数量、同步进度、向量覆盖率、本地图谱或导入失败数。
|
||||
|
||||
### 3.2 列表筛选
|
||||
|
||||
现有搜索同时匹配 GoodBuddy 名称、远端名称、Provider 和实例名称。来源筛选使用菜单:
|
||||
|
||||
```text
|
||||
全部来源
|
||||
本地
|
||||
Dify
|
||||
FastGPT
|
||||
RAGFlow
|
||||
```
|
||||
|
||||
筛选不是新的页面页签。
|
||||
|
||||
## 4. 管理外部实例
|
||||
|
||||
点击“管理外部实例”打开大尺寸 Modal。Modal 使用标题、可滚动内容和固定操作区,遵循
|
||||
全局焦点陷阱、Escape、背景隔离和焦点恢复规则。
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 外部实例 [添加实例] [×] │
|
||||
│ 管理 Dify、FastGPT 和 RAGFlow 的连接 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 名称 Provider 服务地址 状态 操作 │
|
||||
│ 研发知识平台 RAGFlow ragflow.example 正常 ⋯ │
|
||||
│ 客服知识平台 FastGPT fastgpt.example 认证失效 ⋯ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.1 实例行
|
||||
|
||||
显示:
|
||||
|
||||
- 名称、Provider、脱敏后的主机名、状态。
|
||||
- 已绑定知识库数量。
|
||||
- 最近测试时间。
|
||||
- 行尾菜单:测试连接、编辑、停用或启用、删除。
|
||||
|
||||
服务地址可以显示 scheme、host 和非默认端口,不显示路径中的敏感信息、查询或凭据。
|
||||
|
||||
### 4.2 空状态
|
||||
|
||||
```text
|
||||
尚未配置外部实例
|
||||
添加企业现有的 Dify、FastGPT 或 RAGFlow 服务。
|
||||
[添加实例]
|
||||
```
|
||||
|
||||
筛选无结果和加载失败使用不同 `EmptyState` 变体。
|
||||
|
||||
## 5. 添加和编辑实例
|
||||
|
||||
实例表单在管理 Modal 内切换为编辑面板,不叠加第二层 Modal。
|
||||
|
||||
```text
|
||||
添加外部实例
|
||||
|
||||
实例名称 [研发知识平台 ]
|
||||
Provider [RAGFlow ▾]
|
||||
服务地址 [https://ragflow.example ]
|
||||
认证方式 API Key
|
||||
API Key [•••••••••••••••• ]
|
||||
凭据状态 尚未保存
|
||||
|
||||
连接测试
|
||||
服务连接 未测试
|
||||
身份认证 未测试
|
||||
知识库列表 未测试
|
||||
检索接口 绑定知识库时验证
|
||||
|
||||
[取消] [测试连接] [保存实例]
|
||||
```
|
||||
|
||||
### 5.1 字段行为
|
||||
|
||||
- Provider 使用原生 Select;编辑已被绑定的实例时不可切换 Provider。
|
||||
- 服务地址只接受完整 HTTP(S) 地址。字段下方说明其受全局内网兼容模式影响。
|
||||
- API Key 使用密码输入和显示/隐藏图标。明文会在当前表单和保存 IPC 中短暂存在;编辑时
|
||||
留空表示保留,不使用占位假密钥。
|
||||
- 已保存凭据旁提供“替换凭据”和“清除凭据”,清除属于需要确认的操作。
|
||||
- 地址使用 HTTP,或连接测试依赖放宽证书校验时,在测试和保存操作旁持续显示传输风险。
|
||||
用户必须针对当前地址确认后才能保存;地址变化会清除该确认。
|
||||
- “测试连接”不保存草稿;测试期间按钮显示进度并提供取消。
|
||||
- 保存成功进入应用通知,Modal 返回实例列表并聚焦对应行。
|
||||
|
||||
### 5.2 测试结果
|
||||
|
||||
分项状态使用图标、文字和说明,不只依靠颜色:
|
||||
|
||||
```text
|
||||
服务连接 通过 183 ms
|
||||
身份认证 通过
|
||||
知识库列表 受限 当前凭据不能读取列表,可手工填写知识库 ID
|
||||
检索接口 待验证 添加知识库时执行
|
||||
```
|
||||
|
||||
长错误折叠显示,正文经过脱敏。失败后保留全部草稿和 API Key 输入。
|
||||
|
||||
FastGPT 实例额外显示:`检索会计入该实例的 API 使用记录,并可能产生 Embedding 或
|
||||
Rerank 用量。`
|
||||
|
||||
## 6. 新建知识库向导
|
||||
|
||||
扩展现有页面内 `CreateLibraryWizard`,不改为新的全屏页面或 Modal。
|
||||
|
||||
### 6.1 类型选择
|
||||
|
||||
表单第一项使用单选卡片或共享 SegmentedControl:
|
||||
|
||||
```text
|
||||
知识库类型
|
||||
[本地] [Dify] [FastGPT] [RAGFlow]
|
||||
```
|
||||
|
||||
只有选择本地时显示现有存储模式、图谱和本地说明。外部类型选中后显示两栏配置。
|
||||
|
||||
### 6.2 外部类型两栏布局
|
||||
|
||||
```text
|
||||
┌──────────────────────────────┬──────────────────────────────┐
|
||||
│ 知识库来源 │ 检索配置 │
|
||||
│ │ │
|
||||
│ Provider RAGFlow │ 通用配置 │
|
||||
│ 外部实例 [研发平台 ▾] │ 返回片段数 [ 6 ] │
|
||||
│ 远端知识库 [研发规范 ▾] │ 请求超时 [15 秒] │
|
||||
│ [↻] │ 单片段上限 [4000] │
|
||||
│ [手工填写知识库 ID] │ │
|
||||
│ │ RAGFlow 配置 │
|
||||
│ 显示名称 [研发规范 ]│ 相似度阈值 [0.20] │
|
||||
│ 远端名称 研发规范 │ 向量相似度权重 [0.30] │
|
||||
│ 远端 ID 7f31…c902 │ KNN Top K [1024] │
|
||||
│ 测试查询 [部署网络要求 ]│ 图谱检索 [开关] │
|
||||
│ │ Knowledge Compilation [开关]│
|
||||
├──────────────────────────────┴──────────────────────────────┤
|
||||
│ 将向研发知识平台发送测试查询。不会上传文件或完整会话。 │
|
||||
│ [取消] [测试检索] [添加知识库] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
测试查询由用户填写,不自动生成,也不取用当前会话内容。“添加知识库”在真实检索请求
|
||||
成功前禁用;成功返回零条结果仍可添加,但界面显示“检索成功,未找到匹配片段”。验证结果
|
||||
在底部说明区展示,不用成功 Alert 撑高表单。
|
||||
添加成功后沿用本地知识库行为,把新绑定加入当前聊天选择,不重新启用其他已取消知识库。
|
||||
|
||||
### 6.3 目录下拉框
|
||||
|
||||
- 选择实例后自动打开加载状态,不要求再点击“读取”。
|
||||
- 使用可搜索 Select,选项显示名称、远端短 ID 和可用状态。
|
||||
- 右侧刷新使用刷新图标按钮,Tooltip 为“刷新知识库列表”。
|
||||
- 加载时保持控件稳定尺寸,显示“正在读取知识库…”。
|
||||
- 目录为空显示“当前实例没有可访问的知识库”。
|
||||
- 403 或不支持列表时显示原因和“手工填写知识库 ID”。
|
||||
- 列表失败后显示“重试”,不自动切换到手工模式。
|
||||
|
||||
### 6.4 手工模式
|
||||
|
||||
```text
|
||||
远端知识库 ID [ ]
|
||||
显示名称 [ ]
|
||||
[验证知识库]
|
||||
```
|
||||
|
||||
验证成功后显示远端名称和 Provider 返回的摘要。返回自动列表不会丢弃已输入 ID,切换前
|
||||
如有未保存输入须确认。
|
||||
|
||||
## 7. 配置面板
|
||||
|
||||
右侧配置固定分为“通用配置”和“{Provider} 配置”,不创建嵌套卡片。高级字段可以使用
|
||||
一个折叠区,但核心检索方式、阈值和 RAGFlow 图谱检索保持直接可见。
|
||||
|
||||
控件选择:
|
||||
|
||||
- 开关:图谱检索、Score 阈值启用、Rerank、使用 Dify 知识库默认配置。
|
||||
- Select 或 SegmentedControl:少量固定检索方式。
|
||||
- 数字输入或 Stepper:结果数、Token、Top K、超时。
|
||||
- Slider 加数字输入:0 至 1 的阈值和权重,二者保持同步。
|
||||
- Metadata 条件:初始版本不提供自由 JSON 编辑器。
|
||||
|
||||
字段显隐由 GoodBuddy 内置 Provider 描述和 Main 返回的能力状态共同决定。服务端返回的
|
||||
任意字符串不得变成 HTML、控件标签或帮助链接。
|
||||
|
||||
### 7.1 RAGFlow 图谱文案
|
||||
|
||||
开关名称:`图谱检索`
|
||||
|
||||
说明:`检索远端知识库已经生成的图谱内容,不会创建或更新图谱。`
|
||||
|
||||
只有远端详情或受控探测表明图谱已经构建时,该开关才可用。证据缺失时显示“未确认远端
|
||||
图谱状态”并禁用。Knowledge Compilation 单独显示为 `包含 Knowledge Compilation`,默认
|
||||
关闭,不把它和旧 GraphRAG 合成同一开关。
|
||||
|
||||
## 8. 外部知识库详情
|
||||
|
||||
选中外部绑定后,现有详情区按来源类型裁剪:
|
||||
|
||||
```text
|
||||
研发规范 RAGFlow · 远端检索
|
||||
研发知识平台 / 远端知识库 7f31…c902
|
||||
|
||||
[概览] [检索配置] [检索测试]
|
||||
```
|
||||
|
||||
### 8.1 概览
|
||||
|
||||
显示 Provider、实例、远端名称、远端 ID、连接状态、最近成功检索和数据位置。操作包括:
|
||||
|
||||
- 刷新远端信息。
|
||||
- 更换远端知识库。
|
||||
- 编辑 GoodBuddy 显示名称。
|
||||
- 从 GoodBuddy 移除。
|
||||
|
||||
不显示导入文件、添加目录、添加网页、同步、分块、重建索引和本地图谱入口。
|
||||
|
||||
### 8.2 检索配置
|
||||
|
||||
复用创建向导右侧配置面板。存在失效字段时,页头显示“配置需要确认”,字段附近说明
|
||||
Provider 版本变化,保存按钮保持禁用直到用户处理。
|
||||
|
||||
### 8.3 检索测试
|
||||
|
||||
复用现有检索测试工作台的查询、结果和焦点行为,但外部结果采用 Provider 诊断:
|
||||
|
||||
```text
|
||||
RAGFlow · 研发知识平台
|
||||
总耗时 642 ms · 返回 6 条 · 保留 6 条
|
||||
|
||||
1 部署规范.pdf
|
||||
第四章 网络要求…
|
||||
Provider 分数 0.82 · 文档 ID 98ab…
|
||||
```
|
||||
|
||||
外部测试不显示 FTS/CJK/本地向量扫描等本地通道,也不提供依赖本地分块的“查看上下文”。
|
||||
|
||||
## 9. 聊天知识范围
|
||||
|
||||
现有多选列表保留一个选择面,只在每行增加来源:
|
||||
|
||||
```text
|
||||
[✓] 产品资料 本地
|
||||
[✓] 研发规范 RAGFlow · 研发知识平台
|
||||
[ ] 客服知识 FastGPT · 客服平台
|
||||
```
|
||||
|
||||
- 不按 Provider 创建多套选择器。
|
||||
- 不可用绑定保留显示但禁用勾选,并给出认证失效或目标不存在原因。
|
||||
- 当前会话已选绑定变为不可用时,不静默移除 ID;发送前提示该知识库本次无法检索。
|
||||
|
||||
## 10. 执行状态和引用
|
||||
|
||||
活动状态:
|
||||
|
||||
```text
|
||||
正在检索“研发规范”
|
||||
RAGFlow · 研发知识平台 · 已发送当前检索词
|
||||
```
|
||||
|
||||
部分失败:
|
||||
|
||||
```text
|
||||
已检索 3 个知识库,2 个成功,1 个超时
|
||||
[查看详情]
|
||||
```
|
||||
|
||||
引用:
|
||||
|
||||
```text
|
||||
部署规范.pdf / 网络要求
|
||||
研发规范 · RAGFlow · 研发知识平台
|
||||
```
|
||||
|
||||
引用详情显示远端文档名、ID、片段、Provider 分数和实际返回的定位。未完成 URL 安全设计前,
|
||||
不显示“打开远端来源”。
|
||||
|
||||
## 11. 删除确认
|
||||
|
||||
移除绑定:
|
||||
|
||||
```text
|
||||
从 GoodBuddy 移除“研发规范”?
|
||||
这会删除本机绑定和检索配置,不会删除 RAGFlow 中的知识库或文档。
|
||||
|
||||
[取消] [移除]
|
||||
```
|
||||
|
||||
删除有绑定的实例:
|
||||
|
||||
```text
|
||||
“研发知识平台”仍被 3 个知识库使用
|
||||
删除实例会同时从 GoodBuddy 移除这些绑定。远端数据不会改变。
|
||||
|
||||
[取消] [查看知识库] [删除实例和本地绑定]
|
||||
```
|
||||
|
||||
破坏性主操作使用 `danger-solid`,默认焦点放在“取消”。
|
||||
|
||||
## 12. 可访问性与响应式
|
||||
|
||||
- 常规宽度两栏使用稳定 Grid;小于 `960px` 时改为单栏,顺序为来源、通用配置、Provider 配置。
|
||||
- 小于 `720px` 时外部实例表格转为单列列表,行操作保留菜单按钮和完整可访问名称。
|
||||
- Modal、创建向导、下拉框和折叠区支持完整键盘操作。
|
||||
- 异步列表和测试状态使用 `aria-live="polite"`;错误使用与字段关联的 `role="alert"`。
|
||||
- 图标按钮必须提供 Tooltip 和 `aria-label`。
|
||||
- 状态不只靠颜色;加载、成功、受限和失败均包含文字。
|
||||
- 长实例名、地址、远端名称和 ID 使用单行省略,完整值可通过 Tooltip 或详情查看。
|
||||
- 切换类型、实例或退出时存在有效草稿,应按全局离开保护规则确认。
|
||||
|
||||
## 13. 文案表
|
||||
|
||||
| 场景 | 文案 |
|
||||
| --- | --- |
|
||||
| 页面操作 | 管理外部实例 |
|
||||
| 创建类型 | 本地 / Dify / FastGPT / RAGFlow |
|
||||
| 创建主操作 | 添加知识库 |
|
||||
| 列表加载 | 正在读取知识库… |
|
||||
| 列表受限 | 当前凭据不能读取知识库列表,可手工填写知识库 ID。 |
|
||||
| 远端状态 | 远端可检索 |
|
||||
| 移除操作 | 从 GoodBuddy 移除 |
|
||||
| 图谱开关 | 图谱检索 |
|
||||
| 测试说明 | 测试只发送当前查询,不会调用 GoodBuddy 模型或修改远端内容。 |
|
||||
|
||||
产品文案不使用“同步外部知识库”“导入 Dify 知识库”或“创建远端知识库”,因为这些动作
|
||||
都不是本功能的实际行为。
|
||||
|
||||
## 14. 需求追踪
|
||||
|
||||
| UI 区域 | 需求 | 场景 |
|
||||
| --- | --- | --- |
|
||||
| 页面标题与统一列表 | `EK-FR-01`、`EK-FR-10` | `EK-US-C1` |
|
||||
| 外部实例管理与表单 | `EK-FR-02`、`EK-FR-03`、`EK-FR-15`、`EK-FR-16` | `EK-US-A1` 至 `EK-US-A3` |
|
||||
| 新建知识库向导 | `EK-FR-04` 至 `EK-FR-09` | `EK-US-B1` 至 `EK-US-B4` |
|
||||
| 外部详情与检索测试 | `EK-FR-12`、`EK-FR-13` | `EK-US-C3`、`EK-US-D1` 至 `EK-US-D4` |
|
||||
| 聊天范围、状态和引用 | `EK-FR-11`、`EK-FR-12`、`EK-FR-14` | `EK-US-C1` 至 `EK-US-C4` |
|
||||
@@ -0,0 +1,212 @@
|
||||
# 外部知识库 User Stories
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计稿 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联 PRD | [外部知识库接入 PRD](./external-knowledge-prd.md) |
|
||||
|
||||
## 1. 角色
|
||||
|
||||
- **知识库使用者**:在聊天中选择已经添加的本地或外部知识库并查看引用。
|
||||
- **实例维护者**:持有外部知识库 API Key,负责配置实例、选择远端知识库和处理失效连接。
|
||||
- **RAG 调试者**:需要调整 Provider 参数并直接验证检索结果。
|
||||
|
||||
## 2. 实例管理
|
||||
|
||||
### EK-US-A1 添加外部实例
|
||||
|
||||
作为实例维护者,我希望在知识库页面保存一个外部实例,以便后续绑定其中的多个知识库。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户打开“管理外部实例”并选择 Dify、FastGPT 或 RAGFlow
|
||||
- When 用户填写名称、服务地址和 API Key,并执行连接测试
|
||||
- Then 页面分别显示服务、认证、目录和检索检查结果
|
||||
- And 保存后只显示“凭据已配置”,不回显 API Key
|
||||
- And 新实例可被同 Provider 的新建知识库向导选择
|
||||
- And 使用 HTTP 或放宽证书校验时,保存前明确提示 API Key 的传输风险
|
||||
|
||||
### EK-US-A2 修复失效凭据
|
||||
|
||||
作为实例维护者,我希望替换认证失效的 API Key,同时保留已有知识库绑定。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 实例因认证失败处于“认证失效”状态
|
||||
- When 用户选择“替换凭据”、测试并保存
|
||||
- Then 所有绑定继续引用同一实例 ID
|
||||
- And 旧密钥不再使用
|
||||
- And 用户没有输入新密钥时可以取消且不清除旧密钥
|
||||
|
||||
### EK-US-A3 删除仍被使用的实例
|
||||
|
||||
作为实例维护者,我希望删除实例前看到受影响的知识库,以免留下无法解释的绑定。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 实例仍被一个或多个外部知识库绑定引用
|
||||
- When 用户点击“删除实例”
|
||||
- Then 确认界面列出受影响数量和名称
|
||||
- And 用户可以取消,或明确同时移除本地绑定
|
||||
- And 确认后不向外部实例发送删除请求
|
||||
|
||||
## 3. 添加知识库
|
||||
|
||||
### EK-US-B1 从远端列表添加
|
||||
|
||||
作为实例维护者,我希望选择 Provider 和实例后自动看到远端知识库,以便避免手工复制 ID。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 至少存在一个可用的对应 Provider 实例
|
||||
- When 用户在“新建知识库”中选择外部类型和实例
|
||||
- Then GoodBuddy 自动读取有界知识库列表
|
||||
- And 下拉框支持按名称搜索与刷新
|
||||
- And 目录只显示当前凭据可见的知识库,不推断隐藏对象
|
||||
- And 切换实例后旧实例的异步响应不会覆盖新列表
|
||||
|
||||
### EK-US-B2 手工填写远端 ID
|
||||
|
||||
作为实例维护者,我希望列表接口受限时仍可通过远端 ID 添加已有知识库。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 连接测试表明认证和检索可用,但目录读取无权限或不受支持
|
||||
- When 用户进入手工模式并填写远端知识库 ID
|
||||
- Then GoodBuddy 使用详情或测试检索验证该目标
|
||||
- And 验证失败时保留输入并显示原因
|
||||
- And 未经真实验证不能完成添加
|
||||
|
||||
### EK-US-B3 配置 RAGFlow 图谱检索
|
||||
|
||||
作为 RAG 调试者,我希望在添加 RAGFlow 知识库时设置图谱检索,以便使用远端已经构建的图谱内容。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 适配器确认实例检索接口支持 `use_kg`
|
||||
- When 用户选择一个具备相关能力的 RAGFlow 知识库
|
||||
- Then 右侧显示“图谱检索”开关并说明它只影响检索
|
||||
- And 开启后测试请求携带该选项
|
||||
- And 只有远端知识库存在已构建图谱的证据时才允许开启
|
||||
- And 页面不把该开关描述为构建或更新远端图谱
|
||||
- And 能力不明确时该开关禁用并说明原因
|
||||
|
||||
### EK-US-B4 保持本地创建流程
|
||||
|
||||
作为本地知识库用户,我希望新增外部类型后仍能按原流程创建本地知识库。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户在新建向导中选择“本地”
|
||||
- When 用户填写名称、存储模式和图谱设置
|
||||
- Then 表单字段、默认值和创建结果与现有行为一致
|
||||
- And 外部实例、远端 ID 和 Provider 配置不出现
|
||||
|
||||
## 4. 检索和引用
|
||||
|
||||
### EK-US-C1 在聊天中选择外部知识库
|
||||
|
||||
作为知识库使用者,我希望外部知识库出现在现有知识范围选择器中,以便与本地知识库一起使用。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 外部知识库绑定处于可检索状态
|
||||
- When 用户打开聊天知识范围
|
||||
- Then 它与本地知识库出现在同一选择器中
|
||||
- And 条目持续显示 Provider 和实例名称
|
||||
- And 选择结果仍保存 GoodBuddy 知识库 ID
|
||||
- And 新添加的绑定进入当前聊天选择,但不重新启用其他已取消知识库
|
||||
|
||||
### EK-US-C2 只发送检索查询
|
||||
|
||||
作为知识库使用者,我希望清楚知道哪些数据会发送到企业知识库。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 本次请求选择了外部知识库
|
||||
- When GoodBuddy 执行检索
|
||||
- Then 只发送本次检索查询、远端知识库 ID 和已保存检索参数
|
||||
- And 不发送完整会话、本地附件、其他知识库内容、系统提示或模型凭据
|
||||
- And 活动状态显示目标 Provider 和实例
|
||||
|
||||
### EK-US-C3 查看外部引用
|
||||
|
||||
作为知识库使用者,我希望回答中的引用标明外部来源,以便回到企业知识系统核验。
|
||||
|
||||
验收:
|
||||
|
||||
- Given Provider 返回文档名、片段 ID、内容和分数
|
||||
- When GoodBuddy 展示引用
|
||||
- Then 引用显示 Provider、实例、知识库和文档名称
|
||||
- And 详情只显示 Provider 实际返回且通过校验的定位信息
|
||||
- And 缺失页码、链接或分数时不生成替代值
|
||||
|
||||
### EK-US-C4 多知识库部分失败
|
||||
|
||||
作为知识库使用者,我希望一个外部实例暂时失败时仍能使用其他成功结果。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 本次选择了本地知识库和两个外部知识库
|
||||
- When 一个外部请求超时,其他请求成功
|
||||
- Then 成功证据继续进入回答
|
||||
- And 页面显示一个知识库检索失败及其原因
|
||||
- And 超时不显示为“未找到相关内容”
|
||||
- And GoodBuddy 不自动改用同 Provider 的其他实例
|
||||
|
||||
## 5. 配置与诊断
|
||||
|
||||
### EK-US-D1 调整 Provider 配置
|
||||
|
||||
作为 RAG 调试者,我希望只看到当前 Provider 支持的参数,以便理解设置会怎样影响远端检索。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户编辑一个外部知识库绑定
|
||||
- When 页面读取实例能力和已保存配置
|
||||
- Then 通用配置与 Provider 专属配置分区显示
|
||||
- And 每个字段显示范围、默认值和作用
|
||||
- And 不受支持的字段不会被静默发送
|
||||
- And 恢复默认只恢复当前绑定的 Provider 配置
|
||||
|
||||
### EK-US-D2 运行检索测试
|
||||
|
||||
作为 RAG 调试者,我希望在知识库页面直接测试外部检索,不经过聊天模型。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前选中一个外部知识库
|
||||
- When 用户输入查询并点击“测试检索”
|
||||
- Then 页面显示 Provider 耗时、返回数量、片段、来源和截断状态
|
||||
- And 测试不创建聊天消息、不调用 GoodBuddy LLM、不修改远端内容
|
||||
- And 远端原始响应和凭据不进入界面或日志
|
||||
- And FastGPT 测试明确提示远端可能记录 API 使用并产生检索或重排用量
|
||||
|
||||
### EK-US-D3 Provider 版本变化
|
||||
|
||||
作为实例维护者,我希望升级外部系统后看到配置是否仍受支持。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 最近一次能力探测与已保存快照不同
|
||||
- When 用户测试实例或打开绑定设置
|
||||
- Then 页面指出新增、失效或行为变化的配置
|
||||
- And 失效配置不会静默删除或继续发送
|
||||
- And 用户确认有效配置后才更新绑定
|
||||
|
||||
### EK-US-D4 远端知识库被删除
|
||||
|
||||
作为实例维护者,我希望远端目标不存在时看到准确状态并重新绑定。
|
||||
|
||||
验收:
|
||||
|
||||
- Given Provider 返回目标不存在
|
||||
- When 用户打开知识库或执行检索
|
||||
- Then 绑定状态显示“远端知识库不存在”
|
||||
- And 聊天选择器不把它显示为可用
|
||||
- And 用户可以刷新目录并选择新的远端知识库
|
||||
- And 保存新目标前必须重新测试
|
||||
@@ -162,7 +162,7 @@ GoodBuddy 当前已经具备:
|
||||
引用和脱敏诊断。
|
||||
- Agent Runtime 的选择或切换不改变当前向量连接;向量连接的选择或切换也不改变当前
|
||||
Agent Runtime。
|
||||
- 未来记忆、笔记或其他本地语义检索若要复用向量连接,必须经过独立产品设计、权限与
|
||||
- 分区记忆、笔记或其他本地语义检索若要复用向量连接,必须经过独立产品设计、权限与
|
||||
数据路径评审后显式接入,不能因存在全局向量连接而自动获得使用权。
|
||||
|
||||
---
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 实施中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-08-22 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 实施范围 | 第一阶段:可用、可见、可诊断;第二阶段:可调、可优化、可维护 |
|
||||
| 相关设计 | [本地文本向量模型与连接设计](./local-embedding-technical-design.md) |
|
||||
@@ -149,7 +149,7 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
|
||||
|
||||
## 6. 信息架构
|
||||
|
||||
知识工作区继续使用主从布局和现有四个页签:
|
||||
知识工作区使用主从布局和四个面向用户任务的页签:
|
||||
|
||||
```text
|
||||
知识库
|
||||
@@ -160,14 +160,21 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
|
||||
│ └─ 分块查看与维护
|
||||
├─ 知识图谱
|
||||
├─ 任务中心
|
||||
└─ 设置
|
||||
└─ 高级设置
|
||||
├─ 问答效果入口
|
||||
├─ 向量索引
|
||||
├─ 检索设置
|
||||
├─ 分块设置
|
||||
└─ 图谱设置
|
||||
```
|
||||
|
||||
“检索测试”是当前知识库的高频诊断操作,通过知识库标题区次操作打开独立
|
||||
工作台,不新增第五个一级页签。
|
||||
“检索测试”是当前知识库的高频验证操作,通过知识库标题区和“高级设置 →
|
||||
问答效果”打开同一个独立工作台,不新增第五个一级页签。工作台首次打开只突出真实
|
||||
问题输入和测试操作;候选倍数、权重、阈值、重排和上下文预算默认折叠。
|
||||
|
||||
首次使用流程固定为“创建知识库 → 添加资料 → 等待可检索 → 用于当前对话”。创建表单
|
||||
默认只展示名称和描述;存储方式与知识图谱属于折叠的高级设置。没有知识库时,唯一主操作
|
||||
位于首次使用空状态中。
|
||||
|
||||
全局向量模型仍在“设置 → 模型连接 → 向量模型”中配置。GoodBuddy 内置连接和用户添加的
|
||||
OpenAI 兼容连接使用同一连接列表;其界面、数据路径及切换语义以
|
||||
@@ -195,6 +202,10 @@ Tokenizer、原始向量或索引访问能力传入 Runtime。切换 Runtime 不
|
||||
2. 检索方式:模型按需检索、每次先检索。
|
||||
3. 当前范围为空、索引降级或向量未配置时的短说明。
|
||||
|
||||
知识库 ID 选择按 Conversation 持久化。新对话和未保存过知识范围的旧对话默认不选择
|
||||
任何知识库;切换对话恢复各自范围,不得在应用启动时自动勾选全部知识库。新建知识库只
|
||||
加入当前 Conversation,不重新启用用户已取消的其他知识库。
|
||||
|
||||
## 7. 第一阶段:可用、可见、可诊断
|
||||
|
||||
### 7.1 检索方式
|
||||
@@ -296,6 +307,14 @@ Tokenizer、原始向量或索引访问能力传入 Runtime。切换 Runtime 不
|
||||
- 失败文档数。
|
||||
- 当前向量模型与索引是否兼容。
|
||||
|
||||
`indexedDocumentCount` 只统计状态为 `ready` 的文档;`queued`、`parsing` 和
|
||||
`indexing` 计入处理中,`failed` 单独计入需处理。文档行持续显示全文、向量和图谱通道
|
||||
状态。已有可用分块的失败文档仍允许查看分块,并提供重新处理。
|
||||
|
||||
文档行可通过受控 `knowledgeBaseId + documentId` 打开数据库中保存的原始来源。
|
||||
Renderer 不得提交任意路径。移除来源必须先说明将删除的文档、索引、图谱证据及是否包含
|
||||
应用托管副本,用户确认后才执行。
|
||||
|
||||
降级事件包括:
|
||||
|
||||
- 未配置向量模型。
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# 知识库实施进度
|
||||
|
||||
## 已验证实现
|
||||
|
||||
截至 2026-09-05:
|
||||
|
||||
- 知识库选择按 Conversation 持久化,新对话默认空范围。
|
||||
- 创建流程默认只展示名称和描述,高级存储与图谱设置折叠。
|
||||
- 知识库汇总区分可检索、处理中和需处理文档。
|
||||
- 文档行展示全文、向量和图谱状态,并支持打开原始来源和重新处理失败文档。
|
||||
- 移除来源需要确认并说明级联影响。
|
||||
- 检索高级参数默认折叠,“高级设置”提供问答效果入口。
|
||||
- 向量未配置状态可直接前往模型设置。
|
||||
- 有效 DOCX 与多页 PDF 已通过磁盘文件导入、解析、分块和检索生产链路。
|
||||
|
||||
## 验证证据
|
||||
|
||||
2026-09-05 最终验证:
|
||||
|
||||
- `npm test`:325 个测试文件、3511 项测试通过;8 个文件、55 项手动或集成测试按既有
|
||||
配置跳过。
|
||||
- `npm run typecheck`:通过。
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:Electron Main、Preload、Renderer 生产构建及控制面安装包检查通过。
|
||||
- `npx vitest run src/main/knowledge/knowledge-service.test.ts -t "imports real DOCX and PDF files from disk into searchable chunks"`:
|
||||
有效 Open XML DOCX 与两页原生文本 PDF 均通过生产 `KnowledgeService.importPaths`
|
||||
导入、解析、分块和全文检索。
|
||||
|
||||
2026-09-06 对话知识库选择补充验证:
|
||||
|
||||
- `App.test.tsx` 覆盖点击名称、文档数量和行本身时切换选中状态,以及空目标失焦时
|
||||
弹层不提前卸载。Windows 隔离桌面实际点击名称后选中、点击数量后取消,弹层保持打开。
|
||||
- 本轮全量校验结果见[工作栏验证记录](../assistant-workbar/progress.md);本项验证未调用模型。
|
||||
|
||||
## 剩余工作
|
||||
|
||||
- 外部 Dify、FastGPT 和 RAGFlow 知识库接入仍为规划中能力。
|
||||
- 扫描 PDF 的真实 OCR 验收依赖用户已安装并校验的 OCR 模型;本次 DOCX/PDF 回归覆盖
|
||||
原生文本 PDF,不宣称覆盖扫描件 OCR。
|
||||
- PDF.js 在测试环境会提示未配置 `standardFontDataUrl`;文本、页码定位与检索结果不受
|
||||
影响。
|
||||
@@ -0,0 +1,70 @@
|
||||
# 知识库 UI 设计
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
知识库优先帮助普通用户完成“添加资料并可靠提问”,高级 RAG 调试和图谱维护不得阻塞
|
||||
首次使用。全局视觉、页面壳层、通知、焦点和危险操作遵循
|
||||
[UI-DESIGN.md](../../../UI-DESIGN.md)。
|
||||
|
||||
## 2. 信息架构
|
||||
|
||||
```text
|
||||
知识库
|
||||
├─ 知识库列表
|
||||
└─ 当前知识库
|
||||
├─ 文档与来源
|
||||
├─ 知识图谱(启用后显示)
|
||||
├─ 任务中心
|
||||
└─ 高级设置
|
||||
```
|
||||
|
||||
标题区提供“用于当前对话”“检索测试”、编辑和删除。新建知识库是页面主操作。
|
||||
“高级设置”承载问答效果入口、向量索引、分块与图谱配置,不把工程参数放在首次路径。
|
||||
|
||||
## 3. 首次使用
|
||||
|
||||
没有知识库时,页面空状态显示唯一的“新建知识库”主操作。创建表单默认只展示名称和
|
||||
描述;存储方式与知识图谱放入折叠的“高级设置”。创建完成后进入“文档与来源”,用户
|
||||
可以导入文件、文件夹或 URL。
|
||||
|
||||
文档处理完成后,标题区“用于当前对话”可用。该操作把当前知识库加入当前
|
||||
Conversation 的检索范围,返回对话并聚焦输入框。
|
||||
|
||||
## 4. 状态
|
||||
|
||||
知识库列表显示“可用”和“需处理”文档数。详情标题持续显示可检索、处理中、需处理和
|
||||
总文档数,即使用户填写了知识库描述也不隐藏状态。
|
||||
|
||||
文档行显示:
|
||||
|
||||
- 总体处理状态。
|
||||
- 全文状态。
|
||||
- 向量状态。
|
||||
- 启用图谱时的图谱状态。
|
||||
|
||||
失败文档保留错误信息和“重新处理”。只要仍有可用分块,就允许打开分块管理,避免隐藏
|
||||
上一版可用索引。
|
||||
|
||||
## 5. 问答效果与高级参数
|
||||
|
||||
“检索测试”默认只突出真实问题、测试按钮和结果。高级参数使用折叠区,包含候选倍数、
|
||||
通道权重、最低相似度、重排、结果数和上下文预算。“高级设置 → 问答效果”打开同一
|
||||
工作台。
|
||||
|
||||
向量模型未配置时显示“前往设置”,直接进入模型连接,不使用只有说明文字的死路状态。
|
||||
|
||||
## 6. 来源操作
|
||||
|
||||
文档行提供“打开来源”,Main 根据知识库和文档归属重新读取保存的文件路径或 URL。
|
||||
移除来源使用确认对话框,说明文档数、索引、图谱证据和托管副本影响。初始焦点位于
|
||||
取消,提交期间禁止重复操作,失败时保留对话框和错误。
|
||||
|
||||
## 7. 对话范围
|
||||
|
||||
知识库多选属于 Conversation。新对话默认空范围;切换对话恢复各自选择。创建新知识库
|
||||
只加入当前 Conversation,不重新启用其他已取消项。发送请求时冻结当前选择和检索方式。
|
||||
|
||||
输入区的知识库选择弹层使用整行原生 Checkbox 标签,点击名称、文档数量或行内空白
|
||||
均切换一次选择;选中背景和整行焦点环保持可见。Tab 聚焦、Space 切换,Escape 关闭并
|
||||
返回触发按钮。点击行内非焦点元素时产生的空目标失焦不关闭弹层;点击外部或焦点明确
|
||||
移到外部时才关闭,避免原生标签完成勾选前被卸载。
|
||||
@@ -33,6 +33,18 @@
|
||||
|
||||
## 2. Epic A:明确控制是否检索
|
||||
|
||||
### US-A0 首次建立可用知识库
|
||||
|
||||
作为首次使用者,我希望先命名知识库并添加资料,不需要先理解向量、分块或图谱。
|
||||
|
||||
验收:
|
||||
|
||||
- 没有知识库时只显示一个“新建知识库”主操作
|
||||
- 创建表单默认只展示名称和描述
|
||||
- 存储方式和知识图谱位于折叠的高级设置
|
||||
- 文档可检索后提供“用于当前对话”
|
||||
- 高级检索参数不阻塞首次导入和首次提问
|
||||
|
||||
### US-A1 模型按需检索
|
||||
|
||||
作为普通知识使用者,我希望保留由模型判断是否需要检索的模式,以便一般闲聊
|
||||
@@ -73,6 +85,9 @@
|
||||
- 同一请求最多启用 20 个知识库
|
||||
- 对话输入区持续显示已选数量和检索方式
|
||||
- 范围为空时检索方式不产生误导状态
|
||||
- 知识库 ID 与检索方式一起按 Conversation 持久化
|
||||
- 新对话和未保存过范围的旧对话默认不勾选知识库
|
||||
- 切换 Conversation 恢复各自选择,不共享临时全局范围
|
||||
|
||||
## 3. Epic B:检索可见、可诊断
|
||||
|
||||
@@ -233,6 +248,18 @@
|
||||
- 全库重建可取消
|
||||
- 已成功替换的文档继续可用
|
||||
|
||||
### US-D4 查看和移除来源
|
||||
|
||||
作为知识库维护者,我希望打开原始文档并在移除来源前看到准确影响。
|
||||
|
||||
验收:
|
||||
|
||||
- 文档行可通过系统默认应用打开数据库保存的原始来源
|
||||
- Renderer 只能提交知识库 ID 和文档 ID,不能提交任意路径或 URL
|
||||
- 移除来源前显示来源名称、文档数量以及索引和图谱证据影响
|
||||
- 托管模式额外说明应用托管副本会被删除
|
||||
- 用户确认前不得删除来源
|
||||
|
||||
## 6. Epic E:高级分块
|
||||
|
||||
### US-E1 固定分块
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档类型 | 功能进度 |
|
||||
| 状态 | 已实施,发布候选 CI 验收中 |
|
||||
| 版本 | 0.5 |
|
||||
| 状态 | 已实施,六平台打包验收已通过;真实 Skills/MCP 与自定义环境验收待完成 |
|
||||
| 版本 | 0.6 |
|
||||
| 日期 | 2026-09-03 |
|
||||
| 关联入口 | [工具执行环境](./README.md) |
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
带入许可证文件。标准六平台打包任务已改用目标架构原生 Runner,并在打包前执行托管 Python
|
||||
的真实安装、SSL、pip 和 venv 探针。不可变的 `v0.12.0` 尝试已通过 Windows 与 macOS,
|
||||
但 Linux x64/ARM64 在发布前暴露 TAR 大小写冲突误判;`v0.12.1` 在同步 Agent 发布时
|
||||
于原生打包前取消。修正后的 `0.12.2` 候选仍需通过全部六个目标才能完成对应跨平台验收。
|
||||
于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python
|
||||
安装、SSL、pip 和 venv 探针。
|
||||
|
||||
## 已完成
|
||||
|
||||
@@ -67,8 +68,7 @@
|
||||
- [x] 发布六个平台/架构的字节完全相同 OSS 镜像对象并公开回读校验。
|
||||
- [x] 确认托管 Python 保持按需下载,Desktop 发行包不额外带入许可证文件。
|
||||
- [x] Windows ARM64、macOS x64/ARM64 原生 CI 安装、SSL、pip、venv 探针。
|
||||
- [ ] Linux x64/ARM64 原生 CI 安装、SSL、pip、venv 探针;`v0.12.0` 因 TAR 大小写
|
||||
冲突误判失败,`v0.12.1` 未进入原生矩阵,等待 `0.12.2` 修正候选运行结果。
|
||||
- [x] Linux x64/ARM64 原生 CI 安装、SSL、pip、venv 探针;`v0.12.2` 标签工作流已通过。
|
||||
- [ ] 最低 Windows/macOS/glibc 支持矩阵和 Windows NuGet 签名核验。
|
||||
- [ ] 真实 JavaScript/Python SKILL 成果及 Node/Python stdio MCP 调用。
|
||||
- [ ] 自定义 Node/Python 在产品 UI 中的真实选择和任务执行。
|
||||
@@ -100,6 +100,9 @@
|
||||
- `0.12.2` 协调候选的 Release Notes 校验通过;Agent/Desktop 发布链路聚焦测试
|
||||
`106/106` 通过、1 项平台用例跳过。最终 `npm test` 为 `3478` 通过、`51` 跳过,
|
||||
typecheck 与 lint 通过;仍未运行本地 production build、package 或安装探针。
|
||||
- `v0.12.2` 标签工作流 `33714902060` 随后成功完成 source validation、production
|
||||
bundle、Windows/macOS/Linux x64/arm64 六个原生 package job 及各自托管 Python
|
||||
安装探针,并于 2026-09-03 发布非 draft、非 prerelease 的正式 Release。
|
||||
|
||||
### 2026-09-02 完整实现
|
||||
|
||||
|
||||
+12
-11
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-08-19 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 依赖 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md)、[智能心跳 PRD](../smart-heartbeat/prd.md)、[Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
|
||||
|
||||
## 1. 背景
|
||||
@@ -16,13 +16,13 @@ GoodBuddy 当前记忆已经支持:
|
||||
- `global`、`project`、`conversation` 三种作用域。
|
||||
- `preference`、`fact`、`summary`、`procedure` 四种类型。
|
||||
- `proposed`、`confirmed`、`rejected` 三种状态。
|
||||
- 智能心跳提出 Global 或 Project 记忆候选,由用户确认;其长期“未来分区记忆”方向尚未设计。
|
||||
- 智能心跳提出 Global 或 Project 记忆候选,由用户确认;后续按本 PRD 的分区记忆模型增强。
|
||||
|
||||
但当前能力仍不足以支撑自动化和并行实验:
|
||||
|
||||
1. 交互请求会把已加载列表中的最多 20 条已确认记忆直接拼入提示,缺少查询相关度和明确的
|
||||
会话级过滤契约。
|
||||
2. 数据库有会话作用域,但旧版心跳候选与未来唤起需求混在同一产品概念中。
|
||||
2. 数据库有会话作用域,但旧版心跳候选与分区记忆的唤起需求混在同一产品概念中。
|
||||
3. 缺少 Automation、Experiment 和 Run 分区。
|
||||
4. 来源字段存在于表结构,但普通创建和心跳候选尚未完整保存来源关系。
|
||||
5. 缺少事实的有效时间、冲突、替代、访问记录和衰减。
|
||||
@@ -64,10 +64,11 @@ SQLite 显式字段、FTS、来源关系和可选本地 Embedding 足以支持
|
||||
- 同一实体跨大量会话的别名消歧。
|
||||
- 可解释的关系证据链。
|
||||
|
||||
### 2.5 未来分区记忆尚待设计
|
||||
### 2.5 分区记忆与唤起
|
||||
|
||||
已确认智能心跳的长期方向是“未来分区记忆”,但当前尚未定义其数据结构、唤起条件、状态、
|
||||
生命周期、与长期记忆的关系或迁移方式。本 PRD 不新增 `FutureMemory` 类型、表或检索规则。
|
||||
正式产品概念统一为“分区记忆”。时间和内部事件可以作为分区记忆条目的可选唤起条件,但
|
||||
不能改变条目所属分区、绕过分区读取边界,也不建立平行的记忆类型或表。唤起字段、状态、
|
||||
生命周期、页面和旧数据迁移必须在实施前补充设计。
|
||||
|
||||
## 3. 目标
|
||||
|
||||
@@ -430,8 +431,8 @@ Project 记忆与 Global 偏好冲突时:
|
||||
|
||||
每条记忆展示内容、类型、范围、来源、状态、时间、置信度、重要性和冲突。
|
||||
|
||||
智能心跳的完整配置仍在“智能心跳”菜单中管理;未来分区记忆完成独立设计前,不加入记忆
|
||||
中心信息架构。
|
||||
智能心跳的完整配置仍在“智能心跳”菜单中管理。分区记忆的唤起条件完成字段、状态和生命周期
|
||||
设计后,可以在记忆中心增加“待唤起”视图;不得创建平行的记忆分区或内容页。
|
||||
|
||||
## 16. 数据模型建议
|
||||
|
||||
@@ -474,13 +475,13 @@ Project 记忆与 Global 偏好冲突时:
|
||||
6. 增加冲突、时态、替代和归档。
|
||||
7. 增加实验冻结快照与 Run 隔离。
|
||||
8. 增加可选本地 Embedding 和混合排序。
|
||||
9. 未来分区记忆和时间知识图谱都必须在明确需求与独立设计后再实施。
|
||||
9. 增加分区记忆的时间/内部事件唤起;时间知识图谱仍须在明确需求与独立设计后再实施。
|
||||
|
||||
## 19. 验收标准
|
||||
|
||||
- [ ] 普通会话只读取 Global、当前 Project 和当前 Conversation 的允许记忆。
|
||||
- [ ] 智能心跳配置只能属于 Global 或 Main 已验证的一个、多个 Project。
|
||||
- [ ] 未来分区记忆完成独立设计前,不新增相关表、状态或检索行为。
|
||||
- [ ] 唤起条件始终依附于一个已验证的记忆分区,不建立平行的记忆表或范围。
|
||||
- [ ] Task 执行只读取内部 Run 快照绑定的分区。
|
||||
- [ ] 实验 Run 不能读取其他 Run 的消息或记忆。
|
||||
- [ ] 每条非手动记忆都有可追溯来源。
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# 模型连接请求定制
|
||||
|
||||
本功能负责 LLM 模型连接的自定义请求头和自定义请求体。用户可以在同一个连接中保存
|
||||
Provider 要求的附加元数据;GoodBuddy 只在直连链路或 Runtime 明确原生支持的链路中应用
|
||||
这些值,不为不支持的 Runtime 伪造透传能力。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [UI 设计](./ui-design.md) | 编辑入口、校验反馈、兼容性说明和无障碍行为 |
|
||||
| [技术设计](./technical-design.md) | 契约、持久化、合并优先级、Runtime 支持矩阵和验证 |
|
||||
| [功能进度](./progress.md) | 已验证实现、剩余工作和验证证据 |
|
||||
|
||||
本次改动是现有模型连接的有界扩展,不新增独立业务对象或工作流,因此不单独建立 PRD、
|
||||
User Stories 和逻辑设计;本页定义产品边界,技术设计定义唯一的请求合并规则。
|
||||
|
||||
## 术语
|
||||
|
||||
| 术语 | 定义 |
|
||||
| --- | --- |
|
||||
| 自定义请求头 | 用户为一个 LLM 模型连接保存的附加 HTTP Header 字符串映射 |
|
||||
| 自定义请求体 | 用户为一个 LLM 模型连接保存的附加 JSON 顶层字段 |
|
||||
| Runtime 字段 | GoodBuddy 或 Runtime 为模型、消息、工具、流式、认证和协议生成的字段 |
|
||||
| 直连 | GoodBuddy Main 直接向模型 Provider 发出请求 |
|
||||
| 受管远程 OpenCode | 模型请求通过 GoodBuddy Agent 的可信模型网关发出的远程 OpenCode |
|
||||
|
||||
## 产品边界
|
||||
|
||||
- 两项设置都位于“设置 → 模型连接 → LLM → 连接详情”底部,以 JSON 对象编辑。
|
||||
- 直连模型支持请求头和请求体,包括连接测试、文本、工具、摘要和图像生成请求。
|
||||
- Continue 原生支持请求头和请求体。
|
||||
- 本地 OpenCode 原生支持请求头,不使用自定义请求体。
|
||||
- DeepSeek Harness 的 Pi-AI Provider 原生支持请求头,不使用自定义请求体。
|
||||
- 受管远程 OpenCode 的可信模型网关支持请求头和请求体。
|
||||
- 使用 Runtime 自有配置时不引用 GoodBuddy 模型连接,因此不会应用连接级请求定制。
|
||||
- Runtime、协议和认证字段始终优先。用户不能用请求定制替换模型、消息、工具、流式或
|
||||
认证信息。
|
||||
- 自定义值作为普通连接设置保存。API Key 和其他密钥必须使用专用凭据字段,不应写入
|
||||
自定义请求头或请求体。
|
||||
|
||||
## 相关功能
|
||||
|
||||
- [直连模型 Agent](../direct-model-agent/README.md)
|
||||
- [DeepSeek Harness Runtime](../deepseek-harness/README.md)
|
||||
- [远程主机与远程执行](../remote-host/README.md)
|
||||
@@ -0,0 +1,38 @@
|
||||
# 模型连接请求定制进度
|
||||
|
||||
## 2026-09-03
|
||||
|
||||
### 已完成
|
||||
|
||||
- 模型连接设置、v20 → v21 迁移、公开与 resolved profile 已接入有界自定义 Header/Body。
|
||||
- 设置页已提供两个 JSON 编辑器、就地校验、保存阻止、普通设置存储提示和 Runtime 支持范围。
|
||||
- 直连连接测试、文本、工具、摘要和图像请求已统一采用 Runtime 优先的浅合并。
|
||||
- Continue 1.5.47 已通过原生 `requestOptions.headers` 与 `extraBodyProperties` 接入两项配置。
|
||||
- bundled OpenCode 已通过 Provider `options.headers` 接入 Header,并确认没有发送未支持的
|
||||
自定义 Body。
|
||||
- DeepSeek Harness 已通过控制协议 v3 把 Header 交给 Pi-AI Provider,没有接入未支持的
|
||||
自定义 Body。
|
||||
- Main 与 Agent 远程模型网关已接入 Prompt-scoped Header/Body;`runtime/acp` capability
|
||||
已提升为 v5。
|
||||
- 功能入口、支持矩阵、合并优先级、普通设置存储边界和协议版本已写入功能文档及双语
|
||||
`FEATURES`。
|
||||
|
||||
### 已验证
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- 20 个聚焦测试文件:527 项通过,4 项按环境跳过。
|
||||
- 全量 `npm test`:326 个测试文件通过,8 个文件按环境跳过;3506 项通过,55 项跳过。
|
||||
- bundled OpenCode 与 Continue 分别对 loopback Provider 验证 Chat Completions 和
|
||||
Responses。4 项原生探针全部通过:两者都发送自定义 Header;Continue 发送自定义 Body;
|
||||
OpenCode 不发送不支持的 Body。
|
||||
- Main/Agent 远程网关测试验证了合并优先级、认证优先、profile digest 绑定和单次分发。
|
||||
- 三路清理审查完成;已复用共享协议路径、Header 规范化、ACP capability 常量和 JSON
|
||||
类型,并为 Renderer JSON 草稿增加解析前长度上限。
|
||||
|
||||
### 待验证
|
||||
|
||||
- 生产 `npm run build`。
|
||||
- 最小真实模型 Header/Body 探针。
|
||||
- DeepSeek Harness 真实 Provider Header 探针。
|
||||
- 当前源码 Agent bundle 在共享 Linux x64 Host 上的受管远程模型请求。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 模型连接请求定制技术设计
|
||||
|
||||
## 1. 共享契约
|
||||
|
||||
`src/shared/model-request-customization.ts` 定义:
|
||||
|
||||
- `ModelRequestHeaders`:字符串到字符串的映射。
|
||||
- `ModelRequestBody`:顶层 JSON 对象。
|
||||
- 两个 Zod schema、统一边界和合并函数。
|
||||
|
||||
模型连接输入、公开设置、Main 内 resolved profile 和远程 Prompt profile 复用这些类型。
|
||||
Runtime 设置文件版本为 21;版本 20 迁移为两个空对象,不改变已有连接或凭据。
|
||||
|
||||
## 2. 边界
|
||||
|
||||
请求头最多 64 项;名称最长 128 字符,值最长 4096 字符。名称必须是合法 HTTP token,
|
||||
大小写归一后不能重复,值不能包含控制字符。
|
||||
|
||||
请求体编码后最多 64 KiB,最大深度 16、最大节点数 4096;对象或数组单层最多 256 项。
|
||||
键最长 256 字符,字符串值最长 32768 字符,数字必须有限。
|
||||
|
||||
下列请求头由认证、协议或传输层拥有,不能在连接中配置:
|
||||
|
||||
`Authorization`、`X-API-Key`、`API-Key`、`Content-Type`、`Content-Length`、`Host`、
|
||||
`Connection`、`Transfer-Encoding`、`User-Agent`、`Anthropic-Version`。
|
||||
|
||||
下列请求体顶层字段由 GoodBuddy 或 Runtime 拥有,不能在连接中配置:
|
||||
|
||||
`model`、`stream`、`messages`、`input`、`instructions`、`system`、`tools`、
|
||||
`tool_choice`、`parallel_tool_calls`、`max_tokens`、`max_output_tokens`、
|
||||
`stream_options`、`prompt`、`n`、`quality`、`response_format`。
|
||||
|
||||
## 3. 合并规则
|
||||
|
||||
请求头先装载用户值,再装载 Runtime 和协议值,最后注入认证值。名称比较不区分大小写,
|
||||
因此 Runtime、协议和认证值始终覆盖同名用户值。
|
||||
|
||||
请求体只做一次顶层浅合并:
|
||||
|
||||
```ts
|
||||
const providerBody = {
|
||||
...connection.requestBody,
|
||||
...runtimeBody
|
||||
}
|
||||
```
|
||||
|
||||
不递归合并嵌套对象。最终请求仍需通过现有协议策略和请求大小校验。
|
||||
|
||||
## 4. Runtime 支持矩阵
|
||||
|
||||
| 请求路径 | 自定义 Header | 自定义 Body | 实现边界 |
|
||||
| --- | --- | --- | --- |
|
||||
| GoodBuddy 直连 | 支持 | 支持 | Main 在连接测试、文本、工具、摘要和图像请求中合并 |
|
||||
| Continue | 支持 | 支持 | 使用 Continue 1.5.47 的 `requestOptions.headers` 与 `extraBodyProperties` |
|
||||
| 本地 OpenCode | 支持 | 不支持 | 使用 OpenCode Provider `options.headers`;不生成未证实的 Body 配置 |
|
||||
| DeepSeek Harness | 支持 | 不支持 | 使用 `dsh-llm-pi-ai` Provider 的 `headers`;Utility 控制协议 v3 传递 Header |
|
||||
| 受管远程 OpenCode | 支持 | 支持 | Agent Prompt profile 传到可信模型网关后合并 |
|
||||
| Runtime 自有配置 | 不适用 | 不适用 | 该模式不使用 GoodBuddy 模型连接 |
|
||||
|
||||
对不支持自定义 Body 的 Runtime,设置仍保留在连接上,切换回支持路径时继续生效;当前
|
||||
Runtime 不接收也不发送该 Body。
|
||||
|
||||
## 5. 远程模型网关
|
||||
|
||||
非空请求定制随 Prompt-scoped model profile 发送,不写入 Agent 环境、命令行或模型调用
|
||||
ledger。模型 profile digest 绑定规范化后的 Header 和 Body,但继续排除 API Key。
|
||||
|
||||
Main 网关和 Agent 网关都执行相同的可信 profile 合并、协议策略检查和最终 Body 大小检查。
|
||||
认证 Header 在请求摘要生成后注入,避免凭据进入摘要或 ledger。受管远程
|
||||
`runtime/acp` capability 为 v5;旧 v4 Agent 不能接收带请求定制的新 Prompt profile,
|
||||
由现有受管更新流程升级后再运行。
|
||||
|
||||
## 6. 凭据
|
||||
|
||||
API Key 继续由现有 Main 加密设置和 Prompt-scoped Agent 凭据路径管理。自定义 Header 和
|
||||
Body 是普通可见连接设置,不提供第二套通用秘密存储;UI 必须提示不要把密钥放入这两个
|
||||
JSON 对象,标准认证 Header 也由 schema 拒绝。
|
||||
|
||||
## 7. 验证
|
||||
|
||||
- 共享 schema:JSON 类型、边界、保留字段和优先级。
|
||||
- 设置存储:v20 → v21 迁移与 Header/Body round trip。
|
||||
- 直连:连接测试、流式文本、工具、摘要和图像请求。
|
||||
- Runtime:真实 bundled OpenCode/Continue 对 loopback Provider 的请求探针;DeepSeek
|
||||
Utility 启动配置和 Pi-AI Provider 配置。
|
||||
- 远程:Main/Agent 网关合并、认证优先级、摘要绑定、大小限制和不重放。
|
||||
- UI:默认值、保存、无效 JSON 保留和 Runtime 支持说明。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 模型连接请求定制 UI 设计
|
||||
|
||||
本文件定义模型连接请求定制的界面行为。全局视觉、表单和无障碍规则继续以
|
||||
[`UI-DESIGN.md`](../../../UI-DESIGN.md) 为准。
|
||||
|
||||
## 信息架构
|
||||
|
||||
“自定义请求头”和“自定义请求体”位于当前 LLM 连接详情的最底部、连接兼容性摘要之前。
|
||||
它们不新增页签、弹窗或高级模式。每个编辑器使用可纵向调整大小的等宽文本域,默认值为
|
||||
`{}`。
|
||||
|
||||
## 编辑与保存
|
||||
|
||||
- 请求头接受 JSON 对象,所有值必须是字符串。
|
||||
- 请求体接受 JSON 对象,值必须是有界、可序列化的 JSON。
|
||||
- 用户输入在编辑时原样保留。JSON 语法、类型、大小或保留字段无效时,文本域显示
|
||||
`aria-invalid` 和就地错误;点击保存时阻止写入,并在现有设置错误区域显示对应连接。
|
||||
- 保存成功后,编辑器使用两个空格缩进重新格式化已保存对象。
|
||||
- 新连接从两个空对象开始。
|
||||
|
||||
## 兼容性说明
|
||||
|
||||
请求头说明明确列出直连、Continue、OpenCode 和 DeepSeek Harness 在原生支持范围内使用
|
||||
该值。请求体说明明确列出直连、Continue 和受管远程 OpenCode 支持,并说明本地
|
||||
OpenCode 与 DeepSeek Harness 不使用该值。
|
||||
|
||||
同一区域还必须说明:
|
||||
|
||||
- Runtime、协议和认证生成的字段优先。
|
||||
- 请求体只做顶层浅合并,不递归合并对象。
|
||||
- 自定义值按普通连接设置保存,不能用于保存 API Key 或其他密钥。
|
||||
|
||||
## 无障碍与键盘
|
||||
|
||||
- 每个文本域有稳定的可访问名称,并通过 `aria-describedby` 关联兼容性说明。
|
||||
- 无效状态不能只依赖颜色,必须同时提供错误文字和 `aria-invalid`。
|
||||
- 文本域遵循标准键盘编辑、选择、复制和粘贴行为;保存继续使用设置页唯一的主保存操作。
|
||||
- 焦点边框、字体、间距、颜色和深浅主题全部复用全局语义令牌。
|
||||
@@ -102,7 +102,7 @@ bootstrap 能力元数据。公开直连能力仍须完成本文末尾 GitHub/
|
||||
|
||||
探测在 Host Key 已固定且 SSH 认证成功后执行,只检查当前选择的“关于与更新”来源:
|
||||
|
||||
- Host 是受支持的 Linux `x64` 或 `arm64`。
|
||||
- Host 是 Linux `x64`、Linux `arm64` 或 macOS `arm64`;Intel Mac 不在支持范围。
|
||||
- SSH 账号 Home 可写,GoodBuddy-owned staging 可创建。
|
||||
- 可用空间满足签名目录声明的包大小、解包上限和安全余量。
|
||||
- Host 可以通过 HTTPS 访问所选 GitHub 或北京镜像 URL。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
本文记录截至 2026-09-03 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
|
||||
本文记录截至 2026-09-05 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
|
||||
Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线,
|
||||
详细事务与验收边界见
|
||||
[SSH Host 远程环境准备与直连下载设计](./environment-provisioning-technical-design.md);
|
||||
@@ -12,7 +12,7 @@ Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
|
||||
Windows 到 Linux x64 的安装、Agent-owned Prompt、Agent 本地模型 gateway、断线恢复、
|
||||
同一 OpenCode Session 续接、取消和终态清理已经使用真实模型与工具验证。Agent
|
||||
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前未发布
|
||||
Agent 源码 lock 为 `0.11.15`,Desktop 恢复候选为 `0.12.2`,等待各自独立发布审批。
|
||||
Agent 源码 lock 为 `0.11.20`,Desktop 候选为 `0.12.6`,等待各自独立发布审批。
|
||||
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
|
||||
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
|
||||
|
||||
@@ -104,6 +104,11 @@ Detached GoodBuddy Agent
|
||||
小型签名目录。Renderer 只得到本地/在线版本、是否有更新、架构、远端 Runtime 版本、
|
||||
协议和本地状态,不得到缓存路径、key ID 或 digest。目录检查不下载 `.gbagent`,在线
|
||||
包下载只由用户点击带目标版本的操作触发;离线导入/导出通过 Main 管理的文件对话框完成。
|
||||
- GitHub 来源按每页 10 条读取 Release 元数据,最多检查原有的 100 条历史发布,
|
||||
找到首个有效 Agent 目录及签名地址后立即停止。每次响应仍保留 1 MiB 上限,
|
||||
避免一次读取全部历史正文和资产详情导致目录检查失败;签名、URL 与包校验不变。
|
||||
2026-09-06 使用当前 Main 目录读取实现验证公开 GitHub 与北京镜像,两者均可读取
|
||||
Agent `0.11.19` 的 Linux x64、Linux arm64 和 macOS arm64 条目,未下载或安装包。
|
||||
- “自动”只在 operation/prepare 前用 Host capability probe 选择 acquisition:直连明确
|
||||
可用才选 Host 下载,否则选 GoodBuddy 传输;显式选择不被改写。prepare、commit 或
|
||||
adoption 失败后都不跨 acquisition 自动 fallback。
|
||||
@@ -168,7 +173,7 @@ Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、
|
||||
- 选择目录只更新项目草稿,仍需通过正常的 Agent、Workspace、Runtime 验证和项目保存事务才会持久化。
|
||||
- Ask Workspace handle 不暴露写入方法。
|
||||
- Execute Workspace handle 可读写,但这不是 Execute 的唯一权限面;Execute Runtime 本身使用 SSH 账号的正常权限。
|
||||
- 文件预览、搜索、Git diff、目录项和显式文件传输保持字节、条目数和路径长度上限。
|
||||
- 文件预览按有界页面传输并保持 UTF-8 字符边界;超过单页大小时先返回当前页,用户可继续加载,不把单页传输上限误作文件总预览上限。搜索、Git diff、目录项和显式文件传输继续保持各自的字节、条目数和路径长度上限。
|
||||
|
||||
## Runtime
|
||||
|
||||
@@ -182,21 +187,18 @@ selection,Renderer 会恢复为当前 OpenCode 配置;Main 的远程请求
|
||||
|
||||
### Ask
|
||||
|
||||
Ask 使用系统 `bwrap`:
|
||||
Ask 与 Execute 一样直接启动已签名 Runtime,不要求 Host 安装额外的进程隔离命令:
|
||||
|
||||
- Workspace 和 Runtime bundle 只读 bind;
|
||||
- 独立可写 scratch HOME、TMPDIR 和 XDG 目录;
|
||||
- 固定环境名和固定 Runtime argv;
|
||||
- 通过只读边界阻止 Workspace 修改。
|
||||
- `cwd` 为项目 Workspace,并继承 SSH 账号的正常环境;
|
||||
- ACP 权限请求只有在工具种类为原生 `read` 且 Runtime 提供 `allow_once` 选项时才允许;
|
||||
search、edit、execute、未知工具以及只提供持久授权的请求全部拒绝。
|
||||
|
||||
`allow_once` 只解决 OpenCode 发起原生读取前的 ACP 协商,真正的文件系统边界仍是只读
|
||||
`bwrap` bind。Host 缺少 `bwrap` 时 Ask 不可用。
|
||||
- Ask 启动时同时注入 OpenCode 顶层和 build Agent 的 `permission: "ask"`;Agent
|
||||
工具权限分发边界只批准上述原生读取请求。该双重边界不另加文件系统 confinement,
|
||||
也不改变 Runtime 的直接启动方式。
|
||||
|
||||
### Execute
|
||||
|
||||
Execute 不经过 Ask 的 bubblewrap profile:
|
||||
Execute 直接启动已签名 Runtime:
|
||||
|
||||
- 直接启动已签名 Runtime entrypoint;
|
||||
- 使用 Agent 本地模型 gateway 时,签名 Agent launcher 会 `exec` 候选 manifest 锁定的
|
||||
@@ -204,12 +206,14 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
||||
launcher 路径;
|
||||
- `cwd` 为项目 Workspace;
|
||||
- 继承 SSH 账号的正常环境、文件系统、进程和网络能力;
|
||||
- 不注入 OpenCode Ask 权限配置;
|
||||
- 不进行 T2/T3、confinement attestation、approval bridge 或逐工具批准。
|
||||
|
||||
两种模式都保留输入/输出字节上限、用户取消和进程组清理。生产 Prompt 不设置固定墙钟
|
||||
总时限;只要 Runtime 尚未按自身协议结束,Main 和 Agent 就允许其持续运行。启动、握手、
|
||||
单个控制 RPC、重连尝试和关闭清理仍使用独立的有界超时,测试可以显式注入短 Prompt
|
||||
时限验证取消升级,但该覆盖值不是生产默认。无界 Prompt 继续复用已发布的必填
|
||||
两种模式都保留输入字节上限、用户取消和进程组清理;输出只用有界内存队列与 journal
|
||||
配合背压,不按 Prompt 累计输出量停止 Runtime。生产 Prompt 不设置固定墙钟总时限;
|
||||
只要 Runtime 尚未按自身协议结束,Main 和 Agent 就允许其持续运行。启动、握手、单个
|
||||
控制 RPC、重连尝试和关闭清理仍使用独立的有界超时,测试可以显式注入短 Prompt 时限
|
||||
验证取消升级,但该覆盖值不是生产默认。无界 Prompt 继续复用已发布的必填
|
||||
`deadlineAt` 字段,以 year-9999 哨兵表达;Main 与 Agent 必须同时理解该哨兵,Agent
|
||||
`0.11.10` 及更早版本仍会按其 manifest 上限夹紧,因此桌面启用这一语义前必须配套更新
|
||||
到 Agent `0.11.13` 或更高版本。
|
||||
@@ -226,20 +230,41 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
||||
调度、跨 channel 超车、批发送或第二套拥塞控制。
|
||||
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
|
||||
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
|
||||
- Runtime 事件先经过单事件、单请求输出和每次 Prompt 最多 100 个不同工具调用的边界;
|
||||
通过边界后,Renderer 和 Main 将已接受的工具详情保存到本地 SQLite,不再额外缩短其
|
||||
输入、输出、错误或摘要。
|
||||
- Runtime 事件仍对协议定义的单项传输字段应用既有边界,但不按固定工具调用数或 Desktop
|
||||
累计展示量中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生
|
||||
Runtime。Main 和 Renderer 不再按 500 条工具/子 Agent 活动或 2,000 个交错展示块丢弃
|
||||
对话详情;独立运行记录也改由 Main SQLite 完整保存并按批渲染。旧版已经触发本地上限的
|
||||
历史继续显示可能不完整的提示,不能通过迁移隐藏既有数据缺口。
|
||||
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
|
||||
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
|
||||
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
|
||||
状态卡,并持久化任务说明、终态与输出。
|
||||
状态卡,并持久化任务说明、终态与输出。卡片展开后分为“执行过程”和“最终结果”:
|
||||
正文、折叠思考与可展开工具记录按收到的顺序显示,工具状态原位更新;完成后仅在最终
|
||||
结果区显示与末尾正文完全相同的返回结果,避免重复。失败或取消保留已有过程,未终结
|
||||
工具不再显示进行中。
|
||||
- 本地 SDK 通过 Task `metadata.sessionId` 关联子会话,复用稳定消息块 ID 保存过程。
|
||||
固定 OpenCode 1.18.9 的 ACP 不转发子会话,因此 Agent 的模型桥 helper 在受管临时目录
|
||||
写入事件插件,并通过既有 ACP `session/update` 的 `_meta.goodbuddySubagentEvent`
|
||||
转发到所属 Task;不新增端口,不修改签名 Runtime 包。插件文件随 helper 退出清理,
|
||||
事件继续经过既有 Agent transcript、ACK 和 Desktop 消息持久化路径。
|
||||
- ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的
|
||||
`<task><task_result>` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属
|
||||
`toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。
|
||||
远端实时过程需要包含该插件的 Agent;旧 Agent 的最终结果仍可查看,历史未采集过程
|
||||
不会被补造。
|
||||
- 本机 OpenCode SDK 路径允许不同会话并行,同一会话仍按请求顺序执行。每个请求独立拥有
|
||||
SSE 事件订阅,并在正常完成、错误、取消或消费方结束迭代时主动关闭自己的响应流,
|
||||
不关闭其他会话的订阅或共享 Server。取消导致事件迭代结束时保留原取消原因,不改报
|
||||
“事件流意外结束”。这一清理规则属于本机 SDK 路径,不改变远端 Agent 持有 ACP Prompt
|
||||
的断线继续执行语义。
|
||||
- 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。
|
||||
- Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。
|
||||
- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。
|
||||
- 只有远端返回 identity 匹配且 `closed: true` 时,Main 才删除持久化 binding;传输失败或未确认 close 会保留 recovery identity。
|
||||
- 终态 close 由 Agent 在一个事务中为两个方向记录 sequence high-water tombstone,删除剩余 frame 和 active channel,并归零对应 journal quota。tombstone 用于拒绝迟到的旧 epoch frame,因此当前不会按时间自动裁剪。
|
||||
- 断线不主动终止正在运行的 Runtime。用户取消、显式关闭、输出边界或 Agent shutdown
|
||||
才触发停止;连接恢复只受当前用户取消信号和单次重连控制超时约束,不把 Prompt 已运行
|
||||
- 断线不主动终止正在运行的 Runtime。用户取消、显式关闭或 Agent shutdown 才触发停止;
|
||||
输出队列和 journal 达到容量时暂停读取并等待 ACK 释放容量,不把展示或传输容量转换为
|
||||
Runtime 取消。连接恢复只受当前用户取消信号和单次重连控制超时约束,不把 Prompt 已运行
|
||||
时长当作停止条件。
|
||||
- Agent 失联期间继续把 Runtime 输出写入本机 journal;重新连接只校验 controller
|
||||
identity、恢复 binding,并从 Main 最后确认的 cursor 后同步缺失数据。已接受的
|
||||
@@ -250,7 +275,9 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
||||
- 托管 SSH 的生产 Prompt 从一开始就由 Agent 持有 ACP `ClientSideConnection`、原始
|
||||
Prompt Promise、Provider 轮次和 Runtime 进程。SSH relay 或 Desktop 进程不属于该
|
||||
Promise 的生命周期,因此正常退出、强制结束 Desktop 或本机网络中断后,Host 仍能继续
|
||||
后续模型/工具轮次。该路径要求 `runtime/acp` capability v4,即 Agent `0.11.14` 或更高。
|
||||
后续模型/工具轮次。该路径现在要求 `runtime/acp` capability v5;v5 同时保证 Agent
|
||||
能理解 Prompt-scoped 模型连接的自定义 Header/Body,旧 v4 Agent 由现有受管更新流程
|
||||
升级后再运行。
|
||||
- Agent 把 ACP session update、permission decision 和唯一 Prompt 终态写入有界语义
|
||||
transcript。Main 不恢复旧 JSON-RPC Promise,也不重发 `session/prompt`;Fresh Desktop
|
||||
只对同一 controller/binding/operation 执行 takeover、attach 和 ACK-exclusive page。
|
||||
@@ -289,9 +316,10 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
||||
各限制 768 KiB,响应同时核对声明长度与短读。每个
|
||||
`(binding, operation, roundIndex)` 只 dispatch 一次;已 dispatch 但未证明完整交付的调用
|
||||
变为 `outcome-unknown`,不会自动重试。
|
||||
- Prompt 最多 100 个模型调用,并受单次与累计输出 Token 上限保护;这些是资源边界,不是
|
||||
Prompt 墙钟总时限。Provider 单次请求仍有独立超时。实际 usage 在语义 transcript 中同步
|
||||
回 Desktop 的 usage/task 数据。Agent 在所有进程终止路径统一清除 Prompt token 计数。
|
||||
- Agent 不按固定模型调用次数或 Prompt 累计输出 Token 数停止 Runtime;每次 Provider
|
||||
请求仍遵循所选模型 profile 的单次输出设置和独立请求超时。模型桥协议不包含 Prompt
|
||||
调用次数或累计 Token 配额字段。实际 usage 在语义 transcript 中同步回 Desktop 的
|
||||
usage/task 数据。
|
||||
- helper 可以接收同一 Prompt 内并发到达的模型桥请求;它在单一稳定模型桥上按到达
|
||||
顺序等待并交付,不返回本地 `bridge-busy`,每个响应只有在 HTTP 完整 flush 后才
|
||||
发送 delivery ACK。
|
||||
@@ -346,10 +374,68 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
|
||||
|
||||
## 已完成的 E2E 验收记录
|
||||
|
||||
### macOS arm64 Host 支持
|
||||
|
||||
`agents.yml` 增加 `macos-15` / `darwin-arm64` 的原生 CI 组包任务,使用临时测试
|
||||
签名校验 Agent、固定 Node、Koffi 和 OpenCode,并比较两次组包的字节摘要。
|
||||
构建输入与检查的权威说明见 [BUILD.md 的 Agent 原生 CI 策略](../../../BUILD.md#agent-原生-ci-策略)。
|
||||
此 CI 任务不发布工件。生产 lock、`agent-release.yml`、安装器、Desktop 包管理和
|
||||
SSH Host 链路现支持 `linux-x64`、`linux-arm64`、`darwin-arm64`,三个目标共用
|
||||
原有 catalog JSON 和签名格式,不增加 Mac 专用目录。旧版 Linux-only Desktop
|
||||
读取器不能解析混合目录,首次发布 Darwin 条目前需先升级 Desktop。
|
||||
|
||||
macOS 保持 SSH 按需启动 detached Agent,不要求 launchd、开机服务或管理员权限。
|
||||
私有 socket 使用 `/private/tmp`,通过 `getpeereid` 核对 UID;进程身份、启动标识和
|
||||
进程组通过系统原生 API 获取。取消前枚举 Runtime 的实际后代并核对 PID、启动时间
|
||||
和可执行文件,停止独立进程组中的工具,再停止 Runtime,避免只杀主进程组留下工具。
|
||||
|
||||
2026-09-06 当前源码在真实 macOS arm64 Host 的隔离目录通过生产安装器
|
||||
`preparePackage` / `commitPackage`、adopt、health、Runtime activate、stop、
|
||||
bootstrap 和再次 health。实际 Desktop 协议客户端通过 Attach、只读 Workspace
|
||||
打开、真实 Ask 输出、Execute 写入并读回测试文件、取消工具并验证子进程退出,以及
|
||||
重新连接同一 daemon。两个 Darwin 原生进程测试通过,其中一个覆盖独立进程组子进程。
|
||||
共享 Linux x64 Host 同时通过当前源码组包、生产安装、生命周期、真实 Ask 和重连。
|
||||
这些验证没有发布 catalog 或工件;GitHub Actions 和正式下载渠道的发布验收尚未执行。
|
||||
最终本地 typecheck、lint、开发 build 和 diff whitespace 检查通过。最终全量
|
||||
`npm test` 为 3520 通过、57 跳过、0 失败(326 个测试文件通过、9 个跳过)。
|
||||
此前 ActivityPanel 的批量记录用例超时、App 的实时浏览器用例未观察到预期调用,
|
||||
分别复跑后通过;未修改这两个用例或放宽超时,随后完整测试套件通过。
|
||||
隔离测试 ledger 统计 Mac 模型请求 7 次,Linux 首轮
|
||||
1 次;最终 Linux 源码重装后又完成 1 次最小 Ask,总计 9 次真实模型请求。
|
||||
|
||||
以下为较早的仅构建阶段记录,摘要不代表上述最终源码产物:
|
||||
|
||||
2026-09-06:在真实 macOS 26.5.2 / arm64 Host 的独立测试目录运行当前源码
|
||||
`build/agent-ci-bundle.cjs --platform darwin --arch arm64`,退出码为 0。
|
||||
测试使用固定 Node 24.19.0、OpenCode 1.18.9 和内存临时签名密钥;Node 官方
|
||||
归档 SHA-256、OpenCode integrity、Mach-O 架构、内外层签名和两次组包摘要比较
|
||||
通过,Agent CLI 加载、Koffi 原生模块加载与 OpenCode `--version` 检查通过。
|
||||
生成 `goodbuddy-agent-0.11.18-darwin-arm64.gbagent`,本次测试包 SHA-256 为
|
||||
`9c63ba080def56ebd371a622f2650b89d0c652444077bd6ee19c4bd51b60f6ff`。
|
||||
临时签名每次独立运行都会变化,因此该摘要只标识本次测试产物,不是发布输入。
|
||||
源码压缩传输 529,624 字节,大依赖由 Host 直接下载并复用 npm 缓存。
|
||||
真实模型调用为 0 次;没有安装 Agent 服务,没有修改在线 catalog 或发布工件。
|
||||
|
||||
该仅构建阶段的聚焦测试为 53 项通过、1 项平台跳过,typecheck、lint 和开发生产
|
||||
bundle 构建通过;这组历史结果不替代完整 Host 支持改动后的最终验证。
|
||||
|
||||
### Linux 历史验收
|
||||
|
||||
- 2026-09-06 使用当前源码隔离 Agent 验证原生子代理过程插件。最终场景收到 37 条 ACP
|
||||
插件事件,桌面侧从已保留活动重建跟踪器后仍接收后续文本与工具;子代理和父请求正常
|
||||
完成,最终模型调用账本为 4 次 completed。此前成功场景另 4 次,两轮真实 Host 共
|
||||
8 次模型请求。
|
||||
- 同日 Windows 隔离桌面实际发送 OpenCode 原生 Task,展开卡片可看到进行中的正文与
|
||||
read 工具,取消后已有过程保留且工具显示已取消;正确工作区场景完成后显示测试文件
|
||||
内容,最终结果不重复留在过程末尾。本机有界代理共记录 10 次模型请求(含错误路径、
|
||||
取消和成功场景);另外 2 次使用原生测试配置的请求返回 401,改选显式测试模型后通过。
|
||||
未改动正常用户设置,也未发布 Agent 包。全量静态与测试结果见
|
||||
[本轮验证记录](../assistant-workbar/progress.md)。
|
||||
|
||||
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
|
||||
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
|
||||
的双架构验收。Agent `0.11.10` 后续已由原生 workflow 发布并公开验证双架构工件;
|
||||
当前源码 lock 固定为 `0.11.15`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
|
||||
当前源码 lock 固定为 `0.11.20`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
|
||||
- 2026-08-30 在共享 Linux x64 Host 的隔离测试 HOME 中验证当前 `0.11.13` 源码候选:
|
||||
签名 Runtime 清单的测试墙钟上限为 1 秒,模型桥首轮故意延迟 2.515 秒后 Prompt 仍在
|
||||
5.770 秒正常完成;随后 OpenCode 原生 Task 依次产生 `running`、`completed` 子 Agent
|
||||
@@ -397,6 +483,21 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
|
||||
workflow 与 Release Notes 聚焦测试 `106/106` 通过、1 项平台用例跳过;全量
|
||||
`npm test` 为 `3478` 通过、`51` 跳过,typecheck 与 lint 通过。本轮没有新增模型调用,
|
||||
也未在本地运行 production build、package 或安装探针。
|
||||
- 2026-09-03 使用包含当前 Runtime capability 修复的源码 harness,在共享 Linux x64 Host
|
||||
的唯一 `/tmp/goodbuddy-e2e-runtime-capability-*` 隔离 HOME 中读取签名 Runtime 副本。
|
||||
Agent 成功报告已登记 OpenCode Runtime;最终源码又在唯一
|
||||
`/tmp/goodbuddy-e2e-direct-modes-*` 隔离 HOME 中分别直接启动 Ask 与 Execute,两个模式
|
||||
都未经过外部进程隔离命令。Runtime owner 和隔离目录均已清理,未修改 Host current
|
||||
Agent/Runtime。本次只验证能力声明和真实进程启动,没有发送 Prompt,真实文本模型调用
|
||||
0 次。
|
||||
- 2026-09-03 使用包含本次模型轮次与输出背压修复的当前源码 Agent,在共享 Linux x64
|
||||
Host 的唯一 `/tmp/goodbuddy-e2e-*` 目录完成 Runtime 激活和 Agent 本机模型 gateway
|
||||
验证。模型 gateway 通过所选 Anthropic 连接返回预期随机哨兵,HTTP 状态为 200,实际
|
||||
文本模型调用恰好 1 次;Host 临时目录随后删除,共享 Agent/Runtime 安装和进程未修改。
|
||||
- 2026-09-04 使用当前源码 Desktop 和共享 Linux x64 Host 的既有 Agent/Workspace
|
||||
生产链路验证分页文件预览。专用测试文本共 262,166 bytes,首个 256 KiB 页面在三字节
|
||||
UTF-8 字符前回退并显示 262,143 bytes;继续加载后完整追加剩余文本,页面不再显示加载
|
||||
按钮且没有错误。测试未调用模型;专用远端文件、目录和本机隔离用户数据均已清理。
|
||||
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
|
||||
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
|
||||
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
|
||||
@@ -439,9 +540,8 @@ Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转
|
||||
identity 已变化且旧 authority 无法恢复,或原 binding 已有 `outcome-unknown` 终态时,显式
|
||||
新 Prompt 才替换旧 binding。新 ACP session 的首次 Prompt 会把 Desktop 已持久化的有界
|
||||
会话历史作为不可信数据一并发送;成功加载或恢复原 ACP session 时不会重复注入历史。
|
||||
Ask 模式的 Linux Runtime 进程由 `bwrap` 启动后 `exec` 到已签名 Runtime 或 Agent
|
||||
model bridge helper;监督器只接受同一 PID/进程组从固定 `bwrap` 路径到预期最终可执行文件
|
||||
的有界转换,再登记为运行中进程。
|
||||
Ask 和 Execute 的 Linux Runtime 进程都直接启动已签名 Runtime 或 Agent model bridge
|
||||
helper;监督器核对启动后的最终 executable,再登记为运行中进程。
|
||||
|
||||
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
|
||||
v32 保留项目及关联用户数据,并加入 remote-recoverable Task/message/provenance/cursor;
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 入口与范围已实现;未来分区记忆待独立设计 |
|
||||
| 版本 | 0.5 |
|
||||
| 日期 | 2026-08-19 |
|
||||
| 状态 | 入口与范围已实现;分区记忆协同待设计 |
|
||||
| 版本 | 0.6 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 文档角色 | 智能心跳当前能力、权威入口、范围和长期边界 |
|
||||
| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) |
|
||||
@@ -14,8 +14,8 @@
|
||||
| 相关界面 | [通用助手工作栏与执行空间 PRD](../assistant-workbar/prd.md) |
|
||||
| Task 模型 | [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
|
||||
|
||||
> 本文区分近期实施与长期方向。未完成设计的“未来分区记忆”不得转写为数据结构、状态机、
|
||||
> 页面、迁移或验收条件。
|
||||
> 本文区分近期实施与长期方向。分区记忆由[分区记忆 PRD](../memory/prd.md)定义;智能心跳
|
||||
> 不能另行创建平行的记忆类型、表或页面。
|
||||
|
||||
## 1. 已确认的产品边界
|
||||
|
||||
@@ -26,23 +26,23 @@
|
||||
- 智能心跳不并入通用自动化中心,也不作为任务中心条目。
|
||||
- 现有心跳报告、记忆建议、行动建议和审计历史在本轮继续保留。
|
||||
|
||||
### 1.2 长期方向:未来分区记忆
|
||||
### 1.2 长期方向:分区记忆协同
|
||||
|
||||
已确认的方向只有:
|
||||
|
||||
> 智能心跳未来应成为按范围隔离的未来记忆。
|
||||
> 智能心跳后续应按分区回顾本地信息,提出对应分区的记忆候选和可选唤起建议。
|
||||
|
||||
以下内容尚未设计:
|
||||
|
||||
- “未来记忆”的数据结构与存储方式。
|
||||
- 与当前长期记忆、心跳报告和建议的关系。
|
||||
- 唤起条件、状态和生命周期。
|
||||
- 新增 Automation、Run 和 Agent 等分区后的逐分区回顾规则。
|
||||
- 与当前记忆候选、心跳报告和建议的关系。
|
||||
- 时间与内部事件唤起条件的字段、状态和生命周期。
|
||||
- 到期后的页面和交互。
|
||||
- 是否以及如何创建 Task。
|
||||
- 旧版心跳数据如何迁移。
|
||||
|
||||
本轮不得实现 `FutureMemory`、新增未来记忆数据库表,或制作“未来记忆 / 即将唤起 /
|
||||
已唤起”等页面。后续需要独立 PRD 和用户确认。
|
||||
本轮不得扩展分区记忆类型或表。后续唤起能力必须作为分区记忆条目的可选条件,并继续遵守
|
||||
该条目的分区读取边界。
|
||||
|
||||
## 2. 本轮实施范围
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不设计或实现未来分区记忆。
|
||||
- 不扩展分区记忆模型或实现唤起条件。
|
||||
- 不修改 Task、Job 或 Task Center 模型。
|
||||
- 不新增独立 Automation Center。
|
||||
- 不移除定时任务现有入口。
|
||||
@@ -181,7 +181,7 @@
|
||||
|
||||
## 6. 数据与安全要求
|
||||
|
||||
目标合同只扩展现有心跳范围,不引入 Future Memory:
|
||||
目标合同只扩展现有心跳范围,不扩展分区记忆合同:
|
||||
|
||||
```ts
|
||||
type HeartbeatScope =
|
||||
@@ -217,11 +217,11 @@ type HeartbeatScope =
|
||||
- 如需要,设置中心仅保留“打开智能心跳”导航。
|
||||
- 验证应用内仍只有一个完整配置入口。
|
||||
|
||||
### 后续:独立设计
|
||||
### 后续:分区记忆协同设计
|
||||
|
||||
- 未来分区记忆。
|
||||
- 与长期记忆的关系。
|
||||
- 唤起模型与生命周期。
|
||||
- 按 Global、Project、Automation、Run 和 Agent 分区独立回顾并提出候选。
|
||||
- 与已确认记忆、候选、心跳报告和建议的关系。
|
||||
- 时间与内部事件唤起条件及生命周期。
|
||||
- 与 Task 的关系。
|
||||
|
||||
这些内容需要新的 PRD 和明确确认,不属于本轮实现。
|
||||
@@ -242,5 +242,5 @@ type HeartbeatScope =
|
||||
- [x] 设置中心不再包含单条心跳 CRUD。
|
||||
- [x] 任务中心本身以及定时任务现有行为没有被删除。
|
||||
- [x] 智能心跳仍保持只读、禁用工具和有界输入输出。
|
||||
- [x] 没有新增 Future Memory 表、状态机或未确认页面。
|
||||
- [x] 没有新增平行的记忆类型、表或页面。
|
||||
- [x] 加载失败、空状态、字段错误和危险操作满足统一设计与无障碍要求。
|
||||
|
||||
@@ -231,8 +231,10 @@ type TimeTrigger =
|
||||
|
||||
同一 Conversation 的普通消息和 Scheduled Task occurrence 使用同一 FIFO 队列。Agent
|
||||
正在回复时,到期 occurrence 只显示为待执行,不中断当前输出;当前执行结束后才认领下一项。
|
||||
用户显式选择“立即中断并插入”时,系统取消当前 Conversation 的活动请求,并让所选项成为
|
||||
下一项。删除待执行 occurrence 只取消该次运行,不删除稳定 Task、Conversation 或历史结果。
|
||||
执行以失败、取消或中断结束时同样立即释放该 Conversation,下一项无需用户再次操作即会
|
||||
执行。用户显式选择“立即中断并插入”时,系统取消当前 Conversation 的活动请求,并让所选项
|
||||
成为下一项。删除待执行 occurrence 只取消该次运行,不删除稳定 Task、Conversation 或历史
|
||||
结果。
|
||||
|
||||
## 8. 一次触发的对象关系
|
||||
|
||||
@@ -365,6 +367,8 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模
|
||||
- [x] 当前回复期间可以继续发送普通消息,并在 Composer 上沿查看、删除或提升待发送项。
|
||||
- [x] 应用重启恢复尚未执行的队列项和有界附件上下文;已写入 Conversation 的用户输入
|
||||
通过队列项身份完成恢复对账,不会再次进入待发送队列。
|
||||
- [x] 执行失败、取消或中断后立即释放会话,下一项和随后发送的新消息无需用户额外操作
|
||||
即会执行。
|
||||
- [x] 文本结果只写入 Conversation,独立交付物才进入成果。
|
||||
- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。
|
||||
- [ ] 应用重启不自动重放结果未知的副作用。
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# GoodBuddy 0.12.3 发布前审查
|
||||
|
||||
## 状态
|
||||
|
||||
发布候选验证完成。代码问题和发布前 UI 逻辑问题已完成主线程复核与聚焦
|
||||
回归;真实模型、当前源码 Agent 的共享 Linux x64 Host E2E、全量测试、
|
||||
类型检查、Lint 和获批双语发布说明校验均已通过。Desktop/Agent 已分别升至
|
||||
`0.12.3`/`0.11.17`。本次按用户选择不构建 LoongArch 实验预览;剩余操作
|
||||
仅为审查暂存内容并提交,未经后续明确授权不 push、tag 或发布。
|
||||
|
||||
## 审查范围
|
||||
|
||||
- 原始审查范围:`f18e4abb3c0e8c9e7c5c870cb50a67e9ed8880fe` 至
|
||||
`4e60a4ca616846fbfc312932456e1a1e7e0a0388`。
|
||||
- 发布比较基线:Desktop `v0.12.2`
|
||||
(`c52732441aba599e643211b3d2175c8ff7027e26`)。
|
||||
- 后续补充:Linux arm64 上本机 OpenCode Runtime 切换性能。
|
||||
- 用户已有的 Desktop Pet 设计提交
|
||||
`374968d50b3f1ae30165a5e12adf7f6213b6c00c` 保持不变,并纳入最终
|
||||
发布范围核对。
|
||||
|
||||
审查分别覆盖 Desktop/UI、文档与构建、远程执行、模型 Runtime、
|
||||
稳定性以及性能/KISS。Subagent 服务曾出现无 Token、Provider 暂不可用、
|
||||
认证失败和本机 `EPERM`,这些基础设施失败未计为审查完成;六个方向最终
|
||||
都获得了结果,所有候选又由主线程沿生产调用链复核和去重。
|
||||
|
||||
## 已验证并修复的问题
|
||||
|
||||
### 1. 默认直连模型遗漏连接级请求设置
|
||||
|
||||
`createAgentRuntime()` 的默认直连聊天路径没有把当前模型连接的
|
||||
`requestHeaders`、`requestBody` 和 `maximumOutputTokens` 交给
|
||||
`ModelAgentRuntime`。设置页显示已保存,但普通聊天不会采用这些值。
|
||||
|
||||
处理:默认直连路径与独立 profile 路径使用相同字段,并通过真实请求
|
||||
mock 验证 Header、Body 和输出上限到达 Provider 请求。
|
||||
|
||||
### 2. 切换 Embedding 连接会清空无关模型字段
|
||||
|
||||
Main 在切换当前 Embedding 连接时重建 `modelProfiles`,遗漏
|
||||
`maximumOutputTokens`、自定义 Header 和自定义 Body。一次向量连接切换
|
||||
会静默清除已保存的 LLM 设置。
|
||||
|
||||
最终处理:不再从完整设置 snapshot 重建输入。切换操作进入
|
||||
`RuntimeSettingsStore` 的串行队列后,只验证并更新
|
||||
`activeEmbeddingConnectionId`;失败回滚也只在当前值仍等于该次选择时恢复
|
||||
这一字段,因此不会丢失模型 profile、API Key keep 语义、Runtime 选择、
|
||||
环境覆盖或并发保存的其他设置。
|
||||
|
||||
### 3. ACP 入站背压过早恢复
|
||||
|
||||
远端 ACP 队列消费一个事件后就恢复入站,即使剩余项目数或字节数仍达到
|
||||
上限,积压时会反复暂停和恢复并继续放大内存压力。
|
||||
|
||||
处理:统一使用 `pendingUpdateLimitReached()`,只有项目数和字节数都降到
|
||||
阈值以下时才恢复输入。
|
||||
|
||||
### 4. Continue 截断后可能重复或乱序显示答案
|
||||
|
||||
Continue Host 的事件流出现 gap 后仍继续转发后续 delta,最终 fallback
|
||||
又可能输出完整答案,导致已显示内容重复或乱序。
|
||||
|
||||
处理:一旦确认事件流溢出就停止转发后续 delta;最终文本只有在它确实以
|
||||
已显示文本为前缀时才追加未显示后缀。
|
||||
|
||||
### 5. 直连模型可能重放已到达 Provider 的请求
|
||||
|
||||
普通 `ECONNRESET`、Header/Body timeout 和 GoodBuddy 自身请求 timeout
|
||||
并不能证明 POST 尚未到达 Provider。自动重试这些错误可能重复生成和计费,
|
||||
且每次尝试会重新开始请求时限。
|
||||
|
||||
处理:自动重试仅保留明确的连接前失败,包括 DNS 临时失败、连接拒绝、
|
||||
网络/主机不可达、connect timeout,以及明确发生在 TLS 建立前的 reset。
|
||||
普通 reset 和请求阶段 timeout 不再自动重放。
|
||||
|
||||
### 6. 远端 Ask 不能只依赖 ACP 权限回调
|
||||
|
||||
OpenCode 1.18.9 的默认 build Agent 最终权限包含通配 `allow`。若 Runtime
|
||||
未产生 `requestPermission`,仅在 ACP 回调中拒绝写工具不能形成完整的
|
||||
Ask 只读边界。
|
||||
|
||||
处理:Ask 启动同时注入顶层和 build Agent 的 `permission: "ask"`;
|
||||
Execute 不注入该覆盖。受管模型桥 helper 增加固定 `--work-mode` 参数,
|
||||
让直接 Runtime 和模型桥 Runtime 使用同一规则。构建、双语功能清单、
|
||||
远程 Host 技术设计及源码注释同步改为这条双重边界,不再描述已撤回的
|
||||
read-only sandbox。
|
||||
|
||||
实际使用隔离配置运行 `opencode debug config` 和
|
||||
`opencode debug agent build --pure` 验证过 OpenCode 1.18.9 的配置优先级。
|
||||
曾评估独立 HOME/XDG 与 `--pure`,但它们会隐藏用户正常环境或插件,已因
|
||||
可能影响功能而撤回。
|
||||
|
||||
### 7. Main 中保留了一套没有生产调用者的旧模型网关
|
||||
|
||||
`RemoteModelGateway`、`ModelCallOperationStore` 及其共享 operation
|
||||
contracts 只被自身测试和旧 Main 网关引用。真实远端调度由 Agent 侧
|
||||
`AgentModelGateway` 承担,保留两套实现会让修复落到错误路径。
|
||||
|
||||
处理:删除无生产构造者的 Main 旧实现及孤立测试;把仍在使用的 resolved
|
||||
profile digest 移入 `managed-model-bridge.ts`。摘要继续绑定请求定制,
|
||||
但明确排除 API Key。
|
||||
|
||||
### 8. Linux arm64 切换 OpenCode 会阻塞在用后即毁的冷启动
|
||||
|
||||
Composer 切换先同步等待一次 `agent.getStatus()`。旧路径会读取已分配
|
||||
Skills/MCP、创建临时配置和全新 XDG 目录、启动 `opencode serve`、等待
|
||||
health、调用 `session.list`,随后立即杀死进程并递归清理。切换成功后
|
||||
原生能力清单还会执行另一次独立冷启动,首次真实请求再启动可复用实例。
|
||||
此前提高 30 秒上限、改用 health 和跳过 `--version` 都没有消除这次
|
||||
阻塞冷启动。
|
||||
|
||||
处理:本机受管 OpenCode 的被动状态检查只解析设置、模型凭据和已选择
|
||||
路径,不读取 Skills/MCP,也不启动 Server。设置中的显式连接测试、原生
|
||||
能力清单和首次真实请求仍执行完整启动、health 与连接检查。状态探针仍
|
||||
为一次性对象,不把空闲 OpenCode Server 留在执行缓存中。
|
||||
|
||||
Windows x64 上仓库现有真实 bundled OpenCode 回环探针显示每次冷启动约
|
||||
1.8 秒;历史 Linux ARM 回归已证明同一阶段可能超过 10 秒。修复后的被动
|
||||
状态单测为毫秒级且断言没有 spawn、client 或 health 调用。
|
||||
|
||||
### 9. OpenCode 被动状态可能验证错误的 fallback 路径
|
||||
|
||||
被动状态初版先让 validator 搜索可用二进制。若用户明确配置的路径已被
|
||||
删除,validator 可能改为找到 bundled 或 PATH 中的另一个候选并报告就绪,
|
||||
但真实启动仍会选择那条失效的配置路径。
|
||||
|
||||
处理:先按真实 launch 的优先级只选择目标路径,再对这个精确路径执行
|
||||
不启动进程的 canonical-file 校验;校验阶段禁止 fallback 到其他候选。
|
||||
|
||||
### 10. 自定义 Header 可能保存 Fetch 无法编码的值
|
||||
|
||||
原 schema 会接受中文等 UTF-16 code unit 大于 255 的 Header 值,但
|
||||
Runtime 构造 Fetch `Headers` 时会因不符合 ByteString 要求抛错。用户会
|
||||
看到设置保存成功,实际请求却在发送前失败。
|
||||
|
||||
处理:保存和 Runtime 共用的 schema 现在同时拒绝控制字符及 code unit
|
||||
大于 255 的值,并增加中文 Header 值回归用例。
|
||||
|
||||
### 11. 设置保存进行中仍可编辑并丢失新输入
|
||||
|
||||
保存按钮会进入 pending 状态,但 textarea 等设置控件仍可修改。旧保存
|
||||
响应返回后会用已保存 snapshot 覆盖这段新输入并清除 dirty 状态。
|
||||
|
||||
处理:设置主体在保存或连接测试期间统一设为 `inert` 和
|
||||
`aria-busy`,避免产生无法归属到当前请求的新草稿,同时保持现有异步反馈。
|
||||
|
||||
### 12. Embedding-only 更新可能写回环境值或覆盖并发保存
|
||||
|
||||
Embedding-only helper 曾从应用环境变量覆盖后的 effective profile 生成
|
||||
写回输入。在环境临时指定 Anthropic/API Key 时,切换 Embedding 连接可能
|
||||
把这些有效值持久化进原本的 OpenAI/no-auth 配置。该 helper 还会在设置
|
||||
串行队列外读取完整 snapshot;若另一项设置保存先入队,旧 snapshot 随后
|
||||
可能把新保存内容整体覆盖,失败回滚也有同样风险。
|
||||
|
||||
处理:删除整份设置重建 helper,改为在 `RuntimeSettingsStore` 队列内只
|
||||
验证并更新 `activeEmbeddingConnectionId`。失败回滚也只恢复这一字段;
|
||||
若期间用户已选择其他连接则不覆盖。测试覆盖环境覆盖移除、并发完整保存
|
||||
和窄回滚后其他设置仍保留。
|
||||
|
||||
### 13. 内置浏览器卡片没有说明仅 Execute 可用
|
||||
|
||||
“能力与工具”中的浏览器卡片只显示通用的“按模式读写”,但后端只在
|
||||
Execute 模式提供 browser tools,容易让 Ask 用户误以为当前模式可用。
|
||||
|
||||
处理:浏览器卡片增加双语“仅 Execute / Execute only”摘要,其他 MCP
|
||||
卡片和后端权限逻辑不变。
|
||||
|
||||
### 14. 长任务活动展示没有实际容量边界
|
||||
|
||||
候选已取消“超过 100 个工具便终止 Runtime”的功能限制,但同时移除了
|
||||
消息中工具、子 Agent 和交错 block 数组的所有保留上限。长任务会让
|
||||
Renderer 每次事件反复复制并线性搜索不断增长的数组,恢复与 SQLite
|
||||
metadata 也会持续变大,和文档所述“只截断本地展示副本”不一致。
|
||||
|
||||
处理:Runtime 继续不受工具调用数限制;每条回复的本地副本最多保留
|
||||
500 条工具/子 Agent 活动和 2,000 个交错展示块。超出后显示持久的双语
|
||||
提示,已保留活动仍可更新状态,原生任务、模型轮次和工具执行均不取消。
|
||||
该上限高于 `v0.12.2` 已发布的 100 条 schema 上限,不破坏已发布数据。
|
||||
|
||||
### 15. 持续 journal 背压会高频轮询 SQLite
|
||||
|
||||
远端 Runtime 输出在 journal 暂时满时每 25 毫秒重新执行一次同步 SQLite
|
||||
append。Desktop 断连或消费者较慢数分钟时,每个受阻 binding 都会每秒
|
||||
触发 40 次无效事务和事件循环唤醒。
|
||||
|
||||
处理:第一次仍立即尝试,失败后从 25 毫秒开始指数退避,最高 1 秒;
|
||||
容量恢复、取消、deadline 和 Runtime 输出暂停语义不变。
|
||||
|
||||
## 候选问题处置
|
||||
|
||||
- Desktop/UI 首轮审查未发现 P0-P3;发布前第二轮按 Runtime 切换、
|
||||
模型/Embedding 设置和 MCP/工具展示三组检查,共确认并修复上述 5 个
|
||||
可复现问题。
|
||||
- 文档与网站校验未发现发布阻断问题。
|
||||
- 不为 Ask 使用独立 HOME/XDG,也不加 `--pure`,以免禁用正常插件或用户
|
||||
环境。
|
||||
- 不让普通状态或原生清单探针永久进入执行缓存,避免恢复“仅查看状态便
|
||||
留下多个空闲 OpenCode 服务”的已修复问题。
|
||||
- 未发现 Main 旧模型网关的生产、文档或导出调用者;Agent 侧真实网关及
|
||||
共享 wire contracts 保留。
|
||||
- 删除 `canonicalModelRequestHeaders()`;其唯一调用结果会立即进入已有
|
||||
`canonicalJson()`,后者已按稳定 code-unit 顺序递归排序对象键。直接传入
|
||||
Headers 对象转换结果可保持摘要不变并移除 locale 排序重复。
|
||||
- OpenCode/ACP 在单次活动 Prompt 内保留增量工具身份,以正确归并后续状态
|
||||
并在终态识别未完成/失败工具;这些 map 随 Prompt 结束释放。本轮只限制
|
||||
Renderer/Main 的持久展示副本,不为 Runtime 增加会中止功能的工具配额。
|
||||
- 未对没有具体失败路径的全局配额、持久恢复或额外安全层建议增加框架。
|
||||
|
||||
## 当前验证证据
|
||||
|
||||
- `npm run typecheck`:通过。
|
||||
- `npm run lint`:通过。
|
||||
- `npm test`:323 个测试文件通过,8 个文件按环境跳过;3,471 项通过,
|
||||
55 项跳过。
|
||||
- 发布修复聚焦矩阵:13 个测试文件,427 项通过,6 项按环境跳过。
|
||||
- OpenCode/SelectedRuntime 聚焦矩阵:79 项通过。
|
||||
- UI 逻辑矩阵:7 个测试文件,319 项通过;其中相关修复矩阵 4 个文件
|
||||
224 项通过,OpenCode 单文件 62 项通过。
|
||||
- 最终审查修复矩阵:8 个测试文件、389 项通过;最后一次受影响子集
|
||||
4 个文件、286 项通过。受影响文件 ESLint 和 `git diff --check` 通过。
|
||||
- 真实 bundled OpenCode 回环 Provider 探针:2 次本机模型路由均通过,
|
||||
每次约 1.8 秒冷启动。
|
||||
- 2026-09-03 共享 Linux x64 Host 使用当前源码 profile 和已登记真实
|
||||
OpenCode 1.18.9 运行 `debug agent build --pure`:Ask 的最后一条
|
||||
`*/*` wildcard 为 `ask`,Execute 为 `allow`;Ask 保留正常 HOME/XDG,
|
||||
Execute 不注入配置覆盖,直接 Runtime 与 model bridge 规则一致。共
|
||||
0 次模型调用,唯一 `/tmp/goodbuddy-e2e-release-profile-*` 目录已清理,
|
||||
共享安装未修改。
|
||||
- 直连 Runtime 自定义 Header+Body 真实最小请求:1 次调用通过;DeepSeek
|
||||
Harness 自定义 Header 真实最小请求:1 次调用通过。两者均由本机 loopback
|
||||
先观察最终请求再转发,未启用工具或附件,也未输出凭据。
|
||||
- 使用当前源码构建的测试签名 Agent `0.11.17` Linux x64 bundle,在共享
|
||||
Host 的唯一 `/tmp/goodbuddy-e2e-release-*` HOME 中完成真实 attach、
|
||||
workspace validation 和 OpenCode 1.18.9 ACP Runtime 验证。两个并发 Ask
|
||||
分别通过原生读取工具返回各自证据文件,Execute 通过原生 Bash 创建并读回
|
||||
`execute-output.txt`;3 个 Runtime Prompt 共发生 6 次真实 Provider
|
||||
请求,每次均观察到连接级自定义 Header+Body,6 个请求 Body digest
|
||||
全部不同,未发生重放。
|
||||
- 同一 Host E2E 扫描隔离 Agent state 的 7 个文件、170,534 字节,未发现
|
||||
API Key、自定义 Header 值或自定义 Body 值。测试 Agent、Provider 代理
|
||||
均已精确停止;独立清理探针确认剩余测试目录和进程均为 0,共享安装未修改。
|
||||
该 bundle 只使用临时测试签名,不是 production 构建或发布产物。
|
||||
- Desktop `0.12.3`、Agent `0.11.17` 的版本一致性脚本通过,
|
||||
`git diff --check` 通过。
|
||||
- 用户已批准 `0.12.3` 精确中英文发布说明并选择不构建 LoongArch 实验预览;
|
||||
`npm run release:notes:verify` 通过。发布说明源码测试不再把某一历史候选
|
||||
恰好超过 500 字符的内容强加给每个后续版本,而是用独立 schema 样例持续
|
||||
验证长场景条目不受任意字符上限限制。
|
||||
- 当前没有可用 Linux arm64 真机,因此本轮未取得 arm64 实机切换耗时;
|
||||
该限制不冒充 x64 结果。修复已由不 spawn 的被动状态单测、Windows x64
|
||||
回环时序和共享 Linux x64 当前源码 Runtime 路径共同覆盖。
|
||||
|
||||
## 提交与发布边界
|
||||
|
||||
1. 提交前审查暂存内容,排除本机 `.diff*.txt`、临时 bundle/probe、凭据、
|
||||
日志和其他用户私有产物。
|
||||
2. 提交后报告精确 commit。只有取得用户后续明确授权,才可 fetch/push 两个
|
||||
`main`、等待 main CI 并创建 `agent-v0.11.17` 或 `v0.12.3` 标签。
|
||||
@@ -169,8 +169,8 @@ GoodBuddy 应能够:
|
||||
- 当前智能心跳继续提供周期回顾、报告、记忆建议、行动建议和运行历史。
|
||||
- “智能心跳”菜单入口负责完整配置,并支持 Global 或指定一个、多个 Project。
|
||||
- 任务中心和设置中心不复制智能心跳 CRUD。
|
||||
- 智能心跳长期方向是“未来分区记忆”,但数据结构、唤起模型、生命周期、页面以及与任务和
|
||||
长期记忆的关系尚未设计,不能提前实现。
|
||||
- 智能心跳长期按分区回顾信息并提出记忆候选或唤起建议,但协同规则、唤起条件、生命周期、
|
||||
页面以及与任务的关系尚未设计,不能提前实现平行的记忆类型或页面。
|
||||
|
||||
### 4.6 成果和预览
|
||||
|
||||
@@ -277,7 +277,8 @@ GoodBuddy 应能够:
|
||||
### 阶段 0:持久化基础
|
||||
|
||||
- 新增 `assistant.sqlite` 和迁移框架。
|
||||
- 将会话与活动从 `localStorage` 迁移到主进程数据库。
|
||||
- 会话与运行记录已从 `localStorage` 迁移到主进程数据库;旧运行记录可能触及历史上限时
|
||||
保留可见的不完整提示。
|
||||
- 拆分共享契约和 IPC 注册。
|
||||
- 保持现有对话、知识库、设置和审批行为不变。
|
||||
|
||||
@@ -354,7 +355,7 @@ GoodBuddy 应能够:
|
||||
- 用户删除后不再检索到。
|
||||
- 每条记忆显示来源与作用域。
|
||||
- 智能心跳配置明确属于 Global 或指定 Project,当前报告和建议继续沿用已有生命周期。
|
||||
- 未来分区记忆完成独立设计前,不新增相关数据、页面或任务转换。
|
||||
- 分区记忆协同和唤起条件完成设计前,不新增相关数据、页面或任务转换。
|
||||
|
||||
### 8.5 安全
|
||||
|
||||
|
||||
@@ -356,8 +356,9 @@
|
||||
### UX-02 Runtime 显式状态
|
||||
|
||||
- **优先级 / 状态:** P1 / 已完成
|
||||
- **范围:** 顶栏显示就绪、连接中、不可用等文字,并通过 `aria-describedby` 暴露原因。
|
||||
- **验收:** 不依赖颜色或 `title` 即可理解 Runtime 状态和发送不可用原因。
|
||||
- **范围:** 顶栏以状态点和 Runtime/模型名称紧凑表达状态:就绪为绿色,连接中为黄色,
|
||||
不可用为灰色;不重复显示状态文字,并通过 `aria-describedby` 暴露完整原因。
|
||||
- **验收:** 三种状态均不重复显示状态文字;发送不可用原因仍由发送控件的可访问说明提供。
|
||||
|
||||
### UX-03 等待审批实时通知
|
||||
|
||||
|
||||
+9
-1
@@ -12,11 +12,19 @@ export default tseslint.config(
|
||||
'coverage/**',
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'out/**'
|
||||
'out/**',
|
||||
'shareserver/**'
|
||||
]
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['src/main/**/*.ts', 'src/preload/**/*.ts', 'src/shared/**/*.ts'],
|
||||
languageOptions: {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.6",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.2",
|
||||
"version": "0.12.6",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
"package": "opencode-linux-x64-baseline",
|
||||
"integrity": "sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ=="
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"package": "opencode-darwin-arm64",
|
||||
"integrity": "sha512-cTWxW9IFjDdzx9mscQgr5uL478moU64t486JebaSZmUPEGDJVQjvCtXfqbZDzVuoVorRfaytlykqrH3qTSL2VQ=="
|
||||
},
|
||||
"arm64": {
|
||||
"package": "opencode-linux-arm64",
|
||||
"integrity": "sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A=="
|
||||
|
||||
@@ -1,6 +1,178 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.12.6",
|
||||
"releasedAt": "2026-09-06",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**子代理过程与结果显示更完整。** 展开 OpenCode 子代理卡片,可以按顺序查看文字、推理和工具详情,最终结果单独展示。远程项目需要更新 Agent 才能查看实时过程;旧 Agent 仍可显示最终结果,但不会补造缺失的过程记录。",
|
||||
"**工作区文件变更更容易查看。** 点击变更文件,可分别查看已暂存和未暂存差异,包括删除、重命名和未跟踪文件。刷新会更新当前差异和已展开目录,超过 50 项的变更可以继续加载。",
|
||||
"**知识库支持整行勾选。** 点击知识库名称或所在行即可切换选择,不再因焦点变化导致点击无效。选择范围仍按当前对话保存。",
|
||||
"**OpenCode 连续与并行对话更稳定。** 每次请求结束后会释放自身的事件连接,避免连续使用后连接不断积累。取消一条请求不会关闭其他并行会话的连接。",
|
||||
"**命令可以使用工作区外目录。** 直连模型在 Execute 模式下,可以使用绝对路径、相对路径或符号链接指定工作目录。未指定时仍使用项目工作区,Ask 保持只读。",
|
||||
"**恢复 GitHub 来源的 Agent 更新检查。** 选择 GitHub 后,可以正常读取可用 Agent 版本,不再因历史发布信息过大提示“发布目录超出大小限制”。镜像入口和已有本地包不受影响。"
|
||||
],
|
||||
"notices": [
|
||||
"远程子代理实时过程和本轮远程文件差异改进需要更新至 Agent `0.11.20`。提供 Linux x64、Linux arm64 和 macOS arm64 包,Desktop 不内置 Agent。",
|
||||
"本次使用标准六平台桌面发布矩阵,不构建 LoongArch 实验预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**More complete subagent progress and results.** Expand an OpenCode subagent card to view ordered text, reasoning, and tool details, with the final result shown separately. Remote projects require an updated Agent for live progress; older Agents still show final results without inventing missing progress history.",
|
||||
"**Clearer workspace file changes.** Select a changed file to inspect staged and unstaged diffs separately, including deleted, renamed, and untracked files. Refresh updates the selected diff and expanded directories, and lists with more than 50 changes can load additional entries.",
|
||||
"**Whole-row knowledge selection.** Click a knowledge library’s name or row to toggle its selection without focus changes losing the click. Selection remains scoped to the current conversation.",
|
||||
"**More reliable consecutive and parallel OpenCode conversations.** Each request releases its own event connection when it ends, preventing connections from accumulating during continued use. Cancelling one request does not close other parallel conversations’ connections.",
|
||||
"**Commands can use directories outside the workspace.** Direct models in Execute mode can select a working directory using an absolute path, a relative path, or a symbolic link. The project workspace remains the default, and Ask stays read-only.",
|
||||
"**Restored Agent update checks through GitHub.** Selecting GitHub now retrieves available Agent versions without failing because the release history exceeds the response-size limit. Mirror access and existing local packages are unaffected."
|
||||
],
|
||||
"notices": [
|
||||
"Update to Agent `0.11.20` for live remote subagent progress and the remote file-diff improvements in this release. Packages are available for Linux x64, Linux arm64, and macOS arm64; Desktop does not embed the Agent.",
|
||||
"This release uses the standard six-target desktop matrix and does not include an experimental LoongArch preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.12.5",
|
||||
"releasedAt": "2026-09-06",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Apple Silicon Mac 远程项目。** 可以把 macOS arm64 设备添加为 SSH Host,安装独立 Agent 后使用 Ask、Execute 和断线重连。无需管理员权限或 launchd 服务;Intel Mac 暂不支持,远程项目仍为默认关闭的技术预览。"
|
||||
],
|
||||
"fixes": [
|
||||
"**知识库使用流程更完整。** 创建和检索测试默认收起高级参数,资料可用后可直接“用于当前对话”;知识范围按对话保存,新对话默认不选择知识库。文档明确区分可用、处理中和失败,支持打开来源、失败重试,并在移除来源前说明影响和要求确认。",
|
||||
"**切换窗口不再误中断回复。** 来回切换桌面窗口时,仍在生成的回复不会被误判为“上次运行意外中断”,后续消息也不会因此滞留队列。OpenCode 专属操作栏在会话刷新时保持稳定,减少界面抖动。",
|
||||
"**长会话可以完整滚动。** 较长推理、工具记录或正文不再因估算高度导致滚动条提前到底。全部已加载内容可直接滚动查看,无需切换会话刷新。"
|
||||
],
|
||||
"notices": [
|
||||
"**先升级桌面端,再更新 Agent。** Agent `0.11.19` 提供 Linux x64、Linux arm64 和 macOS arm64 包,共用一个签名目录。旧桌面端无法解析包含 macOS 条目的新目录,需升级至 Desktop `0.12.5` 后检查在线 Agent 更新;已有本地包和历史发布仍保留。",
|
||||
"Desktop 不内置 Agent 或远程 Runtime。Ask 保持只读,Execute 使用所选 SSH 账号的完整权限。",
|
||||
"本次使用标准六平台桌面发布矩阵,不构建 LoongArch 实验预览。",
|
||||
"仓库新增独立 ShareServer 管理控制台交互原型,不包含在桌面安装包中,也不是已上线的团队共享服务;原型数据操作不持久化。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Remote projects on Apple Silicon Macs.** Add a macOS arm64 device as an SSH Host and install the separate Agent to use Ask, Execute, and reconnection. No administrator access or launchd service is required; Intel Macs are not supported, and Remote Projects remain an off-by-default technical preview."
|
||||
],
|
||||
"fixes": [
|
||||
"**A more complete knowledge workflow.** Creation and retrieval testing collapse advanced settings by default, and searchable libraries can be sent directly to the current conversation. Knowledge selection is saved per conversation and starts empty for new conversations; documents distinguish ready, processing, and failed states, with source opening, retry, and explicit removal confirmation.",
|
||||
"**Window switching no longer interrupts active replies.** Returning to the desktop window no longer mislabels an active reply as interrupted or leaves subsequent messages queued because of that false state. OpenCode-specific controls remain stable during conversation refreshes, reducing layout shifts.",
|
||||
"**Complete scrolling in long conversations.** Long reasoning, tool records, and replies no longer cause the scrollbar to end early because of estimated message heights. All loaded content remains reachable without switching conversations to refresh the layout."
|
||||
],
|
||||
"notices": [
|
||||
"**Upgrade Desktop before updating the Agent.** Agent `0.11.19` provides Linux x64, Linux arm64, and macOS arm64 packages through one signed catalog. Older Desktop versions cannot parse the new catalog containing macOS entries; upgrade to Desktop `0.12.5` before checking online Agent updates. Existing local packages and historical releases remain available.",
|
||||
"Desktop does not embed the Agent or Remote Runtime. Ask remains read-only; Execute uses the selected SSH account’s full permissions.",
|
||||
"This release uses the standard six-target desktop matrix and does not include an experimental LoongArch preview.",
|
||||
"The repository now includes a standalone interactive ShareServer console prototype. It is not bundled with Desktop or a deployed team-sharing service, and prototype data operations are not persisted."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.12.4",
|
||||
"releasedAt": "2026-09-05",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.12.4 新增会话内 HTML 静态预览,并改善长任务活动、大文件预览、长回复与消息队列、远程任务终态,以及 Linux ARM 上的 OpenCode 后续响应速度。"
|
||||
],
|
||||
"features": [
|
||||
"**会话内 HTML 静态预览。** Agent 和 Subagent 完成回复后,完整 HTML 文档及 HTML 代码块可直接在会话中预览,并支持查看源码和全屏展示。该能力默认开启,可在“平台功能 / 通用设置”中关闭;预览不会执行脚本、访问网络、提交表单或打开窗口。"
|
||||
],
|
||||
"fixes": [
|
||||
"**长任务活动与大文件预览更完整。** Runtime 工具和 Subagent 活动现在持久保存在 Main SQLite 中,不再受旧版条目数、详情长度或缓存总量限制;旧数据可能已被截断时会明确提示。本地和远程超过 256 KiB 的 UTF-8 文本文件可按页继续加载,不再直接拒绝预览。",
|
||||
"**长回复和消息队列不再意外中断。** 直连模型的流式及非流式长回复、工具参数和累计答案不再因固定字节上限失败。上一次运行失败、被中断或未成功启动后,后续消息会释放并继续调度,不再长期停留在等待队列。",
|
||||
"**远程任务终态显示更准确。** 托管 SSH 任务断线恢复后,已持久化的完成或失败状态会覆盖旧的本地流式状态;窗口重新聚焦或恢复可见时也会重新读取会话,避免 Agent 已结束但界面仍显示处理中。",
|
||||
"**Linux ARM 上 OpenCode 后续响应更快。** 原生能力清单与后续对话请求现在复用同一个 Runtime,不再先启动并销毁临时 OpenCode Server、再为首条消息重复冷启动。"
|
||||
],
|
||||
"notices": [
|
||||
"**远程项目仍为技术预览。** Host 需要单独发布的 Agent `0.11.18`;Desktop `0.12.4` 不内置 Agent 或远程 Runtime。Ask 保持只读,Execute 表示授权所选 SSH 账号可用的完整工具、进程、网络和写入权限。",
|
||||
"**HTML 预览是静态展示。** 预览保留作者样式,但禁止脚本和外部资源;关闭该功能后继续显示 HTML 源码。",
|
||||
"**发布平台。** GoodBuddy 使用 Windows、macOS、Linux 的 `x64`/`arm64` 标准发布矩阵。本次不构建独立的 LoongArch 实验预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.12.4 adds static HTML previews in conversations and improves long-run activity, large-file previews, long replies and message queues, remote terminal state, and OpenCode follow-up speed on Linux ARM."
|
||||
],
|
||||
"features": [
|
||||
"**Static HTML previews in conversations.** Completed Agent and Subagent replies can render complete HTML documents and HTML code blocks directly in the conversation, with source and full-screen views. The feature is enabled by default and can be disabled under Platform Features / General; previews cannot run scripts, access the network, submit forms, or open windows."
|
||||
],
|
||||
"fixes": [
|
||||
"**Complete long-run activity and large-file previews.** Runtime tool and Subagent activity is now persisted in Main SQLite without the former item-count, detail-length, or total-cache limits, with a clear warning when legacy data may already have been truncated. Local and remote UTF-8 text files larger than 256 KiB can continue loading page by page instead of being rejected.",
|
||||
"**Long replies and message queues no longer stop unexpectedly.** Direct-model streaming and non-streaming replies, tool arguments, and accumulated answers no longer fail at fixed byte limits. After a previous run fails, is interrupted, or never starts, later messages are released and dispatched instead of remaining queued indefinitely.",
|
||||
"**Accurate terminal state after remote recovery.** After a managed SSH disconnect, persisted completion or failure now replaces stale local streaming state. Conversations are also refreshed when the window regains focus or visibility, avoiding a Processing state after the Agent has already finished.",
|
||||
"**Faster follow-up after switching to OpenCode on Linux ARM.** Native capability discovery and later conversation requests now share one Runtime instead of starting and destroying a temporary OpenCode Server before cold-starting another one for the first message."
|
||||
],
|
||||
"notices": [
|
||||
"**Remote Projects remain a technical preview.** Hosts require the separately published Agent `0.11.18`; Desktop `0.12.4` does not embed the Agent or Remote Runtime. Ask remains read-only, while Execute authorizes the full tools, processes, network access, and writable paths available to the selected SSH account.",
|
||||
"**HTML previews are static.** Author styles are preserved, but scripts and external resources are blocked. Disabling the feature keeps the HTML source visible.",
|
||||
"**Release platforms.** GoodBuddy uses the standard Windows, macOS, and Linux `x64`/`arm64` release matrix. This release does not include a separate experimental LoongArch preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.12.3",
|
||||
"releasedAt": "2026-09-04",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.12.3 新增按模型连接定制请求 Header 和 Body,并把内置浏览器扩展到 OpenCode 与 Continue;同时改善多会话并发、长任务、远程 Ask/Execute 和 OpenCode 切换体验。"
|
||||
],
|
||||
"features": [
|
||||
"**模型请求定制。** 每个 LLM 连接现在可以填写有界 JSON 请求头和顶层请求体,用于兼容自建网关、局域网服务和其他需要额外字段的 Provider。直连模型与 Continue 支持 Header 和 Body,本地 OpenCode 与 DeepSeek Harness 使用各自原生支持的 Header,受管 SSH OpenCode 支持两者;认证、协议和 Runtime 自有字段始终优先。",
|
||||
"**Runtime 共享内置浏览器。** 内置浏览器现在可以分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue,并在 Execute 模式使用。用户与 Agent 共享同一个按会话隔离的浏览器页面;关闭 Agent 浏览器能力后,用户仍可在工作栏中手动导航。"
|
||||
],
|
||||
"fixes": [
|
||||
"**多会话和长任务不再被人为中止。** 同一项目中的不同 OpenCode 会话现在可以并行运行,同一会话内仍保持顺序。OpenCode、Continue 和受管 SSH 任务不再因 GoodBuddy 固定的工具、活动、模型调用或累计输出数量而取消;本机展示达到容量时会给出提示,但原生任务继续运行。",
|
||||
"**OpenCode 状态检查和 Runtime 切换更快。** 被动状态检查现在只验证所选路径和模型凭据,不再启动并立即销毁一个 OpenCode Server,减少较慢设备和 Linux ARM Host 上的切换等待。设置中的显式连接测试、原生能力读取和首次实际请求仍执行完整启动与健康检查,失效的自定义路径也不会被其他候选静默替代。",
|
||||
"**模型重试和流式结果更可靠。** 直连模型会在尚未输出内容时,对明确发生在请求发送前的临时连接故障执行可取消重试;可能已经到达 Provider 的请求不会自动重放。Continue 事件流溢出后也不会再把后续增量与最终完整答案重复显示。",
|
||||
"**远程 Ask 与 Execute 启动边界更一致。** 两种模式现在都直接启动已签名 Runtime,不再要求 Host 安装 bubblewrap。Ask 同时使用 OpenCode Ask 权限和 Agent 原生读取审批保持只读;Execute 不注入该限制,继续使用所选 SSH 账号的完整权限。",
|
||||
"**设置保存和能力提示更准确。** 保存或测试期间设置表单会暂时锁定,避免旧响应覆盖新输入;切换 Embedding 连接也不再改写模型凭据、环境覆盖或并发保存的其他字段。无效 Header 会在保存时提示,浏览器卡片会明确标注仅 Execute 可供 Agent 使用。",
|
||||
"**工作栏与终端操作恢复一致。** 终端工具栏粘贴改走受信任的 Main/Preload 剪贴板路径,避免 Electron 拒绝浏览器剪贴板权限后无法粘贴。工作栏页签、关闭按钮和“+”入口恢复圆角样式,终端面板与工具栏继续保持直角。"
|
||||
],
|
||||
"notices": [
|
||||
"**远程项目仍为技术预览。** Host 需要单独发布的 Agent `0.11.17`;Desktop `0.12.3` 不内置 Agent 或远程 Runtime。Ask 保持只读,Execute 表示授权所选 SSH 账号可用的完整工具、进程、网络和写入权限。",
|
||||
"**请求定制不是密钥存储。** 自定义 Header 和 Body 会随连接普通设置保存,请勿在其中填写 API Key 或其他密钥。各 Runtime 只应用其原生支持的字段,界面会标明实际生效范围。",
|
||||
"**发布平台。** GoodBuddy 使用 Windows、macOS、Linux 的 `x64`/`arm64` 标准发布矩阵。本次不构建独立的 LoongArch 实验预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.12.3 adds per-connection request headers and body fields and extends the built-in browser to OpenCode and Continue, together with smoother concurrent conversations, long tasks, Remote Ask/Execute, and OpenCode switching."
|
||||
],
|
||||
"features": [
|
||||
"**Custom model requests.** Each LLM connection can now supply bounded JSON headers and top-level body fields for self-hosted gateways, LAN services, and providers that require additional values. Direct models and Continue support both headers and body fields, local OpenCode and DeepSeek Harness use their native header support, and managed SSH OpenCode supports both; authentication, protocol, and Runtime-owned fields always take precedence.",
|
||||
"**Built-in browser shared across Runtimes.** The built-in browser can now be assigned to direct models, GoodBuddy-managed OpenCode, and Continue for use in Execute. The user and Agent share the same conversation-isolated page, while disabling Agent browser access still leaves manual workbar navigation available."
|
||||
],
|
||||
"fixes": [
|
||||
"**Concurrent conversations and long tasks keep running.** Separate OpenCode conversations in one project can now run in parallel while requests within one conversation stay ordered. OpenCode, Continue, and managed SSH tasks are no longer cancelled by GoodBuddy-defined tool, activity, model-call, or accumulated-output counts; reaching local display capacity shows a notice while native execution continues.",
|
||||
"**Faster OpenCode status checks and Runtime switching.** Passive status checks now validate only the selected path and model credential instead of starting and immediately destroying an OpenCode Server, reducing switching delays on slower systems and Linux ARM Hosts. Explicit connection tests, native capability discovery, and first real use still perform full startup and health checks, and an invalid custom path is never silently replaced by another candidate.",
|
||||
"**Safer model retries and streaming results.** Direct models perform cancellable retries before any visible output only for temporary connection failures proven to occur before request dispatch; requests that may have reached the provider are never replayed automatically. Continue also avoids showing later stream deltas again with a complete fallback response after an event-stream overflow.",
|
||||
"**Consistent Remote Ask and Execute startup boundaries.** Both modes now launch the signed Runtime directly without requiring bubblewrap on the Host. Ask stays read-only through both OpenCode Ask permissions and Agent native-read approval, while Execute applies no such override and retains the selected SSH account's full permissions.",
|
||||
"**Reliable settings saves and capability guidance.** Settings are temporarily locked while saving or testing so an older response cannot overwrite newer input, and changing the Embedding connection no longer rewrites model credentials, environment overrides, or unrelated concurrent saves. Invalid headers are rejected when saving, and the browser card now clearly identifies Agent access as Execute-only.",
|
||||
"**Restored workbar and terminal behavior.** Terminal toolbar paste now uses the trusted Main/Preload clipboard path instead of a browser clipboard call that Electron could reject. Workbar tabs, close buttons, and the “+” entry use rounded styling again, while the terminal panel and toolbar remain square."
|
||||
],
|
||||
"notices": [
|
||||
"**Remote Projects remain a technical preview.** Hosts require the separately published Agent `0.11.17`; Desktop `0.12.3` does not embed the Agent or Remote Runtime. Ask remains read-only, while Execute authorizes the full tools, processes, network access, and writable paths available to the selected SSH account.",
|
||||
"**Request customization is not credential storage.** Custom headers and body fields are saved as ordinary connection settings, so do not place API keys or other secrets in them. Each Runtime applies only the fields it supports natively, and the interface identifies the effective scope.",
|
||||
"**Release platforms.** GoodBuddy uses the standard Windows, macOS, and Linux `x64`/`arm64` release matrix. This release does not include a separate experimental LoongArch preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.12.2",
|
||||
"releasedAt": "2026-09-03",
|
||||
|
||||
+3
-2
@@ -9,8 +9,9 @@
|
||||
以及知识库、魔法笔记、智能心跳、桌面上下文和远程消息通道等桌面助手能力。
|
||||
下载区位于主要功能说明之前,并明确列出统信 UOS、银河麒麟、海光、兆芯、
|
||||
鲲鹏、飞腾和龙芯。页面不重复设置底部下载推广区。
|
||||
英文页面位于 `en.html`,不展示信创适配文案,三个平台的下载按钮始终前往
|
||||
GitHub 最新正式 Release。
|
||||
英文页面位于 `en.html`,用平台与架构覆盖描述同一产品事实,不使用中文信创
|
||||
政策语汇;页面会标明独立的 LoongArch 实验预览,但三个平台的下载按钮仍始终
|
||||
前往 GitHub 最新正式 Release。
|
||||
首屏产品界面默认正面展示,在精确指针设备上使用克制的 3D 倾斜、
|
||||
柔和跟随光效和同步浮动标签;触屏设备保持静态布局,系统启用“减少动态
|
||||
效果”时不运行该交互。
|
||||
|
||||
+4
-4
@@ -72,7 +72,7 @@
|
||||
|
||||
<nav class="site-navigation" id="site-navigation" aria-label="Main navigation" data-navigation>
|
||||
<a href="#download">Download</a>
|
||||
<a href="#features">Agent Runtime</a>
|
||||
<a href="#features">Highlights</a>
|
||||
<a href="#assistant">Desktop assistant</a>
|
||||
</nav>
|
||||
|
||||
@@ -289,7 +289,7 @@
|
||||
<div class="section-inner">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="kicker">Agent Runtime</p>
|
||||
<p class="kicker">Product highlights</p>
|
||||
<h2 id="features-title">Agent Runtimes in one desktop interface</h2>
|
||||
</div>
|
||||
<p>
|
||||
@@ -321,8 +321,8 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="feature-number">02</span>
|
||||
<h3>Built for desktop platforms</h3>
|
||||
<p>Windows, macOS, and Linux releases are available for both x64 and arm64.</p>
|
||||
<h3>Broad desktop architecture coverage</h3>
|
||||
<p>Official Windows, macOS, and Linux releases cover x64 and arm64, with a separate experimental LoongArch preview.</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
|
||||
+8
-7
@@ -72,7 +72,7 @@
|
||||
|
||||
<nav class="site-navigation" id="site-navigation" aria-label="主导航" data-navigation>
|
||||
<a href="#download">下载</a>
|
||||
<a href="#features">Agent Runtime</a>
|
||||
<a href="#features">产品亮点</a>
|
||||
<a href="#assistant">桌面助手</a>
|
||||
</nav>
|
||||
|
||||
@@ -352,7 +352,8 @@
|
||||
<h3 id="domestic-support-title">桌面版本覆盖国产 x64、ARM64 和龙芯 LoongArch</h3>
|
||||
<p>
|
||||
正式下载覆盖国产 x64 与 ARM64;龙芯 LoongArch 使用独立编译的
|
||||
loong64 预览版。
|
||||
loong64 预览版。这里描述的是系统、指令集与安装包兼容性,
|
||||
不代表已完成所有厂商和整机认证。
|
||||
</p>
|
||||
</div>
|
||||
<div class="domestic-support__items">
|
||||
@@ -375,11 +376,11 @@
|
||||
<div class="section-inner">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="kicker">Agent Runtime</p>
|
||||
<h2 id="features-title">在一个界面中选择和配置 Runtime</h2>
|
||||
<p class="kicker">产品亮点</p>
|
||||
<h2 id="features-title">本地工作空间,统一 Runtime,也支持信创环境</h2>
|
||||
</div>
|
||||
<p>
|
||||
Runtime、模型连接、Skills、MCP 和工作模式均可在桌面界面中配置。
|
||||
从模型连接到执行记录,从桌面工作到国产软硬件适配,都由同一个应用承载。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -407,8 +408,8 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="feature-number">02</span>
|
||||
<h3>覆盖主流桌面系统</h3>
|
||||
<p>Windows、macOS、Linux 同步提供 x64 与 arm64 架构版本。</p>
|
||||
<h3>覆盖主流桌面与信创环境</h3>
|
||||
<p>Windows、macOS、Linux 正式提供 x64 与 arm64 版本,龙芯 LoongArch 另有独立实验预览。</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
|
||||
@@ -250,6 +250,8 @@ for (const breakpoint of ["1199px", "959px", "719px"]) {
|
||||
const requiredCopy = [
|
||||
"免注册",
|
||||
"支持信创软硬件的",
|
||||
"产品亮点",
|
||||
"本地工作空间,统一 Runtime,也支持信创环境",
|
||||
"桌面版本覆盖国产 x64、ARM64 和龙芯 LoongArch",
|
||||
"龙芯 LoongArch 独立编译预览版",
|
||||
"龙芯 LoongArch(实验预览)",
|
||||
@@ -275,9 +277,11 @@ for (const copy of requiredCopy) {
|
||||
|
||||
const requiredEnglishCopy = [
|
||||
"No account required.",
|
||||
"Product highlights",
|
||||
"Model chat and coding agents",
|
||||
"on your desktop.",
|
||||
"Windows, macOS, and Linux",
|
||||
"separate experimental LoongArch preview",
|
||||
"Agent Runtimes in one desktop interface",
|
||||
"Direct models",
|
||||
"OpenCode",
|
||||
|
||||
@@ -36,8 +36,6 @@ const profile: AgentPromptModelProfile = {
|
||||
capabilities: { imageInput: false },
|
||||
limits: {
|
||||
maximumOutputTokens: 4_096,
|
||||
maximumModelCalls: 8,
|
||||
maximumTotalOutputTokens: 32_768,
|
||||
requestTimeoutMilliseconds: 5_000
|
||||
}
|
||||
}
|
||||
@@ -124,6 +122,80 @@ describe('AgentModelGateway', () => {
|
||||
ledger.close()
|
||||
})
|
||||
|
||||
it('merges trusted profile request customization without overriding Runtime fields', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get('x-tenant-id')).toBe('agent-tenant')
|
||||
expect(headers.get('authorization')).toBe(`Bearer ${secret}`)
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
max_output_tokens: 4_096,
|
||||
metadata: { source: 'goodbuddy' },
|
||||
model: 'model-1',
|
||||
temperature: 0.8
|
||||
})
|
||||
return new Response('{}')
|
||||
})
|
||||
const { gateway, ledger } = setup(fetcher)
|
||||
const customizedProfile: AgentPromptModelProfile = {
|
||||
...profile,
|
||||
requestHeaders: {
|
||||
'x-tenant-id': 'agent-tenant'
|
||||
},
|
||||
requestBody: {
|
||||
temperature: 0.2,
|
||||
metadata: { source: 'goodbuddy' }
|
||||
}
|
||||
}
|
||||
const result = await gateway.dispatch(
|
||||
{
|
||||
...context,
|
||||
profile: customizedProfile
|
||||
},
|
||||
{
|
||||
...request,
|
||||
bodyBase64: Buffer.from(
|
||||
JSON.stringify({
|
||||
model: 'model-1',
|
||||
temperature: 0.8
|
||||
})
|
||||
).toString('base64')
|
||||
},
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
await result.acknowledgeDelivery()
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
ledger.close()
|
||||
})
|
||||
|
||||
it('does not impose prompt-wide model-call or output-token quotas', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: 'response',
|
||||
usage: { output_tokens: 4_096 }
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
}
|
||||
)
|
||||
)
|
||||
const { gateway, ledger } = setup(fetcher)
|
||||
|
||||
for (let roundIndex = 0; roundIndex < 101; roundIndex += 1) {
|
||||
const result = await gateway.dispatch(
|
||||
{ ...context, roundIndex },
|
||||
request,
|
||||
new AbortController().signal
|
||||
)
|
||||
await result.acknowledgeDelivery()
|
||||
}
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(101)
|
||||
ledger.close()
|
||||
})
|
||||
|
||||
it('caps OpenCode chat-completions max_tokens before dispatch', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import type { AgentPromptModelProfile } from '../shared/model-bridge-contracts'
|
||||
import {
|
||||
modelBridgePathMatchesProtocol,
|
||||
type AgentPromptModelProfile
|
||||
} from '../shared/model-bridge-contracts'
|
||||
import {
|
||||
REMOTE_MODEL_GATEWAY_LIMITS,
|
||||
remoteModelGatewayRequestSchema,
|
||||
@@ -12,6 +15,11 @@ import {
|
||||
assertTextModelRequestPolicy,
|
||||
ModelRequestPolicyError
|
||||
} from '../shared/model-request-policy'
|
||||
import { canonicalJson } from '../shared/agent-protocol/canonical'
|
||||
import {
|
||||
mergeModelRequestBody,
|
||||
mergeModelRequestHeaders
|
||||
} from '../shared/model-request-customization'
|
||||
import {
|
||||
BoundedResponseTruncatedError,
|
||||
BoundedResponseTooLargeError,
|
||||
@@ -313,7 +321,6 @@ export type AgentModelGatewayContext = {
|
||||
export class AgentModelGateway {
|
||||
readonly #ledger: AgentModelCallLedger
|
||||
readonly #fetch: typeof fetch
|
||||
readonly #promptOutputTokens = new Map<string, number>()
|
||||
|
||||
constructor(options: {
|
||||
ledger: AgentModelCallLedger
|
||||
@@ -334,24 +341,10 @@ export class AgentModelGateway {
|
||||
}> {
|
||||
signal.throwIfAborted()
|
||||
const request = remoteModelGatewayRequestSchema.parse(requestInput)
|
||||
const promptKey = `${context.bindingId}\0${context.operationId}`
|
||||
const usedOutputTokens = this.#promptOutputTokens.get(promptKey) ?? 0
|
||||
const remainingOutputTokens =
|
||||
context.profile.limits.maximumTotalOutputTokens - usedOutputTokens
|
||||
if (remainingOutputTokens < 1) {
|
||||
throw new AgentModelGatewayError(
|
||||
'policy',
|
||||
'Prompt total output token limit is exhausted'
|
||||
)
|
||||
}
|
||||
const reservedOutputTokens = Math.min(
|
||||
context.profile.limits.maximumOutputTokens,
|
||||
remainingOutputTokens
|
||||
)
|
||||
const prepared = prepareProviderRequest(
|
||||
context.profile,
|
||||
request,
|
||||
reservedOutputTokens
|
||||
context.profile.limits.maximumOutputTokens
|
||||
)
|
||||
const requestDigest = sha256(prepared.canonicalRequest)
|
||||
const callId = createAgentModelCallId(context)
|
||||
@@ -444,14 +437,6 @@ export class AgentModelGateway {
|
||||
headers,
|
||||
bodyBase64: Buffer.from(bytes).toString('base64')
|
||||
})
|
||||
this.#promptOutputTokens.set(
|
||||
promptKey,
|
||||
usedOutputTokens +
|
||||
Math.min(
|
||||
reservedOutputTokens,
|
||||
responseOutputTokens(bytes) ?? reservedOutputTokens
|
||||
)
|
||||
)
|
||||
this.#ledger.complete(callId)
|
||||
let finalized = false
|
||||
return {
|
||||
@@ -474,9 +459,6 @@ export class AgentModelGateway {
|
||||
}
|
||||
}
|
||||
|
||||
finalizePrompt(bindingId: string, operationId: string): void {
|
||||
this.#promptOutputTokens.delete(`${bindingId}\0${operationId}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentModelCallId(
|
||||
@@ -506,7 +488,12 @@ function prepareProviderRequest(
|
||||
body: Uint8Array
|
||||
canonicalRequest: string
|
||||
} {
|
||||
if (!pathMatches(profile.protocol, request.path)) {
|
||||
if (
|
||||
!modelBridgePathMatchesProtocol(
|
||||
profile.protocol,
|
||||
request.path
|
||||
)
|
||||
) {
|
||||
throw new AgentModelGatewayError(
|
||||
'policy',
|
||||
'Provider path does not match the prompt profile'
|
||||
@@ -524,6 +511,10 @@ function prepareProviderRequest(
|
||||
'Provider request body is not UTF-8 JSON'
|
||||
)
|
||||
}
|
||||
const normalized = mergeModelRequestBody(
|
||||
profile.requestBody ?? {},
|
||||
json as Record<string, unknown>
|
||||
)
|
||||
try {
|
||||
assertTextModelRequestPolicy(
|
||||
{
|
||||
@@ -531,7 +522,7 @@ function prepareProviderRequest(
|
||||
model: profile.model,
|
||||
supportsImageInput: profile.capabilities.imageInput
|
||||
},
|
||||
json
|
||||
normalized
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof ModelRequestPolicyError)) {
|
||||
@@ -542,7 +533,6 @@ function prepareProviderRequest(
|
||||
'Provider request does not match the prompt profile'
|
||||
)
|
||||
}
|
||||
const normalized = { ...(json as Record<string, unknown>) }
|
||||
const outputField =
|
||||
profile.protocol === 'openai-responses'
|
||||
? 'max_output_tokens'
|
||||
@@ -564,22 +554,37 @@ function prepareProviderRequest(
|
||||
requestedOutput === undefined
|
||||
? maximumOutputTokens
|
||||
: Math.min(requestedOutput as number, maximumOutputTokens)
|
||||
const body = Buffer.from(JSON.stringify(normalized), 'utf8')
|
||||
const body = Buffer.from(canonicalJson(normalized), 'utf8')
|
||||
if (
|
||||
body.byteLength >
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
|
||||
) {
|
||||
throw new AgentModelGatewayError(
|
||||
'policy',
|
||||
'Customized provider request exceeds the configured byte limit'
|
||||
)
|
||||
}
|
||||
const url = providerUrl(profile)
|
||||
const headers = { ...request.headers }
|
||||
const headers = mergeModelRequestHeaders(
|
||||
profile.requestHeaders ?? {},
|
||||
request.headers
|
||||
)
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
headers.set('anthropic-version', '2023-06-01')
|
||||
}
|
||||
const canonicalHeaders = Object.fromEntries(headers.entries())
|
||||
if (profile.protocol === 'anthropic-messages') {
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
if (profile.authentication === 'api-key') {
|
||||
headers['x-api-key'] = profile.apiKey!
|
||||
headers.set('x-api-key', profile.apiKey!)
|
||||
}
|
||||
} else if (profile.authentication === 'api-key') {
|
||||
headers.authorization = `Bearer ${profile.apiKey!}`
|
||||
headers.set('authorization', `Bearer ${profile.apiKey!}`)
|
||||
}
|
||||
return {
|
||||
url,
|
||||
headers,
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
body,
|
||||
canonicalRequest: JSON.stringify({
|
||||
canonicalRequest: canonicalJson({
|
||||
profileId: profile.profileId,
|
||||
provider: profile.provider,
|
||||
protocol: profile.protocol,
|
||||
@@ -587,40 +592,13 @@ function prepareProviderRequest(
|
||||
url: url.toString(),
|
||||
request: {
|
||||
path: request.path,
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(request.headers).sort(([left], [right]) =>
|
||||
left.localeCompare(right)
|
||||
)
|
||||
),
|
||||
headers: canonicalHeaders,
|
||||
bodyBase64: body.toString('base64')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function responseOutputTokens(body: Uint8Array): number | undefined {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(new TextDecoder().decode(body))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
const usage = (value as { usage?: unknown }).usage
|
||||
if (usage === null || typeof usage !== 'object' || Array.isArray(usage)) {
|
||||
return undefined
|
||||
}
|
||||
const record = usage as Record<string, unknown>
|
||||
const tokens = record.output_tokens ?? record.completion_tokens
|
||||
return typeof tokens === 'number' &&
|
||||
Number.isSafeInteger(tokens) &&
|
||||
tokens >= 0
|
||||
? tokens
|
||||
: undefined
|
||||
}
|
||||
|
||||
function providerUrl(profile: AgentPromptModelProfile): URL {
|
||||
const url = new URL(profile.baseUrl)
|
||||
const path = url.pathname.replace(/\/+$/u, '')
|
||||
@@ -635,20 +613,6 @@ function providerUrl(profile: AgentPromptModelProfile): URL {
|
||||
return url
|
||||
}
|
||||
|
||||
function pathMatches(
|
||||
protocol: AgentPromptModelProfile['protocol'],
|
||||
path: string
|
||||
): boolean {
|
||||
if (protocol === 'anthropic-messages') {
|
||||
return path === '/v1/messages'
|
||||
}
|
||||
if (protocol === 'openai-chat-completions') {
|
||||
return path === '/chat/completions' || path === '/v1/chat/completions'
|
||||
}
|
||||
return path === '/responses' || path === '/v1/responses'
|
||||
}
|
||||
|
||||
|
||||
function sha256(value: string | Uint8Array): string {
|
||||
return `sha256:${createHash('sha256').update(value).digest('hex')}`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { open } from 'node:fs/promises'
|
||||
import { binaryTargetArchitecture } from '../shared/node/binary-target'
|
||||
import type { AgentPlatform } from '../shared/agent-target'
|
||||
import type {
|
||||
AgentArchitecture
|
||||
} from '../shared/agent-installation-contracts'
|
||||
@@ -16,7 +18,8 @@ export async function sha256File(filePath: string): Promise<string> {
|
||||
export async function assertElfArchitecture(
|
||||
filePath: string,
|
||||
expected: AgentArchitecture,
|
||||
label: string
|
||||
label: string,
|
||||
platform: AgentPlatform = 'linux'
|
||||
): Promise<void> {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
@@ -27,8 +30,8 @@ export async function assertElfArchitecture(
|
||||
header.length,
|
||||
0
|
||||
)
|
||||
const actual = detectElfArchitecture(
|
||||
header.subarray(0, bytesRead)
|
||||
const actual = binaryTargetArchitecture(
|
||||
header.subarray(0, bytesRead), platform
|
||||
)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
@@ -39,25 +42,3 @@ export async function assertElfArchitecture(
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function detectElfArchitecture(
|
||||
header: Buffer
|
||||
): AgentArchitecture | undefined {
|
||||
if (
|
||||
header.length < 20 ||
|
||||
header[0] !== 0x7f ||
|
||||
header.toString('ascii', 1, 4) !== 'ELF' ||
|
||||
(header[5] !== 1 && header[5] !== 2)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const machine =
|
||||
header[5] === 2
|
||||
? header.readUInt16BE(18)
|
||||
: header.readUInt16LE(18)
|
||||
return machine === 62
|
||||
? 'x64'
|
||||
: machine === 183
|
||||
? 'arm64'
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ describe('Agent CLI fixed command contract', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
'/runtime/bin/opencode'
|
||||
],
|
||||
@@ -69,6 +71,7 @@ describe('Agent CLI fixed command contract', () => {
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
supportsImageInput: false,
|
||||
workMode: 'ask',
|
||||
opencodeEntrypoint: '/runtime/bin/opencode'
|
||||
})
|
||||
await expect(
|
||||
@@ -83,6 +86,8 @@ describe('Agent CLI fixed command contract', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
'/runtime/bin/opencode',
|
||||
'--api-key',
|
||||
@@ -444,7 +449,7 @@ describe('Agent CLI fixed command contract', () => {
|
||||
homeDirectory: 'C:\\Users\\tester',
|
||||
uid: 1234
|
||||
})
|
||||
).toThrow('current Linux UID')
|
||||
).toThrow('current Unix UID')
|
||||
})
|
||||
|
||||
it('rejects injected installation IDs before lifecycle dispatch', async () => {
|
||||
|
||||
+10
-3
@@ -185,6 +185,7 @@ async function runModelBridgeHelper(
|
||||
'protocol',
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'opencode-entrypoint'
|
||||
])
|
||||
requireOptions(options, [
|
||||
@@ -192,6 +193,7 @@ async function runModelBridgeHelper(
|
||||
'protocol',
|
||||
'model',
|
||||
'supports-image-input',
|
||||
'work-mode',
|
||||
'opencode-entrypoint'
|
||||
])
|
||||
const protocol = options.protocol
|
||||
@@ -206,6 +208,10 @@ async function runModelBridgeHelper(
|
||||
if (imageInput !== 'true' && imageInput !== 'false') {
|
||||
throw new Error('Invalid model bridge image-input option')
|
||||
}
|
||||
const workMode = options['work-mode']
|
||||
if (workMode !== 'ask' && workMode !== 'execute') {
|
||||
throw new Error('Invalid model bridge work-mode option')
|
||||
}
|
||||
return await (
|
||||
dependencies.runModelBridgeHelper ??
|
||||
runOpenCodeModelBridgeHelper
|
||||
@@ -214,6 +220,7 @@ async function runModelBridgeHelper(
|
||||
protocol: protocol as ModelBridgeProtocol,
|
||||
model: options.model!,
|
||||
supportsImageInput: imageInput === 'true',
|
||||
workMode,
|
||||
opencodeEntrypoint: options['opencode-entrypoint']!
|
||||
})
|
||||
}
|
||||
@@ -835,13 +842,13 @@ export function deriveManagedInstallationPaths(
|
||||
const platform = options.platform ?? process.platform
|
||||
const uid = options.uid ?? process.getuid?.()
|
||||
if (
|
||||
platform !== 'linux' ||
|
||||
!['linux', 'darwin'].includes(platform) ||
|
||||
uid === undefined ||
|
||||
!Number.isSafeInteger(uid) ||
|
||||
uid < 0
|
||||
) {
|
||||
throw new AgentUnsupportedError(
|
||||
'Managed Agent paths require a current Linux UID',
|
||||
'Managed Agent paths require a current Unix UID',
|
||||
'platform-incompatible'
|
||||
)
|
||||
}
|
||||
@@ -853,7 +860,7 @@ export function deriveManagedInstallationPaths(
|
||||
.digest('hex')
|
||||
.slice(0, 20)
|
||||
const socketRoot = posix.join(
|
||||
'/tmp',
|
||||
platform === 'darwin' ? '/private/tmp' : '/tmp',
|
||||
`goodbuddy-${uid}-${homeHash}`
|
||||
)
|
||||
const socketName = createHash('sha256')
|
||||
|
||||
@@ -100,6 +100,7 @@ export class AgentDaemon {
|
||||
readonly #agentVersion: string
|
||||
readonly #protocol: AttachPreface['protocol']
|
||||
readonly #architecture: 'x64' | 'arm64'
|
||||
readonly #platform = process.platform
|
||||
readonly #remoteUserIdentity: string
|
||||
readonly #stateDirectory: string
|
||||
readonly #socketPath: string
|
||||
@@ -171,7 +172,7 @@ export class AgentDaemon {
|
||||
daemonBootId: this.#bootId,
|
||||
agentVersion: this.#agentVersion,
|
||||
protocol: this.#protocol,
|
||||
platform: 'linux',
|
||||
platform: this.#platform,
|
||||
architecture: this.#architecture,
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: this.#remoteUserIdentity,
|
||||
@@ -180,9 +181,12 @@ export class AgentDaemon {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (process.platform !== 'linux') {
|
||||
if (
|
||||
this.#platform !== 'linux' &&
|
||||
!(this.#platform === 'darwin' && process.arch === 'arm64')
|
||||
) {
|
||||
throw new AgentUnsupportedError(
|
||||
'GoodBuddy Agent daemon is certified only on Linux',
|
||||
'GoodBuddy Agent daemon requires Linux or macOS arm64',
|
||||
'platform-incompatible'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
darwinBootId,
|
||||
inspectDarwinProcess,
|
||||
listDarwinProcessGroup,
|
||||
stopDarwinDescendants
|
||||
} from './darwin-process-identity'
|
||||
|
||||
describe('Darwin process identity', () => {
|
||||
const native = process.platform === 'darwin' && process.arch === 'arm64' ? it : it.skip
|
||||
native('reads actual process identity and enumerates a dedicated child group', async () => {
|
||||
const current = inspectDarwinProcess(process.pid)
|
||||
expect(current?.pid).toBe(process.pid)
|
||||
expect(current?.executablePath).toBe(realpathSync(process.execPath))
|
||||
expect(BigInt(current!.starttime)).toBeGreaterThan(0n)
|
||||
expect(darwinBootId()).toMatch(/^[a-f0-9-]{36}$/u)
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
detached: true, stdio: 'ignore'
|
||||
})
|
||||
await once(child, 'spawn')
|
||||
try {
|
||||
const identity = inspectDarwinProcess(child.pid!)
|
||||
expect(identity?.processGroupId).toBe(child.pid)
|
||||
expect(listDarwinProcessGroup(child.pid!)).toContain(child.pid)
|
||||
expect(listDarwinProcessGroup(child.pid!)).not.toContain(process.pid)
|
||||
} finally {
|
||||
const exited = once(child, 'exit')
|
||||
child.kill()
|
||||
await exited
|
||||
}
|
||||
expect(inspectDarwinProcess(child.pid!)).toBeUndefined()
|
||||
})
|
||||
native('stops a tool child that starts its own process group', async () => {
|
||||
const parent = spawn(process.execPath, ['-e', `
|
||||
const {spawn}=require('node:child_process');
|
||||
const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:'ignore'});
|
||||
child.on('spawn',()=>console.log(child.pid));
|
||||
setInterval(()=>{},1000);
|
||||
`], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
const [output] = await once(parent.stdout!, 'data')
|
||||
const childPid = Number(String(output).trim())
|
||||
try {
|
||||
expect(inspectDarwinProcess(childPid)?.processGroupId).toBe(childPid)
|
||||
await stopDarwinDescendants(parent.pid!, Date.now() + 3000)
|
||||
expect(inspectDarwinProcess(childPid)).toBeUndefined()
|
||||
expect(inspectDarwinProcess(parent.pid!)).toBeDefined()
|
||||
} finally {
|
||||
await stopDarwinDescendants(parent.pid!, Date.now() + 3000)
|
||||
const exited = once(parent, 'exit')
|
||||
parent.kill()
|
||||
await exited
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import koffi from 'koffi'
|
||||
import { realpathSync } from 'node:fs'
|
||||
|
||||
export type DarwinProcessIdentity = {
|
||||
pid: number
|
||||
starttime: string
|
||||
executablePath: string
|
||||
processGroupId: number
|
||||
parentPid: number
|
||||
}
|
||||
|
||||
let binding: ReturnType<typeof createBinding> | undefined
|
||||
|
||||
function createBinding() {
|
||||
const library = koffi.load('/usr/lib/libSystem.B.dylib')
|
||||
return {
|
||||
info: library.func('proc_pidinfo', 'int', [
|
||||
'int', 'int', 'uint64_t', 'void *', 'int'
|
||||
]),
|
||||
path: library.func('proc_pidpath', 'int', ['int', 'void *', 'uint32_t']),
|
||||
list: library.func('proc_listpids', 'int', ['uint32_t', 'uint32_t', 'void *', 'int']),
|
||||
sysctl: library.func('sysctlbyname', 'int', [
|
||||
'str', 'void *', 'void *', 'void *', 'size_t'
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function native() {
|
||||
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
|
||||
throw new Error('Darwin process inspection requires macOS arm64')
|
||||
}
|
||||
return binding ??= createBinding()
|
||||
}
|
||||
|
||||
export function inspectDarwinProcess(pid: number): DarwinProcessIdentity | undefined {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('Invalid process ID')
|
||||
const api = native()
|
||||
const info = Buffer.alloc(136)
|
||||
const size = api.info(pid, 3, 0, info, info.length) as number
|
||||
if (size === 0 && [3, 2].includes(koffi.errno())) return undefined
|
||||
if (size !== info.length || info.readUInt32LE(12) !== pid) {
|
||||
throw new Error(`proc_pidinfo failed (${koffi.errno()})`)
|
||||
}
|
||||
const path = Buffer.alloc(4096)
|
||||
if ((api.path(pid, path, path.length) as number) <= 0) {
|
||||
if ([3, 2].includes(koffi.errno())) return undefined
|
||||
throw new Error(`proc_pidpath failed (${koffi.errno()})`)
|
||||
}
|
||||
const starttime = info.readBigUInt64LE(120) * 1_000_000n +
|
||||
info.readBigUInt64LE(128)
|
||||
return {
|
||||
pid,
|
||||
starttime: starttime.toString(),
|
||||
executablePath: realpathSync(path.subarray(0, path.indexOf(0)).toString('utf8')),
|
||||
processGroupId: info.readUInt32LE(100),
|
||||
parentPid: info.readUInt32LE(16)
|
||||
}
|
||||
}
|
||||
|
||||
export function darwinBootId(): string {
|
||||
const bytes = Buffer.alloc(128)
|
||||
const size = Buffer.alloc(8)
|
||||
size.writeBigUInt64LE(BigInt(bytes.length))
|
||||
if (native().sysctl('kern.bootsessionuuid', bytes, size, null, 0) !== 0) {
|
||||
throw new Error(`Boot session lookup failed (${koffi.errno()})`)
|
||||
}
|
||||
const value = bytes.subarray(0, bytes.indexOf(0)).toString('ascii').toLowerCase()
|
||||
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(value)) {
|
||||
throw new Error('Invalid Darwin boot session identity')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function listDarwinProcessGroup(groupId: number): number[] {
|
||||
return listDarwinPids(2, groupId)
|
||||
}
|
||||
|
||||
function listDarwinPids(kind: number, parent: number): number[] {
|
||||
const api = native()
|
||||
if (!Number.isSafeInteger(parent) || parent <= 0) throw new Error('Invalid process group')
|
||||
const required = api.list(kind, parent, null, 0) as number
|
||||
if (required < 0) throw new Error('Process group enumeration failed')
|
||||
const buffer = Buffer.alloc(required + 4096)
|
||||
const size = api.list(kind, parent, buffer, buffer.length) as number
|
||||
if (size < 0 || size >= buffer.length || size % 4 !== 0) {
|
||||
throw new Error('Process group enumeration was incomplete')
|
||||
}
|
||||
const pids: number[] = []
|
||||
for (let offset = 0; offset < size; offset += 4) {
|
||||
const pid = buffer.readInt32LE(offset)
|
||||
if (pid > 0) pids.push(pid)
|
||||
}
|
||||
return pids
|
||||
}
|
||||
|
||||
export async function stopDarwinDescendants(
|
||||
ownerPid: number,
|
||||
deadline: number
|
||||
): Promise<void> {
|
||||
const descendants: DarwinProcessIdentity[] = []
|
||||
const pending = [ownerPid]
|
||||
const seen = new Set(pending)
|
||||
while (pending.length > 0) {
|
||||
const parentPid = pending.pop()!
|
||||
for (const pid of listDarwinPids(6, parentPid)) {
|
||||
if (seen.has(pid)) continue
|
||||
seen.add(pid)
|
||||
const identity = inspectDarwinProcess(pid)
|
||||
if (!identity || identity.parentPid !== parentPid) continue
|
||||
descendants.push(identity)
|
||||
pending.push(pid)
|
||||
}
|
||||
}
|
||||
const matches = (identity: DarwinProcessIdentity): boolean => {
|
||||
const current = inspectDarwinProcess(identity.pid)
|
||||
return current !== undefined &&
|
||||
current.starttime === identity.starttime &&
|
||||
current.executablePath === identity.executablePath
|
||||
}
|
||||
const signal = (identity: DarwinProcessIdentity, value: NodeJS.Signals): void => {
|
||||
if (!matches(identity)) return
|
||||
try { process.kill(identity.pid, value) }
|
||||
catch (error) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) throw error
|
||||
}
|
||||
}
|
||||
for (const identity of [...descendants].reverse()) signal(identity, 'SIGTERM')
|
||||
const grace = Math.min(deadline, Date.now() + 500)
|
||||
while (Date.now() < grace && descendants.some(matches)) {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
for (const identity of [...descendants].reverse()) signal(identity, 'SIGKILL')
|
||||
while (Date.now() < deadline && descendants.some(matches)) {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
if (descendants.some(matches)) {
|
||||
throw new Error('Runtime child process cleanup could not be verified')
|
||||
}
|
||||
}
|
||||
@@ -24,24 +24,25 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('direct Linux stdio Runtime ownership', () => {
|
||||
it('spawns exact bubblewrap argv without systemd using detached fixed stdio', async () => {
|
||||
it('spawns Ask directly without systemd using detached fixed stdio', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
let launchedIdentity!: LinuxRuntimeProcessIdentity
|
||||
const spawn = vi.fn<DirectLinuxStdioSpawn>((executable, args, options) => {
|
||||
expect(executable).toBe('/usr/bin/bwrap')
|
||||
expect(args).not.toContain('--die-with-parent')
|
||||
expect(args).toContain('--unshare-all')
|
||||
expect(args).toContain('--new-session')
|
||||
expect(args).toContain('GOODBUDDY_RUNTIME_OWNER_TOKEN')
|
||||
expect(executable).toBe('/bundle/bin/opencode')
|
||||
expect(args).toEqual(['acp'])
|
||||
expect(args.join('\0')).not.toMatch(/systemd-run|systemctl|DBUS/iu)
|
||||
expect(options).toEqual({
|
||||
shell: false,
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
cwd: '/state/scratch',
|
||||
cwd: '/workspace',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
LANG: 'C.UTF-8',
|
||||
GOODBUDDY_RUNTIME_OWNER_TOKEN: 'a'.repeat(32)
|
||||
}
|
||||
})
|
||||
launchedIdentity = identity()
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
@@ -122,47 +123,6 @@ describe('direct Linux stdio Runtime ownership', () => {
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('waits for bubblewrap to exec the supervised bridge helper', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const bwrapIdentity = identity()
|
||||
const helperIdentity = {
|
||||
...bwrapIdentity,
|
||||
executablePath: '/agent/node'
|
||||
}
|
||||
const spawn: DirectLinuxStdioSpawn = () => {
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
return child
|
||||
}
|
||||
const readProcessIdentity = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(bwrapIdentity)
|
||||
.mockResolvedValue(helperIdentity)
|
||||
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(),
|
||||
profile: {
|
||||
...profile(),
|
||||
processExecutable: '/agent/node'
|
||||
},
|
||||
identity: { launchId: 'launch-1', processId: 'process-1' },
|
||||
installationId: 'installation-1',
|
||||
registry,
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z',
|
||||
maximumInputBytes: 1024,
|
||||
platform: 'linux',
|
||||
spawn,
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity
|
||||
})
|
||||
|
||||
expect(readProcessIdentity).toHaveBeenCalledTimes(2)
|
||||
expect(owner.processIdentity).toEqual(helperIdentity)
|
||||
child.exitCode = 0
|
||||
child.emit('close', 0, null)
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('rejects PID reuse before sending any signal', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
@@ -294,6 +254,56 @@ describe('direct Linux stdio Runtime ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses stdout backpressure instead of stopping when output queues fill', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const launched = identity()
|
||||
const sendSignal = vi.fn<typeof process.kill>(() => true)
|
||||
let releaseFirst!: () => void
|
||||
const firstConsumed = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(),
|
||||
profile: profile(),
|
||||
identity: { launchId: 'launch-output', processId: 'process-output' },
|
||||
installationId: 'installation-1',
|
||||
registry,
|
||||
deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE,
|
||||
maximumInputBytes: 1024,
|
||||
maximumOutputQueueChunks: 1,
|
||||
platform: 'linux',
|
||||
spawn: () => {
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
return child
|
||||
},
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity: async () => launched,
|
||||
sendSignal
|
||||
})
|
||||
const pause = vi.spyOn(child.stdout, 'pause')
|
||||
const resume = vi.spyOn(child.stdout, 'resume')
|
||||
const received: string[] = []
|
||||
owner.subscribeOutput(async ({ data }) => {
|
||||
received.push(Buffer.from(data).toString())
|
||||
if (received.length === 1) {
|
||||
await firstConsumed
|
||||
}
|
||||
})
|
||||
|
||||
child.stdout.write('one')
|
||||
await vi.waitFor(() => expect(pause).toHaveBeenCalled())
|
||||
child.stdout.write('two')
|
||||
releaseFirst()
|
||||
await vi.waitFor(() => expect(received).toEqual(['one', 'two']))
|
||||
|
||||
expect(resume).toHaveBeenCalled()
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
child.exitCode = 0
|
||||
child.emit('close', 0, null)
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('startup reconciles only owners from the current installation', async () => {
|
||||
const registry = createRegistry()
|
||||
const reservation = {
|
||||
@@ -350,24 +360,12 @@ function fakeChild(): DirectLinuxStdioChild & EventEmitter {
|
||||
|
||||
function profile() {
|
||||
return {
|
||||
executable: '/usr/bin/bwrap' as const,
|
||||
processExecutable: '/usr/bin/bwrap' as const,
|
||||
cwd: '/state/scratch',
|
||||
executable: '/bundle/bin/opencode' as const,
|
||||
processExecutable: '/bundle/bin/opencode' as const,
|
||||
cwd: '/workspace',
|
||||
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' },
|
||||
workMode: 'ask' as const,
|
||||
args: [
|
||||
'--new-session',
|
||||
'--unshare-all',
|
||||
'--clearenv',
|
||||
'--proc',
|
||||
'/proc',
|
||||
'--ro-bind',
|
||||
'/workspace',
|
||||
'/workspace',
|
||||
'--',
|
||||
'/bundle/bin/opencode',
|
||||
'acp'
|
||||
]
|
||||
args: ['acp']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +375,7 @@ function identity(): LinuxRuntimeProcessIdentity {
|
||||
pid: 42,
|
||||
startTimeTicks: 100n,
|
||||
processGroupId: 42,
|
||||
executablePath: '/usr/bin/bwrap'
|
||||
executablePath: '/bundle/bin/opencode'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import type { ModelBridgePolicy } from '../shared/model-bridge-contracts'
|
||||
import {
|
||||
createOpenCodeLaunchProfile,
|
||||
OPENCODE_BWRAP_EXECUTABLE,
|
||||
type OpenCodeLaunchProfile
|
||||
} from './opencode-runtime-profile'
|
||||
import {
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
sameLinuxRuntimeProcessIdentity,
|
||||
type LinuxRuntimeProcessIdentity
|
||||
} from './runtime-process-identity'
|
||||
import { listDarwinProcessGroup, stopDarwinDescendants } from './darwin-process-identity'
|
||||
|
||||
const DEFAULT_MAXIMUM_STDIN_WRITE_BYTES = 1024 * 1024
|
||||
const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128
|
||||
@@ -102,7 +102,6 @@ export type DirectLinuxStdioProcessOwnerOptions = {
|
||||
profileInput?: {
|
||||
bundleDirectory: string
|
||||
workspaceDirectory: string
|
||||
scratchDirectory: string
|
||||
workMode: 'ask' | 'execute'
|
||||
modelBridge?: {
|
||||
agentExecutablePath: string
|
||||
@@ -172,13 +171,13 @@ export class DirectLinuxStdioProcessOwner {
|
||||
#maximumPendingStdinBytes: number
|
||||
#inputBytes = 0
|
||||
#pendingInputBytes = 0
|
||||
#outputBytes = 0
|
||||
#queuedOutputBytes = 0
|
||||
#writeTail: Promise<void> = Promise.resolve()
|
||||
#deadlineTimer?: NodeJS.Timeout
|
||||
#promptGeneration = 1
|
||||
#promptActive = true
|
||||
#draining = false
|
||||
#outputPaused = false
|
||||
#closed = false
|
||||
#stdinClosed = false
|
||||
#stopPromise?: Promise<void>
|
||||
@@ -282,7 +281,6 @@ export class DirectLinuxStdioProcessOwner {
|
||||
this.#maximumPendingStdinBytes = this.#maximumInputBytes
|
||||
this.#inputBytes = 0
|
||||
this.#pendingInputBytes = 0
|
||||
this.#outputBytes = 0
|
||||
this.#promptActive = true
|
||||
this.#promptGeneration += 1
|
||||
this.#scheduleDeadline(
|
||||
@@ -404,6 +402,9 @@ export class DirectLinuxStdioProcessOwner {
|
||||
throw identityError('Runtime PID identity changed before signal')
|
||||
}
|
||||
this.#registry.markStopping(this.ownerId)
|
||||
if (process.platform === 'darwin') {
|
||||
await stopDarwinDescendants(this.processIdentity.pid, Date.parse(deadlineAt))
|
||||
}
|
||||
this.#stdinClosed = true
|
||||
this.#child.stdin.end()
|
||||
this.#sendSignal(-record.processIdentity.processGroupId, 'SIGTERM')
|
||||
@@ -451,24 +452,17 @@ export class DirectLinuxStdioProcessOwner {
|
||||
|
||||
#acceptOutput(chunk: Buffer | Uint8Array): void {
|
||||
if (this.#closed || chunk.byteLength === 0) return
|
||||
if (
|
||||
this.#outputBytes + chunk.byteLength > this.#maximumOutputBytes ||
|
||||
this.#outputQueue.length >= this.#maximumOutputQueueChunks ||
|
||||
this.#queuedOutputBytes + chunk.byteLength > this.#maximumOutputBytes
|
||||
) {
|
||||
this.#child.stdout.pause()
|
||||
this.#outputQueue.splice(0)
|
||||
this.#queuedOutputBytes = 0
|
||||
void this.stop({
|
||||
reason: 'output-quota',
|
||||
deadlineAt: new Date(this.#now() + DEFAULT_STOP_TIMEOUT_MS).toISOString()
|
||||
}).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
const copy = Uint8Array.from(chunk)
|
||||
this.#outputBytes += copy.byteLength
|
||||
this.#queuedOutputBytes += copy.byteLength
|
||||
this.#outputQueue.push(copy)
|
||||
if (
|
||||
this.#outputQueue.length >= this.#maximumOutputQueueChunks ||
|
||||
(this.#maximumOutputBytes > 0 &&
|
||||
this.#queuedOutputBytes >= this.#maximumOutputBytes)
|
||||
) {
|
||||
this.#child.stdout.pause()
|
||||
this.#outputPaused = true
|
||||
}
|
||||
this.#scheduleDrain()
|
||||
}
|
||||
|
||||
@@ -486,6 +480,15 @@ export class DirectLinuxStdioProcessOwner {
|
||||
for (const listener of [...this.#outputListeners]) {
|
||||
await listener({ stream: 'stdout', data: data.slice() })
|
||||
}
|
||||
if (
|
||||
this.#outputPaused &&
|
||||
this.#outputQueue.length < this.#maximumOutputQueueChunks &&
|
||||
(this.#maximumOutputBytes === 0 ||
|
||||
this.#queuedOutputBytes < this.#maximumOutputBytes)
|
||||
) {
|
||||
this.#outputPaused = false
|
||||
this.#child.stdout.resume()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
void this.stop({
|
||||
@@ -541,9 +544,9 @@ export class DirectLinuxStdioProcessOwner {
|
||||
export async function launchDirectLinuxStdioProcessOwner(
|
||||
options: DirectLinuxStdioProcessOwnerOptions
|
||||
): Promise<DirectLinuxStdioProcessOwner> {
|
||||
if ((options.platform ?? process.platform) !== 'linux') {
|
||||
if (!['linux', 'darwin'].includes(options.platform ?? process.platform)) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
'Direct Runtime ownership is Linux-only',
|
||||
'Direct Runtime ownership requires Linux or macOS',
|
||||
'platform'
|
||||
)
|
||||
}
|
||||
@@ -771,26 +774,12 @@ export function bindDirectOwnerToken(
|
||||
) {
|
||||
throw identityError('Runtime owner token marker is invalid or duplicated')
|
||||
}
|
||||
if (profile.workMode === 'execute') {
|
||||
return {
|
||||
...profile,
|
||||
env: {
|
||||
...profile.env,
|
||||
[OWNER_TOKEN_ENVIRONMENT_NAME]: ownerToken
|
||||
}
|
||||
}
|
||||
}
|
||||
const separator = profile.args.indexOf('--')
|
||||
return {
|
||||
...profile,
|
||||
args: [
|
||||
...profile.args.slice(0, separator),
|
||||
'--setenv',
|
||||
OWNER_TOKEN_ENVIRONMENT_NAME,
|
||||
ownerToken,
|
||||
...profile.args.slice(separator)
|
||||
],
|
||||
env: { ...profile.env }
|
||||
env: {
|
||||
...profile.env,
|
||||
[OWNER_TOKEN_ENVIRONMENT_NAME]: ownerToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,6 +790,18 @@ export async function listLinuxPidNamespaceMembers(
|
||||
>,
|
||||
options: { procRoot?: string } = {}
|
||||
): Promise<LinuxRuntimeProcessIdentity[]> {
|
||||
if (process.platform === 'darwin') {
|
||||
const identities: LinuxRuntimeProcessIdentity[] = []
|
||||
for (const pid of listDarwinProcessGroup(owner.processGroupId)) {
|
||||
try {
|
||||
const identity = await readLinuxRuntimeProcessIdentity(pid, { bootId: owner.bootId })
|
||||
if (identity.processGroupId === owner.processGroupId) identities.push(identity)
|
||||
} catch (error) {
|
||||
if (!isMissingProcess(error)) throw error
|
||||
}
|
||||
}
|
||||
return identities.sort((left, right) => left.pid - right.pid)
|
||||
}
|
||||
const procRoot = options.procRoot ?? '/proc'
|
||||
const entries = await readdir(procRoot, { withFileTypes: true })
|
||||
const identities: LinuxRuntimeProcessIdentity[] = []
|
||||
@@ -856,31 +857,8 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile
|
||||
) {
|
||||
throw identityError('Runtime launch profile is malformed')
|
||||
}
|
||||
if (
|
||||
profile.workMode === 'ask' &&
|
||||
(
|
||||
profile.executable !== OPENCODE_BWRAP_EXECUTABLE ||
|
||||
profile.args.includes(['--die', 'with-parent'].join('-')) ||
|
||||
!profile.args.includes('--unshare-all') ||
|
||||
!profile.args.includes('--new-session') ||
|
||||
!profile.args.includes('--clearenv') ||
|
||||
!profile.args.includes('--ro-bind') ||
|
||||
profile.args.filter((argument) => argument === '--').length !== 1
|
||||
)
|
||||
) {
|
||||
throw identityError('Ask Runtime launch profile is not read-only')
|
||||
}
|
||||
if (
|
||||
profile.workMode === 'execute' &&
|
||||
(
|
||||
profile.executable === OPENCODE_BWRAP_EXECUTABLE ||
|
||||
profile.args.includes('--unshare-all') ||
|
||||
profile.args.includes('--clearenv') ||
|
||||
profile.args.includes('--ro-bind') ||
|
||||
profile.args.includes('--bind')
|
||||
)
|
||||
) {
|
||||
throw identityError('Execute Runtime launch profile must run directly')
|
||||
if (profile.processExecutable !== profile.executable) {
|
||||
throw identityError('Runtime launch profile must run directly')
|
||||
}
|
||||
return {
|
||||
executable: profile.executable,
|
||||
|
||||
@@ -230,7 +230,8 @@ export async function verifyPublishedAgentBundle(
|
||||
await assertElfArchitecture(
|
||||
join(installationDirectory, manifest.entrypoint.runtimePath),
|
||||
manifest.arch,
|
||||
'Agent Node runtime'
|
||||
'Agent Node runtime',
|
||||
manifest.platform
|
||||
)
|
||||
const manifestSha256 = sha256Bytes(manifestBytes)
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { assertAbsoluteManagedPath } from './managed-paths'
|
||||
import { inspectDarwinProcess } from './darwin-process-identity'
|
||||
|
||||
const linuxProcessIdentitySchema = z
|
||||
.object({
|
||||
@@ -45,6 +46,14 @@ export class LinuxProcessInspector {
|
||||
|
||||
inspect(pidInput: number): LinuxProcessIdentity | undefined {
|
||||
const pid = positivePid(pidInput)
|
||||
if (process.platform === 'darwin') {
|
||||
const identity = inspectDarwinProcess(pid)
|
||||
return identity === undefined ? undefined : {
|
||||
pid: identity.pid,
|
||||
starttime: identity.starttime,
|
||||
executablePath: identity.executablePath
|
||||
}
|
||||
}
|
||||
try {
|
||||
const starttime = parseStarttime(
|
||||
this.#fileSystem.readText(
|
||||
|
||||
@@ -287,20 +287,20 @@ export function derivePrivateTemporaryRoot(options: {
|
||||
const platform = options.platform ?? process.platform
|
||||
const uid = options.uid ?? process.getuid?.()
|
||||
if (
|
||||
platform !== 'linux' ||
|
||||
!['linux', 'darwin'].includes(platform) ||
|
||||
uid === undefined ||
|
||||
!Number.isSafeInteger(uid) ||
|
||||
uid < 0
|
||||
) {
|
||||
throw new ManagedPathError(
|
||||
'Private Agent endpoint root requires a current Linux UID'
|
||||
'Private Agent endpoint root requires a current Unix UID'
|
||||
)
|
||||
}
|
||||
const canonicalHomeDirectory = resolve(
|
||||
options.homeDirectory ?? homedir()
|
||||
)
|
||||
const temporaryDirectory = resolve(
|
||||
options.temporaryDirectory ?? '/tmp'
|
||||
options.temporaryDirectory ?? (platform === 'darwin' ? '/private/tmp' : '/tmp')
|
||||
)
|
||||
assertAbsoluteManagedPath(canonicalHomeDirectory)
|
||||
assertAbsoluteManagedPath(temporaryDirectory)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { spawn as nodeSpawn } from 'node:child_process'
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
import {
|
||||
createServer,
|
||||
type IncomingHttpHeaders,
|
||||
@@ -8,7 +12,7 @@ import {
|
||||
type ServerResponse
|
||||
} from 'node:http'
|
||||
import type { Socket } from 'node:net'
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
import { isAbsolute, resolve, join } from 'node:path'
|
||||
import type {
|
||||
ModelBridgeModelProtocol
|
||||
} from '../shared/model-bridge-contracts'
|
||||
@@ -44,10 +48,14 @@ export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
|
||||
|
||||
export type OpenCodeModelBridgeProviderConfig = {
|
||||
model: string
|
||||
permission?: 'ask'
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
}
|
||||
build?: {
|
||||
permission: 'ask'
|
||||
}
|
||||
}
|
||||
provider: Record<
|
||||
string,
|
||||
@@ -389,6 +397,7 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
protocol: ModelBridgeProtocol
|
||||
model: string
|
||||
supportsImageInput: boolean
|
||||
workMode: 'ask' | 'execute'
|
||||
opencodeEntrypoint: string
|
||||
environment?: Readonly<NodeJS.ProcessEnv>
|
||||
spawn?: ModelBridgeHelperSpawn
|
||||
@@ -404,16 +413,24 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
const exchange = createUnixModelBridgeExchange({ socketPath })
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
||||
const origin = await proxy.listen()
|
||||
let pluginDirectory: string | undefined
|
||||
try {
|
||||
pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-'))
|
||||
const pluginPath = join(pluginDirectory, 'subagent.mjs')
|
||||
await writeFile(pluginPath, openCodeSubagentPluginSource(), 'utf8')
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: options.protocol,
|
||||
model: options.model,
|
||||
loopbackOrigin: origin,
|
||||
supportsImageInput: options.supportsImageInput
|
||||
supportsImageInput: options.supportsImageInput,
|
||||
workMode: options.workMode
|
||||
})
|
||||
const environment = credentialFreeHelperEnvironment(
|
||||
options.environment ?? process.env,
|
||||
JSON.stringify(config)
|
||||
JSON.stringify({
|
||||
...config,
|
||||
plugin: [pathToFileURL(pluginPath).href]
|
||||
})
|
||||
)
|
||||
const child = (options.spawn ?? defaultHelperSpawn)(
|
||||
opencodeEntrypoint,
|
||||
@@ -436,6 +453,9 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
})
|
||||
} finally {
|
||||
await proxy.close()
|
||||
if (pluginDirectory) {
|
||||
await rm(pluginDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +507,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
loopbackOrigin: string
|
||||
name?: string
|
||||
supportsImageInput?: boolean
|
||||
workMode?: 'ask' | 'execute'
|
||||
}): OpenCodeModelBridgeProviderConfig {
|
||||
const model = boundedMetadataText(input.model, 'Model name')
|
||||
const name = boundedMetadataText(
|
||||
@@ -500,13 +521,25 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
const baseURL = `${origin}/v1`
|
||||
return {
|
||||
model: openCodeModelBridgeModelId(input.protocol, model),
|
||||
...(input.workMode === 'ask'
|
||||
? {
|
||||
permission: 'ask' as const
|
||||
}
|
||||
: {}),
|
||||
// GoodBuddy owns conversation titles. A first-prompt title request can
|
||||
// race the real ACP prompt, abandon its Provider response, and correctly
|
||||
// poison the no-replay ledger before the user request can run.
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
}
|
||||
},
|
||||
...(input.workMode === 'ask'
|
||||
? {
|
||||
build: {
|
||||
permission: 'ask' as const
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
provider: {
|
||||
[providerId]: {
|
||||
|
||||
@@ -465,14 +465,19 @@ describe('model bridge loopback helper', () => {
|
||||
name: 'Private model',
|
||||
loopbackOrigin:
|
||||
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
|
||||
supportsImageInput: true
|
||||
supportsImageInput: true,
|
||||
workMode: 'ask'
|
||||
})
|
||||
|
||||
expect(config).toMatchObject({
|
||||
model: `${providerId}/private-model`,
|
||||
permission: 'ask',
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
},
|
||||
build: {
|
||||
permission: 'ask'
|
||||
}
|
||||
},
|
||||
provider: {
|
||||
@@ -500,6 +505,19 @@ describe('model bridge loopback helper', () => {
|
||||
expect(JSON.stringify(config)).not.toContain('provider.example')
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves Execute provider permissions unchanged', () => {
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
loopbackOrigin:
|
||||
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
|
||||
workMode: 'execute'
|
||||
})
|
||||
|
||||
expect(config).not.toHaveProperty('permission')
|
||||
expect(config.agent).not.toHaveProperty('build')
|
||||
})
|
||||
})
|
||||
|
||||
describe('model bridge Unix broker', () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type RemoteRuntimeBundleManifest
|
||||
} from '../shared/remote-runtime-launch-contracts'
|
||||
import {
|
||||
OPENCODE_BWRAP_EXECUTABLE,
|
||||
createOpenCodeLaunchProfile
|
||||
} from './opencode-runtime-profile'
|
||||
|
||||
@@ -24,28 +23,35 @@ afterEach(() => {
|
||||
}
|
||||
})
|
||||
|
||||
describe('OpenCode bubblewrap launch profile', () => {
|
||||
it('uses a read-only Workspace bind for Ask', () => {
|
||||
describe('OpenCode direct launch profile', () => {
|
||||
it('runs Ask directly in the Workspace', () => {
|
||||
const fixture = createFixture()
|
||||
const profile = createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
workMode: 'ask'
|
||||
})
|
||||
|
||||
expect(profile.executable).toBe(OPENCODE_BWRAP_EXECUTABLE)
|
||||
expect(profile.cwd).toBe(fixture.scratchDirectory)
|
||||
expect(profile.args).toContain('--unshare-all')
|
||||
expect(profile.args).toContain('--clearenv')
|
||||
expectArgumentSequence(profile.args, [
|
||||
'--ro-bind',
|
||||
fixture.workspaceDirectory,
|
||||
fixture.workspaceDirectory
|
||||
])
|
||||
expectArgumentSequence(profile.args, [
|
||||
'--',
|
||||
resolve(fixture.bundleDirectory, 'bin', 'opencode'),
|
||||
'acp'
|
||||
])
|
||||
expect(profile.executable).toBe(
|
||||
resolve(fixture.bundleDirectory, 'bin', 'opencode')
|
||||
)
|
||||
expect(profile.processExecutable).toBe(profile.executable)
|
||||
expect(profile.args).toEqual(['acp'])
|
||||
expect(profile.cwd).toBe(fixture.workspaceDirectory)
|
||||
expect(profile.env).toMatchObject({
|
||||
HOME:
|
||||
process.env.HOME ?? fixture.workspaceDirectory,
|
||||
PATH: process.env.PATH || '/usr/bin:/bin'
|
||||
})
|
||||
expect(
|
||||
JSON.parse(profile.env.OPENCODE_CONFIG_CONTENT!)
|
||||
).toMatchObject({
|
||||
permission: 'ask',
|
||||
agent: {
|
||||
build: {
|
||||
permission: 'ask'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('runs Execute directly with the SSH account environment', () => {
|
||||
@@ -98,29 +104,12 @@ describe('OpenCode bubblewrap launch profile', () => {
|
||||
expect(execute.args).not.toContain('--unshare-all')
|
||||
})
|
||||
|
||||
it('sets only the fixed environment and rejects a changed profile', () => {
|
||||
it('keeps Runtime arguments credential-free and rejects a changed manifest', () => {
|
||||
const fixture = createFixture()
|
||||
const profile = createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
workMode: 'ask'
|
||||
})
|
||||
const setEnvironmentNames = profile.args.flatMap(
|
||||
(argument, index) =>
|
||||
argument === '--setenv'
|
||||
? [profile.args[index + 1]]
|
||||
: []
|
||||
)
|
||||
expect(setEnvironmentNames).toEqual([
|
||||
'HOME',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'XDG_STATE_HOME'
|
||||
])
|
||||
expect(profile.args.join('\0')).not.toContain('ANTHROPIC_API_KEY')
|
||||
|
||||
expect(() =>
|
||||
@@ -142,18 +131,9 @@ describe('OpenCode bubblewrap launch profile', () => {
|
||||
).toThrow(/normalized absolute/iu)
|
||||
})
|
||||
|
||||
it('rejects overlapping bundle, Workspace, and scratch mounts', () => {
|
||||
it('does not impose mount-layout constraints on direct launches', () => {
|
||||
const fixture = createFixture()
|
||||
for (const conflict of [
|
||||
{
|
||||
scratchDirectory: fixture.workspaceDirectory
|
||||
},
|
||||
{
|
||||
scratchDirectory: resolve(
|
||||
fixture.workspaceDirectory,
|
||||
'scratch'
|
||||
)
|
||||
},
|
||||
{
|
||||
workspaceDirectory: resolve(
|
||||
fixture.bundleDirectory,
|
||||
@@ -167,13 +147,13 @@ describe('OpenCode bubblewrap launch profile', () => {
|
||||
)
|
||||
}
|
||||
]) {
|
||||
expect(() =>
|
||||
expect(
|
||||
createOpenCodeLaunchProfile({
|
||||
...fixture,
|
||||
...conflict,
|
||||
workMode: 'ask'
|
||||
})
|
||||
).toThrow(/must not overlap/iu)
|
||||
).toMatchObject({ workMode: 'ask' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -215,19 +195,10 @@ describe('OpenCode bubblewrap launch profile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expectArgumentSequence(profile.args, [
|
||||
'--ro-bind',
|
||||
agentInstallationDirectory,
|
||||
agentInstallationDirectory
|
||||
])
|
||||
expectArgumentSequence(profile.args, [
|
||||
'--ro-bind',
|
||||
bridgeDirectory,
|
||||
bridgeDirectory
|
||||
])
|
||||
expectArgumentSequence(profile.args, [
|
||||
'--',
|
||||
resolve(agentInstallationDirectory, 'node'),
|
||||
expect(profile.executable).toBe(
|
||||
resolve(agentInstallationDirectory, 'node')
|
||||
)
|
||||
expect(profile.args).toEqual([
|
||||
resolve(agentInstallationDirectory, 'lib', 'agent.cjs'),
|
||||
'model-bridge-helper',
|
||||
'--socket-path',
|
||||
@@ -238,6 +209,8 @@ describe('OpenCode bubblewrap launch profile', () => {
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--work-mode',
|
||||
'ask',
|
||||
'--opencode-entrypoint',
|
||||
resolve(fixture.bundleDirectory, 'bin', 'opencode')
|
||||
])
|
||||
@@ -286,11 +259,6 @@ describe('OpenCode bubblewrap launch profile', () => {
|
||||
'runtimes',
|
||||
'opencode'
|
||||
)
|
||||
const scratchDirectory = resolve(
|
||||
managedRoot,
|
||||
'scratch',
|
||||
'prompt'
|
||||
)
|
||||
const agentInstallationDirectory = resolve(
|
||||
managedRoot,
|
||||
'agent',
|
||||
@@ -324,7 +292,6 @@ describe('OpenCode bubblewrap launch profile', () => {
|
||||
manifest: fixture.manifest,
|
||||
bundleDirectory,
|
||||
workspaceDirectory,
|
||||
scratchDirectory,
|
||||
workMode: 'execute',
|
||||
modelBridge
|
||||
})
|
||||
@@ -332,16 +299,18 @@ describe('OpenCode bubblewrap launch profile', () => {
|
||||
cwd: workspaceDirectory,
|
||||
workMode: 'execute'
|
||||
})
|
||||
expect(() =>
|
||||
expect(
|
||||
createOpenCodeLaunchProfile({
|
||||
manifest: fixture.manifest,
|
||||
bundleDirectory,
|
||||
workspaceDirectory,
|
||||
scratchDirectory,
|
||||
workMode: 'ask',
|
||||
modelBridge
|
||||
})
|
||||
).toThrow(/paths must not overlap/iu)
|
||||
).toMatchObject({
|
||||
cwd: workspaceDirectory,
|
||||
workMode: 'ask'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -349,7 +318,6 @@ function createFixture(): {
|
||||
manifest: RemoteRuntimeBundleManifest
|
||||
bundleDirectory: string
|
||||
workspaceDirectory: string
|
||||
scratchDirectory: string
|
||||
} {
|
||||
const root = resolve(
|
||||
mkdtempSync(join(tmpdir(), 'goodbuddy-opencode-profile-'))
|
||||
@@ -357,7 +325,6 @@ function createFixture(): {
|
||||
temporaryPaths.push(root)
|
||||
const bundleDirectory = resolve(root, 'bundle')
|
||||
const workspaceDirectory = resolve(root, 'workspace')
|
||||
const scratchDirectory = resolve(root, 'scratch')
|
||||
for (const directory of [root, bundleDirectory, workspaceDirectory]) {
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
||||
if (process.platform !== 'win32') {
|
||||
@@ -367,7 +334,6 @@ function createFixture(): {
|
||||
return {
|
||||
bundleDirectory,
|
||||
workspaceDirectory,
|
||||
scratchDirectory,
|
||||
manifest: remoteRuntimeBundleManifestSchema.parse({
|
||||
formatVersion: 2,
|
||||
product: 'GoodBuddy',
|
||||
@@ -433,17 +399,3 @@ function createFixture(): {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function expectArgumentSequence(
|
||||
args: readonly string[],
|
||||
sequence: readonly string[]
|
||||
): void {
|
||||
const start = args.findIndex(
|
||||
(value, index) =>
|
||||
value === sequence[0] &&
|
||||
sequence.every(
|
||||
(candidate, offset) => args[index + offset] === candidate
|
||||
)
|
||||
)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
|
||||
@@ -16,11 +16,17 @@ import {
|
||||
type ModelBridgePolicy
|
||||
} from '../shared/model-bridge-contracts'
|
||||
import {
|
||||
assertAbsoluteManagedPath,
|
||||
ensurePrivateDirectoryTree
|
||||
assertAbsoluteManagedPath
|
||||
} from './managed-paths'
|
||||
|
||||
export const OPENCODE_BWRAP_EXECUTABLE = '/usr/bin/bwrap'
|
||||
const OPEN_CODE_ASK_CONFIG = JSON.stringify({
|
||||
permission: 'ask',
|
||||
agent: {
|
||||
build: {
|
||||
permission: 'ask'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type OpenCodeLaunchProfile = {
|
||||
executable: string
|
||||
@@ -35,7 +41,6 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
manifest: RemoteRuntimeBundleManifest
|
||||
bundleDirectory: string
|
||||
workspaceDirectory: string
|
||||
scratchDirectory: string
|
||||
workMode: 'ask' | 'execute'
|
||||
modelBridge?: {
|
||||
agentExecutablePath: string
|
||||
@@ -113,22 +118,6 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
'Model bridge socket or Agent executable escapes its managed directory'
|
||||
)
|
||||
}
|
||||
const managedPaths = [
|
||||
['Runtime bundle', bundleDirectory],
|
||||
['Workspace', workspaceDirectory],
|
||||
...(modelBridge === undefined
|
||||
? []
|
||||
: [
|
||||
[
|
||||
'Agent installation',
|
||||
modelBridge.agentInstallationDirectory
|
||||
] as const,
|
||||
[
|
||||
'Model bridge',
|
||||
modelBridge.bridgeDirectory
|
||||
] as const
|
||||
])
|
||||
] as const
|
||||
const executablePath = resolve(
|
||||
bundleDirectory,
|
||||
...manifest.entrypoint.path.split('/')
|
||||
@@ -163,117 +152,26 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
modelBridge.policy.model,
|
||||
'--supports-image-input',
|
||||
modelBridge.policy.supportsImageInput ? 'true' : 'false',
|
||||
'--work-mode',
|
||||
input.workMode,
|
||||
'--opencode-entrypoint',
|
||||
executablePath
|
||||
]
|
||||
}
|
||||
|
||||
if (input.workMode === 'execute') {
|
||||
return {
|
||||
...runtimeCommand,
|
||||
processExecutable: runtimeCommand.executable,
|
||||
cwd: workspaceDirectory,
|
||||
env: executeEnvironment(process.env, workspaceDirectory),
|
||||
workMode: 'execute'
|
||||
}
|
||||
}
|
||||
|
||||
const scratchDirectory = assertAbsoluteManagedPath(
|
||||
resolve(input.scratchDirectory)
|
||||
)
|
||||
assertDisjointPaths([
|
||||
...managedPaths,
|
||||
['Runtime scratch', scratchDirectory]
|
||||
])
|
||||
const scratchPaths = {
|
||||
home: join(scratchDirectory, 'home'),
|
||||
temporary: join(scratchDirectory, 'tmp'),
|
||||
cache: join(scratchDirectory, 'xdg-cache'),
|
||||
config: join(scratchDirectory, 'xdg-config'),
|
||||
data: join(scratchDirectory, 'xdg-data'),
|
||||
state: join(scratchDirectory, 'xdg-state')
|
||||
}
|
||||
for (const path of Object.values(scratchPaths)) {
|
||||
ensurePrivateDirectoryTree(path, scratchDirectory)
|
||||
}
|
||||
|
||||
const environment: Readonly<Record<
|
||||
(typeof OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES)[number],
|
||||
string
|
||||
>> = {
|
||||
HOME: scratchPaths.home,
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
PATH: '/usr/bin:/bin',
|
||||
TMPDIR: scratchPaths.temporary,
|
||||
XDG_CACHE_HOME: scratchPaths.cache,
|
||||
XDG_CONFIG_HOME: scratchPaths.config,
|
||||
XDG_DATA_HOME: scratchPaths.data,
|
||||
XDG_STATE_HOME: scratchPaths.state
|
||||
}
|
||||
const args = [
|
||||
'--new-session',
|
||||
'--unshare-all',
|
||||
'--clearenv',
|
||||
'--proc',
|
||||
'/proc',
|
||||
'--dev',
|
||||
'/dev',
|
||||
'--tmpfs',
|
||||
'/run',
|
||||
'--ro-bind',
|
||||
'/usr',
|
||||
'/usr',
|
||||
'--ro-bind-try',
|
||||
'/bin',
|
||||
'/bin',
|
||||
'--ro-bind-try',
|
||||
'/lib',
|
||||
'/lib',
|
||||
'--ro-bind-try',
|
||||
'/lib64',
|
||||
'/lib64',
|
||||
'--ro-bind',
|
||||
bundleDirectory,
|
||||
bundleDirectory,
|
||||
'--ro-bind',
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
'--bind',
|
||||
scratchDirectory,
|
||||
scratchDirectory,
|
||||
...Object.entries(environment).flatMap(([name, value]) => [
|
||||
'--setenv',
|
||||
name,
|
||||
value
|
||||
]),
|
||||
'--chdir',
|
||||
workspaceDirectory,
|
||||
...(modelBridge === undefined
|
||||
? []
|
||||
: [
|
||||
'--ro-bind',
|
||||
modelBridge.agentInstallationDirectory,
|
||||
modelBridge.agentInstallationDirectory,
|
||||
'--ro-bind',
|
||||
modelBridge.bridgeDirectory,
|
||||
modelBridge.bridgeDirectory
|
||||
]),
|
||||
'--',
|
||||
runtimeCommand.executable,
|
||||
...runtimeCommand.args
|
||||
] as const
|
||||
|
||||
const environment =
|
||||
input.workMode === 'ask'
|
||||
? {
|
||||
...executeEnvironment(process.env, workspaceDirectory),
|
||||
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG
|
||||
}
|
||||
: executeEnvironment(process.env, workspaceDirectory)
|
||||
return {
|
||||
executable: OPENCODE_BWRAP_EXECUTABLE,
|
||||
...runtimeCommand,
|
||||
processExecutable: runtimeCommand.executable,
|
||||
args,
|
||||
cwd: scratchDirectory,
|
||||
env: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
LANG: 'C.UTF-8'
|
||||
},
|
||||
workMode: 'ask'
|
||||
cwd: workspaceDirectory,
|
||||
env: environment,
|
||||
workMode: input.workMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,26 +219,6 @@ function usableEnvironmentValue(
|
||||
return value !== undefined && value.length > 0 && !value.includes('\0')
|
||||
}
|
||||
|
||||
function assertDisjointPaths(
|
||||
paths: readonly (readonly [label: string, path: string])[]
|
||||
): void {
|
||||
for (let left = 0; left < paths.length; left += 1) {
|
||||
for (let right = left + 1; right < paths.length; right += 1) {
|
||||
const leftEntry = paths[left]!
|
||||
const rightEntry = paths[right]!
|
||||
if (
|
||||
leftEntry[1] === rightEntry[1] ||
|
||||
isStrictDescendant(leftEntry[1], rightEntry[1]) ||
|
||||
isStrictDescendant(rightEntry[1], leftEntry[1])
|
||||
) {
|
||||
throw new Error(
|
||||
`${leftEntry[0]} and ${rightEntry[0]} paths must not overlap`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isStrictDescendant(parent: string, candidate: string): boolean {
|
||||
const child = relative(parent, candidate)
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
|
||||
describe('OpenCode ACP child event plugin', () => {
|
||||
it('emits valid newline-delimited ACP updates only for owned child sessions', async () => {
|
||||
const write = vi.fn()
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource().replace('export default ', '')})`,
|
||||
{ process: { stdout: { write } } }
|
||||
)
|
||||
const hooks = await factory()
|
||||
const event = {
|
||||
type: 'message.part.updated',
|
||||
properties: {
|
||||
sessionID: 'parent',
|
||||
part: {
|
||||
type: 'tool', tool: 'task', callID: 'task',
|
||||
state: { metadata: { sessionId: 'child' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
await hooks.event({ event })
|
||||
await hooks.event({ event: {
|
||||
type: 'message.part.delta', properties: { sessionID: 'child', delta: 'Inspecting' }
|
||||
} })
|
||||
await hooks.event({ event: {
|
||||
type: 'message.part.delta', properties: { sessionID: 'unrelated', delta: 'Private' }
|
||||
} })
|
||||
expect(write).toHaveBeenCalledTimes(2)
|
||||
for (const [line] of write.mock.calls) {
|
||||
expect(line.endsWith('\n')).toBe(true)
|
||||
expect(JSON.parse(line)).toMatchObject({
|
||||
method: 'session/update',
|
||||
params: { sessionId: 'parent', update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'task',
|
||||
_meta: { goodbuddySubagentEvent: { properties: expect.any(Object) } }
|
||||
} }
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user