feat: align controlled AI sessions and MVP controls
This commit is contained in:
@@ -24,6 +24,7 @@ const requiredFiles = [
|
||||
'src/api/mappers.tsx',
|
||||
'src/api/search.ts',
|
||||
'src/api/inbox.ts',
|
||||
'src/api/ai.ts',
|
||||
'scripts/api-client.test.mjs',
|
||||
]
|
||||
|
||||
@@ -51,7 +52,7 @@ if (!projectRailSource.includes('projectNameLabel(project.name)')) {
|
||||
failures.push('project rail labels must derive from the project name')
|
||||
}
|
||||
|
||||
const apiFiles = ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts'].map((name) => `src/api/${name}`)
|
||||
const apiFiles = ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts', 'ai.ts'].map((name) => `src/api/${name}`)
|
||||
const apiSource = apiFiles.map((file) => readFileSync(file, 'utf8')).join('\n')
|
||||
for (const forbidden of [
|
||||
{ pattern: /\bID\s*\?\s*:/, label: 'optional PascalCase ID compatibility field' },
|
||||
@@ -101,6 +102,34 @@ if (!inboxSource.includes('body: { suggestionIds }')) failures.push('inbox confi
|
||||
const inboxPageSource = readFileSync('src/pages/projects/project-inbox.tsx', 'utf8')
|
||||
if (!inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action')
|
||||
|
||||
const aiApiSource = readFileSync('src/api/ai.ts', 'utf8')
|
||||
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
|
||||
if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`)
|
||||
}
|
||||
if (/\b(?:task|note|source)Id\b/.test(aiApiSource)) failures.push('AI session responses must not expose automatic formal object IDs')
|
||||
|
||||
const aiPageSource = readFileSync('src/pages/projects/project-ai.tsx', 'utf8')
|
||||
for (const required of ['AI 助手', '创建会话', 'loading', 'error']) {
|
||||
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
|
||||
}
|
||||
for (const forbidden of ['DeepSeek V4.0 Flash', '给 DeepSeek 发送消息', 'IconAttachment', 'agent-send-button']) {
|
||||
if (aiPageSource.includes(forbidden)) failures.push(`project AI page contains unsupported chat control ${forbidden}`)
|
||||
}
|
||||
|
||||
const unsupportedControls = [
|
||||
{ file: 'src/pages/workspace-explore.tsx', required: '暂未开放', forbidden: ['同步数据源', '添加数据源', 'IconRefresh', 'IconEdit', 'IconDelete'] },
|
||||
{ file: 'src/pages/projects/project-new-channel.tsx', required: '暂未开放', forbidden: ['保存频道', '<Input', '<Select', '<TextArea'] },
|
||||
{ file: 'src/pages/projects/project-statusbar.tsx', forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲'] },
|
||||
{ file: 'src/pages/projects/project-topbar.tsx', forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'] },
|
||||
]
|
||||
for (const check of unsupportedControls) {
|
||||
const source = readFileSync(check.file, 'utf8')
|
||||
if (check.required && !source.includes(check.required)) failures.push(`${check.file} must show ${check.required}`)
|
||||
for (const forbidden of check.forbidden) {
|
||||
if (source.includes(forbidden)) failures.push(`${check.file} contains unsupported control ${forbidden}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync('src/App.tsx')) {
|
||||
failures.push('legacy src/App.tsx should be removed after page split')
|
||||
}
|
||||
|
||||
30
apps/web_v1/src/api/ai.ts
Normal file
30
apps/web_v1/src/api/ai.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { apiRequest, type ApiSession } from './client'
|
||||
|
||||
export type AISessionDTO = {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
context: string
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CreateAISessionInput = {
|
||||
title: string
|
||||
context: string
|
||||
}
|
||||
|
||||
export async function listAISessions(session: ApiSession, projectId: string) {
|
||||
return apiRequest<AISessionDTO[]>(`/api/v1/projects/${projectId}/ai-sessions`, {
|
||||
token: session.token,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAISession(session: ApiSession, projectId: string, input: CreateAISessionInput) {
|
||||
return apiRequest<AISessionDTO>(`/api/v1/projects/${projectId}/ai-sessions`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
|
||||
import '@arco-design/web-react/dist/css/arco.css'
|
||||
import '../App.css'
|
||||
import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client'
|
||||
import { createAISession, listAISessions, type CreateAISessionInput } from '../api/ai'
|
||||
import { analyzeInboxItem, confirmInboxItem } from '../api/inbox'
|
||||
import { mapWorkspace } from '../api/mappers'
|
||||
import {
|
||||
@@ -43,6 +44,16 @@ function App() {
|
||||
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
||||
const workspaceSearch = useWorkbenchSearch(session)
|
||||
|
||||
const handleListAISessions = useCallback((projectId: string) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return listAISessions(session, projectId)
|
||||
}, [session])
|
||||
|
||||
const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput) => {
|
||||
if (!session) return Promise.reject(new Error('未登录'))
|
||||
return createAISession(session, projectId, input)
|
||||
}, [session])
|
||||
|
||||
const dark = theme === 'dark'
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
|
||||
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
|
||||
@@ -346,6 +357,8 @@ function App() {
|
||||
onSelectSearchResult={handleSelectSearchResult}
|
||||
onAnalyzeInbox={handleAnalyzeInbox}
|
||||
onConfirmInbox={handleConfirmInbox}
|
||||
onListAISessions={handleListAISessions}
|
||||
onCreateAISession={handleCreateAISession}
|
||||
/>
|
||||
) : (
|
||||
<Spin loading />
|
||||
|
||||
@@ -1,80 +1,134 @@
|
||||
import { Button, Card, Input, Space, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconArrowUp,
|
||||
IconAttachment,
|
||||
IconPlusCircle,
|
||||
IconRobot,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import type { AISession, ProjectWorkspace } from './project-types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert, Button, Card, Empty, Input, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconPlusCircle, IconRobot } from '@arco-design/web-react/icon'
|
||||
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export function ProjectAi({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
|
||||
const { aiSessions } = activeWorkspace
|
||||
const groupedSessions = groupSessions(aiSessions)
|
||||
export function ProjectAi({
|
||||
activeWorkspace,
|
||||
onSelectItem,
|
||||
onListSessions,
|
||||
onCreateSession,
|
||||
}: {
|
||||
activeWorkspace: ProjectWorkspace
|
||||
onSelectItem: (title: string) => void
|
||||
onListSessions: (projectId: string) => Promise<AISessionDTO[]>
|
||||
onCreateSession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<AISessionDTO[]>([])
|
||||
const [title, setTitle] = useState('')
|
||||
const [context, setContext] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const projectId = activeWorkspace.project.id
|
||||
|
||||
useEffect(() => {
|
||||
let current = true
|
||||
setLoading(true)
|
||||
setError('')
|
||||
void onListSessions(projectId)
|
||||
.then((items) => {
|
||||
if (current) setSessions(items)
|
||||
})
|
||||
.catch((requestError: unknown) => {
|
||||
if (current) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (current) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
current = false
|
||||
}
|
||||
}, [onListSessions, projectId])
|
||||
|
||||
const createSession = async () => {
|
||||
const trimmedTitle = title.trim()
|
||||
if (!trimmedTitle) {
|
||||
setError('请输入 AI 会话标题')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const created = await onCreateSession(projectId, {
|
||||
title: trimmedTitle,
|
||||
context: context.trim(),
|
||||
})
|
||||
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
|
||||
setTitle('')
|
||||
setContext('')
|
||||
onSelectItem(created.title)
|
||||
} catch (requestError) {
|
||||
setError(requestError instanceof Error ? requestError.message : 'AI 会话创建失败,请稍后重试')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-channel-page project-ai-page overview-page">
|
||||
<div className="agent-chat-shell">
|
||||
<Card className="agent-session-list queue-section" bordered>
|
||||
<Button className="agent-new-chat" icon={<IconPlusCircle />}>
|
||||
开启新对话
|
||||
</Button>
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>AI 助手</Title>
|
||||
<Text type="secondary">创建项目内普通会话;不会自动生成任务、笔记或资料。</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agent-session-groups">
|
||||
{groupedSessions.length ? (
|
||||
groupedSessions.map((group) => (
|
||||
<section className="agent-session-group" key={group.label}>
|
||||
<Text type="secondary">{group.label}</Text>
|
||||
{group.items.map((session, index) => (
|
||||
<button
|
||||
className={index === 0 && group.label === groupedSessions[0]?.label ? 'agent-session active' : 'agent-session'}
|
||||
key={session.id}
|
||||
onClick={() => onSelectItem(session.title)}
|
||||
>
|
||||
{session.title}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">暂无对话</Text>
|
||||
)}
|
||||
</div>
|
||||
{error ? <Alert className="agent-request-error" type="error" content={error} closable onClose={() => setError('')} /> : null}
|
||||
|
||||
<div className="agent-chat-shell">
|
||||
<Card className="agent-session-list queue-section" bordered title="会话列表">
|
||||
<Spin loading={loading} style={{ width: '100%' }}>
|
||||
{sessions.length ? (
|
||||
<div className="agent-session-groups">
|
||||
{sessions.map((session) => (
|
||||
<button className="agent-session" key={session.id} onClick={() => onSelectItem(session.title)}>
|
||||
<span>{session.title}</span>
|
||||
<Tag size="small" color={session.status === 'ready' ? 'arcoblue' : 'gray'}>
|
||||
{sessionStatusLabel(session.status)}
|
||||
</Tag>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : loading ? null : <Empty description="暂无 AI 会话" />}
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Card className="agent-chat-panel queue-section" bordered>
|
||||
<div className="agent-chat-main">
|
||||
<Title heading={2}>选择专家,开始对话</Title>
|
||||
</div>
|
||||
<div className="agent-composer">
|
||||
<Input.TextArea placeholder="给 DeepSeek 发送消息" autoSize={{ minRows: 3, maxRows: 6 }} />
|
||||
<div className="agent-composer-footer">
|
||||
<Button className="agent-model-chip" icon={<IconRobot />}>
|
||||
DeepSeek V4.0 Flash
|
||||
</Button>
|
||||
<Space>
|
||||
<Button type="text" icon={<IconAttachment />} />
|
||||
<Button className="agent-send-button" type="primary" shape="circle" icon={<IconArrowUp />} />
|
||||
</Space>
|
||||
<Space direction="vertical" size={16} className="action-form">
|
||||
<div>
|
||||
<Title heading={5}>创建会话</Title>
|
||||
<Text type="secondary">会话状态为“待开始”只表示入口已建立,不代表 AI 已完成处理。</Text>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
<Text>会话标题</Text>
|
||||
<Input value={title} onChange={setTitle} placeholder="例如:报价分析" maxLength={120} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>项目上下文</Text>
|
||||
<Input.TextArea
|
||||
value={context}
|
||||
onChange={setContext}
|
||||
placeholder="描述本次会话要参考的项目背景"
|
||||
autoSize={{ minRows: 5, maxRows: 10 }}
|
||||
/>
|
||||
</label>
|
||||
<Button type="primary" icon={<IconPlusCircle />} loading={creating} onClick={() => void createSession()}>
|
||||
创建会话
|
||||
</Button>
|
||||
<Text type="secondary"><IconRobot /> AI 输出如需转为正式对象,必须另行确认。</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function groupSessions(sessions: AISession[]) {
|
||||
const labels = ['今天', '昨天', '7 天内']
|
||||
return labels.map((label) => ({
|
||||
label,
|
||||
items: sessions.filter((session, index) => session.time.includes(label) || (!labels.some((item) => session.time.includes(item)) && labels.indexOf(label) === fallbackGroupIndex(index))),
|
||||
})).filter((group) => group.items.length > 0)
|
||||
}
|
||||
|
||||
function fallbackGroupIndex(index: number) {
|
||||
if (index === 0) return 0
|
||||
if (index === 1) return 1
|
||||
return 2
|
||||
function sessionStatusLabel(status: string) {
|
||||
if (status === 'ready') return '待开始'
|
||||
if (status === 'failed') return '创建失败'
|
||||
return status || '未知状态'
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ProjectTaskUpdate } from './project-task-edit-modal'
|
||||
import { ProjectTasks } from './project-tasks'
|
||||
import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
|
||||
import type { InboxSuggestionDTO } from '../../api/inbox'
|
||||
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
|
||||
|
||||
export function ProjectChannelPage({
|
||||
activeChannel,
|
||||
@@ -23,6 +24,8 @@ export function ProjectChannelPage({
|
||||
onUpdateTask,
|
||||
onAnalyzeInbox,
|
||||
onConfirmInbox,
|
||||
onListAISessions,
|
||||
onCreateAISession,
|
||||
}: {
|
||||
activeChannel: ChannelKey
|
||||
activeWorkspace: ProjectWorkspace
|
||||
@@ -37,6 +40,8 @@ export function ProjectChannelPage({
|
||||
onUpdateTask: (update: ProjectTaskUpdate) => void
|
||||
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||
onListAISessions: (projectId: string) => Promise<AISessionDTO[]>
|
||||
onCreateAISession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
|
||||
}) {
|
||||
switch (activeChannel) {
|
||||
case 'inbox':
|
||||
@@ -44,7 +49,7 @@ export function ProjectChannelPage({
|
||||
case 'tasks':
|
||||
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
|
||||
case 'ai':
|
||||
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onCreateSession={onCreateAISession} />
|
||||
case 'notes':
|
||||
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
|
||||
case 'cron':
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>计划任务</Title>
|
||||
<Text type="secondary">{project.name} 的定时任务、自动检查和周期性 AI 工作。</Text>
|
||||
<Text type="secondary">{project.name} 的计划任务元数据与提醒安排;当前不会自主执行 AI 工作。</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}>新建计划</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Button, Card, Input, Select, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconApps, IconLink, IconPlus } from '@arco-design/web-react/icon'
|
||||
import { Card, Empty, Typography } from '@arco-design/web-react'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: ProjectWorkspace }) {
|
||||
return (
|
||||
@@ -11,51 +9,11 @@ export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: Projec
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>新建频道</Title>
|
||||
<Text type="secondary">{activeWorkspace.project.name} 的频道入口配置,用于接入外部页面、资料集合或项目流程。</Text>
|
||||
<Text type="secondary">{activeWorkspace.project.name} 暂不支持自定义频道或外部链接接入。</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<IconPlus />}>保存频道</Button>
|
||||
</div>
|
||||
|
||||
<Card className="queue-section channel-editor-card" bordered>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
<label>
|
||||
<Text>频道名称</Text>
|
||||
<Input placeholder="例如:客户研究频道" />
|
||||
</label>
|
||||
<label>
|
||||
<Text>频道类型</Text>
|
||||
<Select defaultValue="link">
|
||||
<Select.Option value="link">外部链接</Select.Option>
|
||||
<Select.Option value="source">资料集合</Select.Option>
|
||||
<Select.Option value="workflow">项目流程</Select.Option>
|
||||
</Select>
|
||||
</label>
|
||||
<label>
|
||||
<Text>图标</Text>
|
||||
<Input placeholder="例如:link / user / file" prefix={<IconApps />} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>链接地址</Text>
|
||||
<Input placeholder="https://example.com/channel" prefix={<IconLink />} />
|
||||
</label>
|
||||
<label>
|
||||
<Text>说明</Text>
|
||||
<TextArea rows={4} placeholder="频道用途、维护规则或采集说明" />
|
||||
</label>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>创建后可接入的内容</Title>
|
||||
<Tag color="gray">配置预览</Tag>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Tag color="arcoblue">外部资料库</Tag>
|
||||
<Tag color="green">项目流程页</Tag>
|
||||
<Tag color="orange">RSS/探索结果</Tag>
|
||||
<Tag color="purple">AI智能体上下文</Tag>
|
||||
</Space>
|
||||
<Empty description="自定义频道暂未开放" />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,115 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { Avatar, Button, Card, Form, Input, Layout, Modal, Radio, Space, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconApps,
|
||||
IconCalendar,
|
||||
IconRobot,
|
||||
IconStorage,
|
||||
IconUser,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import { Layout, Space } from '@arco-design/web-react'
|
||||
import { IconCheckCircle } from '@arco-design/web-react/icon'
|
||||
|
||||
const { Footer } = Layout
|
||||
const { Text, Title } = Typography
|
||||
|
||||
const plans = [
|
||||
{ id: 'starter', name: '基础版', price: '¥29/月', desc: '个人项目、基础智能体与 20GB 存储。' },
|
||||
{ id: 'pro', name: '专业版', price: '¥99/月', desc: '团队协作、高级智能体与 200GB 存储。' },
|
||||
{ id: 'team', name: '团队版', price: '¥299/月', desc: '成员管理、审计日志与私有化部署支持。' },
|
||||
]
|
||||
|
||||
export function ProjectStatusbar() {
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
const [upgradeOpen, setUpgradeOpen] = useState(false)
|
||||
const [selectedPlan, setSelectedPlan] = useState('pro')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Footer className="statusbar">
|
||||
<button className="status-user" type="button" onClick={() => setProfileOpen(true)}>
|
||||
<Avatar size={24} style={{ backgroundColor: '#165DFF' }}>张</Avatar>
|
||||
<span className="status-identity">
|
||||
<Text className="status-name">张明</Text>
|
||||
<span className="status-online-dot" aria-label="在线" title="在线" />
|
||||
</span>
|
||||
<Button className="status-upgrade" size="mini" type="primary" onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setUpgradeOpen(true)
|
||||
}}>升级</Button>
|
||||
</button>
|
||||
|
||||
<Space className="status-system" size={18}>
|
||||
<span><IconInteractionFallback /> 3 个计划进行中</span>
|
||||
<span><IconRobot /> AI 空闲</span>
|
||||
<span><IconStorage /> 152GB 可用</span>
|
||||
<span>状态 <b>正常</b></span>
|
||||
<IconApps />
|
||||
</Space>
|
||||
</Footer>
|
||||
|
||||
<Modal
|
||||
title="个人资料"
|
||||
visible={profileOpen}
|
||||
onCancel={() => setProfileOpen(false)}
|
||||
onOk={() => setProfileOpen(false)}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form layout="vertical" className="status-modal-form">
|
||||
<Form.Item label="头像">
|
||||
<Space>
|
||||
<Avatar size={42} style={{ backgroundColor: '#165DFF' }}>张</Avatar>
|
||||
<Button icon={<IconUser />}>更换头像</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item label="名称">
|
||||
<Input defaultValue="张明" placeholder="请输入名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="邮箱">
|
||||
<Input defaultValue="zhangming@senlin.ai" placeholder="请输入邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item label="当前密码">
|
||||
<Input.Password placeholder="用于确认身份" />
|
||||
</Form.Item>
|
||||
<Form.Item label="新密码">
|
||||
<Input.Password placeholder="不修改密码可留空" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="升级套餐"
|
||||
visible={upgradeOpen}
|
||||
onCancel={() => setUpgradeOpen(false)}
|
||||
onOk={() => setUpgradeOpen(false)}
|
||||
okText="去支付"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Radio.Group value={selectedPlan} onChange={setSelectedPlan} className="billing-plan-group">
|
||||
{plans.map((plan) => (
|
||||
<Card className={selectedPlan === plan.id ? 'billing-plan active' : 'billing-plan'} key={plan.id} bordered>
|
||||
<Radio value={plan.id}>
|
||||
<div>
|
||||
<Title heading={6}>{plan.name}</Title>
|
||||
<Text className="billing-price">{plan.price}</Text>
|
||||
<Text type="secondary">{plan.desc}</Text>
|
||||
</div>
|
||||
</Radio>
|
||||
</Card>
|
||||
))}
|
||||
</Radio.Group>
|
||||
<div className="billing-payments">
|
||||
<Text type="secondary">支付方式</Text>
|
||||
<Space>
|
||||
<Button>微信支付</Button>
|
||||
<Button>支付宝</Button>
|
||||
<Button>企业转账</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
<Footer className="statusbar">
|
||||
<div className="status-user">
|
||||
<span className="status-avatar" aria-hidden="true">森</span>
|
||||
<span className="status-name">已登录</span>
|
||||
</div>
|
||||
<Space className="status-system" size={8}>
|
||||
<IconCheckCircle />
|
||||
<span>服务已连接</span>
|
||||
</Space>
|
||||
</Footer>
|
||||
)
|
||||
}
|
||||
|
||||
function IconInteractionFallback() {
|
||||
return <IconCalendar />
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ export function ProjectTopbar({
|
||||
onSearch: () => void
|
||||
onSelectSearchResult: (result: SearchResultDTO) => void
|
||||
}) {
|
||||
const desktop = isDesktopRuntime()
|
||||
const [popupVisible, setPopupVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,13 +98,6 @@ export function ProjectTopbar({
|
||||
</Dropdown>
|
||||
<Space className="topbar-actions">
|
||||
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
|
||||
{desktop && (
|
||||
<>
|
||||
<Button aria-label="停靠左边" icon={<DockIcon side="left" />} />
|
||||
<Button aria-label="停靠右边" icon={<DockIcon side="right" />} />
|
||||
</>
|
||||
)}
|
||||
<Button icon={<IconApps />} />
|
||||
</Space>
|
||||
</Header>
|
||||
)
|
||||
@@ -117,15 +109,3 @@ function searchTypeLabel(type: string) {
|
||||
if (type === 'note') return '笔记'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
function isDesktopRuntime() {
|
||||
return typeof window !== 'undefined' && '__TAURI__' in window
|
||||
}
|
||||
|
||||
function DockIcon({ side }: { side: 'left' | 'right' }) {
|
||||
return (
|
||||
<span className={`dock-icon dock-icon-${side}`} aria-hidden="true">
|
||||
<span />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,223 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Card, Empty, Grid, Space, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconBook,
|
||||
IconCheckCircle,
|
||||
IconCompass,
|
||||
IconDelete,
|
||||
IconEdit,
|
||||
IconFile,
|
||||
IconLink,
|
||||
IconRefresh,
|
||||
IconStar,
|
||||
IconStorage,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import type { InboxItem, Project, ProjectWorkspace } from './projects/project-types'
|
||||
import { Card, Empty, Typography } from '@arco-design/web-react'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
type DataSourceID = 'all' | 'manual' | 'requirements' | 'architecture'
|
||||
|
||||
type ExploreArticle = InboxItem & {
|
||||
project: Project
|
||||
sourceID: DataSourceID
|
||||
}
|
||||
|
||||
type DataSourceCard = {
|
||||
id: DataSourceID
|
||||
name: string
|
||||
count: number
|
||||
icon: ReactNode
|
||||
color: string
|
||||
}
|
||||
|
||||
const SOURCE_META: Record<DataSourceID, { name: string; icon: ReactNode; color: string }> = {
|
||||
all: { name: '全部', icon: <IconStorage />, color: 'blue' },
|
||||
manual: { name: '手动收集', icon: <IconCompass />, color: 'green' },
|
||||
requirements: { name: '需求文档', icon: <IconFile />, color: 'orange' },
|
||||
architecture: { name: '架构讨论', icon: <IconBook />, color: 'purple' },
|
||||
}
|
||||
|
||||
export function WorkspaceExplorePage({
|
||||
workspaces,
|
||||
onSelectItem,
|
||||
}: {
|
||||
workspaces: ProjectWorkspace[]
|
||||
onSelectItem: (title: string) => void
|
||||
}) {
|
||||
const articles = useMemo(
|
||||
() =>
|
||||
workspaces.flatMap((workspace) =>
|
||||
workspace.inbox.map((item) => ({
|
||||
...item,
|
||||
project: workspace.project,
|
||||
sourceID: detectSource(item),
|
||||
})),
|
||||
),
|
||||
[workspaces],
|
||||
)
|
||||
const [activeSourceID, setActiveSourceID] = useState<DataSourceID>('all')
|
||||
const filteredArticles = useMemo(
|
||||
() => (activeSourceID === 'all' ? articles : articles.filter((article) => article.sourceID === activeSourceID)),
|
||||
[activeSourceID, articles],
|
||||
)
|
||||
const sources = useMemo(() => dataSources(articles), [articles])
|
||||
const [activeArticleID, setActiveArticleID] = useState<string | null>(filteredArticles[0]?.id ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredArticles.length === 0) {
|
||||
setActiveArticleID(null)
|
||||
return
|
||||
}
|
||||
if (!activeArticleID || !filteredArticles.some((article) => article.id === activeArticleID)) {
|
||||
setActiveArticleID(filteredArticles[0].id)
|
||||
}
|
||||
}, [activeArticleID, filteredArticles])
|
||||
|
||||
const selected = filteredArticles.find((article) => article.id === activeArticleID) ?? filteredArticles[0]
|
||||
const activeSource = SOURCE_META[activeSourceID]
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export function WorkspaceExplorePage() {
|
||||
return (
|
||||
<div className="workspace-explore-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>探索</Title>
|
||||
<Text type="secondary">多个外部数据源的集中阅读页,采集文章并沉淀到项目。</Text>
|
||||
<Text type="secondary">外部数据源与同步能力不在当前 MVP 范围内。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconRefresh />}>同步数据源</Button>
|
||||
<Button type="primary" icon={<IconLink />}>
|
||||
添加数据源
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={10} className="explore-source-row">
|
||||
{sources.map((source) => (
|
||||
<Col span={6} key={source.id}>
|
||||
<Card
|
||||
className={activeSourceID === source.id ? 'compact-card explore-source-card active' : 'compact-card explore-source-card'}
|
||||
bordered
|
||||
onClick={() => setActiveSourceID(source.id)}
|
||||
>
|
||||
<span className={`explore-source-icon ${source.color}`}>{source.icon}</span>
|
||||
<span className="explore-source-copy">
|
||||
<Text className="explore-source-name">{source.name}</Text>
|
||||
<Text type="secondary">{source.count} 条</Text>
|
||||
</span>
|
||||
{source.id !== 'all' ? (
|
||||
<span className="explore-source-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<Button type="text" size="mini" icon={<IconEdit />} />
|
||||
<Button type="text" size="mini" status="danger" icon={<IconDelete />} />
|
||||
</span>
|
||||
) : null}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{selected ? (
|
||||
<section className="explore-reader-layout">
|
||||
<Card className="explore-article-list queue-section" bordered>
|
||||
<div className="explore-list-header">
|
||||
<Title heading={5}>
|
||||
<Space size={6}>
|
||||
<span className={`explore-source-icon mini ${activeSource.color}`}>{activeSource.icon}</span>
|
||||
<span>{activeSource.name}</span>
|
||||
</Space>
|
||||
</Title>
|
||||
<Button type="text" icon={<IconRefresh />} />
|
||||
</div>
|
||||
<div className="explore-list-body">
|
||||
{filteredArticles.map((article) => (
|
||||
<button
|
||||
key={`${article.project.id}-${article.id}`}
|
||||
className={article.id === selected.id ? 'explore-article-item active' : 'explore-article-item'}
|
||||
onClick={() => {
|
||||
setActiveArticleID(article.id)
|
||||
onSelectItem(article.title)
|
||||
}}
|
||||
>
|
||||
<span className={`explore-source-logo ${SOURCE_META[article.sourceID].color}`}>
|
||||
{sourceInitial(SOURCE_META[article.sourceID].name)}
|
||||
</span>
|
||||
<span className="explore-article-copy">
|
||||
<Text type="secondary">
|
||||
{SOURCE_META[article.sourceID].name} · {article.time}
|
||||
</Text>
|
||||
<Text className="explore-article-title">{article.title}</Text>
|
||||
<Text className="explore-article-summary" type="secondary" ellipsis={{ showTooltip: true }}>
|
||||
{article.summary || '暂无正文'}
|
||||
</Text>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="explore-article-detail queue-section" bordered>
|
||||
<div className="explore-detail-toolbar">
|
||||
<Title heading={5}>{selected.title}</Title>
|
||||
<Space>
|
||||
<Button type="text" icon={<IconCheckCircle />} />
|
||||
<Button type="text" icon={<IconStar />} />
|
||||
<Button type="text" icon={<IconBook />} />
|
||||
</Space>
|
||||
</div>
|
||||
<article className="explore-article-body">
|
||||
<Space className="explore-article-meta" wrap>
|
||||
<span className={`explore-source-logo small ${SOURCE_META[selected.sourceID].color}`}>
|
||||
{sourceInitial(SOURCE_META[selected.sourceID].name)}
|
||||
</span>
|
||||
<Text type="secondary">{SOURCE_META[selected.sourceID].name}</Text>
|
||||
<Text type="secondary">{selected.project.name}</Text>
|
||||
<Text type="secondary">{selected.time}</Text>
|
||||
</Space>
|
||||
<Paragraph className="explore-article-content">{selected.summary || '暂无正文'}</Paragraph>
|
||||
<blockquote>
|
||||
推荐:将外部文章中的项目机会、风险提示和资料线索归档到对应项目,形成可追踪的计划和知识资产。
|
||||
</blockquote>
|
||||
</article>
|
||||
</Card>
|
||||
</section>
|
||||
) : (
|
||||
<Card className="queue-section" bordered>
|
||||
<Empty description="暂无数据源文章" />
|
||||
</Card>
|
||||
)}
|
||||
<Card className="queue-section" bordered>
|
||||
<Empty description="探索数据源暂未开放" />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function dataSources(articles: ExploreArticle[]): DataSourceCard[] {
|
||||
const counts = new Map<DataSourceID, number>([
|
||||
['all', articles.length],
|
||||
['manual', 0],
|
||||
['requirements', 0],
|
||||
['architecture', 0],
|
||||
])
|
||||
articles.forEach((article) => counts.set(article.sourceID, (counts.get(article.sourceID) ?? 0) + 1))
|
||||
|
||||
return (Object.keys(SOURCE_META) as DataSourceID[]).map((id) => ({
|
||||
id,
|
||||
...SOURCE_META[id],
|
||||
count: counts.get(id) ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
function detectSource(article: InboxItem): DataSourceID {
|
||||
const text = `${article.title} ${article.meta} ${article.tag} ${article.summary}`
|
||||
if (/架构|技术方案|系统设计|architecture/i.test(text)) {
|
||||
return 'architecture'
|
||||
}
|
||||
if (/需求|PRD|产品文档|requirement/i.test(text)) {
|
||||
return 'requirements'
|
||||
}
|
||||
return 'manual'
|
||||
}
|
||||
|
||||
function sourceInitial(name: string) {
|
||||
return name.trim().slice(0, 1) || '源'
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ProjectStatusbar } from './projects/project-statusbar'
|
||||
import { ProjectTopbar } from './projects/project-topbar'
|
||||
import type { SearchResultDTO } from '../api/search'
|
||||
import type { InboxSuggestionDTO } from '../api/inbox'
|
||||
import type { AISessionDTO, CreateAISessionInput } from '../api/ai'
|
||||
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
|
||||
|
||||
const { Content } = Layout
|
||||
@@ -44,6 +45,8 @@ export function ProjectPage({
|
||||
onSelectSearchResult,
|
||||
onAnalyzeInbox,
|
||||
onConfirmInbox,
|
||||
onListAISessions,
|
||||
onCreateAISession,
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeWorkspace: ProjectWorkspace
|
||||
@@ -75,6 +78,8 @@ export function ProjectPage({
|
||||
onSelectSearchResult: (result: SearchResultDTO) => void
|
||||
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||
onListAISessions: (projectId: string) => Promise<AISessionDTO[]>
|
||||
onCreateAISession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
|
||||
}) {
|
||||
const isProject = activeView === 'project'
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
@@ -130,7 +135,7 @@ export function ProjectPage({
|
||||
{activeView === 'workspace' ? (
|
||||
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
|
||||
) : activeView === 'workspace-explore' ? (
|
||||
<WorkspaceExplorePage workspaces={workspaces} onSelectItem={onSelectItem} />
|
||||
<WorkspaceExplorePage />
|
||||
) : (
|
||||
<ProjectChannelPage
|
||||
activeChannel={activeChannel}
|
||||
@@ -146,6 +151,8 @@ export function ProjectPage({
|
||||
onUpdateTask={updateActiveProjectTask}
|
||||
onAnalyzeInbox={onAnalyzeInbox}
|
||||
onConfirmInbox={onConfirmInbox}
|
||||
onListAISessions={onListAISessions}
|
||||
onCreateAISession={onCreateAISession}
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/logic/ai"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
"senlinai-agent/backend/internal/logic/files"
|
||||
"senlinai-agent/backend/internal/logic/inbox"
|
||||
@@ -33,6 +34,8 @@ func main() {
|
||||
fileHandler := files.NewHandler(fileService, cfg.MaxUploadBytes)
|
||||
inboxHandler := inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{}))
|
||||
searchHandler := search.NewHandler(search.NewService(models.DBService))
|
||||
aiGateway := ai.NewGatewayWithSecret(cfg.SystemAIKey, cfg.AIKeyEncryptionSecret)
|
||||
aiHandler := ai.NewHandler(ai.NewSessionService(aiGateway))
|
||||
authHandler := auth.NewHandler(authService)
|
||||
appRouter := httpx.NewProtectedRouter(
|
||||
cfg,
|
||||
@@ -45,6 +48,7 @@ func main() {
|
||||
fileHandler,
|
||||
inboxHandler,
|
||||
searchHandler,
|
||||
aiHandler,
|
||||
)
|
||||
if err := appRouter.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
@@ -25,6 +26,11 @@ type SelectedKey struct {
|
||||
KeyType string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrAIKeyMissing = errors.New("no ai key available")
|
||||
ErrAIRateLimited = errors.New("ai rate limit exceeded")
|
||||
)
|
||||
|
||||
func NewGateway(systemKey string) *Gateway {
|
||||
return NewGatewayWithSecret(systemKey, "development-ai-key-secret-change-me")
|
||||
}
|
||||
@@ -47,15 +53,19 @@ func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error
|
||||
|
||||
func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) {
|
||||
var userKey models.SenlinAgentAIKey
|
||||
if err := models.DBService.Where("user_id = ?", userID).First(&userKey).Error; err == nil {
|
||||
err := models.DBService.Where("user_id = ?", userID).First(&userKey).Error
|
||||
if err == nil {
|
||||
apiKey, err := decryptAPIKey(userKey.EncryptedAPIKey, g.encryptionSecret)
|
||||
if err != nil {
|
||||
return SelectedKey{}, err
|
||||
}
|
||||
return SelectedKey{Provider: userKey.Provider, APIKey: apiKey, KeyType: "user"}, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return SelectedKey{}, err
|
||||
}
|
||||
if g.systemKey == "" {
|
||||
return SelectedKey{}, errors.New("no ai key available")
|
||||
return SelectedKey{}, ErrAIKeyMissing
|
||||
}
|
||||
return SelectedKey{Provider: "openai", APIKey: g.systemKey, KeyType: "system"}, nil
|
||||
}
|
||||
@@ -82,7 +92,7 @@ func (g *Gateway) CheckRateLimit(userID uint, action string, limit int, window t
|
||||
return err
|
||||
}
|
||||
if count >= int64(limit) {
|
||||
return errors.New("ai rate limit exceeded")
|
||||
return ErrAIRateLimited
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
@@ -85,20 +86,25 @@ func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateAISession(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewSessionService()
|
||||
database := newTestDB(t)
|
||||
user := models.SenlinAgentUser{Email: "session@example.com", DisplayName: "Session User", PasswordHash: "hash"}
|
||||
require.NoError(t, database.Create(&user).Error)
|
||||
project := models.SenlinAgentProject{OwnerID: user.ID, Name: "Session Project", Identifier: "SESSION"}
|
||||
require.NoError(t, database.Create(&project).Error)
|
||||
service := NewSessionService(NewGateway("system-key"))
|
||||
|
||||
session, err := service.Create(7, 3, "报价分析")
|
||||
session, err := service.Create(user.ID, project.Identity, "报价分析", "询价上下文")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint(7), session.ProjectID)
|
||||
require.Equal(t, uint(3), session.CreatedBy)
|
||||
require.Equal(t, project.ID, session.ProjectID)
|
||||
require.Equal(t, user.ID, session.CreatedBy)
|
||||
require.Equal(t, "报价分析", session.Title)
|
||||
require.Equal(t, "ready", session.Status)
|
||||
}
|
||||
|
||||
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{})
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
|
||||
120
backend/internal/logic/ai/handlers.go
Normal file
120
backend/internal/logic/ai/handlers.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
// SessionDTO 是普通项目 AI 会话的公开契约,不携带数据库主键或自动创建对象的 ID。
|
||||
type SessionDTO struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Title string `json:"title"`
|
||||
Context string `json:"context"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type createSessionRequest struct {
|
||||
Title string `json:"title"`
|
||||
Context string `json:"context"`
|
||||
}
|
||||
|
||||
// Handler 注册受认证、受项目所有权保护的 AI 会话接口。
|
||||
type Handler struct {
|
||||
service *SessionService
|
||||
}
|
||||
|
||||
func NewHandler(service *SessionService) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router gin.IRouter) {
|
||||
router.GET("/projects/:projectId/ai-sessions", h.list)
|
||||
router.POST("/projects/:projectId/ai-sessions", h.create)
|
||||
}
|
||||
|
||||
func (h *Handler) list(c *gin.Context) {
|
||||
userID, projectIdentity, ok := aiRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sessions, err := h.service.List(userID, projectIdentity)
|
||||
if err != nil {
|
||||
writeAIError(c, err)
|
||||
return
|
||||
}
|
||||
items := make([]SessionDTO, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
items = append(items, sessionDTO(session))
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (h *Handler) create(c *gin.Context) {
|
||||
userID, projectIdentity, ok := aiRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input createSessionRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效")
|
||||
return
|
||||
}
|
||||
session, err := h.service.Create(userID, projectIdentity, input.Title, input.Context)
|
||||
if err != nil {
|
||||
writeAIError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, sessionDTO(*session))
|
||||
}
|
||||
|
||||
func aiRequestContext(c *gin.Context) (uint, string, bool) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
|
||||
return 0, "", false
|
||||
}
|
||||
projectIdentity, ok := httpx.IdentityParam(c, "projectId")
|
||||
if !ok {
|
||||
return 0, "", false
|
||||
}
|
||||
return userID, projectIdentity, true
|
||||
}
|
||||
|
||||
func sessionDTO(session models.SenlinAgentAISession) SessionDTO {
|
||||
return SessionDTO{
|
||||
ID: session.Identity,
|
||||
ProjectID: session.ProjectIdentity,
|
||||
Title: session.Title,
|
||||
Context: session.Context,
|
||||
Status: aiSessionStatus(session),
|
||||
CreatedAt: session.CreatedAt.UTC(),
|
||||
UpdatedAt: session.UpdatedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func writeAIError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在或无权访问")
|
||||
case errors.Is(err, ErrInvalidSession):
|
||||
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请输入 AI 会话标题")
|
||||
case errors.Is(err, ErrAIRateLimited):
|
||||
httpx.Error(c, http.StatusTooManyRequests, "ai_rate_limited", "AI 请求过于频繁,请稍后重试")
|
||||
case errors.Is(err, ErrAIKeyMissing):
|
||||
httpx.Error(c, http.StatusServiceUnavailable, "ai_key_missing", "尚未配置可用的 AI 密钥")
|
||||
default:
|
||||
log.Printf("ai session request failed: %v", err)
|
||||
httpx.Error(c, http.StatusInternalServerError, "internal_error", "AI 会话操作失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
278
backend/internal/logic/ai/handlers_test.go
Normal file
278
backend/internal/logic/ai/handlers_test.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestAISessionHandlersRequireOwnedProject(t *testing.T) {
|
||||
database := newAIHandlerTestDB(t)
|
||||
owner := createAIHandlerUser(t, database, "owner@example.com")
|
||||
intruder := createAIHandlerUser(t, database, "intruder@example.com")
|
||||
project := createAIHandlerProject(t, database, owner.ID, "PRIVATE")
|
||||
gateway := &recordingSessionGateway{}
|
||||
router := aiHandlerTestRouter(intruder.ID, gateway)
|
||||
|
||||
for _, request := range []*http.Request{
|
||||
authenticatedAIRequest(t, http.MethodGet, "/api/v1/projects/"+project.Identity+"/ai-sessions", nil),
|
||||
authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
|
||||
"title": "越权会话", "context": "不得创建",
|
||||
}),
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
var payload httpx.ErrorEnvelope
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
require.Equal(t, "not_found", payload.Error.Code)
|
||||
require.Equal(t, "项目不存在或无权访问", payload.Error.Message)
|
||||
}
|
||||
|
||||
var count int64
|
||||
require.NoError(t, database.Model(&models.SenlinAgentAISession{}).Count(&count).Error)
|
||||
require.Zero(t, count)
|
||||
require.Empty(t, gateway.steps)
|
||||
}
|
||||
|
||||
func TestCreateAISessionChecksRateLimitBeforeSelectingProvider(t *testing.T) {
|
||||
database := newAIHandlerTestDB(t)
|
||||
owner := createAIHandlerUser(t, database, "owner@example.com")
|
||||
project := createAIHandlerProject(t, database, owner.ID, "RATE")
|
||||
gateway := &recordingSessionGateway{
|
||||
selected: SelectedKey{Provider: "openai", APIKey: "system-key", KeyType: "system"},
|
||||
}
|
||||
router := aiHandlerTestRouter(owner.ID, gateway)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
|
||||
"title": "限流顺序", "context": "只创建受控会话",
|
||||
}))
|
||||
|
||||
require.Equal(t, http.StatusCreated, recorder.Code)
|
||||
require.Equal(t, []string{"rate", "select", "record"}, gateway.steps)
|
||||
}
|
||||
|
||||
func TestCreateAISessionReturnsRateLimitBeforeMissingKeyAndAuditsFailure(t *testing.T) {
|
||||
database := newAIHandlerTestDB(t)
|
||||
owner := createAIHandlerUser(t, database, "owner@example.com")
|
||||
project := createAIHandlerProject(t, database, owner.ID, "LIMITED")
|
||||
gateway := NewGatewayWithSecret("", "test-encryption-secret")
|
||||
for range aiSessionCreateLimit {
|
||||
require.NoError(t, gateway.RecordCall(owner.ID, "openai", "system", "ai_session_create", "ready", ""))
|
||||
}
|
||||
router := aiHandlerTestRouter(owner.ID, gateway)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
|
||||
"title": "超过限额", "context": "必须先返回限流",
|
||||
}))
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, recorder.Code)
|
||||
var payload httpx.ErrorEnvelope
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
require.Equal(t, "ai_rate_limited", payload.Error.Code)
|
||||
require.Equal(t, "AI 请求过于频繁,请稍后重试", payload.Error.Message)
|
||||
var latest models.SenlinAgentAICallLog
|
||||
require.NoError(t, database.Order("id desc").First(&latest).Error)
|
||||
require.Equal(t, "none", latest.Provider)
|
||||
require.Equal(t, "none", latest.UsedKeyType)
|
||||
require.Equal(t, "ai_session_create", latest.Action)
|
||||
require.Equal(t, "failed", latest.Status)
|
||||
require.Equal(t, "ai_rate_limited", latest.Error)
|
||||
}
|
||||
|
||||
func TestCreateAISessionWithoutKeyReturnsAuditedErrorAndCreatesNoFormalObjects(t *testing.T) {
|
||||
database := newAIHandlerTestDB(t)
|
||||
owner := createAIHandlerUser(t, database, "owner@example.com")
|
||||
project := createAIHandlerProject(t, database, owner.ID, "NO_KEY")
|
||||
router := aiHandlerTestRouter(owner.ID, NewGatewayWithSecret("", "test-encryption-secret"))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
|
||||
"title": "缺少密钥", "context": "不得伪装为已完成",
|
||||
}))
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
var payload httpx.ErrorEnvelope
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
require.Equal(t, "ai_key_missing", payload.Error.Code)
|
||||
require.Equal(t, "尚未配置可用的 AI 密钥", payload.Error.Message)
|
||||
for _, model := range []any{
|
||||
&models.SenlinAgentAISession{},
|
||||
&models.SenlinAgentTask{},
|
||||
&models.SenlinAgentNote{},
|
||||
&models.SenlinAgentSource{},
|
||||
} {
|
||||
var count int64
|
||||
require.NoError(t, database.Model(model).Count(&count).Error)
|
||||
require.Zero(t, count)
|
||||
}
|
||||
var call models.SenlinAgentAICallLog
|
||||
require.NoError(t, database.First(&call).Error)
|
||||
require.Equal(t, owner.ID, call.UserID)
|
||||
require.Equal(t, "none", call.Provider)
|
||||
require.Equal(t, "none", call.UsedKeyType)
|
||||
require.Equal(t, "ai_session_create", call.Action)
|
||||
require.Equal(t, "failed", call.Status)
|
||||
require.Equal(t, "ai_key_missing", call.Error)
|
||||
}
|
||||
|
||||
func TestCreateAISessionReturnsIdentityDTOAndCompleteAuditWithoutAutomaticObjectIDs(t *testing.T) {
|
||||
database := newAIHandlerTestDB(t)
|
||||
owner := createAIHandlerUser(t, database, "owner@example.com")
|
||||
project := createAIHandlerProject(t, database, owner.ID, "CREATE")
|
||||
router := aiHandlerTestRouter(owner.ID, NewGatewayWithSecret("system-key", "test-encryption-secret"))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
|
||||
"title": "报价分析", "context": "仅整理会话上下文",
|
||||
}))
|
||||
|
||||
require.Equal(t, http.StatusCreated, recorder.Code)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "createdAt", "updatedAt"}, aiMapKeys(payload))
|
||||
identity, err := uuid.Parse(payload["id"].(string))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uuid.Version(7), identity.Version())
|
||||
require.Equal(t, project.Identity, payload["projectId"])
|
||||
require.Equal(t, "报价分析", payload["title"])
|
||||
require.Equal(t, "仅整理会话上下文", payload["context"])
|
||||
require.Equal(t, "ready", payload["status"])
|
||||
for _, forbidden := range []string{"taskId", "noteId", "sourceId", "createdTaskId", "createdNoteId", "createdSourceId"} {
|
||||
require.NotContains(t, payload, forbidden)
|
||||
}
|
||||
|
||||
var call models.SenlinAgentAICallLog
|
||||
require.NoError(t, database.First(&call).Error)
|
||||
require.Equal(t, "openai", call.Provider)
|
||||
require.Equal(t, "system", call.UsedKeyType)
|
||||
require.Equal(t, "ai_session_create", call.Action)
|
||||
require.Equal(t, "ready", call.Status)
|
||||
require.Empty(t, call.Error)
|
||||
for _, model := range []any{&models.SenlinAgentTask{}, &models.SenlinAgentNote{}, &models.SenlinAgentSource{}} {
|
||||
var count int64
|
||||
require.NoError(t, database.Model(model).Count(&count).Error)
|
||||
require.Zero(t, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAISessionsReturnsOnlyOwnedProjectIdentityDTOs(t *testing.T) {
|
||||
database := newAIHandlerTestDB(t)
|
||||
owner := createAIHandlerUser(t, database, "owner@example.com")
|
||||
project := createAIHandlerProject(t, database, owner.ID, "LIST")
|
||||
otherProject := createAIHandlerProject(t, database, owner.ID, "OTHER")
|
||||
require.NoError(t, database.Create(&models.SenlinAgentAISession{
|
||||
ProjectID: project.ID, CreatedBy: owner.ID, Title: "目标会话", Context: "项目上下文",
|
||||
}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentAISession{
|
||||
ProjectID: otherProject.ID, CreatedBy: owner.ID, Title: "其他会话", Context: "不得混入",
|
||||
}).Error)
|
||||
router := aiHandlerTestRouter(owner.ID, &recordingSessionGateway{})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodGet, "/api/v1/projects/"+project.Identity+"/ai-sessions", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
var payload []map[string]any
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
require.Len(t, payload, 1)
|
||||
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "createdAt", "updatedAt"}, aiMapKeys(payload[0]))
|
||||
require.Equal(t, project.Identity, payload[0]["projectId"])
|
||||
require.Equal(t, "目标会话", payload[0]["title"])
|
||||
require.Equal(t, "ready", payload[0]["status"])
|
||||
}
|
||||
|
||||
type recordingSessionGateway struct {
|
||||
steps []string
|
||||
selected SelectedKey
|
||||
rateErr error
|
||||
selectErr error
|
||||
}
|
||||
|
||||
func (g *recordingSessionGateway) CheckRateLimit(uint, string, int, time.Duration) error {
|
||||
g.steps = append(g.steps, "rate")
|
||||
return g.rateErr
|
||||
}
|
||||
|
||||
func (g *recordingSessionGateway) SelectKey(uint) (SelectedKey, error) {
|
||||
g.steps = append(g.steps, "select")
|
||||
return g.selected, g.selectErr
|
||||
}
|
||||
|
||||
func (g *recordingSessionGateway) RecordCall(uint, string, string, string, string, string) error {
|
||||
g.steps = append(g.steps, "record")
|
||||
return nil
|
||||
}
|
||||
|
||||
func newAIHandlerTestDB(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{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
|
||||
func createAIHandlerUser(t *testing.T, database *gorm.DB, email string) models.SenlinAgentUser {
|
||||
t.Helper()
|
||||
user := models.SenlinAgentUser{Email: email, DisplayName: email, PasswordHash: "hash"}
|
||||
require.NoError(t, database.Create(&user).Error)
|
||||
return user
|
||||
}
|
||||
|
||||
func createAIHandlerProject(t *testing.T, database *gorm.DB, ownerID uint, identifier string) models.SenlinAgentProject {
|
||||
t.Helper()
|
||||
project := models.SenlinAgentProject{OwnerID: ownerID, Name: identifier, Identifier: identifier}
|
||||
require.NoError(t, database.Create(&project).Error)
|
||||
return project
|
||||
}
|
||||
|
||||
func aiHandlerTestRouter(userID uint, gateway sessionGateway) http.Handler {
|
||||
return httpx.NewProtectedRouter(
|
||||
config.Config{Env: "test"},
|
||||
func(string) (uint, error) { return userID, nil },
|
||||
NewHandler(NewSessionService(gateway)),
|
||||
)
|
||||
}
|
||||
|
||||
func authenticatedAIRequest(t *testing.T, method, path string, body any) *http.Request {
|
||||
t.Helper()
|
||||
var requestBody *bytes.Reader
|
||||
if body == nil {
|
||||
requestBody = bytes.NewReader(nil)
|
||||
} else {
|
||||
encoded, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
requestBody = bytes.NewReader(encoded)
|
||||
}
|
||||
request := httptest.NewRequest(method, path, requestBody)
|
||||
request.Header.Set("Authorization", "Bearer test-token")
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func aiMapKeys(values map[string]any) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -2,23 +2,118 @@ package ai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"senlinai-agent/backend/internal/logic/projects"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
aiSessionCreateAction = "ai_session_create"
|
||||
aiSessionCreateLimit = 20
|
||||
defaultSessionStatus = "ready"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSession = errors.New("invalid ai session")
|
||||
)
|
||||
|
||||
type sessionGateway interface {
|
||||
CheckRateLimit(userID uint, action string, limit int, window time.Duration) error
|
||||
SelectKey(userID uint) (SelectedKey, error)
|
||||
RecordCall(userID uint, provider string, usedKeyType string, action string, status string, errText string) error
|
||||
}
|
||||
|
||||
// SessionService 只管理项目内普通 AI 会话;它不会把上下文自动转换为任务、笔记或资料。
|
||||
type SessionService struct {
|
||||
gateway sessionGateway
|
||||
}
|
||||
|
||||
func NewSessionService() *SessionService {
|
||||
return &SessionService{}
|
||||
func NewSessionService(gateway sessionGateway) *SessionService {
|
||||
return &SessionService{gateway: gateway}
|
||||
}
|
||||
|
||||
func (s *SessionService) Create(projectID uint, userID uint, title string) (*models.SenlinAgentAISession, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil, errors.New("session title is required")
|
||||
// List 在项目 owner 校验后返回该项目的会话,内部自增 ID 不离开服务边界。
|
||||
func (s *SessionService) List(userID uint, projectIdentity string) ([]models.SenlinAgentAISession, error) {
|
||||
project, err := projects.FindOwnedProject(userID, projectIdentity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session := &models.SenlinAgentAISession{ProjectID: projectID, CreatedBy: userID, Title: title}
|
||||
return session, models.DBService.Create(session).Error
|
||||
var sessions []models.SenlinAgentAISession
|
||||
if err := models.DBService.Where("project_id = ?", project.ID).Order("updated_at desc, id desc").Find(&sessions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sessions == nil {
|
||||
sessions = []models.SenlinAgentAISession{}
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// Create 先校验项目,再限流,最后才选择 provider/key;会话创建不会生成任何正式业务对象。
|
||||
func (s *SessionService) Create(userID uint, projectIdentity, title, context string) (*models.SenlinAgentAISession, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
context = strings.TrimSpace(context)
|
||||
if title == "" {
|
||||
return nil, ErrInvalidSession
|
||||
}
|
||||
project, err := projects.FindOwnedProject(userID, projectIdentity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.gateway == nil {
|
||||
return nil, errors.New("ai gateway is required")
|
||||
}
|
||||
|
||||
if err := s.gateway.CheckRateLimit(userID, aiSessionCreateAction, aiSessionCreateLimit, time.Hour); err != nil {
|
||||
if errors.Is(err, ErrAIRateLimited) {
|
||||
if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", "ai_rate_limited"); auditErr != nil {
|
||||
return nil, fmt.Errorf("record ai rate limit failure: %w", auditErr)
|
||||
}
|
||||
return nil, ErrAIRateLimited
|
||||
}
|
||||
if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", "rate_limit_check_failed"); auditErr != nil {
|
||||
return nil, fmt.Errorf("check rate limit: %v; record failure: %w", err, auditErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
selected, err := s.gateway.SelectKey(userID)
|
||||
if err != nil {
|
||||
code := "provider_selection_failed"
|
||||
if errors.Is(err, ErrAIKeyMissing) {
|
||||
code = "ai_key_missing"
|
||||
}
|
||||
if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", code); auditErr != nil {
|
||||
return nil, fmt.Errorf("select ai key: %v; record failure: %w", err, auditErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session := models.SenlinAgentAISession{
|
||||
ProjectID: project.ID,
|
||||
CreatedBy: userID,
|
||||
Title: title,
|
||||
Context: context,
|
||||
Status: defaultSessionStatus,
|
||||
}
|
||||
if err := models.DBService.Create(&session).Error; err != nil {
|
||||
if auditErr := s.gateway.RecordCall(userID, selected.Provider, selected.KeyType, aiSessionCreateAction, "failed", "session_create_failed"); auditErr != nil {
|
||||
return nil, fmt.Errorf("create ai session: %v; record failure: %w", err, auditErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// ready 只表示会话入口已建立,不表示 provider 已回复或任何业务对象已创建。
|
||||
if err := s.gateway.RecordCall(userID, selected.Provider, selected.KeyType, aiSessionCreateAction, defaultSessionStatus, ""); err != nil {
|
||||
return nil, fmt.Errorf("record ai session creation: %w", err)
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func aiSessionStatus(session models.SenlinAgentAISession) string {
|
||||
if status := strings.TrimSpace(session.Status); status != "" {
|
||||
return status
|
||||
}
|
||||
return defaultSessionStatus
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type SenlinAgentAISession struct {
|
||||
CreatedByIdentity string `gorm:"type:char(36);index"`
|
||||
Title string `gorm:"not null"`
|
||||
Context string `gorm:"type:text"`
|
||||
Status string `gorm:"not null;default:ready"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user