feat(ai): add local expert library
This commit is contained in:
@@ -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)。运行时不会依赖外部专家站点。
|
||||||
|
|
||||||
### 笔记与资料管理
|
### 笔记与资料管理
|
||||||
|
|
||||||
|
|||||||
@@ -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([])']) {
|
||||||
|
|||||||
@@ -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)}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
62
backend/internal/logic/ai/experts.go
Normal file
62
backend/internal/logic/ai/experts.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
backend/internal/models/ai_expert.go
Normal file
26
backend/internal/models/ai_expert.go
Normal 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"
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
2677
backend/internal/models/experts_catalog.json
Normal file
2677
backend/internal/models/experts_catalog.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func AutoMigrate(database *gorm.DB) error {
|
|||||||
&SenlinAgentTask{},
|
&SenlinAgentTask{},
|
||||||
&SenlinAgentNote{},
|
&SenlinAgentNote{},
|
||||||
&SenlinAgentSource{},
|
&SenlinAgentSource{},
|
||||||
|
&SenlinAgentAIExpert{},
|
||||||
&SenlinAgentAISession{},
|
&SenlinAgentAISession{},
|
||||||
&SenlinAgentTag{},
|
&SenlinAgentTag{},
|
||||||
&SenlinAgentProjectChannel{},
|
&SenlinAgentProjectChannel{},
|
||||||
|
|||||||
43
backend/scripts/sync-ai-experts.mjs
Normal file
43
backend/scripts/sync-ai-experts.mjs
Normal 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}`)
|
||||||
22
docs/licenses/agency-agents-zh-MIT.txt
Normal file
22
docs/licenses/agency-agents-zh-MIT.txt
Normal 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.
|
||||||
Reference in New Issue
Block a user