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 输出与正式项目内容相互区分,需要用户确认后才能转化为任务、笔记或资料。
内置专家资料基于 [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')
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('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')
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}`)
}
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 errors = []
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) => {
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 (route.request().method() === 'POST') {
const input = route.request().postDataJSON()
const expert = visualExperts.find((item) => item.id === input.expertId)
await route.fulfill({
json: {
id: '019b0000-0000-7000-8000-000000000010',
@@ -145,6 +160,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
title: input.title,
context: input.context,
status: 'ready',
expert,
createdAt: '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: '报价分析',
context: '分析当前方案的报价结构。',
status: 'ready',
expert: visualExperts[1],
createdAt: '2026-07-22T08:30:00Z',
updatedAt: '2026-07-22T08:30:00Z',
}],
})
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: '视觉检查未配置该接口' } } })
})
@@ -334,6 +355,8 @@ for (const channel of [
hasOverviewHead: Boolean(pageNode?.querySelector('.overview-head')),
hasSessionList: Boolean(pageNode?.querySelector('.agent-session-list')),
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)
@@ -350,6 +373,8 @@ for (const channel of [
}
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.getByRole('button', { name: '发送消息' }).click()
await page.waitForSelector('.agent-user-message')
@@ -357,6 +382,7 @@ for (const channel of [
userMessage: document.querySelector('.agent-user-message')?.textContent?.trim() ?? '',
activeSession: document.querySelector('.agent-session.active')?.textContent?.trim() ?? '',
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 })
}
@@ -552,7 +578,10 @@ for (const check of channelPageChecks) {
if (check.label === '新建频道' && (check.inputCount < 4 || !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)}`)
}
}
@@ -570,7 +599,8 @@ if (
!aiSessionCheck ||
aiSessionCheck.userMessage !== '请分析这个项目的下一步计划' ||
!aiSessionCheck.activeSession.includes('请分析这个项目的下一步计划') ||
aiSessionCheck.composerValue !== ''
aiSessionCheck.composerValue !== '' ||
aiSessionCheck.expertName !== '产品经理'
) {
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);
}
.agent-session-emoji {
width: 18px;
flex: 0 0 18px;
display: inline-grid;
place-items: center;
}
.agent-session:hover,
.agent-session.active {
background: color-mix(in srgb, var(--senlin-primary) 8%, var(--senlin-panel));
@@ -1670,6 +1677,148 @@
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 {
align-self: center;
display: grid;
@@ -1819,6 +1968,10 @@
.agent-composer {
width: calc(100% - 32px);
}
.agent-expert-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.reply-box,

View File

@@ -6,13 +6,46 @@ export type AISessionDTO = {
title: string
context: string
status: string
expert: AIExpertDTO | null
createdAt: 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 = {
title: 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) {

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 '../App.css'
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 { mapWorkspace } from '../api/mappers'
import {
@@ -49,6 +49,11 @@ function App() {
return listAISessions(session, projectId, signal)
}, [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) => {
if (!session) return Promise.reject(new Error('未登录'))
return createAISession(session, projectId, input, signal)
@@ -358,6 +363,7 @@ function App() {
onAnalyzeInbox={handleAnalyzeInbox}
onConfirmInbox={handleConfirmInbox}
onListAISessions={handleListAISessions}
onListAIExperts={handleListAIExperts}
onCreateAISession={handleCreateAISession}
/>
) : (

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from '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 type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
import { IconArrowUp, IconAttachment, IconPlus, IconRobot, IconSearch } from '@arco-design/web-react/icon'
import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
import type { ProjectWorkspace } from './project-types'
const { Text } = Typography
@@ -10,17 +10,24 @@ export function ProjectAi({
activeWorkspace,
onSelectItem,
onListSessions,
onListExperts,
onCreateSession,
}: {
activeWorkspace: ProjectWorkspace
onSelectItem: (title: string) => void
onListSessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onListExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
onCreateSession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) {
const [sessions, setSessions] = useState<AISessionDTO[]>([])
const [experts, setExperts] = useState<AIExpertDTO[]>([])
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 [loading, setLoading] = useState(true)
const [loadingExperts, setLoadingExperts] = useState(true)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const projectId = activeWorkspace.project.id
@@ -38,9 +45,14 @@ export function ProjectAi({
listControllerRef.current = controller
createControllerRef.current = null
setSessions([])
setExperts([])
setSelectedSessionID(null)
setSelectedExpertID(null)
setExpertQuery('')
setExpertCategory('all')
setPrompt('')
setLoading(true)
setLoadingExperts(true)
setCreating(false)
setError('')
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
@@ -52,9 +64,20 @@ export function ProjectAi({
.catch((requestError: unknown) => {
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(() => {
if (isCurrent()) {
setLoading(false)
setLoadingExperts(false)
listControllerRef.current = null
}
})
@@ -66,15 +89,36 @@ export function ProjectAi({
createControllerRef.current?.abort()
createControllerRef.current = null
}
}, [onListSessions, projectId])
}, [onListExperts, onListSessions, projectId])
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionID) ?? null,
[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 message = prompt.trim()
if (!selectedExpert) {
setError('请先选择一位专家')
return
}
if (!message) {
setError('请输入会话内容')
return
@@ -94,10 +138,12 @@ export function ProjectAi({
const created = await onCreateSession(projectId, {
title: sessionTitle(message),
context: message,
expertId: selectedExpert.id,
}, controller.signal)
if (!isCurrent()) return
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
setSelectedSessionID(created.id)
setSelectedExpertID(created.expert?.id ?? selectedExpert.id)
setPrompt('')
onSelectItem(created.title)
} catch (requestError) {
@@ -113,27 +159,27 @@ export function ProjectAi({
function selectSession(session: AISessionDTO) {
setSelectedSessionID(session.id)
setSelectedExpertID(session.expert?.id ?? null)
setError('')
onSelectItem(session.title)
}
function startNewSession() {
setSelectedSessionID(null)
setSelectedExpertID(null)
setExpertQuery('')
setExpertCategory('all')
setPrompt('')
setError('')
}
return (
<div className="project-channel-page project-ai-page">
{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>
<Button
className="agent-new-chat"
icon={<IconPlus />}
onClick={() => {
setSelectedSessionID(null)
setPrompt('')
setError('')
}}
>
</Button>
<Button className="agent-new-chat" icon={<IconPlus />} onClick={startNewSession}></Button>
<Spin loading={loading} style={{ width: '100%' }}>
{sessions.length ? (
@@ -147,7 +193,7 @@ export function ProjectAi({
title={session.title}
onClick={() => selectSession(session)}
>
<IconRobot />
<span className="agent-session-emoji">{session.expert?.emoji || <IconRobot />}</span>
<span>{session.title}</span>
</button>
))}
@@ -163,25 +209,36 @@ export function ProjectAi({
<div className="agent-thread">
<div className="agent-user-message">{selectedSession.context || selectedSession.title}</div>
<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>
<Text className="agent-assistant-name">AI</Text>
<Text className="agent-assistant-name">{selectedExpert?.name || '森林AI'}</Text>
<p></p>
</div>
</div>
</div>
) : (
<div className="agent-empty-state">
<span className="agent-empty-icon"><IconRobot /></span>
<Text></Text>
</div>
<ExpertPicker
experts={visibleExperts}
categories={expertCategories}
selectedExpertID={selectedExpertID}
query={expertQuery}
category={expertCategory}
loading={loadingExperts}
onQueryChange={setExpertQuery}
onCategoryChange={setExpertCategory}
onSelect={setSelectedExpertID}
/>
)}
</div>
<div className="agent-composer">
<Input.TextArea
value={prompt}
placeholder={`给森林AI发送消息结合“${activeWorkspace.project.name}”项目上下文`}
placeholder={selectedExpert
? `${selectedExpert.name}发送消息,结合“${activeWorkspace.project.name}”项目上下文`
: '请先从上方选择一位专家'}
autoSize={{ minRows: 2, maxRows: 7 }}
onChange={setPrompt}
onPressEnter={(event) => {
@@ -192,7 +249,9 @@ export function ProjectAi({
}}
/>
<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">
<Button aria-label="添加附件" type="text" shape="circle" icon={<IconAttachment />} />
<Button
@@ -202,7 +261,7 @@ export function ProjectAi({
shape="circle"
icon={<IconArrowUp />}
loading={creating}
disabled={!prompt.trim()}
disabled={!selectedExpert || !prompt.trim()}
onClick={() => void createSession()}
/>
</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) {
const firstLine = message.split(/\r?\n/, 1)[0].trim()
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 type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
import type { InboxSuggestionDTO } from '../../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
export function ProjectChannelPage({
activeChannel,
@@ -25,6 +25,7 @@ export function ProjectChannelPage({
onAnalyzeInbox,
onConfirmInbox,
onListAISessions,
onListAIExperts,
onCreateAISession,
}: {
activeChannel: ChannelKey
@@ -41,6 +42,7 @@ export function ProjectChannelPage({
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onListAIExperts: (signal?: AbortSignal) => Promise<AIExpertDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) {
switch (activeChannel) {
@@ -49,7 +51,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 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':
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
case 'cron':

View File

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

@@ -15,18 +15,38 @@ import (
// 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"`
ID string `json:"id"`
ProjectID string `json:"projectId"`
Title string `json:"title"`
Context string `json:"context"`
Status string `json:"status"`
Expert *ExpertSummaryDTO `json:"expert"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type createSessionRequest struct {
Title string `json:"title"`
Context string `json:"context"`
Title string `json:"title"`
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 会话接口。
@@ -39,6 +59,8 @@ func NewHandler(service *SessionService) *Handler {
}
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.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", "请求参数无效")
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 {
writeAIError(c, err)
return
@@ -78,6 +100,35 @@ func (h *Handler) create(c *gin.Context) {
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) {
userID, ok := auth.CurrentUserID(c)
if !ok {
@@ -92,7 +143,7 @@ func aiRequestContext(c *gin.Context) (uint, string, bool) {
}
func sessionDTO(session models.SenlinAgentAISession) SessionDTO {
return SessionDTO{
dto := SessionDTO{
ID: session.Identity,
ProjectID: session.ProjectIdentity,
Title: session.Title,
@@ -101,10 +152,29 @@ func sessionDTO(session models.SenlinAgentAISession) SessionDTO {
CreatedAt: session.CreatedAt.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) {
switch {
case errors.Is(err, ErrExpertNotFound):
httpx.Error(c, http.StatusNotFound, "expert_not_found", "专家不存在或已停用")
case errors.Is(err, gorm.ErrRecordNotFound):
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在或无权访问")
case errors.Is(err, ErrInvalidSession):

View File

@@ -50,6 +50,48 @@ func TestAISessionHandlersRequireOwnedProject(t *testing.T) {
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) {
database := newAIHandlerTestDB(t)
owner := createAIHandlerUser(t, database, "owner@example.com")
@@ -210,7 +252,8 @@ func TestCreateAISessionReturnsIdentityDTOAndCompleteAuditWithoutAutomaticObject
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))
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))
require.NoError(t, err)
require.Equal(t, uuid.Version(7), identity.Version())
@@ -256,7 +299,8 @@ func TestListAISessionsReturnsOnlyOwnedProjectIdentityDTOs(t *testing.T) {
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.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, "目标会话", payload[0]["title"])
require.Equal(t, "ready", payload[0]["status"])

View File

@@ -19,6 +19,7 @@ const (
var (
ErrInvalidSession = errors.New("invalid ai session")
ErrExpertNotFound = errors.New("ai expert not found")
)
type sessionGateway interface {
@@ -43,7 +44,7 @@ func (s *SessionService) List(userID uint, projectIdentity string) ([]models.Sen
return nil, err
}
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
}
if sessions == nil {
@@ -54,6 +55,11 @@ func (s *SessionService) List(userID uint, projectIdentity string) ([]models.Sen
// Create 先校验项目,再限流,最后才选择 provider/key会话创建不会生成任何正式业务对象。
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)
context = strings.TrimSpace(context)
if title == "" {
@@ -66,6 +72,10 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
if s.gateway == nil {
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 errors.Is(err, ErrAIRateLimited) {
@@ -100,6 +110,10 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
Context: context,
Status: defaultSessionStatus,
}
if expert != nil {
session.ExpertID = &expert.ID
session.ExpertIdentity = &expert.Identity
}
failureCode := "session_create_failed"
err = models.DBService.Transaction(func(tx *gorm.DB) error {
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
}
session.Expert = expert
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

@@ -3,15 +3,18 @@ package models
import "time"
type SenlinAgentAISession struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
ProjectID uint `gorm:"index;not null"`
ProjectIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"`
CreatedByIdentity string `gorm:"type:char(36);index"`
Title string `gorm:"not null"`
Context string `gorm:"type:text"`
Status string `gorm:"not null;default:ready"`
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
ProjectID uint `gorm:"index;not null"`
ProjectIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"`
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"`
Context string `gorm:"type:text"`
Status string `gorm:"not null;default:ready"`
CreatedAt time.Time
UpdatedAt time.Time
}

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 {
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 {

View File

@@ -1,6 +1,7 @@
package models
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
@@ -31,6 +32,7 @@ func runVersionedMigrations(database *gorm.DB) error {
migrations := []versionedMigration{
{version: 1, name: "normalize_project_identifiers", run: normalizeLegacyProjectIdentifiers},
{version: 2, name: "deduplicate_project_tags", run: deduplicateLegacyProjectTags},
{version: 3, name: "seed_ai_experts", run: seedAIExperts},
}
for _, migration := range migrations {
// Connection callbacks can provide an initialized Gorm session. Start each
@@ -63,6 +65,48 @@ func runVersionedMigrations(database *gorm.DB) error {
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) {
var projects []SenlinAgentProject
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
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
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[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, 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_tags (project_id, name) VALUES (1, 'UI')`).Error)

View File

@@ -32,6 +32,7 @@ func AutoMigrate(database *gorm.DB) error {
&SenlinAgentTask{},
&SenlinAgentNote{},
&SenlinAgentSource{},
&SenlinAgentAIExpert{},
&SenlinAgentAISession{},
&SenlinAgentTag{},
&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.