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
mesalogo 482cd0c467 chore: prepare GoodBuddy 0.12.4 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 Agent Release / Authorize Agent release (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (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
Bump Desktop to 0.12.4 and the separately published GoodBuddy Agent to 0.11.18. Add the approved bilingual release notes for HTML previews, long-run and queue reliability, remote terminal-state convergence, and faster OpenCode follow-up on Linux ARM.

This candidate uses the standard six-platform x64/arm64 matrix and does not request a LoongArch experimental preview.
2026-09-05 10:01:45 +08:00
mesalogo 5a1afb00f7 fix: reuse Runtime after native inventory load
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
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
Native capability loading previously cold-started a temporary Runtime and disposed it immediately, so Linux ARM users paid the OpenCode startup cost again on their first request. Native inventory loading now uses the cached execution Runtime, preserving normal idle eviction and lifecycle cleanup.

Release note: 优化 Linux ARM 上切换 OpenCode 后的响应速度;原生能力清单与后续对话现在复用同一 Runtime,避免重复冷启动。
2026-09-05 09:45:23 +08:00
mesalogo a4b64a1641 feat: preview Agent HTML in conversations and unblock long replies
Agent replies that produced HTML could only be read as source, so users had
to copy the markup into an external file to see the result. Completed Agent
and Subagent output now renders complete HTML documents, single-root HTML
replies, and `html` / `htm` code blocks as an isolated static preview in
place. Each preview keeps a persistent notice that scripts and network are
disabled and offers the same icon actions as Mermaid diagrams: a source
toggle and a full-screen preview. The new "render HTML in conversations"
switch under Platform Features / General is on by default, applies to the
open conversation immediately, and falls back to HTML source when disabled.
Previews cannot run scripts, reach the network, submit forms, open windows,
or load remote media; author stylesheets and inline styles are preserved so
the intended layout survives, and remote resource loading is refused by the
preview CSP instead.

Direct-model replies no longer fail once they grow large. The byte caps on
streaming responses, single stream blocks, non-streaming replies, model tool
arguments, tool request context, aggregate tool results, and accumulated
answer text are removed, so users stop seeing "模型接口流式响应超过安全限制"
on long answers. Response length is now governed by the request timeout,
cancellation, and context compression. Image generation responses keep their
decodable-image validation.

A message sent after a failed or interrupted run stayed in the pending queue
instead of running. The conversation is now released whenever a run reaches
any terminal state, not only when the renderer reports readiness again, and a
dispatched queue item whose run never starts releases its reservation and
returns to pending. Later messages dispatch without extra user action.

Release note: Agent 回复中的 HTML 现在可直接在会话内预览,支持查看源码和全屏预览,默认开启并可在“平台功能 / 通用设置”关闭;直连模型的长回复不再因大小限制中断;上一次运行失败或被中断后,新发送的消息会立即执行而不再排队等待。
2026-09-05 09:10:44 +08:00
mesalogo 9588c4b901 fix: preserve long run activity and large previews
Long Runtime sessions could silently stop retaining tool and Subagent activity, truncate Run history details, and reject workspace previews over 256 KB. Run history now persists in Main SQLite without the former fixed record or detail caps, surfaces possible loss from legacy caches, and renders long histories in batches.

Local and remote workspace previews now load UTF-8-safe 256 KiB pages on demand instead of rejecting larger text files. The legacy migration, clear-data path, IPC contracts, tests, and feature documentation are updated with the resulting behavior.

Release note: 长时间 Runtime 运行现在会完整保留工具和 Subagent 活动;运行记录迁移到 SQLite,并提示旧版可能已丢失的内容。超过 256KB 的本地及远程文本文件可通过分页面预览继续加载。
2026-09-05 00:31:42 +08:00
mesalogo d180f63abd fix: converge remote run state on persisted terminal messages
After a managed SSH disconnect the renderer kept showing the remote
Agent as processing even though the recovered task had already been
persisted as completed. Local streaming copies shadowed the persisted
terminal state in mergePersistedConversations, and recovery completion
never asked the renderer to re-read conversations.

- Prefer the persisted terminal state over a local streaming message
- Settle active runs whose messages already reached a persisted terminal
  state and release the conversation queue once
- Refresh conversations on window focus and visibility changes
- Publish a conversation change before recovery reports completed/failed
2026-09-04 16:54:35 +08:00
188 changed files with 15101 additions and 1454 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` /
+40 -20
View File
@@ -32,9 +32,13 @@ otherwise.
Host heading and only the remote path on each project row.
- [x] **File, screenshot, window, and clipboard context**: Added to model
context only after explicit user selection.
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math, and
constrained Mermaid diagrams. Large diagrams can be zoomed, panned, or
viewed as source, and failed renders preserve the original diagram code.
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math,
constrained Mermaid diagrams, and static in-conversation HTML previews.
Complete HTML and HTML code blocks can be previewed in place after an Agent
reply finishes, with icon actions to reveal the source or open a full-screen
preview. The feature is on by default and can be disabled under Platform
Features / General; previews cannot run scripts, access the network, submit
forms, or open windows.
- [x] **AI response and full-conversation copy**: Completed AI responses expose
a bottom action that copies Markdown source without reasoning, tool logs, or
citation metadata. Full-conversation copy uses the same validated
@@ -56,8 +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
@@ -80,14 +84,16 @@ otherwise.
rather than entering the model-and-project execution cache. For managed
local OpenCode, this passive check validates the selected path and model
credential without starting a throwaway server; the explicit settings test,
native inventory, and first real use still perform full startup and health
checks. Execution Runtimes remain reusable per project. GoodBuddy-managed local OpenCode keeps
native inventory still performs full startup and health checks, then shares
that execution Runtime with the first real request instead of cold-starting
another server. Execution Runtimes remain reusable per project. GoodBuddy-managed local OpenCode keeps
requests in the same conversation ordered, while different conversations in
the same project and different projects can run in parallel. Request-scoped
dynamic MCP tools remain isolated through default wildcard disablement and
explicit current-request enablement. OpenCode and Continue runs do not stop
at a fixed tool-call or activity count, and exceeding local display capture
capacity truncates the displayed copy without cancelling the native task.
at a fixed tool-call or activity count, and every observed tool and Subagent
activity remains in the local conversation instead of being discarded while
the native task continues.
Interactive questions are answered only by foreground conversations; scheduled tasks, remote channels,
delegated work, and other background runs fail immediately with guidance to
rerun in the foreground
@@ -117,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
@@ -142,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.17`, while the current Desktop release candidate is `0.12.3`; 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
@@ -347,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
@@ -393,10 +409,14 @@ otherwise.
activity, and independent artifact files. Ordinary chat responses remain
only in the conversation and are no longer copied into Artifacts; existing
duplicate chat Markdown is hidden from the artifact list but not physically
deleted. Token usage is grouped by Runtime and model and normalizes the
different OpenAI-compatible and Anthropic Messages cache-reporting semantics
when showing cache hit rate. Activity is grouped by conversation and
collapsed by default so long histories do not fill the page.
deleted. Run history is stored in Main SQLite without the previous 500-item,
4,000-character, or 2 MB Renderer limits; the page renders it in batches,
and a persistent warning remains when a migrated legacy cache may already
have lost older records or detail. Token usage is grouped by Runtime and
model and normalizes the different OpenAI-compatible and Anthropic Messages
cache-reporting semantics when showing cache hit rate. Activity is grouped
by conversation and collapsed by default so long histories do not fill the
page.
- [x] **Task and custom-task experience**: Each product-level Task belongs to
one Conversation, while one Conversation can contain multiple Tasks. The
left conversation list exposes Task children through a leading expand
@@ -422,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
+6 -6
View File
@@ -18,7 +18,7 @@
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
- [x] **助手工作栏、多终端与可调布局**:右侧工作栏使用持久“+”能力目录和应用 Tab;用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
@@ -28,8 +28,8 @@
- [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 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,本机展示副本达到容量时只截断后续展示,不取消原生任务。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 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.17`,Desktop 发布候选为 `0.12.3`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node 和 OpenCode 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。macOS 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证;这不等于新版本已经发布。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
- [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
@@ -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 接口并用于语义检索。
@@ -71,10 +71,10 @@
### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;迁移旧缓存时若历史可能已被截断,会持续明确提示,不能把既有缺口伪装成完整。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单。“未来分区记忆”仍只是尚待独立设计的长期方向。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单;按分区回顾、候选生成和唤起条件仍待设计。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
- [ ] **通用监督**(规划中):通过固定监督栏目观察用户选择的会话、任务、自动化或实验对象,提供带证据的评论与人工介入请求,但不自动发言、批准工具或切换 Execute。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
+15 -2
View File
@@ -580,6 +580,13 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown
原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示
复制入口。
- 默认开启“会话 HTML 渲染”时,已完成 Agent 回复中的完整 HTML 与 `html` / `htm`
代码块在原位置显示隔离静态预览;用户消息、推理过程和流式中的回复不得执行 HTML。
每个预览持续标注脚本和网络已禁用,并在标题右侧复用与 Mermaid 图表相同的图标按钮组:
源码按钮使用代码图标并暴露 `aria-expanded`,全屏按钮使用放大图标打开统一 Modal。
全屏预览遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape 关闭和焦点恢复规则,
并沿用同一套清理与 Sandbox/CSP 边界。关闭平台功能后必须显示 HTML 源码,不能隐藏内容
或保留旧预览。
- 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行
或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态,
使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。
@@ -602,6 +609,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
- 消息流的滚动范围必须使用已渲染消息的真实高度。长会话可以按批次只挂载最近消息并由用户
加载更早内容,但不得用固定的消息占位高度替代实际布局,避免推理、工具列表或长正文被
排除在可滚动范围之外。
### 13.2 最近对话
@@ -633,7 +643,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 状态卡片使用统一状态令牌,不只依赖颜色。
- 保留“运行概览 / 待处理建议 / 报告与记录 / 心跳计划”四个同级页面。
- 智能心跳菜单入口是完整配置的权威位置;任务中心和设置中心不得复制同一 CRUD 表单。
- “未来分区记忆”仅为长期方向,数据、状态和页面尚未设计,不得显示占位入口。
- 分区记忆协同与唤起条件尚未实现,不得显示占位入口或平行的记忆类型。
### 13.5 Task Center
@@ -690,7 +700,10 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
更新,保存失败时保留最后确认状态。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
+7 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.17",
"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
+5 -2
View File
@@ -360,7 +360,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
面板提供:
- 有界目录树和文本文件预览。
- 有界目录树和分页文本文件预览;文件超过单页传输大小时先显示已读取内容,并允许用户继续加载后续内容,不因文件总大小超过单页上限而拒绝预览。
- 当前选择、规范化路径和执行空间。
- 可选 Git 状态、Diff 和仓库信息。
- 显式打开、下载副本或在终端中打开。
@@ -756,7 +756,10 @@ remote_operations
终端、进程与 Runtime;无法确认时标记 `interrupted` 或 `outcome_unknown`。
- 日志使用有界环形缓冲或分页持久化,不能无限写入 SQLite。
- Artifact 继续作为成果的权威实体,不把完整成果复制进工作栏状态。
- 当前 Renderer `localStorage` 活动记录不能作为 Runtime、监督或进程的权威来源。
- 运行记录由 Main SQLite 持久化,Renderer 只保留当前内存视图。升级时迁移旧
`localStorage` 记录;若旧缓存达到原数量、详情或总大小边界,页面持续说明更早记录或
部分详情可能已经丢失。新记录不再按固定活动数或详情字符数丢弃,长列表只做可继续展开的
分批渲染。
### 11.3 IPC 与 Preload
+2 -2
View File
@@ -11,8 +11,8 @@
## 1. 背景
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。其长期“未来分区记忆”
方向尚未设计,也不承担持续学习、模式挖掘或自动改进:
智能心跳可以生成摘要、后续任务和记忆候选,但这还不是完整学习。它与分区记忆的协同尚未
实施,也不承担持续学习、模式挖掘或自动改进:
- 候选是否改善未来行为没有评估。
- 一条反思是否会被检索和使用并不确定。
@@ -0,0 +1,29 @@
# 会话 HTML 渲染
会话 HTML 渲染让用户直接查看 Agent 回复中的网页结果,而不必先复制代码到外部文件。
该能力默认开启,也可以在“设置 > 平台功能 > 通用设置”中关闭。
## 功能边界
- 只渲染已完成的 Agent 正文和已完成的子 Agent 输出。
- 支持完整 HTML 文档、单一 HTML 根元素,以及 Markdown 中的 `html` / `htm` 代码块。
- 使用无脚本、无网络、无表单提交和无窗口操作权限的静态隔离预览。
- 每个预览提供源码切换和全屏预览两个图标操作;全屏预览沿用同一清理和隔离边界。
- 用户消息、推理过程和流式中的回复不执行 HTML;关闭功能后继续显示 HTML 源码。
- 本功能不把普通聊天回复复制为成果,也不替代助手工作栏中独立成果的预览流程。
## 文档
- [产品需求](./prd.md):功能范围、状态规则和验收标准。
- [技术设计](./technical-design.md):设置持久化、渲染边界与静态隔离方案。
- [实现进度](./progress.md):已验证实现和验证记录。
## 术语
- **HTML 源码**:Agent 回复中原始、可复制的 HTML 文本。
- **静态预览**:在隔离 `iframe` 中显示的 HTML;脚本、联网和外部操作均被禁用。
- **全屏预览**:在应用级 Modal 中显示同一份静态预览文档,用于查看完整页面布局。
全局聊天和设置界面规则由根目录的
[统一界面设计系统](../../../UI-DESIGN.md)定义。独立成果预览仍由
[助手工作栏](../assistant-workbar/README.md)负责。
@@ -0,0 +1,69 @@
# 会话 HTML 渲染产品需求
## 用户问题
Agent 可以生成网页、报告和交互原型的 HTML,但此前会话只能显示源码或忽略原始标签。
用户必须把内容复制到外部文件后才能查看结果,打断了会话内的阅读和迭代流程。
## 目标
- 让 Agent 生成的 HTML 在回复完成后直接显示为网页预览。
- 保留一键查看原始 HTML 的能力。
- 允许用户通过平台功能开关恢复为只看源码。
- 预览不取得 GoodBuddy Renderer 或外部网络能力。
## 非目标
- 不执行 Agent 提供的 JavaScript。
- 不提供联网网页、表单、弹窗或 Electron `webview`。
- 不在流式输出中反复加载不完整页面。
- 不自动把普通会话回复保存为成果。
## 功能需求
### FR-1 默认设置
“会话 HTML 渲染”是应用级持久设置,默认开启。用户可在“设置 > 平台功能 >
通用设置”中立即关闭或重新开启;保存失败时保留最后确认的状态并显示错误。
### FR-2 可识别输出
开启后,已完成 Agent 回复中的以下内容显示为静态预览:
1. 完整 HTML 文档。
2. 由单一 HTML 根元素构成的完整回复。
3. Markdown 中标记为 `html` 或 `htm` 的代码块。
普通 Markdown 继续按原有规则显示。一个回复中的多个 HTML 代码块各自保持原有位置。
### FR-3 回复状态和角色
- 已完成的主 Agent 正文和已完成的子 Agent 输出可以预览 HTML。
- 用户消息和 Agent 推理只显示文本或源码。
- 流式 Agent 正文在完成前显示源码,完成后原位切换为预览。
### FR-4 源码、全屏与关闭行为
每个预览持续显示“静态安全预览,脚本和网络已禁用”,并在标题右侧提供两个图标操作:
1. 源码按钮,就地展开或收起原始 HTML。
2. 全屏按钮,在应用级 Modal 中查看同一份预览文档。
嵌入预览使用固定高度并在框内滚动,需要更大画面时使用全屏预览。全屏预览可通过关闭
按钮、Escape 或点击遮罩退出,退出后焦点回到全屏按钮。关闭平台功能后,完整 HTML 回复
和 HTML 代码块继续显示源码,不能变成空白。
### FR-5 静态隔离
预览必须禁用脚本、网络请求、表单提交、嵌套页面、插件对象和窗口操作。远程链接和远程
媒体不得从预览加载;内联样式和有界的内联位图可用于还原静态视觉。
## 产品验收
- 新安装和从旧设置升级后,开关均为开启。
- 完整 HTML 和 fenced HTML 在已完成的 Agent 回复中显示预览。
- 关闭开关后,当前会话立即恢复显示源码,重启后选择仍保留。
- 流式回复、推理和用户消息从不执行 HTML。
- 预览可通过键盘访问源码和全屏按钮,并具有明确的 iframe 标题。
- 全屏预览隔离背景、初始聚焦关闭按钮,Escape 退出后焦点回到全屏按钮。
- 恶意脚本、事件属性、远程资源、表单和嵌套 frame 不进入可执行预览。
@@ -0,0 +1,39 @@
# 会话 HTML 渲染实现进度
## 已实现
- [x] 应用级持久开关,默认开启,并提供旧设置迁移。
- [x] 平台功能通用设置中的共享 Switch。
- [x] 已完成 Agent 回复和子 Agent 输出中的完整 HTML 与 fenced HTML 识别。
- [x] 隔离静态预览、持续安全说明和源码切换。
- [x] 预览保留作者 `<style>` 与 `style` 属性,样式不再被整块丢弃。
- [x] 与 Mermaid 一致的图标按钮组:源码切换与全屏预览 Modal。
- [x] 嵌入预览固定 480px 高度,源码面板同值上限。
- [x] 用户消息、推理、流式回复和关闭状态的源码边界。
- [x] 设置变更后当前会话立即重新渲染。
## 验证记录
2026-09-05:
- `npm run typecheck`:通过。
- 聚焦测试(应用设置、Markdown、会话时间线、设置中心、App 集成):306 项通过。
- 全量 `npm test`:`3493 passed, 55 skipped`。
- `npm run lint`:通过。
- `npm run build`:Main、Preload、Renderer 与控制面安装器均成功构建。
修复样式丢失后复验:
- 原实现只要 `<style>` 或 `style` 属性中出现 `@import` / `url()` 就整块删除,带渐变或
背景图的回复因此完全失去样式;现改为保留作者样式,外部资源加载仍由 iframe CSP 拒绝。
- `npx vitest run src/renderer/src/MarkdownRenderer.test.tsx`:`19 passed`。
- 聚焦测试(会话时间线、App 集成、设置中心):`260 passed`。
- `npm run typecheck`、`npm run lint`:通过。
新增全屏预览与图标化源码按钮后复验:
- 源码按钮改为代码图标并暴露 `aria-expanded`;新增放大图标打开全屏 Modal。
- 嵌入预览高度由 `min-height: 320px` 改为固定 `height: 480px`,源码面板上限同步为 480px。
- 新增回归测试覆盖全屏 Modal 的初始焦点、背景 inert、Sandbox/CSP 与 Escape 焦点恢复。
- 聚焦测试(Markdown、会话时间线、App 集成、设置中心):`280 passed`。
- `npm run typecheck`、`npm run lint`:通过。
@@ -0,0 +1,68 @@
# 会话 HTML 渲染技术设计
## 进程和设置边界
`conversationHtmlRenderingEnabled` 属于现有 `ApplicationSettings`。Main 继续通过
版本化 JSON 文件持久化应用设置,IPC 继续复用已验证的
`settings:application:get/update` 通道,Preload 只暴露类型化设置方法。
应用设置格式升级到版本 11。版本 10 及更早设置在读取时补入默认值 `true`,无需保留第二份
状态或独立迁移文件。
## Renderer 数据流
1. `App` 启动时读取应用设置,并把当前值传给每个保活的会话视图。
2. 平台功能 Switch 保存成功后立即更新 `App` 状态。
3. `ChatTimeline` 只为 `role === "assistant"` 且 `state === "complete"` 的正文传入
HTML 渲染权限;推理始终不传入该权限。
4. `MarkdownRenderer` 识别完整 HTML 回复以及 `html` / `htm` fenced code。
5. 关闭权限或回复仍在流式阶段时,HTML 保持代码文本。
## 静态预览
`StaticHtmlPreview` 不使用 `dangerouslySetInnerHTML`。它先用 DOMPurify 清理文档,再通过
`DOMParser` 移除事件属性、非锚点 `href` 和非内联图片的 `src` / `poster`,最后把结果写入
`<iframe sandbox="" srcDoc>`。
作者样式必须保留:`<style>` 元素和 `style` 属性原样传入预览,不按 `@import` 或 `url()`
关键字整块删除,否则任何带渐变或背景图的回复都会退化成无样式文档。外部样式表和远程
字体、图片的实际加载由 iframe 文档的 CSP 拒绝,不需要在清理阶段重复拦截。
iframe 文档注入严格 CSP:
- `default-src 'none'`
- `script-src 'none'`
- `connect-src 'none'`
- `frame-src 'none'`
- `object-src 'none'`
- `form-action 'none'`
- 只允许内联样式以及 `data:` 字体和受支持的内联位图
同时设置 `referrerPolicy="no-referrer"`。Sandbox 不授予 scripts、forms、popups、
same-origin 或 navigation 权限。预览只在回复完成后创建,并使用 lazy loading。
## UI 与无障碍
预览沿用聊天阅读宽度、语义颜色和共享 `icon-button`。`figure` / `figcaption` 表达预览及其
说明,iframe 具有本地化标题。标题右侧是一个 `role="group"` 图标按钮组,与 Mermaid 图表
操作保持一致:源码按钮暴露 `aria-expanded` 与 `aria-controls`,全屏按钮打开 Modal。
嵌入预览高度固定为 480px,超出部分在框内滚动;查看源码面板使用同一数值作为最大高度。
iframe 无法按内容自适应高度,因为 `sandbox=""` 不含 `allow-same-origin` 或 `allow-scripts`,
主文档既读不到内部文档尺寸,也不能接收上报;全屏预览覆盖需要更多空间的场景。
全屏预览复用 `dialog-focus` 的 `activateModalFocus` 与 `trapTabFocus`,遵循统一 Modal 的
背景 inert、初始焦点落在关闭按钮、Tab 循环、Escape 关闭和关闭后焦点恢复。它渲染的是与
嵌入预览完全相同的清理后文档,不额外放宽 Sandbox 或 CSP。切换回复时,源码面板与全屏
预览按当前 source 派生开合状态,不使用会引发级联渲染的重置 effect。
## 验证范围
- 应用设置默认值、关闭持久化和版本 10 迁移。
- fenced HTML、完整 HTML、关闭后的源码回退。
- 作者 `<style>` 与 `style` 属性在预览中保留。
- 脚本、远程资源、表单和事件属性清理,以及 iframe Sandbox/CSP。
- 源码按钮的 `aria-expanded` 切换,以及全屏预览的初始焦点、背景 inert、Escape 关闭和
焦点恢复。
- 主 Agent、子 Agent、用户、推理和流式状态边界。
- 从设置 Switch 到当前会话重新渲染的应用级集成路径。
@@ -338,7 +338,7 @@ GoodBuddy conversationId -> Harness sessionId + process generation
### 9.4 释放与退出
- 原生能力清单通过一次性 Runtime 探测,取得有界快照后立即 dispose,不得因浏览设置或切换项目把 Host 缓存在执行 Runtime 池中。
- 原生能力清单由 `SelectedRuntimeManager` 缓存的执行 Runtime 提供,与随后的对话请求共用同一实例,不再为清单单独启动并销毁一次 Host;该实例遵循执行缓存的空闲淘汰、设置变更 reset 和退出 dispose 规则。
- 删除或释放对话时调用 `goodbuddy/session/release`。
- Runtime dispose 时先拒绝新请求,再取消所有 Session。
- Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。
+14 -2
View File
@@ -6,8 +6,8 @@
| --- | --- |
| 文档类型 | 功能进度 |
| 状态 | 已实施,本机验收完成 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 版本 | 0.4 |
| 日期 | 2026-09-05 |
| 关联入口 | [直连模型 Agent 能力](./README.md) |
## 当前结论
@@ -126,6 +126,18 @@
- `npm run build`:Main、Preload、Renderer 和控制面安装器均成功构建。2026-09-02 按产品
决策移除未对应实际性能问题的 Renderer bundle 基线门禁后复验通过。
## 2026-09-05 移除直连模型响应字节上限
- 直连模型不再对流式响应总量、单个流式块、非流式回复、模型工具参数、工具请求上下文、
聚合工具结果和累计回答长度设置字节上限;长回复和大工具结果由请求超时、取消和上下文
压缩控制。用户不再看到“模型接口流式响应超过安全限制”。
- 图像生成响应仍按可解码图片大小校验,Provider 错误响应仍读取有界文本。
- 回归测试改为验证超过旧上限的长流式回复可以完整返回,以及超过 1 MB 的图片工具结果会
进入下一轮模型请求。
- `npx vitest run src/main/agent/model-runtime.test.ts`:`74 passed, 1 skipped`。
- 全量 `npm test`:`3493 passed, 55 skipped`。
- `npm run typecheck`、`npm run lint` 和 `npm run build`:通过。
## 进度维护要求
- 只有真实生产路径完成并验证后才能勾选实施项。
@@ -327,11 +327,15 @@ type SubagentActor =
不要用宽泛 catch 抹掉取消、超时、Provider HTTP 状态或进程退出上下文。
模型请求重试由 `ModelAgentRuntime` 的统一流式请求边界处理,覆盖普通问答、工具轮次和复用
模型请求重试由 `ModelAgentRuntime` 的统一流式请求处理,覆盖普通问答、工具轮次和复用
同一 Runtime 的上下文摘要。重试分类检查错误及有界 `cause` 链中的 Node/Undici 网络错误
码;每次尝试创建独立超时信号。退避等待监听父请求取消。只有完整模型轮次返回后才执行
工具,因此未产生可见文本或推理内容的重试不会重复执行工具。
模型回复本身不设固定字节上限:流式响应、非流式回复、模型工具参数、请求上下文和工具
结果总量都由请求超时、取消和上下文压缩控制,长回复和大工具结果不会被中断。图像生成
响应仍按可解码图片大小校验。
## 11. 性能
- Shell 探测按执行服务实例缓存,不在每轮模型调用重复探测。
+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。
+60 -9
View File
@@ -2,7 +2,7 @@
## 状态
本文记录截至 2026-09-03 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
本文记录截至 2026-09-05 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线,
详细事务与验收边界见
[SSH Host 远程环境准备与直连下载设计](./environment-provisioning-technical-design.md);
@@ -12,7 +12,7 @@ Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
Windows 到 Linux x64 的安装、Agent-owned Prompt、Agent 本地模型 gateway、断线恢复、
同一 OpenCode Session 续接、取消和终态清理已经使用真实模型与工具验证。Agent
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前未发布
Agent 源码 lock 为 `0.11.17`,Desktop 候选为 `0.12.3`,等待各自独立发布审批。
Agent 源码 lock 为 `0.11.19`,Desktop 候选为 `0.12.5`,等待各自独立发布审批。
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
@@ -168,7 +168,7 @@ Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、
- 选择目录只更新项目草稿,仍需通过正常的 Agent、Workspace、Runtime 验证和项目保存事务才会持久化。
- Ask Workspace handle 不暴露写入方法。
- Execute Workspace handle 可读写,但这不是 Execute 的唯一权限面;Execute Runtime 本身使用 SSH 账号的正常权限。
- 文件预览、搜索、Git diff、目录项和显式文件传输保持字节、条目数和路径长度上限。
- 文件预览按有界页面传输并保持 UTF-8 字符边界;超过单页大小时先返回当前页,用户可继续加载,不把单页传输上限误作文件总预览上限。搜索、Git diff、目录项和显式文件传输继续保持各自的字节、条目数和路径长度上限。
## Runtime
@@ -225,11 +225,11 @@ Execute 直接启动已签名 Runtime:
调度、跨 channel 超车、批发送或第二套拥塞控制。
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
- Runtime 事件仍对单项展示字段做有界截断,但不按固定工具调用数或 Desktop 累计展示量
中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生 Runtime。
Renderer 和 Main 为每条回复保留最多 500 条工具与子 Agent 活动以及 2,000 个交错
展示块;超过后显示本地截断提示,但继续执行 Runtime,也不把展示容量当作 Prompt
工具调用上限。已保留活动的后续状态更新仍会正常归并。
- Runtime 事件仍对协议定义的单项传输字段应用既有边界,但不按固定工具调用数或 Desktop
累计展示量中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生
Runtime。Main 和 Renderer 不再按 500 条工具/子 Agent 活动或 2,000 个交错展示块丢弃
对话详情;独立运行记录也改由 Main SQLite 完整保存并按批渲染。旧版已经触发本地上限的
历史继续显示可能不完整的提示,不能通过迁移隐藏既有数据缺口。
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
@@ -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.17`;正式发布状态以独立 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
@@ -413,6 +460,10 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
Host 的唯一 `/tmp/goodbuddy-e2e-*` 目录完成 Runtime 激活和 Agent 本机模型 gateway
验证。模型 gateway 通过所选 Anthropic 连接返回预期随机哨兵,HTTP 状态为 200,实际
文本模型调用恰好 1 次;Host 临时目录随后删除,共享 Agent/Runtime 安装和进程未修改。
- 2026-09-04 使用当前源码 Desktop 和共享 Linux x64 Host 的既有 Agent/Workspace
生产链路验证分页文件预览。专用测试文本共 262,166 bytes,首个 256 KiB 页面在三字节
UTF-8 字符前回退并显示 262,143 bytes;继续加载后完整追加剩余文本,页面不再显示加载
按钮且没有错误。测试未调用模型;专用远端文件、目录和本机隔离用户数据均已清理。
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
+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] 加载失败、空状态、字段错误和危险操作满足统一设计与无障碍要求。
@@ -231,8 +231,10 @@ type TimeTrigger =
同一 Conversation 的普通消息和 Scheduled Task occurrence 使用同一 FIFO 队列。Agent
正在回复时,到期 occurrence 只显示为待执行,不中断当前输出;当前执行结束后才认领下一项。
用户显式选择“立即中断并插入”时,系统取消当前 Conversation 的活动请求,并让所选项成为
下一项。删除待执行 occurrence 只取消该次运行,不删除稳定 Task、Conversation 或历史结果。
执行以失败、取消或中断结束时同样立即释放该 Conversation,下一项无需用户再次操作即会
执行。用户显式选择“立即中断并插入”时,系统取消当前 Conversation 的活动请求,并让所选项
成为下一项。删除待执行 occurrence 只取消该次运行,不删除稳定 Task、Conversation 或历史
结果。
## 8. 一次触发的对象关系
@@ -365,6 +367,8 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模
- [x] 当前回复期间可以继续发送普通消息,并在 Composer 上沿查看、删除或提升待发送项。
- [x] 应用重启恢复尚未执行的队列项和有界附件上下文;已写入 Conversation 的用户输入
通过队列项身份完成恢复对账,不会再次进入待发送队列。
- [x] 执行失败、取消或中断后立即释放会话,下一项和随后发送的新消息无需用户额外操作
即会执行。
- [x] 文本结果只写入 Conversation,独立交付物才进入成果。
- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。
- [ ] 应用重启不自动重放结果未知的副作用。
+5 -4
View File
@@ -169,8 +169,8 @@ GoodBuddy 应能够:
- 当前智能心跳继续提供周期回顾、报告、记忆建议、行动建议和运行历史。
- “智能心跳”菜单入口负责完整配置,并支持 Global 或指定一个、多个 Project。
- 任务中心和设置中心不复制智能心跳 CRUD。
- 智能心跳长期方向是“未来分区记忆”,但数据结构、唤起模型、生命周期、页面以及与任务和
长期记忆的关系尚未设计,不能提前实现。
- 智能心跳长期按分区回顾信息并提出记忆候选或唤起建议,但协同规则、唤起条件、生命周期、
页面以及与任务的关系尚未设计,不能提前实现平行的记忆类型或页面。
### 4.6 成果和预览
@@ -277,7 +277,8 @@ GoodBuddy 应能够:
### 阶段 0:持久化基础
- 新增 `assistant.sqlite` 和迁移框架。
- 将会话与活动从 `localStorage` 迁移到主进程数据库。
- 会话与运行记录已从 `localStorage` 迁移到主进程数据库;旧运行记录可能触及历史上限时
保留可见的不完整提示。
- 拆分共享契约和 IPC 注册。
- 保持现有对话、知识库、设置和审批行为不变。
@@ -354,7 +355,7 @@ GoodBuddy 应能够:
- 用户删除后不再检索到。
- 每条记忆显示来源与作用域。
- 智能心跳配置明确属于 Global 或指定 Project,当前报告和建议继续沿用已有生命周期。
- 未来分区记忆完成独立设计前,不新增相关数据、页面或任务转换。
- 分区记忆协同和唤起条件完成设计前,不新增相关数据、页面或任务转换。
### 8.5 安全
+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.3",
"version": "0.12.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.12.3",
"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.3",
"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=="
+84
View File
@@ -1,6 +1,90 @@
{
"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",
"notes": {
"zh-CN": {
"highlights": [
"GoodBuddy 0.12.4 新增会话内 HTML 静态预览,并改善长任务活动、大文件预览、长回复与消息队列、远程任务终态,以及 Linux ARM 上的 OpenCode 后续响应速度。"
],
"features": [
"**会话内 HTML 静态预览。** Agent 和 Subagent 完成回复后,完整 HTML 文档及 HTML 代码块可直接在会话中预览,并支持查看源码和全屏展示。该能力默认开启,可在“平台功能 / 通用设置”中关闭;预览不会执行脚本、访问网络、提交表单或打开窗口。"
],
"fixes": [
"**长任务活动与大文件预览更完整。** Runtime 工具和 Subagent 活动现在持久保存在 Main SQLite 中,不再受旧版条目数、详情长度或缓存总量限制;旧数据可能已被截断时会明确提示。本地和远程超过 256 KiB 的 UTF-8 文本文件可按页继续加载,不再直接拒绝预览。",
"**长回复和消息队列不再意外中断。** 直连模型的流式及非流式长回复、工具参数和累计答案不再因固定字节上限失败。上一次运行失败、被中断或未成功启动后,后续消息会释放并继续调度,不再长期停留在等待队列。",
"**远程任务终态显示更准确。** 托管 SSH 任务断线恢复后,已持久化的完成或失败状态会覆盖旧的本地流式状态;窗口重新聚焦或恢复可见时也会重新读取会话,避免 Agent 已结束但界面仍显示处理中。",
"**Linux ARM 上 OpenCode 后续响应更快。** 原生能力清单与后续对话请求现在复用同一个 Runtime,不再先启动并销毁临时 OpenCode Server、再为首条消息重复冷启动。"
],
"notices": [
"**远程项目仍为技术预览。** Host 需要单独发布的 Agent `0.11.18`;Desktop `0.12.4` 不内置 Agent 或远程 Runtime。Ask 保持只读,Execute 表示授权所选 SSH 账号可用的完整工具、进程、网络和写入权限。",
"**HTML 预览是静态展示。** 预览保留作者样式,但禁止脚本和外部资源;关闭该功能后继续显示 HTML 源码。",
"**发布平台。** GoodBuddy 使用 Windows、macOS、Linux 的 `x64`/`arm64` 标准发布矩阵。本次不构建独立的 LoongArch 实验预览。"
]
},
"en-US": {
"highlights": [
"GoodBuddy 0.12.4 adds static HTML previews in conversations and improves long-run activity, large-file previews, long replies and message queues, remote terminal state, and OpenCode follow-up speed on Linux ARM."
],
"features": [
"**Static HTML previews in conversations.** Completed Agent and Subagent replies can render complete HTML documents and HTML code blocks directly in the conversation, with source and full-screen views. The feature is enabled by default and can be disabled under Platform Features / General; previews cannot run scripts, access the network, submit forms, or open windows."
],
"fixes": [
"**Complete long-run activity and large-file previews.** Runtime tool and Subagent activity is now persisted in Main SQLite without the former item-count, detail-length, or total-cache limits, with a clear warning when legacy data may already have been truncated. Local and remote UTF-8 text files larger than 256 KiB can continue loading page by page instead of being rejected.",
"**Long replies and message queues no longer stop unexpectedly.** Direct-model streaming and non-streaming replies, tool arguments, and accumulated answers no longer fail at fixed byte limits. After a previous run fails, is interrupted, or never starts, later messages are released and dispatched instead of remaining queued indefinitely.",
"**Accurate terminal state after remote recovery.** After a managed SSH disconnect, persisted completion or failure now replaces stale local streaming state. Conversations are also refreshed when the window regains focus or visibility, avoiding a Processing state after the Agent has already finished.",
"**Faster follow-up after switching to OpenCode on Linux ARM.** Native capability discovery and later conversation requests now share one Runtime instead of starting and destroying a temporary OpenCode Server before cold-starting another one for the first message."
],
"notices": [
"**Remote Projects remain a technical preview.** Hosts require the separately published Agent `0.11.18`; Desktop `0.12.4` does not embed the Agent or Remote Runtime. Ask remains read-only, while Execute authorizes the full tools, processes, network access, and writable paths available to the selected SSH account.",
"**HTML previews are static.** Author styles are preserved, but scripts and external resources are blocked. Disabling the feature keeps the HTML source visible.",
"**Release platforms.** GoodBuddy uses the standard Windows, macOS, and Linux `x64`/`arm64` release matrix. This release does not include a separate experimental LoongArch preview."
]
}
}
},
{
"version": "0.12.3",
"releasedAt": "2026-09-04",
+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')
+105 -62
View File
@@ -1788,19 +1788,41 @@ describe('ModelAgentRuntime', () => {
}
})
it('bounds the total ordinary streaming response size', async () => {
const chunk = new TextEncoder().encode(
`data: ${JSON.stringify({
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: 'x'.repeat(65_000)
}
})}\n\n`
)
it('streams long ordinary responses without a size limit', async () => {
const encoder = new TextEncoder()
const deltaChunks = 48
const deltaText = 'x'.repeat(65_000)
const body = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(chunk)
start(controller) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'message_start',
message: {
id: 'message-long',
model: 'sonnet-5',
usage: { input_tokens: 5 }
}
})}\n\n`
)
)
for (let index = 0; index < deltaChunks; index += 1) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: deltaText
}
})}\n\n`
)
)
}
controller.enqueue(
encoder.encode('data: {"type":"message_stop"}\n\n')
)
controller.close()
}
})
const runtime = new ModelAgentRuntime({
@@ -1816,22 +1838,26 @@ describe('ModelAgentRuntime', () => {
})
)
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
void _event
let streamedText = ''
let done = false
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
if (event.type === 'text') {
streamedText += event.delta
}
if (event.type === 'done') {
done = true
}
}
await expect(consume()).rejects.toThrow(
'流式响应超过安全限制'
)
expect(done).toBe(true)
expect(streamedText.length).toBe(deltaChunks * deltaText.length)
})
it('preserves bounded provider error messages', async () => {
@@ -5192,29 +5218,44 @@ describe('ModelAgentRuntime', () => {
await replacement.dispose()
})
it('counts image base64 data against the aggregate tool context limit', async () => {
const response = {
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-large-image',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{}'
}
}
]
}
}
]
}
it('keeps an oversized image tool result in the model round', async () => {
const imageData = Buffer.alloc(1024 * 1024 + 1).toString('base64')
const fetcher = vi.fn<typeof fetch>(async () => Response.json(response))
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-large-image',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{}'
}
}
]
}
}
]
})
)
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: '图片已读取。'
}
}
]
})
)
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
@@ -5234,23 +5275,25 @@ describe('ModelAgentRuntime', () => {
}))
})
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'run',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
const events = []
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'run',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
events.push(event)
}
await expect(consume()).rejects.toThrow('结果总量超过 1MB')
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher).toHaveBeenCalledTimes(2)
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(fetcher.mock.calls[1]?.[1]?.body as string).toContain(
imageData
)
})
it('performs and validates a real image generation when testing an image connection', async () => {
+31 -95
View File
@@ -189,10 +189,6 @@ type ModelToolResponse = {
const maxGeneratedImageBytes = 3_900_000
const maxImageResponseBytes = 5_300_000
const maxChatResponseBytes = 2 * 1024 * 1024
const maxStreamBlockBytes = 1024 * 1024
const maxToolArgumentBytes = 128 * 1024
const maxToolContextBytes = 1024 * 1024
const defaultModelRequestTimeoutMs = 10 * 60_000
const modelRequestRetryDelaysMs = [500, 1_000, 2_000] as const
const retryablePreDispatchNetworkErrorCodes = new Set([
@@ -734,12 +730,16 @@ function parseGeneratedImage(value: unknown): {
throw new Error('图像生成接口返回了不支持的图片格式')
}
async function readResponseText(response: Response): Promise<string> {
if (!response.body) {
throw new Error('模型接口未返回响应内容')
}
return response.text()
}
function parseToolArguments(value: unknown): Record<string, unknown> {
let parsed = value
if (typeof value === 'string') {
if (Buffer.byteLength(value) > maxToolArgumentBytes) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
try {
parsed = JSON.parse(value)
} catch (error) {
@@ -751,15 +751,11 @@ function parseToolArguments(value: unknown): Record<string, unknown> {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('模型工具参数必须是 JSON object')
}
let serialized: string
try {
serialized = JSON.stringify(parsed)
JSON.stringify(parsed)
} catch (error) {
throw new Error('模型工具参数无法序列化', { cause: error })
}
if (Buffer.byteLength(serialized) > maxToolArgumentBytes) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
return parsed as Record<string, unknown>
}
@@ -1232,7 +1228,7 @@ function parseSseData(
}
}
async function* readBoundedSseBlocks(
async function* readSseBlocks(
response: Response
): AsyncGenerator<string, void, void> {
if (!response.body) {
@@ -1242,19 +1238,11 @@ async function* readBoundedSseBlocks(
const decoder = new TextDecoder()
let buffer = ''
let completed = false
let receivedBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
receivedBytes += value?.byteLength ?? 0
if (receivedBytes > maxChatResponseBytes) {
throw new Error('模型接口流式响应超过安全限制')
}
buffer += decoder.decode(value, { stream: !done })
buffer = buffer.replaceAll('\r\n', '\n')
if (Buffer.byteLength(buffer) > maxStreamBlockBytes) {
throw new Error('模型接口流式响应块超过安全限制')
}
const blocks = buffer.split('\n\n')
buffer = blocks.pop() ?? ''
@@ -1288,7 +1276,7 @@ async function* readOpenAIResponsesToolStream(
reported: false
}
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
break
@@ -1423,7 +1411,7 @@ async function* readAnthropicToolStream(
let reasoning = ''
let stopReason: unknown
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
break
@@ -1566,12 +1554,6 @@ async function* readAnthropicToolStream(
throw new Error('Anthropic 返回了无效流式工具参数增量')
}
current.partialJson += delta.partial_json
if (
Buffer.byteLength(current.partialJson) >
maxToolArgumentBytes
) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
}
continue
}
@@ -1952,13 +1934,14 @@ export class ModelAgentRuntime implements AgentRuntime {
)
)
})
const responseText = await readBoundedResponseText(response, {
maxBytes: response.ok && imageGeneration
? maxImageResponseBytes
: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText =
response.ok && imageGeneration
? await readBoundedResponseText(response, {
maxBytes: maxImageResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
: await readResponseText(response)
if (!response.ok) {
let detail: string | undefined
try {
@@ -2660,13 +2643,13 @@ export class ModelAgentRuntime implements AgentRuntime {
const response = modelRequest.response
let responseText: string
try {
responseText = await readBoundedResponseText(response, {
maxBytes: response.ok
? maxImageResponseBytes
: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
responseText = response.ok
? await readBoundedResponseText(response, {
maxBytes: maxImageResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
: await readResponseText(response)
} catch (error) {
return normalizeRequestError(error, modelRequest.timedOut())
} finally {
@@ -2828,9 +2811,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
)
)
if (Buffer.byteLength(body) > 2 * 1024 * 1024) {
throw new Error('模型工具请求上下文超过 2MB 安全限制')
}
const request = await this.fetchWithTimeout(
this.getEndpoint(),
{
@@ -2843,11 +2823,7 @@ export class ModelAgentRuntime implements AgentRuntime {
const response = request.response
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let detail: string | undefined
try {
detail = getErrorMessage(
@@ -2890,7 +2866,7 @@ export class ModelAgentRuntime implements AgentRuntime {
let reasoning = ''
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
receivedStop = true
@@ -2972,16 +2948,6 @@ export class ModelAgentRuntime implements AgentRuntime {
? functionDelta.name
: current.name
}
if (
next.id.length > 256 ||
next.name.length > 128 ||
Buffer.byteLength(next.arguments) >
maxToolArgumentBytes
) {
throw new Error(
'OpenAI 模型接口返回的流式工具调用超过安全限制'
)
}
streamedToolCalls.set(index as number, next)
}
}
@@ -3025,11 +2991,7 @@ export class ModelAgentRuntime implements AgentRuntime {
streamed: true
}
}
const responseText = await readBoundedResponseText(response, {
maxBytes: maxChatResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let payload: unknown
try {
payload = responseText.trim()
@@ -3115,11 +3077,7 @@ export class ModelAgentRuntime implements AgentRuntime {
} satisfies ModelUsageAccumulator
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let detail: string | undefined
try {
detail = getErrorMessage(
@@ -3136,7 +3094,7 @@ export class ModelAgentRuntime implements AgentRuntime {
}
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseStreamBlock(block, this.options.protocol)
if (parsed.usage) {
applyUsageUpdate(usage, parsed.usage)
@@ -3619,9 +3577,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
if (response.text) {
answer += response.text
if (Buffer.byteLength(answer) > 1024 * 1024) {
throw new Error('直连模型回答超过 1MB 安全限制')
}
if (!response.streamed) {
yield {
requestId: request.requestId,
@@ -3844,25 +3799,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
}
roundContextBytes += validateToolResult(result)
const retainedContextBytes = compressionState.rounds.reduce(
(total, retainedRound) =>
total + retainedRound.contextBytes,
roundContextBytes
)
if (retainedContextBytes > maxToolContextBytes) {
if (showToolActivity) {
yield {
requestId: request.requestId,
type: 'tool',
callId: call.id,
name: displayName,
state: 'failed',
summary: `直连模型工具结果超过限制:${displayName}`,
input
}
}
throw new Error('直连模型工具结果总量超过 1MB 安全限制')
}
if (responses) {
responsesResults.push({
type: 'function_call_output',
+33 -10
View File
@@ -318,13 +318,9 @@ describe('SelectedRuntimeManager', () => {
await manager.dispose()
})
it('disposes a native-inventory runtime without caching it', async () => {
const inspected = runtime()
const cached = runtime()
const create = vi
.fn()
.mockResolvedValueOnce(inspected.value)
.mockResolvedValueOnce(cached.value)
it('serves the native inventory from the cached execution runtime', async () => {
const shared = runtime()
const create = vi.fn(async () => shared.value)
const manager = new SelectedRuntimeManager(create)
const selection = { provider: 'opencode' as const }
@@ -337,15 +333,42 @@ describe('SelectedRuntimeManager', () => {
provider: 'opencode',
inventoryStatus: 'available'
})
expect(inspected.getNativeSnapshot).toHaveBeenCalledOnce()
expect(inspected.dispose).toHaveBeenCalledOnce()
expect(shared.getNativeSnapshot).toHaveBeenCalledOnce()
expect(shared.dispose).not.toHaveBeenCalled()
await manager.getRuntime(
const controller = await manager.getRuntime(
selection,
executionSpace('local:project-one')
)
expect(create).toHaveBeenCalledOnce()
await expect(controller.getStatus()).resolves.toMatchObject({
available: true
})
await manager.getNativeSnapshot(
selection,
executionSpace('local:project-two')
)
expect(create).toHaveBeenCalledTimes(2)
await manager.dispose()
expect(shared.dispose).toHaveBeenCalled()
})
it('rejects the native inventory when the runtime lacks it', async () => {
const plain = runtime()
const withoutInventory: AgentRuntime = { ...plain.value }
delete withoutInventory.getNativeSnapshot
const manager = new SelectedRuntimeManager(
vi.fn(async () => withoutInventory)
)
await expect(
manager.getNativeSnapshot({
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
})
).rejects.toThrow('当前 Runtime 不支持原生能力清单')
await manager.dispose()
})
it('waits for a pending connection-test runtime during shutdown', async () => {
+5 -12
View File
@@ -355,18 +355,11 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
selection: AgentRuntimeSelection,
executionSpace?: ExecutionSpaceDescriptor
): Promise<RuntimeNativeSnapshot> {
const runtime = await this.createRuntime(selection, executionSpace)
try {
if (this.disposed) {
throw new Error('Agent Runtime 正在关闭')
}
if (!runtime.getNativeSnapshot) {
throw new Error('当前 Runtime 不支持原生能力清单')
}
return await runtime.getNativeSnapshot()
} finally {
await runtime.dispose()
}
// The inventory runs on the same cached Runtime that later serves the
// conversation, so a slow cold start (embedded OpenCode on Linux ARM)
// is paid once instead of once per inventory and again per request.
const controller = await this.getRuntime(selection, executionSpace)
return controller.getNativeSnapshot()
}
private async startRetiring(
+68 -10
View File
@@ -65,6 +65,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -76,6 +77,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -83,11 +85,12 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -116,7 +119,7 @@ describe('ApplicationSettingsStore', () => {
remoteProjectsEnabled: true
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings,
remoteProjectsEnabled: true
@@ -139,6 +142,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -165,6 +169,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -191,6 +196,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -217,6 +223,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -245,11 +252,12 @@ describe('ApplicationSettingsStore', () => {
createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -279,6 +287,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -306,6 +315,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'mirror',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -314,9 +324,10 @@ describe('ApplicationSettingsStore', () => {
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 10,
version: 11,
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -324,9 +335,10 @@ describe('ApplicationSettingsStore', () => {
await store.update({ modelDownloadSource: 'hugging-face' })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 10,
version: 11,
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -351,6 +363,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -359,8 +372,9 @@ describe('ApplicationSettingsStore', () => {
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSeven,
version: 10,
version: 11,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -388,6 +402,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
@@ -397,17 +412,19 @@ describe('ApplicationSettingsStore', () => {
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 10,
version: 11,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
await store.update({ checkUpdatesOnStartup: true })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 10,
version: 11,
checkUpdatesOnStartup: true,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
})
@@ -445,6 +462,7 @@ describe('ApplicationSettingsStore', () => {
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
source: 'custom',
@@ -459,7 +477,8 @@ describe('ApplicationSettingsStore', () => {
const persisted = JSON.parse(await readFile(filePath, 'utf8'))
expect(persisted).toEqual({
...versionNine,
version: 10,
version: 11,
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
source: 'custom',
@@ -523,6 +542,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -546,6 +566,41 @@ describe('ApplicationSettingsStore', () => {
})
})
it('persists disabling conversation HTML rendering', async () => {
const { filePath, store } = await createStore()
await expect(
store.update({ conversationHtmlRenderingEnabled: false })
).resolves.toEqual({
...defaultApplicationSettings,
conversationHtmlRenderingEnabled: false
})
await expect(
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
conversationHtmlRenderingEnabled: false
})
})
it('enables conversation HTML rendering when migrating version 10 settings', async () => {
const { filePath, store } = await createStore()
const versionTenSettings: Record<string, unknown> = {
...defaultApplicationSettings,
version: 10,
lastSeenReleaseNotesVersion: '0.8.18'
}
delete versionTenSettings.conversationHtmlRenderingEnabled
await writeFile(filePath, JSON.stringify(versionTenSettings), 'utf8')
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionTenSettings,
version: 11,
conversationHtmlRenderingEnabled: true
})
})
it.each([
'{not-json',
JSON.stringify({
@@ -634,6 +689,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -641,11 +697,12 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -674,6 +731,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+44 -4
View File
@@ -27,7 +27,7 @@ export {
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 10
const CURRENT_SETTINGS_VERSION = 11
const legacyStoredApplicationSettingsSchema = z
.object({
@@ -58,6 +58,7 @@ const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -71,6 +72,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -84,6 +86,7 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -96,6 +99,7 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -108,6 +112,7 @@ const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true
})
.extend({
@@ -117,13 +122,25 @@ const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.strict()
const versionNineStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ localToolEnvironment: true })
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true
})
.extend({
version: z.literal(9),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionTenStoredApplicationSettingsSchema =
applicationSettingsSchema
.omit({ conversationHtmlRenderingEnabled: true })
.extend({
version: z.literal(10),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
version: z.literal(CURRENT_SETTINGS_VERSION),
@@ -215,6 +232,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -236,8 +254,13 @@ export class ApplicationSettingsStore {
private async migrateLegacy(
settings: Omit<
StoredApplicationSettings,
'localToolEnvironment' | 'version'
> & { version: number }
| 'conversationHtmlRenderingEnabled'
| 'localToolEnvironment'
| 'version'
> & {
conversationHtmlRenderingEnabled?: boolean
version: number
}
): Promise<StoredApplicationSettings> {
const resolved = await this.legacyLocalToolEnvironmentResolver()
const selection = (
@@ -249,6 +272,8 @@ export class ApplicationSettingsStore {
: { source: 'managed' }
const next: StoredApplicationSettings = {
...settings,
conversationHtmlRenderingEnabled:
settings.conversationHtmlRenderingEnabled ?? true,
version: CURRENT_SETTINGS_VERSION,
localToolEnvironment: {
node: selection(resolved.nodeExecutablePath),
@@ -303,6 +328,17 @@ export class ApplicationSettingsStore {
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionTenResult =
versionTenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionTenResult.success) {
const next: StoredApplicationSettings = {
...versionTenResult.data,
version: CURRENT_SETTINGS_VERSION,
conversationHtmlRenderingEnabled: true
}
await this.persist(next)
return next
}
const versionNineResult =
versionNineStoredApplicationSettingsSchema.safeParse(parsed)
if (versionNineResult.success) {
@@ -443,6 +479,8 @@ export class ApplicationSettingsStore {
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
conversationHtmlRenderingEnabled:
stored.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: stored.remoteProjectsEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
@@ -478,6 +516,8 @@ export class ApplicationSettingsStore {
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
conversationHtmlRenderingEnabled:
next.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: next.remoteProjectsEnabled,
magicNotesEnabled: next.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
+185 -14
View File
@@ -159,11 +159,11 @@ describe('AssistantDatabase', () => {
unchanged.close()
})
it('persists more than 100 tool activities in one message', async () => {
it('persists more than 500 tool activities in one message', async () => {
const database = await createDatabase()
const conversationId = '00000000-0000-4000-8000-000000000901'
const messageId = '00000000-0000-4000-8000-000000000902'
const tools = Array.from({ length: 101 }, (_, index) => ({
const tools = Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
@@ -192,7 +192,79 @@ describe('AssistantDatabase', () => {
expect(
database.getConversation(conversationId)?.messages[0]?.tools
).toHaveLength(101)
).toHaveLength(501)
database.close()
})
it('persists complete Run history beyond the former Renderer limits', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-activity-history-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const records = Array.from({ length: 501 }, (_, index) => ({
id: `activity-${index}`,
conversationId: 'conversation-1',
requestId: 'request-1',
scope: { kind: 'global' as const },
kind: 'tool' as const,
title: `工具调用 ${index}`,
detail: index === 500 ? 'x'.repeat(4_001) : '读取文件',
status: 'completed' as const,
createdAt: index
}))
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.replaceActivityHistory({
records,
legacyHistoryMayBeIncomplete: true
})
initial.close()
const reopened = new AssistantDatabase(databasePath)
reopened.initialize('C:\\Workspace')
const history = reopened.getActivityHistory()
expect(history.records).toHaveLength(501)
expect(history.records.at(-1)?.detail).toHaveLength(4_001)
expect(history.legacyHistoryMayBeIncomplete).toBe(true)
reopened.clearAssistantData()
expect(reopened.getActivityHistory()).toEqual({
records: [],
legacyHistoryMayBeIncomplete: false
})
reopened.close()
})
it('keeps the truthful truncation marker on legacy messages', async () => {
const database = await createDatabase()
const conversationId = '00000000-0000-4000-8000-000000000911'
database.saveLocalConversations([
{
header: {
id: conversationId,
title: '旧版截断记录',
updatedAt: 1
},
messages: [
{
id: '00000000-0000-4000-8000-000000000912',
role: 'assistant',
content: '',
createdAt: 1,
state: 'complete',
displayCaptureTruncated: true
}
]
}
])
expect(
database.getConversation(conversationId).messages[0]
?.displayCaptureTruncated
).toBe(true)
database.close()
})
@@ -239,7 +311,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('migrates existing databases to schema version 32', async () => {
it('migrates existing databases to schema version 33', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -268,7 +340,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(32)
).toBe(33)
expect(
current
.prepare(
@@ -374,6 +446,14 @@ describe('AssistantDatabase', () => {
)
.get()
).toEqual({ name: 'conversations_branch_source_idx' })
expect(
current
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name = 'activity_history'`
)
.get()
).toEqual({ name: 'activity_history' })
current.close()
})
@@ -430,7 +510,7 @@ describe('AssistantDatabase', () => {
enableForeignKeyConstraints: true
})
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected.prepare('SELECT * FROM projects ORDER BY rowid').all()
@@ -528,7 +608,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected
@@ -605,7 +685,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected
@@ -855,7 +935,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(32)
).toBe(33)
expect(
current
.prepare(
@@ -993,7 +1073,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(
inspected.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 32 })
).toEqual({ user_version: 33 })
expect(
inspected
.prepare(
@@ -2690,7 +2770,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected.prepare('PRAGMA table_info(task_events)').all()
@@ -3874,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,
@@ -3975,6 +4058,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000214'
],
knowledgeRetrievalMode: 'always',
messages: [
expect.objectContaining({
@@ -3996,8 +4082,7 @@ describe('AssistantDatabase', () => {
}),
expect.objectContaining({
role: 'assistant',
state: 'error',
status: expect.stringContaining('意外中断'),
state: 'streaming',
reasoning: '先分析发布范围',
contextCompression: {
state: 'completed',
@@ -4014,7 +4099,7 @@ describe('AssistantDatabase', () => {
type: 'tool',
tool: expect.objectContaining({
callId: 'call-1',
state: 'interrupted'
state: 'running'
})
}),
expect.objectContaining({
@@ -4362,6 +4447,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: channelDefaultProfileId
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000559'
],
knowledgeRetrievalMode: 'always',
contextMetrics: {
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
@@ -4470,6 +4558,9 @@ describe('AssistantDatabase', () => {
provider: 'model',
profileId: channelDefaultProfileId
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000559'
],
knowledgeRetrievalMode: 'always',
branch: {
sourceConversationId,
@@ -4543,6 +4634,7 @@ describe('AssistantDatabase', () => {
id: branch.id,
projectId: branch.projectId,
runtimeSelection: branch.runtimeSelection,
knowledgeLibraryIds: branch.knowledgeLibraryIds,
knowledgeRetrievalMode: branch.knowledgeRetrievalMode,
branch: branch.branch,
title: '发布方案讨论 · 分支(独立更新)',
@@ -5344,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]!

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