feat(api): 新增图片问答、意图分类与任务管理功能

- 新增 SocialImageAgentService 支持朋友圈/聊天截图解析,提供图片线索提取与图谱问答建议
- 扩展 LLMService 支持多模型配置(意图分类、图片模型)与流式响应,增加思考模式控制
- 新增 /intent/classify 端点用于轻量意图分类(问答/导入/混合)以节省 token
- 新增 /tasks/{taskId} 与 /tasks/{taskId}/retry 端点用于流式任务状态查询与重试
- 前端 Dashboard 扩展人物详情显示(年龄标签、出生日期、别名、关系置信度等)
- 前端导入流程增加任务 ID 追踪与质量回放信息展示
This commit is contained in:
KOSHM-Pig
2026-03-25 00:28:22 +08:00
parent 1f087599c5
commit 447062446a
10 changed files with 3852 additions and 149 deletions

View File

@@ -122,6 +122,64 @@
<span class="detail-label">性别</span>
<span class="detail-value">{{ selectedDetail.data.gender || 'unknown' }}</span>
</div>
<div class="detail-row" v-if="selectedDetail.data.type === 'person' && selectedDetail.data.age_label">
<span class="detail-label">年龄标签</span>
<span class="detail-value">{{ selectedDetail.data.age_label }}</span>
</div>
<div class="detail-row" v-if="selectedDetail.data.type === 'person' && selectedDetail.data.birth_date_utc">
<span class="detail-label">出生日期(UTC)</span>
<span class="detail-value">{{ selectedDetail.data.birth_date_utc }}</span>
</div>
<div class="detail-row" v-if="selectedDetail.data.type === 'person' && selectedDetail.data.aliases?.length">
<span class="detail-label">别名</span>
<span class="detail-value detail-tags">
<span class="detail-tag" v-for="(alias, idx) in selectedDetail.data.aliases.slice(0, 8)" :key="`${selectedDetail.data.id}_alias_${idx}`">
{{ alias }}
</span>
</span>
</div>
<div class="detail-row" v-if="selectedDetail.data.type === 'person' && selectedDetail.data.person_role">
<span class="detail-label">人物角色</span>
<span class="detail-value">{{ formatPersonRole(selectedDetail.data.person_role) }}</span>
</div>
<div class="detail-row" v-if="selectedDetail.data.type === 'person' && selectedDetail.data.relation_to_user">
<span class="detail-label">与用户关系</span>
<span class="detail-value">
{{ selectedDetail.data.relation_to_user }}
<template v-if="Number.isFinite(Number(selectedDetail.data.relation_confidence))">
· {{ formatPercent(selectedDetail.data.relation_confidence) }}
</template>
</span>
</div>
<div class="detail-row" v-if="selectedDetail.data.type === 'person' && selectedDetail.data.occupation">
<span class="detail-label">职业</span>
<span class="detail-value">{{ selectedDetail.data.occupation }}</span>
</div>
<div class="detail-row" v-if="selectedDetail.data.type === 'person' && selectedDetail.data.education_background">
<span class="detail-label">教育背景</span>
<span class="detail-value">{{ selectedDetail.data.education_background }}</span>
</div>
<div class="detail-row" v-if="selectedDetail.data.type === 'person' && selectedDetail.data.residential_status">
<span class="detail-label">常住状态</span>
<span class="detail-value">{{ selectedDetail.data.residential_status }}</span>
</div>
<div class="detail-row" v-if="selectedDetail.data.type === 'person'">
<span class="detail-label">属性标签</span>
<span class="detail-value detail-tags">
<span class="detail-tag" v-if="selectedDetail.data.data_source">
来源: {{ formatDataSource(selectedDetail.data.data_source) }}
</span>
<span class="detail-tag" v-if="selectedDetail.data.consent_status">
同意状态: {{ formatConsentStatus(selectedDetail.data.consent_status) }}
</span>
<span class="detail-tag" v-if="selectedDetail.data.privacy_level">
隐私级别: {{ selectedDetail.data.privacy_level }}
</span>
<span class="detail-tag" v-if="Number.isFinite(Number(selectedDetail.data.data_quality))">
数据质量: {{ formatPercent(selectedDetail.data.data_quality) }}
</span>
</span>
</div>
<div class="detail-row">
<span class="detail-label">ID</span>
<span class="detail-value">{{ selectedDetail.data.id || '-' }}</span>
@@ -1052,6 +1110,51 @@ const formatDateTime = (value) => {
return String(value)
}
const formatPercent = (value) => {
const num = Number(value)
if (!Number.isFinite(num)) return '-'
const ratio = num <= 1 ? num * 100 : num
return `${Math.max(0, Math.min(100, ratio)).toFixed(1)}%`
}
const formatPersonRole = (value) => {
const raw = String(value || '').trim().toLowerCase()
const map = {
self: '本人',
partner: '伴侣',
ex: '前任',
friend: '朋友',
colleague: '同事',
other: '其他',
unknown: '未知'
}
return map[raw] || value || '未知'
}
const formatDataSource = (value) => {
const raw = String(value || '').trim().toLowerCase()
const map = {
ingest: '导入',
user_input: '用户输入',
manual: '手动',
import: '导入',
llm_extract: '模型抽取',
system: '系统'
}
return map[raw] || (value || '未知')
}
const formatConsentStatus = (value) => {
const raw = String(value || '').trim().toLowerCase()
const map = {
granted: '已同意',
denied: '拒绝',
revoked: '已撤回',
unknown: '未知'
}
return map[raw] || (value || '未知')
}
const clearSelection = () => {
selectedDetail.value = null
resetSelectionStyles?.()
@@ -1071,6 +1174,26 @@ const formatIngestProgress = (event) => {
return `阶段: ${stage}`
}
const formatQualityReplayLine = (qualityReplay) => {
if (!qualityReplay || typeof qualityReplay !== 'object') return ''
const output = qualityReplay.output || {}
const relationQuality = output.relation_quality || null
const normalization = output.normalization || null
if (relationQuality) {
const avg = Number(relationQuality.average_score || 0)
const stable = Number(relationQuality.stable_count || 0)
const risk = Number(relationQuality.conflict_risk_count || 0)
return `质量回放: 关系均分 ${(avg * 100).toFixed(1)}% · 稳定 ${stable} · 风险 ${risk}`
}
if (normalization) {
return `质量回放: 人物别名合并 ${Number(normalization.person_alias_merged || 0)} · 组织别名合并 ${Number(normalization.organization_alias_merged || 0)} · 关系去重 ${Number(normalization.relation_deduped || 0)}`
}
if (typeof output.confidence === 'number') {
return `质量回放: 置信度 ${(Number(output.confidence) * 100).toFixed(1)}%`
}
return ''
}
const handleIngest = async () => {
if (!ingestText.value) return
ingestLoading.value = true
@@ -1086,6 +1209,7 @@ const handleIngest = async () => {
})
}
try {
let ingestTaskId = ''
logAt('开始提交导入请求...', 'info')
const payload = {
text: ingestText.value,
@@ -1126,6 +1250,11 @@ const handleIngest = async () => {
}
if (eventName === 'progress') {
logAt(formatIngestProgress(payload), 'info')
} else if (eventName === 'meta') {
if (payload?.task_id) {
ingestTaskId = payload.task_id
logAt(`任务已创建: ${ingestTaskId}`, 'info')
}
} else if (eventName === 'done') {
doneResult = payload
} else if (eventName === 'error') {
@@ -1144,12 +1273,29 @@ const handleIngest = async () => {
if (buffer.trim()) parseSseBlock(buffer)
if (streamError) {
if (ingestTaskId) {
logAt(`可通过 /tasks/${ingestTaskId} 查询失败详情`, 'error')
}
throw new Error(streamError)
}
if (!doneResult?.ok) {
throw new Error(doneResult?.error || doneResult?.message || '分析失败')
}
logAt(`分析成功: ${doneResult.stats?.created?.persons || 0} 人, ${doneResult.stats?.created?.organizations || 0} 组织, ${doneResult.stats?.created?.events || 0} 事件, ${doneResult.stats?.created?.topics || 0} 主题`, 'success')
if (ingestTaskId) {
logAt(`任务完成: ${ingestTaskId}`, 'success')
try {
const taskRes = await fetch(`${apiBase}/tasks/${encodeURIComponent(ingestTaskId)}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
})
const taskData = await taskRes.json()
if (taskRes.ok && taskData?.ok && taskData?.task?.quality_replay) {
const replayLine = formatQualityReplayLine(taskData.task.quality_replay)
if (replayLine) logAt(replayLine, 'info')
}
} catch {}
}
ingestText.value = ''
renderGraph()
} catch (e) {
@@ -1747,6 +1893,23 @@ input:checked + .slider:before {
word-break: break-word;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.detail-tag {
display: inline-flex;
align-items: center;
border: 1px solid #cccccc;
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
color: #333333;
background: #ffffff;
}
.summary-value { line-height: 1.55; }
.panel-grid {