@allbluecn/web-app 0.10.3 → 0.11.1

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 (246) hide show
  1. package/package.json +24 -11
  2. package/public/favicon-dark.svg +1 -0
  3. package/public/favicon.ico +0 -0
  4. package/public/favicon.svg +1 -0
  5. package/public/logo/allblue-logo-app-icon.svg +7 -0
  6. package/public/logo/allblue-logo-dark-square.svg +4 -0
  7. package/public/logo/allblue-logo-dark.png +0 -0
  8. package/public/logo/allblue-logo-dark.svg +4 -0
  9. package/public/logo/allblue-logo-light-square.svg +4 -0
  10. package/public/logo/allblue-logo-light.png +0 -0
  11. package/public/logo/allblue-logo-light.svg +4 -0
  12. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  13. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  14. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  15. package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
  16. package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
  17. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  18. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  19. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  20. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  21. package/src/__tests__/setup.ts +9 -0
  22. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  23. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  24. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  25. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  26. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  27. package/src/components/auth/icon-input.tsx +9 -1
  28. package/src/components/auth/password-input.tsx +9 -1
  29. package/src/components/changelog/changelog-page.tsx +44 -1
  30. package/src/components/error/global-error-boundary.tsx +2 -2
  31. package/src/components/forms/app-field.tsx +3 -1
  32. package/src/components/knowledge/general-groups.ts +15 -0
  33. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  34. package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
  35. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  36. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  37. package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
  38. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  39. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  40. package/src/components/notifications/settings-notifications.tsx +41 -1
  41. package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
  42. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  43. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  44. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  45. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  46. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  47. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  48. package/src/components/story/relations/general-groups.ts +1 -15
  49. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  50. package/src/components/story/story-list-view.tsx +7 -1
  51. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  52. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  53. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  54. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  55. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  56. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  57. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  58. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  59. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  60. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  61. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  62. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  63. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  64. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  65. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  66. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  67. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  68. package/src/lib/__tests__/edition-desktop.test.ts +23 -0
  69. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  70. package/src/lib/__tests__/pgvector.test.ts +2 -2
  71. package/src/lib/app-version.ts +1 -1
  72. package/src/lib/auth/auth.config.ts +123 -99
  73. package/src/lib/changelog/sdk-versions.ts +21 -21
  74. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  75. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  76. package/src/lib/edition-desktop.ts +22 -0
  77. package/src/lib/fetch-json.ts +23 -0
  78. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  79. package/src/lib/sanitize.ts +14 -1
  80. package/src/lib/session.ts +10 -2
  81. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  82. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  83. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  84. package/src/routeTree.gen.ts +114 -0
  85. package/src/routes/__root.tsx +20 -3
  86. package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
  87. package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
  88. package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
  89. package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
  90. package/src/routes/_login/changelog/confirm/route.tsx +28 -0
  91. package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
  92. package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
  93. package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
  94. package/src/routes/_login/login/route.lazy.tsx +4 -2
  95. package/src/routes/_login/register/route.lazy.tsx +28 -17
  96. package/src/routes/_login/register/route.tsx +8 -1
  97. package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
  98. package/src/routes/_login/schemas/-auth.ts +30 -7
  99. package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
  100. package/src/routes/_login/verify-email/route.tsx +28 -0
  101. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  102. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  103. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  104. package/src/routes/_nav/route.tsx +26 -0
  105. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  106. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  107. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  108. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  109. package/src/routes/_public/index/route.tsx +2 -1
  110. package/src/routes/api/auth/desktop-callback.ts +2 -2
  111. package/src/routes/api/billing-webhook.ts +4 -0
  112. package/src/routes/api/cron/changelog-release.ts +100 -0
  113. package/src/routes/api/cron/subscription-reminders.ts +110 -0
  114. package/src/routes/api.auth.$.ts +3 -5
  115. package/src/server/__tests__/agent-crud.test.ts +4 -4
  116. package/src/server/__tests__/auth-registration.test.ts +60 -0
  117. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  118. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  119. package/src/server/__tests__/permissions.test.ts +1 -3
  120. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  121. package/src/server/__tests__/skill-crud.test.ts +3 -3
  122. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  123. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  124. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  125. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  126. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  127. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  128. package/src/server/ai-gateway/index.ts +5 -0
  129. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  130. package/src/server/ai-gateway/llm-fence.ts +82 -0
  131. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  132. package/src/server/ai-gateway/story-prompt.ts +4 -2
  133. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  134. package/src/server/ai-gateway/tools/server.ts +97 -54
  135. package/src/server/auth-registration.ts +40 -0
  136. package/src/server/billing-email.ts +39 -0
  137. package/src/server/builtin-project.ts +21 -0
  138. package/src/server/email/__tests__/deliverability.test.ts +68 -0
  139. package/src/server/email/__tests__/index.test.ts +32 -0
  140. package/src/server/email/__tests__/outbox.test.tsx +129 -0
  141. package/src/server/email/__tests__/product-templates.test.tsx +106 -0
  142. package/src/server/email/__tests__/registry.test.tsx +50 -0
  143. package/src/server/email/deliverability.ts +57 -0
  144. package/src/server/email/drivers/console.ts +11 -0
  145. package/src/server/email/drivers/resend.ts +31 -0
  146. package/src/server/email/drivers/smtp.ts +27 -0
  147. package/src/server/email/index.ts +56 -0
  148. package/src/server/email/outbox.ts +141 -0
  149. package/src/server/email/preferences.ts +20 -0
  150. package/src/server/email/registry.ts +38 -0
  151. package/src/server/email/sender.ts +36 -13
  152. package/src/server/email/templates/_base/button.tsx +12 -0
  153. package/src/server/email/templates/_base/email-layout.tsx +117 -0
  154. package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
  155. package/src/server/email/templates/_base/theme.ts +22 -0
  156. package/src/server/email/templates/auth/reset-password.tsx +58 -0
  157. package/src/server/email/templates/auth/verify-email.tsx +59 -0
  158. package/src/server/email/templates/auth/welcome.tsx +49 -0
  159. package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
  160. package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
  161. package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
  162. package/src/server/email/templates/notification/mirror.tsx +50 -0
  163. package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
  164. package/src/server/email/templates/product/changelog-release.tsx +65 -0
  165. package/src/server/email/templates/team/apply-notify.tsx +53 -0
  166. package/src/server/email/templates/team/approved.tsx +53 -0
  167. package/src/server/email/templates/team/invite.tsx +57 -0
  168. package/src/server/fetch-guard.ts +170 -0
  169. package/src/server/middlewares/security-headers.ts +122 -0
  170. package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
  171. package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
  172. package/src/server/notifications/notification-service.ts +71 -1
  173. package/src/server/notifications/verify-reminder.ts +55 -0
  174. package/src/server/oauth-config.ts +59 -0
  175. package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
  176. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  177. package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
  178. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  179. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  180. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  181. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  182. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  183. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  184. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  185. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  186. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  187. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  188. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  189. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  190. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  191. package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
  192. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  193. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  194. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  195. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  196. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  197. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  198. package/src/server/serverFns/auth/email-verification.ts +132 -0
  199. package/src/server/serverFns/auth/forgot-password.ts +22 -2
  200. package/src/server/serverFns/auth/providers.ts +4 -3
  201. package/src/server/serverFns/auth/register.ts +63 -19
  202. package/src/server/serverFns/auth/registration-mode.ts +23 -0
  203. package/src/server/serverFns/auth/reset-password.ts +1 -1
  204. package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
  205. package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
  206. package/src/server/serverFns/changelog/subscribe.ts +89 -0
  207. package/src/server/serverFns/email-unsubscribe.ts +66 -0
  208. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  209. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  210. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  211. package/src/server/serverFns/kb-doc-search.ts +111 -0
  212. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  213. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  214. package/src/server/serverFns/notifications.ts +10 -0
  215. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  216. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  217. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  218. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  219. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  220. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  221. package/src/server/serverFns/story/kb-general.ts +30 -17
  222. package/src/server/serverFns/story/story-assoc.ts +13 -103
  223. package/src/server/serverFns/story/story-crud.ts +2 -5
  224. package/src/server/serverFns/story/story-groups.ts +4 -13
  225. package/src/server/serverFns/team.ts +50 -12
  226. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  227. package/src/server/serverFns/workspace-kb.ts +403 -0
  228. package/src/server/serverFns/workspace.ts +4 -1
  229. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  230. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  231. package/src/shared/ai-cli/adapter.ts +2 -2
  232. package/src/shared/ai-cli/cli-detector.ts +18 -9
  233. package/src/start.ts +2 -1
  234. package/src/styles/globals.css +114 -0
  235. package/src/types/jest-dom-vitest.d.ts +8 -7
  236. package/vite.shared.ts +105 -4
  237. package/public/file.svg +0 -1
  238. package/public/globe.svg +0 -1
  239. package/public/window.svg +0 -1
  240. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  241. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  242. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  243. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  244. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  245. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  246. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -0,0 +1,174 @@
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 { useServerFn } from "@tanstack/react-start";
20
+ import { toast } from "sonner";
21
+ import { Check, Search } from "lucide-react";
22
+ import { Button } from "@/components/ui/button";
23
+ import { Badge } from "@/components/ui/badge";
24
+ import { Checkbox } from "@/components/ui/checkbox";
25
+ import { Input } from "@/components/ui/input";
26
+ import {
27
+ Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
28
+ } from "@/components/ui/dialog";
29
+ import {
30
+ listWorkspaceAvailableKbsFn,
31
+ setWorkspaceKbsFn,
32
+ } from "@/server/serverFns/workspace-kb";
33
+ import type { KbBrief } from "@/server/serverFns/workspace-kb-utils";
34
+ import { m } from "@/paraglide/messages.js";
35
+
36
+ interface WorkspaceKbManageDialogProps {
37
+ workspaceId: string;
38
+ open: boolean;
39
+ onOpenChange: (open: boolean) => void;
40
+ /** 全量同步成功后回传新的关联列表(父组件更新本地 state) */
41
+ onUpdate: (kbs: KbBrief[]) => void;
42
+ }
43
+
44
+ export function WorkspaceKbManageDialog({
45
+ workspaceId, open, onOpenChange, onUpdate,
46
+ }: WorkspaceKbManageDialogProps) {
47
+ const listKbs = useServerFn(listWorkspaceAvailableKbsFn);
48
+ const setKbs = useServerFn(setWorkspaceKbsFn);
49
+
50
+ const [options, setOptions] = React.useState<
51
+ Array<KbBrief & { linked: boolean }>
52
+ >([]);
53
+ const [selected, setSelected] = React.useState<Set<string>>(new Set());
54
+ const [search, setSearch] = React.useState("");
55
+ const [loading, setLoading] = React.useState(false);
56
+ const [submitting, setSubmitting] = React.useState(false);
57
+
58
+ React.useEffect(() => {
59
+ if (!open) return;
60
+ setSearch("");
61
+ setLoading(true);
62
+ void (async () => {
63
+ try {
64
+ const list = await listKbs({ data: { workspaceId } });
65
+ setOptions(list);
66
+ setSelected(new Set(list.filter((k) => k.linked).map((k) => k.id)));
67
+ } catch (err) {
68
+ toast.error((err as Error)?.message || m.workspace_kb_opFailed());
69
+ setOptions([]);
70
+ } finally {
71
+ setLoading(false);
72
+ }
73
+ })();
74
+ }, [open, workspaceId, listKbs]);
75
+
76
+ const toggle = (id: string) => {
77
+ setSelected((prev) => {
78
+ const next = new Set(prev);
79
+ if (next.has(id)) next.delete(id);
80
+ else next.add(id);
81
+ return next;
82
+ });
83
+ };
84
+
85
+ const filtered = search
86
+ ? options.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()))
87
+ : options;
88
+
89
+ const handleConfirm = async () => {
90
+ if (submitting) return;
91
+ setSubmitting(true);
92
+ try {
93
+ await setKbs({ data: { workspaceId, kbIds: [...selected] } });
94
+ const newLinked = options.filter((k) => selected.has(k.id));
95
+ onUpdate(newLinked.map(({ linked: _linked, ...brief }) => brief));
96
+ onOpenChange(false);
97
+ } catch (err) {
98
+ toast.error((err as Error)?.message || m.workspace_kb_opFailed());
99
+ } finally {
100
+ setSubmitting(false);
101
+ }
102
+ };
103
+
104
+ return (
105
+ <Dialog open={open} onOpenChange={onOpenChange}>
106
+ <DialogContent className="sm:max-w-96 -mt-50">
107
+ <DialogHeader>
108
+ <DialogTitle>{m.workspace_kb_manageTitle()}</DialogTitle>
109
+ </DialogHeader>
110
+
111
+ <div className="relative">
112
+ <Search className="absolute top-2.5 left-2.5 size-4 text-muted-foreground" />
113
+ <Input
114
+ placeholder={m.workspace_kb_manageSearchPlaceholder()}
115
+ value={search}
116
+ onChange={(e) => setSearch(e.target.value)}
117
+ className="h-8 pl-8"
118
+ />
119
+ </div>
120
+
121
+ <div className="h-72 overflow-y-auto rounded-md border p-1">
122
+ {loading ? (
123
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">…</div>
124
+ ) : filtered.length > 0 ? (
125
+ filtered.map((kb) => (
126
+ <div
127
+ key={kb.id}
128
+ role="button"
129
+ tabIndex={0}
130
+ aria-pressed={selected.has(kb.id)}
131
+ onClick={() => toggle(kb.id)}
132
+ onKeyDown={(e) => {
133
+ if (e.key === "Enter" || e.key === " ") {
134
+ e.preventDefault();
135
+ toggle(kb.id);
136
+ }
137
+ }}
138
+ className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
139
+ >
140
+ <Checkbox
141
+ checked={selected.has(kb.id)}
142
+ onCheckedChange={() => toggle(kb.id)}
143
+ onClick={(e) => e.stopPropagation()}
144
+ aria-label={kb.name}
145
+ className="shrink-0"
146
+ />
147
+ <span className="min-w-0 flex-1 truncate text-sm">{kb.name}</span>
148
+ {kb.linked && (
149
+ <Badge variant="secondary" className="shrink-0 text-[10px]">
150
+ {m.workspace_kb_linked()}
151
+ </Badge>
152
+ )}
153
+ </div>
154
+ ))
155
+ ) : (
156
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
157
+ {m.workspace_kb_manageEmpty()}
158
+ </div>
159
+ )}
160
+ </div>
161
+
162
+ <DialogFooter>
163
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
164
+ {m.workspace_kb_cancel()}
165
+ </Button>
166
+ <Button onClick={handleConfirm} disabled={submitting}>
167
+ <Check className="size-4" />
168
+ {m.workspace_kb_confirm()}
169
+ </Button>
170
+ </DialogFooter>
171
+ </DialogContent>
172
+ </Dialog>
173
+ );
174
+ }
@@ -0,0 +1,431 @@
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 { useServerFn } from "@tanstack/react-start";
20
+ import { toast } from "sonner";
21
+ import { BookOpenText, X } from "lucide-react";
22
+ import { Button } from "@/components/ui/button";
23
+ import { Checkbox } from "@/components/ui/checkbox";
24
+ import { Input } from "@/components/ui/input";
25
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
26
+ import {
27
+ Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
28
+ } from "@/components/ui/dialog";
29
+ import { KbFilterPopover, type KbFilter } from "@/components/knowledge/kb-filter-popover";
30
+ import { generalGroupLabel } from "@/components/knowledge/general-groups";
31
+ import { LucideIconByName } from "@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name";
32
+ import { LucideIconPicker } from "@/components/shared-kit/icons/lucide-icon-picker";
33
+ import { useDebouncedCallback, SEARCH_DEBOUNCE_MS } from "@/lib/pacer";
34
+ import {
35
+ searchQuickDocCandidatesFn,
36
+ addQuickDocsFn,
37
+ listWorkspaceAvailableKbsFn,
38
+ getWorkspaceKbCardFn,
39
+ } from "@/server/serverFns/workspace-kb";
40
+ import {
41
+ GENERAL_GROUP_DEFAULT_LABEL,
42
+ LABEL_COLORS,
43
+ QUICK_DOC_LABELS,
44
+ type QuickDocItem,
45
+ type QuickDocLabel,
46
+ } from "@/server/serverFns/workspace-kb-utils";
47
+ import type { QuickDocRow } from "@/server/serverFns/workspace-kb-utils";
48
+ import type { SearchDocRow } from "@/server/serverFns/kb-doc-search";
49
+ import { m } from "@/paraglide/messages.js";
50
+ import { t } from "@/lib/i18n/resolver";
51
+
52
+ type CandidateRow = SearchDocRow;
53
+
54
+ interface WorkspaceQuickDocDialogProps {
55
+ workspaceId: string;
56
+ open: boolean;
57
+ onOpenChange: (open: boolean) => void;
58
+ onAdded: () => void;
59
+ }
60
+
61
+ export function WorkspaceQuickDocDialog({
62
+ workspaceId, open, onOpenChange, onAdded,
63
+ }: WorkspaceQuickDocDialogProps) {
64
+ const searchDocs = useServerFn(searchQuickDocCandidatesFn);
65
+ const addDocs = useServerFn(addQuickDocsFn);
66
+ const listKbs = useServerFn(listWorkspaceAvailableKbsFn);
67
+ const getCard = useServerFn(getWorkspaceKbCardFn);
68
+ const existingRef = React.useRef<Map<string, { displayTitle: string; label: string | null; icon: string | null }>>(new Map());
69
+
70
+ const [kbs, setKbs] = React.useState<Array<{ id: string; name: string; icon?: string | null }>>([]);
71
+ const [filter, setFilter] = React.useState<KbFilter>(null);
72
+ const [items, setItems] = React.useState<CandidateRow[]>([]);
73
+ const [search, setSearch] = React.useState("");
74
+ const [loading, setLoading] = React.useState(false);
75
+ const [submitting, setSubmitting] = React.useState(false);
76
+ const [selectedRows, setSelectedRows] = React.useState<Map<string, CandidateRow>>(new Map());
77
+ const [titles, setTitles] = React.useState<Map<string, string>>(new Map());
78
+ const [labels, setLabels] = React.useState<Map<string, QuickDocLabel | "">>(new Map());
79
+ const [icons, setIcons] = React.useState<Map<string, string>>(new Map());
80
+
81
+ const loadDocs = React.useCallback(
82
+ async (f: KbFilter, query: string) => {
83
+ setLoading(true);
84
+ try {
85
+ const res = await searchDocs({
86
+ data: {
87
+ workspaceId,
88
+ kbId: f?.type === "kb" ? f.id : undefined,
89
+ generalGroup: f?.type === "general" ? (f.group as never) : undefined,
90
+ query,
91
+ },
92
+ });
93
+ setItems(res.items as CandidateRow[]);
94
+ } catch (err) {
95
+ toast.error((err as Error)?.message || m.workspace_kb_opFailed());
96
+ setItems([]);
97
+ } finally {
98
+ setLoading(false);
99
+ }
100
+ },
101
+ [searchDocs, workspaceId],
102
+ );
103
+
104
+ React.useEffect(() => {
105
+ if (!open) return;
106
+ setSelectedRows(new Map());
107
+ setTitles(new Map());
108
+ setLabels(new Map());
109
+ setIcons(new Map());
110
+ setSearch("");
111
+ setFilter(null);
112
+ void (async () => {
113
+ // 先拉回显数据源(当前卡片已有的快捷文档),再加载候选列表,保证勾选回显生效
114
+ try {
115
+ const card = await getCard({ data: { workspaceId } });
116
+ const selected = new Map<string, CandidateRow>();
117
+ const titles = new Map<string, string>();
118
+ const labels = new Map<string, QuickDocLabel | "">();
119
+ const icons = new Map<string, string>();
120
+ for (const q of card.quickDocs) {
121
+ const key = q.docId ?? `ext:${q.group}:${q.externalId}`;
122
+ existingRef.current.set(key, { displayTitle: q.displayTitle, label: q.label, icon: q.icon });
123
+ // 全量回显:已有文档构造为候选行注入勾选(不受搜索/筛选结果限制)
124
+ selected.set(key, {
125
+ id: key,
126
+ title: q.sourceTitle ?? q.displayTitle,
127
+ kbId: q.kbId ?? (q.group ? `ext:${q.group}` : ""),
128
+ kbName: q.kbName ?? (q.group ? generalGroupLabel(q.group) : ""),
129
+ kbIcon: "",
130
+ docIcon: q.docIcon,
131
+ parentId: null,
132
+ } as unknown as CandidateRow);
133
+ titles.set(key, q.displayTitle);
134
+ if (q.label) labels.set(key, q.label as QuickDocLabel);
135
+ if (q.icon) icons.set(key, q.icon);
136
+ }
137
+ setSelectedRows(selected);
138
+ setTitles(titles);
139
+ setLabels(labels);
140
+ setIcons(icons);
141
+ } catch {
142
+ existingRef.current = new Map();
143
+ }
144
+ try {
145
+ // 「关联知识库」组:仅本工作空间已关联的知识库
146
+ const list = await listKbs({ data: { workspaceId } });
147
+ setKbs(list.filter((k) => k.linked).map((k) => ({ id: k.id, name: k.name, icon: k.icon })));
148
+ } catch {
149
+ setKbs([]);
150
+ }
151
+ void loadDocs(null, "");
152
+ })();
153
+ // eslint-disable-next-line react-hooks/exhaustive-deps
154
+ }, [open]);
155
+
156
+ const debouncedSearch = useDebouncedCallback(
157
+ (q: string) => void loadDocs(filter, q),
158
+ { wait: SEARCH_DEBOUNCE_MS },
159
+ );
160
+
161
+ const toggle = (row: CandidateRow) => {
162
+ const wasSelected = selectedRows.has(row.id);
163
+ setSelectedRows((prev) => {
164
+ const next = new Map(prev);
165
+ if (next.has(row.id)) next.delete(row.id);
166
+ else next.set(row.id, row);
167
+ return next;
168
+ });
169
+ // 通用知识库文档勾选时自动填充默认标签(法律法规/行业合规/词典词条/网址)
170
+ if (!wasSelected && row.id.startsWith("ext:")) {
171
+ const group = row.id.split(":")[1];
172
+ const def = GENERAL_GROUP_DEFAULT_LABEL[group];
173
+ if (def) {
174
+ setLabels((prev) => {
175
+ if (prev.has(row.id)) return prev;
176
+ const next = new Map(prev);
177
+ next.set(row.id, def);
178
+ return next;
179
+ });
180
+ }
181
+ }
182
+ };
183
+
184
+ const childrenOf = React.useMemo(() => {
185
+ const map = new Map<string | null, CandidateRow[]>();
186
+ for (const row of items) {
187
+ const key = row.parentId ?? null;
188
+ const arr = map.get(key) ?? [];
189
+ arr.push(row);
190
+ map.set(key, arr);
191
+ }
192
+ return map;
193
+ }, [items]);
194
+
195
+ const renderRows = (parentId: string | null, depth: number): React.ReactNode[] => {
196
+ const rows = childrenOf.get(parentId) ?? [];
197
+ const out: React.ReactNode[] = [];
198
+ for (const row of rows) {
199
+ const isGeneral = row.kbId.startsWith("ext:");
200
+ const picked = selectedRows.has(row.id);
201
+ out.push(
202
+ <div
203
+ key={row.id}
204
+ role="button"
205
+ tabIndex={0}
206
+ aria-pressed={picked}
207
+ onClick={() => toggle(row)}
208
+ onKeyDown={(e) => {
209
+ if (e.key === "Enter" || e.key === " ") {
210
+ e.preventDefault();
211
+ toggle(row);
212
+ }
213
+ }}
214
+ className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
215
+ style={{ paddingLeft: `${8 + depth * 20}px` }}
216
+ >
217
+ <Checkbox checked={picked} onCheckedChange={() => toggle(row)} onClick={(e) => e.stopPropagation()} aria-label={row.title} className="shrink-0" />
218
+ <BookOpenText className="size-3.5 shrink-0 text-muted-foreground" />
219
+ <span className="min-w-0 flex-1 truncate text-sm">{row.title}</span>
220
+ {filter === null && (
221
+ <span className="shrink-0 text-xs text-muted-foreground">
222
+ {isGeneral ? generalGroupLabel(row.kbName) : row.kbName}
223
+ </span>
224
+ )}
225
+ </div>,
226
+ );
227
+ out.push(...renderRows(row.id, depth + 1));
228
+ }
229
+ return out;
230
+ };
231
+
232
+ const handleConfirm = async () => {
233
+ if (submitting || selectedRows.size === 0) return;
234
+ setSubmitting(true);
235
+ try {
236
+ const itemsToAdd: QuickDocItem[] = [...selectedRows.values()]
237
+ .filter((row) => !existingRef.current.has(row.id))
238
+ .map((row) => {
239
+ const displayTitle = (titles.get(row.id) ?? "").trim() || row.title;
240
+ const label = labels.get(row.id) || undefined;
241
+ const icon = icons.get(row.id) || undefined;
242
+ if (row.id.startsWith("ext:")) {
243
+ const rest = row.id.slice("ext:".length);
244
+ const idx = rest.indexOf(":");
245
+ return {
246
+ externalGroup: rest.slice(0, idx) as QuickDocItem["externalGroup"],
247
+ externalId: rest.slice(idx + 1),
248
+ displayTitle,
249
+ label,
250
+ icon,
251
+ };
252
+ }
253
+ return { docId: row.id, displayTitle, label, icon };
254
+ });
255
+ if (itemsToAdd.length === 0) {
256
+ onOpenChange(false);
257
+ return;
258
+ }
259
+ const res = await addDocs({ data: { workspaceId, items: itemsToAdd } });
260
+ if (res.failed.length > 0) {
261
+ toast.warning(m.workspace_kb_partialFailed({ count: res.failed.length }));
262
+ }
263
+ onAdded();
264
+ onOpenChange(false);
265
+ } catch (err) {
266
+ toast.error((err as Error)?.message || m.workspace_kb_opFailed());
267
+ } finally {
268
+ setSubmitting(false);
269
+ }
270
+ };
271
+
272
+ return (
273
+ <Dialog open={open} onOpenChange={onOpenChange}>
274
+ <DialogContent className="sm:max-w-260 -mt-50">
275
+ <DialogHeader>
276
+ <DialogTitle>{m.workspace_kb_quickDocTitle()}</DialogTitle>
277
+ </DialogHeader>
278
+
279
+ <KbFilterPopover
280
+ search={search}
281
+ onSearchChange={(q) => {
282
+ setSearch(q);
283
+ debouncedSearch(q);
284
+ }}
285
+ filter={filter}
286
+ onFilterChange={(f) => {
287
+ setFilter(f);
288
+ void loadDocs(f, search);
289
+ }}
290
+ userKbs={kbs}
291
+ />
292
+
293
+ <div className="h-60 overflow-y-auto rounded-md border p-1">
294
+ {loading ? (
295
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">…</div>
296
+ ) : items.length > 0 ? (
297
+ renderRows(null, 0)
298
+ ) : (
299
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
300
+ {m.workspace_kb_manageEmpty()}
301
+ </div>
302
+ )}
303
+ </div>
304
+
305
+ {selectedRows.size > 0 && (
306
+ <div className="space-y-1.5">
307
+ <div className="text-xs font-medium text-muted-foreground">
308
+ {m.workspace_kb_pickedDocs()}
309
+ </div>
310
+ <div className="grid max-h-40 grid-cols-2 gap-x-12 gap-y-1.5 overflow-y-auto">
311
+ {[...selectedRows.values()].map((row) => {
312
+ const customIcon = icons.get(row.id);
313
+ return (
314
+ <div key={row.id} className="flex min-w-0 items-center gap-2">
315
+ <LucideIconPicker
316
+ value={customIcon}
317
+ modal
318
+ onChange={(name) =>
319
+ setIcons((prev) => {
320
+ const next = new Map(prev);
321
+ next.set(row.id, name);
322
+ return next;
323
+ })
324
+ }
325
+ >
326
+ <button
327
+ type="button"
328
+ className="flex size-8 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground transition-colors hover:bg-muted"
329
+ aria-label={m.workspace_kb_pickIcon()}
330
+ >
331
+ {customIcon ? (
332
+ <LucideIconByName name={customIcon} size={14} />
333
+ ) : (
334
+ <BookOpenText className="size-3.5" />
335
+ )}
336
+ </button>
337
+ </LucideIconPicker>
338
+ <Input
339
+ value={titles.get(row.id) ?? ""}
340
+ placeholder={row.title}
341
+ onChange={(e) =>
342
+ setTitles((prev) => {
343
+ const next = new Map(prev);
344
+ next.set(row.id, e.target.value);
345
+ return next;
346
+ })
347
+ }
348
+ className="h-8 min-w-0 flex-1"
349
+ aria-label={m.workspace_kb_displayTitlePlaceholder()}
350
+ />
351
+ <Select
352
+ value={labels.get(row.id) ?? ""}
353
+ onValueChange={(v) =>
354
+ setLabels((prev) => {
355
+ const next = new Map(prev);
356
+ next.set(row.id, v as QuickDocLabel | "");
357
+ return next;
358
+ })
359
+ }
360
+ >
361
+ <SelectTrigger className="w-28 shrink-0 text-xs" aria-label={m.workspace_kb_label()}>
362
+ <SelectValue placeholder={m.workspace_kb_labelNone()} />
363
+ </SelectTrigger>
364
+ <SelectContent>
365
+ <SelectItem value="none">{m.workspace_kb_labelNone()}</SelectItem>
366
+ {QUICK_DOC_LABELS.map((key) => (
367
+ <SelectItem key={key} value={key}>
368
+ <span
369
+ className="text-xs inline-flex items-center rounded-full border px-2 py-0.5"
370
+ style={LABEL_COLORS[key]}
371
+ >
372
+ {t(`workspace_kb_label_${key}`)}
373
+ </span>
374
+ </SelectItem>
375
+ ))}
376
+ </SelectContent>
377
+ </Select>
378
+ <Button
379
+ variant="ghost"
380
+ size="sm"
381
+ className="h-7 size-7 p-0"
382
+ onClick={() => toggle(row)}
383
+ aria-label={m.workspace_kb_cancel()}
384
+ >
385
+ <X className="size-3.5" />
386
+ </Button>
387
+ </div>
388
+ );
389
+ })}
390
+ </div>
391
+ </div>
392
+ )}
393
+
394
+ <DialogFooter className="flex items-center justify-between sm:justify-between">
395
+ <label className="flex cursor-pointer items-center gap-2 text-sm">
396
+ <Checkbox
397
+ checked={
398
+ items.length > 0 && selectedRows.size === items.length
399
+ ? true
400
+ : selectedRows.size > 0
401
+ ? "indeterminate"
402
+ : false
403
+ }
404
+ disabled={items.length === 0}
405
+ onCheckedChange={() => {
406
+ setSelectedRows((prev) => {
407
+ if (prev.size === items.length) return new Map();
408
+ return new Map(items.map((row) => [row.id, row]));
409
+ });
410
+ }}
411
+ />
412
+ {m.workspace_kb_selectAll()}
413
+ {selectedRows.size > 0 && (
414
+ <span className="text-xs text-muted-foreground">
415
+ {m.workspace_kb_selectedCount({ count: selectedRows.size })}
416
+ </span>
417
+ )}
418
+ </label>
419
+ <div className="flex items-center gap-2">
420
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
421
+ {m.workspace_kb_cancel()}
422
+ </Button>
423
+ <Button onClick={handleConfirm} disabled={submitting || selectedRows.size === 0}>
424
+ {m.workspace_kb_confirm()}
425
+ </Button>
426
+ </div>
427
+ </DialogFooter>
428
+ </DialogContent>
429
+ </Dialog>
430
+ );
431
+ }