From e8382a14f073bbc0ef695e86758f03c380c7a3de Mon Sep 17 00:00:00 2001 From: zengjiayi819 Date: Tue, 20 Oct 2026 17:05:05 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(settings):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=8E=A7=E5=88=B6=E4=BB=B6=E5=AF=B9=E9=BD=90=E3=80=8C?= =?UTF-8?q?+=E6=96=B0=E5=BB=BA=E6=96=87=E6=A1=A3=E3=80=8D=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主/次按钮统一 40px 高、10px 圆角、13px 字,图标与文字垂直居中 - 文本输入与下拉同规格:40px 高、10px 圆角、focus 给 3px 柔和外圈 - 左侧导航对齐弹窗列表行:46px 行高、10px 圆角,选中态改浅蓝底 + 蓝字 - 分段控件外壳 12px / 按钮 9px 圆角;连接模式选项按 46px 列表行重排 - 卡片统一 16px 圆角 + 柔和双层阴影,卡片头与设置行内边距加到 20px --- .../src/components/pages/SettingsPage.css | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/apps/desktop/src/renderer/src/components/pages/SettingsPage.css b/apps/desktop/src/renderer/src/components/pages/SettingsPage.css index 85450da8..9094c7fc 100644 --- a/apps/desktop/src/renderer/src/components/pages/SettingsPage.css +++ b/apps/desktop/src/renderer/src/components/pages/SettingsPage.css @@ -1683,3 +1683,166 @@ .screen-permission-card { flex-direction: column; align-items: stretch; } .screen-permission-card > button { width: 100%; } } + +/* —— 控制件规范:与「+新建文档」弹窗(.room-picker-*)保持一致 —— + 参考值:40px 控件高度、10px 控件圆角、24px 级内边距、柔和阴影、浅蓝强调。 + 全部规则限定在 .settings-page 内,避免波及应用其它页面。 */ +.settings-page { + --settings-control-height: 40px; + --settings-control-radius: 10px; + --settings-card-radius: 16px; + --settings-soft-shadow: 0 1px 2px rgba(21, 26, 34, 0.05), 0 12px 32px rgba(21, 26, 34, 0.05); +} + +/* 主/次按钮:统一 40px 高、10px 圆角、13px 字,图标与文字垂直居中。 */ +.settings-page .primary-button, +.settings-page .secondary-button { + min-height: var(--settings-control-height); + gap: 8px; + padding: 0 14px; + border-radius: var(--settings-control-radius); + font-size: 13px; + transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; +} + +.settings-page .primary-button svg, +.settings-page .secondary-button svg { + width: 16px; + height: 16px; +} + +/* 文本类控件:同高同圆角,聚焦给和搜索框一致的柔和外圈。 */ +.settings-page input[type='text'], +.settings-page input[type='search'], +.settings-page input[type='password'], +.settings-page input[type='number'], +.settings-page input[type='time'], +.settings-page input[type='url'], +.settings-page input[type='email'], +.settings-page select { + min-height: var(--settings-control-height); + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--settings-control-radius); + background: var(--surface-alt); + color: var(--text); + font: inherit; + font-size: 13px; + outline: none; + transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; +} + +.settings-page input[type='text']:focus-visible, +.settings-page input[type='search']:focus-visible, +.settings-page input[type='password']:focus-visible, +.settings-page input[type='number']:focus-visible, +.settings-page input[type='time']:focus-visible, +.settings-page input[type='url']:focus-visible, +.settings-page input[type='email']:focus-visible, +.settings-page select:focus-visible, +.settings-page textarea:focus-visible { + border-color: var(--accent); + background: var(--surface); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +/* 按钮聚焦圈:与弹窗输入框同款,键盘操作时可见。 */ +.settings-page button:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--accent-soft); +} + +/* 分段控件:外壳 12px、内部 9px,跟弹窗里 10px 的控件圆角同一档。 */ +.settings-page .segmented-control { + padding: 4px; + border-radius: 12px; +} + +.settings-page .segmented-control button { + min-height: 32px; + padding: 0 14px; + border-radius: 9px; + font-size: 12px; +} + +/* 左侧导航:行高对齐弹窗列表行(实际 46px)、10px 圆角;选中态改浅蓝底 + 蓝字。 */ +.settings-navigation { + gap: 4px; + padding: 0; +} + +.settings-navigation-item { + min-height: var(--settings-control-height); + grid-template-columns: 28px minmax(0, 1fr); + gap: 10px; + padding: 8px 12px; + border-radius: var(--settings-control-radius); +} + +.settings-navigation-item strong { + font-size: 12.5px; +} + +.settings-navigation-icon { + width: 28px; + height: 28px; + border-radius: 8px; +} + +.settings-navigation-item[data-active='true'] { + border-color: transparent; + background: var(--accent-soft); + box-shadow: none; + color: var(--accent-strong); +} + +.settings-navigation-item[data-active='true'] .settings-navigation-icon { + background: rgba(255, 255, 255, 0.72); + color: var(--accent-strong); +} + +/* 连接模式选项:本质是可点列表行,对齐弹窗里 46px / 10px 的列表行规格。 */ +.settings-page .connector-mode-option { + min-height: 46px; + padding: 10px 14px; + border-radius: var(--settings-control-radius); +} + +.settings-page .connector-mode-option-icon { + width: 28px; + height: 28px; + border-radius: 8px; +} + +/* 卡片:16px 圆角 + 柔和阴影 + 更松的行内边距,撑出呼吸感。 + 注意只描真实卡片(section / .reality-settings-section):models 与 token-usage + 两组只是透明栅格容器,给它们加阴影会凭空多出一个矩形。 */ +.settings-content > section, +.settings-page .reality-settings-section, +.settings-page .cloud-account-section { + border-radius: var(--settings-card-radius); + box-shadow: var(--settings-soft-shadow); +} + +/* 卡片头部:图标 32px、标题撑开;后续操作按钮另占一列。 */ +.settings-page .reality-settings-section > header { + grid-template-columns: 32px minmax(0, 1fr); + gap: 12px; + padding: 15px 20px; +} + +.settings-page .reality-settings-section > header > span { + width: 32px; + height: 32px; + border-radius: 9px; +} + +.settings-page .reality-settings-section h2 { + font-size: 14.5px; +} + +.settings-page .reality-setting-row { + min-height: 58px; + gap: 16px; + padding: 14px 20px; +} From d6bbd7fc44d39fa34865de503c32218e83f89a32 Mon Sep 17 00:00:00 2001 From: zengjiayi819 Date: Tue, 20 Oct 2026 17:05:05 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(settings):=20=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=A4=B4=E9=83=A8=E6=93=8D=E4=BD=9C=E6=8C=89=E9=92=AE=E7=AB=96?= =?UTF-8?q?=E6=8E=92=E2=80=94=E2=80=94=E6=94=B9=20flex=20=E4=B8=A4?= =?UTF-8?q?=E7=AB=AF=E5=AF=B9=E9=BD=90=20+=20=E8=BD=BB=E9=87=8F=E6=AC=A1?= =?UTF-8?q?=E7=BA=A7=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:头部是 30px + 1fr 两列栅格,第三个子元素(操作按钮)被排进第二行 第一列的 30px 里,文字只能逐字竖排,右侧留出大片空白 - 头部改 flex / nowrap:图标 + 标题在左撑开,操作按钮 flex:0 0 auto 靠右 - 文字按钮改轻量次级样式:32px 高、浅灰底、极细描边、12px 字、white-space nowrap、宽度随内容;纯图标按钮(刷新/测试)保持方形尺寸 - 去掉头部浅蓝底色,只留 1px 分隔线,标题过长走 ellipsis 截断 --- .../src/components/pages/SettingsPage.css | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/pages/SettingsPage.css b/apps/desktop/src/renderer/src/components/pages/SettingsPage.css index 9094c7fc..a2ac78cc 100644 --- a/apps/desktop/src/renderer/src/components/pages/SettingsPage.css +++ b/apps/desktop/src/renderer/src/components/pages/SettingsPage.css @@ -1824,19 +1824,65 @@ box-shadow: var(--settings-soft-shadow); } -/* 卡片头部:图标 32px、标题撑开;后续操作按钮另占一列。 */ +/* 卡片头部:左侧图标 + 标题撑开、右侧操作靠右。原来 30px + 1fr 的两列栅格 + 会把第三个子元素(操作按钮)挤到第二行第一列的 30px 里,文字只能逐字竖排。 */ .settings-page .reality-settings-section > header { - grid-template-columns: 32px minmax(0, 1fr); + display: flex; + flex-wrap: nowrap; + align-items: center; gap: 12px; padding: 15px 20px; + border-bottom: 1px solid var(--border); + background: transparent; } .settings-page .reality-settings-section > header > span { width: 32px; height: 32px; + flex: 0 0 32px; border-radius: 9px; } +.settings-page .reality-settings-section > header > div:not(.rc-header-actions) { + flex: 1 1 auto; + min-width: 0; +} + +.settings-page .reality-settings-section > header h2 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-page .reality-settings-section > header > .rc-header-actions { + flex: 0 0 auto; +} + +/* 头部任何按钮都不许被挤窄;纯图标按钮(刷新/测试)保持原有方形尺寸。 */ +.settings-page .reality-settings-section > header > button { + flex: 0 0 auto; +} + +/* 带文字的操作按钮:轻量次级样式——浅灰底、极细描边、32px 高, + 宽度随文字、绝不折行。 */ +.settings-page .reality-settings-section > header > button:not(.cloud-account-refresh):not(.rc-icon-button) { + width: auto; + min-height: 32px; + padding: 0 12px; + white-space: nowrap; + border: 1px solid transparent; + border-radius: var(--settings-control-radius); + background: var(--surface-alt); + color: var(--text); + font-size: 12px; + font-weight: 600; +} + +.settings-page .reality-settings-section > header > button:not(.cloud-account-refresh):not(.rc-icon-button):hover:not(:disabled) { + border-color: var(--border); + background: var(--hover); +} + .settings-page .reality-settings-section h2 { font-size: 14.5px; } From 862ea6f265df3c0b5f9a3103eb77dfb18a1741b4 Mon Sep 17 00:00:00 2001 From: zengjiayi819 Date: Tue, 20 Oct 2026 17:05:05 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(settings):=20=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E4=B8=8E=E9=A2=84=E7=AE=97=20Tab=20=E4=B8=8E=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E8=B4=B4=E5=90=88=E2=80=94=E2=80=94=E8=A1=A5?= =?UTF-8?q?=2016px=20=E9=97=B4=E8=B7=9D=E4=B8=8E=E5=B1=82=E7=BA=A7?= =?UTF-8?q?=E5=AF=B9=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:Tab 胶囊外层没有下外边距,与下方卡片实测间距 0.0px,两个圆角白块 直接贴合糊成一体 - #settings-token-usage 组容器改 grid + gap:16px,两个模块垂直分开 - Tab 栏明确浅灰底(surface-alt),与白卡形成层级;分段控件 3px 内衬让整条 高度对齐 40px(h-10) --- .../src/components/pages/SettingsPage.css | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/pages/SettingsPage.css b/apps/desktop/src/renderer/src/components/pages/SettingsPage.css index a2ac78cc..3e306931 100644 --- a/apps/desktop/src/renderer/src/components/pages/SettingsPage.css +++ b/apps/desktop/src/renderer/src/components/pages/SettingsPage.css @@ -1752,9 +1752,10 @@ box-shadow: 0 0 0 3px var(--accent-soft); } -/* 分段控件:外壳 12px、内部 9px,跟弹窗里 10px 的控件圆角同一档。 */ +/* 分段控件:外壳 12px、内部 9px,跟弹窗里 10px 的控件圆角同一档; + 3px 内衬 + 32px 按钮 + 1px 边框 = 整条 40px,与 h-10 控件高度对齐。 */ .settings-page .segmented-control { - padding: 4px; + padding: 3px; border-radius: 12px; } @@ -1814,6 +1815,18 @@ border-radius: 8px; } +/* 「用量与预算」:胶囊 Tab 与下方内容卡片是两个独立模块,中间留 16px, + 否则两个圆角块会直接贴合、视觉上糊成一团。 */ +.settings-page .settings-content[data-active-setting='settings-token-usage'] > #settings-token-usage { + display: grid; + gap: 16px; +} + +.settings-page .usage-budget-switcher { + margin: 0; + background: var(--surface-alt); +} + /* 卡片:16px 圆角 + 柔和阴影 + 更松的行内边距,撑出呼吸感。 注意只描真实卡片(section / .reality-settings-section):models 与 token-usage 两组只是透明栅格容器,给它们加阴影会凭空多出一个矩形。 */ From e375845688c8a4a24af47602e9902abcac5c3c98 Mon Sep 17 00:00:00 2001 From: zengjiayi819 Date: Tue, 20 Oct 2026 19:11:08 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(settings):=20=E7=8E=B0=E5=AE=9E?= =?UTF-8?q?=E6=84=9F=E7=9F=A5=E8=BD=AC=E5=86=99=E8=AF=AD=E8=A8=80=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=A4=8D=E9=80=89=E8=8A=AF=E7=89=87=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=A4=9A=E9=80=89=E8=AF=AD=E4=B9=89=E4=B8=8D=E5=86=8D=E4=BC=AA?= =?UTF-8?q?=E8=A3=85=E6=88=90=E5=88=86=E6=AE=B5=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:languages 是字符串数组(默认中文 + English 同时选中),却用分段控件 渲染;两个选项能同时点亮时,用户分不清这是单选还是开关、点了会发生什么 - 改为两个彼此独立的复选芯片:16px 勾选框(选中填强调色并打勾)、8px 间距、 40px 高、10px 圆角;选中浅蓝底 + 强调色描边,未选中白底常规描边 - 语义补齐 role="checkbox" + aria-checked,分组保留 aria-label - 不加任何小字提示,文案仍是「中文 / English」;行为不变,至少保留一种语言 - 上方「转写服务」「录音来源」保持分段控件(真单选),两种形态天然区分 --- .../src/components/pages/SettingsPage.css | 68 +++++++++++++++++++ .../src/components/pages/SettingsPage.tsx | 27 +++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/pages/SettingsPage.css b/apps/desktop/src/renderer/src/components/pages/SettingsPage.css index 3e306931..725a4df4 100644 --- a/apps/desktop/src/renderer/src/components/pages/SettingsPage.css +++ b/apps/desktop/src/renderer/src/components/pages/SettingsPage.css @@ -1827,6 +1827,74 @@ background: var(--surface-alt); } +/* 转写语言:多选。分段控件是单选语义,两个都能同时点亮时用户分不清含义, + 改成彼此分开、带勾选框的芯片——靠形状和勾选状态说明“可多选”,不加任何小字。 */ +.settings-page .language-toggles { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.settings-page .language-toggle { + display: inline-flex; + min-height: var(--settings-control-height); + align-items: center; + gap: 8px; + padding: 0 14px; + border: 1px solid var(--border); + border-radius: var(--settings-control-radius); + background: var(--surface); + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 600; + white-space: nowrap; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} + +.settings-page .language-toggle:hover { + border-color: var(--border-strong); + background: var(--hover); +} + +.settings-page .language-toggle[data-active='true'] { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent-strong); +} + +.settings-page .language-toggle:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.settings-page .language-toggle-box { + display: inline-grid; + width: 16px; + height: 16px; + flex: 0 0 16px; + place-items: center; + border: 1.5px solid var(--border-strong); + border-radius: 5px; + background: var(--surface); + color: transparent; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.settings-page .language-toggle[data-active='true'] .language-toggle-box { + border-color: var(--accent); + background: var(--accent); + color: #fff; +} + +.settings-page .language-toggle-box svg { + width: 11px; + height: 11px; + stroke-width: 3.2; +} + /* 卡片:16px 圆角 + 柔和阴影 + 更松的行内边距,撑出呼吸感。 注意只描真实卡片(section / .reality-settings-section):models 与 token-usage 两组只是透明栅格容器,给它们加阴影会凭空多出一个矩形。 */ diff --git a/apps/desktop/src/renderer/src/components/pages/SettingsPage.tsx b/apps/desktop/src/renderer/src/components/pages/SettingsPage.tsx index 7fb46a0b..11fe89fb 100644 --- a/apps/desktop/src/renderer/src/components/pages/SettingsPage.tsx +++ b/apps/desktop/src/renderer/src/components/pages/SettingsPage.tsx @@ -4,6 +4,7 @@ import { Brain, Camera, CalendarClock, + Check, Cloud, LoaderCircle, Languages, @@ -924,10 +925,32 @@ export function SettingsPage({ onStartFullOnboarding }: { onStartFullOnboarding?
{t('surface:settings.transcriptionLanguages')}
-
+ {/* 多选:用带勾选框的独立芯片表达,不用分段控件(那是单选语义)。 */} +
{([['zh', 'surface:settings.chinese'], ['en', 'surface:settings.english']] as const).map(([value, label]) => { const active = realitySettings.languages.includes(value) - return + return ( + + ) })}
From 50a8f8d7599e3abb239c0fee1cdff6f5dfce040f Mon Sep 17 00:00:00 2001 From: zengjiayi819 Date: Wed, 21 Oct 2026 10:01:50 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat(ctxroom):=20=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=A7=BB=E5=8A=A8=E5=88=B0=E5=85=B6=E4=BB=96?= =?UTF-8?q?=20Room=E2=80=94=E2=80=94=E2=8B=AF=20=E8=8F=9C=E5=8D=95/?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=9C=80=E8=BF=91=E7=9B=AE=E6=A0=87=E6=88=BF?= =?UTF-8?q?/=E4=B8=A4=E6=88=BF=E6=B8=85=E5=8D=95=E4=B8=8E=E7=9F=A5?= =?UTF-8?q?=E8=AF=86=E6=8A=95=E5=BD=B1=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 产物行 ⋯ 与文档编辑器 ⋯ 新增「移动到其他 Room」:目标房支持搜索与最近使用, 产物库工具条可多选批量迁移。 - 归属只迁移 room_doc_links(文档 id/版本/评论与深链不变),并向源房广播 document.moved、向目标房广播 document.changed,两侧清单实时收敛。 - knowledge 同步迁移 room_source_memberships / room_entity_mentions / room_entity_facts 与 route_decisions(防启动回填复活旧房),重算 Room 关系、 重排搜索索引并触发两房概览再生。 - 产物界面样式整编:控件 36–40px、按键与弹窗 10–18px 圆角、柔和分层阴影; 修复「新建产物」浮层定位偏移、压住工具栏与文字竖排。 - 补充 gateway 移动/批量/REST 契约测试与桌面弹层、多选、工具条结构测试。 --- .../main/gateway/document-gateway-bridge.ts | 16 + apps/desktop/src/main/index.ts | 4 + apps/desktop/src/preload/index.ts | 2 + .../context-room/ported/ContextRoom.css | 505 ++++++++++++++-- .../components/MoveDocumentToRoomDialog.tsx | 259 +++++++++ .../detail-editor/TiptapDocumentActions.tsx | 23 +- .../detail-panels/ArtifactLibraryPane.tsx | 540 +++++++++++------- .../ported/hooks/useRoomDocuments.ts | 27 +- .../src/i18n/locales/en-US/contextRoom.json | 28 + .../src/i18n/locales/zh-CN/contextRoom.json | 28 + apps/desktop/src/shared/sources.ts | 4 + .../context-room-resource-import.test.tsx | 84 +++ .../move-document-to-room-dialog.test.tsx | 157 +++++ .../src/modules/documents/core/index.ts | 1 + .../modules/documents/core/move-service.ts | 162 ++++++ apps/gateway/src/modules/documents/routes.ts | 58 ++ apps/gateway/src/modules/documents/service.ts | 56 ++ apps/gateway/src/modules/knowledge/service.ts | 136 +++++ apps/gateway/src/server/create-server.ts | 9 + apps/gateway/tests/document-move.test.ts | 287 ++++++++++ packages/agent-contract/src/index.ts | 40 ++ 21 files changed, 2165 insertions(+), 261 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/context-room/ported/components/MoveDocumentToRoomDialog.tsx create mode 100644 apps/desktop/tests/move-document-to-room-dialog.test.tsx create mode 100644 apps/gateway/src/modules/documents/core/move-service.ts create mode 100644 apps/gateway/tests/document-move.test.ts diff --git a/apps/desktop/src/main/gateway/document-gateway-bridge.ts b/apps/desktop/src/main/gateway/document-gateway-bridge.ts index 157ead19..312554bc 100644 --- a/apps/desktop/src/main/gateway/document-gateway-bridge.ts +++ b/apps/desktop/src/main/gateway/document-gateway-bridge.ts @@ -18,6 +18,8 @@ import type { CompleteExternalDocumentPatchInput, ExternalDocumentProjectionBinding, ImportRoomDocumentInput, + MoveRoomDocumentsResult, + MovedRoomDocument, PreparedExternalDocumentPatch, ExternalDocumentSourceKind, PrepareExternalDocumentPatchInput, @@ -297,6 +299,20 @@ export class DocumentGatewayBridge { }, true) } + move(documentId: string, fromRoomId: string, roomId: string): Promise { + return this.request(`/v1/documents/${encodeURIComponent(documentId)}/move`, { + method: 'POST', + body: JSON.stringify({ fromRoomId, roomId }), + }, true) + } + + moveMany(documentIds: string[], fromRoomId: string, roomId: string): Promise { + return this.request('/v1/documents/move', { + method: 'POST', + body: JSON.stringify({ documentIds, fromRoomId, roomId }), + }, true) + } + delete(documentId: string): Promise { return this.request(`/v1/documents/${encodeURIComponent(documentId)}`, { method: 'DELETE' }) } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a63e38ac..3cd6983b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -452,6 +452,8 @@ const DOCUMENT_CHANNELS = { import: 'documents:import', save: 'documents:save', delete: 'documents:delete', + move: 'documents:move', + moveMany: 'documents:move-many', restore: 'documents:restore', deletePermanently: 'documents:delete-permanently', emptyTrash: 'documents:empty-trash', @@ -2713,6 +2715,8 @@ function registerDocumentHandlers( if (vaultSyncDeps) await trashDocumentInVault(vaultSyncDeps, documentId) return deleted }, + move: (_event, documentId, fromRoomId, roomId) => bridge.move(documentId, fromRoomId, roomId), + moveMany: (_event, documentIds, fromRoomId, roomId) => bridge.moveMany(documentIds, fromRoomId, roomId), restore: (_event, documentId) => bridge.restore(documentId), deletePermanently: async (_event, documentId) => { if (vaultSyncDeps) await trashDocumentInVault(vaultSyncDeps, documentId) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 0f827e5f..e28b41e1 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -821,6 +821,8 @@ const api: NxcoreDesktopApi = { import: (input) => invoke('documents:import', input), save: (documentId, input) => invoke('documents:save', documentId, input), delete: (documentId) => invoke('documents:delete', documentId), + move: (documentId, fromRoomId, roomId) => invoke('documents:move', documentId, fromRoomId, roomId), + moveMany: (documentIds, fromRoomId, roomId) => invoke('documents:move-many', documentIds, fromRoomId, roomId), restore: (documentId) => invoke('documents:restore', documentId), deletePermanently: (documentId) => invoke('documents:delete-permanently', documentId), emptyTrash: (roomId) => invoke('documents:empty-trash', roomId), diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css b/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css index 765cc80a..7d240df6 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css +++ b/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css @@ -3690,15 +3690,18 @@ --cr-border: #e5e7eb; --cr-blue: #408cf0; --cr-blue-soft: rgba(64, 140, 240, 0.14); - z-index: 1050; + z-index: 60; + box-sizing: border-box; width: min(320px, calc(100vw - 24px)); + min-width: min(200px, calc(100vw - 24px)); + max-width: calc(100vw - 24px); padding: 20px; - border: 1px solid var(--cr-border); - border-radius: 16px; + border: 1px solid rgba(17, 24, 39, 0.06); + border-radius: 18px; background: var(--cr-surface); box-shadow: - 0 24px 70px rgba(21, 26, 34, 0.24), - 0 2px 8px rgba(21, 26, 34, 0.06); + 0 24px 60px rgba(17, 24, 39, 0.16), + 0 6px 18px rgba(17, 24, 39, 0.06); color: var(--cr-text); font-family: 'Context Room Inter', 'HarmonyOS Sans SC', 'PingFang SC', sans-serif; } @@ -3714,9 +3717,10 @@ .context-room-document-create-popover input { width: 100%; height: 40px; - padding: 0 12px; + padding: 0 14px; border: 1px solid var(--cr-border); - border-radius: 10px; + border-radius: 12px; + line-height: 1; outline: none; background: var(--cr-surface-secondary); color: var(--cr-text); @@ -3744,7 +3748,7 @@ gap: 7px; margin-top: 12px; border: 1px solid var(--cr-border); - border-radius: 10px; + border-radius: 12px; background: var(--cr-surface); color: var(--cr-blue); font-size: 12.5px; @@ -3786,11 +3790,12 @@ } .context-room-document-create-popover footer button { - min-width: 64px; - height: 34px; - padding: 0 14px; + min-width: 72px; + height: 38px; + padding: 0 16px; border: 1px solid var(--cr-border); - border-radius: 9px; + border-radius: 10px; + line-height: 1; background: var(--cr-surface); color: var(--cr-muted); font-size: 12.5px; @@ -3894,30 +3899,45 @@ .context-room-artifact-toolbar { display: flex; flex: 0 0 auto; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; - padding: 10px 10px 8px; + row-gap: 8px; + padding: 12px 12px 10px; border-bottom: 1px solid var(--cr-border); } +/* 工具条右侧动作组:整体不可压缩,窄面板时整组换行而不是把「新建产物」挤成竖排。 */ +.context-room-artifact-toolbar-actions { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 6px; + margin-left: auto; +} + /* 统一下拉筛选(FilterSelect):胶囊触发器 + 自绘菜单;选中非默认值转蓝。 */ .context-room-filter-select { display: inline-flex; - max-width: 180px; - height: 26px; + max-width: 190px; + height: 36px; align-items: center; - gap: 5px; - padding: 0 9px 0 12px; + gap: 6px; + padding: 0 12px 0 14px; border: 1px solid var(--cr-border); - border-radius: 999px; + border-radius: 10px; background: var(--cr-surface); color: var(--cr-muted); - font-size: 11px; + font-size: 12px; font-family: inherit; + line-height: 1; white-space: nowrap; cursor: pointer; - transition: border-color 0.15s ease, color 0.15s ease; + transition: border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease; } .context-room-filter-select:hover { @@ -3938,8 +3958,8 @@ .context-room-filter-select svg { flex: none; - width: 12px; - height: 12px; + width: 14px; + height: 14px; color: var(--cr-tertiary); } @@ -3956,22 +3976,26 @@ /* 下拉菜单弹层:与卡片菜单同底(card-menu 提供白底/阴影/变量域),补齐布局与选中态。 */ .context-room-filter-menu { --cr-blue: #3d6ff6; - min-width: 136px; - padding: 5px; - border: 1px solid var(--cr-border, #e5e7eb); - border-radius: 10px; + min-width: 150px; + padding: 7px; + border: 1px solid rgba(17, 24, 39, 0.06); + border-radius: 12px; + box-shadow: + 0 16px 40px rgba(17, 24, 39, 0.14), + 0 4px 12px rgba(17, 24, 39, 0.05); animation: context-room-filter-menu-in 0.16s ease; } .context-room-filter-menu-item { display: flex; + min-height: 36px; align-items: center; - gap: 8px; - padding: 6px 8px; - border-radius: 6px; + gap: 9px; + padding: 8px 10px; + border-radius: 8px; background: transparent; color: #1f1f1f; - font-size: 12px; + font-size: 12.5px; cursor: pointer; outline: none; user-select: none; @@ -4014,30 +4038,40 @@ .context-room-artifact-new { display: inline-flex; + min-width: 92px; + height: 36px; + flex: 0 0 auto; align-items: center; - gap: 4px; - padding: 3px 9px; + justify-content: center; + gap: 6px; + padding: 0 14px; border: 1px solid var(--cr-border); - border-radius: 6px; + border-radius: 10px; + background: var(--cr-surface); color: var(--cr-text); - font-size: 11px; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + transition: border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease; } .context-room-artifact-new:hover { border-color: var(--cr-blue); + background: var(--cr-blue-soft); color: var(--cr-blue); } .context-room-artifact-new svg { - width: 12px; - height: 12px; + width: 14px; + height: 14px; } .context-room-artifact-list { min-height: 0; flex: 1; overflow-y: auto; - padding: 6px 8px 12px; + padding: 8px 10px 14px; } .context-room-artifact-row { @@ -4050,12 +4084,14 @@ display: flex; width: 100%; min-width: 0; + min-height: 44px; align-items: center; - gap: 9px; - padding: 8px 26px 8px 8px; - border-radius: 8px; + gap: 10px; + padding: 8px 34px 8px 10px; + border-radius: 10px; text-align: left; cursor: pointer; + transition: background-color 0.12s ease; } .context-room-artifact-item:hover { @@ -4082,19 +4118,19 @@ .context-room-artifact-ico { display: inline-flex; - width: 30px; - height: 30px; + width: 34px; + height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; - border-radius: 7px; + border-radius: 9px; background: var(--cr-surface-sunken); color: var(--cr-muted); } .context-room-artifact-ico svg { - width: 15px; - height: 15px; + width: 16px; + height: 16px; } .context-room-artifact-body { @@ -4114,7 +4150,7 @@ .context-room-artifact-body b { color: var(--cr-text); - font-size: 12px; + font-size: 12.5px; font-weight: 500; } @@ -4131,7 +4167,7 @@ } .context-room-artifact-tag { - padding: 1px 6px; + padding: 2px 8px; border: 1px solid var(--cr-border); border-radius: 999px; color: var(--cr-muted); @@ -4147,9 +4183,9 @@ .context-room-artifact-acts { position: absolute; top: 50%; - right: 6px; + right: 8px; display: inline-flex; - gap: 2px; + gap: 4px; opacity: 0; transition: opacity 0.15s ease; } @@ -15338,6 +15374,7 @@ button.eg-walk.is-next:hover { /* 新建产物:类型选择网格(轻文档 / Word / PPT / Excel)。 */ .context-room-document-create-popover.is-chooser { width: min(400px, calc(100vw - 24px)); + min-width: min(300px, calc(100vw - 24px)); } .context-room-document-create-types { @@ -15351,9 +15388,9 @@ button.eg-walk.is-next:hover { align-items: center; gap: 10px; min-width: 0; - padding: 12px 14px; + padding: 13px 15px; border: 1px solid var(--cr-border); - border-radius: 10px; + border-radius: 12px; background: var(--cr-surface); color: var(--cr-text); cursor: pointer; @@ -15372,8 +15409,8 @@ button.eg-walk.is-next:hover { } .context-room-document-create-type > svg { - width: 18px; - height: 18px; + width: 20px; + height: 20px; flex: none; color: var(--cr-blue); } @@ -15388,6 +15425,7 @@ button.eg-walk.is-next:hover { .context-room-document-create-type-body b { font-size: 13px; font-weight: 600; + white-space: nowrap; } .context-room-document-create-type-body small { @@ -15805,3 +15843,364 @@ button.eg-walk.is-next:hover { right: 0; text-align: center; } + +/* ── 产物行 ⋯ 操作菜单(移动到其他 Room / 删除) ── */ +.context-room-artifact-acts .context-room-resource-more { + display: inline-flex; + width: 26px; + height: 26px; + align-items: center; + justify-content: center; + border-radius: 8px; + color: var(--cr-muted); + opacity: 1; + transition: + background-color 120ms ease, + color 120ms ease; +} + +.context-room-artifact-acts .context-room-resource-more:hover:not(:disabled), +.context-room-artifact-acts .context-room-resource-more:focus-visible { + background: var(--cr-surface-hover); + color: var(--cr-text); +} + +.context-room-artifact-acts .context-room-resource-more svg { + width: 16px; + height: 16px; +} + +/* ── 移动到其他 Room 目标选择器 ── */ +.context-room-move-dialog { + display: flex; + width: min(480px, 100%); + max-width: 100%; + flex-direction: column; + gap: 14px; + margin: 0 auto; + padding: 22px 20px 20px; +} + +.context-room-move-dialog header { + display: flex; + align-items: flex-start; + gap: 12px; + padding-right: 44px; +} + +.context-room-move-dialog h2 { + margin: 0; + color: var(--cr-text); + font-size: 16px; + font-weight: 600; + line-height: 1.35; +} + +.context-room-move-dialog header p { + margin: 3px 0 0; + color: var(--cr-muted); + font-size: 12px; + line-height: 1.5; +} + +.context-room-move-symbol { + display: inline-flex; + width: 40px; + height: 40px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border-radius: 12px; + background: var(--cr-blue-soft); + color: var(--cr-blue); +} + +.context-room-move-symbol svg { + width: 19px; + height: 19px; +} + +.context-room-move-search { + display: flex; + height: 40px; + align-items: center; + gap: 9px; + padding: 0 13px; + border: 1px solid var(--cr-border); + border-radius: 12px; + background: var(--cr-surface); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.context-room-move-search:focus-within { + border-color: var(--cr-blue); + box-shadow: 0 0 0 3px var(--cr-blue-soft); +} + +.context-room-move-search svg { + width: 15px; + height: 15px; + flex: 0 0 auto; + color: var(--cr-tertiary); +} + +.context-room-move-search input { + width: 100%; + min-width: 0; + border: 0; + outline: none; + background: transparent; + color: var(--cr-text); + font-size: 13px; + line-height: 1; +} + +.context-room-move-list { + display: flex; + max-height: min(320px, 46vh); + flex-direction: column; + gap: 2px; + overflow-y: auto; + padding: 6px; + border: 1px solid var(--cr-border); + border-radius: 12px; + background: var(--cr-surface); +} + +.context-room-move-group { + display: flex; + align-items: center; + gap: 6px; + margin: 8px 8px 4px; + color: var(--cr-tertiary); + font-size: 11px; +} + +.context-room-move-group svg { + width: 11px; + height: 11px; +} + +.context-room-move-room { + display: flex; + width: 100%; + min-height: 40px; + align-items: center; + padding: 9px 12px; + border-radius: 9px; + text-align: left; + transition: background-color 0.12s ease; +} + +.context-room-move-room:hover { + background: var(--cr-surface-hover); +} + +.context-room-move-room[data-selected] { + background: var(--cr-blue-soft); +} + +.context-room-move-room-body { + display: flex; + min-width: 0; + flex-direction: column; + gap: 1px; +} + +.context-room-move-room-body b { + overflow: hidden; + color: var(--cr-text); + font-size: 13px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.context-room-move-room[data-selected] .context-room-move-room-body b { + color: var(--cr-blue); +} + +.context-room-move-room-body small { + color: var(--cr-tertiary); + font-size: 11px; +} + +.context-room-move-hint { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + padding: 10px; + color: var(--cr-tertiary); + font-size: 12px; +} + +.context-room-move-hint svg { + width: 13px; + height: 13px; +} + +.context-room-move-error { + margin: 0; + color: #b54732; + font-size: 11px; + line-height: 1.5; +} + +.context-room-move-dialog footer { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.context-room-action-confirm-note { + margin: 0; + color: var(--cr-tertiary); + font-size: 11px; + line-height: 1.5; +} + +/* 产物相关的弹窗:16px 大圆角 + 柔和分层阴影(rounded-2xl 档)。 */ +.context-room-dialog-content.context-room-move-dialog-shell { + width: min(520px, calc(100vw - 32px)); + border: 1px solid rgba(17, 24, 39, 0.06); + border-radius: 16px; + box-shadow: + 0 24px 60px rgba(17, 24, 39, 0.16), + 0 6px 18px rgba(17, 24, 39, 0.06); +} + +.context-room-dialog-content.context-room-artifact-delete-shell { + width: min(480px, calc(100vw - 32px)); + border: 1px solid rgba(17, 24, 39, 0.06); + border-radius: 16px; + box-shadow: + 0 24px 60px rgba(17, 24, 39, 0.16), + 0 6px 18px rgba(17, 24, 39, 0.06); +} + +.context-room-artifact-delete-shell .context-room-action-confirm { + width: 100%; +} + +/* 弹窗底部按钮统一 40px 高、12px 圆角,图标/文字垂直居中。 */ +.context-room-move-dialog footer button, +.context-room-artifact-delete-shell .context-room-action-confirm footer button { + min-height: 40px; + padding: 0 16px; + border-radius: 12px; + font-size: 13px; + line-height: 1; +} + +/* ── 产物库多选整理 ── */ +.context-room-artifact-select-toggle, +.context-room-artifact-selection-move, +.context-room-artifact-selection-cancel { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 13px; + border: 1px solid var(--cr-border); + border-radius: 10px; + background: var(--cr-surface); + color: var(--cr-text); + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + transition: border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease; +} + +.context-room-artifact-select-toggle:hover, +.context-room-artifact-selection-cancel:hover { + border-color: var(--cr-blue); + color: var(--cr-blue); +} + +.context-room-artifact-select-toggle svg, +.context-room-artifact-selection-move svg { + width: 14px; + height: 14px; +} + +.context-room-artifact-selection { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.context-room-artifact-selection > span { + color: var(--cr-muted); + font-size: 12px; + white-space: nowrap; +} + +.context-room-artifact-selection-move { + border-color: var(--cr-blue); + background: var(--cr-blue-soft); + color: var(--cr-blue); +} + +.context-room-artifact-selection-move:disabled { + border-color: var(--cr-border); + background: transparent; + color: var(--cr-tertiary); + cursor: not-allowed; +} + +.context-room-artifact-check { + display: inline-flex; + width: 18px; + height: 18px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + margin-left: 6px; + border: 1px solid var(--cr-border); + border-radius: 5px; + background: var(--cr-surface); + color: #fff; +} + +.context-room-artifact-check[aria-checked='true'] { + border-color: var(--cr-blue); + background: var(--cr-blue); +} + +.context-room-artifact-check svg { + width: 12px; + height: 12px; +} + +.context-room-artifact-row[data-selecting] .context-room-artifact-item { + padding-left: 4px; +} + +/* 产物行 ⋯ 菜单:12px 圆角 + 柔和阴影 + 36px 行高,与浮层/弹窗同一视觉档。 */ +.context-room-artifact-menu { + z-index: 60; + min-width: 200px; + padding: 8px; + border-color: rgba(17, 24, 39, 0.06); + border-radius: 12px; + box-shadow: + 0 16px 40px rgba(17, 24, 39, 0.14), + 0 4px 12px rgba(17, 24, 39, 0.05); +} + +.context-room-artifact-menu [role='menuitem'] { + min-height: 36px; + gap: 9px; + padding: 8px 10px; + border-radius: 8px; + font-size: 12.5px; +} + +.context-room-artifact-menu svg { + width: 15px; + height: 15px; +} diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/MoveDocumentToRoomDialog.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/MoveDocumentToRoomDialog.tsx new file mode 100644 index 00000000..cac2e2ab --- /dev/null +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/MoveDocumentToRoomDialog.tsx @@ -0,0 +1,259 @@ +import type { MovedRoomDocument } from '@nxcore/agent-contract' +import { Clock, FolderInput, LoaderCircle, Search } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +import { showToast } from '@/state/toast' +import { useLocale } from '../../../../i18n/LocaleContext' +import { ReferenceDialog } from './shared' + +export interface MovableDocumentSummary { + id: string + title: string + /** 文档当前所属 Room(源房)。 */ + roomId: string +} + +interface RoomOption { + id: string + title: string + kind?: string +} + +const RECENT_KEY = 'everroom:move-document-recent-rooms' +const RECENT_LIMIT = 5 + +function readRecentRoomIds(): string[] { + try { + const raw = window.localStorage.getItem(RECENT_KEY) + if (!raw) return [] + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) + ? parsed.filter((value): value is string => typeof value === 'string').slice(0, RECENT_LIMIT) + : [] + } catch { + return [] + } +} + +function rememberRecentRoomId(roomId: string): void { + try { + const next = [roomId, ...readRecentRoomIds().filter((id) => id !== roomId)].slice(0, RECENT_LIMIT) + window.localStorage.setItem(RECENT_KEY, JSON.stringify(next)) + } catch { + // 隐私模式/配额受限时静默降级:最近使用只是快捷入口,不阻断移动。 + } +} + +/** + * 「移动到其他 Room」目标选择器:搜索过滤 + 最近使用置顶 + 单选确认。 + * + * 房间清单每次打开都从网关快照取(避免本地快照滞后),排除文档当前所在房。 + * 批量(多选)与单个走同一弹层:documents.length > 1 时调用批量移动接口。 + */ +export function MoveDocumentToRoomDialog({ + open, + onOpenChange, + documents, + onMoved, +}: { + open: boolean + onOpenChange: (open: boolean) => void + documents: MovableDocumentSummary[] + onMoved?: (moved: MovedRoomDocument[]) => void +}) { + const { t } = useLocale() + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState(null) + const [query, setQuery] = useState('') + const [selectedRoomId, setSelectedRoomId] = useState(null) + const [moving, setMoving] = useState(false) + const [moveError, setMoveError] = useState(null) + + const sourceRoomId = documents[0]?.roomId ?? null + const documentKey = documents.map((document) => document.id).join('\u0000') + + useEffect(() => { + if (!open) return + setQuery('') + setSelectedRoomId(null) + setMoveError(null) + setLoadError(null) + const api = window.nxcore?.contextRooms + if (!api) { + setRooms([]) + setLoadError(t('contextRoom:moveDocument.roomsUnavailable')) + return + } + let cancelled = false + setLoading(true) + void api.list() + .then((snapshot) => { + if (cancelled) return + setRooms((snapshot?.rooms ?? []).map((room) => ({ + id: room.id, + title: room.title, + kind: room.kind, + }))) + }) + .catch((error: unknown) => { + if (cancelled) return + setLoadError(error instanceof Error ? error.message : t('contextRoom:moveDocument.roomsUnavailable')) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [open, documentKey]) + + const candidates = useMemo( + () => rooms.filter((room) => room.id !== sourceRoomId), + [rooms, sourceRoomId], + ) + const keyword = query.trim().toLocaleLowerCase() + const matches = useMemo( + () => (keyword + ? candidates.filter((room) => + room.title.toLocaleLowerCase().includes(keyword) + || (room.kind ?? '').toLocaleLowerCase().includes(keyword)) + : candidates), + [candidates, keyword], + ) + const recentIds = open ? readRecentRoomIds() : [] + const recentRooms = matches.filter((room) => recentIds.includes(room.id)) + const otherRooms = matches.filter((room) => !recentIds.includes(room.id)) + const targetRoom = rooms.find((room) => room.id === selectedRoomId) ?? null + const isBatch = documents.length > 1 + + const submit = async () => { + const target = selectedRoomId + if (!target || documents.length === 0) return + const api = window.nxcore?.documents + if (!api) { + setMoveError(t('contextRoom:moveDocument.documentsUnavailable')) + return + } + setMoving(true) + setMoveError(null) + try { + const moved = documents.length > 1 + ? (await api.moveMany(documents.map((document) => document.id), documents[0]!.roomId, target)).items + : [await api.move(documents[0]!.id, documents[0]!.roomId, target)] + rememberRecentRoomId(target) + const roomTitle = rooms.find((room) => room.id === target)?.title ?? target + showToast({ + title: t('contextRoom:moveDocument.moved'), + message: isBatch + ? t('contextRoom:moveDocument.movedManyToRoom', { count: documents.length, room: roomTitle }) + : t('contextRoom:moveDocument.movedToRoom', { name: documents[0]!.title, room: roomTitle }), + }) + onMoved?.(moved) + onOpenChange(false) + } catch (error: unknown) { + setMoveError(error instanceof Error && error.message + ? error.message + : t('contextRoom:moveDocument.moveFailed')) + } finally { + setMoving(false) + } + } + + const renderRoom = (room: RoomOption) => ( + + ) + + return ( + +
+
+ + +
+

{t('contextRoom:moveDocument.title')}

+

+ {isBatch + ? t('contextRoom:moveDocument.subtitleBatch', { count: documents.length }) + : t('contextRoom:moveDocument.subtitle', { name: documents[0]?.title ?? '' })} +

+
+
+ + + +
+ {loading ? ( +

+

+ ) : null} + {loadError ?

{loadError}

: null} + {!loading && !loadError && recentRooms.length > 0 ? ( +

+

+ ) : null} + {!loading && !loadError ? recentRooms.map(renderRoom) : null} + {!loading && !loadError && otherRooms.length > 0 && recentRooms.length > 0 ? ( +

{t('contextRoom:moveDocument.allRooms')}

+ ) : null} + {!loading && !loadError ? otherRooms.map(renderRoom) : null} + {!loading && !loadError && matches.length === 0 ? ( +

{t('contextRoom:moveDocument.noMatch')}

+ ) : null} +
+ + {moveError ?

{moveError}

: null} + +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-editor/TiptapDocumentActions.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-editor/TiptapDocumentActions.tsx index bce4453f..d9082c66 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-editor/TiptapDocumentActions.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/detail-editor/TiptapDocumentActions.tsx @@ -1,7 +1,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import type { DocumentDiffResult, DocumentVersionSnapshot, RoomDocument } from '@nxcore/agent-contract' import type { Editor } from '@tiptap/react' -import { ChevronRight, Download, Ellipsis, ExternalLink, FileDown, FileText, MessageSquare, Trash2 } from 'lucide-react' +import { ChevronRight, Download, Ellipsis, ExternalLink, FileDown, FileText, FolderInput, MessageSquare, Trash2 } from 'lucide-react' import { useEffect, useState } from 'react' import { useLocale } from '../../../../../i18n/LocaleContext' @@ -9,6 +9,7 @@ import { showToast } from '../../../../../state/toast' import { ActionConfirmDialog } from '../../components/shared' import { createDocxBlob, docxExportFileName } from './tiptapDocxExport' import { exportEditorPdf } from './tiptapPdfExport' +import { MoveDocumentToRoomDialog } from '../MoveDocumentToRoomDialog' import { DocumentHistoryPanel } from './DocumentHistoryPanel' import { ExternalExportDialog } from './ExternalExportDialog' import { ExternalImportDialog } from './ExternalImportDialog' @@ -77,6 +78,7 @@ export function TiptapDocumentActions({ }) { const { t } = useLocale() const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [moveDialogOpen, setMoveDialogOpen] = useState(false) const [exportingPdf, setExportingPdf] = useState(false) const [exportProvider, setExportProvider] = useState<'feishu' | 'notion' | null>(null) const [importOpen, setImportOpen] = useState(false) @@ -151,6 +153,7 @@ export function TiptapDocumentActions({ } const deleteDisabled = writing || saving || !backendDocument || !onDeleteDocument + const moveDisabled = writing || saving || !backendDocument return (
)} + setMoveDialogOpen(true)} + > + + + (null); const [deletingArtifact, setDeletingArtifact] = useState(false); const [deleteError, setDeleteError] = useState(null); + // 移动目标:空数组 = 弹层关闭;单个/批量共用同一目标选择器。 + const [moveTargets, setMoveTargets] = useState>([]); + // 多选整理:开启后云文档行进入勾选态,工具条提供批量「移动到其他 Room」。 + const [selectionMode, setSelectionMode] = useState(false); + const [selectedDocumentIds, setSelectedDocumentIds] = useState>(() => new Set()); + + const exitSelection = () => { + setSelectionMode(false); + setSelectedDocumentIds(new Set()); + }; + + const toggleDocumentSelection = (documentId: string) => { + setSelectedDocumentIds((current) => { + const next = new Set(current); + if (next.has(documentId)) next.delete(documentId); + else next.add(documentId); + return next; + }); + }; + + const selectedDocuments = backendDocuments.filter((document) => selectedDocumentIds.has(document.id)); // 文稿页「+新建文档」进入本 Room 的一次性意图:自动展开「+新建产物」面板, // 直接停在创建类型选择界面(轻文档 / Word / PPT / Excel)。 @@ -273,140 +296,239 @@ export function ArtifactLibraryPane({ onChange={setFilter} ariaLabel={t('contextRoom:boardTab.library')} /> - { - if (!nextOpen && creatingDocument) return; - setCreateError(null); - setCreatePopoverOpen(nextOpen); - if (!nextOpen) { setNewDocumentTitle(''); setCreateType(null); } - }} - > - +
+ {selectionMode ? ( +
+ {t('contextRoom:artifactLibrary.selectedCount', { count: selectedDocumentIds.size })} + + +
+ ) : ( - - - - {createType === null ? ( -
- {CREATE_TYPES.map(({ id, label, hint, icon: Icon }) => ( - - ))} -
- ) : createType === 'doc' ? ( -
{ event.preventDefault(); void createDocument(); }}> - - setNewDocumentTitle(event.target.value)} - disabled={creatingDocument} - /> - { - const file = event.currentTarget.files?.[0]; - event.currentTarget.value = ''; - if (file) void importMarkdownDocument(file); - }} - /> - - {createError ? {createError} : null} -
- + + + + {createType === null ? ( +
+ {CREATE_TYPES.map(({ id, label, hint, icon: Icon }) => ( + + ))} +
+ ) : createType === 'doc' ? ( + { event.preventDefault(); void createDocument(); }}> + + setNewDocumentTitle(event.target.value)} + disabled={creatingDocument} + /> + { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ''; + if (file) void importMarkdownDocument(file); + }} + /> + - + +
+
+ ) : ( +
{ event.preventDefault(); dispatchOfficeCreate(createType); }}> + + setNewDocumentTitle(event.target.value)} + /> + { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ''; + if (files.length > 0) void importOfficeFiles(files); + }} + /> + - -
- ) : ( -
{ event.preventDefault(); dispatchOfficeCreate(createType); }}> - - setNewDocumentTitle(event.target.value)} - /> - { - const files = Array.from(event.currentTarget.files ?? []); - event.currentTarget.value = ''; - if (files.length > 0) void importOfficeFiles(files); - }} - /> - - {createError ? {createError} : null} - {t('contextRoom:artifactLibrary.officeCreateHint')} -
- - -
-
- )} - -
-
- + {createError ? {createError} : null} + {t('contextRoom:artifactLibrary.officeCreateHint')} +
+ + +
+ + )} + + + + +
+ 0} + onOpenChange={(open) => { + if (!open) setMoveTargets([]); + }} + documents={moveTargets} + onMoved={() => exitSelection()} + /> + { + if (!open && deletingArtifact) return; + if (!open) { + setPendingDelete(null); + setDeleteError(null); + } + }} + title={pendingDelete + ? pendingDelete.kind === 'file' + ? t('contextRoom:artifactLibrary.confirmDeleteFileName', { name: pendingDelete.name }) + : t('contextRoom:resource.confirmMovingDocumentNameToTrash', { name: pendingDelete.name }) + : ''} + contentClassName="context-room-artifact-delete-shell" + > +
+
+

{pendingDelete?.kind === 'file' + ? t('contextRoom:artifactLibrary.deleteFile') + : t('contextRoom:resource.moveToTrash')}

+
+
+

{pendingDelete?.kind === 'file' + ? t('contextRoom:artifactLibrary.confirmDeleteFile') + : t('contextRoom:resource.confirmMoveToTrash')}

+

+ {pendingDelete + ? pendingDelete.kind === 'file' + ? t('contextRoom:artifactLibrary.deleteFileIrreversible') + : t('contextRoom:resource.nameCanBeRestoredFromTrash', { name: pendingDelete.name }) + : null} +

+ {deleteError ?

{deleteError}

: null} +
+
+ + +
+
+
{visibleArtifacts.map(({ key, resource }) => { const office = resource.kind === 'knowledge-file' ? resource : null; @@ -414,12 +536,34 @@ export function ArtifactLibraryPane({ const backendDocument = cloud ? backendDocuments.find((document) => document.id === cloud.binding.docId) ?? null : null; const citations = backendDocument ? citationCounts.get(backendDocument.id) ?? 0 : 0; return ( -
+
+ {selectionMode && backendDocument ? ( + + ) : null} {office || backendDocument ? ( - {office ? ( - - ) : null} - { - if (!open && deletingArtifact && pendingDelete?.key === key) return; - setDeleteError(null); - setPendingDelete(open - ? office - ? { key, kind: 'file', fileId: office.fileId, name: resource.name } - : backendDocument - ? { key, kind: 'document', document: backendDocument, name: resource.name } - : null - : null); - }} - > - + + - - - + + -

{office ? t('contextRoom:artifactLibrary.confirmDeleteFile') : t('contextRoom:resource.confirmMoveToTrash')}

- {office - ? t('contextRoom:artifactLibrary.deleteFileIrreversible') - : t('contextRoom:resource.nameCanBeRestoredFromTrash', { name: resource.name })} - {deleteError ? {deleteError} : null} -
- - - - -
- -
-
-
+
) : null}
diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/hooks/useRoomDocuments.ts b/apps/desktop/src/renderer/src/components/context-room/ported/hooks/useRoomDocuments.ts index 2666516e..91c6a9e3 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/hooks/useRoomDocuments.ts +++ b/apps/desktop/src/renderer/src/components/context-room/ported/hooks/useRoomDocuments.ts @@ -1,4 +1,4 @@ -import type { DocumentEvent, RoomDocument, TiptapJsonContent } from '@nxcore/agent-contract' +import type { DocumentEvent, DocumentMovedPayload, RoomDocument, TiptapJsonContent } from '@nxcore/agent-contract' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import i18n from '@/i18n/i18next' @@ -36,6 +36,7 @@ const DOCUMENT_STATE_EVENT_TYPES = new Set([ 'document.changed', 'document.operation.changed', 'document.deleted', + 'document.moved', ]) export function useRoomDocuments(roomIds: string[]) { @@ -105,6 +106,30 @@ export function useRoomDocuments(roomIds: string[]) { const unsubscribeEvents = documents.onEvent(({ event }) => { if (!DOCUMENT_STATE_EVENT_TYPES.has(event.type)) return + // 跨 Room 移动:事件发往源房,先摘除再拉一次目标房基线(目标房另有 + // document.changed 增量,重复合并按 id 去重,幂等)。 + if (event.type === 'document.moved') { + const payload = event.payload && typeof event.payload === 'object' + ? event.payload as Partial + : null + setDocumentsByRoom((current) => ({ + ...current, + [event.roomId]: (current[event.roomId] ?? []).filter((candidate) => candidate.id !== event.documentId), + })) + setTrashedDocumentsByRoom((current) => ({ + ...current, + [event.roomId]: (current[event.roomId] ?? []).filter((candidate) => candidate.id !== event.documentId), + })) + setFocusedDocumentByRoom((current) => current[event.roomId] === event.documentId + ? { ...current, [event.roomId]: null } + : current) + invalidateDocumentBlockReferences({ roomId: event.roomId, documentId: event.documentId }) + if (typeof payload?.toRoomId === 'string' && payload.toRoomId) { + void refreshRoom(payload.toRoomId).catch(() => undefined) + } + return + } + const document = documentFromEvent(event) if (document) { upsertDocument(document) diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US/contextRoom.json b/apps/desktop/src/renderer/src/i18n/locales/en-US/contextRoom.json index 2883b4f3..b57dc8d7 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US/contextRoom.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US/contextRoom.json @@ -87,6 +87,13 @@ "artifactLibrary.agentGenerated": "AI generated", "artifactLibrary.revealInFolder": "Reveal in Folder", "artifactLibrary.revealFailed": "Could not reveal in folder", + "artifactLibrary.moreActions": "More actions", + "artifactLibrary.moveToAnotherRoom": "Move to another Room", + "artifactLibrary.select": "Select", + "artifactLibrary.cancelSelection": "Cancel selection", + "artifactLibrary.selectedCount": "{count} selected", + "artifactLibrary.moveSelected": "Move to another Room", + "artifactLibrary.selectDocument": "Select “{name}”", "artifactLibrary.newArtifact": "New artifact", "artifactLibrary.newLightDoc": "Light document", "artifactLibrary.newLightDocHint": "Markdown syntax, great for notes and co-editing", @@ -1355,6 +1362,25 @@ "selectionRewriteAgent.noReplacementText": "Agent did not return replacement text.", "selectionRewriteAgent.timedOut": "Agent rewrite timed out.", "shared.cancel": "Cancel", + "moveDocument.title": "Move to another Room", + "moveDocument.subtitle": "Choose the Room to move “{name}” into.", + "moveDocument.subtitleBatch": "Choose the Room to move {count} artifacts into.", + "moveDocument.searchPlaceholder": "Search Rooms", + "moveDocument.targetRoom": "Target Room", + "moveDocument.recent": "Recently used", + "moveDocument.allRooms": "All Rooms", + "moveDocument.noMatch": "No matching Rooms", + "moveDocument.loadingRooms": "Loading Rooms…", + "moveDocument.roomsUnavailable": "Room list is unavailable. Try again later.", + "moveDocument.documentsUnavailable": "Document service is unavailable. Try again later.", + "moveDocument.moved": "Artifact moved", + "moveDocument.movedToRoom": "“{name}” has been moved to “{room}”. Both Room lists and indexes are updated.", + "moveDocument.movedManyToRoom": "{count} artifacts moved to “{room}”. Both Room lists and indexes are updated.", + "moveDocument.moveFailed": "Move failed. Try again later.", + "moveDocument.cancel": "Cancel", + "moveDocument.move": "Move", + "moveDocument.moving": "Moving…", + "moveDocument.moveToRoom": "Move to “{room}”", "shared.closeDialog": "Close dialog", "shared.completeTheCurrentContextRoomAction": "Complete the current Context Room action", "shared.confirmAction": "Confirm action", @@ -1465,6 +1491,8 @@ "tiptapDocumentActions.importFromExternal": "Import from Feishu/Notion…", "tiptapDocumentActions.markdownExported": "Markdown exported", "tiptapDocumentActions.moreActions": "More actions", + "tiptapDocumentActions.moveToAnotherRoom": "Move to another Room", + "tiptapDocumentActions.saveBeforeMoving": "Finish saving before moving the document", "tiptapDocumentActions.moreDocumentActions": "More document actions", "tiptapDocumentActions.moveToTrash": "Move to Trash", "tiptapDocumentActions.nameWillBeMovedToTrashYouCan": "“{name}” will be moved to Trash. You can restore it later.", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN/contextRoom.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN/contextRoom.json index 287c3101..ecae489d 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN/contextRoom.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN/contextRoom.json @@ -87,6 +87,13 @@ "artifactLibrary.agentGenerated": "AI 生成", "artifactLibrary.revealInFolder": "在文件夹中显示", "artifactLibrary.revealFailed": "在文件夹中显示失败", + "artifactLibrary.moreActions": "更多操作", + "artifactLibrary.moveToAnotherRoom": "移动到其他 Room", + "artifactLibrary.select": "多选", + "artifactLibrary.cancelSelection": "退出多选", + "artifactLibrary.selectedCount": "已选 {count} 项", + "artifactLibrary.moveSelected": "移动到其他 Room", + "artifactLibrary.selectDocument": "选择「{name}」", "artifactLibrary.newArtifact": "新建产物", "artifactLibrary.newLightDoc": "轻文档", "artifactLibrary.newLightDocHint": "Markdown 语法,适合笔记与协作编辑", @@ -1355,6 +1362,25 @@ "selectionRewriteAgent.noReplacementText": "Agent 没有返回可替换的文本。", "selectionRewriteAgent.timedOut": "Agent 重写超时。", "shared.cancel": "取消", + "moveDocument.title": "移动到其他 Room", + "moveDocument.subtitle": "选择「{name}」要迁入的 Room。", + "moveDocument.subtitleBatch": "选择 {count} 个产物要迁入的 Room。", + "moveDocument.searchPlaceholder": "搜索 Room 名称", + "moveDocument.targetRoom": "目标 Room", + "moveDocument.recent": "最近使用", + "moveDocument.allRooms": "全部 Room", + "moveDocument.noMatch": "没有匹配的 Room", + "moveDocument.loadingRooms": "正在加载 Room…", + "moveDocument.roomsUnavailable": "Room 清单暂不可用,请稍后重试。", + "moveDocument.documentsUnavailable": "文档服务暂不可用,请稍后重试。", + "moveDocument.moved": "已移动产物", + "moveDocument.movedToRoom": "「{name}」已移动到「{room}」,两侧清单与索引已同步更新。", + "moveDocument.movedManyToRoom": "{count} 个产物已移动到「{room}」,两侧清单与索引已同步更新。", + "moveDocument.moveFailed": "移动失败,请稍后重试。", + "moveDocument.cancel": "取消", + "moveDocument.move": "移动", + "moveDocument.moving": "正在移动…", + "moveDocument.moveToRoom": "移动到「{room}」", "shared.closeDialog": "关闭对话框", "shared.completeTheCurrentContextRoomAction": "完成当前 Context Room 操作", "shared.confirmAction": "确认执行", @@ -1465,6 +1491,8 @@ "tiptapDocumentActions.importFromExternal": "从飞书/Notion 导入…", "tiptapDocumentActions.markdownExported": "已导出 Markdown", "tiptapDocumentActions.moreActions": "更多操作", + "tiptapDocumentActions.moveToAnotherRoom": "移动到其他 Room", + "tiptapDocumentActions.saveBeforeMoving": "保存完成后再移动文档", "tiptapDocumentActions.moreDocumentActions": "文档更多操作", "tiptapDocumentActions.moveToTrash": "移到回收站", "tiptapDocumentActions.nameWillBeMovedToTrashYouCan": "“{name}”将移到回收站,你仍可以在回收站中恢复。", diff --git a/apps/desktop/src/shared/sources.ts b/apps/desktop/src/shared/sources.ts index a5fbba24..1da36055 100644 --- a/apps/desktop/src/shared/sources.ts +++ b/apps/desktop/src/shared/sources.ts @@ -33,6 +33,8 @@ import type { RoomDuplicateCheckResult, RoomAppliedEntitiesResult, RoomOverviewProjection, + MovedRoomDocument, + MoveRoomDocumentsResult, RoomMergeOperation, RoomMergePreview, RoomMail, @@ -1487,6 +1489,8 @@ export interface NxcoreDesktopApi { import(input: ImportRoomDocumentInput): Promise save(documentId: string, input: SaveRoomDocumentInput): Promise delete(documentId: string): Promise + move(documentId: string, fromRoomId: string, roomId: string): Promise + moveMany(documentIds: string[], fromRoomId: string, roomId: string): Promise restore(documentId: string): Promise deletePermanently(documentId: string): Promise emptyTrash(roomId: string): Promise diff --git a/apps/desktop/tests/context-room-resource-import.test.tsx b/apps/desktop/tests/context-room-resource-import.test.tsx index 88064b17..cfa3f2ba 100644 --- a/apps/desktop/tests/context-room-resource-import.test.tsx +++ b/apps/desktop/tests/context-room-resource-import.test.tsx @@ -1,3 +1,4 @@ +/** @vitest-environment happy-dom */ import TestRenderer, { act } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' @@ -14,6 +15,37 @@ vi.mock('../src/renderer/src/i18n/LocaleContext', async (importOriginal) => { }) import { ArtifactLibraryPane } from '../src/renderer/src/components/context-room/ported/components/detail-panels/ArtifactLibraryPane' +import type { RoomDocument } from '@nxcore/agent-contract' + +const nativeDocument: RoomDocument = { + id: 'doc-artifact-1', + roomId: 'room-test', + title: '季度总结', + contentJson: { type: 'doc', content: [{ type: 'paragraph' }] }, + contentSchemaVersion: 1, + version: 2, + status: 'active', + origin: 'native', + activeTransactionId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +function renderArtifactLibraryWith(backendDocuments: RoomDocument[]) { + return TestRenderer.create( + {}} + onCreateDocument={vi.fn().mockResolvedValue(undefined)} + onDeleteDocument={() => Promise.resolve()} + onRestoreDocument={() => Promise.resolve()} + onDeleteDocumentPermanently={() => Promise.resolve()} + />, + ) +} function renderArtifactLibrary() { return TestRenderer.create( @@ -54,3 +86,55 @@ describe('产物库:文件导入入口只在新建产物弹层内', () => { expect(newArtifact).toBeTruthy() }) }) + +describe('产物库:产物操作菜单与多选整理', () => { + it('每个产物行都有 ⋯ 操作菜单入口(移动/删除收纳其中)', async () => { + let renderer: TestRenderer.ReactTestRenderer | null = null + await act(async () => { + renderer = renderArtifactLibraryWith([nativeDocument]) + }) + const more = renderer!.root.findAllByType('button') + .filter((node) => node.props.className === 'context-room-resource-more') + expect(more).toHaveLength(1) + // 行内不再常驻独立删除钮(删除收进 ⋯ 菜单) + expect(renderer!.root.findAllByType('button') + .some((node) => node.props.className === 'context-room-resource-delete')).toBe(false) + }) + + it('工具条右侧动作组同时容纳多选与新建产物(不被挤成竖排)', async () => { + let renderer: TestRenderer.ReactTestRenderer | null = null + await act(async () => { + renderer = renderArtifactLibrary() + }) + const actions = renderer!.root.findAllByProps({ className: 'context-room-artifact-toolbar-actions' }) + expect(actions).toHaveLength(1) + const buttons = actions[0]!.findAllByType('button') + expect(buttons.some((node) => node.props.className === 'context-room-artifact-new')).toBe(true) + expect(buttons.some((node) => node.props.className === 'context-room-artifact-select-toggle')).toBe(true) + }) + + it('多选模式勾选产物后批量移动入口可用', async () => { + let renderer: TestRenderer.ReactTestRenderer | null = null + await act(async () => { + renderer = renderArtifactLibraryWith([nativeDocument]) + }) + const toggle = renderer!.root.findAllByType('button') + .find((node) => node.props.className === 'context-room-artifact-select-toggle') + expect(toggle).toBeTruthy() + await act(async () => { + toggle!.props.onClick() + }) + + const checkbox = renderer!.root.findAllByType('button') + .find((node) => node.props.className === 'context-room-artifact-check') + expect(checkbox?.props['aria-checked']).toBe(false) + await act(async () => { + checkbox!.props.onClick() + }) + expect(renderer!.root.findAllByType('button') + .find((node) => node.props.className === 'context-room-artifact-check')?.props['aria-checked']).toBe(true) + const moveSelected = renderer!.root.findAllByType('button') + .find((node) => node.props.className === 'context-room-artifact-selection-move') + expect(moveSelected?.props.disabled).toBe(false) + }) +}) diff --git a/apps/desktop/tests/move-document-to-room-dialog.test.tsx b/apps/desktop/tests/move-document-to-room-dialog.test.tsx new file mode 100644 index 00000000..cee25b71 --- /dev/null +++ b/apps/desktop/tests/move-document-to-room-dialog.test.tsx @@ -0,0 +1,157 @@ +import TestRenderer, { act } from 'react-test-renderer' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { MovedRoomDocument, RoomDocument } from '@nxcore/agent-contract' + +// 无 DOM 环境:Radix Portal 不渲染,替换成透传容器只测弹窗内部状态机。 +vi.mock('../src/renderer/src/components/context-room/ported/components/shared', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ReferenceDialog: function MockReferenceDialog({ open, children }: { + open: boolean + children: ReactNode + }) { + return open ?
{children}
: null + }, + } +}) + +import { MoveDocumentToRoomDialog } from '../src/renderer/src/components/context-room/ported/components/MoveDocumentToRoomDialog' + +const document: RoomDocument = { + id: 'doc-1', + roomId: 'room-a', + title: '季度复盘', + contentJson: { type: 'doc', content: [] }, + contentSchemaVersion: 1, + version: 3, + status: 'active', + origin: 'native', + activeTransactionId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', +} + +const moved: MovedRoomDocument = { + documentId: 'doc-1', + fromRoomId: 'room-a', + toRoomId: 'room-b', + title: '季度复盘', + document: { ...document, roomId: 'room-b' }, +} + +function installWindow() { + const list = vi.fn().mockResolvedValue({ + rooms: [ + { id: 'room-a', title: '甲房', kind: '项目' }, + { id: 'room-b', title: '乙房', kind: '主题' }, + { id: 'room-c', title: '丙房', kind: '议题' }, + ], + deletedRooms: [], + updatedAt: null, + }) + const move = vi.fn().mockResolvedValue(moved) + const moveMany = vi.fn().mockResolvedValue({ items: [moved] }) + ;(globalThis as { window?: unknown }).window = { + nxcore: { contextRooms: { list }, documents: { move, moveMany } }, + localStorage: { + store: new Map(), + getItem(key: string) { + return this.store.get(key) ?? null + }, + setItem(key: string, value: string) { + this.store.set(key, value) + }, + }, + dispatchEvent: vi.fn(), + } + return { list, move, moveMany } +} + +function findButtonWithText(renderer: TestRenderer.ReactTestRenderer, text: string) { + return renderer.root.findAllByType('button').find((node) => + node.findAllByType('b').some((label) => label.children.join('') === text) + || node.children.some((child) => typeof child === 'string' && child.includes(text))) +} + +describe('移动到其他 Room 弹层', () => { + beforeEach(() => { + installWindow() + }) + + afterEach(() => { + delete (globalThis as { window?: unknown }).window + }) + + it('列出可迁入 Room、排除源房,选中后按目标房移动', async () => { + const api = installWindow() + const onMoved = vi.fn() + const onOpenChange = vi.fn() + let renderer: TestRenderer.ReactTestRenderer | null = null + await act(async () => { + renderer = TestRenderer.create( + , + ) + }) + + const buttons = renderer!.root.findAllByType('button') + const target = buttons.find((node) => + node.findAllByType('b').some((label) => label.children.join('') === '乙房')) + expect(target).toBeTruthy() + // 源房不出现在候选清单里 + expect(buttons.some((node) => + node.findAllByType('b').some((label) => label.children.join('') === '甲房'))).toBe(false) + + await act(async () => { + target!.props.onClick() + }) + + const confirm = renderer!.root.findAllByType('button') + .find((node) => node.props.className === 'context-room-primary') + expect(confirm).toBeTruthy() + await act(async () => { + confirm!.props.onClick() + }) + + expect(api.move).toHaveBeenCalledWith('doc-1', 'room-a', 'room-b') + expect(onMoved).toHaveBeenCalledWith([moved]) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('多个产物走批量移动接口', async () => { + const api = installWindow() + let renderer: TestRenderer.ReactTestRenderer | null = null + await act(async () => { + renderer = TestRenderer.create( + {}} + documents={[ + { id: 'doc-1', title: '一', roomId: 'room-a' }, + { id: 'doc-2', title: '二', roomId: 'room-a' }, + ]} + />, + ) + }) + + const target = findButtonWithText(renderer!, '丙房') + await act(async () => { + target!.props.onClick() + }) + const confirm = renderer!.root.findAllByType('button') + .find((node) => node.props.className === 'context-room-primary') + await act(async () => { + confirm!.props.onClick() + }) + + expect(api.moveMany).toHaveBeenCalledWith(['doc-1', 'doc-2'], 'room-a', 'room-c') + expect(api.move).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/apps/gateway/src/modules/documents/core/index.ts b/apps/gateway/src/modules/documents/core/index.ts index 6f1d53cb..ada0958f 100644 --- a/apps/gateway/src/modules/documents/core/index.ts +++ b/apps/gateway/src/modules/documents/core/index.ts @@ -1,6 +1,7 @@ export * from "./commit-service.js"; export * from "./content-engine.js"; export * from "./lifecycle-service.js"; +export * from "./move-service.js"; export * from "./query-service.js"; export * from "./repository.js"; export * from "./yjs-history-service.js"; diff --git a/apps/gateway/src/modules/documents/core/move-service.ts b/apps/gateway/src/modules/documents/core/move-service.ts new file mode 100644 index 00000000..471346c6 --- /dev/null +++ b/apps/gateway/src/modules/documents/core/move-service.ts @@ -0,0 +1,162 @@ +import type { MovedRoomDocument } from "@nxcore/agent-contract"; +import { and, eq } from "drizzle-orm"; +import type { GatewayDatabase } from "../../../infrastructure/database/client.js"; +import { + contextRooms, + documentRoomImports, + documents, + externalDocumentBindings, + roomDocumentLinks, +} from "../../../infrastructure/database/schema.js"; +import { DocumentServiceError } from "../errors.js"; +import { toRoomDocument } from "./repository.js"; + +/** + * Room 产物跨 Room 移动(restructure:产物放错房/主题更适合其它房)。 + * + * 归属的唯一事实源是 room_doc_links:移动 = 删源房链接 + 建目标房链接。 + * 文档正文、版本、评论、块引用都跟文档 id 走,不做副本;这样原房清单立即 + * 少一条、目标房清单立即多一条,且文档 id 保持不变(深链/引用不失效)。 + * + * 知识侧投影(room_source_memberships / room_entity_mentions / + * room_entity_facts / route_decisions)与搜索索引由 knowledge 模块的 + * document.moved 钩子同步,本服务不直接写知识表。 + */ +export class DocumentRoomMoveService { + constructor(private readonly db: GatewayDatabase) {} + + move(documentId: string, fromRoomId: string, targetRoomId: string): MovedRoomDocument { + const from = fromRoomId.trim(); + const to = targetRoomId.trim(); + if (!from || !to) throw new DocumentServiceError("INVALID_ROOM", "Room id is required", 400); + if (from === to) { + throw new DocumentServiceError("SAME_ROOM", "Document already belongs to this Room", 409); + } + this.requireActiveRoom(to); + + const moved = this.db.transaction((tx) => { + const row = tx.select().from(documents).where(eq(documents.id, documentId)).get(); + if (!row) throw new DocumentServiceError("NOT_FOUND", "Document not found", 404); + if (row.deletedAt) { + throw new DocumentServiceError( + "DOCUMENT_TRASHED", + "Restore the document from trash before moving it", + 409, + ); + } + if (row.activeTransactionId) { + throw new DocumentServiceError("DOCUMENT_BUSY", "Agent is writing this document", 409); + } + const sourceLink = tx.select().from(roomDocumentLinks) + .where(and(eq(roomDocumentLinks.roomId, from), eq(roomDocumentLinks.documentId, documentId))) + .get(); + if (!sourceLink) { + throw new DocumentServiceError( + "ROOM_MISMATCH", + "Document does not belong to the source Room", + 409, + ); + } + + const now = new Date(); + const targetLink = tx.select().from(roomDocumentLinks) + .where(and(eq(roomDocumentLinks.roomId, to), eq(roomDocumentLinks.documentId, documentId))) + .get(); + tx.delete(roomDocumentLinks) + .where(and(eq(roomDocumentLinks.roomId, from), eq(roomDocumentLinks.documentId, documentId))) + .run(); + if (targetLink) { + // 目标房已有链接(多房归属):源链接摘除即可,目标链接刷新为最近挂载。 + tx.update(roomDocumentLinks).set({ linkedAt: now }) + .where(and(eq(roomDocumentLinks.roomId, to), eq(roomDocumentLinks.documentId, documentId))) + .run(); + } else { + tx.insert(roomDocumentLinks).values({ + roomId: to, + documentId, + folderId: null, + linkedAt: now, + }).run(); + } + // 导入台账 / 外部投影绑定跟随归属,避免「移动后仍按源房同步」。 + tx.update(documentRoomImports).set({ roomId: to }) + .where(and(eq(documentRoomImports.roomId, from), eq(documentRoomImports.documentId, documentId))) + .run(); + tx.update(externalDocumentBindings).set({ roomId: to, updatedAt: now }) + .where(and(eq(externalDocumentBindings.roomId, from), eq(externalDocumentBindings.documentId, documentId))) + .run(); + + return { + documentId, + fromRoomId: from, + toRoomId: to, + title: row.title, + document: toRoomDocument(row, to), + } satisfies MovedRoomDocument; + }); + return moved; + } + + /** + * 批量移动:先整批预检(文档存在、未入回收站、无进行中的 Agent 写入、 + * 确实属于源房、目标房有效),再逐文档独立事务搬运。预检把「一半成功一半 + * 失败」压缩到数据库异常的极小概率;真遇到异常时已完成的项保持移动结果 + * (事件已广播,两侧清单自洽),调用方按错误提示重试剩余项即可。 + */ + moveMany(documentIds: string[], fromRoomId: string, targetRoomId: string): MovedRoomDocument[] { + const unique = [...new Set(documentIds.map((id) => id.trim()).filter(Boolean))]; + if (unique.length === 0) { + throw new DocumentServiceError("INVALID_DOCUMENT_IDS", "Select at least one document", 400); + } + if (unique.length > 100) { + throw new DocumentServiceError("TOO_MANY_DOCUMENTS", "Move at most 100 documents at once", 400); + } + const from = fromRoomId.trim(); + const to = targetRoomId.trim(); + if (!from || !to) throw new DocumentServiceError("INVALID_ROOM", "Room id is required", 400); + if (from === to) { + throw new DocumentServiceError("SAME_ROOM", "Document already belongs to this Room", 409); + } + this.requireActiveRoom(to); + for (const documentId of unique) this.assertMovable(documentId, from); + return unique.map((documentId) => this.move(documentId, from, to)); + } + + /** 预检(只读):批量入口在写入前一次性暴露可预期错误。 */ + private assertMovable(documentId: string, fromRoomId: string): void { + const row = this.db.select().from(documents).where(eq(documents.id, documentId)).get(); + if (!row) throw new DocumentServiceError("NOT_FOUND", "Document not found", 404); + if (row.deletedAt) { + throw new DocumentServiceError( + "DOCUMENT_TRASHED", + "Restore the document from trash before moving it", + 409, + ); + } + if (row.activeTransactionId) { + throw new DocumentServiceError("DOCUMENT_BUSY", "Agent is writing this document", 409); + } + const sourceLink = this.db.select({ documentId: roomDocumentLinks.documentId }) + .from(roomDocumentLinks) + .where(and(eq(roomDocumentLinks.roomId, fromRoomId), eq(roomDocumentLinks.documentId, documentId))) + .get(); + if (!sourceLink) { + throw new DocumentServiceError( + "ROOM_MISMATCH", + "Document does not belong to the source Room", + 409, + ); + } + } + + private requireActiveRoom(roomId: string): void { + const room = this.db.select({ + id: contextRooms.id, + lifecycle: contextRooms.lifecycle, + deletedAt: contextRooms.deletedAt, + }).from(contextRooms).where(eq(contextRooms.id, roomId)).get(); + if (!room || room.deletedAt || room.lifecycle !== "active") { + throw new DocumentServiceError("TARGET_ROOM_NOT_FOUND", "Target Room not found", 404); + } + } +} diff --git a/apps/gateway/src/modules/documents/routes.ts b/apps/gateway/src/modules/documents/routes.ts index c223ad95..df3e6f44 100644 --- a/apps/gateway/src/modules/documents/routes.ts +++ b/apps/gateway/src/modules/documents/routes.ts @@ -1,4 +1,5 @@ import type { FastifyPluginAsyncTypebox } from "@fastify/type-provider-typebox"; +import type { MoveRoomDocumentInput } from "@nxcore/agent-contract"; import { Type } from "@sinclair/typebox"; import { agentDocumentMarkdown } from "./agent-markdown.js"; import { documentBodyContent } from "./content-model.js"; @@ -6,6 +7,10 @@ import { DocumentServiceError, type DocumentService } from "./service.js"; import type { DocumentIndexBackfillReadTrigger } from "./index-backfill/read-trigger.js"; import type { TaskFolderService } from "./task-folders.js"; const IdParams = Type.Object({ id: Type.String({ minLength: 1, maxLength: 128 }) }); +const MoveRoomBody = Type.Object({ + fromRoomId: Type.String({ minLength: 1, maxLength: 128 }), + roomId: Type.String({ minLength: 1, maxLength: 128 }), +}); const JsonDocument = Type.Object({ type: Type.Literal("doc") }, { additionalProperties: true }); const BlockReference = Type.Object({ roomId: Type.String({ minLength: 1, maxLength: 128 }), @@ -304,6 +309,59 @@ export function documentRoutes( }, ); + // 产物移动到其它 Room:归属切换 + 两房清单事件 + 知识侧索引/记忆投影同步。 + app.post( + "/v1/documents/:id/move", + { + schema: { + tags: ["documents"], + params: IdParams, + body: MoveRoomBody, + }, + }, + async (request, reply) => { + try { + return await service.moveToRoom(request.params.id, request.body as MoveRoomDocumentInput); + } catch (error) { + if (error instanceof DocumentServiceError) { + return reply.code(error.statusCode).send(errorPayload(error)); + } + throw error; + } + }, + ); + + // 多选批量移动:同源房的一批文档迁入同一目标 Room。 + app.post( + "/v1/documents/move", + { + schema: { + tags: ["documents"], + body: Type.Object({ + documentIds: Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { + minItems: 1, + maxItems: 100, + }), + fromRoomId: Type.String({ minLength: 1, maxLength: 128 }), + roomId: Type.String({ minLength: 1, maxLength: 128 }), + }), + }, + }, + async (request, reply) => { + try { + return await service.moveManyToRoom(request.body.documentIds, { + fromRoomId: request.body.fromRoomId, + roomId: request.body.roomId, + }); + } catch (error) { + if (error instanceof DocumentServiceError) { + return reply.code(error.statusCode).send(errorPayload(error)); + } + throw error; + } + }, + ); + app.post( "/v1/documents/import", { diff --git a/apps/gateway/src/modules/documents/service.ts b/apps/gateway/src/modules/documents/service.ts index a7f63b2f..fb568a9f 100644 --- a/apps/gateway/src/modules/documents/service.ts +++ b/apps/gateway/src/modules/documents/service.ts @@ -12,6 +12,9 @@ import type { DocumentVersionSnapshot, DocumentDiffResult, ImportRoomDocumentInput, + MoveRoomDocumentInput, + MoveRoomDocumentsResult, + MovedRoomDocument, ResolveDocumentBlockReferencesInput, RoomDocument, SaveRoomDocumentInput, @@ -42,6 +45,7 @@ import { DocumentLifecycleService, DocumentQueryService, DocumentRepository, + DocumentRoomMoveService, YjsHistoryService, type AtomicDocumentCreateInput, type AtomicDocumentCommitInput, @@ -236,6 +240,7 @@ export class DocumentService { private readonly commitService: DocumentCommitService; private readonly queryService: DocumentQueryService; private readonly lifecycleService: DocumentLifecycleService; + private readonly moveService: DocumentRoomMoveService; private readonly yjsHistory: YjsHistoryService; /** @@ -253,6 +258,14 @@ export class DocumentService { */ resolveDocWriterDraft: DocWriterDraftResolver | null = null; + /** + * Room 产物移动完成后的知识侧同步钩子(create-server 装配注入): + * 迁移 room_source_memberships / room_entity_mentions / room_entity_facts / + * route_decisions 投影,重排搜索索引并触发两房概览再生成。 + * documents 模块不直接依赖 knowledge。 + */ + onDocumentMoved: ((moved: MovedRoomDocument) => void) | null = null; + constructor( private readonly db: GatewayDatabase, readonly broker: DocumentEventBroker, @@ -268,6 +281,7 @@ export class DocumentService { this.yjsHistory = new YjsHistoryService(); this.commitService = new DocumentCommitService(db, this.repository, this.contentEngine, {}, this.yjsHistory); this.queryService = new DocumentQueryService(db, this.repository, this.contentEngine); + this.moveService = new DocumentRoomMoveService(db); this.lifecycleService = new DocumentLifecycleService(db, this.repository, { trashed: (document) => this.publish( document.roomId, @@ -298,6 +312,48 @@ export class DocumentService { return this.queryService.list(roomId, trashed, origin); } + /** + * 单个产物移动到其它 Room:改属归属链接后,向源房广播 document.moved + * (清单摘除)与向目标房广播 document.changed(清单新增),再交知识侧 + * 钩子同步索引与记忆投影。 + */ + moveToRoom(documentId: string, input: MoveRoomDocumentInput): Promise { + return this.queue.enqueue(() => { + const moved = this.moveService.move(documentId, input.fromRoomId, input.roomId); + this.publishMoved(moved); + return moved; + }); + } + + /** 批量移动(同源房多选 → 同一目标房):逐条发事件,失败整体回滚到调用方提示。 */ + moveManyToRoom( + documentIds: string[], + input: MoveRoomDocumentInput, + ): Promise { + return this.queue.enqueue(() => { + const items = this.moveService.moveMany(documentIds, input.fromRoomId, input.roomId); + for (const moved of items) this.publishMoved(moved); + return { items }; + }); + } + + private publishMoved(moved: MovedRoomDocument): void { + this.publish(moved.fromRoomId, moved.documentId, null, "document.moved", { + documentId: moved.documentId, + fromRoomId: moved.fromRoomId, + toRoomId: moved.toRoomId, + title: moved.title, + }); + this.publish(moved.toRoomId, moved.documentId, null, "document.changed", { + document: moved.document, + }); + try { + this.onDocumentMoved?.(moved); + } catch { + // 知识侧同步失败不回滚移动(清单归属已正确),索引在下次重排时自愈。 + } + } + get(documentId: string): RoomDocument | null { return this.queryService.get(documentId); } diff --git a/apps/gateway/src/modules/knowledge/service.ts b/apps/gateway/src/modules/knowledge/service.ts index a2c4af1a..6f9d5450 100644 --- a/apps/gateway/src/modules/knowledge/service.ts +++ b/apps/gateway/src/modules/knowledge/service.ts @@ -14,6 +14,7 @@ import { ingestEvents, jobs, roomDocumentLinks, + roomEntityFacts, roomEntityMentions, roomSourceMemberships, roomWikis, @@ -1042,6 +1043,141 @@ export class KnowledgeService { this.pendingSchedules.set(document.id, { timer, version: document.version }); } + /** + * 产物跨 Room 移动后的知识侧同步(documents 模块 document.moved 钩子): + * 迁移来源成员关系、实体提及与事实投影,改写历史路由决策指向目标房(避免 + * 启动回填把旧房归属复活),重算 Room 关系图、重排搜索索引并触发两房概览再生。 + * 只做投影搬运,不复制文档本体。 + */ + handleDocumentMoved(input: { documentId: string; fromRoomId: string; toRoomId: string }): void { + const { documentId, fromRoomId, toRoomId } = input; + if (!documentId || !fromRoomId || !toRoomId || fromRoomId === toRoomId) return; + let movedMemberships = 0; + try { + this.db.transaction((tx) => { + const now = new Date(); + // 1) 来源成员关系:目标已有则折叠版本/权重,否则整行改挂。 + const sourceMemberships = tx.select().from(roomSourceMemberships).where(and( + eq(roomSourceMemberships.roomId, fromRoomId), + eq(roomSourceMemberships.sourceKind, "everroom-doc"), + eq(roomSourceMemberships.sourceId, documentId), + )).all(); + const targetMembership = tx.select().from(roomSourceMemberships).where(and( + eq(roomSourceMemberships.roomId, toRoomId), + eq(roomSourceMemberships.sourceKind, "everroom-doc"), + eq(roomSourceMemberships.sourceId, documentId), + )).get(); + for (const row of sourceMemberships) { + movedMemberships += 1; + if (targetMembership) { + tx.update(roomSourceMemberships).set({ + sourceVersion: Math.max(targetMembership.sourceVersion, row.sourceVersion), + sourceTitle: row.sourceTitle ?? targetMembership.sourceTitle, + effectiveWeight: Math.max(targetMembership.effectiveWeight, row.effectiveWeight), + trusted: targetMembership.trusted || row.trusted, + entityIndexed: targetMembership.entityIndexed || row.entityIndexed, + updatedAt: now, + }).where(eq(roomSourceMemberships.id, targetMembership.id)).run(); + tx.delete(roomSourceMemberships).where(eq(roomSourceMemberships.id, row.id)).run(); + } else { + tx.update(roomSourceMemberships).set({ roomId: toRoomId, updatedAt: now }) + .where(eq(roomSourceMemberships.id, row.id)).run(); + } + } + + // 2) 实体提及:按 (room, entity, source) 唯一键折叠。 + for (const mention of tx.select().from(roomEntityMentions).where(and( + eq(roomEntityMentions.roomId, fromRoomId), + eq(roomEntityMentions.sourceKind, "everroom-doc"), + eq(roomEntityMentions.sourceId, documentId), + )).all()) { + const existing = tx.select().from(roomEntityMentions).where(and( + eq(roomEntityMentions.roomId, toRoomId), + eq(roomEntityMentions.entityId, mention.entityId), + eq(roomEntityMentions.sourceKind, "everroom-doc"), + eq(roomEntityMentions.sourceId, documentId), + )).get(); + if (existing) { + tx.update(roomEntityMentions).set({ + sourceVersion: Math.max(existing.sourceVersion, mention.sourceVersion), + salience: Math.max(existing.salience, mention.salience), + relevanceFactor: Math.max(existing.relevanceFactor, mention.relevanceFactor), + trusted: existing.trusted || mention.trusted, + evidence: existing.evidence ?? mention.evidence, + updatedAt: now, + }).where(eq(roomEntityMentions.id, existing.id)).run(); + tx.delete(roomEntityMentions).where(eq(roomEntityMentions.id, mention.id)).run(); + } else { + tx.update(roomEntityMentions).set({ roomId: toRoomId, updatedAt: now }) + .where(eq(roomEntityMentions.id, mention.id)).run(); + } + } + + // 3) 事实记忆:按 (room, source, factId) 唯一键折叠。 + for (const fact of tx.select().from(roomEntityFacts).where(and( + eq(roomEntityFacts.roomId, fromRoomId), + eq(roomEntityFacts.sourceKind, "everroom-doc"), + eq(roomEntityFacts.sourceId, documentId), + )).all()) { + const existing = tx.select().from(roomEntityFacts).where(and( + eq(roomEntityFacts.roomId, toRoomId), + eq(roomEntityFacts.sourceKind, "everroom-doc"), + eq(roomEntityFacts.sourceId, documentId), + eq(roomEntityFacts.factId, fact.factId), + )).get(); + if (existing) { + tx.update(roomEntityFacts).set({ + sourceVersion: Math.max(existing.sourceVersion, fact.sourceVersion), + updatedAt: now, + }).where(eq(roomEntityFacts.id, existing.id)).run(); + tx.delete(roomEntityFacts).where(eq(roomEntityFacts.id, fact.id)).run(); + } else { + tx.update(roomEntityFacts).set({ roomId: toRoomId, updatedAt: now }) + .where(eq(roomEntityFacts.id, fact.id)).run(); + } + } + + // 4) 历史路由决策改指目标房:启动/实体合并后的 rebuildFromFacts 只认 + // 最新决策,若仍记旧房会把已移动的文档重新投影回旧房(记忆复活)。 + for (const decision of tx.select().from(routeDecisions).where(and( + eq(routeDecisions.sourceKind, "everroom-doc"), + eq(routeDecisions.sourceId, documentId), + )).all()) { + if (decision.status === "reverted") continue; + const evidence = decision.evidence && typeof decision.evidence === "object" + ? { ...(decision.evidence as Record) } + : {}; + evidence.rooms = [{ roomId: toRoomId }]; + evidence.linkOnlyRooms = []; + tx.update(routeDecisions).set({ + primaryRoomId: toRoomId, + linkedRoomIds: [], + evidence, + updatedAt: now, + }).where(eq(routeDecisions.id, decision.id)).run(); + } + }); + } catch (error) { + this.logger.warn( + { event: "knowledge.document-move.failed", documentId, fromRoomId, toRoomId, error }, + "document move knowledge sync failed", + ); + } + + try { + if (movedMemberships === 0) this.relationRegistry.rebuildFromFacts(); + else this.relationRegistry.recomputeAll(); + } catch (error) { + this.logger.warn({ event: "knowledge.document-move.relations", documentId, error }, "relation recompute failed"); + } + enqueueSearchIndex(this.db, { itemType: "document", refId: documentId, priority: "high" }); + this.roomOverviewRefreshTrigger?.([fromRoomId, toRoomId], "document-moved"); + this.logger.info( + { event: "knowledge.document-moved", documentId, fromRoomId, toRoomId, movedMemberships }, + "document moved between rooms", + ); + } + /** 手动"立即沉淀"入口(REST route/manual):绕过防抖直接入队。 */ routeDocumentNow(documentId: string): { queued: boolean; roomId: string | null } { const target = this.getDocumentWithRoom(documentId); diff --git a/apps/gateway/src/server/create-server.ts b/apps/gateway/src/server/create-server.ts index 077662a8..17f07637 100644 --- a/apps/gateway/src/server/create-server.ts +++ b/apps/gateway/src/server/create-server.ts @@ -799,6 +799,15 @@ export async function createServer(config: GatewayConfig, overrides: ServerOverr }); contextRoomService.setDuplicateService(roomDuplicateService); knowledgeService.setRoomDuplicateIndexTrigger(() => roomDuplicateService.requestRebuild()); + // 产物跨 Room 移动:documents 只改归属链接并广播事件,知识侧投影(成员关系/ + // 实体提及/事实/路由决策)、搜索索引与两房概览在此跟随同步。 + documentService.onDocumentMoved = (moved) => { + knowledgeService.handleDocumentMoved({ + documentId: moved.documentId, + fromRoomId: moved.fromRoomId, + toRoomId: moved.toRoomId, + }); + }; // 知识涌现(思路板块):漫步的四源投影,只读不写回基础图谱。 // 聚焦模式由 RouteMindmapService(写作路线导图)承接。 const emergenceService = new EmergenceService({ diff --git a/apps/gateway/tests/document-move.test.ts b/apps/gateway/tests/document-move.test.ts new file mode 100644 index 00000000..009ad53a --- /dev/null +++ b/apps/gateway/tests/document-move.test.ts @@ -0,0 +1,287 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { and, eq } from 'drizzle-orm' +import type { DocumentEvent, TiptapJsonContent } from '@nxcore/agent-contract' +import { createDatabase } from '../src/infrastructure/database/client.js' +import { + contextRooms, + jobs, + roomDocumentLinks, + roomEntityFacts, + roomEntityMentions, + rooms, + roomSourceMemberships, + routeDecisions, + documents, +} from '../src/infrastructure/database/schema.js' +import Fastify from 'fastify' +import { DocumentEventBroker } from '../src/modules/documents/event-broker.js' +import { documentRoutes } from '../src/modules/documents/routes.js' +import { DocumentService } from '../src/modules/documents/service.js' +import { KnowledgeService, type KnowledgeServiceConfig } from '../src/modules/knowledge/service.js' + +const temporaryDirectories: string[] = [] +const disposables: Array<() => void | Promise> = [] + +const contentJson: TiptapJsonContent = { + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '季度复盘草稿' }] }], +} + +async function createHarness() { + const dataDir = await mkdtemp(join(tmpdir(), 'nxcore-document-move-test-')) + temporaryDirectories.push(dataDir) + const { db, sqlite } = createDatabase(join(dataDir, 'gateway.sqlite'), resolve('drizzle')) + for (const [id, title] of [['room-1', '旧房'], ['room-2', '新房']] as const) { + db.insert(contextRooms).values({ id, title, data: {}, position: 0 }).run() + db.insert(rooms).values({ id, title, kind: '议题' }).run() + } + const broker = new DocumentEventBroker() + const service = new DocumentService(db, broker) + const events: DocumentEvent[] = [] + broker.listen((event) => events.push(event)) + const knowledge = new KnowledgeService(db, { + baseUrl: '', + serviceId: 'everroom', + teamId: 'everroom', + dataDir, + roomWikisEnabled: false, + ingestDebounceMs: 600_000, + routerEnabled: false, + entityPromoteScore: 2.4, + entityPromoteSources: 3, + mergeAutoDice: 0.75, + mergeJudgeDice: 0.6, + llm: null, + embeddingLlm: null, + embeddingModel: '', + } satisfies KnowledgeServiceConfig, { info: () => undefined, warn: () => undefined, error: () => undefined }) + service.onDocumentMoved = (moved) => knowledge.handleDocumentMoved({ + documentId: moved.documentId, + fromRoomId: moved.fromRoomId, + toRoomId: moved.toRoomId, + }) + disposables.push(() => { + sqlite.close() + }) + return { db, service, events } +} + +afterEach(async () => { + await Promise.all(disposables.splice(0).map((dispose) => dispose())) + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe('Room 产物移动到其他 Room', () => { + it('切换归属链接并广播两房事件:源房摘除、目标房新增', async () => { + const { db, service, events } = await createHarness() + const document = await service.import({ + id: 'doc-move-1', + roomId: 'room-1', + title: '季度复盘', + contentJson, + origin: 'native', + }) + + const moved = await service.moveToRoom(document.id, { fromRoomId: 'room-1', roomId: 'room-2' }) + + expect(moved.fromRoomId).toBe('room-1') + expect(moved.toRoomId).toBe('room-2') + expect(moved.document.roomId).toBe('room-2') + expect(service.list('room-1').map((item) => item.id)).not.toContain(document.id) + expect(service.list('room-2').map((item) => item.id)).toContain(document.id) + expect(service.get(document.id)?.roomId).toBe('room-2') + + const movedEvent = events.find((event) => event.type === 'document.moved') + expect(movedEvent?.roomId).toBe('room-1') + expect((movedEvent?.payload as { toRoomId?: string }).toRoomId).toBe('room-2') + const targetEvent = events.find((event) => + event.type === 'document.changed' && event.roomId === 'room-2') + expect((targetEvent?.payload as { document?: { roomId?: string } }).document?.roomId).toBe('room-2') + }) + + it('同步迁移来源成员关系、实体提及、事实与路由决策', async () => { + const { db, service } = await createHarness() + const document = await service.import({ + id: 'doc-move-2', + roomId: 'room-1', + title: '客户访谈纪要', + contentJson, + origin: 'native', + }) + db.insert(roomSourceMemberships).values({ + id: 'membership-1', + roomId: 'room-1', + sourceKind: 'everroom-doc', + sourceId: document.id, + sourceVersion: document.version, + evidenceGroupKey: 'everroom-doc:doc-move-2', + role: 'entry', + effectiveWeight: 1, + qualityLevel: 'high', + trusted: true, + entityIndexed: true, + }).run() + db.insert(roomEntityMentions).values({ + id: 'mention-1', + roomId: 'room-1', + entityId: 'entity-customer', + sourceKind: 'everroom-doc', + sourceId: document.id, + sourceVersion: document.version, + evidenceGroupKey: 'everroom-doc:doc-move-2', + salience: 0.9, + relevanceFactor: 1, + qualityLevel: 'high', + trusted: true, + }).run() + db.insert(roomEntityFacts).values({ + id: 'fact-1', + roomId: 'room-1', + factId: 'fact-customer-renewal', + content: '客户计划续约', + type: '属性', + sourceKind: 'everroom-doc', + sourceId: document.id, + sourceVersion: document.version, + evidenceGroupKey: 'everroom-doc:doc-move-2', + }).run() + db.insert(routeDecisions).values({ + id: 'decision-1', + sourceKind: 'everroom-doc', + sourceId: document.id, + sourceVersion: document.version, + primaryRoomId: 'room-1', + confidence: 1, + decidedBy: 'entry', + evidence: { rooms: [{ roomId: 'room-1' }] }, + status: 'auto', + }).run() + + await service.moveToRoom(document.id, { fromRoomId: 'room-1', roomId: 'room-2' }) + + const memberships = db.select().from(roomSourceMemberships) + .where(and( + eq(roomSourceMemberships.sourceKind, 'everroom-doc'), + eq(roomSourceMemberships.sourceId, document.id), + )).all() + expect(memberships.map((row) => row.roomId)).toEqual(['room-2']) + + const mentions = db.select().from(roomEntityMentions) + .where(and( + eq(roomEntityMentions.sourceKind, 'everroom-doc'), + eq(roomEntityMentions.sourceId, document.id), + )).all() + expect(mentions.map((row) => row.roomId)).toEqual(['room-2']) + + const facts = db.select().from(roomEntityFacts) + .where(and( + eq(roomEntityFacts.sourceKind, 'everroom-doc'), + eq(roomEntityFacts.sourceId, document.id), + )).all() + expect(facts.map((row) => row.roomId)).toEqual(['room-2']) + + const decision = db.select().from(routeDecisions).where(eq(routeDecisions.id, 'decision-1')).get() + expect(decision?.primaryRoomId).toBe('room-2') + expect(decision?.linkedRoomIds ?? []).toEqual([]) + expect((decision?.evidence as { rooms?: Array<{ roomId: string }> }).rooms).toEqual([{ roomId: 'room-2' }]) + + // 搜索索引重排入队(worker 从新归属重建 search_index_rooms)。 + const searchJobs = db.select().from(jobs).all() + .filter((job) => JSON.stringify(job.payload).includes(document.id)) + expect(searchJobs.length).toBeGreaterThan(0) + }) + + it('拒绝同房移动、缺失目标房与回收站文档', async () => { + const { service } = await createHarness() + const document = await service.import({ + id: 'doc-move-3', + roomId: 'room-1', + title: '待整理', + contentJson, + origin: 'native', + }) + + await expect(service.moveToRoom(document.id, { fromRoomId: 'room-1', roomId: 'room-1' })) + .rejects.toMatchObject({ code: 'SAME_ROOM' }) + await expect(service.moveToRoom(document.id, { fromRoomId: 'room-1', roomId: 'room-missing' })) + .rejects.toMatchObject({ code: 'TARGET_ROOM_NOT_FOUND' }) + + await service.delete(document.id) + await expect(service.moveToRoom(document.id, { fromRoomId: 'room-1', roomId: 'room-2' })) + .rejects.toMatchObject({ code: 'DOCUMENT_TRASHED' }) + }) + + it('批量预检发现未知文档时整批不执行', async () => { + const { service } = await createHarness() + await service.import({ id: 'doc-batch-ok', roomId: 'room-1', title: '可移动', contentJson, origin: 'native' }) + + await expect(service.moveManyToRoom(['doc-batch-ok', 'doc-batch-missing'], { + fromRoomId: 'room-1', + roomId: 'room-2', + })).rejects.toMatchObject({ code: 'NOT_FOUND' }) + + expect(service.list('room-1').map((item) => item.id)).toContain('doc-batch-ok') + expect(service.list('room-2')).toHaveLength(0) + }) + + it('批量移动同源房多个产物到同一目标房', async () => { + const { db, service } = await createHarness() + const ids = ['doc-batch-1', 'doc-batch-2'] + for (const id of ids) { + await service.import({ id, roomId: 'room-1', title: id, contentJson, origin: 'native' }) + } + + const result = await service.moveManyToRoom(ids, { fromRoomId: 'room-1', roomId: 'room-2' }) + + expect(result.items.map((item) => item.documentId)).toEqual(ids) + const links = db.select().from(roomDocumentLinks).all() + .filter((link) => ids.includes(link.documentId)) + expect(links.map((link) => link.roomId)).toEqual(['room-2', 'room-2']) + const stillOnSource = db.select().from(documents).all() + .filter((row) => ids.includes(row.id) && row.deletedAt !== null) + expect(stillOnSource).toHaveLength(0) + }) +}) + +describe('POST /v1/documents/:id/move', () => { + it('按 REST 契约移动并返回移动后的文档;目标房缺失返回 404', async () => { + const { service } = await createHarness() + const document = await service.import({ + id: 'doc-route-1', + roomId: 'room-1', + title: '路由移动', + contentJson, + origin: 'native', + }) + const app = Fastify() + await app.register(documentRoutes(service)) + + const response = await app.inject({ + method: 'POST', + url: `/v1/documents/${document.id}/move`, + payload: { fromRoomId: 'room-1', roomId: 'room-2' }, + }) + expect(response.statusCode).toBe(200) + expect(response.json().toRoomId).toBe('room-2') + expect(response.json().document.roomId).toBe('room-2') + + const missing = await app.inject({ + method: 'POST', + url: `/v1/documents/${document.id}/move`, + payload: { fromRoomId: 'room-2', roomId: 'room-missing' }, + }) + expect(missing.statusCode).toBe(404) + + const batch = await app.inject({ + method: 'POST', + url: '/v1/documents/move', + payload: { documentIds: [document.id], fromRoomId: 'room-2', roomId: 'room-1' }, + }) + expect(batch.statusCode).toBe(200) + expect(batch.json().items).toHaveLength(1) + await app.close() + }) +}) diff --git a/packages/agent-contract/src/index.ts b/packages/agent-contract/src/index.ts index 153a16fd..d10bcb7c 100644 --- a/packages/agent-contract/src/index.ts +++ b/packages/agent-contract/src/index.ts @@ -1141,6 +1141,34 @@ export interface RoomDocument { updatedAt: string; } +/** Room 产物跨 Room 移动(产物放错房或主题更适合其它房时的整理动作)。 */ +export interface MoveRoomDocumentInput { + /** 文档当前所属 Room(只移除这一条归属,文档在其它 Room 的链接不受影响)。 */ + fromRoomId: string; + /** 目标 Room。 */ + roomId: string; +} + +/** 批量移动:一次选择多个产物迁往同一目标 Room。 */ +export interface MoveRoomDocumentsInput { + documentIds: string[]; + fromRoomId: string; + roomId: string; +} + +export interface MovedRoomDocument { + documentId: string; + fromRoomId: string; + toRoomId: string; + title: string; + /** 移动后的文档(roomId 已指向目标 Room)。 */ + document: RoomDocument; +} + +export interface MoveRoomDocumentsResult { + items: MovedRoomDocument[]; +} + export interface RoomTaskFolder { id: string; roomId: string; @@ -1511,6 +1539,7 @@ export type DocumentEventType = | "document.changed" | "document.operation.changed" | "document.deleted" + | "document.moved" | "document.comments.changed"; export interface DocumentEvent { @@ -1529,6 +1558,17 @@ export interface DocumentEventFrame { event: DocumentEvent; } +/** + * document.moved 的负载:事件发往源 Room(`roomId` = 源房),接收端据此从 + * 源房清单摘除该文档;目标房另行收到 document.changed(携带移动后的文档)。 + */ +export interface DocumentMovedPayload { + documentId: string; + fromRoomId: string; + toRoomId: string; + title: string; +} + export interface ImportRoomDocumentInput { id: string; roomId: string; From 74392b0401356ce387509c744ab7a7620128a261 Mon Sep 17 00:00:00 2001 From: zengjiayi819 Date: Wed, 21 Oct 2026 11:23:45 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(ctxroom):=20=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=E5=9B=BE=E8=B0=B1=E8=A1=A5=E8=87=AA=E9=80=82=E5=BA=94=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E2=80=94=E2=80=94=E7=BC=A9=E6=94=BE=E5=A4=B1=E6=8E=A7?= =?UTF-8?q?=E5=90=8E=E4=B8=80=E9=94=AE=E7=BC=A9=E6=94=BE=E5=B1=85=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 力导向画布的视口可自由缩放平移,缩到最小或拖出视野后整张图看不见, 而关系板块原本只有 Room 关系面板头部一枚易被忽略的图标按钮, 首页图谱、实体与事实、内容建联、Wiki 图谱都没有入口。 - 新增共享浮层组件 GraphFitButton:钉在画布右上角,图标 +「适应画布」, 点击走各自画布的 fitView(按节点包围盒重算 scale 0.2–4 并 moveCenter) - 铺到首页 Room 关系图谱、Room 关系、实体与事实、内容建联、Wiki 图谱; 空图(0 节点/无关系)不渲染,避免空点 - Room 关系面板摘下头部纯图标按钮,同一动作不再两处入口,头部也更宽松 - 样式 .context-room-graph-fit 压过首帧加载遮罩;i18n 收敛为 graphs.fitToCanvas / graphs.fitCanvasHint,移除被取代的 relations.fitRelatedRoomGraph --- .../context-room/ported/ContextRoom.css | 37 +++++ .../components/EntityFactGraphCanvas.tsx | 2 + .../ported/components/GraphFitButton.tsx | 33 +++++ .../ported/components/HomeView.tsx | 11 +- .../ported/components/LinkGraphCanvas.tsx | 2 + .../ported/components/WikiGraphCanvas.tsx | 2 + .../detail-panels/RelationsPane.tsx | 31 +++-- .../src/i18n/locales/en-US/contextRoom.json | 3 +- .../src/i18n/locales/zh-CN/contextRoom.json | 3 +- .../context-room-graph-fit-buttons.test.tsx | 109 +++++++++++++++ .../context-room-home-graph-fit.test.tsx | 131 ++++++++++++++++++ .../context-room-relations-pane.test.tsx | 93 ++++++++++++- 12 files changed, 437 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/context-room/ported/components/GraphFitButton.tsx create mode 100644 apps/desktop/tests/context-room-graph-fit-buttons.test.tsx create mode 100644 apps/desktop/tests/context-room-home-graph-fit.test.tsx diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css b/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css index 7d240df6..96084009 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css +++ b/apps/desktop/src/renderer/src/components/context-room/ported/ContextRoom.css @@ -9180,6 +9180,7 @@ } .context-room-related-graph-canvas { + position: relative; height: auto; min-height: 0; min-width: 0; @@ -9191,6 +9192,42 @@ background-size: 24px 24px; } +/* 「自适应」浮层:视口可自由缩放平移,图缩小到看不见或拖走后, + 这颗按钮仍钉在画布右上角,一键把节点整体缩放居中回来。 + Context Room 各力导向图谱(首页全图/Room 关系/实体事实/建联/Wiki)共用同一套样式。 */ +.context-room-graph-fit { + position: absolute; + top: 10px; + right: 10px; + z-index: 2; + display: inline-flex; + height: 26px; + align-items: center; + gap: 5px; + padding: 0 9px; + border: 1px solid var(--cr-border); + border-radius: 6px; + background: color-mix(in srgb, var(--cr-surface) 92%, transparent); + color: var(--cr-muted); + font-size: 11px; + line-height: 1; + white-space: nowrap; + box-shadow: 0 1px 4px rgb(15 23 42 / 8%); + backdrop-filter: blur(4px); +} + +.context-room-graph-fit:hover { + border-color: var(--cr-border-strong); + background: var(--cr-surface); + color: var(--cr-text); +} + +.context-room-graph-fit svg { + width: 13px; + height: 13px; + flex: 0 0 auto; +} + .context-room-related-rooms-pane > .context-room-room-graph-inspector { height: auto; min-height: 0; diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/EntityFactGraphCanvas.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/EntityFactGraphCanvas.tsx index cbbbe589..a4de7302 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/EntityFactGraphCanvas.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/EntityFactGraphCanvas.tsx @@ -11,6 +11,7 @@ import { useForceGraphLayout, } from '@/components/graph' import { useLocale } from '../../../../i18n/LocaleContext' +import { GraphFitButton } from './GraphFitButton' import type { EntityFactGraphData, EntityFactGraphNode } from './entityFactGraphModel' /** 详情面板小视口:布局稳定后只居中、不缩小;收敛期间相机逐帧跟随内容。 */ @@ -158,6 +159,7 @@ export function EntityFactGraphCanvas({ data, onSelect, selectedId }: EntityFact onReleaseNode={layout.release} onSelectNode={onSelect} /> + {data.nodes.length > 0 ? canvasRef.current?.fitView()} /> : null}
{data.nodes.map((node: EntityFactGraphNode) => ( + ) +} \ No newline at end of file diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/HomeView.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/HomeView.tsx index cbde5a8b..f92df9d9 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/HomeView.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/HomeView.tsx @@ -7,7 +7,7 @@ import { Search, Trash2, } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocale } from '../../../../i18n/LocaleContext'; import type { ContextRoomRecord } from '../types'; @@ -18,7 +18,8 @@ import { RoomCard } from './RoomCard'; import { RoomCreationStudio } from './RoomCreationStudio'; import { RoomLifecycleDialogs } from './RoomDialogs'; import { isMergeRecommendationCandidate, RoomDuplicateCenter } from './RoomDuplicateCenter'; -import { RoomGraphCanvas } from './RoomGraphCanvas'; +import { GraphFitButton } from './GraphFitButton'; +import { RoomGraphCanvas, type RoomGraphCanvasHandle } from './RoomGraphCanvas'; import { RoomNodeInspector } from './RoomGraphInspector'; import { RoomRelationInspector } from './RoomRelationControls'; import { useRoomRelationGraph } from '../hooks/useRoomRelationGraph'; @@ -37,9 +38,11 @@ function RoomGraph({ const { error, graph, loading, reload } = useRoomRelationGraph(null); const selected = rooms.find((room) => room.id === selectedId); const selectedRelation = (graph?.edges ?? []).find((edge) => edge.id === selectedRelationId) ?? null; + const graphRef = useRef(null); // 首页图谱对齐原型:无工具栏(搜索/筛选/关系管理收进 Room 详情的关系面板), - // 只保留标题 + 画布 + 点选节点/关系出检查器。 + // 只保留标题 + 画布 + 点选节点/关系出检查器;另加一颗常驻画布的自适应按钮, + // 因为视口能自由缩放平移,缩到最小或拖走后整张图会看不见。 return (
@@ -49,6 +52,7 @@ function RoomGraph({
{loading && !graph ?
{t('contextRoom:relations.loadingGraph')}
: ( )} + {graph ? void graphRef.current?.fitView()} /> : null} {error ?
{t('contextRoom:relations.degradedNoSyntheticEdges')}
: null}
{selectedRelation ? ( diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/LinkGraphCanvas.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/LinkGraphCanvas.tsx index 04d8d192..2cdd2035 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/LinkGraphCanvas.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/LinkGraphCanvas.tsx @@ -12,6 +12,7 @@ import { type ForceGraphEdge, } from '@/components/graph' import { useLocale } from '../../../../i18n/LocaleContext' +import { GraphFitButton } from './GraphFitButton' import { LINK_GRAPH_ROOT_ID, linkGraphBlockNodeId, @@ -353,6 +354,7 @@ export function LinkGraphCanvas({ onReleaseNode={layout.release} onSelectNode={onSelect} /> + {data.nodes.length > 0 ? canvasRef.current?.fitView()} /> : null}
{data.nodes.map((node: LinkGraphNode) => ( -
{loading && !graph ?
{t('contextRoom:relations.loadingGraph')}
: hasRelations ? ( - { setSelectedRelationId(null); setSelectedGraphRoomId(roomId ?? room.id); setInspectorOpen(Boolean(roomId)) }} - onSelectRelation={(relationId) => { setSelectedRelationId(relationId); setSelectedGraphRoomId(room.id); setInspectorOpen(Boolean(relationId)) }} - onOpenRoom={onOpenRoom} - /> + <> + { setSelectedRelationId(null); setSelectedGraphRoomId(roomId ?? room.id); setInspectorOpen(Boolean(roomId)) }} + onSelectRelation={(relationId) => { setSelectedRelationId(relationId); setSelectedGraphRoomId(room.id); setInspectorOpen(Boolean(relationId)) }} + onOpenRoom={onOpenRoom} + /> + {/* 视口可自由缩放平移,缩到最小或拖出视野后图会看不见; + 浮层按钮常驻画布右上角,一键把图谱自适应缩放并居中。 */} + void graphRef.current?.fitView()} /> + ) : ( ({ + useForceGraphLayout: () => ({ + controller: null, + positions: null, + revision: 0, + resize: vi.fn(), + cancelAutoFit: vi.fn(), + drag: vi.fn(), + release: vi.fn(), + }), +})) + +vi.mock('../src/renderer/src/components/graph/PixiForceGraphCanvas', async () => { + const { forwardRef, useImperativeHandle } = await import('react') + return { + PixiForceGraphCanvas: forwardRef(function MockPixiForceGraphCanvas( + _props: Record, + ref, + ) { + useImperativeHandle(ref, () => ({ + fitView: () => { + fitViewCalls.count += 1 + }, + }), []) + return null + }), + } +}) + +import { EntityFactGraphCanvas } from '../src/renderer/src/components/context-room/ported/components/EntityFactGraphCanvas' +import { LinkGraphCanvas } from '../src/renderer/src/components/context-room/ported/components/LinkGraphCanvas' +import { WikiGraphCanvas } from '../src/renderer/src/components/context-room/ported/components/WikiGraphCanvas' + +/** 关系板块下的三张力导向图谱都应带一颗常驻的自适应按钮。 */ +describe('关系板块各图谱的自适应按钮', () => { + afterEach(() => { + fitViewCalls.count = 0 + }) + + async function renderAndFit(element: React.ReactElement) { + let renderer: TestRenderer.ReactTestRenderer + await act(async () => { + renderer = TestRenderer.create(element) + }) + const fitButton = renderer!.root.findByProps({ className: 'context-room-graph-fit' }) + expect(fitButton.props['aria-label']).toBe('自适应图谱:缩放并居中') + await act(async () => { + fitButton.props.onClick() + }) + expect(fitViewCalls.count).toBe(1) + renderer!.unmount() + } + + it('实体与事实图谱可自适应缩放居中', async () => { + await renderAndFit( + , + ) + }) + + it('内容建联图谱可自适应缩放居中', async () => { + await renderAndFit( + , + ) + }) + + it('Wiki 图谱可自适应缩放居中', async () => { + await renderAndFit( + , + ) + }) +}) \ No newline at end of file diff --git a/apps/desktop/tests/context-room-home-graph-fit.test.tsx b/apps/desktop/tests/context-room-home-graph-fit.test.tsx new file mode 100644 index 00000000..8391fc18 --- /dev/null +++ b/apps/desktop/tests/context-room-home-graph-fit.test.tsx @@ -0,0 +1,131 @@ +/** @vitest-environment happy-dom */ + +import TestRenderer, { act } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const fitViewCalls = { count: 0 } + +vi.mock('../src/renderer/src/components/context-room/ported/components/RoomGraphCanvas', async () => { + const { forwardRef, useImperativeHandle } = await import('react') + return { + RoomGraphCanvas: forwardRef(function MockRoomGraphCanvas( + _props: Record, + ref, + ) { + useImperativeHandle(ref, () => ({ + fitView: async () => { + fitViewCalls.count += 1 + }, + }), []) + return null + }), + } +}) + +vi.mock('../src/renderer/src/i18n/LocaleContext', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useLocale: () => ({ + locale: 'zh-CN', + t: (message: string, values?: Record) => actual.translate('zh-CN', message, values), + }), + } +}) + +vi.mock('../src/renderer/src/components/context-room/ContextRoomStateProvider', () => ({ + // 面板只消费 refreshFromBackend(创建后重拉推荐池);测试无需真实 Provider。 + useContextRoomState: () => ({ + state: null, + setState: () => {}, + backendReady: true, + refreshFromBackend: async () => null, + }), +})) + +import { createContextRoomFixture } from './context-room-fixture' +import { HomeView } from '../src/renderer/src/components/context-room/ported/components/HomeView' + +const handlers = { + onRenameRoom: vi.fn(), + onDeleteRoom: vi.fn(), + onRestoreRoom: vi.fn(), + onOpenDetail: vi.fn(), + onShowAll: vi.fn(), + onFocusAgent: vi.fn(), + onRefreshRooms: vi.fn().mockResolvedValue(undefined), +} + +type KnowledgeStub = { getRoomGraph: ReturnType; getRoomRelations: ReturnType } + +/** 只挂 nxcore.knowledge:happy-dom 的其余 window 能力(定时器/事件)要保留。 */ +function stubKnowledge(knowledge: KnowledgeStub) { + ;(window as unknown as { nxcore?: unknown }).nxcore = { knowledge } +} + +function stubRoomGraph(edges: unknown[]) { + stubKnowledge({ + getRoomRelations: vi.fn(), + getRoomGraph: vi.fn().mockResolvedValue({ + revision: 1, + generatedAt: '2026-08-25T00:00:00.000Z', + indexing: { status: 'ready', pendingSources: 0 }, + nodes: [ + { id: 'room-a', title: 'Room A', kind: '项目', origin: 'user', updatedAt: '2026-08-25T00:00:00.000Z' }, + { id: 'room-b', title: 'Room B', kind: '主题', origin: 'user', updatedAt: '2026-08-25T00:00:00.000Z' }, + ], + edges, + }), + }) +} + +function renderHome() { + return TestRenderer.create( + , + ) +} + +describe('首页 Room 关系图谱的自适应按钮', () => { + afterEach(() => { + fitViewCalls.count = 0 + delete (window as unknown as { nxcore?: unknown }).nxcore + }) + + // 首页画布可自由缩放平移:缩到最小或拖走后全图看不见,浮层按钮必须常驻并可点。 + it('keeps an always-visible fit control that re-fits the whole graph', async () => { + stubRoomGraph([]) + let renderer: TestRenderer.ReactTestRenderer | null = null + await act(async () => { + renderer = renderHome() + }) + + const fitButton = renderer!.root.findByProps({ className: 'context-room-graph-fit' }) + expect(fitButton.props['aria-label']).toBe('自适应图谱:缩放并居中') + + await act(async () => { + fitButton.props.onClick() + }) + + expect(fitViewCalls.count).toBe(1) + renderer!.unmount() + }) + + it('does not offer the fit control before the graph has loaded', async () => { + stubKnowledge({ + getRoomRelations: vi.fn(), + // 永不 resolve:停在加载态,画布还没渲染。 + getRoomGraph: vi.fn(() => new Promise(() => {})), + }) + let renderer: TestRenderer.ReactTestRenderer | null = null + await act(async () => { + renderer = renderHome() + }) + + expect(renderer!.root.findAllByProps({ className: 'context-room-graph-fit' })).toHaveLength(0) + renderer!.unmount() + }) +}) diff --git a/apps/desktop/tests/context-room-relations-pane.test.tsx b/apps/desktop/tests/context-room-relations-pane.test.tsx index cb180eed..63f221ec 100644 --- a/apps/desktop/tests/context-room-relations-pane.test.tsx +++ b/apps/desktop/tests/context-room-relations-pane.test.tsx @@ -9,9 +9,10 @@ const graphProps: Array<{ onSelectRelation: (relationId: string | null) => void onSelectRoom: (roomId: string | null) => void }> = [] +const fitViewCalls = { count: 0 } vi.mock('../src/renderer/src/components/context-room/ported/components/RoomGraphCanvas', async () => { - const { forwardRef } = await import('react') + const { forwardRef, useImperativeHandle } = await import('react') return { RoomGraphCanvas: forwardRef(function MockRoomGraphCanvas( props: { @@ -19,9 +20,14 @@ vi.mock('../src/renderer/src/components/context-room/ported/components/RoomGraph onSelectRelation: (relationId: string | null) => void onSelectRoom: (roomId: string | null) => void }, - _ref, + ref, ) { graphProps.push(props) + useImperativeHandle(ref, () => ({ + fitView: async () => { + fitViewCalls.count += 1 + }, + }), []) return ( <> @@ -194,3 +200,86 @@ describe('RelationsPane graph selection', () => { expect(renderer!.root.findByProps({ 'data-relation-type': 'supports' }).props.className).toBe('context-room-room-graph-inspector') }) }) + +describe('RelationsPane graph fit control', () => { + const edge = { + id: 'relation-a-b', + sourceRoomId: 'room-a', + targetRoomId: 'room-b', + directed: false, + type: 'shared_evidence', + origin: 'auto', + score: 1.2, + strength: 'weak', + sharedSourceCount: 1, + sharedEntityCount: 0, + directMentionCount: 0, + pinned: false, + hidden: false, + label: null, + note: null, + topReasons: [], + updatedAt: '2026-08-25T00:00:00.000Z', + } + + function stubRelations(edges: unknown[]) { + vi.stubGlobal('window', { + nxcore: { + knowledge: { + getRoomGraph: vi.fn(), + getRoomRelations: vi.fn().mockResolvedValue({ + revision: 1, + generatedAt: '2026-08-25T00:00:00.000Z', + indexing: { status: 'ready', pendingSources: 0 }, + nodes: [ + { id: 'room-a', title: 'Current Room', kind: '项目', origin: 'user', updatedAt: '2026-08-25T00:00:00.000Z' }, + { id: 'room-b', title: 'Related Room', kind: '项目', origin: 'user', updatedAt: '2026-08-25T00:00:00.000Z' }, + ], + edges, + }), + }, + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) + } + + afterEach(() => { + graphProps.length = 0 + fitViewCalls.count = 0 + vi.unstubAllGlobals() + }) + + // 画布可自由缩放平移:缩到最小或拖走后图看不见,浮层按钮必须始终可点。 + it('keeps an always-visible fit control that re-fits the graph', async () => { + const current = room('room-a', '项目', 'Current Room') + const related = room('room-b', '项目', 'Related Room') + stubRelations([edge]) + let renderer: TestRenderer.ReactTestRenderer + + await act(async () => { + renderer = TestRenderer.create() + }) + + const fitButton = renderer!.root.findByProps({ className: 'context-room-graph-fit' }) + expect(fitButton.props['aria-label']).toBe('自适应图谱:缩放并居中') + + await act(async () => { + fitButton.props.onClick() + }) + + expect(fitViewCalls.count).toBe(1) + }) + + it('hides the fit control when there is no graph to fit', async () => { + const current = room('room-a', '项目', 'Current Room') + stubRelations([]) + let renderer: TestRenderer.ReactTestRenderer + + await act(async () => { + renderer = TestRenderer.create() + }) + + expect(renderer!.root.findAllByProps({ className: 'context-room-graph-fit' })).toHaveLength(0) + }) +}) From edeccb6a276b33f6d30bd9fba03422117e8806e2 Mon Sep 17 00:00:00 2001 From: zengjiayi819 Date: Wed, 21 Oct 2026 15:32:25 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(wiki):=20=E5=9B=BE=E8=B0=B1=E8=87=AA?= =?UTF-8?q?=E9=80=82=E5=BA=94=E6=8C=89=E9=92=AE=E6=94=B6=E8=BF=9B=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=9D=A1=E2=80=94=E2=80=94=E4=B8=8D=E5=86=8D=E5=8E=8B?= =?UTF-8?q?=E4=BD=8F=E3=80=8C=E6=98=BE=E7=A4=BA=E5=85=A8=E9=83=A8=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:上一版把自适应浮层钉在画布右上角,而 Wiki 页图谱的工具条 (搜索框 / 聚类图例 / 显示全部)也浮在画布顶部同一片区域,两者叠在一起。 - WikiGraphCanvas 改 forwardRef 暴露 fitView(),新增 showFitButton(默认 true) - Wiki 页关掉内置浮层,在 .wiki-graph-overlay 工具条里加「适应画布」, 复用 .wiki-graph-mode 同款样式 + 图标,与「显示全部」同排、间距 8px - Context Room 内的 Wiki 图谱没有自带工具条,继续用画布浮层 - surface 命名空间补 wiki.graphFitToCanvas / wiki.graphFitHint --- .../ported/components/WikiGraphCanvas.tsx | 35 +++++- .../src/components/pages/WikiPage.css | 11 ++ .../src/components/pages/WikiPage.tsx | 18 +++- .../src/i18n/locales/en-US/surface.json | 2 + .../src/i18n/locales/zh-CN/surface.json | 2 + .../tests/wiki-page-graph-fit.test.tsx | 100 ++++++++++++++++++ 6 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/tests/wiki-page-graph-fit.test.tsx diff --git a/apps/desktop/src/renderer/src/components/context-room/ported/components/WikiGraphCanvas.tsx b/apps/desktop/src/renderer/src/components/context-room/ported/components/WikiGraphCanvas.tsx index b4f4b164..bfd5a404 100644 --- a/apps/desktop/src/renderer/src/components/context-room/ported/components/WikiGraphCanvas.tsx +++ b/apps/desktop/src/renderer/src/components/context-room/ported/components/WikiGraphCanvas.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react' +import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react' import type { KnowledgeWikiGraphDto } from '../../../../../../shared/knowledge' import { @@ -61,12 +61,28 @@ function initialPositions(count: number, width: number, height: number) { return positions } -/** Wiki 内链图谱:Worker 计算 d3-force,画布只读取 SharedArrayBuffer 坐标。 */ -export function WikiGraphCanvas({ graph, selectedPath, onSelectPage }: { +export interface WikiGraphCanvasHandle { + fitView(): void +} + +interface WikiGraphCanvasProps { graph: KnowledgeWikiGraphDto selectedPath: string | null onSelectPage: (path: string) => void -}) { + /** + * 是否在画布右上角挂内置的自适应浮层。宿主自己有图谱工具栏时关掉, + * 改成在工具栏里放一颗,避免和工具栏的按钮叠在一起。 + */ + showFitButton?: boolean +} + +/** Wiki 内链图谱:Worker 计算 d3-force,画布只读取 SharedArrayBuffer 坐标。 */ +function WikiGraphCanvasComponent({ + graph, + selectedPath, + onSelectPage, + showFitButton = true, +}: WikiGraphCanvasProps, ref: React.ForwardedRef) { const { t } = useLocale() const canvasRef = useRef(null) const nodeIndex = useMemo( @@ -121,6 +137,11 @@ export function WikiGraphCanvas({ graph, selectedPath, onSelectPage }: { const selectedId = selectedPath ? graph.nodes.find((node) => node.path === selectedPath)?.id ?? null : null + useImperativeHandle(ref, () => ({ + fitView() { + canvasRef.current?.fitView() + }, + }), []) return (
@@ -145,7 +166,9 @@ export function WikiGraphCanvas({ graph, selectedPath, onSelectPage }: { if (path) onSelectPage(path) }} /> - {graph.nodes.length > 0 ? canvasRef.current?.fitView()} /> : null} + {showFitButton && graph.nodes.length > 0 + ? canvasRef.current?.fitView()} /> + : null}
{graph.nodes.map((node) => ( + {/* 自适应按钮放在工具条里(原来浮在画布右上角会压住「显示全部」), + 画布自带的那颗在这里关掉,避免同屏两个。 */} +
{t('surface:wiki.countPages', { count: (displayGraph ?? graph).nodes.length })} diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US/surface.json b/apps/desktop/src/renderer/src/i18n/locales/en-US/surface.json index c67529b8..5cb3d6d3 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US/surface.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US/surface.json @@ -1154,6 +1154,8 @@ "wiki.graphSearchPlaceholder": "Search nodes…", "wiki.graphShowAll": "Show all", "wiki.graphCoreOnly": "Core only", + "wiki.graphFitHint": "Fit graph: zoom and center", + "wiki.graphFitToCanvas": "Fit to canvas", "wiki.graphNoMatches": "No matching nodes", "wiki.graphLegend": "Legend", "wiki.selectWiki": "Select a wiki", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN/surface.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN/surface.json index 13e16d20..23ea8db1 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN/surface.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN/surface.json @@ -1156,6 +1156,8 @@ "wiki.graphSearchPlaceholder": "搜索节点…", "wiki.graphShowAll": "显示全部", "wiki.graphCoreOnly": "只看核心", + "wiki.graphFitHint": "自适应图谱:缩放并居中", + "wiki.graphFitToCanvas": "适应画布", "wiki.graphNoMatches": "没有匹配的节点", "wiki.graphLegend": "图例", "wiki.selectWiki": "选择 wiki", diff --git a/apps/desktop/tests/wiki-page-graph-fit.test.tsx b/apps/desktop/tests/wiki-page-graph-fit.test.tsx new file mode 100644 index 00000000..332a85dd --- /dev/null +++ b/apps/desktop/tests/wiki-page-graph-fit.test.tsx @@ -0,0 +1,100 @@ +/** @vitest-environment happy-dom */ + +import TestRenderer, { act } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const fitViewCalls = { count: 0 } + +// node 环境没有 window/canvas/Worker:画布与力导向布局换成轻量替身, +// 只验证「Wiki 页图谱工具条里的自适应按钮 → 画布 fitView」这条接线。 +vi.mock('../src/renderer/src/components/graph/PixiForceGraphCanvas', async () => { + const { forwardRef, useImperativeHandle } = await import('react') + return { + PixiForceGraphCanvas: forwardRef(function MockPixiForceGraphCanvas( + _props: Record, + ref, + ) { + useImperativeHandle(ref, () => ({ + fitView: () => { + fitViewCalls.count += 1 + }, + }), []) + return null + }), + } +}) + +vi.mock('../src/renderer/src/components/graph/useForceGraphLayout', () => ({ + useForceGraphLayout: () => ({ + controller: null, + positions: null, + revision: 0, + resize: vi.fn(), + cancelAutoFit: vi.fn(), + drag: vi.fn(), + release: vi.fn(), + }), +})) + +vi.mock('@/state/toast', () => ({ showToast: vi.fn() })) + +import { WikiPage } from '../src/renderer/src/components/pages/WikiPage' + +function stubKnowledge() { + const knowledge = { + listWikis: vi.fn().mockResolvedValue({ items: [{ roomId: 'room-a', status: 'ready', pageCount: 1 }] }), + listRooms: vi.fn().mockResolvedValue({ items: [{ id: 'room-a', title: 'Room A', kind: '项目' }] }), + listWikiPages: vi.fn().mockResolvedValue({ + status: 'ready', + items: [{ path: 'overview.md', title: '概览', description: '', updatedAt: '2026-08-25T00:00:00.000Z' }], + }), + readWikiPage: vi.fn().mockResolvedValue({ markdown: '# 概览' }), + getWikiGraph: vi.fn().mockResolvedValue({ + nodes: [{ id: 'page-a', title: '页 A', path: 'a.md', inLinks: 0 }], + edges: [], + }), + } + ;(window as unknown as { nxcore?: unknown }).nxcore = { knowledge } + return knowledge +} + +describe('Wiki 页图谱的「适应画布」按钮', () => { + afterEach(() => { + fitViewCalls.count = 0 + delete (window as unknown as { nxcore?: unknown }).nxcore + }) + + // 曾经挂在画布右上角的自适应浮层会压住工具条上的「显示全部」; + // 现在统一收进工具条,一处一颗、互不重叠。 + it('sits in the graph toolbar instead of floating over the canvas', async () => { + const knowledge = stubKnowledge() + let renderer: TestRenderer.ReactTestRenderer + await act(async () => { + renderer = TestRenderer.create() + }) + + const graphTab = renderer!.root.findAllByType('button') + .find((button) => button.props.role === 'tab' && button.children.includes('图谱'))! + await act(async () => { + graphTab.props.onClick() + }) + + expect(knowledge.getWikiGraph).toHaveBeenCalledWith('room-a') + // 浮层版不再出现(此前与「显示全部」重叠的就是它)。 + expect(renderer!.root.findAllByProps({ className: 'context-room-graph-fit' })).toHaveLength(0) + + const modeButton = renderer!.root.findAllByType('button') + .find((button) => button.props.className === 'wiki-graph-mode')! + expect(modeButton.children).toEqual(['显示全部']) + + const fitButton = renderer!.root.findByProps({ className: 'wiki-graph-mode wiki-graph-fit' }) + expect(fitButton.props['aria-label']).toBe('自适应图谱:缩放并居中') + + await act(async () => { + fitButton.props.onClick() + }) + expect(fitViewCalls.count).toBe(1) + + renderer!.unmount() + }) +}) \ No newline at end of file From f5356e6fe028d6a61ffcaf0ad04589ee29d5c185 Mon Sep 17 00:00:00 2001 From: zengjiayi819 Date: Wed, 21 Oct 2026 15:56:36 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(office):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=90=8E=E9=B9=88=E9=B9=95=E5=9B=9E=E6=B5=B7?= =?UTF-8?q?=E2=80=94=E2=80=94error=20=E4=B8=8D=E5=86=8D=E6=98=AF=E5=B8=B8?= =?UTF-8?q?=E9=A9=BB=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:办公室的骑手状态由「最后一次运行的成败」推导,只要失败过一次, Agent 就会无限期停在路肩示警——即使这次失败早就写进了「最近活动」, 鹈鹕也回不到海里,办公室再也恢复不到初始状态。 - gateway:state 只区分 running / idle,运行一结束就回海; lastRun 原样保留(status=失败 + 时间戳),最近活动依旧能看到失败记录 - desktop:最近活动的圆点改按 lastRun.status 上色, 否则刚失败的记录会因为 agent.state=idle 显示成绿灯 - 顺手修:骑手 rAF 的最后一帧可能在卸载后到达,原来会读已置空的按钮引用 抛 TypeError,现在跳过该帧 - 测试:gateway agent-status-service(失败后 idle / 运行中 running / 无历史 idle)、 desktop agent-status-page-pelican-reset(failure 后游泳在海里、最近活动仍显示失败、 运行中仍在公路上) --- .../src/components/pages/AgentStatusPage.tsx | 20 +++- .../agent-status-page-pelican-reset.test.tsx | 97 +++++++++++++++++++ .../src/modules/agent/status-service.ts | 16 +-- .../tests/agent-status-service.test.ts | 62 ++++++++++++ 4 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/tests/agent-status-page-pelican-reset.test.tsx create mode 100644 apps/gateway/tests/agent-status-service.test.ts diff --git a/apps/desktop/src/renderer/src/components/pages/AgentStatusPage.tsx b/apps/desktop/src/renderer/src/components/pages/AgentStatusPage.tsx index 8560cad0..6815df01 100644 --- a/apps/desktop/src/renderer/src/components/pages/AgentStatusPage.tsx +++ b/apps/desktop/src/renderer/src/components/pages/AgentStatusPage.tsx @@ -141,6 +141,20 @@ function orbPoint(t: number) { return { x: 50 + t * 1340, y: 210 - Math.sin(t * Math.PI) * 160 } } +const FAILED_RUN_STATUSES = new Set(['failed', 'interrupted', 'timed_out']) + +/** + * 「最近活动」的圆点跟着那一次运行的结果走。 + * 办公室的实时状态一次运行结束就回海(恢复初始状态),失败的痕迹只留在这条记录里, + * 所以这里不能再读 agent.state(那会把刚失败的记录显示成绿灯)。 + */ +function runMarkState(agent: AgentWorkspaceStatus): AgentWorkspaceState { + const status = agent.lastRun?.status + if (!status) return 'idle' + if (FAILED_RUN_STATUSES.has(status)) return 'error' + return status === 'accepted' || status === 'running' ? 'running' : 'idle' +} + function StateMark({ state }: { state: AgentWorkspaceState }) { if (state === 'running') return