Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
237b8a8cee | ||
|
|
d08ba33c3e | ||
|
|
b03c25685c | ||
|
|
7003141e56 | ||
|
|
e692362414 |
@@ -1,6 +1,8 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
# Local build output left by the extracted ShareServer project.
|
||||
shareserver/build-server/
|
||||
coverage/
|
||||
.vite/
|
||||
.runtime-resources/
|
||||
|
||||
+14
-2
@@ -32,6 +32,10 @@ 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] **Per-file workspace diffs**: Changed files expose separate staged and
|
||||
unstaged diffs, including deleted, renamed, and untracked files. Refresh
|
||||
reloads the selected diff and expanded directories; more than 50 changes
|
||||
remain accessible through incremental loading.
|
||||
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math,
|
||||
constrained Mermaid diagrams, and static in-conversation HTML previews.
|
||||
Complete HTML and HTML code blocks can be previewed in place after an Agent
|
||||
@@ -74,7 +78,9 @@ otherwise.
|
||||
and capability scope. OpenCode, Continue, DeepSeek Harness, and managed SSH
|
||||
do not receive duplicate copies of these tools. The Windows local command
|
||||
path and a real-model edit, test, fix, and review loop have passed; native
|
||||
macOS and Linux command validation remains.
|
||||
macOS and Linux command validation remains. Execute commands can select
|
||||
absolute, relative, or symbolic-link directories outside the workspace;
|
||||
the workspace stays the default and relative-path base. Ask remains read-only.
|
||||
- [x] **OpenCode and Continue**: Use isolated child processes, an environment
|
||||
variable allowlist, unified configuration, cancellation, total execution
|
||||
limits, bounded streaming output, and activity records. Shared process
|
||||
@@ -152,7 +158,7 @@ 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.19`, while the current Desktop release candidate is `0.12.5`; formal
|
||||
`0.11.20`, while the current Desktop release candidate is `0.12.6`; formal
|
||||
publication status follows the separate Agent and Desktop
|
||||
release channels. Current macOS source has passed native package installation,
|
||||
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
|
||||
@@ -213,6 +219,12 @@ otherwise.
|
||||
up to three read-only experts running in parallel. Chat shows each
|
||||
expandable full expert response first and the main Agent's synthesis below,
|
||||
and persists both with the conversation.
|
||||
- [x] **OpenCode child progress and final results**: Child cards show ordered
|
||||
text, reasoning, and tool progress separately from the final result. Remote
|
||||
live progress requires Agent `0.11.20`; older packages still expose final
|
||||
results without reconstructing missing progress.
|
||||
- [x] **OpenCode event-stream cleanup**: Each completed or cancelled request
|
||||
closes its own subscription without cancelling parallel conversations.
|
||||
- [x] **Role-bound model connections**: Each role can inherit the default model
|
||||
or select an independent text-model connection. Invalid connections safely
|
||||
fall back to the default; the synthesis role always inherits the default.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
|
||||
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
|
||||
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
|
||||
- [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异和已展开目录,超过 50 项变更可继续加载。
|
||||
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
|
||||
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
|
||||
- [x] **助手工作栏、多终端与可调布局**:右侧工作栏使用持久“+”能力目录和应用 Tab;用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
|
||||
@@ -36,6 +37,9 @@
|
||||
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
|
||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权。
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
|
||||
- [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。
|
||||
- [x] **OpenCode 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。
|
||||
- [x] **直连模型命令工作目录**:Execute 命令可通过绝对路径、相对路径或符号链接使用工作区外目录;工作区仍为默认目录和相对路径基准,Ask 保持只读。
|
||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
|
||||
- [x] **模型请求定制**:每个 LLM 连接可填写有界 JSON 自定义请求头和顶层请求体。直连与 Continue 支持两者;本地 OpenCode 和 DeepSeek Harness 只使用各自原生支持的请求头,受管 SSH OpenCode 模型网关支持两者。Runtime、协议和认证字段始终优先,这些普通连接设置不能用于保存 API Key 或其他密钥。详见[模型连接请求定制](./docs/features/model-connections/README.md)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.11.19",
|
||||
"agentVersion": "0.11.20",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| 日期 | 2026-09-03 |
|
||||
| 适用范围 | GoodBuddy 桌面端、ShareServer、网关间联邦协议 |
|
||||
| 桌面端设计 | [设备发现与共享](../features/device-sharing/README.md) |
|
||||
| 服务端设计 | [ShareServer](../features/share-server/README.md) |
|
||||
| 服务端设计 | [ShareServer](../../../goodbuddy-shareserver/docs/features/share-server/README.md) |
|
||||
|
||||
## 1. 文档职责
|
||||
|
||||
@@ -72,7 +72,8 @@ ShareServer 负责身份、目录、策略、任务路由、更新和审计。
|
||||
| `Package` | 可下载到本机安装的签名能力工件 |
|
||||
| `FederationTrust` | 两个组织网关之间经管理员确认的双边信任关系 |
|
||||
|
||||
“网关”是 ShareServer 的网络角色;产品名称和代码目录统一使用 `ShareServer` / `shareserver`。
|
||||
“网关”是 ShareServer 的网络角色;产品名称为 `ShareServer`,独立仓库为
|
||||
`goodbuddy-shareserver`。
|
||||
|
||||
## 4. 系统拓扑
|
||||
|
||||
@@ -406,21 +407,27 @@ Revocation
|
||||
|
||||
## 15. 仓库边界
|
||||
|
||||
目标目录结构:
|
||||
当前仓库边界(跨仓库文档链接约定两个仓库克隆到同一级目录):
|
||||
|
||||
```text
|
||||
docs/
|
||||
architecture/share-network-architecture.md
|
||||
features/device-sharing/
|
||||
features/share-server/
|
||||
packages/
|
||||
share-protocol/ # 与 Electron、数据库和 Web 框架无关的 schema/类型
|
||||
shareserver/ # 独立 Web 服务及管理前端
|
||||
src/ # GoodBuddy Desktop
|
||||
goodbuddy/
|
||||
docs/architecture/share-network-architecture.md
|
||||
docs/features/device-sharing/
|
||||
src/ # GoodBuddy Desktop
|
||||
goodbuddy-shareserver/
|
||||
docs/features/share-server/
|
||||
server/ # 独立 Hono Web 服务
|
||||
shared/ # 当前原型 fixture,不是跨端协议
|
||||
src/ # React 管理前端
|
||||
```
|
||||
|
||||
当前文档先固定边界,不创建空服务脚手架。实现时 `shareserver` 不反向依赖桌面端 `src/shared`;
|
||||
双方依赖独立 `share-protocol`。协议包不得包含数据库模型、Electron API、服务实现或凭据。
|
||||
ShareServer 源码和服务端文档已迁入独立仓库:
|
||||
`ssh://git@git.digiman.live:11022/root/goodbuddy-shareserver.git`。
|
||||
当前服务是交互原型,尚未提供真实 Desktop 集成。共享网络总体设计仍由本文统一维护。
|
||||
|
||||
ShareServer 不反向依赖桌面端 `src/shared`,构建和运行不要求桌面仓库存在。
|
||||
真实跨端协议实现时双方依赖独立 `share-protocol` 包,具体仓库归属和发布方式届时确定,
|
||||
不提前创建空包。协议包不得包含数据库模型、Electron API、服务实现或凭据。
|
||||
|
||||
## 16. 共同验收标准
|
||||
|
||||
@@ -442,9 +449,9 @@ src/ # GoodBuddy Desktop
|
||||
- [设备发现与共享功能逻辑](../features/device-sharing/logic-design.md)
|
||||
- [设备发现与共享 UI](../features/device-sharing/ui-design.md)
|
||||
- [设备发现与共享技术设计](../features/device-sharing/technical-design.md)
|
||||
- [ShareServer PRD](../features/share-server/prd.md)
|
||||
- [ShareServer 功能逻辑](../features/share-server/logic-design.md)
|
||||
- [ShareServer 管理界面](../features/share-server/ui-design.md)
|
||||
- [ShareServer 技术设计](../features/share-server/technical-design.md)
|
||||
- [ShareServer PRD](../../../goodbuddy-shareserver/docs/features/share-server/prd.md)
|
||||
- [ShareServer 功能逻辑](../../../goodbuddy-shareserver/docs/features/share-server/logic-design.md)
|
||||
- [ShareServer 管理界面](../../../goodbuddy-shareserver/docs/features/share-server/ui-design.md)
|
||||
- [ShareServer 技术设计](../../../goodbuddy-shareserver/docs/features/share-server/technical-design.md)
|
||||
- [SSH 远程主机与 Agent](../features/remote-host/README.md)
|
||||
- [GoodBuddy 统一界面设计系统](../../UI-DESIGN.md)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
| --- | --- |
|
||||
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
|
||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 |
|
||||
| [实现与验证进度](./progress.md) | 工作区改动的验证证据与剩余阻塞 |
|
||||
|
||||
当前尚未拆出独立 User Stories、功能逻辑、UI 设计、技术设计和进度文档;相关内容仍保留在
|
||||
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
|
||||
两份现有 PRD 中,后续实际维护该功能时按职责拆分。
|
||||
|
||||
@@ -366,6 +366,22 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
- 显式打开、下载副本或在终端中打开。
|
||||
- HTML 文件的源码与安全预览。
|
||||
|
||||
当前项目目录的文件浏览与 Git 交互遵循以下规则:
|
||||
|
||||
- 页签下不重复显示“项目工作区”标题,保留刷新按钮。
|
||||
- 未提交更改以彩色 Git 字母标识状态:M 修改、A 新增、D 删除、R 重命名、C 复制、
|
||||
U 未跟踪或冲突、T 类型变化;冲突用危险色区别于未跟踪文件,同时保留原始状态。
|
||||
- 点击更改项进入该文件 Diff,分别显示已暂存和未暂存差异,增删行使用对应状态色;
|
||||
删除文件也可查看。目录树中的文件仍打开内容预览。不再提供底部全仓库 Diff 入口。
|
||||
- 手动刷新或任务结束更新 Git 状态时,同步重读根目录和已展开目录,保留目录展开状态,
|
||||
清除未展开目录的旧缓存;正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。
|
||||
无首次提交的仓库也应显示新增和暂存文件。
|
||||
- 未提交更改首批显示 50 项,可继续分批加载其余变更,不能使后续文件尤其是已删除
|
||||
文件失去 Diff 入口。
|
||||
- 系统默认应用和资源管理器打开按钮仅用于本机项目,SSH 项目不显示这两个按钮。
|
||||
|
||||
目录切换、下载副本、终端打开及 HTML 安全预览仍属于后续能力,不能据此视为已实现。
|
||||
|
||||
本机和远程访问都必须由 Main 或远程 GoodBuddy Agent 在对应文件系统上执行路径规范化、相对路径和
|
||||
符号链接边界检查。Renderer 只能提交受约束的相对路径和已授权范围 ID。
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-06 项目工作区
|
||||
|
||||
范围见 [PRD 工作区](./prd.md#76-工作区)。
|
||||
|
||||
当前源码已移除重复标题与底部完整 Diff 入口,使用彩色 Git 状态字母,点击更改项读取
|
||||
单文件 Diff。已暂存与未暂存差异分开展示;本机 Git 测试覆盖无首次提交、未跟踪、
|
||||
暂存后继续修改、重命名、删除及包含方括号的路径。Git 刷新驱动目录树重新读取,保留
|
||||
展开状态;SSH 项目不提供本机系统打开按钮。审查后补齐了当前 Diff 随刷新重读、
|
||||
旧请求结果失效,以及超过 50 项变更的继续加载入口。
|
||||
|
||||
验证记录:
|
||||
|
||||
- 最终 `npm run typecheck`、`npm run lint`、`npm run build` 均通过。
|
||||
- 工作区专项 7 个测试文件复跑:317 项通过,3 项 Linux 专用测试在 Windows 跳过。
|
||||
任务完成测试同时检查目录树出现新文件。
|
||||
- 最终 `npm test`:326 个测试文件、3552 项测试通过,9 个文件、58 项测试按既有条件
|
||||
跳过。复核首轮曾有一项浏览器地址草稿测试超时,单独复跑及最终全量复跑均通过;
|
||||
更早的超时/失败记录不再作为最终验收结果。
|
||||
- 后续复核已通过既有凭据和固定 Host Key 经 VPN 连接共享 Linux x64 Host,使用当前
|
||||
源码构建的隔离 Agent 包安装并启动。真实 `RemoteWorkspaceAccess → Agent git/status`
|
||||
与 `git/diff` 路径覆盖暂存后继续修改、删除、重命名、未跟踪和方括号路径;均通过。
|
||||
Git 验证未调用模型。
|
||||
- Windows 隔离桌面实例已通过真实点击验收:当前项目单文件暂存/工作树 Diff 分组与
|
||||
增删颜色正确,修改测试文件后点击刷新可更新正在查看的 Diff,53 项变更经加载更多
|
||||
全部可访问。截图留在本地验证目录,不纳入产品包。
|
||||
|
||||
目录分页、任意目录切换和 HTML 预览不在本轮改动范围内。
|
||||
@@ -78,15 +78,14 @@ validating
|
||||
| 输入 | 结果 |
|
||||
| --- | --- |
|
||||
| 未提供 | 当前工作区根目录 |
|
||||
| `.` 或合法相对子目录 | 解析到工作区内目录 |
|
||||
| 绝对路径 | 拒绝 |
|
||||
| `..` 越界、符号链接越界 | 拒绝 |
|
||||
| `.` 或相对路径 | 以当前工作区为基准解析,可指向工作区外目录 |
|
||||
| 绝对路径 | 使用指定目录 |
|
||||
| `..` 或指向工作区外的符号链接 | 解析到实际目标目录,按当前用户权限执行 |
|
||||
| 不存在或不是目录 | 拒绝 |
|
||||
| 当前工作区身份在验证期间变化 | 拒绝本次调用 |
|
||||
|
||||
首版进程工具比 Execute 的 OS 权限范围更窄地固定工作目录输入,只是为了保持请求与项目
|
||||
一致;命令本身仍以当前用户权限运行,可以显式访问该账号有权访问的其他绝对路径。该规则
|
||||
不是 Runtime OS 沙箱,也不能宣称文件系统 containment。
|
||||
工作区提供默认目录和相对路径基准,不是 Execute 命令的文件系统边界。单次命令选择其他
|
||||
工作目录不会修改父请求绑定的项目或工作区,也不会影响后续未指定 `cwd` 的命令。
|
||||
|
||||
## 6. Shell 选择
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ Subagent 使用父请求的项目、执行空间、工作模式、直连模型
|
||||
|
||||
- Ask 父请求只能创建 Ask Subagent。
|
||||
- Execute 父请求创建 Execute Subagent。
|
||||
- 子级不能切换项目、目录、Runtime、模型连接或工作模式。
|
||||
- 子级不能切换父请求绑定的项目、工作区、Runtime、模型连接或工作模式;Execute 命令的
|
||||
单次工作目录选择遵循 FR-3。
|
||||
- 子级不再看到 `subagent_delegate`,首版最大委派深度固定为 1。
|
||||
- 子任务不是新的顶层 Task 或 Conversation,结果先返回父模型。
|
||||
|
||||
@@ -79,7 +80,7 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
|
||||
### 4.1 包含
|
||||
|
||||
- 本机 Windows、macOS、Linux 的前台 Shell 命令执行。
|
||||
- 当前工作区默认工作目录及合法子目录选择。
|
||||
- 当前工作区默认工作目录,以及当前用户可访问的本机工作目录选择。
|
||||
- Exit Code、标准输出、标准错误、Shell、目录、耗时和截断状态。
|
||||
- 命令超时、用户取消、进程树停止和应用退出清理。
|
||||
- 新进程使用当前本机工具环境提供的 Node、Python 和 PATH。
|
||||
@@ -118,7 +119,8 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
|
||||
### FR-3 工作目录和执行位置
|
||||
|
||||
- 默认目录是父请求当前工作区。
|
||||
- 可选工作目录必须解析为当前工作区本身或其子目录。
|
||||
- 可选工作目录支持绝对路径、相对工作区路径及指向工作区外目录的路径或符号链接,
|
||||
不额外限制在工作区内;目标必须是实际存在且当前用户可访问的目录。
|
||||
- 本机执行只能发生在当前桌面执行空间。
|
||||
- 当前请求绑定 SSH 执行空间且没有远端进程后端时不注册工具,绝不在桌面目录回退执行。
|
||||
|
||||
|
||||
@@ -84,6 +84,26 @@
|
||||
GoodBuddy 托管 Node/Python 已可用。
|
||||
- 远端直连模型进程执行不在首版,后续需要独立增加 Agent 进程协议和真机验证。
|
||||
|
||||
## 2026-09-06 命令工作目录限制修复
|
||||
|
||||
- FR-3 / US-A3:移除命令工作目录的工作区范围限制。绝对路径、`..` 和指向工作区外
|
||||
的符号链接均按本机文件系统解析,工作区只作为默认目录和相对路径基准。
|
||||
- 保留目录存在性、本机执行空间绑定、Ask 拒绝命令、取消、超时和进程树回收。
|
||||
本次没有改变工作区文件读写工具或远端 Agent 路径。
|
||||
- `direct-model-process-service.test.ts` 覆盖绝对路径、相对路径、外部目录、符号链接、
|
||||
不存在的目录和普通文件;当前 Windows 环境通过。
|
||||
- `model-runtime.test.ts` 新增两个生产工具链回归:模型协议响应提供绝对或相对外部
|
||||
`cwd`,经 `ModelAgentRuntime → ModelToolProvider → LocalDirectModelProcessService`
|
||||
启动真实 PowerShell,在专用工作区外测试目录复制文件并读回验证。模型响应为本机
|
||||
测试替身,没有外部模型调用;不将此记录为真实模型或 macOS/Linux 真机验收。
|
||||
- 最终聚焦验证:`opencode-runtime`、`opencode-runtime-lifecycle`、
|
||||
`direct-model-process-service`、`model-tool-provider`、`model-runtime` 五个测试文件
|
||||
`182 passed, 1 skipped`;`npm run typecheck` 和 `npm run lint` 通过。
|
||||
- 本次共享工作区的全量 `npm test` 为 `3542 passed, 57 skipped, 1 failed`。唯一失败是
|
||||
`App.test.tsx` 的 `opens the global assistant sidebar and switches work tabs`,
|
||||
仍期望“项目工作区”标题,单独复测同样失败;该侧栏处于其他会话的修改范围,本次未改动。
|
||||
因此本次全量验证不能记录为全部通过。
|
||||
|
||||
## 2026-09-01 至 2026-09-02 实施与验证证据
|
||||
|
||||
- 新增 `direct-model-process-service.ts`:PowerShell/Bash/Sh 探测、工作区目录校验、96 KiB
|
||||
|
||||
@@ -89,7 +89,8 @@ const processExecuteInputSchema = z.object({
|
||||
}).strict()
|
||||
```
|
||||
|
||||
- `cwd` 只接受相对工作区路径。
|
||||
- `cwd` 接受绝对路径或相对工作区路径,省略时使用工作区根目录;按本机路径语义解析
|
||||
`..` 和符号链接,不限制目标位于工作区内。工具 schema 向模型明确这一规则。
|
||||
- 首版不接受 `env`、`stdin`、`background`、`pty`、`shell` 或任意 executable 参数。
|
||||
- Shell 选择由执行后端决定,防止模型绕过平台契约启动另一套受管接口。
|
||||
- 命令字符串仍可以调用当前账号本来有权运行的程序;Execute 不增加命令白名单。
|
||||
@@ -114,8 +115,8 @@ type ProcessExecuteResult = {
|
||||
}
|
||||
```
|
||||
|
||||
`cwd` 返回工作区相对显示值,不把不必要的绝对用户路径送入模型上下文。Shell 在模型结果
|
||||
中只返回稳定标签;绝对可执行路径只进入 Main 诊断状态。
|
||||
`cwd` 在工作区内返回相对显示值,根目录为 `.`;工作区外返回规范化绝对路径,准确说明
|
||||
命令实际执行位置。Shell 在模型结果中只返回稳定标签;绝对可执行路径只进入 Main 诊断状态。
|
||||
|
||||
### 4.3 输出边界
|
||||
|
||||
@@ -293,7 +294,7 @@ DeepSeek Harness 的 Main 工具代理必须使用仅包含分配 MCP 和 Web
|
||||
- `failed`:启动或契约失败。
|
||||
- `cancelled`:父请求取消。
|
||||
|
||||
结果摘要包含 Shell、相对目录、退出码、耗时和截断标记。完整有界 stdout/stderr 进入工具
|
||||
结果摘要包含 Shell、工作目录、退出码、耗时和截断标记。完整有界 stdout/stderr 进入工具
|
||||
输出字段,不写应用诊断日志。
|
||||
|
||||
### 9.2 Subagent 事件
|
||||
@@ -351,7 +352,7 @@ type SubagentActor =
|
||||
- 工具可见性矩阵和 Runtime target 过滤。
|
||||
- Ask 伪造进程调用拒绝。
|
||||
- Windows PowerShell、POSIX Bash/Sh 选择和参数。
|
||||
- cwd 默认、合法子目录、越界、符号链接越界和不存在。
|
||||
- cwd 默认、相对子目录、绝对路径、工作区外目录、指向外部的符号链接和不存在的目录。
|
||||
- exit 0、非零退出、spawn 失败、超时、取消和输出截断。
|
||||
- 环境筛选、本机工具 PATH 优先且模型凭据不进入进程。
|
||||
- 子级模式、模型、项目、执行空间和能力继承。
|
||||
|
||||
@@ -28,7 +28,7 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由
|
||||
```text
|
||||
编程能力
|
||||
进程执行
|
||||
使用当前工作区和当前用户权限运行 PowerShell/Bash 命令。
|
||||
使用当前用户权限运行 PowerShell、Bash 或 Sh 命令,默认在工作区执行,也可指定其他工作目录。
|
||||
编程 Subagent
|
||||
将聚焦任务委派给同一模型连接;继承当前工作模式和工作区。
|
||||
```
|
||||
@@ -54,7 +54,7 @@ PowerShell · packages/app · npm test
|
||||
```
|
||||
|
||||
- 命令只显示单行有界摘要,完整参数进入可展开详情。
|
||||
- 目录显示工作区相对路径;工作区根显示 `.`。
|
||||
- 工作区内目录显示相对路径,工作区根显示 `.`;工作区外目录显示规范化绝对路径。
|
||||
- 不在摘要中显示完整环境或凭据。
|
||||
|
||||
### 3.2 状态
|
||||
|
||||
@@ -42,12 +42,13 @@ Shell、目录、退出码、输出和耗时。
|
||||
Given 当前项目位于 macOS 或 Linux 本机,When 模型运行同一工具,Then GoodBuddy 使用
|
||||
Bash,Bash 不可用时使用 Sh,工具契约保持不变且实际 Shell 明确可见。
|
||||
|
||||
### US-A3 使用工作区子目录
|
||||
### US-A3 选择命令工作目录
|
||||
|
||||
关联:FR-3。
|
||||
|
||||
Given 项目包含多个包,When 模型传入工作区内相对目录,Then 命令在该目录执行;When
|
||||
路径越出工作区、不是目录或不存在,Then 命令不启动并返回准确错误。
|
||||
Given 当前请求为本机 Execute,When 模型传入绝对路径或相对工作区路径,Then 命令在
|
||||
解析后的目录执行,包括工作区外目录和符号链接目标,And 后续省略 `cwd` 时仍使用原工作区。
|
||||
When 目标不是目录或不存在,Then 命令不启动并返回准确错误。
|
||||
|
||||
### US-A4 根据非零退出继续修正
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
有效 Open XML DOCX 与两页原生文本 PDF 均通过生产 `KnowledgeService.importPaths`
|
||||
导入、解析、分块和全文检索。
|
||||
|
||||
2026-09-06 对话知识库选择补充验证:
|
||||
|
||||
- `App.test.tsx` 覆盖点击名称、文档数量和行本身时切换选中状态,以及空目标失焦时
|
||||
弹层不提前卸载。Windows 隔离桌面实际点击名称后选中、点击数量后取消,弹层保持打开。
|
||||
- 本轮全量校验结果见[工作栏验证记录](../assistant-workbar/progress.md);本项验证未调用模型。
|
||||
|
||||
## 剩余工作
|
||||
|
||||
- 外部 Dify、FastGPT 和 RAGFlow 知识库接入仍为规划中能力。
|
||||
|
||||
@@ -63,3 +63,8 @@ Conversation 的检索范围,返回对话并聚焦输入框。
|
||||
|
||||
知识库多选属于 Conversation。新对话默认空范围;切换对话恢复各自选择。创建新知识库
|
||||
只加入当前 Conversation,不重新启用其他已取消项。发送请求时冻结当前选择和检索方式。
|
||||
|
||||
输入区的知识库选择弹层使用整行原生 Checkbox 标签,点击名称、文档数量或行内空白
|
||||
均切换一次选择;选中背景和整行焦点环保持可见。Tab 聚焦、Space 切换,Escape 关闭并
|
||||
返回触发按钮。点击行内非焦点元素时产生的空目标失焦不关闭弹层;点击外部或焦点明确
|
||||
移到外部时才关闭,避免原生标签完成勾选前被卸载。
|
||||
|
||||
@@ -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.19`,Desktop 候选为 `0.12.5`,等待各自独立发布审批。
|
||||
Agent 源码 lock 为 `0.11.20`,Desktop 候选为 `0.12.6`,等待各自独立发布审批。
|
||||
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
|
||||
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
|
||||
|
||||
@@ -104,6 +104,11 @@ Detached GoodBuddy Agent
|
||||
小型签名目录。Renderer 只得到本地/在线版本、是否有更新、架构、远端 Runtime 版本、
|
||||
协议和本地状态,不得到缓存路径、key ID 或 digest。目录检查不下载 `.gbagent`,在线
|
||||
包下载只由用户点击带目标版本的操作触发;离线导入/导出通过 Main 管理的文件对话框完成。
|
||||
- GitHub 来源按每页 10 条读取 Release 元数据,最多检查原有的 100 条历史发布,
|
||||
找到首个有效 Agent 目录及签名地址后立即停止。每次响应仍保留 1 MiB 上限,
|
||||
避免一次读取全部历史正文和资产详情导致目录检查失败;签名、URL 与包校验不变。
|
||||
2026-09-06 使用当前 Main 目录读取实现验证公开 GitHub 与北京镜像,两者均可读取
|
||||
Agent `0.11.19` 的 Linux x64、Linux arm64 和 macOS arm64 条目,未下载或安装包。
|
||||
- “自动”只在 operation/prepare 前用 Host capability probe 选择 acquisition:直连明确
|
||||
可用才选 Host 下载,否则选 GoodBuddy 传输;显式选择不被改写。prepare、commit 或
|
||||
adoption 失败后都不跨 acquisition 自动 fallback。
|
||||
@@ -233,7 +238,25 @@ Execute 直接启动已签名 Runtime:
|
||||
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
|
||||
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
|
||||
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
|
||||
状态卡,并持久化任务说明、终态与输出。
|
||||
状态卡,并持久化任务说明、终态与输出。卡片展开后分为“执行过程”和“最终结果”:
|
||||
正文、折叠思考与可展开工具记录按收到的顺序显示,工具状态原位更新;完成后仅在最终
|
||||
结果区显示与末尾正文完全相同的返回结果,避免重复。失败或取消保留已有过程,未终结
|
||||
工具不再显示进行中。
|
||||
- 本地 SDK 通过 Task `metadata.sessionId` 关联子会话,复用稳定消息块 ID 保存过程。
|
||||
固定 OpenCode 1.18.9 的 ACP 不转发子会话,因此 Agent 的模型桥 helper 在受管临时目录
|
||||
写入事件插件,并通过既有 ACP `session/update` 的 `_meta.goodbuddySubagentEvent`
|
||||
转发到所属 Task;不新增端口,不修改签名 Runtime 包。插件文件随 helper 退出清理,
|
||||
事件继续经过既有 Agent transcript、ACK 和 Desktop 消息持久化路径。
|
||||
- ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的
|
||||
`<task><task_result>` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属
|
||||
`toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。
|
||||
远端实时过程需要包含该插件的 Agent;旧 Agent 的最终结果仍可查看,历史未采集过程
|
||||
不会被补造。
|
||||
- 本机 OpenCode SDK 路径允许不同会话并行,同一会话仍按请求顺序执行。每个请求独立拥有
|
||||
SSE 事件订阅,并在正常完成、错误、取消或消费方结束迭代时主动关闭自己的响应流,
|
||||
不关闭其他会话的订阅或共享 Server。取消导致事件迭代结束时保留原取消原因,不改报
|
||||
“事件流意外结束”。这一清理规则属于本机 SDK 路径,不改变远端 Agent 持有 ACP Prompt
|
||||
的断线继续执行语义。
|
||||
- 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。
|
||||
- Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。
|
||||
- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。
|
||||
@@ -398,10 +421,21 @@ bundle 构建通过;这组历史结果不替代完整 Host 支持改动后的
|
||||
|
||||
### Linux 历史验收
|
||||
|
||||
- 2026-09-06 使用当前源码隔离 Agent 验证原生子代理过程插件。最终场景收到 37 条 ACP
|
||||
插件事件,桌面侧从已保留活动重建跟踪器后仍接收后续文本与工具;子代理和父请求正常
|
||||
完成,最终模型调用账本为 4 次 completed。此前成功场景另 4 次,两轮真实 Host 共
|
||||
8 次模型请求。
|
||||
- 同日 Windows 隔离桌面实际发送 OpenCode 原生 Task,展开卡片可看到进行中的正文与
|
||||
read 工具,取消后已有过程保留且工具显示已取消;正确工作区场景完成后显示测试文件
|
||||
内容,最终结果不重复留在过程末尾。本机有界代理共记录 10 次模型请求(含错误路径、
|
||||
取消和成功场景);另外 2 次使用原生测试配置的请求返回 401,改选显式测试模型后通过。
|
||||
未改动正常用户设置,也未发布 Agent 包。全量静态与测试结果见
|
||||
[本轮验证记录](../assistant-workbar/progress.md)。
|
||||
|
||||
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
|
||||
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
|
||||
的双架构验收。Agent `0.11.10` 后续已由原生 workflow 发布并公开验证双架构工件;
|
||||
当前源码 lock 固定为 `0.11.19`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
|
||||
当前源码 lock 固定为 `0.11.20`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
|
||||
- 2026-08-30 在共享 Linux x64 Host 的隔离测试 HOME 中验证当前 `0.11.13` 源码候选:
|
||||
签名 Runtime 清单的测试墙钟上限为 1 秒,模型桥首轮故意延迟 2.515 秒后 Prompt 仍在
|
||||
5.770 秒正常完成;随后 OpenCode 原生 Task 依次产生 `running`、`completed` 子 Agent
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# ShareServer
|
||||
|
||||
ShareServer 是 GoodBuddy 设备共享网络的独立 Web 服务。它为一个或多个组织提供身份与设备
|
||||
目录、能力发布、授权策略、任务路由、可选数据中继、能力包更新、网关联邦和审计管理。
|
||||
|
||||
ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不自动取得设备上的能力、
|
||||
文件、凭据或 SSH Host。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [PRD](./prd.md) | 管理角色、产品范围、功能需求和验收 |
|
||||
| [功能逻辑](./logic-design.md) | 组织隔离、设备、发布、策略、任务和联邦状态规则 |
|
||||
| [管理界面设计](./ui-design.md) | ShareServer Web 控制台的信息架构与交互 |
|
||||
| [技术设计](./technical-design.md) | 服务模块、API、数据、部署、运维和验证 |
|
||||
| [实施进度](./progress.md) | 已验证实现、剩余生产工作与验证证据 |
|
||||
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
|
||||
|
||||
当前没有单独的 User Stories:管理员、能力发布者、审计员和普通成员场景已进入 PRD 与逻辑
|
||||
设计。仓库已建立可运行的全控制台交互原型;它用于确认信息架构与交互,不代表生产服务已经
|
||||
实现。当前边界和运行方法见[实施进度](./progress.md)与
|
||||
[`shareserver/README.md`](../../../shareserver/README.md)。
|
||||
|
||||
## 角色
|
||||
|
||||
- **系统管理员**:维护实例、身份源、域名、证书、存储和全局限制。
|
||||
- **组织管理员**:管理本组织成员、设备、组、策略、能力和联邦关系。
|
||||
- **能力发布者**:在获准范围内发布设备能力或签名能力包。
|
||||
- **审批人**:处理调用、发布、更新和联邦申请。
|
||||
- **审计员**:只读查看和导出获准范围内的审计事件。
|
||||
- **成员**:主要通过 GoodBuddy Desktop 使用能力,只在 Web 端处理个人设备和审批。
|
||||
@@ -1,219 +0,0 @@
|
||||
# ShareServer 功能逻辑设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 关联 PRD | [ShareServer PRD](./prd.md) |
|
||||
| 共享规则 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
|
||||
## 1. 核心不变量
|
||||
|
||||
### INV-1 组织隔离先于角色
|
||||
|
||||
任何查询和变更先绑定 organization,再计算角色。系统管理员的跨组织操作使用独立系统管理
|
||||
入口并形成审计,不能依靠省略 organization 条件获得全局数据。
|
||||
|
||||
### INV-2 注册不等于发布
|
||||
|
||||
设备注册只建立身份和连接资格。没有有效 Publication 时,目录中没有该设备能力。
|
||||
|
||||
### INV-3 目录不等于授权
|
||||
|
||||
看到能力不能推导可调用或可下载。创建任务和下载包时重新计算当前授权。
|
||||
|
||||
### INV-4 服务端授权不能扩大端点授权
|
||||
|
||||
ShareServer 的 `allow` 只是签发任务资格。Provider 和调用方仍执行各自本地策略;任一端拒绝
|
||||
即终止动作。
|
||||
|
||||
### INV-5 策略拒绝优先
|
||||
|
||||
同一动作命中多个规则时使用 `deny > require_approval > allow`。没有匹配 allow 时默认 deny。
|
||||
|
||||
### INV-6 联邦为双边交集
|
||||
|
||||
单方创建或接受的关系不生效。双方 active 且两端发布、Grant 和数据策略均允许时才能跨组织
|
||||
创建任务。
|
||||
|
||||
### INV-7 任务提供者不可改写
|
||||
|
||||
ShareServer 只路由调用方冻结的 provider,不在离线、繁忙或失败时替换节点。
|
||||
|
||||
### INV-8 不恢复短期权威
|
||||
|
||||
备份恢复不恢复 Web 会话、设备 socket、一次性挑战、审批 nonce 或任务数据通道。终端必须
|
||||
重新连接并证明身份。
|
||||
|
||||
### INV-9 初始化入口一次性关闭
|
||||
|
||||
初始化只在实例尚无系统管理员时可用。首个系统管理员创建成功后,后续请求即使持有旧的
|
||||
一次性引导材料也不能再次进入初始化流程。
|
||||
|
||||
### INV-10 数字人发布不等于记忆公开
|
||||
|
||||
发布数字人只建立一个有界 Agent Publication。它默认不取得所有者任何本地记忆;所有者可以
|
||||
在发布时勾选 Agent、Global、指定 Project 和指定 Automation 分区,不能开放 Conversation
|
||||
和 Run 分区。ShareServer 和调用者只能看到允许分区种类与“本次调用会使用本地记忆”的政策
|
||||
事实,不能读取原始条目、分区内容、来源正文或检索结果。新增可读分区按权限扩大重新审批,
|
||||
移除分区可以立即收紧。
|
||||
|
||||
## 2. 主要状态
|
||||
|
||||
### 2.1 成员
|
||||
|
||||
```text
|
||||
invited -> active -> suspended -> active
|
||||
invited -> expired | revoked
|
||||
active/suspended -> removed
|
||||
```
|
||||
|
||||
`suspended` 不能登录或创建新任务;历史归属保留。`removed` 不级联删除审计和已完成任务。
|
||||
|
||||
### 2.2 注册设备
|
||||
|
||||
```text
|
||||
pending -> active <-> offline
|
||||
pending -> expired | rejected
|
||||
active/offline -> revoked
|
||||
```
|
||||
|
||||
客户端心跳只决定 `active/offline`,不能清除 `revoked`。重新注册生成新设备 registration。
|
||||
|
||||
### 2.3 Publication
|
||||
|
||||
```text
|
||||
draft -> pending_approval -> active
|
||||
draft/pending_approval -> rejected
|
||||
active <-> paused
|
||||
active/paused -> revoked
|
||||
```
|
||||
|
||||
provider 离线不会把 Publication 变为 paused;目录同时展示“已发布”和“当前离线”两个事实。
|
||||
|
||||
### 2.4 审批
|
||||
|
||||
```text
|
||||
pending -> approved | rejected | cancelled | expired
|
||||
```
|
||||
|
||||
审批对象内容摘要、申请主体或策略版本变化后,原审批不能复用。approved 只产生其声明范围内
|
||||
的 Grant 或一次性决定。
|
||||
|
||||
### 2.5 Package Release
|
||||
|
||||
```text
|
||||
draft -> verifying -> available -> paused -> available
|
||||
verifying -> invalid
|
||||
available/paused -> revoked
|
||||
```
|
||||
|
||||
发布后的版本和字节不可变。修复内容必须使用新版本;revoked 不删除审计证据和对象摘要。
|
||||
|
||||
### 2.6 FederationTrust
|
||||
|
||||
```text
|
||||
draft -> offered -> mutually_confirmed -> active
|
||||
offered -> rejected | expired
|
||||
active <-> paused
|
||||
任意非终态 -> revoked
|
||||
```
|
||||
|
||||
`mutually_confirmed` 只表示身份确认完成;双方策略同步成功后才进入 active。
|
||||
|
||||
## 3. 授权计算顺序
|
||||
|
||||
1. 验证请求身份、会话 audience 和 organization。
|
||||
2. 验证用户、设备、provider、Publication 和 FederationTrust 当前状态。
|
||||
3. 校验能力或数字人版本、动作、输入 schema、数据类别、大小和委托链。
|
||||
4. 收集调用方组织、提供方组织、Publication 和 Grant 规则。
|
||||
5. 按拒绝优先合并,计算 `deny/require_approval/allow`。
|
||||
6. 校验次数、并发、预算和有效期。
|
||||
7. 返回决定、稳定原因和策略引用;需要批准时建立内容绑定申请。
|
||||
8. 创建任务时再次计算,并把决定快照写入任务。
|
||||
|
||||
目录查询可以预计算“可能可用”,但不能替代第 1 至 8 步。
|
||||
|
||||
## 4. 组织角色边界
|
||||
|
||||
| 动作 | 系统管理员 | 组织管理员 | 发布者 | 审批人 | 审计员 | 成员 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 实例设置 | 是 | 否 | 否 | 否 | 只读有限项 | 否 |
|
||||
| 成员与组 | 紧急管理 | 是 | 否 | 否 | 只读 | 自己 |
|
||||
| 注册设备 | 只读/撤销 | 是 | 自己 | 否 | 只读 | 自己 |
|
||||
| 发布能力/包 | 否 | 是 | 授权范围 | 否 | 只读 | 否 |
|
||||
| 发布自己的数字人 | 否 | 是 | 授权范围 | 否 | 只读 | 自己 |
|
||||
| 编辑策略 | 否 | 是 | 否 | 否 | 只读 | 否 |
|
||||
| 处理审批 | 否 | 可配置 | 否 | 授权范围 | 只读 | 自己的申请 |
|
||||
| 联邦关系 | 实例级许可 | 是 | 否 | 可共同审批 | 只读 | 否 |
|
||||
| 审计导出 | 实例级 | 组织级 | 自己相关 | 自己相关 | 授权范围 | 自己相关 |
|
||||
|
||||
实际权限仍由策略计算,表格定义产品默认职责而非硬编码角色名称。
|
||||
|
||||
## 5. 任务路由决策
|
||||
|
||||
| 条件 | 结果 |
|
||||
| --- | --- |
|
||||
| provider 在线且双方可直连 | 签发短期直连协商信息 |
|
||||
| 无法直连且双方策略允许中继 | 建立有界中继通道 |
|
||||
| 中继关闭或任一方禁止 | 创建失败,说明无可用路由 |
|
||||
| provider 离线 | 不排队到无限期,按能力配置有界等待或拒绝 |
|
||||
| provider 接受后连接中断 | 保留 task,等待同一 provider 恢复/查询 |
|
||||
| provider 终态未知且恢复期结束 | `outcome_unknown` |
|
||||
| 调用方取消 | 转发取消;provider 确认后终态 cancelled |
|
||||
|
||||
中继容量不足只产生排队或资源错误,不切换 provider,不改变是否允许中继。
|
||||
|
||||
## 6. 更新策略优先级
|
||||
|
||||
```text
|
||||
紧急撤销
|
||||
> 组织强制固定版本/禁止版本
|
||||
> 设备组发布通道
|
||||
> 用户允许的版本范围
|
||||
> 当前已安装版本
|
||||
```
|
||||
|
||||
任何权限扩大都从自动路径转为 `require_approval`。降级只有在目标版本仍被签名目录允许且
|
||||
策略明确指定时才提供,不自动视为故障回滚。
|
||||
|
||||
## 7. 联邦可见性
|
||||
|
||||
联邦目录只导出对方明确允许的能力投影:组织显示名、能力、版本范围、数据政策和可用性摘要。
|
||||
不导出内部设备名、成员清单、组结构、节点地址、内部策略全文或审计记录。跨组织请求使用
|
||||
双方关联 ID,双方各自解析本地主体。
|
||||
|
||||
## 8. 删除、撤销与保留
|
||||
|
||||
- 删除组织前必须停止新任务、撤销联邦和设备,并按保留策略归档审计;不提供无提示级联。
|
||||
- 删除用户不删除其历史动作,审计显示不可变主体摘要。
|
||||
- 撤销设备、Publication、Grant、Package 或联邦后立即阻止新令牌。
|
||||
- 对象存储中的未完成任务正文按任务期限清理;审计元数据按组织保留策略清理。
|
||||
- 法定保留只锁定匹配审计事件,不使被撤销身份重新可用。
|
||||
|
||||
## 9. 需求追踪
|
||||
|
||||
| 需求 | 主要逻辑 |
|
||||
| --- | --- |
|
||||
| `FR-1` | `INV-1`、`INV-9` 和系统管理员角色边界 |
|
||||
| `FR-2` | 组织隔离不变量、成员状态和角色边界 |
|
||||
| `FR-3` | 注册不等于发布、设备状态和短期权威恢复规则 |
|
||||
| `FR-4` | 注册、Publication、目录与授权不变量 |
|
||||
| `FR-5` | 拒绝优先不变量和授权计算顺序 |
|
||||
| `FR-6` | 审批状态、内容绑定和授权重算 |
|
||||
| `FR-7` | provider 冻结不变量和任务路由决策 |
|
||||
| `FR-8` | Package Release 状态和更新策略优先级 |
|
||||
| `FR-9` | 联邦双边交集、FederationTrust 状态和联邦可见性 |
|
||||
| `FR-10` | 组织隔离、删除撤销与保留规则 |
|
||||
| `FR-11` | 组织角色边界及各对象状态;展示行为由 UI 设计定义 |
|
||||
| `FR-12` | 短期权威恢复规则和逻辑完整性;部署结构由技术设计定义 |
|
||||
| `FR-13` | 注册、Publication、目录与授权不变量,provider 冻结及 `INV-10` |
|
||||
|
||||
## 10. 逻辑完整性
|
||||
|
||||
本设计覆盖实例、组织、成员、设备、能力与数字人发布、授权、审批、任务、更新、联邦、撤销
|
||||
和恢复。实现前仍需确定首个 OIDC 提供者矩阵、默认审计保留期、部署规模目标和中继带宽上限;这些部署
|
||||
参数应可配置,不改变拒绝优先、双边联邦和 provider 冻结规则。
|
||||
@@ -1,195 +0,0 @@
|
||||
# ShareServer PRD
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 适用产品 | 独立部署的 ShareServer 与 Web 管理控制台 |
|
||||
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
| 关联文档 | [功能逻辑](./logic-design.md)、[管理界面](./ui-design.md)、[技术设计](./technical-design.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
局域网配对适合附近设备,但不能统一解决跨网络访问、成员离职、组织策略、集中更新、审计和
|
||||
跨组织协作。ShareServer 提供一个可自建的组织控制面,让设备通过主动出站连接注册能力并
|
||||
接收任务,不要求为每台桌面设备开放公网端口。
|
||||
|
||||
## 2. 产品目标
|
||||
|
||||
1. 组织可在自有网络、私有云或公网独立部署 ShareServer。
|
||||
2. 管理员可统一管理用户、组、设备、能力、权限、更新和审计。
|
||||
3. 成员通过桌面端使用获准能力,无需日常进入管理控制台。
|
||||
4. 设备离线、用户撤销或策略变化能准确影响新任务,不破坏历史证据。
|
||||
5. 两个组织可经双边确认建立有限范围的联邦协作。
|
||||
6. ShareServer 可以只运行控制面,也可按部署策略提供数据中继和包存储。
|
||||
7. 成员可以显式开放自己的数字人,获准的组织成员可通过 `@数字人` 发起有界协作任务。
|
||||
|
||||
## 3. 产品范围
|
||||
|
||||
### 3.1 包含
|
||||
|
||||
- 本地账号及可配置 OIDC 身份源。
|
||||
- 组织、成员、角色、用户组、设备组和服务账号。
|
||||
- 设备注册、状态、撤销和合规元数据。
|
||||
- 能力目录、Publication、Grant、强制策略和审批。
|
||||
- 设备出站控制通道、任务路由、取消和可选中继。
|
||||
- 签名能力包仓库、发布通道、更新策略和撤销。
|
||||
- ShareServer 间双边联邦。
|
||||
- 组织数字人目录、数字人 Publication、授权发现和 `@数字人` 调用。
|
||||
- 审计、用量、保留和导出。
|
||||
- Web 管理控制台和成员自助页面。
|
||||
|
||||
### 3.2 不包含
|
||||
|
||||
- 远程管理设备桌面、任意 Shell 或任意文件系统。
|
||||
- 发现未注册的局域网设备。
|
||||
- 保存或分发用户 API Key、Cookie 和桌面登录状态。
|
||||
- 默认查看任务正文、附件和模型输出。
|
||||
- 上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆。
|
||||
- 因为成员加入组织而自动开放其数字人、记忆、Runtime、工作区或工具。
|
||||
- 未经提供者发布自动盘点设备能力。
|
||||
- 自动信任同域邮箱、同一网络或其他 ShareServer。
|
||||
- 公共开放市场、计费结算或匿名能力调用。
|
||||
- 替代 GoodBuddy 官方客户端、模型和 Agent 发布源。
|
||||
|
||||
## 4. 功能需求
|
||||
|
||||
### FR-1 实例初始化
|
||||
|
||||
- 首次启动通过一次性引导创建实例标识和首个系统管理员。
|
||||
- 引导完成后永久关闭初始化入口,后续管理员通过正常身份流程创建。
|
||||
- 实例公开描述包含名称、协议版本、登录方式和联邦端点,不包含内部拓扑。
|
||||
|
||||
### FR-2 身份与组织
|
||||
|
||||
- 支持一个实例承载多个隔离组织,也支持部署为单组织模式。
|
||||
- 支持本地账号和 OIDC;组织可限制允许的身份源。
|
||||
- 成员可以属于多个组并拥有有界角色,角色不隐式跨组织。
|
||||
- 停用用户立即停止签发新设备和任务凭据。
|
||||
|
||||
### FR-3 设备管理
|
||||
|
||||
- 桌面端经用户登录、一次性挑战和设备签名完成注册。
|
||||
- 设备条目显示所有者、平台、客户端版本、最近在线、连接和撤销状态。
|
||||
- 设备主动建立出站控制连接;服务端不能借此调用未发布能力。
|
||||
- 管理员可撤销设备并终止其新任务资格。
|
||||
|
||||
### FR-4 能力目录
|
||||
|
||||
- 只接收已注册设备或服务节点提交的显式 Publication。
|
||||
- 目录按调用者身份、设备状态、组织和策略过滤后返回。
|
||||
- 支持同一能力多个版本和多个 provider,不掩盖来源。
|
||||
- provider 离线时保留目录事实但显示不可用,不派发新任务。
|
||||
- 数字人作为由具体设备或服务节点提供的复合 Agent 能力进入目录,持续显示所有者、提供者、
|
||||
运行位置和可调用状态,不伪装成 ShareServer 自身能力。
|
||||
|
||||
### FR-5 授权策略
|
||||
|
||||
- Grant 可绑定用户、组、设备、设备组和联邦组织。
|
||||
- 可约束能力、版本、动作、输入类型、数据分类、提供者、时间、次数、并发和委托。
|
||||
- 支持组织强制策略和用户可收紧偏好;拒绝优先。
|
||||
- 每个决定返回稳定结果、命中策略和面向用户的原因。
|
||||
|
||||
### FR-6 审批
|
||||
|
||||
- 调用、能力发布、数字人上线或范围扩大、权限扩大更新和联邦关系可以要求审批。
|
||||
- 审批项显示申请人、对象、影响范围、数据类别、期限和策略依据。
|
||||
- 批准结果有明确范围和有效期,不提供无边界“永久允许”。
|
||||
- 申请内容变化后旧审批失效。
|
||||
|
||||
### FR-7 任务路由
|
||||
|
||||
- 根据冻结 provider、在线连接和路由策略把任务声明发送给目标设备。
|
||||
- 支持直连协商、网关中继和取消传播。
|
||||
- 任务正文与控制元数据分离;默认审计不保存正文。
|
||||
- 已接受任务断线后允许查询同一 task,不自动创建重复执行。
|
||||
|
||||
### FR-8 能力包与更新
|
||||
|
||||
- 保存不可变签名 Package 和发布目录。
|
||||
- 支持组织、组、平台、架构和维护窗口的更新策略。
|
||||
- 支持固定版本、允许版本范围、暂停发布和紧急撤销。
|
||||
- 权限扩大必须触发新批准,不能被普通自动更新策略覆盖。
|
||||
- 包来源、大小、摘要、签名和下载结果可审计。
|
||||
|
||||
### FR-9 联邦
|
||||
|
||||
- 两个 ShareServer 通过指纹交换和双方管理员确认建立关系。
|
||||
- 双方分别配置公开能力、允许主体、数据范围、用量和期限。
|
||||
- 最终权限取双方策略交集;任一端撤销后停止新任务。
|
||||
- 不默认链式转发到第三个组织。
|
||||
|
||||
### FR-10 审计与用量
|
||||
|
||||
- 记录身份、设备、发布、授权、审批、任务、包和联邦管理事件。
|
||||
- 默认仅记录共享架构定义的有界元数据。
|
||||
- 支持按组织隔离查询、条件筛选、分页和流式导出。
|
||||
- 审计员只读,不能因查看日志取得任务正文或管理权限。
|
||||
|
||||
### FR-11 管理控制台
|
||||
|
||||
- 提供概览、组织与成员、设备、能力目录、数字人、权限策略、审批、发布与更新、网关联邦、
|
||||
任务与中继、审计、系统设置。
|
||||
- 普通成员只看到自己的设备、授权和待办;管理员栏目按权限隐藏并在 API 再次校验。
|
||||
- 普通成员可以管理自己发布的数字人,并发现和 `@` 当前组织内获准调用的数字人。
|
||||
- 所有高影响操作说明对象、范围和后果。
|
||||
|
||||
### FR-12 可部署性
|
||||
|
||||
- 支持容器部署,配置与持久数据分离。
|
||||
- 支持外部 PostgreSQL;对象存储和中继按功能启用。
|
||||
- 提供健康、就绪、指标、结构化日志、备份和恢复说明。
|
||||
- 服务升级不能要求同时升级所有在线桌面端;按协议版本协商兼容集合。
|
||||
|
||||
### 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. 产品验收
|
||||
|
||||
- 新实例可完成初始化、登录、创建组织、邀请成员和注册真实桌面设备。
|
||||
- 两个不同组织不能通过 API、搜索、计数、错误或审计结果读取对方数据。
|
||||
- 设备只有发布能力后才进入获准目录,撤销后不能接收新任务。
|
||||
- 调用授权能同时处理用户、设备、组织、提供者、数据和联邦限制。
|
||||
- 任务中继关闭时控制面仍可运行;中继开启时可取消并形成准确终态。
|
||||
- 权限扩大的包更新不能自动部署。
|
||||
- 两个独立 ShareServer 能建立、使用、暂停和撤销双边联邦。
|
||||
- 成员可以显式发布一个数字人,获准的同组织成员能从选择器插入 `@handle` 并完成一次有界
|
||||
调用;未获准成员不能通过目录、搜索、补全或直接 API 枚举和调用该数字人。
|
||||
- 暂停或撤销数字人后不能创建新调用,已完成任务与有界审计仍可追溯到原 Publication 和
|
||||
provider。
|
||||
- 数字人调用可以使用所有者在发布时勾选的 Agent、Global、指定 Project 或指定 Automation
|
||||
分区,但不能开放 Conversation 和 Run;ShareServer 和调用者不能读取原始记忆、未选择的
|
||||
本地分区或记忆来源正文。
|
||||
- 备份恢复后组织、策略、发布和审计身份保持一致,短期连接和令牌不恢复。
|
||||
@@ -1,71 +0,0 @@
|
||||
# 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 记忆边界展示,因此这些能力仍属于剩余生产与原型工作,
|
||||
不能由现有通用能力目录或原型操作确认替代。
|
||||
@@ -1,269 +0,0 @@
|
||||
# ShareServer 技术设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 关联 PRD | [ShareServer PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
|
||||
|
||||
## 1. 仓库与运行边界
|
||||
|
||||
实现时在仓库顶层创建独立项目:
|
||||
|
||||
```text
|
||||
shareserver/
|
||||
README.md
|
||||
package.json
|
||||
src/
|
||||
api/
|
||||
auth/
|
||||
organizations/
|
||||
devices/
|
||||
capabilities/
|
||||
policies/
|
||||
approvals/
|
||||
tasks/
|
||||
relay/
|
||||
packages/
|
||||
federation/
|
||||
audit/
|
||||
web/
|
||||
migrations/
|
||||
deploy/
|
||||
tests/
|
||||
```
|
||||
|
||||
仓库已创建可独立运行的 `shareserver/` 全控制台交互原型。原型使用 Hono 提供健康、就绪、
|
||||
界面快照和无持久化操作确认 API,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的
|
||||
`src/shared`。当前尚未建立生产数据库、认证、设备连接和共享协议,因此也不提前创建空的
|
||||
`packages/share-protocol`。进入真实 Desktop 协议实现时,服务端和 Desktop 共同依赖
|
||||
`packages/share-protocol`,ShareServer 仍不得导入 Electron 的 `src/shared`。
|
||||
|
||||
## 2. 技术选型约束
|
||||
|
||||
- 使用与仓库一致的 TypeScript/Node.js 工具链,具体 HTTP 框架在实现前根据维护性选择。
|
||||
- PostgreSQL 是组织、策略、任务元数据和审计的权威数据库。
|
||||
- Package 与可选中继暂存使用 S3 兼容对象存储;不开启对应功能时不要求对象存储。
|
||||
- Web 控制台与 API 同源部署,采用严格 CSP,不在浏览器保存设备凭据。
|
||||
- 单实例部署可完整工作;水平扩展时再引入共享事件/连接路由组件,不为未验证规模预设复杂
|
||||
分布式协议。
|
||||
|
||||
## 3. 服务模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `auth` | 本地身份、OIDC、Web session、服务和设备凭据 |
|
||||
| `organizations` | 组织、成员、组、角色和范围解析 |
|
||||
| `devices` | 注册挑战、设备记录、出站连接和在线状态 |
|
||||
| `capabilities` | manifest、数字人 profile、provider、Publication 和目录投影 |
|
||||
| `policies` | Grant、强制策略和确定性授权计算 |
|
||||
| `approvals` | 内容绑定申请、决定、过期和通知 |
|
||||
| `tasks` | task/attempt、provider 路由、状态和取消 |
|
||||
| `relay` | 可选有界数据中继,不解释能力正文 |
|
||||
| `packages` | 不可变对象、签名目录、通道和撤销 |
|
||||
| `federation` | 网关身份、offer、双边策略和跨组织映射 |
|
||||
| `audit` | 事务内审计写入、查询、保留和导出 |
|
||||
|
||||
模块通过服务接口调用,API handler 不直接拼接跨组织 SQL 或自行计算权限。
|
||||
|
||||
## 4. API 边界
|
||||
|
||||
建议分为四类 audience:
|
||||
|
||||
```text
|
||||
/api/v1/web/* 管理控制台与成员自助 API
|
||||
/api/v1/device/* 设备注册、目录和任务控制 API
|
||||
/api/v1/federation/* ShareServer 间 API
|
||||
/api/v1/public/* 服务描述、健康与登录入口
|
||||
```
|
||||
|
||||
设备使用 WebSocket `/api/v1/device/connect` 建立出站控制通道。包下载使用短期、绑定主体和对象
|
||||
摘要的 URL。联邦 API 使用网关身份,不接受普通用户 session。每类 token 使用不同 issuer/
|
||||
audience/scope,不能互换。
|
||||
|
||||
所有请求经 schema、认证、organization scope、授权、速率和大小边界。列表使用游标分页和固定
|
||||
最大页;错误返回 request ID、稳定 code 和有界参数。
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
核心表建议为:
|
||||
|
||||
```text
|
||||
instances
|
||||
identity_providers
|
||||
users
|
||||
organizations
|
||||
memberships
|
||||
groups
|
||||
group_members
|
||||
role_bindings
|
||||
registered_devices
|
||||
device_credentials
|
||||
device_connections
|
||||
capabilities
|
||||
capability_versions
|
||||
agent_profiles
|
||||
providers
|
||||
publications
|
||||
grants
|
||||
policies
|
||||
approval_requests
|
||||
approval_decisions
|
||||
tasks
|
||||
task_attempts
|
||||
task_events
|
||||
package_objects
|
||||
package_releases
|
||||
release_channels
|
||||
update_policies
|
||||
federation_trusts
|
||||
federation_capability_exports
|
||||
audit_events
|
||||
revocations
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 所有组织数据表包含不可为空的 `organization_id`,并由 repository 层强制作用域。
|
||||
- 唯一约束同时包含 organization,避免跨组织名称冲突泄漏。
|
||||
- 密钥保存为单向摘要或经外部主密钥加密的密文;数据库不保存明文 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. 事务与一致性
|
||||
|
||||
- 业务变更与对应审计事件在同一 PostgreSQL 事务提交。
|
||||
- Publication/Grant/Policy 使用 revision 做乐观并发,Web 草稿不能覆盖他人更新。
|
||||
- 授权决定创建 task 时再次读取当前 revision,并将命中策略摘要冻结到 task。
|
||||
- 设备在线状态允许短暂最终一致;身份、撤销、授权和任务接受必须走权威存储。
|
||||
- 重复注册挑战、联邦 offer 和 task create 使用唯一幂等键。
|
||||
- 无法确认 provider 副作用时只更新 `outcome_unknown`,不靠消息队列自动重放。
|
||||
|
||||
## 7. 设备连接与任务
|
||||
|
||||
连接建立后完成设备 credential、organization、客户端协议和当前 registration 校验。服务端为
|
||||
每个连接设置心跳、空闲、帧、发送队列和并发上限。
|
||||
|
||||
任务流程:
|
||||
|
||||
```text
|
||||
authorize -> create task -> resolve exact provider connection
|
||||
-> send declaration -> provider accepts/rejects
|
||||
-> establish direct or relay route -> stream progress
|
||||
-> provider terminal result -> caller delivery -> audit terminal state
|
||||
```
|
||||
|
||||
同一设备重复连接时使用 connection generation;新连接接管未来任务,旧连接仍只允许完成或
|
||||
查询已归属 task,随后有界关闭。不能把新任务随机发到身份相同但 generation 过期的 socket。
|
||||
|
||||
## 8. 中继
|
||||
|
||||
中继是可选模块:
|
||||
|
||||
- 控制面与中继可部署在不同进程,但共享 task 授权验证。
|
||||
- 每个 relay ticket 绑定 task、调用方、provider、方向、期限和最大字节。
|
||||
- 实现背压和全局/组织/设备并发及带宽上限。
|
||||
- 不解析能力正文,不写普通日志;需要临时落盘时使用加密 GoodBuddy-owned 暂存并按 task 清理。
|
||||
- 中继关闭、过载或策略禁止时返回准确路由错误,不静默转到其他节点。
|
||||
|
||||
## 9. Package 存储
|
||||
|
||||
上传先进入不可公开暂存:流式计算大小和 SHA-256,验证签名、manifest、版本、平台和权限后,
|
||||
以 digest 发布为不可变对象。相同 digest 可去重,但组织授权和 Release 分别保存。
|
||||
|
||||
Release catalog 使用独立签名域。服务器不能重打包已签名 payload;镜像目标必须保持相同字节、
|
||||
大小和 SHA-256。撤销更新目录和授权,不覆盖或原地删除仍受审计保留的对象。
|
||||
|
||||
## 10. 联邦协议
|
||||
|
||||
- 每个实例生成网关身份和可轮换证书,私钥不进入数据库备份明文。
|
||||
- offer 包含双方可人工核对的组织名、端点、指纹、协议和 nonce。
|
||||
- 双方确认后交换有界 capability projection 和本地映射 ID。
|
||||
- 跨组织 task 带双方关联 ID 和短期签名声明;每端独立授权并各写审计。
|
||||
- 不接受调用方提供的任意回调 URL、第三方 hop 或内部主体 ID。
|
||||
- 证书轮换需要旧身份签名或双方重新确认;身份突变直接暂停关系。
|
||||
|
||||
## 11. Web 安全
|
||||
|
||||
- 使用 HttpOnly、Secure、SameSite cookie 和 CSRF 防护管理 Web session。
|
||||
- OIDC 使用 state、nonce、PKCE 和固定回调地址。
|
||||
- 富文本字段按纯文本显示;manifest/schema 使用安全代码查看器,不执行 HTML。
|
||||
- 权限隐藏仅改善界面,所有 API 在服务端重新校验。
|
||||
- 登录、设备注册、目录、审批、导出和联邦接口分别限速。
|
||||
- 密钥字段只能替换、轮换和测试,永不回显。
|
||||
|
||||
## 12. 部署与配置
|
||||
|
||||
最小生产部署:
|
||||
|
||||
```text
|
||||
TLS reverse proxy/load balancer
|
||||
ShareServer API + Web
|
||||
PostgreSQL
|
||||
可选:S3 compatible object storage
|
||||
可选:Relay worker
|
||||
```
|
||||
|
||||
部署配置通过环境变量或挂载文件提供数据库、主密钥、公开 URL、OIDC、邮件、对象存储和限制。
|
||||
普通 Web 管理员不能修改基础设施秘密。提供数据库迁移命令、不可变镜像、非 root 容器、健康
|
||||
与就绪端点、优雅停止和部署示例。
|
||||
|
||||
## 13. 可观测性与备份
|
||||
|
||||
- 结构化日志只包含 request/task/organization 关联 ID、稳定事件和错误码。
|
||||
- 指标覆盖请求、WebSocket、授权结果、任务状态、中继、Package 和联邦,不包含能力正文。
|
||||
- tracing 跨内部服务传播关联 ID,跨联邦只传播双方同意的关联标识。
|
||||
- 备份 PostgreSQL、Package 对象和网关身份;不备份缓存、Web session、nonce 或中继暂存。
|
||||
- 恢复后撤销列表、组织 ID、签名目录和审计序列保持有效,所有设备重新连接。
|
||||
|
||||
## 14. 测试策略
|
||||
|
||||
### 14.1 自动化
|
||||
|
||||
- schema、认证 audience、CSRF、OIDC 和设备挑战。
|
||||
- 每个 repository/API 的跨组织隔离与枚举防护。
|
||||
- 拒绝优先授权、审批内容绑定、预算和并发。
|
||||
- device generation、离线、撤销、重连和 provider 冻结。
|
||||
- task 幂等、取消、结果未知和不自动重放。
|
||||
- Package 流式校验、不可变性、权限 diff 和撤销。
|
||||
- 联邦双方确认、策略交集、暂停、证书变化和禁止第三 hop。
|
||||
- 审计事务、过滤、分页、导出和正文缺失。
|
||||
|
||||
### 14.2 集成与部署
|
||||
|
||||
- Desktop 与真实 ShareServer 完成注册、目录、调用、文件和取消。
|
||||
- 两个隔离组织验证搜索、计数、错误、对象和审计不泄漏。
|
||||
- 两个 ShareServer 完成联邦建立、跨组织调用和单方撤销。
|
||||
- 中继关闭与开启、容量耗尽、连接中断和大文件背压。
|
||||
- 数据库备份恢复、版本升级、滚动停止和设备重连。
|
||||
- 生产 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 上叠加生产状态。
|
||||
@@ -1,200 +0,0 @@
|
||||
# ShareServer Web 管理界面设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 关联 PRD | [ShareServer PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
|
||||
ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现,但沿用 GoodBuddy 的语义
|
||||
词汇、状态含义和克制的运营工具风格。界面以列表、表格和主从详情为主,不使用营销首页或
|
||||
装饰性卡片墙。
|
||||
|
||||
## 1. 使用对象
|
||||
|
||||
- 普通成员主要在 GoodBuddy Desktop 使用能力;Web 端只处理自己的设备、申请和授权。
|
||||
- 组织管理员使用完整组织控制台。
|
||||
- 系统管理员使用与组织控制台分开的实例管理范围。
|
||||
- 审计员进入只读视图,不显示可变更控件。
|
||||
|
||||
登录后直接进入当前身份可用的实际控制台,不显示产品宣传页。
|
||||
|
||||
## 2. 全局框架
|
||||
|
||||
```text
|
||||
顶部栏:ShareServer 实例名 | 当前组织 | 全局搜索 | 审批 | 账号
|
||||
|
||||
侧栏:
|
||||
概览
|
||||
组织与成员
|
||||
设备
|
||||
能力目录
|
||||
数字人
|
||||
权限策略
|
||||
审批
|
||||
发布与更新
|
||||
网关联邦
|
||||
任务与中继
|
||||
审计
|
||||
系统设置(仅系统管理员)
|
||||
```
|
||||
|
||||
当前组织始终显示。切换组织会重新加载整个数据范围,不保留上一组织的选择、筛选或详情。
|
||||
系统管理使用“实例范围”标识,不能伪装成某个组织。
|
||||
|
||||
## 3. 概览
|
||||
|
||||
概览只显示需要行动和运行事实:
|
||||
|
||||
- 待审批数量及最旧等待时间。
|
||||
- 在线/离线/已撤销设备。
|
||||
- 可用/离线/暂停能力。
|
||||
- 当前任务、失败和结果未知。
|
||||
- 包更新异常和紧急撤销。
|
||||
- 联邦连接异常。
|
||||
- 中继流量和容量,仅在中继启用时显示。
|
||||
|
||||
指标可点击进入已带筛选的对应页面。没有事件时不显示虚构趋势。
|
||||
|
||||
## 4. 组织与成员
|
||||
|
||||
使用“成员”“用户组”“角色”三个同级页签。成员表包含姓名、身份源、组、状态、设备数和
|
||||
最近活动。邀请是唯一主操作;暂停和移除进入行末菜单。
|
||||
|
||||
成员详情采用抽屉,显示身份、组、角色、设备、有效 Grant、申请和审计。管理员不能查看成员
|
||||
任务正文或桌面文件。
|
||||
|
||||
## 5. 设备
|
||||
|
||||
```text
|
||||
设备 所有者 平台 状态 能力 最近在线
|
||||
Jiang-PC Jiang Windows 在线 2 刚刚
|
||||
GPU-Worker-02 AI Team Linux 离线 8 2 小时前
|
||||
```
|
||||
|
||||
设备详情包含概览、连接、已发布能力、Grant 和活动。设备状态与 Publication 状态分别显示。
|
||||
撤销设备使用危险确认,说明新任务、活动任务和重新注册影响。
|
||||
|
||||
## 6. 能力目录
|
||||
|
||||
目录以能力为第一列,提供者为可展开信息:
|
||||
|
||||
```text
|
||||
能力 版本 提供者 可见范围 可用性
|
||||
图像生成 2.1.0 3 个节点 设计组 2/3 在线
|
||||
```
|
||||
|
||||
详情包含 manifest、provider、Publication、允许输入、数据政策、调用趋势和最近错误。管理员
|
||||
看到的是组织元数据,不通过目录读取能力内部数据。
|
||||
|
||||
### 6.1 数字人
|
||||
|
||||
数字人使用独立栏目呈现,不混入普通工具能力表。目录按 `@handle`、显示名、所有者、专业
|
||||
说明、提供者、Runtime、版本、在线状态和可用范围筛选;只有当前成员可发现的数字人进入
|
||||
搜索和提及补全。
|
||||
|
||||
数字人详情显示:
|
||||
|
||||
- 所有者、来源组织、执行设备或服务节点。
|
||||
- Publication 版本、支持的输入/输出、可用时间、并发和有效期。
|
||||
- 数据去向、路由、中继可读性、是否允许委托。
|
||||
- 是否使用本地记忆、允许的分区种类,以及允许处理的数据敏感度;不显示记忆条目、具体分区
|
||||
名称、来源正文和检索结果。
|
||||
- 当前成员的 `discover/inspect/invoke` 权限、命中策略和审批要求。
|
||||
|
||||
成员可以在“我的数字人”中创建多个用途不同的发布草稿,选择一个本地 Agent、设置唯一
|
||||
`@handle` 和开放范围、提交审批、暂停、恢复、更新或撤销。加入组织不会生成默认数字人,
|
||||
创建本地 Agent 也不会自动发布。
|
||||
|
||||
发布表单的“可用记忆”默认全部关闭,并允许勾选数字人的 Agent、Global、指定 Project 和
|
||||
指定 Automation 分区。Conversation 和 Run 不作为可选项。表单持续说明记忆只在提供者本地
|
||||
检索,调用者和 ShareServer 看不到内容;新增分区在保存前明确标为权限扩大,移除分区明确
|
||||
标为立即收紧。
|
||||
|
||||
组织会话和任务输入区使用带搜索的显式选择器插入 `@数字人`,不能只靠自由文本猜测 handle。
|
||||
选择后持续显示目标数字人;发送前确认实际接收者、执行位置、发送数据和授权。离线、无权或
|
||||
需要审批的项保持可读并说明原因,但不能形成已发送调用。
|
||||
|
||||
## 7. 权限策略
|
||||
|
||||
策略页分“强制策略”和“Grant”。创建和编辑使用结构化表单:主体、能力、动作、输入、提供者、
|
||||
时间、预算、批准和委托。保存前提供自然语言摘要:
|
||||
|
||||
> 允许“设计组”从合规设备调用组织内“图像生成”2.x;只发送文本和图片;每人每日 20 次;
|
||||
> 不允许下游委托。
|
||||
|
||||
策略冲突预览显示最终结果和命中规则,但不提供允许规则覆盖拒绝规则的顺序拖拽。
|
||||
|
||||
## 8. 审批
|
||||
|
||||
审批收件箱按调用、发布、更新和联邦分类。详情必须显示申请人、能力或对象、目标范围、数据
|
||||
类别、权限变化、期限和策略依据。批准按钮使用具体文案,例如“允许本次调用”“批准此版本
|
||||
发布”,不能只有“同意”。
|
||||
|
||||
批量批准仅用于同类型、同策略且不包含权限扩大的低影响申请;联邦和权限扩大更新不支持
|
||||
批量批准。
|
||||
|
||||
## 9. 发布与更新
|
||||
|
||||
页面分“能力包”“发布通道”“更新策略”“撤销记录”。Package 表显示版本、发布者、签名、
|
||||
大小、目标平台、权限变化和状态。上传后先进入验证,不直接发布。
|
||||
|
||||
版本详情提供 manifest、摘要、签名、兼容范围、权限 diff、依赖和下载事实。紧急撤销位于
|
||||
独立 danger zone,需要输入版本标识确认,并说明受影响设备和新任务。
|
||||
|
||||
## 10. 网关联邦
|
||||
|
||||
```text
|
||||
合作组织 对端地址 状态 开放能力 有效期
|
||||
法务服务商 share.legal.example 已连接 2 项 12-31
|
||||
```
|
||||
|
||||
建立关系采用向导:填写端点、核对组织和指纹、选择公开范围、设置调用与数据限制、生成 offer、
|
||||
等待对方确认、复核双方交集。只有双方完成后显示“已连接”。
|
||||
|
||||
详情明确展示“我方开放”“对方开放”“最终可用”三个集合。暂停和撤销说明对活动任务的处理。
|
||||
|
||||
## 11. 任务与中继
|
||||
|
||||
任务表显示调用方、能力、provider、路由、数据类别、状态、耗时和时间。默认不显示正文。
|
||||
活动任务提供取消;结果未知使用独立状态并显示 provider 最后确认点。
|
||||
|
||||
中继未启用时页面显示控制面正常和启用位置,不展示零值图表。启用后显示活动连接、吞吐、
|
||||
排队、容量和拒绝原因,不提供查看正文的调试入口。
|
||||
|
||||
## 12. 审计
|
||||
|
||||
提供操作者、动作、对象、组织、结果、时间和关联 ID 的可筛选表格。导出前显示条件、估算行数、
|
||||
时区和字段范围;大导出作为后台任务生成有时效下载。审计详情保持只读,不把原始内部错误、
|
||||
认证信息或任务正文显示给管理员。
|
||||
|
||||
## 13. 系统设置
|
||||
|
||||
只包含实例名称、公开地址、身份源、邮件、数据库/对象存储状态、中继开关与上限、保留策略、
|
||||
联邦总开关、证书和版本。秘密字段只支持替换或测试,不回显原值。
|
||||
|
||||
运行时基础设施配置优先来自部署环境;Web 界面不能修改数据库连接串、主密钥或容器挂载路径。
|
||||
|
||||
## 14. 状态、响应式与无障碍
|
||||
|
||||
- 加载、无数据、无匹配、无权限、服务错误和部分不可用分别展示。
|
||||
- 管理表格支持键盘访问、可见焦点、表头关联和分页;行菜单有可访问名称。
|
||||
- 窄屏将表格转为字段列表,保持组织、来源、状态和风险信息,不把管理动作挤成不可辨图标。
|
||||
- 所有时间显示实例时区并可查看 ISO 时间;联邦事件同时显示对端时间时标明时区。
|
||||
- 状态不只依赖颜色。危险操作在确认后保持原页面上下文和可恢复错误。
|
||||
|
||||
## 15. 交互原型视觉基线
|
||||
|
||||
2026-09-05 的首个可运行原型采用深海科技控制台方向,并同时提供浅色主题。视觉实现遵循
|
||||
shadcn/ui 的本地组件模式和 Radix 可访问 primitives,布局参考成熟后台的侧栏、顶栏、数据
|
||||
表格和详情对话框,但不复制第三方示例业务页面。
|
||||
|
||||
原型已覆盖数字人需求加入前的一级栏目、管理员与成员自助视图切换、组织范围、搜索筛选、
|
||||
页签、详情、创建表单、危险确认和 390px 窄屏导航。动态发光、网格和网络脉冲只用于运行状态
|
||||
与品牌氛围;业务表格仍保持克制,状态继续同时使用图标与文字。原型操作会调用无持久化确认
|
||||
API,并持续标明不会写入生产数据,不能据此宣称真实审批、撤销、策略或任务操作已实现。
|
||||
`FR-13` 新增的数字人目录、“我的数字人”和 `@` 选择器尚未进入当前原型。
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.6",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.6",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.12.6",
|
||||
"releasedAt": "2026-09-06",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**子代理过程与结果显示更完整。** 展开 OpenCode 子代理卡片,可以按顺序查看文字、推理和工具详情,最终结果单独展示。远程项目需要更新 Agent 才能查看实时过程;旧 Agent 仍可显示最终结果,但不会补造缺失的过程记录。",
|
||||
"**工作区文件变更更容易查看。** 点击变更文件,可分别查看已暂存和未暂存差异,包括删除、重命名和未跟踪文件。刷新会更新当前差异和已展开目录,超过 50 项的变更可以继续加载。",
|
||||
"**知识库支持整行勾选。** 点击知识库名称或所在行即可切换选择,不再因焦点变化导致点击无效。选择范围仍按当前对话保存。",
|
||||
"**OpenCode 连续与并行对话更稳定。** 每次请求结束后会释放自身的事件连接,避免连续使用后连接不断积累。取消一条请求不会关闭其他并行会话的连接。",
|
||||
"**命令可以使用工作区外目录。** 直连模型在 Execute 模式下,可以使用绝对路径、相对路径或符号链接指定工作目录。未指定时仍使用项目工作区,Ask 保持只读。",
|
||||
"**恢复 GitHub 来源的 Agent 更新检查。** 选择 GitHub 后,可以正常读取可用 Agent 版本,不再因历史发布信息过大提示“发布目录超出大小限制”。镜像入口和已有本地包不受影响。"
|
||||
],
|
||||
"notices": [
|
||||
"远程子代理实时过程和本轮远程文件差异改进需要更新至 Agent `0.11.20`。提供 Linux x64、Linux arm64 和 macOS arm64 包,Desktop 不内置 Agent。",
|
||||
"本次使用标准六平台桌面发布矩阵,不构建 LoongArch 实验预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**More complete subagent progress and results.** Expand an OpenCode subagent card to view ordered text, reasoning, and tool details, with the final result shown separately. Remote projects require an updated Agent for live progress; older Agents still show final results without inventing missing progress history.",
|
||||
"**Clearer workspace file changes.** Select a changed file to inspect staged and unstaged diffs separately, including deleted, renamed, and untracked files. Refresh updates the selected diff and expanded directories, and lists with more than 50 changes can load additional entries.",
|
||||
"**Whole-row knowledge selection.** Click a knowledge library’s name or row to toggle its selection without focus changes losing the click. Selection remains scoped to the current conversation.",
|
||||
"**More reliable consecutive and parallel OpenCode conversations.** Each request releases its own event connection when it ends, preventing connections from accumulating during continued use. Cancelling one request does not close other parallel conversations’ connections.",
|
||||
"**Commands can use directories outside the workspace.** Direct models in Execute mode can select a working directory using an absolute path, a relative path, or a symbolic link. The project workspace remains the default, and Ask stays read-only.",
|
||||
"**Restored Agent update checks through GitHub.** Selecting GitHub now retrieves available Agent versions without failing because the release history exceeds the response-size limit. Mirror access and existing local packages are unaffected."
|
||||
],
|
||||
"notices": [
|
||||
"Update to Agent `0.11.20` for live remote subagent progress and the remote file-diff improvements in this release. Packages are available for Linux x64, Linux arm64, and macOS arm64; Desktop does not embed the Agent.",
|
||||
"This release uses the standard six-target desktop matrix and does not include an experimental LoongArch preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.12.5",
|
||||
"releasedAt": "2026-09-06",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
build-server
|
||||
dist
|
||||
node_modules
|
||||
*.log
|
||||
*.png
|
||||
@@ -1,4 +0,0 @@
|
||||
build-server/
|
||||
dist/
|
||||
node_modules/
|
||||
*.png
|
||||
@@ -1,24 +0,0 @@
|
||||
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"]
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,33 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
<!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>
|
||||
Generated
-5770
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// @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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
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 }
|
||||
@@ -1,9 +0,0 @@
|
||||
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}`)
|
||||
})
|
||||
@@ -1,324 +0,0 @@
|
||||
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',
|
||||
审批范围: '设备注册',
|
||||
当前阶段: '设备当前离线',
|
||||
可执行操作: '查看设备'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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>
|
||||
)
|
||||
@@ -1,204 +0,0 @@
|
||||
@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 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.server.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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/**']
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { spawn as nodeSpawn } from 'node:child_process'
|
||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
import {
|
||||
createServer,
|
||||
type IncomingHttpHeaders,
|
||||
@@ -8,7 +12,7 @@ import {
|
||||
type ServerResponse
|
||||
} from 'node:http'
|
||||
import type { Socket } from 'node:net'
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
import { isAbsolute, resolve, join } from 'node:path'
|
||||
import type {
|
||||
ModelBridgeModelProtocol
|
||||
} from '../shared/model-bridge-contracts'
|
||||
@@ -409,7 +413,11 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
const exchange = createUnixModelBridgeExchange({ socketPath })
|
||||
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
||||
const origin = await proxy.listen()
|
||||
let pluginDirectory: string | undefined
|
||||
try {
|
||||
pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-'))
|
||||
const pluginPath = join(pluginDirectory, 'subagent.mjs')
|
||||
await writeFile(pluginPath, openCodeSubagentPluginSource(), 'utf8')
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: options.protocol,
|
||||
model: options.model,
|
||||
@@ -419,7 +427,10 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
})
|
||||
const environment = credentialFreeHelperEnvironment(
|
||||
options.environment ?? process.env,
|
||||
JSON.stringify(config)
|
||||
JSON.stringify({
|
||||
...config,
|
||||
plugin: [pathToFileURL(pluginPath).href]
|
||||
})
|
||||
)
|
||||
const child = (options.spawn ?? defaultHelperSpawn)(
|
||||
opencodeEntrypoint,
|
||||
@@ -442,6 +453,9 @@ export async function runOpenCodeModelBridgeHelper(options: {
|
||||
})
|
||||
} finally {
|
||||
await proxy.close()
|
||||
if (pluginDirectory) {
|
||||
await rm(pluginDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
||||
|
||||
describe('OpenCode ACP child event plugin', () => {
|
||||
it('emits valid newline-delimited ACP updates only for owned child sessions', async () => {
|
||||
const write = vi.fn()
|
||||
const factory = runInNewContext(
|
||||
`(${openCodeSubagentPluginSource().replace('export default ', '')})`,
|
||||
{ process: { stdout: { write } } }
|
||||
)
|
||||
const hooks = await factory()
|
||||
const event = {
|
||||
type: 'message.part.updated',
|
||||
properties: {
|
||||
sessionID: 'parent',
|
||||
part: {
|
||||
type: 'tool', tool: 'task', callID: 'task',
|
||||
state: { metadata: { sessionId: 'child' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
await hooks.event({ event })
|
||||
await hooks.event({ event: {
|
||||
type: 'message.part.delta', properties: { sessionID: 'child', delta: 'Inspecting' }
|
||||
} })
|
||||
await hooks.event({ event: {
|
||||
type: 'message.part.delta', properties: { sessionID: 'unrelated', delta: 'Private' }
|
||||
} })
|
||||
expect(write).toHaveBeenCalledTimes(2)
|
||||
for (const [line] of write.mock.calls) {
|
||||
expect(line.endsWith('\n')).toBe(true)
|
||||
expect(JSON.parse(line)).toMatchObject({
|
||||
method: 'session/update',
|
||||
params: { sessionId: 'parent', update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'task',
|
||||
_meta: { goodbuddySubagentEvent: { properties: expect.any(Object) } }
|
||||
} }
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* OpenCode's ACP adapter omits native child sessions. This plugin forwards
|
||||
* their SDK events as ACP metadata on the owning Task, using the same stdio
|
||||
* stream and prompt lifetime as the built-in adapter.
|
||||
*/
|
||||
export function openCodeSubagentPluginSource(): string {
|
||||
return `export default ${plugin.toString()}`
|
||||
}
|
||||
|
||||
function plugin() {
|
||||
const tasks = new Map<string, { sessionId: string; callId: string }>()
|
||||
return Promise.resolve({
|
||||
event: async ({ event }: { event: {
|
||||
type: string
|
||||
properties: {
|
||||
sessionID?: string
|
||||
part?: {
|
||||
type: string
|
||||
tool?: string
|
||||
callID?: string
|
||||
state?: {
|
||||
metadata?: { sessionId?: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
} }) => {
|
||||
const properties = event.properties
|
||||
const part = properties.part
|
||||
let owner = properties.sessionID ? tasks.get(properties.sessionID) : undefined
|
||||
if (
|
||||
!owner && event.type === 'message.part.updated' &&
|
||||
part?.type === 'tool' && part.tool === 'task' &&
|
||||
part.callID && properties.sessionID
|
||||
) {
|
||||
owner = { sessionId: properties.sessionID, callId: part.callID }
|
||||
const child = part.state?.metadata?.sessionId
|
||||
if (child) tasks.set(child, owner)
|
||||
}
|
||||
if (!owner || ![
|
||||
'message.updated', 'message.part.updated', 'message.part.delta'
|
||||
].includes(event.type)) return
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId: owner.sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: owner.callId,
|
||||
_meta: { goodbuddySubagentEvent: event }
|
||||
}
|
||||
}
|
||||
}) + '\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -127,6 +127,32 @@ describe('WorkspaceGitService', () => {
|
||||
code: 'git-unsafe'
|
||||
})
|
||||
})
|
||||
|
||||
linuxIt('diffs unborn, untracked, staged, renamed and deleted files by literal path', async () => {
|
||||
const root = temporaryDirectory()
|
||||
const executable = await resolveVerifiedGitExecutable()
|
||||
git(executable, root, 'init', '--quiet')
|
||||
const service = new WorkspaceGitService({ gitExecutable: executable })
|
||||
const access = accessFor(root)
|
||||
const diff = (relativePath: string, staged = false) => service.diff(access, {
|
||||
workspaceId: 'workspace-a', generation: 1, relativePath, staged, maximumBytes: 4096
|
||||
})
|
||||
writeFileSync(join(root, '[new].txt'), 'original\n')
|
||||
expect((await diff('[new].txt')).patch).toContain('+original')
|
||||
expect((await diff('[new].txt', true)).patch).toBe('')
|
||||
git(executable, root, 'add', '.')
|
||||
expect((await diff('[new].txt', true)).patch).toContain('+original')
|
||||
writeFileSync(join(root, '[new].txt'), 'edited\n')
|
||||
expect((await diff('[new].txt')).patch).toContain('+edited')
|
||||
git(executable, root, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'initial')
|
||||
git(executable, root, 'mv', '[new].txt', 'renamed file.txt')
|
||||
expect((await diff('renamed file.txt', true)).patch).toContain('rename from [new].txt')
|
||||
expect((await diff('renamed file.txt')).patch).toContain('+edited')
|
||||
rmSync(join(root, 'renamed file.txt'))
|
||||
expect((await diff('renamed file.txt')).patch).toContain('-original')
|
||||
git(executable, root, 'add', '-u')
|
||||
expect((await diff('[new].txt', true)).patch).toContain('-original')
|
||||
})
|
||||
})
|
||||
|
||||
function git(executable: string, cwd: string, ...args: string[]): void {
|
||||
|
||||
@@ -184,8 +184,23 @@ export class WorkspaceGitService {
|
||||
options: WorkspaceIoOptions = {}
|
||||
): Promise<RemoteGitDiffResult> {
|
||||
const repository = await this.#repository(accessService, options)
|
||||
if (request.relativePath !== undefined && request.relativePath !== '') {
|
||||
await accessService.stat(request.relativePath, options)
|
||||
const selectedPath = request.relativePath || undefined
|
||||
const status = selectedPath === undefined ? undefined : await this.status(
|
||||
accessService,
|
||||
{ workspaceId: request.workspaceId, generation: request.generation,
|
||||
includeIgnored: false, maximumEntries: 2000 },
|
||||
options
|
||||
)
|
||||
const file = status?.entries.find((entry) => entry.relativePath === selectedPath)
|
||||
if (selectedPath !== undefined && !file) {
|
||||
throw new WorkspaceServiceError('File is no longer in the Git changes list', 'git-failed')
|
||||
}
|
||||
const untracked = file?.index === 'untracked'
|
||||
if (untracked && request.staged) {
|
||||
return { repositoryIdentity: repository.repositoryIdentity, patch: '', byteLength: 0, truncated: false }
|
||||
}
|
||||
if (untracked) {
|
||||
await accessService.stat(selectedPath!, options)
|
||||
}
|
||||
const cursor = parseCursor(request.cursor)
|
||||
if (cursor > MAXIMUM_GIT_PROCESS_BYTES) {
|
||||
@@ -205,13 +220,14 @@ export class WorkspaceGitService {
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
'--no-color',
|
||||
'--no-renames',
|
||||
'--ignore-submodules=all',
|
||||
...(request.staged ? ['--cached'] : []),
|
||||
...(request.relativePath === undefined ||
|
||||
request.relativePath === ''
|
||||
? []
|
||||
: ['--', request.relativePath])
|
||||
...(untracked ? ['--no-index', '--', NULL_DEVICE, selectedPath!] : [
|
||||
...(request.staged ? ['--cached'] : []),
|
||||
...(selectedPath === undefined ? [] : ['--',
|
||||
...[...new Set([file?.originalRelativePath, selectedPath])]
|
||||
.filter((path): path is string => path !== undefined)
|
||||
.map((path) => `:(literal)${path}`)])
|
||||
])
|
||||
]
|
||||
if (cursor > 0) {
|
||||
const cached = this.#diffSnapshots.get(snapshotKey)
|
||||
@@ -228,7 +244,7 @@ export class WorkspaceGitService {
|
||||
cached === undefined ||
|
||||
cached.repositoryIdentity !== repository.repositoryIdentity ||
|
||||
current === undefined ||
|
||||
current.exitCode !== 0 ||
|
||||
(current.exitCode !== 0 && !(untracked && current.exitCode === 1)) ||
|
||||
current.truncated ||
|
||||
!current.stdout.equals(cached.bytes) ||
|
||||
cursor > cached.bytes.byteLength
|
||||
@@ -254,7 +270,7 @@ export class WorkspaceGitService {
|
||||
options,
|
||||
MAXIMUM_GIT_PROCESS_BYTES
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
if (result.exitCode !== 0 && !(untracked && result.exitCode === 1)) {
|
||||
throw gitFailure(result)
|
||||
}
|
||||
const middle = await repositorySnapshot(repository, accessService)
|
||||
@@ -266,7 +282,7 @@ export class WorkspaceGitService {
|
||||
MAXIMUM_GIT_PROCESS_BYTES
|
||||
)
|
||||
if (
|
||||
verification.exitCode !== 0 ||
|
||||
(verification.exitCode !== 0 && !(untracked && verification.exitCode === 1)) ||
|
||||
verification.truncated !== result.truncated ||
|
||||
!verification.stdout.equals(result.stdout)
|
||||
) {
|
||||
@@ -876,6 +892,7 @@ function parseStatus(
|
||||
continue
|
||||
}
|
||||
entries.push({
|
||||
statusCode,
|
||||
relativePath: remoteRelativePathSchema.parse(relativePath),
|
||||
index: mapStatus(statusCode[0]!, statusCode),
|
||||
worktree: mapStatus(statusCode[1]!, statusCode),
|
||||
|
||||
@@ -1544,6 +1544,58 @@ describe('AcpRemoteRuntime', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps ACP content results and bridged child progress in the same card', async () => {
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const input = { subagent_type: 'general', prompt: 'Read seed' }
|
||||
const server = fakeServer({
|
||||
prompt: async ({ sessionId }) => {
|
||||
for (const event of [
|
||||
{ type: 'message.part.updated', properties: {
|
||||
sessionID: sessionId, part: {
|
||||
id: 'task', callID: 'task', type: 'tool', tool: 'task',
|
||||
state: { status: 'running', input, metadata: { sessionId: 'child' } }
|
||||
}
|
||||
} },
|
||||
{ type: 'message.part.delta', properties: {
|
||||
sessionID: 'child', partID: 'text', field: 'text', delta: 'Inspecting seed'
|
||||
} },
|
||||
{ type: 'message.part.updated', properties: {
|
||||
sessionID: 'child', part: {
|
||||
id: 'read', callID: 'read', type: 'tool', tool: 'read',
|
||||
state: { status: 'completed', input: { path: 'seed.txt' }, output: 'seed' }
|
||||
}
|
||||
} }
|
||||
]) {
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId, update: {
|
||||
sessionUpdate: 'tool_call_update', toolCallId: 'task',
|
||||
_meta: { goodbuddySubagentEvent: event }
|
||||
}
|
||||
})
|
||||
}
|
||||
await client.value!.sessionUpdate({
|
||||
sessionId, update: {
|
||||
sessionUpdate: 'tool_call', toolCallId: 'task', title: 'Read seed', rawInput: input,
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: '**Final seed**' } }]
|
||||
}
|
||||
})
|
||||
return { stopReason: 'end_turn' }
|
||||
}
|
||||
})
|
||||
client.value = server.client
|
||||
const events = await collect(runtime(server).run(request, new AbortController().signal))
|
||||
const children = events.filter(event => event.type === 'subagent')
|
||||
expect(children.at(-1)).toMatchObject({
|
||||
state: 'completed', output: '**Final seed**',
|
||||
progress: [
|
||||
{ type: 'text', content: 'Inspecting seed' },
|
||||
{ type: 'tool', tool: { name: 'read', state: 'completed', output: 'seed' } }
|
||||
]
|
||||
})
|
||||
expect(events.some(event => event.type === 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds native subagent output per event without a request-wide stop', async () => {
|
||||
const client: { value?: AgentSideConnection } = {}
|
||||
const server = fakeServer({
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from './remote-runtime-channel'
|
||||
import type { RuntimeSessionBindingStore } from './runtime-session-binding-store'
|
||||
import {
|
||||
OpenCodeSubagentProgress,
|
||||
parseOpenCodeSubagentInput,
|
||||
toOpenCodeSubagentEvent
|
||||
} from './opencode-subagent'
|
||||
@@ -136,6 +137,7 @@ export class RemotePromptRecoveryUnavailableError extends Error {
|
||||
}
|
||||
|
||||
type ActivePrompt = {
|
||||
subagentProgress?: OpenCodeSubagentProgress
|
||||
requestId: string
|
||||
operationId: string
|
||||
sessionId: string
|
||||
@@ -153,6 +155,7 @@ type ActivePrompt = {
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||
rawInput?: unknown
|
||||
rawOutput?: unknown
|
||||
contentOutput?: string
|
||||
retainedInput?: string
|
||||
retainedOutput?: string
|
||||
retainedError?: string
|
||||
@@ -2067,6 +2070,34 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
prompt: ActivePrompt,
|
||||
update: SessionUpdate
|
||||
): RuntimePublicEvent | undefined {
|
||||
if (this.options.runtimeId === 'opencode') {
|
||||
if (!prompt.subagentProgress) {
|
||||
prompt.subagentProgress = new OpenCodeSubagentProgress(
|
||||
prompt.requestId, prompt.sessionId
|
||||
)
|
||||
for (const tool of prompt.toolCalls.values()) {
|
||||
if (tool.retainedSubagent) {
|
||||
prompt.subagentProgress.retain({
|
||||
...tool.retainedSubagent,
|
||||
requestId: prompt.requestId, type: 'subagent'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const childEvent = update._meta?.goodbuddySubagentEvent
|
||||
if (childEvent !== undefined) {
|
||||
const event = prompt.subagentProgress.update(
|
||||
childEvent, 'toolCallId' in update ? update.toolCallId : undefined
|
||||
)
|
||||
if (event?.runtimeCallId) {
|
||||
const previous = prompt.toolCalls.get(event.runtimeCallId)
|
||||
prompt.toolCalls.set(event.runtimeCallId, {
|
||||
...previous, retainedSubagent: event
|
||||
})
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
if (
|
||||
(update.sessionUpdate === 'agent_message_chunk' ||
|
||||
update.sessionUpdate === 'agent_thought_chunk') &&
|
||||
@@ -2118,6 +2149,13 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
update.rawOutput,
|
||||
retainedMaximum
|
||||
),
|
||||
contentOutput:
|
||||
update.content === undefined || update.content === null
|
||||
? previous?.contentOutput
|
||||
: update.content.flatMap((entry) =>
|
||||
entry.type === 'content' && entry.content.type === 'text'
|
||||
? [entry.content.text] : []
|
||||
).join('\n').slice(0, retainedMaximum),
|
||||
retainedInput:
|
||||
update.rawInput === undefined
|
||||
? previous?.retainedInput
|
||||
@@ -2148,22 +2186,22 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
callId,
|
||||
state: toolCall.status ?? 'pending',
|
||||
input: toolCall.rawInput,
|
||||
output: toolCall.rawOutput
|
||||
output: toolCall.contentOutput || toolCall.rawOutput
|
||||
})
|
||||
: undefined
|
||||
if (subagent) {
|
||||
return subagent
|
||||
return prompt.subagentProgress?.retain(subagent) ?? subagent
|
||||
}
|
||||
if (toolCall.retainedSubagent) {
|
||||
const retained = toolCall.retainedSubagent
|
||||
const state =
|
||||
toolCall.status === 'pending'
|
||||
? ('queued' as const)
|
||||
? retained.state
|
||||
: toolCall.status === 'in_progress'
|
||||
? ('running' as const)
|
||||
: toolCall.status ?? retained.state
|
||||
const detail = safeStringify(
|
||||
toolCall.rawOutput,
|
||||
toolCall.contentOutput || toolCall.rawOutput,
|
||||
retainedMaximum
|
||||
)
|
||||
return {
|
||||
@@ -2191,7 +2229,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
retainedMaximum
|
||||
) ?? toolCall.retainedInput
|
||||
const output = safeStringify(
|
||||
toolCall.rawOutput,
|
||||
toolCall.contentOutput || toolCall.rawOutput,
|
||||
retainedMaximum
|
||||
) ?? toolCall.retainedOutput
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { join, relative } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LocalWorkspaceAccess } from '../workspace'
|
||||
import {
|
||||
@@ -110,10 +110,10 @@ describe('LocalDirectModelProcessService', () => {
|
||||
).rejects.toThrow()
|
||||
await expect(
|
||||
service.execute(
|
||||
{ command: 'echo ok', cwd: join(process.cwd(), 'absolute') },
|
||||
{ command: 'echo ok', cwd: 'invalid\0directory' },
|
||||
context
|
||||
)
|
||||
).rejects.toThrow('相对于工作区')
|
||||
).rejects.toThrow('无效字符')
|
||||
expect(spawnProcess).not.toHaveBeenCalled()
|
||||
await service.dispose()
|
||||
})
|
||||
@@ -198,68 +198,82 @@ describe('LocalDirectModelProcessService', () => {
|
||||
await unavailable.dispose()
|
||||
})
|
||||
|
||||
it('resolves only existing workspace directories and rejects symlink escapes', async () => {
|
||||
const { root, access } = await workspace()
|
||||
const child = new FakeChild()
|
||||
const { service, spawnProcess } = fakeService(child)
|
||||
await mkdir(join(root, 'nested'))
|
||||
|
||||
const execution = service.execute(
|
||||
{ command: 'pwd', cwd: 'nested' },
|
||||
{
|
||||
conversationId: 'cwd',
|
||||
workspace: access,
|
||||
signal: new AbortController().signal
|
||||
it.each(['relative', 'absolute', 'outside', 'parent', 'symlink'])(
|
||||
'resolves an existing %s working directory without workspace containment',
|
||||
async (kind) => {
|
||||
const { root, access } = await workspace()
|
||||
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-'))
|
||||
temporaryDirectories.push(outside)
|
||||
const nested = join(root, 'nested')
|
||||
await mkdir(nested)
|
||||
const link = join(root, 'outside-link')
|
||||
if (kind === 'symlink') {
|
||||
await symlink(
|
||||
outside,
|
||||
link,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
}
|
||||
)
|
||||
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
|
||||
expect(spawnProcess.mock.calls[0]?.[2].cwd).toBe(
|
||||
await realpath(join(root, 'nested'))
|
||||
)
|
||||
child.close(0)
|
||||
await expect(execution).resolves.toMatchObject({ cwd: 'nested' })
|
||||
const cwd =
|
||||
kind === 'relative'
|
||||
? 'nested'
|
||||
: kind === 'absolute'
|
||||
? nested
|
||||
: kind === 'outside'
|
||||
? outside
|
||||
: kind === 'parent'
|
||||
? relative(root, outside)
|
||||
: 'outside-link'
|
||||
const expectedPath = await realpath(
|
||||
kind === 'relative' || kind === 'absolute' ? nested : outside
|
||||
)
|
||||
const child = new FakeChild()
|
||||
const { service, spawnProcess } = fakeService(child)
|
||||
|
||||
await expect(
|
||||
service.execute(
|
||||
{ command: 'pwd', cwd: '..' },
|
||||
const execution = service.execute(
|
||||
{ command: 'pwd', cwd },
|
||||
{
|
||||
conversationId: 'escape',
|
||||
conversationId: 'cwd',
|
||||
workspace: access,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('不能超出')
|
||||
|
||||
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-'))
|
||||
temporaryDirectories.push(outside)
|
||||
const link = join(root, 'outside-link')
|
||||
try {
|
||||
await symlink(
|
||||
outside,
|
||||
link,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
await expect(
|
||||
service.execute(
|
||||
{ command: 'pwd', cwd: 'outside-link' },
|
||||
{
|
||||
conversationId: 'symlink',
|
||||
workspace: access,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('符号链接')
|
||||
} catch (error) {
|
||||
if (
|
||||
!(
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'EPERM'
|
||||
)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce())
|
||||
expect(spawnProcess.mock.calls[0]?.[2].cwd).toBe(expectedPath)
|
||||
child.close(0)
|
||||
await expect(execution).resolves.toMatchObject({
|
||||
cwd:
|
||||
kind === 'relative' || kind === 'absolute'
|
||||
? 'nested'
|
||||
: expectedPath
|
||||
})
|
||||
await service.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects missing working directories and files before starting a process', async () => {
|
||||
const { root, access } = await workspace()
|
||||
const file = join(root, 'file.txt')
|
||||
await writeFile(file, 'not a directory')
|
||||
const { service, spawnProcess } = fakeService(new FakeChild())
|
||||
const context = {
|
||||
conversationId: 'invalid-cwd',
|
||||
workspace: access,
|
||||
signal: new AbortController().signal
|
||||
}
|
||||
await expect(
|
||||
service.execute(
|
||||
{ command: 'pwd', cwd: join(root, 'missing') },
|
||||
context
|
||||
)
|
||||
).rejects.toThrow()
|
||||
await expect(
|
||||
service.execute(
|
||||
{ command: 'pwd', cwd: file },
|
||||
context
|
||||
)
|
||||
).rejects.toThrow('不是目录')
|
||||
expect(spawnProcess).not.toHaveBeenCalled()
|
||||
await service.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,15 @@ export const DIRECT_MODEL_PROCESS_TRUNCATION_MARKER =
|
||||
export const processExecuteInputSchema = z
|
||||
.object({
|
||||
command: z.string().trim().min(1).max(100_000),
|
||||
cwd: z.string().trim().min(1).max(4_096).optional(),
|
||||
cwd: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(4_096)
|
||||
.describe(
|
||||
'工作目录,可使用绝对路径或相对于当前工作区的路径;省略时使用工作区根目录,也可选择工作区外目录。'
|
||||
)
|
||||
.optional(),
|
||||
timeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -350,10 +358,6 @@ async function resolveWorkingDirectory(
|
||||
if (input.cwd?.includes('\0')) {
|
||||
throw new Error('工作目录包含无效字符')
|
||||
}
|
||||
if (input.cwd && isAbsolute(input.cwd)) {
|
||||
throw new Error('工作目录必须相对于工作区')
|
||||
}
|
||||
|
||||
const initialIdentity = await workspace.getIdentity()
|
||||
if (initialIdentity.kind !== 'local') {
|
||||
throw new Error('本机进程不能在远程工作区中运行')
|
||||
@@ -365,23 +369,8 @@ async function resolveWorkingDirectory(
|
||||
throw new Error('项目工作区不是目录')
|
||||
}
|
||||
const candidatePath = resolve(canonicalRoot, input.cwd ?? '.')
|
||||
const lexicalDifference = relative(canonicalRoot, candidatePath)
|
||||
if (
|
||||
lexicalDifference === '..' ||
|
||||
lexicalDifference.startsWith(`..${sep}`) ||
|
||||
isAbsolute(lexicalDifference)
|
||||
) {
|
||||
throw new Error('工作目录不能超出项目工作区')
|
||||
}
|
||||
const canonicalPath = await realpath(candidatePath)
|
||||
const canonicalDifference = relative(canonicalRoot, canonicalPath)
|
||||
if (
|
||||
canonicalDifference === '..' ||
|
||||
canonicalDifference.startsWith(`..${sep}`) ||
|
||||
isAbsolute(canonicalDifference)
|
||||
) {
|
||||
throw new Error('工作目录不能通过符号链接超出项目工作区')
|
||||
}
|
||||
if (!(await stat(canonicalPath)).isDirectory()) {
|
||||
throw new Error('工作目录不是目录')
|
||||
}
|
||||
@@ -397,7 +386,12 @@ async function resolveWorkingDirectory(
|
||||
}
|
||||
return {
|
||||
canonicalPath,
|
||||
displayPath: canonicalDifference || '.'
|
||||
displayPath:
|
||||
canonicalDifference === '..' ||
|
||||
canonicalDifference.startsWith(`..${sep}`) ||
|
||||
isAbsolute(canonicalDifference)
|
||||
? canonicalPath
|
||||
: canonicalDifference || '.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
RecoverableModelToolError,
|
||||
@@ -2496,6 +2499,69 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(toolProvider.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['absolute', 'relative'])(
|
||||
'executes a real command outside the workspace using a %s cwd through the model tool loop',
|
||||
async (pathKind) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cwd-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const target = join(root, 'target')
|
||||
await mkdir(workspace)
|
||||
await mkdir(target)
|
||||
await writeFile(join(target, 'input.txt'), 'outside-workspace')
|
||||
const command = process.platform === 'win32'
|
||||
? "Copy-Item 'input.txt' 'output.txt'"
|
||||
: 'cp input.txt output.txt'
|
||||
const responses = [
|
||||
{
|
||||
choices: [{
|
||||
message: {
|
||||
role: 'assistant', content: null,
|
||||
tool_calls: [{
|
||||
id: 'call-process', type: 'function',
|
||||
function: {
|
||||
name: 'process_execute',
|
||||
arguments: JSON.stringify({
|
||||
command,
|
||||
cwd: pathKind === 'absolute' ? target : relative(workspace, target)
|
||||
})
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
},
|
||||
{ choices: [{ message: { role: 'assistant', content: 'done' } }] }
|
||||
]
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
|
||||
const runtime = new ModelAgentRuntime({
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
model: 'test-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'none',
|
||||
fetcher,
|
||||
defaultWorkspace: workspace
|
||||
})
|
||||
try {
|
||||
const events: RuntimeEvent[] = []
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
prompt: 'Copy the file in the requested directory.',
|
||||
workMode: 'execute'
|
||||
}, new AbortController().signal, async () => 'once')) {
|
||||
events.push(event)
|
||||
}
|
||||
expect(await readFile(join(target, 'output.txt'), 'utf8')).toBe('outside-workspace')
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
type: 'tool', callId: 'call-process', state: 'completed'
|
||||
}))
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('delegates one programming Subagent that can run a real project command without recursion', async () => {
|
||||
const command =
|
||||
process.platform === 'win32'
|
||||
|
||||
@@ -1217,7 +1217,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
|
||||
scopeKey: 'model:builtin:process_execute',
|
||||
title: '允许运行项目命令?',
|
||||
description:
|
||||
'该命令会在当前工作区中使用当前用户权限运行,并可访问该账号有权访问的主机资源。',
|
||||
'该命令会在指定目录(默认当前工作区)中使用当前用户权限运行,并可访问该账号有权访问的主机资源。',
|
||||
toolName: tool.displayName,
|
||||
argumentSummary,
|
||||
allowPermanent: false
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2'
|
||||
import { expect, it } from 'vitest'
|
||||
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
|
||||
const binaryPath = join(
|
||||
process.cwd(), '.runtime-resources', process.arch,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
|
||||
)
|
||||
|
||||
it.skipIf(!existsSync(binaryPath))(
|
||||
'closes real event streams after parallel runs without cancelling a peer',
|
||||
async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-lifecycle-'))
|
||||
let cancelledController: AbortController | undefined
|
||||
const model = createServer((request, response) => {
|
||||
void (async () => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += chunk.toString()
|
||||
if (body.includes('CANCEL_LIFECYCLE')) {
|
||||
setTimeout(() => cancelledController?.abort(
|
||||
new DOMException('Cancelled', 'AbortError')
|
||||
), 30)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
for (const [delta, finishReason] of [
|
||||
[{ role: 'assistant', content: 'LIFECYCLE_OK' }, null],
|
||||
[{}, 'stop']
|
||||
]) {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: 'lifecycle', object: 'chat.completion.chunk', created: 1,
|
||||
model: 'lifecycle',
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }]
|
||||
})}\n\n`)
|
||||
}
|
||||
response.end('data: [DONE]\n\n')
|
||||
})().catch(() => response.destroy())
|
||||
})
|
||||
await new Promise<void>((resolve) => model.listen(0, '127.0.0.1', resolve))
|
||||
const address = model.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No model port')
|
||||
const gateway = new KnowledgeMcpGateway({} as never)
|
||||
await gateway.start()
|
||||
let openEventBodies = 0
|
||||
const runtime = new OpenCodeRuntime({
|
||||
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
|
||||
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(), name: 'Lifecycle fixture', modelName: 'lifecycle',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
protocol: 'openai-chat-completions', authentication: 'none'
|
||||
}
|
||||
}, {
|
||||
createClient: (options) => createOpencodeClient({
|
||||
...options,
|
||||
fetch: async (request, init) => {
|
||||
const response = await fetch(request, init)
|
||||
const url = request instanceof Request ? request.url : request
|
||||
if (new URL(url).pathname !== '/event' || !response.body) return response
|
||||
openEventBodies++
|
||||
const reader = response.body.getReader()
|
||||
let closed = false
|
||||
const close = (): void => {
|
||||
if (!closed) openEventBodies--
|
||||
closed = true
|
||||
}
|
||||
return new Response(new ReadableStream({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const item = await reader.read()
|
||||
if (item.done) {
|
||||
close()
|
||||
controller.close()
|
||||
} else controller.enqueue(item.value)
|
||||
} catch (error) {
|
||||
close()
|
||||
controller.error(error)
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
close()
|
||||
await reader.cancel(reason)
|
||||
}
|
||||
}), { status: response.status, headers: response.headers })
|
||||
}
|
||||
})
|
||||
})
|
||||
const run = async (controller = new AbortController(), cancel = false): Promise<string> => {
|
||||
const requestId = crypto.randomUUID()
|
||||
const token = gateway.grant(requestId, [crypto.randomUUID()], controller.signal)!
|
||||
const deadline = setTimeout(() => controller.abort(), 30_000)
|
||||
let text = ''
|
||||
try {
|
||||
for await (const event of runtime.run({
|
||||
requestId, conversationId: crypto.randomUUID(), workMode: 'ask',
|
||||
prompt: cancel ? 'CANCEL_LIFECYCLE' : 'Reply LIFECYCLE_OK without tools.',
|
||||
knowledgeCapabilityToken: token
|
||||
}, controller.signal)) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
}
|
||||
return text
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
gateway.revoke(token)
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (const concurrency of [1, 4, 8]) {
|
||||
const results = await Promise.all(Array.from({ length: concurrency }, () => run()))
|
||||
expect(results).toEqual(Array(concurrency).fill('LIFECYCLE_OK'))
|
||||
await expect.poll(() => openEventBodies).toBe(0)
|
||||
}
|
||||
cancelledController = new AbortController()
|
||||
const cancelled = run(cancelledController, true)
|
||||
await Promise.all([
|
||||
expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }),
|
||||
expect(run()).resolves.toBe('LIFECYCLE_OK')
|
||||
])
|
||||
await expect.poll(() => openEventBodies).toBe(0)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await gateway.dispose()
|
||||
model.closeAllConnections()
|
||||
await new Promise<void>((resolve) => model.close(() => resolve()))
|
||||
await rm(workspace, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
120_000
|
||||
)
|
||||
@@ -1290,6 +1290,59 @@ describe('OpenCodeRuntime embedded launcher', () => {
|
||||
expect(runtime.requiresToolApproval).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['completed', 'failed', 'cancelled', 'consumer-return'])(
|
||||
'closes the event subscription when a run is %s',
|
||||
async (outcome) => {
|
||||
const setup = runClient([])
|
||||
const controller = new AbortController()
|
||||
let subscriptionSignal: AbortSignal | undefined
|
||||
const subscribe = setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
|
||||
subscribe.mockImplementation(async (_input, options: { signal: AbortSignal }) => {
|
||||
subscriptionSignal = options.signal
|
||||
return {
|
||||
stream: (async function* () {
|
||||
if (outcome === 'cancelled') {
|
||||
controller.abort(new DOMException('Cancelled', 'AbortError'))
|
||||
return
|
||||
}
|
||||
if (outcome === 'failed') return
|
||||
yield {
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'session-1' }
|
||||
}
|
||||
})()
|
||||
}
|
||||
})
|
||||
const runtime = new OpenCodeRuntime(
|
||||
options({ embedded: false, baseUrl: 'http://127.0.0.1:4096' }),
|
||||
{ createClient: (() => setup.client) as typeof createOpencodeClient }
|
||||
)
|
||||
const collect = async (): Promise<void> => {
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(),
|
||||
conversationId: crypto.randomUUID(),
|
||||
prompt: 'test',
|
||||
workMode: 'execute'
|
||||
}, controller.signal)) {
|
||||
if (outcome === 'consumer-return' && event.type === 'done') break
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (outcome === 'failed') {
|
||||
await expect(collect()).rejects.toThrow('事件流意外结束')
|
||||
} else if (outcome === 'cancelled') {
|
||||
await expect(collect()).rejects.toMatchObject({ name: 'AbortError' })
|
||||
} else {
|
||||
await collect()
|
||||
}
|
||||
expect(subscriptionSignal?.aborted).toBe(true)
|
||||
expect(controller.signal.aborted).toBe(outcome === 'cancelled')
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('serializes external runs that share one conversation session', async () => {
|
||||
const child = fakeChild()
|
||||
let releaseFirst!: () => void
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
requestProcessTreeTermination,
|
||||
waitForProcessExit
|
||||
} from './child-process-termination'
|
||||
import { toOpenCodeSubagentEvent } from './opencode-subagent'
|
||||
import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent'
|
||||
import { promptWithUntrustedConversationHistory } from './runtime-conversation-history'
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000
|
||||
@@ -1980,6 +1980,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
let knowledgeToolIds: string[] = []
|
||||
let customMcpName: string | undefined
|
||||
let customMcpToken: string | undefined
|
||||
const subscriptionController = new AbortController()
|
||||
try {
|
||||
if (
|
||||
request.knowledgeCapabilityToken &&
|
||||
@@ -2193,7 +2194,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
{ directory },
|
||||
{ signal: controlSignal }
|
||||
),
|
||||
signal
|
||||
AbortSignal.any([signal, subscriptionController.signal])
|
||||
)
|
||||
|
||||
const abortSession = (): void => {
|
||||
@@ -2216,6 +2217,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
>()
|
||||
const reasoningPartIds = new Set<string>()
|
||||
const subagentProgress = new OpenCodeSubagentProgress(request.requestId, sessionId)
|
||||
const reportedQuestionIds = new Map<string, string>()
|
||||
let aggregateOutputBytes = 0
|
||||
let outputTruncated = false
|
||||
@@ -2314,6 +2316,18 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const repliedPermissionIds = new Set<string>()
|
||||
const reportedMessageIds = new Set<string>()
|
||||
for await (const event of subscription.stream) {
|
||||
const childProgress = subagentProgress.update(event)
|
||||
if (
|
||||
childProgress &&
|
||||
'sessionID' in event.properties &&
|
||||
event.properties.sessionID !== sessionId
|
||||
) {
|
||||
const task = childProgress.runtimeCallId
|
||||
? toolStates.get(childProgress.runtimeCallId)
|
||||
: undefined
|
||||
if (task) task.subagent = childProgress
|
||||
yield childProgress
|
||||
}
|
||||
if (
|
||||
event.type === 'message.updated' &&
|
||||
event.properties.sessionID === sessionId &&
|
||||
@@ -2423,7 +2437,9 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
...(subagent ? { subagent } : {})
|
||||
})
|
||||
if (subagent) {
|
||||
yield subagent
|
||||
const retained = subagentProgress.retain(subagent)
|
||||
toolStates.get(callId)!.subagent = retained
|
||||
yield retained
|
||||
} else {
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
@@ -2699,6 +2715,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
)
|
||||
)
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
throw new Error('OpenCode 事件流意外结束')
|
||||
} catch (error) {
|
||||
abortSession()
|
||||
@@ -2739,6 +2756,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
subscriptionController.abort()
|
||||
const cleanupSignal = AbortSignal.timeout(1_000)
|
||||
if (knowledgeMcpName) {
|
||||
await awaitWithAbort(
|
||||
|
||||
@@ -1,9 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toOpenCodeSubagentEvent } from './opencode-subagent'
|
||||
import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent'
|
||||
|
||||
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
|
||||
|
||||
describe('OpenCode native subagent events', () => {
|
||||
it('extracts ACP output objects without displaying protocol metadata', () => {
|
||||
const event = toOpenCodeSubagentEvent({
|
||||
requestId, callId: 'task', state: 'completed',
|
||||
input: { subagent_type: 'general', prompt: 'Inspect seed' },
|
||||
output: {
|
||||
output: '<task id="child" state="completed">\n<task_result>\n**Done**\n</task_result>\n</task>',
|
||||
metadata: { sessionId: 'child' }
|
||||
}
|
||||
})
|
||||
expect(event?.output).toBe('**Done**')
|
||||
})
|
||||
|
||||
it('keeps ordered child text, reasoning and tools separate from the final result', () => {
|
||||
const tracker = new OpenCodeSubagentProgress(requestId, 'parent')
|
||||
const task = (status: string) => ({
|
||||
type: 'message.part.updated',
|
||||
properties: {
|
||||
sessionID: 'parent',
|
||||
part: {
|
||||
id: 'task-part', callID: 'task', type: 'tool', tool: 'task',
|
||||
state: {
|
||||
status, input: { subagent_type: 'general', prompt: 'Inspect seed' },
|
||||
metadata: { sessionId: 'child' },
|
||||
...(status === 'completed' ? { output: 'Final result' } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
tracker.update(task('running'))
|
||||
tracker.update({
|
||||
type: 'message.updated',
|
||||
properties: { sessionID: 'child', info: { id: 'user', role: 'user' } }
|
||||
})
|
||||
expect(tracker.update({
|
||||
type: 'message.part.updated',
|
||||
properties: { sessionID: 'child', part: {
|
||||
id: 'prompt', messageID: 'user', type: 'text', text: 'Do not display prompt'
|
||||
} }
|
||||
})).toBeUndefined()
|
||||
const delta = (sessionID: string, partID: string, text: string) => ({
|
||||
type: 'message.part.delta',
|
||||
properties: { sessionID, partID, field: 'text', delta: text }
|
||||
})
|
||||
expect(tracker.update(delta('unrelated', 'text', 'Not ours'))).toBeUndefined()
|
||||
tracker.update(delta('child', 'text', 'Inspecting '))
|
||||
const first = tracker.update(delta('child', 'text', 'seed'))
|
||||
expect(first?.progress?.[0]).toMatchObject({ type: 'text', content: 'Inspecting seed' })
|
||||
tracker.update({
|
||||
type: 'message.part.updated', properties: { sessionID: 'child', part: {
|
||||
id: 'thought', type: 'reasoning', text: ''
|
||||
} }
|
||||
})
|
||||
tracker.update(delta('child', 'thought', 'Check contents'))
|
||||
for (const status of ['running', 'completed']) {
|
||||
tracker.update({
|
||||
type: 'message.part.updated', properties: { sessionID: 'child', part: {
|
||||
id: 'read', callID: 'read', type: 'tool', tool: 'read',
|
||||
state: { status, input: { path: 'seed.txt' }, output: 'seed contents' }
|
||||
} }
|
||||
})
|
||||
}
|
||||
const done = tracker.update(task('completed'))
|
||||
expect(done?.output).toBe('Final result')
|
||||
expect(done?.progress?.map(b => b.type)).toEqual(['text', 'reasoning', 'tool'])
|
||||
expect(done?.progress?.[2]).toMatchObject({ tool: { state: 'completed', output: 'seed contents' } })
|
||||
expect(first?.progress).toHaveLength(1)
|
||||
})
|
||||
it('maps Task tool updates to stable bounded subagent identities', () => {
|
||||
const running = toOpenCodeSubagentEvent({
|
||||
requestId,
|
||||
@@ -57,4 +124,23 @@ describe('OpenCode native subagent events', () => {
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes recovered live child events by the ACP owning Task without new metadata', () => {
|
||||
const tracker = new OpenCodeSubagentProgress(requestId, 'parent')
|
||||
tracker.retain({
|
||||
...toOpenCodeSubagentEvent({
|
||||
requestId, callId: 'task', state: 'running',
|
||||
input: { subagent_type: 'general', prompt: 'Read seed' }
|
||||
})!,
|
||||
progress: []
|
||||
})
|
||||
const event = {
|
||||
type: 'message.part.delta',
|
||||
properties: { sessionID: 'child', partID: 'text', field: 'text', delta: 'Resumed progress' }
|
||||
}
|
||||
expect(tracker.update(event)).toBeUndefined()
|
||||
expect(tracker.update(event, 'task')).toMatchObject({
|
||||
state: 'running', progress: [{ type: 'text', content: 'Resumed progress' }]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { SubagentEvent } from '../../shared/contracts'
|
||||
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import { boundedToolDetail } from './approval-summary'
|
||||
|
||||
type OpenCodeSubagentState =
|
||||
@@ -113,11 +114,19 @@ export function toOpenCodeSubagentEvent(
|
||||
? 'completed'
|
||||
: 'failed'
|
||||
const failed = state === 'failed'
|
||||
const detail = isRecord(update.output)
|
||||
? update.output.output ?? update.output.error
|
||||
: update.output
|
||||
const result = typeof detail === 'string'
|
||||
? detail.match(
|
||||
/^\s*<task\b[^>]*>\s*<task_result>\s*([\s\S]*?)\s*<\/task_result>\s*<\/task>\s*$/u
|
||||
)?.[1] ?? detail
|
||||
: detail
|
||||
const output = failed
|
||||
? undefined
|
||||
: boundedDetail(update.output, 16_000)
|
||||
: boundedDetail(result, 16_000)
|
||||
const error = failed
|
||||
? boundedDetail(update.error ?? update.output, 1_000)
|
||||
? boundedDetail(update.error ?? detail, 1_000)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
@@ -138,3 +147,137 @@ export function toOpenCodeSubagentEvent(
|
||||
...(error ? { error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks only sessions explicitly linked by a Task's metadata.sessionId.
|
||||
* The same snapshots travel through the local SDK and the remote ACP bridge.
|
||||
*/
|
||||
export class OpenCodeSubagentProgress {
|
||||
private readonly roles = new Map<string, string>()
|
||||
private readonly reasoning = new Set<string>()
|
||||
private readonly tasks = new Map<string, {
|
||||
event: SubagentEvent
|
||||
sessionId?: string
|
||||
blocks: ConversationMessageBlock[]
|
||||
}>()
|
||||
|
||||
constructor(
|
||||
private readonly requestId: string,
|
||||
private readonly parentSessionId?: string
|
||||
) {}
|
||||
|
||||
update(value: unknown, ownerCallId?: string): SubagentEvent | undefined {
|
||||
if (!isRecord(value) || !isRecord(value.properties)) return undefined
|
||||
const properties = value.properties
|
||||
const part = properties.part
|
||||
if (
|
||||
value.type === 'message.part.updated' &&
|
||||
isRecord(part) && part.type === 'tool' &&
|
||||
part.tool === 'task' && isRecord(part.state) &&
|
||||
(!this.parentSessionId || properties.sessionID === this.parentSessionId) &&
|
||||
![...this.tasks.values()].some(
|
||||
(task) => task.sessionId === properties.sessionID
|
||||
)
|
||||
) {
|
||||
const callId = boundedText(part.callID ?? part.id, 256)
|
||||
if (!callId) return undefined
|
||||
const event = toOpenCodeSubagentEvent({
|
||||
requestId: this.requestId,
|
||||
callId,
|
||||
input: part.state.input,
|
||||
state: part.state.status as OpenCodeSubagentState,
|
||||
output: part.state.output,
|
||||
error: part.state.error
|
||||
})
|
||||
if (!event) return undefined
|
||||
const previous = this.tasks.get(callId)
|
||||
const metadata = part.state.metadata
|
||||
const task = {
|
||||
event,
|
||||
sessionId: (isRecord(metadata)
|
||||
? boundedText(metadata.sessionId, 256)
|
||||
: undefined) ?? previous?.sessionId,
|
||||
blocks: previous?.blocks ?? []
|
||||
}
|
||||
this.tasks.set(callId, task)
|
||||
return { ...event, progress: [...task.blocks] }
|
||||
}
|
||||
const task = [...this.tasks.values()].find(
|
||||
(candidate) => candidate.sessionId === properties.sessionID
|
||||
) ?? (ownerCallId ? this.tasks.get(ownerCallId) : undefined)
|
||||
if (!task) return undefined
|
||||
if (value.type === 'message.updated' && isRecord(properties.info)) {
|
||||
this.roles.set(String(properties.info.id), String(properties.info.role))
|
||||
return undefined
|
||||
}
|
||||
const messageId = isRecord(part) ? part.messageID : properties.messageID
|
||||
if (this.roles.get(String(messageId)) === 'user') return undefined
|
||||
const partId = isRecord(part) ? part.id : properties.partID
|
||||
if (typeof partId !== 'string') return undefined
|
||||
const id = deterministicUuid(`${task.event.childTaskId}\0${partId}`)
|
||||
const index = task.blocks.findIndex((block) => block.id === id)
|
||||
const previous = task.blocks[index]
|
||||
let block: ConversationMessageBlock | undefined
|
||||
if (value.type === 'message.part.updated' && isRecord(part)) {
|
||||
if (part.type === 'reasoning') this.reasoning.add(id)
|
||||
if (
|
||||
(part.type === 'text' || part.type === 'reasoning') &&
|
||||
typeof part.text === 'string' && part.text &&
|
||||
part.ignored !== true && part.synthetic !== true
|
||||
) {
|
||||
block = { id, type: part.type, content: part.text.slice(0, 16_000) }
|
||||
} else if (part.type === 'tool' && isRecord(part.state)) {
|
||||
block = {
|
||||
id,
|
||||
type: 'tool',
|
||||
tool: {
|
||||
callId: String(part.callID ?? partId).slice(0, 256),
|
||||
name: String(part.tool).slice(0, 200),
|
||||
summary: boundedText(part.state.title, 240) ?? String(part.tool),
|
||||
state: part.state.status === 'error'
|
||||
? 'failed'
|
||||
: part.state.status as 'pending' | 'running' | 'completed',
|
||||
input: boundedDetail(part.state.input, 4_000),
|
||||
output: boundedDetail(part.state.output, 16_000),
|
||||
error: boundedDetail(part.state.error, 1_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
value.type === 'message.part.delta' &&
|
||||
typeof properties.delta === 'string' && properties.delta &&
|
||||
(properties.field === 'text' || properties.field === 'reasoning')
|
||||
) {
|
||||
block = {
|
||||
id,
|
||||
type: this.reasoning.has(id) || previous?.type === 'reasoning' || properties.field === 'reasoning'
|
||||
? 'reasoning'
|
||||
: 'text',
|
||||
content: (
|
||||
(previous?.type === 'text' || previous?.type === 'reasoning'
|
||||
? previous.content : '') + properties.delta
|
||||
).slice(0, 16_000)
|
||||
}
|
||||
}
|
||||
if (!block) return undefined
|
||||
if (index >= 0) task.blocks[index] = block
|
||||
else task.blocks.push(block)
|
||||
return { ...task.event, progress: [...task.blocks] }
|
||||
}
|
||||
|
||||
retain(event: SubagentEvent): SubagentEvent {
|
||||
const task = event.runtimeCallId
|
||||
? this.tasks.get(event.runtimeCallId)
|
||||
: undefined
|
||||
if (!task) {
|
||||
if (event.runtimeCallId && event.progress) {
|
||||
this.tasks.set(event.runtimeCallId, {
|
||||
event, blocks: [...event.progress]
|
||||
})
|
||||
}
|
||||
return event
|
||||
}
|
||||
task.event = event
|
||||
return { ...event, progress: [...task.blocks] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1394,6 +1394,7 @@ function reduceRecoveredAgentEvent(
|
||||
workMode: event.workMode,
|
||||
state: event.state,
|
||||
reason: event.reason,
|
||||
progress: event.progress,
|
||||
output: event.output,
|
||||
error: event.error
|
||||
}
|
||||
|
||||
@@ -23,6 +23,47 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('getWorkspaceChanges', () => {
|
||||
it('reads unborn repositories, nested untracked files, index and worktree diffs separately', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
|
||||
temporaryDirectories.push(directory)
|
||||
await execute('git', ['init'], { cwd: directory })
|
||||
expect(await getWorkspaceChanges(directory)).toMatchObject({ available: true, files: [], patch: '' })
|
||||
await mkdir(join(directory, 'new directory'))
|
||||
const path = 'new directory/[file].txt'
|
||||
await writeFile(join(directory, path), 'index\n')
|
||||
expect((await getWorkspaceChanges(directory)).files).toContainEqual({ path, status: '??' })
|
||||
expect((await getWorkspaceChanges(directory, path)).patch).toContain('+index')
|
||||
await execute('git', ['add', '--', path], { cwd: directory })
|
||||
await writeFile(join(directory, path), 'worktree\n')
|
||||
const diff = await getWorkspaceChanges(directory, path)
|
||||
expect(diff).toMatchObject({ available: true })
|
||||
expect(diff.stagedPatch).toContain('+index')
|
||||
expect(diff.patch).toContain('-index')
|
||||
expect(diff.patch).toContain('+worktree')
|
||||
})
|
||||
|
||||
it('reads deleted and renamed paths literally without including unrelated changes', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const git = (...args: string[]) => execute('git', args, { cwd: directory })
|
||||
await git('init')
|
||||
await writeFile(join(directory, 'old name.txt'), 'original\n')
|
||||
await writeFile(join(directory, 'other.txt'), 'other\n')
|
||||
await git('add', '.')
|
||||
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'initial')
|
||||
await git('mv', 'old name.txt', '[new].txt')
|
||||
await writeFile(join(directory, '[new].txt'), 'edited\n')
|
||||
await writeFile(join(directory, 'other.txt'), 'unrelated\n')
|
||||
const renamed = await getWorkspaceChanges(directory, '[new].txt')
|
||||
expect(renamed.stagedPatch).toContain('rename from old name.txt')
|
||||
expect(renamed.stagedPatch).toContain('rename to [new].txt')
|
||||
expect(renamed.patch).toContain('+edited')
|
||||
expect(renamed.patch).not.toContain('unrelated')
|
||||
await rm(join(directory, '[new].txt'))
|
||||
expect((await getWorkspaceChanges(directory, '[new].txt')).patch).toContain('-original')
|
||||
await git('add', '-u')
|
||||
expect((await getWorkspaceChanges(directory, 'old name.txt')).stagedPatch).toContain('-original')
|
||||
})
|
||||
it('returns tracked and untracked Git workspace changes', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
@@ -59,8 +59,8 @@ const previewFileNames = new Set([
|
||||
export class WorkspaceChangesService {
|
||||
constructor(private readonly workspace: WorkspaceAccess) {}
|
||||
|
||||
getChanges(signal?: AbortSignal): Promise<WorkspaceChanges> {
|
||||
return this.workspace.getChanges({ signal })
|
||||
getChanges(signal?: AbortSignal, path?: string): Promise<WorkspaceChanges> {
|
||||
return this.workspace.getChanges({ signal, path })
|
||||
}
|
||||
|
||||
async listDirectory(
|
||||
@@ -158,13 +158,14 @@ export async function resolveWorkspaceEntryPath(
|
||||
}
|
||||
|
||||
export async function getWorkspaceChanges(
|
||||
workspace: string | WorkspaceAccess
|
||||
workspace: string | WorkspaceAccess,
|
||||
path?: string
|
||||
): Promise<WorkspaceChanges> {
|
||||
if (typeof workspace !== 'string') {
|
||||
return new WorkspaceChangesService(workspace).getChanges()
|
||||
return new WorkspaceChangesService(workspace).getChanges(undefined, path)
|
||||
}
|
||||
return withLocalWorkspace(workspace, (access) =>
|
||||
new WorkspaceChangesService(access).getChanges()
|
||||
new WorkspaceChangesService(access).getChanges(undefined, path)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4157,6 +4157,7 @@ describe('registerIpcHandlers workspace files', () => {
|
||||
|
||||
for (const [channel, input] of [
|
||||
[ipcChannels.workspaceChangesGet, projectId],
|
||||
[ipcChannels.workspaceFileDiff, { projectId, path: 'deleted.txt' }],
|
||||
[
|
||||
ipcChannels.workspaceDirectoryList,
|
||||
{ projectId, path: '' }
|
||||
|
||||
+18
-1
@@ -6601,6 +6601,23 @@ export function registerIpcHandlers(
|
||||
}
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.workspaceFileDiff,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const value = workspaceFileRequestSchema.omit({ offsetBytes: true }).parse(input)
|
||||
const project = assistantDatabase.getProject(value.projectId)
|
||||
if (project.executionSpace?.kind === 'ssh') {
|
||||
await requireRemoteProjectsEnabled()
|
||||
}
|
||||
const executionSpace = spaceResolver.resolveProject(project)
|
||||
try {
|
||||
return await getWorkspaceChanges(executionSpace.workspaceAccess, value.path)
|
||||
} finally {
|
||||
await executionSpace.workspaceAccess.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
registerHandler(
|
||||
ipcChannels.workspaceDirectoryList,
|
||||
async (event, input: unknown) => {
|
||||
@@ -6652,9 +6669,9 @@ export function registerIpcHandlers(
|
||||
await requireRemoteProjectsEnabled()
|
||||
}
|
||||
const executionSpace = spaceResolver.resolveProject(project)
|
||||
spaceResolver.assertLocal(executionSpace)
|
||||
let targetPath: string
|
||||
try {
|
||||
spaceResolver.assertLocal(executionSpace)
|
||||
targetPath = await resolveWorkspaceEntryPath(
|
||||
executionSpace.rootPath,
|
||||
value.path,
|
||||
|
||||
@@ -257,6 +257,48 @@ function response(
|
||||
}
|
||||
|
||||
describe('AgentPackageManager remote install candidates', () => {
|
||||
it('pages GitHub release metadata and stops at the first signed catalog', async () => {
|
||||
const fixture = await createFixture('github')
|
||||
const pages: number[] = []
|
||||
const desktopReleases = Array.from({ length: 10 }, () => ({
|
||||
tag_name: 'v9.0.0',
|
||||
body: 'release notes'.repeat(1500),
|
||||
assets: []
|
||||
}))
|
||||
expect(JSON.stringify(Array(10).fill(desktopReleases)).length)
|
||||
.toBeGreaterThan(1024 * 1024)
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = new URL(input.toString())
|
||||
if (url.hostname === 'api.github.com') {
|
||||
expect(url.searchParams.get('per_page')).toBe('10')
|
||||
const page = Number(url.searchParams.get('page'))
|
||||
pages.push(page)
|
||||
return response(JSON.stringify(
|
||||
page === 1 ? desktopReleases : fixture.releases
|
||||
))
|
||||
}
|
||||
return response(url.pathname.endsWith('.sig')
|
||||
? fixture.signature : fixture.catalogBytes)
|
||||
})
|
||||
|
||||
const snapshot = await fixture.manager(transport).getSnapshot()
|
||||
expect(snapshot.catalog.state).toBe('available')
|
||||
expect(snapshot.entries[0]?.latestVersion).toBe('2.0.0')
|
||||
expect(pages).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it.each([0, 10])('bounds GitHub discovery when each page contains %i releases', async (count) => {
|
||||
const fixture = await createFixture('github')
|
||||
const transport = vi.fn<typeof fetch>(async () =>
|
||||
response(JSON.stringify(Array.from({ length: count }, () => ({
|
||||
tag_name: 'v9.0.0'
|
||||
}))))
|
||||
)
|
||||
const snapshot = await fixture.manager(transport).getSnapshot()
|
||||
expect(snapshot.catalog.state).toBe('unavailable')
|
||||
expect(transport).toHaveBeenCalledTimes(count === 0 ? 1 : 10)
|
||||
})
|
||||
|
||||
it('selects Linux and Darwin arm64 independently from one catalog', async () => {
|
||||
await createFixture('mirror', (catalog) => {
|
||||
for (const platform of ['linux', 'darwin'] as const) {
|
||||
|
||||
@@ -61,7 +61,7 @@ const CATALOG_SIGNATURE_DOMAIN = Buffer.from(
|
||||
const MIRROR_ROOT =
|
||||
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/'
|
||||
const GITHUB_RELEASES_API =
|
||||
'https://api.github.com/repos/mesalogo/goodbuddy/releases?per_page=100'
|
||||
'https://api.github.com/repos/mesalogo/goodbuddy/releases?per_page=10'
|
||||
const GITHUB_RELEASE_ROOT =
|
||||
'https://github.com/mesalogo/goodbuddy/releases/download/'
|
||||
const MAXIMUM_CATALOG_BYTES = 1024 * 1024
|
||||
@@ -1449,17 +1449,29 @@ export class AgentPackageManager {
|
||||
])
|
||||
}
|
||||
|
||||
async *#githubReleases(signal?: AbortSignal): AsyncGenerator<unknown> {
|
||||
// Keep the existing 100-release search window without downloading all
|
||||
// historical release bodies and asset metadata in one response.
|
||||
for (let page = 1; page <= 10; page += 1) {
|
||||
signal?.throwIfAborted()
|
||||
const releases = await this.#fetchJson(
|
||||
`${GITHUB_RELEASES_API}&page=${page}`,
|
||||
signal
|
||||
)
|
||||
if (!Array.isArray(releases)) {
|
||||
throw new Error('GitHub Agent 发布目录无效')
|
||||
}
|
||||
yield* releases.slice(0, 10)
|
||||
if (releases.length < 10) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchGithubCatalog(
|
||||
signal?: AbortSignal
|
||||
): Promise<[Buffer, Buffer]> {
|
||||
const releases = await this.#fetchJson(
|
||||
GITHUB_RELEASES_API,
|
||||
signal
|
||||
)
|
||||
if (!Array.isArray(releases)) {
|
||||
throw new Error('GitHub Agent 发布目录无效')
|
||||
}
|
||||
for (const untrusted of releases.slice(0, 100)) {
|
||||
for await (const untrusted of this.#githubReleases(signal)) {
|
||||
if (
|
||||
typeof untrusted !== 'object' ||
|
||||
untrusted === null ||
|
||||
|
||||
@@ -775,53 +775,76 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
|
||||
canonicalRoot,
|
||||
input.signal
|
||||
)
|
||||
const [statusResult, patchResult] = await Promise.all([
|
||||
runGit(
|
||||
const git = (args: string[]): Promise<CommandResult> =>
|
||||
runGit(canonicalRoot, withSafeGitArguments(args), input.signal, filterOverrides)
|
||||
const statusResult = await runGit(
|
||||
canonicalRoot,
|
||||
withSafeGitArguments([
|
||||
'status',
|
||||
'--porcelain=v1',
|
||||
'-z',
|
||||
'--untracked-files=normal'
|
||||
]),
|
||||
input.signal,
|
||||
filterOverrides
|
||||
),
|
||||
runGit(
|
||||
canonicalRoot,
|
||||
withSafeGitArguments([
|
||||
'diff',
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
'--no-color',
|
||||
'HEAD'
|
||||
'--untracked-files=all'
|
||||
]),
|
||||
input.signal,
|
||||
filterOverrides
|
||||
)
|
||||
])
|
||||
if (statusResult.code !== 0 || patchResult.code !== 0) {
|
||||
const detail = statusResult.stderr || patchResult.stderr
|
||||
if (statusResult.code !== 0) {
|
||||
const detail = statusResult.stderr
|
||||
return {
|
||||
rootPath: this.rootPath,
|
||||
available: false,
|
||||
status: '',
|
||||
patch: '',
|
||||
files: [],
|
||||
truncated: statusResult.truncated || patchResult.truncated,
|
||||
truncated: statusResult.truncated,
|
||||
error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区'
|
||||
}
|
||||
}
|
||||
const changedFiles = parseChangedFiles(statusResult.stdout)
|
||||
const diffArgs = ['diff', '--no-ext-diff', '--no-textconv', '--no-color']
|
||||
let patchResult: CommandResult
|
||||
let stagedResult: CommandResult | undefined
|
||||
if (input.path !== undefined) {
|
||||
const path = normalizeRelativePath(input.path, false).path
|
||||
const file = changedFiles.files.find((entry) => entry.path === path)
|
||||
if (!file) {
|
||||
throw new Error('File is no longer in the Git changes list')
|
||||
}
|
||||
const paths = [...new Set([file.previousPath, path].filter((value): value is string => value !== undefined))]
|
||||
.map((value) => `:(literal)${value}`)
|
||||
if (file.status === '??') {
|
||||
await this.resolveEntryPath(path, 'file')
|
||||
patchResult = await git([...diffArgs, '--no-index', '--', NULL_DEVICE, path])
|
||||
if (patchResult.code !== 0 && patchResult.code !== 1) {
|
||||
throw new Error(patchResult.stderr || 'Git diff failed')
|
||||
}
|
||||
} else {
|
||||
;[stagedResult, patchResult] = await Promise.all([
|
||||
git([...diffArgs, '--cached', '--', ...paths]),
|
||||
git([...diffArgs, '--', ...paths])
|
||||
])
|
||||
if (stagedResult.code !== 0 || patchResult.code !== 0) {
|
||||
throw new Error(stagedResult.stderr || patchResult.stderr || 'Git diff failed')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const head = await git(['rev-parse', '--verify', '--quiet', 'HEAD'])
|
||||
patchResult = await git([...diffArgs, ...(head.code === 0 ? ['HEAD'] : ['--cached'])])
|
||||
}
|
||||
return {
|
||||
rootPath: this.rootPath,
|
||||
available: true,
|
||||
status: formatChangedFiles(changedFiles.files),
|
||||
patch: patchResult.stdout,
|
||||
...(patchResult.code !== 0 && input.path === undefined
|
||||
? { error: patchResult.stderr.trim().slice(0, 2000) || 'Git diff failed' }
|
||||
: {}),
|
||||
...(stagedResult ? { stagedPatch: stagedResult.stdout } : {}),
|
||||
files: changedFiles.files,
|
||||
truncated:
|
||||
statusResult.truncated ||
|
||||
patchResult.truncated ||
|
||||
stagedResult?.truncated === true ||
|
||||
changedFiles.truncated
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -113,6 +113,26 @@ function createAccess(lease: RemoteWorkspaceTransportLease): {
|
||||
}
|
||||
|
||||
describe('RemoteWorkspaceAccess', () => {
|
||||
it('requests both single-file diff layers and preserves raw Git status', async () => {
|
||||
const lease = createLease({
|
||||
getGitStatus: vi.fn(async () => ({
|
||||
repositoryIdentity: 'repository-1', branch: 'main', truncated: false,
|
||||
entries: [{ relativePath: '[file].txt', index: 'modified' as const, worktree: 'unmodified' as const, statusCode: 'T.' }]
|
||||
})),
|
||||
getGitDiff: vi.fn(async (request) => ({
|
||||
repositoryIdentity: 'repository-1', patch: request.staged ? 'staged' : 'worktree', byteLength: request.staged ? 6 : 8, truncated: request.staged
|
||||
}))
|
||||
})
|
||||
const { access } = createAccess(lease)
|
||||
expect(await access.getChanges({ path: '[file].txt' })).toMatchObject({
|
||||
available: true, patch: 'worktree', stagedPatch: 'staged', truncated: true,
|
||||
files: [{ path: '[file].txt', status: 'T ' }]
|
||||
})
|
||||
expect(lease.getGitDiff).toHaveBeenCalledWith(expect.objectContaining({ relativePath: '[file].txt', staged: true }), undefined)
|
||||
expect(lease.getGitDiff).toHaveBeenCalledWith(expect.objectContaining({ relativePath: '[file].txt', staged: false }), undefined)
|
||||
expect(lease.statWorkspace).not.toHaveBeenCalled()
|
||||
await access.dispose()
|
||||
})
|
||||
it('lazily validates once and maps bounded workspace methods', async () => {
|
||||
const lease = createLease({
|
||||
listWorkspace: vi.fn(async () => ({
|
||||
|
||||
@@ -247,7 +247,7 @@ function mapChangedFiles(
|
||||
: `${gitStatusCharacter(entry.index)}${gitStatusCharacter(entry.worktree)}`
|
||||
return {
|
||||
path: entry.relativePath,
|
||||
status,
|
||||
status: entry.statusCode?.replaceAll('.', ' ') ?? status,
|
||||
...(entry.originalRelativePath
|
||||
? { previousPath: entry.originalRelativePath }
|
||||
: {})
|
||||
@@ -577,8 +577,7 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
input.signal?.throwIfAborted()
|
||||
const reference = this.reference(opened)
|
||||
const [status, diff] = await Promise.all([
|
||||
opened.lease
|
||||
const status = await opened.lease
|
||||
.getGitStatus(
|
||||
remoteGitStatusRequestSchema.parse({
|
||||
...reference,
|
||||
@@ -590,12 +589,13 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
)
|
||||
.then((result) =>
|
||||
remoteGitStatusResultSchema.parse(result)
|
||||
),
|
||||
opened.lease
|
||||
)
|
||||
const readDiff = (staged: boolean) => opened.lease
|
||||
.getGitDiff(
|
||||
remoteGitDiffRequestSchema.parse({
|
||||
...reference,
|
||||
staged: false,
|
||||
staged,
|
||||
...(input.path === undefined ? {} : { relativePath: input.path }),
|
||||
maximumBytes:
|
||||
REMOTE_WORKSPACE_LIMITS.maximumGitDiffBytes
|
||||
}),
|
||||
@@ -604,8 +604,12 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
.then((result) =>
|
||||
remoteGitDiffResultSchema.parse(result)
|
||||
)
|
||||
const [diff, stagedDiff] = await Promise.all([
|
||||
readDiff(false),
|
||||
input.path === undefined ? undefined : readDiff(true)
|
||||
])
|
||||
if (status.repositoryIdentity !== diff.repositoryIdentity) {
|
||||
if (status.repositoryIdentity !== diff.repositoryIdentity ||
|
||||
(stagedDiff && status.repositoryIdentity !== stagedDiff.repositoryIdentity)) {
|
||||
if (attempt === 0) {
|
||||
continue
|
||||
}
|
||||
@@ -617,10 +621,12 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
available: true,
|
||||
status: formatChangedFiles(files),
|
||||
patch: diff.patch,
|
||||
...(stagedDiff ? { stagedPatch: stagedDiff.patch } : {}),
|
||||
files,
|
||||
truncated:
|
||||
status.truncated ||
|
||||
diff.truncated ||
|
||||
stagedDiff?.truncated === true ||
|
||||
diff.nextCursor !== undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ export type SearchPage = {
|
||||
}
|
||||
|
||||
export type WorkspaceChangesInput = {
|
||||
path?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
|
||||
@@ -1089,6 +1089,11 @@ const desktopApi: DesktopApi = {
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
getFileDiff: (projectId: string, path: string) =>
|
||||
ipcRenderer.invoke(ipcChannels.workspaceFileDiff, {
|
||||
projectId,
|
||||
path
|
||||
}) as Promise<WorkspaceChanges>,
|
||||
getChanges: (projectId: string) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.workspaceChangesGet,
|
||||
|
||||
@@ -523,6 +523,7 @@ const api: DesktopApi = {
|
||||
})
|
||||
},
|
||||
workspace: {
|
||||
getFileDiff: vi.fn(),
|
||||
getChanges: vi.fn(async () => ({
|
||||
rootPath: 'C:\\Workspace',
|
||||
available: true,
|
||||
@@ -4775,7 +4776,14 @@ describe('App', () => {
|
||||
name: /产品知识/u
|
||||
})
|
||||
await waitFor(() => expect(scopeCheckbox).toHaveFocus())
|
||||
fireEvent.click(scopeCheckbox)
|
||||
fireEvent.focusOut(scopeCheckbox, { relatedTarget: null })
|
||||
expect(knowledgeScopeTrigger).toHaveAttribute('aria-expanded', 'true')
|
||||
fireEvent.click(screen.getByText('产品知识', { selector: 'span' }))
|
||||
expect(scopeCheckbox).toBeChecked()
|
||||
fireEvent.click(scopeCheckbox.closest('label')!)
|
||||
expect(scopeCheckbox).not.toBeChecked()
|
||||
fireEvent.click(scopeCheckbox.closest('label')!.querySelector('small')!)
|
||||
expect(scopeCheckbox).toBeChecked()
|
||||
const composerInput = screen.getByLabelText('向 GoodBuddy 提问')
|
||||
fireEvent.focusOut(scopeCheckbox, {
|
||||
relatedTarget: composerInput
|
||||
@@ -5815,6 +5823,13 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('refreshes generated workspace files when a run completes', async () => {
|
||||
vi.mocked(api.workspace.listDirectory).mockImplementation(async (_projectId, path) => ({
|
||||
path,
|
||||
entries: vi.mocked(api.workspace.getChanges).mock.calls.length >= 2
|
||||
? [{ name: 'tree-only.txt', path: 'tree-only.txt', type: 'file' as const }]
|
||||
: [],
|
||||
truncated: false
|
||||
}))
|
||||
vi.mocked(api.workspace.getChanges)
|
||||
.mockResolvedValueOnce({
|
||||
rootPath: project.rootPath,
|
||||
@@ -5852,6 +5867,7 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
expect(await screen.findByText('generated.md')).toBeInTheDocument()
|
||||
expect(await screen.findByText('tree-only.txt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores stale Git changes after switching projects', async () => {
|
||||
@@ -10246,8 +10262,8 @@ describe('App', () => {
|
||||
screen.getByRole('tab', { name: '工作区' })
|
||||
).toHaveAttribute('title', '浏览项目文件、Git 变更与文件内容')
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '项目工作区' })
|
||||
).toBeInTheDocument()
|
||||
screen.queryByRole('heading', { name: '项目工作区' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '刷新工作区文件' })
|
||||
).toBeInTheDocument()
|
||||
|
||||
@@ -4740,6 +4740,7 @@ function App(): React.JSX.Element {
|
||||
workMode: event.workMode,
|
||||
state: event.state,
|
||||
reason: event.reason,
|
||||
progress: event.progress,
|
||||
output: event.output,
|
||||
error: event.error
|
||||
}
|
||||
@@ -5886,6 +5887,10 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
[activeProjectId]
|
||||
)
|
||||
const loadWorkspaceDiff = useCallback(async (path: string) => {
|
||||
if (!activeProjectId) throw new Error(tRef.current('notices.selectProject'))
|
||||
return window.goodbuddy.workspace.getFileDiff(activeProjectId, path)
|
||||
}, [activeProjectId])
|
||||
const openWorkspaceEntry = useCallback(
|
||||
async (
|
||||
path: string,
|
||||
@@ -9787,7 +9792,7 @@ function App(): React.JSX.Element {
|
||||
className="knowledge-scope"
|
||||
onBlurCapture={(event) => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
event.relatedTarget instanceof Node &&
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
setKnowledgeScopeOpen(false)
|
||||
@@ -11228,6 +11233,7 @@ function App(): React.JSX.Element {
|
||||
onListWorkspaceDirectory={listWorkspaceDirectory}
|
||||
onLoadWorkspaceFile={loadWorkspaceFile}
|
||||
onOpenWorkspaceEntry={openWorkspaceEntry}
|
||||
onLoadWorkspaceDiff={loadWorkspaceDiff}
|
||||
onRefreshChanges={refreshWorkspaceChanges}
|
||||
onTabChange={setAssistantSidebarTab}
|
||||
open={assistantSidebarOpen}
|
||||
|
||||
@@ -408,6 +408,46 @@ describe('ChatTimeline', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('renders live child progress in order and keeps the final result separate', () => {
|
||||
const child = {
|
||||
childTaskId: '00000000-0000-4000-8000-000000000111',
|
||||
expertId: '00000000-0000-4000-8000-000000000211',
|
||||
expertName: 'general', routingMode: 'native' as const,
|
||||
state: 'running' as const,
|
||||
progress: [
|
||||
{ id: 'text', type: 'text' as const, content: 'Inspecting seed' },
|
||||
{ id: 'tool', type: 'tool' as const, tool: {
|
||||
callId: 'read', name: 'read', state: 'completed' as const,
|
||||
summary: 'Read seed', output: 'seed contents'
|
||||
} },
|
||||
{ id: 'reasoning', type: 'reasoning' as const, content: 'Check the result' }
|
||||
]
|
||||
}
|
||||
const props = {
|
||||
artifactById: new Map(), conversationId: 'conversation-1',
|
||||
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
|
||||
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
|
||||
}
|
||||
const message: Message = {
|
||||
id: 'assistant', role: 'assistant', content: '', createdAt: 0,
|
||||
state: 'streaming', subagents: [child]
|
||||
}
|
||||
const view = render(<ChatTimeline {...props} messages={[message]} />)
|
||||
expect(screen.queryByText('Inspecting seed')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('general', { selector: 'strong' }))
|
||||
fireEvent(screen.getByText('general', { selector: 'strong' }).closest('details')!, new Event('toggle'))
|
||||
const progress = screen.getByRole('region', { name: '执行过程' })
|
||||
expect(within(progress).getByText('Inspecting seed')).toBeInTheDocument()
|
||||
expect(within(progress).getByText('Check the result').closest('details')).not.toHaveAttribute('open')
|
||||
expect(screen.queryByRole('region', { name: '最终结果' })).not.toBeInTheDocument()
|
||||
view.rerender(<ChatTimeline {...props} messages={[{
|
||||
...message, state: 'complete',
|
||||
subagents: [{ ...child, state: 'completed', output: 'Final seed' }]
|
||||
}]} />)
|
||||
expect(screen.getByRole('region', { name: '最终结果' })).toHaveTextContent('Final seed')
|
||||
expect(screen.getByRole('region', { name: '执行过程' })).not.toHaveTextContent('Final seed')
|
||||
})
|
||||
|
||||
it('labels native OpenCode subagents and shows their task description', () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
|
||||
@@ -292,6 +292,14 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
actor.kind === 'direct-model' && subagent.workMode
|
||||
? `${source} · ${subagent.workMode === 'execute' ? 'Execute' : 'Ask'}`
|
||||
: source
|
||||
const progress = subagent.progress?.filter((block, index, blocks) =>
|
||||
!(
|
||||
subagent.state === 'completed' &&
|
||||
index === blocks.length - 1 &&
|
||||
block.type === 'text' &&
|
||||
block.content.trim() === subagent.output?.trim()
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<details
|
||||
@@ -332,9 +340,37 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
</summary>
|
||||
{expanded && (
|
||||
<div className="subagent-status-card__details">
|
||||
{progress && progress.length > 0 && (
|
||||
<section aria-label={t('chat.subagents.progress')}>
|
||||
<strong>{t('chat.subagents.progress')}</strong>
|
||||
<div className="subagent-status-card__progress">
|
||||
{groupMessageBlocks(progress).map((item) =>
|
||||
item.kind === 'tools' ? (
|
||||
<ToolExecutionList key={item.id} tools={item.tools.map((tool) =>
|
||||
(tool.state === 'pending' || tool.state === 'running') &&
|
||||
subagent.state !== 'queued' && subagent.state !== 'running'
|
||||
? { ...tool, state: subagent.state === 'cancelled' ? 'cancelled' : 'interrupted' }
|
||||
: tool
|
||||
)} />
|
||||
) : item.kind === 'subagents' ? null
|
||||
: item.block.type === 'reasoning' ? (
|
||||
<MessageReasoning
|
||||
key={item.block.id}
|
||||
content={item.block.content}
|
||||
streaming={false}
|
||||
/>
|
||||
) : item.block.type === 'text' ? (
|
||||
<div key={item.block.id} className="markdown-content">
|
||||
<MarkdownRenderer>{item.block.content}</MarkdownRenderer>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{subagent.output ? (
|
||||
<section>
|
||||
<strong>{t('chat.subagents.output')}</strong>
|
||||
<section aria-label={t('chat.subagents.finalOutput')}>
|
||||
<strong>{t('chat.subagents.finalOutput')}</strong>
|
||||
<div className="markdown-content">
|
||||
<MarkdownRenderer
|
||||
renderHtml={
|
||||
@@ -345,9 +381,9 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
</MarkdownRenderer>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
) : !subagent.progress?.length ? (
|
||||
<p>{t('chat.subagents.noOutput')}</p>
|
||||
)}
|
||||
) : null}
|
||||
{subagent.error &&
|
||||
(subagent.state === 'failed' ||
|
||||
subagent.state === 'cancelled') && (
|
||||
|
||||
@@ -76,6 +76,7 @@ function renderSidebar({
|
||||
onListWorkspaceDirectory={onListWorkspaceDirectory}
|
||||
onLoadArtifact={onLoadArtifact}
|
||||
onLoadWorkspaceFile={onLoadWorkspaceFile}
|
||||
onLoadWorkspaceDiff={vi.fn()}
|
||||
onOpenWorkspaceEntry={vi.fn(async () => undefined)}
|
||||
onInteractBrowser={vi.fn(async () => undefined)}
|
||||
onRefreshChanges={vi.fn(async () => undefined)}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CircleAlert,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderTree,
|
||||
ClockFading,
|
||||
Monitor,
|
||||
Plus,
|
||||
@@ -117,6 +116,7 @@ type RightAssistantSidebarProps = {
|
||||
onImportArtifacts: () => Promise<void>
|
||||
onLoadArtifact: (artifactId: string) => Promise<void>
|
||||
onRefreshChanges: () => Promise<void>
|
||||
onLoadWorkspaceDiff: (path: string) => Promise<WorkspaceChanges>
|
||||
onListWorkspaceDirectory: (
|
||||
path: string
|
||||
) => Promise<WorkspaceDirectoryListing>
|
||||
@@ -500,6 +500,7 @@ export function RightAssistantSidebar({
|
||||
onImportArtifacts,
|
||||
onLoadArtifact,
|
||||
onRefreshChanges,
|
||||
onLoadWorkspaceDiff,
|
||||
onListWorkspaceDirectory,
|
||||
onLoadWorkspaceFile,
|
||||
onOpenWorkspaceEntry,
|
||||
@@ -629,7 +630,6 @@ export function RightAssistantSidebar({
|
||||
useState(false)
|
||||
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
|
||||
useState('')
|
||||
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
|
||||
const [taskFilter, setTaskFilter] = useState<
|
||||
'attention' | 'active' | 'paused' | 'finished'
|
||||
>('active')
|
||||
@@ -1583,15 +1583,12 @@ export function RightAssistantSidebar({
|
||||
</section>
|
||||
) : (
|
||||
<section className="assistant-sidebar__section">
|
||||
<h3>
|
||||
<FolderTree size={15} />
|
||||
{t('sidebar.workspace.projectTitle')}
|
||||
<div className="workspace-files__toolbar">
|
||||
<button
|
||||
aria-label={t('sidebar.workspace.refreshAriaLabel')}
|
||||
className="icon-button"
|
||||
disabled={!workspaceProjectId}
|
||||
onClick={() => {
|
||||
setWorkspaceRefreshVersion((current) => current + 1)
|
||||
void runAction(
|
||||
onRefreshChanges,
|
||||
t('sidebar.errors.refreshWorkspace')
|
||||
@@ -1602,12 +1599,14 @@ export function RightAssistantSidebar({
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</h3>
|
||||
</div>
|
||||
<WorkspaceFilesPanel
|
||||
changedFiles={workspaceChanges?.files ?? emptyChangedFiles}
|
||||
key={`${workspaceProjectId ?? 'none'}:${workspaceRefreshVersion}`}
|
||||
key={workspaceProjectId ?? 'none'}
|
||||
refreshToken={workspaceChanges}
|
||||
onLoadDiff={onLoadWorkspaceDiff}
|
||||
onListDirectory={onListWorkspaceDirectory}
|
||||
onOpenEntry={onOpenWorkspaceEntry}
|
||||
onOpenEntry={currentProject?.executionSpace?.kind === 'ssh' ? undefined : onOpenWorkspaceEntry}
|
||||
onOpenFile={openWorkspaceFile}
|
||||
projectId={workspaceProjectId}
|
||||
/>
|
||||
@@ -1618,17 +1617,6 @@ export function RightAssistantSidebar({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{workspaceChanges?.patch && (
|
||||
<details className="assistant-sidebar__diff-details">
|
||||
<summary>{t('sidebar.workspace.fullDiff')}</summary>
|
||||
<pre className="assistant-sidebar__diff">
|
||||
{workspaceChanges.patch}
|
||||
{workspaceChanges.truncated
|
||||
? t('sidebar.workspace.truncatedDiff')
|
||||
: ''}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
import { gitStatusLetter, WorkspaceFilesPanel } from './WorkspaceFilesPanel'
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
@@ -9,6 +9,9 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('WorkspaceFilesPanel', () => {
|
||||
it.each([['??', 'U'], [' M', 'M'], ['A ', 'A'], [' D', 'D'], ['R ', 'R'], ['C ', 'C'], ['UU', 'U'], ['AM', 'AM'], [' T', 'T']])('maps %s to %s without calling every new file added', (status, letter) => {
|
||||
expect(gitStatusLetter(status)).toBe(letter)
|
||||
})
|
||||
it('lists the project tree, expands directories, and opens files', async () => {
|
||||
const onListDirectory = vi.fn(async (path: string) =>
|
||||
path
|
||||
@@ -45,6 +48,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
|
||||
render(
|
||||
<WorkspaceFilesPanel
|
||||
onLoadDiff={vi.fn()}
|
||||
changedFiles={[{ path: 'notes.txt', status: ' M' }]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={onOpenEntry}
|
||||
@@ -74,7 +78,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
})
|
||||
)
|
||||
expect(onOpenEntry).toHaveBeenCalledWith('docs', 'directory')
|
||||
expect(screen.getAllByText('修改')).not.toHaveLength(0)
|
||||
expect(screen.getAllByText('M')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores stale directory results after the active project changes', async () => {
|
||||
@@ -100,6 +104,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
})
|
||||
const { rerender } = render(
|
||||
<WorkspaceFilesPanel
|
||||
onLoadDiff={vi.fn()}
|
||||
changedFiles={[]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={vi.fn(async () => undefined)}
|
||||
@@ -111,6 +116,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
await waitFor(() => expect(onListDirectory).toHaveBeenCalledOnce())
|
||||
rerender(
|
||||
<WorkspaceFilesPanel
|
||||
onLoadDiff={vi.fn()}
|
||||
changedFiles={[]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={vi.fn(async () => undefined)}
|
||||
@@ -133,6 +139,7 @@ describe('WorkspaceFilesPanel', () => {
|
||||
|
||||
render(
|
||||
<WorkspaceFilesPanel
|
||||
onLoadDiff={vi.fn()}
|
||||
changedFiles={[{ path: 'notes.txt', status: ' M' }]}
|
||||
onListDirectory={onListDirectory}
|
||||
onOpenEntry={vi.fn(async () => undefined)}
|
||||
@@ -149,7 +156,85 @@ describe('WorkspaceFilesPanel', () => {
|
||||
await screen.findByText('Current workspace')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Uncommitted changes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Modified')).toBeInTheDocument()
|
||||
expect(screen.getByText('M')).toBeInTheDocument()
|
||||
expect(onListDirectory).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('refreshes expanded directories and drops collapsed caches without remounting', async () => {
|
||||
let version = 0
|
||||
const onListDirectory = vi.fn(async (path: string) => ({
|
||||
path,
|
||||
entries: path ? [{ name: `file-${version}.txt`, path: `${path}/file-${version}.txt`, type: 'file' as const }]
|
||||
: [{ name: 'docs', path: 'docs', type: 'directory' as const }],
|
||||
truncated: false
|
||||
}))
|
||||
const props = { projectId: 'project', changedFiles: [], onListDirectory, onLoadDiff: vi.fn(), onOpenFile: vi.fn() }
|
||||
const { rerender } = render(<WorkspaceFilesPanel {...props} refreshToken={version} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'docs' }))
|
||||
await screen.findByText('file-0.txt')
|
||||
version += 1
|
||||
rerender(<WorkspaceFilesPanel {...props} refreshToken={version} />)
|
||||
await screen.findByText('file-1.txt')
|
||||
expect(screen.queryByText('file-0.txt')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-expanded', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'docs' }))
|
||||
version += 1
|
||||
rerender(<WorkspaceFilesPanel {...props} refreshToken={version} />)
|
||||
await waitFor(() => expect(onListDirectory).toHaveBeenCalledTimes(5))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'docs' }))
|
||||
await screen.findByText('file-2.txt')
|
||||
expect(screen.queryByRole('button', { name: /默认应用|资源管理器/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens a deleted file diff rather than reading the missing file', async () => {
|
||||
const onLoadDiff = vi.fn(async () => ({ rootPath: '/project', available: true, files: [], status: '', patch: '-deleted content', stagedPatch: '-staged content', truncated: true }))
|
||||
const onOpenFile = vi.fn()
|
||||
render(<WorkspaceFilesPanel projectId="project" changedFiles={[{ path: 'deleted.txt', status: ' D' }]}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))}
|
||||
onLoadDiff={onLoadDiff} onOpenFile={onOpenFile} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /deleted.txt/ }))
|
||||
await screen.findByText('-deleted content')
|
||||
expect(screen.getByText('-staged content')).toBeInTheDocument()
|
||||
expect(screen.getByText('差异内容已截断。')).toBeInTheDocument()
|
||||
expect(onLoadDiff).toHaveBeenCalledWith('deleted.txt')
|
||||
expect(onOpenFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reloads the selected diff on refresh and ignores an older pending response', async () => {
|
||||
let resolveOld!: (value: Awaited<ReturnType<typeof onLoadDiff>>) => void
|
||||
const value = { rootPath: '/project', available: true, files: [], status: '', patch: '+fresh', stagedPatch: '', truncated: false }
|
||||
const onLoadDiff = vi.fn<() => Promise<typeof value>>()
|
||||
.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
.mockResolvedValue(value)
|
||||
const props = {
|
||||
projectId: 'project', changedFiles: [{ path: 'seed.txt', status: ' M' }],
|
||||
onListDirectory: vi.fn(async () => ({ path: '', entries: [], truncated: false })),
|
||||
onLoadDiff, onOpenFile: vi.fn()
|
||||
}
|
||||
const view = render(<WorkspaceFilesPanel {...props} refreshToken={0} />)
|
||||
await waitFor(() => expect(props.onListDirectory).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('button', { name: /seed.txt/ }))
|
||||
view.rerender(<WorkspaceFilesPanel {...props} refreshToken={1} />)
|
||||
await screen.findByText('+fresh')
|
||||
await act(async () => resolveOld({ ...value, patch: '+stale' }))
|
||||
expect(screen.queryByText('+stale')).not.toBeInTheDocument()
|
||||
expect(onLoadDiff).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('loads more changed files so deleted files after the first batch have a diff entry', async () => {
|
||||
const onLoadDiff = vi.fn(async () => ({
|
||||
rootPath: '/project', available: true, files: [], status: '',
|
||||
patch: '-last deleted', stagedPatch: '', truncated: false
|
||||
}))
|
||||
render(<WorkspaceFilesPanel
|
||||
projectId="project"
|
||||
changedFiles={Array.from({ length: 51 }, (_, index) => ({ path: `deleted-${index}.txt`, status: ' D' }))}
|
||||
onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))}
|
||||
onLoadDiff={onLoadDiff} onOpenFile={vi.fn()}
|
||||
/>)
|
||||
expect(screen.queryByRole('button', { name: /deleted-50.txt/ })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '加载更多变更(剩余 1 个)' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /deleted-50.txt/ }))
|
||||
await screen.findByText('-last deleted')
|
||||
expect(onLoadDiff).toHaveBeenCalledWith('deleted-50.txt')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
FileSearch,
|
||||
FileText,
|
||||
Folder,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
WorkspaceChangedFile,
|
||||
WorkspaceChanges,
|
||||
WorkspaceDirectoryEntry,
|
||||
WorkspaceDirectoryListing
|
||||
} from '../../shared/assistant-contracts'
|
||||
@@ -23,36 +26,27 @@ import type {
|
||||
type WorkspaceFilesPanelProps = {
|
||||
projectId?: string
|
||||
changedFiles: WorkspaceChangedFile[]
|
||||
refreshToken?: unknown
|
||||
onLoadDiff: (path: string) => Promise<WorkspaceChanges>
|
||||
onListDirectory: (path: string) => Promise<WorkspaceDirectoryListing>
|
||||
onOpenFile: (path: string) => void
|
||||
onOpenEntry: (
|
||||
onOpenEntry?: (
|
||||
path: string,
|
||||
type: WorkspaceDirectoryEntry['type']
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
function statusKey(
|
||||
status: string
|
||||
): 'added' | 'deleted' | 'renamed' | 'modified' {
|
||||
const value = status.trim()
|
||||
if (value === '??') {
|
||||
return 'added'
|
||||
}
|
||||
if (value.includes('D')) {
|
||||
return 'deleted'
|
||||
}
|
||||
if (value.includes('R')) {
|
||||
return 'renamed'
|
||||
}
|
||||
if (value.includes('A')) {
|
||||
return 'added'
|
||||
}
|
||||
return 'modified'
|
||||
export function gitStatusLetter(status: string): string {
|
||||
if (status === '??') return 'U'
|
||||
if (['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(status)) return 'U'
|
||||
return status.trim().replaceAll('.', '').split('').filter((value, index, values) => values.indexOf(value) === index).join('')
|
||||
}
|
||||
|
||||
export function WorkspaceFilesPanel({
|
||||
projectId,
|
||||
changedFiles,
|
||||
refreshToken,
|
||||
onLoadDiff,
|
||||
onListDirectory,
|
||||
onOpenFile,
|
||||
onOpenEntry
|
||||
@@ -80,6 +74,18 @@ export function WorkspaceFilesPanel({
|
||||
}>({})
|
||||
const requestGeneration = useRef(0)
|
||||
const inFlightPaths = useRef(new Set<string>())
|
||||
const [diff, setDiff] = useState<{ path: string; value?: WorkspaceChanges; error?: string }>()
|
||||
const diffRequest = useRef(0)
|
||||
const [visibleChangeCount, setVisibleChangeCount] = useState(50)
|
||||
const openDiff = (path: string): void => {
|
||||
const request = ++diffRequest.current
|
||||
setDiff({ path })
|
||||
void onLoadDiff(path).then((value) => {
|
||||
if (request === diffRequest.current) setDiff({ path, value, error: value.error })
|
||||
}).catch((reason: unknown) => {
|
||||
if (request === diffRequest.current) setDiff({ path, error: reason instanceof Error ? reason.message : t('files.errors.read') })
|
||||
})
|
||||
}
|
||||
|
||||
const loadDirectory = useCallback(
|
||||
async (path: string, generation: number): Promise<void> => {
|
||||
@@ -118,8 +124,8 @@ export function WorkspaceFilesPanel({
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
inFlightPaths.current.delete(path)
|
||||
if (requestGeneration.current === generation) {
|
||||
inFlightPaths.current.delete(path)
|
||||
setLoadingState((current) => {
|
||||
const next = new Set(
|
||||
current.projectId === projectId ? current.value : []
|
||||
@@ -133,6 +139,14 @@ export function WorkspaceFilesPanel({
|
||||
[onListDirectory, projectId]
|
||||
)
|
||||
|
||||
const refreshDirectories = useEffectEvent((generation: number) => {
|
||||
const paths = expandedState.projectId === projectId ? [...expandedState.value] : []
|
||||
setListingState({ projectId, value: {} })
|
||||
setLoadingState({ projectId, value: new Set() })
|
||||
void loadDirectory('', generation)
|
||||
for (const path of paths) void loadDirectory(path, generation)
|
||||
if (diff) openDiff(diff.path)
|
||||
})
|
||||
useEffect(() => {
|
||||
requestGeneration.current += 1
|
||||
const generation = requestGeneration.current
|
||||
@@ -141,10 +155,14 @@ export function WorkspaceFilesPanel({
|
||||
return
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
void loadDirectory('', generation)
|
||||
refreshDirectories(generation)
|
||||
}, 0)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [loadDirectory, projectId])
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
requestGeneration.current += 1
|
||||
diffRequest.current += 1
|
||||
}
|
||||
}, [loadDirectory, projectId, refreshToken])
|
||||
|
||||
const changedByPath = useMemo(
|
||||
() =>
|
||||
@@ -190,7 +208,7 @@ export function WorkspaceFilesPanel({
|
||||
|
||||
const openEntry = (entry: WorkspaceDirectoryEntry): void => {
|
||||
setErrorState({ projectId })
|
||||
void onOpenEntry(entry.path, entry.type).catch((reason: unknown) => {
|
||||
void onOpenEntry?.(entry.path, entry.type).catch((reason: unknown) => {
|
||||
setErrorState({
|
||||
projectId,
|
||||
value:
|
||||
@@ -227,7 +245,7 @@ export function WorkspaceFilesPanel({
|
||||
{expanded ? <FolderOpen size={15} /> : <Folder size={15} />}
|
||||
<span title={entry.path}>{entry.name}</span>
|
||||
</button>
|
||||
<button
|
||||
{onOpenEntry && <button
|
||||
aria-label={t('files.openFolderAriaLabel', {
|
||||
name: entry.name
|
||||
})}
|
||||
@@ -237,7 +255,7 @@ export function WorkspaceFilesPanel({
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="workspace-files__children">
|
||||
@@ -271,12 +289,12 @@ export function WorkspaceFilesPanel({
|
||||
<FileText size={15} />
|
||||
<span>{entry.name}</span>
|
||||
{changed && (
|
||||
<small className="workspace-files__change">
|
||||
{t(`files.statuses.${statusKey(changed.status)}`)}
|
||||
<small className="workspace-files__change" data-status={gitStatusLetter(changed.status)} title={changed.status}>
|
||||
{gitStatusLetter(changed.status)}
|
||||
</small>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
{onOpenEntry && <button
|
||||
aria-label={t('files.openFileAriaLabel', {
|
||||
name: entry.name
|
||||
})}
|
||||
@@ -286,7 +304,7 @@ export function WorkspaceFilesPanel({
|
||||
type="button"
|
||||
>
|
||||
<FileSearch size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -300,34 +318,68 @@ export function WorkspaceFilesPanel({
|
||||
}
|
||||
|
||||
const root = listings['']
|
||||
const renderPatch = (patch: string): React.JSX.Element => (
|
||||
<pre className="assistant-sidebar__diff workspace-files__diff">
|
||||
{patch.split('\n').map((line, index) => (
|
||||
<span key={index} data-line={
|
||||
line.startsWith('+++') || line.startsWith('---') ? 'header'
|
||||
: line.startsWith('+') ? 'added'
|
||||
: line.startsWith('-') ? 'deleted'
|
||||
: line.startsWith('@@') ? 'hunk' : undefined
|
||||
}>{line}{'\n'}</span>
|
||||
))}
|
||||
</pre>
|
||||
)
|
||||
if (diff) {
|
||||
return <section className="assistant-sidebar__preview" aria-busy={!diff.value && !diff.error}>
|
||||
<header>
|
||||
<button className="assistant-sidebar__back" type="button" onClick={() => {
|
||||
diffRequest.current += 1
|
||||
setDiff(undefined)
|
||||
}}><ChevronLeft size={14} />{t('files.currentWorkspace')}</button>
|
||||
<strong>{diff.path}</strong>
|
||||
</header>
|
||||
{diff.error ? <p role="alert">{diff.error}<button type="button" onClick={() => openDiff(diff.path)}>{t('files.retry')}</button></p>
|
||||
: !diff.value ? <p>{t('files.reading')}</p> : <>
|
||||
{diff.value.stagedPatch && <><h4>{t('files.staged')}</h4>{renderPatch(diff.value.stagedPatch)}</>}
|
||||
{diff.value.patch && <><h4>{t('files.unstaged')}</h4>{renderPatch(diff.value.patch)}</>}
|
||||
{!diff.value.patch && !diff.value.stagedPatch && <p>{t('files.noDiff')}</p>}
|
||||
{diff.value.truncated && <p>{t('files.diffTruncated')}</p>}
|
||||
</>}
|
||||
</section>
|
||||
}
|
||||
return (
|
||||
<div className="workspace-files">
|
||||
{changedFiles.length > 0 && (
|
||||
<div className="workspace-files__changed">
|
||||
<strong>{t('files.changedTitle')}</strong>
|
||||
{changedFiles.slice(0, 50).map((file) => {
|
||||
const deleted = file.status.includes('D')
|
||||
{changedFiles.slice(0, visibleChangeCount).map((file) => {
|
||||
return (
|
||||
<button
|
||||
className="workspace-files__changed-row"
|
||||
disabled={deleted}
|
||||
key={`${file.status}:${file.path}`}
|
||||
onClick={() => onOpenFile(file.path)}
|
||||
title={file.path}
|
||||
onClick={() => openDiff(file.path)}
|
||||
title={`${file.previousPath ? `${file.previousPath} -> ` : ''}${file.path} (${file.status})`}
|
||||
type="button"
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>{file.path}</span>
|
||||
<small>
|
||||
{t(`files.statuses.${statusKey(file.status)}`)}
|
||||
<small data-status={gitStatusLetter(file.status)} data-conflict={['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'].includes(file.status)} aria-label={file.status}>
|
||||
{gitStatusLetter(file.status)}
|
||||
</small>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{changedFiles.length > 50 && (
|
||||
<p className="workspace-files__status">
|
||||
{t('files.changesTruncated')}
|
||||
</p>
|
||||
{changedFiles.length > visibleChangeCount && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => setVisibleChangeCount((count) => count + 50)}
|
||||
>
|
||||
{t('files.loadMoreChanges', {
|
||||
count: changedFiles.length - visibleChangeCount
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -376,6 +376,8 @@ export const app = {
|
||||
fallbackTask: '{{name}} subagent task',
|
||||
task: 'Task',
|
||||
output: 'Subagent output',
|
||||
progress: 'Execution progress',
|
||||
finalOutput: 'Final result',
|
||||
error: 'Execution details',
|
||||
noOutput: 'This subagent has no output to display yet.',
|
||||
incomplete:
|
||||
|
||||
@@ -432,6 +432,11 @@ export const workspace = {
|
||||
taskList: 'Task list'
|
||||
},
|
||||
files: {
|
||||
staged: 'Staged changes',
|
||||
unstaged: 'Working tree changes',
|
||||
noDiff: 'No textual differences (the file may be empty or only its status changed).',
|
||||
diffTruncated: 'Diff output truncated.',
|
||||
retry: 'Retry',
|
||||
statuses: {
|
||||
added: 'Added',
|
||||
deleted: 'Deleted',
|
||||
@@ -452,7 +457,7 @@ export const workspace = {
|
||||
'This folder has more than 500 items. Only the first 500 are shown.',
|
||||
selectProject: 'Select a project to browse its workspace.',
|
||||
changedTitle: 'Uncommitted changes',
|
||||
changesTruncated: 'Only the first 50 uncommitted changes are shown.',
|
||||
loadMoreChanges: 'Load more changes ({{count}} remaining)',
|
||||
currentWorkspace: 'Current workspace',
|
||||
readingWorkspace: 'Reading workspace…',
|
||||
rootTruncated:
|
||||
|
||||
@@ -358,6 +358,8 @@ export const app = {
|
||||
fallbackTask: '{{name}} 子专家任务',
|
||||
task: '任务',
|
||||
output: '子代理输出',
|
||||
progress: '执行过程',
|
||||
finalOutput: '最终结果',
|
||||
error: '执行说明',
|
||||
noOutput: '子代理暂时没有可显示的输出。',
|
||||
incomplete: '父请求已结束,但子 Agent 未报告完成状态。',
|
||||
|
||||
@@ -422,6 +422,11 @@ export const workspace = {
|
||||
taskList: '任务列表'
|
||||
},
|
||||
files: {
|
||||
staged: '已暂存更改',
|
||||
unstaged: '工作树更改',
|
||||
noDiff: '没有文本差异(文件可能为空,或仅状态发生变化)。',
|
||||
diffTruncated: '差异内容已截断。',
|
||||
retry: '重试',
|
||||
statuses: {
|
||||
added: '新增',
|
||||
deleted: '删除',
|
||||
@@ -441,7 +446,7 @@ export const workspace = {
|
||||
directoryTruncated: '目录项目超过 500 项,仅显示前 500 项。',
|
||||
selectProject: '选择项目后可浏览项目工作区。',
|
||||
changedTitle: '未提交更改',
|
||||
changesTruncated: '仅显示前 50 个未提交更改。',
|
||||
loadMoreChanges: '加载更多变更(剩余 {{count}} 个)',
|
||||
currentWorkspace: '当前工作区',
|
||||
readingWorkspace: '正在读取工作区…',
|
||||
rootTruncated: '根目录项目超过 500 项,仅显示前 500 项。',
|
||||
|
||||
+58
-10
@@ -4283,16 +4283,6 @@ button > svg {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.assistant-sidebar__diff-details {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.assistant-sidebar__diff-details summary {
|
||||
padding: var(--space-2) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-files {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -4300,6 +4290,46 @@ button > svg {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workspace-files__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.workspace-files__diff [data-line="added"] {
|
||||
color: var(--success);
|
||||
background: var(--success-subtle);
|
||||
}
|
||||
|
||||
.workspace-files__diff [data-line="deleted"] {
|
||||
color: var(--danger);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
.workspace-files__diff [data-line="hunk"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workspace-files [data-status] {
|
||||
color: var(--warning);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-files [data-status="A"],
|
||||
.workspace-files [data-status="U"] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.workspace-files [data-status="D"],
|
||||
.workspace-files [data-conflict="true"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.workspace-files [data-status="R"],
|
||||
.workspace-files [data-status="C"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workspace-files__heading,
|
||||
.workspace-files__changed > strong {
|
||||
color: var(--text-secondary);
|
||||
@@ -6226,6 +6256,14 @@ button > svg {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.subagent-status-card__progress {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
padding-left: var(--space-3);
|
||||
border-left: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.subagent-status-card__details section {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@@ -11960,6 +11998,16 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.knowledge-scope__popover label:has(input:checked) {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.knowledge-scope__popover label:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.knowledge-scope__popover label input {
|
||||
accent-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
@@ -266,6 +266,7 @@ const conversationSubagentActivityBaseSchema = z.object({
|
||||
'cancelled'
|
||||
]),
|
||||
reason: z.string().trim().min(1).max(240).optional(),
|
||||
progress: conversationMessageBlocksSchema.optional(),
|
||||
output: z.string().optional(),
|
||||
error: z.string().trim().min(1).max(1_000).optional()
|
||||
})
|
||||
@@ -573,6 +574,7 @@ export type WorkspaceChanges = {
|
||||
available: boolean
|
||||
status: string
|
||||
patch: string
|
||||
stagedPatch?: string
|
||||
files: WorkspaceChangedFile[]
|
||||
truncated: boolean
|
||||
error?: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user