feat: init commint
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
69
frontend/src/features/agents/AgentRunListPage.test.tsx
Normal file
69
frontend/src/features/agents/AgentRunListPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
385
frontend/src/features/tickets/TicketAutomationPanel.tsx
Normal file
385
frontend/src/features/tickets/TicketAutomationPanel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
? '平台已按受控策略应用方案'
|
||||
: '等待分析结果与策略检查',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user