Compare commits

...
3 Commits
Author SHA1 Message Date
mesalogo 2c5300d031 chore: finalize expanded desktop 0.15.6 release scope
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Include device-sharing metadata and the persistent New note entry in bilingual release notes, align five-app documentation, and update the configuration fixture. Per user instruction, validation proceeds through CI without more local tests. Agent remains 0.15.1.
2026-10-01 01:43:53 +08:00
mesalogo a3b41bf2b1 feat: add device sharing metadata application 2026-10-01 01:31:44 +08:00
mesalogo 0a02a17cb6 fix: keep New note in the magic notes page header 2026-10-01 01:31:43 +08:00
47 changed files with 986 additions and 96 deletions
+9 -1
View File
@@ -149,7 +149,7 @@ records are listed separately and do not introduce another feature status.
the popup; Local Inference Monitor opens a separate modal preserving the workspace, while other apps
open their main content pages. Manage Apps uses a LayoutGrid icon and opens Application Center with searchable cards and settings details;
layout and interaction rules are defined in the [UI design](./docs/features/application-tool-navigation/ui-design.md).
All four application cards support arrow and drag reordering regardless of enablement or pinning.
All five application rows support arrow and drag reordering regardless of enablement or pinning.
Cards, the launcher, and the filtered sidebar share one persisted order. Knowledge
is always enabled and pinned, with Open and reorder controls. Supervisor, Notes,
and Local Inference Monitor have enablement and pinning settings. Supervisor uses
@@ -171,6 +171,14 @@ records are listed separately and do not introduce another feature status.
local-engine checks, and remaining platform/package coverage. Historical
failures are not the current full-suite result. See the
[validation progress](./docs/features/application-tool-navigation/progress.md).
- [x] **Local device-sharing metadata** (implemented in source): Open Device Sharing
from Apps or Application Center, save a service URL and device name, register the
device, browse returned records, publish capability or knowledge metadata, and
revoke this device ID's entries. The default service is `http://127.0.0.1:8787`.
This unauthenticated local service uses IDs for identification only. Server source
mode does not upload or sync content, and independent knowledge search/read/download
flags are declarations, not content access or remote execution. See
[scope and validation](./docs/features/device-sharing/README.md).
- [ ] **Privately deployable application marketplace** (planned): Extends
application management with an organization-owned catalog and application
distribution. Current management covers built-in apps only, not marketplace
+2 -1
View File
@@ -36,7 +36,8 @@
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
- [x] **应用中心与导航**:底部“应用”和“设置”为显示文字的等高按钮,中间使用弱竖分隔线。“应用”带向上箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”使用 `LayoutGrid` 图标,打开标题仍为“应用中心”的可搜索卡片及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。四张应用卡片均支持上下移动和拖动排序,不受启用或常驻状态限制;卡片、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
- [x] **应用中心与导航**:底部“应用”和“设置”为显示文字的等高按钮,中间使用弱竖分隔线。“应用”带向上箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”使用 `LayoutGrid` 图标,打开标题仍为“应用中心”的可搜索列表及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。五个应用行均支持上下移动和拖动排序,不受启用或常驻状态限制;列表、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。设备共享始终可从应用菜单打开,不提供启用或常驻开关。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
- [x] **本地设备共享元数据**(源码已实现):从应用菜单或应用中心进入设备共享,保存服务地址和设备名称、注册本机、浏览服务返回的目录、发布能力或知识元数据并撤销本机 ID 的条目。默认服务为 `http://127.0.0.1:8787`。服务未认证,ID 只用于识别;服务器来源不上传或同步内容,知识搜索/读取/下载标志是独立声明,不提供正文访问或远程执行。详见[范围与验证](./docs/features/device-sharing/README.md)。
- [ ] **私有化应用市场**(规划中):在应用管理基础上提供组织自己的目录与应用分发。当前只管理内置应用,尚无市场浏览或安装能力,详见 [FR-15](./docs/features/application-tool-navigation/prd.md#fr-15-私有化市场与-yaml-交换后续)。
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入更完整的执行监督、统一 Runtime 监控、受管进程、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。
- [ ] **ShareServer Office 协同编辑**(设计中):通过可选 ShareServer 集成 ONLYOFFICE Docs,在助手工作栏中以文件名打开多个文档 Tab,支持 DOCX、XLSX、PPTX 的人工编辑、保存、撤销、源文件冲突保护和后续 AI 选区修改。Office 正文会由所选 ShareServer 和编辑引擎处理;Desktop 不内置或启动 Document Server,未配置服务时不宣称离线编辑。详见[设计文档](./docs/features/office-document-editing/README.md)。
+4 -4
View File
@@ -337,9 +337,9 @@
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
单行截断,不能撑宽或挤压侧栏。
- 侧栏底部提供“应用 / Apps”和“设置 / Settings”两个横向等高 `nav-item` 按钮,均显示图标和文字,中间使用弱竖分隔线。“应用”带向下箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”使用 `LayoutGrid` 图标,打开既有整窗管理 Modal,标题仍为“应用中心 / Application Center”,共同应用设置沿用设置 Modal。
四个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库、智能心跳始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。原工作区配置及心跳单条计划 enabled 保持不变。只有魔法笔记、本机推理监控可修改启用和常驻,取消常驻不关闭功能。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
五个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。监督者沿用智能心跳身份,可修改启用和常驻,执行边界见[监督者逻辑](./docs/features/conversation-supervision/logic-design.md#应用启停与执行)。魔法笔记、本机推理监控同样可修改启用和常驻。设备共享始终可从应用菜单及管理 Modal 打开,标签为“内置”,提供排序,不提供启用或常驻开关;在主内容区打开元数据页面,规则见[设备共享 UI](./docs/features/device-sharing/ui-design.md#当前首期页面)。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。
管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;四行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;五行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
- 本机推理监控 Modal 复用整窗 Portal、背景隔离和焦点循环;停止、重启及释放使用其上方的紧凑影响确认。Escape 只关闭最上层,操作提交期间禁止关闭及重复提交;焦点恢复与尺寸见[本机推理监控 UI 规则](./docs/features/application-tool-navigation/ui-design.md#91-本机推理管理)。
### 6.9 助手工作栏
@@ -794,7 +794,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 魔法笔记设置提供“发送画布页数”单选框,复用设置表单 `.field` 控件样式,范围 1~8,默认 1。标题同行的问号使用 `InlineHelp`,下拉框通过稳定的帮助 ID 关联展开的说明,交互遵循 6.14。说明包含当前页序前 N 页、不足时发送全部页、数量指单个画布页数而非记录数,以及完整保存与导出不受影响。修改后沿用设置保存反馈,下次分析生效。
- 草稿、已保存画布记录及来源为画布的待办,按默认模型的 `supportsImageInput` 使用所选前 N 页图像与文字,或只发送同一范围文字并标注“仅文字分析(未使用画布图像)”。所选页只有视觉内容的记录/草稿遇到文字模型时提示切换支持图像输入的默认模型,不声称已理解笔迹或布局。待办分析同时保留任务标题、说明和所选来源画布页的上下文。
- 画布文字评论在提取的 `plainText` 不变时保留;图像评论随画布内容或布局变化失效,在保存后自动评论模式下重新分析。草稿内容变化清除旧草稿分析,评论区显示实际使用的输入模式。存储和判定细节见[画布技术设计](./docs/features/magic-notes/technical-design.md#canvas-analysis-and-comments)。
- 总览是全宽的“全部笔记”页,以自适应卡片网格(最小 `260px`)显示全部笔记,不显示右侧空白详情,也不自动打开第一篇;卡片显示标题、短日期、两行预览和记录数。打开笔记后切为左侧紧凑列表与右侧详情,页头右上角显示带文字的“返回总览”,点击回到全部笔记页并恢复搜索、滚动位置与卡片焦点;“切换到待办”只在总览显示。列表按紧凑两行行项显示:第一行为标题与短日期(今天显示时间,其余显示月/日,完整时间在工具提示中),第二行为单行预览;记录数只提供给读屏。行项无边框、透明底色,悬停为次级底色,选中为淡蓝底色加左侧 `3px` 强调标记;“更多”按钮悬停或聚焦时覆盖在日期位置。保留搜索、新建、置顶菜单和当前选中状态。打开笔记只更新右侧详情;列表独立滚动,切换保留搜索和列表位置,并在工作区挂载期间记住各篇笔记的阅读位置。明确定位某条记录时优先跳到该记录。
- 总览是全宽的“全部笔记”页,以自适应卡片网格(最小 `260px`)显示全部笔记,不显示右侧空白详情,也不自动打开第一篇;卡片显示标题、短日期、两行预览和记录数。打开笔记后切为左侧紧凑列表与右侧详情,页头右上角显示带文字的“返回总览”,点击回到全部笔记页并恢复搜索、滚动位置与卡片焦点;“切换到待办”只在总览显示。列表按紧凑两行行项显示:第一行为标题与短日期(今天显示时间,其余显示月/日,完整时间在工具提示中),第二行为单行预览;记录数只提供给读屏。行项无边框、透明底色,悬停为次级底色,选中为淡蓝底色加左侧 `3px` 强调标记;“更多”按钮悬停或聚焦时覆盖在日期位置。保留搜索、置顶菜单和当前选中状态。打开笔记只更新右侧详情;列表独立滚动,切换保留搜索和列表位置,并在工作区挂载期间记住各篇笔记的阅读位置。明确定位某条记录时优先跳到该记录。
- 笔记列表默认展开、宽 `280px`,支持拖动分隔线在 `240–420px` 内调宽,方向键每次调整 `16px`,Home/End 跳到边界;桌面端详情页提供列表显隐开关,记住宽度和展开偏好。工作区宽度不超过 `900px` 时切换显示列表与详情,返回恢复列表位置和入口焦点,不覆盖桌面偏好。新建成功后打开新笔记;切换、新建和删除当前笔记都保护未保存的正文与标题。
- 待办页是独立任务视图:搜索任务标题、说明或来源笔记,搜索框右侧同一工具栏放置“全部 / 未完成 / 已完成”筛选,显示匹配数与总数;任务按来源笔记分组。列表独立滚动,页头、搜索和筛选保持可见,窄屏工具栏空间不足时换行。直接勾选完成或恢复未完成,不自动选择任务;状态变化后按当前筛选更新列表。
- 待办内容容器宽于 `700px` 时,任务列表稳定在左侧,右侧显示当前任务说明、来源记录和 AI 评论;未选择时右侧提示选择任务。点击任务只更新右侧,不改变列表宽度、行位置或滚动位置,再次点击取消选择。AI 不增加第三栏。容器不超过 `700px` 时,按需详情替换同一内容区域,并提供“返回待办列表”;列表保持挂载,返回恢复滚动和任务行焦点,不将列表与详情上下堆叠。任务按钮提供 `aria-expanded` 和 `aria-controls`。
@@ -813,7 +813,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 画布新建、编辑和只读态统一保留 `12px` 内容间距;详情中已保存画布去掉预览外框和工具栏底色、下划线,保留灰色画布衬底与白色纸张,编辑态保留编辑器边框。记录头部操作按内容宽度排列,空间不足时自然换行。文字新建的保存区与编辑器属于同一卡片,空白编辑区使用最小高度,不随容器百分比高度拉长。文字工具栏图标、菜单和原生滚动条须跟随主题。
- 标签使用中性胶囊样式(`--surface-subtle` 底色、`--border-subtle` 边框、`--font-caption`),不使用颜色区分。总览搜索框下方是标签筛选行:“全部”加各标签和对应篇数,均为 `aria-pressed` 切换按钮;可以多选,多选时只显示同时带有全部所选标签的笔记。选中时使用 `--accent-subtle` 底色和强调色边框。卡片最多显示 3 个标签,其余合并为“+N”。打开笔记后,左侧列表不显示筛选行,只显示“已筛选:A · B”提示条和清除按钮。
- 笔记标题下方是标签编辑行:已有标签后带 × 移除按钮,末尾是虚线“+ 标签”按钮。点击后展开组合框,按输入内容过滤已有标签,也可以新建。方向键切换选项,Enter、逗号或中文逗号确认,Escape 关闭并把焦点还给“+ 标签”。达到 10 个时,“+ 标签”禁用并说明上限。
- 列表标题行的新建按钮左侧是“管理标签”:总览显示图标加文字,打开笔记后只显示图标。点击后打开共享 `custom-task-dialog` 对话框,按使用篇数列出标签,每行提供重命名和删除图标按钮。重命名在行内编辑;新名称与已有标签相同时,提示保存后会合并。删除在行内二次确认,并说明涉及的篇数。Escape 依次退出行内编辑或确认,最后关闭对话框。
- 列表标题行右侧只放“管理标签”,不再放 `+` 新建按钮。“新建笔记”统一放在页头右上角,是主按钮,始终位于“切换到待办 / 切换到笔记”或“返回总览”的右侧。打开笔记时点击,桌面端展开左侧列表并显示标题输入,窄屏先回到列表。“管理标签”在总览显示图标加文字,打开笔记后只显示图标。点击后打开共享 `custom-task-dialog` 对话框,按使用篇数列出标签,每行提供重命名和删除图标按钮。重命名在行内编辑;新名称与已有标签相同时,提示保存后会合并。删除在行内二次确认,并说明涉及的篇数。Escape 依次退出行内编辑或确认,最后关闭对话框。
- 整篇笔记的置顶、取消置顶和删除放在列表行的“更多”菜单,打开详情时仍可操作,标题编辑区不重复提供入口。按钮在悬停、键盘聚焦或菜单展开时显示,触屏设备常显;菜单沿用会话列表样式,通过 Portal 避免滚动区域裁切,支持方向键、Home、End 和 Escape。删除在菜单内二次确认并说明笔记标题及全部记录;删除当前笔记还须处理未保存草稿,删除其他笔记保留当前内容与草稿。打开菜单不切换笔记。
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
@@ -46,3 +46,15 @@ Agent 保持 0.15.1:相对其发布标签,daemon 与锁文件无变化。
保留消息数量、DOM 复用、零额外渲染、草稿、附件、展开状态和滚动位置的全部断言;
不修改全局超时或将运行时性能要求替换为等待通过。
调整后完整 App 319 项、类型检查及 lint 通过。
## 再次扩展候选
用户要求将当前未提交改动一起提交。新增 `0a02a17`(新建笔记入口)和
`a3b41bf`(设备共享元数据),并纳入五份应用导航文档修改。
设备共享需另行运行 ShareServer,首期面向本地未认证服务,仅注册设备及管理元数据;
不执行远程能力,不传输或检索知识正文,设备 ID 不构成所有权认证。
Agent 实现和锁不变,仍不升版。
扩展源码类型检查和 lint 已通过。用户随后明确要求停止本地测试、直接推送等 CI,
进行中的定向测试已中断,不计为通过,也不沿用旧候选的全量结果。
最终发布取决于包含新增提交和文档的精确候选 CI 与生产构建。
@@ -1,9 +1,13 @@
# 应用导航与系统工具入口
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,四个内置应用共用可调整的保存顺序,知识库始终启用并常驻;
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,五个内置应用共用可调整的保存顺序,知识库始终启用并常驻;
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部“应用”向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有“应用中心”管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
商店入口、推荐位或安装流程。
设备共享作为第五项追加到原有应用顺序,始终可从菜单和管理 Modal 打开,不提供启用、常驻
开关;服务地址与设备名称在其页面保存。首期仅支持本地服务的设备注册与元数据目录,详见
[设备共享](../device-sharing/README.md)。
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
本轮 A–D 已接入生产源码:应用菜单、应用管理 Modal、共享应用设置、持久化偏好及本机推理监控。菜单不按打开历史或常驻状态筛选,无模态遮罩;点击应用行关闭菜单,知识库、智能心跳和魔法笔记打开主内容区页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。管理列表、菜单和侧栏按同一保存顺序排列,侧栏按启用和常驻过滤。知识库在管理 Modal 中提供“打开”和排序,不提供启用、常驻开关或通用空设置;原工作区配置及心跳单条计划 enabled 保持原义。验证范围及集成缺口见[实施进度](./progress.md)。
@@ -25,7 +29,7 @@
待实现的应用扩展见 [FR-17](./prd.md#fr-17-主机管理与云环境管理待实现):
主机管理从设置中心迁入独立工作页面,云环境管理作为同级应用;业务定义由
[远程主机功能文档](../remote-host/README.md)维护。上文四应用描述仍为当前实现。
[远程主机功能文档](../remote-host/README.md)维护。该计划不包含已接入的设备共享元数据页面。
现有 `heartbeat` 入口承载监督者工作回顾与故事线图谱,可管理启用、常驻和排序。应用默认关闭;完整执行边界见[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)。未来市场复用同一应用身份,不增加平级重复入口,目标定义见[监督者产品设计](../conversation-supervision/supervisor-prd.md)。
@@ -16,7 +16,7 @@
2. 运行记录不进入工具注册表、应用显隐设置或插件市场。
3. 文件浏览器、终端、浏览器和资源监控始终是系统工具,不能卸载或被插件替换。
4. 左下角提供应用中心和独立固定设置入口;文件、终端、浏览器、资源监控只通过右侧工作栏 Tab 和“+”访问,不进入应用中心。
5. 四个内置应用共享保存顺序。知识库始终启用并常驻,位置可调整;智能心跳、魔法笔记、本机推理监控可独立设置启用和常驻。取消常驻不关闭功能,关闭不删除数据或导航偏好。
5. 五个内置应用共享保存顺序。知识库始终启用并常驻,位置可调整;智能心跳、魔法笔记、本机推理监控可独立设置启用和常驻。设备共享始终可从应用菜单打开,不常驻侧栏,也不提供启停开关。取消常驻不关闭功能,关闭不删除数据或导航偏好。
6. 入口不能扩大执行空间、文件、PTY、网络或浏览器权限。
7. 应用中心始终可达,点击向上展开无模态遮罩的锚定菜单,只列已启用应用,不使用打开历史或常驻过滤。菜单底部“管理应用”打开既有 Modal,关闭的可选应用在其中仍可搜索、配置和重新开启。
8. 所有入口和新请求的功能判断读取同一份保存成功的权威设置;保存中的目标值不提前生效。
@@ -25,6 +25,7 @@
```ts
type NavigationContribution =
| { type: 'application'; id: 'device-sharing'; source: 'builtin'; enabled: true; pinned: false }
| {
type: 'system-entry'
id: 'chat' | 'activity' | 'application-center' | 'settings'
@@ -46,7 +46,7 @@
- 用户从侧栏底部进入应用中心,查找、打开和配置内置应用。
- 主导航承载应用入口,本机推理监控打开 Modal,其余内置应用打开完整页面;辅助工具由工作栏承载。
- 运行记录始终可发现,不受应用显示设置和插件状态影响。
- 四个内置应用均支持排序;智能心跳、魔法笔记和本机推理监控可独立设置启用与常驻,知识库始终显示,未常驻的已启用应用仍可从应用菜单打开。
- 五个内置应用均支持排序;智能心跳、魔法笔记和本机推理监控可独立设置启用与常驻,知识库始终显示,设备共享始终可从应用菜单打开且不常驻侧栏。未常驻的已启用应用仍可从应用菜单打开。
- 为插件应用和插件工具保留明确的入口契约,不提前建设插件安装能力。
## 4. 非目标
@@ -75,7 +75,7 @@
### FR-3 内置应用区
主导航在当前对话之后按共享保存顺序显示应用,知识库始终显示,可选应用仅显示已启用且常驻项;运行记录位于应用区之后。四个应用均可排序。本机推理监控入口打开独立 Modal,其余应用打开对应一级页面,保留
主导航在当前对话之后按共享保存顺序显示应用,知识库始终显示,可选应用仅显示已启用且常驻项;运行记录位于应用区之后。五个应用均可排序。本机推理监控入口打开独立 Modal,其余应用打开对应一级页面,保留
现有徽标、范围和页面状态。管理 Modal 列出全部内置应用,知识库只提供“打开”和排序。
### FR-4 隐藏应用
@@ -127,13 +127,13 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
### FR-11 应用导航设置
管理 Modal 的四个应用行均提供上下移动和拖动排序,不受启用或常驻状态限制;管理列表、菜单和侧栏共用一份顺序。箭头边界按完整顺序计算,搜索过滤不丢失隐藏项。智能心跳、魔法笔记和本机推理监控另提供应用设置、常驻管理;知识库提供“打开”和排序,不添加通用空设置页或开关。原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存,
管理 Modal 的五个应用行均提供上下移动和拖动排序,不受启用或常驻状态限制;管理列表、菜单和侧栏共用一份顺序。箭头边界按完整顺序计算,搜索过滤不丢失隐藏项。智能心跳、魔法笔记和本机推理监控另提供应用设置、常驻管理;知识库与设备共享提供“打开”和排序,不添加通用空设置页或开关。原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存,
修改即时提交;请求期间串行处理并禁用会修改同一配置的控件,失败就地反馈。
魔法笔记设置统一从应用中心对应行进入,笔记页与全局设置不再提供重复入口。持久化成功后的设置变更同步覆盖工具配置写入;每次重开中心先刷新并锁定修改,较新的确认快照优先于迟到的读取结果。
### FR-12 偏好持久化
四个应用的顺序及可选应用启用、常驻按设备持久化,重启后恢复。默认顺序为知识库、智能心跳、魔法笔记、本机推理监控。心跳应用默认关闭,其余应用默认启用;心跳和魔法笔记默认常驻,本机推理监控默认不常驻;
五个应用的顺序及可选应用启用、常驻按设备持久化,重启后恢复。默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享。心跳应用默认关闭,其余应用默认启用;心跳和魔法笔记默认常驻,本机推理监控默认不常驻,设备共享不提供常驻开关;
魔法笔记待办数量默认显示,评论方式与形式沿用现有默认值。迁移保留已保存的启用、常驻和排序选择,
旧魔法笔记入口开关绑定业务启用值,迁为“启用应用”并保留原值;常驻使用独立配置,
只有已发布的独立显隐偏好才映射到常驻,不能由常驻推导业务启停。旧可选应用顺序补入知识库、智能心跳默认位置并保留已有相对顺序,具体补齐规则见[技术设计](./technical-design.md#43-持久化与-ipc)。`heartbeatEnabled` 的缺失值与保存值按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)处理;不读取未发布草案中的 `knowledgeEnabled`。
@@ -173,7 +173,7 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
- 主导航能明确区分固定系统入口和可配置内置应用。
- 运行记录在所有应用显隐组合下保持可见并可打开。
- 左下角不再用“本地工作区”与系统架构占据主要空间。
- 应用菜单只列已启用应用,点击行关闭菜单;本机推理监控打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。四个应用均可排序,知识库始终可用,三个可选应用可管理启用与常驻,首期无商店入口。
- 应用菜单只列已启用应用,点击行关闭菜单;本机推理监控打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。五个应用均可排序,知识库与设备共享始终可用,三个可选应用可管理启用与常驻,首期无商店入口。
- 系统工具仅从右侧 Tab 与“+”访问;选择 Tab 不创建或改绑实例,“+”可显式创建额外浏览器或终端。
- 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、
最小化后停止请求新样本。
@@ -188,7 +188,7 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
- 侧栏最底部应用中心打开向上锚定的轻量菜单,无模态遮罩;底部“管理应用”进入与设置相同的整窗 Modal 和可搜索应用列表,设置按钮保持独立。
- 应用中心提供魔法笔记、知识库、智能心跳及本轮追加的本机推理监控入口,不要求安装。本机推理监控 Modal 与服务单独实现和验证。
- 四个应用共享排序;支持智能心跳、魔法笔记、本机推理监控常驻主导航及取消常驻,知识库始终显示。已启用未常驻应用仍可从菜单打开,取消常驻不删除数据或停止后台任务。
- 五个应用共享排序;支持智能心跳、魔法笔记、本机推理监控常驻主导航及取消常驻,知识库始终显示。设备共享仅从应用菜单和管理 Modal 进入,业务定义见[设备共享](../device-sharing/README.md)。已启用未常驻应用仍可从菜单打开,取消常驻不删除数据或停止后台任务。
- 通用应用设置统一从应用中心对应行进入;应用页面保留实际业务配置,本机推理监控保留“模型与连接设置”。
魔法笔记优先接入待办数量、AI 评论方式和评论形式,入口显示与常驻控制统一,避免重复开关。
- 文件、终端、浏览器保留在右侧工作栏 Tab,不在左下角复制入口,也不纳入应用中心。
@@ -274,4 +274,4 @@ Modal 只有服务列表,显示名称、简短状态、引擎、模型、资
导航扩展须保留已有应用排序、常驻偏好和远程能力启用值;取消主机常驻不影响云环境使用
主机能力。两个应用的能力依赖、迁移范围及产品验收由
[主机与云环境 PRD](../remote-host/prd.md)和[功能逻辑](../remote-host/logic-design.md)定义,
业务页面布局见[界面设计](../remote-host/ui-design.md)。该项尚未实现,不改变上文四应用的当前状态描述。
业务页面布局见[界面设计](../remote-host/ui-design.md)。该项尚未实现,不改变上文五应用的当前状态描述。
@@ -1,5 +1,13 @@
# 应用导航实施进度
## 2026-10-01:设备共享入口
新增 `device-sharing`,应用菜单及管理 Modal 均进入主内容区页面;管理行可排序,不提供启用
或常驻开关。旧顺序通过现有存储归一化追加新项,保留原设置;权威写入要求五项完整顺序。
ApplicationCenter、ApplicationMenu、设置迁移以及 App 入口/排序回归已通过,真实 Electron
从应用中心进入页面并完成后端流程。精确命令、完整范围及全仓测试记录见
[设备共享实施进度](../device-sharing/progress.md)。下方历史四应用记录保留原验证时的范围。
## 2026-09-28: Composer shortcut initialization follow-up
The composer now retains successful shortcut-settings snapshots independently of
@@ -22,6 +22,7 @@ type WorkspaceView =
| 'knowledge'
| 'heartbeat'
| 'local-inference'
| 'device-sharing'
| 'activity'
| 'settings'
```
@@ -31,7 +32,7 @@ type WorkspaceView =
实现以[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md)为准。代码中尚无资源指标采集和资源监控
面板。侧栏底部已替换为应用中心与独立设置按钮。
应用中心、四应用共享排序、可选应用常驻及启用联动、共同设置页和本机推理监控已接入生产源码;本机资源监控的
应用中心、五应用共享排序、可选应用常驻及启用联动、共同设置页和本机推理监控已接入生产源码;本机资源监控的
Main/Preload/Renderer 实现仍待交付。系统工具只使用右侧工作栏已有 Tab 和“+”链路。
行为由[功能逻辑](./logic-design.md)定义,验证范围见[实施进度](./progress.md)。
@@ -61,7 +62,7 @@ Main/Preload/Renderer 实现仍待交付。系统工具只使用右侧工作栏
### 3.1 内置应用清单
```ts
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat' | 'local-inference'
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat' | 'local-inference' | 'device-sharing'
type BuiltInApplicationDefinition = {
id: BuiltInApplicationId
@@ -81,7 +82,7 @@ type BuiltInApplicationDefinition = {
应用中心调用既有 `navigateFromSidebar` 或同一页面导航函数,沿用未保存离开检查和焦点
策略。打开前检查确认的启用值;未常驻但已启用的应用不附带常驻写入。本机推理监控从常驻入口、菜单及管理 Modal 均经 `setView('local-inference')` 打开独立 Modal,保留底层工作区与设置草稿;其他应用继续页面导航。
`ApplicationMenu` 按四个应用共用的持久化顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。
`ApplicationMenu` 按五个应用共用的持久化顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。设备共享始终启用,进入主内容区页面,无常驻字段;业务边界见[设备共享技术设计](../device-sharing/technical-design.md#当前首期实现)。
应用中心对应行的设置操作仅接受 `EditableApplicationId`;应用页和全局设置不提供重复入口。知识库的管理行提供“打开”和排序,原工作区实际配置保留,不添加通用空设置页。
共同页复用设置 Modal 容器、配置读取及更新方法,不建立独立配置存储或重复表单。
@@ -124,9 +125,9 @@ type ApplicationNavigationSettings = {
}
```
`applicationNavigation` 保存四个应用的完整顺序和三个可选应用的常驻布尔值。默认顺序为
`knowledge`、`heartbeat`、`magic-notes`、`local-inference`;心跳和魔法笔记常驻默认 `true`,本机推理监控默认 `false`。
权威返回 schema 和更新 schema 均要求四个 ID 各出现一次,拒绝重复、未知和缺失 ID;`pinned` 包含三个可选应用,缺失 `heartbeat` 时补 `true`,显式布尔值保留。Renderer 直接读取该顺序,再按各入口的可见性过滤。知识库始终启用、常驻,无可写开关键。
`applicationNavigation` 保存五个应用的完整顺序和三个可选应用的常驻布尔值。默认顺序为
`knowledge`、`heartbeat`、`magic-notes`、`local-inference`、`device-sharing`;心跳和魔法笔记常驻默认 `true`,本机推理监控默认 `false`,设备共享无常驻开关。
权威返回 schema 和更新 schema 均要求五个 ID 各出现一次,拒绝重复、未知和缺失 ID;`pinned` 包含三个可选应用,缺失 `heartbeat` 时补 `true`,显式布尔值保留。Renderer 直接读取该顺序,再按各入口的可见性过滤。知识库始终启用、常驻,无可写开关键。
按[排序规则](./logic-design.md#3-主导航组合规则)提交完整顺序;不持久化可推导的可见 ID 列表。
首期不预建插件字段,也不为未发布的旧隐藏 ID 草案增加迁移读取器。
@@ -166,7 +167,7 @@ Main 继续校验可信 sender、Zod 输入,复用 `ApplicationSettingsStore`
`true`、`false`、单选值和无关设置,不迁移或清理笔记、知识及会话业务数据。
新安装和升级时缺失整个 `applicationNavigation` 均使用共享默认值,本机推理监控启用但不常驻;本次不增加存储版本。已保存的常驻值均保留,无法区分旧默认 `true` 与用户主动固定,不强制改为 `false`。
仅存储读取允许补齐旧数据:缺失的 `knowledge`、`heartbeat` 按此顺序前置,再保留已有 ID 的相对顺序,最后按默认顺序追加缺失的可选应用。旧 `[local-inference, magic-notes]` 因此变为 `[knowledge, heartbeat, local-inference, magic-notes]`;完整四项顺序原样保留。缺失或空顺序采用默认顺序,部分 `pinned` 映射仅补缺失键,显式 `false` 保留。重复或未知 ID 仍按现有损坏文件处理规则处理,不静默去重或丢弃。版本 11 迁移时写入归一化结果;版本 12 读取时归一化,下一次设置保存时写入完整结果。IPC 和配置工具写入不接受这些部分数据。
仅存储读取允许补齐旧数据:缺失的 `knowledge`、`heartbeat` 按此顺序前置,再保留已有 ID 的相对顺序,最后按默认顺序追加缺失项。旧 `[local-inference, magic-notes]` 因此变为 `[knowledge, heartbeat, local-inference, magic-notes, device-sharing]`;旧完整四项顺序保留并在末尾追加 `device-sharing`。缺失或空顺序采用默认顺序,部分 `pinned` 映射仅补缺失键,显式 `false` 保留。重复或未知 ID 仍按现有损坏文件处理规则处理,不静默去重或丢弃。版本 11 迁移时写入归一化结果;版本 12 读取时归一化,下一次设置保存时写入完整结果。IPC 和配置工具写入不接受这些部分数据。
版本 12 属于未发布的分支格式,原草案 `knowledgeEnabled` 已移除,不增加兼容读取器。`heartbeatEnabled` 是当前应用启用字段,由 Store 归一化缺失值并保留显式保存值。
@@ -352,7 +353,7 @@ resourceMonitor.getLocalSnapshot(input: {
### 10.2 Main 与契约测试
- 顺序包含四个应用 ID 各一次;公开写入拒绝重复、缺失、未知 ID 及无效常驻字段;旧存储顺序按迁移规则补齐,不为未发布的 `knowledgeEnabled` 添加迁移。
- 顺序包含五个应用 ID 各一次;公开写入拒绝重复、缺失、未知 ID 及无效常驻字段;旧存储顺序按迁移规则补齐,不为未发布的 `knowledgeEnabled` 添加迁移。
- 未配置的心跳应用默认关闭,其余应用启用及笔记布尔项默认开启,本机推理监控默认不常驻;已保存的启用、常驻值和评论单选值经迁移及重启保留。
- 更新常驻或顺序不修改启用、计划或业务数据;笔记既有配置键保持语义。
- 串行 patch 在 Main 最新快照上合并,原子写入失败不发布目标状态;响应丢失后重读实际结果。
@@ -85,13 +85,16 @@
```
取消常驻或关闭可选应用后不留下空白。知识库始终启用并显示;心跳按启用和常驻显示,位置遵守共享应用顺序。运行记录始终位于应用区之后。
四个应用均在管理行上通过拖动及“上移”“下移”按钮排序;首尾无目标时禁用对应按钮。两种方式提交完整应用顺序,保存成功后更新,重启保留。当前对话、运行记录和底部系统入口不参与排序。
五个应用均在管理行上通过拖动及“上移”“下移”按钮排序;首尾无目标时禁用对应按钮。两种方式提交完整应用顺序,保存成功后更新,重启保留。当前对话、运行记录和底部系统入口不参与排序。
取消常驻不关闭当前页面或本机推理监控 Modal。重新常驻按已保存顺序恢复,关闭应用保留常驻与顺序偏好。
主侧栏始终读取最后确认配置,不展示尚未保存成功的排序或开关目标值。
## 4. 应用中心与共同设置
设备共享追加到默认顺序末尾。管理行显示“内置”、打开与排序按钮,不提供通用启用或常驻
开关;应用菜单始终保留该项,点击后在主内容区打开[设备共享页面](../device-sharing/ui-design.md#当前首期页面)。
底部点击打开轻量菜单,锚定按钮向上展开,无模态遮罩、背景隔离或焦点循环。菜单按共享保存顺序列出已启用应用,不按打开历史或常驻状态过滤。点击应用行关闭菜单,本机推理监控打开独立 Modal,知识库、智能心跳和魔法笔记在主内容区打开页面。分隔线下的“管理应用”关闭菜单并打开既有管理 Modal。
菜单通过共享 `AnchoredMenu` 挂载到 body,宽度最多 `280px`,距窗口边缘至少 `16px`,与按钮上缘间隔 `8px`;短窗口限制高度并内部滚动,滚动、窗口和内容尺寸变化时重算位置。原生浏览器相交遮挡复用共享机制。
@@ -47,7 +47,7 @@
## 3. Epic B:管理应用入口
本节的启用、常驻和共同设置适用于智能心跳、魔法笔记、本机推理监控;四个应用均可排序,知识库始终启用并常驻。心跳的默认关闭及执行边界按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收,保留既有故事 ID。
本节的启用、常驻和共同设置适用于智能心跳、魔法笔记、本机推理监控;五个应用均可排序,知识库始终启用并常驻。设备共享始终可从应用菜单和管理 Modal 打开,不提供启用或常驻开关;业务验收见[设备共享首期需求](../device-sharing/prd.md#当前首期本地元数据)。心跳的默认关闭及执行边界按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收,保留既有故事 ID。
### US-B1 识别完整应用
@@ -139,14 +139,14 @@
验收:
- Given 首次使用且未配置导航偏好
- Then 默认顺序为知识库、智能心跳、魔法笔记、本机推理监控;心跳默认关闭且保留默认常驻偏好,魔法笔记默认常驻,本机推理监控默认启用但不常驻,仍可从应用菜单打开;侧栏只按启用与常驻过滤可选项
- Then 默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享;心跳默认关闭且保留默认常驻偏好,魔法笔记默认常驻,本机推理监控默认启用但不常驻,仍可从应用菜单打开;设备共享只在应用菜单和管理 Modal 提供入口;侧栏只按启用与常驻过滤可选项
- When 用户修改常驻,或通过拖动、键盘可操作的上移和下移按钮排序并保存成功
- Then 管理列表从上到下、菜单和侧栏按同一保存顺序显示,知识库和智能心跳可移动;当前对话、运行记录及底部系统入口位置不变,重启后恢复保存结果
- And 每行均提供上下移动,以完整保存顺序中的相邻应用为目标;未常驻及关闭应用仍可排序,设置详情不提供排序控件
- And 搜索时箭头仍按完整顺序移动,保留未命中应用,拖动禁用;清除搜索后显示完整保存结果
- Given 旧设置仅保存本机推理监控、魔法笔记的顺序
- When 升级后读取设置
- Then 知识库、智能心跳补在前面,两个可选应用保留原有相对顺序和常驻值
- Then 知识库、智能心跳补在前面,两个可选应用保留原有相对顺序和常驻值,设备共享追加到末尾
- Given 应用已关闭或取消常驻
- When 用户重新开启或常驻
- Then 按保存顺序恢复入口;关闭或取消常驻不删除顺序,已关闭应用仍可管理常驻偏好
+10 -5
View File
@@ -1,7 +1,11 @@
# 设备发现与共享
本功能是 GoodBuddy 桌面端管理附近设备、本机共享和 ShareServer 连接的唯一入口。默认连接
方式是局域网;网关由用户额外添加。功能不包含 ShareServer 的组织管理 Web 界面。
设备共享从“应用 → 设备共享”或“管理应用 → 设备共享 → 打开”进入。当前首期连接本地未认证
ShareServer 元数据服务,默认地址为 `http://127.0.0.1:8787`,可注册本机、浏览设备与发布目录、
发布能力或知识的元数据并撤销本机 ID 的条目。设备 ID 只用于识别,不是所有权认证。
当前实现不执行远程能力,不读取、搜索或下载知识正文;来源模式和权限均为元数据声明。
局域网发现、配对、组织网关和远程调用仍是后续设计,不是首期交付内容。
## 文档导航
@@ -9,12 +13,13 @@
| --- | --- |
| [PRD](./prd.md) | 用户问题、范围、功能需求和产品验收 |
| [功能逻辑](./logic-design.md) | 不变量、状态、授权优先级和失败结果 |
| [UI 设计](./ui-design.md) | 设置入口、四个页签、配对与调用交互 |
| [UI 设计](./ui-design.md) | 当前应用入口、单页表单与列表,以及后续发现、配对界面 |
| [技术设计](./technical-design.md) | Desktop 进程边界、持久化、IPC、网络和验证 |
| [实施进度](./progress.md) | 首期实现、检查命令、真实 Electron 联调证据及未测范围 |
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
当前没有单独的 User Stories:正常、失败、撤销、权限和兼容场景已直接进入 PRD 验收与逻辑
决策表。功能尚未实施,因此不创建 `progress.md`。
当前没有单独的 User Stories:首期验收见 PRD 的 `FR-M1` 至 `FR-M5`;后续场景仍在原有
需求与逻辑决策表中保留。下列 Peer、配对、网关术语仅用于后续设计。
## 术语
+18 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
@@ -12,6 +12,23 @@
本文负责桌面端状态、不变量和决策规则,不重复跨端协议定义。
## 当前首期规则
本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。后文发现、配对、授权和任务状态均为
后续设计,不适用于本地未认证元数据服务。
本机 ID 在首次读取设置时生成并原子保存;并发首次读取共用一次加载。名称与服务 URL 更新
串行保存,不接受 Renderer 写入设备 ID。已存在设置无法读取或解析时直接报错,避免静默
更换身份。切换服务保持本机 ID,但目标服务必须有本机注册记录才能发布。
页面初次加载读取真实目录,空列表、加载中和读取失败分别展示。刷新失败保留旧列表并标记
可能过期,禁用发布和撤销;用户可修改配置或重试。每次请求固定使用开始时读取的服务地址。
页面操作期间锁定重复提交。保存失败保留设置草稿,发布失败保留条目草稿。
发布状态只有 `published` 与 `revoked`。撤销入口匹配本机 ID,Main 再查询本机发布列表并确认
条目 ID;这只是防止误操作,不是认证。撤销只改变元数据状态。能力类型发送三个 `false`
权限;知识类型按独立选择发送。目录读取无需注册,注册记录不表示在线。
## 1. 核心不变量
### INV-1 连接方式独立
+25 -1
View File
@@ -4,13 +4,37 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 适用产品 | GoodBuddy Desktop |
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
| 关联文档 | [功能逻辑](./logic-design.md)、[UI 设计](./ui-design.md)、[技术设计](./technical-design.md) |
## 当前首期:本地元数据
2026-10-01 首期范围由 `FR-M1` 至 `FR-M5` 定义。后文 `FR-1` 至 `FR-14` 保留为后续共享网络
设计,不能用来推断首期已有发现、认证、调用或内容传输能力。
- **FR-M1 入口与设置**:应用中心及应用菜单提供“设备共享”。本机设备 ID 稳定保存,用户可
修改服务 URL 和设备名称。设置保存后重启仍保留;新设备默认连接 `http://127.0.0.1:8787`。
- **FR-M2 注册与浏览**:用户显式注册本机,随后查看服务实际返回的设备及发布列表。注册
包含 ID、名称、平台、应用版本;时间来自服务记录,不从更新时间推断在线状态。
- **FR-M3 元数据发布**:注册本机后可发布名称、描述、能力或知识类型、设备或服务器来源。
知识的搜索、读取、下载三个标志独立选择。服务器来源不会上传、同步或托管内容;权限
标志不提供内容访问实现。
- **FR-M4 撤销与刷新**:只给匹配本机 ID 的已发布条目提供撤销入口,确认后更新服务记录并
刷新目录,显示已撤销状态。该筛选不构成认证,服务本身没有所有权鉴权。
- **FR-M5 失败恢复**:请求有超时,错误可见;失败保留用户输入,列表失败不伪装为空目录。
修改服务 URL 后必须先保存,再向新服务注册或发布。既有应用设置、排序和常驻偏好保留。
验收顺序为保存配置、注册本机、浏览返回列表、发布知识元数据、检查三个独立标志、撤销、
刷新并重启读取设备 ID。网络请求由 Main 发出,Renderer 只调用显式 preload 方法。
## 后续设计
以下章节为完整共享网络计划,尚未实施。
## 1. 背景
GoodBuddy 的能力当前主要在本机执行,SSH 远程项目则要求用户预先配置具体 Host。个人和组织
+14
View File
@@ -0,0 +1,14 @@
# 设备共享实施进度
日期:2026-10-01。当前实现范围见 [README](./README.md)。
`a3b41bf` 已接入应用入口、Main 服务、可信 IPC、preload 与页面,
支持配置 ShareServer、注册设备、读取目录、发布/撤销元数据及本机 ID 持久化。
设备 ID 不是认证凭据,权限字段不代表已实现远程操作;正文传输、能力调用、
发现和配对仍不属于本期实现。
当前组合源码类型检查和 lint 通过。仓库包含服务、IPC、页面测试和需指定
`GOODBUDDY_SHARING_SERVER_ROOT` 的真实 ShareServer/Electron 联调用例。
本轮发布准备中的定向测试被用户中断,并要求直接推送交 CI 验证;
不据此声明测试或真实服务联调通过。发布结果以最终候选 CI 为准,
条件跳过的真实服务用例不能计入已验证能力。
@@ -4,13 +4,44 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
## 当前首期实现
本节实现 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。后文模块树、SQLite 表、共享协议包、
局域网与网关协议都是后续设计,当前不创建这些基础设施。
`src/shared/device-sharing-contracts.ts` 定义严格输入和返回类型;`DesktopApi.sharing` 暴露
`getSettings`、`saveSettings`、`registerDevice`、`getCatalog`、`publish`、`revoke` 六个方法。
生产 preload 对应六个固定 IPC 通道,`device-sharing-ipc.ts` 对每次调用检查可信主窗口主帧,
对变更输入进行 Zod 校验,不暴露通用网络入口。
`DeviceSharingService` 在 Main 内使用 `fetch`,每个请求含响应体读取采用 10 秒超时,非成功
响应保留 HTTP 状态;响应再经 Zod 校验。请求只在页面读取或用户动作时发出,不启动后台发现。
URL 接受 HTTP/HTTPS,禁止内嵌凭据、查询和片段。没有令牌或 Agent/Runtime 改动。
| 方法 | HTTP 请求 |
| --- | --- |
| 注册 | `POST /api/v1/sharing/devices/register`,字段为 `id,name,platform,appVersion` |
| 设备列表 | `GET /api/v1/sharing/devices`,读取 `{devices:[]}` |
| 发布列表 | `GET /api/v1/sharing/publications`,读取 `{publications:[]}` |
| 发布 | `POST /api/v1/sharing/publications`,Main 生成条目 ID 并填入本机设备 ID |
| 撤销 | 先按 `?deviceId=` 获取本机条目,再 `POST /api/v1/sharing/publications/:id/revoke` |
设备 ID、名称与服务 URL 存在用户数据目录的 `device-sharing-settings.json`,版本为 1;
复用 `writeJsonFileAtomically`,加载 Promise 去重、保存串行。损坏设置报错而不重置 ID。
此文件不保存目录副本或凭据。导航沿用已有存储归一化,把 `device-sharing` 追加到旧顺序,
保留既有应用相对顺序与设置;新入口可排序,无启用或常驻开关,始终可从应用菜单进入。
服务测试使用真实 loopback HTTP,验证契约、独立权限、识别范围、持久化、错误和超时;
Electron 测试通过环境变量选择相邻真实 ShareServer 源码,使用临时服务数据与桌面设置。
具体命令和实测边界见[实施进度](./progress.md)。
## 1. 进程边界
```text
+19 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
@@ -14,6 +14,24 @@
本文只定义桌面端界面。所有颜色、间距、字体、通知、Switch、PageTabs、焦点和响应式行为以
全局设计系统为准。
## 当前首期页面
本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据),后文四页签、配对及调用界面为后续设计。
入口在应用菜单和应用中心,主内容区使用 `standard` PageShell、PageHeader 与全局范围徽标。
标题下常显未认证、ID 识别用途和无执行/内容访问的边界。内容依次为本机设置、已注册设备、
发布表单和发布目录。服务 URL 与设备名称可编辑,设备 ID 只读;注册、保存与刷新为次操作,
“发布元数据”为唯一主按钮。
类型与来源使用短标签原生选择框;知识的搜索、读取、下载使用三个独立 Checkbox。服务器来源
不上传或同步内容的说明常显。发布按钮附近显示已保存的目标服务及设备名称,尚未注册或
配置未保存时给出操作提示。列表展示服务返回的名称、标识、平台/类型、更新时间和发布状态,
不展示在线灯或执行按钮。
撤销使用共享危险入口和就地确认,说明保留记录且不删除本机内容。成功与操作失败通过应用
通知反馈;目录加载失败在页面显示并由刷新按钮恢复。宽度收窄时按钮换行,长 URL 和 ID 换行;
表单复用 `.field`,颜色与间距使用共享令牌。页面文案提供中英文。
## 1. 信息架构
设置中心新增一级分类“设备发现与共享”,使用 `standard` PageShell:
+3 -2
View File
@@ -22,8 +22,9 @@ comments. The divider can be dragged and remembers its width; each note group
can independently collapse its tasks while retaining its title and count.
Narrow containers use on-demand detail with an explicit return to the
mounted list. One content-sized button switches to the other view in the page
header when note detail is closed; New note lives in the notes list heading or the
To-dos page header. Status filters sit beside search and wrap
header when note detail is closed; New note is always the primary page-header
action to the right of the view switch or Back to overview. The notes list
heading keeps only Manage tags. Status filters sit beside search and wrap
when needed. Note detail removes the surrounding frame and stream padding while
keeping editor borders and the AI divider. Returning from a source note restores the task
context and protects unsaved editor changes. A successful save followed by no
+10
View File
@@ -1,5 +1,15 @@
# Magic Notes Progress
## 2026-10-01: Header New Note Action
New note is now one primary page-header button that always follows the view
switch (overview, to-dos) or Back to overview (note detail). The `+` icon in the
notes list heading was removed; the heading keeps only Manage tags. From note
detail the button opens the list's title form (desktop reopens a collapsed list;
narrow returns to the list first). Workspace tests (133) and the Electron layout
test pass, with overview and detail screenshots checked at 1280px; the App note
subset (11), typecheck, and lint on changed files pass.
## 2026-09-30: Navigate After Record Mount
Quick Notes navigation now scrolls after React commits the target record, rather
+6 -2
View File
@@ -8,9 +8,11 @@
"zh-CN": {
"highlights": [],
"features": [
"**可选的透明磨砂外观**:在外观设置中开启“透明磨砂特效”,聊天内容可滚动经过顶栏背后,执行清单浮层也使用磨砂背景。默认关闭,选择会保存并在重启后恢复;效果只作用于应用内部,不透出操作系统桌面。"
"**可选的透明磨砂外观**:在外观设置中开启“透明磨砂特效”,聊天内容可滚动经过顶栏背后,执行清单浮层也使用磨砂背景。默认关闭,选择会保存并在重启后恢复;效果只作用于应用内部,不透出操作系统桌面。",
"**设备共享元数据入口**:从应用菜单或应用管理打开“设备共享”,连接另行运行的 ShareServer 服务后,可注册本机、浏览设备和发布目录、发布能力或知识的元数据并撤销本机 ID 的条目。首期面向本地未认证服务,设备 ID 不是所有权认证;不执行远程能力,不搜索、读取或下载知识正文,权限选项仅为元数据声明。"
],
"fixes": [
"**新建笔记入口保持可见**:“新建笔记”统一放在魔法笔记页面标题栏,不再藏在列表工具栏中。打开详情或待办时仍可发起新建,并沿用未保存草稿保护。",
"**改善本地 OpenCode 工具连接恢复**:明确的暂时性 MCP 传输故障会在清理成功后最多重试一次,超时或取消后结果不确定的连接名称不再复用,避免晚到初始化覆盖新连接。鉴权、协议和未知错误不会自动重试,Prompt 与工具调用不会重放;该修正不代表远程 Agent 新增恢复能力。",
"**减少输入和切换会话时的重复处理**:输入时未变化的消息历史及任务条不再反复渲染,切换已缓存会话时复用稳定的消息回调。实时输出、滚动位置、草稿和异步附件归属保持不变,执行统计索引也减少了轮询时对事件记录的扫描。",
"**草稿和队列图片可直接放大预览**:点击图片缩略图即可打开共享查看器,无需先执行 OCR。队列附件保持只读,不再展示解析来源和设置入口;草稿仍可显式提取文字。",
@@ -25,9 +27,11 @@
"en-US": {
"highlights": [],
"features": [
"**Optional frosted-glass appearance**: Enable the effect in Appearance to let chat content scroll behind the header and give execution-checklist panels a frosted background. It is off by default and your choice persists across restarts; the effect is internal to the app and does not reveal the operating-system desktop."
"**Optional frosted-glass appearance**: Enable the effect in Appearance to let chat content scroll behind the header and give execution-checklist panels a frosted background. It is off by default and your choice persists across restarts; the effect is internal to the app and does not reveal the operating-system desktop.",
"**Device-sharing metadata entry**: Open Device Sharing from the application menu or manager and connect to a separately running ShareServer to register this device, browse device and publication catalogs, publish capability or knowledge metadata, and revoke entries associated with this device ID. This first stage targets a local unauthenticated service, and device IDs are not ownership authentication; it does not execute remote capabilities or search, read, or download knowledge content, and permission options are metadata declarations only."
],
"fixes": [
"**Keep New note visible**: New note now stays in the Magic Notes page header rather than the list toolbar. You can start a note while viewing details or to-dos, with existing unsaved-draft protection retained.",
"**Improve local OpenCode tool connection recovery**: Explicit transient MCP transport failures receive at most one retry after successful cleanup, and names with uncertain timeout or cancellation outcomes are retired so late initialization cannot replace a live connection. Authentication, protocol, and unknown errors are not retried, and prompts or tool calls are not replayed; this is not a new remote Agent recovery capability.",
"**Reduce repeated work while typing and switching conversations**: Unchanged message history and task strips avoid repeated renders during typing, with stable callbacks reused across cached conversation switches. Streaming, scroll positions, drafts, and asynchronous attachment ownership remain intact, while execution-statistics indexes reduce event scans during polling.",
"**Preview draft and queued images directly**: Click an image thumbnail to open the shared viewer without running OCR first. Queued attachments remain read-only and no longer show parsing-source or settings entries; drafts retain explicit text extraction.",
+3 -3
View File
@@ -220,7 +220,7 @@ describe('ApplicationSettingsStore', () => {
const settings = await store.get()
expect(settings.warnings).toBeUndefined()
expect(settings.magicNotesEnabled).toBe(false)
expect(settings.applicationNavigation).toEqual({ order: expected, pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } })
expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } })
expect(applicationSettingsSchema.parse(settings)).toEqual(settings)
await store.update({ checkUpdatesOnStartup: false })
expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation)
@@ -230,7 +230,7 @@ describe('ApplicationSettingsStore', () => {
it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => {
const { store, filePath } = await createStore()
const applicationNavigation = {
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'],
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false },
}
const changed = vi.fn()
@@ -275,7 +275,7 @@ describe('ApplicationSettingsStore', () => {
pinned: { 'magic-notes': false, 'local-inference': pinned }
}
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8')
const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] }
const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] }
expect((await store.get()).applicationNavigation).toEqual(normalized)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized)
+25
View File
@@ -0,0 +1,25 @@
import { expect, it, vi } from 'vitest'
import { registerDeviceSharingIpc } from './device-sharing-ipc'
import { ipcChannels } from '../shared/ipc-channels'
const handlers = vi.hoisted(() => new Map<string, (event: unknown, input?: unknown) => unknown>())
vi.mock('electron', () => ({ ipcMain: {
handle: (channel: string, handler: (event: unknown, input?: unknown) => unknown) => handlers.set(channel, handler),
removeHandler: (channel: string) => handlers.delete(channel)
} }))
it('checks the trusted main frame on every explicit method and validates mutation input', () => {
const webContents = { mainFrame: { url: 'file:///app.html' }, getURL: () => 'file:///app.html' }
const service = { getSettings: vi.fn(), saveSettings: vi.fn(), registerDevice: vi.fn(), getCatalog: vi.fn(), publish: vi.fn(), revoke: vi.fn() }
const dispose = registerDeviceSharingIpc({ webContents } as never, service as never)
const event = { sender: webContents, senderFrame: webContents.mainFrame }
expect(handlers.size).toBe(6)
for (const handler of handlers.values()) {
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame })).toThrow('IPC')
expect(() => handler({ sender: webContents, senderFrame: { url: 'file:///app.html' } })).toThrow('IPC')
}
expect(() => handlers.get(ipcChannels.sharingSettingsSave)!(event, { deviceId: 'forged', name: 'x', serverUrl: 'http://localhost' })).toThrow()
expect(() => handlers.get(ipcChannels.sharingPublish)!(event, { name: 'x' })).toThrow()
expect(() => handlers.get(ipcChannels.sharingRevoke)!(event, '')).toThrow()
handlers.get(ipcChannels.sharingRegister)!(event)
expect(service.registerDevice).toHaveBeenCalledOnce()
dispose(); expect(handlers.size).toBe(0)
})
+24
View File
@@ -0,0 +1,24 @@
import { ipcMain, type BrowserWindow } from 'electron'
import { ipcChannels } from '../shared/ipc-channels'
import { sharingIdSchema, sharingPublicationDraftSchema, sharingSettingsInputSchema } from '../shared/device-sharing-contracts'
import { assertTrustedSender } from './trusted-ipc-sender'
import type { DeviceSharingService } from './device-sharing-service'
export function registerDeviceSharingIpc(window: BrowserWindow, service: DeviceSharingService): () => void {
const handlers = {
[ipcChannels.sharingSettingsGet]: () => service.getSettings(),
[ipcChannels.sharingSettingsSave]: (input: unknown) => service.saveSettings(sharingSettingsInputSchema.parse(input)),
[ipcChannels.sharingRegister]: () => service.registerDevice(),
[ipcChannels.sharingCatalog]: () => service.getCatalog(),
[ipcChannels.sharingPublish]: (input: unknown) => service.publish(sharingPublicationDraftSchema.parse(input)),
[ipcChannels.sharingRevoke]: (input: unknown) => service.revoke(sharingIdSchema.parse(input))
}
for (const [channel, handler] of Object.entries(handlers)) {
ipcMain.removeHandler(channel)
ipcMain.handle(channel, (event, input: unknown) => {
assertTrustedSender(event, window)
return handler(input)
})
}
return () => { for (const channel of Object.keys(handlers)) ipcMain.removeHandler(channel) }
}
+90
View File
@@ -0,0 +1,90 @@
// @vitest-environment node
import { createServer } from 'node:http'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { DeviceSharingService } from './device-sharing-service'
import type { SharingDevice, SharingPublication } from '../shared/device-sharing-contracts'
const cleanup: (() => Promise<unknown>)[] = []
afterEach(async () => { for (const fn of cleanup.splice(0).reverse()) await fn() })
async function setup(timeout = 1000) {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-sharing-'))
cleanup.push(() => rm(directory, { recursive: true, force: true }))
const devices: SharingDevice[] = []
const publications: SharingPublication[] = []
const requests: { path: string; method: string; body: Record<string, unknown> }[] = []
let failure = 0
let stall = false
const server = createServer(async (request, response) => {
if (stall) return
if (failure) { response.writeHead(failure).end(); return }
const buffers: Buffer[] = []
for await (const chunk of request) buffers.push(Buffer.from(chunk))
const body = JSON.parse(Buffer.concat(buffers).toString() || '{}')
const path = request.url!
requests.push({ path, method: request.method!, body })
let value: unknown
if (path.endsWith('/devices/register')) {
value = { ...body, registeredAt: '2026-10-01', updatedAt: '2026-10-01' }
devices.splice(0, devices.length, value as SharingDevice)
} else if (path.endsWith('/devices')) value = { devices }
else if (request.method === 'GET') value = { publications }
else if (path.endsWith('/revoke')) {
const entry = publications.find(item => path.includes(item.id))!
entry.status = 'revoked'; value = entry
} else {
value = { ...body, status: 'published', createdAt: '2026-10-01', updatedAt: '2026-10-01' }
publications.push(value as SharingPublication)
}
response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify(value))
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
cleanup.push(() => new Promise<void>(resolve => { server.closeAllConnections(); server.close(() => resolve()) }))
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`
const file = join(directory, 'settings.json')
const service = new DeviceSharingService(file, 'test-version', timeout)
await service.saveSettings({ name: 'Desktop', serverUrl: url })
return { service, file, url, requests, publications, fail: (code: number) => { failure = code }, stall: () => { stall = true } }
}
it('persists one identity across concurrent first reads, saves and restarts without accepting renderer identity', async () => {
const { service, file, url } = await setup()
const [first, second] = await Promise.all([service.getSettings(), service.getSettings()])
expect(first.deviceId).toBe(second.deviceId)
await service.saveSettings({ name: 'Renamed', serverUrl: `${url}/` })
expect(await new DeviceSharingService(file, 'v2').getSettings()).toEqual({ ...first, name: 'Renamed' })
await expect(service.saveSettings({ name: 'Bad', serverUrl: url, deviceId: 'forged' })).rejects.toThrow()
expect(JSON.parse(await readFile(file, 'utf8')).deviceId).toBe(first.deviceId)
await writeFile(file, '{broken')
await expect(new DeviceSharingService(file, 'v2').getSettings()).rejects.toThrow()
expect(await readFile(file, 'utf8')).toBe('{broken')
})
it('uses the exact HTTP contract for registration, catalog, both publication kinds and own-device revocation', async () => {
const { service, requests, publications } = await setup()
const device = await service.registerDevice()
expect(requests[0]).toEqual({ path: '/api/v1/sharing/devices/register', method: 'POST', body: {
id: device.id, name: 'Desktop', platform: process.platform, appVersion: 'test-version'
} })
const draft = { name: 'Knowledge', description: 'Metadata only', kind: 'knowledge', sourceMode: 'server', permissions: { search: true, read: false, download: true } }
const publication = await service.publish(draft)
expect(publication).toMatchObject({ ...draft, deviceId: device.id, status: 'published' })
expect(await service.getCatalog()).toEqual({ devices: [device], publications: [publication] })
expect(await service.publish({ ...draft, kind: 'capability', sourceMode: 'device' })).toMatchObject({ permissions: { search: false, read: false, download: false } })
publications.push({ ...publication, id: 'other-publication', deviceId: 'other-device' })
await expect(service.revoke('other-publication')).rejects.toThrow('does not belong')
expect(requests.some(item => item.path.includes('other-publication/revoke'))).toBe(false)
expect(await service.revoke(publication.id)).toMatchObject({ status: 'revoked' })
expect(requests.at(-2)?.path).toBe(`/api/v1/sharing/publications?deviceId=${device.id}`)
expect(requests.at(-1)).toMatchObject({ path: `/api/v1/sharing/publications/${publication.id}/revoke`, method: 'POST', body: {} })
})
it('preserves HTTP failure status and bounds a stalled request', async () => {
const { service, fail, stall } = await setup(50)
fail(503)
await expect(service.getCatalog()).rejects.toThrow('HTTP 503')
fail(0); stall()
await expect(service.registerDevice()).rejects.toThrow(/timeout/i)
})
+100
View File
@@ -0,0 +1,100 @@
import { randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { hostname } from 'node:os'
import { z } from 'zod'
import {
sharingDeviceSchema, sharingIdSchema, sharingPublicationDraftSchema, sharingPublicationSchema,
sharingSettingsInputSchema, sharingSettingsSchema,
type SharingCatalog, type SharingSettings
} from '../shared/device-sharing-contracts'
import { assertSupportedSettingsVersion, isMissingFileError, writeJsonFileAtomically } from './settings-file-utils'
const storedSchema = sharingSettingsSchema.extend({ version: z.literal(1) }).strict()
export class DeviceSharingService {
private settings?: Promise<SharingSettings>
private updateQueue: Promise<unknown> = Promise.resolve()
constructor(private readonly filePath: string, private readonly appVersion: string, private readonly timeoutMs = 10_000) {}
getSettings(): Promise<SharingSettings> {
if (!this.settings) {
this.settings = (async () => {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
assertSupportedSettingsVersion(value, 1, version => `Unsupported sharing settings version: ${version}`)
const stored = storedSchema.parse(value)
return { deviceId: stored.deviceId, name: stored.name, serverUrl: stored.serverUrl }
} catch (error) {
// Never silently replace a saved device identity on a read/parse failure.
if (!isMissingFileError(error)) throw error
const settings = { deviceId: randomUUID(), name: hostname(), serverUrl: 'http://127.0.0.1:8787' }
await writeJsonFileAtomically(this.filePath, { ...settings, version: 1 })
return settings
}
})().catch(error => { this.settings = undefined; throw error })
}
return this.settings
}
saveSettings(input: unknown): Promise<SharingSettings> {
const operation = this.updateQueue.then(async () => {
const patch = sharingSettingsInputSchema.parse(input)
const current = await this.getSettings()
const next = { ...current, ...patch, serverUrl: patch.serverUrl.replace(/\/+$/, '') }
await writeJsonFileAtomically(this.filePath, { ...next, version: 1 })
this.settings = Promise.resolve(next)
return next
})
this.updateQueue = operation.catch(() => undefined)
return operation
}
private async request<T>(settings: SharingSettings, path: string, schema: z.ZodType<T>, body?: unknown): Promise<T> {
const response = await fetch(`${settings.serverUrl.replace(/\/+$/, '')}/api/v1/sharing/${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(this.timeoutMs)
})
if (!response.ok) throw new Error(`Sharing HTTP ${response.status} ${response.statusText}`)
return schema.parse(await response.json())
}
async registerDevice() {
const settings = await this.getSettings()
return this.request(settings, 'devices/register', sharingDeviceSchema, {
id: settings.deviceId, name: settings.name, platform: process.platform, appVersion: this.appVersion
})
}
async getCatalog(): Promise<SharingCatalog> {
const settings = await this.getSettings()
const [devices, publications] = await Promise.all([
this.request(settings, 'devices', z.object({ devices: z.array(sharingDeviceSchema) })),
this.request(settings, 'publications', z.object({ publications: z.array(sharingPublicationSchema) }))
])
return { ...devices, ...publications }
}
async publish(input: unknown) {
const draft = sharingPublicationDraftSchema.parse(input)
const settings = await this.getSettings()
return this.request(settings, 'publications', sharingPublicationSchema, {
...draft, id: randomUUID(), deviceId: settings.deviceId,
permissions: draft.kind === 'knowledge' ? draft.permissions : { search: false, read: false, download: false }
})
}
async revoke(input: unknown) {
const id = sharingIdSchema.parse(input)
const settings = await this.getSettings()
const { publications } = await this.request(settings,
`publications?deviceId=${encodeURIComponent(settings.deviceId)}`,
z.object({ publications: z.array(sharingPublicationSchema) }))
if (!publications.some(item => item.id === id && item.deviceId === settings.deviceId)) {
throw new Error('Publication does not belong to this device ID')
}
return this.request(settings, `publications/${encodeURIComponent(id)}/revoke`, sharingPublicationSchema, {})
}
}
+6
View File
@@ -86,6 +86,8 @@ import {
} from './feedback/feedback-http-client'
import { FeedbackService } from './feedback/feedback-service'
import { registerFeedbackIpcHandler } from './feedback/feedback-ipc'
import { registerDeviceSharingIpc } from './device-sharing-ipc'
import { DeviceSharingService } from './device-sharing-service'
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
import type { SystemModelUsageInput } from '../shared/assistant-contracts'
import {
@@ -211,6 +213,7 @@ let tray: Tray | undefined
let isQuitting = false
let removeIpcHandlers: (() => Promise<void>) | undefined
let removeFeedbackIpcHandler: (() => void) | undefined
let removeDeviceSharingIpc: (() => void) | undefined
let runtime: AgentRuntimeController | undefined
let selectedRuntimeManager: SelectedRuntimeManager | undefined
const localRuntimeRegistry = new LocalRuntimeRegistry()
@@ -1576,6 +1579,8 @@ if (hasSingleInstanceLock) {
startupFeedbackService
)
loadMainWindow(mainWindow)
removeDeviceSharingIpc = registerDeviceSharingIpc(mainWindow,
new DeviceSharingService(join(app.getPath('userData'), 'device-sharing-settings.json'), app.getVersion()))
setImmediate(() => {
void repairStaleWindowsNotificationShortcuts({
platform: process.platform,
@@ -1664,6 +1669,7 @@ app.on('before-quit', (event) => {
() => storageUpgrade,
() => feedbackService?.dispose(),
() => removeFeedbackIpcHandler?.(),
() => removeDeviceSharingIpc?.(),
() => dshExtensionInstaller?.dispose(),
() => imageGenerationService?.dispose(),
() => knowledgeService?.beginShutdown(),
+8
View File
@@ -808,6 +808,14 @@ const desktopApi: DesktopApi = {
act: (input) => ipcRenderer.invoke(ipcChannels.localInferenceAct, input),
cancel: (taskId) => ipcRenderer.invoke(ipcChannels.localInferenceCancel, { taskId })
},
sharing: {
getSettings: () => ipcRenderer.invoke(ipcChannels.sharingSettingsGet),
saveSettings: input => ipcRenderer.invoke(ipcChannels.sharingSettingsSave, input),
registerDevice: () => ipcRenderer.invoke(ipcChannels.sharingRegister),
getCatalog: () => ipcRenderer.invoke(ipcChannels.sharingCatalog),
publish: input => ipcRenderer.invoke(ipcChannels.sharingPublish, input),
revoke: id => ipcRenderer.invoke(ipcChannels.sharingRevoke, id)
},
speechModels: {
getSnapshot: () =>
ipcRenderer.invoke(
+27 -5
View File
@@ -245,6 +245,12 @@ const terminalSnapshot: TerminalSnapshot = {
};
const api: DesktopApi & RuntimeNativeClientApi = {
sharing: {
getSettings: vi.fn(async () => ({ deviceId: 'test-device', name: 'Test device', serverUrl: 'http://127.0.0.1:8787' })),
saveSettings: vi.fn(), registerDevice: vi.fn(),
getCatalog: vi.fn(async () => ({ devices: [], publications: [] })),
publish: vi.fn(), revoke: vi.fn()
},
openRuntimeNativeClient: vi.fn(async () => ({ kind: "terminal" as const, terminal: terminalSnapshot })),
getRuntimeNativeClient: vi.fn(async () => null),
stopRuntimeNativeClient: vi.fn(async () => undefined),
@@ -14322,24 +14328,40 @@ describe("App", () => {
await waitFor(() => expect(move).toBeEnabled())
fireEvent.click(move)
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
'监督者', '知识库', '魔法笔记', '本机推理监控',
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享',
]))
await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
fireEvent.dragStart(cards[3]!)
fireEvent.drop(cards[1]!)
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
'监督者', '本机推理监控', '知识库', '魔法笔记',
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享',
]))
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes'])
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing'])
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
'对话', '监督者', '知识库', '魔法笔记', '运行记录',
])
fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用'])
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享', '管理应用'])
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记'])
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享'])
})
it('opens device sharing from Application Center while keeping the Settings entry available', async () => {
render(<App />)
await screen.findByRole('button', { name: '监督者' })
fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const row = screen.getByText('设备共享', { exact: true }).closest('article')!
fireEvent.click(within(row).getByRole('button', { name: '打开' }))
await waitFor(() => {
expect(screen.getByRole('heading', { name: '设备共享' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled()
})
expect(screen.queryByRole('dialog', { name: '应用中心' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '设置' }))
expect(await screen.findByRole('dialog', { name: '设置中心' })).toBeInTheDocument()
})
it('refreshes externally changed all-app order before editing and filters sidebar pins', async () => {
+3 -1
View File
@@ -14,6 +14,7 @@ import { AttachmentCapabilityNotice } from './AttachmentCapabilityNotice';
import { DocumentConversationContext } from './DocumentConversationContext';
import { maximumAttachmentsPerMessage } from '../../shared/attachment-limits';
import { buildRuntimeHistory } from '../../shared/runtime-history';
import { DeviceSharingPage } from './DeviceSharingPage';
import {
ApplicationAvailability,
ApplicationCenter,
@@ -542,7 +543,7 @@ type ActiveRun = {
};
type WorkspaceView =
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "activity" | "settings";
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "activity" | "settings";
const intentRoutePreloaders: Partial<
Record<WorkspaceView, () => Promise<unknown>>
@@ -10923,6 +10924,7 @@ function App(): React.JSX.Element {
</PageShell>
</KeepAliveRoute>
)}
{view === 'device-sharing' && <DeviceSharingPage notify={notify} />}
{localInferenceOpen && (
<LocalInferencePage
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
+14 -14
View File
@@ -102,7 +102,7 @@ describe('Application Center', () => {
fireEvent.drop(card)
expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: {
...defaultApplicationNavigation,
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference'],
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'],
} })
})
@@ -214,7 +214,7 @@ describe('Application Center', () => {
fireEvent.dragStart(notes)
fireEvent.drop(inference)
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: {
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
} })
fireEvent.click(within(inference).getByRole('button', { name: '打开' }))
expect(handlers.onOpen).not.toHaveBeenCalled()
@@ -238,9 +238,9 @@ describe('Application Center', () => {
const handlers = props()
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent)
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控'])
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享'])
expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
expect(moveUp.closest('article')).not.toBeNull()
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
@@ -248,18 +248,18 @@ describe('Application Center', () => {
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
applicationNavigation: {
...pinnedSettings.applicationNavigation,
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
},
})
expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
expect(await screen.findByRole('status')).not.toBeEmptyDOMElement()
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控'])
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享'])
rerender(<ApplicationCenter {...handlers} settings={{
...pinnedSettings,
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] },
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] },
}} />)
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记'])
expect(screen.getByRole('button', { name: '下移 魔法笔记' })).toBeDisabled()
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享'])
expect(screen.getByRole('button', { name: '下移 设备共享' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
@@ -281,7 +281,7 @@ describe('Application Center', () => {
expect(handlers.onUpdate).toHaveBeenCalledWith({
applicationNavigation: {
...pinnedSettings.applicationNavigation,
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
},
})
})
@@ -322,12 +322,12 @@ describe('Application Center', () => {
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '推理' } })
const card = screen.getByRole('article')
expect(card).toHaveAttribute('draggable', 'false')
expect(within(card).getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
expect(within(card).getByRole('button', { name: '下移 本机推理监控' })).toBeEnabled()
fireEvent.click(within(card).getByRole('button', { name: '上移 本机推理监控' }))
expect(handlers.onUpdate).toHaveBeenCalledWith({
applicationNavigation: {
...pinnedSettings.applicationNavigation,
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
},
})
})
@@ -342,7 +342,7 @@ describe('Application Center', () => {
})
expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
expect(screen.getAllByRole('article')).toHaveLength(4)
expect(screen.getAllByRole('article')).toHaveLength(5)
})
it('edits all notes preferences through the shared settings form', () => {
+5 -3
View File
@@ -11,6 +11,7 @@ import {
ArrowLeft,
ArrowUp,
Cpu,
Network,
HeartPulse,
Library,
Settings,
@@ -31,6 +32,7 @@ import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import './application-center.css'
export const applicationDefinitions = {
'device-sharing': { icon: Network, title: 'sharing.title' },
'magic-notes': {
icon: Sparkles,
title: 'navigation.magicNotes',
@@ -56,7 +58,7 @@ export function isApplicationEnabled(
settings: ApplicationSettings | undefined,
id: BuiltInApplicationId,
): boolean {
if (id === 'knowledge') return true
if (id === 'knowledge' || id === 'device-sharing') return true
if (id === 'heartbeat') return settings?.heartbeatEnabled === true
return (
settings !== undefined &&
@@ -449,7 +451,7 @@ export function ApplicationCenter({
<div className="application-center__title">
<strong>{t(definition.title)}</strong>
<small>
{alwaysShown
{id === 'device-sharing' ? t('applications.builtin') : alwaysShown
? t('applications.alwaysShown')
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
</small>
@@ -466,7 +468,7 @@ export function ApplicationCenter({
>
{t('applications.open')}
</button>
{!alwaysShown && (
{id !== 'knowledge' && id !== 'device-sharing' && (
<button
className="secondary-button"
type="button"
@@ -0,0 +1,64 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { DeviceSharingPage } from './DeviceSharingPage'
import type { DeviceSharingApi, SharingCatalog, SharingPublicationDraft } from '../../shared/device-sharing-contracts'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
const settings = { deviceId: 'mine', name: 'My desktop', serverUrl: 'http://127.0.0.1:8787' }
function setup() {
const catalog: SharingCatalog = { devices: [], publications: [] }
const sharing = {
getSettings: vi.fn(async () => settings), saveSettings: vi.fn(async input => ({ ...settings, ...input })),
getCatalog: vi.fn(async () => structuredClone(catalog)),
registerDevice: vi.fn(async () => {
const device = { id: 'mine', name: 'My desktop', platform: 'win32', appVersion: '1', registeredAt: 'today', updatedAt: 'today' }
catalog.devices.push(device); return device
}),
publish: vi.fn(async (input: SharingPublicationDraft) => {
const item = { ...input, id: 'publication', deviceId: 'mine', status: 'published' as const, createdAt: 'today', updatedAt: 'today' }
catalog.publications.push(item); return item
}),
revoke: vi.fn(async () => { catalog.publications[0]!.status = 'revoked'; return catalog.publications[0]! })
} satisfies DeviceSharingApi
vi.stubGlobal('goodbuddy', { sharing })
const notify = vi.fn()
render(<DeviceSharingPage notify={notify} />)
return { sharing, catalog, notify }
}
it('registers, publishes independent knowledge flags, revokes and refreshes actual returned rows', async () => {
const { sharing, notify } = setup()
await waitFor(() => expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled())
expect(screen.getByRole('button', { name: '发布元数据' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '注册本机' }))
await waitFor(() => expect(screen.queryByText('暂无注册设备')).not.toBeInTheDocument())
fireEvent.change(screen.getByLabelText('条目名称'), { target: { value: 'Shared knowledge' } })
fireEvent.change(screen.getByLabelText('类型'), { target: { value: 'knowledge' } })
fireEvent.change(screen.getByLabelText('来源模式'), { target: { value: 'server' } })
fireEvent.click(screen.getByRole('checkbox', { name: '搜索' }))
fireEvent.click(screen.getByRole('checkbox', { name: '下载' }))
await waitFor(() => expect(screen.getByRole('button', { name: '发布元数据' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '发布元数据' }))
await waitFor(() => expect(screen.getByRole('button', { name: '撤销发布' })).toBeEnabled())
expect(sharing.publish).toHaveBeenCalledWith(expect.objectContaining({ sourceMode: 'server', permissions: { search: true, read: false, download: true } }))
const row = screen.getByText('Shared knowledge').closest('article')!
expect(within(row).getByText(/搜索: 允许 · 读取: 不允许 · 下载: 允许/)).toBeInTheDocument()
fireEvent.click(within(row).getByRole('button', { name: '撤销发布' }))
fireEvent.click(screen.getByRole('button', { name: '确认撤销发布' }))
await waitFor(() => { expect(screen.getByText('已撤销')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '刷新列表' })).toBeEnabled() })
expect(sharing.revoke).toHaveBeenCalledWith('publication')
expect(notify).toHaveBeenCalledWith({ tone: 'success', message: '发布条目已撤销' })
})
it('preserves unsaved input on failure and does not offer revocation for other device IDs', async () => {
const { sharing, catalog, notify } = setup()
await waitFor(() => expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled())
catalog.publications.push({ name: 'Other', id: 'other', deviceId: 'theirs', description: '', kind: 'capability', sourceMode: 'device', permissions: { search: false, read: false, download: false }, status: 'published', createdAt: 'today', updatedAt: 'today' })
fireEvent.click(screen.getByRole('button', { name: '刷新列表' }))
await screen.findByText('Other')
expect(screen.queryByRole('button', { name: '撤销发布' })).not.toBeInTheDocument()
await waitFor(() => expect(screen.getByLabelText('设备名称')).toBeEnabled())
sharing.saveSettings.mockRejectedValueOnce(new Error('Disk full'))
fireEvent.change(screen.getByLabelText('设备名称'), { target: { value: 'My edit' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => expect(notify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'Error: Disk full' })))
expect(screen.getByLabelText('设备名称')).toHaveValue('My edit')
expect(screen.getByRole('button', { name: '注册本机' })).toBeDisabled()
})
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { SharingCatalog, SharingPublicationDraft, SharingSettings } from '../../shared/device-sharing-contracts'
import { sharingSettingsInputSchema } from '../../shared/device-sharing-contracts'
import { EmptyState, PageHeader, PageShell } from './WorkspacePrimitives'
import type { AppNotificationInput } from './notifications'
import './device-sharing.css'
const emptyDraft: SharingPublicationDraft = {
name: '', description: '', kind: 'capability', sourceMode: 'device',
permissions: { search: false, read: false, download: false }
}
export function DeviceSharingPage({ notify }: { notify: (input: AppNotificationInput) => void }): React.JSX.Element {
const { t } = useTranslation('app')
const [settings, setSettings] = useState<SharingSettings>()
const [name, setName] = useState('')
const [serverUrl, setServerUrl] = useState('')
const [catalog, setCatalog] = useState<SharingCatalog>()
const [draft, setDraft] = useState(emptyDraft)
const [busy, setBusy] = useState(true)
const [error, setError] = useState('')
const [reload, setReload] = useState(0)
const [revokeId, setRevokeId] = useState<string>()
const pending = useRef(false)
useEffect(() => {
let active = true
void window.goodbuddy.sharing.getSettings().then(async next => {
if (!active) return
setSettings(next); setName(next.name); setServerUrl(next.serverUrl)
const snapshot = await window.goodbuddy.sharing.getCatalog()
if (active) { setCatalog(snapshot); setError('') }
}).catch((reason: unknown) => { if (active) setError(String(reason)) })
.finally(() => { if (active) setBusy(false) })
return () => { active = false }
}, [reload])
const refresh = async (): Promise<void> => {
try {
setCatalog(await window.goodbuddy.sharing.getCatalog())
setError('')
} catch (reason) {
setError(String(reason))
}
}
const run = async (action: () => Promise<unknown>, message?: string): Promise<void> => {
if (pending.current) return
pending.current = true; setBusy(true)
try {
await action()
if (message) notify({ tone: 'success', message })
} catch (reason) {
notify({ tone: 'error', message: String(reason), dedupeKey: 'device-sharing-action' })
} finally { pending.current = false; setBusy(false) }
}
const dirty = settings && (name !== settings.name || serverUrl !== settings.serverUrl)
const registered = Boolean(settings && catalog?.devices.some(device => device.id === settings.deviceId))
const canPublish = registered && !dirty && !error && !busy
const validSettings = sharingSettingsInputSchema.safeParse({ name, serverUrl }).success
return <PageShell variant="standard">
<PageHeader headingId="device-sharing-title" title={t('sharing.title')} scope={{ kind: 'global' }} description={t('sharing.boundary')}
actions={<button className="secondary-button" disabled={busy} onClick={() => {
if (!settings) { setBusy(true); setReload(value => value + 1) }
else void run(refresh)
}}>{t('sharing.refresh')}</button>} />
<div className="device-sharing" aria-busy={busy}>
{busy && <p role="status">{t('sharing.working')}</p>}
{error && <div role="alert" className="settings-warning">{t('sharing.loadFailed')} {error}</div>}
{settings && <section aria-labelledby="sharing-settings">
<h2 id="sharing-settings">{t('sharing.thisDevice')}</h2>
<p>{t('sharing.deviceId')}: <code>{settings.deviceId}</code></p>
<form onSubmit={event => { event.preventDefault(); void run(async () => {
const next = await window.goodbuddy.sharing.saveSettings({ name, serverUrl })
setSettings(next); setName(next.name); setServerUrl(next.serverUrl); setCatalog(undefined)
await refresh()
}, t('sharing.saved')) }}>
<label className="field"><span>{t('sharing.serverUrl')}</span><input type="url" required value={serverUrl} disabled={busy} onChange={event => setServerUrl(event.target.value)} /></label>
<label className="field"><span>{t('sharing.deviceName')}</span><input required maxLength={200} value={name} disabled={busy} onChange={event => setName(event.target.value)} /></label>
<div className="device-sharing__actions">
<button className="secondary-button" disabled={busy || !dirty || !validSettings}>{t('sharing.save')}</button>
<button type="button" className="secondary-button" disabled={busy || Boolean(dirty)} onClick={() => void run(async () => {
await window.goodbuddy.sharing.registerDevice(); await refresh()
}, t('sharing.registered'))}>{t('sharing.register')}</button>
</div>
{dirty && <p>{t('sharing.saveFirst')}</p>}
</form>
</section>}
<section aria-labelledby="sharing-devices">
<h2 id="sharing-devices">{t('sharing.devices')}</h2>
{catalog?.devices.length === 0 && <EmptyState title={t('sharing.noDevices')} description={t('sharing.registerFirst')} />}
{catalog?.devices.map(device => <article key={device.id}>
<h3>{device.name} {device.id === settings?.deviceId && <small>({t('sharing.thisDevice')})</small>}</h3>
<p>{device.platform} · {device.appVersion} · <code>{device.id}</code></p>
<p>{t('sharing.updatedAt')}: {device.updatedAt}</p>
</article>)}
</section>
<section aria-labelledby="sharing-publish">
<h2 id="sharing-publish">{t('sharing.publishMetadata')}</h2>
<p>{t('sharing.sourceHelp')}</p>
{!registered && <p>{t('sharing.registerFirst')}</p>}
<form onSubmit={event => { event.preventDefault(); if (!canPublish) return; void run(async () => {
await window.goodbuddy.sharing.publish(draft); setDraft(emptyDraft); await refresh()
}, t('sharing.publishedNotice')) }}>
<fieldset disabled={busy}>
<label className="field"><span>{t('sharing.name')}</span><input required maxLength={200} value={draft.name} onChange={event => setDraft({ ...draft, name: event.target.value })} /></label>
<label className="field"><span>{t('sharing.description')}</span><textarea maxLength={4000} value={draft.description} onChange={event => setDraft({ ...draft, description: event.target.value })} /></label>
<label className="field"><span>{t('sharing.kind')}</span><select value={draft.kind} onChange={event => setDraft({ ...draft, kind: event.target.value as SharingPublicationDraft['kind'] })}>
<option value="capability">{t('sharing.capability')}</option><option value="knowledge">{t('sharing.knowledge')}</option>
</select></label>
<label className="field"><span>{t('sharing.sourceMode')}</span><select value={draft.sourceMode} onChange={event => setDraft({ ...draft, sourceMode: event.target.value as SharingPublicationDraft['sourceMode'] })}>
<option value="device">{t('sharing.device')}</option><option value="server">{t('sharing.server')}</option>
</select></label>
{draft.kind === 'knowledge' && <fieldset><legend>{t('sharing.permissions')}</legend>
{(['search', 'read', 'download'] as const).map(permission => <label key={permission} className="device-sharing__permission"><input type="checkbox" checked={draft.permissions[permission]} onChange={event => setDraft({ ...draft, permissions: { ...draft.permissions, [permission]: event.target.checked } })} />{t(`sharing.${permission}`)}</label>)}
</fieldset>}
</fieldset>
<p>{t('sharing.target')}: {settings?.serverUrl} · {settings?.name}</p>
<button className="primary-button" disabled={!canPublish || !draft.name.trim()}>{t('sharing.publishMetadata')}</button>
</form>
</section>
<section aria-labelledby="sharing-publications">
<h2 id="sharing-publications">{t('sharing.publications')}</h2>
{catalog?.publications.length === 0 && <EmptyState title={t('sharing.noPublications')} description={t('sharing.publishHelp')} />}
{catalog?.publications.map(item => <article key={item.id}>
<h3>{item.name} <small>{t(`sharing.${item.status}`)}</small></h3>
<p>{item.description}</p>
<p>{t(`sharing.${item.kind}`)} · {t('sharing.sourceMode')}: {t(`sharing.${item.sourceMode}`)}</p>
<p>{t('sharing.deviceId')}: <code>{item.deviceId}</code> · {t('sharing.updatedAt')}: {item.updatedAt}</p>
{item.kind === 'knowledge' && <p>{(['search', 'read', 'download'] as const).map(key => `${t(`sharing.${key}`)}: ${t(item.permissions[key] ? 'sharing.yes' : 'sharing.no')}`).join(' · ')}</p>}
{item.deviceId === settings?.deviceId && item.status === 'published' && (revokeId === item.id ? <div>
<p>{t('sharing.revokeHelp')}</p><div className="device-sharing__actions">
<button className="secondary-button" disabled={busy} onClick={() => setRevokeId(undefined)}>{t('sharing.cancel')}</button>
<button className="danger-solid" disabled={busy || Boolean(dirty) || Boolean(error)} onClick={() => void run(async () => {
await window.goodbuddy.sharing.revoke(item.id); setRevokeId(undefined); await refresh()
}, t('sharing.revokedNotice'))}>{t('sharing.confirmRevoke')}</button>
</div>
</div> : <button className="danger-ghost" disabled={busy || Boolean(dirty) || Boolean(error)} onClick={() => setRevokeId(item.id)}>{t('sharing.revoke')}</button>)}
</article>)}
</section>
</div>
</PageShell>
}
+10 -1
View File
@@ -518,7 +518,11 @@ describe('MagicNotesWorkspace overview navigation', () => {
expect(css).toMatch(/\.magic-notes-page #magic-library-switch\s*\{[^}]*flex: 0 0 auto/)
expect(css).toMatch(/\.magic-notes-page #magic-library-switch\s*\{[^}]*width: fit-content/)
const switchButton = screen.getByRole('button', { name: '切换到待办' })
expect(screen.getByRole('region', { name: '笔记列表' })).toContainElement(screen.getByRole('button', { name: '新建笔记' }))
// New note sits in the page header, directly after the library switch; the list heading has no + button.
const newNoteButton = screen.getByRole('button', { name: '新建笔记' })
expect(newNoteButton.closest('.page-header__actions')).not.toBeNull()
expect(switchButton.nextElementSibling).toBe(newNoteButton)
expect(container.querySelector('#magic-library-panel-notes .magic-notes-pane-heading .icon-button')).toBeNull()
expect(switchButton.querySelector('.lucide-list-todo')).toBeInTheDocument()
switchButton.focus()
fireEvent.click(switchButton)
@@ -527,6 +531,7 @@ describe('MagicNotesWorkspace overview navigation', () => {
expect(screen.getByRole('region', { name: '待办列表' })).toBeVisible()
expect(container.querySelector('.magic-notes-layout')).not.toHaveClass('magic-notes-layout--detail')
expect(screen.queryByRole('tabpanel')).not.toBeInTheDocument()
expect(switchButton.nextElementSibling).toBe(screen.getByRole('button', { name: '新建笔记' }))
await waitFor(() => expect(switchButton).toHaveFocus())
fireEvent.click(switchButton)
expect(screen.getByRole('button', { name: '切换到待办' })).toBe(switchButton)
@@ -554,6 +559,10 @@ describe('MagicNotesWorkspace overview navigation', () => {
expect(screen.getByRole('separator', { name: '调整笔记列表宽度' })).toBeVisible()
// Back to overview lives in the page header's top-right actions while a note is open.
expect(screen.getByRole('button', { name: '返回总览' }).closest('.page-header__actions')).not.toBeNull()
expect(screen.getByRole('button', { name: '返回总览' }).nextElementSibling).toBe(screen.getByRole('button', { name: '新建笔记' }))
fireEvent.click(screen.getByRole('button', { name: '新建笔记' }))
expect(within(overview).getByLabelText('笔记标题')).toBeVisible()
fireEvent.click(within(overview).getByRole('button', { name: '取消' }))
expect(screen.queryByRole('button', { name: '切换到待办' })).not.toBeInTheDocument()
expect(screen.getByRole('searchbox')).toBeVisible()
expect(screen.getByRole('button', { name: '新建笔记' })).toBeVisible()
+18 -19
View File
@@ -2178,22 +2178,26 @@ export function MagicNotesWorkspace({
{libraryView === 'notes' ? <ListTodo aria-hidden="true" size={15} /> : <BookOpen aria-hidden="true" size={15} />}
{t(libraryView === 'notes' ? 'actions.switchToTodos' : 'actions.switchToNotes')}
</button>
{libraryView === 'todos' && <button
id="magic-note-new"
className="primary-button"
disabled={Boolean(busy)}
type="button"
onClick={() => {
setValidation(undefined)
setLibraryView('notes')
setCreating(true)
}}
>
<Plus aria-hidden="true" size={15} />
{t('actions.newNote')}
</button>}
</>
)}
<button
id="magic-note-new"
className="primary-button"
disabled={Boolean(busy)}
type="button"
onClick={() => {
setValidation(undefined)
setLibraryView('notes')
setCreating(true)
if (detailView) {
if (isNarrowWorkspace) void requestDraftSwitch({ kind: 'overview' })
else setNotesPaneOpen(true)
}
}}
>
<Plus aria-hidden="true" size={15} />
{t('actions.newNote')}
</button>
</>
}
help={t('page.description')}
@@ -2265,11 +2269,6 @@ export function MagicNotesWorkspace({
disabled={Boolean(busy)}
onClick={() => setTagManagerOpen(true)}
/>
<button id="magic-note-new" type="button" className="icon-button"
disabled={Boolean(busy)} aria-label={t('actions.newNote')} title={t('actions.newNote')}
onClick={() => { setValidation(undefined); setCreating(true) }}>
<Plus size={15} />
</button>
</div>
<label className="magic-notes-search">
<span className="sr-only">{t('notes.searchLabel')}</span>
+10
View File
@@ -0,0 +1,10 @@
.device-sharing { display: grid; gap: var(--space-6); overflow-wrap: anywhere; }
.device-sharing section { border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }
.device-sharing h2, .device-sharing h3 { font-size: var(--font-section-title); }
.device-sharing small, .device-sharing p { color: var(--text-secondary); }
.device-sharing form, .device-sharing fieldset { display: grid; gap: var(--space-3); min-width: 0; }
.device-sharing fieldset { border: 0; padding: 0; margin: 0; }
.device-sharing form > button { justify-self: start; }
.device-sharing article { padding-block: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
.device-sharing__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.device-sharing__permission { display: flex; align-items: center; gap: var(--space-2); }
@@ -2,6 +2,19 @@ import type { TranslationShape } from '../../resource-types'
import type { app as chineseApp } from '../zh-CN/app'
export const app = {
sharing: {
title: 'Device Sharing', boundary: 'Local unauthenticated metadata service. Device IDs identify records, not authenticated owners; registration does not indicate online status. Remote execution and knowledge content access are not provided here.',
refresh: 'Refresh lists', working: 'Working…', loadFailed: 'Could not load the lists. Previous results may be stale. Check the service URL and refresh.',
thisDevice: 'This device', deviceId: 'Device ID', serverUrl: 'Service URL', deviceName: 'Device name', save: 'Save settings', saved: 'Sharing settings saved',
register: 'Register this device', registered: 'Device registration saved to the service', saveFirst: 'Save settings before registering or publishing.',
devices: 'Registered devices', noDevices: 'No registered devices', registerFirst: 'Register this device to publish metadata.', updatedAt: 'Updated at',
publishMetadata: 'Publish metadata', sourceHelp: 'Source mode describes the entry only. Selecting Server does not upload or sync content; knowledge permissions are declarations only.',
name: 'Entry name', description: 'Description', kind: 'Kind', capability: 'Capability', knowledge: 'Knowledge', sourceMode: 'Source mode', device: 'Device', server: 'Server',
permissions: 'Knowledge permission declarations', search: 'Search', read: 'Read', download: 'Download', target: 'Publish to', publishedNotice: 'Metadata published',
publications: 'Publication catalog', noPublications: 'No publications', publishHelp: 'Enter a name, kind and source to publish metadata.',
published: 'Published', revoked: 'Revoked', yes: 'Allowed', no: 'Not allowed', revoke: 'Revoke publication', confirmRevoke: 'Confirm revocation', cancel: 'Cancel',
revokeHelp: 'The catalog record will remain with a revoked status. This changes metadata only and does not delete local content.', revokedNotice: 'Publication revoked'
},
runtimeChecklist: {
title: 'Execution checklist', count: '{{completed}}/{{total}} completed',
finished: 'Request completed', failed: 'Request failed',
@@ -22,6 +35,7 @@ export const app = {
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
descriptions: {
'device-sharing': 'Register devices, publish and revoke capability or knowledge metadata.',
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.'
}
@@ -1,4 +1,17 @@
export const app = {
sharing: {
title: '设备共享', boundary: '本地未认证的元数据服务。设备 ID 仅用于识别,不代表身份认证;注册记录不代表设备在线。此页不提供远程执行或知识内容访问。',
refresh: '刷新列表', working: '正在处理…', loadFailed: '列表读取失败,已有列表可能过期。请检查服务地址并刷新。',
thisDevice: '本机', deviceId: '设备 ID', serverUrl: '服务地址', deviceName: '设备名称', save: '保存设置', saved: '共享设置已保存',
register: '注册本机', registered: '本机注册信息已保存到服务', saveFirst: '请先保存设置,再注册或发布。',
devices: '已注册设备', noDevices: '暂无注册设备', registerFirst: '注册本机后即可发布元数据。', updatedAt: '更新时间',
publishMetadata: '发布元数据', sourceHelp: '来源模式只描述条目来源。选择“服务器”不会上传或同步内容;知识权限只保存声明。',
name: '条目名称', description: '描述', kind: '类型', capability: '能力', knowledge: '知识', sourceMode: '来源模式', device: '设备', server: '服务器',
permissions: '知识权限声明', search: '搜索', read: '读取', download: '下载', target: '发布到', publishedNotice: '元数据已发布',
publications: '发布目录', noPublications: '暂无发布条目', publishHelp: '填写名称、类型与来源后发布一条元数据。',
published: '已发布', revoked: '已撤销', yes: '允许', no: '不允许', revoke: '撤销发布', confirmRevoke: '确认撤销发布', cancel: '取消',
revokeHelp: '撤销后保留目录记录并标记为已撤销。此操作只修改元数据,不删除本机内容。', revokedNotice: '发布条目已撤销'
},
runtimeChecklist: {
title: '执行清单', count: '已完成 {{completed}}/{{total}}',
finished: '请求已完成', failed: '请求失败',
@@ -19,6 +32,7 @@ export const app = {
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
descriptions: {
'device-sharing': '注册设备,发布和撤销能力或知识的元数据。',
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
}
+1 -6
View File
@@ -1408,21 +1408,16 @@
.magic-note-list-item[aria-current='true'] { border-color: Highlight; }
}
/* Heading: title and count grouped left, new-note action right. */
/* Heading: title and count grouped left, tag management right. New note lives in the page header. */
#magic-library-panel-notes > .magic-notes-pane-heading {
justify-content: flex-start;
gap: var(--space-2);
}
#magic-library-panel-notes > .magic-notes-pane-heading > .icon-button,
#magic-library-panel-notes > .magic-notes-pane-heading > #magic-note-tag-manager {
margin-left: auto;
}
#magic-library-panel-notes > .magic-notes-pane-heading > #magic-note-tag-manager + .icon-button {
margin-left: var(--space-1);
}
.magic-note-tag-manager-button {
min-height: 28px;
margin-block: -4px;
+2 -2
View File
@@ -37,12 +37,12 @@ export const defaultSupervisorModelConcurrency = 1
export const supervisorModelConcurrencySchema = z.number().int().min(1).max(4)
export const supervisionTimeoutSecondsSchema = z.number().int().min(30).max(600)
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference'] as const
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'] as const
export type BuiltInApplicationId = typeof builtInApplicationIds[number]
export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference'] as const
export type EditableApplicationId = typeof editableApplicationIds[number]
export const defaultApplicationNavigation = {
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference'] as BuiltInApplicationId[],
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing'] as BuiltInApplicationId[],
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false }
}
export const applicationNavigationSchema = z.object({
+1
View File
@@ -1767,6 +1767,7 @@ export type KnowledgeSearchReference = {
}
export type DesktopApi = {
sharing: import('./device-sharing-contracts').DeviceSharingApi
openRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientInput) => Promise<import('./runtime-native-client-contracts').RuntimeNativeClientResult>
getRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientInput) => Promise<import('./runtime-native-client-contracts').RuntimeNativeClientService | null>
stopRuntimeNativeClient: (input: import('./runtime-native-client-contracts').RuntimeNativeClientService) => Promise<void>
+41
View File
@@ -0,0 +1,41 @@
import { z } from 'zod'
export const sharingIdSchema = z.string().trim().min(1).max(200)
export const sharingSettingsInputSchema = z.object({
serverUrl: z.url().refine(value => {
if (!URL.canParse(value)) return false
const url = new URL(value)
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash
}, 'Use an HTTP(S) service URL without credentials, query or fragment'),
name: z.string().trim().min(1).max(200)
}).strict()
export const sharingSettingsSchema = sharingSettingsInputSchema.extend({ deviceId: sharingIdSchema })
export const sharingDeviceSchema = z.object({
id: sharingIdSchema, name: z.string(), platform: z.string(), appVersion: z.string(),
registeredAt: z.string(), updatedAt: z.string()
})
export const sharingPublicationDraftSchema = z.object({
name: z.string().trim().min(1).max(200),
kind: z.enum(['capability', 'knowledge']),
description: z.string().trim().max(4000),
sourceMode: z.enum(['device', 'server']),
permissions: z.object({ search: z.boolean(), read: z.boolean(), download: z.boolean() }).strict()
}).strict()
export const sharingPublicationSchema = sharingPublicationDraftSchema.extend({
id: sharingIdSchema, deviceId: sharingIdSchema,
status: z.enum(['published', 'revoked']), createdAt: z.string(), updatedAt: z.string()
})
export type SharingSettings = z.infer<typeof sharingSettingsSchema>
export type SharingSettingsInput = z.infer<typeof sharingSettingsInputSchema>
export type SharingDevice = z.infer<typeof sharingDeviceSchema>
export type SharingPublicationDraft = z.infer<typeof sharingPublicationDraftSchema>
export type SharingPublication = z.infer<typeof sharingPublicationSchema>
export type SharingCatalog = { devices: SharingDevice[]; publications: SharingPublication[] }
export type DeviceSharingApi = {
getSettings: () => Promise<SharingSettings>
saveSettings: (input: SharingSettingsInput) => Promise<SharingSettings>
registerDevice: () => Promise<SharingDevice>
getCatalog: () => Promise<SharingCatalog>
publish: (input: SharingPublicationDraft) => Promise<SharingPublication>
revoke: (id: string) => Promise<SharingPublication>
}
@@ -23,7 +23,7 @@ describe('GoodBuddy configuration contracts', () => {
operation: 'application.update',
updates: { applicationNavigation: {
...defaultApplicationNavigation,
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'],
} },
}
expect(goodbuddyConfigOperationSchema.parse(operation)).toEqual(operation)
+6
View File
@@ -1,4 +1,10 @@
export const ipcChannels = {
sharingSettingsGet: 'sharing:settings-get',
sharingSettingsSave: 'sharing:settings-save',
sharingRegister: 'sharing:register',
sharingCatalog: 'sharing:catalog',
sharingPublish: 'sharing:publish',
sharingRevoke: 'sharing:revoke',
localInferenceGet: 'local-inference:get',
localInferenceAct: 'local-inference:act',
localInferenceCancel: 'local-inference:cancel',
+62
View File
@@ -0,0 +1,62 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer as createHttpServer } from 'node:http'
import { build } from 'esbuild'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it.skipIf(!process.env.GOODBUDDY_SHARING_SERVER_ROOT)('uses the real ShareServer through Electron preload and trusted Main IPC', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-sharing-electron-'))
const portProbe = createHttpServer()
await new Promise<void>(resolve => portProbe.listen(0, '127.0.0.1', resolve))
const port = (portProbe.address() as { port: number }).port
await new Promise<void>(resolve => portProbe.close(() => resolve()))
const backend = spawn(process.execPath, ['--import', 'tsx', 'server/index.ts'], {
cwd: process.env.GOODBUDDY_SHARING_SERVER_ROOT,
env: { ...process.env, SHARESERVER_PORT: String(port), SHARESERVER_DATA_DIR: join(directory, 'server-data') },
stdio: ['ignore', 'pipe', 'pipe']
})
let backendOutput = ''
backend.stdout.on('data', data => { backendOutput += data })
backend.stderr.on('data', data => { backendOutput += data })
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'sharing-smoke', configureServer(server) {
server.middlewares.use('/sharing.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/sharing.html', '<html><body><div id="root"></div><script type="module" src="/tests/support/device-sharing-renderer.tsx"></script></body></html>'))
})
} }], server: { host: '127.0.0.1', port: 0, watch: null } })
try {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(backendOutput || 'ShareServer startup timed out')), 15000)
backend.once('error', reject)
backend.once('exit', code => { clearTimeout(timer); reject(new Error(`ShareServer exit ${code}: ${backendOutput}`)) })
backend.stdout.on('data', () => { if (backendOutput.includes('listening on')) { clearTimeout(timer); resolve() } })
})
await build({ entryPoints: ['tests/support/device-sharing-main.ts'], outfile: join(directory, 'main.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
await server.listen()
const env: NodeJS.ProcessEnv = { ...process.env, GB_SHARING_DIRECTORY: directory, GB_SHARING_URL: `http://127.0.0.1:${port}`,
GB_SHARING_RENDERER: server.resolvedUrls!.local[0] + 'sharing.html' }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [join(directory, 'main.cjs')], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timer = setTimeout(() => child.kill(), 60000)
try {
expect(await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }), output).toBe(0)
process.stdout.write(output)
} finally { clearTimeout(timer) }
} finally {
const stopped = new Promise(resolve => backend.once('exit', resolve))
if (backend.exitCode === null) { backend.kill(); await stopped }
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 90000)
+70
View File
@@ -0,0 +1,70 @@
import { app, BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { join } from 'node:path'
import { setTimeout } from 'node:timers/promises'
import { DeviceSharingService } from '../../src/main/device-sharing-service'
import { registerDeviceSharingIpc } from '../../src/main/device-sharing-ipc'
const directory = process.env.GB_SHARING_DIRECTORY!
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
void app.whenReady().then(async () => {
const file = join(directory, 'sharing-settings.json')
const service = new DeviceSharingService(file, 'electron-smoke')
await service.saveSettings({ name: 'Sharing smoke device', serverUrl: process.env.GB_SHARING_URL! })
const win = new BrowserWindow({ show: true, width: 1100, height: 850, webPreferences: {
preload: join(directory, 'preload.cjs'), sandbox: true, contextIsolation: true, nodeIntegration: false
} })
const dispose = registerDeviceSharingIpc(win, service)
const errors: string[] = []
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
const js = (code: string) => win.webContents.executeJavaScript(code)
const wait = async (code: string): Promise<void> => {
for (let count = 0; count < 300; count++) { if (await js(code)) return; await setTimeout(25) }
throw new Error(`Timed out: ${code}; ${errors.join('\n')}`)
}
const click = async (text: string): Promise<void> => {
await wait(`[...document.querySelectorAll('button')].some(e => e.textContent === ${JSON.stringify(text)} && !e.disabled)`)
const rect = await js(`(() => { const e = [...document.querySelectorAll('button')].find(e=>e.textContent===${JSON.stringify(text)}); e.scrollIntoView({block:'center'}); const r=e.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)} })()`)
win.webContents.sendInputEvent({ type: 'mouseDown', ...rect, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', ...rect, button: 'left', clickCount: 1 })
}
const field = async (label: string, value: string, tag = 'input'): Promise<void> => {
await js(`(() => { const e = [...document.querySelectorAll('label')].find(e => e.querySelector('span')?.textContent === ${JSON.stringify(label)}).querySelector(${JSON.stringify(tag)}); Object.getOwnPropertyDescriptor(${tag === 'select' ? 'HTMLSelectElement' : 'HTMLInputElement'}.prototype,'value').set.call(e,${JSON.stringify(value)}); e.dispatchEvent(new Event('${tag === 'select' ? 'change' : 'input'}',{bubbles:true})); })()`)
}
try {
await win.loadURL(process.env.GB_SHARING_RENDERER!)
await wait(`!![...document.querySelectorAll('article')].find(e => e.textContent.includes('Device Sharing'))`)
await js(`[...document.querySelectorAll('article')].find(e=>e.textContent.includes('Device Sharing')).querySelector('button').click()`)
await click('Register this device')
await wait(`!!document.querySelector('#sharing-devices + article') || document.querySelector('[aria-labelledby="sharing-devices"]').textContent.includes('Sharing smoke device')`)
await field('Entry name', 'Smoke knowledge')
await field('Kind', 'knowledge', 'select')
await field('Source mode', 'server', 'select')
await wait(`document.querySelectorAll('input[type=checkbox]').length === 3`)
await js(`document.querySelectorAll('input[type=checkbox]')[0].click(); document.querySelectorAll('input[type=checkbox]')[2].click()`)
await click('Publish metadata')
await wait(`document.querySelector('[aria-labelledby="sharing-publications"]').textContent.includes('Smoke knowledge')`)
let catalog = await service.getCatalog()
assert.equal(catalog.devices.length, 1)
assert.equal(catalog.publications.length, 1)
assert.deepEqual(catalog.publications[0]!.permissions, { search: true, read: false, download: true })
assert.equal(catalog.publications[0]!.sourceMode, 'server')
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme='${theme}'`)
win.setContentSize(480, 700)
await wait('window.innerWidth === 480')
assert.equal(await js('document.documentElement.scrollWidth <= window.innerWidth'), true, 'narrow layout overflow')
}
await click('Revoke publication')
await click('Confirm revocation')
await wait(`document.querySelector('[aria-labelledby="sharing-publications"]').textContent.includes('Revoked')`)
await click('Refresh lists')
await wait(`!document.querySelector('.device-sharing').getAttribute('aria-busy') || document.querySelector('.device-sharing').getAttribute('aria-busy') === 'false'`)
catalog = await service.getCatalog()
assert.equal(catalog.publications[0]!.status, 'revoked')
assert.deepEqual(await new DeviceSharingService(file, 'restart').getSettings(), await service.getSettings())
assert.equal(errors.length, 0, errors.join('\n'))
console.log('SHARING_ELECTRON_OK: application center -> register -> knowledge/server metadata (search=true, read=false, download=true) -> revoke -> refresh; persisted identity; light/dark 480px; real ShareServer')
} finally { dispose(); win.destroy() }
}).then(() => app.exit(0), error => { console.error(error); app.exit(1) })
+21
View File
@@ -0,0 +1,21 @@
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { ApplicationCenter } from '../../src/renderer/src/ApplicationCenter'
import { DeviceSharingPage } from '../../src/renderer/src/DeviceSharingPage'
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../src/shared/application-settings-contracts'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import '../../src/renderer/src/styles.css'
const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
function Harness(): React.JSX.Element {
const [open, setOpen] = useState(true)
const [page, setPage] = useState(false)
return <UiLocaleProvider initialPreference="en-US">
{open && <ApplicationCenter settings={settings} pending={false} onClose={() => setOpen(false)}
onOpen={id => { if (id === 'device-sharing') { setPage(true); setOpen(false) } }}
onUpdate={async () => false} onRetry={() => undefined} />}
{page && <DeviceSharingPage notify={input => { document.documentElement.dataset.notification = input.message }} />}
</UiLocaleProvider>
}
createRoot(document.getElementById('root')!).render(<Harness />)