diff --git a/apps/web_v1/scripts/structure-check.mjs b/apps/web_v1/scripts/structure-check.mjs index 1be2bde..9c9d28c 100644 --- a/apps/web_v1/scripts/structure-check.mjs +++ b/apps/web_v1/scripts/structure-check.mjs @@ -7,6 +7,7 @@ const requiredFiles = [ 'src/pages/workspace-home.tsx', 'src/pages/workspace-explore.tsx', 'src/pages/projects/project-overview.tsx', + 'src/pages/projects/project-inbox.tsx', 'src/pages/projects/project-channel-page.tsx', 'src/pages/projects/project-tasks.tsx', 'src/pages/projects/project-ai.tsx', @@ -33,7 +34,7 @@ for (const token of ['--color-primary: #165dff', '--font-sans', '--space-4: 16px if (!tokenSource.toLowerCase().includes(token)) failures.push(`missing token ${token}`) } for (const forbidden of ['森林Agent', 'AI智能体', '实时接口', '进度 60%']) { - for (const file of requiredFiles.filter((name) => name.endsWith('.tsx'))) { + for (const file of requiredFiles.filter((name) => name.endsWith('.tsx') && existsSync(name))) { if (readFileSync(file, 'utf8').includes(forbidden)) failures.push(`${file} contains forbidden copy ${forbidden}`) } } @@ -42,7 +43,7 @@ if (existsSync('src/App.tsx')) { failures.push('legacy src/App.tsx should be removed after page split') } -for (const removed of ['src/pages/projects/project-data.tsx', 'src/pages/projects/project-inspector.tsx', 'src/pages/projects/project-inbox.tsx']) { +for (const removed of ['src/pages/projects/project-data.tsx', 'src/pages/projects/project-inspector.tsx']) { if (existsSync(removed)) { failures.push(`${removed} should be removed; frontend data must come from backend APIs`) } @@ -105,6 +106,11 @@ const inboxSource = existsSync('src/api/inbox.ts') ? readFileSync('src/api/inbox for (const path of ['/api/v1/projects/', '/api/v1/inbox/']) { if (inboxSource && !inboxSource.includes(path)) failures.push(`inbox API must use ${path}`) } +if (inboxSource.includes('body: { suggestions }')) failures.push('inbox confirm must not send client-authored suggestion content') +if (!inboxSource.includes('body: { suggestionIds }')) failures.push('inbox confirm must send only saved suggestion identities') + +const inboxPageSource = existsSync('src/pages/projects/project-inbox.tsx') ? readFileSync('src/pages/projects/project-inbox.tsx', 'utf8') : '' +if (inboxPageSource && !inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action') const html = readFileSync('index.html', 'utf8') if (!html.includes('')) failures.push('index language must be zh-CN') diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index 2cffe54..db7e42d 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -20,6 +20,14 @@ const errors = [] const failures = [] const projectId = '019b0000-0000-7000-8000-000000000001' const taskSearchResultId = '019b0000-0000-7000-8000-000000000003' +const inboxItemId = '019b0000-0000-7000-8000-000000000004' +const inboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000005' +const inboxNoteSuggestionId = '019b0000-0000-7000-8000-000000000006' +const inboxSourceSuggestionId = '019b0000-0000-7000-8000-000000000007' +const secondInboxItemId = '019b0000-0000-7000-8000-000000000008' +const secondInboxTaskSuggestionId = '019b0000-0000-7000-8000-000000000009' +const secondInboxNoteSuggestionId = '019b0000-0000-7000-8000-000000000010' +const secondInboxSourceSuggestionId = '019b0000-0000-7000-8000-000000000011' const unknownProjectId = '019b0000-0000-7000-8000-000000000099' const externalProjectId = '019b0000-0000-7000-8000-000000000088' const externalTaskId = '019b0000-0000-7000-8000-000000000089' @@ -27,8 +35,16 @@ const externalNoteId = '019b0000-0000-7000-8000-000000000090' const searchRequests = [] const workspaceRequests = [] const projectPatchRequests = [] +const inboxAnalyzeRequests = [] +const inboxConfirmRequests = [] let expectingProjectPatchError = false let expectedProjectPatchConsoleErrorCount = 0 +let expectingInboxConfirmError = false +let expectedInboxConfirmConsoleErrorCount = 0 +let expectingInboxRefreshError = false +let expectedInboxRefreshConsoleErrorCount = 0 +let failNextInboxConfirm = true +let failNextInboxWorkspaceRefresh = false page.on('console', (message) => { if (message.type() !== 'error') return if ( @@ -39,6 +55,22 @@ page.on('console', (message) => { expectedProjectPatchConsoleErrorCount += 1 return } + if ( + expectingInboxConfirmError && + expectedInboxConfirmConsoleErrorCount === 0 && + message.text() === 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)' + ) { + expectedInboxConfirmConsoleErrorCount += 1 + return + } + if ( + expectingInboxRefreshError && + expectedInboxRefreshConsoleErrorCount === 0 && + message.text() === 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)' + ) { + expectedInboxRefreshConsoleErrorCount += 1 + return + } errors.push(message.text()) }) @@ -55,16 +87,38 @@ const visualCheckWorkspace = { }, channels: [ { id: 'overview', projectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 }, - { id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 1, url: '', sortOrder: 1 }, - { id: 'ai', projectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 2 }, - { id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 3 }, - { id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 4 }, + { id: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'mail', count: 2, url: '', sortOrder: 1 }, + { id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 1, url: '', sortOrder: 2 }, + { id: 'ai', projectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 3 }, + { id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 4 }, + { id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 5 }, ], tags: [{ id: '019b0000-0000-7000-8000-000000000002', name: '产品' }], recentSessions: [ { id: 'session-1', projectId, title: '移动导航会话', summary: '检查抽屉关闭行为', updatedAt: '2026-07-21T02:00:00Z', references: [] }, ], - inbox: [], + inbox: [ + { + id: inboxItemId, + projectId, + source: '手动收集', + title: '整理客户访谈', + summary: '把访谈结论整理为后续任务、会议纪要和背景资料。', + status: 'open', + tag: '待处理', + time: '2026-07-21T02:30:00Z', + }, + { + id: secondInboxItemId, + projectId, + source: '手动收集', + title: '项目复盘记录', + summary: '整理项目复盘中的行动项和背景信息。', + status: 'open', + tag: '待处理', + time: '2026-07-21T02:20:00Z', + }, + ], tasks: [], aiSessions: [], notesSources: [], @@ -88,9 +142,62 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { } if (url.pathname === `/api/v1/projects/${projectId}/workspace`) { workspaceRequests.push(url.pathname) + if (failNextInboxWorkspaceRefresh) { + failNextInboxWorkspaceRefresh = false + await route.fulfill({ + status: 500, + json: { error: { code: 'internal_error', message: '工作区刷新失败,请稍后重试' } }, + }) + return + } await route.fulfill({ json: visualCheckWorkspace }) return } + if (url.pathname === `/api/v1/inbox/${inboxItemId}/analyze` && method === 'POST') { + inboxAnalyzeRequests.push(inboxItemId) + if (inboxAnalyzeRequests.length === 1) await new Promise((resolve) => setTimeout(resolve, 250)) + await route.fulfill({ + json: { + suggestions: [ + { id: inboxTaskSuggestionId, kind: 'task', title: '跟进客户反馈', body: '联系客户确认下一步时间。' }, + { id: inboxNoteSuggestionId, kind: 'note', title: '客户访谈纪要', body: '保存访谈中的关键结论。' }, + { id: inboxSourceSuggestionId, kind: 'source', title: '访谈背景资料', body: '保存为文本资料,不伪造文件路径。' }, + ], + }, + }) + return + } + if (url.pathname === `/api/v1/inbox/${secondInboxItemId}/analyze` && method === 'POST') { + inboxAnalyzeRequests.push(secondInboxItemId) + await route.fulfill({ + json: { + suggestions: [ + { id: secondInboxTaskSuggestionId, kind: 'task', title: '整理复盘任务', body: '安排下一次复盘。' }, + { id: secondInboxNoteSuggestionId, kind: 'note', title: '复盘记录', body: '保存复盘结论。' }, + { id: secondInboxSourceSuggestionId, kind: 'source', title: '复盘资料', body: '保存复盘背景。' }, + ], + }, + }) + return + } + if ([`/api/v1/inbox/${inboxItemId}/confirm`, `/api/v1/inbox/${secondInboxItemId}/confirm`].includes(url.pathname) && method === 'POST') { + inboxConfirmRequests.push(route.request().postDataJSON()) + await new Promise((resolve) => setTimeout(resolve, 250)) + if (failNextInboxConfirm) { + failNextInboxConfirm = false + await route.fulfill({ + status: 500, + json: { error: { code: 'internal_error', message: '确认创建失败,请稍后重试' } }, + }) + return + } + const confirmedItemId = url.pathname.split('/').at(-2) + visualCheckWorkspace.inbox.find((item) => item.id === confirmedItemId).status = 'processed' + visualCheckWorkspace.channels.find((channel) => channel.type === 'inbox').count -= 1 + if (confirmedItemId === secondInboxItemId) failNextInboxWorkspaceRefresh = true + await route.fulfill({ json: { createdCount: inboxConfirmRequests.at(-1).suggestionIds.length } }) + return + } if (url.pathname === '/api/v1/search') { const query = url.searchParams.get('q') ?? '' searchRequests.push(query) @@ -487,6 +594,119 @@ const stageHoverMetrics = await collectMetrics() await page.locator('.channel-list').hover() const channelListHoverMetrics = await collectMetrics() +const inboxChannelButton = page.locator('.channel-button', { hasText: 'Inbox 消息流' }) +if (await inboxChannelButton.count() === 0) { + failures.push('project sidebar must expose the Inbox channel from the workspace API') +} else { + await inboxChannelButton.click() + const inboxPage = page.locator('.project-inbox-page') + if (await inboxPage.count() === 0) { + failures.push('Inbox channel must render the confirmation workflow page') + } else { + for (const content of ['整理客户访谈', '把访谈结论整理为后续任务、会议纪要和背景资料。']) { + if (!await inboxPage.getByText(content, { exact: true }).first().isVisible()) failures.push(`Inbox content missing ${content}`) + } + if (await inboxPage.getByRole('button', { name: '确认创建', exact: true }).count() !== 1) { + failures.push('Inbox page must render exactly one 确认创建 action') + } + const firstInboxRow = inboxPage.locator('.mail-item', { hasText: '整理客户访谈' }) + const secondInboxRow = inboxPage.locator('.mail-item', { hasText: '项目复盘记录' }) + await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click() + await secondInboxRow.click() + await page.waitForTimeout(300) + if (await inboxPage.getByRole('checkbox').count() !== 0) { + failures.push('a stale analysis response must not attach the previous item drafts to the newly selected Inbox item') + } + await firstInboxRow.click() + await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click() + await inboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {}) + if (inboxAnalyzeRequests.length !== 2 || inboxConfirmRequests.length !== 0) { + failures.push('analysis must load drafts without invoking confirmation writes') + } + if (await inboxPage.getByRole('checkbox').count() !== 3) { + failures.push('analysis must render one checkbox for each saved suggestion') + } + const sourceSuggestionCheckbox = inboxPage.locator('.arco-checkbox', { hasText: '访谈背景资料' }) + await sourceSuggestionCheckbox.click() + if (await sourceSuggestionCheckbox.locator('input[type="checkbox"]').isChecked()) { + failures.push('suggestion checkbox must allow the user to exclude a draft before confirmation') + } + + const confirmButton = inboxPage.getByRole('button', { name: '确认创建', exact: true }) + expectingInboxConfirmError = true + await confirmButton.click() + await page.waitForTimeout(50) + if (!await confirmButton.evaluate((button) => button.classList.contains('arco-btn-loading'))) { + failures.push('Inbox confirmation must show loading while the write is pending') + } + await page.waitForTimeout(300) + if (!await inboxPage.getByText('确认创建失败,请稍后重试', { exact: true }).isVisible()) { + failures.push('failed Inbox confirmation must show the API Chinese error') + } + expectingInboxConfirmError = false + if (expectedInboxConfirmConsoleErrorCount !== 1) { + failures.push(`expected one simulated Inbox confirm console error, got ${expectedInboxConfirmConsoleErrorCount}`) + } + + const workspaceRequestCount = workspaceRequests.length + await confirmButton.click() + await inboxPage.getByText('已创建 2 个对象', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {}) + if (!await inboxPage.getByText('已创建 2 个对象', { exact: true }).isVisible()) { + failures.push('successful Inbox confirmation must show the created object count') + } + const submitted = inboxConfirmRequests.at(-1)?.suggestionIds ?? [] + if (JSON.stringify(submitted) !== JSON.stringify([inboxTaskSuggestionId, inboxNoteSuggestionId])) { + failures.push(`Inbox confirm must send only checked saved suggestion identities, got ${JSON.stringify(submitted)}`) + } + if (workspaceRequests.length <= workspaceRequestCount) { + failures.push('successful Inbox confirmation must refresh the project workspace') + } + await page.screenshot({ path: 'test-results/project-inbox-confirmed.png', fullPage: true }) + await page.setViewportSize({ width: 390, height: 844 }) + await page.waitForTimeout(250) + await page.screenshot({ path: 'test-results/project-inbox-confirmed-mobile.png', fullPage: true }) + const inboxMobileLayout = await inboxPage.evaluate((node) => { + const workflow = node.querySelector('.mail-layout') + const detail = node.querySelector('.inbox-detail') + const detailRight = detail?.getBoundingClientRect().right ?? node.getBoundingClientRect().right + const clippedDetailLabels = [...node.querySelectorAll('.inbox-detail-head > div > .arco-typography-secondary, .inbox-draft-section .section-header > .arco-typography-secondary')] + .filter((label) => label.getBoundingClientRect().right > detailRight + 1) + .map((label) => label.textContent?.trim()) + return { + pageOverflow: node.scrollWidth > node.clientWidth, + workflowOverflow: workflow ? workflow.scrollWidth > workflow.clientWidth : true, + clippedDetailLabels, + } + }) + if (inboxMobileLayout.pageOverflow || inboxMobileLayout.workflowOverflow || inboxMobileLayout.clippedDetailLabels.length) { + failures.push(`Inbox confirmation page must stack without mobile horizontal overflow, got ${JSON.stringify(inboxMobileLayout)}`) + } + await page.setViewportSize({ width: 1440, height: 1024 }) + + await secondInboxRow.click() + await inboxPage.getByRole('button', { name: '分析内容', exact: true }).click() + await inboxPage.getByRole('checkbox').first().waitFor({ state: 'attached', timeout: 2000 }).catch(() => {}) + expectingInboxRefreshError = true + const secondConfirmButton = inboxPage.getByRole('button', { name: '确认创建', exact: true }) + await secondConfirmButton.click() + await inboxPage.getByText('已创建 3 个对象', { exact: true }).waitFor({ state: 'visible', timeout: 2000 }).catch(() => {}) + if (!await inboxPage.getByText('已创建 3 个对象', { exact: true }).isVisible()) { + failures.push('a completed confirmation must preserve its created count when workspace refresh fails') + } + if (!await inboxPage.getByText('对象已创建,但工作区刷新失败,请稍后重新进入项目', { exact: true }).isVisible()) { + failures.push('workspace refresh failure after confirmation must be shown separately in Chinese') + } + if (!await secondConfirmButton.isDisabled()) { + failures.push('a confirmed item must remain non-repeatable when its workspace refresh fails') + } + expectingInboxRefreshError = false + if (expectedInboxRefreshConsoleErrorCount !== 1) { + failures.push(`expected one simulated Inbox refresh console error, got ${expectedInboxRefreshConsoleErrorCount}`) + } + await page.screenshot({ path: 'test-results/project-inbox-refresh-warning.png', fullPage: true }) + } +} + const channelPageChecks = [] for (const channel of [ { label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' }, diff --git a/apps/web_v1/src/App.css b/apps/web_v1/src/App.css index 65f449f..dbfc932 100644 --- a/apps/web_v1/src/App.css +++ b/apps/web_v1/src/App.css @@ -1421,6 +1421,99 @@ text-overflow: ellipsis; } +.inbox-detail .arco-card-body { + display: grid; + align-content: start; + gap: var(--space-4); +} + +.inbox-detail-head, +.inbox-confirm-bar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} + +.inbox-detail-head h5, +.inbox-draft-section h6 { + margin: 0; +} + +.inbox-content, +.inbox-draft-section { + display: grid; + gap: var(--space-2); + border-top: 1px solid var(--color-border); + padding-top: var(--space-3); +} + +.inbox-content p { + margin: 0; + color: var(--color-text); + line-height: 1.7; +} + +.inbox-section-label { + font-weight: 700; +} + +.inbox-suggestion-list { + display: grid; + gap: var(--space-2); +} + +.inbox-suggestion-list .arco-checkbox { + width: 100%; + align-items: flex-start; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + padding: 10px 12px; +} + +.inbox-suggestion-copy { + min-width: 0; + display: grid; + gap: 5px; +} + +.inbox-suggestion-copy > span:first-child { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.inbox-feedback { + margin: 0; +} + +.inbox-confirm-bar { + align-items: center; + border-top: 1px solid var(--color-border); + padding-top: var(--space-3); +} + +@media (max-width: 960px) { + .inbox-workspace { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 560px) { + .project-inbox-page .overview-head, + .inbox-detail-head, + .inbox-confirm-bar { + align-items: stretch; + flex-direction: column; + } + + .inbox-detail-head .arco-btn, + .inbox-confirm-bar .arco-btn { + width: 100%; + min-height: 40px; + } +} + .agent-chat-shell { min-height: calc(100vh - 170px); display: grid; diff --git a/apps/web_v1/src/api/inbox.ts b/apps/web_v1/src/api/inbox.ts index 508dbd6..7ef3647 100644 --- a/apps/web_v1/src/api/inbox.ts +++ b/apps/web_v1/src/api/inbox.ts @@ -12,6 +12,7 @@ export type InboxItemDTO = { } export type InboxSuggestionDTO = { + id: string kind: 'task' | 'note' | 'source' title: string body: string @@ -21,6 +22,10 @@ export type AnalyzeInboxResponseDTO = { suggestions: InboxSuggestionDTO[] } +export type ConfirmInboxResponseDTO = { + createdCount: number +} + export type CaptureInboxInput = { sourceType: string title: string @@ -42,11 +47,10 @@ export async function analyzeInboxItem(session: ApiSession, inboxId: string) { }) } -export async function confirmInboxItem(session: ApiSession, inboxId: string, suggestions: InboxSuggestionDTO[]) { - return apiRequest(`/api/v1/inbox/${inboxId}/confirm`, { +export async function confirmInboxItem(session: ApiSession, inboxId: string, suggestionIds: string[]) { + return apiRequest(`/api/v1/inbox/${inboxId}/confirm`, { method: 'POST', token: session.token, - body: { suggestions }, - responseType: 'void', + body: { suggestionIds }, }) } diff --git a/apps/web_v1/src/api/mappers.tsx b/apps/web_v1/src/api/mappers.tsx index e81122b..4263d55 100644 --- a/apps/web_v1/src/api/mappers.tsx +++ b/apps/web_v1/src/api/mappers.tsx @@ -28,7 +28,7 @@ export function mapWorkspace(payload: ProjectWorkspaceDTO, index = 0): ProjectWo return { project, - channels: payload.channels.filter((channel) => channel.type !== 'inbox').map(mapChannel), + channels: payload.channels.map(mapChannel), tags: payload.tags.map((tag) => tag.name), recentSessions: payload.recentSessions.map(mapAISession), inbox: payload.inbox.map(mapInbox), diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index 5867200..44cfc71 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -3,6 +3,7 @@ import { ConfigProvider, Message, Spin } from '@arco-design/web-react' import '@arco-design/web-react/dist/css/arco.css' import '../App.css' import { login, setApiBaseUrl, type ApiSession } from '../api/client' +import { analyzeInboxItem, confirmInboxItem } from '../api/inbox' import { mapWorkspace } from '../api/mappers' import { createCronPlan, @@ -229,6 +230,24 @@ function App() { Message.success('项目设置已更新') } + async function handleAnalyzeInbox(inboxId: string) { + const response = await analyzeInboxItem(requireSession(), inboxId) + return response.suggestions + } + + async function handleConfirmInbox(inboxId: string, suggestionIds: string[]) { + const response = await confirmInboxItem(requireSession(), inboxId, suggestionIds) + try { + await refreshAfterAction() + return { createdCount: response.createdCount } + } catch { + return { + createdCount: response.createdCount, + refreshError: '对象已创建,但工作区刷新失败,请稍后重新进入项目', + } + } + } + function handleSelectSearchResult(result: SearchResultDTO) { const target = searchResultTarget(result.type) if (!target) { @@ -296,6 +315,8 @@ function App() { onSearchQueryChange={workspaceSearch.onQueryChange} onSearch={() => void workspaceSearch.onSearch()} onSelectSearchResult={handleSelectSearchResult} + onAnalyzeInbox={handleAnalyzeInbox} + onConfirmInbox={handleConfirmInbox} /> ) : ( diff --git a/apps/web_v1/src/pages/projects/project-channel-page.tsx b/apps/web_v1/src/pages/projects/project-channel-page.tsx index 2e1b313..5e64aa2 100644 --- a/apps/web_v1/src/pages/projects/project-channel-page.tsx +++ b/apps/web_v1/src/pages/projects/project-channel-page.tsx @@ -1,11 +1,13 @@ import { ProjectAi } from './project-ai' import { ProjectCron } from './project-cron' +import { ProjectInbox } from './project-inbox' import { ProjectNewChannel } from './project-new-channel' import { ProjectNotes } from './project-notes' import { ProjectOverview } from './project-overview' import type { ProjectTaskUpdate } from './project-task-edit-modal' import { ProjectTasks } from './project-tasks' -import type { ChannelKey, ProjectWorkspace } from './project-types' +import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types' +import type { InboxSuggestionDTO } from '../../api/inbox' export function ProjectChannelPage({ activeChannel, @@ -19,6 +21,8 @@ export function ProjectChannelPage({ onCreateCronPlan, onCreateProjectTag, onUpdateTask, + onAnalyzeInbox, + onConfirmInbox, }: { activeChannel: ChannelKey activeWorkspace: ProjectWorkspace @@ -31,8 +35,12 @@ export function ProjectChannelPage({ onCreateCronPlan: () => void onCreateProjectTag: (name: string) => void onUpdateTask: (update: ProjectTaskUpdate) => void + onAnalyzeInbox: (inboxId: string) => Promise + onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise }) { switch (activeChannel) { + case 'inbox': + return case 'tasks': return case 'ai': diff --git a/apps/web_v1/src/pages/projects/project-inbox.tsx b/apps/web_v1/src/pages/projects/project-inbox.tsx new file mode 100644 index 0000000..ea86031 --- /dev/null +++ b/apps/web_v1/src/pages/projects/project-inbox.tsx @@ -0,0 +1,223 @@ +import { useMemo, useRef, useState } from 'react' +import { Alert, Button, Card, Checkbox, Empty, Space, Tag, Typography } from '@arco-design/web-react' +import { IconCheckCircle, IconRobot } from '@arco-design/web-react/icon' +import type { InboxSuggestionDTO } from '../../api/inbox' +import type { InboxConfirmationOutcome, InboxItem, ProjectWorkspace } from './project-types' + +const { Title, Text, Paragraph } = Typography + +type ProjectInboxProps = { + activeWorkspace: ProjectWorkspace + onAnalyze: (inboxId: string) => Promise + onConfirm: (inboxId: string, suggestionIds: string[]) => Promise +} + +export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectInboxProps) { + const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '') + const [draftSuggestions, setDraftSuggestions] = useState([]) + const [selectedSuggestionIds, setSelectedSuggestionIds] = useState([]) + const [analyzing, setAnalyzing] = useState(false) + const [confirming, setConfirming] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [refreshWarning, setRefreshWarning] = useState('') + const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState([]) + const analysisGeneration = useRef(0) + const selectedItem = useMemo( + () => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0], + [activeWorkspace.inbox, selectedItemId], + ) + + function selectItem(item: InboxItem) { + analysisGeneration.current += 1 + setSelectedItemId(item.id) + setDraftSuggestions([]) + setSelectedSuggestionIds([]) + setError('') + setSuccess('') + setRefreshWarning('') + setAnalyzing(false) + } + + async function analyzeSelectedItem() { + if (!selectedItem || selectedItem.status !== 'open') return + setAnalyzing(true) + setError('') + setSuccess('') + setRefreshWarning('') + const generation = analysisGeneration.current + 1 + analysisGeneration.current = generation + const analyzedItemId = selectedItem.id + try { + const suggestions = await onAnalyze(analyzedItemId) + if (analysisGeneration.current !== generation || selectedItemId !== analyzedItemId) return + setDraftSuggestions(suggestions) + setSelectedSuggestionIds(suggestions.map((suggestion) => suggestion.id)) + } catch (reason) { + if (analysisGeneration.current !== generation) return + setError(reason instanceof Error ? reason.message : '分析失败,请稍后重试') + } finally { + if (analysisGeneration.current === generation) setAnalyzing(false) + } + } + + async function confirmSelectedSuggestions() { + if (!selectedItem) return + if (selectedSuggestionIds.length === 0) { + setError('请至少勾选一条建议') + return + } + setConfirming(true) + setError('') + setSuccess('') + try { + const outcome = await onConfirm(selectedItem.id, selectedSuggestionIds) + setLocallyConfirmedItemIds((current) => current.includes(selectedItem.id) ? current : [...current, selectedItem.id]) + setSuccess(`已创建 ${outcome.createdCount} 个对象`) + setRefreshWarning(outcome.refreshError ?? '') + } catch (reason) { + setError(reason instanceof Error ? reason.message : '确认创建失败,请稍后重试') + } finally { + setConfirming(false) + } + } + + function toggleSuggestion(identity: string, checked: boolean) { + setSelectedSuggestionIds((current) => checked + ? current.includes(identity) ? current : [...current, identity] + : current.filter((value) => value !== identity)) + } + + return ( +
+
+
+ Inbox 消息流 + 查看原始内容,分析为候选草稿,并在确认后创建正式对象。 +
+
+ + {activeWorkspace.inbox.length === 0 ? ( + + + + ) : ( +
+ +
+ 收集内容({activeWorkspace.inbox.length}) +
+ {activeWorkspace.inbox.map((item) => ( + + ))} +
+ + + {selectedItem ? ( + <> +
+
+ + {selectedItem.title} + + {isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) ? '已处理' : '待处理'} + + + {selectedItem.meta} · {selectedItem.time} +
+ +
+ +
+ 原始内容 + {selectedItem.summary || '暂无内容'} +
+ +
+
+ 分析建议 + 仅勾选内容会在确认后创建 +
+ {draftSuggestions.length === 0 ? ( + 点击“分析内容”生成候选草稿,分析不会创建正式对象。 + ) : ( +
+ {draftSuggestions.map((suggestion) => ( + toggleSuggestion(suggestion.id, checked)} + > + + + {suggestionLabel(suggestion.kind)} + {suggestion.title} + + {suggestion.body || '暂无补充内容'} + + + ))} +
+ )} +
+ + {error && } + {success && } />} + {refreshWarning && } + +
+ 正式写入前请核对勾选项 + +
+ + ) : null} +
+
+ )} +
+ ) +} + +function isInboxItemProcessed(item: InboxItem, locallyConfirmedItemIds: string[]) { + return item.status !== 'open' || locallyConfirmedItemIds.includes(item.id) +} + +function suggestionLabel(kind: InboxSuggestionDTO['kind']) { + if (kind === 'task') return '任务' + if (kind === 'note') return '笔记' + return '资料' +} + +function suggestionColor(kind: InboxSuggestionDTO['kind']) { + if (kind === 'task') return 'arcoblue' + if (kind === 'note') return 'green' + return 'purple' +} diff --git a/apps/web_v1/src/pages/projects/project-types.ts b/apps/web_v1/src/pages/projects/project-types.ts index 405759e..52628c1 100644 --- a/apps/web_v1/src/pages/projects/project-types.ts +++ b/apps/web_v1/src/pages/projects/project-types.ts @@ -39,6 +39,11 @@ export type InboxItem = { status: string } +export type InboxConfirmationOutcome = { + createdCount: number + refreshError?: string +} + export type TaskItem = { id: string title: string diff --git a/apps/web_v1/src/pages/workspace-home.tsx b/apps/web_v1/src/pages/workspace-home.tsx index 913c308..61394c5 100644 --- a/apps/web_v1/src/pages/workspace-home.tsx +++ b/apps/web_v1/src/pages/workspace-home.tsx @@ -9,7 +9,8 @@ import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-s import { ProjectStatusbar } from './projects/project-statusbar' import { ProjectTopbar } from './projects/project-topbar' import type { SearchResultDTO } from '../api/search' -import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types' +import type { InboxSuggestionDTO } from '../api/inbox' +import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types' const { Content } = Layout @@ -42,6 +43,8 @@ export function ProjectPage({ onSearchQueryChange, onSearch, onSelectSearchResult, + onAnalyzeInbox, + onConfirmInbox, }: { activeView: WorkbenchView activeWorkspace: ProjectWorkspace @@ -71,6 +74,8 @@ export function ProjectPage({ onSearchQueryChange: (value: string) => void onSearch: () => void onSelectSearchResult: (result: SearchResultDTO) => void + onAnalyzeInbox: (inboxId: string) => Promise + onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise }) { const isProject = activeView === 'project' const [navOpen, setNavOpen] = useState<'projects' | 'channels' | null>(null) @@ -152,6 +157,8 @@ export function ProjectPage({ onCreateCronPlan={onCreateCronPlan} onCreateProjectTag={onCreateProjectTag} onUpdateTask={updateActiveProjectTask} + onAnalyzeInbox={onAnalyzeInbox} + onConfirmInbox={onConfirmInbox} /> )} diff --git a/backend/internal/logic/inbox/handlers.go b/backend/internal/logic/inbox/handlers.go index 0b50733..80d0d4d 100644 --- a/backend/internal/logic/inbox/handlers.go +++ b/backend/internal/logic/inbox/handlers.go @@ -1,17 +1,33 @@ package inbox import ( + "errors" "net/http" - "strconv" + "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" + "senlinai-agent/backend/internal/httpx" "senlinai-agent/backend/internal/logic/auth" + "senlinai-agent/backend/internal/models" ) type Handler struct { service *Service } +// InboxItemDTO 隔离 Gorm 自增主键,只通过公开 identity 关联项目和条目。 +type InboxItemDTO struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + SourceType string `json:"sourceType"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + func NewHandler(service *Service) *Handler { return &Handler{service: service} } @@ -23,68 +39,101 @@ func (h *Handler) Register(router gin.IRouter) { } func (h *Handler) capture(c *gin.Context) { - userID, ok := auth.CurrentUserID(c) + userID, ok := currentInboxUser(c) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } - projectID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"}) + projectIdentity, ok := httpx.IdentityParam(c, "id") + if !ok { return } var input struct { - SourceType string `json:"source_type"` + SourceType string `json:"sourceType"` Title string `json:"title"` Body string `json:"body"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效") return } - item, err := h.service.Capture(CaptureInput{ProjectID: uint(projectID), UserID: userID, SourceType: input.SourceType, Title: input.Title, Body: input.Body}) + item, err := h.service.Capture(CaptureInput{ + ProjectIdentity: projectIdentity, UserID: userID, SourceType: input.SourceType, Title: input.Title, Body: input.Body, + }) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeInboxError(c, err) return } - c.JSON(http.StatusCreated, item) + c.JSON(http.StatusCreated, inboxItemDTO(*item)) } func (h *Handler) analyze(c *gin.Context) { - userID, ok := auth.CurrentUserID(c) + userID, ok := currentInboxUser(c) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } - itemID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"}) + itemIdentity, ok := httpx.IdentityParam(c, "id") + if !ok { return } - suggestions, err := h.service.Analyze(uint(itemID), userID) + suggestions, err := h.service.Analyze(itemIdentity, userID) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeInboxError(c, err) return } c.JSON(http.StatusOK, gin.H{"suggestions": suggestions}) } func (h *Handler) confirm(c *gin.Context) { - itemID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"}) + userID, ok := currentInboxUser(c) + if !ok { + return + } + itemIdentity, ok := httpx.IdentityParam(c, "id") + if !ok { return } var input struct { - Suggestions []Suggestion `json:"suggestions"` + SuggestionIDs []string `json:"suggestionIds"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效") return } - if err := h.service.Confirm(uint(itemID), input.Suggestions); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + result, err := h.service.Confirm(itemIdentity, userID, input.SuggestionIDs) + if err != nil { + writeInboxError(c, err) return } - c.Status(http.StatusNoContent) + c.JSON(http.StatusOK, result) +} + +func currentInboxUser(c *gin.Context) (uint, bool) { + userID, ok := auth.CurrentUserID(c) + if !ok { + httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") + return 0, false + } + return userID, true +} + +func inboxItemDTO(item models.SenlinAgentInboxItem) InboxItemDTO { + return InboxItemDTO{ + ID: item.Identity, ProjectID: item.ProjectIdentity, SourceType: item.SourceType, + Title: item.Title, Body: item.Body, Status: item.Status, + CreatedAt: item.CreatedAt.UTC(), UpdatedAt: item.UpdatedAt.UTC(), + } +} + +func writeInboxError(c *gin.Context, err error) { + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + httpx.Error(c, http.StatusNotFound, "not_found", "Inbox 条目或项目不存在") + case errors.Is(err, ErrInboxAlreadyConfirmed): + httpx.Error(c, http.StatusConflict, "conflict", "该 Inbox 条目已经确认处理") + case errors.Is(err, ErrProjectAndUserRequired), errors.Is(err, ErrSourceTypeRequired), + errors.Is(err, ErrSuggestionSelectionRequired), errors.Is(err, ErrSuggestionNotFound): + httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数或建议选择无效") + default: + httpx.Error(c, http.StatusInternalServerError, "internal_error", "Inbox 操作失败,请稍后重试") + } } diff --git a/backend/internal/logic/inbox/service.go b/backend/internal/logic/inbox/service.go index 1d587d7..a32f588 100644 --- a/backend/internal/logic/inbox/service.go +++ b/backend/internal/logic/inbox/service.go @@ -2,25 +2,43 @@ package inbox import ( "errors" + "strings" + "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" "senlinai-agent/backend/internal/models" ) +var ( + ErrProjectAndUserRequired = errors.New("project and user are required") + ErrSourceTypeRequired = errors.New("source type is required") + ErrInvalidSuggestion = errors.New("invalid inbox suggestion") + ErrSuggestionSelectionRequired = errors.New("suggestion selection is required") + ErrSuggestionNotFound = errors.New("selected suggestion was not saved for inbox item") + ErrInboxAlreadyConfirmed = errors.New("inbox item already confirmed") +) + type CaptureInput struct { - ProjectID uint - UserID uint - SourceType string - Title string - Body string + ProjectIdentity string + UserID uint + SourceType string + Title string + Body string } +// Suggestion 是分析接口返回的候选草稿;ID 由服务端保存后生成,确认接口不再接受 kind/title/body。 type Suggestion struct { + ID string `json:"id"` Kind string `json:"kind"` Title string `json:"title"` Body string `json:"body"` } +type ConfirmResult struct { + CreatedCount int `json:"createdCount"` +} + type Analyzer interface { Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error) } @@ -30,72 +48,239 @@ type StaticAnalyzer struct { } func (a StaticAnalyzer) Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error) { - return a.Suggestions, nil + if len(a.Suggestions) > 0 { + return a.Suggestions, nil + } + subject := suggestionSubject(item) + body := strings.TrimSpace(item.Body) + if body == "" { + body = strings.TrimSpace(item.Title) + } + // 默认分析器只把用户主动收集的内容整理成可审阅草稿;它不调用 provider,也不创建正式对象。 + return []Suggestion{ + {Kind: "task", Title: "跟进:" + subject, Body: body}, + {Kind: "note", Title: subject + "整理笔记", Body: body}, + {Kind: "source", Title: subject + "背景资料", Body: body}, + }, nil } type Service struct { analyzer Analyzer + db *gorm.DB } -func NewService(analyzer Analyzer) *Service { - return &Service{analyzer: analyzer} +func NewService(analyzer Analyzer, databases ...*gorm.DB) *Service { + var database *gorm.DB + if len(databases) > 0 { + database = databases[0] + } + return &Service{analyzer: analyzer, db: database} } +func (s *Service) database() *gorm.DB { + if s.db != nil { + return s.db + } + return models.DBService +} + +// Capture 先以 owner + project identity 校验项目边界,再创建归属于当前用户的原始 Inbox 条目。 func (s *Service) Capture(input CaptureInput) (*models.SenlinAgentInboxItem, error) { - if input.ProjectID == 0 || input.UserID == 0 { - return nil, errors.New("project and user are required") + if strings.TrimSpace(input.ProjectIdentity) == "" || input.UserID == 0 { + return nil, ErrProjectAndUserRequired } - if input.SourceType == "" { - return nil, errors.New("source type is required") + sourceType := strings.TrimSpace(input.SourceType) + if sourceType == "" { + return nil, ErrSourceTypeRequired } - item := &models.SenlinAgentInboxItem{ - ProjectID: input.ProjectID, - CreatedBy: input.UserID, - SourceType: input.SourceType, - Title: input.Title, - Body: input.Body, - Status: "open", - } - return item, models.DBService.Create(item).Error -} - -func (s *Service) Analyze(itemID uint, userID uint) ([]Suggestion, error) { var item models.SenlinAgentInboxItem - if err := models.DBService.First(&item, itemID).Error; err != nil { - return nil, err - } - if s.analyzer == nil { - return []Suggestion{}, nil - } - return s.analyzer.Analyze(item, userID) -} - -func (s *Service) Confirm(itemID uint, selected []Suggestion) error { - return models.DBService.Transaction(func(tx *gorm.DB) error { - var item models.SenlinAgentInboxItem - if err := tx.First(&item, itemID).Error; err != nil { + err := s.database().Transaction(func(tx *gorm.DB) error { + var project models.SenlinAgentProject + if err := tx.Where("owner_id = ? AND identity = ?", input.UserID, input.ProjectIdentity).First(&project).Error; err != nil { return err } - sourceInboxItemID := item.ID - // Inbox 建议只有在用户确认后才创建正式对象,并把来源 ID 写入每个对象以保留可追溯性。 - for _, suggestion := range selected { - switch suggestion.Kind { - case "task": - if err := tx.Create(&models.SenlinAgentTask{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil { - return err - } - case "note": - if err := tx.Create(&models.SenlinAgentNote{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil { - return err - } - case "source": - if err := tx.Create(&models.SenlinAgentSource{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil { - return err - } - default: - return errors.New("unsupported suggestion kind") - } + item = models.SenlinAgentInboxItem{ + ProjectID: project.ID, ProjectIdentity: project.Identity, CreatedBy: input.UserID, + SourceType: sourceType, Title: strings.TrimSpace(input.Title), Body: strings.TrimSpace(input.Body), Status: "open", } - return tx.Model(&item).Update("status", "processed").Error + return tx.Create(&item).Error }) + return &item, err +} + +// Analyze 只保存候选草稿,不创建任何任务、笔记或资料;重新分析会原子替换该条目的旧草稿。 +func (s *Service) Analyze(itemIdentity string, userID uint) ([]Suggestion, error) { + item, err := findOwnedInboxItem(s.database(), itemIdentity, userID, false) + if err != nil { + return nil, err + } + if item.Status != "open" { + return nil, ErrInboxAlreadyConfirmed + } + generated := []Suggestion{} + if s.analyzer != nil { + generated, err = s.analyzer.Analyze(*item, userID) + if err != nil { + return nil, err + } + } + normalized, err := normalizeSuggestions(generated) + if err != nil { + return nil, err + } + + saved := make([]Suggestion, 0, len(normalized)) + err = s.database().Transaction(func(tx *gorm.DB) error { + locked, err := findOwnedInboxItem(tx, itemIdentity, userID, true) + if err != nil { + return err + } + if locked.Status != "open" { + return ErrInboxAlreadyConfirmed + } + if err := tx.Where("inbox_item_id = ?", locked.ID).Delete(&models.SenlinAgentInboxSuggestion{}).Error; err != nil { + return err + } + for _, suggestion := range normalized { + draft := models.SenlinAgentInboxSuggestion{ + InboxItemID: locked.ID, InboxItemIdentity: locked.Identity, CreatedBy: userID, + Kind: suggestion.Kind, Title: suggestion.Title, Body: suggestion.Body, + } + if err := tx.Create(&draft).Error; err != nil { + return err + } + saved = append(saved, Suggestion{ID: draft.Identity, Kind: draft.Kind, Title: draft.Title, Body: draft.Body}) + } + return nil + }) + return saved, err +} + +// Confirm 只按服务端保存的建议 identity 创建对象;全部写入和 Inbox 状态变更位于同一事务中。 +func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionIdentities []string) (ConfirmResult, error) { + selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities) + if err != nil { + return ConfirmResult{}, err + } + result := ConfirmResult{} + err = s.database().Transaction(func(tx *gorm.DB) error { + item, err := findOwnedInboxItem(tx, itemIdentity, userID, true) + if err != nil { + return err + } + if item.Status != "open" { + return ErrInboxAlreadyConfirmed + } + + var saved []models.SenlinAgentInboxSuggestion + if err := tx.Where("inbox_item_id = ? AND created_by = ? AND identity IN ?", item.ID, userID, selected).Find(&saved).Error; err != nil { + return err + } + if len(saved) != len(selected) { + return ErrSuggestionNotFound + } + byIdentity := make(map[string]models.SenlinAgentInboxSuggestion, len(saved)) + for _, suggestion := range saved { + byIdentity[suggestion.Identity] = suggestion + } + for _, identity := range selected { + suggestion, ok := byIdentity[identity] + if !ok { + return ErrSuggestionNotFound + } + if err := createConfirmedObject(tx, *item, suggestion); err != nil { + return err + } + result.CreatedCount++ + } + return tx.Model(item).Update("status", "processed").Error + }) + return result, err +} + +func findOwnedInboxItem(tx *gorm.DB, identity string, userID uint, lock bool) (*models.SenlinAgentInboxItem, error) { + var item models.SenlinAgentInboxItem + query := tx.Model(&models.SenlinAgentInboxItem{}). + Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_inbox_items.project_id"). + Where("senlin_agent_inbox_items.identity = ? AND senlin_agent_inbox_items.created_by = ? AND senlin_agent_projects.owner_id = ?", identity, userID, userID) + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +func normalizeSuggestions(suggestions []Suggestion) ([]Suggestion, error) { + result := make([]Suggestion, 0, len(suggestions)) + for _, suggestion := range suggestions { + kind := strings.ToLower(strings.TrimSpace(suggestion.Kind)) + title := strings.TrimSpace(suggestion.Title) + if title == "" || (kind != "task" && kind != "note" && kind != "source") { + return nil, ErrInvalidSuggestion + } + result = append(result, Suggestion{Kind: kind, Title: title, Body: strings.TrimSpace(suggestion.Body)}) + } + return result, nil +} + +func suggestionSubject(item models.SenlinAgentInboxItem) string { + value := strings.TrimSpace(item.Title) + if value == "" { + value = strings.TrimSpace(item.Body) + } + if value == "" { + return "Inbox 内容" + } + runes := []rune(value) + if len(runes) > 40 { + return string(runes[:40]) + "…" + } + return value +} + +func normalizeSelectedIdentities(values []string) ([]string, error) { + if len(values) == 0 { + return nil, ErrSuggestionSelectionRequired + } + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + identity, err := uuid.Parse(strings.TrimSpace(value)) + if err != nil || identity.Version() != 7 || identity.Variant() != uuid.RFC4122 { + return nil, ErrSuggestionNotFound + } + normalized := identity.String() + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + return result, nil +} + +func createConfirmedObject(tx *gorm.DB, item models.SenlinAgentInboxItem, suggestion models.SenlinAgentInboxSuggestion) error { + sourceInboxItemID := item.ID + switch suggestion.Kind { + case "task": + return tx.Create(&models.SenlinAgentTask{ + ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, + SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body, Status: "open", + }).Error + case "note": + return tx.Create(&models.SenlinAgentNote{ + ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, + SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body, + }).Error + case "source": + // 文本型 Inbox 资料不是上传文件,不构造或伪造任何本地路径。 + return tx.Create(&models.SenlinAgentSource{ + ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, + SourceInboxItemID: &sourceInboxItemID, Kind: "text", Title: suggestion.Title, ContentText: suggestion.Body, + }).Error + default: + return ErrInvalidSuggestion + } } diff --git a/backend/internal/logic/inbox/service_test.go b/backend/internal/logic/inbox/service_test.go index 4c1f238..e3ba889 100644 --- a/backend/internal/logic/inbox/service_test.go +++ b/backend/internal/logic/inbox/service_test.go @@ -1,56 +1,369 @@ package inbox import ( + "bytes" + "encoding/json" + "errors" "fmt" + "net/http" + "net/http/httptest" "testing" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "github.com/google/uuid" "github.com/stretchr/testify/require" "gorm.io/gorm" + "senlinai-agent/backend/internal/config" + "senlinai-agent/backend/internal/httpx" "senlinai-agent/backend/internal/models" ) -func TestAnalyzeReturnsSuggestionsWithoutCreatingObjects(t *testing.T) { - database := newTestDB(t) - service := NewService(StaticAnalyzer{ - Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}}, - }) - item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"}) - require.NoError(t, err) - - suggestions, err := service.Analyze(item.ID, 1) - - require.NoError(t, err) - require.Len(t, suggestions, 1) - var count int64 - require.NoError(t, database.Model(&models.SenlinAgentTask{}).Count(&count).Error) - require.Equal(t, int64(0), count) +type inboxTestFixture struct { + database *gorm.DB + owner models.SenlinAgentUser + other models.SenlinAgentUser + project models.SenlinAgentProject } -func TestConfirmCreatesSelectedObjectsAndKeepsInboxItem(t *testing.T) { - database := newTestDB(t) - service := NewService(StaticAnalyzer{ - Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}}, +type inboxSuggestionResponse struct { + ID string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` +} + +type inboxAnalyzeResponse struct { + Suggestions []inboxSuggestionResponse `json:"suggestions"` +} + +func TestStaticAnalyzerBuildsReviewableDraftsFromInboxContent(t *testing.T) { + item := models.SenlinAgentInboxItem{Title: "客户访谈", Body: "客户希望下周确认交付计划。"} + + suggestions, err := (StaticAnalyzer{}).Analyze(item, 7) + + require.NoError(t, err) + require.Len(t, suggestions, 3) + require.Equal(t, []string{"task", "note", "source"}, []string{suggestions[0].Kind, suggestions[1].Kind, suggestions[2].Kind}) + for _, suggestion := range suggestions { + require.Contains(t, suggestion.Title, "客户访谈") + require.Equal(t, item.Body, suggestion.Body) + } +} + +func TestInboxCaptureUsesOwnedUUIDv7AndCamelCaseDTO(t *testing.T) { + fixture := newInboxTestFixture(t) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{}) + + response := performInboxJSON(t, router, http.MethodPost, "/api/v1/projects/"+fixture.project.Identity+"/inbox", gin.H{ + "sourceType": "text", + "title": "客户回访", + "body": "整理会议记录", }) - item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"}) - require.NoError(t, err) - suggestions, err := service.Analyze(item.ID, 1) - require.NoError(t, err) - err = service.Confirm(item.ID, suggestions) + require.Equal(t, http.StatusCreated, response.Code) + var payload map[string]any + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &payload)) + require.ElementsMatch(t, []string{"id", "projectId", "sourceType", "title", "body", "status", "createdAt", "updatedAt"}, inboxMapKeys(payload)) + require.Equal(t, fixture.project.Identity, payload["projectId"]) + require.Equal(t, "text", payload["sourceType"]) + require.Equal(t, "open", payload["status"]) + requireUUIDv7(t, payload["id"].(string)) + + var item models.SenlinAgentInboxItem + require.NoError(t, fixture.database.Where("identity = ?", payload["id"]).First(&item).Error) + require.Equal(t, fixture.owner.ID, item.CreatedBy) + require.Equal(t, fixture.owner.Identity, item.CreatedByIdentity) + require.Equal(t, fixture.project.ID, item.ProjectID) + require.Equal(t, fixture.project.Identity, item.ProjectIdentity) +} + +func TestInboxCaptureRejectsNumericAndUnownedProjectIdentities(t *testing.T) { + fixture := newInboxTestFixture(t) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{}) + + numeric := performInboxJSON(t, router, http.MethodPost, "/api/v1/projects/1/inbox", gin.H{ + "sourceType": "text", "body": "numeric path", + }) + require.Equal(t, http.StatusBadRequest, numeric.Code) + + otherProject := models.SenlinAgentProject{OwnerID: fixture.other.ID, Name: "Other", Identifier: "OTHER"} + require.NoError(t, fixture.database.Create(&otherProject).Error) + unowned := performInboxJSON(t, router, http.MethodPost, "/api/v1/projects/"+otherProject.Identity+"/inbox", gin.H{ + "sourceType": "text", "body": "not mine", + }) + require.Equal(t, http.StatusNotFound, unowned.Code) + + var count int64 + require.NoError(t, fixture.database.Model(&models.SenlinAgentInboxItem{}).Count(&count).Error) + require.Zero(t, count) +} + +func TestAnalyzeUsesInboxIdentityAndCreatesNoFormalObjects(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}, + }}) + + response := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) + + require.Equal(t, http.StatusOK, response.Code) + analysis := decodeInboxAnalysis(t, response) + require.Len(t, analysis.Suggestions, 1) + require.Equal(t, "task", analysis.Suggestions[0].Kind) + require.Equal(t, "跟进报价", analysis.Suggestions[0].Title) + requireUUIDv7(t, analysis.Suggestions[0].ID) + requireFormalObjectCounts(t, fixture.database, 0, 0, 0) - require.NoError(t, err) - var tasks []models.SenlinAgentTask - require.NoError(t, database.Find(&tasks).Error) - require.Len(t, tasks, 1) - require.Equal(t, "跟进报价", tasks[0].Title) - require.NotNil(t, tasks[0].SourceInboxItemID) - require.Equal(t, item.ID, *tasks[0].SourceInboxItemID) var reloaded models.SenlinAgentInboxItem - require.NoError(t, database.First(&reloaded, item.ID).Error) + require.NoError(t, fixture.database.First(&reloaded, item.ID).Error) + require.Equal(t, "open", reloaded.Status) +} + +func TestConfirmCreatesOnlySelectedSavedSuggestionsWithInboxIdentity(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}, + {Kind: "note", Title: "会议纪要", Body: "保留讨论结论"}, + {Kind: "source", Title: "背景资料", Body: "这是一段收集内容,不是上传文件"}, + {Kind: "task", Title: "不创建的任务", Body: "未被勾选"}, + }}) + analysisResponse := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) + require.Equal(t, http.StatusOK, analysisResponse.Code) + analysis := decodeInboxAnalysis(t, analysisResponse) + require.Len(t, analysis.Suggestions, 4) + selectedIDs := []string{analysis.Suggestions[0].ID, analysis.Suggestions[1].ID, analysis.Suggestions[2].ID} + + response := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestionIds": selectedIDs, + }) + + require.Equal(t, http.StatusOK, response.Code) + var result struct { + CreatedCount int `json:"createdCount"` + } + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result)) + require.Equal(t, 3, result.CreatedCount) + requireFormalObjectCounts(t, fixture.database, 1, 1, 1) + + var task models.SenlinAgentTask + require.NoError(t, fixture.database.First(&task).Error) + require.Equal(t, "跟进报价", task.Title) + require.Equal(t, fixture.project.ID, task.ProjectID) + require.Equal(t, fixture.owner.ID, task.CreatedBy) + requireSourceInboxIdentity(t, item, task.SourceInboxItemID, task.SourceInboxItemIdentity) + + var note models.SenlinAgentNote + require.NoError(t, fixture.database.First(¬e).Error) + require.Equal(t, "会议纪要", note.Title) + require.Equal(t, fixture.project.ID, note.ProjectID) + require.Equal(t, fixture.owner.ID, note.CreatedBy) + requireSourceInboxIdentity(t, item, note.SourceInboxItemID, note.SourceInboxItemIdentity) + + var source models.SenlinAgentSource + require.NoError(t, fixture.database.First(&source).Error) + require.Equal(t, "背景资料", source.Title) + require.Equal(t, "text", source.Kind) + require.Empty(t, source.FilePath) + require.Equal(t, "这是一段收集内容,不是上传文件", source.ContentText) + require.Equal(t, fixture.project.ID, source.ProjectID) + require.Equal(t, fixture.owner.ID, source.CreatedBy) + requireSourceInboxIdentity(t, item, source.SourceInboxItemID, source.SourceInboxItemIdentity) + + var skipped int64 + require.NoError(t, fixture.database.Model(&models.SenlinAgentTask{}).Where("title = ?", "不创建的任务").Count(&skipped).Error) + require.Zero(t, skipped) + var reloaded models.SenlinAgentInboxItem + require.NoError(t, fixture.database.First(&reloaded, item.ID).Error) require.Equal(t, "processed", reloaded.Status) } +func TestConfirmRejectsClientForgedSuggestionsAndUnknownSuggestionIdentities(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "服务端建议", Body: "只能创建这个建议"}, + }}) + analysisResponse := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) + require.Equal(t, http.StatusOK, analysisResponse.Code) + + forgedContent := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestions": []gin.H{{"kind": "task", "title": "客户端伪造任务", "body": "不可信内容"}}, + }) + require.Equal(t, http.StatusBadRequest, forgedContent.Code) + + unknownIdentity, err := uuid.NewV7() + require.NoError(t, err) + unknownSuggestion := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestionIds": []string{unknownIdentity.String()}, + }) + require.Equal(t, http.StatusBadRequest, unknownSuggestion.Code) + requireFormalObjectCounts(t, fixture.database, 0, 0, 0) +} + +func TestAnalyzeAndConfirmHideAnotherUsersInboxItem(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + ownerRouter := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "私有建议", Body: "不得越权确认"}, + }}) + analysis := decodeInboxAnalysis(t, performInboxJSON(t, ownerRouter, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) + require.Len(t, analysis.Suggestions, 1) + otherRouter := fixture.router(fixture.other.ID, StaticAnalyzer{}) + + unauthorizedAnalyze := performInboxJSON(t, otherRouter, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) + require.Equal(t, http.StatusNotFound, unauthorizedAnalyze.Code) + unauthorizedConfirm := performInboxJSON(t, otherRouter, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestionIds": []string{analysis.Suggestions[0].ID}, + }) + require.Equal(t, http.StatusNotFound, unauthorizedConfirm.Code) + requireFormalObjectCounts(t, fixture.database, 0, 0, 0) +} + +func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "事务任务", Body: "必须随失败回滚"}, + {Kind: "source", Title: "失败资料", Body: "模拟持久化失败"}, + }}) + analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) + require.Len(t, analysis.Suggestions, 2) + + const callbackName = "test:fail_inbox_source_create" + require.NoError(t, fixture.database.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "SenlinAgentSource" { + tx.AddError(errors.New("simulated source write failure")) + } + })) + t.Cleanup(func() { _ = fixture.database.Callback().Create().Remove(callbackName) }) + + response := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestionIds": []string{analysis.Suggestions[0].ID, analysis.Suggestions[1].ID}, + }) + + require.Equal(t, http.StatusInternalServerError, response.Code) + requireFormalObjectCounts(t, fixture.database, 0, 0, 0) + var reloaded models.SenlinAgentInboxItem + require.NoError(t, fixture.database.First(&reloaded, item.ID).Error) + require.Equal(t, "open", reloaded.Status) +} + +func TestRepeatedConfirmReturnsConflictWithoutDuplicateObjects(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "只创建一次", Body: "重复确认不能复制"}, + }}) + analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) + body := gin.H{"suggestionIds": []string{analysis.Suggestions[0].ID}} + + first := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body) + second := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body) + + require.Equal(t, http.StatusOK, first.Code) + require.Equal(t, http.StatusConflict, second.Code) + var payload httpx.ErrorEnvelope + require.NoError(t, json.Unmarshal(second.Body.Bytes(), &payload)) + require.Equal(t, "conflict", payload.Error.Code) + requireFormalObjectCounts(t, fixture.database, 1, 0, 0) +} + +func (fixture inboxTestFixture) router(userID uint, analyzer Analyzer) http.Handler { + return httpx.NewProtectedRouter( + config.Config{Env: "test"}, + func(string) (uint, error) { return userID, nil }, + NewHandler(NewService(analyzer)), + ) +} + +func (fixture inboxTestFixture) createInbox(t *testing.T, userID, projectID uint) models.SenlinAgentInboxItem { + t.Helper() + item := models.SenlinAgentInboxItem{ + ProjectID: projectID, CreatedBy: userID, SourceType: "text", Title: "待整理", Body: "需要整理的原始内容", Status: "open", + } + require.NoError(t, fixture.database.Create(&item).Error) + return item +} + +func newInboxTestFixture(t *testing.T) inboxTestFixture { + t.Helper() + database := newTestDB(t) + owner := models.SenlinAgentUser{Email: t.Name() + "-owner@example.com", DisplayName: "Owner", PasswordHash: "hash"} + other := models.SenlinAgentUser{Email: t.Name() + "-other@example.com", DisplayName: "Other", PasswordHash: "hash"} + require.NoError(t, database.Create(&owner).Error) + require.NoError(t, database.Create(&other).Error) + project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "Inbox Project", Identifier: "INBOX"} + require.NoError(t, database.Create(&project).Error) + return inboxTestFixture{database: database, owner: owner, other: other, project: project} +} + +func performInboxJSON(t *testing.T, router http.Handler, method, path string, payload any) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + if payload != nil { + require.NoError(t, json.NewEncoder(&body).Encode(payload)) + } + request := httptest.NewRequest(method, path, &body) + request.Header.Set("Authorization", "Bearer test-token") + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func decodeInboxAnalysis(t *testing.T, response *httptest.ResponseRecorder) inboxAnalyzeResponse { + t.Helper() + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + var payload inboxAnalyzeResponse + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &payload)) + return payload +} + +func requireFormalObjectCounts(t *testing.T, database *gorm.DB, tasks, notes, sources int64) { + t.Helper() + for _, check := range []struct { + model any + want int64 + }{ + {model: &models.SenlinAgentTask{}, want: tasks}, + {model: &models.SenlinAgentNote{}, want: notes}, + {model: &models.SenlinAgentSource{}, want: sources}, + } { + var count int64 + require.NoError(t, database.Model(check.model).Count(&count).Error) + require.Equal(t, check.want, count) + } +} + +func requireSourceInboxIdentity(t *testing.T, item models.SenlinAgentInboxItem, sourceID *uint, sourceIdentity *string) { + t.Helper() + require.NotNil(t, sourceID) + require.Equal(t, item.ID, *sourceID) + require.NotNil(t, sourceIdentity) + require.Equal(t, item.Identity, *sourceIdentity) +} + +func requireUUIDv7(t *testing.T, value string) { + t.Helper() + parsed, err := uuid.Parse(value) + require.NoError(t, err) + require.Equal(t, uuid.Version(7), parsed.Version()) + require.Equal(t, uuid.RFC4122, parsed.Variant()) +} + +func inboxMapKeys(value map[string]any) []string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + return keys +} + func newTestDB(t *testing.T) *gorm.DB { t.Helper() database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) diff --git a/backend/internal/models/identity.go b/backend/internal/models/identity.go index 1b8e27f..641bc9f 100644 --- a/backend/internal/models/identity.go +++ b/backend/internal/models/identity.go @@ -28,6 +28,16 @@ func (m *SenlinAgentInboxItem) BeforeCreate(tx *gorm.DB) error { return resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity) } +func (m *SenlinAgentInboxSuggestion) BeforeCreate(tx *gorm.DB) error { + if err := ensureIdentity(&m.Identity); err != nil { + return err + } + if err := resolveIdentity(tx, &SenlinAgentInboxItem{}, m.InboxItemID, &m.InboxItemIdentity); err != nil { + return err + } + return resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity) +} + func (m *SenlinAgentTask) BeforeCreate(tx *gorm.DB) error { if err := ensureIdentity(&m.Identity); err != nil { return err diff --git a/backend/internal/models/inbox_suggestion.go b/backend/internal/models/inbox_suggestion.go new file mode 100644 index 0000000..a06fc05 --- /dev/null +++ b/backend/internal/models/inbox_suggestion.go @@ -0,0 +1,21 @@ +package models + +import "time" + +// SenlinAgentInboxSuggestion 保存分析阶段的候选草稿;它不是任务、笔记或资料,只有用户确认后才会创建正式对象。 +type SenlinAgentInboxSuggestion struct { + ID uint `gorm:"primaryKey"` + Identity string `gorm:"type:char(36);uniqueIndex"` + InboxItemID uint `gorm:"index;not null"` + InboxItemIdentity string `gorm:"type:char(36);index"` + CreatedBy uint `gorm:"index;not null"` + CreatedByIdentity string `gorm:"type:char(36);index"` + Kind string `gorm:"not null"` + Title string `gorm:"not null"` + Body string `gorm:"type:text"` + CreatedAt time.Time +} + +func (SenlinAgentInboxSuggestion) TableName() string { + return "senlin_agent_inbox_suggestions" +} diff --git a/backend/internal/models/new.go b/backend/internal/models/new.go index 4d9752d..89fe3de 100644 --- a/backend/internal/models/new.go +++ b/backend/internal/models/new.go @@ -27,6 +27,7 @@ func AutoMigrate(database *gorm.DB) error { &SenlinAgentUser{}, &SenlinAgentProject{}, &SenlinAgentInboxItem{}, + &SenlinAgentInboxSuggestion{}, &SenlinAgentTask{}, &SenlinAgentNote{}, &SenlinAgentSource{},