@allbluecn/web-app 0.10.2 → 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.
- package/package.json +26 -11
- package/public/favicon.ico +0 -0
- package/public/logo/allblue-logo-app-icon.svg +7 -0
- package/public/logo/allblue-logo-dark-square.svg +4 -0
- package/public/logo/allblue-logo-dark.svg +4 -0
- package/public/logo/allblue-logo-light-square.svg +4 -0
- package/public/logo/allblue-logo-light.svg +4 -0
- package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
- package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
- package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
- package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
- package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
- package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
- package/src/__tests__/security/xss-sanitize.test.ts +99 -0
- package/src/__tests__/setup.ts +9 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
- package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
- package/src/components/error/global-error-boundary.tsx +2 -2
- package/src/components/knowledge/general-groups.ts +15 -0
- package/src/components/knowledge/kb-filter-popover.tsx +143 -0
- package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
- package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
- package/src/components/layout/sidebar-03/nav-secondary.tsx +57 -47
- package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
- package/src/components/mdx.tsx +9 -0
- package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
- package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
- package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
- package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
- package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
- package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
- package/src/components/story/relations/general-groups.ts +1 -15
- package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
- package/src/components/story/story-list-view.tsx +7 -1
- package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
- package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
- package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
- package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
- package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
- package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
- package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
- package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
- package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
- package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
- package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
- package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
- package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
- package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
- package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
- package/src/lib/__tests__/msw-sample.test.ts +68 -0
- package/src/lib/__tests__/pgvector.test.ts +2 -2
- package/src/lib/changelog/sdk-versions.ts +21 -21
- package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
- package/src/lib/collections/reference/workspaces-collection.ts +3 -1
- package/src/lib/fetch-json.ts +23 -0
- package/src/lib/fumadocs-i18n.ts +7 -0
- package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
- package/src/lib/layout.shared.tsx +40 -0
- package/src/lib/sanitize.ts +14 -1
- package/src/lib/session.ts +9 -1
- package/src/lib/source.ts +15 -0
- package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
- package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
- package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
- package/src/routeTree.gen.ts +65 -0
- package/src/routes/$lang/docs/$/route.lazy.tsx +62 -0
- package/src/routes/$lang/docs/$/route.tsx +52 -0
- package/src/routes/-tests/docs-redirect.test.ts +45 -0
- package/src/routes/__root.tsx +13 -7
- package/src/routes/__tests__/-edition-routing.test.tsx +4 -4
- package/src/routes/__tests__/route-tree-structure.test.ts +65 -0
- package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
- package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
- package/src/routes/_nav/route.tsx +21 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
- package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/api/search.ts +13 -0
- package/src/routes/docs.tsx +9 -0
- package/src/server/__tests__/agent-crud.test.ts +4 -4
- package/src/server/__tests__/chat-conversations.test.ts +1 -3
- package/src/server/__tests__/fetch-guard.test.ts +99 -0
- package/src/server/__tests__/permissions.test.ts +1 -3
- package/src/server/__tests__/resources.team-seats.test.ts +2 -0
- package/src/server/__tests__/skill-crud.test.ts +3 -3
- package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
- package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
- package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
- package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
- package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
- package/src/server/ai-gateway/ai-output-guard.ts +77 -0
- package/src/server/ai-gateway/index.ts +5 -0
- package/src/server/ai-gateway/kb-context-guard.ts +46 -0
- package/src/server/ai-gateway/llm-fence.ts +82 -0
- package/src/server/ai-gateway/persistence/run-store.ts +19 -0
- package/src/server/ai-gateway/story-prompt.ts +4 -2
- package/src/server/ai-gateway/tools/definitions.ts +12 -6
- package/src/server/ai-gateway/tools/server.ts +97 -54
- package/src/server/builtin-project.ts +21 -0
- package/src/server/email/sender.ts +8 -2
- package/src/server/fetch-guard.ts +170 -0
- package/src/server/middlewares/security-headers.ts +113 -0
- package/src/server/serverFns/__tests__/billing.test.ts +263 -0
- package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
- package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
- package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
- package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
- package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
- package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
- package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
- package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
- package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
- package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
- package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
- package/src/server/serverFns/__tests__/team-invitation.test.ts +3 -3
- package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
- package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
- package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
- package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
- package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
- package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
- package/src/server/serverFns/kb-doc-search.ts +111 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
- package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
- package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
- package/src/server/serverFns/story/kb-general-consts.ts +39 -0
- package/src/server/serverFns/story/kb-general.ts +30 -17
- package/src/server/serverFns/story/story-assoc.ts +13 -103
- package/src/server/serverFns/story/story-crud.ts +2 -5
- package/src/server/serverFns/story/story-groups.ts +4 -13
- package/src/server/serverFns/team.ts +1 -1
- package/src/server/serverFns/workspace-kb-utils.ts +138 -0
- package/src/server/serverFns/workspace-kb.ts +403 -0
- package/src/server/serverFns/workspace.ts +4 -1
- package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
- package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
- package/src/shared/ai-cli/adapter.ts +2 -2
- package/src/shared/ai-cli/cli-detector.ts +18 -9
- package/src/start.ts +2 -1
- package/src/styles/globals.css +116 -0
- package/src/types/jest-dom-vitest.d.ts +8 -7
- package/vite.shared.ts +18 -1
- package/public/file.svg +0 -1
- package/public/globe.svg +0 -1
- package/public/window.svg +0 -1
- package/src/lib/__tests__/cli-detector.test.ts +0 -80
- /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
- /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
- /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
- /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
- /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
- /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
// 工作空间知识库卡片:纯类型 + zod schema(不 import prisma,客户端可安全导入)。
|
|
19
|
+
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { GENERAL_GROUPS, externalDocId, type GeneralGroup } from "./story/kb-general-consts";
|
|
22
|
+
|
|
23
|
+
export const QUICK_DOC_TITLE_MAX = 200;
|
|
24
|
+
|
|
25
|
+
/** 知识库卡片快捷文档的固定列数(看板列数) */
|
|
26
|
+
export const QUICK_DOC_COLUMNS = 5;
|
|
27
|
+
|
|
28
|
+
/** 快捷文档标签枚举(存 DB 的 key,展示文案走 i18n workspace_kb_label_*) */
|
|
29
|
+
export const QUICK_DOC_LABELS = [
|
|
30
|
+
"mrd",
|
|
31
|
+
"brd",
|
|
32
|
+
"feasibility",
|
|
33
|
+
"competitor",
|
|
34
|
+
"charter",
|
|
35
|
+
"infoArchitecture",
|
|
36
|
+
"techArchitecture",
|
|
37
|
+
"dataStructure",
|
|
38
|
+
"integration",
|
|
39
|
+
"copywriting",
|
|
40
|
+
"dictionary",
|
|
41
|
+
"legal",
|
|
42
|
+
"compliance",
|
|
43
|
+
"kbDictionary",
|
|
44
|
+
"website",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type QuickDocLabel = (typeof QUICK_DOC_LABELS)[number];
|
|
48
|
+
|
|
49
|
+
/** 通用知识库分组 → 勾选时自动填充的默认标签 */
|
|
50
|
+
export const GENERAL_GROUP_DEFAULT_LABEL: Record<string, QuickDocLabel> = {
|
|
51
|
+
legal: "legal",
|
|
52
|
+
compliance: "compliance",
|
|
53
|
+
dictionary: "kbDictionary",
|
|
54
|
+
bookmark: "website",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** 标签徽章莫兰迪配色(CSS 变量引用,light/dark 双主题见 globals.css「知识库快捷文档标签莫兰迪色板」) */
|
|
58
|
+
export const LABEL_COLORS: Record<string, { backgroundColor: string; borderColor: string; color: string }> = {
|
|
59
|
+
mrd: { backgroundColor: "var(--kb-label-mrd-bg)", borderColor: "var(--kb-label-mrd-border)", color: "var(--kb-label-mrd-text)" },
|
|
60
|
+
brd: { backgroundColor: "var(--kb-label-brd-bg)", borderColor: "var(--kb-label-brd-border)", color: "var(--kb-label-brd-text)" },
|
|
61
|
+
feasibility: { backgroundColor: "var(--kb-label-feasibility-bg)", borderColor: "var(--kb-label-feasibility-border)", color: "var(--kb-label-feasibility-text)" },
|
|
62
|
+
competitor: { backgroundColor: "var(--kb-label-competitor-bg)", borderColor: "var(--kb-label-competitor-border)", color: "var(--kb-label-competitor-text)" },
|
|
63
|
+
charter: { backgroundColor: "var(--kb-label-charter-bg)", borderColor: "var(--kb-label-charter-border)", color: "var(--kb-label-charter-text)" },
|
|
64
|
+
infoArchitecture: { backgroundColor: "var(--kb-label-infoArchitecture-bg)", borderColor: "var(--kb-label-infoArchitecture-border)", color: "var(--kb-label-infoArchitecture-text)" },
|
|
65
|
+
techArchitecture: { backgroundColor: "var(--kb-label-techArchitecture-bg)", borderColor: "var(--kb-label-techArchitecture-border)", color: "var(--kb-label-techArchitecture-text)" },
|
|
66
|
+
dataStructure: { backgroundColor: "var(--kb-label-dataStructure-bg)", borderColor: "var(--kb-label-dataStructure-border)", color: "var(--kb-label-dataStructure-text)" },
|
|
67
|
+
integration: { backgroundColor: "var(--kb-label-integration-bg)", borderColor: "var(--kb-label-integration-border)", color: "var(--kb-label-integration-text)" },
|
|
68
|
+
copywriting: { backgroundColor: "var(--kb-label-copywriting-bg)", borderColor: "var(--kb-label-copywriting-border)", color: "var(--kb-label-copywriting-text)" },
|
|
69
|
+
dictionary: { backgroundColor: "var(--kb-label-dictionary-bg)", borderColor: "var(--kb-label-dictionary-border)", color: "var(--kb-label-dictionary-text)" },
|
|
70
|
+
legal: { backgroundColor: "var(--kb-label-legal-bg)", borderColor: "var(--kb-label-legal-border)", color: "var(--kb-label-legal-text)" },
|
|
71
|
+
compliance: { backgroundColor: "var(--kb-label-compliance-bg)", borderColor: "var(--kb-label-compliance-border)", color: "var(--kb-label-compliance-text)" },
|
|
72
|
+
kbDictionary: { backgroundColor: "var(--kb-label-kbDictionary-bg)", borderColor: "var(--kb-label-kbDictionary-border)", color: "var(--kb-label-kbDictionary-text)" },
|
|
73
|
+
website: { backgroundColor: "var(--kb-label-website-bg)", borderColor: "var(--kb-label-website-border)", color: "var(--kb-label-website-text)" },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const quickDocItemSchema = z
|
|
77
|
+
.object({
|
|
78
|
+
docId: z.string().optional(),
|
|
79
|
+
externalGroup: z.enum(GENERAL_GROUPS).optional(),
|
|
80
|
+
externalId: z.string().optional(),
|
|
81
|
+
displayTitle: z.string().min(1).max(QUICK_DOC_TITLE_MAX),
|
|
82
|
+
label: z.enum(QUICK_DOC_LABELS).optional(),
|
|
83
|
+
icon: z.string().optional(),
|
|
84
|
+
})
|
|
85
|
+
.refine(
|
|
86
|
+
(v) =>
|
|
87
|
+
v.docId ? !v.externalGroup && !v.externalId : Boolean(v.externalGroup && v.externalId),
|
|
88
|
+
{ message: "docId 与 externalGroup/externalId 必须二选一" },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
export type QuickDocItem = z.infer<typeof quickDocItemSchema>;
|
|
92
|
+
|
|
93
|
+
export type KbBrief = { id: string; name: string; icon: string; visibility: string };
|
|
94
|
+
|
|
95
|
+
export type QuickDocRow = {
|
|
96
|
+
id: string;
|
|
97
|
+
displayTitle: string;
|
|
98
|
+
columnIndex: number;
|
|
99
|
+
sortOrder: number;
|
|
100
|
+
label: string | null;
|
|
101
|
+
icon: string | null;
|
|
102
|
+
source: "doc" | "general";
|
|
103
|
+
docId: string | null;
|
|
104
|
+
kbId: string | null;
|
|
105
|
+
kbName: string | null;
|
|
106
|
+
docIcon: string | null;
|
|
107
|
+
group: GeneralGroup | null;
|
|
108
|
+
externalId: string | null;
|
|
109
|
+
faviconUrl: string | null;
|
|
110
|
+
sourceTitle: string | null;
|
|
111
|
+
/** 外部条目的直接 URL(bookmark 等类型);null 表示走内部路由 */
|
|
112
|
+
url: string | null;
|
|
113
|
+
stale: boolean;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type WorkspaceKbCardData = { kbs: KbBrief[]; quickDocs: QuickDocRow[] };
|
|
117
|
+
|
|
118
|
+
export function quickDocItemKey(
|
|
119
|
+
item: Pick<QuickDocItem, "docId" | "externalGroup" | "externalId">,
|
|
120
|
+
): string {
|
|
121
|
+
return item.docId ?? externalDocId(item.externalGroup!, item.externalId!);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 外部条目 → 详情路由路径 */
|
|
125
|
+
export function generalDocHref(group: GeneralGroup, externalId: string): string {
|
|
126
|
+
switch (group) {
|
|
127
|
+
case "legal":
|
|
128
|
+
return `/knowledge/laws/${externalId}`;
|
|
129
|
+
case "compliance":
|
|
130
|
+
return `/knowledge/compliance/${externalId}`;
|
|
131
|
+
case "dictionary":
|
|
132
|
+
return `/knowledge/dictionaries/${externalId}`;
|
|
133
|
+
case "bookmark":
|
|
134
|
+
return `/knowledge/bookmarks/${externalId}`;
|
|
135
|
+
case "summary":
|
|
136
|
+
return "/knowledge/summary";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
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
|
+
// 工作空间知识库卡片 serverFn 模块(纯净:仅导出 createServerFn 调用)。
|
|
19
|
+
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
22
|
+
import { getRequest } from "@tanstack/react-start/server";
|
|
23
|
+
import { prisma } from "@allbluecn/database";
|
|
24
|
+
import { requireAuth } from "@/server/auth-helpers";
|
|
25
|
+
import { GENERAL_GROUP_TABLES, GENERAL_GROUPS, type GeneralGroup } from "./story/kb-general";
|
|
26
|
+
import type { QuickDocRow, WorkspaceKbCardData, QuickDocItem } from "./workspace-kb-utils";
|
|
27
|
+
import {
|
|
28
|
+
searchGeneralGroupDocs,
|
|
29
|
+
listUserTeamKbIds,
|
|
30
|
+
SEARCH_DOC_SELECT,
|
|
31
|
+
toSearchDocRow,
|
|
32
|
+
type SearchDocRow,
|
|
33
|
+
} from "./kb-doc-search";
|
|
34
|
+
import { quickDocItemSchema, quickDocItemKey, QUICK_DOC_COLUMNS } from "./workspace-kb-utils";
|
|
35
|
+
|
|
36
|
+
/** owner 或 active 成员可访问 */
|
|
37
|
+
async function requireWorkspaceMember(workspaceId: string, userId: string) {
|
|
38
|
+
const ws = await prisma.workspace.findUnique({
|
|
39
|
+
where: { id: workspaceId },
|
|
40
|
+
select: { ownerId: true, members: { where: { userId, status: "active" }, select: { id: true } } },
|
|
41
|
+
});
|
|
42
|
+
if (!ws) throw new Error("工作空间不存在");
|
|
43
|
+
if (ws.ownerId !== userId && ws.members.length === 0) throw new Error("仅工作空间成员可访问");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const getWorkspaceKbCardFn = createServerFn({ method: "GET" })
|
|
47
|
+
.validator(z.object({ workspaceId: z.string() }))
|
|
48
|
+
.handler(async ({ data }): Promise<WorkspaceKbCardData> => {
|
|
49
|
+
const request = getRequest();
|
|
50
|
+
const session = await requireAuth(request);
|
|
51
|
+
const userId = session.user!.id!;
|
|
52
|
+
await requireWorkspaceMember(data.workspaceId, userId);
|
|
53
|
+
|
|
54
|
+
const [linkRows, quickRows] = await Promise.all([
|
|
55
|
+
prisma.workspaceKnowledgeBase.findMany({
|
|
56
|
+
where: { workspaceId: data.workspaceId },
|
|
57
|
+
select: { kb: { select: { id: true, name: true, icon: true, visibility: true } } },
|
|
58
|
+
orderBy: { createdAt: "asc" },
|
|
59
|
+
}),
|
|
60
|
+
prisma.workspaceQuickDoc.findMany({
|
|
61
|
+
where: { workspaceId: data.workspaceId },
|
|
62
|
+
orderBy: [{ columnIndex: "asc" }, { sortOrder: "asc" }],
|
|
63
|
+
select: {
|
|
64
|
+
id: true, displayTitle: true, columnIndex: true, sortOrder: true, label: true, icon: true,
|
|
65
|
+
docId: true, externalGroup: true, externalId: true,
|
|
66
|
+
doc: {
|
|
67
|
+
select: { title: true, icon: true, isDeleted: true, kb: { select: { id: true, name: true } } },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// 外部条目按组批量查当前标题(displayTitle 之外的兜底参考)
|
|
74
|
+
const extTitles = new Map<string, string | null>();
|
|
75
|
+
const extFavicons = new Map<string, string | null>();
|
|
76
|
+
const extUrls = new Map<string, string | null>();
|
|
77
|
+
const byGroup = new Map<Exclude<GeneralGroup, "summary">, string[]>();
|
|
78
|
+
for (const r of quickRows) {
|
|
79
|
+
if (!r.docId && r.externalGroup && r.externalId) {
|
|
80
|
+
if (r.externalGroup === "summary") {
|
|
81
|
+
extTitles.set(`summary:${r.externalId}`, null);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const group = r.externalGroup as Exclude<GeneralGroup, "summary">;
|
|
85
|
+
const arr = byGroup.get(group) ?? [];
|
|
86
|
+
arr.push(r.externalId);
|
|
87
|
+
byGroup.set(group, arr);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const [group, ids] of byGroup) {
|
|
91
|
+
const rows = await (
|
|
92
|
+
prisma[GENERAL_GROUP_TABLES[group]] as unknown as {
|
|
93
|
+
findMany: (args: Record<string, unknown>) => Promise<Array<{ id: string; title: string; faviconUrl?: string | null; url?: string | null }>>;
|
|
94
|
+
}
|
|
95
|
+
).findMany({ where: { id: { in: ids } }, select: { id: true, title: true, faviconUrl: true, url: true } });
|
|
96
|
+
const map = new Map(rows.map((r) => [r.id, r.title]));
|
|
97
|
+
const faviconMap = new Map(rows.map((r) => [r.id, r.faviconUrl ?? null]));
|
|
98
|
+
const urlMap = new Map(rows.map((r) => [r.id, (r.url as string | null) ?? null]));
|
|
99
|
+
for (const id of ids) extTitles.set(`${group}:${id}`, map.get(id) ?? null);
|
|
100
|
+
for (const id of ids) extFavicons.set(`${group}:${id}`, faviconMap.get(id) ?? null);
|
|
101
|
+
for (const id of ids) extUrls.set(`${group}:${id}`, urlMap.get(id) ?? null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
kbs: linkRows.map((r) => r.kb),
|
|
106
|
+
quickDocs: quickRows.map((r): QuickDocRow => {
|
|
107
|
+
if (r.docId) {
|
|
108
|
+
const stale = !r.doc || r.doc.isDeleted;
|
|
109
|
+
return {
|
|
110
|
+
id: r.id, displayTitle: r.displayTitle, columnIndex: r.columnIndex, sortOrder: r.sortOrder, label: r.label, icon: r.icon,
|
|
111
|
+
source: "doc", docId: r.docId,
|
|
112
|
+
kbId: r.doc?.kb.id ?? null, kbName: r.doc?.kb.name ?? null,
|
|
113
|
+
docIcon: r.doc?.icon ?? null, group: null, externalId: null,
|
|
114
|
+
faviconUrl: null, sourceTitle: r.doc?.title ?? null, url: null, stale,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const sourceTitle = r.externalGroup
|
|
118
|
+
? (extTitles.get(`${r.externalGroup}:${r.externalId}`) ?? null)
|
|
119
|
+
: null;
|
|
120
|
+
const faviconUrl = r.externalGroup && r.externalGroup !== "summary"
|
|
121
|
+
? (extFavicons.get(`${r.externalGroup}:${r.externalId}`) ?? null)
|
|
122
|
+
: null;
|
|
123
|
+
return {
|
|
124
|
+
id: r.id, displayTitle: r.displayTitle, columnIndex: r.columnIndex, sortOrder: r.sortOrder, label: r.label, icon: r.icon,
|
|
125
|
+
source: "general", docId: null, kbId: null, kbName: null, docIcon: null,
|
|
126
|
+
group: (r.externalGroup as GeneralGroup | null), externalId: r.externalId,
|
|
127
|
+
faviconUrl, sourceTitle, url: r.externalGroup ? (extUrls.get(`${r.externalGroup}:${r.externalId}`) ?? null) : null,
|
|
128
|
+
stale: sourceTitle === null,
|
|
129
|
+
};
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const listWorkspaceAvailableKbsFn = createServerFn({ method: "GET" })
|
|
135
|
+
.validator(z.object({ workspaceId: z.string() }))
|
|
136
|
+
.handler(async ({ data }) => {
|
|
137
|
+
const request = getRequest();
|
|
138
|
+
const session = await requireAuth(request);
|
|
139
|
+
const userId = session.user!.id!;
|
|
140
|
+
await requireWorkspaceMember(data.workspaceId, userId);
|
|
141
|
+
|
|
142
|
+
const linked = await prisma.workspaceKnowledgeBase.findMany({
|
|
143
|
+
where: { workspaceId: data.workspaceId },
|
|
144
|
+
select: { kbId: true },
|
|
145
|
+
});
|
|
146
|
+
const linkedIds = new Set(linked.map((l) => l.kbId));
|
|
147
|
+
// 候选范围:本空间已关联 ∪ 我创建的 team 库(manage 弹窗需完整候选;快捷文档弹窗侧仅取已关联)
|
|
148
|
+
const kbs = await prisma.knowledgeBase.findMany({
|
|
149
|
+
where: {
|
|
150
|
+
isDeleted: false,
|
|
151
|
+
OR: [{ id: { in: [...linkedIds] } }, { visibility: "team", userId }],
|
|
152
|
+
},
|
|
153
|
+
select: { id: true, name: true, icon: true, visibility: true },
|
|
154
|
+
orderBy: { name: "asc" },
|
|
155
|
+
});
|
|
156
|
+
return kbs.map((k) => ({ ...k, linked: linkedIds.has(k.id) }));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
export const setWorkspaceKbsFn = createServerFn({ method: "POST" })
|
|
160
|
+
.validator(z.object({ workspaceId: z.string(), kbIds: z.array(z.string()) }))
|
|
161
|
+
.handler(async ({ data }) => {
|
|
162
|
+
const request = getRequest();
|
|
163
|
+
const session = await requireAuth(request);
|
|
164
|
+
await requireWorkspaceMember(data.workspaceId, session.user!.id!);
|
|
165
|
+
|
|
166
|
+
if (data.kbIds.length > 0) {
|
|
167
|
+
const valid = await prisma.knowledgeBase.findMany({
|
|
168
|
+
where: { id: { in: data.kbIds }, visibility: "team", isDeleted: false },
|
|
169
|
+
select: { id: true },
|
|
170
|
+
});
|
|
171
|
+
if (valid.length !== data.kbIds.length) {
|
|
172
|
+
throw new Error("仅允许团队可见的知识库关联工作空间");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
await prisma.$transaction(async (tx) => {
|
|
176
|
+
await tx.workspaceKnowledgeBase.deleteMany({
|
|
177
|
+
where: { workspaceId: data.workspaceId, kbId: { notIn: data.kbIds } },
|
|
178
|
+
});
|
|
179
|
+
if (data.kbIds.length > 0) {
|
|
180
|
+
await tx.workspaceKnowledgeBase.createMany({
|
|
181
|
+
data: data.kbIds.map((kbId) => ({ workspaceId: data.workspaceId, kbId })),
|
|
182
|
+
skipDuplicates: true,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
return { ok: true };
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
export const searchQuickDocCandidatesFn = createServerFn({ method: "GET" })
|
|
190
|
+
.validator(
|
|
191
|
+
z.object({
|
|
192
|
+
workspaceId: z.string(),
|
|
193
|
+
kbId: z.string().optional(),
|
|
194
|
+
generalGroup: z.enum(GENERAL_GROUPS).optional(),
|
|
195
|
+
query: z.string().default(""),
|
|
196
|
+
}),
|
|
197
|
+
)
|
|
198
|
+
.handler(async ({ data }) => {
|
|
199
|
+
const request = getRequest();
|
|
200
|
+
const session = await requireAuth(request);
|
|
201
|
+
const userId = session.user!.id!;
|
|
202
|
+
await requireWorkspaceMember(data.workspaceId, userId);
|
|
203
|
+
|
|
204
|
+
if (data.generalGroup) {
|
|
205
|
+
const items = await searchGeneralGroupDocs(data.generalGroup, userId, data.query);
|
|
206
|
+
return { items };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const teamKbIds = await listUserTeamKbIds(userId);
|
|
210
|
+
|
|
211
|
+
if (data.kbId) {
|
|
212
|
+
if (!teamKbIds.includes(data.kbId)) throw new Error("知识库不在可用范围内");
|
|
213
|
+
const docs = await prisma.knowledgeDocument.findMany({
|
|
214
|
+
where: {
|
|
215
|
+
kbId: data.kbId,
|
|
216
|
+
isDeleted: false,
|
|
217
|
+
...(data.query ? { title: { contains: data.query, mode: "insensitive" as const } } : {}),
|
|
218
|
+
},
|
|
219
|
+
select: SEARCH_DOC_SELECT,
|
|
220
|
+
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
|
221
|
+
take: 500,
|
|
222
|
+
});
|
|
223
|
+
return { items: docs.map(toSearchDocRow) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 默认态:用户全部 team 库文档平铺(按时间倒序,截断 100)
|
|
227
|
+
const rows = await prisma.knowledgeDocument.findMany({
|
|
228
|
+
where: {
|
|
229
|
+
isDeleted: false,
|
|
230
|
+
kb: { isDeleted: false, id: { in: teamKbIds } },
|
|
231
|
+
...(data.query ? { title: { contains: data.query, mode: "insensitive" as const } } : {}),
|
|
232
|
+
},
|
|
233
|
+
select: SEARCH_DOC_SELECT,
|
|
234
|
+
orderBy: { createdAt: "desc" },
|
|
235
|
+
take: 100,
|
|
236
|
+
});
|
|
237
|
+
return { items: rows.map(toSearchDocRow) };
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
export const addQuickDocsFn = createServerFn({ method: "POST" })
|
|
241
|
+
.validator(z.object({ workspaceId: z.string(), items: z.array(quickDocItemSchema).min(1) }))
|
|
242
|
+
.handler(async ({ data }) => {
|
|
243
|
+
const request = getRequest();
|
|
244
|
+
const session = await requireAuth(request);
|
|
245
|
+
const userId = session.user!.id!;
|
|
246
|
+
await requireWorkspaceMember(data.workspaceId, userId);
|
|
247
|
+
|
|
248
|
+
const existing = await prisma.workspaceQuickDoc.findMany({
|
|
249
|
+
where: { workspaceId: data.workspaceId },
|
|
250
|
+
select: { columnIndex: true, sortOrder: true },
|
|
251
|
+
});
|
|
252
|
+
// 看板式追加:新文档放到「最短列」末尾(并列取最左),保持各列视觉均衡
|
|
253
|
+
const counts = new Array(QUICK_DOC_COLUMNS).fill(0) as number[];
|
|
254
|
+
const maxOrder = new Array(QUICK_DOC_COLUMNS).fill(-1) as number[];
|
|
255
|
+
for (const row of existing) {
|
|
256
|
+
const c = Math.min(Math.max(row.columnIndex, 0), QUICK_DOC_COLUMNS - 1);
|
|
257
|
+
counts[c] += 1;
|
|
258
|
+
maxOrder[c] = Math.max(maxOrder[c], row.sortOrder);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const created: string[] = [];
|
|
262
|
+
const failed: Array<{ key: string; reason: "outOfScope" | "duplicate" }> = [];
|
|
263
|
+
|
|
264
|
+
for (const item of data.items as QuickDocItem[]) {
|
|
265
|
+
const key = quickDocItemKey(item);
|
|
266
|
+
const targetCol = counts.indexOf(Math.min(...counts));
|
|
267
|
+
const order = maxOrder[targetCol] + 1;
|
|
268
|
+
if (item.docId) {
|
|
269
|
+
const doc = await prisma.knowledgeDocument.findFirst({
|
|
270
|
+
where: { id: item.docId, isDeleted: false },
|
|
271
|
+
select: { id: true },
|
|
272
|
+
});
|
|
273
|
+
if (!doc) {
|
|
274
|
+
failed.push({ key, reason: "outOfScope" });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const dup = await prisma.workspaceQuickDoc.findFirst({
|
|
278
|
+
where: { workspaceId: data.workspaceId, docId: item.docId },
|
|
279
|
+
select: { id: true },
|
|
280
|
+
});
|
|
281
|
+
if (dup) {
|
|
282
|
+
failed.push({ key, reason: "duplicate" });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
await prisma.workspaceQuickDoc.create({
|
|
286
|
+
data: {
|
|
287
|
+
workspaceId: data.workspaceId, docId: item.docId,
|
|
288
|
+
displayTitle: item.displayTitle, columnIndex: targetCol, sortOrder: order, label: item.label ?? null, icon: item.icon ?? null, createdBy: userId,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
counts[targetCol] += 1;
|
|
292
|
+
maxOrder[targetCol] = order;
|
|
293
|
+
created.push(key);
|
|
294
|
+
} else {
|
|
295
|
+
const group = item.externalGroup!;
|
|
296
|
+
const exists =
|
|
297
|
+
group === "summary"
|
|
298
|
+
? null
|
|
299
|
+
: await (
|
|
300
|
+
prisma[GENERAL_GROUP_TABLES[group as Exclude<GeneralGroup, "summary">]] as unknown as {
|
|
301
|
+
findFirst: (args: Record<string, unknown>) => Promise<{ id: string } | null>;
|
|
302
|
+
}
|
|
303
|
+
).findFirst({ where: { id: item.externalId!, isDeleted: false }, select: { id: true } });
|
|
304
|
+
if (!exists) {
|
|
305
|
+
failed.push({ key, reason: "outOfScope" });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const dup = await prisma.workspaceQuickDoc.findFirst({
|
|
309
|
+
where: { workspaceId: data.workspaceId, externalGroup: group, externalId: item.externalId },
|
|
310
|
+
select: { id: true },
|
|
311
|
+
});
|
|
312
|
+
if (dup) {
|
|
313
|
+
failed.push({ key, reason: "duplicate" });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
await prisma.workspaceQuickDoc.create({
|
|
317
|
+
data: {
|
|
318
|
+
workspaceId: data.workspaceId, externalGroup: group, externalId: item.externalId,
|
|
319
|
+
displayTitle: item.displayTitle, columnIndex: targetCol, sortOrder: order, label: item.label ?? null, icon: item.icon ?? null, createdBy: userId,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
counts[targetCol] += 1;
|
|
323
|
+
maxOrder[targetCol] = order;
|
|
324
|
+
created.push(key);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { created, failed };
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
export const renameQuickDocFn = createServerFn({ method: "POST" })
|
|
331
|
+
.validator(
|
|
332
|
+
z.object({
|
|
333
|
+
quickDocId: z.string(),
|
|
334
|
+
displayTitle: z.string().min(1).max(200),
|
|
335
|
+
/** 传入空串清除自定义图标;不传保持不变 */
|
|
336
|
+
icon: z.string().optional(),
|
|
337
|
+
}),
|
|
338
|
+
)
|
|
339
|
+
.handler(async ({ data }) => {
|
|
340
|
+
const request = getRequest();
|
|
341
|
+
const session = await requireAuth(request);
|
|
342
|
+
const row = await prisma.workspaceQuickDoc.findUnique({
|
|
343
|
+
where: { id: data.quickDocId },
|
|
344
|
+
select: { workspaceId: true },
|
|
345
|
+
});
|
|
346
|
+
if (!row) throw new Error("快捷文档不存在");
|
|
347
|
+
await requireWorkspaceMember(row.workspaceId, session.user!.id!);
|
|
348
|
+
await prisma.workspaceQuickDoc.update({
|
|
349
|
+
where: { id: data.quickDocId },
|
|
350
|
+
data: {
|
|
351
|
+
displayTitle: data.displayTitle,
|
|
352
|
+
...(data.icon !== undefined ? { icon: data.icon || null } : {}),
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
return { ok: true };
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
export const removeQuickDocFn = createServerFn({ method: "POST" })
|
|
359
|
+
.validator(z.object({ quickDocId: z.string() }))
|
|
360
|
+
.handler(async ({ data }) => {
|
|
361
|
+
const request = getRequest();
|
|
362
|
+
const session = await requireAuth(request);
|
|
363
|
+
const row = await prisma.workspaceQuickDoc.findUnique({
|
|
364
|
+
where: { id: data.quickDocId },
|
|
365
|
+
select: { workspaceId: true },
|
|
366
|
+
});
|
|
367
|
+
if (!row) throw new Error("快捷文档不存在");
|
|
368
|
+
await requireWorkspaceMember(row.workspaceId, session.user!.id!);
|
|
369
|
+
await prisma.workspaceQuickDoc.delete({ where: { id: data.quickDocId } });
|
|
370
|
+
return { ok: true };
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
export const reorderQuickDocsFn = createServerFn({ method: "POST" })
|
|
374
|
+
.validator(
|
|
375
|
+
z.object({
|
|
376
|
+
workspaceId: z.string(),
|
|
377
|
+
items: z.array(
|
|
378
|
+
z.object({ id: z.string(), columnIndex: z.number().int().min(0), sortOrder: z.number().int().min(0) }),
|
|
379
|
+
),
|
|
380
|
+
}),
|
|
381
|
+
)
|
|
382
|
+
.handler(async ({ data }) => {
|
|
383
|
+
const request = getRequest();
|
|
384
|
+
const session = await requireAuth(request);
|
|
385
|
+
await requireWorkspaceMember(data.workspaceId, session.user!.id!);
|
|
386
|
+
const ids = data.items.map((item) => item.id);
|
|
387
|
+
const owned = await prisma.workspaceQuickDoc.findMany({
|
|
388
|
+
where: { id: { in: ids }, workspaceId: data.workspaceId },
|
|
389
|
+
select: { id: true },
|
|
390
|
+
});
|
|
391
|
+
if (owned.length !== new Set(ids).size) {
|
|
392
|
+
throw new Error("包含不属于本工作空间的快捷文档");
|
|
393
|
+
}
|
|
394
|
+
await prisma.$transaction(
|
|
395
|
+
data.items.map((item) =>
|
|
396
|
+
prisma.workspaceQuickDoc.update({
|
|
397
|
+
where: { id: item.id },
|
|
398
|
+
data: { columnIndex: item.columnIndex, sortOrder: item.sortOrder },
|
|
399
|
+
}),
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
return { ok: true };
|
|
403
|
+
});
|
|
@@ -141,7 +141,7 @@ export const createWorkspaceFn = createServerFn({ method: "POST" })
|
|
|
141
141
|
ownerId: userId,
|
|
142
142
|
},
|
|
143
143
|
});
|
|
144
|
-
await tx.knowledgeBase.create({
|
|
144
|
+
const kb = await tx.knowledgeBase.create({
|
|
145
145
|
data: {
|
|
146
146
|
name: data.name,
|
|
147
147
|
description: `${data.name}工作空间同步创建的知识库`,
|
|
@@ -152,6 +152,9 @@ export const createWorkspaceFn = createServerFn({ method: "POST" })
|
|
|
152
152
|
workspaceId: workspace.id,
|
|
153
153
|
},
|
|
154
154
|
});
|
|
155
|
+
await tx.workspaceKnowledgeBase.create({
|
|
156
|
+
data: { workspaceId: workspace.id, kbId: kb.id },
|
|
157
|
+
});
|
|
155
158
|
return workspace;
|
|
156
159
|
});
|
|
157
160
|
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, vi } from "vitest"
|
|
2
|
+
import type { CLIAgentInfo } from "@allbluecn/kernel"
|
|
3
|
+
|
|
4
|
+
const execFileSyncMock = vi.hoisted(() => vi.fn())
|
|
5
|
+
const execSyncMock = vi.hoisted(() => vi.fn())
|
|
6
|
+
|
|
7
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal<typeof import("node:child_process")>()
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
execFileSync: execFileSyncMock,
|
|
12
|
+
execSync: execSyncMock,
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
vi.mock("child_process", async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal<typeof import("child_process")>()
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
execFileSync: execFileSyncMock,
|
|
21
|
+
execSync: execSyncMock,
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const testAgent: CLIAgentInfo = {
|
|
26
|
+
id: "claude-code",
|
|
27
|
+
name: "Claude Code",
|
|
28
|
+
binary: "claude",
|
|
29
|
+
version: "v1",
|
|
30
|
+
available: true,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("executeViaAgent 命令执行安全", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
execFileSyncMock.mockReset()
|
|
36
|
+
execSyncMock.mockReset()
|
|
37
|
+
vi.resetModules()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("用 execFileSync 数组参数调用 CLI(无 shell 拼接)", async () => {
|
|
41
|
+
execFileSyncMock.mockReturnValue(" hello response \n")
|
|
42
|
+
const { executeViaAgent } = await import("../adapter")
|
|
43
|
+
const result = await executeViaAgent(testAgent, "你好", {} as never)
|
|
44
|
+
expect(result).toBe("hello response")
|
|
45
|
+
expect(execFileSyncMock).toHaveBeenCalledWith(
|
|
46
|
+
"claude",
|
|
47
|
+
["-p", "你好"],
|
|
48
|
+
expect.objectContaining({ timeout: 60000, encoding: "utf-8" })
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("execSync 未被调用(锁定无 shell 注入不变量)", async () => {
|
|
53
|
+
execFileSyncMock.mockReturnValue("ok")
|
|
54
|
+
const { executeViaAgent } = await import("../adapter")
|
|
55
|
+
await executeViaAgent(testAgent, "test", {} as never)
|
|
56
|
+
expect(execSyncMock).not.toHaveBeenCalled()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("prompt 中的 shell 元字符作为字面量传递(防注入回归)", async () => {
|
|
60
|
+
const dangerous = `"; rm -rf /; echo $(whoami) \`id\`" && whoami`
|
|
61
|
+
execFileSyncMock.mockReturnValue("safe")
|
|
62
|
+
const { executeViaAgent } = await import("../adapter")
|
|
63
|
+
await executeViaAgent(testAgent, dangerous, {} as never)
|
|
64
|
+
expect(execFileSyncMock.mock.calls[0]![1]).toEqual(["-p", dangerous])
|
|
65
|
+
expect(execSyncMock).not.toHaveBeenCalled()
|
|
66
|
+
})
|
|
67
|
+
})
|