@allbluecn/web-app 0.10.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/package.json +21 -11
  2. package/public/favicon.ico +0 -0
  3. package/public/logo/allblue-logo-app-icon.svg +7 -0
  4. package/public/logo/allblue-logo-dark-square.svg +4 -0
  5. package/public/logo/allblue-logo-dark.svg +4 -0
  6. package/public/logo/allblue-logo-light-square.svg +4 -0
  7. package/public/logo/allblue-logo-light.svg +4 -0
  8. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  9. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  10. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  11. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  12. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  13. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  14. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  15. package/src/__tests__/setup.ts +9 -0
  16. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  17. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  18. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  19. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  20. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  21. package/src/components/error/global-error-boundary.tsx +2 -2
  22. package/src/components/knowledge/general-groups.ts +15 -0
  23. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  24. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  25. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  26. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  27. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  28. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  29. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  30. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  31. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  32. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  33. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  34. package/src/components/story/relations/general-groups.ts +1 -15
  35. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  36. package/src/components/story/story-list-view.tsx +7 -1
  37. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  38. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  39. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  40. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  41. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  42. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  43. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  44. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  45. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  46. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  47. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  48. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  49. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  50. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  51. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  52. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  53. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  54. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  55. package/src/lib/__tests__/pgvector.test.ts +2 -2
  56. package/src/lib/changelog/sdk-versions.ts +21 -21
  57. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  58. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  59. package/src/lib/fetch-json.ts +23 -0
  60. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  61. package/src/lib/sanitize.ts +14 -1
  62. package/src/lib/session.ts +9 -1
  63. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  64. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  65. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  66. package/src/routes/__tests__/-edition-routing.test.tsx +4 -4
  67. package/src/routes/__tests__/route-tree-structure.test.ts +65 -0
  68. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  69. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  70. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  71. package/src/routes/_nav/route.tsx +21 -0
  72. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  73. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  74. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  75. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  76. package/src/server/__tests__/agent-crud.test.ts +4 -4
  77. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  78. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  79. package/src/server/__tests__/permissions.test.ts +1 -3
  80. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  81. package/src/server/__tests__/skill-crud.test.ts +3 -3
  82. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  83. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  84. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  85. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  86. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  87. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  88. package/src/server/ai-gateway/index.ts +5 -0
  89. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  90. package/src/server/ai-gateway/llm-fence.ts +82 -0
  91. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  92. package/src/server/ai-gateway/story-prompt.ts +4 -2
  93. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  94. package/src/server/ai-gateway/tools/server.ts +97 -54
  95. package/src/server/builtin-project.ts +21 -0
  96. package/src/server/email/sender.ts +8 -2
  97. package/src/server/fetch-guard.ts +170 -0
  98. package/src/server/middlewares/security-headers.ts +113 -0
  99. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  100. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  101. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  102. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  103. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  104. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  105. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  106. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  107. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  108. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  109. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  110. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  111. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  112. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  113. package/src/server/serverFns/__tests__/team-invitation.test.ts +3 -3
  114. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  115. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  116. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  117. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  118. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  119. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  120. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  121. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  122. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  123. package/src/server/serverFns/kb-doc-search.ts +111 -0
  124. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  125. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  126. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  127. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  128. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  129. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  130. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  131. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  132. package/src/server/serverFns/story/kb-general.ts +30 -17
  133. package/src/server/serverFns/story/story-assoc.ts +13 -103
  134. package/src/server/serverFns/story/story-crud.ts +2 -5
  135. package/src/server/serverFns/story/story-groups.ts +4 -13
  136. package/src/server/serverFns/team.ts +1 -1
  137. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  138. package/src/server/serverFns/workspace-kb.ts +403 -0
  139. package/src/server/serverFns/workspace.ts +4 -1
  140. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  141. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  142. package/src/shared/ai-cli/adapter.ts +2 -2
  143. package/src/shared/ai-cli/cli-detector.ts +18 -9
  144. package/src/start.ts +2 -1
  145. package/src/styles/globals.css +114 -0
  146. package/src/types/jest-dom-vitest.d.ts +8 -7
  147. package/vite.shared.ts +18 -1
  148. package/public/file.svg +0 -1
  149. package/public/globe.svg +0 -1
  150. package/public/window.svg +0 -1
  151. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  152. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  153. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  154. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  155. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  156. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  157. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isAllowedUri, sanitizeUrl } from '@/components/tiptap/lib/utils/url'
3
+
4
+ // 安全测试样板:SSRF / 协议注入防护(富文本编辑器 URL 白名单)。
5
+ // 对应攻击面:用户构造恶意链接协议(javascript:/data:/file:)进入 DOM。
6
+ // 防线:isAllowedUri 协议白名单(http/https/ftp/ftps/mailto/tel/callto/sms/cid/xmpp)。
7
+
8
+ describe('SSRF 防护:isAllowedUri 协议白名单', () => {
9
+ it.each([
10
+ 'javascript:alert(1)',
11
+ 'JavaScript:alert(1)', // 大小写绕过
12
+ 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
13
+ 'vbscript:msgbox(1)',
14
+ 'file:///etc/passwd',
15
+ 'chrome://settings',
16
+ ])('拒绝危险协议:%s', (uri) => {
17
+ expect(isAllowedUri(uri)).toBeFalsy()
18
+ })
19
+
20
+ it.each([
21
+ 'https://example.com/page',
22
+ 'http://example.com',
23
+ 'mailto:user@example.com',
24
+ 'tel:+8613800000000',
25
+ '/relative/path',
26
+ '#anchor',
27
+ undefined,
28
+ ])('放行安全 URI:%s', (uri) => {
29
+ expect(isAllowedUri(uri)).toBeTruthy()
30
+ })
31
+
32
+ it('空白字符混淆绕过被拦截(\\u0000-\\u0020 控制字符剥离后仍校验协议)', () => {
33
+ // "java\tscript:" 类混淆:控制字符剥离后按协议匹配拒绝
34
+ expect(isAllowedUri('java\tscript:alert(1)')).toBeFalsy()
35
+ })
36
+ })
37
+
38
+ describe('SSRF 防护:sanitizeUrl 兜底为 #', () => {
39
+ it('危险协议链接降级为 #(不进入 DOM 的 href)', () => {
40
+ expect(sanitizeUrl('javascript:alert(1)', 'https://example.com')).toBe('#')
41
+ })
42
+
43
+ it('合法 URL 解析为绝对地址放行', () => {
44
+ expect(sanitizeUrl('/docs/a', 'https://example.com')).toBe('https://example.com/docs/a')
45
+ })
46
+
47
+ // 已知边界(gap,非缺陷):isAllowedUri 只校验协议不校验主机。
48
+ // 云元数据地址(169.254.169.254)/内网 IP 在协议白名单内会被放行。
49
+ // 编辑器场景(仅生成链接不服务端 fetch)可接受;服务端 fetch 场景
50
+ // (SearXNG web_search / 附件抓取)需要独立的主机级白名单,见测试方案设计文档。
51
+ it('边界记录:内网地址协议合法时被放行(仅协议级校验)', () => {
52
+ expect(isAllowedUri('http://169.254.169.254/latest/meta-data')).toBeTruthy()
53
+ })
54
+ })
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { sanitize, sanitizeSvg } from '@/lib/sanitize'
3
+
4
+ // 安全测试样板:XSS 防护(渲染链路 DOMPurify 清理)。
5
+ // 对应攻击面:用户输入 → 富文本/HTML 渲染 → 存储型/反射型 XSS。
6
+ // 防线:AGENTS.md 约定「所有 HTML 渲染必须经过 sanitize() 清理」。
7
+
8
+ describe('XSS 防护:sanitize() 攻击载荷清理', () => {
9
+ it.each([
10
+ ['script 标签注入', '<script>alert("xss")</script>', 'script'],
11
+ ['img onerror 事件注入', '<img src=x onerror="alert(1)">', 'onerror'],
12
+ ['内联事件属性注入', '<div onclick="steal()">click</div>', 'onclick'],
13
+ ['javascript: 协议链接', '<a href="javascript:alert(1)">click</a>', 'javascript:'],
14
+ ['iframe 嵌套注入', '<iframe src="https://evil.example"></iframe>', 'iframe'],
15
+ ['SVG onload 注入', '<svg onload="alert(1)"></svg>', 'onload'],
16
+ ])('清理 %s', (_name, payload, marker) => {
17
+ const cleaned = sanitize(payload)
18
+ expect(cleaned.toLowerCase()).not.toContain(marker)
19
+ })
20
+
21
+ it('合法内容不被误杀(保真性)', () => {
22
+ const html = '<p>hello <strong>world</strong> <a href="https://example.com">link</a></p>'
23
+ const cleaned = sanitize(html)
24
+ expect(cleaned).toContain('<p>hello <strong>world</strong>')
25
+ expect(cleaned).toContain('href="https://example.com"')
26
+ })
27
+
28
+ it('混合攻击载荷与正常内容的定向清理', () => {
29
+ const html = '<p>正常段落</p><script>evil()</script><p>另一段</p>'
30
+ const cleaned = sanitize(html)
31
+ expect(cleaned).toContain('正常段落')
32
+ expect(cleaned).toContain('另一段')
33
+ expect(cleaned).not.toContain('evil()')
34
+ })
35
+ })
36
+
37
+ // sanitizeSvg 双向断言:
38
+ // - 正向(不误杀):NotionAvatar 依赖的 SVG 滤镜链结构完整保留,任何 DOMPurify 升级
39
+ // 若静默改变默认配置破坏滤镜链,此处立即报红,防止头像渲染白屏
40
+ // - 反向(不放过):SVG 内嵌的 script / 事件处理器 / javascript: 协议仍被清除,
41
+ // 静态资源被篡改时提供纵深防御
42
+ describe('XSS 防护:sanitizeSvg() SVG 结构完整性 + 恶意内容清理', () => {
43
+ // 与 notion-avatar.tsx 中 SVGFilter 常量 + buildAvatar() 输出同构的样例
44
+ const avatarSvg = `<svg viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%">
45
+ <defs>
46
+ <filter id="notion-avatar-filter" x="-20%" y="-20%" width="140%" height="140%" filterUnits="objectBoundingBox" primitiveUnits="userSpaceOnUse" color-interpolation-filters="linearRGB">
47
+ <feMorphology operator="dilate" radius="20" in="SourceAlpha" result="morphology"/>
48
+ <feFlood flood-color="#ffffff" flood-opacity="1" result="flood"/>
49
+ <feComposite in="flood" in2="morphology" operator="in" result="composite"/>
50
+ <feMerge result="merge">
51
+ <feMergeNode in="composite" result="mergeNode"/>
52
+ <feMergeNode in="SourceGraphic" result="mergeNode1"/>
53
+ </feMerge>
54
+ </filter>
55
+ </defs>
56
+ <g id="avatar" transform="scale(-1,1) translate(-1080, 0)">
57
+ <path d="M10 20 L100 50" stroke="#000" fill="#fff"/>
58
+ </g>
59
+ </svg>`
60
+
61
+ it.each([
62
+ ['<defs> 元素', /<defs[\s>]/],
63
+ ['<filter> 元素', /<filter[\s>]/],
64
+ ['<feMorphology> 元素', /<feMorphology[\s>]/],
65
+ ['<feFlood> 元素', /<feFlood[\s>]/],
66
+ ['<feComposite> 元素', /<feComposite[\s>]/],
67
+ ['<feMerge> 元素', /<feMerge[\s>]/],
68
+ ['<feMergeNode> 元素', /<feMergeNode[\s>]/],
69
+ ['<g> 元素', /<g[\s>]/],
70
+ ['<path> 元素', /<path[\s>]/],
71
+ ['filterUnits 属性', /filterUnits=/],
72
+ ['primitiveUnits 属性', /primitiveUnits=/],
73
+ ['color-interpolation-filters 属性', /color-interpolation-filters=/],
74
+ ['operator 属性', /operator=/],
75
+ ['radius 属性', /radius=/],
76
+ ['flood-color 属性', /flood-color=/],
77
+ ['flood-opacity 属性', /flood-opacity=/],
78
+ [' in 属性', / in=/],
79
+ [' in2 属性', / in2=/],
80
+ [' result 属性', / result=/],
81
+ ['transform 属性', /transform=/],
82
+ ['viewBox 属性', /viewBox=/],
83
+ ['xmlns 属性', /xmlns=/],
84
+ ])('%s 保留', (_name, re) => {
85
+ const cleaned = sanitizeSvg(avatarSvg)
86
+ expect(cleaned).toMatch(re)
87
+ })
88
+
89
+ it.each([
90
+ ['script 标签', '<svg><script>alert(1)</script></svg>', /<script/i],
91
+ ['path onload 事件', '<svg><path onload="alert(1)"/></svg>', /onload=/i],
92
+ ['svg onload 事件', '<svg onload="alert(1)"></svg>', /onload=/i],
93
+ ['a javascript: 协议', '<svg><a href="javascript:alert(1)">x</a></svg>', /javascript:/i],
94
+ ['foreignObject 嵌套', '<svg><foreignObject><body onload="alert(1)"></foreignObject></svg>', /onload=/i],
95
+ ])('%s 被清除', (_name, payload, marker) => {
96
+ const cleaned = sanitizeSvg(payload)
97
+ expect(cleaned).not.toMatch(marker)
98
+ })
99
+ })
@@ -20,6 +20,15 @@ import '@testing-library/jest-dom/vitest'
20
20
 
21
21
  import { overwriteGetLocale } from '@/paraglide/runtime.js'
22
22
 
23
+ // jsdom 无 ResizeObserver(@dnd-kit/dom 等库模块加载时引用)
24
+ if (typeof globalThis.ResizeObserver === 'undefined') {
25
+ globalThis.ResizeObserver = class ResizeObserver {
26
+ observe() {}
27
+ unobserve() {}
28
+ disconnect() {}
29
+ } as unknown as typeof globalThis.ResizeObserver
30
+ }
31
+
23
32
  // Global i18n key resolver for test mocks
24
33
  const zhMessages = JSON.parse(
25
34
  require('fs').readFileSync(
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ // VRT 视觉回归:主题表面聚合组件的亮色/暗色渲染基线。
19
+ // 基线由 darwin 本机生成并提交入库;本机即门禁环境(同一台机器生成+对比)。
20
+
21
+ import { expect, test } from 'vitest'
22
+ import { page } from 'vitest/browser'
23
+ import { render } from 'vitest-browser-react'
24
+ import { ThemeProvider } from 'next-themes'
25
+ import { TooltipProvider } from '@/components/ui/tooltip'
26
+ import { ThemeSurface } from '@/components/shared-kit/theme/theme-surface'
27
+
28
+ test('theme surface light baseline', async () => {
29
+ await render(
30
+ <ThemeProvider forcedTheme="light">
31
+ <TooltipProvider>
32
+ <ThemeSurface />
33
+ </TooltipProvider>
34
+ </ThemeProvider>,
35
+ )
36
+ await expect(page.getByTestId('theme-surface')).toMatchScreenshot({
37
+ comparatorName: 'pixelmatch',
38
+ comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
39
+ })
40
+ })
41
+
42
+ test('theme surface dark baseline', async () => {
43
+ await render(
44
+ <ThemeProvider forcedTheme="dark">
45
+ <TooltipProvider>
46
+ <ThemeSurface />
47
+ </TooltipProvider>
48
+ </ThemeProvider>,
49
+ )
50
+ await expect(page.getByTestId('theme-surface')).toMatchScreenshot({
51
+ comparatorName: 'pixelmatch',
52
+ comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
53
+ })
54
+ })
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ // VRT 视觉回归:UserAvatar 首字母 fallback 渲染基线。
19
+
20
+ import { expect, test } from 'vitest'
21
+ import { page } from 'vitest/browser'
22
+ import { render } from 'vitest-browser-react'
23
+ import { UserAvatar } from '@/components/shared-kit'
24
+
25
+ test('user avatar initial fallback baseline', async () => {
26
+ const screen = await render(<UserAvatar name="张三" size="lg" />)
27
+ const root = screen.container.firstChild as HTMLElement
28
+ await expect(page.elementLocator(root)).toMatchScreenshot({
29
+ comparatorName: 'pixelmatch',
30
+ comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
31
+ })
32
+ })
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { Link, useLocation } from '@tanstack/react-router'
19
19
  import { useMemo } from 'react'
20
- import { LogIn, FileQuestionIcon } from 'lucide-react'
20
+ import { LogIn, CircleAlert } from 'lucide-react'
21
21
  import { isUnauthorizedError } from '@/lib/auth/anonymous-safe'
22
22
  import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
23
23
  import { Button } from '@/components/ui/button'
@@ -41,7 +41,7 @@ function GlobalErrorBoundaryInner({ error, info }: { error: unknown; info?: { co
41
41
  <Empty>
42
42
  <EmptyHeader>
43
43
  <EmptyMedia variant="icon">
44
- {unauthorized ? <LogIn /> : <FileQuestionIcon />}
44
+ {unauthorized ? <LogIn className="size-5" /> : <CircleAlert className="size-5" />}
45
45
  </EmptyMedia>
46
46
  <EmptyTitle>
47
47
  {unauthorized ? m.common_errorPage_loginRequired() : m.common_errorPage_error()}
@@ -0,0 +1,15 @@
1
+ import { m } from '@/paraglide/messages.js'
2
+
3
+ const GROUP_LABELS: Record<string, () => string> = {
4
+ legal: m.story_knowledgeDialog_groupLegal,
5
+ compliance: m.story_knowledgeDialog_groupCompliance,
6
+ dictionary: m.story_knowledgeDialog_groupDictionary,
7
+ bookmark: m.story_knowledgeDialog_groupBookmark,
8
+ summary: m.story_knowledgeDialog_groupSummary,
9
+ }
10
+
11
+ /** 通用知识库分组 key → 标签(未知 key 原样返回) */
12
+ export function generalGroupLabel(group: string): string {
13
+ const labelFn = GROUP_LABELS[group]
14
+ return labelFn ? labelFn() : group
15
+ }
@@ -0,0 +1,143 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import * as React from "react";
19
+ import { BookOpenText, Bookmark, BookMarked, Check, ChevronsUpDown, Scale, ShieldCheck, Sprout } from "lucide-react";
20
+ import { Button } from "@/components/ui/button";
21
+ import { Input } from "@/components/ui/input";
22
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
23
+ import { LucideIconByName } from "@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name";
24
+ import { generalGroupLabel } from "./general-groups";
25
+ import { m } from "@/paraglide/messages.js";
26
+
27
+ export type KbFilter = { type: "kb"; id: string } | { type: "general"; group: string } | null;
28
+
29
+ const GENERAL_GROUP_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
30
+ legal: Scale,
31
+ compliance: ShieldCheck,
32
+ dictionary: BookMarked,
33
+ bookmark: Bookmark,
34
+ summary: Sprout,
35
+ };
36
+
37
+ const GENERAL_GROUP_META = [
38
+ { key: "legal" },
39
+ { key: "compliance" },
40
+ { key: "dictionary" },
41
+ { key: "bookmark" },
42
+ { key: "summary" },
43
+ ] as const;
44
+
45
+ interface KbFilterPopoverProps {
46
+ search: string;
47
+ onSearchChange: (q: string) => void;
48
+ filter: KbFilter;
49
+ onFilterChange: (f: KbFilter) => void;
50
+ userKbs: Array<{ id: string; name: string; icon?: string | null }>;
51
+ /** 文案覆盖(缺省用 workspace_kb_* 通用文案);story 弹窗传入保持原文案零变化 */
52
+ searchPlaceholder?: string;
53
+ allLabel?: string;
54
+ userKbsLabel?: string;
55
+ generalKbsLabel?: string;
56
+ }
57
+
58
+ /** 搜索框 + 知识库筛选下拉(用户 team 库 + 通用知识库两组)。story 关联弹窗与工作空间快捷文档弹窗共用。 */
59
+ export function KbFilterPopover({
60
+ search, onSearchChange, filter, onFilterChange, userKbs,
61
+ searchPlaceholder, allLabel, userKbsLabel, generalKbsLabel,
62
+ }: KbFilterPopoverProps) {
63
+ const [open, setOpen] = React.useState(false);
64
+ const activeKb = filter?.type === "kb" ? userKbs.find((k) => k.id === filter.id) : undefined;
65
+
66
+ const select = (f: KbFilter) => {
67
+ onFilterChange(f);
68
+ setOpen(false);
69
+ };
70
+
71
+ return (
72
+ <div className="flex items-center gap-2">
73
+ <Input
74
+ placeholder={searchPlaceholder ?? m.workspace_kb_searchPlaceholder()}
75
+ value={search}
76
+ onChange={(e) => onSearchChange(e.target.value)}
77
+ className="h-8"
78
+ />
79
+ <Popover open={open} onOpenChange={setOpen}>
80
+ <PopoverTrigger asChild>
81
+ <Button variant="outline" size="sm" className="h-8 shrink-0 gap-1">
82
+ <BookOpenText className="size-3.5" />
83
+ {activeKb
84
+ ? activeKb.name
85
+ : filter?.type === "general"
86
+ ? generalGroupLabel(filter.group)
87
+ : (allLabel ?? m.workspace_kb_allKbs())}
88
+ <ChevronsUpDown className="size-3.5 opacity-50" />
89
+ </Button>
90
+ </PopoverTrigger>
91
+ <PopoverContent align="end" className="w-60 p-1">
92
+ <button
93
+ type="button"
94
+ className="mt-1 flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
95
+ onClick={() => select(null)}
96
+ >
97
+ {allLabel ?? m.workspace_kb_allKbs()}
98
+ {filter === null && <Check className="size-3.5" />}
99
+ </button>
100
+ <div className="border-t px-2 pt-3 text-xs font-medium text-muted-foreground">
101
+ {userKbsLabel ?? m.workspace_kb_userKbs()}
102
+ </div>
103
+ {userKbs.map((kb) => (
104
+ <button
105
+ key={kb.id}
106
+ type="button"
107
+ className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
108
+ onClick={() => select({ type: "kb", id: kb.id })}
109
+ >
110
+ <span className="flex min-w-0 items-center gap-2">
111
+ {kb.icon && (
112
+ <LucideIconByName name={kb.icon} size={14} className="shrink-0 text-muted-foreground" />
113
+ )}
114
+ <span className="truncate">{kb.name}</span>
115
+ </span>
116
+ {filter?.type === "kb" && filter.id === kb.id && <Check className="size-3.5" />}
117
+ </button>
118
+ ))}
119
+ <div className="border-t px-2 pt-3 text-xs font-medium text-muted-foreground">
120
+ {generalKbsLabel ?? m.workspace_kb_generalKbs()}
121
+ </div>
122
+ {GENERAL_GROUP_META.map((group) => {
123
+ const Icon = GENERAL_GROUP_ICONS[group.key];
124
+ return (
125
+ <button
126
+ key={group.key}
127
+ type="button"
128
+ className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
129
+ onClick={() => select({ type: "general", group: group.key })}
130
+ >
131
+ <span className="flex min-w-0 items-center gap-2">
132
+ <Icon className="size-3.5 shrink-0 text-muted-foreground" />
133
+ <span className="truncate">{generalGroupLabel(group.key)}</span>
134
+ </span>
135
+ {filter?.type === "general" && filter.group === group.key && <Check className="size-3.5" />}
136
+ </button>
137
+ );
138
+ })}
139
+ </PopoverContent>
140
+ </Popover>
141
+ </div>
142
+ );
143
+ }
@@ -197,8 +197,9 @@ export function LegalComplianceEditor({ type, editingId }: Props) {
197
197
  } else {
198
198
  await updateComplianceFn({ data: payload })
199
199
  }
200
+ qc.invalidateQueries({ queryKey: [type] })
200
201
  },
201
- [editingId, type],
202
+ [editingId, type, qc],
202
203
  )
203
204
 
204
205
  const handlePropertyChange = useCallback((key: string, value: unknown) => {
@@ -237,9 +238,10 @@ export function LegalComplianceEditor({ type, editingId }: Props) {
237
238
  } else {
238
239
  await updateComplianceFn({ data: payload })
239
240
  }
241
+ qc.invalidateQueries({ queryKey: [type] })
240
242
  },
241
243
  }),
242
- [editingId, type, saveField],
244
+ [editingId, type, saveField, qc],
243
245
  )
244
246
 
245
247
  if (!record) {
@@ -4,7 +4,7 @@ import { overwriteGetLocale } from "@/paraglide/runtime.js";
4
4
 
5
5
  overwriteGetLocale(() => "zh");
6
6
 
7
- const mockUseLiveQuery = vi.fn();
7
+ const mockUseQuery = vi.fn();
8
8
  const mockUseLocation = vi.fn();
9
9
  const mockUseSidebar = vi.fn();
10
10
 
@@ -13,10 +13,12 @@ vi.mock("@tanstack/react-router", () => ({
13
13
  <a href={to}>{children}</a>
14
14
  ),
15
15
  useLocation: () => mockUseLocation(),
16
+ useRouter: () => ({ navigate: vi.fn() }),
16
17
  }));
17
18
 
18
- vi.mock("@tanstack/react-db", () => ({
19
- useLiveQuery: (...args: any[]) => mockUseLiveQuery(...args),
19
+ vi.mock("@tanstack/react-query", () => ({
20
+ useQuery: (...args: any[]) => mockUseQuery(...args),
21
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
20
22
  }));
21
23
 
22
24
  vi.mock("@/components/ui/sidebar", () => ({
@@ -52,10 +54,11 @@ vi.mock("@/lib/utils", () => ({
52
54
  }));
53
55
 
54
56
  vi.mock("@/lib/collections/reference/workspaces-collection", () => ({
55
- workspacesCollection: { id: "workspaces" },
57
+ WORKSPACES_QUERY_KEY: ["workspaces"],
56
58
  }));
57
59
 
58
60
  vi.mock("@/server/serverFns/workspace", () => ({
61
+ listPinnedWorkspacesBriefFn: vi.fn().mockResolvedValue([]),
59
62
  toggleWorkspacePinFn: vi.fn().mockResolvedValue({ success: true }),
60
63
  }));
61
64
 
@@ -68,7 +71,7 @@ beforeEach(() => {
68
71
  cleanup();
69
72
  mockUseLocation.mockReturnValue({ pathname: "/workspaces" });
70
73
  mockUseSidebar.mockReturnValue({ state: "expanded" });
71
- mockUseLiveQuery.mockReturnValue({
74
+ mockUseQuery.mockReturnValue({
72
75
  data: [
73
76
  { id: "ws1", name: "工作空间 A", icon: "folder" },
74
77
  { id: "ws2", name: "工作空间 B", icon: "folder-kanban" },
@@ -103,7 +106,7 @@ describe("NavWorkspace", () => {
103
106
  });
104
107
 
105
108
  it("空 collection 时不渲染工作空间列表项", async () => {
106
- mockUseLiveQuery.mockReturnValue({ data: [] });
109
+ mockUseQuery.mockReturnValue({ data: [] });
107
110
  render(<NavWorkspace />);
108
111
  await waitFor(() => {
109
112
  expect(screen.queryByTestId("project-icon")).not.toBeInTheDocument();
@@ -70,11 +70,14 @@ import { Link, useLocation } from "@tanstack/react-router";
70
70
  import { cn } from "@/lib/utils";
71
71
  import { isParentActive } from "@/lib/sidebar-utils";
72
72
  import { useEffect, useState } from "react";
73
- import { useLiveQuery } from "@tanstack/react-db";
74
- import { knowledgeBasesCollection } from "@/lib/collections/knowledge/knowledge-bases-collection";
73
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
74
+ import { KNOWLEDGE_BASES_QUERY_KEY } from "@/lib/collections/knowledge/knowledge-bases-collection";
75
+ import {
76
+ listKnowledgeFn,
77
+ toggleKnowledgePinFn,
78
+ } from "@/server/serverFns/knowledge";
75
79
  import { KnowledgeFormDialog } from "@/components/knowledge/dialog/knowledge-form-dialog";
76
80
  import { useServerFn } from "@tanstack/react-start";
77
- import { toggleKnowledgePinFn } from "@/server/serverFns/knowledge";
78
81
 
79
82
  import { AnimatedGradientText } from "@/components/magicui/animated-gradient-text";
80
83
  import { m } from "@/paraglide/messages.js";
@@ -151,6 +154,7 @@ interface KnowledgeItem {
151
154
 
152
155
  function UnpinButton({ kb }: { kb: KnowledgeItem }) {
153
156
  const togglePin = useServerFn(toggleKnowledgePinFn);
157
+ const queryClient = useQueryClient();
154
158
  const [pinned, setPinned] = useState(kb.pinnedToSidebar);
155
159
 
156
160
  const handleClick = async () => {
@@ -158,7 +162,7 @@ function UnpinButton({ kb }: { kb: KnowledgeItem }) {
158
162
  setPinned(newPinned);
159
163
  try {
160
164
  await togglePin({ data: { kbId: kb.id, pinned: newPinned } });
161
- (knowledgeBasesCollection as any).utils?.refetch?.();
165
+ void queryClient.invalidateQueries({ queryKey: KNOWLEDGE_BASES_QUERY_KEY });
162
166
  window.dispatchEvent(new CustomEvent("knowledge-pinned", { detail: { kbId: kb.id, pinned: newPinned } }));
163
167
  } catch {
164
168
  setPinned(!newPinned);
@@ -183,11 +187,13 @@ function UnpinButton({ kb }: { kb: KnowledgeItem }) {
183
187
  }
184
188
 
185
189
  function KnowledgeBaseLinks({ pathname }: { pathname: string }) {
186
- const { data: items } = useLiveQuery(
187
- (q) => q.from({ kb: knowledgeBasesCollection })
188
- );
189
- const itemsList =
190
- (items as unknown as Array<KnowledgeItem> | undefined) ?? [];
190
+ // useQuery 直读 per-request queryClient(_nav loader 已 SSR 预取),
191
+ // 列表随 SSR HTML 直出,刷新无需等待;避免 module-level collection 的跨请求泄漏
192
+ const { data: items } = useQuery({
193
+ queryKey: KNOWLEDGE_BASES_QUERY_KEY,
194
+ queryFn: () => listKnowledgeFn(),
195
+ });
196
+ const itemsList = (items as unknown as Array<KnowledgeItem> | undefined) ?? [];
191
197
 
192
198
  if (itemsList.length === 0) return null;
193
199
 
@@ -228,18 +234,14 @@ export function NavKnowledge() {
228
234
  const { state } = useSidebar();
229
235
  const { pathname } = useLocation();
230
236
  const isCollapsed = state === "collapsed";
231
- const [mounted, setMounted] = useState(false);
232
237
  const [open, setOpen] = useState(false);
233
238
  const [createOpen, setCreateOpen] = useState(false);
239
+ const queryClient = useQueryClient();
234
240
  const active = isParentActive(pathname, "/knowledge");
235
241
 
236
- useEffect(() => {
237
- setMounted(true);
238
- }, []);
239
-
240
242
  useEffect(() => {
241
243
  const refresh = () => {
242
- (knowledgeBasesCollection as any).utils?.refetch?.();
244
+ void queryClient.invalidateQueries({ queryKey: KNOWLEDGE_BASES_QUERY_KEY });
243
245
  };
244
246
  window.addEventListener("knowledge-created", refresh);
245
247
  window.addEventListener("knowledge-updated", refresh);
@@ -251,7 +253,7 @@ export function NavKnowledge() {
251
253
  window.removeEventListener("knowledge-deleted", refresh);
252
254
  window.removeEventListener("knowledge-pinned", refresh);
253
255
  };
254
- }, []);
256
+ }, [queryClient]);
255
257
 
256
258
  return (
257
259
  <SidebarGroup className="p-0 space-y-1">
@@ -350,7 +352,7 @@ export function NavKnowledge() {
350
352
  )}
351
353
  </Collapsible>
352
354
 
353
- {!isCollapsed && mounted && (
355
+ {!isCollapsed && (
354
356
  <KnowledgeBaseLinks pathname={pathname} />
355
357
  )}
356
358