feat(api): 新增图片问答、意图分类与任务管理功能
- 新增 SocialImageAgentService 支持朋友圈/聊天截图解析,提供图片线索提取与图谱问答建议
- 扩展 LLMService 支持多模型配置(意图分类、图片模型)与流式响应,增加思考模式控制
- 新增 /intent/classify 端点用于轻量意图分类(问答/导入/混合)以节省 token
- 新增 /tasks/{taskId} 与 /tasks/{taskId}/retry 端点用于流式任务状态查询与重试
- 前端 Dashboard 扩展人物详情显示(年龄标签、出生日期、别名、关系置信度等)
- 前端导入流程增加任务 ID 追踪与质量回放信息展示
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user