Compare commits

..
Author SHA1 Message Date
mesalogo ee804edcae fix: isolate desktop lint from ShareServer configuration
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build darwin arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Exclude the independent prototype from root lint and explicitly anchor TypeScript parser configuration to the Desktop repository. This fixes clean CI runners detecting both root and ShareServer configuration directories. Product behavior and approved release notes are unchanged.
2026-09-06 17:49:44 +08:00
mesalogo 85a4a374fa fix: keep ShareServer prototype tests outside desktop releases
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Exclude the unfinished standalone ShareServer prototype from the Desktop root test suite and remove its CI dependency installation. Preserve its independent test configuration and document the release boundary. Approved Desktop and Agent release notes remain unchanged.
2026-09-06 17:37:37 +08:00
mesalogo 89a81dd07c fix: install ShareServer test dependencies in release CI
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
The root validation suite includes ShareServer tests, but clean CI runners only installed Desktop dependencies and could not resolve react-router-dom. Install the independently locked ShareServer dependencies before running validators. Release content and approved bilingual notes are unchanged.
2026-09-06 17:01:50 +08:00
mesalogo 0eb664c0b6 chore: prepare GoodBuddy 0.12.5 and Agent 0.11.19 releases
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Record approved bilingual release notes and align Desktop and Agent version metadata. Publish Desktop first so users can upgrade before the shared Agent catalog gains macOS arm64 entries. No LoongArch preview is included.
2026-09-06 16:44:46 +08:00
mesalogo 9c6d1611e2 feat: support macOS arm64 remote Agent hosts
Extend GoodBuddy Agent packaging, installation, SSH attachment, Runtime lifecycle, and Desktop package management to macOS arm64. Linux x64, Linux arm64, and Darwin arm64 share the existing signed catalog. Preserve Ask read-only behavior and Execute account permissions, and stop detached tool descendants during Mac cancellation.

Upgrade Desktop before publishing the first mixed-platform catalog because older Linux-only readers reject Darwin entries. Intel Macs are not supported; no launchd service or administrator access is required. This commit does not publish a release.

Validation: 3520 tests passed, 57 skipped; typecheck, lint, and development build passed. Real Mac installation, Ask/Execute, cancellation and reconnect passed; final Linux installation, Ask and reconnect passed. Nine real model requests were recorded.

Release note: 支持将 Apple Silicon Mac 用作远程项目 Host,可安装 macOS arm64 Agent 并使用 Ask、Execute 和断线重连;与 Linux 共用一个包目录,发布混合目录前需升级 Desktop。
2026-09-06 16:14:02 +08:00
mesalogo 1f01c26e04 fix: keep replies streaming while switching windows
Switching away from the desktop window and back could mark a reply that was
still being generated as "上次运行意外中断", freeze its output partway through,
and push the next message into the pending queue even though nothing had
actually failed. The conversation area also shifted on each switch because the
OpenCode runtime controls were unmounted and re-detected on every refresh.

Reading the conversation store no longer treats an in-progress reply as
interrupted; only startup recovery marks a run that an application exit truly
cut off. Runtime feature detection is now keyed on the resolved runtime scope,
so the OpenCode toolbar and manual Compact control stay in place across
refreshes. No request timeout or recovery behavior changed.

Release note: 修复来回切换桌面窗口时,正在生成的回复被误判为"上次运行意外中断"、输出中途停住、之后发送的消息只能进入待发送队列的问题。使用 OpenCode Runtime 时,会话区也不再因重复检测专属功能而抖动。
2026-09-05 16:28:45 +08:00
mesalogo dd8289fdef fix: make local knowledge bases ready for daily use
Knowledge selection was shared globally, document readiness overstated usable content, and important import and source-management paths exposed technical details or lacked safe recovery actions. Knowledge scope now persists per conversation, readiness reflects real processing channels, and the workspace guides users from creation and import into cited chat with controlled source actions.

Release note: 知识库现按对话保存使用范围,准确区分可用、处理中和失败文档,并提供更简洁的创建、检索测试、来源打开、删除确认和失败重试流程。
2026-09-05 15:51:07 +08:00
mesalogo d5fa0ec67c fix: keep long conversation content scrollable
Large messages containing reasoning, tool lists, or long replies could use an estimated off-screen height that left later content outside the reachable scroll range. Conversation messages now participate in layout with their actual height while the existing 80-message render window keeps long histories bounded.

Release note: 修复偶发的长会话滚动条提前到底问题;包含较长推理、工具记录或正文时,现在可以直接滚动查看全部已加载内容,无需切换会话刷新。
2026-09-05 14:51:21 +08:00
mesalogo 6eb73657e0 chore: ignore Python bytecode caches 2026-09-05 14:02:53 +08:00
mesalogo 4041edefa8 feat: add ShareServer console prototype
ShareServer previously existed only as design documents. The repository now includes a standalone Hono service and a responsive React management-console prototype covering administrator and member workflows, with local build, test, and container entry points.

The sharing design now includes organization-scoped digital agents and explicit local partitioned-memory selection for @ calls. Memory documentation consistently uses partitioned memory and treats time or internal events only as optional recall triggers.

This remains an interactive prototype: fixture actions do not persist, and production authentication, PostgreSQL isolation, device connections, routing, relay, and federation still require implementation.

Release note: 新增可独立运行的 ShareServer 管理控制台交互原型,并完善组织数字人与分区记忆共享设计;当前原型数据和操作不会写入生产系统。
2026-09-05 14:01:20 +08:00
145 changed files with 11675 additions and 653 deletions
+35 -14
View File
@@ -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"
+54 -14
View File
@@ -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"
+2
View File
@@ -8,6 +8,8 @@ coverage/
.agent-resources/
*.log
*.tsbuildinfo
__pycache__/
*.py[cod]
.env
.env.*
!.env.example
+20 -4
View File
@@ -145,7 +145,7 @@ npm run dist:linux:arm64
组件,缺少远端包也不能阻塞普通桌面打包或发布。
“设置 > 平台功能 > 远程项目(技术预览)”列出用户数据目录中已经下载或导入的
Linux x64/arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
Linux x64、Linux arm64 和 macOS arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
小型签名目录,显示本地版本、在线最新兼容版本以及 `有更新 / 已是最新 / 未下载 /
校验失败`;此检查不会下载 `.gbagent`。只有用户点击“下载”或“更新到”后才传输包,
用户也可导入或导出 `.gbagent` 离线包。
@@ -254,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` /
+19 -9
View File
@@ -60,8 +60,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
@@ -123,12 +123,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
@@ -148,9 +152,11 @@ otherwise.
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
database. Successful tool START/END events appear exactly once, with no
Prompt, provider, or tool replay observed. The current Agent source lock is
`0.11.18`, while the current Desktop release candidate is `0.12.4`; formal
`0.11.19`, while the current Desktop release candidate is `0.12.5`; formal
publication status follows the separate Agent and Desktop
release channels.
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
@@ -353,7 +359,11 @@ 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
@@ -432,8 +442,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
+3 -3
View File
@@ -29,7 +29,7 @@
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 同时使用 OpenCode Ask 权限配置,并在 Agent 工具分发边界只允许原生读取审批;Execute 不注入该权限覆盖,使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v5 持有 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-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.18`,Desktop 发布候选为 `0.12.4`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
- [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。
@@ -56,7 +56,7 @@
- [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 接口并用于语义检索。
@@ -74,7 +74,7 @@
- [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、费用、失败率和成果差异。
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
+5 -2
View File
@@ -609,6 +609,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
- 消息流的滚动范围必须使用已渲染消息的真实高度。长会话可以按批次只挂载最近消息并由用户
加载更早内容,但不得用固定的消息占位高度替代实际布局,避免推理、工具列表或长正文被
排除在可滚动范围之外。
### 13.2 最近对话
@@ -640,7 +643,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 状态卡片使用统一状态令牌,不只依赖颜色。
- 保留“运行概览 / 待处理建议 / 报告与记录 / 心跳计划”四个同级页面。
- 智能心跳菜单入口是完整配置的权威位置;任务中心和设置中心不得复制同一 CRUD 表单。
- “未来分区记忆”仅为长期方向,数据、状态和页面尚未设计,不得显示占位入口。
- 分区记忆协同与唤起条件尚未实现,不得显示占位入口或平行的记忆类型。
### 13.5 Task Center
@@ -700,7 +703,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
更新,保存失败时保留最后确认状态。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- “平台功能”使用共享 `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 不丢弃草稿,关闭设置中心前必须先保存或撤销。
+7 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.18",
"agentVersion": "0.11.19",
"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",
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+50 -3
View File
@@ -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
View File
@@ -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')
+39 -39
View File
@@ -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 树或独立导航。
+15 -6
View File
@@ -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
+2 -2
View File
@@ -11,8 +11,8 @@
## 1. 背景
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。其长期“未来分区记忆”
方向尚未设计,也不承担持续学习、模式挖掘或自动改进:
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。它与分区记忆的协同尚未
实施,也不承担持续学习、模式挖掘或自动改进:
- 候选是否改善未来行为没有评估。
- 一条反思是否会被检索和使用并不确定。
+7 -3
View File
@@ -1,7 +1,8 @@
# 知识库
知识库负责资料导入、解析、分块、索引、检索、图谱和带来源的知识问答,也负责将
Dify、FastGPT 和 RAGFlow 中已有的知识库接入同一检索范围。
知识库已提供本地资料导入、解析、分块、索引、检索、图谱和带来源的知识问答。
将 Dify、FastGPT 和 RAGFlow 中已有知识库接入同一检索范围仍处于规划阶段,不属于
当前已提供能力。
## 文档导航
@@ -9,6 +10,8 @@ Dify、FastGPT 和 RAGFlow 中已有的知识库接入同一检索范围。
| --- | --- |
| [知识库检索与分块增强 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) | 外部实例、知识库绑定、检索范围和产品验收 |
@@ -27,4 +30,5 @@ Dify、FastGPT 和 RAGFlow 中已有的知识库接入同一检索范围。
或 Agent,也不在外部实例中创建、修改、上传或删除知识库内容。远端资料不会批量同步
或进入本地索引;实际用于回答的有界片段会随引用保存在本地会话中。
当前尚未拆出覆盖整个知识库功能家族的统一逻辑、UI 设计和功能进度文档。
本目录尚未拆出统一逻辑设计;当前本地知识库状态规则由 PRD、User Stories 和实现契约
共同约束。外部知识库继续使用独立逻辑设计,直至进入实施阶段。
@@ -162,7 +162,7 @@ GoodBuddy 当前已经具备:
引用和脱敏诊断。
- Agent Runtime 的选择或切换不改变当前向量连接;向量连接的选择或切换也不改变当前
Agent Runtime。
- 未来记忆、笔记或其他本地语义检索若要复用向量连接,必须经过独立产品设计、权限与
- 分区记忆、笔记或其他本地语义检索若要复用向量连接,必须经过独立产品设计、权限与
数据路径评审后显式接入,不能因存在全局向量连接而自动获得使用权。
---
+25 -6
View File
@@ -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 不得提交任意路径。移除来源必须先说明将删除的文档、索引、图谱证据及是否包含
应用托管副本,用户确认后才执行。
降级事件包括:
- 未配置向量模型。
+35
View File
@@ -0,0 +1,35 @@
# 知识库实施进度
## 已验证实现
截至 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`
导入、解析、分块和全文检索。
## 剩余工作
- 外部 Dify、FastGPT 和 RAGFlow 知识库接入仍为规划中能力。
- 扫描 PDF 的真实 OCR 验收依赖用户已安装并校验的 OCR 模型;本次 DOCX/PDF 回归覆盖
原生文本 PDF,不宣称覆盖扫描件 OCR。
- PDF.js 在测试环境会提示未配置 `standardFontDataUrl`;文本、页码定位与检索结果不受
影响。
+65
View File
@@ -0,0 +1,65 @@
# 知识库 UI 设计
## 1. 设计目标
知识库优先帮助普通用户完成“添加资料并可靠提问”,高级 RAG 调试和图谱维护不得阻塞
首次使用。全局视觉、页面壳层、通知、焦点和危险操作遵循
[UI-DESIGN.md](../../../UI-DESIGN.md)。
## 2. 信息架构
```text
知识库
├─ 知识库列表
└─ 当前知识库
├─ 文档与来源
├─ 知识图谱(启用后显示)
├─ 任务中心
└─ 高级设置
```
标题区提供“用于当前对话”“检索测试”、编辑和删除。新建知识库是页面主操作。
“高级设置”承载问答效果入口、向量索引、分块与图谱配置,不把工程参数放在首次路径。
## 3. 首次使用
没有知识库时,页面空状态显示唯一的“新建知识库”主操作。创建表单默认只展示名称和
描述;存储方式与知识图谱放入折叠的“高级设置”。创建完成后进入“文档与来源”,用户
可以导入文件、文件夹或 URL。
文档处理完成后,标题区“用于当前对话”可用。该操作把当前知识库加入当前
Conversation 的检索范围,返回对话并聚焦输入框。
## 4. 状态
知识库列表显示“可用”和“需处理”文档数。详情标题持续显示可检索、处理中、需处理和
总文档数,即使用户填写了知识库描述也不隐藏状态。
文档行显示:
- 总体处理状态。
- 全文状态。
- 向量状态。
- 启用图谱时的图谱状态。
失败文档保留错误信息和“重新处理”。只要仍有可用分块,就允许打开分块管理,避免隐藏
上一版可用索引。
## 5. 问答效果与高级参数
“检索测试”默认只突出真实问题、测试按钮和结果。高级参数使用折叠区,包含候选倍数、
通道权重、最低相似度、重排、结果数和上下文预算。“高级设置 → 问答效果”打开同一
工作台。
向量模型未配置时显示“前往设置”,直接进入模型连接,不使用只有说明文字的死路状态。
## 6. 来源操作
文档行提供“打开来源”,Main 根据知识库和文档归属重新读取保存的文件路径或 URL。
移除来源使用确认对话框,说明文档数、索引、图谱证据和托管副本影响。初始焦点位于
取消,提交期间禁止重复操作,失败时保留对话框和错误。
## 7. 对话范围
知识库多选属于 Conversation。新对话默认空范围;切换对话恢复各自选择。创建新知识库
只加入当前 Conversation,不重新启用其他已取消项。发送请求时冻结当前选择和检索方式。
@@ -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 固定分块
+12 -11
View File
@@ -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 的消息或记忆。
- [ ] 每条非手动记忆都有可追溯来源。
@@ -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。
+49 -2
View File
@@ -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.18`,Desktop 候选为 `0.12.4`,等待各自独立发布审批。
Agent 源码 lock 为 `0.11.19`,Desktop 候选为 `0.12.5`,等待各自独立发布审批。
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
@@ -351,10 +351,57 @@ 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-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.18`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
当前源码 lock 固定为 `0.11.19`;正式发布状态以独立 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
+4 -1
View File
@@ -14,10 +14,13 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不
| [功能逻辑](./logic-design.md) | 组织隔离、设备、发布、策略、任务和联邦状态规则 |
| [管理界面设计](./ui-design.md) | ShareServer Web 控制台的信息架构与交互 |
| [技术设计](./technical-design.md) | 服务模块、API、数据、部署、运维和验证 |
| [实施进度](./progress.md) | 已验证实现、剩余生产工作与验证证据 |
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
当前没有单独的 User Stories:管理员、能力发布者、审计员和普通成员场景已进入 PRD 与逻辑
设计。服务尚未实施,因此不创建 `progress.md`。
设计。仓库已建立可运行的全控制台交互原型;它用于确认信息架构与交互,不代表生产服务已经
实现。当前边界和运行方法见[实施进度](./progress.md)与
[`shareserver/README.md`](../../../shareserver/README.md)。
## 角色
+15 -5
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 版本 | 0.2 |
| 日期 | 2026-09-05 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 共享规则 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
@@ -53,6 +53,14 @@ ShareServer 只路由调用方冻结的 provider,不在离线、繁忙或失
初始化只在实例尚无系统管理员时可用。首个系统管理员创建成功后,后续请求即使持有旧的
一次性引导材料也不能再次进入初始化流程。
### INV-10 数字人发布不等于记忆公开
发布数字人只建立一个有界 Agent Publication。它默认不取得所有者任何本地记忆;所有者可以
在发布时勾选 Agent、Global、指定 Project 和指定 Automation 分区,不能开放 Conversation
和 Run 分区。ShareServer 和调用者只能看到允许分区种类与“本次调用会使用本地记忆”的政策
事实,不能读取原始条目、分区内容、来源正文或检索结果。新增可读分区按权限扩大重新审批,
移除分区可以立即收紧。
## 2. 主要状态
### 2.1 成员
@@ -120,7 +128,7 @@ active <-> paused
1. 验证请求身份、会话 audience 和 organization。
2. 验证用户、设备、provider、Publication 和 FederationTrust 当前状态。
3. 校验能力版本、动作、输入 schema、数据类别、大小和委托链。
3. 校验能力或数字人版本、动作、输入 schema、数据类别、大小和委托链。
4. 收集调用方组织、提供方组织、Publication 和 Grant 规则。
5. 按拒绝优先合并,计算 `deny/require_approval/allow`。
6. 校验次数、并发、预算和有效期。
@@ -137,6 +145,7 @@ active <-> paused
| 成员与组 | 紧急管理 | 是 | 否 | 否 | 只读 | 自己 |
| 注册设备 | 只读/撤销 | 是 | 自己 | 否 | 只读 | 自己 |
| 发布能力/包 | 否 | 是 | 授权范围 | 否 | 只读 | 否 |
| 发布自己的数字人 | 否 | 是 | 授权范围 | 否 | 只读 | 自己 |
| 编辑策略 | 否 | 是 | 否 | 否 | 只读 | 否 |
| 处理审批 | 否 | 可配置 | 否 | 授权范围 | 只读 | 自己的申请 |
| 联邦关系 | 实例级许可 | 是 | 否 | 可共同审批 | 只读 | 否 |
@@ -201,9 +210,10 @@ active <-> paused
| `FR-10` | 组织隔离、删除撤销与保留规则 |
| `FR-11` | 组织角色边界及各对象状态;展示行为由 UI 设计定义 |
| `FR-12` | 短期权威恢复规则和逻辑完整性;部署结构由技术设计定义 |
| `FR-13` | 注册、Publication、目录与授权不变量,provider 冻结及 `INV-10` |
## 10. 逻辑完整性
本设计覆盖实例、组织、成员、设备、发布、授权、审批、任务、更新、联邦、撤销和恢复。实现
前仍需确定首个 OIDC 提供者矩阵、默认审计保留期、部署规模目标和中继带宽上限;这些部署
本设计覆盖实例、组织、成员、设备、能力与数字人发布、授权、审批、任务、更新、联邦、撤销
和恢复。实现前仍需确定首个 OIDC 提供者矩阵、默认审计保留期、部署规模目标和中继带宽上限;这些部署
参数应可配置,不改变拒绝优先、双边联邦和 provider 冻结规则。
+51 -5
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 版本 | 0.2 |
| 日期 | 2026-09-05 |
| 适用产品 | 独立部署的 ShareServer 与 Web 管理控制台 |
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
| 关联文档 | [功能逻辑](./logic-design.md)、[管理界面](./ui-design.md)、[技术设计](./technical-design.md) |
@@ -25,6 +25,7 @@
4. 设备离线、用户撤销或策略变化能准确影响新任务,不破坏历史证据。
5. 两个组织可经双边确认建立有限范围的联邦协作。
6. ShareServer 可以只运行控制面,也可按部署策略提供数据中继和包存储。
7. 成员可以显式开放自己的数字人,获准的组织成员可通过 `@数字人` 发起有界协作任务。
## 3. 产品范围
@@ -37,6 +38,7 @@
- 设备出站控制通道、任务路由、取消和可选中继。
- 签名能力包仓库、发布通道、更新策略和撤销。
- ShareServer 间双边联邦。
- 组织数字人目录、数字人 Publication、授权发现和 `@数字人` 调用。
- 审计、用量、保留和导出。
- Web 管理控制台和成员自助页面。
@@ -46,6 +48,8 @@
- 发现未注册的局域网设备。
- 保存或分发用户 API Key、Cookie 和桌面登录状态。
- 默认查看任务正文、附件和模型输出。
- 上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆。
- 因为成员加入组织而自动开放其数字人、记忆、Runtime、工作区或工具。
- 未经提供者发布自动盘点设备能力。
- 自动信任同域邮箱、同一网络或其他 ShareServer。
- 公共开放市场、计费结算或匿名能力调用。
@@ -79,6 +83,8 @@
- 目录按调用者身份、设备状态、组织和策略过滤后返回。
- 支持同一能力多个版本和多个 provider,不掩盖来源。
- provider 离线时保留目录事实但显示不可用,不派发新任务。
- 数字人作为由具体设备或服务节点提供的复合 Agent 能力进入目录,持续显示所有者、提供者、
运行位置和可调用状态,不伪装成 ShareServer 自身能力。
### FR-5 授权策略
@@ -89,7 +95,7 @@
### FR-6 审批
- 调用、能力发布、权限扩大更新和联邦关系可以要求审批。
- 调用、能力发布、数字人上线或范围扩大、权限扩大更新和联邦关系可以要求审批。
- 审批项显示申请人、对象、影响范围、数据类别、期限和策略依据。
- 批准结果有明确范围和有效期,不提供无边界“永久允许”。
- 申请内容变化后旧审批失效。
@@ -125,9 +131,10 @@
### FR-11 管理控制台
- 提供概览、组织与成员、设备、能力目录、权限策略、审批、发布与更新、网关联邦、任务与
中继、审计、系统设置。
- 提供概览、组织与成员、设备、能力目录、数字人、权限策略、审批、发布与更新、网关联邦、
任务与中继、审计、系统设置。
- 普通成员只看到自己的设备、授权和待办;管理员栏目按权限隐藏并在 API 再次校验。
- 普通成员可以管理自己发布的数字人,并发现和 `@` 当前组织内获准调用的数字人。
- 所有高影响操作说明对象、范围和后果。
### FR-12 可部署性
@@ -137,6 +144,38 @@
- 提供健康、就绪、指标、结构化日志、备份和恢复说明。
- 服务升级不能要求同时升级所有在线桌面端;按协议版本协商兼容集合。
### FR-13 数字人发布与组织内 `@` 调用
- “数字人”是运行在成员设备或组织服务节点上的可调用 Agent Publication。一个成员可以按
不同用途发布零个或多个数字人,每个数字人独立配置 handle、能力、提供者和记忆来源;
加入组织、注册设备或创建本地 Agent 都不会自动发布。
- 发布时必须明确数字人的组织内唯一 `@handle`、显示名、说明、版本、提供者、支持的输入/
输出类型、可用时间、并发、有效期、目标用户或用户组,以及
`discover/inspect/invoke` 动作。`@数字人` 是 `invoke` 的界面入口,不绕过 Grant 或审批。
- 数字人目录持续显示所有者、来源组织、执行设备或服务节点、Runtime 类型、版本、在线状态、
数据策略和最近可用性;重名显示名不能掩盖唯一 handle 和实际提供者。
- 获准成员在组织会话或任务输入中通过显式选择器插入 `@数字人`。发送前必须显示实际接收者、
执行位置、将发送的数据类别、附件、路由、中继可读性、有效授权和可能的下游委托。
- 一次 `@数字人` 创建绑定所选数字人 Publication、版本和 provider 的远程 Task。数字人离线
或拒绝时不得改发给同名数字人或其他 provider;网络中断后的终态遵守 `FR-7`。
- 数字人默认无权读取所有者的其他本地记忆。发布时,所有者可以勾选该数字人的 Agent 分区、
Global 分区、指定 Project 分区和指定 Automation 分区;首期不能开放短期的 Conversation
和 Run 分区。一次调用只在 Publication 冻结的允许分区中读取当前有效、已确认且敏感度
允许的记忆。
- 记忆检索和上下文组装始终在 Provider 本地完成。ShareServer 只保存 Publication 的分区
种类、经过验证的本地关联 ID 摘要和是否使用专属记忆等策略元数据;原始记忆、分区内容、
来源正文和检索结果不上传 ShareServer,也不向调用者展示。
- 数字人输出可能受到其 Agent 记忆影响,因此发布页和调用确认必须显示“使用数字人专属记忆”
这一事实及允许的数据敏感度。Restricted 记忆不得用于远端调用;暂停或撤销数字人后停止
新调用,但保留历史任务和有界审计。
- Provider 在执行边界重新验证 Publication、Grant、输入 schema、数据类别、大小、期限和
本地 Agent 记忆权限。ShareServer 的允许决定不能扩大数字人所在设备的本地权限。
- 所有者可以暂停、恢复、更新或撤销自己的数字人。handle 或能力、数据、工具、委托发生
变化,或新增可读记忆分区时,旧批准失效并重新进入审批;移除可读分区可以立即收紧而不
等待新批准,普通文案修改不得改变既有权限。
- 审计默认只记录调用方、数字人、Publication、provider、数据类别、路由、授权、状态、耗时
和大小,不保存消息正文、附件内容、模型输出或记忆内容。
## 5. 产品验收
- 新实例可完成初始化、登录、创建组织、邀请成员和注册真实桌面设备。
@@ -146,4 +185,11 @@
- 任务中继关闭时控制面仍可运行;中继开启时可取消并形成准确终态。
- 权限扩大的包更新不能自动部署。
- 两个独立 ShareServer 能建立、使用、暂停和撤销双边联邦。
- 成员可以显式发布一个数字人,获准的同组织成员能从选择器插入 `@handle` 并完成一次有界
调用;未获准成员不能通过目录、搜索、补全或直接 API 枚举和调用该数字人。
- 暂停或撤销数字人后不能创建新调用,已完成任务与有界审计仍可追溯到原 Publication 和
provider。
- 数字人调用可以使用所有者在发布时勾选的 Agent、Global、指定 Project 或指定 Automation
分区,但不能开放 Conversation 和 Run;ShareServer 和调用者不能读取原始记忆、未选择的
本地分区或记忆来源正文。
- 备份恢复后组织、策略、发布和审计身份保持一致,短期连接和令牌不恢复。
+71
View File
@@ -0,0 +1,71 @@
# ShareServer 实施进度
## 当前状态
ShareServer 已进入可运行交互原型阶段。当前实现用于验证完整控制台信息架构、视觉方向与操作
流程,不提供生产身份、组织隔离、设备连接、授权、任务路由或数据持久化。
### 2026-09-06:桌面发布与测试边界
ShareServer 功能尚未完成,不属于本次 Desktop `0.12.5` / Agent `0.11.19`
的产品交付或发布验收范围。用户确认本次发布不需要单独测试 ShareServer,
其原型测试通过也不代表生产功能已完成。
桌面端根测试和 lint 配置排除 `shareserver/**`,桌面发布 CI 不为其单独安装测试依赖。
ShareServer 独立项目自身的测试配置保留,供后续功能开发使用;本次不运行其独立测试。
此前候选 `89a81dd` 临时加入的 `npm ci --prefix shareserver` 步骤已撤除。
ShareServer 的实现与验证在后续功能开发中单独推进;真实 Desktop 集成验收仍属于
下文的剩余生产工作。
## 已验证实现
### 2026-09-05:全控制台交互原型
- `shareserver/` 是独立 Node.js/TypeScript 项目,不依赖 Electron Main 或
`src/shared`。
- Hono 同源提供健康、就绪、原型快照和无持久化操作确认 API。
- 管理控制台覆盖概览、组织与成员、设备、能力目录、权限策略、审批、发布与更新、网关联邦、
任务与中继、审计和系统设置。
- 成员自助覆盖自己的设备、授权和申请;角色切换后导航和 URL 范围同步变化。
- 控制台支持深浅主题、组织范围、全局搜索、列表搜索、状态筛选、详情、表单、确认、成功/
失败反馈、响应式侧栏和键盘对话框。
- 构建后的 Web 由 Hono 同源托管;`Dockerfile` 使用多阶段构建和非 root 运行用户。
- readiness 明确返回 `interactive-prototype` 与 `productionReady=false`,避免把 fixture
误认为生产服务。
验证证据:
```text
cd shareserver
npm test # 2 files, 5 tests passed
npm run lint # passed
npm run build # typecheck + Vite Web + server TypeScript passed
```
浏览器实际验收:
- 生产构建在 `http://127.0.0.1:8787` 启动并成功加载 API 快照。
- 宽屏管理员概览、审批详情、批准确认和原型 API 提交流程通过。
- `390 × 844` 成员自助页面、筛选区和列表布局通过。
- 深色与浅色审批页的 WCAG 2 A/AA 自动检查均为 0 个确定违规;半透明侧栏被检查器列为
需人工判断项,已通过实际截图复核文字可读性。
- 仓库级 `npm test` 通过 325 个测试文件、3501 个测试,另有 8 个文件和 55 个测试按既有
条件跳过;仓库级 `npm run typecheck` 与 `npm run lint` 通过。
## 剩余生产工作
按技术设计从以下纵向切片开始,不能把原型 fixture 逐步演变为权威状态:
1. FR-1 一次性初始化、本地管理员身份、Web session 和 CSRF。
2. PostgreSQL migration、实例/组织/成员表、强制 organization repository 作用域和事务审计。
3. 真实组织与成员管理 API,再将对应原型页面切换到权威数据。
4. 设备挑战注册、凭据和出站 WebSocket 连接。
5. Publication、目录、拒绝优先策略、审批和任务路由。
6. Package、可选中继、联邦、保留、导出、备份恢复和真实 Desktop 端到端验证。
当前尚未满足 PRD 的产品验收,也未进行真实 Desktop/ShareServer 集成测试。
2026-09-05 PRD `FR-13` 新增组织数字人发布和 `@数字人` 调用。当前交互原型尚无“数字人”
目录、发布管理、提及选择器或 Agent 记忆边界展示,因此这些能力仍属于剩余生产与原型工作,
不能由现有通用能力目录或原型操作确认替代。
+32 -5
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 版本 | 0.2 |
| 日期 | 2026-09-05 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
@@ -38,8 +38,11 @@ shareserver/
tests/
```
当前只提交设计文档,不创建无法运行的目录占位。服务端和 Desktop 共同依赖未来的
`packages/share-protocol`,ShareServer 不导入 Electron 的 `src/shared`。
仓库已创建可独立运行的 `shareserver/` 全控制台交互原型。原型使用 Hono 提供健康、就绪、
界面快照和无持久化操作确认 API,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的
`src/shared`。当前尚未建立生产数据库、认证、设备连接和共享协议,因此也不提前创建空的
`packages/share-protocol`。进入真实 Desktop 协议实现时,服务端和 Desktop 共同依赖
`packages/share-protocol`,ShareServer 仍不得导入 Electron 的 `src/shared`。
## 2. 技术选型约束
@@ -57,7 +60,7 @@ shareserver/
| `auth` | 本地身份、OIDC、Web session、服务和设备凭据 |
| `organizations` | 组织、成员、组、角色和范围解析 |
| `devices` | 注册挑战、设备记录、出站连接和在线状态 |
| `capabilities` | manifest、provider、Publication 和目录投影 |
| `capabilities` | manifest、数字人 profile、provider、Publication 和目录投影 |
| `policies` | Grant、强制策略和确定性授权计算 |
| `approvals` | 内容绑定申请、决定、过期和通知 |
| `tasks` | task/attempt、provider 路由、状态和取消 |
@@ -104,6 +107,7 @@ device_credentials
device_connections
capabilities
capability_versions
agent_profiles
providers
publications
grants
@@ -130,6 +134,13 @@ revocations
- 密钥保存为单向摘要或经外部主密钥加密的密文;数据库不保存明文 token。
- 在线连接和短期 nonce 主要在内存;表中只保存恢复所需元数据,不把 socket 当持久对象。
- task event 只存控制状态和有界统计;正文流不进入普通关系表。
- `agent_profiles` 只保存组织内唯一 handle、显示元数据和其能力版本引用。数字人复用
Publication、Grant、Approval 和 Task,不创建第二套授权或路由模型;本地 Agent 记忆条目、
分区目录和来源正文不进入 ShareServer 数据库。
- 数字人 Publication revision 保存有界 `memory_source_policy`,只允许 `agent`、`global`、
`project` 和 `automation` 种类及 Provider 验证后的本地关联 ID 摘要。Provider 执行时按该
revision 在本地重新解析分区并检索;服务端不接收记忆文本、来源正文、Embedding 或检索
结果。新增来源是权限扩大,移除来源是单向收紧。
## 6. 事务与一致性
@@ -240,3 +251,19 @@ PostgreSQL
- 生产 TLS、OIDC 和对象存储组合的端到端验证。
设计完成不代表服务已实现;只有独立部署和真实 Desktop 端到端路径通过后才能更新实施状态。
## 15. 当前原型实现
2026-09-05 建立的交互原型固定以下技术基线:
- API:Hono + `@hono/node-server`,保留 `/api/v1/public/*` 与
`/api/v1/web/*` audience 路径。
- Web:React、Vite、TypeScript、Tailwind CSS 和 Radix primitives,按 shadcn/ui 组件模式
维护本地可修改组件。
- 路由与范围:管理员和成员自助使用不同可见导航;当前组织持续显示。
- 部署:生产构建由同源 Hono 服务托管,并提供非 root 多阶段 Docker 镜像。
`/api/v1/public/ready` 返回 `mode=interactive-prototype` 和
`productionReady=false`。原型 fixture、角色切换和操作确认只用于评审信息架构,不得被
Desktop 或部署脚本当作真实身份、策略、任务或审计 API。下一实施阶段必须从 FR-1
初始化、本地身份、PostgreSQL 组织作用域和事务审计开始,不在 fixture 上叠加生产状态。
+43 -2
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 版本 | 0.2 |
| 日期 | 2026-09-05 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
@@ -33,6 +33,7 @@ ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现
组织与成员
设备
能力目录
数字人
权限策略
审批
发布与更新
@@ -90,6 +91,34 @@ GPU-Worker-02 AI Team Linux 离线 8 2 小时前
详情包含 manifest、provider、Publication、允许输入、数据政策、调用趋势和最近错误。管理员
看到的是组织元数据,不通过目录读取能力内部数据。
### 6.1 数字人
数字人使用独立栏目呈现,不混入普通工具能力表。目录按 `@handle`、显示名、所有者、专业
说明、提供者、Runtime、版本、在线状态和可用范围筛选;只有当前成员可发现的数字人进入
搜索和提及补全。
数字人详情显示:
- 所有者、来源组织、执行设备或服务节点。
- Publication 版本、支持的输入/输出、可用时间、并发和有效期。
- 数据去向、路由、中继可读性、是否允许委托。
- 是否使用本地记忆、允许的分区种类,以及允许处理的数据敏感度;不显示记忆条目、具体分区
名称、来源正文和检索结果。
- 当前成员的 `discover/inspect/invoke` 权限、命中策略和审批要求。
成员可以在“我的数字人”中创建多个用途不同的发布草稿,选择一个本地 Agent、设置唯一
`@handle` 和开放范围、提交审批、暂停、恢复、更新或撤销。加入组织不会生成默认数字人,
创建本地 Agent 也不会自动发布。
发布表单的“可用记忆”默认全部关闭,并允许勾选数字人的 Agent、Global、指定 Project 和
指定 Automation 分区。Conversation 和 Run 不作为可选项。表单持续说明记忆只在提供者本地
检索,调用者和 ShareServer 看不到内容;新增分区在保存前明确标为权限扩大,移除分区明确
标为立即收紧。
组织会话和任务输入区使用带搜索的显式选择器插入 `@数字人`,不能只靠自由文本猜测 handle。
选择后持续显示目标数字人;发送前确认实际接收者、执行位置、发送数据和授权。离线、无权或
需要审批的项保持可读并说明原因,但不能形成已发送调用。
## 7. 权限策略
策略页分“强制策略”和“Grant”。创建和编辑使用结构化表单:主体、能力、动作、输入、提供者、
@@ -157,3 +186,15 @@ GPU-Worker-02 AI Team Linux 离线 8 2 小时前
- 窄屏将表格转为字段列表,保持组织、来源、状态和风险信息,不把管理动作挤成不可辨图标。
- 所有时间显示实例时区并可查看 ISO 时间;联邦事件同时显示对端时间时标明时区。
- 状态不只依赖颜色。危险操作在确认后保持原页面上下文和可恢复错误。
## 15. 交互原型视觉基线
2026-09-05 的首个可运行原型采用深海科技控制台方向,并同时提供浅色主题。视觉实现遵循
shadcn/ui 的本地组件模式和 Radix 可访问 primitives,布局参考成熟后台的侧栏、顶栏、数据
表格和详情对话框,但不复制第三方示例业务页面。
原型已覆盖数字人需求加入前的一级栏目、管理员与成员自助视图切换、组织范围、搜索筛选、
页签、详情、创建表单、危险确认和 390px 窄屏导航。动态发光、网格和网络脉冲只用于运行状态
与品牌氛围;业务表格仍保持克制,状态继续同时使用图标与文字。原型操作会调用无持久化确认
API,并持续标明不会写入生产数据,不能据此宣称真实审批、撤销、策略或任务操作已实现。
`FR-13` 新增的数字人目录、“我的数字人”和 `@` 选择器尚未进入当前原型。
+19 -19
View File
@@ -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] 加载失败、空状态、字段错误和危险操作满足统一设计与无障碍要求。
+3 -3
View File
@@ -169,8 +169,8 @@ GoodBuddy 应能够:
- 当前智能心跳继续提供周期回顾、报告、记忆建议、行动建议和运行历史。
- “智能心跳”菜单入口负责完整配置,并支持 Global 或指定一个、多个 Project。
- 任务中心和设置中心不复制智能心跳 CRUD。
- 智能心跳长期方向是“未来分区记忆”,但数据结构、唤起模型、生命周期、页面以及与任务和
长期记忆的关系尚未设计,不能提前实现。
- 智能心跳长期按分区回顾信息并提出记忆候选或唤起建议,但协同规则、唤起条件、生命周期、
页面以及与任务的关系尚未设计,不能提前实现平行的记忆类型或页面。
### 4.6 成果和预览
@@ -355,7 +355,7 @@ GoodBuddy 应能够:
- 用户删除后不再检索到。
- 每条记忆显示来源与作用域。
- 智能心跳配置明确属于 Global 或指定 Project,当前报告和建议继续沿用已有生命周期。
- 未来分区记忆完成独立设计前,不新增相关数据、页面或任务转换。
- 分区记忆协同和唤起条件完成设计前,不新增相关数据、页面或任务转换。
### 8.5 安全
+9 -1
View File
@@ -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: {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.12.4",
"version": "0.12.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.12.4",
"version": "0.12.5",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.12.4",
"version": "0.12.5",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+4
View File
@@ -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=="
+40
View File
@@ -1,6 +1,46 @@
{
"formatVersion": 1,
"releases": [
{
"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",
+5
View File
@@ -0,0 +1,5 @@
build-server
dist
node_modules
*.log
*.png
+4
View File
@@ -0,0 +1,4 @@
build-server/
dist/
node_modules/
*.png
+24
View File
@@ -0,0 +1,24 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
ENV NODE_ENV=production
ENV SHARESERVER_PORT=8787
ENV SHARESERVER_HOST=0.0.0.0
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/build-server ./build-server
USER node
EXPOSE 8787
CMD ["node", "build-server/server/index.js"]
+85
View File
@@ -0,0 +1,85 @@
# ShareServer interactive prototype
This directory contains the first runnable ShareServer project. It is an
interactive control-console prototype based on the
[ShareServer PRD](../docs/features/share-server/prd.md), not a production
ShareServer implementation.
## Included
- Hono HTTP service with health, readiness, prototype snapshot, and prototype
action endpoints.
- Same-origin React, Vite, TypeScript, Tailwind CSS, and shadcn/ui-style Web
console.
- Administrator views for overview, members, devices, capabilities, policies,
approvals, releases, federation, tasks, audit, and settings.
- Member self-service views for personal devices, grants, and requests.
- Responsive navigation, dark and light themes, search, filters, details,
forms, confirmations, and keyboard-accessible dialogs.
- Production build and a non-root container image.
The UI uses official shadcn/ui conventions and Radix primitives. Its information
architecture was informed by the MIT-licensed
[`satnaing/shadcn-admin`](https://github.com/satnaing/shadcn-admin), but this
project does not copy that template or include its Clerk integration and sample
business pages.
## Not included yet
Prototype records are deliberately read-only fixtures. The action endpoint
returns an acknowledgement but does not persist changes. PostgreSQL,
initialization and login, organization isolation, real authorization, device
WebSocket connections, task routing, relay, package storage, federation, and
auditable mutations remain production implementation work.
The readiness endpoint reports this explicitly:
```json
{
"status": "ready",
"mode": "interactive-prototype",
"productionReady": false
}
```
## Run locally
```text
cd shareserver
npm install
npm run dev
```
Open `http://127.0.0.1:5174`. Vite proxies `/api` to the Hono service on port
`8787`.
## Run the production build
```text
cd shareserver
npm run build
npm start
```
Open `http://127.0.0.1:8787`.
Set `SHARESERVER_PORT` to use another listen port. Local runs bind to
`127.0.0.1` by default; set `SHARESERVER_HOST` explicitly when a trusted reverse
proxy or container needs another interface. Do not expose the prototype as a
production service.
## Container
```text
docker build -t goodbuddy-shareserver-prototype ./shareserver
docker run --rm -p 8787:8787 goodbuddy-shareserver-prototype
```
## Validation
```text
npm test
npm run typecheck
npm run lint
npm run build
```
+33
View File
@@ -0,0 +1,33 @@
import js from '@eslint/js'
import reactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{
ignores: ['build-server/**', 'dist/**', 'node_modules/**']
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.vitest
}
},
plugins: {
'react-hooks': reactHooks
},
rules: {
...reactHooks.configs.recommended.rules
}
},
{
files: ['server/**/*.ts'],
languageOptions: {
globals: globals.node
}
}
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#07111f" />
<title>ShareServer Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5770
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@goodbuddy/shareserver",
"version": "0.1.0-prototype.1",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently -k -n api,web -c cyan,magenta \"npm:dev:api\" \"npm:dev:web\"",
"dev:api": "tsx watch server/index.ts",
"dev:web": "vite",
"build": "npm run typecheck && vite build && npm run build:server",
"build:server": "node -e \"require('node:fs').rmSync('build-server',{recursive:true,force:true})\" && tsc -p tsconfig.server.json",
"start": "node build-server/server/index.js",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.server.json",
"lint": "eslint ."
},
"dependencies": {
"@hono/node-server": "2.1.1",
"@radix-ui/react-dialog": "1.1.23",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"hono": "4.13.7",
"lucide-react": "1.27.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.3",
"tailwind-merge": "3.6.0"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@tailwindcss/vite": "4.3.3",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@types/node": "26.1.2",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.2.0",
"concurrently": "10.0.5",
"eslint": "10.8.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.8.0",
"jsdom": "30.0.1",
"tailwindcss": "4.3.3",
"tsx": "4.21.0",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"vite": "7.3.6",
"vitest": "4.1.10"
}
}
+40
View File
@@ -0,0 +1,40 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { app } from './app.js'
describe('ShareServer prototype API', () => {
it('reports prototype readiness without claiming production readiness', async () => {
const response = await app.request('/api/v1/public/ready')
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
status: 'ready',
mode: 'interactive-prototype',
productionReady: false
})
})
it('returns the organization-scoped console snapshot', async () => {
const response = await app.request('/api/v1/web/prototype/snapshot')
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
instance: { organization: 'MesaLab' },
pages: {
devices: expect.any(Array),
approvals: expect.any(Array),
myDevices: expect.any(Array)
}
})
})
it('rejects malformed prototype actions', async () => {
const response = await app.request('/api/v1/web/prototype/actions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: '批准申请' })
})
expect(response.status).toBe(400)
await expect(response.json()).resolves.toMatchObject({
code: 'INVALID_PROTOTYPE_ACTION'
})
})
})
+75
View File
@@ -0,0 +1,75 @@
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { prototypeSnapshot } from '../shared/prototype-data.js'
const app = new Hono()
app.use('*', logger())
app.use(
'*',
secureHeaders({
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"]
},
crossOriginEmbedderPolicy: false
})
)
app.get('/api/v1/public/health', (context) =>
context.json({
status: 'ok',
service: 'shareserver',
version: '0.1.0-prototype.1'
})
)
app.get('/api/v1/public/ready', (context) =>
context.json({
status: 'ready',
mode: 'interactive-prototype',
productionReady: false
})
)
app.get('/api/v1/web/prototype/snapshot', (context) =>
context.json({
...prototypeSnapshot,
instance: {
...prototypeSnapshot.instance,
generatedAt: new Date().toISOString()
}
})
)
app.post('/api/v1/web/prototype/actions', async (context) => {
const body = await context.req.json<{ action?: string; targetId?: string }>().catch(() => null)
if (!body?.action || !body.targetId) {
return context.json(
{
code: 'INVALID_PROTOTYPE_ACTION',
message: '缺少原型操作或目标对象。',
requestId: crypto.randomUUID()
},
400
)
}
return context.json({
accepted: true,
action: body.action,
targetId: body.targetId,
prototypeOnly: true,
requestId: crypto.randomUUID()
})
})
app.use('*', serveStatic({ root: './dist' }))
app.get('*', serveStatic({ path: './dist/index.html' }))
export { app }
+9
View File
@@ -0,0 +1,9 @@
import { serve } from '@hono/node-server'
import { app } from './app.js'
const port = Number(process.env.SHARESERVER_PORT ?? 8787)
const hostname = process.env.SHARESERVER_HOST ?? '127.0.0.1'
serve({ fetch: app.fetch, port, hostname }, (info) => {
console.log(`ShareServer interactive prototype listening on http://${hostname}:${info.port}`)
})
+324
View File
@@ -0,0 +1,324 @@
export type PrototypeRole = 'admin' | 'member'
export type Metric = {
label: string
value: string
detail: string
tone: 'cyan' | 'violet' | 'amber' | 'rose'
target: string
}
export type PrototypeRecord = {
id: string
primary: string
secondary: string
status: string
statusTone: 'online' | 'warning' | 'offline' | 'danger' | 'neutral'
meta: string[]
detail: Record<string, string>
}
export type PrototypeSnapshot = {
instance: {
name: string
organization: string
protocol: string
region: string
generatedAt: string
}
metrics: Metric[]
activity: Array<{ time: string; label: string; value: number }>
pages: Record<string, PrototypeRecord[]>
}
const record = (
id: string,
primary: string,
secondary: string,
status: string,
statusTone: PrototypeRecord['statusTone'],
meta: string[],
detail: Record<string, string>
): PrototypeRecord => ({ id, primary, secondary, status, statusTone, meta, detail })
export const prototypeSnapshot: PrototypeSnapshot = {
instance: {
name: 'Nebula ShareServer',
organization: 'MesaLab',
protocol: 'Share Protocol 1.0',
region: '上海 · 私有云',
generatedAt: '2026-09-05T09:42:00+08:00'
},
metrics: [
{ label: '待处理审批', value: '6', detail: '最旧等待 42 分钟', tone: 'amber', target: '/approvals' },
{ label: '在线设备', value: '18 / 24', detail: '2 台需要关注', tone: 'cyan', target: '/devices' },
{ label: '可用能力', value: '37', detail: '来自 21 个 Provider', tone: 'violet', target: '/capabilities' },
{ label: '活动任务', value: '12', detail: '过去 24 小时成功率 98.2%', tone: 'rose', target: '/tasks' }
],
activity: [
{ time: '08:00', label: '任务', value: 12 },
{ time: '09:00', label: '任务', value: 19 },
{ time: '10:00', label: '任务', value: 15 },
{ time: '11:00', label: '任务', value: 26 },
{ time: '12:00', label: '任务', value: 21 },
{ time: '13:00', label: '任务', value: 33 },
{ time: '14:00', label: '任务', value: 28 },
{ time: '15:00', label: '任务', value: 42 },
{ time: '16:00', label: '任务', value: 36 },
{ time: '17:00', label: '任务', value: 48 },
{ time: '18:00', label: '任务', value: 39 },
{ time: '19:00', label: '任务', value: 51 }
],
pages: {
members: [
record('m-1', '林江', 'jiang@mesalab.internal', '活跃', 'online', ['系统管理员', '3 台设备', '刚刚'], {
身份源: '本地账号',
用户组: '平台工程、审批人',
有效授权: '12 项 Grant',
最近活动: '批准图像生成能力发布'
}),
record('m-2', '陈汐', 'xi.chen@mesalab.internal', '活跃', 'online', ['组织管理员', '2 台设备', '8 分钟前'], {
身份源: '企业 OIDC',
用户组: '设计中心',
有效授权: '8 项 Grant',
最近活动: '调用文档转换能力'
}),
record('m-3', '王简', 'jian.wang@partner.example', '待接受邀请', 'warning', ['成员', '0 台设备', '2 小时前'], {
身份源: '邀请待确认',
用户组: '法务协作',
有效授权: '邀请生效后 2 项',
最近活动: '尚未登录'
})
],
groups: [
record('g-1', '平台工程', '维护内部 Agent 与能力节点', '12 名成员', 'online', ['8 个 Grant', '4 个设备组', '今天更新'], {
成员: '12',
设备组: 'GPU 节点、开发工作站',
角色: '发布者、审批人',
范围: 'MesaLab'
}),
record('g-2', '设计中心', '使用视觉与内容能力', '28 名成员', 'online', ['5 个 Grant', '2 个设备组', '昨天更新'], {
成员: '28',
设备组: '设计工作站',
角色: '成员',
范围: 'MesaLab'
})
],
roles: [
record('r-1', '组织管理员', '管理成员、设备、策略与联邦', '6 人', 'online', ['系统内置', '高权限', '全组织'], {
权限: '组织范围管理',
绑定人数: '6',
可委托: '否',
最近变更: '系统内置'
}),
record('r-2', '能力发布者', '发布获准能力和签名包', '9 人', 'neutral', ['自定义', '有界范围', '4 个组'], {
权限: 'Publication、Package',
绑定人数: '9',
可委托: '按策略',
最近变更: '3 天前'
})
],
devices: [
record('d-1', 'Jiang-PC', '林江 · Windows 11 x64', '在线', 'online', ['2 项能力', '客户端 0.12.4', '刚刚'], {
注册状态: '已验证',
连接代次: 'generation 84',
最近地址: '内网出口 · 已脱敏',
合规状态: '符合组织基线'
}),
record('d-2', 'GPU-Worker-02', 'AI Team · Linux x64', '在线', 'online', ['8 项能力', '客户端 0.12.3', '12 秒前'], {
注册状态: '已验证',
连接代次: 'generation 221',
最近地址: '计算节点区 · 已脱敏',
合规状态: '符合组织基线'
}),
record('d-3', 'Design-Mac-07', '陈汐 · macOS arm64', '离线', 'offline', ['3 项能力', '客户端 0.12.2', '2 小时前'], {
注册状态: '已验证',
连接代次: 'generation 17',
最近地址: '办公网络 · 已脱敏',
合规状态: '客户端建议更新'
}),
record('d-4', 'Legacy-Lab', '测试组 · Linux x64', '已撤销', 'danger', ['0 项可用能力', '客户端 0.10.1', '12 天前'], {
注册状态: '2026-08-24 已撤销',
连接代次: '无活动连接',
最近地址: '不再保留',
合规状态: '不具备新任务资格'
})
],
capabilities: [
record('c-1', '图像生成', 'image.generate · 2.1.0', '2 / 3 在线', 'warning', ['3 个 Provider', '设计组', 'remote'], {
输入: '文本、参考图片',
数据策略: '任务期保留,不允许委托',
版本: '2.0.x – 2.1.x',
最近错误: 'Design-Mac-07 离线'
}),
record('c-2', '文档转 Word', 'document.convert.docx · 1.4.0', '可用', 'online', ['2 个 Provider', '全组织', 'remote + package'], {
输入: 'PDF、图片、文本',
数据策略: '处理完成立即清理',
版本: '1.4.0',
最近错误: '无'
}),
record('c-3', '代码安全扫描', 'code.security.scan · 3.0.2', '已暂停', 'offline', ['4 个 Provider', '平台工程', 'remote'], {
输入: '代码目录',
数据策略: '仅组织内,不允许联网',
版本: '3.0.2',
最近错误: 'Publication 被管理员暂停'
})
],
policies: [
record('p-1', '生产数据禁止跨组织', '组织强制策略 · deny', '已生效', 'online', ['优先级最高', '数据分类:生产', '刚刚评估'], {
作用主体: '全体成员与设备',
能力范围: '全部能力',
决策: '跨组织调用时拒绝',
修订版本: 'revision 18'
}),
record('p-2', '设计组图像能力', 'Grant · allow', '已生效', 'online', ['设计中心', '每日 20 次/人', '有效至 12-31'], {
作用主体: '用户组:设计中心',
能力范围: '图像生成 2.x',
决策: '允许文本与图片输入',
修订版本: 'revision 6'
}),
record('p-3', '外部法务文档转换', 'Grant · require_approval', '已生效', 'warning', ['联邦组织', '每次审批', '有效至 10-01'], {
作用主体: '法务服务商',
能力范围: '文档转 Word 1.4.0',
决策: '每次调用需要审批',
修订版本: 'revision 3'
})
],
approvals: [
record('a-1', '权限扩大更新', '代码安全扫描 3.1.0', '等待审批', 'warning', ['申请人:周启', '新增网络访问', '等待 42 分钟'], {
申请类型: 'Package 权限变化',
影响范围: '平台工程组 12 台设备',
策略依据: '权限扩大必须重新批准',
有效期限: '批准后 7 天内发布'
}),
record('a-2', '本次能力调用', '法务服务商 → 文档转 Word', '等待审批', 'warning', ['申请人:王简', '机密文档', '等待 18 分钟'], {
申请类型: '联邦能力调用',
影响范围: '1 个 PDF,2.8 MB',
策略依据: '外部法务文档转换',
有效期限: '本次调用,15 分钟'
}),
record('a-3', '发布图像生成能力', 'GPU-Worker-05 · 2.1.0', '等待审批', 'warning', ['申请人:苏禾', '组织内发布', '等待 6 分钟'], {
申请类型: 'Publication',
影响范围: '设计中心可发现并调用',
策略依据: '新增 Provider 需要审批',
有效期限: '批准后持续有效'
})
],
releases: [
record('pkg-1', 'document-convert', '1.4.0 · Linux/Windows/macOS', '可用', 'online', ['签名已验证', '48.2 MB', '稳定通道'], {
摘要: 'sha256: 9b3a…42e1',
发布者: 'MesaLab Platform',
权限变化: '无',
下载事实: '24 台成功,0 台失败'
}),
record('pkg-2', 'security-scanner', '3.1.0 · Linux x64', '等待审批', 'warning', ['签名已验证', '128.6 MB', '候选通道'], {
摘要: 'sha256: a18f…91d0',
发布者: 'Security Team',
权限变化: '新增受限网络访问',
下载事实: '尚未进入发布目录'
}),
record('pkg-3', 'image-runtime', '2.0.4 · Linux x64', '紧急撤销', 'danger', ['签名有效', '3.2 GB', '已停止下载'], {
摘要: 'sha256: f221…d872',
发布者: 'AI Team',
权限变化: '无',
下载事实: '因运行崩溃于 09-02 撤销'
})
],
federation: [
record('f-1', '法务服务商', 'share.legal.example', '已连接', 'online', ['2 项开放能力', '双边确认', '有效至 12-31'], {
我方开放: '文档转 Word',
对方开放: '合同审阅、法规检索',
最终可用: '2 项',
网关指纹: 'SHA256:9F:2A:…:71'
}),
record('f-2', '华东研究院', 'gateway.east-research.internal', '已暂停', 'offline', ['5 项开放能力', '我方暂停', '无新任务'], {
我方开放: '代码扫描、文档解析',
对方开放: '仿真计算、数据清洗、模型评测',
最终可用: '暂停期间 0 项',
网关指纹: 'SHA256:42:BC:…:E9'
})
],
tasks: [
record('t-1', 'task_8B2K…19QF', '陈汐 → 图像生成', '运行中', 'online', ['GPU-Worker-02', '网关直连', '已运行 38 秒'], {
数据类别: '普通 · 文本与图片',
Provider: 'GPU-Worker-02 / image.generate 2.1.0',
路由: '经 ShareServer 协商后端点直连',
最后确认点: 'Provider 已报告 62%'
}),
record('t-2', 'task_4D8M…71XA', '王简 → 文档转 Word', '等待审批', 'warning', ['法务服务商', '联邦中继', '18 分钟前'], {
数据类别: '机密 · PDF',
Provider: 'MesaLab / document.convert.docx',
路由: '联邦 + 端到端加密中继',
最后确认点: '尚未发送正文'
}),
record('t-3', 'task_2A9P…82CD', '平台工程 → 代码安全扫描', '结果未知', 'danger', ['GPU-Worker-09', '网关直连', '1 小时前'], {
数据类别: '内部 · 代码',
Provider: 'GPU-Worker-09 / code.security.scan 3.0.2',
路由: '端点直连',
最后确认点: 'Provider 执行后连接中断'
})
],
audit: [
record('e-1', '批准 Publication', '林江 · GPU-Worker-05', '成功', 'online', ['MesaLab', 'audit_7K2…1PA', '09:38:12'], {
操作者: '林江 (user_81A…)',
对象: 'publication_71B…',
结果: 'active',
时间: '2026-09-05T09:38:12+08:00'
}),
record('e-2', '撤销设备', '陈汐 · Legacy-Lab', '成功', 'online', ['MesaLab', 'audit_1M4…9FD', '09:14:07'], {
操作者: '陈汐 (user_22C…)',
对象: 'device_B82…',
结果: 'revoked',
时间: '2026-09-05T09:14:07+08:00'
}),
record('e-3', '创建联邦任务', '王简 · 法务服务商', '需要审批', 'warning', ['MesaLab', 'audit_8X7…0QA', '09:02:44'], {
操作者: '王简 (external_subject_18D…)',
对象: 'task_4D8…',
结果: 'require_approval',
时间: '2026-09-05T09:02:44+08:00'
})
],
myDevices: [
record('md-1', 'Jiang-PC', '当前设备 · Windows 11 x64', '在线', 'online', ['2 项已发布能力', '5 项可用授权', '刚刚'], {
注册时间: '2026-08-11',
当前组织: 'MesaLab',
发布能力: '屏幕理解、文档解析',
登录状态: '当前 Web 会话不等于设备凭据'
}),
record('md-2', 'Home-Mac', 'macOS arm64', '离线', 'offline', ['未发布能力', '3 项可用授权', '4 天前'], {
注册时间: '2026-07-28',
当前组织: 'MesaLab',
发布能力: '无',
登录状态: '需要重新连接 ShareServer'
})
],
myGrants: [
record('mg-1', '图像生成 2.x', '设计中心 Grant', '可调用', 'online', ['文本与图片', '每日 20 次', '组织内'], {
Provider: '3 个已发布节点',
数据范围: '普通、内部',
有效期: '2026-12-31',
委托: '不允许'
}),
record('mg-2', '文档转 Word 1.4.0', '全组织 Grant', '可调用与下载', 'online', ['PDF、图片、文本', '每次最大 10 MB', '组织内'], {
Provider: '2 个在线节点',
数据范围: '普通、内部、机密',
有效期: '持续有效',
委托: '不允许'
})
],
myRequests: [
record('mr-1', '本次能力调用', '法务服务商 → 文档转 Word', '等待审批', 'warning', ['机密文档', '18 分钟前', '15 分钟有效'], {
申请对象: 'task_4D8M…71XA',
审批范围: '仅本次调用',
当前阶段: '尚未发送正文',
可执行操作: '取消申请'
}),
record('mr-2', '设备注册', 'Home-Mac', '已批准', 'online', ['MesaLab', '2026-07-28', '已完成'], {
申请对象: 'device_91M…A21',
审批范围: '设备注册',
当前阶段: '设备当前离线',
可执行操作: '查看设备'
})
]
}
}
+32
View File
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { prototypeSnapshot } from '../shared/prototype-data'
import App from './App'
describe('ShareServer console', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => prototypeSnapshot
})
)
window.history.replaceState({}, '', '/')
})
it('renders the organization overview from the API snapshot', async () => {
render(<App />)
expect(await screen.findByRole('heading', { name: '运行概览' })).toBeInTheDocument()
expect(screen.getByText('Nebula ShareServer')).toBeInTheDocument()
expect(screen.getByText('在线设备')).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: '管理控制台' })).toBeInTheDocument()
})
it('opens global search with the keyboard shortcut', async () => {
render(<App />)
await screen.findByRole('heading', { name: '运行概览' })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }))
expect(await screen.findByRole('dialog', { name: '全局搜索' })).toBeInTheDocument()
})
})
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react'
import {
BrowserRouter,
Navigate,
Route,
Routes,
useLocation
} from 'react-router-dom'
import type { PrototypeRole, PrototypeSnapshot } from '../shared/prototype-data'
import { pages, visiblePages } from './app-config'
import { AppShell } from './components/app-shell'
import { DataPage } from './components/data-page'
import { OverviewPage } from './components/overview-page'
import { SettingsPage } from './components/settings-page'
import { Button } from './components/ui/button'
async function loadSnapshot(): Promise<PrototypeSnapshot> {
const response = await fetch('/api/v1/web/prototype/snapshot')
if (!response.ok) throw new Error('控制台快照加载失败')
return response.json() as Promise<PrototypeSnapshot>
}
function LoadingState() {
return (
<div className="grid min-h-screen place-items-center bg-[#07111f]" role="status" aria-busy="true">
<div className="text-center">
<div className="mx-auto size-10 animate-spin rounded-full border-2 border-cyan-300/15 border-t-cyan-300" />
<p className="mt-4 text-sm text-slate-400">正在连接 ShareServer…</p>
</div>
</div>
)
}
function Console({
snapshot,
role,
onRoleChange
}: {
snapshot: PrototypeSnapshot
role: PrototypeRole
onRoleChange: (role: PrototypeRole) => void
}) {
const location = useLocation()
const allowedPaths = visiblePages(role).map((page) => page.path)
const fallback = role === 'admin' ? '/' : '/me/devices'
if (!allowedPaths.includes(location.pathname)) {
return <Navigate replace to={fallback} />
}
return (
<AppShell snapshot={snapshot} role={role} onRoleChange={onRoleChange}>
<Routes>
{pages.map((page) => (
<Route
key={page.path}
path={page.path}
element={
page.path === '/' ? (
<OverviewPage snapshot={snapshot} />
) : page.path === '/settings' ? (
<SettingsPage snapshot={snapshot} />
) : (
<DataPage page={page} snapshot={snapshot} />
)
}
/>
))}
<Route path="*" element={<Navigate replace to={fallback} />} />
</Routes>
</AppShell>
)
}
export default function App() {
const [snapshot, setSnapshot] = useState<PrototypeSnapshot | null>(null)
const [error, setError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [role, setRole] = useState<PrototypeRole>('admin')
useEffect(() => {
let cancelled = false
loadSnapshot()
.then((data) => {
if (!cancelled) setSnapshot(data)
})
.catch((reason: unknown) => {
if (!cancelled) {
setError(reason instanceof Error ? reason.message : '控制台加载失败')
}
})
return () => {
cancelled = true
}
}, [reloadKey])
if (error) {
return (
<div className="grid min-h-screen place-items-center bg-[#07111f] p-6 text-slate-100">
<div role="alert" className="max-w-md rounded-2xl border border-rose-400/20 bg-rose-400/[0.06] p-6 text-center">
<p className="font-medium">{error}</p>
<p className="mt-2 text-sm text-slate-400">请确认 ShareServer API 已启动,然后重试。</p>
<Button
className="mt-5"
onClick={() => {
setError(null)
setReloadKey((key) => key + 1)
}}
>
重新连接
</Button>
</div>
</div>
)
}
if (!snapshot) return <LoadingState />
return (
<BrowserRouter>
<Console snapshot={snapshot} role={role} onRoleChange={setRole} />
</BrowserRouter>
)
}
+157
View File
@@ -0,0 +1,157 @@
import {
Activity,
Boxes,
Building2,
CircleUserRound,
Cpu,
FileKey2,
Gauge,
GitFork,
PackageCheck,
ScrollText,
Settings2,
ShieldCheck,
UserRoundCheck
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { PrototypeRole } from '../shared/prototype-data'
export type PageConfig = {
path: string
title: string
shortTitle?: string
description: string
dataKey?: string
icon: LucideIcon
role: PrototypeRole | 'all'
primaryAction?: string
tabs?: Array<{ label: string; dataKey: string }>
}
export const pages: PageConfig[] = [
{
path: '/',
title: '运行概览',
description: '需要行动的事项与当前 ShareServer 运行事实。',
icon: Gauge,
role: 'admin'
},
{
path: '/members',
title: '组织与成员',
description: '管理当前组织的成员、用户组和有界角色。',
dataKey: 'members',
icon: Building2,
role: 'admin',
primaryAction: '邀请成员',
tabs: [
{ label: '成员', dataKey: 'members' },
{ label: '用户组', dataKey: 'groups' },
{ label: '角色', dataKey: 'roles' }
]
},
{
path: '/devices',
title: '设备',
description: '查看已注册设备、连接状态与合规元数据。',
dataKey: 'devices',
icon: Cpu,
role: 'admin',
primaryAction: '生成注册链接'
},
{
path: '/capabilities',
title: '能力目录',
description: '查看显式发布的能力、版本和 Provider 可用性。',
dataKey: 'capabilities',
icon: Boxes,
role: 'admin'
},
{
path: '/policies',
title: '权限策略',
description: '配置拒绝优先的组织强制策略与 Grant。',
dataKey: 'policies',
icon: ShieldCheck,
role: 'admin',
primaryAction: '新建策略'
},
{
path: '/approvals',
title: '审批',
description: '处理调用、发布、更新和联邦申请。',
dataKey: 'approvals',
icon: UserRoundCheck,
role: 'admin'
},
{
path: '/releases',
title: '发布与更新',
description: '管理签名能力包、发布通道、更新策略和撤销。',
dataKey: 'releases',
icon: PackageCheck,
role: 'admin',
primaryAction: '上传能力包'
},
{
path: '/federation',
title: '网关联邦',
description: '管理经双边确认的组织协作边界。',
dataKey: 'federation',
icon: GitFork,
role: 'admin',
primaryAction: '建立联邦关系'
},
{
path: '/tasks',
title: '任务与中继',
description: '查看任务路由、终态和可选中继容量。',
dataKey: 'tasks',
icon: Activity,
role: 'admin'
},
{
path: '/audit',
title: '审计',
description: '按组织查询有界操作元数据,不包含任务正文。',
dataKey: 'audit',
icon: ScrollText,
role: 'admin',
primaryAction: '导出当前结果'
},
{
path: '/settings',
title: '系统设置',
description: '查看实例、身份源、基础设施状态与保留策略。',
icon: Settings2,
role: 'admin'
},
{
path: '/me/devices',
title: '我的设备',
description: '查看你在当前组织注册的设备与连接状态。',
dataKey: 'myDevices',
icon: CircleUserRound,
role: 'member'
},
{
path: '/me/grants',
title: '我的授权',
description: '查看你获准发现、调用或下载的能力。',
dataKey: 'myGrants',
icon: FileKey2,
role: 'member'
},
{
path: '/me/requests',
title: '我的申请',
description: '跟踪你的设备、能力与任务审批。',
dataKey: 'myRequests',
icon: UserRoundCheck,
role: 'member'
}
]
export function visiblePages(role: PrototypeRole): PageConfig[] {
return pages.filter((page) => page.role === 'all' || page.role === role)
}
+250
View File
@@ -0,0 +1,250 @@
import {
Bell,
ChevronsUpDown,
Command,
Menu,
MoonStar,
Search,
Shield,
Sun,
X
} from 'lucide-react'
import { useEffect, useState, type ReactNode } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import type { PrototypeRole, PrototypeSnapshot } from '../../shared/prototype-data'
import { visiblePages } from '../app-config'
import { cn } from '../lib/utils'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
function BrandMark() {
return (
<div className="relative grid size-10 shrink-0 place-items-center rounded-xl border border-cyan-300/25 bg-cyan-300/10 text-cyan-300 shadow-[0_0_34px_-10px_rgba(34,211,238,.9)]">
<span className="absolute inset-1 rounded-lg border border-violet-400/20" />
<Shield aria-hidden="true" className="size-5" />
</div>
)
}
export function AppShell({
snapshot,
role,
onRoleChange,
children
}: {
snapshot: PrototypeSnapshot
role: PrototypeRole
onRoleChange: (role: PrototypeRole) => void
children: ReactNode
}) {
const [mobileOpen, setMobileOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [dark, setDark] = useState(true)
const navigate = useNavigate()
const navigation = visiblePages(role)
useEffect(() => {
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
}, [dark])
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
setSearchOpen(true)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
const nav = (
<>
<div className="flex h-19 items-center gap-3 border-b border-white/[0.07] px-5">
<BrandMark />
<div className="min-w-0">
<p className="truncate text-sm font-semibold tracking-wide text-white">
{snapshot.instance.name}
</p>
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-cyan-300/70">
Control plane
</p>
</div>
</div>
<div className="px-3 py-4">
<button className="flex w-full items-center gap-3 rounded-xl border border-white/[0.07] bg-white/[0.035] px-3 py-2.5 text-left outline-none transition hover:bg-white/[0.06] focus-visible:ring-2 focus-visible:ring-cyan-400/70">
<span className="grid size-8 place-items-center rounded-lg bg-violet-400/12 text-sm font-semibold text-violet-200">
M
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-100">
{snapshot.instance.organization}
</span>
<span className="block truncate text-[11px] text-slate-500">当前组织</span>
</span>
<ChevronsUpDown aria-hidden="true" className="size-4 text-slate-500" />
</button>
</div>
<nav aria-label={role === 'admin' ? '管理控制台' : '成员自助'} className="flex-1 overflow-y-auto px-3 pb-4">
<p className="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-600">
{role === 'admin' ? '组织控制台' : '成员自助'}
</p>
<div className="space-y-1">
{navigation.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
'group relative flex min-h-10 items-center gap-3 rounded-xl px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-cyan-400/70',
isActive
? 'bg-cyan-300/[0.09] font-medium text-cyan-100'
: 'text-slate-400 hover:bg-white/[0.045] hover:text-slate-100'
)
}
>
{({ isActive }) => (
<>
{isActive ? (
<span className="absolute inset-y-2 left-0 w-0.5 rounded-full bg-cyan-300 shadow-[0_0_12px_rgba(34,211,238,.9)]" />
) : null}
<Icon aria-hidden="true" className="size-[18px]" />
<span>{item.title}</span>
{item.path === '/approvals' ? (
<span className="ml-auto rounded-full bg-amber-300/15 px-1.5 py-0.5 text-[10px] font-semibold text-amber-200">
6
</span>
) : null}
</>
)}
</NavLink>
)
})}
</div>
</nav>
<div className="border-t border-white/[0.07] p-3">
<button
onClick={() => {
const nextRole = role === 'admin' ? 'member' : 'admin'
onRoleChange(nextRole)
navigate(nextRole === 'admin' ? '/' : '/me/devices')
}}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left outline-none transition hover:bg-white/[0.05] focus-visible:ring-2 focus-visible:ring-cyan-400/70"
>
<span className="grid size-9 place-items-center rounded-full border border-violet-300/20 bg-gradient-to-br from-cyan-300/25 to-violet-400/25 text-sm font-semibold text-white">
林
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-200">林江</span>
<span className="block truncate text-[11px] text-slate-500">
{role === 'admin' ? '切换到成员自助' : '切换到管理控制台'}
</span>
</span>
<ChevronsUpDown aria-hidden="true" className="size-4 text-slate-600" />
</button>
</div>
</>
)
return (
<div className="min-h-screen bg-[var(--canvas)] text-[var(--text-primary)]">
<div className="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
<div className="absolute -left-60 -top-72 size-[620px] rounded-full bg-cyan-500/[0.055] blur-3xl" />
<div className="absolute -right-48 top-24 size-[520px] rounded-full bg-violet-500/[0.06] blur-3xl" />
<div className="grid-overlay absolute inset-0 opacity-40" />
</div>
<aside className="app-sidebar fixed inset-y-0 left-0 z-30 hidden w-[258px] flex-col border-r border-white/[0.075] bg-[#07111f]/95 backdrop-blur-xl lg:flex">
{nav}
</aside>
{mobileOpen ? (
<div className="fixed inset-0 z-40 lg:hidden">
<button
aria-label="关闭导航"
className="absolute inset-0 bg-slate-950/75 backdrop-blur-sm"
onClick={() => setMobileOpen(false)}
/>
<aside className="app-sidebar relative flex h-full w-[min(300px,86vw)] flex-col border-r border-white/10 bg-[#07111f] shadow-2xl">
<Button
aria-label="关闭导航"
className="absolute right-3 top-3 z-10"
size="icon"
variant="ghost"
onClick={() => setMobileOpen(false)}
>
<X className="size-5" />
</Button>
{nav}
</aside>
</div>
) : null}
<div className="relative lg:pl-[258px]">
<header className="sticky top-0 z-20 flex h-16 items-center gap-3 border-b border-white/[0.07] bg-[var(--header)] px-4 backdrop-blur-xl sm:px-6">
<Button
aria-label="打开导航"
className="lg:hidden"
size="icon"
variant="ghost"
onClick={() => setMobileOpen(true)}
>
<Menu className="size-5" />
</Button>
<button
className="flex h-9 max-w-md flex-1 items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.035] px-3 text-left text-sm text-slate-500 outline-none transition hover:border-white/[0.15] hover:bg-white/[0.055] focus-visible:ring-2 focus-visible:ring-cyan-400/70 sm:flex-initial sm:basis-[360px]"
onClick={() => setSearchOpen(true)}
>
<Search aria-hidden="true" className="size-4" />
<span className="min-w-0 flex-1 truncate">搜索设备、能力、任务或成员</span>
<span className="hidden items-center gap-1 rounded border border-white/10 px-1.5 py-0.5 text-[10px] text-slate-500 sm:flex">
<Command className="size-3" />K
</span>
</button>
<div className="ml-auto flex items-center gap-1">
<span className="mr-2 hidden items-center gap-2 text-xs text-slate-500 xl:flex">
<span className="size-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,.8)]" />
控制面正常
</span>
<Button
aria-label={dark ? '切换到浅色主题' : '切换到深色主题'}
size="icon"
variant="ghost"
onClick={() => setDark((value) => !value)}
>
{dark ? <Sun className="size-4" /> : <MoonStar className="size-4" />}
</Button>
<Button aria-label="查看通知,6 条待处理" size="icon" variant="ghost">
<Bell className="size-4" />
<span className="absolute ml-3 mt-[-13px] size-1.5 rounded-full bg-amber-300" />
</Button>
</div>
</header>
<main className="min-h-[calc(100vh-4rem)] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
<div className="mx-auto max-w-[1440px]">{children}</div>
</main>
</div>
<Dialog
open={searchOpen}
onOpenChange={setSearchOpen}
title="全局搜索"
description={`搜索范围:组织 ${snapshot.instance.organization}`}
width="max-w-2xl"
>
<div className="relative">
<Search className="absolute left-3 top-3 size-4 text-slate-500" />
<input
autoFocus
aria-label="搜索关键词"
className="h-11 w-full rounded-xl border border-white/10 bg-white/[0.04] pl-10 pr-3 text-sm outline-none placeholder:text-slate-600 focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/15"
placeholder="输入设备、能力、任务 ID 或成员名称…"
/>
</div>
<p className="mt-5 text-center text-sm text-slate-500">
输入关键词后,将按当前身份和组织权限显示结果。
</p>
</Dialog>
</div>
)
}
+417
View File
@@ -0,0 +1,417 @@
import {
ArrowRight,
Check,
ChevronLeft,
ChevronRight,
Filter,
MoreHorizontal,
Search,
SlidersHorizontal,
X
} from 'lucide-react'
import { useMemo, useState } from 'react'
import type { PrototypeRecord, PrototypeSnapshot } from '../../shared/prototype-data'
import type { PageConfig } from '../app-config'
import { cn } from '../lib/utils'
import { Button } from './ui/button'
import { Dialog } from './ui/dialog'
import { StatusBadge } from './ui/status-badge'
import { PageHeader } from './page-header'
type Feedback = {
tone: 'success' | 'danger'
message: string
}
async function sendPrototypeAction(action: string, targetId: string): Promise<void> {
const response = await fetch('/api/v1/web/prototype/actions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action, targetId })
})
if (!response.ok) {
throw new Error('原型操作未能提交,请重试。')
}
}
function DetailPanel({
record,
page,
onAction
}: {
record: PrototypeRecord
page: PageConfig
onAction: (action: string, record: PrototypeRecord) => void
}) {
return (
<div>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/[0.07] pb-5">
<div>
<p className="text-base font-semibold text-slate-100">{record.primary}</p>
<p className="mt-1 text-sm text-slate-400">{record.secondary}</p>
</div>
<StatusBadge status={record.status} tone={record.statusTone} />
</div>
<dl className="divide-y divide-white/[0.06]">
{Object.entries(record.detail).map(([label, value]) => (
<div key={label} className="grid gap-1 py-4 sm:grid-cols-[140px_1fr] sm:gap-4">
<dt className="text-xs font-medium text-slate-500">{label}</dt>
<dd className="text-sm leading-6 text-slate-200">{value}</dd>
</div>
))}
</dl>
<div className="mt-5 flex flex-wrap justify-end gap-2 border-t border-white/[0.07] pt-5">
{page.path === '/approvals' ? (
<>
<Button variant="danger" onClick={() => onAction('拒绝申请', record)}>
<X className="size-4" />
拒绝
</Button>
<Button onClick={() => onAction('批准申请', record)}>
<Check className="size-4" />
批准此申请
</Button>
</>
) : null}
{page.path === '/devices' && record.status !== '已撤销' ? (
<Button variant="danger" onClick={() => onAction('撤销设备', record)}>
撤销设备
</Button>
) : null}
{page.path === '/tasks' && record.status === '运行中' ? (
<Button variant="danger" onClick={() => onAction('取消任务', record)}>
取消任务
</Button>
) : null}
{page.path === '/federation' ? (
<Button variant="secondary" onClick={() => onAction('切换联邦状态', record)}>
{record.status === '已连接' ? '暂停关系' : '恢复关系'}
</Button>
) : null}
{!['/approvals', '/devices', '/tasks', '/federation'].includes(page.path) ? (
<Button variant="secondary" onClick={() => onAction('保存变更', record)}>
编辑配置
</Button>
) : null}
</div>
</div>
)
}
export function DataPage({
page,
snapshot
}: {
page: PageConfig
snapshot: PrototypeSnapshot
}) {
const [activeKey, setActiveKey] = useState(page.dataKey ?? '')
const [query, setQuery] = useState('')
const [statusFilter, setStatusFilter] = useState('全部状态')
const [selected, setSelected] = useState<PrototypeRecord | null>(null)
const [actionTarget, setActionTarget] = useState<{
action: string
record: PrototypeRecord
} | null>(null)
const [primaryOpen, setPrimaryOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [feedback, setFeedback] = useState<Feedback | null>(null)
const records = useMemo(
() => snapshot.pages[activeKey] ?? [],
[activeKey, snapshot.pages]
)
const statuses = ['全部状态', ...new Set(records.map((item) => item.status))]
const visibleRecords = useMemo(() => {
const normalized = query.trim().toLowerCase()
return records.filter((item) => {
const matchesQuery =
!normalized ||
[item.primary, item.secondary, ...item.meta]
.join(' ')
.toLowerCase()
.includes(normalized)
const matchesStatus = statusFilter === '全部状态' || item.status === statusFilter
return matchesQuery && matchesStatus
})
}, [query, records, statusFilter])
const handleAction = (action: string, record: PrototypeRecord) => {
setSelected(null)
setActionTarget({ action, record })
}
const confirmAction = async () => {
if (!actionTarget) return
setSubmitting(true)
try {
await sendPrototypeAction(actionTarget.action, actionTarget.record.id)
setFeedback({
tone: 'success',
message: `${actionTarget.action}已在交互原型中完成,不会写入生产数据。`
})
setActionTarget(null)
} catch (error) {
setFeedback({
tone: 'danger',
message: error instanceof Error ? error.message : '操作失败,请重试。'
})
} finally {
setSubmitting(false)
}
}
return (
<>
<PageHeader
eyebrow={`组织 · ${snapshot.instance.organization}`}
title={page.title}
description={page.description}
action={
page.primaryAction ? (
<Button onClick={() => setPrimaryOpen(true)}>
{page.primaryAction}
<ArrowRight className="size-4" />
</Button>
) : undefined
}
/>
{feedback ? (
<div
role={feedback.tone === 'danger' ? 'alert' : 'status'}
className={cn(
'mb-5 flex items-center justify-between gap-4 rounded-xl border px-4 py-3 text-sm',
feedback.tone === 'success'
? 'border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-200'
: 'border-rose-400/20 bg-rose-400/[0.08] text-rose-200'
)}
>
<span>{feedback.message}</span>
<button
aria-label="关闭消息"
className="rounded p-1 outline-none hover:bg-white/10 focus-visible:ring-2"
onClick={() => setFeedback(null)}
>
<X className="size-4" />
</button>
</div>
) : null}
{page.tabs ? (
<div
role="tablist"
aria-label={`${page.title}分类`}
className="mb-5 flex w-fit max-w-full gap-1 overflow-x-auto rounded-xl border border-white/[0.07] bg-white/[0.025] p-1"
>
{page.tabs.map((tab) => (
<button
key={tab.dataKey}
role="tab"
aria-selected={activeKey === tab.dataKey}
className={cn(
'min-h-8 shrink-0 rounded-lg px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-cyan-400/70',
activeKey === tab.dataKey
? 'bg-white/[0.09] font-medium text-slate-100 shadow-sm'
: 'text-slate-500 hover:text-slate-200'
)}
onClick={() => {
setActiveKey(tab.dataKey)
setStatusFilter('全部状态')
setQuery('')
}}
>
{tab.label}
</button>
))}
</div>
) : null}
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)] shadow-[0_18px_50px_-36px_rgba(0,0,0,.7)]">
<div className="flex flex-col gap-3 border-b border-white/[0.07] p-4 sm:flex-row sm:items-center">
<label className="relative min-w-0 flex-1 sm:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-2.5 size-4 text-slate-500" />
<span className="sr-only">搜索{page.title}</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
className="h-9 w-full rounded-lg border border-white/[0.08] bg-black/10 pl-9 pr-3 text-sm outline-none placeholder:text-slate-600 focus:border-cyan-300/35 focus:ring-2 focus:ring-cyan-400/10"
placeholder={`搜索${page.title}…`}
/>
</label>
<label className="relative">
<span className="sr-only">状态筛选</span>
<Filter className="pointer-events-none absolute left-3 top-2.5 size-4 text-slate-500" />
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="h-9 min-w-36 appearance-none rounded-lg border border-white/[0.08] bg-[var(--control)] pl-9 pr-8 text-sm text-slate-300 outline-none focus:border-cyan-300/35 focus:ring-2 focus:ring-cyan-400/10"
>
{statuses.map((status) => (
<option key={status}>{status}</option>
))}
</select>
</label>
<Button variant="secondary">
<SlidersHorizontal className="size-4" />
更多筛选
</Button>
</div>
<div className="hidden grid-cols-[minmax(220px,1.45fr)_minmax(160px,.85fr)_minmax(300px,1.7fr)_44px] border-b border-white/[0.06] px-5 py-3 text-[11px] font-medium uppercase tracking-[0.12em] text-slate-400 md:grid">
<span>对象</span>
<span>状态</span>
<span>范围与事实</span>
<span className="sr-only">操作</span>
</div>
<div className="divide-y divide-white/[0.055]">
{visibleRecords.map((item) => (
<button
key={item.id}
className="group grid w-full gap-4 px-5 py-4 text-left outline-none transition hover:bg-cyan-300/[0.025] focus-visible:bg-cyan-300/[0.04] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cyan-400/50 md:grid-cols-[minmax(220px,1.45fr)_minmax(160px,.85fr)_minmax(300px,1.7fr)_44px] md:items-center"
onClick={() => setSelected(item)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-[var(--text-strong)]">
{item.primary}
</span>
<span className="mt-1 block truncate text-xs text-[var(--text-muted)]">
{item.secondary}
</span>
</span>
<StatusBadge status={item.status} tone={item.statusTone} />
<span className="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--text-muted)]">
{item.meta.map((meta) => (
<span key={meta} className="truncate">
{meta}
</span>
))}
</span>
<span className="hidden size-8 place-items-center rounded-lg text-slate-500 transition group-hover:bg-white/[0.06] group-hover:text-slate-200 md:grid">
<MoreHorizontal className="size-4" />
</span>
</button>
))}
{visibleRecords.length === 0 ? (
<div className="px-5 py-16 text-center">
<p className="text-sm font-medium text-slate-300">没有符合条件的结果</p>
<p className="mt-1 text-xs text-slate-500">请修改关键词或清除状态筛选。</p>
<Button
className="mt-4"
size="sm"
variant="secondary"
onClick={() => {
setQuery('')
setStatusFilter('全部状态')
}}
>
清除筛选
</Button>
</div>
) : null}
</div>
<footer className="flex items-center justify-between border-t border-white/[0.07] px-5 py-3 text-xs text-[var(--text-muted)]">
<span>显示 {visibleRecords.length} / {records.length} 项</span>
<div className="flex items-center gap-1">
<Button aria-label="上一页" disabled size="icon" variant="ghost">
<ChevronLeft className="size-4" />
</Button>
<span className="px-2">第 1 页</span>
<Button aria-label="下一页" disabled size="icon" variant="ghost">
<ChevronRight className="size-4" />
</Button>
</div>
</footer>
</section>
<Dialog
open={Boolean(selected)}
onOpenChange={(open) => !open && setSelected(null)}
title={`${page.title}详情`}
description="当前组织范围内可见的有界元数据。"
>
{selected ? (
<DetailPanel record={selected} page={page} onAction={handleAction} />
) : null}
</Dialog>
<Dialog
open={primaryOpen}
onOpenChange={setPrimaryOpen}
title={page.primaryAction ?? '创建对象'}
description={`该表单展示 ${page.title} 的完整交互结构;本阶段不会写入生产数据库。`}
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
setPrimaryOpen(false)
setFeedback({
tone: 'success',
message: `${page.primaryAction ?? '操作'}已在交互原型中提交。`
})
}}
>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-slate-400">名称或标识</span>
<input
required
className="h-10 w-full rounded-lg border border-white/10 bg-white/[0.035] px-3 text-sm outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
placeholder={`输入${page.title}名称`}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-slate-400">范围</span>
<select className="h-10 w-full rounded-lg border border-white/10 bg-[var(--control)] px-3 text-sm outline-none focus:border-cyan-300/40">
<option>组织:{snapshot.instance.organization}</option>
<option>指定用户组</option>
<option>仅当前用户</option>
</select>
</label>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-slate-400">说明</span>
<textarea
className="min-h-24 w-full resize-y rounded-lg border border-white/10 bg-white/[0.035] p-3 text-sm outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
placeholder="补充用途、影响范围或有效期"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setPrimaryOpen(false)}>
取消
</Button>
<Button type="submit">{page.primaryAction ?? '提交'}</Button>
</div>
</form>
</Dialog>
<Dialog
open={Boolean(actionTarget)}
onOpenChange={(open) => !open && setActionTarget(null)}
title={actionTarget?.action ?? '确认操作'}
description={
actionTarget
? `对象:${actionTarget.record.primary}。请确认操作范围和直接影响。`
: undefined
}
>
<div className="rounded-xl border border-amber-400/15 bg-amber-300/[0.055] p-4 text-sm leading-6 text-amber-100/80">
这是交互原型。确认后会调用 ShareServer 原型 API 并返回请求编号,但不会更改生产数据。
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setActionTarget(null)}>
取消
</Button>
<Button
variant={actionTarget?.action.includes('撤销') || actionTarget?.action.includes('拒绝') ? 'danger' : 'default'}
disabled={submitting}
onClick={confirmAction}
>
{submitting ? '正在提交…' : actionTarget?.action}
</Button>
</div>
</Dialog>
</>
)
}
@@ -0,0 +1,214 @@
import {
Activity,
ArrowUpRight,
CheckCircle2,
CircleAlert,
GitFork,
RadioTower,
ServerCog,
ShieldCheck,
Sparkles
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import type { Metric, PrototypeSnapshot } from '../../shared/prototype-data'
import { PageHeader } from './page-header'
import { StatusBadge } from './ui/status-badge'
const metricStyles: Record<Metric['tone'], string> = {
cyan: 'from-cyan-400/16 to-cyan-400/[0.025] border-cyan-300/15 text-cyan-300',
violet: 'from-violet-400/16 to-violet-400/[0.025] border-violet-300/15 text-violet-300',
amber: 'from-amber-400/16 to-amber-400/[0.025] border-amber-300/15 text-amber-200',
rose: 'from-rose-400/16 to-rose-400/[0.025] border-rose-300/15 text-rose-300'
}
export function OverviewPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
const navigate = useNavigate()
const maxActivity = Math.max(...snapshot.activity.map((item) => item.value))
const recent = [
{
icon: ShieldCheck,
title: '策略决定',
copy: '生产数据跨组织请求被强制策略拒绝',
time: '2 分钟前',
tone: 'warning' as const
},
{
icon: CheckCircle2,
title: '能力发布',
copy: 'GPU-Worker-05 的图像生成能力已批准',
time: '4 分钟前',
tone: 'online' as const
},
{
icon: GitFork,
title: '联邦心跳',
copy: '法务服务商网关完成双边状态同步',
time: '8 分钟前',
tone: 'neutral' as const
}
]
return (
<>
<PageHeader
eyebrow={`实例 · ${snapshot.instance.region}`}
title="运行概览"
description={`${snapshot.instance.name} 正在为组织 ${snapshot.instance.organization} 提供控制面服务。`}
action={
<div className="flex items-center gap-2 rounded-xl border border-emerald-400/15 bg-emerald-400/[0.06] px-3 py-2 text-xs text-emerald-200">
<span className="size-1.5 rounded-full bg-emerald-300 shadow-[0_0_10px_rgba(52,211,153,.9)]" />
所有核心服务正常
</div>
}
/>
<section aria-label="关键指标" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{snapshot.metrics.map((metric) => (
<button
key={metric.label}
className={`group relative overflow-hidden rounded-2xl border bg-gradient-to-br p-5 text-left outline-none transition duration-200 hover:-translate-y-0.5 hover:border-white/20 focus-visible:ring-2 focus-visible:ring-cyan-400/70 ${metricStyles[metric.tone]}`}
onClick={() => navigate(metric.target)}
>
<div className="absolute -right-8 -top-8 size-24 rounded-full bg-current opacity-[0.055] blur-2xl" />
<div className="flex items-start justify-between">
<span className="text-xs font-medium text-[var(--text-muted)]">{metric.label}</span>
<ArrowUpRight className="size-4 opacity-50 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:opacity-100" />
</div>
<p className="mt-5 text-3xl font-semibold tracking-[-0.04em] text-[var(--text-strong)]">
{metric.value}
</p>
<p className="mt-2 text-xs text-[var(--text-muted)]">{metric.detail}</p>
</button>
))}
</section>
<div className="mt-5 grid gap-5 xl:grid-cols-[1.55fr_.85fr]">
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold text-[var(--text-strong)]">任务活动</h2>
<p className="mt-1 text-xs text-[var(--text-muted)]">过去 12 小时创建的任务</p>
</div>
<span className="rounded-lg border border-white/[0.07] bg-white/[0.035] px-2.5 py-1.5 text-xs text-[var(--text-muted)]">
24 小时
</span>
</div>
<div className="mt-8 flex h-52 items-end gap-2 sm:gap-3" role="img" aria-label="过去十二小时任务量柱状图">
{snapshot.activity.map((item, index) => (
<div key={item.time} className="group flex h-full min-w-0 flex-1 flex-col justify-end">
<div className="relative flex-1">
<div
className="absolute inset-x-0 bottom-0 min-h-1 rounded-t-md bg-gradient-to-t from-cyan-400/35 to-cyan-300/90 shadow-[0_0_18px_-8px_rgba(34,211,238,.8)] transition group-hover:from-violet-400/50 group-hover:to-violet-300"
style={{ height: `${Math.max(6, (item.value / maxActivity) * 100)}%` }}
>
<span className="absolute -top-6 left-1/2 hidden -translate-x-1/2 text-[10px] text-cyan-200 group-hover:block">
{item.value}
</span>
</div>
</div>
<span className={`mt-2 text-center text-[9px] text-slate-600 ${index % 2 ? 'hidden sm:block' : ''}`}>
{item.time}
</span>
</div>
))}
</div>
</section>
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-[var(--text-strong)]">网络脉冲</h2>
<p className="mt-1 text-xs text-[var(--text-muted)]">连接与路由实时摘要</p>
</div>
<RadioTower className="size-5 text-cyan-300" />
</div>
<div className="relative mt-7 grid place-items-center py-4">
<div className="pulse-ring absolute size-36 rounded-full border border-cyan-300/10" />
<div className="pulse-ring pulse-delay absolute size-24 rounded-full border border-cyan-300/15" />
<div className="relative grid size-16 place-items-center rounded-full border border-cyan-300/25 bg-cyan-300/10 shadow-[0_0_45px_-10px_rgba(34,211,238,.7)]">
<ServerCog className="size-7 text-cyan-200" />
</div>
</div>
<dl className="mt-4 grid grid-cols-3 gap-2 text-center">
<div className="rounded-xl bg-white/[0.025] p-3">
<dt className="text-[10px] text-slate-500">设备连接</dt>
<dd className="mt-1 text-sm font-semibold text-slate-200">18</dd>
</div>
<div className="rounded-xl bg-white/[0.025] p-3">
<dt className="text-[10px] text-slate-500">联邦网关</dt>
<dd className="mt-1 text-sm font-semibold text-slate-200">1 / 2</dd>
</div>
<div className="rounded-xl bg-white/[0.025] p-3">
<dt className="text-[10px] text-slate-500">中继占用</dt>
<dd className="mt-1 text-sm font-semibold text-slate-200">34%</dd>
</div>
</dl>
</section>
</div>
<div className="mt-5 grid gap-5 lg:grid-cols-2">
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)]">
<header className="flex items-center justify-between border-b border-white/[0.065] px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-[var(--text-strong)]">需要关注</h2>
<p className="mt-1 text-xs text-[var(--text-muted)]">按影响与等待时间排序</p>
</div>
<CircleAlert className="size-5 text-amber-300" />
</header>
<div className="divide-y divide-white/[0.055]">
{snapshot.pages.approvals.slice(0, 3).map((item) => (
<button
key={item.id}
className="flex w-full items-center gap-4 px-5 py-4 text-left outline-none transition hover:bg-white/[0.025] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cyan-400/60"
onClick={() => navigate('/approvals')}
>
<span className="grid size-9 shrink-0 place-items-center rounded-xl border border-amber-300/15 bg-amber-300/[0.07] text-amber-200">
<Sparkles className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--text-strong)]">{item.primary}</span>
<span className="mt-1 block truncate text-xs text-[var(--text-muted)]">{item.secondary}</span>
</span>
<span className="hidden text-xs text-slate-600 sm:block">{item.meta[2]}</span>
</button>
))}
</div>
</section>
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)]">
<header className="flex items-center justify-between border-b border-white/[0.065] px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-[var(--text-strong)]">最近活动</h2>
<p className="mt-1 text-xs text-[var(--text-muted)]">仅显示有界审计元数据</p>
</div>
<Activity className="size-5 text-violet-300" />
</header>
<div className="divide-y divide-white/[0.055]">
{recent.map((item) => {
const Icon = item.icon
return (
<div key={item.copy} className="flex items-center gap-4 px-5 py-4">
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-white/[0.04] text-slate-300">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium text-slate-500">{item.title}</span>
<span className="mt-1 block truncate text-sm text-[var(--text-primary)]">{item.copy}</span>
</span>
<span className="text-xs text-slate-600">{item.time}</span>
</div>
)
})}
</div>
</section>
</div>
<footer className="mt-6 flex flex-wrap items-center gap-3 text-[11px] text-slate-600">
<StatusBadge status="原型数据" tone="neutral" />
<span>{snapshot.instance.protocol}</span>
<span>·</span>
<span>快照 {new Date(snapshot.instance.generatedAt).toLocaleString('zh-CN')}</span>
</footer>
</>
)
}
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react'
export function PageHeader({
title,
description,
eyebrow,
action
}: {
title: string
description: string
eyebrow?: string
action?: ReactNode
}) {
return (
<header className="mb-7 flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
{eyebrow ? (
<p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-cyan-300">
{eyebrow}
</p>
) : null}
<h1 className="text-2xl font-semibold tracking-[-0.025em] text-[var(--text-strong)] sm:text-[28px]">
{title}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-muted)]">
{description}
</p>
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</header>
)
}
@@ -0,0 +1,250 @@
import {
Braces,
CheckCircle2,
CloudCog,
Database,
KeyRound,
Mail,
RadioTower,
Save,
ShieldCheck
} from 'lucide-react'
import { useState } from 'react'
import type { LucideIcon } from 'lucide-react'
import type { PrototypeSnapshot } from '../../shared/prototype-data'
import { PageHeader } from './page-header'
import { Button } from './ui/button'
import { StatusBadge } from './ui/status-badge'
function SettingCard({
icon: Icon,
title,
description,
children
}: {
icon: LucideIcon
title: string
description: string
children: React.ReactNode
}) {
return (
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
<header className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl border border-cyan-300/15 bg-cyan-300/[0.065] text-cyan-300">
<Icon className="size-5" />
</span>
<div>
<h2 className="text-sm font-semibold text-[var(--text-strong)]">{title}</h2>
<p className="mt-1 text-xs leading-5 text-[var(--text-muted)]">{description}</p>
</div>
</header>
<div className="mt-5 border-t border-white/[0.06] pt-5">{children}</div>
</section>
)
}
function Toggle({
label,
description,
checked,
onChange
}: {
label: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4 rounded-xl p-2 outline-none hover:bg-white/[0.025]">
<span>
<span className="block text-sm font-medium text-[var(--text-primary)]">{label}</span>
<span className="mt-1 block text-xs leading-5 text-[var(--text-muted)]">{description}</span>
</span>
<input
type="checkbox"
role="switch"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="peer sr-only"
/>
<span className="relative mt-0.5 h-6 w-11 shrink-0 rounded-full border border-white/10 bg-slate-700 transition peer-checked:border-cyan-300/30 peer-checked:bg-cyan-400/80 peer-focus-visible:ring-2 peer-focus-visible:ring-cyan-400/70 after:absolute after:left-0.5 after:top-0.5 after:size-5 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-5" />
</label>
)
}
export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
const [relay, setRelay] = useState(true)
const [federation, setFederation] = useState(true)
const [oidc, setOidc] = useState(false)
const [saved, setSaved] = useState(false)
return (
<>
<PageHeader
eyebrow="实例范围"
title="系统设置"
description="管理实例公开信息与功能开关。数据库连接、主密钥和容器挂载路径只从部署环境读取。"
action={
<Button
onClick={() => {
setSaved(true)
window.setTimeout(() => setSaved(false), 4500)
}}
>
<Save className="size-4" />
保存设置
</Button>
}
/>
{saved ? (
<div role="status" className="mb-5 rounded-xl border border-emerald-400/20 bg-emerald-400/[0.07] px-4 py-3 text-sm text-emerald-200">
原型设置已保存到当前浏览器会话,不会写入生产配置。
</div>
) : null}
<div className="grid gap-5 xl:grid-cols-2">
<SettingCard
icon={CloudCog}
title="实例信息"
description="公开名称、地址与协议协商信息。"
>
<div className="grid gap-4">
<label>
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">实例名称</span>
<input
defaultValue={snapshot.instance.name}
className="h-10 w-full rounded-lg border border-white/10 bg-white/[0.025] px-3 text-sm outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
/>
</label>
<label>
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">公开地址</span>
<input
defaultValue="https://share.mesalab.internal"
className="h-10 w-full rounded-lg border border-white/10 bg-white/[0.025] px-3 font-mono text-xs outline-none focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/10"
/>
</label>
<div className="flex items-center justify-between rounded-xl bg-white/[0.025] px-3 py-2.5">
<span className="text-xs text-[var(--text-muted)]">{snapshot.instance.protocol}</span>
<StatusBadge status="兼容" tone="online" />
</div>
</div>
</SettingCard>
<SettingCard
icon={Database}
title="基础设施"
description="状态可见,敏感连接信息不在 Web 控制台回显。"
>
<div className="space-y-2">
{[
['PostgreSQL', '正常', '主数据与审计权威'],
['对象存储', '正常', 'Package 与有界中继暂存'],
['网关身份', '正常', '证书将在 82 天后轮换']
].map(([name, status, copy]) => (
<div key={name} className="flex items-center gap-3 rounded-xl bg-white/[0.025] px-3 py-3">
<CheckCircle2 className="size-4 shrink-0 text-emerald-300" />
<span className="min-w-0 flex-1">
<span className="block text-sm text-[var(--text-primary)]">{name}</span>
<span className="block truncate text-xs text-[var(--text-muted)]">{copy}</span>
</span>
<span className="text-xs text-emerald-300">{status}</span>
</div>
))}
</div>
</SettingCard>
<SettingCard
icon={KeyRound}
title="身份与登录"
description="本地账号可用;OIDC 需要部署管理员配置固定回调地址。"
>
<div className="space-y-2">
<Toggle
checked={oidc}
onChange={setOidc}
label="允许企业 OIDC"
description="使用 state、nonce 和 PKCE 完成登录。"
/>
<div className="flex items-center justify-between rounded-xl p-2">
<span>
<span className="block text-sm text-[var(--text-primary)]">本地账号</span>
<span className="mt-1 block text-xs text-[var(--text-muted)]">首个系统管理员已完成初始化</span>
</span>
<StatusBadge status="已启用" tone="online" />
</div>
</div>
</SettingCard>
<SettingCard
icon={RadioTower}
title="中继与联邦"
description="控制面独立运行,关闭中继不会影响设备注册和目录。"
>
<div className="space-y-2">
<Toggle
checked={relay}
onChange={setRelay}
label="启用数据中继"
description="网络无法直连且双方策略允许时提供有界中继。"
/>
<Toggle
checked={federation}
onChange={setFederation}
label="允许网关联邦"
description="仍需双方管理员核对指纹并分别确认关系。"
/>
</div>
</SettingCard>
<SettingCard
icon={Mail}
title="通知"
description="用于邀请、审批和高影响状态通知。"
>
<div className="flex items-center justify-between rounded-xl bg-white/[0.025] px-3 py-3">
<span>
<span className="block text-sm text-[var(--text-primary)]">SMTP 邮件</span>
<span className="mt-1 block text-xs text-[var(--text-muted)]">凭据由部署环境提供,永不回显</span>
</span>
<Button size="sm" variant="secondary">发送测试</Button>
</div>
</SettingCard>
<SettingCard
icon={Braces}
title="保留与审计"
description="默认只保留有界元数据,不保存提示正文、文件与模型输出。"
>
<div className="grid gap-4 sm:grid-cols-2">
<label>
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">审计保留期</span>
<select className="h-10 w-full rounded-lg border border-white/10 bg-[var(--control)] px-3 text-sm outline-none">
<option>365 天</option>
<option>180 天</option>
<option>90 天</option>
</select>
</label>
<label>
<span className="mb-1.5 block text-xs font-medium text-[var(--text-muted)]">实例时区</span>
<select className="h-10 w-full rounded-lg border border-white/10 bg-[var(--control)] px-3 text-sm outline-none">
<option>Asia/Shanghai (UTC+8)</option>
<option>UTC</option>
</select>
</label>
</div>
</SettingCard>
</div>
<section className="mt-5 rounded-2xl border border-rose-400/15 bg-rose-400/[0.035] p-5">
<div className="flex items-start gap-3">
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-rose-300" />
<div className="flex-1">
<h2 className="text-sm font-semibold text-rose-200">危险操作</h2>
<p className="mt-1 text-xs leading-5 text-rose-200/60">
删除组织前必须停止新任务、撤销联邦和设备,并按保留策略归档审计。
</p>
</div>
<Button variant="danger">删除组织</Button>
</div>
</section>
</>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type { ButtonHTMLAttributes } from 'react'
import { cn } from '../../lib/utils'
const buttonVariants = cva(
'inline-flex min-h-9 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition-[color,background-color,border-color,box-shadow,transform] duration-150 outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70 disabled:pointer-events-none disabled:opacity-45 active:translate-y-px',
{
variants: {
variant: {
default:
'border border-cyan-300/25 bg-cyan-400 text-slate-950 shadow-[0_0_28px_-8px_rgba(34,211,238,.75)] hover:bg-cyan-300',
secondary:
'border border-white/10 bg-white/[0.055] text-slate-100 hover:border-white/20 hover:bg-white/[0.09]',
ghost: 'text-slate-300 hover:bg-white/[0.07] hover:text-white',
danger:
'border border-rose-400/25 bg-rose-500/12 text-rose-200 hover:bg-rose-500/20'
},
size: {
default: 'h-9',
sm: 'h-8 min-h-8 px-2.5 text-xs',
icon: 'size-9 min-h-9 px-0'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>
export function Button({
className,
variant,
size,
type = 'button',
...props
}: ButtonProps) {
return (
<button
type={type}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
}
+46
View File
@@ -0,0 +1,46 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import type { ReactNode } from 'react'
export function Dialog({
open,
onOpenChange,
title,
description,
children,
width = 'max-w-xl'
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
children: ReactNode
width?: string
}) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-slate-950/75 backdrop-blur-sm data-[state=open]:animate-in" />
<DialogPrimitive.Content
className={`fixed left-1/2 top-1/2 z-50 max-h-[86vh] w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-2xl border border-white/10 bg-[var(--dialog)] p-6 text-[var(--text-primary)] shadow-2xl outline-none ${width}`}
>
<div className="pr-10">
<DialogPrimitive.Title className="text-lg font-semibold tracking-tight">
{title}
</DialogPrimitive.Title>
{description ? (
<DialogPrimitive.Description className="mt-1 text-sm leading-6 text-slate-400">
{description}
</DialogPrimitive.Description>
) : null}
</div>
<DialogPrimitive.Close className="absolute right-4 top-4 grid size-9 place-items-center rounded-lg text-slate-400 outline-none transition hover:bg-white/[0.07] hover:text-white focus-visible:ring-2 focus-visible:ring-cyan-400/70">
<X aria-hidden="true" className="size-4" />
<span className="sr-only">关闭</span>
</DialogPrimitive.Close>
<div className="mt-6">{children}</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
@@ -0,0 +1,43 @@
import { CircleCheck, CircleMinus, CircleX, Clock3, Radio } from 'lucide-react'
import type { PrototypeRecord } from '../../../shared/prototype-data'
import { cn } from '../../lib/utils'
const styles: Record<PrototypeRecord['statusTone'], string> = {
online: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-300',
warning: 'border-amber-400/20 bg-amber-400/10 text-amber-200',
offline: 'border-slate-400/15 bg-slate-400/10 text-slate-400',
danger: 'border-rose-400/20 bg-rose-400/10 text-rose-300',
neutral: 'border-violet-400/20 bg-violet-400/10 text-violet-300'
}
const icons = {
online: CircleCheck,
warning: Clock3,
offline: CircleMinus,
danger: CircleX,
neutral: Radio
}
export function StatusBadge({
status,
tone,
className
}: {
status: string
tone: PrototypeRecord['statusTone']
className?: string
}) {
const Icon = icons[tone]
return (
<span
className={cn(
'inline-flex w-fit items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium',
styles[tone],
className
)}
>
<Icon aria-hidden="true" className="size-3.5" />
{status}
</span>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
+204
View File
@@ -0,0 +1,204 @@
@import "tailwindcss";
:root,
:root[data-theme="dark"] {
color-scheme: dark;
--canvas: #07111f;
--header: rgba(7, 17, 31, 0.78);
--sidebar: #07111f;
--panel: rgba(12, 25, 43, 0.78);
--dialog: #0a1525;
--control: #0d1b2e;
--text-strong: #f1f6fd;
--text-primary: #cdd9e8;
--text-muted: #718198;
}
:root[data-theme="light"] {
color-scheme: light;
--canvas: #edf3fa;
--header: rgba(247, 250, 253, 0.82);
--sidebar: #0a1627;
--panel: rgba(255, 255, 255, 0.86);
--dialog: #ffffff;
--control: #f7fafc;
--text-strong: #0e1a2b;
--text-primary: #2b3b50;
--text-muted: #52657d;
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
background: var(--canvas);
font-family:
Inter, "Microsoft YaHei UI", "Noto Sans SC", system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
text-rendering: optimizeLegibility;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
::selection {
background: rgba(34, 211, 238, 0.25);
color: #fff;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: rgba(113, 129, 152, 0.35);
background-clip: padding-box;
}
.grid-overlay {
background-image:
linear-gradient(rgba(93, 125, 158, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(93, 125, 158, 0.035) 1px, transparent 1px);
background-size: 38px 38px;
mask-image: linear-gradient(to bottom, black, transparent 92%);
}
.pulse-ring {
animation: pulse-ring 2.8s ease-out infinite;
}
.pulse-delay {
animation-delay: 0.8s;
}
@keyframes pulse-ring {
0% {
opacity: 0.25;
transform: scale(0.82);
}
70% {
opacity: 0.04;
transform: scale(1.15);
}
100% {
opacity: 0;
transform: scale(1.2);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (max-width: 640px) {
.grid-overlay {
background-size: 28px 28px;
}
}
:root[data-theme="light"] .border-white\/\[0\.075\],
:root[data-theme="light"] .border-white\/\[0\.07\],
:root[data-theme="light"] .border-white\/\[0\.065\],
:root[data-theme="light"] .border-white\/\[0\.06\],
:root[data-theme="light"] .border-white\/\[0\.055\],
:root[data-theme="light"] .border-white\/\[0\.08\],
:root[data-theme="light"] .border-white\/10 {
border-color: rgba(45, 67, 91, 0.12);
}
:root[data-theme="light"] .text-slate-100,
:root[data-theme="light"] .text-slate-200,
:root[data-theme="light"] .text-slate-300 {
color: #25364c;
}
:root[data-theme="light"] .text-slate-400,
:root[data-theme="light"] .text-slate-500,
:root[data-theme="light"] .text-slate-600 {
color: #52657d;
}
:root[data-theme="light"] .text-cyan-100,
:root[data-theme="light"] .text-cyan-200,
:root[data-theme="light"] .text-cyan-300 {
color: #006579;
}
:root[data-theme="light"] .text-amber-100,
:root[data-theme="light"] .text-amber-200,
:root[data-theme="light"] .text-amber-300 {
color: #8c5300;
}
:root[data-theme="light"] .text-emerald-200,
:root[data-theme="light"] .text-emerald-300 {
color: #087453;
}
:root[data-theme="light"] .text-violet-200,
:root[data-theme="light"] .text-violet-300 {
color: #6842b5;
}
:root[data-theme="light"] .text-rose-200,
:root[data-theme="light"] .text-rose-300 {
color: #b81743;
}
:root[data-theme="light"] .app-sidebar .text-white,
:root[data-theme="light"] .app-sidebar .text-slate-100,
:root[data-theme="light"] .app-sidebar .text-slate-200,
:root[data-theme="light"] .app-sidebar .text-slate-300 {
color: #e8f1fb;
}
:root[data-theme="light"] .app-sidebar .text-slate-400 {
color: #9eb0c5;
}
:root[data-theme="light"] .app-sidebar .text-slate-500,
:root[data-theme="light"] .app-sidebar .text-slate-600 {
color: #72879f;
}
:root[data-theme="light"] .app-sidebar .text-cyan-100,
:root[data-theme="light"] .app-sidebar .text-cyan-200,
:root[data-theme="light"] .app-sidebar .text-cyan-300 {
color: #67e8f9;
}
:root[data-theme="light"] main .bg-white\/\[0\.025\],
:root[data-theme="light"] main .bg-white\/\[0\.035\],
:root[data-theme="light"] main .bg-white\/\[0\.04\] {
background-color: rgba(29, 56, 84, 0.035);
}
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "shared"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.server.json" }
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "build-server",
"rootDir": ".",
"types": ["node"]
},
"include": ["server", "shared"],
"exclude": ["server/**/*.test.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5174,
proxy: {
'/api': 'http://127.0.0.1:8787'
}
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'],
exclude: ['build-server/**', 'dist/**', 'node_modules/**']
}
})
+6 -25
View File
@@ -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
}
+1 -1
View File
@@ -449,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 () => {
+3 -3
View File
@@ -842,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'
)
}
@@ -860,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')
+7 -3
View File
@@ -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
}
})
})
+139
View File
@@ -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')
}
}
@@ -28,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
@@ -401,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')
@@ -540,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'
)
}
@@ -786,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[] = []
@@ -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(
+3 -3
View File
@@ -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)
+19 -3
View File
@@ -67,23 +67,39 @@ export async function createLinuxPeerIdentityProvider(
byteOrder?: 'BE' | 'LE'
loadKoffi?: () => Promise<Koffi>
} = {}
): Promise<LinuxPeerIdentityProvider> {
): Promise<UnixPeerIdentityProvider> {
const platform = options.platform ?? process.platform
const architecture = options.architecture ?? arch()
const byteOrder = options.byteOrder ?? endianness()
if (
platform !== 'linux' ||
(platform !== 'linux' && !(platform === 'darwin' && architecture === 'arm64')) ||
!SUPPORTED_ARCHITECTURES.has(architecture) ||
byteOrder !== 'LE'
) {
throw unavailable(
'SO_PEERCRED is certified only on little-endian Linux x64 and arm64'
'Unix peer credentials require little-endian Linux or macOS arm64'
)
}
try {
const koffi =
await (options.loadKoffi?.() ??
import('koffi').then((module) => module.default))
if (platform === 'darwin') {
const library = koffi.load('/usr/lib/libSystem.B.dylib')
const getpeereid = library.func('getpeereid', 'int', [
'int', 'void *', 'void *'
])
return {
async getPeerIdentity(socket) {
const uid = Buffer.alloc(4)
const gid = Buffer.alloc(4)
if (getpeereid(socketDescriptor(socket), uid, gid) !== 0) {
throw new Error(`getpeereid failed with errno ${koffi.errno()}`)
}
return { uid: uid.readUInt32LE() }
}
}
}
return new LinuxPeerIdentityProvider(createNativeBinding(koffi))
} catch (error) {
if (error instanceof AgentUnsupportedError) {
+2 -2
View File
@@ -225,7 +225,7 @@ export async function createProductionRuntimeProtocol(
try {
if (
verificationEnvironment === 'production' &&
process.platform === 'linux'
(process.platform === 'linux' || process.platform === 'darwin')
) {
await (
options.reconcileOrphanedProcesses ??
@@ -436,7 +436,7 @@ export function derivePrivateModelBridgeDirectory(
controllerGeneration?: number
}
): string {
if (process.platform === 'linux') {
if (process.platform === 'linux' || process.platform === 'darwin') {
const stateDirectory = resolve(stateDirectoryInput)
ensurePrivateDirectory(stateDirectory, { create: false })
const userRoot = ensurePrivateTemporaryRoot(
@@ -1,5 +1,6 @@
import { readFile, readlink } from 'node:fs/promises'
import { posix } from 'node:path'
import { darwinBootId, inspectDarwinProcess } from './darwin-process-identity'
export type LinuxRuntimeProcessIdentity = {
bootId: string
@@ -72,6 +73,19 @@ export async function readLinuxRuntimeProcessIdentity(
options: { procRoot?: string; bootId?: string } = {}
): Promise<LinuxRuntimeProcessIdentity> {
const pid = positiveInteger(pidInput, 'PID')
if (process.platform === 'darwin') {
const identity = inspectDarwinProcess(pid)
if (identity === undefined) {
throw Object.assign(new Error('Runtime process no longer exists'), { code: 'ESRCH' })
}
return {
bootId: options.bootId ?? darwinBootId(),
pid,
startTimeTicks: BigInt(identity.starttime),
processGroupId: identity.processGroupId,
executablePath: identity.executablePath
}
}
const procRoot = options.procRoot ?? '/proc'
if (!posix.isAbsolute(procRoot) || procRoot.includes('\0')) {
throw new Error('Linux proc root must be absolute')
+94 -3
View File
@@ -3954,6 +3954,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000214'
],
knowledgeRetrievalMode: 'always',
title: '发布讨论',
updatedAt: 1_775_000_000_000,
@@ -4055,6 +4058,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000214'
],
knowledgeRetrievalMode: 'always',
messages: [
expect.objectContaining({
@@ -4076,8 +4082,7 @@ describe('AssistantDatabase', () => {
}),
expect.objectContaining({
role: 'assistant',
state: 'error',
status: expect.stringContaining('意外中断'),
state: 'streaming',
reasoning: '先分析发布范围',
contextCompression: {
state: 'completed',
@@ -4094,7 +4099,7 @@ describe('AssistantDatabase', () => {
type: 'tool',
tool: expect.objectContaining({
callId: 'call-1',
state: 'interrupted'
state: 'running'
})
}),
expect.objectContaining({
@@ -4442,6 +4447,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: channelDefaultProfileId
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000559'
],
knowledgeRetrievalMode: 'always',
contextMetrics: {
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
@@ -4550,6 +4558,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: channelDefaultProfileId
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000559'
],
knowledgeRetrievalMode: 'always',
branch: {
sourceConversationId,
@@ -4623,6 +4634,7 @@ describe('AssistantDatabase', () => {
id: branch.id,
projectId: branch.projectId,
runtimeSelection: branch.runtimeSelection,
knowledgeLibraryIds: branch.knowledgeLibraryIds,
knowledgeRetrievalMode: branch.knowledgeRetrievalMode,
branch: branch.branch,
title: '发布方案讨论 · 分支(独立更新)',
@@ -5424,6 +5436,85 @@ describe('AssistantDatabase', () => {
durable.close()
})
it('keeps a live streaming message streaming until startup recovery runs', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-live-streaming-read-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const conversationId =
'00000000-0000-4000-8000-000000000971'
const assistantMessageId =
'00000000-0000-4000-8000-000000000972'
const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
database.saveLocalConversations([
{
header: {
id: conversationId,
title: '流式进行中',
updatedAt: 1_775_000_000_000
},
messages: [
{
id: assistantMessageId,
role: 'assistant',
content: '正在回答',
createdAt: 1_775_000_000_001,
state: 'streaming',
status: '正在生成',
tools: [
{
callId: 'call-1',
name: 'read',
state: 'running',
summary: '正在调用'
}
]
}
]
}
])
const liveExpectation = {
id: assistantMessageId,
state: 'streaming',
status: '正在生成',
tools: [
expect.objectContaining({
callId: 'call-1',
state: 'running'
})
]
}
// Reading during a live run must not manufacture an interruption; the
// renderer keeps saving the streaming message while the run continues.
expect(
database.getConversation(conversationId).messages[0]
).toMatchObject(liveExpectation)
expect(
database.listConversations()[0]?.messages[0]
).toMatchObject(liveExpectation)
database.close()
const recovered = new AssistantDatabase(databasePath)
recovered.initialize('C:\\Workspace')
expect(
recovered.getConversation(conversationId).messages[0]
).toMatchObject({
id: assistantMessageId,
state: 'error',
status: '上次运行意外中断,可以重新发送问题',
tools: [
expect.objectContaining({
callId: 'call-1',
state: 'interrupted'
})
]
})
recovered.close()
})
it('keeps duplicate chat replies out of artifact listings', async () => {
const database = await createDatabase()
const project = database.listProjects()[0]!
+28 -43
View File
@@ -211,7 +211,6 @@ type MessageRow = {
state: ConversationSnapshot['messages'][number]['state']
metadata_json: string
created_at: string
remote_recoverable?: number
}
type MagicNoteRow = {
@@ -1033,6 +1032,7 @@ function interruptActiveToolBlocks(
}
const conversationContextStateSchema = conversationSnapshotSchema.pick({
knowledgeLibraryIds: true,
contextMetrics: true,
contextCompressionState: true
})
@@ -1041,7 +1041,7 @@ function parseConversationContextState(
value: string | null
): Pick<
ConversationSnapshot,
'contextMetrics' | 'contextCompressionState'
'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState'
> {
if (!value) {
return {}
@@ -1059,12 +1059,14 @@ function parseConversationContextState(
function serializeConversationContextState(
conversation: Pick<
ConversationSnapshot,
'contextMetrics' | 'contextCompressionState'
'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState'
>
): string | null {
return conversation.contextMetrics ||
return conversation.knowledgeLibraryIds !== undefined ||
conversation.contextMetrics ||
conversation.contextCompressionState
? JSON.stringify({
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState
})
@@ -1081,9 +1083,9 @@ function toConversationSnapshot(
runtimeSelection: parseRuntimeSelection(
conversation.runtime_selection_json
),
...parseConversationContextState(conversation.context_state_json),
knowledgeRetrievalMode:
conversation.knowledge_retrieval_mode ?? undefined,
...parseConversationContextState(conversation.context_state_json),
...(conversation.channel &&
conversation.conversation_type &&
conversation.account_display
@@ -1107,38 +1109,31 @@ function toConversationSnapshot(
: {}),
title: conversation.title,
updatedAt: Date.parse(conversation.updated_at),
// Startup recovery is the only authority for interruption: it persists
// the terminal state once. Deriving it while reading would also hit
// messages that are streaming right now, because the renderer keeps
// saving them during a live run.
messages: messages.map((message) => {
const metadata = JSON.parse(
message.metadata_json
) as MessageMetadata
const interrupted =
message.state === 'streaming' &&
message.remote_recoverable !== 1
return {
id: message.id,
queueItemId: metadata.queueItemId,
role: message.role,
content: message.content,
reasoning: metadata.reasoning,
blocks: interrupted
? interruptActiveToolBlocks(metadata.blocks)
: metadata.blocks,
blocks: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
createdAt:
metadata.createdAt ?? Date.parse(message.created_at),
state: interrupted ? ('error' as const) : message.state,
status: interrupted
? interruptedMessageStatus
: metadata.status,
state: message.state,
status: metadata.status,
contextCompression: metadata.contextCompression,
contextCompressions: metadata.contextCompressions,
tools: interrupted
? interruptActiveTools(metadata.tools)
: metadata.tools,
subagents: interrupted
? interruptActiveSubagents(metadata.subagents)
: metadata.subagents,
tools: metadata.tools,
subagents: metadata.subagents,
sources: metadata.sources,
sourceReferences: metadata.sourceReferences,
knowledgeRetrieval: metadata.knowledgeRetrieval,
@@ -2601,18 +2596,10 @@ export class AssistantDatabase {
.all() as ConversationRow[]
const messageStatement = database.prepare(
`SELECT id, conversation_id, role, content, state, metadata_json,
created_at, remote_recoverable
created_at
FROM (
SELECT id, conversation_id, role, content, state, metadata_json,
created_at, sequence,
EXISTS(
SELECT 1 FROM tasks
WHERE tasks.id = messages.request_id
AND tasks.remote_recoverable = 1
AND tasks.status IN (
'running', 'waiting_approval', 'interrupted'
)
) AS remote_recoverable
created_at, sequence
FROM messages
WHERE conversation_id = ?
ORDER BY sequence DESC
@@ -2648,18 +2635,10 @@ export class AssistantDatabase {
const messages = database
.prepare(
`SELECT id, conversation_id, role, content, state, metadata_json,
created_at, remote_recoverable
created_at
FROM (
SELECT id, conversation_id, role, content, state,
metadata_json, created_at, sequence,
EXISTS(
SELECT 1 FROM tasks
WHERE tasks.id = messages.request_id
AND tasks.remote_recoverable = 1
AND tasks.status IN (
'running', 'waiting_approval', 'interrupted'
)
) AS remote_recoverable
metadata_json, created_at, sequence
FROM messages
WHERE conversation_id = ?
ORDER BY sequence DESC
@@ -2992,7 +2971,7 @@ export class AssistantDatabase {
const source = database
.prepare(
`SELECT id, project_id, runtime_selection_json,
knowledge_retrieval_mode, title
knowledge_retrieval_mode, context_state_json, title
FROM conversations
WHERE id = ? AND status = 'active' AND channel IS NULL`
)
@@ -3002,6 +2981,7 @@ export class AssistantDatabase {
project_id: string | null
runtime_selection_json: string | null
knowledge_retrieval_mode: 'auto' | 'always' | null
context_state_json: string | null
title: string
}
| undefined
@@ -3052,13 +3032,18 @@ export class AssistantDatabase {
knowledge_retrieval_mode, context_state_json, work_mode,
title, branch_source_conversation_id, branch_source_title,
status, created_at, updated_at)
VALUES (?, ?, ?, ?, NULL, 'ask', ?, ?, ?, 'active', ?, ?)`
VALUES (?, ?, ?, ?, ?, 'ask', ?, ?, ?, 'active', ?, ?)`
)
.run(
destinationConversationId,
source.project_id,
source.runtime_selection_json,
source.knowledge_retrieval_mode,
serializeConversationContextState({
knowledgeLibraryIds:
parseConversationContextState(source.context_state_json)
.knowledgeLibraryIds
}),
input.title,
source.id,
source.title,
+6 -4
View File
@@ -1537,7 +1537,8 @@ describe('registerIpcHandlers SSH hosts', () => {
]
} as const
const agentPackageManager = {
getSnapshot: vi.fn(async () => agentPackageInventory),
getAllSnapshot: vi.fn(async () => agentPackageInventory),
forPlatform: vi.fn(() => agentPackageManager),
download: vi.fn(
async (
architecture: 'x64' | 'arm64',
@@ -1675,7 +1676,7 @@ describe('registerIpcHandlers SSH hosts', () => {
)?.(event, { refresh: true })
).resolves.toEqual(agentPackageInventory)
expect(
agentPackageManager.getSnapshot
agentPackageManager.getAllSnapshot
).toHaveBeenCalledWith({ refresh: true })
await expect(
electronMocks.handlers.get(
@@ -1690,6 +1691,7 @@ describe('registerIpcHandlers SSH hosts', () => {
ipcChannels.sshHostsAgentPackageProgress,
{
architecture: 'x64',
platform: 'linux',
phase: 'downloading',
completedBytes: 1,
totalBytes: 2
@@ -1902,8 +1904,8 @@ describe('registerIpcHandlers SSH hosts', () => {
})
).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
expect(
agentPackageManager.getSnapshot
).toHaveBeenCalledOnce()
agentPackageManager.getAllSnapshot
).toHaveBeenCalledTimes(3)
await expect(
electronMocks.handlers.get(
+51 -11
View File
@@ -74,6 +74,7 @@ import {
knowledgeChunkPageSchema,
knowledgeChunksListInputSchema,
knowledgeChunkUpdateInputSchema,
knowledgeDocumentOpenInputSchema,
knowledgeDocumentRebuildInputSchema,
knowledgeLibraryRebuildInputSchema,
knowledgeReferenceContextInputSchema,
@@ -892,6 +893,8 @@ function getKnowledgeSnapshot(
sourceCount: library.sourceCount,
documentCount: library.documentCount,
indexedDocumentCount: library.indexedDocumentCount,
processingDocumentCount: library.processingDocumentCount,
failedDocumentCount: library.failedDocumentCount,
retrievalSettings: library.retrievalSettings,
chunkingSettings: library.chunkingSettings,
chunkingRebuildRequired: library.chunkingRebuildRequired,
@@ -926,6 +929,9 @@ function getKnowledgeSnapshot(
name: document.title,
path: document.sourceLocation,
status: document.status,
textIndexStatus: document.textIndexStatus,
vectorIndexStatus: document.vectorIndexStatus,
graphIndexStatus: document.graphIndexStatus,
indexProgress: document.status === 'ready' ? 100 : 0,
chunkCount: document.chunkCount,
size: document.size,
@@ -5117,7 +5123,7 @@ export function registerIpcHandlers(
const request =
agentPackageInventoryRequestSchema.parse(input ?? {})
return agentPackageInventorySchema.parse(
await agentPackageManager.getSnapshot(request)
await agentPackageManager.getAllSnapshot(request)
)
}
)
@@ -5129,10 +5135,9 @@ export function registerIpcHandlers(
if (!agentPackageManager) {
throw new Error('Agent 包管理服务不可用')
}
const { architecture } =
const { architecture, platform } =
agentPackageArchitectureRequestSchema.parse(input)
return agentPackageInventorySchema.parse(
await agentPackageManager.download(
await agentPackageManager.forPlatform(platform).download(
architecture,
(progress) =>
sendValidatedProgress(
@@ -5142,7 +5147,7 @@ export function registerIpcHandlers(
progress
)
)
)
return agentPackageManager.getAllSnapshot()
}
)
@@ -5165,9 +5170,8 @@ export function registerIpcHandlers(
if (result.canceled || !archivePath) {
return undefined
}
return agentPackageInventorySchema.parse(
await agentPackageManager.importArchive(archivePath)
)
await agentPackageManager.importArchive(archivePath)
return agentPackageManager.getAllSnapshot()
}
)
@@ -5178,10 +5182,10 @@ export function registerIpcHandlers(
if (!agentPackageManager) {
throw new Error('Agent 包管理服务不可用')
}
const { architecture } =
const { architecture, platform } =
agentPackageArchitectureRequestSchema.parse(input)
const defaultPath =
await agentPackageManager.getExportArchiveName(
await agentPackageManager.forPlatform(platform).getExportArchiveName(
architecture
)
const result = await dialog.showSaveDialog(window, {
@@ -5198,7 +5202,7 @@ export function registerIpcHandlers(
const destination = result.filePath.endsWith('.gbagent')
? result.filePath
: `${result.filePath}.gbagent`
await agentPackageManager.exportArchive(
await agentPackageManager.forPlatform(platform).exportArchive(
architecture,
destination
)
@@ -7969,6 +7973,42 @@ export function registerIpcHandlers(
}
)
registerHandler(
ipcChannels.knowledgeOpenDocumentSource,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = knowledgeDocumentOpenInputSchema.parse(input)
const reference = knowledgeService.getDocumentSource(value)
if (!reference) {
throw new Error('文档来源不存在')
}
if (reference.source.type === 'url') {
const target = new URL(reference.source.location)
if (!['http:', 'https:'].includes(target.protocol)) {
throw new Error('文档来源 URL 协议不受支持')
}
await shell.openExternal(target.href)
return
}
const storedPath =
reference.document.sourceLocation ?? reference.source.location
if (!isAbsolute(storedPath)) {
throw new Error('文档来源路径无效')
}
if ((await lstat(storedPath)).isSymbolicLink()) {
throw new Error('文档来源不能是符号链接')
}
const targetPath = await realpath(storedPath)
if (!(await stat(targetPath)).isFile()) {
throw new Error('文档来源不是可打开的文件')
}
const openError = await shell.openPath(targetPath)
if (openError) {
throw new Error('无法打开文档来源')
}
}
)
registerHandler(
ipcChannels.knowledgeReferenceContext,
(event, input: unknown) => {
@@ -207,6 +207,51 @@ describe('KnowledgeDatabase', () => {
).toBe(true)
})
it('counts only ready documents as indexed and separates actionable states', async () => {
const { database } = await createDatabase()
const library = database.createKnowledgeBase({
name: 'Status counts',
storageMode: 'reference'
})
const source = database.upsertSource({
knowledgeBaseId: library.id,
type: 'file',
location: 'C:\\notes\\status.md',
displayName: 'status.md',
status: 'ready'
})
for (const [index, status] of [
'ready',
'queued',
'parsing',
'indexing',
'failed'
].entries()) {
database.upsertDocument(
{
knowledgeBaseId: library.id,
sourceId: source.id,
externalId: `status-${index}`,
title: `Status ${index}`,
metadata: { status }
},
[{
id: `status-${index}-chunk`,
ordinal: 0,
content: `status ${status}`
}]
)
}
expect(database.getKnowledgeBaseCounts().get(library.id)).toEqual({
sourceCount: 1,
documentCount: 5,
indexedDocumentCount: 1,
processingDocumentCount: 3,
failedDocumentCount: 1
})
})
it('normalizes legacy negative vector thresholds without losing settings', async () => {
const { database, path } = await createDatabase()
const library = database.createKnowledgeBase({
+18 -1
View File
@@ -846,6 +846,8 @@ export class KnowledgeDatabase {
sourceCount: number
documentCount: number
indexedDocumentCount: number
processingDocumentCount: number
failedDocumentCount: number
}
> {
const rows = this.requireDatabase()
@@ -855,8 +857,15 @@ export class KnowledgeDatabase {
COUNT(DISTINCT d.id) AS document_count,
COUNT(DISTINCT CASE
WHEN COALESCE(json_extract(d.metadata, '$.status'), 'ready')
<> 'failed'
= 'ready'
THEN d.id END) AS indexed_document_count
,COUNT(DISTINCT CASE
WHEN json_extract(d.metadata, '$.status')
IN ('queued', 'parsing', 'indexing')
THEN d.id END) AS processing_document_count
,COUNT(DISTINCT CASE
WHEN json_extract(d.metadata, '$.status') = 'failed'
THEN d.id END) AS failed_document_count
FROM knowledge_bases kb
LEFT JOIN knowledge_sources s ON s.knowledge_base_id = kb.id
LEFT JOIN documents d ON d.knowledge_base_id = kb.id
@@ -872,6 +881,14 @@ export class KnowledgeDatabase {
indexedDocumentCount: asNumber(
row,
'indexed_document_count'
),
processingDocumentCount: asNumber(
row,
'processing_document_count'
),
failedDocumentCount: asNumber(
row,
'failed_document_count'
)
}
])
@@ -7,6 +7,7 @@ import {
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { strToU8, zipSync } from 'fflate'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ExtractStructured } from './graph-extractor'
import { embeddingStorageProvider } from './embedding-provider-key'
@@ -17,6 +18,83 @@ import { UrlImporter } from './url-importer'
const temporaryDirectories: string[] = []
const services: KnowledgeService[] = []
function createRealDocxFixture(): Buffer {
return Buffer.from(
zipSync({
'[Content_Types].xml': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
'</Types>'
),
'_rels/.rels': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
'</Relationships>'
),
'word/document.xml': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
'<w:body>' +
'<w:p><w:r><w:t>蓝鲸发布流程</w:t></w:r></w:p>' +
'<w:p><w:r><w:t>发布前必须完成审批和回归测试。</w:t></w:r></w:p>' +
'<w:tbl><w:tr><w:tc><w:p><w:r><w:t>负责人</w:t></w:r></w:p></w:tc>' +
'<w:tc><w:p><w:r><w:t>产品团队</w:t></w:r></w:p></w:tc></w:tr></w:tbl>' +
'<w:sectPr/></w:body></w:document>'
),
'word/_rels/document.xml.rels': strToU8(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>'
)
})
)
}
function createRealPdfFixture(...pageTexts: string[]): Buffer {
const texts = pageTexts.length > 0 ? pageTexts : ['']
const fontObjectId = texts.length + 3
const firstContentObjectId = fontObjectId + 1
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
`<< /Type /Pages /Kids [${texts
.map((_, index) => `${index + 3} 0 R`)
.join(' ')}] /Count ${texts.length} >>`,
...texts.map(
(_, index) =>
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ' +
`/Resources << /Font << /F1 ${fontObjectId} 0 R >> >> ` +
`/Contents ${firstContentObjectId + index} 0 R >>`
),
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
...texts.map((text) => {
const stream = `BT /F1 14 Tf 72 720 Td (${text}) Tj ET`
return (
`<< /Length ${Buffer.byteLength(stream)} >>\n` +
`stream\n${stream}\nendstream`
)
})
]
let content = '%PDF-1.4\n'
const offsets = [0]
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(content))
content += `${index + 1} 0 obj\n${object}\nendobj\n`
}
const xrefOffset = Buffer.byteLength(content)
content += `xref\n0 ${objects.length + 1}\n`
content += '0000000000 65535 f \n'
content += offsets
.slice(1)
.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`)
.join('')
content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n`
content += `startxref\n${xrefOffset}\n%%EOF\n`
return Buffer.from(content)
}
async function createService(
urlImporter?: UrlImporter,
embeddingProvider?: EmbeddingProvider,
@@ -48,6 +126,83 @@ afterEach(async () => {
})
describe('KnowledgeService', () => {
it('imports real DOCX and PDF files from disk into searchable chunks', async () => {
const { directory, service } = await createService()
const docxPath = join(directory, '蓝鲸发布手册.docx')
const pdfPath = join(directory, 'Blue-Harbor-operations.pdf')
await Promise.all([
writeFile(docxPath, createRealDocxFixture()),
writeFile(
pdfPath,
createRealPdfFixture(
'Blue Harbor deployment requires a signed approval.',
'Rollback begins by restoring the verified package.'
)
)
])
const library = service.createLibrary({
name: '真实办公文档',
storageMode: 'reference',
graphEnabled: false
})
await service.importPaths(library.id, [docxPath, pdfPath])
const snapshot = service.snapshot(library.id)
expect(snapshot.libraries[0]).toMatchObject({
documentCount: 2,
indexedDocumentCount: 2,
processingDocumentCount: 0,
failedDocumentCount: 0
})
expect(snapshot.documents).toHaveLength(2)
expect(
snapshot.documents.every(
(document) =>
document.status === 'ready' &&
document.textIndexStatus === 'ready' &&
document.vectorIndexStatus === 'disabled'
)
).toBe(true)
const docxDocument = snapshot.documents.find(
(document) => document.sourceLocation === docxPath
)
const pdfDocument = snapshot.documents.find(
(document) => document.sourceLocation === pdfPath
)
expect(docxDocument).toBeDefined()
expect(pdfDocument).toBeDefined()
expect(
service.getDocumentSource({
knowledgeBaseId: library.id,
documentId: docxDocument!.id
})
).toMatchObject({
document: { sourceLocation: docxPath },
source: { knowledgeBaseId: library.id }
})
const docxChunks = service.database.listChunks(docxDocument!.id)
const pdfChunks = service.database.listChunks(pdfDocument!.id)
expect(docxChunks.map((chunk) => chunk.content).join('\n')).toContain(
'发布前必须完成审批和回归测试'
)
expect(docxChunks.some((chunk) => chunk.location?.includes('表格'))).toBe(
true
)
expect(pdfChunks.map((chunk) => chunk.location)).toEqual([
'第 1 页',
'第 2 页'
])
expect(service.search(library.id, '发布审批')[0]?.document.id).toBe(
docxDocument!.id
)
expect(
service.search(library.id, 'verified package')[0]?.document.id
).toBe(pdfDocument!.id)
})
it('uses document and query embedding roles without exposing providers to runtimes', async () => {
const embed = vi.fn(async (input: readonly string[]) =>
input.map(() => [9, 9])
+88 -2
View File
@@ -30,6 +30,8 @@ import {
knowledgeDocumentRebuildInputSchema,
knowledgeLibraryRebuildInputSchema,
knowledgeReferenceContextInputSchema,
knowledgeDocumentOpenInputSchema,
type KnowledgeDocumentOpenInput,
knowledgeRetrieveInputSchema,
knowledgeSettingsUpdateInputSchema,
type KnowledgeChunkDeleteInput,
@@ -109,6 +111,8 @@ export type KnowledgeLibrarySnapshot = KnowledgeBase & {
sourceCount: number
documentCount: number
indexedDocumentCount: number
processingDocumentCount: number
failedDocumentCount: number
}
export type KnowledgeSourceSnapshot = KnowledgeSource & {
@@ -120,6 +124,9 @@ export type KnowledgeSourceSnapshot = KnowledgeSource & {
export type KnowledgeDocumentSnapshot = Document & {
chunkCount: number
status: 'queued' | 'parsing' | 'indexing' | 'ready' | 'failed'
textIndexStatus: 'waiting' | 'ready' | 'failed'
vectorIndexStatus: 'disabled' | 'waiting' | 'ready' | 'failed' | 'missing'
graphIndexStatus: 'disabled' | 'on-demand' | 'waiting' | 'ready' | 'failed'
size?: number
error?: string
}
@@ -1067,7 +1074,9 @@ export class KnowledgeService {
const counts = libraryCounts.get(library.id) ?? {
sourceCount: 0,
documentCount: 0,
indexedDocumentCount: 0
indexedDocumentCount: 0,
processingDocumentCount: 0,
failedDocumentCount: 0
}
return {
...library,
@@ -1120,6 +1129,21 @@ export class KnowledgeService {
: undefined
}))
const chunkCounts = this.database.getDocumentChunkCounts(libraryId)
const tasks = this.database.listKnowledgeTasks(libraryId)
const library = libraries.find((item) => item.id === libraryId)
const graphTaskByDocument = new Map<
string,
KnowledgeTaskItem
>()
for (const task of tasks) {
if (
task.documentId &&
task.kind === 'graph' &&
!graphTaskByDocument.has(task.documentId)
) {
graphTaskByDocument.set(task.documentId, task)
}
}
const documents = libraryDocuments.map((document) => {
const status =
typeof document.metadata.status === 'string' &&
@@ -1132,6 +1156,49 @@ export class KnowledgeService {
...document,
chunkCount: chunkCounts.get(document.id) ?? 0,
status,
textIndexStatus:
status === 'ready'
? ('ready' as const)
: status === 'failed'
? ('failed' as const)
: ('waiting' as const),
vectorIndexStatus: (() => {
if (!this.embeddingProvider) {
return 'disabled' as const
}
if (status !== 'ready') {
return 'waiting' as const
}
const state = this.database.getEmbeddingIndexState(
document.id,
embeddingStorageProvider(this.embeddingProvider),
this.embeddingProvider.model
)
return state?.status === 'ready'
? ('ready' as const)
: state?.status === 'error'
? ('failed' as const)
: ('missing' as const)
})(),
graphIndexStatus: (() => {
if (!library?.graphEnabled) {
return 'disabled' as const
}
if (library.graphStrategy === 'ask') {
return 'on-demand' as const
}
if (status !== 'ready') {
return 'waiting' as const
}
const task = graphTaskByDocument.get(document.id)
if (task?.status === 'failed' || task?.status === 'interrupted') {
return 'failed' as const
}
if (task?.status === 'succeeded') {
return 'ready' as const
}
return 'waiting' as const
})(),
size:
typeof document.metadata.size === 'number'
? document.metadata.size
@@ -1150,7 +1217,7 @@ export class KnowledgeService {
entities: graph.entities,
relations: graph.relations,
evidence: graph.evidence,
tasks: this.database.listKnowledgeTasks(libraryId)
tasks
}
}
@@ -1698,6 +1765,25 @@ export class KnowledgeService {
}
}
getDocumentSource(rawInput: KnowledgeDocumentOpenInput) {
const input = knowledgeDocumentOpenInputSchema.parse(rawInput)
const document = this.database.getDocument(input.documentId)
if (
!document ||
document.knowledgeBaseId !== input.knowledgeBaseId
) {
return undefined
}
const source = this.database.getSource(document.sourceId)
if (
!source ||
source.knowledgeBaseId !== input.knowledgeBaseId
) {
return undefined
}
return { document, source }
}
async searchHybrid(
knowledgeBaseId: string,
query: string,
+20 -38
View File
@@ -1,3 +1,5 @@
import { binaryTargetArchitecture } from '../../shared/node/binary-target'
import type { AgentPlatform } from '../../shared/agent-target'
import {
createHash,
createPublicKey,
@@ -206,9 +208,10 @@ export async function verifyAgentBundleDirectory(
await assertElfArchitecture(
join(bundleDirectory, manifest.entrypoint.runtimePath),
manifest.arch,
'Agent Node runtime'
'Agent Node runtime',
manifest.platform
)
const koffiPaths = requiredKoffiPayloadPaths(manifest.arch)
const koffiPaths = requiredKoffiPayloadPaths(manifest.arch, manifest.platform)
assertKoffiManifest(
manifest,
runtimeLock.koffi.version,
@@ -218,7 +221,8 @@ export async function verifyAgentBundleDirectory(
await assertElfArchitecture(
join(bundleDirectory, ...nativePath.split('/')),
manifest.arch,
'Agent Koffi native binding'
'Agent Koffi native binding',
manifest.platform
)
}
return {
@@ -349,15 +353,19 @@ export function assertAgentManifestMatchesRuntimeLock(
}
function requiredKoffiPayloadPaths(
architecture: AgentArchitecture
architecture: AgentArchitecture,
platform: AgentPlatform
): {
required: string[]
native: string[]
} {
const packageRoot = 'lib/node_modules/koffi'
const nativePackage =
`@koromix/koffi-linux-${architecture}`
`@koromix/koffi-${platform}-${architecture}`
const nativeRoot = `lib/node_modules/${nativePackage}`
const native = platform === 'darwin'
? [`${nativeRoot}/darwin_${architecture}/koffi.node`]
: [`${nativeRoot}/linux_${architecture}/koffi.node`, `${nativeRoot}/musl_${architecture}/koffi.node`]
return {
required: [
`${packageRoot}/package.json`,
@@ -366,13 +374,9 @@ function requiredKoffiPayloadPaths(
`${packageRoot}/src/koffi/src/static.js`,
`${nativeRoot}/package.json`,
`${nativeRoot}/index.js`,
`${nativeRoot}/linux_${architecture}/koffi.node`,
`${nativeRoot}/musl_${architecture}/koffi.node`
...native
],
native: [
`${nativeRoot}/linux_${architecture}/koffi.node`,
`${nativeRoot}/musl_${architecture}/koffi.node`
]
native
}
}
@@ -390,7 +394,7 @@ function assertKoffiManifest(
}
}
const nativePackage =
`@koromix/koffi-linux-${manifest.arch}`
`@koromix/koffi-${manifest.platform}-${manifest.arch}`
const koffiLicense = manifest.licenses.find(
(license) => license.package === 'koffi'
)
@@ -614,14 +618,15 @@ function modeString(mode: number): string {
async function assertElfArchitecture(
filePath: string,
expectedArchitecture: AgentArchitecture,
description: string
description: string,
platform: AgentPlatform
): Promise<void> {
const handle = await open(filePath, 'r')
try {
const header = Buffer.alloc(64)
const { bytesRead } = await handle.read(header, 0, header.length, 0)
const actualArchitecture = detectElfArchitecture(
header.subarray(0, bytesRead)
const actualArchitecture = binaryTargetArchitecture(
header.subarray(0, bytesRead), platform
)
if (actualArchitecture !== expectedArchitecture) {
throw new Error(
@@ -633,29 +638,6 @@ async function assertElfArchitecture(
}
}
function detectElfArchitecture(
buffer: Buffer
): AgentArchitecture | undefined {
if (
buffer.length < 20 ||
buffer[0] !== 0x7f ||
buffer.toString('ascii', 1, 4) !== 'ELF' ||
(buffer[5] !== 1 && buffer[5] !== 2)
) {
return undefined
}
const machine =
buffer[5] === 2
? buffer.readUInt16BE(18)
: buffer.readUInt16LE(18)
if (machine === 62) {
return 'x64'
}
if (machine === 183) {
return 'arm64'
}
return undefined
}
function parseContract<T>(
schema: { safeParse(value: unknown): {
@@ -43,7 +43,7 @@ export type AgentInstallationIdentity = {
major: number
minor: number
}
platform: 'linux'
platform: 'linux' | 'darwin'
architecture: AgentArchitecture
supervisor: 'detached-on-demand'
}
@@ -239,7 +239,7 @@ export class AgentInstallationManager {
protocol: {
...(registeredProtocol ?? runtimeLock.protocol)
},
platform: 'linux',
platform: probe.platform,
architecture: registered.arch,
supervisor: 'detached-on-demand'
}
@@ -20,6 +20,7 @@ import {
} from '../../shared/agent-package-contracts'
import {
AgentPackageManager,
selectLatestCompatibleEntry,
type VerifiedRemoteAgentInstallCandidate
} from './agent-package-manager'
@@ -256,6 +257,22 @@ function response(
}
describe('AgentPackageManager remote install candidates', () => {
it('selects Linux and Darwin arm64 independently from one catalog', async () => {
await createFixture('mirror', (catalog) => {
for (const platform of ['linux', 'darwin'] as const) {
catalog.entries.push({
...catalog.entries[0]!,
platform,
architecture: 'arm64',
archive: `goodbuddy-agent-2.0.0-${platform}-arm64.gbagent`
})
}
const parsed = agentPackageCatalogSchema.parse(catalog)
expect(selectLatestCompatibleEntry(parsed, 'arm64', '2.0.0').platform).toBe('linux')
expect(selectLatestCompatibleEntry(parsed, 'arm64', '2.0.0', 'darwin').platform).toBe('darwin')
expect(() => selectLatestCompatibleEntry(parsed, 'x64', '2.0.0', 'darwin')).toThrow()
})
})
it('removes interrupted manager-owned staging on startup', async () => {
const fixture = await createFixture('mirror')
const packageRoot = join(
+88 -26
View File
@@ -16,6 +16,7 @@ import {
writeFile
} from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { agentPlatformSchema, type AgentPlatform } from '../../shared/agent-target'
import {
agentPackageArchiveName,
agentPackageCatalogSchema,
@@ -94,6 +95,7 @@ type CatalogState =
}
export type AgentPackageManagerOptions = {
platform?: AgentPlatform
userDataPath: string
desktopVersion: string
keyRegistryPath: string
@@ -104,7 +106,7 @@ export type AgentPackageManagerOptions = {
export type VerifiedRemoteAgentInstallCandidate = {
source: UpdateSource
platform: 'linux'
platform: AgentPlatform
architecture: AgentArchitecture
version: string
minimumDesktopVersion: string
@@ -161,6 +163,7 @@ export type AgentPackageArchiveLease = {
}
export type AcquireInstallArchiveOptions = {
platform?: AgentPlatform
signal?: AbortSignal
onProgress?: (progress: AgentPackageDownloadProgress) => void
}
@@ -185,7 +188,7 @@ function candidateFromCatalogEntry(
): VerifiedRemoteAgentInstallCandidate {
return {
source,
platform: 'linux',
platform: entry.platform,
architecture: entry.architecture,
version: entry.version,
minimumDesktopVersion: entry.minimumDesktopVersion,
@@ -209,7 +212,7 @@ function candidateFromVerifiedRecord(
const descriptor = record.verified.descriptor
return {
source,
platform: 'linux',
platform: descriptor.platform,
architecture: descriptor.architecture,
version: descriptor.version,
minimumDesktopVersion: descriptor.minimumDesktopVersion,
@@ -220,7 +223,8 @@ function candidateFromVerifiedRecord(
},
archive: agentPackageArchiveName(
descriptor.version,
descriptor.architecture
descriptor.architecture,
descriptor.platform
),
size,
sha256: record.archiveSha256,
@@ -229,6 +233,9 @@ function candidateFromVerifiedRecord(
}
export class AgentPackageManager {
readonly #options: AgentPackageManagerOptions
readonly #platform: AgentPlatform
readonly #platformManagers = new Map<AgentPlatform, AgentPackageManager>()
readonly #rootDirectory: string
readonly #desktopVersion: string
readonly #keyRegistryPath: string
@@ -250,6 +257,8 @@ export class AgentPackageManager {
#catalogState?: CatalogState
constructor(options: AgentPackageManagerOptions) {
this.#options = options
this.#platform = agentPlatformSchema.parse(options.platform ?? 'linux')
this.#rootDirectory = resolve(
options.userDataPath,
'remote-components',
@@ -263,6 +272,31 @@ export class AgentPackageManager {
this.#startupCleanup = this.#cleanupInterruptedArtifacts()
}
forPlatform(platform: AgentPlatform): AgentPackageManager {
if (platform === this.#platform) return this
let manager = this.#platformManagers.get(platform)
if (!manager) {
manager = new AgentPackageManager({ ...this.#options, platform })
this.#platformManagers.set(platform, manager)
}
return manager
}
async getAllSnapshot(options: { refresh?: boolean } = {}): Promise<AgentPackageInventory> {
const linux = await this.forPlatform('linux').getSnapshot(options)
const darwin = this.forPlatform('darwin')
darwin.#catalogState = this.forPlatform('linux').#catalogState
const mac = await darwin.getInventory(options)
return agentPackageInventorySchema.parse({
...linux,
entries: [...linux.entries, ...mac.entries]
})
}
#select(catalog: AgentPackageCatalog, architecture: AgentArchitecture, desktopVersion: string) {
return selectLatestCompatibleEntry(catalog, architecture, desktopVersion, this.#platform)
}
async getInventory(
options: { refresh?: boolean } = {}
): Promise<AgentPackageInventory> {
@@ -270,7 +304,9 @@ export class AgentPackageManager {
this.#installed.clear()
}
const entries = await Promise.all(
agentArchitectureSchema.options.map(async (architecture) => {
agentArchitectureSchema.options.filter(architecture =>
this.#platform === 'linux' || architecture === 'arm64'
).map(async (architecture) => {
let record: InstalledRecord | undefined
let state: 'not-downloaded' | 'verified' | 'invalid'
try {
@@ -283,7 +319,7 @@ export class AgentPackageManager {
}
const latestVersion = this.#latestVersion(architecture)
return {
platform: 'linux' as const,
platform: this.#platform,
architecture,
state,
version: record?.verified.descriptor.version ?? null,
@@ -334,8 +370,11 @@ export class AgentPackageManager {
async getRemoteEnvironmentCatalog(
architecture: AgentArchitecture,
options: { signal?: AbortSignal } = {}
options: { signal?: AbortSignal; platform?: AgentPlatform } = {}
): Promise<VerifiedRemoteAgentEnvironmentCatalog> {
if (options.platform && options.platform !== this.#platform) {
return this.forPlatform(options.platform).getRemoteEnvironmentCatalog(architecture, options)
}
options.signal?.throwIfAborted()
let entry: AgentPackageCatalogEntry
let source: UpdateSource | undefined
@@ -346,7 +385,7 @@ export class AgentPackageManager {
source,
options.signal
)
entry = selectLatestCompatibleEntry(
entry = this.#select(
catalog,
architecture,
this.#desktopVersion
@@ -406,8 +445,11 @@ export class AgentPackageManager {
*/
async getRemoteInstallCandidate(
architecture: AgentArchitecture,
options: { signal?: AbortSignal } = {}
options: { signal?: AbortSignal; platform?: AgentPlatform } = {}
): Promise<VerifiedRemoteAgentInstallCandidate> {
if (options.platform && options.platform !== this.#platform) {
return this.forPlatform(options.platform).getRemoteInstallCandidate(architecture, options)
}
options.signal?.throwIfAborted()
const source = await this.#getUpdateSource()
options.signal?.throwIfAborted()
@@ -415,7 +457,7 @@ export class AgentPackageManager {
source,
options.signal
)
const entry = selectLatestCompatibleEntry(
const entry = this.#select(
catalog,
architecture,
this.#desktopVersion
@@ -463,7 +505,7 @@ export class AgentPackageManager {
source,
signal
)
const entry = selectLatestCompatibleEntry(
const entry = this.#select(
catalog,
architecture,
this.#desktopVersion
@@ -484,6 +526,11 @@ export class AgentPackageManager {
expectedCandidate: VerifiedRemoteAgentInstallCandidate,
options: AcquireInstallArchiveOptions = {}
): Promise<AgentPackageArchiveLease> {
if (expectedCandidate.platform !== this.#platform) {
return this.forPlatform(expectedCandidate.platform).acquireInstallArchive(
architecture, expectedCandidate, options
)
}
return this.#runExclusive(
architecture,
async () => {
@@ -507,7 +554,7 @@ export class AgentPackageManager {
source,
options.signal
)
const entry = selectLatestCompatibleEntry(
const entry = this.#select(
catalog,
architecture,
this.#desktopVersion
@@ -534,6 +581,9 @@ export class AgentPackageManager {
architecture: AgentArchitecture,
options: AcquireInstallArchiveOptions = {}
): Promise<AgentPackageArchiveLease> {
if (options.platform && options.platform !== this.#platform) {
return this.forPlatform(options.platform).acquireGoodBuddyInstallArchive(architecture, options)
}
return this.#runExclusive(
architecture,
async () => {
@@ -562,7 +612,7 @@ export class AgentPackageManager {
}
let entry: AgentPackageCatalogEntry
if (this.#catalogState?.state === 'available') {
entry = selectLatestCompatibleEntry(
entry = this.#select(
this.#catalogState.catalog,
architecture,
this.#desktopVersion
@@ -584,7 +634,7 @@ export class AgentPackageManager {
source,
options.signal
)
entry = selectLatestCompatibleEntry(
entry = this.#select(
catalog,
architecture,
this.#desktopVersion
@@ -651,6 +701,7 @@ export class AgentPackageManager {
): Promise<boolean> {
const descriptor = record.verified.descriptor
if (
descriptor.platform === candidate.platform &&
descriptor.architecture === candidate.architecture &&
descriptor.version === candidate.version &&
descriptor.minimumDesktopVersion ===
@@ -831,11 +882,12 @@ export class AgentPackageManager {
)
const architecture =
installed.verified.descriptor.architecture
return await this.#runExclusive(
const manager = this.forPlatform(installed.verified.descriptor.platform)
return await manager.#runExclusive(
architecture,
async () => {
await this.#publishInstalled(installed, architecture)
return this.getInventory()
await manager.#publishInstalled(installed, architecture)
return manager.getInventory()
}
)
} finally {
@@ -918,7 +970,8 @@ export class AgentPackageManager {
.verified.descriptor
return agentPackageArchiveName(
descriptor.version,
descriptor.architecture
descriptor.architecture,
descriptor.platform
)
}
@@ -978,7 +1031,10 @@ export class AgentPackageManager {
desktopVersion: this.#desktopVersion,
trustedRegistry: await this.#loadTrustedRegistry()
})
if (packageContent.descriptor.version !== entry.name) {
if (
packageContent.descriptor.version !== entry.name ||
packageContent.descriptor.platform !== this.#platform
) {
continue
}
const archivePath = join(directory, 'package.gbagent')
@@ -1051,6 +1107,10 @@ export class AgentPackageManager {
architecture: AgentArchitecture,
catalogEntry?: AgentPackageCatalogEntry
): Promise<InstalledRecord> {
if (
installed.verified.descriptor.platform !== this.#platform ||
installed.verified.descriptor.architecture !== architecture
) throw new Error('Agent package does not match the selected platform and architecture')
const destination = join(
this.#architectureRoot(architecture),
installed.verified.descriptor.version
@@ -1318,7 +1378,7 @@ export class AgentPackageManager {
return null
}
try {
return selectLatestCompatibleEntry(
return this.#select(
this.#catalogState.catalog,
architecture,
this.#desktopVersion
@@ -1701,7 +1761,7 @@ export class AgentPackageManager {
await mkdir(this.#rootDirectory, { recursive: true })
const path = join(
this.#rootDirectory,
`.stage-linux-${architecture}-${randomUUID()}`
`.stage-${this.#platform}-${architecture}-${randomUUID()}`
)
await mkdir(path)
return path
@@ -1710,7 +1770,7 @@ export class AgentPackageManager {
async #cleanupInterruptedArtifacts(): Promise<void> {
await this.#removeOwnedTemporaryDirectories(
this.#rootDirectory,
/^\.stage-linux-(?:x64|arm64|import)-[0-9a-f-]{36}$/u
new RegExp(`^\\.stage-${this.#platform}-(?:x64|arm64|import)-[0-9a-f-]{36}$`, 'u')
)
await Promise.all(
agentArchitectureSchema.options.map((architecture) =>
@@ -1756,7 +1816,7 @@ export class AgentPackageManager {
#architectureRoot(
architecture: AgentArchitecture
): string {
return join(this.#rootDirectory, `linux-${architecture}`)
return join(this.#rootDirectory, `${this.#platform}-${architecture}`)
}
#loadTrustedRegistry(): Promise<AgentReleaseKeyRegistry> {
@@ -1825,10 +1885,10 @@ export class AgentPackageManager {
observer:
| ((progress: AgentPackageDownloadProgress) => void)
| undefined,
progress: AgentPackageDownloadProgress
progress: Omit<AgentPackageDownloadProgress, 'platform'>
): void {
try {
observer?.(agentPackageDownloadProgressSchema.parse(progress))
observer?.(agentPackageDownloadProgressSchema.parse({ ...progress, platform: this.#platform }))
} catch {
// Progress observers cannot alter the package operation.
}
@@ -1838,11 +1898,13 @@ export class AgentPackageManager {
export function selectLatestCompatibleEntry(
catalog: AgentPackageCatalog,
architecture: AgentArchitecture,
desktopVersion: string
desktopVersion: string,
platform: AgentPlatform = 'linux'
): AgentPackageCatalogEntry {
const entry = catalog.entries
.filter(
(candidate) =>
candidate.platform === platform &&
candidate.architecture === architecture &&
compareSemanticVersions(
desktopVersion,
@@ -188,6 +188,7 @@ export async function verifyExtractedAgentPackage(options: {
}
)
if (
agentBundle.manifest.platform !== descriptor.platform ||
agentBundle.manifest.agentVersion !== descriptor.version ||
agentBundle.manifest.protocol.major !==
descriptor.agentProtocol.major ||
@@ -231,6 +232,7 @@ export async function verifyExtractedAgentPackage(options: {
}
)
if (
runtimeBundle.manifest.platform !== descriptor.platform ||
runtimeBundle.manifest.runtimeVersion !==
descriptor.remoteRuntime.version ||
runtimeBundle.manifest.bundleDigest !==
@@ -63,6 +63,7 @@ const windowsReservedNamePattern =
type JsonRecord = Record<string, unknown>
type Architecture = 'x64' | 'arm64'
type Platform = 'linux' | 'darwin'
type Protocol = { major: number; minor: number }
type FileRecord = {
path: string
@@ -94,7 +95,7 @@ export type PackageInstallerAgentIdentity = {
agentVersion: string
manifestSha256: string
binaryDigest: string
platform: 'linux'
platform: Platform
architecture: Architecture
protocol: Protocol
supervisor: 'detached-on-demand'
@@ -107,7 +108,7 @@ export type PackageInstallerRuntimeIdentity = {
manifestDigest: string
runtimeAdapterDigest: string
acpCapabilitiesDigest: string
platform: 'linux'
platform: Platform
architecture: Architecture
protocol: Protocol
}
@@ -818,7 +819,7 @@ function assertOuterDescriptor(
descriptor.component !== 'agent' ||
!versionPattern.test(String(descriptor.version)) ||
!versionPattern.test(String(descriptor.minimumDesktopVersion)) ||
descriptor.platform !== 'linux' ||
!isSupportedTarget(descriptor.platform, architecture) ||
!['x64', 'arm64'].includes(String(architecture)) ||
typeof descriptor.signingKeyId !== 'string' ||
!digestPattern.test(String(descriptor.contentDigest)) ||
@@ -920,7 +921,7 @@ function assertAgentManifest(
manifest.formatVersion !== 1 ||
manifest.product !== 'GoodBuddy' ||
!versionPattern.test(String(manifest.agentVersion)) ||
manifest.platform !== 'linux' ||
!isSupportedTarget(manifest.platform, manifest.arch) ||
!['x64', 'arm64'].includes(String(manifest.arch)) ||
!isRecord(manifest.entrypoint) ||
!Array.isArray(manifest.licenses)
@@ -944,6 +945,7 @@ function assertAgentManifest(
lock.formatVersion !== 1 ||
manifest.agentVersion !== lock.agentVersion ||
manifest.arch !== descriptor.architecture ||
manifest.platform !== descriptor.platform ||
manifest.agentVersion !== descriptor.version ||
canonicalJson(agentProtocol) !== canonicalJson(descriptor.agentProtocol) ||
canonicalJson(agentProtocol) !== canonicalJson(lock.protocol)
@@ -1002,7 +1004,7 @@ function assertAgentManifest(
}
const architecture = manifest.arch as Architecture
const koffiPackageRoot = 'lib/node_modules/koffi'
const koffiNativePackage = `@koromix/koffi-linux-${architecture}`
const koffiNativePackage = `@koromix/koffi-${manifest.platform}-${architecture}`
const koffiNativeRoot =
`lib/node_modules/${koffiNativePackage}`
for (const required of [
@@ -1017,10 +1019,7 @@ function assertAgentManifest(
throw new Error(`Agent Koffi payload is missing: ${required}`)
}
}
for (const native of [
`lib/node_modules/@koromix/koffi-linux-${manifest.arch}/linux_${manifest.arch}/koffi.node`,
`lib/node_modules/@koromix/koffi-linux-${manifest.arch}/musl_${manifest.arch}/koffi.node`
]) {
for (const native of nativeKoffiPaths(manifest.platform as Platform, architecture)) {
if (!declared.has(native)) {
throw new Error(`Agent Koffi payload is missing: ${native}`)
}
@@ -1091,11 +1090,12 @@ function assertRuntimeManifest(
const runtime = isRecord(lock.runtimes) && isRecord(lock.runtimes.opencode)
? lock.runtimes.opencode
: undefined
const targetKey = descriptor.platform === 'darwin' ? 'darwin-arm64' : String(descriptor.architecture)
const target =
runtime !== undefined &&
isRecord(runtime.targets) &&
isRecord(runtime.targets[String(descriptor.architecture)])
? runtime.targets[String(descriptor.architecture)]
isRecord(runtime.targets[targetKey])
? runtime.targets[targetKey]
: undefined
const remote = descriptor.remoteRuntime as JsonRecord
if (
@@ -1103,7 +1103,7 @@ function assertRuntimeManifest(
manifest.product !== 'GoodBuddy' ||
manifest.runtimeId !== 'opencode' ||
manifest.provider !== 'opencode' ||
manifest.platform !== 'linux' ||
manifest.platform !== descriptor.platform ||
manifest.architecture !== descriptor.architecture ||
!versionPattern.test(String(manifest.runtimeVersion)) ||
!digestPattern.test(String(manifest.bundleDigest)) ||
@@ -1255,7 +1255,26 @@ function assertRuntimeManifest(
}
}
function elfArchitecture(bytes: Buffer): Architecture | undefined {
function isSupportedTarget(platform: unknown, architecture: unknown): boolean {
return (platform === 'linux' && (architecture === 'x64' || architecture === 'arm64')) ||
(platform === 'darwin' && architecture === 'arm64')
}
function nativeKoffiPaths(platform: Platform, architecture: Architecture): string[] {
const root = `lib/node_modules/@koromix/koffi-${platform}-${architecture}`
return platform === 'darwin'
? [`${root}/darwin_${architecture}/koffi.node`]
: [`${root}/linux_${architecture}/koffi.node`, `${root}/musl_${architecture}/koffi.node`]
}
function elfArchitecture(bytes: Buffer, platform: Platform): Architecture | undefined {
if (platform === 'darwin') {
if (bytes.length < 8) return undefined
const cpu = bytes.readUInt32LE(0) === 0xfeedfacf
? bytes.readUInt32LE(4)
: bytes.readUInt32BE(0) === 0xfeedfacf ? bytes.readUInt32BE(4) : 0
return cpu === 0x0100000c ? 'arm64' : undefined
}
if (
bytes.length < 20 ||
!bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
@@ -1273,32 +1292,16 @@ function elfArchitecture(bytes: Buffer): Architecture | undefined {
function assertExtractedArchitectures(
preparedRoot: string,
architecture: Architecture
architecture: Architecture,
platform: Platform
): void {
for (const path of [
join(preparedRoot, 'agent', 'node'),
join(
preparedRoot,
'agent',
'lib',
'node_modules',
`@koromix/koffi-linux-${architecture}`,
`linux_${architecture}`,
'koffi.node'
),
join(
preparedRoot,
'agent',
'lib',
'node_modules',
`@koromix/koffi-linux-${architecture}`,
`musl_${architecture}`,
'koffi.node'
),
...nativeKoffiPaths(platform, architecture).map(path => join(preparedRoot, 'agent', path)),
join(preparedRoot, 'runtime', 'bin', 'opencode')
]) {
if (
elfArchitecture(readFileHeader(path, 64)) !== architecture
elfArchitecture(readFileHeader(path, 64), platform) !== architecture
) {
throw new Error(`Installed executable architecture mismatch: ${basename(path)}`)
}
@@ -1518,7 +1521,7 @@ function verifyArchive(
agentVersion: String(agentManifest.agentVersion),
manifestSha256: agentManifestSha256,
binaryDigest: `sha256:${agentManifestSha256}`,
platform: 'linux',
platform: descriptor.platform as Platform,
architecture,
protocol: protocol(agentManifest.protocol, 'Agent protocol'),
supervisor: 'detached-on-demand'
@@ -1530,7 +1533,7 @@ function verifyArchive(
manifestDigest: runtime.manifestDigest,
runtimeAdapterDigest: String(runtime.manifest.adapterDigest),
acpCapabilitiesDigest: String(runtime.manifest.acpCapabilitiesDigest),
platform: 'linux',
platform: descriptor.platform as Platform,
architecture,
protocol: protocol(runtime.manifest.protocol, 'Runtime protocol')
}
@@ -1766,7 +1769,7 @@ function extractPayload(
} finally {
closeSync(handle)
}
assertExtractedArchitectures(temporary, verified.agent.architecture)
assertExtractedArchitectures(temporary, verified.agent.architecture, verified.agent.platform)
chmodSync(temporary, 0o700)
renameSync(temporary, destinationRoot)
} catch (error) {
@@ -2127,7 +2130,7 @@ function readPreparedState(path: string): PreparedState {
!sha256Pattern.test(String(agent.manifestSha256)) ||
agent.installationId !== `agent-${String(agent.manifestSha256)}` ||
agent.binaryDigest !== `sha256:${String(agent.manifestSha256)}` ||
agent.platform !== 'linux' ||
!isSupportedTarget(agent.platform, agent.architecture) ||
!['x64', 'arm64'].includes(String(agent.architecture)) ||
agent.supervisor !== 'detached-on-demand' ||
runtime.runtimeId !== 'opencode' ||
@@ -2136,7 +2139,7 @@ function readPreparedState(path: string): PreparedState {
!digestPattern.test(String(runtime.manifestDigest)) ||
!digestPattern.test(String(runtime.runtimeAdapterDigest)) ||
!digestPattern.test(String(runtime.acpCapabilitiesDigest)) ||
runtime.platform !== 'linux' ||
runtime.platform !== agent.platform ||
runtime.architecture !== agent.architecture
) {
throw new Error('Prepared package state identity is invalid')
@@ -2159,7 +2162,7 @@ function readPreparedState(path: string): PreparedState {
agentVersion: String(agent.agentVersion),
manifestSha256: String(agent.manifestSha256),
binaryDigest: String(agent.binaryDigest),
platform: 'linux',
platform: agent.platform as Platform,
architecture: agent.architecture as Architecture,
protocol: agentProtocol,
supervisor: 'detached-on-demand'
@@ -2171,7 +2174,7 @@ function readPreparedState(path: string): PreparedState {
manifestDigest: String(runtime.manifestDigest),
runtimeAdapterDigest: String(runtime.runtimeAdapterDigest),
acpCapabilitiesDigest: String(runtime.acpCapabilitiesDigest),
platform: 'linux',
platform: agent.platform as Platform,
architecture: runtime.architecture as Architecture,
protocol: runtimeProtocol
}
@@ -2322,7 +2325,7 @@ function assertPreparedIdentity(
agentVersion: String(agentManifest.agentVersion),
manifestSha256: agentManifestSha256,
binaryDigest: `sha256:${agentManifestSha256}`,
platform: 'linux',
platform: agentManifest.platform as Platform,
architecture,
protocol: protocol(agentManifest.protocol, 'Agent protocol'),
supervisor: 'detached-on-demand'
@@ -2336,7 +2339,7 @@ function assertPreparedIdentity(
acpCapabilitiesDigest: String(
runtime.manifest.acpCapabilitiesDigest
),
platform: 'linux',
platform: agentManifest.platform as Platform,
architecture,
protocol: protocol(
runtime.manifest.protocol,
@@ -48,7 +48,7 @@ type ManagedRemoteRuntimeIdentity = {
bundleDigest: string
runtimeAdapterDigest: string
acpCapabilitiesDigest: string
platform: 'linux'
platform: 'linux' | 'darwin'
architecture: 'x64' | 'arm64'
}
@@ -648,7 +648,7 @@ function runtimeIdentityFromCapabilities(
): ManagedRemoteRuntimeIdentity {
if (
installation.runtimeId !== 'opencode' ||
installation.platform !== 'linux'
(installation.platform !== 'linux' && !(installation.platform === 'darwin' && installation.architecture === 'arm64'))
) {
throw new Error(
'Remote Agent does not advertise the current Runtime'
@@ -660,7 +660,7 @@ function runtimeIdentityFromCapabilities(
bundleDigest: installation.bundleDigest,
runtimeAdapterDigest: installation.runtimeAdapterDigest,
acpCapabilitiesDigest: installation.acpCapabilitiesDigest,
platform: 'linux',
platform: installation.platform,
architecture: installation.architecture
}
assertRuntimeAdvertised(runtime, capabilities)
@@ -155,6 +155,7 @@ function harness(options: {
installationId: agentInstallationId,
binaryDigest: status.binaryDigest,
version: status.agentVersion,
platform: 'linux',
architecture,
protocolMajor: 1
},

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