feat(ai): add local expert library

This commit is contained in:
2026-07-22 17:09:54 +08:00
parent b28a5d635c
commit 42dd0dce47
22 changed files with 3432 additions and 60 deletions

View File

@@ -40,7 +40,9 @@ Inbox 用于承接尚未归类的想法、消息和材料。用户可以查看
### AI 会话 ### AI 会话
用户可以在项目内创建带有项目背景的 AI 会话入口,提前明确本次协作的目标和参考上下文。AI 输出与正式项目内容相互区分,需要用户确认后才能转化为任务、笔记或资料。 用户可以从本地专家库中选择适合的专业角色,再创建带有项目背景的 AI 会话。专家库支持按分类浏览和关键词搜索,每个会话都会保留所选专家与项目上下文。AI 输出与正式项目内容相互区分,需要用户确认后才能转化为任务、笔记或资料。
内置专家资料基于 [agency-agents-zh](https://github.com/jnMetaCode/agency-agents-zh) 本地化,许可说明见 [docs/licenses/agency-agents-zh-MIT.txt](docs/licenses/agency-agents-zh-MIT.txt)。运行时不会依赖外部专家站点。
### 笔记与资料管理 ### 笔记与资料管理

View File

@@ -111,7 +111,7 @@ const inboxPageSource = readFileSync('src/pages/projects/project-inbox.tsx', 'ut
if (!inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action') if (!inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action')
const aiApiSource = readFileSync('src/api/ai.ts', 'utf8') const aiApiSource = readFileSync('src/api/ai.ts', 'utf8')
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) { for (const required of ['/api/v1/projects/', '/ai-sessions', '/api/v1/ai-experts', 'listAIExperts', 'listAISessions', 'createAISession']) {
if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`) if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`)
} }
if (!aiApiSource.includes('signal?: AbortSignal')) failures.push('AI API requests must accept an AbortSignal') if (!aiApiSource.includes('signal?: AbortSignal')) failures.push('AI API requests must accept an AbortSignal')
@@ -119,7 +119,7 @@ if (!aiApiSource.includes('signal,')) failures.push('AI API requests must pass t
if (/\b(?:task|note|source)Id\b/.test(aiApiSource)) failures.push('AI session responses must not expose automatic formal object IDs') 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') const aiPageSource = readFileSync('src/pages/projects/project-ai.tsx', 'utf8')
for (const required of ['新建会话', 'agent-chat-shell', 'agent-composer', 'IconAttachment', 'agent-send-button', 'onPressEnter', 'loading', 'error']) { for (const required of ['新建会话', '选择专家进行会话', 'agent-expert-picker', 'selectedExpertID', 'expertId', 'agent-chat-shell', 'agent-composer', 'IconAttachment', 'agent-send-button', 'onPressEnter', 'loading', 'error']) {
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`) if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
} }
for (const required of ['AbortController', 'generationRef', 'projectRef', 'setSessions([])']) { for (const required of ['AbortController', 'generationRef', 'projectRef', 'setSessions([])']) {

View File

@@ -18,6 +18,20 @@ const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 }) const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 })
const errors = [] const errors = []
const projectId = '019b0000-0000-7000-8000-000000000001' const projectId = '019b0000-0000-7000-8000-000000000001'
const visualExperts = [
{
id: '019b0000-0000-7000-8000-000000000020', slug: 'product-manager', category: 'product', categoryName: '产品',
name: '产品经理', description: '负责需求分析、路线规划和产品交付。', emoji: '🧭', color: '#165DFF',
},
{
id: '019b0000-0000-7000-8000-000000000021', slug: 'ui-designer', category: 'design', categoryName: '设计',
name: 'UI 设计师', description: '负责界面设计、组件规范和视觉一致性。', emoji: '🎨', color: '#722ED1',
},
{
id: '019b0000-0000-7000-8000-000000000022', slug: 'frontend-developer', category: 'engineering', categoryName: '工程',
name: '前端开发者', description: '负责现代 Web 应用实现与性能优化。', emoji: '💻', color: '#0FC6C2',
},
]
page.on('console', (message) => { page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text()) if (message.type() === 'error') errors.push(message.text())
}) })
@@ -138,6 +152,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions`) { if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions`) {
if (route.request().method() === 'POST') { if (route.request().method() === 'POST') {
const input = route.request().postDataJSON() const input = route.request().postDataJSON()
const expert = visualExperts.find((item) => item.id === input.expertId)
await route.fulfill({ await route.fulfill({
json: { json: {
id: '019b0000-0000-7000-8000-000000000010', id: '019b0000-0000-7000-8000-000000000010',
@@ -145,6 +160,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
title: input.title, title: input.title,
context: input.context, context: input.context,
status: 'ready', status: 'ready',
expert,
createdAt: '2026-07-22T09:00:00Z', createdAt: '2026-07-22T09:00:00Z',
updatedAt: '2026-07-22T09:00:00Z', updatedAt: '2026-07-22T09:00:00Z',
}, },
@@ -158,12 +174,17 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
title: '报价分析', title: '报价分析',
context: '分析当前方案的报价结构。', context: '分析当前方案的报价结构。',
status: 'ready', status: 'ready',
expert: visualExperts[1],
createdAt: '2026-07-22T08:30:00Z', createdAt: '2026-07-22T08:30:00Z',
updatedAt: '2026-07-22T08:30:00Z', updatedAt: '2026-07-22T08:30:00Z',
}], }],
}) })
return return
} }
if (url.pathname === '/api/v1/ai-experts') {
await route.fulfill({ json: visualExperts })
return
}
await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } }) await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } })
}) })
@@ -334,6 +355,8 @@ for (const channel of [
hasOverviewHead: Boolean(pageNode?.querySelector('.overview-head')), hasOverviewHead: Boolean(pageNode?.querySelector('.overview-head')),
hasSessionList: Boolean(pageNode?.querySelector('.agent-session-list')), hasSessionList: Boolean(pageNode?.querySelector('.agent-session-list')),
hasComposer: Boolean(pageNode?.querySelector('.agent-composer')), hasComposer: Boolean(pageNode?.querySelector('.agent-composer')),
hasExpertPicker: Boolean(pageNode?.querySelector('.agent-expert-picker')),
expertHeading: pageNode?.querySelector('.agent-expert-heading')?.textContent?.replace(/\s+/g, ' ').trim() ?? '',
} }
}, channel) }, channel)
@@ -350,6 +373,8 @@ for (const channel of [
} }
if (channel.label === 'AI 会话') { if (channel.label === 'AI 会话') {
await page.screenshot({ path: 'test-results/project-ai-expert-picker-light.png', fullPage: true })
await page.getByRole('button', { name: /产品经理/ }).click()
await page.locator('.agent-composer textarea').fill('请分析这个项目的下一步计划') await page.locator('.agent-composer textarea').fill('请分析这个项目的下一步计划')
await page.getByRole('button', { name: '发送消息' }).click() await page.getByRole('button', { name: '发送消息' }).click()
await page.waitForSelector('.agent-user-message') await page.waitForSelector('.agent-user-message')
@@ -357,6 +382,7 @@ for (const channel of [
userMessage: document.querySelector('.agent-user-message')?.textContent?.trim() ?? '', userMessage: document.querySelector('.agent-user-message')?.textContent?.trim() ?? '',
activeSession: document.querySelector('.agent-session.active')?.textContent?.trim() ?? '', activeSession: document.querySelector('.agent-session.active')?.textContent?.trim() ?? '',
composerValue: document.querySelector('.agent-composer textarea')?.value ?? '', composerValue: document.querySelector('.agent-composer textarea')?.value ?? '',
expertName: document.querySelector('.agent-assistant-name')?.textContent?.trim() ?? '',
})) }))
await page.screenshot({ path: 'test-results/project-ai-session-light.png', fullPage: true }) await page.screenshot({ path: 'test-results/project-ai-session-light.png', fullPage: true })
} }
@@ -552,7 +578,10 @@ for (const check of channelPageChecks) {
if (check.label === '新建频道' && (check.inputCount < 4 || !check.hasSaveChannel)) { if (check.label === '新建频道' && (check.inputCount < 4 || !check.hasSaveChannel)) {
failures.push(`expected legacy new channel editor, got inputs=${check.inputCount}, save=${check.hasSaveChannel}`) failures.push(`expected legacy new channel editor, got inputs=${check.inputCount}, save=${check.hasSaveChannel}`)
} }
if (check.label === 'AI 会话' && (check.hasOverviewHead || !check.hasSessionList || !check.hasComposer)) { if (check.label === 'AI 会话' && (
check.hasOverviewHead || !check.hasSessionList || !check.hasComposer || !check.hasExpertPicker ||
!check.expertHeading.includes('选择专家进行会话')
)) {
failures.push(`expected headerless AI session chat layout, got ${JSON.stringify(check)}`) failures.push(`expected headerless AI session chat layout, got ${JSON.stringify(check)}`)
} }
} }
@@ -570,7 +599,8 @@ if (
!aiSessionCheck || !aiSessionCheck ||
aiSessionCheck.userMessage !== '请分析这个项目的下一步计划' || aiSessionCheck.userMessage !== '请分析这个项目的下一步计划' ||
!aiSessionCheck.activeSession.includes('请分析这个项目的下一步计划') || !aiSessionCheck.activeSession.includes('请分析这个项目的下一步计划') ||
aiSessionCheck.composerValue !== '' aiSessionCheck.composerValue !== '' ||
aiSessionCheck.expertName !== '产品经理'
) { ) {
failures.push(`expected AI composer to create and select a session, got ${JSON.stringify(aiSessionCheck)}`) failures.push(`expected AI composer to create and select a session, got ${JSON.stringify(aiSessionCheck)}`)
} }

View File

@@ -1642,6 +1642,13 @@
color: var(--senlin-muted); color: var(--senlin-muted);
} }
.agent-session-emoji {
width: 18px;
flex: 0 0 18px;
display: inline-grid;
place-items: center;
}
.agent-session:hover, .agent-session:hover,
.agent-session.active { .agent-session.active {
background: color-mix(in srgb, var(--senlin-primary) 8%, var(--senlin-panel)); background: color-mix(in srgb, var(--senlin-primary) 8%, var(--senlin-panel));
@@ -1670,6 +1677,148 @@
scrollbar-width: thin; scrollbar-width: thin;
} }
.agent-expert-picker {
width: min(920px, 100%);
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.agent-expert-heading {
display: grid;
justify-items: center;
gap: 8px;
margin-bottom: 2px;
text-align: center;
}
.agent-expert-heading strong {
color: var(--senlin-text);
font-size: 20px;
}
.agent-expert-search {
width: min(560px, 100%);
align-self: center;
}
.agent-expert-search.arco-input-wrapper {
height: 40px;
border-radius: 12px;
background: color-mix(in srgb, var(--senlin-bg) 68%, var(--senlin-panel));
}
.agent-expert-categories {
display: flex;
gap: 7px;
overflow-x: auto;
padding: 1px 0 4px;
scrollbar-width: none;
}
.agent-expert-categories button {
flex: 0 0 auto;
border: 1px solid var(--senlin-border);
border-radius: 999px;
background: var(--senlin-panel);
color: var(--senlin-muted);
padding: 5px 11px;
cursor: pointer;
font-size: 12px;
}
.agent-expert-categories button:hover,
.agent-expert-categories button.active {
border-color: color-mix(in srgb, var(--senlin-primary) 45%, var(--senlin-border));
background: var(--senlin-soft-blue);
color: var(--senlin-primary);
}
.agent-expert-loading {
min-height: 0;
}
.agent-expert-loading > .arco-spin-children {
min-height: 0;
}
.agent-expert-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding-bottom: 4px;
}
.agent-expert-card {
min-width: 0;
min-height: 132px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
align-items: start;
gap: 11px;
border: 1px solid var(--senlin-border);
border-radius: 13px;
background: var(--senlin-panel);
color: var(--senlin-text);
padding: 13px;
text-align: left;
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.agent-expert-card:hover,
.agent-expert-card.active {
border-color: color-mix(in srgb, var(--senlin-primary) 55%, var(--senlin-border));
box-shadow: 0 8px 22px color-mix(in srgb, var(--senlin-primary) 10%, transparent);
transform: translateY(-1px);
}
.agent-expert-card.active {
background: color-mix(in srgb, var(--senlin-soft-blue) 62%, var(--senlin-panel));
}
.agent-expert-avatar {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
color: #fff;
font-size: 20px;
}
.agent-expert-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.agent-expert-copy small {
overflow: hidden;
color: var(--senlin-primary);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-expert-copy strong {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-expert-copy > span {
display: -webkit-box;
overflow: hidden;
color: var(--senlin-muted);
font-size: 12px;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.agent-empty-state { .agent-empty-state {
align-self: center; align-self: center;
display: grid; display: grid;
@@ -1819,6 +1968,10 @@
.agent-composer { .agent-composer {
width: calc(100% - 32px); width: calc(100% - 32px);
} }
.agent-expert-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
.reply-box, .reply-box,

View File

@@ -6,13 +6,46 @@ export type AISessionDTO = {
title: string title: string
context: string context: string
status: string status: string
expert: AIExpertDTO | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
export type AIExpertDTO = {
id: string
slug: string
category: string
categoryName: string
name: string
description: string
emoji: string
color: string
}
export type AIExpertDetailDTO = AIExpertDTO & {
systemPrompt: string
source: string
sourceLicense: string
}
export type CreateAISessionInput = { export type CreateAISessionInput = {
title: string title: string
context: string context: string
expertId?: string
}
export async function listAIExperts(session: ApiSession, signal?: AbortSignal) {
return apiRequest<AIExpertDTO[]>('/api/v1/ai-experts', {
token: session.token,
signal,
})
}
export async function getAIExpert(session: ApiSession, expertId: string, signal?: AbortSignal) {
return apiRequest<AIExpertDetailDTO>(`/api/v1/ai-experts/${expertId}`, {
token: session.token,
signal,
})
} }
export async function listAISessions(session: ApiSession, projectId: string, signal?: AbortSignal) { export async function listAISessions(session: ApiSession, projectId: string, signal?: AbortSignal) {

View File

@@ -3,7 +3,7 @@ import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
import '@arco-design/web-react/dist/css/arco.css' import '@arco-design/web-react/dist/css/arco.css'
import '../App.css' import '../App.css'
import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client' import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client'
import { createAISession, listAISessions, type CreateAISessionInput } from '../api/ai' import { createAISession, listAIExperts, listAISessions, type CreateAISessionInput } from '../api/ai'
import { analyzeInboxItem, confirmInboxItem } from '../api/inbox' import { analyzeInboxItem, confirmInboxItem } from '../api/inbox'
import { mapWorkspace } from '../api/mappers' import { mapWorkspace } from '../api/mappers'
import { import {
@@ -49,6 +49,11 @@ function App() {
return listAISessions(session, projectId, signal) return listAISessions(session, projectId, signal)
}, [session]) }, [session])
const handleListAIExperts = useCallback((signal?: AbortSignal) => {
if (!session) return Promise.reject(new Error('未登录'))
return listAIExperts(session, signal)
}, [session])
const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => { const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => {
if (!session) return Promise.reject(new Error('未登录')) if (!session) return Promise.reject(new Error('未登录'))
return createAISession(session, projectId, input, signal) return createAISession(session, projectId, input, signal)
@@ -358,6 +363,7 @@ function App() {
onAnalyzeInbox={handleAnalyzeInbox} onAnalyzeInbox={handleAnalyzeInbox}
onConfirmInbox={handleConfirmInbox} onConfirmInbox={handleConfirmInbox}
onListAISessions={handleListAISessions} onListAISessions={handleListAISessions}
onListAIExperts={handleListAIExperts}
onCreateAISession={handleCreateAISession} onCreateAISession={handleCreateAISession}
/> />
) : ( ) : (

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Button, Card, Empty, Input, Spin, Typography } from '@arco-design/web-react' import { Alert, Button, Card, Empty, Input, Spin, Typography } from '@arco-design/web-react'
import { IconArrowUp, IconAttachment, IconPlus, IconRobot } from '@arco-design/web-react/icon' import { IconArrowUp, IconAttachment, IconPlus, IconRobot, IconSearch } from '@arco-design/web-react/icon'
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai' import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
import type { ProjectWorkspace } from './project-types' import type { ProjectWorkspace } from './project-types'
const { Text } = Typography const { Text } = Typography
@@ -10,17 +10,24 @@ export function ProjectAi({
activeWorkspace, activeWorkspace,
onSelectItem, onSelectItem,
onListSessions, onListSessions,
onListExperts,
onCreateSession, onCreateSession,
}: { }: {
activeWorkspace: ProjectWorkspace activeWorkspace: ProjectWorkspace
onSelectItem: (title: string) => void onSelectItem: (title: string) => void
onListSessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]> onListSessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onListExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
onCreateSession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO> onCreateSession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) { }) {
const [sessions, setSessions] = useState<AISessionDTO[]>([]) const [sessions, setSessions] = useState<AISessionDTO[]>([])
const [experts, setExperts] = useState<AIExpertDTO[]>([])
const [selectedSessionID, setSelectedSessionID] = useState<string | null>(null) const [selectedSessionID, setSelectedSessionID] = useState<string | null>(null)
const [selectedExpertID, setSelectedExpertID] = useState<string | null>(null)
const [expertQuery, setExpertQuery] = useState('')
const [expertCategory, setExpertCategory] = useState('all')
const [prompt, setPrompt] = useState('') const [prompt, setPrompt] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingExperts, setLoadingExperts] = useState(true)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const projectId = activeWorkspace.project.id const projectId = activeWorkspace.project.id
@@ -38,9 +45,14 @@ export function ProjectAi({
listControllerRef.current = controller listControllerRef.current = controller
createControllerRef.current = null createControllerRef.current = null
setSessions([]) setSessions([])
setExperts([])
setSelectedSessionID(null) setSelectedSessionID(null)
setSelectedExpertID(null)
setExpertQuery('')
setExpertCategory('all')
setPrompt('') setPrompt('')
setLoading(true) setLoading(true)
setLoadingExperts(true)
setCreating(false) setCreating(false)
setError('') setError('')
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
@@ -52,9 +64,20 @@ export function ProjectAi({
.catch((requestError: unknown) => { .catch((requestError: unknown) => {
if (isCurrent()) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试') if (isCurrent()) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
}) })
.finally(() => {
if (isCurrent()) setLoading(false)
})
void onListExperts(controller.signal)
.then((items) => {
if (isCurrent()) setExperts(items)
})
.catch((requestError: unknown) => {
if (isCurrent()) setError(requestError instanceof Error ? requestError.message : '专家库加载失败,请稍后重试')
})
.finally(() => { .finally(() => {
if (isCurrent()) { if (isCurrent()) {
setLoading(false) setLoadingExperts(false)
listControllerRef.current = null listControllerRef.current = null
} }
}) })
@@ -66,15 +89,36 @@ export function ProjectAi({
createControllerRef.current?.abort() createControllerRef.current?.abort()
createControllerRef.current = null createControllerRef.current = null
} }
}, [onListSessions, projectId]) }, [onListExperts, onListSessions, projectId])
const selectedSession = useMemo( const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionID) ?? null, () => sessions.find((session) => session.id === selectedSessionID) ?? null,
[selectedSessionID, sessions], [selectedSessionID, sessions],
) )
const selectedExpert = useMemo(
() => experts.find((expert) => expert.id === selectedExpertID) ?? selectedSession?.expert ?? null,
[experts, selectedExpertID, selectedSession],
)
const expertCategories = useMemo(() => {
const categories = new Map<string, string>()
experts.forEach((expert) => categories.set(expert.category, expert.categoryName))
return [...categories.entries()].map(([id, name]) => ({ id, name }))
}, [experts])
const visibleExperts = useMemo(() => {
const keyword = expertQuery.trim().toLocaleLowerCase()
return experts.filter((expert) => {
if (expertCategory !== 'all' && expert.category !== expertCategory) return false
if (!keyword) return true
return `${expert.name} ${expert.description} ${expert.categoryName}`.toLocaleLowerCase().includes(keyword)
})
}, [expertCategory, expertQuery, experts])
const createSession = async () => { const createSession = async () => {
const message = prompt.trim() const message = prompt.trim()
if (!selectedExpert) {
setError('请先选择一位专家')
return
}
if (!message) { if (!message) {
setError('请输入会话内容') setError('请输入会话内容')
return return
@@ -94,10 +138,12 @@ export function ProjectAi({
const created = await onCreateSession(projectId, { const created = await onCreateSession(projectId, {
title: sessionTitle(message), title: sessionTitle(message),
context: message, context: message,
expertId: selectedExpert.id,
}, controller.signal) }, controller.signal)
if (!isCurrent()) return if (!isCurrent()) return
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)]) setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
setSelectedSessionID(created.id) setSelectedSessionID(created.id)
setSelectedExpertID(created.expert?.id ?? selectedExpert.id)
setPrompt('') setPrompt('')
onSelectItem(created.title) onSelectItem(created.title)
} catch (requestError) { } catch (requestError) {
@@ -113,27 +159,27 @@ export function ProjectAi({
function selectSession(session: AISessionDTO) { function selectSession(session: AISessionDTO) {
setSelectedSessionID(session.id) setSelectedSessionID(session.id)
setSelectedExpertID(session.expert?.id ?? null)
setError('') setError('')
onSelectItem(session.title) onSelectItem(session.title)
} }
function startNewSession() {
setSelectedSessionID(null)
setSelectedExpertID(null)
setExpertQuery('')
setExpertCategory('all')
setPrompt('')
setError('')
}
return ( return (
<div className="project-channel-page project-ai-page"> <div className="project-channel-page project-ai-page">
{error ? <Alert className="agent-request-error" type="error" content={error} closable onClose={() => setError('')} /> : null} {error ? <Alert className="agent-request-error" type="error" content={error} closable onClose={() => setError('')} /> : null}
<div className="agent-chat-shell"> <div className="agent-chat-shell">
<Card className="agent-session-list queue-section" bordered> <Card className="agent-session-list queue-section" bordered>
<Button <Button className="agent-new-chat" icon={<IconPlus />} onClick={startNewSession}></Button>
className="agent-new-chat"
icon={<IconPlus />}
onClick={() => {
setSelectedSessionID(null)
setPrompt('')
setError('')
}}
>
</Button>
<Spin loading={loading} style={{ width: '100%' }}> <Spin loading={loading} style={{ width: '100%' }}>
{sessions.length ? ( {sessions.length ? (
@@ -147,7 +193,7 @@ export function ProjectAi({
title={session.title} title={session.title}
onClick={() => selectSession(session)} onClick={() => selectSession(session)}
> >
<IconRobot /> <span className="agent-session-emoji">{session.expert?.emoji || <IconRobot />}</span>
<span>{session.title}</span> <span>{session.title}</span>
</button> </button>
))} ))}
@@ -163,25 +209,36 @@ export function ProjectAi({
<div className="agent-thread"> <div className="agent-thread">
<div className="agent-user-message">{selectedSession.context || selectedSession.title}</div> <div className="agent-user-message">{selectedSession.context || selectedSession.title}</div>
<div className="agent-assistant-message"> <div className="agent-assistant-message">
<span className="agent-assistant-avatar"><IconRobot /></span> <span className="agent-assistant-avatar" style={{ background: selectedExpert?.color || undefined }}>
{selectedExpert?.emoji || <IconRobot />}
</span>
<div> <div>
<Text className="agent-assistant-name">AI</Text> <Text className="agent-assistant-name">{selectedExpert?.name || '森林AI'}</Text>
<p></p> <p></p>
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
<div className="agent-empty-state"> <ExpertPicker
<span className="agent-empty-icon"><IconRobot /></span> experts={visibleExperts}
<Text></Text> categories={expertCategories}
</div> selectedExpertID={selectedExpertID}
query={expertQuery}
category={expertCategory}
loading={loadingExperts}
onQueryChange={setExpertQuery}
onCategoryChange={setExpertCategory}
onSelect={setSelectedExpertID}
/>
)} )}
</div> </div>
<div className="agent-composer"> <div className="agent-composer">
<Input.TextArea <Input.TextArea
value={prompt} value={prompt}
placeholder={`给森林AI发送消息结合“${activeWorkspace.project.name}”项目上下文`} placeholder={selectedExpert
? `${selectedExpert.name}发送消息,结合“${activeWorkspace.project.name}”项目上下文`
: '请先从上方选择一位专家'}
autoSize={{ minRows: 2, maxRows: 7 }} autoSize={{ minRows: 2, maxRows: 7 }}
onChange={setPrompt} onChange={setPrompt}
onPressEnter={(event) => { onPressEnter={(event) => {
@@ -192,7 +249,9 @@ export function ProjectAi({
}} }}
/> />
<div className="agent-composer-footer"> <div className="agent-composer-footer">
<Button className="agent-model-chip" icon={<IconRobot />}></Button> <Button className="agent-model-chip" icon={<span>{selectedExpert?.emoji || <IconRobot />}</span>}>
{selectedExpert?.name || '选择专家'}
</Button>
<div className="agent-composer-actions"> <div className="agent-composer-actions">
<Button aria-label="添加附件" type="text" shape="circle" icon={<IconAttachment />} /> <Button aria-label="添加附件" type="text" shape="circle" icon={<IconAttachment />} />
<Button <Button
@@ -202,7 +261,7 @@ export function ProjectAi({
shape="circle" shape="circle"
icon={<IconArrowUp />} icon={<IconArrowUp />}
loading={creating} loading={creating}
disabled={!prompt.trim()} disabled={!selectedExpert || !prompt.trim()}
onClick={() => void createSession()} onClick={() => void createSession()}
/> />
</div> </div>
@@ -215,6 +274,72 @@ export function ProjectAi({
) )
} }
function ExpertPicker({
experts,
categories,
selectedExpertID,
query,
category,
loading,
onQueryChange,
onCategoryChange,
onSelect,
}: {
experts: AIExpertDTO[]
categories: Array<{ id: string; name: string }>
selectedExpertID: string | null
query: string
category: string
loading: boolean
onQueryChange: (value: string) => void
onCategoryChange: (value: string) => void
onSelect: (value: string) => void
}) {
return (
<div className="agent-expert-picker">
<div className="agent-expert-heading">
<span className="agent-empty-icon"><IconRobot /></span>
<strong></strong>
<Text type="secondary"></Text>
</div>
<Input
className="agent-expert-search"
prefix={<IconSearch />}
value={query}
onChange={onQueryChange}
placeholder="搜索专家名称、简介或分类"
allowClear
/>
<div className="agent-expert-categories">
<button className={category === 'all' ? 'active' : ''} onClick={() => onCategoryChange('all')}></button>
{categories.map((item) => (
<button className={category === item.id ? 'active' : ''} key={item.id} onClick={() => onCategoryChange(item.id)}>{item.name}</button>
))}
</div>
<Spin className="agent-expert-loading" loading={loading}>
{experts.length ? (
<div className="agent-expert-grid">
{experts.map((expert) => (
<button
className={selectedExpertID === expert.id ? 'agent-expert-card active' : 'agent-expert-card'}
key={expert.id}
onClick={() => onSelect(expert.id)}
>
<span className="agent-expert-avatar" style={{ background: expert.color }}>{expert.emoji || expert.name.slice(0, 1)}</span>
<span className="agent-expert-copy">
<small>{expert.categoryName}</small>
<strong>{expert.name}</strong>
<span>{expert.description}</span>
</span>
</button>
))}
</div>
) : loading ? null : <Empty description="没有匹配的专家,换个关键词试试" />}
</Spin>
</div>
)
}
function sessionTitle(message: string) { function sessionTitle(message: string) {
const firstLine = message.split(/\r?\n/, 1)[0].trim() const firstLine = message.split(/\r?\n/, 1)[0].trim()
const characters = Array.from(firstLine) const characters = Array.from(firstLine)

View File

@@ -8,7 +8,7 @@ import type { ProjectTaskUpdate } from './project-task-edit-modal'
import { ProjectTasks } from './project-tasks' import { ProjectTasks } from './project-tasks'
import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types' import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
import type { InboxSuggestionDTO } from '../../api/inbox' import type { InboxSuggestionDTO } from '../../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai' import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
export function ProjectChannelPage({ export function ProjectChannelPage({
activeChannel, activeChannel,
@@ -25,6 +25,7 @@ export function ProjectChannelPage({
onAnalyzeInbox, onAnalyzeInbox,
onConfirmInbox, onConfirmInbox,
onListAISessions, onListAISessions,
onListAIExperts,
onCreateAISession, onCreateAISession,
}: { }: {
activeChannel: ChannelKey activeChannel: ChannelKey
@@ -41,6 +42,7 @@ export function ProjectChannelPage({
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]> onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome> onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]> onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onListAIExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO> onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) { }) {
switch (activeChannel) { switch (activeChannel) {
@@ -49,7 +51,7 @@ export function ProjectChannelPage({
case 'tasks': case 'tasks':
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} /> return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
case 'ai': case 'ai':
return <ProjectAi key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onCreateSession={onCreateAISession} /> return <ProjectAi key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onListExperts={onListAIExperts} onCreateSession={onCreateAISession} />
case 'notes': case 'notes':
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} /> return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
case 'cron': case 'cron':

View File

@@ -9,7 +9,7 @@ import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar' import { ProjectTopbar } from './projects/project-topbar'
import type { SearchResultDTO } from '../api/search' import type { SearchResultDTO } from '../api/search'
import type { InboxSuggestionDTO } from '../api/inbox' import type { InboxSuggestionDTO } from '../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../api/ai' import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../api/ai'
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types' import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
const { Content } = Layout const { Content } = Layout
@@ -46,6 +46,7 @@ export function ProjectPage({
onAnalyzeInbox, onAnalyzeInbox,
onConfirmInbox, onConfirmInbox,
onListAISessions, onListAISessions,
onListAIExperts,
onCreateAISession, onCreateAISession,
}: { }: {
activeView: WorkbenchView activeView: WorkbenchView
@@ -79,6 +80,7 @@ export function ProjectPage({
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]> onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome> onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]> onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onListAIExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO> onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) { }) {
const isProject = activeView === 'project' const isProject = activeView === 'project'
@@ -152,6 +154,7 @@ export function ProjectPage({
onAnalyzeInbox={onAnalyzeInbox} onAnalyzeInbox={onAnalyzeInbox}
onConfirmInbox={onConfirmInbox} onConfirmInbox={onConfirmInbox}
onListAISessions={onListAISessions} onListAISessions={onListAISessions}
onListAIExperts={onListAIExperts}
onCreateAISession={onCreateAISession} onCreateAISession={onCreateAISession}
/> />
)} )}

View File

@@ -0,0 +1,62 @@
package ai
import (
"errors"
"strings"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
// ExpertFilter 是本地专家库的查询条件。
type ExpertFilter struct {
Category string
Query string
}
func (s *SessionService) ListExperts(filter ExpertFilter) ([]models.SenlinAgentAIExpert, error) {
query := models.DBService.Model(&models.SenlinAgentAIExpert{}).Where("enabled = ?", true)
if category := strings.TrimSpace(filter.Category); category != "" {
query = query.Where("category = ?", category)
}
if keyword := strings.ToLower(strings.TrimSpace(filter.Query)); keyword != "" {
like := "%" + keyword + "%"
query = query.Where(
"LOWER(name) LIKE ? OR LOWER(description) LIKE ? OR LOWER(category_name) LIKE ?",
like, like, like,
)
}
var experts []models.SenlinAgentAIExpert
if err := query.Order("category asc, id asc").Find(&experts).Error; err != nil {
return nil, err
}
if experts == nil {
experts = []models.SenlinAgentAIExpert{}
}
return experts, nil
}
func (s *SessionService) GetExpert(identity string) (*models.SenlinAgentAIExpert, error) {
var expert models.SenlinAgentAIExpert
if err := models.DBService.Where("identity = ? AND enabled = ?", strings.TrimSpace(identity), true).First(&expert).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrExpertNotFound
}
return nil, err
}
return &expert, nil
}
func findExpertByIdentity(tx *gorm.DB, identity string) (*models.SenlinAgentAIExpert, error) {
if strings.TrimSpace(identity) == "" {
return nil, nil
}
var expert models.SenlinAgentAIExpert
if err := tx.Where("identity = ? AND enabled = ?", identity, true).First(&expert).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrExpertNotFound
}
return nil, err
}
return &expert, nil
}

View File

@@ -20,6 +20,7 @@ type SessionDTO struct {
Title string `json:"title"` Title string `json:"title"`
Context string `json:"context"` Context string `json:"context"`
Status string `json:"status"` Status string `json:"status"`
Expert *ExpertSummaryDTO `json:"expert"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
@@ -27,6 +28,25 @@ type SessionDTO struct {
type createSessionRequest struct { type createSessionRequest struct {
Title string `json:"title"` Title string `json:"title"`
Context string `json:"context"` Context string `json:"context"`
ExpertID string `json:"expertId"`
}
type ExpertSummaryDTO struct {
ID string `json:"id"`
Slug string `json:"slug"`
Category string `json:"category"`
CategoryName string `json:"categoryName"`
Name string `json:"name"`
Description string `json:"description"`
Emoji string `json:"emoji"`
Color string `json:"color"`
}
type ExpertDetailDTO struct {
ExpertSummaryDTO
SystemPrompt string `json:"systemPrompt"`
Source string `json:"source"`
SourceLicense string `json:"sourceLicense"`
} }
// Handler 注册受认证、受项目所有权保护的 AI 会话接口。 // Handler 注册受认证、受项目所有权保护的 AI 会话接口。
@@ -39,6 +59,8 @@ func NewHandler(service *SessionService) *Handler {
} }
func (h *Handler) Register(router gin.IRouter) { func (h *Handler) Register(router gin.IRouter) {
router.GET("/ai-experts", h.listExperts)
router.GET("/ai-experts/:id", h.getExpert)
router.GET("/projects/:id/ai-sessions", h.list) router.GET("/projects/:id/ai-sessions", h.list)
router.POST("/projects/:id/ai-sessions", h.create) router.POST("/projects/:id/ai-sessions", h.create)
} }
@@ -70,7 +92,7 @@ func (h *Handler) create(c *gin.Context) {
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效") httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效")
return return
} }
session, err := h.service.Create(userID, projectIdentity, input.Title, input.Context) session, err := h.service.CreateWithExpert(userID, projectIdentity, input.Title, input.Context, input.ExpertID)
if err != nil { if err != nil {
writeAIError(c, err) writeAIError(c, err)
return return
@@ -78,6 +100,35 @@ func (h *Handler) create(c *gin.Context) {
c.JSON(http.StatusCreated, sessionDTO(*session)) c.JSON(http.StatusCreated, sessionDTO(*session))
} }
func (h *Handler) listExperts(c *gin.Context) {
experts, err := h.service.ListExperts(ExpertFilter{Category: c.Query("category"), Query: c.Query("q")})
if err != nil {
writeAIError(c, err)
return
}
items := make([]ExpertSummaryDTO, 0, len(experts))
for _, expert := range experts {
items = append(items, expertSummaryDTO(expert))
}
c.JSON(http.StatusOK, items)
}
func (h *Handler) getExpert(c *gin.Context) {
identity, ok := httpx.IdentityParam(c, "id")
if !ok {
return
}
expert, err := h.service.GetExpert(identity)
if err != nil {
writeAIError(c, err)
return
}
c.JSON(http.StatusOK, ExpertDetailDTO{
ExpertSummaryDTO: expertSummaryDTO(*expert),
SystemPrompt: expert.SystemPrompt, Source: expert.Source, SourceLicense: expert.SourceLicense,
})
}
func aiRequestContext(c *gin.Context) (uint, string, bool) { func aiRequestContext(c *gin.Context) (uint, string, bool) {
userID, ok := auth.CurrentUserID(c) userID, ok := auth.CurrentUserID(c)
if !ok { if !ok {
@@ -92,7 +143,7 @@ func aiRequestContext(c *gin.Context) (uint, string, bool) {
} }
func sessionDTO(session models.SenlinAgentAISession) SessionDTO { func sessionDTO(session models.SenlinAgentAISession) SessionDTO {
return SessionDTO{ dto := SessionDTO{
ID: session.Identity, ID: session.Identity,
ProjectID: session.ProjectIdentity, ProjectID: session.ProjectIdentity,
Title: session.Title, Title: session.Title,
@@ -101,10 +152,29 @@ func sessionDTO(session models.SenlinAgentAISession) SessionDTO {
CreatedAt: session.CreatedAt.UTC(), CreatedAt: session.CreatedAt.UTC(),
UpdatedAt: session.UpdatedAt.UTC(), UpdatedAt: session.UpdatedAt.UTC(),
} }
if session.Expert != nil {
dto.Expert = expertSummaryPointer(*session.Expert)
}
return dto
}
func expertSummaryPointer(expert models.SenlinAgentAIExpert) *ExpertSummaryDTO {
dto := expertSummaryDTO(expert)
return &dto
}
func expertSummaryDTO(expert models.SenlinAgentAIExpert) ExpertSummaryDTO {
return ExpertSummaryDTO{
ID: expert.Identity, Slug: expert.Slug, Category: expert.Category,
CategoryName: expert.CategoryName, Name: expert.Name, Description: expert.Description,
Emoji: expert.Emoji, Color: expert.Color,
}
} }
func writeAIError(c *gin.Context, err error) { func writeAIError(c *gin.Context, err error) {
switch { switch {
case errors.Is(err, ErrExpertNotFound):
httpx.Error(c, http.StatusNotFound, "expert_not_found", "专家不存在或已停用")
case errors.Is(err, gorm.ErrRecordNotFound): case errors.Is(err, gorm.ErrRecordNotFound):
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在或无权访问") httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在或无权访问")
case errors.Is(err, ErrInvalidSession): case errors.Is(err, ErrInvalidSession):

View File

@@ -50,6 +50,48 @@ func TestAISessionHandlersRequireOwnedProject(t *testing.T) {
require.Empty(t, gateway.steps) require.Empty(t, gateway.steps)
} }
func TestAIExpertLibraryListsDetailsAndCreatesAssociatedSession(t *testing.T) {
database := newAIHandlerTestDB(t)
owner := createAIHandlerUser(t, database, "expert-owner@example.com")
project := createAIHandlerProject(t, database, owner.ID, "EXPERTS")
var expert models.SenlinAgentAIExpert
require.NoError(t, database.Order("id asc").First(&expert).Error)
router := aiHandlerTestRouter(owner.ID, NewGatewayWithSecret("system-key", "test-encryption-secret"))
listRecorder := httptest.NewRecorder()
router.ServeHTTP(listRecorder, authenticatedAIRequest(t, http.MethodGet, "/api/v1/ai-experts", nil))
require.Equal(t, http.StatusOK, listRecorder.Code)
var listPayload []map[string]any
require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload))
require.Len(t, listPayload, 267)
require.Equal(t, expert.Identity, listPayload[0]["id"])
require.NotContains(t, listPayload[0], "systemPrompt")
detailRecorder := httptest.NewRecorder()
router.ServeHTTP(detailRecorder, authenticatedAIRequest(t, http.MethodGet, "/api/v1/ai-experts/"+expert.Identity, nil))
require.Equal(t, http.StatusOK, detailRecorder.Code)
var detailPayload map[string]any
require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
require.Equal(t, expert.Name, detailPayload["name"])
require.NotEmpty(t, detailPayload["systemPrompt"])
require.Equal(t, "MIT", detailPayload["sourceLicense"])
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
"title": "专家会话", "context": "请给出下一步计划", "expertId": expert.Identity,
}))
require.Equal(t, http.StatusCreated, createRecorder.Code)
var createPayload map[string]any
require.NoError(t, json.Unmarshal(createRecorder.Body.Bytes(), &createPayload))
responseExpert := createPayload["expert"].(map[string]any)
require.Equal(t, expert.Identity, responseExpert["id"])
require.Equal(t, expert.Name, responseExpert["name"])
var session models.SenlinAgentAISession
require.NoError(t, database.First(&session).Error)
require.NotNil(t, session.ExpertID)
require.Equal(t, expert.ID, *session.ExpertID)
}
func TestCreateAISessionChecksRateLimitBeforeSelectingProvider(t *testing.T) { func TestCreateAISessionChecksRateLimitBeforeSelectingProvider(t *testing.T) {
database := newAIHandlerTestDB(t) database := newAIHandlerTestDB(t)
owner := createAIHandlerUser(t, database, "owner@example.com") owner := createAIHandlerUser(t, database, "owner@example.com")
@@ -210,7 +252,8 @@ func TestCreateAISessionReturnsIdentityDTOAndCompleteAuditWithoutAutomaticObject
require.Equal(t, http.StatusCreated, recorder.Code) require.Equal(t, http.StatusCreated, recorder.Code)
var payload map[string]any var payload map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload)) require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "createdAt", "updatedAt"}, aiMapKeys(payload)) require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "expert", "createdAt", "updatedAt"}, aiMapKeys(payload))
require.Nil(t, payload["expert"])
identity, err := uuid.Parse(payload["id"].(string)) identity, err := uuid.Parse(payload["id"].(string))
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, uuid.Version(7), identity.Version()) require.Equal(t, uuid.Version(7), identity.Version())
@@ -256,7 +299,8 @@ func TestListAISessionsReturnsOnlyOwnedProjectIdentityDTOs(t *testing.T) {
var payload []map[string]any var payload []map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload)) require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
require.Len(t, payload, 1) require.Len(t, payload, 1)
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "createdAt", "updatedAt"}, aiMapKeys(payload[0])) require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "expert", "createdAt", "updatedAt"}, aiMapKeys(payload[0]))
require.Nil(t, payload[0]["expert"])
require.Equal(t, project.Identity, payload[0]["projectId"]) require.Equal(t, project.Identity, payload[0]["projectId"])
require.Equal(t, "目标会话", payload[0]["title"]) require.Equal(t, "目标会话", payload[0]["title"])
require.Equal(t, "ready", payload[0]["status"]) require.Equal(t, "ready", payload[0]["status"])

View File

@@ -19,6 +19,7 @@ const (
var ( var (
ErrInvalidSession = errors.New("invalid ai session") ErrInvalidSession = errors.New("invalid ai session")
ErrExpertNotFound = errors.New("ai expert not found")
) )
type sessionGateway interface { type sessionGateway interface {
@@ -43,7 +44,7 @@ func (s *SessionService) List(userID uint, projectIdentity string) ([]models.Sen
return nil, err return nil, err
} }
var sessions []models.SenlinAgentAISession var sessions []models.SenlinAgentAISession
if err := models.DBService.Where("project_id = ?", project.ID).Order("updated_at desc, id desc").Find(&sessions).Error; err != nil { if err := models.DBService.Preload("Expert").Where("project_id = ?", project.ID).Order("updated_at desc, id desc").Find(&sessions).Error; err != nil {
return nil, err return nil, err
} }
if sessions == nil { if sessions == nil {
@@ -54,6 +55,11 @@ func (s *SessionService) List(userID uint, projectIdentity string) ([]models.Sen
// Create 先校验项目,再限流,最后才选择 provider/key会话创建不会生成任何正式业务对象。 // Create 先校验项目,再限流,最后才选择 provider/key会话创建不会生成任何正式业务对象。
func (s *SessionService) Create(userID uint, projectIdentity, title, context string) (*models.SenlinAgentAISession, error) { func (s *SessionService) Create(userID uint, projectIdentity, title, context string) (*models.SenlinAgentAISession, error) {
return s.CreateWithExpert(userID, projectIdentity, title, context, "")
}
// CreateWithExpert 创建带本地专家角色的项目会话。
func (s *SessionService) CreateWithExpert(userID uint, projectIdentity, title, context, expertIdentity string) (*models.SenlinAgentAISession, error) {
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
context = strings.TrimSpace(context) context = strings.TrimSpace(context)
if title == "" { if title == "" {
@@ -66,6 +72,10 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
if s.gateway == nil { if s.gateway == nil {
return nil, errors.New("ai gateway is required") return nil, errors.New("ai gateway is required")
} }
expert, err := findExpertByIdentity(models.DBService, expertIdentity)
if err != nil {
return nil, err
}
if err := s.gateway.ReserveRateLimit(userID, aiSessionCreateAction, aiSessionCreateLimit, time.Hour); err != nil { if err := s.gateway.ReserveRateLimit(userID, aiSessionCreateAction, aiSessionCreateLimit, time.Hour); err != nil {
if errors.Is(err, ErrAIRateLimited) { if errors.Is(err, ErrAIRateLimited) {
@@ -100,6 +110,10 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
Context: context, Context: context,
Status: defaultSessionStatus, Status: defaultSessionStatus,
} }
if expert != nil {
session.ExpertID = &expert.ID
session.ExpertIdentity = &expert.Identity
}
failureCode := "session_create_failed" failureCode := "session_create_failed"
err = models.DBService.Transaction(func(tx *gorm.DB) error { err = models.DBService.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&session).Error; err != nil { if err := tx.Create(&session).Error; err != nil {
@@ -119,6 +133,7 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
} }
return nil, err return nil, err
} }
session.Expert = expert
return &session, nil return &session, nil
} }

View File

@@ -0,0 +1,26 @@
package models
import "time"
// SenlinAgentAIExpert 是本地专家库中的可选 AI 角色。
type SenlinAgentAIExpert struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
Slug string `gorm:"size:160;uniqueIndex;not null"`
Category string `gorm:"size:80;index;not null"`
CategoryName string `gorm:"size:120;not null"`
Name string `gorm:"size:160;index;not null"`
Description string `gorm:"type:text;not null"`
Emoji string `gorm:"size:32"`
Color string `gorm:"size:32"`
SystemPrompt string `gorm:"type:text;not null"`
Source string `gorm:"size:255;not null"`
SourceLicense string `gorm:"size:32;not null"`
Enabled bool `gorm:"not null;default:true;index"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (SenlinAgentAIExpert) TableName() string {
return "senlin_agent_ai_experts"
}

View File

@@ -9,6 +9,9 @@ type SenlinAgentAISession struct {
ProjectIdentity string `gorm:"type:char(36);index"` ProjectIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"` CreatedBy uint `gorm:"index;not null"`
CreatedByIdentity string `gorm:"type:char(36);index"` CreatedByIdentity string `gorm:"type:char(36);index"`
ExpertID *uint `gorm:"index"`
ExpertIdentity *string `gorm:"type:char(36);index"`
Expert *SenlinAgentAIExpert `gorm:"foreignKey:ExpertID"`
Title string `gorm:"not null"` Title string `gorm:"not null"`
Context string `gorm:"type:text"` Context string `gorm:"type:text"`
Status string `gorm:"not null;default:ready"` Status string `gorm:"not null;default:ready"`

File diff suppressed because one or more lines are too long

View File

@@ -90,7 +90,14 @@ func (m *SenlinAgentAISession) BeforeCreate(tx *gorm.DB) error {
if err := resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity); err != nil { if err := resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity); err != nil {
return err return err
} }
return resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity) if err := resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil {
return err
}
return resolveOptionalIdentity(tx, &SenlinAgentAIExpert{}, m.ExpertID, &m.ExpertIdentity)
}
func (m *SenlinAgentAIExpert) BeforeCreate(_ *gorm.DB) error {
return ensureIdentity(&m.Identity)
} }
func (m *SenlinAgentTag) BeforeCreate(tx *gorm.DB) error { func (m *SenlinAgentTag) BeforeCreate(tx *gorm.DB) error {

View File

@@ -1,6 +1,7 @@
package models package models
import ( import (
_ "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
@@ -31,6 +32,7 @@ func runVersionedMigrations(database *gorm.DB) error {
migrations := []versionedMigration{ migrations := []versionedMigration{
{version: 1, name: "normalize_project_identifiers", run: normalizeLegacyProjectIdentifiers}, {version: 1, name: "normalize_project_identifiers", run: normalizeLegacyProjectIdentifiers},
{version: 2, name: "deduplicate_project_tags", run: deduplicateLegacyProjectTags}, {version: 2, name: "deduplicate_project_tags", run: deduplicateLegacyProjectTags},
{version: 3, name: "seed_ai_experts", run: seedAIExperts},
} }
for _, migration := range migrations { for _, migration := range migrations {
// Connection callbacks can provide an initialized Gorm session. Start each // Connection callbacks can provide an initialized Gorm session. Start each
@@ -63,6 +65,48 @@ func runVersionedMigrations(database *gorm.DB) error {
return nil return nil
} }
//go:embed experts_catalog.json
var expertCatalogJSON []byte
type embeddedExpertCatalog struct {
Source string `json:"source"`
License string `json:"license"`
Experts []embeddedExpertCatalogItem `json:"experts"`
}
type embeddedExpertCatalogItem struct {
Slug string `json:"slug"`
Category string `json:"category"`
CategoryName string `json:"categoryName"`
Name string `json:"name"`
Description string `json:"description"`
Emoji string `json:"emoji"`
Color string `json:"color"`
SystemPrompt string `json:"systemPrompt"`
}
func seedAIExperts(tx *gorm.DB) (map[string]int, error) {
var catalog embeddedExpertCatalog
if err := json.Unmarshal(expertCatalogJSON, &catalog); err != nil {
return nil, fmt.Errorf("decode embedded AI expert catalog: %w", err)
}
records := make([]SenlinAgentAIExpert, 0, len(catalog.Experts))
for _, expert := range catalog.Experts {
records = append(records, SenlinAgentAIExpert{
Slug: expert.Slug, Category: expert.Category, CategoryName: expert.CategoryName,
Name: expert.Name, Description: expert.Description, Emoji: expert.Emoji,
Color: expert.Color, SystemPrompt: expert.SystemPrompt, Source: catalog.Source,
SourceLicense: catalog.License, Enabled: true,
})
}
if len(records) > 0 {
if err := tx.CreateInBatches(&records, 25).Error; err != nil {
return nil, err
}
}
return map[string]int{"inserted": len(records)}, nil
}
func normalizeLegacyProjectIdentifiers(tx *gorm.DB) (map[string]int, error) { func normalizeLegacyProjectIdentifiers(tx *gorm.DB) (map[string]int, error) {
var projects []SenlinAgentProject var projects []SenlinAgentProject
if err := tx.Model(&SenlinAgentProject{}).Select("id", "owner_id", "identifier").Order("owner_id asc, id asc").Find(&projects).Error; err != nil { if err := tx.Model(&SenlinAgentProject{}).Select("id", "owner_id", "identifier").Order("owner_id asc, id asc").Find(&projects).Error; err != nil {

View File

@@ -57,14 +57,18 @@ func TestAutoMigrateUpgradesLegacyProjectsAndTagsWithoutLosingAssociations(t *te
var migrationCount int64 var migrationCount int64
require.NoError(t, database.Table("senlin_agent_schema_migrations").Count(&migrationCount).Error) require.NoError(t, database.Table("senlin_agent_schema_migrations").Count(&migrationCount).Error)
require.Equal(t, int64(2), migrationCount) require.Equal(t, int64(3), migrationCount)
var auditDetails []string var auditDetails []string
require.NoError(t, database.Table("senlin_agent_schema_migrations").Order("version asc").Pluck("details", &auditDetails).Error) require.NoError(t, database.Table("senlin_agent_schema_migrations").Order("version asc").Pluck("details", &auditDetails).Error)
require.Contains(t, auditDetails[0], `"updated":3`) require.Contains(t, auditDetails[0], `"updated":3`)
require.Contains(t, auditDetails[1], `"deduplicated":1`) require.Contains(t, auditDetails[1], `"deduplicated":1`)
require.Contains(t, auditDetails[2], `"inserted":267`)
var expertCount int64
require.NoError(t, database.Model(&SenlinAgentAIExpert{}).Count(&expertCount).Error)
require.Equal(t, int64(267), expertCount)
require.NoError(t, AutoMigrate(database), "versioned migrations must be safe to run again") require.NoError(t, AutoMigrate(database), "versioned migrations must be safe to run again")
require.NoError(t, database.Table("senlin_agent_schema_migrations").Count(&migrationCount).Error) require.NoError(t, database.Table("senlin_agent_schema_migrations").Count(&migrationCount).Error)
require.Equal(t, int64(2), migrationCount) require.Equal(t, int64(3), migrationCount)
require.Error(t, database.Exec(`INSERT INTO senlin_agent_projects (owner_id, name, identifier) VALUES (7, 'still duplicate', 'DUP')`).Error) require.Error(t, database.Exec(`INSERT INTO senlin_agent_projects (owner_id, name, identifier) VALUES (7, 'still duplicate', 'DUP')`).Error)
require.Error(t, database.Exec(`INSERT INTO senlin_agent_tags (project_id, name) VALUES (1, 'UI')`).Error) require.Error(t, database.Exec(`INSERT INTO senlin_agent_tags (project_id, name) VALUES (1, 'UI')`).Error)

View File

@@ -32,6 +32,7 @@ func AutoMigrate(database *gorm.DB) error {
&SenlinAgentTask{}, &SenlinAgentTask{},
&SenlinAgentNote{}, &SenlinAgentNote{},
&SenlinAgentSource{}, &SenlinAgentSource{},
&SenlinAgentAIExpert{},
&SenlinAgentAISession{}, &SenlinAgentAISession{},
&SenlinAgentTag{}, &SenlinAgentTag{},
&SenlinAgentProjectChannel{}, &SenlinAgentProjectChannel{},

View File

@@ -0,0 +1,43 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
const sourceRoot = process.argv[2]
const assetPath = process.argv[3]
if (!sourceRoot || !assetPath) {
throw new Error('usage: node backend/scripts/sync-ai-experts.mjs <agency-agents-zh-dir> <experts-asset.js>')
}
const asset = await readFile(assetPath, 'utf8')
const catalogMatch = asset.match(/^const e=JSON\.parse\(('(?:\\.|[^'])*')\)/s)
if (!catalogMatch) throw new Error('Chinese expert catalog was not found in the supplied asset')
const encodedCatalog = Function(`"use strict"; return ${catalogMatch[1]}`)()
const summaries = JSON.parse(encodedCatalog)
const experts = []
for (const summary of summaries) {
const promptPath = path.join(sourceRoot, summary.category, `${summary.id}.md`)
const systemPrompt = await readFile(promptPath, 'utf8')
experts.push({
slug: summary.id,
category: summary.category,
categoryName: summary.categoryName,
name: summary.name,
description: summary.description,
emoji: summary.emoji,
color: summary.color,
systemPrompt,
})
}
const outputPath = path.resolve('backend/internal/models/experts_catalog.json')
await mkdir(path.dirname(outputPath), { recursive: true })
await writeFile(outputPath, `${JSON.stringify({
source: 'https://github.com/jnMetaCode/agency-agents-zh',
reference: 'https://ao.aiolaola.com/experts',
license: 'MIT',
experts,
}, null, 2)}\n`, 'utf8')
console.log(`wrote ${experts.length} experts to ${outputPath}`)

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 Michael Sitarzewski (original English version)
Copyright (c) 2026 jnMetaCode (Chinese translation and localization)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.