@allbluecn/web-app 0.21.0 → 0.22.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 +3 -3
- package/src/components/billing/__tests__/use-resource.test.tsx +2 -0
- package/src/components/billing/subscription-provider.tsx +2 -0
- package/src/components/bug/create-bug-dialog.tsx +1 -1
- package/src/components/bug/detail/props-section.tsx +1 -0
- package/src/components/bug/inline-editors/labels-editor.tsx +4 -1
- package/src/components/bug/views/bug-list/batch-toolbar.tsx +5 -2
- package/src/components/bug/views/bug-list/inline-bug-creator.tsx +1 -1
- package/src/components/bug/views/bug-list/row.tsx +1 -1
- package/src/components/design/editor/dashboard/design-dashboard.tsx +21 -7
- package/src/components/design/editor/layout/design-navigation.tsx +22 -12
- package/src/components/harness/dock/dock-panel-container.tsx +17 -4
- package/src/components/harness/harness-workbench-page.tsx +14 -1
- package/src/components/harness/todo-panel.tsx +2 -5
- package/src/components/harness/use-todo-projection.ts +3 -3
- package/src/components/story/ai/story-ai-panel.tsx +2 -2
- package/src/components/story/create-story-dialog.tsx +1 -1
- package/src/components/story/detail/story-collab-gate.tsx +191 -0
- package/src/components/story/detail/story-detail-body.tsx +5 -169
- package/src/components/story/detail/story-properties.tsx +1 -0
- package/src/components/story/filters/field-configs.tsx +6 -0
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +2 -1
- package/src/components/story/inline-editors/labels-editor.tsx +14 -10
- package/src/components/story/peek-panel.tsx +1 -1
- package/src/components/story/relations/story-ref-row.tsx +1 -1
- package/src/components/story/views/story-list/batch-toolbar.tsx +1 -1
- package/src/components/story/views/story-list/inline-story-creator.tsx +1 -0
- package/src/components/story/views/story-list/row.tsx +1 -1
- package/src/components/testcase/runs/convert-bug-button.tsx +1 -1
- package/src/design-engine/document/serialize-read.ts +30 -1
- package/src/design-engine/engine/app.ts +18 -1
- package/src/design-engine/engine/engine-base.ts +2 -0
- package/src/lib/changelog/sdk-versions.ts +4 -4
- package/src/lib/collections/reference/labels-collection.ts +1 -1
- package/src/paraglide/messages/_index.d.ts +4 -0
- package/src/paraglide/messages/bug_list_loadmorefailed2.d.ts +17 -0
- package/src/paraglide/messages/bug_list_loadmorefailed2.js +30 -0
- package/src/paraglide/messages/bug_storypanel_partialfailed2.d.ts +19 -0
- package/src/paraglide/messages/bug_storypanel_partialfailed2.js +30 -0
- package/src/paraglide/messages/chat_workspace_githubnotconnected2.d.ts +17 -0
- package/src/paraglide/messages/chat_workspace_githubnotconnected2.js +30 -0
- package/src/paraglide/messages/dashboard_activityfeed_ai1.d.ts +17 -0
- package/src/paraglide/messages/dashboard_activityfeed_ai1.js +30 -0
- package/src/routes/_nav/bugs/$bugId/route.tsx +1 -1
- package/src/routes/_nav/bugs/index.tsx +1 -1
- package/src/routes/_nav/dashboard/-components/activity-feed.tsx +23 -4
- package/src/routes/_nav/dashboard/route.lazy.tsx +6 -6
- package/src/routes/_nav/dashboard/route.tsx +11 -4
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +35 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +1 -1
- package/src/routes/_nav/stories/index.tsx +1 -1
- package/src/routes/api/billing-webhook.ts +1 -1
- package/src/server/__tests__/resources.agents.test.ts +2 -0
- package/src/server/__tests__/resources.license.test.ts +2 -0
- package/src/server/__tests__/resources.team-seats.test.ts +2 -0
- package/src/server/__tests__/resources.test.ts +2 -0
- package/src/server/ai-gateway/__tests__/pgvector-memory-adapter.test.ts +16 -0
- package/src/server/ai-gateway/memory/pgvector-adapter.ts +14 -0
- package/src/server/ai-gateway/storage/__tests__/blob-store.test.ts +4 -0
- package/src/server/ai-gateway/storage/db-blob-store.ts +4 -0
- package/src/server/billing-email.ts +18 -5
- package/src/server/bug/bug-crud-helpers.ts +107 -0
- package/src/server/knowledge/knowledge-helpers.ts +40 -0
- package/src/server/resources.ts +43 -2
- package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +27 -14
- package/src/server/serverFns/activity.ts +61 -0
- package/src/server/serverFns/anyhub/install.ts +3 -0
- package/src/server/serverFns/bug/bug-crud.ts +10 -83
- package/src/server/serverFns/knowledge.ts +2 -17
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +2 -2
- package/src/server/serverFns/story/kb-scope.ts +6 -6
- package/src/server/serverFns/story/story-crud/labels.ts +20 -7
- package/src/server/story/story-crud/label-upsert.ts +47 -9
- package/src/start.ts +15 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { chat_workspace_githubnotconnected2 as "chat_workspace_githubNotConnected" };
|
|
2
|
+
export type LocalizedString = import("../runtime.js").LocalizedString;
|
|
3
|
+
export type Chat_Workspace_Githubnotconnected2Inputs = {};
|
|
4
|
+
/**
|
|
5
|
+
* | output |
|
|
6
|
+
* | --- |
|
|
7
|
+
* | "GitHub not connected: save a GitHub Token in Settings → AI Providers, or sign in with GitHub and retry" |
|
|
8
|
+
*
|
|
9
|
+
* @param {Chat_Workspace_Githubnotconnected2Inputs} inputs
|
|
10
|
+
* @param {{ locale?: "zh" | "en" }} options
|
|
11
|
+
* @returns {LocalizedString}
|
|
12
|
+
*/
|
|
13
|
+
declare const chat_workspace_githubnotconnected2: ((inputs?: Chat_Workspace_Githubnotconnected2Inputs, options?: {
|
|
14
|
+
locale?: "zh" | "en";
|
|
15
|
+
}) => LocalizedString) & import("../runtime.js").MessageMetadata<Chat_Workspace_Githubnotconnected2Inputs, {
|
|
16
|
+
locale?: "zh" | "en";
|
|
17
|
+
}, {}>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
|
3
|
+
|
|
4
|
+
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
|
5
|
+
|
|
6
|
+
/** @typedef {{}} Chat_Workspace_Githubnotconnected2Inputs */
|
|
7
|
+
|
|
8
|
+
const zh_chat_workspace_githubnotconnected2 = /** @type {(inputs: Chat_Workspace_Githubnotconnected2Inputs) => LocalizedString} */ () => {
|
|
9
|
+
return /** @type {LocalizedString} */ (`未连接 GitHub:请在「设置 → AI 提供方」保存 GitHub Token,或使用 GitHub 登录后重试`)
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const en_chat_workspace_githubnotconnected2 = /** @type {(inputs: Chat_Workspace_Githubnotconnected2Inputs) => LocalizedString} */ () => {
|
|
13
|
+
return /** @type {LocalizedString} */ (`GitHub not connected: save a GitHub Token in Settings → AI Providers, or sign in with GitHub and retry`)
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* | output |
|
|
18
|
+
* | --- |
|
|
19
|
+
* | "GitHub not connected: save a GitHub Token in Settings → AI Providers, or sign in with GitHub and retry" |
|
|
20
|
+
*
|
|
21
|
+
* @param {Chat_Workspace_Githubnotconnected2Inputs} inputs
|
|
22
|
+
* @param {{ locale?: "zh" | "en" }} options
|
|
23
|
+
* @returns {LocalizedString}
|
|
24
|
+
*/
|
|
25
|
+
const chat_workspace_githubnotconnected2 = /** @type {((inputs?: Chat_Workspace_Githubnotconnected2Inputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Chat_Workspace_Githubnotconnected2Inputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
|
|
26
|
+
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
|
27
|
+
if (locale === "en") return en_chat_workspace_githubnotconnected2(inputs)
|
|
28
|
+
return zh_chat_workspace_githubnotconnected2(inputs)
|
|
29
|
+
});
|
|
30
|
+
export { chat_workspace_githubnotconnected2 as "chat_workspace_githubNotConnected" }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { dashboard_activityfeed_ai1 as "dashboard_activityFeed_ai" };
|
|
2
|
+
export type LocalizedString = import("../runtime.js").LocalizedString;
|
|
3
|
+
export type Dashboard_Activityfeed_Ai1Inputs = {};
|
|
4
|
+
/**
|
|
5
|
+
* | output |
|
|
6
|
+
* | --- |
|
|
7
|
+
* | "AI" |
|
|
8
|
+
*
|
|
9
|
+
* @param {Dashboard_Activityfeed_Ai1Inputs} inputs
|
|
10
|
+
* @param {{ locale?: "zh" | "en" }} options
|
|
11
|
+
* @returns {LocalizedString}
|
|
12
|
+
*/
|
|
13
|
+
declare const dashboard_activityfeed_ai1: ((inputs?: Dashboard_Activityfeed_Ai1Inputs, options?: {
|
|
14
|
+
locale?: "zh" | "en";
|
|
15
|
+
}) => LocalizedString) & import("../runtime.js").MessageMetadata<Dashboard_Activityfeed_Ai1Inputs, {
|
|
16
|
+
locale?: "zh" | "en";
|
|
17
|
+
}, {}>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
|
3
|
+
|
|
4
|
+
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
|
5
|
+
|
|
6
|
+
/** @typedef {{}} Dashboard_Activityfeed_Ai1Inputs */
|
|
7
|
+
|
|
8
|
+
const zh_dashboard_activityfeed_ai1 = /** @type {(inputs: Dashboard_Activityfeed_Ai1Inputs) => LocalizedString} */ () => {
|
|
9
|
+
return /** @type {LocalizedString} */ (`AI`)
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const en_dashboard_activityfeed_ai1 = /** @type {(inputs: Dashboard_Activityfeed_Ai1Inputs) => LocalizedString} */ () => {
|
|
13
|
+
return /** @type {LocalizedString} */ (`AI`)
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* | output |
|
|
18
|
+
* | --- |
|
|
19
|
+
* | "AI" |
|
|
20
|
+
*
|
|
21
|
+
* @param {Dashboard_Activityfeed_Ai1Inputs} inputs
|
|
22
|
+
* @param {{ locale?: "zh" | "en" }} options
|
|
23
|
+
* @returns {LocalizedString}
|
|
24
|
+
*/
|
|
25
|
+
const dashboard_activityfeed_ai1 = /** @type {((inputs?: Dashboard_Activityfeed_Ai1Inputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Dashboard_Activityfeed_Ai1Inputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
|
|
26
|
+
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
|
27
|
+
if (locale === "en") return en_dashboard_activityfeed_ai1(inputs)
|
|
28
|
+
return zh_dashboard_activityfeed_ai1(inputs)
|
|
29
|
+
});
|
|
30
|
+
export { dashboard_activityfeed_ai1 as "dashboard_activityFeed_ai" }
|
|
@@ -63,7 +63,7 @@ export const Route = createFileRoute("/_nav/bugs/$bugId")({
|
|
|
63
63
|
const [collab, users, labels, neighbors] = await Promise.all([
|
|
64
64
|
collabPromise,
|
|
65
65
|
listUsersBriefFn({ data: bug.projectId ? { projectId: bug.projectId } : {} }),
|
|
66
|
-
listLabelsFn(),
|
|
66
|
+
listLabelsFn({ data: { projectId: bug.projectId } }),
|
|
67
67
|
getBugNeighborsFn({ data: { bugId: params.bugId } }),
|
|
68
68
|
]);
|
|
69
69
|
return { bug, collab, users, labels, neighbors };
|
|
@@ -24,7 +24,7 @@ export const Route = createFileRoute("/_nav/bugs/")({
|
|
|
24
24
|
// 分组/table 视图均走 usePagedBugGroups 自拉数据,listBugsFn 零消费方不再预取
|
|
25
25
|
const [users, labels, projects] = await Promise.all([
|
|
26
26
|
listUsersBriefFn(),
|
|
27
|
-
listLabelsFn(),
|
|
27
|
+
listLabelsFn({ data: {} }),
|
|
28
28
|
listProjectsBriefFn(),
|
|
29
29
|
]);
|
|
30
30
|
return { users, labels, projects };
|
|
@@ -18,12 +18,27 @@
|
|
|
18
18
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
19
19
|
import { MessageSquare } from 'lucide-react'
|
|
20
20
|
import { m } from "@/paraglide/messages.js";
|
|
21
|
+
import { ACTIVITY_LABEL } from "@/shared/story/constants";
|
|
22
|
+
|
|
23
|
+
function formatRelative(iso: string): string {
|
|
24
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
25
|
+
const min = Math.floor(diff / 60000);
|
|
26
|
+
if (min < 1) return m.story_time_justNow();
|
|
27
|
+
if (min < 60) return m.story_time_minutesAgo({ count: min });
|
|
28
|
+
const hr = Math.floor(min / 60);
|
|
29
|
+
if (hr < 24) return m.story_time_hoursAgo({ count: hr });
|
|
30
|
+
const day = Math.floor(hr / 24);
|
|
31
|
+
if (day < 30) return m.story_time_daysAgo({ count: day });
|
|
32
|
+
return new Date(iso).toLocaleDateString();
|
|
33
|
+
}
|
|
21
34
|
|
|
22
35
|
interface Activity {
|
|
23
36
|
id: string
|
|
24
|
-
|
|
25
|
-
|
|
37
|
+
action: string
|
|
38
|
+
isAI: boolean
|
|
26
39
|
createdAt: string
|
|
40
|
+
storyIdentifier: string
|
|
41
|
+
storyName: string
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
interface ActivityFeedProps {
|
|
@@ -47,9 +62,13 @@ export function ActivityFeed({ activities }: ActivityFeedProps) {
|
|
|
47
62
|
{activities.map((activity) => (
|
|
48
63
|
<div key={activity.id} className="flex items-start gap-3">
|
|
49
64
|
<div className="flex-1">
|
|
50
|
-
<p className="text-sm">
|
|
65
|
+
<p className="text-sm">
|
|
66
|
+
<span className="font-medium">{activity.storyIdentifier}</span>{" "}
|
|
67
|
+
{ACTIVITY_LABEL[activity.action]?.() ?? activity.action}
|
|
68
|
+
{activity.isAI ? ` · ${m.dashboard_activityFeed_ai()}` : ""}
|
|
69
|
+
</p>
|
|
51
70
|
<p className="text-xs text-muted-foreground">
|
|
52
|
-
{activity.
|
|
71
|
+
{activity.storyName} · {formatRelative(activity.createdAt)}
|
|
53
72
|
</p>
|
|
54
73
|
</div>
|
|
55
74
|
</div>
|
|
@@ -38,7 +38,7 @@ export const Route = createLazyFileRoute('/_nav/dashboard')({
|
|
|
38
38
|
})
|
|
39
39
|
|
|
40
40
|
function DashboardPage() {
|
|
41
|
-
const { prds, activityData } = Route.useLoaderData()
|
|
41
|
+
const { prds, activityData, stats, recentActivities } = Route.useLoaderData()
|
|
42
42
|
const context = Route.useRouteContext()
|
|
43
43
|
// 明暗跟随全局 ThemeModeProvider(auto 模式含系统外观 + 时段规则),
|
|
44
44
|
// 不再自建 MutationObserver 监听 documentElement class
|
|
@@ -54,10 +54,10 @@ function DashboardPage() {
|
|
|
54
54
|
<AiChatPrompt hasAiKey={context?.session?.user?.hasAiKey} />
|
|
55
55
|
|
|
56
56
|
<StatsGrid
|
|
57
|
-
prdCount={
|
|
58
|
-
completedToday={
|
|
59
|
-
unreadComments={
|
|
60
|
-
installedApps={
|
|
57
|
+
prdCount={stats.prdCount}
|
|
58
|
+
completedToday={stats.completedToday}
|
|
59
|
+
unreadComments={stats.unreadComments}
|
|
60
|
+
installedApps={stats.installedApps}
|
|
61
61
|
className="mt-60"
|
|
62
62
|
/>
|
|
63
63
|
|
|
@@ -71,7 +71,7 @@ function DashboardPage() {
|
|
|
71
71
|
</Card>
|
|
72
72
|
|
|
73
73
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
74
|
-
<ActivityFeed activities={
|
|
74
|
+
<ActivityFeed activities={recentActivities} />
|
|
75
75
|
<Card className="border">
|
|
76
76
|
<CardHeader>
|
|
77
77
|
<CardTitle>{m.dashboard_dashboard_quickEntry()}</CardTitle>
|
|
@@ -17,22 +17,29 @@
|
|
|
17
17
|
|
|
18
18
|
import { createFileRoute } from '@tanstack/react-router'
|
|
19
19
|
import { listDesignsFn } from '@/server/serverFns/design'
|
|
20
|
-
import { getActivityDataFn } from '@/server/serverFns/activity'
|
|
20
|
+
import { getActivityDataFn, getDashboardStatsFn, getRecentActivitiesFn } from '@/server/serverFns/activity'
|
|
21
21
|
import { resolveTimezone } from '@/lib/datetime'
|
|
22
22
|
|
|
23
23
|
export const Route = createFileRoute('/_nav/dashboard')({
|
|
24
24
|
loader: async ({ context }) => {
|
|
25
25
|
try {
|
|
26
26
|
const timezone = resolveTimezone(null, context.session?.user?.timezone)
|
|
27
|
-
const [prds, activityData] = await Promise.all([
|
|
27
|
+
const [prds, activityData, stats, recentActivities] = await Promise.all([
|
|
28
28
|
listDesignsFn({ data: {} }),
|
|
29
29
|
getActivityDataFn({ data: { timezone } }),
|
|
30
|
+
getDashboardStatsFn(),
|
|
31
|
+
getRecentActivitiesFn({ data: { take: 10 } }),
|
|
30
32
|
])
|
|
31
|
-
return { prds, activityData }
|
|
33
|
+
return { prds, activityData, stats, recentActivities }
|
|
32
34
|
} catch (e) {
|
|
33
35
|
// 降级为空数据前先留痕(否则服务端故障被伪装成「用户没有数据」)
|
|
34
36
|
console.error('[dashboard] loader failed:', e instanceof Error ? e.message : String(e))
|
|
35
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
prds: [],
|
|
39
|
+
activityData: [],
|
|
40
|
+
stats: { prdCount: 0, completedToday: 0, unreadComments: 0, installedApps: 0 },
|
|
41
|
+
recentActivities: [],
|
|
42
|
+
}
|
|
36
43
|
}
|
|
37
44
|
},
|
|
38
45
|
})
|
|
@@ -59,12 +59,17 @@ function ProjectSettingsPage() {
|
|
|
59
59
|
const [displayIdError, setDisplayIdError] = useState("");
|
|
60
60
|
const [useCustomTz, setUseCustomTz] = useState(!!project.timezone);
|
|
61
61
|
const [projectTimezone, setProjectTimezone] = useState(project.timezone ?? "");
|
|
62
|
+
// S17(裁决:独立保存):时区卡片拆出独立保存——与基本信息卡「保存更改」
|
|
63
|
+
// 解耦,脏检查(与 server 权威值比对)控制按钮可用性,保存即时落库
|
|
64
|
+
const [savingTz, setSavingTz] = useState(false);
|
|
62
65
|
const [saving, setSaving] = useState(false);
|
|
63
66
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
64
67
|
const [kbChecked, setKbChecked] = useState<Set<string>>(() => new Set(linkedKbIds));
|
|
65
68
|
const [savingKbs, setSavingKbs] = useState(false);
|
|
66
69
|
const [deleteMode, setDeleteMode] = useState<"transfer" | "clear">("transfer");
|
|
67
70
|
const userTimezone = useTimezone();
|
|
71
|
+
// S17:时区脏检查(与 server 权威值比对;按钮仅变化时可用)
|
|
72
|
+
const tzUnchanged = (useCustomTz ? projectTimezone : null) === project.timezone;
|
|
68
73
|
|
|
69
74
|
useEffect(() => {
|
|
70
75
|
setIcon(project.icon);
|
|
@@ -96,7 +101,6 @@ function ProjectSettingsPage() {
|
|
|
96
101
|
icon,
|
|
97
102
|
description: description.trim() || null,
|
|
98
103
|
displayId: displayId.toUpperCase(),
|
|
99
|
-
timezone: useCustomTz ? projectTimezone : null,
|
|
100
104
|
},
|
|
101
105
|
});
|
|
102
106
|
toast.success(m.projects_settings_saved());
|
|
@@ -109,6 +113,25 @@ function ProjectSettingsPage() {
|
|
|
109
113
|
}
|
|
110
114
|
};
|
|
111
115
|
|
|
116
|
+
// S17:时区独立保存(不再搭车基本信息卡)
|
|
117
|
+
const handleSaveTimezone = async () => {
|
|
118
|
+
setSavingTz(true);
|
|
119
|
+
try {
|
|
120
|
+
await updateProjectFn({
|
|
121
|
+
data: {
|
|
122
|
+
projectId: project.id,
|
|
123
|
+
timezone: useCustomTz ? projectTimezone : null,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
toast.success(m.projects_settings_saved());
|
|
127
|
+
await router.invalidate();
|
|
128
|
+
} catch (err) {
|
|
129
|
+
toastError(err, m.projects_settings_saveFailed());
|
|
130
|
+
} finally {
|
|
131
|
+
setSavingTz(false);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
112
135
|
const saveKbs = async () => {
|
|
113
136
|
setSavingKbs(true);
|
|
114
137
|
try {
|
|
@@ -246,6 +269,17 @@ function ProjectSettingsPage() {
|
|
|
246
269
|
)}
|
|
247
270
|
</div>
|
|
248
271
|
</RadioGroup>
|
|
272
|
+
<div>
|
|
273
|
+
<Button
|
|
274
|
+
size="sm"
|
|
275
|
+
variant="outline"
|
|
276
|
+
onClick={handleSaveTimezone}
|
|
277
|
+
disabled={savingTz || tzUnchanged}
|
|
278
|
+
>
|
|
279
|
+
{savingTz ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
|
|
280
|
+
{m.common_button_save()}
|
|
281
|
+
</Button>
|
|
282
|
+
</div>
|
|
249
283
|
</CardContent>
|
|
250
284
|
</Card>
|
|
251
285
|
)}
|
|
@@ -74,7 +74,7 @@ export const Route = createFileRoute("/_nav/stories/$storyId")({
|
|
|
74
74
|
listActivitiesFn({ data: { storyId: params.storyId, take: 50 } }),
|
|
75
75
|
getStoryNeighborsFn({ data: { storyId: params.storyId } }),
|
|
76
76
|
listProjectsBriefFn(),
|
|
77
|
-
listLabelsFn(),
|
|
77
|
+
listLabelsFn({ data: { projectId: story.projectId ?? undefined } }),
|
|
78
78
|
listStoriesFn({ data: { archived: false, take: RELATED_STORIES_TAKE } }),
|
|
79
79
|
listChildStoriesFn({ data: { parentId: params.storyId } }),
|
|
80
80
|
getDependenciesFn({ data: { storyId: params.storyId } }),
|
|
@@ -24,7 +24,7 @@ export const Route = createFileRoute("/_nav/stories/")({
|
|
|
24
24
|
const [stories, users, labels, projects] = await Promise.all([
|
|
25
25
|
listStoriesFn({ data: {} }),
|
|
26
26
|
listUsersBriefFn(),
|
|
27
|
-
listLabelsFn(),
|
|
27
|
+
listLabelsFn({ data: {} }),
|
|
28
28
|
listProjectsBriefFn(),
|
|
29
29
|
]);
|
|
30
30
|
return { stories, users, labels, projects };
|
|
@@ -39,7 +39,7 @@ export const Route = createFileRoute('/api/billing-webhook')({
|
|
|
39
39
|
}
|
|
40
40
|
try {
|
|
41
41
|
await billing.handleWebhookEvent(rawBody, signature)
|
|
42
|
-
await sendBillingEmailForEvent(rawBody)
|
|
42
|
+
await sendBillingEmailForEvent(rawBody, signature, billing)
|
|
43
43
|
return new Response(JSON.stringify({ received: true }), {
|
|
44
44
|
status: 200,
|
|
45
45
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -18,6 +18,8 @@ vi.mock('@allbluecn/database', () => ({
|
|
|
18
18
|
aiUsageLog: { count: vi.fn().mockResolvedValue(0), aggregate: vi.fn().mockResolvedValue({ _sum: { durationMs: null } }) },
|
|
19
19
|
userAgent: { count: vi.fn().mockResolvedValue(0) },
|
|
20
20
|
userSkill: { count: vi.fn().mockResolvedValue(0) },
|
|
21
|
+
anyHubApp: { count: vi.fn().mockResolvedValue(0) },
|
|
22
|
+
blob: { aggregate: vi.fn().mockResolvedValue({ _sum: { size: null } }) },
|
|
21
23
|
generationRun: { count: vi.fn().mockResolvedValue(0) },
|
|
22
24
|
subscription: { findUnique: vi.fn() },
|
|
23
25
|
plan: { findUnique: vi.fn(), findFirst: vi.fn() },
|
|
@@ -28,6 +28,8 @@ vi.mock('@allbluecn/database', () => ({
|
|
|
28
28
|
aiUsageLog: { count: vi.fn().mockResolvedValue(0), aggregate: vi.fn().mockResolvedValue({ _sum: { durationMs: null } }) },
|
|
29
29
|
userAgent: { count: vi.fn().mockResolvedValue(0) },
|
|
30
30
|
userSkill: { count: vi.fn().mockResolvedValue(0) },
|
|
31
|
+
anyHubApp: { count: vi.fn().mockResolvedValue(0) },
|
|
32
|
+
blob: { aggregate: vi.fn().mockResolvedValue({ _sum: { size: null } }) },
|
|
31
33
|
generationRun: { count: vi.fn().mockResolvedValue(0) },
|
|
32
34
|
subscription: { findUnique: vi.fn() },
|
|
33
35
|
plan: { findUnique: vi.fn(), findFirst: vi.fn() },
|
|
@@ -10,6 +10,8 @@ vi.mock('@allbluecn/database', () => ({
|
|
|
10
10
|
aiUsageLog: { count: vi.fn() },
|
|
11
11
|
userAgent: { count: vi.fn() },
|
|
12
12
|
userSkill: { count: vi.fn() },
|
|
13
|
+
anyHubApp: { count: vi.fn().mockResolvedValue(0) },
|
|
14
|
+
blob: { aggregate: vi.fn().mockResolvedValue({ _sum: { size: null } }) },
|
|
13
15
|
},
|
|
14
16
|
}))
|
|
15
17
|
vi.mock('@/server/auth-helpers', () => ({ requireAuth: vi.fn() }))
|
|
@@ -39,6 +39,8 @@ vi.mock('@allbluecn/database', () => ({
|
|
|
39
39
|
aiUsageLog: { count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { durationMs: null } }) },
|
|
40
40
|
userAgent: { count: vi.fn().mockResolvedValue(0) },
|
|
41
41
|
userSkill: { count: vi.fn().mockResolvedValue(0) },
|
|
42
|
+
anyHubApp: { count: vi.fn().mockResolvedValue(0) },
|
|
43
|
+
blob: { aggregate: vi.fn().mockResolvedValue({ _sum: { size: null } }) },
|
|
42
44
|
generationRun: { count: vi.fn().mockResolvedValue(0) },
|
|
43
45
|
},
|
|
44
46
|
}))
|
|
@@ -101,6 +101,22 @@ afterEach(() => {
|
|
|
101
101
|
vi.restoreAllMocks()
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
+
describe("S25:per-user 记忆事实上限窗口", () => {
|
|
105
|
+
it("save 成功后执行裁剪 DELETE(保留最近 200 条,淘汰最旧)", async () => {
|
|
106
|
+
mocks.queryRaw.mockResolvedValue({ rows: [{ embedding: "[1,2,3]" }] })
|
|
107
|
+
mocks.executeRaw.mockResolvedValue(undefined)
|
|
108
|
+
const adapter = makeAdapter({ now: () => new Date("2026-01-01T00:00:00Z").toISOString() })
|
|
109
|
+
await adapter.save(
|
|
110
|
+
{ userId: "u1", threadId: "t1" },
|
|
111
|
+
{ user: "u".repeat(30), assistant: "a" },
|
|
112
|
+
)
|
|
113
|
+
const calls = mocks.executeRaw.mock.calls.map((c) => String(c[0]))
|
|
114
|
+
expect(calls.length).toBe(2)
|
|
115
|
+
expect(calls[1]).toContain("DELETE FROM \"memory_facts\"")
|
|
116
|
+
expect(calls[1]).toContain("LIMIT")
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
104
120
|
describe("pgvector MemoryAdapter.save", () => {
|
|
105
121
|
it("持久化 turn(embedding + 原文)并返回 ok 与 latencyMs", async () => {
|
|
106
122
|
advanceNowBy(25)
|
|
@@ -85,6 +85,8 @@ export function createPgVectorMemoryAdapter(
|
|
|
85
85
|
options: PgVectorMemoryAdapterOptions = {},
|
|
86
86
|
): MemoryAdapter {
|
|
87
87
|
const minUserChars = options.minUserChars ?? DEFAULT_MIN_USER_CHARS
|
|
88
|
+
// S25:per-user 记忆事实上限(超限淘汰最旧)
|
|
89
|
+
const MEMORY_FACTS_USER_CAP = 200
|
|
88
90
|
const candidateLimit = options.recallCandidateLimit ?? DEFAULT_RECALL_CANDIDATE_LIMIT
|
|
89
91
|
const topK = options.recallTopK ?? DEFAULT_RECALL_TOP_K
|
|
90
92
|
const similarityThreshold =
|
|
@@ -180,6 +182,18 @@ export function createPgVectorMemoryAdapter(
|
|
|
180
182
|
await prisma.$executeRaw`
|
|
181
183
|
INSERT INTO "memory_facts" ("id", "userId", "threadId", "content", "embedding")
|
|
182
184
|
VALUES (${id}, ${scope.userId}, ${scope.threadId}, ${content}, ${formatVector(embedding)}::vector)`
|
|
185
|
+
// S25(裁决:按高收益方向):per-user 上限窗口——超限淘汰最旧,防无限
|
|
186
|
+
// 增长;相似度去重复杂度不成比例,不做。上限写死 200(长期记忆召回
|
|
187
|
+
// 取 TOP-K 远小于此,裁剪只影响被淘汰的最旧事实)。
|
|
188
|
+
await prisma.$executeRaw`
|
|
189
|
+
DELETE FROM "memory_facts"
|
|
190
|
+
WHERE "userId" = ${scope.userId}
|
|
191
|
+
AND "id" NOT IN (
|
|
192
|
+
SELECT "id" FROM "memory_facts"
|
|
193
|
+
WHERE "userId" = ${scope.userId}
|
|
194
|
+
ORDER BY "createdAt" DESC
|
|
195
|
+
LIMIT ${MEMORY_FACTS_USER_CAP}
|
|
196
|
+
)`
|
|
183
197
|
return [
|
|
184
198
|
{
|
|
185
199
|
ok: true,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { prisma } from "@allbluecn/database"
|
|
2
|
+
import { requireResourceForUser } from "@/server/resources"
|
|
2
3
|
import type { BlobRef, BlobStore } from "./blob-store"
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -19,6 +20,9 @@ export class DbBlobStore implements BlobStore {
|
|
|
19
20
|
if (size === 0 || size > MAX_BLOB_BYTES) {
|
|
20
21
|
throw new Error(`blob size ${size} exceeds limit ${MAX_BLOB_BYTES}`)
|
|
21
22
|
}
|
|
23
|
+
// S6:blob 存储配额预检(存储汇点覆盖全部上传路径;TOCTOU 统计性预检同
|
|
24
|
+
// requireResource 口径)。1MB=1MiB,向上取整。
|
|
25
|
+
await requireResourceForUser(userId, 'blobStorageMb', Math.ceil(size / (1024 * 1024)))
|
|
22
26
|
const record = await prisma.blob.create({
|
|
23
27
|
data: { mimeType, bytes: Uint8Array.from(blob), size, userId },
|
|
24
28
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// 计费 webhook
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// 计费 webhook 邮件链路。S43(裁决落地):不再单靠「上游 handleWebhookEvent
|
|
2
|
+
// 已验签」的顺序契约——本函数在 JSON.parse 前调用 billing 服务暴露的
|
|
3
|
+
// verifyWebhookSignatureRaw 独立验签(Pro 实现;OSS 无 billing 时上游 gate
|
|
4
|
+
// 已 501 拦截,本函数不会被触达)。上游双重验签成本为一次 HMAC,可忽略。
|
|
5
5
|
import { prisma } from '@allbluecn/database'
|
|
6
6
|
import { sendTemplateEmail } from '@/server/email'
|
|
7
7
|
|
|
@@ -11,8 +11,21 @@ interface CreemEvent {
|
|
|
11
11
|
object?: { id?: string; subscriptionId?: string; metadata?: Record<string, string> }
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export async function sendBillingEmailForEvent(
|
|
14
|
+
export async function sendBillingEmailForEvent(
|
|
15
|
+
rawBody: string,
|
|
16
|
+
signature?: string,
|
|
17
|
+
billing?: {
|
|
18
|
+
verifyWebhookSignatureRaw?(rawBody: string, signature: string): Promise<unknown>
|
|
19
|
+
},
|
|
20
|
+
): Promise<void> {
|
|
15
21
|
try {
|
|
22
|
+
// S43:独立验签优先——Pro 端未实现(旧 Pro 包)时回退顺序契约并留证
|
|
23
|
+
if (billing?.verifyWebhookSignatureRaw) {
|
|
24
|
+
if (!signature) throw new Error('缺少 creem-signature(邮件链路独立验签)')
|
|
25
|
+
await billing.verifyWebhookSignatureRaw(rawBody, signature)
|
|
26
|
+
} else {
|
|
27
|
+
console.warn('[billing-email] billing 服务未暴露独立验签,回退上游顺序契约')
|
|
28
|
+
}
|
|
16
29
|
const evt = JSON.parse(rawBody) as CreemEvent
|
|
17
30
|
// 官方路径 `object.id`;保留 `object.subscriptionId` fallback 兜底旧版/非标准 payload。
|
|
18
31
|
const subId = evt.object?.id ?? evt.object?.subscriptionId
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
// S2(裁决:纯机械拆分):bug-crud.ts 的纯 helper 子流程迁此——主文件只留
|
|
19
|
+
// serverFn 定义。import 自兄弟文件,测试经 bug-crud re-export 零改动。
|
|
20
|
+
|
|
21
|
+
import { getPrisma } from "@allbluecn/database/get-prisma";
|
|
22
|
+
import type { AvatarConfig } from "@allbluecn/kernel";
|
|
23
|
+
import { isUserInTeam } from "@/server/team-access";
|
|
24
|
+
|
|
25
|
+
export async function validateBugAssignee(projectId: string, assigneeId: string | null | undefined) {
|
|
26
|
+
if (!assigneeId) return;
|
|
27
|
+
const prisma = await getPrisma();
|
|
28
|
+
const project = await prisma.project.findUnique({
|
|
29
|
+
where: { id: projectId },
|
|
30
|
+
select: { ownerId: true },
|
|
31
|
+
});
|
|
32
|
+
if (!project) throw new Error("项目不存在");
|
|
33
|
+
const ok = await isUserInTeam(project.ownerId, assigneeId);
|
|
34
|
+
if (!ok) throw new Error("负责人必须是团队成员");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 批量写路径的成员圈定(R6):与 deleteBugFn 同口径——目标 bugs 所属每个项目
|
|
38
|
+
// 都须有当前用户的有效成员关系(status 非 suspended),任一无权即整体拒绝。
|
|
39
|
+
// 判定在事务前完成,防止批量入口绕过单删的权限语义。
|
|
40
|
+
export async function assertBugsInMemberProjects(bugIds: string[], userId: string) {
|
|
41
|
+
const prisma = await getPrisma();
|
|
42
|
+
const rows = await prisma.bug.findMany({
|
|
43
|
+
where: { id: { in: bugIds } },
|
|
44
|
+
select: { projectId: true },
|
|
45
|
+
});
|
|
46
|
+
const projectIds = [...new Set(rows.map((r) => r.projectId))];
|
|
47
|
+
if (projectIds.length === 0) return;
|
|
48
|
+
const memberships = await prisma.projectMember.findMany({
|
|
49
|
+
where: { userId, projectId: { in: projectIds }, status: { not: "suspended" } },
|
|
50
|
+
select: { projectId: true },
|
|
51
|
+
});
|
|
52
|
+
const allowed = new Set(memberships.map((m) => m.projectId));
|
|
53
|
+
if (projectIds.some((p) => !allowed.has(p))) {
|
|
54
|
+
throw new Error("无权限操作该项目的 BUG");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function toBugActivityItem(a: {
|
|
59
|
+
id: string;
|
|
60
|
+
action: string;
|
|
61
|
+
field: string | null;
|
|
62
|
+
oldValue: string | null;
|
|
63
|
+
newValue: string | null;
|
|
64
|
+
isAI: boolean;
|
|
65
|
+
createdAt: Date;
|
|
66
|
+
user: {
|
|
67
|
+
id: string;
|
|
68
|
+
username: string;
|
|
69
|
+
email: string;
|
|
70
|
+
avatarConfig: unknown;
|
|
71
|
+
bgShape: string | null;
|
|
72
|
+
bgColor: string | null;
|
|
73
|
+
} | null;
|
|
74
|
+
}) {
|
|
75
|
+
return {
|
|
76
|
+
id: a.id,
|
|
77
|
+
action: a.action,
|
|
78
|
+
field: a.field,
|
|
79
|
+
oldValue: a.oldValue,
|
|
80
|
+
newValue: a.newValue,
|
|
81
|
+
isAI: a.isAI,
|
|
82
|
+
createdAt: a.createdAt.toISOString(),
|
|
83
|
+
user: a.user
|
|
84
|
+
? { ...a.user, avatarConfig: (a.user.avatarConfig as AvatarConfig | null) ?? null }
|
|
85
|
+
: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bug.description(Text 列 JSON 串)→ {html, markdown} 门卫 content。
|
|
91
|
+
* 与 components/story/types.ts parseStoryDescription 读取口径同构的镜像
|
|
92
|
+
* (服务端不 import 组件模块);历史裸串兜底为双轨原串。Bug.description 与
|
|
93
|
+
* Story.description 存储同构(schema 注释),S12 裁决 UI 本期不接协同编辑,
|
|
94
|
+
* 但旁路写(创建对话框等)仍须经理卫转发防互吞。
|
|
95
|
+
*/
|
|
96
|
+
export function bugDescriptionToContent(description: string): { html: string; markdown: string } {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(description) as { html?: unknown; markdown?: unknown };
|
|
99
|
+
if (typeof parsed.html === "string") {
|
|
100
|
+
const markdown = typeof parsed.markdown === "string" ? parsed.markdown : parsed.html;
|
|
101
|
+
return { html: parsed.html, markdown };
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// 历史裸串兜底
|
|
105
|
+
}
|
|
106
|
+
return { html: description, markdown: description };
|
|
107
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
// S2(纯机械拆分):knowledge.ts 纯 helper 迁 server 树兄弟文件
|
|
19
|
+
//(serverFns 树外,纯净性口径②)。
|
|
20
|
+
|
|
21
|
+
// 文档 content/properties 的 JSON 体积上限(updateDocumentFn schema refine 消费)
|
|
22
|
+
export const MAX_DOC_CONTENT_JSON_CHARS = 2_000_000
|
|
23
|
+
export const MAX_DOC_PROPERTIES_JSON_CHARS = 256_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
export function jsonSizeOk(value: unknown, max: number): boolean {
|
|
27
|
+
if (value === undefined || value === null) return true
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(value).length <= max
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 嵌入行 id 约定为 `${documentId}-${chunkIndex}`(knowledge-index-utils
|
|
36
|
+
* indexDocumentEmbeddings 写入),chunkIndex 可为多位数字,取最后一个 "-" 前缀。 */
|
|
37
|
+
export function documentIdFromEmbeddingRowId(rowId: string): string {
|
|
38
|
+
const cut = rowId.lastIndexOf('-')
|
|
39
|
+
return cut > 0 ? rowId.slice(0, cut) : rowId
|
|
40
|
+
}
|