feat: complete multimodal ticket intake

This commit is contained in:
hailin
2026-07-27 18:59:28 +08:00
parent 96452934a3
commit 93065c0a00
39 changed files with 6443 additions and 267 deletions

View File

@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TicketDraft } from './draft-types'
import { TicketCreatePage } from './TicketCreatePage'
import type { Ticket } from './types'
const mocks = vi.hoisted(() => ({
mobile: false,
@@ -12,13 +13,15 @@ const mocks = vi.hoisted(() => ({
listDrafts: vi.fn(),
createDraft: vi.fn(),
analyzeDraft: vi.fn(),
cancelDraft: vi.fn(),
getDraft: vi.fn(),
updateDraft: vi.fn(),
answerDraft: vi.fn(),
confirmDraft: vi.fn(),
rejectDraft: vi.fn(),
uploadImage: vi.fn(),
deleteImage: vi.fn()
subscribeEvents: vi.fn(),
uploadMaterial: vi.fn(),
deleteMaterial: vi.fn()
}))
vi.mock('@/hooks/use-mobile', () => ({ useIsMobile: () => mocks.mobile }))
@@ -28,22 +31,27 @@ vi.mock('./draft-api', () => ({
listTicketDrafts: mocks.listDrafts,
createTicketDraft: mocks.createDraft,
analyzeTicketDraft: mocks.analyzeDraft,
cancelTicketDraftAnalysis: mocks.cancelDraft,
getTicketDraft: mocks.getDraft,
updateTicketDraft: mocks.updateDraft,
answerTicketDraft: mocks.answerDraft,
confirmTicketDraft: mocks.confirmDraft,
rejectTicketDraft: mocks.rejectDraft,
uploadTicketDraftImage: mocks.uploadImage,
deleteTicketDraftImage: mocks.deleteImage
subscribeAgentRunEvents: mocks.subscribeEvents,
uploadTicketDraftMaterial: mocks.uploadMaterial,
deleteTicketDraftMaterial: mocks.deleteMaterial
}))
function draft(status: TicketDraft['status'], revision: number): TicketDraft {
const hasFields = ['needs_input', 'ticket_created', 'ready', 'confirmed'].includes(status)
return {
public_id: 'draft-1',
status,
revision,
source_text: '生产采集任务未更新',
fields: status === 'ready' ? {
problem_valid: hasFields,
invalid_reason: '',
fields: hasFields ? {
title: '生产采集任务未更新',
description: '生产环境采集任务两小时没有更新',
category_code: 'technical_issue',
@@ -57,15 +65,28 @@ function draft(status: TicketDraft['status'], revision: number): TicketDraft {
related_system: '数据中台'
} : {},
last_ai_fields: {},
field_evidence: {},
message_timeline: [],
missing_fields: [],
questions: [],
guided_rounds: 0,
agent_run_public_id: null,
agent_progress: {
stage: hasFields ? 'completed' : 'idle',
message: hasFields ? '受理完成' : '等待开始分析',
current_step: '',
total_steps: 2,
retry_count: 0,
elapsed_ms: 0,
slow: false
},
materials: [],
images: [],
messages: [],
decision: '',
error_code: '',
error_message: '',
created_ticket: null,
confirmed_ticket: null,
created_at: '2026-07-16T00:00:00Z',
updated_at: '2026-07-16T00:00:00Z',
@@ -79,38 +100,181 @@ describe('可信 AI 建单页', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.mobile = false
mocks.availability.mockResolvedValue({ available: true, reason: '', vision_available: false, vision_reason: '当前模型不支持图片' })
mocks.listDrafts.mockResolvedValue([])
mocks.createDraft.mockResolvedValue(draft('editing', 1))
mocks.analyzeDraft.mockResolvedValue(draft('ready', 2))
mocks.confirmDraft.mockResolvedValue({
...draft('confirmed', 3),
decision: 'accepted',
confirmed_ticket: { id: 19 }
mocks.availability.mockResolvedValue({
available: true,
reason: '',
vision_available: false,
vision_reason: '当前模型不支持图片',
max_file_size: 20 * 1024 * 1024,
max_files: 10,
max_total_size: 100 * 1024 * 1024,
supported_extensions: ['jpg', 'jpeg', 'png', 'webp', 'pdf', 'txt', 'log', 'docx', 'xlsx', 'pptx']
})
mocks.listDrafts.mockResolvedValue([])
mocks.createDraft.mockResolvedValue(draft('collecting', 1))
mocks.uploadMaterial.mockResolvedValue(draft('collecting', 2))
mocks.analyzeDraft.mockResolvedValue({
...draft('ticket_created', 2),
decision: 'auto_created',
created_ticket: { id: 19 } as Ticket
})
mocks.subscribeEvents.mockReturnValue(() => undefined)
mocks.cancelDraft.mockResolvedValue({
...draft('failed', 3),
error_code: 'analysis_cancelled',
error_message: '智能受理已取消,原始材料和已填字段均已保留'
})
mocks.getDraft.mockResolvedValue(draft('analyzing', 2))
mocks.confirmDraft.mockResolvedValue({
...draft('ticket_created', 3),
decision: 'accepted',
created_ticket: { id: 19 } as Ticket,
confirmed_ticket: { id: 19 } as Ticket
})
window.location.hash = ''
})
it('必须经过结构化审核和显式确认才创建工单', async () => {
it('形成有效问题描述后自动创建并跳转工单', async () => {
const user = userEvent.setup()
render(<TicketCreatePage />)
const source = await screen.findByPlaceholderText(/生产环境中/)
await user.type(source, '生产采集任务未更新')
await user.click(screen.getByRole('button', { name: '生成结构化草稿' }))
await user.click(screen.getByRole('button', { name: '开始智能受理' }))
expect(await screen.findByText('最终确认')).toBeInTheDocument()
await waitFor(() => expect(window.location.hash).toBe('#/tickets/19'))
expect(mocks.confirmDraft).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: '确认创建工单' }))
await user.click(screen.getByRole('button', { name: '确认创建' }))
await waitFor(() => expect(mocks.confirmDraft).toHaveBeenCalledWith('draft-1', 2))
expect(mocks.createTicket).not.toHaveBeenCalled()
})
it('问题信息不足时保留受理并提供人工兜底', async () => {
mocks.analyzeDraft.mockResolvedValue({
...draft('needs_input', 2),
problem_valid: false,
invalid_reason: '请补充具体问题现象',
missing_fields: ['description'],
questions: ['请描述具体问题现象。']
})
const user = userEvent.setup()
render(<TicketCreatePage />)
const source = await screen.findByPlaceholderText(/生产环境中/)
await user.type(source, '你好,在吗')
await user.click(screen.getByRole('button', { name: '开始智能受理' }))
expect(await screen.findByText('人工兜底建单')).toBeInTheDocument()
expect(screen.getByText('请补充具体问题现象')).toBeInTheDocument()
expect(mocks.confirmDraft).not.toHaveBeenCalled()
expect(window.location.hash).toBe('')
})
it('上传通用材料后使用最新版本启动受理', async () => {
const uploaded = {
...draft('collecting', 2),
materials: [{
id: 'material-1',
file_name: 'service.log',
material_type: 'log' as const,
extension: 'log',
claimed_mime_type: 'text/plain',
mime_type: 'text/plain',
file_size: 12,
status: 'temporary',
scan_status: 'clean' as const,
scan_result: {},
parse_status: 'parsed' as const,
parse_error: '',
parsed_text_length: 12
}]
}
mocks.uploadMaterial.mockResolvedValue(uploaded)
mocks.analyzeDraft.mockResolvedValue({
...draft('ticket_created', 3),
decision: 'auto_created',
created_ticket: { id: 19 } as Ticket
})
const user = userEvent.setup()
const { container } = render(<TicketCreatePage />)
await screen.findByText('问题材料(可选)')
const input = container.querySelector('input[type="file"]') as HTMLInputElement
expect(input).not.toBeNull()
await user.upload(input, new File(['ERROR timeout'], 'service.log', { type: 'text/plain' }))
await user.click(screen.getByRole('button', { name: '开始智能受理' }))
await waitFor(() => expect(mocks.uploadMaterial).toHaveBeenCalled())
expect(mocks.analyzeDraft).toHaveBeenCalledWith('draft-1', 2)
})
it('通过有序运行事件更新进度并在完成后刷新工单', async () => {
const analyzing = {
...draft('analyzing', 2),
agent_run_public_id: 'run-1',
agent_progress: {
...draft('analyzing', 2).agent_progress,
stage: 'queued',
message: '任务已进入队列'
}
}
mocks.analyzeDraft.mockResolvedValue(analyzing)
mocks.getDraft.mockResolvedValue({
...draft('ticket_created', 3),
decision: 'auto_created',
created_ticket: { id: 27 } as Ticket
})
mocks.subscribeEvents.mockImplementation((_publicId, options) => {
queueMicrotask(() => {
options.onOpen?.()
options.onEvent({
sequence: 5,
event_type: 'completed',
payload: { message: '正式工单已创建', ticket_id: 27 },
created_at: '2026-07-27T10:00:00+08:00'
})
})
return () => undefined
})
const user = userEvent.setup()
render(<TicketCreatePage />)
await user.type(await screen.findByPlaceholderText(/生产环境中/), '生产环境无法登录')
await user.click(screen.getByRole('button', { name: '开始智能受理' }))
await waitFor(() => expect(mocks.subscribeEvents).toHaveBeenCalledWith(
'run-1',
expect.objectContaining({ after: 0 })
))
await waitFor(() => expect(window.location.hash).toBe('#/tickets/27'))
expect(mocks.getDraft).toHaveBeenCalledWith('draft-1')
})
it('运行中可取消并保留重试与人工接管入口', async () => {
mocks.analyzeDraft.mockResolvedValue({
...draft('analyzing', 2),
agent_run_public_id: 'run-cancel',
agent_progress: {
...draft('analyzing', 2).agent_progress,
stage: 'extracting',
message: '正在提取问题事实'
}
})
const user = userEvent.setup()
render(<TicketCreatePage />)
await user.type(await screen.findByPlaceholderText(/生产环境中/), '生产环境无法登录')
await user.click(screen.getByRole('button', { name: '开始智能受理' }))
await user.click(await screen.findByRole('button', { name: '取消受理' }))
await waitFor(() => expect(mocks.cancelDraft).toHaveBeenCalledWith('draft-1', 2))
expect(await screen.findByText('智能受理已取消')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '重新分析' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '转为普通表单' })).toBeInTheDocument()
})
it('移动端只显示桌面端提示', () => {
mocks.mobile = true
render(<TicketCreatePage />)
expect(screen.getByText('请使用桌面端创建工单')).toBeInTheDocument()
expect(screen.queryByText('生成结构化草稿')).not.toBeInTheDocument()
expect(screen.queryByText('开始智能受理')).not.toBeInTheDocument()
})
})

View File

@@ -1,15 +1,22 @@
import * as React from 'react'
import {
AlertCircle,
Activity,
ArrowRight,
Ban,
Bot,
Check,
CheckCircle2,
File,
FileImage,
FileSpreadsheet,
FileText,
LoaderCircle,
MessageSquareMore,
Presentation,
RotateCcw,
Send,
ShieldCheck,
Sparkles,
Trash2
} from 'lucide-react'
@@ -34,7 +41,7 @@ import {
import { Badge } from '@/components/ui/badge'
import { Bubble, BubbleContent } from '@/components/ui/bubble'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Message, MessageContent, MessageGroup } from '@/components/ui/message'
@@ -55,17 +62,19 @@ import { createTicket } from './api'
import {
analyzeTicketDraft,
answerTicketDraft,
cancelTicketDraftAnalysis,
confirmTicketDraft,
createTicketDraft,
deleteTicketDraftImage,
deleteTicketDraftMaterial,
getTicketDraft,
getTicketDraftAvailability,
listTicketDrafts,
rejectTicketDraft,
subscribeAgentRunEvents,
updateTicketDraft,
uploadTicketDraftImage
uploadTicketDraftMaterial
} from './draft-api'
import type { TicketDraft, TicketDraftAvailability } from './draft-types'
import type { AgentRunEvent, TicketDraft, TicketDraftAvailability, TicketDraftMaterial } from './draft-types'
import { TICKET_CATEGORIES } from './ticketCategories'
import type { TicketInput } from './types'
@@ -104,11 +113,39 @@ function mergeForm(fields: Partial<TicketInput>): TicketInput {
return { ...initialForm, ...fields }
}
function formatFileSize(size: number) {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
function MaterialIcon({ material }: { material: Pick<TicketDraftMaterial, 'material_type'> }) {
if (material.material_type === 'image') return <FileImage className="size-4" />
if (material.material_type === 'spreadsheet') return <FileSpreadsheet className="size-4" />
if (material.material_type === 'presentation') return <Presentation className="size-4" />
if (material.material_type === 'document' || material.material_type === 'text' || material.material_type === 'log') return <FileText className="size-4" />
return <File className="size-4" />
}
function MaterialStatus({ material }: { material: TicketDraftMaterial }) {
if (material.scan_status === 'infected') return <Badge variant="destructive">已拦截</Badge>
if (material.scan_status === 'failed') return <Badge variant="destructive">扫描失败</Badge>
if (material.parse_status === 'failed' || material.parse_status === 'blocked') return <Badge variant="warning">解析失败</Badge>
if (material.scan_status === 'clean' && ['parsed', 'not_applicable'].includes(material.parse_status)) return <Badge variant="success"><ShieldCheck />可用</Badge>
return <Badge variant="secondary">处理中</Badge>
}
function DraftSpine({ draft }: { draft: TicketDraft | null }) {
const active = draft?.status === 'analyzing' ? 1 : draft?.status === 'ready' || draft?.status === 'failed' ? 2 : draft?.status === 'confirmed' ? 3 : 0
const active = draft?.status === 'analyzing'
? 1
: draft?.status === 'needs_input' || draft?.status === 'ready' || draft?.status === 'failed'
? 2
: draft?.status === 'ticket_created' || draft?.status === 'confirmed'
? 3
: 0
return (
<div className="grid grid-cols-4 gap-2 rounded-xl border bg-card p-3" aria-label="建单进度">
{['起草', '补齐', '审核', '确认'].map((label, index) => (
{['提交材料', '智能解析', '补全事实', '正式建单'].map((label, index) => (
<div key={label} className="flex min-w-0 items-center gap-2">
<span className={cn('flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold', index < active && 'border-primary bg-primary text-primary-foreground', index === active && 'border-primary text-primary')}>
{index < active ? <Check className="size-3.5" /> : index + 1}
@@ -121,6 +158,105 @@ function DraftSpine({ draft }: { draft: TicketDraft | null }) {
)
}
const ANALYSIS_STAGES = [
{ label: '解析材料', stages: ['queued', 'parsing', 'starting'] },
{ label: '提取事实', stages: ['extracting', 'executing', 'generating', 'validating', 'repairing', 'retrying', 'extraction_completed'] },
{ label: '创建工单', stages: ['creating_ticket'] },
{ label: '完成', stages: ['completed'] }
]
const EVENT_LABELS: Record<string, string> = {
queued: '任务已排队',
parsing: '正在解析材料',
extracting: '正在提取问题事实',
starting: 'Agent 开始运行',
executing: '正在执行受理步骤',
generating: '正在生成结构化字段',
validating: '正在校验提取结果',
repairing: '正在修正结构化结果',
retrying: '模型调用正在重试',
extraction_completed: '字段提取已完成',
creating_ticket: '正在创建正式工单',
completed: '正式工单已创建',
needs_input: '需要补充问题信息',
cancellation_requested: '已请求取消受理',
cancelled: '智能受理已取消',
failed: '受理运行失败'
}
function AnalysisProgress({
draft,
events,
streamState,
busy,
onCancel
}: {
draft: TicketDraft
events: AgentRunEvent[]
streamState: 'connecting' | 'connected' | 'reconnecting'
busy: boolean
onCancel: () => void
}) {
const progress = draft.agent_progress
const latestEvent = events[events.length - 1]
const liveStage = latestEvent?.event_type ?? progress.stage
const liveMessage = latestEvent?.payload.message
?? latestEvent?.payload.progress_message
?? progress.message
const activeIndex = Math.max(0, ANALYSIS_STAGES.findIndex((item) => item.stages.includes(liveStage)))
const elapsedSeconds = Math.max(Math.round(progress.elapsed_ms / 1000), 0)
return (
<Card className="gap-0 py-0">
<CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b bg-muted/30 py-3.5">
<div className="grid gap-1">
<CardTitle className="text-sm font-semibold">智能受理运行</CardTitle>
<CardDescription className="text-xs leading-5">运行事件按服务端序号实时补充,断线后从最后一条继续。</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant={streamState === 'connected' ? 'success' : 'secondary'}>
<Activity />{streamState === 'connected' ? '实时同步' : streamState === 'reconnecting' ? '正在重连' : '正在连接'}
</Badge>
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={busy}>
<Ban data-icon="inline-start" />取消受理
</Button>
</div>
</CardHeader>
<CardContent className="grid min-h-56 content-center gap-5 p-6">
<div className="flex items-center justify-center gap-3">
<LoaderCircle className="size-7 animate-spin text-primary" />
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium">{liveMessage}</p>
{progress.retry_count ? <Badge variant="warning">已重试 {progress.retry_count} 次</Badge> : null}
</div>
<p className="mt-1 text-sm text-muted-foreground">已等待 {elapsedSeconds} 秒。形成有效问题描述后,系统会直接创建正式工单。</p>
</div>
</div>
<div className="grid grid-cols-4 gap-2" aria-label="AI 分析阶段">
{ANALYSIS_STAGES.map((item, index) => (
<div key={item.label} className={cn('rounded-md border px-2 py-2 text-center text-xs text-muted-foreground', index < activeIndex && 'border-success/40 bg-success/10 text-success', index === activeIndex && 'border-primary/50 bg-primary/5 font-medium text-foreground')}>
{index < activeIndex ? <Check className="mr-1 inline size-3" /> : null}{item.label}
</div>
))}
</div>
{events.length ? (
<div className="grid gap-1.5 rounded-lg border bg-muted/20 p-3" aria-label="实时运行事件">
{events.slice(-4).map((event) => (
<div key={event.sequence} className="grid grid-cols-[3.5rem_minmax(0,1fr)] items-center gap-2 text-xs">
<span className="font-mono text-muted-foreground">#{String(event.sequence).padStart(3, '0')}</span>
<span className={cn('truncate text-muted-foreground', event === latestEvent && 'font-medium text-foreground')}>
{EVENT_LABELS[event.event_type] ?? event.payload.message ?? event.event_type}
</span>
</div>
))}
</div>
) : null}
{progress.slow ? <Alert><RotateCcw /><AlertTitle>模型响应较慢,任务仍在继续</AlertTitle><AlertDescription>无需重复提交。系统会继续等待并显示重试状态;你也可以稍后返回恢复这次受理。</AlertDescription></Alert> : null}
</CardContent>
</Card>
)
}
export function TicketCreatePage() {
const mobile = useIsMobile()
const [availability, setAvailability] = React.useState<TicketDraftAvailability | null>(null)
@@ -138,11 +274,14 @@ export function TicketCreatePage() {
const [conversation, setConversation] = React.useState<ConversationItem[]>([])
const [error, setError] = React.useState('')
const [busy, setBusy] = React.useState(false)
const [runEvents, setRunEvents] = React.useState<AgentRunEvent[]>([])
const [streamState, setStreamState] = React.useState<'connecting' | 'connected' | 'reconnecting'>('connecting')
const lastEventSequence = React.useRef(0)
const acceptDraft = React.useCallback((next: TicketDraft) => {
setDraft(next)
setConversation(next.messages.map((item) => ({ role: item.role, content: item.content })))
if (next.status === 'ready') {
if (next.status === 'needs_input' || next.status === 'ready') {
setForm((current) => {
const merged = mergeForm(next.fields)
const changed = new Set(Object.keys(merged).filter((key) => merged[key as keyof TicketInput] !== current[key as keyof TicketInput]))
@@ -155,27 +294,86 @@ export function TicketCreatePage() {
React.useEffect(() => {
let cancelled = false
Promise.all([getTicketDraftAvailability(), listTicketDrafts()])
.then(([nextAvailability, drafts]) => {
const requestedDraftId = new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('draft')
Promise.all([
getTicketDraftAvailability(),
listTicketDrafts(),
requestedDraftId ? getTicketDraft(requestedDraftId).catch(() => null) : Promise.resolve(null)
])
.then(([nextAvailability, drafts, requestedDraft]) => {
if (cancelled) return
setAvailability(nextAvailability)
setMode(nextAvailability.available ? 'ai' : 'manual')
setRecentDraft(drafts[0] ?? null)
if (requestedDraft) {
acceptDraft(requestedDraft)
setSourceText(requestedDraft.source_text)
setRecentDraft(null)
} else {
setRecentDraft(drafts[0] ?? null)
}
})
.catch(() => {
if (!cancelled) setMode('manual')
})
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
}, [])
}, [acceptDraft])
React.useEffect(() => {
if (!draft || draft.status !== 'analyzing') return
const timer = window.setTimeout(async () => {
try { acceptDraft(await getTicketDraft(draft.public_id)) } catch (reason) { setError(reason instanceof Error ? reason.message : '查询 AI 草稿失败') }
}, 1200)
return () => window.clearTimeout(timer)
}, [acceptDraft, draft])
lastEventSequence.current = 0
setRunEvents([])
setStreamState('connecting')
}, [draft?.agent_run_public_id])
React.useEffect(() => {
if (!draft || draft.status !== 'analyzing' || !draft.agent_run_public_id) return
let active = true
let fallbackTimer: number | undefined
const refreshDraft = async () => {
try {
const next = await getTicketDraft(draft.public_id)
if (active) acceptDraft(next)
} catch (reason) {
if (active) setError(reason instanceof Error ? reason.message : '查询 AI 受理状态失败')
}
}
// sequence 同时用于去重和组件重建后的续传;SSE 异常时再以查询接口兜底收敛终态。
const close = subscribeAgentRunEvents(draft.agent_run_public_id, {
after: lastEventSequence.current,
onOpen: () => {
if (!active) return
setStreamState('connected')
if (fallbackTimer) window.clearTimeout(fallbackTimer)
},
onEvent: (event) => {
if (!active || event.sequence <= lastEventSequence.current) return
lastEventSequence.current = event.sequence
setRunEvents((current) => [...current, event].slice(-20))
if (['completed', 'failed', 'needs_input', 'cancelled', 'timed_out'].includes(event.event_type)) {
void refreshDraft()
}
},
onError: () => {
if (!active) return
setStreamState('reconnecting')
if (fallbackTimer) window.clearTimeout(fallbackTimer)
fallbackTimer = window.setTimeout(() => void refreshDraft(), 2500)
}
})
return () => {
active = false
close()
if (fallbackTimer) window.clearTimeout(fallbackTimer)
}
}, [acceptDraft, draft?.agent_run_public_id, draft?.public_id, draft?.status])
React.useEffect(() => {
const ticket = draft?.created_ticket ?? draft?.confirmed_ticket
if ((draft?.status === 'ticket_created' || draft?.status === 'confirmed') && ticket) {
toast.success(draft.decision === 'auto_created' ? '已自动创建正式工单' : '已创建正式工单')
window.location.hash = `/tickets/${ticket.id}`
}
}, [draft])
function updateField<Key extends keyof TicketInput>(field: Key, value: TicketInput[Key]) {
setForm((current) => ({ ...current, [field]: value }))
@@ -194,11 +392,13 @@ export function TicketCreatePage() {
}
async function startAi() {
if (!sourceText.trim()) { setError('请先输入问题描述'); return }
if (!sourceText.trim() && pendingFiles.length === 0) { setError('请先输入问题描述或上传问题材料'); return }
setBusy(true); setError('')
setRunEvents([]); lastEventSequence.current = 0
try {
let next = await createTicketDraft(sourceText)
for (const file of pendingFiles) next = await uploadTicketDraftImage(next.public_id, file)
// 每次上传都会推进 revision,必须串行使用服务端返回的新版本。
for (const file of pendingFiles) next = await uploadTicketDraftMaterial(next.public_id, file)
next = await analyzeTicketDraft(next.public_id, next.revision)
acceptDraft(next)
setRecentDraft(null)
@@ -207,6 +407,7 @@ export function TicketCreatePage() {
async function saveIfDirty(current: TicketDraft) {
if (!dirty) return current
// revision 是受理记录的乐观锁;冲突交给服务端拒绝,避免覆盖另一页面的补充结果。
const saved = await updateTicketDraft(current.public_id, current.revision, form)
acceptDraft(saved)
return saved
@@ -221,6 +422,19 @@ export function TicketCreatePage() {
} catch (reason) { setError(reason instanceof Error ? reason.message : '重新分析失败') } finally { setBusy(false) }
}
async function cancelAnalysis() {
if (!draft || draft.status !== 'analyzing') return
setBusy(true); setError('')
try {
acceptDraft(await cancelTicketDraftAnalysis(draft.public_id, draft.revision))
toast.info('智能受理已取消,材料与已填字段仍可恢复')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '取消智能受理失败')
} finally {
setBusy(false)
}
}
async function submitAnswer() {
if (!draft || !answer.trim()) return
const question = draft.questions[0] ?? '请补充缺失信息'
@@ -239,8 +453,9 @@ export function TicketCreatePage() {
const saved = await saveIfDirty(draft)
const confirmed = await confirmTicketDraft(saved.public_id, saved.revision)
acceptDraft(confirmed)
toast.success(confirmed.decision === 'accepted' ? '已按 AI 草稿创建工单' : '已按修改后的草稿创建工单')
if (confirmed.confirmed_ticket) window.location.hash = `/tickets/${confirmed.confirmed_ticket.id}`
toast.success('已按人工补全内容创建工单')
const ticket = confirmed.created_ticket ?? confirmed.confirmed_ticket
if (ticket) window.location.hash = `/tickets/${ticket.id}`
} catch (reason) { setError(reason instanceof Error ? reason.message : '确认创建失败') } finally { setBusy(false) }
}
@@ -254,58 +469,96 @@ export function TicketCreatePage() {
async function discardDraft() {
if (!draft) return
try { await rejectTicketDraft(draft.public_id, draft.revision) } catch { /* dismissal remains local */ }
setDraft(null); setForm(initialForm); setSourceText(''); setConversation([]); setMode('manual')
setDraft(null); setForm(initialForm); setSourceText(''); setPendingFiles([]); setConversation([]); setMode('manual')
}
function selectMaterials(files: File[]) {
if (!availability) return
const maxFiles = availability.max_files || 10
const maxFileSize = availability.max_file_size || 20 * 1024 * 1024
const maxTotalSize = availability.max_total_size || 100 * 1024 * 1024
if (files.length > maxFiles) {
setError(`每次受理最多选择 ${maxFiles} 个材料`)
return
}
const oversized = files.find((file) => file.size > maxFileSize)
if (oversized) {
setError(`${oversized.name} 超过单文件 ${formatFileSize(maxFileSize)} 限制`)
return
}
if (files.reduce((total, file) => total + file.size, 0) > maxTotalSize) {
setError(`材料总量不能超过 ${formatFileSize(maxTotalSize)}`)
return
}
if (!availability.vision_available) {
const image = files.find((file) => file.type.startsWith('image/'))
if (image) {
setError(`${image.name} 是图片,但当前受理模型不支持图片理解`)
return
}
}
setError('')
setPendingFiles(files)
}
if (mobile) {
return <AppPage header={<AppPageHeader title="创建工单" description="AI 建单配置与确认仅支持桌面端" />}><Alert><AlertCircle /><AlertTitle>请使用桌面端创建工单</AlertTitle><AlertDescription>移动端当前仅支持查看。输入、图片上传、逐问补齐和最终确认需要在桌面端完成。</AlertDescription></Alert></AppPage>
return <AppPage header={<AppPageHeader title="创建工单" description="AI 建单配置与确认仅支持桌面端" />}><Alert><AlertCircle /><AlertTitle>请使用桌面端创建工单</AlertTitle><AlertDescription>移动端当前仅支持查看。输入、材料上传、逐问补齐和最终确认需要在桌面端完成。</AlertDescription></Alert></AppPage>
}
if (loading) return <AppPage header={<AppPageHeader title="创建工单" />}><div className="grid gap-3"><Skeleton className="h-20" /><Skeleton className="h-64" /></div></AppPage>
const aiReady = mode === 'ai' && availability?.available
const reviewReady = draft?.status === 'ready'
const reviewReady = draft?.status === 'needs_input' || draft?.status === 'ready'
const selectableExtensions = (availability?.supported_extensions ?? [])
.filter((extension) => availability?.vision_available || !['jpg', 'jpeg', 'png', 'webp'].includes(extension))
const materialAccept = selectableExtensions.map((extension) => `.${extension}`).join(',')
const materialLimitText = availability
? `最多 ${availability.max_files || 10} 个;单个 ${formatFileSize(availability.max_file_size || 20 * 1024 * 1024)},总计 ${formatFileSize(availability.max_total_size || 100 * 1024 * 1024)}。`
: ''
return (
<AppPage
contentClassName="grid gap-4"
header={<AppPageHeader title="创建工单" description="AI 只生成可审核草稿;未经你的明确确认,不会创建工单。" actions={<div className="flex gap-2"><Button variant={mode === 'ai' ? 'default' : 'outline'} onClick={() => availability?.available && setMode('ai')} disabled={!availability?.available}><Sparkles data-icon="inline-start" />AI 起草</Button><Button variant={mode === 'manual' ? 'default' : 'outline'} onClick={() => setMode('manual')}>普通表单</Button></div>} />}
header={<AppPageHeader title="创建工单" description="提交聊天材料后,AI 会提取事实;问题描述有效时直接创建正式工单。" actions={<div className="flex gap-2"><Button variant={mode === 'ai' ? 'default' : 'outline'} onClick={() => availability?.available && setMode('ai')} disabled={!availability?.available}><Sparkles data-icon="inline-start" />智能受理</Button><Button variant={mode === 'manual' ? 'default' : 'outline'} onClick={() => setMode('manual')}>普通表单</Button></div>} />}
>
{aiReady ? <DraftSpine draft={draft} /> : null}
{error ? <Alert variant="destructive"><AlertCircle /><AlertTitle>当前操作未完成</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null}
{!availability?.available && availability?.reason ? <Alert><Bot /><AlertTitle>AI 起草暂不可用</AlertTitle><AlertDescription>{availability.reason},仍可使用普通表单建单。</AlertDescription></Alert> : null}
{recentDraft && !draft && aiReady ? <Alert><RotateCcw /><AlertTitle>发现 24 小时内未完成草稿</AlertTitle><AlertDescription className="flex items-center justify-between gap-3"><span>{recentDraft.source_text || '未命名草稿'}</span><Button size="sm" variant="outline" onClick={() => { acceptDraft(recentDraft); setSourceText(recentDraft.source_text) }}>恢复草稿</Button></AlertDescription></Alert> : null}
{recentDraft && !draft && aiReady ? <Alert><RotateCcw /><AlertTitle>发现 24 小时内未完成受理</AlertTitle><AlertDescription className="flex items-center justify-between gap-3"><span>{recentDraft.source_text || `仅包含 ${recentDraft.materials.length} 份材料的受理`}</span><Button size="sm" variant="outline" onClick={() => { acceptDraft(recentDraft); setSourceText(recentDraft.source_text) }}>恢复受理</Button></AlertDescription></Alert> : null}
{aiReady && !draft ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<AppSection variant="card" title="用自然语言描述问题" description="可包含现象、对象、环境、发生时间和期望结果。">
<AppField label="问题描述" htmlFor="ai-ticket-source" required><Textarea id="ai-ticket-source" className="min-h-44" value={sourceText} onChange={(event) => setSourceText(event.target.value)} placeholder="例如:生产环境中,客户 A 的采集任务从上午 10 点起没有更新,任务编号为 JOB-1024,请技术支持排查。" /></AppField>
<AppSection variant="card" title="提交问题材料" description="粘贴聊天记录,也可以上传截图、PDF、文本、日志或 Office 文档。">
<AppField label="聊天记录或补充说明" htmlFor="ai-ticket-source"><Textarea id="ai-ticket-source" className="min-h-44" value={sourceText} onChange={(event) => setSourceText(event.target.value)} placeholder="例如:生产环境中,客户 A 的采集任务从上午 10 点起没有更新,任务编号为 JOB-1024,请技术支持排查。" /></AppField>
<FieldGroup className="gap-3">
<AppField label="问题截图(可选)" description={availability?.vision_available ? '最多 4 张;单张 10 MB,总计 20 MB;支持 JPEG、PNG、WebP。' : availability?.vision_reason}>
<Input type="file" accept="image/jpeg,image/png,image/webp" multiple disabled={!availability?.vision_available} onChange={(event) => setPendingFiles(Array.from(event.target.files ?? []).slice(0, 4))} />
<AppField
label="问题材料(可选)"
description={`${materialLimitText} 支持 ${selectableExtensions.map((item) => item.toUpperCase()).join('、')}。${availability?.vision_available ? '' : ` ${availability?.vision_reason || ''}`}`}
>
<Input type="file" accept={materialAccept} multiple onChange={(event) => selectMaterials(Array.from(event.target.files ?? []))} />
</AppField>
{pendingFiles.length ? <div className="flex flex-wrap gap-2">{pendingFiles.map((file) => <Badge key={`${file.name}-${file.size}`} variant="secondary"><FileImage className="size-3" />{file.name}</Badge>)}</div> : null}
{pendingFiles.length ? <div className="flex flex-wrap gap-2">{pendingFiles.map((file) => <Badge key={`${file.name}-${file.size}`} variant="secondary"><FileText />{file.name} · {formatFileSize(file.size)}</Badge>)}</div> : null}
</FieldGroup>
<div className="flex justify-end"><Button onClick={() => void startAi()} disabled={busy || !sourceText.trim()}>{busy ? <LoaderCircle className="animate-spin" /> : <Sparkles />}<span>{busy ? '准备分析…' : '生成结构化草稿'}</span></Button></div>
<div className="flex justify-end"><Button onClick={() => void startAi()} disabled={busy || (!sourceText.trim() && pendingFiles.length === 0)}>{busy ? <LoaderCircle className="animate-spin" /> : <Sparkles data-icon="inline-start" />}<span>{busy ? '准备受理…' : '开始智能受理'}</span></Button></div>
</AppSection>
<AppSection variant="card" title="AI 会做什么" action={<Badge variant="ai">真实功能</Badge>}><div className="grid gap-3 text-sm text-muted-foreground"><p>提取全部工单字段,并标出缺失信息。</p><p>默认集中展示缺失项,也可切换为最多 5 轮逐问。</p><p>你可以修改任意字段,最终必须显式确认。</p></div></AppSection>
<AppSection variant="card" title="受理规则" action={<Badge variant="ai">自动建单</Badge>}><div className="grid gap-3 text-sm text-muted-foreground"><p>材料先校验格式并执行恶意文件扫描,通过后才进入解析。</p><p>单份材料失败不会删除原件,也不会伪造解析结果。</p><p>形成有效问题描述后立即建单,其他字段允许后续补齐。</p></div></AppSection>
</div>
) : null}
{draft?.status === 'analyzing' ? <Card><CardContent className="flex min-h-56 flex-col items-center justify-center gap-3"><LoaderCircle className="size-8 animate-spin text-primary" /><div className="text-center"><p className="font-medium">AI 正在生成可审核草稿</p><p className="text-sm text-muted-foreground">页面会自动刷新,当前不会创建工单。</p></div></CardContent></Card> : null}
{draft?.status === 'analyzing' ? <AnalysisProgress draft={draft} events={runEvents} streamState={streamState} busy={busy} onCancel={() => void cancelAnalysis()} /> : null}
{draft?.status === 'failed' ? <Alert variant="destructive"><AlertCircle /><AlertTitle>AI 分析失败</AlertTitle><AlertDescription className="grid gap-3"><span>{draft.error_message || '原文、图片和已填字段均已保留。'}</span><div className="flex gap-2"><Button variant="outline" size="sm" onClick={() => void retryAnalysis()} disabled={busy}>重新分析</Button><Button variant="outline" size="sm" onClick={() => setMode('manual')}>转为普通表单</Button></div></AlertDescription></Alert> : null}
{draft?.status === 'failed' ? <Alert variant={draft.error_code === 'analysis_cancelled' ? 'default' : 'destructive'}><AlertCircle /><AlertTitle>{draft.error_code === 'analysis_cancelled' ? '智能受理已取消' : 'AI 分析失败'}</AlertTitle><AlertDescription className="grid gap-3"><span>{draft.error_message || '原文、材料和已填字段均已保留。'}</span><div className="flex gap-2"><Button variant="outline" size="sm" onClick={() => void retryAnalysis()} disabled={busy}>重新分析</Button><Button variant="outline" size="sm" onClick={() => setMode('manual')}>转为普通表单</Button></div></AlertDescription></Alert> : null}
{(reviewReady || mode === 'manual' || draft?.status === 'failed') ? (
<form className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]" onSubmit={handleManualSubmit}>
<div className="grid gap-4">
{reviewReady && draft.missing_fields.length ? <Alert><MessageSquareMore /><AlertTitle>还需补齐 {draft.missing_fields.length} 项</AlertTitle><AlertDescription>{draft.missing_fields.map((code) => FIELD_LABELS[code] ?? code).join('、')}</AlertDescription></Alert> : null}
{reviewReady && draft.missing_fields.length ? <Alert><MessageSquareMore /><AlertTitle>尚未形成可自动建单的完整描述</AlertTitle><AlertDescription>{draft.invalid_reason || `建议补充:${draft.missing_fields.map((code) => FIELD_LABELS[code] ?? code).join('、')}`}</AlertDescription></Alert> : null}
<TicketFields form={form} changedFields={changedFields} updateField={updateField} />
<AppSection variant="card" title={reviewReady ? '最终确认' : '提交工单'} description={reviewReady ? '保存你的修改,并在确认弹窗中核对整张工单。' : '普通表单不会调用 AI。'} footer={<div className="ml-auto flex gap-2">{draft ? <Button type="button" variant="ghost" onClick={() => void discardDraft()}><Trash2 data-icon="inline-start" />放弃草稿</Button> : null}{reviewReady ? <AlertDialog><AlertDialogTrigger asChild><Button type="button" disabled={busy}><CheckCircle2 data-icon="inline-start" />确认创建工单</Button></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>确认创建这张工单?</AlertDialogTitle><AlertDialogDescription>标题:{form.title || '未填写'};分类:{TICKET_CATEGORIES.find((item) => item.code === form.category_code)?.label ?? form.category_code};优先级:{form.priority};图片 {draft.images.length} 张。确认后将幂等创建真实工单。</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>返回检查</AlertDialogCancel><AlertDialogAction onClick={() => void confirmDraft()}>确认创建</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog> : <Button type="submit" disabled={busy}><Send data-icon="inline-start" />{busy ? '创建中…' : '创建工单'}</Button>}</div>}><div className="flex items-start gap-2 text-sm text-muted-foreground"><CheckCircle2 className="mt-0.5 size-4" /><span>{reviewReady ? 'AI 产物与人工修改会作为整单决定保留,AI 不会自动提交。' : '提交后工单进入支持处理队列。'}</span></div></AppSection>
<AppSection variant="card" title={reviewReady ? '人工兜底建单' : '提交工单'} description={reviewReady ? '当 AI 无法形成有效问题时,可补齐字段后人工创建。' : '普通表单不会调用 AI。'} footer={<div className="ml-auto flex gap-2">{draft ? <Button type="button" variant="ghost" onClick={() => void discardDraft()}><Trash2 data-icon="inline-start" />放弃受理</Button> : null}{reviewReady ? <AlertDialog><AlertDialogTrigger asChild><Button type="button" disabled={busy}><CheckCircle2 data-icon="inline-start" />按补全内容建单</Button></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>按人工补全内容创建工单?</AlertDialogTitle><AlertDialogDescription>标题:{form.title || '未填写'};分类:{TICKET_CATEGORIES.find((item) => item.code === form.category_code)?.label ?? form.category_code};优先级:{form.priority};材料 {draft.materials.length} 份。确认后将幂等创建正式工单。</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>返回检查</AlertDialogCancel><AlertDialogAction onClick={() => void confirmDraft()}>创建工单</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog> : <Button type="submit" disabled={busy}><Send data-icon="inline-start" />{busy ? '创建中…' : '创建工单'}</Button>}</div>}><div className="flex items-start gap-2 text-sm text-muted-foreground"><CheckCircle2 className="mt-0.5 size-4" /><span>{reviewReady ? '人工补全值会记录为受保护字段,后续 Agent 不会静默覆盖。' : '提交后工单进入支持处理队列。'}</span></div></AppSection>
</div>
<div className="grid content-start gap-4">
{reviewReady ? <AppSection variant="card" title="缺失信息处理" action={<Button size="sm" variant="outline" onClick={() => setGuided((value) => !value)} disabled={draft.guided_rounds >= 5}>{guided ? '关闭逐问' : '切换逐问'}</Button>}>{guided ? <GuidedPanel draft={draft} conversation={conversation} answer={answer} setAnswer={setAnswer} submitAnswer={submitAnswer} busy={busy} /> : <div className="grid gap-2">{draft.questions.length ? draft.questions.map((question) => <div key={question} className="rounded-lg border bg-muted/40 p-3 text-sm">{question}</div>) : <p className="text-sm text-muted-foreground">AI 未发现缺失信息,请直接审核字段。</p>}</div>}</AppSection> : null}
{draft?.images.length ? <AppSection variant="card" title={`图片 ${draft.images.length}/4`}>{draft.images.map((image) => <div key={image.id} className="flex items-center justify-between gap-2 rounded-lg border p-2 text-sm"><span className="truncate"><FileImage className="mr-2 inline size-4" />{image.file_name}</span>{draft.status !== 'confirmed' ? <Button type="button" variant="ghost" size="icon-sm" onClick={async () => { try { acceptDraft(await deleteTicketDraftImage(draft.public_id, image.id)) } catch (reason) { toast.error(reason instanceof Error ? reason.message : '删除失败') } }}><Trash2 /><span className="sr-only">删除图片</span></Button> : null}</div>)}</AppSection> : null}
<AppSection variant="card" title="安全边界"><div className="grid gap-2 text-sm text-muted-foreground"><p>AI 只生成草稿,不创建或修改工单。</p><p>连续点击或网络重试只会返回同一张工单。</p><p>管理员审计不保存完整原文、图片或模型响应。</p></div></AppSection>
{draft?.materials.length ? <AppSection variant="card" title={`材料清单 ${draft.materials.length}/${availability?.max_files || 10}`}>{draft.materials.map((material) => <div key={material.id} className="flex items-center justify-between gap-3 rounded-lg border p-3 text-sm"><div className="flex min-w-0 items-center gap-2"><MaterialIcon material={material} /><div className="min-w-0"><p className="truncate font-medium">{material.file_name}</p><p className="text-xs text-muted-foreground">{formatFileSize(material.file_size)} · {material.extension.toUpperCase()}{material.parse_error ? ` · ${material.parse_error}` : ''}</p></div></div><div className="flex shrink-0 items-center gap-1"><MaterialStatus material={material} />{draft.status !== 'ticket_created' && draft.status !== 'confirmed' ? <Button type="button" variant="ghost" size="icon-sm" onClick={async () => { try { acceptDraft(await deleteTicketDraftMaterial(draft.public_id, material.id)) } catch (reason) { toast.error(reason instanceof Error ? reason.message : '删除失败') } }}><Trash2 /><span className="sr-only">删除材料</span></Button> : null}</div></div>)}</AppSection> : null}
<AppSection variant="card" title="受理边界"><div className="grid gap-2 text-sm text-muted-foreground"><p>只有形成有效问题描述后才会自动创建正式工单。</p><p>连续点击或任务重试只会关联同一张工单。</p><p>原始材料、字段来源和 Agent 运行均可追溯。</p></div></AppSection>
</div>
</form>
) : null}
@@ -315,7 +568,7 @@ export function TicketCreatePage() {
function GuidedPanel({ draft, conversation, answer, setAnswer, submitAnswer, busy }: { draft: TicketDraft; conversation: ConversationItem[]; answer: string; setAnswer: (value: string) => void; submitAnswer: () => void; busy: boolean }) {
const currentQuestion = draft.questions[0]
return <div className="grid gap-3"><div className="h-64 overflow-hidden rounded-lg border"><MessageScroller><MessageScrollerViewport><MessageScrollerContent className="p-3"><MessageGroup>{conversation.map((item, index) => <MessageScrollerItem key={`${index}-${item.role}`}><Message align={item.role === 'user' ? 'end' : 'start'}><MessageContent><Bubble variant={item.role === 'user' ? 'default' : 'muted'} align={item.role === 'user' ? 'end' : 'start'}><BubbleContent>{item.content}</BubbleContent></Bubble></MessageContent></Message></MessageScrollerItem>)}{currentQuestion ? <MessageScrollerItem scrollAnchor><Message><MessageContent><Bubble variant="muted"><BubbleContent>{currentQuestion}</BubbleContent></Bubble></MessageContent></Message></MessageScrollerItem> : null}</MessageGroup></MessageScrollerContent></MessageScrollerViewport></MessageScroller></div><div className="flex gap-2"><Input value={answer} onChange={(event) => setAnswer(event.target.value)} placeholder={draft.guided_rounds >= 5 ? '已达到 5 轮上限' : '输入补充信息'} disabled={busy || draft.guided_rounds >= 5 || !currentQuestion} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitAnswer() } }} /><Button type="button" size="icon" onClick={submitAnswer} disabled={busy || !answer.trim()}><ArrowRight /><span className="sr-only">提交回答</span></Button></div><p className="text-xs text-muted-foreground">已进行 {draft.guided_rounds}/5 轮;每次回答都会重新生成草稿并高亮变化。</p></div>
return <div className="grid gap-3"><div className="h-64 overflow-hidden rounded-lg border"><MessageScroller><MessageScrollerViewport><MessageScrollerContent className="p-3"><MessageGroup>{conversation.map((item, index) => <MessageScrollerItem key={`${index}-${item.role}`}><Message align={item.role === 'user' ? 'end' : 'start'}><MessageContent><Bubble variant={item.role === 'user' ? 'default' : 'muted'} align={item.role === 'user' ? 'end' : 'start'}><BubbleContent>{item.content}</BubbleContent></Bubble></MessageContent></Message></MessageScrollerItem>)}{currentQuestion ? <MessageScrollerItem scrollAnchor><Message><MessageContent><Bubble variant="muted"><BubbleContent>{currentQuestion}</BubbleContent></Bubble></MessageContent></Message></MessageScrollerItem> : null}</MessageGroup></MessageScrollerContent></MessageScrollerViewport></MessageScroller></div><div className="flex gap-2"><Input value={answer} onChange={(event) => setAnswer(event.target.value)} placeholder={draft.guided_rounds >= 5 ? '已达到 5 轮上限' : '输入补充信息'} disabled={busy || draft.guided_rounds >= 5 || !currentQuestion} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); submitAnswer() } }} /><Button type="button" size="icon" onClick={submitAnswer} disabled={busy || !answer.trim()}><ArrowRight /><span className="sr-only">提交回答</span></Button></div><p className="text-xs text-muted-foreground">已进行 {draft.guided_rounds}/5 轮;每次回答都会重新分析受理材料并高亮变化。</p></div>
}
function TicketFields({ form, changedFields, updateField }: { form: TicketInput; changedFields: Set<string>; updateField: <Key extends keyof TicketInput>(field: Key, value: TicketInput[Key]) => void }) {

View File

@@ -1,7 +1,54 @@
import { apiRequest } from '@/lib/api'
import { apiRequest, apiUrl } from '@/lib/api'
import type { TicketInput } from './types'
import type { TicketDraft, TicketDraftAvailability } from './draft-types'
import type { AgentRunEvent, TicketDraft, TicketDraftAvailability } from './draft-types'
const AGENT_RUN_EVENT_TYPES = [
'queued',
'parsing',
'extracting',
'starting',
'executing',
'generating',
'validating',
'repairing',
'retrying',
'extraction_completed',
'creating_ticket',
'completed',
'needs_input',
'failed',
'cancellation_requested',
'cancelled',
'timed_out'
]
export function subscribeAgentRunEvents(
publicId: string,
options: {
after?: number
onEvent: (event: AgentRunEvent) => void
onOpen?: () => void
onError?: () => void
}
) {
// 初次订阅使用 after;连接内断线由 EventSource 携带 Last-Event-ID 自动续传。
const query = options.after ? `?after=${options.after}` : ''
const source = new EventSource(apiUrl(`/agent-runs/${publicId}/events${query}`), {
withCredentials: true
})
const handleEvent = (message: MessageEvent<string>) => {
try {
options.onEvent(JSON.parse(message.data) as AgentRunEvent)
} catch {
options.onError?.()
}
}
AGENT_RUN_EVENT_TYPES.forEach((eventType) => source.addEventListener(eventType, handleEvent))
source.onopen = () => options.onOpen?.()
source.onerror = () => options.onError?.()
return () => source.close()
}
export function getTicketDraftAvailability() {
return apiRequest<TicketDraftAvailability>('/ticket-drafts/availability')
@@ -29,21 +76,24 @@ export function updateTicketDraft(publicId: string, revision: number, fields: Ti
})
}
export function uploadTicketDraftImage(publicId: string, file: File) {
export function uploadTicketDraftMaterial(publicId: string, file: File) {
const body = new FormData()
body.append('file', file)
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/images`, {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/materials`, {
method: 'POST',
body
})
}
export function deleteTicketDraftImage(publicId: string, imageId: string) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/images/${imageId}`, {
export function deleteTicketDraftMaterial(publicId: string, materialId: string) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/materials/${materialId}`, {
method: 'DELETE'
})
}
export const uploadTicketDraftImage = uploadTicketDraftMaterial
export const deleteTicketDraftImage = deleteTicketDraftMaterial
export function analyzeTicketDraft(publicId: string, revision: number) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/analyze`, {
method: 'POST',
@@ -51,6 +101,13 @@ export function analyzeTicketDraft(publicId: string, revision: number) {
})
}
export function cancelTicketDraftAnalysis(publicId: string, revision: number) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/cancel`, {
method: 'POST',
body: JSON.stringify({ expected_revision: revision })
})
}
export function answerTicketDraft(publicId: string, revision: number, question: string, answer: string) {
return apiRequest<TicketDraft>(`/ticket-drafts/${publicId}/answers`, {
method: 'POST',

View File

@@ -1,15 +1,35 @@
import type { Ticket, TicketInput } from './types'
export type TicketDraftStatus = 'editing' | 'analyzing' | 'ready' | 'failed' | 'confirmed' | 'rejected' | 'expired'
export type TicketDraftStatus =
| 'collecting'
| 'analyzing'
| 'needs_input'
| 'ticket_created'
| 'failed'
| 'rejected'
| 'expired'
| 'editing'
| 'ready'
| 'confirmed'
export type TicketDraftImage = {
export type TicketDraftMaterial = {
id: string
file_name: string
material_type: 'image' | 'document' | 'text' | 'log' | 'spreadsheet' | 'presentation'
extension: string
claimed_mime_type: string
mime_type: string
file_size: number
status: string
scan_status: 'pending' | 'scanning' | 'clean' | 'infected' | 'failed'
scan_result: Record<string, unknown>
parse_status: 'pending' | 'parsed' | 'not_applicable' | 'blocked' | 'failed'
parse_error: string
parsed_text_length: number
}
export type TicketDraftImage = TicketDraftMaterial
export type TicketDraftMessage = {
role: 'assistant' | 'user'
content: string
@@ -17,22 +37,79 @@ export type TicketDraftMessage = {
created_at: string
}
export type TicketDraftAgentProgress = {
stage: string
message: string
current_step: string
total_steps: number
retry_count: number
elapsed_ms: number
slow: boolean
}
export type AgentRunEvent = {
sequence: number
event_type: string
payload: {
run_status?: string
progress_stage?: string
progress_message?: string
message?: string
error_code?: string
error_message?: string
ticket_id?: number
questions?: string[]
}
created_at: string
}
export type TicketDraftFieldEvidence = {
confidence?: number
source_refs?: string[]
excerpt?: string
location?: string
raw_value?: string
timezone?: string
conflicts?: string[]
generated_from?: string
}
export type TicketDraftTimelineItem = {
sequence: number
speaker: string
role: 'customer' | 'support' | 'unknown'
content: string
occurred_at: string
occurred_at_raw: string
timezone: string
source_ref: string
confidence: number | null
conflict: string
}
export type TicketDraft = {
public_id: string
status: TicketDraftStatus
revision: number
source_text: string
problem_valid: boolean | null
invalid_reason: string
fields: Partial<TicketInput>
last_ai_fields: Partial<TicketInput>
field_evidence: Record<string, TicketDraftFieldEvidence>
message_timeline: TicketDraftTimelineItem[]
missing_fields: string[]
questions: string[]
guided_rounds: number
agent_run_public_id: string | null
agent_progress: TicketDraftAgentProgress
materials: TicketDraftMaterial[]
images: TicketDraftImage[]
messages: TicketDraftMessage[]
decision: 'accepted' | 'modified' | 'rejected' | ''
decision: 'auto_created' | 'accepted' | 'modified' | 'rejected' | ''
error_code: string
error_message: string
created_ticket: Ticket | null
confirmed_ticket: Ticket | null
created_at: string
updated_at: string
@@ -44,4 +121,8 @@ export type TicketDraftAvailability = {
reason: string
vision_available: boolean
vision_reason: string
max_file_size: number
max_files: number
max_total_size: number
supported_extensions: string[]
}