feat: organize news packages into verified event timelines
Development CI / check-and-build (push) Successful in 2m33s
GitHub Git mirror / mirror (push) Successful in 5s
Development CI / deploy-development (push) Failing after 7s

Add optional events, evidence-backed timelines, versioned package corrections and bounded historical reprocessing. Keep independent packages readable and isolate retries by failed stage. Configure reasoning effort per model stage and refresh default news Skill drafts without publishing.
This commit is contained in:
eezd committed 2026-11-01 11:46:11 +08:00
1 parent 2f1e07b7b1
commit f1d060d475
28 files changed
+1983 -149

No files matched your search

+9 -2
View File
@@ -6,9 +6,16 @@ export const saveRiskSettingsApi = (data: Risk.Settings) => request<null>({ url:
export const enableRiskApi = (data: Risk.Settings) => request<null>({ url: "/risk/enable", method: "post", timeout: 650000, data })
export const getRiskOverviewApi = (hours: number) => request<Risk.Overview>({ url: "/risk/overview", method: "get", params: { hours } })
export const listRiskEventsApi = (params: Risk.EventQuery) => request<{ items: Risk.RiskEvent[], total: number }>({ url: "/risk/summaries", method: "get", params })
export const getRiskEventApi = (id: number) => request<{ evidence: { id: number, kind: string, data: unknown, fetched_at: string }[], event: Risk.RiskEvent, history: Risk.Analysis[], versions: { id: number, revision: number, state: string, model: string, result: Risk.RiskResult | null, error: string | null, analyzed_at: string | null }[] }>({ url: `/risk/summaries/${id}`, method: "get" })
export const getRiskEventApi = (id: number) => request<{ related_events?: { id: number, reason: string }[], corrections?: { id: number, result: unknown }[], grouping_versions?: { id: number, state: string, error: string | null }[], evidence: { id: number, kind: string, data: unknown, fetched_at: string }[], event: Risk.RiskEvent, history: Risk.Analysis[], versions: { id: number, revision: number, state: string, model: string, result: Risk.RiskResult | null, error: string | null, analyzed_at: string | null }[] }>({ url: `/risk/summaries/${id}`, method: "get" })
export const listRiskAnalysesApi = (params: { news_id?: number, revision_no?: number, failed?: boolean, stage?: string }, notify = true) => request<Risk.Analysis[]>({ url: "/risk/tasks", method: "get", params, notify })
export const retryRiskAnalysisApi = (id: number, stage = "grouping") => request<null>({ url: `/risk/tasks/${id}/retry`, method: "post", params: { stage } })
export const listNewsSummariesApi = (newsId: number, revision: number) => request<Risk.RiskEvent[]>({ url: `/risk/news/${newsId}/summaries`, method: "get", params: { revision_no: revision } })
export const createNewsSkillTemplatesApi = () => request<{ grouping_skill_id: number, analysis_skill_id: number, outlook_skill_id: number }>({ url: "/risk/skill-templates", method: "post" })
export const createNewsSkillTemplatesApi = (refreshExisting = false) => request<{ grouping_skill_id: number, analysis_skill_id: number, outlook_skill_id: number }>({ url: "/risk/skill-templates", method: "post", data: { refresh_existing: refreshExisting } })
export const listNewsEventsApi = (page = 1) => request<{ items: Risk.NewsEvent[], total: number }>({ url: "/risk/events", method: "get", params: { page } })
export const getNewsEventApi = (id: number) => request<Risk.EventDetail>({ url: `/risk/events/${id}`, method: "get" })
export const previewReprocessApi = (data: Risk.ReprocessRange) => request<Risk.ReprocessPreview>({ url: "/risk/reprocess/preview", method: "post", data })
export const startReprocessApi = (data: Risk.ReprocessRange) => request<{ id: number }>({ url: "/risk/reprocess", method: "post", data })
export const listReprocessApi = () => request<Risk.ReprocessJob[]>({ url: "/risk/reprocess", method: "get" })
export const cancelReprocessApi = (id: number) => request<null>({ url: `/risk/reprocess/${id}/cancel`, method: "post" })
export const retryReprocessApi = (id: number) => request<null>({ url: `/risk/reprocess/${id}/retry`, method: "post" })
+35 -2
View File
@@ -1,3 +1,5 @@
import type { Effort } from "../settings/type"
export interface RiskResult {
summary: string
reasoning: string
@@ -19,6 +21,8 @@ export interface RiskResult {
evidence_refs?: { article_id: number, quote: string }[]
}
export interface RiskEvent {
main_event_id?: number | null
replacement_ids?: number[]
tags: string[]
content_kind: string
importance_stars: number
@@ -46,6 +50,9 @@ export interface Settings {
second_model: string
third_provider_id: number | null
third_model: string
reasoning_effort: Effort | null
second_reasoning_effort: Effort | null
third_reasoning_effort: Effort | null
grouping_skill_id: number | null
analysis_skill_id: number | null
outlook_skill_id: number | null
@@ -62,14 +69,14 @@ export interface Analysis {
analyzed_at: string | null
historical: boolean
}
export interface Reading { id: number, revision: number, priority: number, reason: string }
export interface Reading { kind?: "package" | "event", id: number, revision: number, priority: number, reason: string }
export interface Trend { summary: string, conditions: string, counter_evidence: string, watchpoints: string }
export interface Briefing {
reading: Reading[]
expansion: Reading[]
as_of: string
synthesis: { summary: string, market: string, short_term: Trend, medium_term: Trend } | null
inputs: { id: number, revision: number, category: string, tags: string[], result: RiskResult & { title: string } }[]
inputs: { kind?: "package" | "event", id: number, revision: number, category: string, tags: string[], result: RiskResult & { title: string } }[]
}
export interface Overview {
briefing: Briefing | null
@@ -86,3 +93,29 @@ export interface Overview {
heartbeat: string | null
}
export interface EventQuery { tag?: string, importance?: number, hours?: number, stars?: number, q?: string, category?: string, asset?: string, start?: string, end?: string, page?: number }
export interface TimelinePoint {
occurred_at: string | null
time_precision: string
time_text: string
reported_at: string | null
description: string
verification: string
package_id: number
package_revision: number
evidence_refs: { article_id: number, quote: string }[]
}
export interface NewsEvent {
id: number
title: string
state: string
error: string | null
result: { summary: string, importance_stars: number, risk_stars: number, counter_evidence: string, timeline_points: TimelinePoint[], uncertainty: string, opinions: string, watchpoints: string } | null
}
export interface EventDetail extends NewsEvent {
packages: RiskEvent[]
versions: { id: number, revision: number, state: string, error: string | null, result: NewsEvent["result"] }[]
}
export interface ReprocessRange { start: string, end: string }
export interface ReprocessPreview { articles: number, packages: number, events: number }
export interface ReprocessJob { id: number, state: string, total: number, completed: number, failures: { stage: "grouping" | "analysis" | "events" | "outlook", id: number, error: string }[] }
@@ -0,0 +1,209 @@
<script setup lang="ts">
import type * as Risk from "@@/apis/risk/type"
import { formatTime } from "@@/utils/text"
import * as api from "@/common/apis/risk"
import { useUserStore } from "@/pinia/stores/user"
const emit = defineEmits<{ package: [id: number] }>()
const user = useUserStore()
const items = ref<Risk.NewsEvent[]>([])
const total = ref(0)
const page = ref(1)
const detail = ref<Risk.EventDetail | null>(null)
const opened = ref(false)
const detailError = ref(false)
const detailId = ref(0)
const range = ref<[Date, Date] | null>(null)
const preview = ref<Risk.ReprocessPreview | null>(null)
const jobs = ref<Risk.ReprocessJob[]>([])
const busy = ref(false)
const error = ref(false)
let sequence = 0
let detailSequence = 0
let operation = 0
let active = true
async function load() {
const seq = ++sequence
const version = user.sessionVersion
try {
const [events, runs] = await Promise.all([api.listNewsEventsApi(page.value), api.listReprocessApi()])
if (!active || seq !== sequence || version !== user.sessionVersion) return
items.value = events.items
total.value = events.total
jobs.value = runs
error.value = false
} catch {
if (active && seq === sequence && version === user.sessionVersion) error.value = true
}
}
async function open(id: number) {
const seq = ++detailSequence
const version = user.sessionVersion
detail.value = null
detailError.value = false
detailId.value = id
opened.value = true
try {
const result = await api.getNewsEventApi(id)
if (active && opened.value && seq === detailSequence && version === user.sessionVersion) detail.value = result
} catch {
if (active && seq === detailSequence && version === user.sessionVersion) detailError.value = true
}
}
async function inspect() {
if (!range.value || busy.value) return
const version = user.sessionVersion
const data = { start: range.value[0].toISOString(), end: range.value[1].toISOString() }
const op = ++operation
busy.value = true
try {
const result = await api.previewReprocessApi(data)
if (active && version === user.sessionVersion && range.value && data.start === range.value[0].toISOString() && data.end === range.value[1].toISOString()) preview.value = result
} finally {
if (active && op === operation && version === user.sessionVersion) busy.value = false
}
}
async function start() {
if (!range.value || !preview.value || busy.value) return
const version = user.sessionVersion
const data = { start: range.value[0].toISOString(), end: range.value[1].toISOString() }
await ElMessageBox.confirm(`重整理 ${preview.value.articles} 篇新闻将产生模型费用,关联包和事件也会更新。是否启动?`, "重整理")
if (!active || version !== user.sessionVersion || busy.value) return
const op = ++operation
busy.value = true
try {
await api.startReprocessApi(data)
if (active && version === user.sessionVersion) {
preview.value = null
await load()
}
} finally {
if (active && op === operation && version === user.sessionVersion) busy.value = false
}
}
async function action(id: number, retry: boolean) {
const version = user.sessionVersion
await (retry ? api.retryReprocessApi(id) : api.cancelReprocessApi(id))
if (active && version === user.sessionVersion) await load()
}
watch(range, () => {
preview.value = null
})
watch(page, load)
watch(() => user.sessionVersion, () => {
sequence++
detailSequence++
operation++
busy.value = false
detailError.value = false
items.value = []
jobs.value = []
detail.value = null
preview.value = null
opened.value = false
})
watch(opened, (value) => {
if (!value) detailSequence++
})
let timer: ReturnType<typeof setInterval>
onMounted(() => {
void load()
timer = setInterval(() => {
if (!document.hidden) void load()
}, 15000)
})
onBeforeUnmount(() => {
active = false
sequence++
detailSequence++
clearInterval(timer)
})
defineExpose({ open })
</script>
<template>
<el-card shadow="never">
<template #header>
事件与时间线
</template>
<el-alert v-if="error" title="事件读取失败,可重试" type="warning" :closable="false" />
<el-button link @click="load">
刷新
</el-button>
<div v-for="event in items" :key="event.id" class="my-3">
<el-button link @click="open(event.id)">
{{ event.title }}
</el-button>
<p>{{ event.result?.summary || '正在整理组成包的时间线' }}</p>
<p v-if="event.result">
重要性 {{ event.result.importance_stars }} 星 · 风险 {{ event.result.risk_stars }}X
</p>
<p v-if="event.error">
更新失败:{{ event.error }};保留上一成功版本
</p>
</div>
<el-pagination v-model:current-page="page" :total="total" :page-size="20" layout="prev, pager, next" />
<details class="mt-4">
<summary>按时间范围重整理新闻</summary>
<p>按新闻发布时间选择范围,使用现存最新修订。不会还原历史时点;共享包和事件可能同时更新。AI 暂停时任务等待。</p>
<el-date-picker v-model="range" type="datetimerange" class="!max-w-full" start-placeholder="起始时间" end-placeholder="结束时间" />
<el-button :loading="busy" @click="inspect">
预览范围
</el-button>
<template v-if="preview">
<p>{{ preview.articles }} 篇新闻 · {{ preview.packages }} 个包 · {{ preview.events }} 个事件</p>
<el-button :loading="busy" @click="start">
启动重整理
</el-button>
</template>
<div v-for="job in jobs" :key="job.id" class="my-2">
#{{ job.id }} · {{ job.state }} · {{ job.completed }}/{{ job.total }}
<el-button v-if="['pending', 'running'].includes(job.state)" link @click="action(job.id, false)">
取消
</el-button>
<el-button v-if="job.state === 'failed'" link @click="action(job.id, true)">
重试失败项
</el-button>
<p v-for="failure in job.failures" :key="`${failure.stage}:${failure.id}`">
{{ ({ grouping: "新闻初筛", analysis: "包研判", events: "事件时间线", outlook: "阅读编排" })[failure.stage] }} #{{ failure.id }}:{{ failure.error }}
</p>
</div>
</details>
</el-card>
<el-drawer v-model="opened" title="事件发展时间线" size="min(760px, 100vw)">
<el-alert v-if="detailError" title="事件详情读取失败" type="warning" :closable="false" />
<el-button v-if="detailError" @click="open(detailId)">
重试
</el-button>
<template v-if="detail">
<h2>{{ detail.title }}</h2><p>{{ detail.result?.summary }}</p>
<el-alert v-if="detail.error" :title="detail.error" type="warning" :closable="false" />
<div v-for="(point, index) in detail.result?.timeline_points" :key="index" class="my-4 border-l-2 pl-3">
<b>{{ point.time_text || '发生时间未知' }}</b> · {{ point.time_precision }} · {{ point.verification }}
<p>{{ point.description }}</p><p>报道时间:{{ formatTime(point.reported_at) }}</p>
<el-button link @click="emit('package', point.package_id)">
包 #{{ point.package_id }} · v{{ point.package_revision }}
</el-button>
<blockquote v-for="(ref, n) in point.evidence_refs" :key="n">
{{ ref.quote }}
</blockquote>
</div>
<p>反证:{{ detail.result?.counter_evidence }}</p><p>不确定性:{{ detail.result?.uncertainty }}</p><p>意见:{{ detail.result?.opinions }}</p><p>关注:{{ detail.result?.watchpoints }}</p>
<p>组成包</p>
<div v-for="item in detail.packages" :key="item.id">
<el-button link @click="emit('package', item.id)">
{{ item.title }}
</el-button>
</div>
<p>版本历史</p>
<details v-for="version in detail.versions" :key="version.id" class="my-2">
<summary>v{{ version.revision }} · {{ version.state }} {{ version.error }}</summary>
<p>{{ version.result?.summary }}</p>
<p v-for="(point, index) in version.result?.timeline_points" :key="index">
{{ point.time_text }} · {{ point.description }} · 包 #{{ point.package_id }} v{{ point.package_revision }}
</p>
<p>{{ version.result?.uncertainty }}</p>
</details>
</template>
</el-drawer>
</template>
+62 -16
View File
@@ -3,11 +3,19 @@ import type { SkillNode } from "@@/apis/skills/type"
import type * as Risk from "@/common/apis/risk/type"
import type { Provider } from "@/common/apis/settings/type"
import { listSkillTreeApi } from "@@/apis/skills"
import { EFFORTS } from "@@/constants/ai"
import { formatTime } from "@@/utils/text"
import * as api from "@/common/apis/risk"
import { listProviderModelsApi, listProvidersApi } from "@/common/apis/settings"
import { useUserStore } from "@/pinia/stores/user"
import EventResearch from "./components/EventResearch.vue"
const eventResearch = ref<InstanceType<typeof EventResearch> | null>(null)
function openReading(row: Risk.Reading) {
if (row.kind === "event") void eventResearch.value?.open(row.id)
else void openEvent(row.id)
}
const user = useUserStore()
const route = useRoute()
const hours = ref(72)
@@ -28,8 +36,8 @@ const thirdModels = ref<string[]>([])
const thirdError = ref("")
let thirdSequence = 0
const readingRows = computed(() => overview.value?.briefing?.reading.slice((readingPage.value - 1) * 20, readingPage.value * 20) ?? [])
function readingTitle(id: number) {
return overview.value?.briefing?.inputs.find(p => p.id === id)?.result.title ?? `包 #${id}`
function readingTitle(id: number, kind = "package") {
return overview.value?.briefing?.inputs.find(p => p.id === id && (p.kind ?? "package") === kind)?.result.title ?? `包 #${id}`
}
const q = ref("")
const range = ref<[Date, Date] | null>(null)
@@ -141,13 +149,13 @@ async function loadThirdModels() {
}
async function createTemplates() {
const version = user.sessionVersion
const value = await api.createNewsSkillTemplatesApi()
const value = await api.createNewsSkillTemplatesApi(true)
if (version !== user.sessionVersion || !settings.value) return
Object.assign(settings.value, value)
const skills = await listSkillTreeApi()
if (version !== user.sessionVersion) return
newsSkills.value = skills.filter(s => s.scope === "news" && s.kind === "skill")
ElMessage.success("已创建草稿,请前往 Skills 页面编辑并发布")
ElMessage.success("默认新闻 Skills 已准备好,请前往 Skills 页面审阅并发布")
}
async function save(enable: boolean) {
if (!settings.value) return
@@ -268,7 +276,7 @@ onBeforeUnmount(() => {
<div v-for="level in [5, 4, 3, 2, 1]" :key="level">
<div class="text-amber-600">
<span :title="levels[level]">{{ starText(level) }}</span>
</div><strong class="text-2xl">{{ overview.counts[String(level)] ?? 0 }}</strong><span class="ml-1 text-sm text-slate-500">事件</span>
</div><strong class="text-2xl">{{ overview.counts[String(level)] ?? 0 }}</strong><span class="ml-1 text-sm text-slate-500">包</span>
</div>
</div>
<p class="mb-0 text-sm text-slate-500">
@@ -276,7 +284,7 @@ onBeforeUnmount(() => {
失败 {{ overview.coverage.failed }}
</el-button>
</p>
<p>事件总结:完成 {{ overview.stage2.done ?? 0 }} · 待更新 {{ overview.stage2.pending ?? 0 }} · 处理中 {{ overview.stage2.processing ?? 0 }} · 失败 {{ overview.stage2.failed ?? 0 }}</p>
<p>包研判:完成 {{ overview.stage2.done ?? 0 }} · 待更新 {{ overview.stage2.pending ?? 0 }} · 处理中 {{ overview.stage2.processing ?? 0 }} · 失败 {{ overview.stage2.failed ?? 0 }}</p>
<p v-if="overview.stage3">
阅读编排:{{ overview.stage3.state === 'processing' ? '处理中' : overview.stage3.state === 'failed' ? '失败' : overview.stage3.pending ? '等待聚合更新' : '已更新' }}
</p>
@@ -290,19 +298,19 @@ onBeforeUnmount(() => {
近72小时完整阅读清单 · 按优先级排序
</template>
<template v-if="overview?.briefing">
<p>生成于 {{ formatTime(overview.briefing.as_of) }} · 共 {{ overview.briefing.reading.length }} 个重要包</p>
<p>生成于 {{ formatTime(overview.briefing.as_of) }} · 共 {{ overview.briefing.reading.length }} 项事件与独立包</p>
<el-alert v-if="overview.stale" title="阅读稿可能已过期或更新失败,当前保留上次成功结果" type="warning" :closable="false" />
<div v-for="(row, index) in readingRows" :key="row.id" class="my-3">
<el-button link @click="openEvent(row.id)">
{{ (readingPage - 1) * 20 + index + 1 }}. {{ readingTitle(row.id) }}
<div v-for="(row, index) in readingRows" :key="`${row.kind}:${row.id}`" class="my-3">
<el-button link @click="openReading(row)">
{{ (readingPage - 1) * 20 + index + 1 }}. {{ readingTitle(row.id, row.kind) }}
</el-button>
<p>{{ row.reason }} · 优先级 {{ row.priority }} · 依据版本 {{ row.revision }}</p>
</div>
<el-pagination v-model:current-page="readingPage" :total="overview.briefing.reading.length" :page-size="20" layout="prev, pager, next, total" />
<p><b>拓展阅读</b></p>
<div v-for="row in overview.briefing.expansion" :key="row.id">
<el-button link @click="openEvent(row.id)">
{{ readingTitle(row.id) }}
<div v-for="row in overview.briefing.expansion" :key="`${row.kind}:${row.id}`">
<el-button link @click="openReading(row)">
{{ readingTitle(row.id, row.kind) }}
</el-button><p>{{ row.reason }}</p>
</div>
<template v-if="overview.briefing.synthesis">
@@ -317,6 +325,7 @@ onBeforeUnmount(() => {
<el-empty v-else description="暂无阅读稿,请配置三个阶段模型并启用" />
<el-alert v-if="overview?.error" :title="overview.error" type="warning" :closable="false" />
</el-card>
<EventResearch ref="eventResearch" @package="openEvent" />
<div class="flex flex-wrap gap-3">
<el-switch v-model="archive" active-text="历史归档" />
<el-select v-model="importance" class="w-40">
@@ -375,6 +384,11 @@ onBeforeUnmount(() => {
接口未返回可选模型,可手动填写模型 ID。
</p>
</el-form-item>
<el-form-item label="第一阶段思考强度">
<el-select v-model="settings.reasoning_effort" class="w-full" clearable :value-on-clear="null" placeholder="跟随接入配置">
<el-option v-for="effort in EFFORTS" :key="effort" :value="effort" :label="effort" />
</el-select>
</el-form-item>
<el-form-item label="第二阶段接入配置">
<el-select v-model="settings.second_provider_id" class="w-full">
<el-option v-for="provider in providers" :key="provider.id" :value="provider.id" :label="provider.name" />
@@ -391,6 +405,11 @@ onBeforeUnmount(() => {
{{ secondModelsError }}
</p>
</el-form-item>
<el-form-item label="第二阶段思考强度">
<el-select v-model="settings.second_reasoning_effort" class="w-full" clearable :value-on-clear="null" placeholder="跟随接入配置">
<el-option v-for="effort in EFFORTS" :key="effort" :value="effort" :label="effort" />
</el-select>
</el-form-item>
<el-form-item label="第三阶段高级模型接入配置">
<el-select v-model="settings.third_provider_id" class="w-full">
<el-option v-for="provider in providers" :key="provider.id" :value="provider.id" :label="provider.name" />
@@ -403,13 +422,21 @@ onBeforeUnmount(() => {
刷新列表
</el-button><p>{{ thirdError }}</p>
</el-form-item>
<el-form-item label="第三阶段思考强度">
<el-select v-model="settings.third_reasoning_effort" class="w-full" clearable :value-on-clear="null" placeholder="跟随接入配置">
<el-option v-for="effort in EFFORTS" :key="effort" :value="effort" :label="effort" />
</el-select>
</el-form-item>
<p class="text-sm text-slate-500">
思考强度留空时跟随接入配置;接入配置也未设置时,由模型默认处理。不支持所选强度的模型会在测试启用时明确报错。
</p>
<el-form-item v-for="field in (['grouping_skill_id', 'analysis_skill_id', 'outlook_skill_id'] as const)" :key="field" :label="({ grouping_skill_id: '初筛归并 Skill', analysis_skill_id: '包核验 Skill', outlook_skill_id: '高级阅读编排 Skill' })[field]">
<el-select v-model="settings[field]" class="w-full">
<el-option v-for="skill in newsSkills" :key="skill.id" :value="skill.id" :label="`${skill.path} · ${skill.published_version_no ? `已发布 v${skill.published_version_no}` : '未发布'}`" />
</el-select>
</el-form-item>
<el-button @click="createTemplates">
创建默认新闻 Skills 草稿
生成/更新默认新闻 Skills 草稿
</el-button>
<router-link to="/skills">
编辑与发布 Skills
@@ -423,11 +450,30 @@ onBeforeUnmount(() => {
</el-button>
</el-form>
</el-dialog>
<el-drawer v-model="showDetail" title="事件证据与评级历史" size="min(760px, 100vw)">
<el-drawer v-model="showDetail" title="新闻包证据与评级历史" size="min(760px, 100vw)">
<template v-if="detail">
<el-button v-if="detail.event.main_event_id" link @click="eventResearch?.open(detail.event.main_event_id)">
查看主事件时间线
</el-button>
<el-button v-for="id in detail.event.replacement_ids" :key="id" link @click="openEvent(id)">
查看纠正后的包 #{{ id }}
</el-button>
<h2 class="text-lg">
{{ detail.event.title }}
</h2>
<div v-for="link in detail.related_events" :key="link.id">
<el-button link @click="eventResearch?.open(link.id)">
关联事件 #{{ link.id }}
</el-button> {{ link.reason }}
</div>
<details v-if="detail.corrections?.length">
<summary>分包纠正历史</summary><pre v-for="item in detail.corrections" :key="item.id" class="whitespace-pre-wrap break-all">{{ item.result }}</pre>
</details>
<details v-if="detail.grouping_versions?.length">
<summary>Luna 分包版本</summary><p v-for="item in detail.grouping_versions" :key="item.id">
#{{ item.id }} · {{ item.state }} {{ item.error }}
</p>
</details>
<template v-if="detail.event.result">
<p>{{ detail.event.result.summary }}</p><p><b>事实:</b>{{ detail.event.result.facts }}</p><p><b>时间线:</b>{{ detail.event.result.timeline }}</p>
<p><b>资产影响:</b>{{ detail.event.result.asset_impacts }}</p><p><b>相关意见:</b>{{ detail.event.result.opinions }}</p>
@@ -471,7 +517,7 @@ onBeforeUnmount(() => {
</el-drawer>
<el-drawer v-model="showFailures" title="失败任务(最近100条)" size="min(700px, 100vw)">
<el-select v-model="taskStage">
<el-option label="文章初筛" value="grouping" /><el-option label="事件总结" value="analysis" /><el-option label="市场综述" value="outlook" />
<el-option label="文章初筛" value="grouping" /><el-option label="包研判" value="analysis" /><el-option label="事件时间线" value="events" /><el-option label="市场综述" value="outlook" />
</el-select>
<div v-for="item in failures" :key="item.id" class="mb-4">
<b>{{ item.input.title }}</b><p>{{ item.error }}</p><el-button @click="retry(item.id)">
+97
View File
@@ -739,3 +739,100 @@ test("创建新闻 Skills 后刷新列表,旧会话响应不能覆盖新会话
assert.equal(page.newsSkills.value[0].id, 2)
assert.equal(notifications, 0)
})
test("三阶段思考强度独立保存,重建模板显式请求新版草稿", async () => {
let saved: Record<string, unknown> = {}
let refresh: unknown
const config = { enabled: false, model: "luna", reasoning_effort: null, second_reasoning_effort: null, third_reasoning_effort: null }
const load = moduleLoader({
"@/common/apis/risk": {
saveRiskSettingsApi: async (data: Record<string, unknown>) => saved = data,
getRiskOverviewApi: async () => ({ coverage: {}, counts: {} }),
listRiskEventsApi: async () => ({ items: [], total: 0 }),
createNewsSkillTemplatesApi: async (value: unknown) => {
refresh = value
return { grouping_skill_id: 1, analysis_skill_id: 2, outlook_skill_id: 3 }
}
},
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/settings": {},
"@/pinia/stores/user": { useUserStore: () => ({ sessionVersion: 1 }) }
}, globals)
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
page.settings.value = { ...config, reasoning_effort: "low", second_reasoning_effort: "high", third_reasoning_effort: "xhigh" }
await page.save(false)
assert.equal(saved.reasoning_effort, "low")
assert.equal(saved.second_reasoning_effort, "high")
assert.equal(saved.third_reasoning_effort, "xhigh")
page.settings.value.second_reasoning_effort = null
await page.save(false)
assert.equal(saved.reasoning_effort, "low")
assert.equal(saved.second_reasoning_effort, null)
assert.equal(saved.third_reasoning_effort, "xhigh")
await page.createTemplates()
assert.equal(refresh, true)
assert.equal(page.settings.value.analysis_skill_id, 2)
})
test("事件详情忽略旧请求与旧会话,失败可重试", async () => {
const user = vue.reactive({ sessionVersion: 1 })
const pending: ReturnType<typeof Promise.withResolvers>[] = []
const load = moduleLoader({
"@/pinia/stores/user": { useUserStore: () => user },
"@/common/apis/risk": { getNewsEventApi: () => {
const request = Promise.withResolvers()
pending.push(request)
return request.promise
} },
"@@/utils/text": { formatTime: (value: string) => value }
}, globals)
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {}, emit() {} })
const first = page.open(1)
const second = page.open(2)
pending[1].resolve({ id: 2 })
await second
pending[0].reject(new Error("Old failure"))
await first
assert.equal(page.detail.value.id, 2)
assert.equal(page.detailError.value, false)
const failed = page.open(3)
pending[2].reject(new Error("Failed"))
await failed
assert.equal(page.detailError.value, true)
const retry = page.open(3)
user.sessionVersion++
await vue.nextTick()
pending[3].resolve({ id: 3 })
await retry
assert.equal(page.detail.value, null)
assert.equal(page.opened.value, false)
})
test("重整理预览的旧会话完成不能解除新操作的忙碌状态", async () => {
const user = vue.reactive({ sessionVersion: 1 })
const pending: ReturnType<typeof Promise.withResolvers>[] = []
const load = moduleLoader({
"@/pinia/stores/user": { useUserStore: () => user },
"@/common/apis/risk": { previewReprocessApi: () => {
const request = Promise.withResolvers()
pending.push(request)
return request.promise
} },
"@@/utils/text": { formatTime: (value: string) => value }
}, globals)
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {}, emit() {} })
page.range.value = [new Date("2026-11-01"), new Date("2026-11-02")]
await vue.nextTick()
const old = page.inspect()
user.sessionVersion++
await vue.nextTick()
const current = page.inspect()
pending[0].resolve({ articles: 1 })
await old
assert.equal(page.busy.value, true)
assert.equal(page.preview.value, null)
pending[1].resolve({ articles: 2 })
await current
assert.equal(page.busy.value, false)
assert.equal(page.preview.value.articles, 2)
})