@allbluecn/web-app 0.22.0 → 0.22.3
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 +6 -8
- package/public/logo/allblue-logo-app-icon.svg +1 -1
- package/src/components/changelog/changelog-page.tsx +24 -5
- package/src/components/desktop/cloud-login.tsx +91 -0
- package/src/components/desktop/conflict-center.tsx +148 -0
- package/src/components/desktop/renamed-toast.tsx +23 -0
- package/src/components/desktop/shell-widgets.tsx +19 -0
- package/src/components/desktop/sync-indicator.tsx +60 -0
- package/src/components/desktop/sync-status-card.tsx +60 -0
- package/src/components/layout/sidebar-03/app-sidebar.tsx +38 -3
- package/src/components/shared-kit/avatars/__tests__/avatar-shape.test.ts +17 -1
- package/src/components/shared-kit/avatars/avatar-shape.ts +10 -0
- package/src/components/shared-kit/avatars/user-avatar.tsx +2 -2
- package/src/lib/auth/auth.config.ts +4 -1
- package/src/lib/changelog/sdk-versions.ts +33 -2
- package/src/lib/desktop-bridge-types.ts +30 -0
- package/src/lib/desktop-detect.ts +2 -0
- package/src/lib/vector-store/__tests__/factory.test.ts +0 -7
- package/src/lib/vector-store/index.ts +2 -4
- package/src/paraglide/messages/_index.d.ts +1 -1
- package/src/paraglide/messages/_index.js +32 -16
- package/src/paraglide/messages/common_sidebar_usermenu_loginsaas3.d.ts +1 -1
- package/src/paraglide/messages/common_sidebar_usermenu_loginsaas3.js +3 -3
- package/src/paraglide/messages/en.js +11 -6
- package/src/paraglide/messages/{tasks_ai_desktop_notice.d.ts → settings_tabs_sync.d.ts} +5 -5
- package/src/paraglide/messages/settings_tabs_sync.js +29 -0
- package/src/paraglide/messages/zh.js +11 -6
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +107 -49
- package/src/routes/_nav/route.lazy.tsx +12 -0
- package/src/routes/_nav/settings/-settings-tabs.tsx +1 -1
- package/src/routes/_nav/settings/sync/route.lazy.tsx +40 -0
- package/src/routes/_nav/settings/sync/route.tsx +15 -0
- package/src/routes/_nav/tasks/index.lazy.tsx +1 -11
- package/src/routes/api/billing-webhook.ts +6 -2
- package/src/routes/api/sync/bootstrap.ts +38 -0
- package/src/routes/api/sync/pull.ts +96 -0
- package/src/routes/api/sync/push.ts +169 -0
- package/src/routes/api/sync/snapshot.ts +104 -0
- package/src/server/__tests__/fetch-guard.test.ts +2 -2
- package/src/server/__tests__/resource-registry.test.ts +4 -2
- package/src/server/__tests__/sandbox-entitlement.test.ts +5 -0
- package/src/server/__tests__/security-headers.test.ts +25 -0
- package/src/server/__tests__/task-assigned-query.test.ts +0 -11
- package/src/server/__tests__/task-serverfn-crud.test.ts +1 -22
- package/src/server/__tests__/task-serverfn-queries.test.ts +1 -9
- package/src/server/ai-gateway/__tests__/chat-stream-builder-memory.test.ts +0 -11
- package/src/server/ai-gateway/__tests__/embedding-request.test.ts +1 -1
- package/src/server/ai-gateway/__tests__/provider-adapter.test.ts +2 -2
- package/src/server/ai-gateway/__tests__/run-store-durable.test.ts +1 -1
- package/src/server/ai-gateway/durability/__tests__/pg-stream.test.ts +1 -1
- package/src/server/ai-gateway/durability/__tests__/reaper-real-db.test.ts +1 -1
- package/src/server/ai-gateway/memory/index.ts +2 -2
- package/src/server/ai-gateway/sandbox/__tests__/instance-store.test.ts +1 -1
- package/src/server/ai-task/assigned-query.ts +4 -15
- package/src/server/desktop-guest.ts +12 -0
- package/src/server/fetch-guard.ts +3 -3
- package/src/server/middlewares/desktop-token.ts +23 -0
- package/src/server/remote-auth.ts +6 -0
- package/src/server/serverFns/__tests__/billing.test.ts +58 -1
- package/src/server/serverFns/__tests__/update-seats.test.ts +8 -8
- package/src/server/serverFns/agents/agent-crud.ts +3 -1
- package/src/server/serverFns/auth/session.ts +9 -0
- package/src/server/serverFns/billing.ts +10 -4
- package/src/server/serverFns/bug/bug-crud.ts +7 -2
- package/src/server/serverFns/story/__tests__/labels.test.ts +37 -15
- package/src/server/serverFns/story/story-comments.ts +2 -1
- package/src/server/serverFns/story/story-crud/activities.ts +1 -1
- package/src/server/serverFns/story/story-crud/user-brief.ts +1 -1
- package/src/server/serverFns/story/story-crud.ts +2 -1
- package/src/server/serverFns/story/story-transfer.ts +2 -1
- package/src/server/serverFns/tasks/task-crud.ts +0 -13
- package/src/server/serverFns/tasks/task-queries.ts +1 -12
- package/src/server/serverFns/testcase/plans.ts +1 -1
- package/src/server/serverFns/testcase/run-cases.ts +1 -1
- package/src/server/serverFns/testcase/runs.ts +1 -1
- package/src/server/serverFns/testcase/testcase-activities.ts +1 -1
- package/src/server/serverFns/testcase/testcase-board.ts +1 -1
- package/src/server/serverFns/testcase/testcase-crud.ts +1 -1
- package/src/server/serverFns/workspace.ts +1 -1
- package/src/server/story/types.ts +7 -22
- package/src/server/story/user-brief.ts +48 -0
- package/src/server/sync/scopes.ts +29 -0
- package/src/start.ts +10 -2
- package/src/styles/globals.css +10 -0
- package/src/__tests__/desktop-connection-validate.test.ts +0 -66
- package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +0 -143
- package/src/lib/vector-store/sqlite-vec-store.ts +0 -224
- package/src/paraglide/messages/tasks_ai_desktop_notice.js +0 -29
- package/src/routes/__desktop/onboarding/route.lazy.tsx +0 -175
- package/src/routes/__desktop/onboarding/route.tsx +0 -11
- package/src/routes/_nav/settings/connection/route.lazy.tsx +0 -64
- package/src/routes/_nav/settings/connection/route.tsx +0 -10
|
@@ -22,7 +22,7 @@ import { z } from "zod";
|
|
|
22
22
|
import { requireAuth } from "@/server/auth-helpers";
|
|
23
23
|
import { migrateStoriesToIndependentProject } from "@/server/builtin-project";
|
|
24
24
|
import { requireResource } from "@/server/resources";
|
|
25
|
-
import { USER_BRIEF_SELECT } from "@/server/story/
|
|
25
|
+
import { USER_BRIEF_SELECT } from "@/server/story/user-brief";
|
|
26
26
|
import { startOfMonth, endOfMonth, subMonths, eachDayOfInterval } from "date-fns";
|
|
27
27
|
import { formatInTimeZone } from "date-fns-tz";
|
|
28
28
|
|
|
@@ -26,14 +26,13 @@ import type {
|
|
|
26
26
|
ActivityItem,
|
|
27
27
|
} from "@/shared/story/types";
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
} satisfies Prisma.UserDefaultArgs["select"];
|
|
29
|
+
// USER_BRIEF_SELECT / castUserBrief 已下沉浏览器安全单一来源
|
|
30
|
+
// @/server/story/user-brief.ts(零服务端依赖):serverFn 在模块顶层消费时
|
|
31
|
+
// 客户端 split stub 会保留 import,不能让消费方经由本文件(顶层值引
|
|
32
|
+
// get-prisma)拖进 server-only 链(口径⑤)。此处 re-export 保持旧路径兼容。
|
|
33
|
+
import { castUserBrief, USER_BRIEF_SELECT } from "@/server/story/user-brief";
|
|
34
|
+
|
|
35
|
+
export { USER_BRIEF_SELECT, castUserBrief };
|
|
37
36
|
|
|
38
37
|
export const STORY_LIST_SELECT = {
|
|
39
38
|
id: true,
|
|
@@ -136,20 +135,6 @@ export function castStoryUsersLight<S extends StoryListRowLight>(s: S) {
|
|
|
136
135
|
};
|
|
137
136
|
}
|
|
138
137
|
|
|
139
|
-
export function castUserBrief(u: {
|
|
140
|
-
id: string;
|
|
141
|
-
username: string;
|
|
142
|
-
email: string;
|
|
143
|
-
avatarConfig: unknown;
|
|
144
|
-
bgShape: string | null;
|
|
145
|
-
bgColor: string | null;
|
|
146
|
-
}): UserBrief {
|
|
147
|
-
return {
|
|
148
|
-
...u,
|
|
149
|
-
avatarConfig: (u.avatarConfig as AvatarConfig | null) ?? null,
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
138
|
/** 活动记录 select(含 user brief),供 serverFn 返回新创建的活动给 UI 即时追加 */
|
|
154
139
|
export const ACTIVITY_SELECT = {
|
|
155
140
|
id: true,
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
// 用户摘要 select(含 email 版)+ castUserBrief 的浏览器安全单一来源。
|
|
19
|
+
// **必须保持零服务端依赖**(仅 import type):serverFn 文件在模块顶层消费
|
|
20
|
+
// 这些绑定时,客户端 split stub 会保留顶层 import——本模块被拖进浏览器时
|
|
21
|
+
// 不得携带 prisma/dotenv 等重依赖(口径⑤,scripts/check-serverfn-purity.mjs)。
|
|
22
|
+
// 注意与 server/lib/user-brief.ts(审计最小披露版,无 email)语义不同,勿合并。
|
|
23
|
+
import type { Prisma } from "@allbluecn/database"
|
|
24
|
+
import type { AvatarConfig } from "@allbluecn/kernel"
|
|
25
|
+
import type { UserBrief } from "@/shared/story/types"
|
|
26
|
+
|
|
27
|
+
export const USER_BRIEF_SELECT = {
|
|
28
|
+
id: true,
|
|
29
|
+
username: true,
|
|
30
|
+
email: true,
|
|
31
|
+
avatarConfig: true,
|
|
32
|
+
bgShape: true,
|
|
33
|
+
bgColor: true,
|
|
34
|
+
} satisfies Prisma.UserDefaultArgs["select"];
|
|
35
|
+
|
|
36
|
+
export function castUserBrief(u: {
|
|
37
|
+
id: string;
|
|
38
|
+
username: string;
|
|
39
|
+
email: string;
|
|
40
|
+
avatarConfig: unknown;
|
|
41
|
+
bgShape: string | null;
|
|
42
|
+
bgColor: string | null;
|
|
43
|
+
}): UserBrief {
|
|
44
|
+
return {
|
|
45
|
+
...u,
|
|
46
|
+
avatarConfig: (u.avatarConfig as AvatarConfig | null) ?? null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 同步 scope 解析(迁移设计 §3.2):用户可见归属键集 = 所属 workspace 集
|
|
6
|
+
// ∪ 成员 project 集 ∪ 本人 self——**服务端从会话解析**,客户端只回显游标
|
|
7
|
+
// (防越权枚举)。sync_change_log.scope 的键空间与此一一对应。
|
|
8
|
+
import type { PrismaClient } from "@allbluecn/database";
|
|
9
|
+
|
|
10
|
+
export type SyncScopes = { workspaces: string[]; projects: string[]; self: string };
|
|
11
|
+
|
|
12
|
+
export async function resolveSyncScopes(
|
|
13
|
+
prisma: PrismaClient,
|
|
14
|
+
userId: string,
|
|
15
|
+
): Promise<string[]> {
|
|
16
|
+
const [ownedWorkspaces, joinedWorkspaces, joinedProjects] = await Promise.all([
|
|
17
|
+
prisma.workspace.findMany({ where: { ownerId: userId }, select: { id: true } }),
|
|
18
|
+
prisma.workspaceMember.findMany({ where: { userId }, select: { workspaceId: true } }),
|
|
19
|
+
prisma.projectMember.findMany({ where: { userId }, select: { projectId: true } }),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const scopes = new Set<string>();
|
|
23
|
+
// 键格式与服务端触发器一致:workspaceId/projectId kind 存裸 id,self 存 'user:<id>'
|
|
24
|
+
scopes.add(`user:${userId}`);
|
|
25
|
+
for (const w of ownedWorkspaces) scopes.add(w.id);
|
|
26
|
+
for (const w of joinedWorkspaces) scopes.add(w.workspaceId);
|
|
27
|
+
for (const p of joinedProjects) scopes.add(p.projectId);
|
|
28
|
+
return [...scopes];
|
|
29
|
+
}
|
package/src/start.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { createStart, createCsrfMiddleware, createMiddleware } from '@tanstack/react-start'
|
|
19
19
|
import { securityHeadersMiddleware } from '@/server/middlewares/security-headers'
|
|
20
20
|
import { requestContextMiddleware } from '@/server/middlewares/request'
|
|
21
|
+
import { desktopTokenMiddleware } from "@/server/middlewares/desktop-token"
|
|
21
22
|
import { auditRequestMiddleware } from '@/server/middlewares/audit'
|
|
22
23
|
import { neonKeepaliveMiddleware } from '@/server/keepalive/middleware'
|
|
23
24
|
|
|
@@ -140,14 +141,21 @@ const apiCorsMiddleware = createMiddleware().server(async ({ request, next }: an
|
|
|
140
141
|
})
|
|
141
142
|
|
|
142
143
|
export const startInstance = createStart(async () => {
|
|
143
|
-
const requestMiddleware = [securityHeadersMiddleware, requestContextMiddleware, neonKeepaliveMiddleware, aiPortsMiddleware, apiCorsMiddleware, csrfMiddleware, auditRequestMiddleware]
|
|
144
|
+
const requestMiddleware = [securityHeadersMiddleware, requestContextMiddleware, neonKeepaliveMiddleware, aiPortsMiddleware, apiCorsMiddleware, csrfMiddleware, auditRequestMiddleware, desktopTokenMiddleware]
|
|
144
145
|
if (import.meta.env.SSR) {
|
|
145
146
|
ensureGithubSyncTimer()
|
|
146
147
|
// S18(审计 A08 建议,裁决:仅 stdin 兜底):桌面端 sidecar 由 Rust 壳拉起,
|
|
147
148
|
// 壳崩溃时子进程沦落孤儿继续监听端口,用户重开应用拉起新 sidecar 即端口
|
|
148
149
|
// 冲突/旧代码伺服。兜底:desktop 部署目标下 stdin EOF(父进程死亡时管道
|
|
149
150
|
// 关闭)即自杀退出——Vercel/容器部署无 stdin 或有外部管护,不受影响。
|
|
150
|
-
|
|
151
|
+
// Electron 壳(迁移 P2)排除:utilityProcess 生命周期由框架绑定主进程
|
|
152
|
+
//(main 退出即被回收,孤儿问题不存在),而 GUI 启动时 stdin=/dev/null
|
|
153
|
+
// 恒 EOF——守卫会立即误杀 backend(实测「打包后无窗口」的第二层根因)。
|
|
154
|
+
if (
|
|
155
|
+
process.env.DEPLOY_TARGET === 'desktop' &&
|
|
156
|
+
process.env.DESKTOP_HOST !== 'electron' &&
|
|
157
|
+
!globalAny.__sidecarStdinGuard
|
|
158
|
+
) {
|
|
151
159
|
globalAny.__sidecarStdinGuard = true
|
|
152
160
|
process.stdin.resume()
|
|
153
161
|
process.stdin.once('end', () => {
|
package/src/styles/globals.css
CHANGED
|
@@ -845,3 +845,13 @@ html.theme-transitioning *::after {
|
|
|
845
845
|
#peek-dock-panel-slot:not(:has(> *)) {
|
|
846
846
|
display: none;
|
|
847
847
|
}
|
|
848
|
+
|
|
849
|
+
/* Electron 桌面壳自绘标题栏拖动区(迁移 P6 顶栏改造):
|
|
850
|
+
-webkit-app-region 仅在 Electron renderer 生效,浏览器/云端部署 no-op;
|
|
851
|
+
拖动区内的可交互元素必须逐个 app-region-no-drag,否则点击被窗口拖动吞掉。 */
|
|
852
|
+
.app-region-drag {
|
|
853
|
+
-webkit-app-region: drag;
|
|
854
|
+
}
|
|
855
|
+
.app-region-no-drag {
|
|
856
|
+
-webkit-app-region: no-drag;
|
|
857
|
+
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
-
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
3
|
-
import { connectionTest, validateConnectionUrl } from '../../../desktop/src/connection-manager/connection-test'
|
|
4
|
-
import { DEFAULT_SAAS_URL } from '../../../desktop/src/connection-manager/constants'
|
|
5
|
-
|
|
6
|
-
// 桌面 connection-manager 经 @desktop 别名编入 web 应用;
|
|
7
|
-
// validateConnectionUrl 是 switch_mode 提交前的 scheme 白名单(安全相关),
|
|
8
|
-
// 独立测试防止 file:/// 等任意 scheme 回归进 open_remote_window 链路。
|
|
9
|
-
|
|
10
|
-
afterEach(() => {
|
|
11
|
-
vi.unstubAllGlobals()
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
describe('validateConnectionUrl(桌面连接 URL 白名单)', () => {
|
|
15
|
-
it('空 URL 拒绝', () => {
|
|
16
|
-
expect(validateConnectionUrl('')).not.toBeNull()
|
|
17
|
-
expect(validateConnectionUrl(' ')).not.toBeNull()
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('无法解析的字符串拒绝', () => {
|
|
21
|
-
expect(validateConnectionUrl('not a url')).not.toBeNull()
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('非 http/https scheme 拒绝(file/ftp/javascript)', () => {
|
|
25
|
-
expect(validateConnectionUrl('file:///etc/passwd')).not.toBeNull()
|
|
26
|
-
expect(validateConnectionUrl('ftp://example.com')).not.toBeNull()
|
|
27
|
-
expect(validateConnectionUrl('javascript:alert(1)')).not.toBeNull()
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('http/https 合法(含尾斜杠裁剪)', () => {
|
|
31
|
-
expect(validateConnectionUrl('https://prd.mycompany.com')).toBeNull()
|
|
32
|
-
expect(validateConnectionUrl('http://10.0.0.8:3000/')).toBeNull()
|
|
33
|
-
})
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
describe('connectionTest(/api/health 探测)', () => {
|
|
37
|
-
it('非法 URL 直接返回错误,不发请求', async () => {
|
|
38
|
-
const fetchSpy = vi.fn()
|
|
39
|
-
vi.stubGlobal('fetch', fetchSpy)
|
|
40
|
-
const result = await connectionTest('file:///etc/passwd')
|
|
41
|
-
expect(result.ok).toBe(false)
|
|
42
|
-
expect(result.error).toBeTruthy()
|
|
43
|
-
expect(fetchSpy).not.toHaveBeenCalled()
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('HTTP 200 视为连通并返回延迟', async () => {
|
|
47
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
|
|
48
|
-
const result = await connectionTest(DEFAULT_SAAS_URL)
|
|
49
|
-
expect(result.ok).toBe(true)
|
|
50
|
-
expect(typeof result.latencyMs).toBe('number')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('非 200 返回 HTTP 状态码错误', async () => {
|
|
54
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 502 })))
|
|
55
|
-
const result = await connectionTest('https://example.com')
|
|
56
|
-
expect(result.ok).toBe(false)
|
|
57
|
-
expect(result.error).toBe('HTTP 502')
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('网络异常返回错误信息', async () => {
|
|
61
|
-
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
|
62
|
-
const result = await connectionTest('https://example.com')
|
|
63
|
-
expect(result.ok).toBe(false)
|
|
64
|
-
expect(result.error).toBe('Failed to fetch')
|
|
65
|
-
})
|
|
66
|
-
})
|
|
@@ -1,143 +0,0 @@
|
|
|
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 { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
19
|
-
import Database from "better-sqlite3";
|
|
20
|
-
import * as sqliteVec from "sqlite-vec";
|
|
21
|
-
import { sqliteVecStore, _setDbForTest } from "../sqlite-vec-store";
|
|
22
|
-
|
|
23
|
-
function makeMemDb(): Database.Database {
|
|
24
|
-
const db = new Database(":memory:");
|
|
25
|
-
sqliteVec.load(db);
|
|
26
|
-
db.exec(
|
|
27
|
-
`CREATE TABLE knowledge_embeddings (id TEXT PRIMARY KEY, content TEXT, kbId TEXT);`
|
|
28
|
-
);
|
|
29
|
-
// 测试用 4 维(生产 1536)
|
|
30
|
-
db.exec(
|
|
31
|
-
`CREATE VIRTUAL TABLE vec_knowledge_embeddings USING vec0(embedding float[4] distance_metric=cosine);`
|
|
32
|
-
);
|
|
33
|
-
return db;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
describe("sqliteVecStore", () => {
|
|
37
|
-
let db: Database.Database;
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
db = makeMemDb();
|
|
40
|
-
_setDbForTest(db);
|
|
41
|
-
});
|
|
42
|
-
afterEach(() => {
|
|
43
|
-
_setDbForTest(null);
|
|
44
|
-
db.close();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("insert + search 返回相似度(越大越相似)", async () => {
|
|
48
|
-
await sqliteVecStore.insertEmbedding(
|
|
49
|
-
"knowledge_embeddings",
|
|
50
|
-
"doc1",
|
|
51
|
-
"内容1",
|
|
52
|
-
[1, 0, 0, 0],
|
|
53
|
-
{ kbId: "kb1" }
|
|
54
|
-
);
|
|
55
|
-
await sqliteVecStore.insertEmbedding(
|
|
56
|
-
"knowledge_embeddings",
|
|
57
|
-
"doc2",
|
|
58
|
-
"内容2",
|
|
59
|
-
[0.9, 0.1, 0, 0],
|
|
60
|
-
{ kbId: "kb1" }
|
|
61
|
-
);
|
|
62
|
-
await sqliteVecStore.insertEmbedding(
|
|
63
|
-
"knowledge_embeddings",
|
|
64
|
-
"doc3",
|
|
65
|
-
"内容3",
|
|
66
|
-
[0, 1, 0, 0],
|
|
67
|
-
{ kbId: "kb1" }
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
const results = await sqliteVecStore.searchByVector(
|
|
71
|
-
[1, 0, 0, 0],
|
|
72
|
-
"knowledge_embeddings",
|
|
73
|
-
3
|
|
74
|
-
);
|
|
75
|
-
expect(results[0].id).toBe("doc1");
|
|
76
|
-
expect(results[0].similarity).toBeGreaterThan(0.99);
|
|
77
|
-
expect(results[2].id).toBe("doc3");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("whereColumn 过滤", async () => {
|
|
81
|
-
await sqliteVecStore.insertEmbedding(
|
|
82
|
-
"knowledge_embeddings",
|
|
83
|
-
"a",
|
|
84
|
-
"x",
|
|
85
|
-
[1, 0, 0, 0],
|
|
86
|
-
{ kbId: "kb1" }
|
|
87
|
-
);
|
|
88
|
-
await sqliteVecStore.insertEmbedding(
|
|
89
|
-
"knowledge_embeddings",
|
|
90
|
-
"b",
|
|
91
|
-
"y",
|
|
92
|
-
[1, 0, 0, 0],
|
|
93
|
-
{ kbId: "kb2" }
|
|
94
|
-
);
|
|
95
|
-
const r = await sqliteVecStore.searchByVector(
|
|
96
|
-
[1, 0, 0, 0],
|
|
97
|
-
"knowledge_embeddings",
|
|
98
|
-
10,
|
|
99
|
-
"kbId",
|
|
100
|
-
"kb1"
|
|
101
|
-
);
|
|
102
|
-
expect(r.length).toBe(1);
|
|
103
|
-
expect(r.find((x) => x.id === "b")).toBeUndefined();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("delete 清理业务表 + vec 表", async () => {
|
|
107
|
-
await sqliteVecStore.insertEmbedding(
|
|
108
|
-
"knowledge_embeddings",
|
|
109
|
-
"a",
|
|
110
|
-
"x",
|
|
111
|
-
[1, 0, 0, 0],
|
|
112
|
-
{ kbId: "kb1" }
|
|
113
|
-
);
|
|
114
|
-
await sqliteVecStore.deleteEmbeddings(
|
|
115
|
-
"knowledge_embeddings",
|
|
116
|
-
"kbId",
|
|
117
|
-
"kb1"
|
|
118
|
-
);
|
|
119
|
-
const r = await sqliteVecStore.searchByVector(
|
|
120
|
-
[1, 0, 0, 0],
|
|
121
|
-
"knowledge_embeddings",
|
|
122
|
-
10
|
|
123
|
-
);
|
|
124
|
-
expect(r).toHaveLength(0);
|
|
125
|
-
const rows = db
|
|
126
|
-
.prepare("SELECT COUNT(*) AS c FROM knowledge_embeddings")
|
|
127
|
-
.get() as { c: number };
|
|
128
|
-
expect(rows.c).toBe(0);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("非法表名/列名抛错", async () => {
|
|
132
|
-
await expect(
|
|
133
|
-
sqliteVecStore.searchByVector([1, 0, 0, 0], "evil" as never)
|
|
134
|
-
).rejects.toThrow("非法表名");
|
|
135
|
-
await expect(
|
|
136
|
-
sqliteVecStore.deleteEmbeddings(
|
|
137
|
-
"knowledge_embeddings",
|
|
138
|
-
"evil" as never,
|
|
139
|
-
"x"
|
|
140
|
-
)
|
|
141
|
-
).rejects.toThrow("非法删除过滤列");
|
|
142
|
-
});
|
|
143
|
-
});
|
|
@@ -1,224 +0,0 @@
|
|
|
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 Database from "better-sqlite3";
|
|
19
|
-
import * as sqliteVec from "sqlite-vec";
|
|
20
|
-
import type { VectorStore, VectorSearchResult } from "./types";
|
|
21
|
-
import {
|
|
22
|
-
ALLOWED_TABLES,
|
|
23
|
-
ALLOWED_FILTER_COLUMNS,
|
|
24
|
-
ALLOWED_INSERT_COLUMNS,
|
|
25
|
-
} from "./allowlist";
|
|
26
|
-
|
|
27
|
-
let db: Database.Database | null = null;
|
|
28
|
-
|
|
29
|
-
function getDb(): Database.Database {
|
|
30
|
-
if (db) return db;
|
|
31
|
-
const url = process.env.DATABASE_URL;
|
|
32
|
-
if (!url) {
|
|
33
|
-
throw new Error("[sqlite-vec-store] DATABASE_URL 未配置,无法打开 SQLite 向量库");
|
|
34
|
-
}
|
|
35
|
-
const path = url.replace(/^file:/, "");
|
|
36
|
-
db = new Database(path);
|
|
37
|
-
db.pragma("journal_mode = WAL");
|
|
38
|
-
sqliteVec.load(db);
|
|
39
|
-
return db;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** 测试用:注入内存 db(绕过文件连接) */
|
|
43
|
-
export function _setDbForTest(testDb: Database.Database | null) {
|
|
44
|
-
db = testDb;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function validateTable(table: string): void {
|
|
48
|
-
if (!(ALLOWED_TABLES as readonly string[]).includes(table)) {
|
|
49
|
-
throw new Error(`非法表名: ${table}`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function toBuffer(embedding: number[]): Buffer {
|
|
54
|
-
return Buffer.from(new Float32Array(embedding).buffer);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** 建表(首次或测试初始化时调用;桌面端正式建表在 desktop-init.ts)。
|
|
58
|
-
* base 表必须带齐 searchByVector/insertEmbedding 消费的过滤列(kbId/documentId/
|
|
59
|
-
* designPageId/chunkIndex)——旧 2 列 schema 与查询 schema 不符,误用即运行时
|
|
60
|
-
* "no such column"(审计 ⚪)。对已存在的旧表逐列幂等补齐。 */
|
|
61
|
-
export function ensureVecTables(d: Database.Database) {
|
|
62
|
-
for (const t of ALLOWED_TABLES) {
|
|
63
|
-
d.exec(
|
|
64
|
-
`CREATE TABLE IF NOT EXISTS ${t} (
|
|
65
|
-
id TEXT PRIMARY KEY,
|
|
66
|
-
content TEXT,
|
|
67
|
-
kbId TEXT,
|
|
68
|
-
documentId TEXT,
|
|
69
|
-
designPageId TEXT,
|
|
70
|
-
chunkIndex INTEGER
|
|
71
|
-
);`
|
|
72
|
-
);
|
|
73
|
-
// 旧库(仅 id/content 两列)逐列幂等补齐:duplicate column 错误即已存在
|
|
74
|
-
for (const [col, ddl] of [
|
|
75
|
-
["kbId", "TEXT"],
|
|
76
|
-
["documentId", "TEXT"],
|
|
77
|
-
["designPageId", "TEXT"],
|
|
78
|
-
["chunkIndex", "INTEGER"],
|
|
79
|
-
] as const) {
|
|
80
|
-
try {
|
|
81
|
-
d.exec(`ALTER TABLE ${t} ADD COLUMN ${col} ${ddl};`);
|
|
82
|
-
} catch {
|
|
83
|
-
// 列已存在:忽略
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
// spike 0.3:cosine 必须列级语法(无逗号无引号),table-level 会报错
|
|
87
|
-
d.exec(
|
|
88
|
-
`CREATE VIRTUAL TABLE IF NOT EXISTS vec_${t} USING vec0(embedding float[1536] distance_metric=cosine);`
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export const sqliteVecStore: VectorStore = {
|
|
94
|
-
async searchByVector(
|
|
95
|
-
embedding,
|
|
96
|
-
table,
|
|
97
|
-
limit = 10,
|
|
98
|
-
whereColumn,
|
|
99
|
-
whereValue
|
|
100
|
-
): Promise<VectorSearchResult[]> {
|
|
101
|
-
validateTable(table);
|
|
102
|
-
if (
|
|
103
|
-
whereColumn &&
|
|
104
|
-
!(ALLOWED_FILTER_COLUMNS as readonly string[]).includes(whereColumn)
|
|
105
|
-
) {
|
|
106
|
-
// 与 pg 版防护对齐:列名直接内插进 SQL,必须白名单(双后端行为一致性)
|
|
107
|
-
throw new Error(`非法过滤列: ${whereColumn}`);
|
|
108
|
-
}
|
|
109
|
-
const d = getDb();
|
|
110
|
-
const blob = toBuffer(embedding);
|
|
111
|
-
let rows: Array<{ id: string; content: string; distance: number }>;
|
|
112
|
-
if (whereColumn && whereValue !== undefined) {
|
|
113
|
-
if (Array.isArray(whereValue)) {
|
|
114
|
-
if (whereValue.length === 0) return [];
|
|
115
|
-
const placeholders = whereValue.map(() => "?").join(", ");
|
|
116
|
-
rows = d
|
|
117
|
-
.prepare(
|
|
118
|
-
`SELECT t.id AS id, t.content AS content, v.distance AS distance
|
|
119
|
-
FROM vec_${table} v JOIN ${table} t ON t.rowid = v.rowid
|
|
120
|
-
WHERE v.embedding MATCH ? AND k = ? AND t.${whereColumn} IN (${placeholders})
|
|
121
|
-
ORDER BY v.distance`
|
|
122
|
-
)
|
|
123
|
-
.all(blob, limit, ...whereValue) as Array<{
|
|
124
|
-
id: string;
|
|
125
|
-
content: string;
|
|
126
|
-
distance: number;
|
|
127
|
-
}>;
|
|
128
|
-
} else {
|
|
129
|
-
rows = d
|
|
130
|
-
.prepare(
|
|
131
|
-
`SELECT t.id AS id, t.content AS content, v.distance AS distance
|
|
132
|
-
FROM vec_${table} v JOIN ${table} t ON t.rowid = v.rowid
|
|
133
|
-
WHERE v.embedding MATCH ? AND k = ? AND t.${whereColumn} = ?
|
|
134
|
-
ORDER BY v.distance`
|
|
135
|
-
)
|
|
136
|
-
.all(blob, limit, whereValue) as Array<{
|
|
137
|
-
id: string;
|
|
138
|
-
content: string;
|
|
139
|
-
distance: number;
|
|
140
|
-
}>;
|
|
141
|
-
}
|
|
142
|
-
} else {
|
|
143
|
-
rows = d
|
|
144
|
-
.prepare(
|
|
145
|
-
`SELECT t.id AS id, t.content AS content, v.distance AS distance
|
|
146
|
-
FROM vec_${table} v JOIN ${table} t ON t.rowid = v.rowid
|
|
147
|
-
WHERE v.embedding MATCH ? AND k = ?
|
|
148
|
-
ORDER BY v.distance`
|
|
149
|
-
)
|
|
150
|
-
.all(blob, limit) as Array<{
|
|
151
|
-
id: string;
|
|
152
|
-
content: string;
|
|
153
|
-
distance: number;
|
|
154
|
-
}>;
|
|
155
|
-
}
|
|
156
|
-
// spike 0.3:cosine 度量下 similarity = 1 - distance,与 pgvector 1-(<=>) 1:1 对齐
|
|
157
|
-
return rows.map((r) => ({
|
|
158
|
-
id: r.id,
|
|
159
|
-
content: r.content,
|
|
160
|
-
similarity: 1 - r.distance,
|
|
161
|
-
}));
|
|
162
|
-
},
|
|
163
|
-
|
|
164
|
-
async insertEmbedding(table, id, content, embedding, extraFields) {
|
|
165
|
-
validateTable(table);
|
|
166
|
-
const d = getDb();
|
|
167
|
-
if (extraFields && Object.keys(extraFields).length > 0) {
|
|
168
|
-
// 键名直接拼进 SQL 列名:不在白名单即拒绝(与 pg 版同款注入面)
|
|
169
|
-
for (const k of Object.keys(extraFields)) {
|
|
170
|
-
if (!(ALLOWED_INSERT_COLUMNS as readonly string[]).includes(k)) {
|
|
171
|
-
throw new Error(`非法插入列: ${k}`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
// 事务包裹两段写:vec 插入失败(如维度不匹配)时 base 行不得残留成孤儿
|
|
176
|
-
const insertTx = d.transaction(() => {
|
|
177
|
-
if (!extraFields || Object.keys(extraFields).length === 0) {
|
|
178
|
-
d.prepare(
|
|
179
|
-
`INSERT INTO ${table} (id, content) VALUES (?, ?)`
|
|
180
|
-
).run(id, content);
|
|
181
|
-
} else {
|
|
182
|
-
const keys = Object.keys(extraFields);
|
|
183
|
-
const vals = Object.values(extraFields);
|
|
184
|
-
const cols = keys.map((k) => `"${k}"`).join(", ");
|
|
185
|
-
const ph = keys.map(() => "?").join(", ");
|
|
186
|
-
d.prepare(
|
|
187
|
-
`INSERT INTO ${table} (${cols}, id, content) VALUES (${ph}, ?, ?)`
|
|
188
|
-
).run(...vals, id, content);
|
|
189
|
-
}
|
|
190
|
-
const row = d
|
|
191
|
-
.prepare(`SELECT rowid AS rid FROM ${table} WHERE id = ?`)
|
|
192
|
-
.get(id) as { rid: number };
|
|
193
|
-
// spike 0.1:better-sqlite3 13 绑定 vec0 rowid 必须 BigInt,JS number 会绑成 REAL 报错
|
|
194
|
-
d.prepare(
|
|
195
|
-
`INSERT INTO vec_${table} (rowid, embedding) VALUES (?, ?)`
|
|
196
|
-
).run(BigInt(row.rid), toBuffer(embedding));
|
|
197
|
-
});
|
|
198
|
-
insertTx();
|
|
199
|
-
},
|
|
200
|
-
|
|
201
|
-
async deleteEmbeddings(table, whereColumn, whereValue) {
|
|
202
|
-
validateTable(table);
|
|
203
|
-
if (
|
|
204
|
-
!(ALLOWED_FILTER_COLUMNS as readonly string[]).includes(whereColumn)
|
|
205
|
-
) {
|
|
206
|
-
throw new Error(`非法删除过滤列: ${whereColumn}`);
|
|
207
|
-
}
|
|
208
|
-
const d = getDb();
|
|
209
|
-
// 事务包裹两段删:中断时两表不得出现不一致
|
|
210
|
-
const deleteTx = d.transaction(() => {
|
|
211
|
-
const rows = d.prepare(
|
|
212
|
-
`SELECT rowid AS rid FROM ${table} WHERE ${whereColumn} = ?`
|
|
213
|
-
).all(whereValue) as Array<{ rid: number }>;
|
|
214
|
-
if (rows.length === 0) return;
|
|
215
|
-
const rids = rows.map((r) => BigInt(r.rid));
|
|
216
|
-
const vecPh = rids.map(() => "?").join(",");
|
|
217
|
-
d.prepare(`DELETE FROM vec_${table} WHERE rowid IN (${vecPh})`).run(
|
|
218
|
-
...rids
|
|
219
|
-
);
|
|
220
|
-
d.prepare(`DELETE FROM ${table} WHERE ${whereColumn} = ?`).run(whereValue);
|
|
221
|
-
});
|
|
222
|
-
deleteTx();
|
|
223
|
-
},
|
|
224
|
-
};
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/* eslint-disable */
|
|
2
|
-
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
|
3
|
-
|
|
4
|
-
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
|
5
|
-
|
|
6
|
-
/** @typedef {{}} Tasks_Ai_Desktop_NoticeInputs */
|
|
7
|
-
|
|
8
|
-
const zh_tasks_ai_desktop_notice = /** @type {(inputs: Tasks_Ai_Desktop_NoticeInputs) => LocalizedString} */ () => {
|
|
9
|
-
return /** @type {LocalizedString} */ (`AI 任务需在 Web 端使用`)
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
const en_tasks_ai_desktop_notice = /** @type {(inputs: Tasks_Ai_Desktop_NoticeInputs) => LocalizedString} */ () => {
|
|
13
|
-
return /** @type {LocalizedString} */ (`AI tasks are available on the web app`)
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* | output |
|
|
18
|
-
* | --- |
|
|
19
|
-
* | "AI tasks are available on the web app" |
|
|
20
|
-
*
|
|
21
|
-
* @param {Tasks_Ai_Desktop_NoticeInputs} inputs
|
|
22
|
-
* @param {{ locale?: "zh" | "en" }} options
|
|
23
|
-
* @returns {LocalizedString}
|
|
24
|
-
*/
|
|
25
|
-
export const tasks_ai_desktop_notice = /** @type {((inputs?: Tasks_Ai_Desktop_NoticeInputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Tasks_Ai_Desktop_NoticeInputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
|
|
26
|
-
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
|
27
|
-
if (locale === "en") return en_tasks_ai_desktop_notice(inputs)
|
|
28
|
-
return zh_tasks_ai_desktop_notice(inputs)
|
|
29
|
-
});
|