feat: init commint

This commit is contained in:
2026-09-18 15:26:19 +08:00
parent 3b61a767a0
commit 9f47dbb5cc
95 changed files with 7712 additions and 1137 deletions

View File

@@ -20,7 +20,7 @@ import { JsonEditor, parseJsonObject } from '@/features/ai-platform/components/J
import { MobileEditGuard } from '@/features/ai-platform/components/MobileEditGuard'
import { SchemaInputEditor } from '@/features/ai-platform/components/SchemaInputEditor'
import { StatusBadge } from '@/features/ai-platform/status'
import { listDeployments } from '@/features/modelhub/api'
import { listDeploymentOptions } from '@/features/modelhub/api'
import type { ModelDeployment } from '@/features/modelhub/types'
import { getSkill, listSkills } from '@/features/skills/api'
import type { SkillVersion } from '@/features/skills/types'
@@ -37,6 +37,36 @@ type EditableStep = {
}
type SkillOption = SkillVersion & { definitionName: string }
const modelModeStrength: Record<ModelDeployment['structured_output_mode'], number> = {
unconfigured: 0,
none: 0,
prompt_schema: 1,
json_object: 2,
native_json_schema: 3,
}
const enforcementStrength: Record<SkillVersion['output_enforcement'], number> = {
validated_json: 1,
json_object: 2,
native_json_schema: 3,
}
function StructuredCompatibility({ skill, deployment }: { skill?: SkillOption; deployment?: ModelDeployment }) {
if (!skill || !deployment) return null
const verified = deployment.structured_output_test_status === 'passed'
const compatible = verified && modelModeStrength[deployment.structured_output_mode] >= enforcementStrength[skill.output_enforcement]
const modeLabel = deployment.structured_output_mode === 'native_json_schema' ? '原生 JSON Schema' : deployment.structured_output_mode === 'json_object' ? 'JSON Object' : deployment.structured_output_mode === 'prompt_schema' ? 'Prompt Schema' : '未配置'
const enforcementLabel = skill.output_enforcement === 'native_json_schema' ? '原生 JSON Schema' : skill.output_enforcement === 'json_object' ? 'JSON Object' : '校验型 JSON'
return <Alert variant={compatible ? 'default' : 'destructive'}>
<AlertTitle>{compatible ? '结构化输出兼容' : '结构化输出不兼容'}</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-1">
<Badge variant="outline">Skill 要求:{enforcementLabel}</Badge>
<Badge variant="outline">模型模式:{modeLabel}</Badge>
<Badge variant={verified ? 'success' : 'destructive'}>{verified ? '模型已验证' : deployment.structured_output_test_status === 'failed' ? '模型验证失败' : '模型未验证'}</Badge>
{!compatible ? <span className="basis-full pt-1">该配置无法发布或运行,请提升模型模式并通过结构化测试,或降低 Skill 的最低保障。</span> : null}
</AlertDescription>
</Alert>
}
export function AgentDetailPage({ agentId }: { agentId: number }) {
const [detail, setDetail] = React.useState<AgentDetail | null>(null)
const [draft, setDraft] = React.useState<AgentVersion | null>(null)
@@ -73,7 +103,7 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
const load = React.useCallback(async () => {
try {
const [item, nextDeployments, definitions] = await Promise.all([getAgent(agentId), listDeployments(), listSkills()])
const [item, nextDeployments, definitions] = await Promise.all([getAgent(agentId), listDeploymentOptions(), listSkills()])
const skillDetails = await Promise.all(definitions.map((skill) => getSkill(skill.id)))
setDetail(item); setMeta({ name: item.name, description: item.description }); setDeployments(nextDeployments)
setSkills(skillDetails.flatMap((skill) => skill.versions.map((version) => ({ ...version, definitionName: skill.name }))))
@@ -106,7 +136,7 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
return <AppPage header={<AppPageHeader title={detail.name} breadcrumbLabel={detail.code} description={`${detail.code} · 有序步骤执行,任一步失败后立即终止。`} meta={detail.active_version ? <Badge variant="success">当前 v{detail.active_version}</Badge> : <Badge variant="secondary">尚未发布</Badge>} actions={<>{draft ? <><Button variant="outline" onClick={() => void save()}><Save data-icon="inline-start" />保存草稿</Button><Button onClick={() => void publish()}><Send data-icon="inline-start" />发布 v{draft.version}</Button></> : <Button onClick={() => void createDraft()}><CopyPlus data-icon="inline-start" />创建新草稿</Button>}</>} />}>
<Tabs defaultValue="basic"><TabsList variant="line" className="max-w-full overflow-x-auto"><TabsTrigger value="basic">基础与模型</TabsTrigger><TabsTrigger value="steps">步骤</TabsTrigger><TabsTrigger value="schema">输入 Schema</TabsTrigger><TabsTrigger value="test">测试</TabsTrigger><TabsTrigger value="versions">版本</TabsTrigger></TabsList>
<TabsContent value="basic" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5"><FieldGroup className="grid gap-3 md:grid-cols-2"><AppField label="名称"><Input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} /></AppField><AppField label="描述"><Input value={meta.description} onChange={(e) => setMeta({ ...meta, description: e.target.value })} /></AppField><AppField label="模型"><Select value={modelId} onValueChange={setModelId}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{deployments.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectContent></Select></AppField><AppField label="Temperature 覆盖" description="留空使用模型默认值"><Input type="number" min="0" max="2" step="0.1" value={temperature} onChange={(e) => setTemperature(e.target.value)} /></AppField><AppField label="最大输出 Token 覆盖" description="留空使用模型默认值"><Input type="number" min="1" max="32768" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} /></AppField></FieldGroup><Card><CardHeader className="flex-row items-start justify-between gap-4"><div className="grid gap-1"><CardTitle className="text-sm">允许自动解决工单</CardTitle><CardDescription>授予已发布版本执行 ticket.resolve 的能力。实际执行仍需同时通过全局开关、发起人权限、方案范围、风险和状态机检查。</CardDescription></div><Switch checked={allowTicketResolve} onCheckedChange={setAllowTicketResolve} aria-label="允许自动解决工单" /></CardHeader></Card><AppField label="系统 Prompt"><Textarea className="min-h-40" value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} /></AppField></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="steps" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-3">{steps.map((step, index) => <Card key={`${index}-${step.code}`}><CardHeader className="flex-row items-center justify-between"><div><CardTitle className="text-sm">{index + 1}. {step.code}</CardTitle><CardDescription>输入映射后传递给 Skill;模型未指定时继承 Agent 默认值</CardDescription></div><div className="flex gap-1"><Button variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => moveStep(index, -1)}><ArrowUp /><span className="sr-only">上移</span></Button><Button variant="ghost" size="icon-sm" disabled={index === steps.length - 1} onClick={() => moveStep(index, 1)}><ArrowDown /><span className="sr-only">下移</span></Button><Button variant="ghost" size="icon-sm" disabled={steps.length === 1} onClick={() => setSteps((current) => current.filter((_, i) => i !== index))}><Trash2 /><span className="sr-only">删除</span></Button></div></CardHeader><CardContent className="grid gap-3 md:grid-cols-2"><AppField label="步骤代码"><Input value={step.code} onChange={(e) => updateStep(index, { code: e.target.value })} /></AppField><AppField label="Skill 版本"><Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version}({item.status})</SelectItem>)}</SelectContent></Select></AppField><AppField label="步骤模型" description="仅文本与视觉模型可用于 Agent 步骤"><Select value={step.model_deployment_id || 'inherit'} onValueChange={(value) => updateStep(index, { model_deployment_id: value === 'inherit' ? '' : value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="inherit">继承默认模型 · {deployments.find((item) => String(item.id) === modelId)?.display_name ?? draft.model_display_name}</SelectItem>{deployments.filter((item) => ['text_chat', 'vision_chat'].includes(item.capability)).map((item) => <SelectItem key={item.id} value={String(item.id)} disabled={!item.enabled}>{item.display_name} · {item.capability}{item.enabled ? '' : '(已停用)'}</SelectItem>)}</SelectContent></Select></AppField><div className="md:col-span-2"><JsonEditor id={`agent-step-mapping-${index}`} label="输入映射" value={step.mapping} onChange={(value) => updateStep(index, { mapping: value })} rows={6} /></div></CardContent></Card>)}<Button variant="outline" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), model_deployment_id: '', mapping: '{}' }])}><Plus data-icon="inline-start" />添加步骤</Button></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="steps" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-3">{steps.map((step, index) => <Card key={`${index}-${step.code}`}><CardHeader className="flex-row items-center justify-between"><div><CardTitle className="text-sm">{index + 1}. {step.code}</CardTitle><CardDescription>输入映射后传递给 Skill;模型未指定时继承 Agent 默认值</CardDescription></div><div className="flex gap-1"><Button variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => moveStep(index, -1)}><ArrowUp /><span className="sr-only">上移</span></Button><Button variant="ghost" size="icon-sm" disabled={index === steps.length - 1} onClick={() => moveStep(index, 1)}><ArrowDown /><span className="sr-only">下移</span></Button><Button variant="ghost" size="icon-sm" disabled={steps.length === 1} onClick={() => setSteps((current) => current.filter((_, i) => i !== index))}><Trash2 /><span className="sr-only">删除</span></Button></div></CardHeader><CardContent className="grid gap-3 md:grid-cols-2"><AppField label="步骤代码"><Input value={step.code} onChange={(e) => updateStep(index, { code: e.target.value })} /></AppField><AppField label="Skill 版本"><Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version} · {item.output_enforcement === 'native_json_schema' ? '原生 Schema' : item.output_enforcement === 'json_object' ? 'JSON Object' : '校验型 JSON'}({item.status})</SelectItem>)}</SelectContent></Select></AppField><AppField label="步骤模型" description="仅文本与视觉模型可用于 Agent 步骤"><Select value={step.model_deployment_id || 'inherit'} onValueChange={(value) => updateStep(index, { model_deployment_id: value === 'inherit' ? '' : value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="inherit">继承默认模型 · {deployments.find((item) => String(item.id) === modelId)?.display_name ?? draft.model_display_name}</SelectItem>{deployments.filter((item) => ['text_chat', 'vision_chat'].includes(item.capability)).map((item) => <SelectItem key={item.id} value={String(item.id)} disabled={!item.enabled}>{item.display_name} · {item.structured_output_mode} · {item.structured_output_test_status === 'passed' ? '已验证' : '未验证'}{item.enabled ? '' : '(已停用)'}</SelectItem>)}</SelectContent></Select></AppField><div className="md:col-span-2"><StructuredCompatibility skill={skills.find((item) => String(item.id) === step.skill_version_id)} deployment={deployments.find((item) => String(item.id) === (step.model_deployment_id || modelId))} /></div><div className="md:col-span-2"><JsonEditor id={`agent-step-mapping-${index}`} label="输入映射" value={step.mapping} onChange={(value) => updateStep(index, { mapping: value })} rows={6} /></div></CardContent></Card>)}<Button variant="outline" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), model_deployment_id: '', mapping: '{}' }])}><Plus data-icon="inline-start" />添加步骤</Button></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="schema" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5 xl:grid-cols-2"><JsonEditor id="agent-input-schema" label="Agent 输入 JSON Schema" value={inputSchema} onChange={setInputSchema} /><JsonEditor id="agent-example-input" label="示例输入" value={exampleInput} onChange={setExampleInput} rows={10} /></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="test" className="grid gap-4 pt-4"><Alert><Play /><AlertTitle>异步沙箱与发布版本测试</AlertTitle><AlertDescription>草稿运行会标记为沙箱;正式运行只能选择已发布版本。启动后跳转到运行轨迹并自动轮询。</AlertDescription></Alert><AppField label="运行版本"><Select value={testVersion} onValueChange={(value) => { setTestVersion(value); const selected = value === 'draft' ? draft : detail.versions.find((item) => String(item.version) === value); if (selected) setTestInput(JSON.stringify(selected.example_input, null, 2)) }}><SelectTrigger className="w-full md:w-80"><SelectValue /></SelectTrigger><SelectContent>{draft ? <SelectItem value="draft">草稿 v{draft.version}(沙箱)</SelectItem> : null}{detail.versions.filter((item) => item.status === 'published').map((item) => <SelectItem key={item.id} value={String(item.version)}>已发布 v{item.version}</SelectItem>)}</SelectContent></Select></AppField><SchemaInputEditor schema={(testVersion === 'draft' ? draft?.input_schema : detail.versions.find((item) => String(item.version) === testVersion)?.input_schema) ?? {}} value={testInput} onChange={setTestInput} /><div><Button onClick={() => void run()} disabled={!testVersion}><Play data-icon="inline-start" />启动异步运行</Button></div></TabsContent>
<TabsContent value="versions" className="grid gap-3 pt-4">{detail.versions.map((version) => <Card key={version.id}><CardHeader className="flex-row items-center justify-between"><div><CardTitle>版本 v{version.version}</CardTitle><CardDescription>{version.model_display_name} · {version.steps.length} 个步骤 · {version.steps.filter((step) => step.model_deployment_id !== null).length} 个独立模型绑定 · {version.published_at ? formatDateTime(version.published_at) : formatDateTime(version.updated_at)}</CardDescription></div><div className="flex items-center gap-2"><StatusBadge status={version.status} />{version.status === 'published' && detail.active_version !== version.version ? <Button variant="outline" size="sm" onClick={() => void rollback(version.version)}>回滚至此版本</Button> : null}</div></CardHeader></Card>)}</TabsContent>

View File

@@ -15,7 +15,7 @@ import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { listDeployments } from '@/features/modelhub/api'
import { listDeploymentOptions } from '@/features/modelhub/api'
import type { ModelDeployment } from '@/features/modelhub/types'
import { getSkill, listSkills } from '@/features/skills/api'
import type { SkillVersion } from '@/features/skills/types'
@@ -48,7 +48,7 @@ export function AgentListPage() {
const load = React.useCallback(async () => {
try {
const [nextAgents, nextDeployments, definitions] = await Promise.all([listAgents(), listDeployments(), listSkills()])
const [nextAgents, nextDeployments, definitions] = await Promise.all([listAgents(), listDeploymentOptions(), listSkills()])
const details = await Promise.all(definitions.map((item) => getSkill(item.id)))
const options = details.flatMap((item) => item.versions.map((version) => ({ ...version, definitionName: item.name })))
setItems(nextAgents)

View File

@@ -49,7 +49,7 @@ export function AgentRunDetailPage({ publicId }: { publicId: string }) {
{run.error_message ? <Alert variant="destructive"><AlertTitle>{run.error_code || '运行失败'}</AlertTitle><AlertDescription>{run.error_message}</AlertDescription></Alert> : null}
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)_300px]">
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">执行脊柱</CardTitle><CardDescription>{run.steps.length}/{run.total_steps} 个步骤已创建</CardDescription></CardHeader><CardContent className="max-h-[640px] overflow-auto p-2"><ol className="grid gap-1">{run.steps.map((step) => <li key={step.id}><button type="button" onClick={() => setSelectedStepId(step.id)} className={cn('grid w-full grid-cols-[28px_minmax(0,1fr)] gap-2 rounded-md p-2 text-left transition-colors hover:bg-muted', selectedStepId === step.id && 'bg-muted')}><span className={cn('flex size-7 items-center justify-center rounded-full border text-xs font-medium', step.status === 'succeeded' && 'border-success/40 bg-success/10 text-success', step.status === 'failed' && 'border-destructive/40 bg-destructive/10 text-destructive')}>{step.order}</span><span className="min-w-0"><span className="block truncate font-medium">{step.name}</span><span className="mt-1 flex items-center justify-between gap-2"><span className="font-mono text-xs text-muted-foreground">{step.code}</span><StatusBadge status={step.status} /></span></span></button>{step.order < run.steps.length ? <span className="ml-[21px] block h-3 border-l border-border" /> : null}</li>)}</ol></CardContent></Card>
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">{selected ? `${selected.order}. ${selected.name}` : '运行输入与最终输出'}</CardTitle><CardDescription>{selected ? `${selected.code} · ${selected.model_display_name}(${selected.model_capability})` : '尚未生成步骤记录'}</CardDescription></CardHeader><CardContent className="p-4"><Tabs defaultValue="input"><TabsList><TabsTrigger value="input">输入</TabsTrigger><TabsTrigger value="output">输出</TabsTrigger><TabsTrigger value="attempts">调用尝试</TabsTrigger></TabsList><TabsContent value="input" className="pt-3"><JsonBlock value={selected?.input_payload ?? run.input_payload} /></TabsContent><TabsContent value="output" className="pt-3"><JsonBlock value={selected?.output_payload ?? run.final_output} /></TabsContent><TabsContent value="attempts" className="grid gap-3 pt-3">{selected?.attempts.length ? selected.attempts.map((attempt) => <Card key={attempt.id}><CardHeader><div className="flex items-center justify-between"><CardTitle className="text-sm">{attempt.attempt_type}</CardTitle><StatusBadge status={attempt.status} /></div><CardDescription>{attempt.input_tokens + attempt.output_tokens} tokens · {attempt.duration_ms} ms</CardDescription></CardHeader><CardContent className="grid gap-2">{attempt.validation_error ? <Alert variant="destructive"><AlertTitle>结构校验错误</AlertTitle><AlertDescription>{attempt.validation_error}</AlertDescription></Alert> : null}<JsonBlock value={attempt.raw_output} /></CardContent></Card>) : <AppEmptyState title="暂无调用尝试" description="排队步骤或尚未开始的步骤不会产生调用记录。" />}</TabsContent></Tabs></CardContent></Card>
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">{selected ? `${selected.order}. ${selected.name}` : '运行输入与最终输出'}</CardTitle><CardDescription>{selected ? `${selected.code} · ${selected.model_display_name}(${selected.model_capability})` : '尚未生成步骤记录'}</CardDescription></CardHeader><CardContent className="p-4"><Tabs defaultValue="input"><TabsList><TabsTrigger value="input">输入</TabsTrigger><TabsTrigger value="output">输出</TabsTrigger><TabsTrigger value="attempts">调用尝试</TabsTrigger></TabsList><TabsContent value="input" className="pt-3"><JsonBlock value={selected?.input_payload ?? run.input_payload} /></TabsContent><TabsContent value="output" className="pt-3"><JsonBlock value={selected?.output_payload ?? run.final_output} /></TabsContent><TabsContent value="attempts" className="grid gap-3 pt-3">{selected?.attempts.length ? selected.attempts.map((attempt, index) => <Card key={attempt.id}><CardHeader><div className="flex items-center justify-between gap-3"><CardTitle className="text-sm">{attempt.attempt_type === 'structure_repair' ? '结构修复' : attempt.attempt_type === 'transient_retry' ? '瞬时重试' : '首次调用'}</CardTitle><StatusBadge status={attempt.status} /></div><CardDescription>{attempt.input_tokens + attempt.output_tokens} tokens · {attempt.duration_ms} ms</CardDescription><div className="flex flex-wrap gap-1"><Badge variant="outline">模式:{attempt.structured_output.mode || '未记录'}</Badge><Badge variant={attempt.structured_output.schema_hash ? 'success' : 'destructive'}>{attempt.structured_output.schema_hash ? '已收到 Schema' : '未收到 Schema'}</Badge><Badge variant={attempt.structured_output.response_format_included ? 'success' : 'secondary'}>{attempt.structured_output.response_format_included ? '已传 response_format' : '未传 response_format'}</Badge>{attempt.structured_output.schema_hash ? <Badge variant="secondary" title={attempt.structured_output.schema_hash}>Schema {attempt.structured_output.schema_hash.slice(0, 12)}</Badge> : null}</div></CardHeader><CardContent className="grid gap-2">{attempt.attempt_type === 'structure_repair' ? <Alert><AlertTitle>进入修复的原因</AlertTitle><AlertDescription>{selected.attempts[index - 1]?.validation_error || '首次输出未通过 JSON 解析或 Schema 校验。'}</AlertDescription></Alert> : null}{attempt.validation_error ? <Alert variant="destructive"><AlertTitle>结构校验错误</AlertTitle><AlertDescription>{attempt.validation_error}</AlertDescription></Alert> : null}<JsonBlock value={attempt.raw_output} /></CardContent></Card>) : <AppEmptyState title="暂无调用尝试" description="排队步骤或尚未开始的步骤不会产生调用记录。" />}</TabsContent></Tabs></CardContent></Card>
<div className="grid content-start gap-4"><Card><CardHeader><CardTitle className="text-sm">运行指标</CardTitle></CardHeader><CardContent className="grid gap-3 text-sm"><Metric icon={Coins} label="输入 Token" value={String(run.input_tokens)} /><Metric icon={Coins} label="输出 Token" value={String(run.output_tokens)} /><Metric icon={Clock3} label="总耗时" value={`${run.duration_ms} ms`} />{selected ? <><Separator /><Metric icon={Coins} label="步骤 Token" value={String(selected.input_tokens + selected.output_tokens)} /><Metric icon={Clock3} label="步骤耗时" value={`${selected.duration_ms} ms`} /></> : null}</CardContent></Card><Card><CardHeader><CardTitle className="text-sm">配置快照</CardTitle><CardDescription>历史记录不受后续配置修改影响</CardDescription></CardHeader><CardContent><JsonBlock value={run.config_snapshot} /></CardContent></Card>{run.final_output !== null ? <Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><Braces className="size-4" />最终结构化输出</CardTitle></CardHeader><CardContent><JsonBlock value={run.final_output} /></CardContent></Card> : null}</div>
</div>
</AppPage>

View File

@@ -0,0 +1,69 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentRunListPage } from './AgentRunListPage'
const mocks = vi.hoisted(() => ({
listAgentRuns: vi.fn()
}))
vi.mock('./api', () => ({
listAgentRuns: mocks.listAgentRuns
}))
const run = {
public_id: '11111111-1111-1111-1111-111111111111',
agent_id: 1,
agent_name: '技术支持分析 Agent',
agent_version: 1,
status: 'succeeded',
sandbox: false,
current_step: 'reply',
progress_stage: 'completed',
progress_message: '运行完成',
retry_count: 0,
total_steps: 2,
input_tokens: 10,
output_tokens: 5,
duration_ms: 120,
error_code: '',
error_message: '',
created_at: '2026-07-28T04:00:00Z',
started_at: '2026-07-28T04:00:00Z',
finished_at: '2026-07-28T04:00:01Z'
}
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('AgentRunListPage', () => {
it('按页加载运行记录并支持翻页', async () => {
mocks.listAgentRuns
.mockResolvedValueOnce({
items: [run],
page: 1,
page_size: 20,
total: 21,
total_pages: 2
})
.mockResolvedValueOnce({
items: [{ ...run, public_id: '22222222-2222-2222-2222-222222222222' }],
page: 2,
page_size: 20,
total: 21,
total_pages: 2
})
render(<AgentRunListPage />)
expect(await screen.findByText('共 21 条,当前第 1 / 2 页')).toBeInTheDocument()
expect(mocks.listAgentRuns).toHaveBeenCalledWith({ page: 1, pageSize: 20 })
fireEvent.click(screen.getByRole('link', { name: '下一页' }))
await waitFor(() => expect(mocks.listAgentRuns).toHaveBeenLastCalledWith({ page: 2, pageSize: 20 }))
expect(await screen.findByText('共 21 条,当前第 2 / 2 页')).toBeInTheDocument()
})
})

View File

@@ -7,17 +7,83 @@ import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { StatusBadge } from '@/features/ai-platform/status'
import { formatDateTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { listAgentRuns } from './api'
import type { AgentRunSummary } from './types'
import type { AgentRunListResponse, AgentRunSummary } from './types'
const DEFAULT_PAGE_SIZE = 20
function getVisiblePages(currentPage: number, totalPages: number) {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1)
const pages: Array<number | 'left-ellipsis' | 'right-ellipsis'> = [1]
const start = Math.max(2, currentPage - 1)
const end = Math.min(totalPages - 1, currentPage + 1)
if (start > 2) pages.push('left-ellipsis')
for (let page = start; page <= end; page += 1) pages.push(page)
if (end < totalPages - 1) pages.push('right-ellipsis')
pages.push(totalPages)
return pages
}
export function AgentRunListPage() {
const [items, setItems] = React.useState<AgentRunSummary[]>([])
const [page, setPage] = React.useState(1)
const [pageSize, setPageSize] = React.useState(DEFAULT_PAGE_SIZE)
const [result, setResult] = React.useState<AgentRunListResponse>({
items: [],
page: 1,
page_size: DEFAULT_PAGE_SIZE,
total: 0,
total_pages: 0
})
const [error, setError] = React.useState('')
const load = React.useCallback(async () => { try { setItems(await listAgentRuns()); setError('') } catch (reason) { setError(reason instanceof Error ? reason.message : '加载运行记录失败') } }, [])
const [loading, setLoading] = React.useState(false)
const requestSequence = React.useRef(0)
const load = React.useCallback(async () => {
const sequence = requestSequence.current + 1
requestSequence.current = sequence
setLoading(true)
try {
const nextResult = await listAgentRuns({ page, pageSize })
if (requestSequence.current !== sequence) return
setResult(nextResult)
setError('')
if (nextResult.total_pages > 0 && nextResult.page > nextResult.total_pages) {
setPage(nextResult.total_pages)
}
} catch (reason) {
if (requestSequence.current === sequence) {
setError(reason instanceof Error ? reason.message : '加载运行记录失败')
}
} finally {
if (requestSequence.current === sequence) setLoading(false)
}
}, [page, pageSize])
React.useEffect(() => { void load() }, [load])
function goToPage(nextPage: number) {
setPage(Math.min(Math.max(nextPage, 1), Math.max(result.total_pages, 1)))
}
const columns: AppDataTableColumn<AgentRunSummary>[] = [
{ key: 'agent', header: 'Agent', cell: (item) => <div><p className="font-medium">{item.agent_name}</p><p className="font-mono text-xs text-muted-foreground">{item.public_id.slice(0, 8)}…</p></div> },
{ key: 'version', header: '版本', cell: (item) => <div className="flex gap-1"><Badge variant="outline">v{item.agent_version}</Badge>{item.sandbox ? <Badge variant="warning">沙箱</Badge> : <Badge variant="secondary">正式</Badge>}</div> },
@@ -26,5 +92,111 @@ export function AgentRunListPage() {
{ key: 'usage', header: 'Token / 耗时', cell: (item) => <span className="text-muted-foreground">{item.input_tokens + item.output_tokens} · {item.duration_ms} ms</span> },
{ key: 'created', header: '创建时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.created_at)}</span> }
]
return <AppPage header={<AppPageHeader title="Agent 运行记录" description="查看沙箱与正式版本的执行结果、Token、耗时和版本快照。" actions={<><Button variant="outline" onClick={() => { window.location.hash = '/agents' }}>Agent 管理</Button><Button variant="outline" onClick={() => void load()}><RefreshCw data-icon="inline-start" />刷新</Button></>} />}>{error ? <AppEmptyState title="无法加载运行记录" description={error} /> : <AppDataTable columns={columns} rows={items} getRowKey={(item) => item.public_id} emptyTitle="暂无运行记录" emptyDescription="从 Agent 详情的测试页启动一次运行。" onRowClick={(item) => { window.location.hash = `/agent-runs/${item.public_id}` }} />}</AppPage>
const visiblePages = getVisiblePages(result.page, result.total_pages)
return (
<AppPage
header={
<AppPageHeader
title="Agent 运行记录"
description="查看沙箱与正式版本的执行结果、Token、耗时和版本快照。"
actions={
<>
<Button variant="outline" onClick={() => { window.location.hash = '/agents' }}>Agent 管理</Button>
<Button variant="outline" disabled={loading} onClick={() => void load()}>
<RefreshCw data-icon="inline-start" className={cn(loading && 'animate-spin')} />
刷新
</Button>
</>
}
/>
}
>
{error ? (
<AppEmptyState title="无法加载运行记录" description={error} />
) : (
<AppDataTable
columns={columns}
rows={result.items}
getRowKey={(item) => item.public_id}
emptyTitle="暂无运行记录"
emptyDescription="从 Agent 详情的测试页启动一次运行。"
footer={
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>共 {result.total} 条,当前第 {result.page} / {Math.max(result.total_pages, 1)} 页</span>
<div className="flex items-center gap-2">
<span>每页</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value))
setPage(1)
}}
>
<SelectTrigger className="h-8 w-20" aria-label="每页条数">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[20, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>{size}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span>条</span>
</div>
</div>
<Pagination className="mx-0 w-auto justify-start lg:justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
className={cn((result.page <= 1 || loading) && 'pointer-events-none opacity-50')}
onClick={(event) => {
event.preventDefault()
goToPage(result.page - 1)
}}
/>
</PaginationItem>
{visiblePages.map((item) => (
typeof item === 'number' ? (
<PaginationItem key={item}>
<PaginationLink
href="#"
isActive={item === result.page}
onClick={(event) => {
event.preventDefault()
goToPage(item)
}}
>
{item}
</PaginationLink>
</PaginationItem>
) : (
<PaginationItem key={item}>
<PaginationEllipsis />
</PaginationItem>
)
))}
<PaginationItem>
<PaginationNext
href="#"
className={cn((result.page >= result.total_pages || loading) && 'pointer-events-none opacity-50')}
onClick={(event) => {
event.preventDefault()
goToPage(result.page + 1)
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
}
onRowClick={(item) => { window.location.hash = `/agent-runs/${item.public_id}` }}
/>
)}
</AppPage>
)
}

View File

@@ -1,5 +1,5 @@
import { apiRequest } from '@/lib/api'
import type { AgentDefinition, AgentDetail, AgentRunDetail, AgentRunSummary, AgentVersion } from './types'
import type { AgentDefinition, AgentDetail, AgentRunDetail, AgentRunListResponse, AgentVersion } from './types'
export const listAgents = () => apiRequest<AgentDefinition[]>('/agents')
export const createAgent = (payload: object) => apiRequest<AgentDetail>('/agents', { method: 'POST', body: JSON.stringify(payload) })
@@ -10,6 +10,24 @@ export const updateAgentDraft = (id: number, payload: object) => apiRequest<Agen
export const publishAgent = (id: number) => apiRequest<AgentVersion>(`/agents/${id}/publish`, { method: 'POST' })
export const rollbackAgent = (id: number, version: number) => apiRequest<AgentDetail>(`/agents/${id}/rollback/${version}`, { method: 'POST' })
export const startAgentRun = (id: number, payload: object) => apiRequest<AgentRunDetail>(`/agents/${id}/runs`, { method: 'POST', body: JSON.stringify(payload) })
export const listAgentRuns = () => apiRequest<AgentRunSummary[]>('/agent-runs')
export const listAgentRuns = ({
page = 1,
pageSize = 20,
status,
agentId
}: {
page?: number
pageSize?: number
status?: string
agentId?: number
} = {}) => {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize)
})
if (status) params.set('status', status)
if (agentId) params.set('agent_id', String(agentId))
return apiRequest<AgentRunListResponse>(`/agent-runs?${params.toString()}`)
}
export const getAgentRun = (id: string) => apiRequest<AgentRunDetail>(`/agent-runs/${id}`)
export const cancelAgentRun = (id: string) => apiRequest<AgentRunDetail>(`/agent-runs/${id}/cancel`, { method: 'POST' })

View File

@@ -6,9 +6,12 @@ export type AgentStep = {
skill_name: string
skill_version: number
skill_status: string
skill_output_enforcement: 'native_json_schema' | 'json_object' | 'validated_json'
model_deployment_id: number | null
model_display_name: string
model_capability: string
model_structured_output_mode: string
model_structured_output_test_status: string
input_mapping: Record<string, string>
}
@@ -51,6 +54,13 @@ export type AgentRunAttempt = {
input_tokens: number
output_tokens: number
duration_ms: number
structured_output: {
mode?: string
schema_name?: string
schema_hash?: string
response_format_included?: boolean
schema_in_system_message?: boolean
}
created_at: string
}
@@ -95,6 +105,14 @@ export type AgentRunSummary = {
finished_at: string | null
}
export type AgentRunListResponse = {
items: AgentRunSummary[]
page: number
page_size: number
total: number
total_pages: number
}
export type AgentRunDetail = AgentRunSummary & {
input_payload: Record<string, unknown>
final_output: unknown

View File

@@ -1,4 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentDetailPage } from '@/features/agents/AgentDetailPage'
@@ -9,6 +10,11 @@ import { SkillDetailPage } from '@/features/skills/SkillDetailPage'
const mocks = vi.hoisted(() => ({
listProviders: vi.fn(),
listDeployments: vi.fn(),
listDeploymentOptions: vi.fn(),
listInvocations: vi.fn(),
getDeploymentDeletionImpact: vi.fn(),
deleteDeployment: vi.fn(),
updateDeployment: vi.fn(),
testDeployment: vi.fn(),
getModelTest: vi.fn(),
getSkill: vi.fn(),
@@ -18,18 +24,35 @@ const mocks = vi.hoisted(() => ({
updateAgent: vi.fn(),
updateAgentDraft: vi.fn(),
getAgentRun: vi.fn(),
cancelAgentRun: vi.fn()
cancelAgentRun: vi.fn(),
currentUser: { is_superuser: true, permissions: ['model.invocation.view'] }
}))
vi.mock('@/features/modelhub/api', () => ({
listProviders: mocks.listProviders,
listDeployments: mocks.listDeployments,
listDeploymentOptions: mocks.listDeploymentOptions,
listInvocations: mocks.listInvocations,
testDeployment: mocks.testDeployment,
getModelTest: mocks.getModelTest,
createProvider: vi.fn(),
updateProvider: vi.fn(),
createDeployment: vi.fn(),
updateDeployment: vi.fn()
updateDeployment: mocks.updateDeployment,
testStructuredOutput: vi.fn(),
getProviderDeletionImpact: vi.fn(),
getDeploymentDeletionImpact: mocks.getDeploymentDeletionImpact,
deleteProvider: vi.fn(),
deleteDeployment: mocks.deleteDeployment,
restoreProvider: vi.fn(),
restoreDeployment: vi.fn(),
getInvocationDiagnostics: vi.fn()
}))
vi.mock('@/features/auth/AuthProvider', () => ({
useAuth: () => ({
user: mocks.currentUser
})
}))
vi.mock('@/features/skills/api', () => ({
@@ -62,6 +85,16 @@ const provider = {
default_timeout_seconds: 60,
credential_set: true,
credential_masked: '****1234',
image_egress_approved: false,
image_egress_approved_by: null,
image_egress_approved_at: null,
credential_updated_at: null,
credential_updated_by: null,
archived_at: null,
archived_by: null,
deployment_count: 1,
active_deployment_count: 1,
effective_status: 'ready',
created_at: '2026-07-14T00:00:00Z',
updated_at: '2026-07-14T00:00:00Z'
}
@@ -75,6 +108,16 @@ const deployment = {
capability: 'text_chat',
default_temperature: 0.2,
max_output_tokens: 2048,
structured_output_mode: 'prompt_schema',
structured_output_test_status: 'passed',
structured_output_verified_at: '2026-07-14T00:00:00Z',
structured_output_test_message: '已通过',
effective_status: 'ready',
provider_effective_status: 'ready',
reference_summary: { agent_version_count: 0, step_binding_count: 0, run_step_count: 0, invocation_count: 0 },
archived_at: null,
archived_by: null,
archived_via_provider: false,
enabled: true
}
@@ -93,6 +136,9 @@ afterEach(() => {
})
beforeEach(() => {
mocks.currentUser.is_superuser = true
mocks.currentUser.permissions = ['model.invocation.view']
mocks.listInvocations.mockResolvedValue({ items: [], page: 1, page_size: 20, total: 0, total_pages: 0 })
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
@@ -107,14 +153,18 @@ beforeEach(() => {
describe('AI 平台真实页面', () => {
it('展示脱敏渠道并轮询异步模型测试', async () => {
mocks.listProviders.mockResolvedValue([provider])
mocks.listDeployments.mockResolvedValue([deployment])
const user = userEvent.setup()
mocks.listProviders.mockResolvedValue({ items: [provider], page: 1, page_size: 20, total: 1, total_pages: 1 })
mocks.listDeployments.mockResolvedValue({ items: [deployment], page: 1, page_size: 20, total: 1, total_pages: 1 })
mocks.testDeployment.mockResolvedValue(queuedTest)
mocks.getModelTest.mockResolvedValue({ ...queuedTest, status: 'succeeded', output_text: '模型连接正常', input_tokens: 8, output_tokens: 4, duration_ms: 320, finished_at: '2026-07-14T00:00:01Z' })
render(<ModelProviderPage />)
expect(await screen.findByText('****1234')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '连通测试' }))
fireEvent.click(screen.getByRole('tab', { name: '模型' }))
expect(await screen.findByText('support-model')).toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: '模型操作' }))
await user.click(await screen.findByRole('menuitem', { name: '连通测试' }))
fireEvent.click(screen.getByRole('button', { name: '开始测试' }))
await waitFor(() => expect(mocks.getModelTest).toHaveBeenCalledWith('test-uuid'), { timeout: 2500 })
@@ -123,14 +173,82 @@ describe('AI 平台真实页面', () => {
it('展示列表失败态', async () => {
mocks.listProviders.mockRejectedValue(new Error('后端不可用'))
mocks.listDeployments.mockResolvedValue([])
mocks.listDeployments.mockResolvedValue({ items: [], page: 1, page_size: 20, total: 0, total_pages: 0 })
render(<ModelProviderPage />)
expect(await screen.findByText('无法加载模型配置')).toBeInTheDocument()
expect(await screen.findByText('无法加载模型中心')).toBeInTheDocument()
expect(screen.getByText('后端不可用')).toBeInTheDocument()
})
it('删除前展示影响并按后端结果归档模型', async () => {
const user = userEvent.setup()
mocks.listProviders.mockResolvedValue({ items: [provider], page: 1, page_size: 20, total: 1, total_pages: 1 })
mocks.listDeployments.mockResolvedValue({ items: [deployment], page: 1, page_size: 20, total: 1, total_pages: 1 })
mocks.getDeploymentDeletionImpact.mockResolvedValue({
suggested_action: 'archive',
deployment_count: 1,
agent_version_count: 1,
step_binding_count: 1,
run_step_count: 2,
invocation_count: 3
})
mocks.deleteDeployment.mockResolvedValue({
result: 'archived',
impact: {
suggested_action: 'archive',
deployment_count: 1,
agent_version_count: 1,
step_binding_count: 1,
run_step_count: 2,
invocation_count: 3
}
})
render(<ModelProviderPage />)
fireEvent.click(await screen.findByRole('tab', { name: '模型' }))
await user.click(await screen.findByRole('button', { name: '模型操作' }))
await user.click(await screen.findByRole('menuitem', { name: '删除或归档' }))
expect(await screen.findByText(/检测到历史依赖/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '确认归档' }))
await waitFor(() => expect(mocks.deleteDeployment).toHaveBeenCalledWith(2))
})
it('Embedding 模型不展示结构化输出配置并在保存时自动归一', async () => {
const user = userEvent.setup()
const embeddingDeployment = {
...deployment,
display_name: '知识向量模型',
model_name: 'embedding-model',
capability: 'embedding',
structured_output_mode: 'unconfigured',
structured_output_test_status: 'untested',
structured_output_verified_at: null,
structured_output_test_message: ''
}
mocks.listProviders.mockResolvedValue({ items: [provider], page: 1, page_size: 20, total: 1, total_pages: 1 })
mocks.listDeployments.mockResolvedValue({ items: [embeddingDeployment], page: 1, page_size: 20, total: 1, total_pages: 1 })
mocks.updateDeployment.mockResolvedValue({ ...embeddingDeployment, structured_output_mode: 'none' })
render(<ModelProviderPage />)
fireEvent.click(await screen.findByRole('tab', { name: '模型' }))
await user.click(await screen.findByRole('button', { name: '模型操作' }))
await user.click(await screen.findByRole('menuitem', { name: '编辑' }))
const dialog = screen.getByRole('dialog', { name: '编辑模型' })
expect(within(dialog).queryByText('结构化输出模式')).not.toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: '保存更改' }))
await waitFor(() => expect(mocks.updateDeployment).toHaveBeenCalledWith(
embeddingDeployment.id,
expect.objectContaining({
capability: 'embedding',
structured_output_mode: 'none'
})
))
})
it('发布 Skill 草稿版本', async () => {
mocks.getSkill.mockResolvedValue({
id: 1, code: 'structure', name: '问题结构化', description: '', active_version: null, draft_version: 1, updated_at: '2026-07-14T00:00:00Z',
@@ -217,7 +335,7 @@ describe('AI 平台真实页面', () => {
}]
}
mocks.getAgent.mockResolvedValue(agent)
mocks.listDeployments.mockResolvedValue([deployment, visionDeployment])
mocks.listDeploymentOptions.mockResolvedValue([deployment, visionDeployment])
mocks.listSkills.mockResolvedValue([{ id: 1 }])
mocks.getSkill.mockResolvedValue({
id: 1,

View File

@@ -1,5 +1,19 @@
import * as React from 'react'
import { Activity, KeyRound, Pencil, Plus, RefreshCw, Settings2 } from 'lucide-react'
import {
Activity,
ArchiveRestore,
Braces,
CirclePower,
Database,
KeyRound,
MoreHorizontal,
Pencil,
Plus,
RefreshCw,
Search,
ShieldAlert,
Trash2,
} from 'lucide-react'
import { toast } from 'sonner'
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
@@ -8,285 +22,413 @@ import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppField } from '@/components/app/AppField'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { StatusBadge } from '@/features/ai-platform/status'
import { useIsMobile } from '@/hooks/use-mobile'
import { useAuth } from '@/features/auth/AuthProvider'
import { formatDateTime } from '@/lib/format'
import { createDeployment, createProvider, getModelTest, listDeployments, listProviders, testDeployment, updateDeployment, updateProvider } from './api'
import type { ModelDeployment, ModelInvocation, ModelProvider } from './types'
import {
createDeployment,
createProvider,
deleteDeployment,
deleteProvider,
getDeploymentDeletionImpact,
getInvocationDiagnostics,
getModelTest,
getProviderDeletionImpact,
listDeployments,
listInvocations,
listProviders,
restoreDeployment,
restoreProvider,
testDeployment,
testStructuredOutput,
updateDeployment,
updateProvider,
} from './api'
import type {
DeletionImpact,
ModelDeployment,
ModelInvocation,
ModelInvocationDiagnostics,
ModelProvider,
PageResult,
} from './types'
const providerInitial = { name: '', base_url: '', api_key: '', default_timeout_seconds: '60', enabled: true, image_egress_approved: false }
const deploymentInitial = { provider_id: '', display_name: '', model_name: '', capability: 'text_chat', default_temperature: '0.2', max_output_tokens: '2048' }
const deploymentInitial = { provider_id: '', display_name: '', model_name: '', capability: 'text_chat', structured_output_mode: 'native_json_schema', default_temperature: '0.2', max_output_tokens: '2048' }
const emptyPage = <T,>(): PageResult<T> => ({ items: [], page: 1, page_size: 20, total: 0, total_pages: 0 })
const modeLabels: Record<ModelDeployment['structured_output_mode'], string> = {
unconfigured: '未配置',
native_json_schema: '原生 Schema',
json_object: 'JSON Object',
prompt_schema: 'Prompt Schema',
none: '不适用',
}
const capabilityLabels: Record<string, string> = {
text_chat: '文本',
vision_chat: '视觉',
embedding: 'Embedding',
rerank: '重排',
}
const invocationLabels: Record<string, string> = {
model_test: '连通测试',
structured_output_test: '结构化测试',
agent_step: 'Agent 步骤',
}
function PageFooter({ page, totalPages, total, onChange }: { page: number; totalPages: number; total: number; onChange: (page: number) => void }) {
return <div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span>共 {total} 条 · 第 {page}/{Math.max(totalPages, 1)} 页</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => onChange(page - 1)}>上一页</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => onChange(page + 1)}>下一页</Button>
</div>
</div>
}
function StatusBadge({ status }: { status: string }) {
const labels: Record<string, string> = {
ready: '可用',
archived: '已归档',
disabled: '已停用',
credential_missing: '缺少凭证',
provider_disabled: '渠道停用',
provider_credential_missing: '渠道缺少凭证',
provider_archived: '渠道归档',
unverified: '未验证',
}
const variant = status === 'ready' ? 'success' : status === 'archived' ? 'outline' : status.includes('missing') || status === 'unverified' ? 'warning' : 'secondary'
return <Badge variant={variant}>{labels[status] ?? status}</Badge>
}
function SearchToolbar({ value, onChange, children }: { value: string; onChange: (value: string) => void; children?: React.ReactNode }) {
return <div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-52 flex-1 md:max-w-sm">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input className="pl-8" value={value} onChange={(event) => onChange(event.target.value)} placeholder="搜索名称、模型或渠道…" />
</div>
{children}
</div>
}
export function ModelProviderPage() {
const mobile = useIsMobile()
const [providers, setProviders] = React.useState<ModelProvider[]>([])
const [deployments, setDeployments] = React.useState<ModelDeployment[]>([])
const { user } = useAuth()
const superuser = Boolean(user?.is_superuser)
const canViewDiagnostics = superuser || Boolean(user?.permissions.includes('model.invocation.view'))
const [tab, setTab] = React.useState('providers')
const [providers, setProviders] = React.useState<PageResult<ModelProvider>>(emptyPage)
const [deployments, setDeployments] = React.useState<PageResult<ModelDeployment>>(emptyPage)
const [invocations, setInvocations] = React.useState<PageResult<ModelInvocation>>(emptyPage)
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState('')
const [providerForm, setProviderForm] = React.useState(providerInitial)
const [deploymentForm, setDeploymentForm] = React.useState(deploymentInitial)
const [providerCreateOpen, setProviderCreateOpen] = React.useState(false)
const [deploymentCreateOpen, setDeploymentCreateOpen] = React.useState(false)
const [creatingProvider, setCreatingProvider] = React.useState(false)
const [creatingDeployment, setCreatingDeployment] = React.useState(false)
const [providerCreateError, setProviderCreateError] = React.useState('')
const [deploymentCreateError, setDeploymentCreateError] = React.useState('')
const [editing, setEditing] = React.useState<ModelProvider | null>(null)
const [editingDeployment, setEditingDeployment] = React.useState<ModelDeployment | null>(null)
const [testing, setTesting] = React.useState<ModelDeployment | null>(null)
const [prompt, setPrompt] = React.useState('请只回复:模型连接正常')
const [testResult, setTestResult] = React.useState<ModelInvocation | null>(null)
const [providerQuery, setProviderQuery] = React.useState('')
const [providerEnabled, setProviderEnabled] = React.useState('all')
const [providerArchived, setProviderArchived] = React.useState('active')
const [providerPage, setProviderPage] = React.useState(1)
const [deploymentQuery, setDeploymentQuery] = React.useState('')
const [deploymentProvider, setDeploymentProvider] = React.useState('all')
const [deploymentCapability, setDeploymentCapability] = React.useState('all')
const [deploymentStatus, setDeploymentStatus] = React.useState('all')
const [deploymentTestStatus, setDeploymentTestStatus] = React.useState('all')
const [deploymentArchived, setDeploymentArchived] = React.useState('active')
const [deploymentPage, setDeploymentPage] = React.useState(1)
const [invocationProvider, setInvocationProvider] = React.useState('all')
const [invocationDeployment, setInvocationDeployment] = React.useState('all')
const [invocationStatus, setInvocationStatus] = React.useState('all')
const [invocationType, setInvocationType] = React.useState('all')
const [invocationDateFrom, setInvocationDateFrom] = React.useState('')
const [invocationDateTo, setInvocationDateTo] = React.useState('')
const [invocationErrorCode, setInvocationErrorCode] = React.useState('')
const [invocationPage, setInvocationPage] = React.useState(1)
const [providerCreateOpen, setProviderCreateOpen] = React.useState(false)
const [providerForm, setProviderForm] = React.useState(providerInitial)
const [deploymentCreateOpen, setDeploymentCreateOpen] = React.useState(false)
const [deploymentForm, setDeploymentForm] = React.useState(deploymentInitial)
const [pending, setPending] = React.useState(false)
const [formError, setFormError] = React.useState('')
const [editingProvider, setEditingProvider] = React.useState<ModelProvider | null>(null)
const [editingDeployment, setEditingDeployment] = React.useState<ModelDeployment | null>(null)
const [replacementKey, setReplacementKey] = React.useState('')
const [clearCredential, setClearCredential] = React.useState(false)
const [testing, setTesting] = React.useState<ModelDeployment | null>(null)
const [testKind, setTestKind] = React.useState<'connection' | 'structured'>('connection')
const [testPrompt, setTestPrompt] = React.useState('请只回复:模型连接正常')
const [testResult, setTestResult] = React.useState<ModelInvocation | null>(null)
const [lifecycle, setLifecycle] = React.useState<{ kind: 'provider' | 'deployment'; id: number; name: string; impact: DeletionImpact } | null>(null)
const [detail, setDetail] = React.useState<ModelInvocation | null>(null)
const [diagnostics, setDiagnostics] = React.useState<ModelInvocationDiagnostics | null>(null)
const loadProviders = React.useCallback(async () => {
const result = await listProviders({ q: providerQuery, enabled: providerEnabled === 'all' ? '' : providerEnabled, archived: providerArchived, page: providerPage, page_size: 20 })
setProviders(result)
setDeploymentForm((current) => ({ ...current, provider_id: current.provider_id || String(result.items.find((item) => !item.archived_at)?.id ?? '') }))
}, [providerArchived, providerEnabled, providerPage, providerQuery])
const loadDeployments = React.useCallback(async () => {
setDeployments(await listDeployments({
q: deploymentQuery,
provider_id: deploymentProvider === 'all' ? '' : deploymentProvider,
capability: deploymentCapability === 'all' ? '' : deploymentCapability,
effective_status: deploymentStatus === 'all' ? '' : deploymentStatus,
structured_test_status: deploymentTestStatus === 'all' ? '' : deploymentTestStatus,
archived: deploymentArchived,
page: deploymentPage,
page_size: 20,
}))
}, [deploymentArchived, deploymentCapability, deploymentPage, deploymentProvider, deploymentQuery, deploymentStatus, deploymentTestStatus])
const loadInvocations = React.useCallback(async () => {
setInvocations(await listInvocations({
provider_id: invocationProvider === 'all' ? '' : invocationProvider,
deployment_id: invocationDeployment === 'all' ? '' : invocationDeployment,
status: invocationStatus === 'all' ? '' : invocationStatus,
invocation_type: invocationType === 'all' ? '' : invocationType,
date_from: invocationDateFrom,
date_to: invocationDateTo,
error_code: invocationErrorCode,
page: invocationPage,
page_size: 20,
}))
}, [invocationDateFrom, invocationDateTo, invocationDeployment, invocationErrorCode, invocationPage, invocationProvider, invocationStatus, invocationType])
const load = React.useCallback(async () => {
setLoading(true)
try {
const [nextProviders, nextDeployments] = await Promise.all([listProviders(), listDeployments()])
setProviders(nextProviders)
setDeployments(nextDeployments)
setDeploymentForm((current) => ({ ...current, provider_id: current.provider_id || String(nextProviders[0]?.id ?? '') }))
await Promise.all([loadProviders(), loadDeployments(), loadInvocations()])
setError('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '加载模型配置失败')
setError(reason instanceof Error ? reason.message : '加载模型中心失败')
} finally {
setLoading(false)
}
}, [])
}, [loadDeployments, loadInvocations, loadProviders])
React.useEffect(() => { void load() }, [load])
React.useEffect(() => { setProviderPage(1) }, [providerArchived, providerEnabled, providerQuery])
React.useEffect(() => { setDeploymentPage(1) }, [deploymentArchived, deploymentCapability, deploymentProvider, deploymentQuery, deploymentStatus, deploymentTestStatus])
React.useEffect(() => { setInvocationPage(1) }, [invocationDateFrom, invocationDateTo, invocationDeployment, invocationErrorCode, invocationProvider, invocationStatus, invocationType])
React.useEffect(() => {
if (!testResult || !['queued', 'running'].includes(testResult.status)) return
const timer = window.setTimeout(async () => {
try { setTestResult(await getModelTest(testResult.public_id)) } catch (reason) { setError(reason instanceof Error ? reason.message : '查询测试结果失败') }
try { setTestResult(await getModelTest(testResult.public_id)) } catch (reason) { toast.error(reason instanceof Error ? reason.message : '测试状态查询失败') }
}, 1200)
return () => window.clearTimeout(timer)
}, [testResult])
React.useEffect(() => {
if (testResult && ['succeeded', 'failed'].includes(testResult.status)) void load()
}, [load, testResult])
function openProviderCreateDialog() {
if (mobile) {
toast.info('模型渠道配置请使用桌面端完成')
return
}
setProviderCreateOpen(true)
}
function openDeploymentCreateDialog() {
if (mobile) {
toast.info('模型配置请使用桌面端完成')
return
}
setDeploymentCreateOpen(true)
}
async function handleCreateProvider(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreatingProvider(true)
setProviderCreateError('')
async function createProviderAction(event: React.FormEvent) {
event.preventDefault(); setPending(true); setFormError('')
try {
await createProvider({ ...providerForm, default_timeout_seconds: Number(providerForm.default_timeout_seconds) })
setProviderForm(providerInitial)
setProviderCreateOpen(false)
toast.success('模型渠道已创建')
await load()
} catch (reason) {
setProviderCreateError(reason instanceof Error ? reason.message : '创建失败')
} finally {
setCreatingProvider(false)
}
setProviderForm(providerInitial); setProviderCreateOpen(false); toast.success('渠道已创建'); await load()
} catch (reason) { setFormError(reason instanceof Error ? reason.message : '创建失败') } finally { setPending(false) }
}
async function handleUpdateProvider() {
if (!editing) return
async function createDeploymentAction(event: React.FormEvent) {
event.preventDefault(); setPending(true); setFormError('')
try {
const keyInput = document.querySelector<HTMLInputElement>('#edit-provider-api-key')?.value ?? ''
await updateProvider(editing.id, { name: editing.name, base_url: editing.base_url, default_timeout_seconds: editing.default_timeout_seconds, enabled: editing.enabled, image_egress_approved: editing.image_egress_approved, api_key: keyInput })
setEditing(null)
toast.success('渠道配置已保存')
await load()
await createDeployment({ ...deploymentForm, structured_output_mode: ['embedding', 'rerank'].includes(deploymentForm.capability) ? 'none' : deploymentForm.structured_output_mode, provider_id: Number(deploymentForm.provider_id), default_temperature: Number(deploymentForm.default_temperature), max_output_tokens: Number(deploymentForm.max_output_tokens), enabled: true })
setDeploymentForm((current) => ({ ...deploymentInitial, provider_id: current.provider_id })); setDeploymentCreateOpen(false); toast.success('模型已创建'); await load()
} catch (reason) { setFormError(reason instanceof Error ? reason.message : '创建失败') } finally { setPending(false) }
}
async function saveProvider() {
if (!editingProvider) return
try {
await updateProvider(editingProvider.id, {
name: editingProvider.name,
base_url: editingProvider.base_url,
default_timeout_seconds: editingProvider.default_timeout_seconds,
enabled: editingProvider.enabled,
image_egress_approved: editingProvider.image_egress_approved,
api_key: replacementKey,
clear_api_key: clearCredential,
})
setEditingProvider(null); setReplacementKey(''); setClearCredential(false); toast.success('渠道配置已保存'); await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') }
}
async function handleCreateDeployment(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreatingDeployment(true)
setDeploymentCreateError('')
try {
await createDeployment({ ...deploymentForm, provider_id: Number(deploymentForm.provider_id), default_temperature: Number(deploymentForm.default_temperature), max_output_tokens: Number(deploymentForm.max_output_tokens), enabled: true })
setDeploymentForm((current) => ({ ...deploymentInitial, provider_id: current.provider_id }))
setDeploymentCreateOpen(false)
toast.success('模型配置已创建')
await load()
} catch (reason) {
setDeploymentCreateError(reason instanceof Error ? reason.message : '创建失败')
} finally {
setCreatingDeployment(false)
}
}
async function handleUpdateDeployment() {
async function saveDeployment() {
if (!editingDeployment) return
try {
await updateDeployment(editingDeployment.id, {
display_name: editingDeployment.display_name,
model_name: editingDeployment.model_name,
capability: editingDeployment.capability,
structured_output_mode: ['embedding', 'rerank'].includes(editingDeployment.capability) ? 'none' : editingDeployment.structured_output_mode,
default_temperature: editingDeployment.default_temperature,
max_output_tokens: editingDeployment.max_output_tokens,
enabled: editingDeployment.enabled
enabled: editingDeployment.enabled,
})
setEditingDeployment(null)
toast.success('模型配置已保存')
await load()
setEditingDeployment(null); toast.success('模型配置已保存'); await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') }
}
async function handleTest() {
async function openLifecycle(kind: 'provider' | 'deployment', id: number, name: string) {
try {
const impact = kind === 'provider' ? await getProviderDeletionImpact(id) : await getDeploymentDeletionImpact(id)
setLifecycle({ kind, id, name, impact })
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '无法计算删除影响') }
}
async function confirmLifecycle() {
if (!lifecycle) return
try {
const result = lifecycle.kind === 'provider' ? await deleteProvider(lifecycle.id) : await deleteDeployment(lifecycle.id)
toast.success(result.result === 'deleted' ? '配置已永久删除' : '配置已归档')
setLifecycle(null); await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '生命周期操作失败') }
}
async function restore(kind: 'provider' | 'deployment', id: number) {
try {
if (kind === 'provider') await restoreProvider(id); else await restoreDeployment(id)
toast.success('已恢复并保持停用,请重新验证后启用'); await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '恢复失败') }
}
async function toggleProvider(provider: ModelProvider) {
try {
await updateProvider(provider.id, { enabled: !provider.enabled })
toast.success(provider.enabled ? '渠道已停用' : '渠道已启用')
await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '状态更新失败') }
}
async function toggleDeployment(deployment: ModelDeployment) {
try {
await updateDeployment(deployment.id, { enabled: !deployment.enabled })
toast.success(deployment.enabled ? '模型已停用' : '模型已启用')
await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '状态更新失败') }
}
async function startTest() {
if (!testing) return
try { setTestResult(await testDeployment(testing.id, prompt)) } catch (reason) { toast.error(reason instanceof Error ? reason.message : '测试启动失败') }
try {
setTestResult(testKind === 'structured' ? await testStructuredOutput(testing.id) : await testDeployment(testing.id, testPrompt))
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '测试启动失败') }
}
async function loadDiagnostics() {
if (!detail) return
try { setDiagnostics(await getInvocationDiagnostics(detail.public_id)) } catch (reason) { toast.error(reason instanceof Error ? reason.message : '诊断内容不可用') }
}
const providerColumns: AppDataTableColumn<ModelProvider>[] = [
{ key: 'name', header: '渠道', cell: (item) => <div><p className="font-medium">{item.name}</p><p className="text-xs text-muted-foreground">{item.base_url}</p></div> },
{ key: 'credential', header: '凭证', cell: (item) => <span className="inline-flex items-center gap-1 text-sm text-muted-foreground"><KeyRound className="size-3.5" />{item.credential_set ? item.credential_masked : '未配置'}</span> },
{ key: 'timeout', header: '超时', cell: (item) => `${item.default_timeout_seconds} 秒` },
{ key: 'image-egress', header: '图片出境', cell: (item) => <Badge title={item.image_egress_approved_at ? `${item.image_egress_approved_by ?? '管理员'} · ${formatDateTime(item.image_egress_approved_at)}` : undefined} variant={item.image_egress_approved ? 'warning' : 'secondary'}>{item.image_egress_approved ? '已授权' : '未授权'}</Badge> },
{ key: 'status', header: '状态', cell: (item) => <Badge variant={item.enabled ? 'success' : 'secondary'}>{item.enabled ? '已启用' : '已停用'}</Badge> },
{ key: 'provider', header: '渠道', cell: (item) => <div><p className="font-medium">{item.name}</p><p className="max-w-72 truncate font-mono text-xs text-muted-foreground">{item.base_url}</p></div> },
{ key: 'credential', header: '凭证', cell: (item) => <div className="grid gap-1"><span className="inline-flex items-center gap-1"><KeyRound className="size-3.5" />{item.credential_set ? item.credential_masked : '未配置'}</span>{item.credential_updated_at ? <span className="text-xs text-muted-foreground">{formatDateTime(item.credential_updated_at)}</span> : null}</div> },
{ key: 'models', header: '模型', cell: (item) => <span>{item.active_deployment_count} 活跃 / {item.deployment_count} 总计</span> },
{ key: 'status', header: '有效状态', cell: (item) => <StatusBadge status={item.effective_status} /> },
{ key: 'updated', header: '更新时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span> },
{ key: 'action', header: '操作', cell: (item) => <Button variant="ghost" size="sm" onClick={() => setEditing({ ...item })}><Pencil data-icon="inline-start" />编辑</Button> }
{ key: 'actions', header: '', className: 'w-12', cell: (item) => <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon-sm"><MoreHorizontal /><span className="sr-only">渠道操作</span></Button></DropdownMenuTrigger><DropdownMenuContent align="end">
{!item.archived_at ? <DropdownMenuItem onSelect={() => { setEditingProvider({ ...item }); setReplacementKey(''); setClearCredential(false) }}><Pencil />编辑</DropdownMenuItem> : null}
{!item.archived_at ? <DropdownMenuItem onSelect={() => void toggleProvider(item)}><CirclePower />{item.enabled ? '停用' : '启用'}</DropdownMenuItem> : null}
{superuser && !item.archived_at ? <DropdownMenuItem variant="destructive" onSelect={() => void openLifecycle('provider', item.id, item.name)}><Trash2 />删除或归档</DropdownMenuItem> : null}
{superuser && item.archived_at ? <DropdownMenuItem onSelect={() => void restore('provider', item.id)}><ArchiveRestore />恢复</DropdownMenuItem> : null}
</DropdownMenuContent></DropdownMenu> },
]
const deploymentColumns: AppDataTableColumn<ModelDeployment>[] = [
{ key: 'name', header: '模型', cell: (item) => <div><p className="font-medium">{item.display_name}</p><p className="text-xs text-muted-foreground">{item.model_name}</p></div> },
{ key: 'provider', header: '渠道', cell: (item) => item.provider_name },
{ key: 'capability', header: '能力', cell: (item) => <Badge variant="outline">{item.capability === 'vision_chat' ? '文本 + 视觉' : item.capability === 'embedding' ? 'Embedding' : item.capability === 'rerank' ? '重排' : '纯文本'}</Badge> },
{ key: 'params', header: '默认参数', cell: (item) => <span className="text-muted-foreground">T {item.default_temperature} · {item.max_output_tokens} tokens</span> },
{ key: 'status', header: '状态', cell: (item) => <Badge variant={item.enabled ? 'success' : 'secondary'}>{item.enabled ? '可用' : '停用'}</Badge> },
{ key: 'action', header: '操作', cell: (item) => <div className="flex gap-1"><Button variant="ghost" size="sm" onClick={() => setEditingDeployment({ ...item })}><Settings2 data-icon="inline-start" />配置</Button><Button variant="outline" size="sm" disabled={item.capability === 'embedding'} title={item.capability === 'embedding' ? '请通过知识检索验证向量模型' : undefined} onClick={() => { setTesting(item); setTestResult(null) }}><Activity data-icon="inline-start" />连通测试</Button></div> }
{ key: 'model', header: '模型', cell: (item) => <div><p className="font-medium">{item.display_name}</p><p className="font-mono text-xs text-muted-foreground">{item.model_name}</p></div> },
{ key: 'provider', header: '渠道 / 能力', cell: (item) => <div className="grid gap-1"><span>{item.provider_name}</span><Badge className="w-fit" variant="outline">{capabilityLabels[item.capability] ?? item.capability}</Badge></div> },
{ key: 'structured', header: '结构化输出', cell: (item) => <div className="grid gap-1"><span>{modeLabels[item.structured_output_mode]}</span>{item.structured_output_mode !== 'none' ? <Badge className="w-fit" variant={item.structured_output_test_status === 'passed' ? 'success' : item.structured_output_test_status === 'failed' ? 'destructive' : 'warning'}>{item.structured_output_test_status === 'passed' ? '已验证' : item.structured_output_test_status === 'failed' ? '验证失败' : '未验证'}</Badge> : null}</div> },
{ key: 'metrics', header: '调用指标', cell: (item) => <div className="text-xs text-muted-foreground"><p>{item.structured_output_metrics?.model_invocation_count ?? 0} 次 · 成功 {((item.structured_output_metrics?.model_success_rate ?? 0) * 100).toFixed(1)}%</p><p>首过 {((item.structured_output_metrics?.first_pass_rate ?? 0) * 100).toFixed(1)}% · 修复 {((item.structured_output_metrics?.repair_rate ?? 0) * 100).toFixed(1)}%</p></div> },
{ key: 'references', header: '引用', cell: (item) => <span>{Object.values(item.reference_summary ?? {}).reduce((sum, value) => sum + value, 0)}</span> },
{ key: 'status', header: '有效状态', cell: (item) => <StatusBadge status={item.effective_status ?? (item.enabled ? 'ready' : 'disabled')} /> },
{ key: 'actions', header: '', className: 'w-12', cell: (item) => <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon-sm"><MoreHorizontal /><span className="sr-only">模型操作</span></Button></DropdownMenuTrigger><DropdownMenuContent align="end">
{!item.archived_at ? <DropdownMenuItem onSelect={() => setEditingDeployment({ ...item })}><Pencil />编辑</DropdownMenuItem> : null}
{!item.archived_at ? <DropdownMenuItem onSelect={() => void toggleDeployment(item)}><CirclePower />{item.enabled ? '停用' : '启用'}</DropdownMenuItem> : null}
{!item.archived_at && !['embedding', 'rerank'].includes(item.capability) ? <DropdownMenuItem disabled={!item.enabled || item.provider_effective_status !== 'ready'} onSelect={() => { setTesting(item); setTestKind('connection'); setTestResult(null) }}><Activity />连通测试</DropdownMenuItem> : null}
{!item.archived_at && !['none', 'unconfigured'].includes(item.structured_output_mode) ? <DropdownMenuItem disabled={!item.enabled || item.provider_effective_status !== 'ready'} onSelect={() => { setTesting(item); setTestKind('structured'); setTestResult(null) }}><Braces />结构化测试</DropdownMenuItem> : null}
{superuser && !item.archived_at ? <DropdownMenuItem variant="destructive" onSelect={() => void openLifecycle('deployment', item.id, item.display_name)}><Trash2 />删除或归档</DropdownMenuItem> : null}
{superuser && item.archived_at ? <DropdownMenuItem onSelect={() => void restore('deployment', item.id)}><ArchiveRestore />恢复</DropdownMenuItem> : null}
</DropdownMenuContent></DropdownMenu> },
]
const invocationColumns: AppDataTableColumn<ModelInvocation>[] = [
{ key: 'time', header: '时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.created_at)}</span> },
{ key: 'type', header: '调用类型', cell: (item) => <Badge variant="outline">{invocationLabels[item.invocation_type] ?? item.invocation_type}</Badge> },
{ key: 'model', header: '模型', cell: (item) => <div><p>{item.deployment_display_name ?? String(item.model_snapshot_summary.model_name ?? '历史模型')}</p><p className="text-xs text-muted-foreground">{item.provider_name}</p></div> },
{ key: 'status', header: '状态', cell: (item) => <Badge variant={item.status === 'succeeded' ? 'success' : item.status === 'failed' ? 'destructive' : 'secondary'}>{item.status}</Badge> },
{ key: 'tokens', header: 'Token', cell: (item) => `${item.input_tokens ?? 0} / ${item.output_tokens ?? 0}` },
{ key: 'duration', header: '耗时', cell: (item) => `${item.duration_ms} ms` },
{ key: 'error', header: '错误码', cell: (item) => item.error_code ? <span className="font-mono text-xs text-destructive">{item.error_code}</span> : '—' },
{ key: 'diagnostics', header: '诊断', cell: (item) => <Badge variant={item.diagnostics_available ? 'outline' : 'secondary'}>{item.diagnostics_available ? '保留中' : '已过期'}</Badge> },
]
return (
<AppPage
header={<AppPageHeader
title="模型中心"
description="管理 OpenAI-compatible 渠道、凭证与文本模型。凭证只显示脱敏结果。"
actions={<Button variant="outline" onClick={() => void load()}><RefreshCw data-icon="inline-start" />刷新</Button>}
/>}
>
{error ? <AppEmptyState title="无法加载模型配置" description={error} /> : null}
{loading ? <div className="grid gap-3"><Skeleton className="h-32" /><Skeleton className="h-56" /></div> : null}
{!loading && !error ? (
<>
<AppSection
title="模型渠道"
description="维护服务地址、访问凭证和请求超时。"
action={<Button size="sm" onClick={openProviderCreateDialog}><Plus data-icon="inline-start" />新增渠道</Button>}
>
<AppDataTable columns={providerColumns} rows={providers} getRowKey={(item) => item.id} emptyTitle="暂无模型渠道" emptyDescription="先配置真实渠道,再创建模型配置。" />
</AppSection>
<AppSection
title="模型配置"
description="选择渠道并配置业务可识别的模型名称和默认生成参数。"
action={<Button size="sm" onClick={openDeploymentCreateDialog} disabled={!providers.length}><Plus data-icon="inline-start" />新增模型</Button>}
>
<AppDataTable columns={deploymentColumns} rows={deployments} getRowKey={(item) => item.id} emptyTitle="暂无模型配置" emptyDescription={providers.length ? '新增模型配置后即可供 Skill 和 Agent 使用。' : '请先新增一个模型渠道。'} />
</AppSection>
</>
) : null}
return <AppPage header={<AppPageHeader title="模型中心" description="管理文本、视觉、Embedding 与重排模型的完整生命周期、验证状态和调用诊断。" actions={<Button variant="outline" onClick={() => void load()}><RefreshCw data-icon="inline-start" />刷新</Button>} />}>
{error ? <AppEmptyState title="无法加载模型中心" description={error} /> : null}
{loading && !providers.total && !deployments.total && !invocations.total ? <div className="grid gap-3"><Skeleton className="h-12" /><Skeleton className="h-80" /></div> : null}
{!error ? <Tabs value={tab} onValueChange={setTab}>
<TabsList variant="line" className="mb-4">
<TabsTrigger value="providers" onClick={() => setTab('providers')}><Database />渠道</TabsTrigger>
<TabsTrigger value="deployments" onClick={() => setTab('deployments')}><Braces />模型</TabsTrigger>
<TabsTrigger value="invocations" onClick={() => setTab('invocations')}><Activity />调用记录</TabsTrigger>
</TabsList>
<TabsContent value="providers" className="grid gap-3">
<div className="flex items-center justify-between gap-3"><div><h2 className="font-heading text-lg">模型渠道</h2><p className="text-sm text-muted-foreground">凭证、出境授权和子模型生命周期在渠道层集中治理。</p></div><Button onClick={() => { setFormError(''); setProviderCreateOpen(true) }}><Plus data-icon="inline-start" />新增渠道</Button></div>
<AppDataTable columns={providerColumns} rows={providers.items} getRowKey={(item) => item.id} emptyTitle="没有符合条件的渠道" emptyDescription="调整筛选条件,或创建新的模型渠道。" toolbar={<SearchToolbar value={providerQuery} onChange={setProviderQuery}><Select value={providerEnabled} onValueChange={setProviderEnabled}><SelectTrigger className="w-32"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部启用状态</SelectItem><SelectItem value="true">已启用</SelectItem><SelectItem value="false">已停用</SelectItem></SelectContent></Select><Select value={providerArchived} onValueChange={setProviderArchived}><SelectTrigger className="w-32"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="active">活跃渠道</SelectItem><SelectItem value="archived">已归档</SelectItem><SelectItem value="all">全部</SelectItem></SelectContent></Select></SearchToolbar>} footer={<PageFooter page={providers.page} totalPages={providers.total_pages} total={providers.total} onChange={setProviderPage} />} />
</TabsContent>
<TabsContent value="deployments" className="grid gap-3">
<div className="flex items-center justify-between gap-3"><div><h2 className="font-heading text-lg">模型部署</h2><p className="text-sm text-muted-foreground">有效状态综合渠道、凭证、停用、归档与结构化验证结果。</p></div><Button onClick={() => { setFormError(''); setDeploymentCreateOpen(true) }}><Plus data-icon="inline-start" />新增模型</Button></div>
<AppDataTable columns={deploymentColumns} rows={deployments.items} getRowKey={(item) => item.id} emptyTitle="没有符合条件的模型" emptyDescription="调整筛选,或为现有渠道添加模型。" toolbar={<SearchToolbar value={deploymentQuery} onChange={setDeploymentQuery}>
<Select value={deploymentProvider} onValueChange={setDeploymentProvider}><SelectTrigger className="w-36"><SelectValue placeholder="全部渠道" /></SelectTrigger><SelectContent><SelectItem value="all">全部渠道</SelectItem>{providers.items.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.name}</SelectItem>)}</SelectContent></Select>
<Select value={deploymentCapability} onValueChange={setDeploymentCapability}><SelectTrigger className="w-32"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部能力</SelectItem>{Object.entries(capabilityLabels).map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select>
<Select value={deploymentStatus} onValueChange={setDeploymentStatus}><SelectTrigger className="w-32"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部状态</SelectItem><SelectItem value="ready">可用</SelectItem><SelectItem value="unverified">未验证</SelectItem><SelectItem value="disabled">已停用</SelectItem></SelectContent></Select>
<Select value={deploymentTestStatus} onValueChange={setDeploymentTestStatus}><SelectTrigger className="w-32"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部验证结果</SelectItem><SelectItem value="untested">未验证</SelectItem><SelectItem value="passed">已通过</SelectItem><SelectItem value="failed">失败</SelectItem></SelectContent></Select>
<Select value={deploymentArchived} onValueChange={setDeploymentArchived}><SelectTrigger className="w-32"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="active">活跃模型</SelectItem><SelectItem value="archived">已归档</SelectItem><SelectItem value="all">全部</SelectItem></SelectContent></Select>
</SearchToolbar>} footer={<PageFooter page={deployments.page} totalPages={deployments.total_pages} total={deployments.total} onChange={setDeploymentPage} />} />
</TabsContent>
<TabsContent value="invocations" className="grid gap-3">
<div><h2 className="font-heading text-lg">调用记录</h2><p className="text-sm text-muted-foreground">列表只展示摘要;完整输入输出按权限和保留期单独加载。</p></div>
<AppDataTable columns={invocationColumns} rows={invocations.items} getRowKey={(item) => item.public_id} emptyTitle="暂无调用记录" emptyDescription="模型测试和 Agent 调用完成后会在这里留下可审计记录。" onRowClick={(item) => { setDetail(item); setDiagnostics(null) }} toolbar={<div className="flex flex-wrap gap-2">
<Select value={invocationProvider} onValueChange={setInvocationProvider}><SelectTrigger className="w-36"><SelectValue placeholder="全部渠道" /></SelectTrigger><SelectContent><SelectItem value="all">全部渠道</SelectItem>{providers.items.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.name}</SelectItem>)}</SelectContent></Select>
<Select value={invocationDeployment} onValueChange={setInvocationDeployment}><SelectTrigger className="w-40"><SelectValue placeholder="全部模型" /></SelectTrigger><SelectContent><SelectItem value="all">全部模型</SelectItem>{deployments.items.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectContent></Select>
<Select value={invocationType} onValueChange={setInvocationType}><SelectTrigger className="w-40"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部调用类型</SelectItem>{Object.entries(invocationLabels).map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select>
<Select value={invocationStatus} onValueChange={setInvocationStatus}><SelectTrigger className="w-32"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="all">全部状态</SelectItem><SelectItem value="succeeded">成功</SelectItem><SelectItem value="failed">失败</SelectItem><SelectItem value="running">运行中</SelectItem></SelectContent></Select>
<Input className="w-40" type="date" aria-label="开始日期" value={invocationDateFrom} onChange={(event) => setInvocationDateFrom(event.target.value)} />
<Input className="w-40" type="date" aria-label="结束日期" value={invocationDateTo} onChange={(event) => setInvocationDateTo(event.target.value)} />
<Input className="w-44" placeholder="错误码" aria-label="错误码" value={invocationErrorCode} onChange={(event) => setInvocationErrorCode(event.target.value)} />
</div>} footer={<PageFooter page={invocations.page} totalPages={invocations.total_pages} total={invocations.total} onChange={setInvocationPage} />} />
</TabsContent>
</Tabs> : null}
<AppCreateDialog
open={providerCreateOpen}
onOpenChange={(open) => { setProviderCreateOpen(open); if (!open) setProviderCreateError('') }}
title="新增模型渠道"
description="连接一个兼容 OpenAI API 的模型服务。访问凭证将由后端安全保存。"
submitLabel="创建渠道"
onSubmit={handleCreateProvider}
pending={creatingProvider}
error={providerCreateError}
>
<FieldGroup>
<AppField label="渠道名称" htmlFor="provider-name" required>
<Input id="provider-name" autoFocus required value={providerForm.name} onChange={(event) => setProviderForm({ ...providerForm, name: event.target.value })} placeholder="例如:内部 OpenAI" />
</AppField>
<AppField label="Base URL" htmlFor="provider-url" description="填写包含版本路径的完整服务地址。" required>
<Input id="provider-url" required type="url" value={providerForm.base_url} onChange={(event) => setProviderForm({ ...providerForm, base_url: event.target.value })} placeholder="https://api.example.com/v1" />
</AppField>
<AppField label="API Key" htmlFor="provider-key" description="保存后仅显示脱敏结果。" required>
<Input id="provider-key" required type="password" autoComplete="new-password" value={providerForm.api_key} onChange={(event) => setProviderForm({ ...providerForm, api_key: event.target.value })} placeholder="请输入访问凭证" />
</AppField>
<AppField label="请求超时(秒)" htmlFor="provider-timeout">
<Input id="provider-timeout" type="number" min="1" max="300" value={providerForm.default_timeout_seconds} onChange={(event) => setProviderForm({ ...providerForm, default_timeout_seconds: event.target.value })} />
</AppField>
<AppField label="图片数据出境授权" description="开启后,该渠道允许接收管理员确认的工单截图。授权会记录当前管理员与时间。"><div className="flex items-center gap-2"><Switch checked={providerForm.image_egress_approved} onCheckedChange={(checked) => setProviderForm({ ...providerForm, image_egress_approved: checked })} /><span className="text-sm text-muted-foreground">{providerForm.image_egress_approved ? '已确认授权' : '不允许发送图片'}</span></div></AppField>
</FieldGroup>
</AppCreateDialog>
<AppCreateDialog open={providerCreateOpen} onOpenChange={setProviderCreateOpen} title="新增模型渠道" description="保存 OpenAI-compatible 服务地址和加密凭证。" submitLabel="创建渠道" onSubmit={createProviderAction} pending={pending} error={formError}>
<FieldGroup className="grid gap-4"><AppField label="渠道名称"><Input required value={providerForm.name} onChange={(event) => setProviderForm({ ...providerForm, name: event.target.value })} /></AppField><AppField label="Base URL"><Input required type="url" value={providerForm.base_url} onChange={(event) => setProviderForm({ ...providerForm, base_url: event.target.value })} /></AppField><AppField label="API Key"><Input required type="password" value={providerForm.api_key} onChange={(event) => setProviderForm({ ...providerForm, api_key: event.target.value })} /></AppField><AppField label="请求超时(秒)"><Input type="number" min="1" max="300" value={providerForm.default_timeout_seconds} onChange={(event) => setProviderForm({ ...providerForm, default_timeout_seconds: event.target.value })} /></AppField><div className="flex items-center justify-between gap-3 rounded-lg border p-3"><div><p className="font-medium">图片出境授权</p><p className="text-xs text-muted-foreground">仅视觉模型需要,授权信息会进入审计。</p></div><Switch checked={providerForm.image_egress_approved} onCheckedChange={(image_egress_approved) => setProviderForm({ ...providerForm, image_egress_approved })} /></div><div className="flex items-center justify-between gap-3"><span>创建后启用渠道</span><Switch checked={providerForm.enabled} onCheckedChange={(enabled) => setProviderForm({ ...providerForm, enabled })} /></div></FieldGroup>
</AppCreateDialog>
<AppCreateDialog open={deploymentCreateOpen} onOpenChange={setDeploymentCreateOpen} title="新增模型部署" description="选择能力和结构化输出适配方式,保存后再执行验证。" submitLabel="创建模型" onSubmit={createDeploymentAction} pending={pending} error={formError}>
<FieldGroup className="grid gap-4"><AppField label="渠道"><Select value={deploymentForm.provider_id} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, provider_id: value })}><SelectTrigger><SelectValue placeholder="选择渠道" /></SelectTrigger><SelectContent>{providers.items.filter((item) => !item.archived_at).map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.name}</SelectItem>)}</SelectContent></Select></AppField><AppField label="显示名称"><Input required value={deploymentForm.display_name} onChange={(event) => setDeploymentForm({ ...deploymentForm, display_name: event.target.value })} /></AppField><AppField label="实际模型名"><Input required value={deploymentForm.model_name} onChange={(event) => setDeploymentForm({ ...deploymentForm, model_name: event.target.value })} /></AppField><AppField label="能力"><Select value={deploymentForm.capability} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, capability: value, structured_output_mode: ['embedding', 'rerank'].includes(value) ? 'none' : 'native_json_schema' })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{Object.entries(capabilityLabels).map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></AppField>{!['embedding', 'rerank'].includes(deploymentForm.capability) ? <AppField label="结构化输出模式"><Select value={deploymentForm.structured_output_mode} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, structured_output_mode: value })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="native_json_schema">原生 JSON Schema</SelectItem><SelectItem value="json_object">JSON Object</SelectItem><SelectItem value="prompt_schema">Prompt Schema</SelectItem></SelectContent></Select></AppField> : null}<div className="grid grid-cols-2 gap-3"><AppField label="Temperature"><Input type="number" min="0" max="2" step="0.1" value={deploymentForm.default_temperature} onChange={(event) => setDeploymentForm({ ...deploymentForm, default_temperature: event.target.value })} /></AppField><AppField label="最大输出 Token"><Input type="number" min="1" value={deploymentForm.max_output_tokens} onChange={(event) => setDeploymentForm({ ...deploymentForm, max_output_tokens: event.target.value })} /></AppField></div></FieldGroup>
</AppCreateDialog>
<AppCreateDialog
open={deploymentCreateOpen}
onOpenChange={(open) => { setDeploymentCreateOpen(open); if (!open) setDeploymentCreateError('') }}
title="新增模型配置"
description="配置业务显示名称、实际模型名和默认生成参数。"
submitLabel="创建模型"
onSubmit={handleCreateDeployment}
pending={creatingDeployment}
submitDisabled={!providers.length}
error={deploymentCreateError}
>
<FieldGroup className="sm:grid sm:grid-cols-2">
<AppField label="模型渠道" required>
<Select value={deploymentForm.provider_id} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, provider_id: value })}>
<SelectTrigger className="w-full"><SelectValue placeholder="选择渠道" /></SelectTrigger>
<SelectContent><SelectGroup>{providers.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.name}</SelectItem>)}</SelectGroup></SelectContent>
</Select>
</AppField>
<AppField label="显示名称" htmlFor="deployment-display" required>
<Input id="deployment-display" autoFocus required value={deploymentForm.display_name} onChange={(event) => setDeploymentForm({ ...deploymentForm, display_name: event.target.value })} placeholder="例如:客服分析模型" />
</AppField>
<AppField label="实际模型名" htmlFor="deployment-model" description="填写渠道可识别的模型标识。" required>
<Input id="deployment-model" required value={deploymentForm.model_name} onChange={(event) => setDeploymentForm({ ...deploymentForm, model_name: event.target.value })} placeholder="例如:gpt-4.1-mini" />
</AppField>
<AppField label="模型能力" required><Select value={deploymentForm.capability} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem><SelectItem value="embedding">Embedding</SelectItem><SelectItem value="rerank">重排</SelectItem></SelectGroup></SelectContent></Select></AppField>
<AppField label="Temperature" htmlFor="deployment-temperature" description="0 更稳定,数值越高结果越发散。">
<Input id="deployment-temperature" type="number" min="0" max="2" step="0.1" value={deploymentForm.default_temperature} onChange={(event) => setDeploymentForm({ ...deploymentForm, default_temperature: event.target.value })} />
</AppField>
<AppField label="最大输出 Token" htmlFor="deployment-tokens">
<Input id="deployment-tokens" type="number" min="1" max="32768" value={deploymentForm.max_output_tokens} onChange={(event) => setDeploymentForm({ ...deploymentForm, max_output_tokens: event.target.value })} />
</AppField>
</FieldGroup>
</AppCreateDialog>
<Dialog open={Boolean(editingProvider)} onOpenChange={(open) => { if (!open) setEditingProvider(null) }}><DialogContent className="sm:max-w-lg"><DialogHeader><DialogTitle>编辑渠道</DialogTitle></DialogHeader>{editingProvider ? <FieldGroup className="grid gap-4"><AppField label="渠道名称"><Input value={editingProvider.name} onChange={(event) => setEditingProvider({ ...editingProvider, name: event.target.value })} /></AppField><AppField label="Base URL"><Input value={editingProvider.base_url} onChange={(event) => setEditingProvider({ ...editingProvider, base_url: event.target.value })} /></AppField><AppField label="请求超时(秒)"><Input type="number" min="1" max="300" value={editingProvider.default_timeout_seconds} onChange={(event) => setEditingProvider({ ...editingProvider, default_timeout_seconds: Number(event.target.value) })} /></AppField><AppField label="替换 API Key" description="留空表示不替换"><Input type="password" disabled={clearCredential} value={replacementKey} onChange={(event) => setReplacementKey(event.target.value)} /></AppField><div className="flex items-start justify-between gap-3 rounded-lg border p-3"><div><p className="font-medium">清除凭证</p><p className="text-xs text-muted-foreground">清除后渠道会自动停用。</p></div><Switch checked={clearCredential} onCheckedChange={(value) => { setClearCredential(value); if (value) setReplacementKey('') }} /></div><div className="flex items-center justify-between gap-3"><span>图片出境授权</span><Switch checked={editingProvider.image_egress_approved} onCheckedChange={(image_egress_approved) => setEditingProvider({ ...editingProvider, image_egress_approved })} /></div><div className="flex items-center justify-between"><span>启用渠道</span><Switch checked={editingProvider.enabled} onCheckedChange={(enabled) => setEditingProvider({ ...editingProvider, enabled })} /></div></FieldGroup> : null}<DialogFooter><Button variant="outline" onClick={() => setEditingProvider(null)}>取消</Button><Button onClick={() => void saveProvider()}>保存更改</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(editingDeployment)} onOpenChange={(open) => { if (!open) setEditingDeployment(null) }}><DialogContent className="sm:max-w-lg"><DialogHeader><DialogTitle>编辑模型</DialogTitle></DialogHeader>{editingDeployment ? <FieldGroup className="grid gap-4"><AppField label="显示名称"><Input value={editingDeployment.display_name} onChange={(event) => setEditingDeployment({ ...editingDeployment, display_name: event.target.value })} /></AppField><AppField label="实际模型名"><Input value={editingDeployment.model_name} onChange={(event) => setEditingDeployment({ ...editingDeployment, model_name: event.target.value })} /></AppField><AppField label="能力"><Select value={editingDeployment.capability} onValueChange={(capability) => setEditingDeployment({ ...editingDeployment, capability, structured_output_mode: ['embedding', 'rerank'].includes(capability) ? 'none' : editingDeployment.structured_output_mode === 'none' ? 'native_json_schema' : editingDeployment.structured_output_mode })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{Object.entries(capabilityLabels).map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></AppField>{!['embedding', 'rerank'].includes(editingDeployment.capability) ? <AppField label="结构化输出模式"><Select value={editingDeployment.structured_output_mode} onValueChange={(value: ModelDeployment['structured_output_mode']) => setEditingDeployment({ ...editingDeployment, structured_output_mode: value })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="native_json_schema">原生 JSON Schema</SelectItem><SelectItem value="json_object">JSON Object</SelectItem><SelectItem value="prompt_schema">Prompt Schema</SelectItem></SelectContent></Select></AppField> : null}<div className="grid grid-cols-2 gap-3"><AppField label="Temperature"><Input type="number" min="0" max="2" step="0.1" value={editingDeployment.default_temperature} onChange={(event) => setEditingDeployment({ ...editingDeployment, default_temperature: Number(event.target.value) })} /></AppField><AppField label="最大输出 Token"><Input type="number" min="1" value={editingDeployment.max_output_tokens} onChange={(event) => setEditingDeployment({ ...editingDeployment, max_output_tokens: Number(event.target.value) })} /></AppField></div><div className="flex items-center justify-between"><span>启用模型</span><Switch checked={editingDeployment.enabled} onCheckedChange={(enabled) => setEditingDeployment({ ...editingDeployment, enabled })} /></div></FieldGroup> : null}<DialogFooter><Button variant="outline" onClick={() => setEditingDeployment(null)}>取消</Button><Button onClick={() => void saveDeployment()}>保存更改</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(testing)} onOpenChange={(open) => { if (!open) setTesting(null) }}><DialogContent className="sm:max-w-xl"><DialogHeader><DialogTitle>{testKind === 'structured' ? '结构化输出测试' : '模型连通测试'} · {testing?.display_name}</DialogTitle></DialogHeader>{testKind === 'connection' ? <Textarea value={testPrompt} onChange={(event) => setTestPrompt(event.target.value)} /> : <p className="text-sm text-muted-foreground">使用平台固定的嵌套对象、数组、枚举和 strict Schema 单次验证,不执行修复。</p>}{testResult ? <div className="rounded-lg border bg-muted/30 p-3"><div className="mb-2 flex items-center gap-2"><Badge>{testResult.status}</Badge>{testResult.error_code ? <span className="font-mono text-xs text-destructive">{testResult.error_code}</span> : null}</div><pre className="max-h-56 overflow-auto whitespace-pre-wrap text-xs">{testResult.output_text || testResult.error_message || '等待模型返回…'}</pre></div> : null}<DialogFooter><Button variant="outline" onClick={() => setTesting(null)}>关闭</Button><Button disabled={Boolean(testResult && ['queued', 'running'].includes(testResult.status))} onClick={() => void startTest()}><Activity data-icon="inline-start" />开始测试</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(editing)} onOpenChange={(open) => !open && setEditing(null)}><DialogContent><DialogHeader><DialogTitle>编辑模型渠道</DialogTitle></DialogHeader>{editing ? <FieldGroup>
<AppField label="渠道名称"><Input value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} /></AppField>
<AppField label="Base URL"><Input value={editing.base_url} onChange={(e) => setEditing({ ...editing, base_url: e.target.value })} /></AppField>
<AppField label="替换 API Key" description="留空表示不替换"><Input id="edit-provider-api-key" type="password" placeholder={editing.credential_masked || '输入新密钥'} /></AppField>
<AppField label="默认超时"><Input type="number" min="1" max="300" value={editing.default_timeout_seconds} onChange={(e) => setEditing({ ...editing, default_timeout_seconds: Number(e.target.value) })} /></AppField>
<AppField label="启用状态"><div className="flex h-8 items-center gap-2"><Switch checked={editing.enabled} onCheckedChange={(checked) => setEditing({ ...editing, enabled: checked })} /><span className="text-sm text-muted-foreground">{editing.enabled ? '已启用' : '已停用'}</span></div></AppField>
<AppField label="图片数据出境授权" description="仅视觉模型且渠道已授权时,工单图片才会发送至模型。"><div className="flex h-8 items-center gap-2"><Switch checked={editing.image_egress_approved} onCheckedChange={(checked) => setEditing({ ...editing, image_egress_approved: checked })} /><span className="text-sm text-muted-foreground">{editing.image_egress_approved ? '已授权' : '未授权'}</span></div></AppField>
</FieldGroup> : null}<DialogFooter><Button variant="outline" onClick={() => setEditing(null)}>取消</Button><Button onClick={() => void handleUpdateProvider()}>保存</Button></DialogFooter></DialogContent></Dialog>
<AlertDialog open={Boolean(lifecycle)} onOpenChange={(open) => { if (!open) setLifecycle(null) }}><AlertDialogContent><AlertDialogHeader><div className="mb-2 flex size-10 items-center justify-center rounded-xl bg-destructive/10 text-destructive"><ShieldAlert /></div><AlertDialogTitle>{lifecycle?.impact.suggested_action === 'delete' ? '永久删除' : '归档'}「{lifecycle?.name}」</AlertDialogTitle><AlertDialogDescription>{lifecycle?.impact.suggested_action === 'delete' ? '该配置没有任何历史依赖,将被永久删除。' : '检测到历史依赖,本次不会物理删除,而会停用并归档配置。'}{lifecycle?.kind === 'provider' && lifecycle.impact.suggested_action === 'archive' ? ' 渠道凭证与图片出境授权会立即清除,恢复后必须重新录入并验证。' : ''}</AlertDialogDescription></AlertDialogHeader>{lifecycle ? <div className="grid grid-cols-2 gap-2 rounded-lg bg-muted/40 p-3 text-sm"><span>子模型 {lifecycle.impact.deployment_count}</span><span>Agent 版本 {lifecycle.impact.agent_version_count}</span><span>步骤绑定 {lifecycle.impact.step_binding_count}</span><span>运行步骤 {lifecycle.impact.run_step_count}</span><span>调用记录 {lifecycle.impact.invocation_count}</span></div> : null}<AlertDialogFooter><AlertDialogCancel>取消</AlertDialogCancel><AlertDialogAction onClick={() => void confirmLifecycle()}>{lifecycle?.impact.suggested_action === 'delete' ? '确认永久删除' : '确认归档'}</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog>
<Dialog open={Boolean(editingDeployment)} onOpenChange={(open) => !open && setEditingDeployment(null)}><DialogContent><DialogHeader><DialogTitle>编辑模型配置</DialogTitle></DialogHeader>{editingDeployment ? <FieldGroup>
<AppField label="显示名称"><Input value={editingDeployment.display_name} onChange={(e) => setEditingDeployment({ ...editingDeployment, display_name: e.target.value })} /></AppField>
<AppField label="模型名"><Input value={editingDeployment.model_name} onChange={(e) => setEditingDeployment({ ...editingDeployment, model_name: e.target.value })} /></AppField>
<AppField label="模型能力"><Select value={editingDeployment.capability} onValueChange={(value) => setEditingDeployment({ ...editingDeployment, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem><SelectItem value="embedding">Embedding</SelectItem><SelectItem value="rerank">重排</SelectItem></SelectGroup></SelectContent></Select></AppField>
<AppField label="Temperature"><Input type="number" min="0" max="2" step="0.1" value={editingDeployment.default_temperature} onChange={(e) => setEditingDeployment({ ...editingDeployment, default_temperature: Number(e.target.value) })} /></AppField>
<AppField label="最大输出 Token"><Input type="number" min="1" max="32768" value={editingDeployment.max_output_tokens} onChange={(e) => setEditingDeployment({ ...editingDeployment, max_output_tokens: Number(e.target.value) })} /></AppField>
<AppField label="启用状态"><div className="flex h-8 items-center gap-2"><Switch checked={editingDeployment.enabled} onCheckedChange={(checked) => setEditingDeployment({ ...editingDeployment, enabled: checked })} /><span className="text-sm text-muted-foreground">{editingDeployment.enabled ? '已启用' : '已停用'}</span></div></AppField>
</FieldGroup> : null}<DialogFooter><Button variant="outline" onClick={() => setEditingDeployment(null)}>取消</Button><Button onClick={() => void handleUpdateDeployment()}>保存</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(testing)} onOpenChange={(open) => !open && setTesting(null)}><DialogContent className="sm:max-w-2xl"><DialogHeader><DialogTitle>模型连通测试{testing ? `:${testing.display_name}` : ''}</DialogTitle></DialogHeader>
<AppField label="测试提示词" htmlFor="model-test-prompt"><Textarea id="model-test-prompt" value={prompt} onChange={(e) => setPrompt(e.target.value)} /></AppField>
{testResult ? <Alert><Activity /><AlertTitle className="flex items-center gap-2">测试状态 <StatusBadge status={testResult.status} /></AlertTitle><AlertDescription className="grid gap-1"><span>{testResult.output_text || testResult.error_message || '任务处理中…'}</span><span className="text-xs">Token:{testResult.input_tokens ?? '渠道未提供'} / {testResult.output_tokens ?? '渠道未提供'} · {testResult.duration_ms} ms · 重试 {testResult.retry_count}</span></AlertDescription></Alert> : null}
<DialogFooter><Button variant="outline" onClick={() => setTesting(null)}>关闭</Button><Button onClick={() => void handleTest()} disabled={testResult ? ['queued', 'running'].includes(testResult.status) : false}><Activity data-icon="inline-start" />开始测试</Button></DialogFooter>
</DialogContent></Dialog>
</AppPage>
)
<Sheet open={Boolean(detail)} onOpenChange={(open) => { if (!open) { setDetail(null); setDiagnostics(null) } }}><SheetContent className="sm:max-w-xl"><SheetHeader><SheetTitle>调用详情</SheetTitle><SheetDescription>{detail ? `${invocationLabels[detail.invocation_type] ?? detail.invocation_type} · ${formatDateTime(detail.created_at)}` : ''}</SheetDescription></SheetHeader>{detail ? <div className="scrollbar-panel grid gap-4 overflow-y-auto px-4 pb-6"><div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm"><span>状态:{detail.status}</span><span>耗时:{detail.duration_ms} ms</span><span>输入 Token:{detail.input_tokens ?? 0}</span><span>输出 Token:{detail.output_tokens ?? 0}</span><span className="col-span-2">错误码:{detail.error_code || '—'}</span></div><div><p className="mb-2 font-medium">模型快照摘要</p><pre className="overflow-auto rounded-lg bg-muted p-3 text-xs">{JSON.stringify(detail.model_snapshot_summary, null, 2)}</pre></div>{detail.error_message ? <div><p className="mb-2 font-medium">错误摘要</p><p className="rounded-lg bg-destructive/5 p-3 text-sm text-destructive">{detail.error_message}</p></div> : null}<div className="rounded-lg border p-3"><div className="flex items-center justify-between gap-3"><div><p className="font-medium">输入输出诊断</p><p className="text-xs text-muted-foreground">{detail.diagnostics_available ? `保留至 ${detail.diagnostics_expires_at ? formatDateTime(detail.diagnostics_expires_at) : '未知'}` : '诊断内容已过保留期'}</p></div>{canViewDiagnostics && detail.diagnostics_available ? <Button variant="outline" size="sm" onClick={() => void loadDiagnostics()}>加载诊断</Button> : null}</div>{!canViewDiagnostics ? <p className="mt-3 text-xs text-muted-foreground">需要 model.invocation.view 权限。</p> : null}{diagnostics ? <div className="mt-3 grid gap-3"><pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 text-xs">{JSON.stringify(diagnostics.input_snapshot, null, 2)}</pre><pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 text-xs">{diagnostics.output_text}</pre></div> : null}</div></div> : null}</SheetContent></Sheet>
</AppPage>
}

View File

@@ -1,11 +1,44 @@
import { apiRequest } from '@/lib/api'
import type { ModelDeployment, ModelInvocation, ModelProvider } from './types'
import type {
DeletionImpact,
LifecycleResult,
ModelDeployment,
ModelInvocation,
ModelInvocationDiagnostics,
ModelProvider,
PageResult,
} from './types'
export const listProviders = () => apiRequest<ModelProvider[]>('/modelhub/providers')
const query = (params: Record<string, string | number | undefined>) => {
const search = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') search.set(key, String(value))
})
const value = search.toString()
return value ? `?${value}` : ''
}
export const listProviders = (params: Record<string, string | number | undefined> = {}) =>
apiRequest<PageResult<ModelProvider>>(`/modelhub/providers${query(params)}`)
export const createProvider = (payload: object) => apiRequest<ModelProvider>('/modelhub/providers', { method: 'POST', body: JSON.stringify(payload) })
export const updateProvider = (id: number, payload: object) => apiRequest<ModelProvider>(`/modelhub/providers/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
export const listDeployments = () => apiRequest<ModelDeployment[]>('/modelhub/deployments')
export const getProviderDeletionImpact = (id: number) => apiRequest<DeletionImpact>(`/modelhub/providers/${id}/deletion-impact`)
export const deleteProvider = (id: number) => apiRequest<LifecycleResult>(`/modelhub/providers/${id}`, { method: 'DELETE' })
export const restoreProvider = (id: number) => apiRequest<ModelProvider>(`/modelhub/providers/${id}/restore`, { method: 'POST' })
export const listDeployments = (params: Record<string, string | number | undefined> = {}) =>
apiRequest<PageResult<ModelDeployment>>(`/modelhub/deployments${query(params)}`)
export const listDeploymentOptions = () => apiRequest<ModelDeployment[]>('/modelhub/deployment-options')
export const createDeployment = (payload: object) => apiRequest<ModelDeployment>('/modelhub/deployments', { method: 'POST', body: JSON.stringify(payload) })
export const updateDeployment = (id: number, payload: object) => apiRequest<ModelDeployment>(`/modelhub/deployments/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
export const getDeploymentDeletionImpact = (id: number) => apiRequest<DeletionImpact>(`/modelhub/deployments/${id}/deletion-impact`)
export const deleteDeployment = (id: number) => apiRequest<LifecycleResult>(`/modelhub/deployments/${id}`, { method: 'DELETE' })
export const restoreDeployment = (id: number) => apiRequest<ModelDeployment>(`/modelhub/deployments/${id}/restore`, { method: 'POST' })
export const testDeployment = (id: number, prompt: string) => apiRequest<ModelInvocation>(`/modelhub/deployments/${id}/test`, { method: 'POST', body: JSON.stringify({ prompt }) })
export const testStructuredOutput = (id: number) => apiRequest<ModelInvocation>(`/modelhub/deployments/${id}/structured-output-test`, { method: 'POST' })
export const listInvocations = (params: Record<string, string | number | undefined> = {}) =>
apiRequest<PageResult<ModelInvocation>>(`/modelhub/invocations${query(params)}`)
export const getInvocation = (id: string) => apiRequest<ModelInvocation>(`/modelhub/invocations/${id}`)
export const getInvocationDiagnostics = (id: string) => apiRequest<ModelInvocationDiagnostics>(`/modelhub/invocations/${id}/diagnostics`)
export const getModelTest = (id: string) => apiRequest<ModelInvocation>(`/modelhub/model-tests/${id}`)

View File

@@ -1,3 +1,11 @@
export type PageResult<T> = {
items: T[]
page: number
page_size: number
total: number
total_pages: number
}
export type ModelProvider = {
id: number
name: string
@@ -9,10 +17,39 @@ export type ModelProvider = {
image_egress_approved: boolean
image_egress_approved_by: string | null
image_egress_approved_at: string | null
credential_updated_at: string | null
credential_updated_by: string | null
archived_at: string | null
archived_by: string | null
deployment_count: number
active_deployment_count: number
effective_status: string
created_at: string
updated_at: string
}
export type StructuredOutputMetrics = {
initial_count: number
first_pass_count: number
first_pass_rate: number
repair_count: number
repair_rate: number
structural_failure_count: number
structural_failure_rate: number
invocation_count: number
input_tokens: number
output_tokens: number
duration_ms: number
repair_alert: boolean
repair_alert_threshold: number
model_invocation_count?: number
model_success_rate?: number
model_failure_rate?: number
model_input_tokens?: number
model_output_tokens?: number
model_duration_ms?: number
}
export type ModelDeployment = {
id: number
provider_id: number
@@ -22,14 +59,34 @@ export type ModelDeployment = {
capability: string
default_temperature: number
max_output_tokens: number
structured_output_mode: 'unconfigured' | 'native_json_schema' | 'json_object' | 'prompt_schema' | 'none'
structured_output_test_status: 'untested' | 'passed' | 'failed'
structured_output_verified_at: string | null
structured_output_test_message: string
structured_output_metrics?: StructuredOutputMetrics
enabled: boolean
effective_status?: string
provider_effective_status?: string
reference_summary?: {
agent_version_count: number
step_binding_count: number
run_step_count: number
invocation_count: number
}
archived_at?: string | null
archived_by?: string | null
archived_via_provider?: boolean
}
export type ModelInvocation = {
public_id: string
deployment_id: number | null
deployment_display_name: string | null
provider_name: string | null
invocation_type: 'model_test' | 'structured_output_test' | 'agent_step' | string
status: 'queued' | 'running' | 'succeeded' | 'failed'
model_snapshot: Record<string, unknown>
model_snapshot_summary: Record<string, unknown>
output_text: string
input_tokens: number | null
output_tokens: number | null
@@ -40,4 +97,30 @@ export type ModelInvocation = {
created_at: string
started_at: string | null
finished_at: string | null
diagnostics_scrubbed_at: string | null
diagnostics_expires_at: string | null
diagnostics_available: boolean
}
export type ModelInvocationDiagnostics = {
public_id: string
input_snapshot: Record<string, unknown>
output_text: string
diagnostics_scrubbed_at: string | null
diagnostics_expires_at: string
diagnostics_available: boolean
}
export type DeletionImpact = {
suggested_action: 'delete' | 'archive'
deployment_count: number
agent_version_count: number
step_binding_count: number
run_step_count: number
invocation_count: number
}
export type LifecycleResult = {
result: 'deleted' | 'archived'
impact: DeletionImpact
}

View File

@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { JsonEditor, parseJsonObject } from '@/features/ai-platform/components/JsonEditor'
@@ -29,6 +30,7 @@ export function SkillDetailPage({ skillId }: { skillId: number }) {
const [prompt, setPrompt] = React.useState('')
const [inputSchema, setInputSchema] = React.useState('{}')
const [outputSchema, setOutputSchema] = React.useState('{}')
const [outputEnforcement, setOutputEnforcement] = React.useState<SkillVersion['output_enforcement']>('validated_json')
const [exampleInput, setExampleInput] = React.useState('{}')
const [error, setError] = React.useState('')
@@ -42,6 +44,7 @@ export function SkillDetailPage({ skillId }: { skillId: number }) {
setPrompt(nextDraft.prompt_template)
setInputSchema(JSON.stringify(nextDraft.input_schema, null, 2))
setOutputSchema(JSON.stringify(nextDraft.output_schema, null, 2))
setOutputEnforcement(nextDraft.output_enforcement)
setExampleInput(JSON.stringify(nextDraft.example_input, null, 2))
}
setError('')
@@ -56,7 +59,7 @@ export function SkillDetailPage({ skillId }: { skillId: number }) {
try {
await Promise.all([
updateSkill(skillId, meta),
updateSkillDraft(skillId, { prompt_template: prompt, input_schema: parseJsonObject(inputSchema, '输入 Schema'), output_schema: parseJsonObject(outputSchema, '输出 Schema'), example_input: parseJsonObject(exampleInput, '示例输入') })
updateSkillDraft(skillId, { prompt_template: prompt, input_schema: parseJsonObject(inputSchema, '输入 Schema'), output_schema: parseJsonObject(outputSchema, '输出 Schema'), output_enforcement: outputEnforcement, example_input: parseJsonObject(exampleInput, '示例输入') })
])
toast.success('Skill 草稿已保存并通过校验'); await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') }
@@ -73,11 +76,11 @@ export function SkillDetailPage({ skillId }: { skillId: number }) {
return <AppPage header={<AppPageHeader title={detail.name} breadcrumbLabel={detail.code} description={`${detail.code} · 发布版本不可变,编辑操作始终写入草稿。`} meta={detail.active_version ? <Badge variant="success">当前 v{detail.active_version}</Badge> : <Badge variant="secondary">尚未发布</Badge>} actions={<>{draft ? <><Button variant="outline" onClick={() => void save()}><Save data-icon="inline-start" />保存草稿</Button><Button onClick={() => void publish()}><Send data-icon="inline-start" />发布 v{draft.version}</Button></> : <Button onClick={() => void createDraft()}><CopyPlus data-icon="inline-start" />创建新草稿</Button>}</>} />}>
<Tabs defaultValue="editor"><TabsList variant="line"><TabsTrigger value="editor">草稿编辑</TabsTrigger><TabsTrigger value="versions">版本记录</TabsTrigger></TabsList>
<TabsContent value="editor" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5">
<AppSection title={`基础信息 · 草稿 v${draft.version}`}><FieldGroup className="grid gap-3 md:grid-cols-2"><AppField label="名称"><Input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} /></AppField><AppField label="描述"><Input value={meta.description} onChange={(e) => setMeta({ ...meta, description: e.target.value })} /></AppField></FieldGroup></AppSection>
<AppSection title={`基础信息 · 草稿 v${draft.version}`}><FieldGroup className="grid gap-3 md:grid-cols-2"><AppField label="名称"><Input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} /></AppField><AppField label="描述"><Input value={meta.description} onChange={(e) => setMeta({ ...meta, description: e.target.value })} /></AppField><AppField label="最低输出保障" description="发布 Agent 时,模型能力必须达到该级别。"><Select value={outputEnforcement} onValueChange={(value) => setOutputEnforcement(value as SkillVersion['output_enforcement'])}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="native_json_schema">原生 JSON Schema</SelectItem><SelectItem value="json_object">JSON Object</SelectItem><SelectItem value="validated_json">校验型 JSON</SelectItem></SelectGroup></SelectContent></Select></AppField></FieldGroup></AppSection>
<AppSection title="Prompt 模板" description="仅允许 {{ input.* }} 变量,不支持表达式、循环或代码执行。"><AppField label="模板" htmlFor="skill-prompt"><Textarea id="skill-prompt" className="min-h-48 font-mono text-xs leading-5" value={prompt} onChange={(e) => setPrompt(e.target.value)} /></AppField></AppSection>
<div className="grid gap-5 xl:grid-cols-2"><JsonEditor id="skill-input-schema" label="输入 JSON Schema" value={inputSchema} onChange={setInputSchema} /><JsonEditor id="skill-output-schema" label="输出 JSON Schema" value={outputSchema} onChange={setOutputSchema} /><JsonEditor id="skill-example-input" label="示例输入" value={exampleInput} onChange={setExampleInput} rows={8} /></div>
</div> : <AppEmptyState title="当前没有可编辑草稿" description="从当前发布版本克隆一个新草稿后再编辑。" />}</MobileEditGuard></TabsContent>
<TabsContent value="versions" className="grid gap-3 pt-4">{detail.versions.map((version) => <Card key={version.id}><CardHeader><div className="flex items-center justify-between gap-3"><div><CardTitle>版本 v{version.version}</CardTitle><CardDescription>{version.published_at ? `发布于 ${formatDateTime(version.published_at)}` : `更新于 ${formatDateTime(version.updated_at)}`}</CardDescription></div><div className="flex items-center gap-2"><StatusBadge status={version.status} />{version.status === 'published' && detail.active_version !== version.version ? <Button variant="outline" size="sm" onClick={() => void rollback(version.version)}>回滚至此版本</Button> : null}</div></div></CardHeader><CardContent><p className="line-clamp-3 whitespace-pre-wrap font-mono text-xs text-muted-foreground">{version.prompt_template}</p></CardContent></Card>)}</TabsContent>
<TabsContent value="versions" className="grid gap-3 pt-4">{detail.versions.map((version) => <Card key={version.id}><CardHeader><div className="flex items-center justify-between gap-3"><div><CardTitle>版本 v{version.version}</CardTitle><CardDescription>{version.published_at ? `发布于 ${formatDateTime(version.published_at)}` : `更新于 ${formatDateTime(version.updated_at)}`}{version.structured_output_metrics?.initial_count ? ` · 首过 ${(version.structured_output_metrics.first_pass_rate * 100).toFixed(1)}% · 修复 ${(version.structured_output_metrics.repair_rate * 100).toFixed(1)}%` : ''}</CardDescription></div><div className="flex items-center gap-2">{version.structured_output_metrics?.repair_alert ? <Badge variant="destructive">修复率告警</Badge> : null}<Badge variant="outline">{version.output_enforcement === 'native_json_schema' ? '原生 JSON Schema' : version.output_enforcement === 'json_object' ? 'JSON Object' : '校验型 JSON'}</Badge><StatusBadge status={version.status} />{version.status === 'published' && detail.active_version !== version.version ? <Button variant="outline" size="sm" onClick={() => void rollback(version.version)}>回滚至此版本</Button> : null}</div></div></CardHeader><CardContent><p className="line-clamp-3 whitespace-pre-wrap font-mono text-xs text-muted-foreground">{version.prompt_template}</p></CardContent></Card>)}</TabsContent>
</Tabs>
</AppPage>
}

View File

@@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useIsMobile } from '@/hooks/use-mobile'
import { formatDateTime } from '@/lib/format'
@@ -21,7 +22,7 @@ import type { SkillDefinition } from './types'
export function SkillListPage() {
const mobile = useIsMobile()
const [items, setItems] = React.useState<SkillDefinition[]>([])
const [form, setForm] = React.useState({ code: '', name: '', description: '' })
const [form, setForm] = React.useState({ code: '', name: '', description: '', output_enforcement: 'validated_json' })
const [createOpen, setCreateOpen] = React.useState(false)
const [creating, setCreating] = React.useState(false)
const [createError, setCreateError] = React.useState('')
@@ -45,7 +46,7 @@ export function SkillListPage() {
setCreating(true)
setCreateError('')
try {
const created = await createSkill({ ...form, prompt_template: '请根据以下输入返回 JSON:\n{{ input.problem }}', input_schema: { type: 'object', properties: { problem: { type: 'string' } }, required: ['problem'], additionalProperties: false }, output_schema: { type: 'object' }, example_input: { problem: '示例问题' } })
const created = await createSkill({ ...form, prompt_template: '请根据以下输入返回 JSON:\n{{ input.problem }}', input_schema: { type: 'object', properties: { problem: { type: 'string' } }, required: ['problem'], additionalProperties: false }, output_schema: { type: 'object', properties: { result: { type: 'string' } }, required: ['result'], additionalProperties: false }, example_input: { problem: '示例问题' } })
toast.success('Skill 草稿已创建')
setCreateOpen(false)
window.location.hash = `/skills/${created.id}`
@@ -92,6 +93,12 @@ export function SkillListPage() {
<AppField label="描述" htmlFor="skill-description" description="说明该 Skill 解决的问题和适用范围。">
<Input id="skill-description" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="例如:将客户问题整理为标准字段" />
</AppField>
<AppField label="最低输出保障" description="决定该 Skill 可绑定的模型结构化输出能力。" required>
<Select value={form.output_enforcement} onValueChange={(value) => setForm({ ...form, output_enforcement: value })}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup><SelectItem value="native_json_schema">原生 JSON Schema</SelectItem><SelectItem value="json_object">JSON Object</SelectItem><SelectItem value="validated_json">校验型 JSON</SelectItem></SelectGroup></SelectContent>
</Select>
</AppField>
</FieldGroup>
</AppCreateDialog>

View File

@@ -5,6 +5,19 @@ export type SkillVersion = {
prompt_template: string
input_schema: Record<string, unknown>
output_schema: Record<string, unknown>
output_enforcement: 'native_json_schema' | 'json_object' | 'validated_json'
structured_output_metrics?: {
initial_count: number
first_pass_rate: number
repair_count: number
repair_rate: number
structural_failure_count: number
invocation_count: number
input_tokens: number
output_tokens: number
duration_ms: number
repair_alert: boolean
}
example_input: Record<string, unknown>
created_at: string
updated_at: string

View File

@@ -227,6 +227,7 @@ export function SupportAnalysisWorkspace({
error,
starting,
solutionBusy,
automated = false,
onStart,
onRecommendSolution,
onAdoptSolution
@@ -237,6 +238,7 @@ export function SupportAnalysisWorkspace({
error: string
starting: boolean
solutionBusy: boolean
automated?: boolean
onStart: () => void
onRecommendSolution: (solutionId: number) => void
onAdoptSolution: (solutionId: number) => void
@@ -274,10 +276,12 @@ export function SupportAnalysisWorkspace({
尚未运行支持分析。基于已发布知识、方案与已解决历史工单生成可追溯的排查建议。
</p>
</div>
<Button onClick={onStart} disabled={starting || readOnly}>
<BrainCircuit data-icon="inline-start" />
{starting ? '正在启动' : '开始分析'}
</Button>
{!automated ? (
<Button onClick={onStart} disabled={starting || readOnly}>
<BrainCircuit data-icon="inline-start" />
{starting ? '正在启动' : '开始分析'}
</Button>
) : null}
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<Badge variant="outline">问题描述已就绪</Badge>
@@ -345,7 +349,7 @@ export function SupportAnalysisWorkspace({
</SelectContent>
</Select>
) : null}
{!readOnly ? (
{!readOnly && !automated ? (
<Button
size="sm"
variant={selected.status === 'failed' ? 'default' : 'outline'}
@@ -477,7 +481,7 @@ export function SupportAnalysisWorkspace({
key={recommendation.solutionId}
recommendation={recommendation}
busy={solutionBusy}
readOnly={readOnly}
readOnly={readOnly || automated}
onRecommend={onRecommendSolution}
onAdopt={onAdoptSolution}
/>
@@ -523,15 +527,15 @@ export function SupportAnalysisWorkspace({
<div className="flex flex-col gap-3 rounded-lg border bg-muted/20 p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-medium">
{view.formalReplyTask ? '已生成客户回复任务' : '正在生成正式回复任务'}
{view.formalReplyTask ? '已生成客户回复任务' : '正式回复任务生成失败'}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{view.formalReplyTask
? '正式任务是唯一可编辑、发送和确认送达的回复来源。'
: output.customer_reply_draft || '分析结果已完成,等待回复任务同步。'}
: '分析结果已完成,但正式回复任务未生成;自动流程会明确失败并进入重试或人工接管。'}
</p>
</div>
{view.formalReplyTask ? (
{view.formalReplyTask && !automated ? (
<Button
size="sm"
variant="outline"

View File

@@ -0,0 +1,385 @@
import * as React from 'react'
import {
AlertTriangle,
Check,
CheckCircle2,
Clock3,
Copy,
RefreshCw,
Send,
ShieldAlert
} from 'lucide-react'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { listChannelEndpoints } from '@/features/channels/api'
import type { ChannelEndpoint } from '@/features/channels/types'
import { formatDateTime } from '@/lib/format'
import type {
CustomerReplyTask,
Ticket,
TicketAutomationApproveInput,
TicketAutomationRun,
TicketSupportAnalysis
} from './types'
const STEP_LABELS: Record<string, string> = {
accept_intake: '核对受理信息',
start_processing: '进入自动处理',
support_analysis: '执行支持分析',
approve_and_send: '等待批准方案并发送',
wait_delivery: '等待渠道送达',
delivery_failed: '渠道投递失败',
wait_delivery_callback: '确认回复送达',
wait_customer: '等待客户反馈',
closed: '流程已完成',
collect_input: '等待关键资料',
human_takeover: '等待人工接管'
}
function automationTitle(run: TicketAutomationRun) {
if (run.status === 'waiting_approval') return '方案与回复已准备好'
if (run.status === 'waiting_delivery') return '回复正在投递'
if (run.status === 'waiting_customer') return '已解决,等待客户反馈'
if (run.status === 'completed') return '自动闭环已完成'
if (run.status === 'waiting_input') return '需要一次补充关键信息'
if (run.status === 'failed') return '自动处理需要人工接管'
if (run.status === 'cancelled') return '自动处理已停止'
return '系统正在自动处理'
}
function countdownLabel(nextActionAt: string | null) {
if (!nextActionAt) return ''
const milliseconds = new Date(nextActionAt).getTime() - Date.now()
if (milliseconds <= 0) return '即将自动关闭'
const hours = Math.floor(milliseconds / 3_600_000)
const minutes = Math.ceil((milliseconds % 3_600_000) / 60_000)
return hours > 0 ? `${hours} 小时 ${minutes} 分钟后自动关闭` : `${minutes} 分钟后自动关闭`
}
export function TicketAutomationPanel({
ticket,
run,
replyTask,
analysis,
canApprove,
canSendChannel,
busy,
onApprove,
onAction
}: {
ticket: Ticket
run: TicketAutomationRun
replyTask: CustomerReplyTask | null
analysis: TicketSupportAnalysis | null
canApprove: boolean
canSendChannel: boolean
busy: boolean
onApprove: (payload: TicketAutomationApproveInput) => Promise<void>
onAction: (action: 'retry' | 'pause' | 'resume' | 'take_over') => Promise<void>
}) {
const [approvalOpen, setApprovalOpen] = React.useState(false)
const [finalText, setFinalText] = React.useState('')
const [resolutionText, setResolutionText] = React.useState('')
const [rootCause, setRootCause] = React.useState('')
const [manualConfirmed, setManualConfirmed] = React.useState(false)
const [endpoints, setEndpoints] = React.useState<ChannelEndpoint[]>([])
const [deliveryMode, setDeliveryMode] = React.useState<'channel' | 'manual'>('manual')
const [endpointId, setEndpointId] = React.useState('')
const [conversationId, setConversationId] = React.useState('')
React.useEffect(() => {
if (!approvalOpen) return
setFinalText(replyTask?.final_text || replyTask?.draft_text || '')
setResolutionText(
analysis?.final_output?.analysis_summary
|| ticket.resolution
|| ''
)
setRootCause(ticket.resolution_root_cause || '')
setManualConfirmed(false)
if (canSendChannel) {
listChannelEndpoints()
.then((items) => {
const enabled = items.filter((item) => item.enabled)
setEndpoints(enabled)
setEndpointId(String(enabled[0]?.id ?? ''))
setDeliveryMode(enabled.length ? 'channel' : 'manual')
})
.catch(() => {
setEndpoints([])
setDeliveryMode('manual')
})
}
}, [analysis, approvalOpen, canSendChannel, replyTask, ticket])
const blocking = run.blocking_questions.length
? run.blocking_questions.join(';')
: run.last_error_message
const waitingCustomer = run.status === 'waiting_customer'
const currentStep = STEP_LABELS[run.current_step] ?? '继续自动处理'
async function submitApproval() {
if (!replyTask) return
await onApprove({
expected_ticket_updated_at: ticket.updated_at,
expected_reply_revision: replyTask.revision,
final_text: finalText,
resolution_text: resolutionText,
root_cause: rootCause,
solution_id: analysis?.automation_decision.solution_id ?? null,
channel_endpoint_id: deliveryMode === 'channel' ? Number(endpointId) : null,
external_conversation_id: deliveryMode === 'channel' ? conversationId.trim() : '',
manual_delivery_confirmed: deliveryMode === 'manual' && manualConfirmed
})
setApprovalOpen(false)
}
async function copyReply() {
await navigator.clipboard.writeText(finalText)
toast.success('客户回复已复制')
}
return (
<>
<div
className={`grid gap-4 rounded-xl border p-4 sm:p-5 ${
run.display_phase === 'attention'
? 'border-warning/40 bg-warning/10'
: run.status === 'completed'
? 'border-success/40 bg-success/10'
: 'bg-muted/20'
}`}
aria-live="polite"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 gap-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full border bg-background">
{run.status === 'completed' ? (
<CheckCircle2 className="size-4 text-success" />
) : run.display_phase === 'attention' ? (
<ShieldAlert className="size-4 text-warning" />
) : waitingCustomer ? (
<Clock3 className="size-4 text-info" />
) : (
<RefreshCw className={`size-4 ${run.display_phase === 'processing' ? 'animate-spin' : ''}`} />
)}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold">{automationTitle(run)}</h2>
<Badge variant="outline">{currentStep}</Badge>
</div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{blocking
|| (waitingCustomer
? countdownLabel(run.next_action_at)
: '字段判断、状态推进、支持分析和回复准备由系统连续完成。')}
</p>
{run.retry_count > 0 ? (
<p className="mt-1 text-xs text-muted-foreground">已自动重试 {run.retry_count} 次</p>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{run.status === 'waiting_approval' ? (
<Button
onClick={() => setApprovalOpen(true)}
disabled={!canApprove || !replyTask || busy}
>
<Send data-icon="inline-start" />
批准方案并发送
</Button>
) : null}
{run.display_phase === 'attention' ? (
<>
<Button variant="outline" disabled={busy} onClick={() => void onAction('retry')}>
<RefreshCw data-icon="inline-start" />
重试自动处理
</Button>
<Button disabled={busy} onClick={() => void onAction('take_over')}>
转人工处理
</Button>
</>
) : null}
{waitingCustomer ? (
<Button variant="outline" disabled={busy} onClick={() => void onAction('take_over')}>
恢复人工处理
</Button>
) : null}
</div>
</div>
{run.status === 'waiting_approval' && !replyTask ? (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>正式回复任务未生成</AlertTitle>
<AlertDescription>自动流程不会显示假加载状态,请重试或转人工处理。</AlertDescription>
</Alert>
) : null}
{run.status === 'waiting_customer' && run.next_action_at ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs text-muted-foreground">
<span>客户认可会立即关闭;负向反馈会自动恢复处理。</span>
<span>计划关闭:{formatDateTime(run.next_action_at)}</span>
</div>
) : null}
</div>
<Dialog open={approvalOpen} onOpenChange={setApprovalOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>批准方案并发送</DialogTitle>
<DialogDescription>
这是正常流程中唯一一次人工确认。提交后将冻结解决结论与回复版本,并在确认送达后自动标记解决。
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-2">
<div className="grid gap-2">
<Label htmlFor="automation-resolution">解决结论</Label>
<Textarea
id="automation-resolution"
value={resolutionText}
onChange={(event) => setResolutionText(event.target.value)}
rows={4}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="automation-root-cause">根因(可选)</Label>
<Textarea
id="automation-root-cause"
value={rootCause}
onChange={(event) => setRootCause(event.target.value)}
rows={2}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="automation-reply">最终客户回复</Label>
<Button type="button" size="sm" variant="ghost" onClick={() => void copyReply()}>
<Copy data-icon="inline-start" />
复制
</Button>
</div>
<Textarea
id="automation-reply"
value={finalText}
onChange={(event) => setFinalText(event.target.value)}
rows={8}
/>
<p className="text-xs text-muted-foreground">当前回复版本:v{replyTask?.revision ?? '-'}</p>
</div>
{endpoints.length ? (
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={deliveryMode === 'channel' ? 'default' : 'outline'}
onClick={() => setDeliveryMode('channel')}
>
平台渠道发送
</Button>
<Button
type="button"
size="sm"
variant={deliveryMode === 'manual' ? 'default' : 'outline'}
onClick={() => setDeliveryMode('manual')}
>
已在外部发送
</Button>
</div>
{deliveryMode === 'channel' ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<Label>发送渠道</Label>
<Select value={endpointId} onValueChange={setEndpointId}>
<SelectTrigger><SelectValue placeholder="选择渠道" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{endpoints.map((endpoint) => (
<SelectItem key={endpoint.id} value={String(endpoint.id)}>
{endpoint.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="automation-conversation">外部会话 ID</Label>
<Input
id="automation-conversation"
value={conversationId}
onChange={(event) => setConversationId(event.target.value)}
placeholder="例如:group-20260727-001"
/>
</div>
</div>
) : null}
</div>
) : null}
{deliveryMode === 'manual' ? (
<label className="flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-sm">
<input
type="checkbox"
className="mt-0.5 size-4 accent-primary"
checked={manualConfirmed}
onChange={(event) => setManualConfirmed(event.target.checked)}
/>
<span>
<span className="font-medium">我已在外部渠道完成发送</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
无平台渠道时使用合并确认;接入渠道后由受控队列回传送达状态。
</span>
</span>
</label>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setApprovalOpen(false)}>取消</Button>
<Button
onClick={() => void submitApproval()}
disabled={
busy
|| !replyTask
|| (deliveryMode === 'manual' && !manualConfirmed)
|| (deliveryMode === 'channel' && (!endpointId || !conversationId.trim()))
|| !finalText.trim()
|| !resolutionText.trim()
}
>
<Check data-icon="inline-start" />
{deliveryMode === 'channel' ? '批准并进入发送队列' : '批准并确认已发送'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -149,7 +149,8 @@ describe('SupportAnalysisPanel', () => {
expect(screen.getByText('现象与连接池耗尽的已发布方案一致。')).toBeInTheDocument()
expect(screen.getByText('检查连接池占用')).toBeInTheDocument()
expect(screen.getByText('我们正在检查订单服务连接池状态。')).toBeInTheDocument()
expect(screen.getByText('正式回复任务生成失败')).toBeInTheDocument()
expect(screen.getByText(/自动流程会明确失败/)).toBeInTheDocument()
expect(screen.getByText('需要人工处理')).toBeInTheDocument()
expect(screen.getByText(/Agent 未建议自动解决/)).toBeInTheDocument()
expect(screen.getAllByText('订单连接池恢复方案')).toHaveLength(2)
@@ -179,11 +180,11 @@ describe('SupportAnalysisPanel', () => {
describe('工单连续卷宗派生状态', () => {
it.each([
['pending_info', 0, '立即补全受理信息'],
['pending', 0, '先分派处理人'],
['pending', 0, '进入排查处理'],
['in_progress', 1, '完成支持分析并记录排查结果'],
['pending_confirm', 2, '编辑回复并完成客户确认'],
['resolved', 3, '确认解决结论并沉淀知识'],
['closed', 4, '复核关闭记录与知识资产']
['resolved', 2, '确认解决结论并沉淀知识'],
['closed', 2, '复核关闭记录与知识资产']
] as const)('为 %s 显示阶段 %s 和当前任务', (status, index, title) => {
const ticket = { ...activeTicket, status, assignee: null } as Ticket
expect(getTicketDossierStageIndex(status)).toBe(index)

View File

@@ -43,7 +43,10 @@ import { formatDateTime } from '@/lib/format'
import {
confirmCustomerReplySent,
actOnTicketAutomation,
approveTicketAutomationAndSend,
getTicket,
getTicketAutomation,
getTicketIntakeTrace,
listCustomerReplyTasks,
listTicketAttachments,
@@ -54,10 +57,12 @@ import {
listTicketSupportAnalyses,
startTicketSupportAnalysis,
startTicketKnowledgeDistillation,
subscribeTicketAutomationEvents,
transitionTicket,
updateCustomerReplyTask
} from './api'
import { SupportAnalysisWorkspace } from './SupportAnalysisWorkspace'
import { TicketAutomationPanel } from './TicketAutomationPanel'
import { TicketActivityPanel } from './TicketActivityPanel'
import { TicketCollaborationPanel } from './TicketCollaborationPanel'
import { TicketEditSheet } from './TicketEditSheet'
@@ -67,6 +72,8 @@ import type {
CustomerReplyTask,
Ticket,
TicketAttachment,
TicketAutomationApproveInput,
TicketAutomationRun,
TicketEvent,
TicketFieldEvidence,
TicketIntakeTrace,
@@ -152,17 +159,54 @@ function AttachmentStatus({ attachment }: { attachment: TicketAttachment }) {
return <Badge variant="success"><ShieldCheck />扫描通过</Badge>
}
const DOSSIER_STAGES = ['信息确认', '排查处理', '客户确认', '解决关闭', '知识沉淀'] as const
const DOSSIER_STAGES = ['受理完成', '自动处理中', '确认与结束'] as const
export function getTicketDossierStageIndex(status: TicketStatus) {
if (status === 'pending_info' || status === 'pending') return 0
if (status === 'in_progress') return 1
if (status === 'pending_confirm') return 2
if (status === 'resolved') return 3
return 4
return 2
}
export function getTicketCurrentTask(ticket: Ticket, missingFields: string[]) {
export function getTicketCurrentTask(
ticket: Ticket,
missingFields: string[],
automation: TicketAutomationRun | null = null
) {
if (automation) {
if (automation.status === 'waiting_approval') {
return {
title: '批准方案并发送',
description: '支持分析、解决建议与正式客户回复已经准备完成,这是正常路径中唯一一次人工确认。',
tone: 'default' as const
}
}
if (automation.status === 'waiting_customer') {
return {
title: '等待客户反馈并自动关闭',
description: '客户认可会立即关闭;无异议窗口结束后由系统关闭,负向反馈会恢复处理。',
tone: 'success' as const
}
}
if (automation.display_phase === 'attention') {
return {
title: '自动处理需要人工关注',
description: automation.last_error_message || automation.blocking_questions.join(';'),
tone: 'warning' as const
}
}
if (automation.status === 'completed') {
return {
title: '工单已自动闭环',
description: '最终结论、批准记录、送达证据与关闭原因均已进入审计记录。',
tone: 'success' as const
}
}
return {
title: '系统正在连续处理',
description: '无需手动分派、开始处理或启动分析,完成后只需一次批准发送。',
tone: 'default' as const
}
}
if (ticket.status === 'pending_info') {
return {
title: '立即补全受理信息',
@@ -174,10 +218,8 @@ export function getTicketCurrentTask(ticket: Ticket, missingFields: string[]) {
}
if (ticket.status === 'pending') {
return {
title: ticket.assignee ? '开始排查处理' : '先分派处理人',
description: ticket.assignee
? '资料已进入待处理队列,可以开始支持分析或记录人工排查。'
: '当前工单尚未分派,明确责任人后再进入排查处理。',
title: '进入排查处理',
description: '负责人不再是处理前置条件;只有人工接管时才需要按工作组路由。',
tone: 'default' as const
}
}
@@ -343,6 +385,8 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
const [supportAnalyses, setSupportAnalyses] = React.useState<TicketSupportAnalysis[]>([])
const [knowledgeDistillations, setKnowledgeDistillations] = React.useState<TicketKnowledgeDistillation[]>([])
const [replyTasks, setReplyTasks] = React.useState<CustomerReplyTask[]>([])
const [automation, setAutomation] = React.useState<TicketAutomationRun | null>(null)
const [automationBusy, setAutomationBusy] = React.useState(false)
const [channelReplyTask, setChannelReplyTask] = React.useState<CustomerReplyTask | null>(null)
const [supportAnalysisError, setSupportAnalysisError] = React.useState('')
const [startingSupportAnalysis, setStartingSupportAnalysis] = React.useState(false)
@@ -372,6 +416,13 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
user?.is_superuser || user?.permissions.includes('ticket.assign')
)
const canConfirmReply = Boolean(user?.is_superuser || user?.permissions.includes('ticket.reply'))
const canApproveAutomation = Boolean(
user?.is_superuser
|| (
user?.permissions.includes('ticket.reply')
&& user?.permissions.includes('ticket.resolve')
)
)
const canSendChannelReply = Boolean(
user?.is_superuser
|| (
@@ -383,6 +434,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
React.useEffect(() => {
refreshTicket()
refreshEvents()
getTicketAutomation(ticketId).then(setAutomation).catch(() => setAutomation(null))
setMaterialsLoaded(false)
setKnowledgeLoaded(false)
setFieldEvidence([])
@@ -396,6 +448,32 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
listSimilarTickets(ticketId).then(setSimilarTickets).catch(() => setSimilarTickets([]))
}, [ticketId])
React.useEffect(() => {
if (!automation?.public_id) return
let lastSequence = 0
// 自动化 SSE 只承担“有变化”通知,具体页面状态仍从聚合接口刷新,避免拼接局部事件产生漂移。
return subscribeTicketAutomationEvents(automation.public_id, {
after: lastSequence,
onEvent: (event) => {
lastSequence = Math.max(lastSequence, event.sequence)
Promise.all([
getTicketAutomation(ticketId),
getTicket(ticketId),
listTicketSupportAnalyses(ticketId),
listCustomerReplyTasks(ticketId)
])
.then(([nextAutomation, nextTicket, analyses, tasks]) => {
setAutomation(nextAutomation)
setTicket(nextTicket)
setSupportAnalyses(analyses)
setReplyTasks(tasks)
})
.catch(() => undefined)
refreshEvents()
}
})
}, [automation?.public_id, ticketId])
React.useEffect(() => {
let cancelled = false
async function refreshSupportData() {
@@ -666,6 +744,45 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
refreshEvents()
}
async function handleAutomationApproval(payload: TicketAutomationApproveInput) {
setAutomationBusy(true)
try {
const updated = await approveTicketAutomationAndSend(ticketId, payload)
setAutomation(updated)
await Promise.all([refreshReplyTasks(), getTicket(ticketId).then(setTicket)])
refreshEvents()
toast.success('方案与回复已冻结,工单将在送达后自动标记解决')
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : '批准发送失败')
const [nextAutomation, nextTicket] = await Promise.all([
getTicketAutomation(ticketId),
getTicket(ticketId)
])
setAutomation(nextAutomation)
setTicket(nextTicket)
throw reason
} finally {
setAutomationBusy(false)
}
}
async function handleAutomationAction(
action: 'retry' | 'pause' | 'resume' | 'take_over'
) {
setAutomationBusy(true)
try {
const updated = await actOnTicketAutomation(ticketId, action)
setAutomation(updated)
await getTicket(ticketId).then(setTicket)
refreshEvents()
toast.success(action === 'take_over' ? '已转为人工处理' : '自动处理状态已更新')
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : '自动化操作失败')
} finally {
setAutomationBusy(false)
}
}
if (error) {
return (
<AppPage maxWidth="6xl" header={<AppPageHeader title="工单详情" />}>
@@ -684,15 +801,12 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
const nextStatuses = getTicketNextStatuses(ticket.category_code, ticket.status)
const intakeMissingFields = missingInfoTask?.payload.missing_fields ?? getIntakeMissingFields(events)
const task = getTicketCurrentTask(ticket, intakeMissingFields)
const task = getTicketCurrentTask(ticket, intakeMissingFields, automation)
const stageIndex = getTicketDossierStageIndex(ticket.status)
const latestSupportAnalysis = supportAnalyses[0]
const primaryAction = getTicketPrimaryAction(
ticket,
supportAnalyses,
nextStatuses,
replyTasks
)
const primaryAction = automation
? null
: getTicketPrimaryAction(ticket, supportAnalyses, nextStatuses, replyTasks)
const preferredStatus = primaryAction?.kind === 'transition' ? primaryAction.status : undefined
const PrimaryStatusIcon = preferredStatus ? STATUS_ACTIONS[preferredStatus]?.icon : undefined
const defaultLinkedSolution = selectDefaultTicketSolution(linkedSolutions)
@@ -776,7 +890,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
<PencilLine />
编辑信息
</DropdownMenuItem>
{nextStatuses
{!automation ? nextStatuses
.filter(
(status) =>
!(primaryAction?.kind === 'transition' && status === preferredStatus)
@@ -793,7 +907,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
{getStatusActionLabel(ticket.status, status)}
</DropdownMenuItem>
)
})}
}) : null}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
@@ -808,7 +922,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
/>}
>
<ol
className="grid grid-cols-5 overflow-hidden rounded-xl border bg-card"
className="grid grid-cols-3 overflow-hidden rounded-xl border bg-card"
aria-label="工单处理阶段"
>
{DOSSIER_STAGES.map((stage, index) => (
@@ -856,44 +970,66 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
<Separator />
<section className="px-4 py-4 sm:px-6" aria-labelledby="current-task-title">
<div
className={`flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between ${
task.tone === 'warning'
? 'border-warning/40 bg-warning/10'
: task.tone === 'success'
? 'border-success/40 bg-success/10'
: 'bg-muted/25'
}`}
>
<div className="flex items-start gap-3">
<ClipboardList className="mt-0.5 size-5 shrink-0" />
<div>
<h2 id="current-task-title" className="font-semibold">{task.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{task.description}</p>
{automation ? (
<TicketAutomationPanel
ticket={ticket}
run={automation}
replyTask={
replyTasks.find((item) => item.id === automation.reply_task_id)
?? replyTasks[0]
?? null
}
analysis={
supportAnalyses.find((item) => item.id === automation.support_analysis_id)
?? supportAnalyses[0]
?? null
}
canApprove={canApproveAutomation}
canSendChannel={canSendChannelReply}
busy={automationBusy}
onApprove={handleAutomationApproval}
onAction={handleAutomationAction}
/>
) : (
<div
className={`flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between ${
task.tone === 'warning'
? 'border-warning/40 bg-warning/10'
: task.tone === 'success'
? 'border-success/40 bg-success/10'
: 'bg-muted/25'
}`}
>
<div className="flex items-start gap-3">
<ClipboardList className="mt-0.5 size-5 shrink-0" />
<div>
<h2 id="current-task-title" className="font-semibold">{task.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{task.description}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{missingInfoTask ? <Badge variant="secondary">任务中心已登记</Badge> : null}
{ticket.status === 'pending_info' ? (
<Button size="sm" variant="outline" onClick={() => setEditSheetOpen(true)}>
<PencilLine data-icon="inline-start" />
补全信息
</Button>
) : ticket.status === 'pending_confirm' && replyTasks.length ? (
<Button
size="sm"
variant="outline"
onClick={() =>
document.getElementById('customer-reply-section')?.scrollIntoView({
block: 'start'
})
}
>
编辑客户回复
</Button>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{missingInfoTask ? <Badge variant="secondary">任务中心已登记</Badge> : null}
{ticket.status === 'pending_info' ? (
<Button size="sm" variant="outline" onClick={() => setEditSheetOpen(true)}>
<PencilLine data-icon="inline-start" />
补全信息
</Button>
) : ticket.status === 'pending_confirm' && replyTasks.length ? (
<Button
size="sm"
variant="outline"
onClick={() =>
document.getElementById('customer-reply-section')?.scrollIntoView({
block: 'start'
})
}
>
编辑客户回复
</Button>
) : null}
</div>
</div>
)}
</section>
<Separator />
@@ -909,13 +1045,14 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
error={supportAnalysisError}
starting={startingSupportAnalysis}
solutionBusy={solutionBusy}
automated={Boolean(automation)}
onStart={() => void handleStartSupportAnalysis()}
onRecommendSolution={(solutionId) => void handleRecommendSolution(solutionId)}
onAdoptSolution={(solutionId) => void handleAdoptSolution(solutionId)}
/>
</section>
{replyTasks.length ? (
{!automation && replyTasks.length ? (
<>
<Separator />
<div id="customer-reply-section" className="px-4 py-6 sm:px-6">

View File

@@ -1,9 +1,12 @@
import { apiRequest } from '@/lib/api'
import { apiRequest, apiUrl } from '@/lib/api'
import type {
CustomerReplyTask,
Ticket,
TicketAttachment,
TicketAutomationApproveInput,
TicketAutomationEvent,
TicketAutomationRun,
TicketCollaborator,
TicketCollaboratorRole,
TicketComment,
@@ -97,6 +100,78 @@ export function listCustomerReplyTasks(ticketId: number) {
return apiRequest<CustomerReplyTask[]>(`/tickets/${ticketId}/reply-tasks`)
}
export function getTicketAutomation(ticketId: number) {
return apiRequest<TicketAutomationRun | null>(`/tickets/${ticketId}/automation`)
}
export function approveTicketAutomationAndSend(
ticketId: number,
payload: TicketAutomationApproveInput
) {
return apiRequest<TicketAutomationRun>(`/tickets/${ticketId}/approve-and-send`, {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function actOnTicketAutomation(
ticketId: number,
action: 'retry' | 'pause' | 'resume' | 'take_over'
) {
return apiRequest<TicketAutomationRun>(`/tickets/${ticketId}/automation/actions`, {
method: 'POST',
body: JSON.stringify({ action })
})
}
const TICKET_AUTOMATION_EVENT_TYPES = [
'queued',
'intake_accepted',
'processing_started',
'analysis_started',
'analysis_retrying',
'waiting_input',
'waiting_approval',
'reply_queued',
'manual_delivery_confirmed',
'waiting_customer',
'customer_negative_feedback',
'customer_feedback_uncertain',
'completed',
'failed',
'cancelled'
]
export function subscribeTicketAutomationEvents(
publicId: string,
options: {
after?: number
onEvent: (event: TicketAutomationEvent) => void
onOpen?: () => void
onError?: () => void
}
) {
// EventSource 会在同一连接重试时自动携带 Last-Event-ID;页面恢复时使用 after 补齐。
const query = options.after ? `?after=${options.after}` : ''
const source = new EventSource(
apiUrl(`/ticket-automation-runs/${publicId}/events${query}`),
{ withCredentials: true }
)
const handleEvent = (message: MessageEvent<string>) => {
try {
options.onEvent(JSON.parse(message.data) as TicketAutomationEvent)
} catch {
options.onError?.()
}
}
TICKET_AUTOMATION_EVENT_TYPES.forEach((eventType) => {
source.addEventListener(eventType, handleEvent)
})
source.onopen = () => options.onOpen?.()
source.onerror = () => options.onError?.()
return () => source.close()
}
export function updateCustomerReplyTask(
ticketId: number,
replyTaskId: number,

View File

@@ -100,6 +100,8 @@ export type TicketDraft = {
message_timeline: TicketDraftTimelineItem[]
missing_fields: string[]
questions: string[]
blocking_missing_fields?: string[]
blocking_questions?: string[]
guided_rounds: number
agent_run_public_id: string | null
agent_progress: TicketDraftAgentProgress

View File

@@ -38,7 +38,7 @@ export type SupportAnalysisViewModel = {
citedCount: number
formalReplyTask: CustomerReplyTask | null
automation: {
status: 'eligible' | 'manual_recommendation' | 'auto_resolved' | 'unavailable'
status: 'eligible' | 'manual_recommendation' | 'waiting_approval' | 'auto_resolved' | 'unavailable'
label: string
primaryReason: string
checks: Array<{ key: string; label: string; passed: boolean }>
@@ -135,6 +135,7 @@ function automationView(analysis: TicketSupportAnalysis | null) {
decision?.status ?? 'unavailable'
const labels = {
eligible: '可自动解决',
waiting_approval: '等待一次批准',
auto_resolved: '已自动解决',
manual_recommendation: '需要人工处理',
unavailable: '尚未完成策略判断'
@@ -148,6 +149,8 @@ function automationView(analysis: TicketSupportAnalysis | null) {
? REASON_LABELS[firstReason] ?? firstReason
: status === 'eligible'
? '全部自动化检查已通过'
: status === 'waiting_approval'
? '方案与风险检查已冻结,等待技术支持一次确认'
: status === 'auto_resolved'
? '平台已按受控策略应用方案'
: '等待分析结果与策略检查',

View File

@@ -56,6 +56,7 @@ export type Ticket = {
current_result: string
requester_id: number | null
assignee_id: number | null
follow_up_of_id?: number | null
requester: TicketUser | null
assignee: TicketUser | null
problem_started_at: string | null
@@ -112,6 +113,7 @@ export type TicketSupportAnalysis = {
run_public_id: string
agent_version: number
status: string
result_mode?: 'trusted' | 'manual_only'
progress_stage: string
progress_message: string
query: string
@@ -120,7 +122,7 @@ export type TicketSupportAnalysis = {
final_output: SupportAnalysisOutput | null
automation_decision: {
policy_version?: string
status?: 'eligible' | 'manual_recommendation' | 'auto_resolved'
status?: 'eligible' | 'manual_recommendation' | 'waiting_approval' | 'auto_resolved'
threshold?: number
solution_id?: number | null
score?: number
@@ -138,6 +140,59 @@ export type TicketSupportAnalysis = {
finished_at: string | null
}
export type TicketAutomationStatus =
| 'queued'
| 'running'
| 'waiting_input'
| 'waiting_approval'
| 'waiting_delivery'
| 'waiting_customer'
| 'completed'
| 'failed'
| 'cancelled'
export type TicketAutomationRun = {
public_id: string
ticket_id: number
ticket_status: TicketStatus
status: TicketAutomationStatus
current_step: string
display_phase: 'processing' | 'confirmation' | 'completed' | 'attention'
next_human_action: '' | 'approve_and_send' | 'take_over'
blocking_missing_fields: string[]
blocking_questions: string[]
retry_count: number
last_error_code: string
last_error_message: string
support_analysis_id: number | null
reply_task_id: number | null
next_action_at: string | null
approved_at: string | null
started_at: string | null
finished_at: string | null
created_at: string
updated_at: string
}
export type TicketAutomationApproveInput = {
expected_ticket_updated_at: string
expected_reply_revision: number
final_text: string
resolution_text: string
root_cause?: string
solution_id?: number | null
channel_endpoint_id?: number | null
external_conversation_id?: string
manual_delivery_confirmed?: boolean
}
export type TicketAutomationEvent = {
sequence: number
event_type: string
payload: Record<string, unknown>
created_at: string
}
export type KnowledgeDistillationOutput = {
should_distill: boolean
reason: string

View File

@@ -25,9 +25,14 @@ async function readError(response: Response) {
} catch {
payload = null
}
const detail = payload && typeof payload === 'object' && 'detail' in payload
? String((payload as { detail: unknown }).detail)
: `API 请求失败(${response.status})`
const detailValue = payload && typeof payload === 'object' && 'detail' in payload
? (payload as { detail: unknown }).detail
: null
const detail = typeof detailValue === 'string'
? detailValue
: detailValue && typeof detailValue === 'object' && 'message' in detailValue
? `${'code' in detailValue ? `${String(detailValue.code)}: ` : ''}${String(detailValue.message)}`
: `API 请求失败(${response.status})`
return new ApiError(response.status, detail, payload)
}