Add three-stage cross-domain news research and reading briefings
Group original news into tagged fact packages, review each package with evidence and read-only research, and build complete 72-hour reading lists with independent model selections. Add paused RSS/Atom subscriptions, news-scoped Skills, versioned analysis records, and locked deployment support for SearXNG. Preserve original news and keep live migration and legacy AI cleanup separately authorized. Validate with 604 backend tests, 49 frontend tests, 19 CI control tests, lint, type checks, production build, and disposable PostgreSQL migration and three-stage smoke checks.
This commit is contained in:
1 parent
ae35d3d608
commit
80d266dba7
44 files changed
+2682
-574
No files matched your search
@@ -43,3 +43,5 @@ export function subscribeCrawlerApi(signal: AbortSignal, version: number, onOpen
|
||||
if (name === "log") onMessage(data)
|
||||
})
|
||||
}
|
||||
|
||||
export const createRssApi = (data: Crawler.RssInput) => request<{ code: string }>({ url: "/crawler/sources/rss", method: "post", data })
|
||||
@@ -62,6 +62,7 @@ export interface CrawlLog {
|
||||
}
|
||||
|
||||
export interface SourcePatch {
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
interval_seconds?: number
|
||||
/** ISO 时间表示回补到该时间;null 表示取消回补 */
|
||||
@@ -73,3 +74,5 @@ export type PatchSourceResponseData = null
|
||||
export type ListLogsResponseData = CrawlLog[]
|
||||
|
||||
export type RestartCrawlerResponseData = null
|
||||
|
||||
export interface RssInput { name: string, url: string, interval_seconds: number }
|
||||
@@ -5,7 +5,10 @@ export const getRiskSettingsApi = () => request<Risk.Settings>({ url: "/risk/set
|
||||
export const saveRiskSettingsApi = (data: Risk.Settings) => request<null>({ url: "/risk/settings", method: "put", data })
|
||||
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/events", method: "get", params })
|
||||
export const getRiskEventApi = (id: number) => request<{ event: Risk.RiskEvent, history: Risk.Analysis[] }>({ url: `/risk/events/${id}`, method: "get" })
|
||||
export const listRiskAnalysesApi = (params: { news_id?: number, revision_no?: number, failed?: boolean }, notify = true) => request<Risk.Analysis[]>({ url: "/risk/analyses", method: "get", params, notify })
|
||||
export const retryRiskAnalysisApi = (id: number) => request<null>({ url: `/risk/analyses/${id}/retry`, method: "post" })
|
||||
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 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" })
|
||||
@@ -2,24 +2,55 @@ export interface RiskResult {
|
||||
summary: string
|
||||
reasoning: string
|
||||
uncertainty: string
|
||||
evidence: string
|
||||
evidence?: string
|
||||
direction: string
|
||||
verification: string
|
||||
confidence: string
|
||||
stars: number
|
||||
risk_stars: number
|
||||
importance_stars: number
|
||||
facts?: string
|
||||
timeline?: string
|
||||
asset_impacts?: string
|
||||
opinions?: string
|
||||
counter_evidence?: string
|
||||
watchpoints?: string
|
||||
claims?: { statement: string, verdict: string, evidence_refs: { article_id: number, quote: string }[] }[]
|
||||
related_packages?: { id: number, relation: string, reason: string }[]
|
||||
evidence_refs?: { article_id: number, quote: string }[]
|
||||
}
|
||||
export interface RiskEvent {
|
||||
tags: string[]
|
||||
content_kind: string
|
||||
importance_stars: number
|
||||
article_count: number
|
||||
id: number
|
||||
title: string
|
||||
category: string
|
||||
entities: string[]
|
||||
stars: number
|
||||
risk_stars: number
|
||||
peak_stars: number
|
||||
result: RiskResult
|
||||
result: RiskResult | null
|
||||
state: string
|
||||
analyzed_at: string | null
|
||||
revision: number
|
||||
published_revision: number
|
||||
error: string | null
|
||||
published_at: string
|
||||
updated_at: string
|
||||
}
|
||||
export interface Settings { enabled: boolean, provider_id: number | null, model: string, start_at: string }
|
||||
export interface Settings {
|
||||
enabled: boolean
|
||||
provider_id: number | null
|
||||
model: string
|
||||
second_provider_id: number | null
|
||||
second_model: string
|
||||
third_provider_id: number | null
|
||||
third_model: string
|
||||
grouping_skill_id: number | null
|
||||
analysis_skill_id: number | null
|
||||
outlook_skill_id: number | null
|
||||
start_at: string | null
|
||||
}
|
||||
export interface Analysis {
|
||||
id: number
|
||||
news_id: number
|
||||
@@ -31,10 +62,22 @@ export interface Analysis {
|
||||
analyzed_at: string | null
|
||||
historical: boolean
|
||||
}
|
||||
export interface Reading { 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 } }[]
|
||||
}
|
||||
export interface Overview {
|
||||
briefing: Briefing | null
|
||||
stage2: Record<string, number>
|
||||
stage3?: { state: string, pending: boolean, heartbeat: string | null }
|
||||
as_of: string
|
||||
counts: Record<string, number>
|
||||
coverage: { total: number, done: number, failed: number, pending: number, processing: number }
|
||||
coverage: { total: number, done: number, skipped: number, failed: number, pending: number, processing: number }
|
||||
scenario: { direction: string, summary: string, conditions: string, counter_factors: string, uncertainty: string, event_ids: number[] } | null
|
||||
snapshot_at: string | null
|
||||
quotes: { at: string, BTCUSDT?: { lastPrice: string, priceChangePercent: string }, ETHUSDT?: { lastPrice: string, priceChangePercent: string } } | null
|
||||
@@ -42,4 +85,4 @@ export interface Overview {
|
||||
error: string | null
|
||||
heartbeat: string | null
|
||||
}
|
||||
export interface EventQuery { hours?: number, stars?: number, q?: string, start?: string, end?: string, page?: number }
|
||||
export interface EventQuery { tag?: string, importance?: number, hours?: number, stars?: number, q?: string, category?: string, asset?: string, start?: string, end?: string, page?: number }
|
||||
@@ -3,6 +3,7 @@ export type NodeKind = "folder" | "skill"
|
||||
export interface SkillNode {
|
||||
id: number
|
||||
parent_id: number | null
|
||||
scope: "shared" | "news"
|
||||
kind: NodeKind
|
||||
name: string
|
||||
path: string
|
||||
@@ -28,7 +29,7 @@ export type ListSkillTreeResponseData = SkillNode[]
|
||||
|
||||
export type CreateSkillNodeResponseData = SkillNode
|
||||
|
||||
export interface CreateSkillNodeBodyRequestData { parent_id: number | null, kind: NodeKind, name: string, priority?: number, content?: string }
|
||||
export interface CreateSkillNodeBodyRequestData { parent_id: number | null, kind: NodeKind, name: string, priority?: number, scope?: "shared" | "news", content?: string }
|
||||
|
||||
export type PatchSkillNodeResponseData = SkillNode
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSessionScope } from "@@/composables/useSessionScope"
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: SkillNode[]
|
||||
scope?: "shared" | "news"
|
||||
editable?: boolean
|
||||
selectedId?: number | null
|
||||
/** 需要高亮的节点(如 Chat 刚生成草稿的 skill) */
|
||||
@@ -79,7 +80,7 @@ async function create(parentId: number | null, kind: NodeKind) {
|
||||
inputValidator: NAME_RULE
|
||||
})
|
||||
sessionScope.assertCurrent()
|
||||
const node = await createSkillNodeApi({ parent_id: parentId, kind, name: value.trim() })
|
||||
const node = await createSkillNodeApi({ parent_id: parentId, kind, name: value.trim(), scope: props.scope ?? "shared" })
|
||||
emit("changed")
|
||||
if (kind === "skill")
|
||||
emit("select", node)
|
||||
@@ -137,7 +138,7 @@ function onCollapse(item: TreeItem) {
|
||||
:class="{ 'opacity-50': !data.data.enabled, 'bg-amber-100': highlightIds?.includes(data.id) }"
|
||||
>
|
||||
<span>{{ data.data.kind === "folder" ? "📁" : "📄" }}</span>
|
||||
<span class="truncate">{{ data.data.name }}</span>
|
||||
<span class="truncate">{{ data.data.name }}{{ data.data.scope === "news" ? " · 新闻" : "" }}</span>
|
||||
<template v-if="data.data.kind === 'skill'">
|
||||
<el-tag size="small" effect="plain" class="shrink-0">
|
||||
P{{ data.data.priority }}
|
||||
|
||||
@@ -56,7 +56,7 @@ async function publish(version: SkillVersion) {
|
||||
const seq = requestSeq
|
||||
const current = props.skill?.published_version_no
|
||||
const action = current !== null && current !== undefined && version.version_no < current ? "回退到" : "发布"
|
||||
await ElMessageBox.confirm(`${action} v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
|
||||
await ElMessageBox.confirm(`${action} v${version.version_no}?发布后对应用途的下一次任务起使用此版本。`, "发布确认", { type: "warning" })
|
||||
sessionScope.assertCurrent()
|
||||
if (seq !== requestSeq || !visible.value || skillId !== props.skill?.id)
|
||||
throw new CanceledError("查看的 Skill 已切换")
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CrawlLog, SourceInfo } from "@@/apis/crawler/type"
|
||||
import type { NewsBrief } from "@@/apis/news/type"
|
||||
import { patchSourceApi } from "@@/apis/crawler"
|
||||
import { listNewsApi } from "@@/apis/news"
|
||||
import { useSessionScope } from "@@/composables/useSessionScope"
|
||||
import { formatTime } from "@@/utils/text"
|
||||
import { CircleCheck, RefreshRight } from "@element-plus/icons-vue"
|
||||
import dayjs from "dayjs"
|
||||
@@ -20,10 +21,18 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const latest = ref<NewsBrief[]>([])
|
||||
const sessionScope = useSessionScope()
|
||||
const sourceName = ref(props.source.name)
|
||||
async function saveName() {
|
||||
sessionScope.assertCurrent()
|
||||
await patchSourceApi(props.source.code, { name: sourceName.value })
|
||||
if (sessionScope.isCurrent()) emit("changed")
|
||||
}
|
||||
const interval = ref(props.source.interval_seconds)
|
||||
|
||||
async function loadLatest() {
|
||||
latest.value = (await listNewsApi({ source: [props.source.code], limit: 5 })).items
|
||||
const result = await listNewsApi({ source: [props.source.code], limit: 5 })
|
||||
if (sessionScope.isCurrent()) latest.value = result.items
|
||||
}
|
||||
|
||||
// 本来源有新的“本轮完成”日志时刷新最新入库列表
|
||||
@@ -97,6 +106,11 @@ onMounted(loadLatest)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="source.code.startsWith('rss_')" class="flex gap-2">
|
||||
<el-input v-model="sourceName" placeholder="订阅名称" /><el-button @click="saveName">
|
||||
保存名称
|
||||
</el-button>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-3 border rounded-lg border-solid p-4 shadow-sm"
|
||||
:class="alert ? 'border-rose-300 bg-rose-50/50' : 'border-slate-200 bg-white'"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SourcesInfo } from "@@/apis/crawler/type"
|
||||
import { listSourcesApi, restartCrawlerApi } from "@@/apis/crawler"
|
||||
import { createRssApi, listSourcesApi, restartCrawlerApi } from "@@/apis/crawler"
|
||||
import { useSessionScope } from "@@/composables/useSessionScope"
|
||||
import { formatTime } from "@@/utils/text"
|
||||
import { Monitor, RefreshRight } from "@element-plus/icons-vue"
|
||||
@@ -9,6 +9,22 @@ import CalendarSourcePanel from "./components/CalendarSourcePanel.vue"
|
||||
import SourcePanel from "./components/SourcePanel.vue"
|
||||
import { useCrawlerLogs } from "./composables/useCrawlerLogs"
|
||||
|
||||
const rssVisible = ref(false)
|
||||
const rssSaving = ref(false)
|
||||
const rssDraft = reactive({ name: "", url: "", interval_seconds: 300 })
|
||||
async function addRss() {
|
||||
if (rssSaving.value) return
|
||||
rssSaving.value = true
|
||||
sessionScope.assertCurrent()
|
||||
try {
|
||||
await createRssApi({ ...rssDraft })
|
||||
if (!sessionScope.isCurrent()) return
|
||||
rssVisible.value = false
|
||||
await refresh()
|
||||
} finally {
|
||||
rssSaving.value = false
|
||||
}
|
||||
}
|
||||
const sessionScope = useSessionScope()
|
||||
|
||||
// 连续失败达到该次数时卡片告警
|
||||
@@ -89,6 +105,22 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="rssVisible" title="新增RSS/Atom订阅(保存后默认暂停)">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="rssDraft.name" />
|
||||
</el-form-item><el-form-item label="公开订阅地址">
|
||||
<el-input v-model="rssDraft.url" />
|
||||
</el-form-item><el-form-item label="间隔(秒)">
|
||||
<el-input-number v-model="rssDraft.interval_seconds" :min="60" :max="86400" />
|
||||
</el-form-item><el-button :loading="rssSaving" @click="addRss">
|
||||
保存订阅
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
<el-button @click="rssVisible = true">
|
||||
新增 RSS/Atom 订阅
|
||||
</el-button>
|
||||
<div v-if="!info" v-loading="loading" class="min-h-40">
|
||||
<el-result v-if="failed" icon="error" title="采集状态读取失败">
|
||||
<template #extra>
|
||||
|
||||
+212
-45
@@ -1,15 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
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 { formatTime } from "@@/utils/text"
|
||||
import * as api from "@/common/apis/risk"
|
||||
import { listProviderModelsApi, listProvidersApi } from "@/common/apis/settings"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
|
||||
const user = useUserStore()
|
||||
const hours = ref(24)
|
||||
const stars = ref(1)
|
||||
const route = useRoute()
|
||||
const hours = ref(72)
|
||||
const stars = ref(0)
|
||||
const importance = ref(0)
|
||||
const category = ref("")
|
||||
const asset = ref("")
|
||||
const taskStage = ref("grouping")
|
||||
const newsSkills = ref<SkillNode[]>([])
|
||||
const secondModels = ref<string[]>([])
|
||||
const secondModelsError = ref("")
|
||||
const secondModelsLoading = ref(false)
|
||||
let secondModelsSequence = 0
|
||||
const archive = ref(false)
|
||||
const tag = ref("")
|
||||
const readingPage = ref(1)
|
||||
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}`
|
||||
}
|
||||
const q = ref("")
|
||||
const range = ref<[Date, Date] | null>(null)
|
||||
const page = ref(1)
|
||||
@@ -36,15 +57,16 @@ let modelsSequence = 0
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
const labels: Record<string, string> = { bullish: "偏多", bearish: "偏空", mixed: "震荡/分歧", neutral: "中性", unknown: "无法判断", official: "官方公告", reported: "媒体报道", unverified: "待核实", refuted: "更正/辟谣", insufficient: "信息不足", low: "低", medium: "中", high: "高" }
|
||||
const starText = (value: number) => value ? "★".repeat(value) : "无明显风险"
|
||||
const levels = ["无明显风险", "关注", "警惕", "高风险", "严重", "极端系统性风险"]
|
||||
const levels = ["未评级", "背景信息", "局部进展", "重要变化", "广泛影响", "重大变化"]
|
||||
async function load() {
|
||||
const seq = ++sequence
|
||||
const version = user.sessionVersion
|
||||
loading.value = true
|
||||
try {
|
||||
const [summary, list] = await Promise.all([api.getRiskOverviewApi(hours.value), api.listRiskEventsApi({ hours: archive.value ? undefined : hours.value, stars: stars.value, q: q.value, page: page.value, start: archive.value ? range.value?.[0].toISOString() : undefined, end: archive.value ? range.value?.[1].toISOString() : undefined })])
|
||||
const [summary, list] = await Promise.all([api.getRiskOverviewApi(hours.value), api.listRiskEventsApi({ importance: importance.value, tag: tag.value, hours: archive.value ? undefined : hours.value, stars: stars.value, q: q.value, page: page.value, category: category.value, asset: asset.value, start: archive.value ? range.value?.[0].toISOString() : undefined, end: archive.value ? range.value?.[1].toISOString() : undefined })])
|
||||
if (seq !== sequence || version !== user.sessionVersion) return
|
||||
overview.value = summary
|
||||
readingPage.value = Math.min(readingPage.value, Math.max(1, Math.ceil((summary.briefing?.reading.length ?? 0) / 20)))
|
||||
events.value = list.items
|
||||
total.value = list.total
|
||||
failed.value = false
|
||||
@@ -56,8 +78,9 @@ async function load() {
|
||||
}
|
||||
async function openSettings() {
|
||||
const version = user.sessionVersion
|
||||
const [config, choices] = await Promise.all([api.getRiskSettingsApi(), listProvidersApi()])
|
||||
const [config, choices, skills] = await Promise.all([api.getRiskSettingsApi(), listProvidersApi(), listSkillTreeApi()])
|
||||
if (version !== user.sessionVersion) return
|
||||
newsSkills.value = skills.filter(s => s.scope === "news" && s.kind === "skill")
|
||||
settings.value = config
|
||||
providers.value = choices
|
||||
showSettings.value = true
|
||||
@@ -84,6 +107,46 @@ async function loadModels() {
|
||||
if (seq === modelsSequence && version === user.sessionVersion) modelsLoading.value = false
|
||||
}
|
||||
}
|
||||
async function loadSecondModels() {
|
||||
const seq = ++secondModelsSequence
|
||||
const version = user.sessionVersion
|
||||
const id = settings.value?.second_provider_id
|
||||
secondModels.value = []
|
||||
secondModelsError.value = ""
|
||||
secondModelsLoading.value = false
|
||||
if (!showSettings.value || !id) return
|
||||
secondModelsLoading.value = true
|
||||
try {
|
||||
const choices = await listProviderModelsApi(id)
|
||||
if (seq === secondModelsSequence && version === user.sessionVersion) secondModels.value = choices
|
||||
} catch (error) {
|
||||
if (seq === secondModelsSequence && version === user.sessionVersion) secondModelsError.value = error instanceof Error ? error.message : "读取模型列表失败"
|
||||
} finally {
|
||||
if (seq === secondModelsSequence && version === user.sessionVersion) secondModelsLoading.value = false
|
||||
}
|
||||
}
|
||||
async function loadThirdModels() {
|
||||
const seq = ++thirdSequence
|
||||
const version = user.sessionVersion
|
||||
const id = settings.value?.third_provider_id
|
||||
thirdModels.value = []
|
||||
thirdError.value = ""
|
||||
if (!showSettings.value || !id) return
|
||||
try {
|
||||
const rows = await listProviderModelsApi(id)
|
||||
if (seq === thirdSequence && version === user.sessionVersion) thirdModels.value = rows
|
||||
} catch (error) {
|
||||
if (seq === thirdSequence && version === user.sessionVersion) thirdError.value = error instanceof Error ? error.message : "模型列表读取失败"
|
||||
}
|
||||
}
|
||||
async function createTemplates() {
|
||||
const version = user.sessionVersion
|
||||
const value = await api.createNewsSkillTemplatesApi()
|
||||
if (version !== user.sessionVersion || !settings.value) return
|
||||
Object.assign(settings.value, value)
|
||||
newsSkills.value = (await listSkillTreeApi()).filter(s => s.scope === "news" && s.kind === "skill")
|
||||
ElMessage.success("已创建草稿,请前往 Skills 页面编辑并发布")
|
||||
}
|
||||
async function save(enable: boolean) {
|
||||
if (!settings.value) return
|
||||
const version = user.sessionVersion
|
||||
@@ -109,24 +172,34 @@ async function openEvent(id: number) {
|
||||
}
|
||||
async function openFailures() {
|
||||
const version = user.sessionVersion
|
||||
const data = await api.listRiskAnalysesApi({ failed: true })
|
||||
const stage = taskStage.value
|
||||
const data = await api.listRiskAnalysesApi({ failed: true, stage })
|
||||
if (stage !== taskStage.value) return
|
||||
if (version !== user.sessionVersion) return
|
||||
failures.value = data
|
||||
showFailures.value = true
|
||||
}
|
||||
async function retry(id: number) {
|
||||
const version = user.sessionVersion
|
||||
await api.retryRiskAnalysisApi(id)
|
||||
await api.retryRiskAnalysisApi(id, taskStage.value)
|
||||
if (version === user.sessionVersion) await openFailures()
|
||||
}
|
||||
function search() {
|
||||
if (page.value === 1) void load()
|
||||
else page.value = 1
|
||||
}
|
||||
watch([hours, stars, archive, range], search)
|
||||
watch([hours, stars, importance, archive, range, category, asset, tag], search)
|
||||
watch(page, () => void load())
|
||||
watch([showSettings, () => settings.value?.provider_id], () => void loadModels())
|
||||
watch([showSettings, () => settings.value?.second_provider_id], () => void loadSecondModels())
|
||||
watch([showSettings, () => settings.value?.third_provider_id], () => void loadThirdModels())
|
||||
watch(taskStage, () => {
|
||||
if (showFailures.value) void openFailures()
|
||||
})
|
||||
watch(() => user.sessionVersion, () => {
|
||||
thirdSequence++
|
||||
secondModelsSequence++
|
||||
secondModels.value = []
|
||||
modelsSequence++
|
||||
showSettings.value = false
|
||||
settings.value = null
|
||||
@@ -141,6 +214,8 @@ watch(showDetail, (visible) => {
|
||||
})
|
||||
onMounted(() => {
|
||||
void load()
|
||||
const summaryId = Number(route.query.summary)
|
||||
if (Number.isSafeInteger(summaryId) && summaryId > 0) void openEvent(summaryId)
|
||||
timer = setInterval(() => {
|
||||
if (!document.hidden && !loading.value) void load()
|
||||
}, 30000)
|
||||
@@ -149,6 +224,8 @@ onBeforeUnmount(() => {
|
||||
sequence++
|
||||
detailSequence++
|
||||
modelsSequence++
|
||||
thirdSequence++
|
||||
secondModelsSequence++
|
||||
clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
@@ -158,9 +235,9 @@ onBeforeUnmount(() => {
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<h1 class="m-0 text-xl font-semibold">
|
||||
新闻风险观察
|
||||
72小时新闻阅读与研判
|
||||
</h1><p class="mb-0 mt-1 text-sm text-slate-500">
|
||||
加密市场事件、证据与条件情景
|
||||
跨领域信息、事实核验、市场与社会趋势
|
||||
</p>
|
||||
</div>
|
||||
<el-button @click="openSettings">
|
||||
@@ -176,11 +253,13 @@ onBeforeUnmount(() => {
|
||||
24 小时
|
||||
</el-radio-button><el-radio-button :value="48">
|
||||
48 小时
|
||||
</el-radio-button><el-radio-button :value="72">
|
||||
72 小时
|
||||
</el-radio-button><el-radio-button :value="168">
|
||||
7 天
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="text-xs text-slate-500">分析起点:2026-10-26 00:00 北京时间</span>
|
||||
<span class="text-xs text-slate-500">仅处理新流程首次启用后发布或源站修订的新闻</span>
|
||||
</div>
|
||||
<el-card v-if="overview" shadow="never">
|
||||
<div class="flex flex-wrap gap-5">
|
||||
@@ -191,10 +270,14 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0 text-sm text-slate-500">
|
||||
已分析 {{ overview.coverage.done }} / {{ overview.coverage.total }} 条 · 待处理 {{ overview.coverage.pending }} · 处理中 {{ overview.coverage.processing }} · <el-button link type="danger" @click="openFailures">
|
||||
初筛完成 {{ overview.coverage.done }} / {{ overview.coverage.total }} 条 · 待处理 {{ overview.coverage.pending }} · 处理中 {{ overview.coverage.processing }} · <el-button link type="danger" @click="openFailures">
|
||||
失败 {{ 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 v-if="overview.stage3">
|
||||
阅读编排:{{ overview.stage3.state === 'processing' ? '处理中' : overview.stage3.state === 'failed' ? '失败' : overview.stage3.pending ? '等待聚合更新' : '已更新' }}
|
||||
</p>
|
||||
<el-alert v-if="overview.coverage.done < overview.coverage.total" class="mt-3" type="info" :closable="false" title="分析覆盖尚未完整;没有风险事件不代表没有风险。" />
|
||||
<p class="mb-0 text-xs text-slate-500">
|
||||
Worker 心跳:{{ formatTime(overview.heartbeat) }} · 数据读取:{{ formatTime(overview.as_of) }}
|
||||
@@ -202,52 +285,66 @@ onBeforeUnmount(() => {
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="flex justify-between">
|
||||
<span>AI 综合判断 · 未来数小时至 24 小时</span><el-tag v-if="overview?.stale" type="warning">
|
||||
尚无最新判断
|
||||
</el-tag>
|
||||
</div>
|
||||
近72小时完整阅读清单 · 按优先级排序
|
||||
</template>
|
||||
<template v-if="overview?.scenario">
|
||||
<el-tag>{{ labels[overview.scenario.direction] }}</el-tag><p class="whitespace-pre-wrap">
|
||||
{{ overview.scenario.summary }}
|
||||
</p>
|
||||
<p><b>触发条件:</b>{{ overview.scenario.conditions }}</p><p><b>反向因素:</b>{{ overview.scenario.counter_factors }}</p><p><b>不确定性:</b>{{ overview.scenario.uncertainty }}</p>
|
||||
<el-button v-for="id in overview.scenario.event_ids" :key="id" link @click="openEvent(id)">
|
||||
依据事件 #{{ id }}
|
||||
</el-button>
|
||||
<p class="text-xs text-slate-500">
|
||||
生成于 {{ formatTime(overview.snapshot_at) }},历史补分析不代表当时预测。
|
||||
</p>
|
||||
</template><el-empty v-else description="尚无综合判断,请配置模型并启用分析" />
|
||||
<div v-if="overview?.quotes" class="flex flex-wrap gap-4 text-sm text-slate-600">
|
||||
<span v-for="symbol in (['BTCUSDT', 'ETHUSDT'] as const)" :key="symbol">{{ symbol }}:{{ overview.quotes[symbol]?.lastPrice ?? '不可用' }} · 24h {{ overview.quotes[symbol]?.priceChangePercent ?? '-' }}%</span><span>快照 {{ formatTime(overview.quotes.at) }}</span>
|
||||
</div>
|
||||
<el-alert v-if="overview?.error" class="mt-3" type="warning" :closable="false" :title="overview.error" />
|
||||
<template v-if="overview?.briefing">
|
||||
<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) }}
|
||||
</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) }}
|
||||
</el-button><p>{{ row.reason }}</p>
|
||||
</div>
|
||||
<template v-if="overview.briefing.synthesis">
|
||||
<p><b>综合判断:</b>{{ overview.briefing.synthesis.summary }}</p>
|
||||
<p><b>市场影响:</b>{{ overview.briefing.synthesis.market }}</p>
|
||||
<div v-for="(trend, index) in [overview.briefing.synthesis.short_term, overview.briefing.synthesis.medium_term]" :key="index">
|
||||
<p><b>{{ index === 0 ? '未来24~72小时' : '未来1~4周' }}:</b>{{ trend.summary }}</p>
|
||||
<p>成立条件:{{ trend.conditions }}</p><p>反向证据:{{ trend.counter_evidence }}</p><p>关注点:{{ trend.watchpoints }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<el-empty v-else description="暂无阅读稿,请配置三个阶段模型并启用" />
|
||||
<el-alert v-if="overview?.error" :title="overview.error" type="warning" :closable="false" />
|
||||
</el-card>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<el-switch v-model="archive" active-text="历史归档" />
|
||||
<el-select v-model="stars" class="w-40">
|
||||
<el-option :value="0" label="全部评级" /><el-option v-for="n in 5" :key="n" :value="n" :label="`${n} 星及以上`" />
|
||||
<el-select v-model="importance" class="w-40">
|
||||
<el-option :value="0" label="全部重要性" /><el-option v-for="n in 5" :key="n" :value="n" :label="`重要性${n}星及以上`" />
|
||||
</el-select>
|
||||
<el-select v-model="stars" class="w-40">
|
||||
<el-option :value="0" label="全部风险评级" /><el-option v-for="n in 5" :key="n" :value="n" :label="`${n} 星及以上`" />
|
||||
</el-select>
|
||||
<el-input v-model="category" class="w-40" clearable placeholder="内容分类" />
|
||||
<el-input v-model="tag" class="w-40" clearable placeholder="TAG标签" />
|
||||
<el-input v-model="asset" class="w-40" clearable placeholder="受影响资产" />
|
||||
<el-input v-model="q" class="w-60" clearable placeholder="事件关键词" @keyup.enter="search" @clear="search" />
|
||||
<el-date-picker v-if="archive" v-model="range" type="datetimerange" start-placeholder="开始" end-placeholder="结束" />
|
||||
</div>
|
||||
<div v-loading="loading" class="flex flex-col gap-3">
|
||||
<el-card v-for="event in events" :key="event.id" shadow="never" class="cursor-pointer" @click="openEvent(event.id)">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-amber-600">{{ event.result.verification === 'insufficient' ? '信息不足,无法判断' : starText(event.stars) }}</span><el-tag size="small">
|
||||
{{ labels[event.result.verification] }}
|
||||
<span class="text-amber-600">{{ event.result?.verification === 'insufficient' ? '信息不足,无法判断' : starText(event.risk_stars) }}</span><el-tag size="small">
|
||||
{{ labels[event.result?.verification ?? "unknown"] }}
|
||||
</el-tag><el-tag size="small" type="info">
|
||||
{{ labels[event.result.direction] }}
|
||||
{{ labels[event.result?.direction ?? "unknown"] }}
|
||||
</el-tag><span class="ml-auto text-xs text-slate-500">最新进展 {{ formatTime(event.updated_at) }}</span>
|
||||
</div>
|
||||
<span v-if="event.state !== 'done'" class="text-xs text-amber-600">{{ event.error ? "更新失败,保留上次总结" : "总结更新中" }}</span>
|
||||
<h3 class="my-2 text-base">
|
||||
{{ event.title }}
|
||||
</h3><p class="my-2 text-sm text-slate-600">
|
||||
{{ event.result.summary }}
|
||||
{{ event.result?.summary }}
|
||||
</p><p class="mb-0 text-xs text-slate-500">
|
||||
{{ event.entities.join(' · ') }} · 历史最高 {{ starText(event.peak_stars) }}
|
||||
重要性 {{ event.importance_stars }} 星 · {{ event.tags.join(" · ") }} · {{ event.entities.join(' · ') }} · {{ event.article_count }} 篇相关文章 · 历史最高 {{ starText(event.peak_stars) }} · 总结 {{ formatTime(event.analyzed_at) }}
|
||||
</p>
|
||||
</el-card>
|
||||
<el-empty v-if="!events.length && !loading" description="该范围内暂无已归档的风险事件" />
|
||||
@@ -255,12 +352,12 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<el-dialog v-model="showSettings" title="新闻 AI 分析设置" width="min(600px, 95vw)">
|
||||
<el-form v-if="settings" label-position="top">
|
||||
<el-form-item label="API 接入配置(复用地址、协议和密钥)">
|
||||
<el-form-item label="第一阶段 Luna 接入配置">
|
||||
<el-select v-model="settings.provider_id" class="w-full">
|
||||
<el-option v-for="provider in providers" :key="provider.id" :value="provider.id" :label="provider.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="新闻模型名称">
|
||||
<el-form-item label="第一阶段模型名称">
|
||||
<div class="w-full flex gap-2">
|
||||
<el-select v-model="settings.model" class="min-w-0 flex-1" filterable allow-create default-first-option :loading="modelsLoading" :disabled="!settings.provider_id" placeholder="选择模型,或输入模型 ID 后回车">
|
||||
<el-option v-for="model in models" :key="model" :value="model" :label="model" />
|
||||
@@ -276,7 +373,46 @@ onBeforeUnmount(() => {
|
||||
接口未返回可选模型,可手动填写模型 ID。
|
||||
</p>
|
||||
</el-form-item>
|
||||
<el-alert type="info" :closable="false" title="测试并启用会调用模型,随后分析起点之后的全部新闻;不会修改 Chat 或交易模型。" />
|
||||
<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" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="第二阶段模型名称">
|
||||
<el-select v-model="settings.second_model" class="w-full" filterable allow-create default-first-option :loading="secondModelsLoading" :disabled="!settings.second_provider_id">
|
||||
<el-option v-for="model in secondModels" :key="model" :value="model" :label="model" />
|
||||
</el-select>
|
||||
<el-button :disabled="!settings.second_provider_id" @click="loadSecondModels">
|
||||
刷新列表
|
||||
</el-button>
|
||||
<p v-if="secondModelsError">
|
||||
{{ secondModelsError }}
|
||||
</p>
|
||||
</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" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="第三阶段模型名称">
|
||||
<el-select v-model="settings.third_model" class="w-full" filterable allow-create default-first-option>
|
||||
<el-option v-for="model in thirdModels" :key="model" :value="model" :label="model" />
|
||||
</el-select><el-button @click="loadThirdModels">
|
||||
刷新列表
|
||||
</el-button><p>{{ thirdError }}</p>
|
||||
</el-form-item>
|
||||
<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 草稿
|
||||
</el-button>
|
||||
<router-link to="/skills">
|
||||
编辑与发布 Skills
|
||||
</router-link>
|
||||
<el-alert type="info" :closable="false" title="测试并启用会分别调用分包、中级研判和高级阅读编排模型。首次启用后仅处理新发布或源站修订的新闻;暂停后恢复沿用原起点。" />
|
||||
<p>当前状态:{{ settings.enabled ? '已启用' : '已关闭' }}</p>
|
||||
<el-button :loading="saving" @click="save(false)">
|
||||
保存并暂停分析
|
||||
@@ -289,13 +425,41 @@ onBeforeUnmount(() => {
|
||||
<template v-if="detail">
|
||||
<h2 class="text-lg">
|
||||
{{ detail.event.title }}
|
||||
</h2><div v-for="item in detail.history" :key="item.id" class="mb-5 border-b border-slate-200 pb-4">
|
||||
</h2>
|
||||
<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>
|
||||
<p><b>不确定性:</b>{{ detail.event.result.uncertainty }}</p>
|
||||
<blockquote v-for="ref in detail.event.result.evidence_refs" :key="`${ref.article_id}:${ref.quote}`">
|
||||
来源 #{{ ref.article_id }}:{{ ref.quote }}
|
||||
</blockquote>
|
||||
</template>
|
||||
<p>{{ detail.event.result?.counter_evidence }}</p><p>关注点:{{ detail.event.result?.watchpoints }}</p>
|
||||
<div v-for="claim in detail.event.result?.claims" :key="claim.statement">
|
||||
<b>{{ claim.verdict }}</b> {{ claim.statement }}<blockquote v-for="ref in claim.evidence_refs" :key="`${ref.article_id}:${ref.quote}`">
|
||||
来源 {{ ref.article_id }}:{{ ref.quote }}
|
||||
</blockquote>
|
||||
</div>
|
||||
<div v-for="relation in detail.event.result?.related_packages" :key="relation.id">
|
||||
<el-button link @click="openEvent(relation.id)">
|
||||
相关包 #{{ relation.id }} · {{ relation.relation }}
|
||||
</el-button><p>{{ relation.reason }}</p>
|
||||
</div>
|
||||
<h3>核验工具与证据</h3><div v-for="record in detail.evidence" :key="record.id">
|
||||
<details><summary>{{ record.kind }} · {{ formatTime(record.fetched_at) }}</summary><pre class="whitespace-pre-wrap break-all">{{ record.data }}</pre></details>
|
||||
</div>
|
||||
<h3>总结版本</h3>
|
||||
<div v-for="version in detail.versions" :key="version.id" class="mb-3 border-b pb-2">
|
||||
v{{ version.revision }} · {{ version.model }} · {{ version.state }} · {{ formatTime(version.analyzed_at) }}
|
||||
<p>{{ version.error || version.result?.summary }}</p>
|
||||
</div>
|
||||
<h3>相关文章</h3><div v-for="item in detail.history" :key="item.id" class="mb-5 border-b border-slate-200 pb-4">
|
||||
<a :href="item.input.url" target="_blank" rel="noopener noreferrer">{{ item.input.title || '原始新闻' }}</a><p class="text-xs text-slate-500">
|
||||
{{ item.input.source }} · 发布 {{ formatTime(item.input.published_at) }} · 进展 {{ formatTime(item.input.progress_at) }} · 分析 {{ formatTime(item.analyzed_at) }} · {{ item.model }} {{ item.historical ? '· 补分析' : '' }}
|
||||
</p>
|
||||
<template v-if="item.result">
|
||||
<p class="text-amber-600">
|
||||
{{ starText(item.result.stars) }} · {{ labels[item.result.verification] }}
|
||||
{{ starText(item.result.risk_stars) }} · {{ labels[item.result.verification] }}
|
||||
</p><p>{{ item.result.summary }}</p><p>依据:{{ item.result.reasoning }}</p><blockquote class="text-sm text-slate-600">
|
||||
{{ item.result.evidence }}
|
||||
</blockquote><p>不确定性:{{ item.result.uncertainty }}</p>
|
||||
@@ -304,6 +468,9 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</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-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)">
|
||||
重试
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Analysis } from "@/common/apis/risk/type"
|
||||
import { listRiskAnalysesApi } from "@/common/apis/risk"
|
||||
import type { Analysis, RiskEvent } from "@/common/apis/risk/type"
|
||||
import { listNewsSummariesApi, listRiskAnalysesApi } from "@/common/apis/risk"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
|
||||
const props = defineProps<{ newsId: number, revision: number }>()
|
||||
const user = useUserStore()
|
||||
const analysis = ref<Analysis | null>(null)
|
||||
const summaries = ref<RiskEvent[]>([])
|
||||
const failed = ref(false)
|
||||
const loading = ref(false)
|
||||
const ownerVersion = user.sessionVersion
|
||||
@@ -19,11 +20,13 @@ async function load(reset = true) {
|
||||
loading.value = true
|
||||
if (reset) {
|
||||
analysis.value = null
|
||||
summaries.value = []
|
||||
failed.value = false
|
||||
}
|
||||
try {
|
||||
const rows = await listRiskAnalysesApi({ news_id: props.newsId, revision_no: props.revision }, reset)
|
||||
const [rows, events] = await Promise.all([listRiskAnalysesApi({ news_id: props.newsId, revision_no: props.revision }, reset), listNewsSummariesApi(props.newsId, props.revision)])
|
||||
if (seq === sequence && version === user.sessionVersion) {
|
||||
summaries.value = events
|
||||
analysis.value = rows[0] ?? null
|
||||
failed.value = false
|
||||
}
|
||||
@@ -36,7 +39,7 @@ async function load(reset = true) {
|
||||
watch(() => [props.newsId, props.revision], () => void load(), { immediate: true })
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
if (!document.hidden && !loading.value && analysis.value?.state !== "done") void load(false)
|
||||
if (!document.hidden && !loading.value && (!["done"].includes(analysis.value?.state ?? "") || summaries.value.some(e => ["pending", "processing"].includes(e.state)))) void load(false)
|
||||
}, 15000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
@@ -48,18 +51,21 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="rounded bg-sky-50 p-3 text-sm">
|
||||
<b>AI 新闻分析</b>
|
||||
<p v-if="analysis?.state === 'done' && analysis.result">
|
||||
{{ analysis.result.verification === 'insufficient' ? '信息不足,无法判断' : analysis.result.stars ? '★'.repeat(analysis.result.stars) : '无明显风险' }} · {{ analysis.result.summary }}
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ failed ? '分析状态读取失败' : analysis?.state === 'failed' ? `分析失败:${analysis.error}` : '等待分析,不能据此判断无风险' }}
|
||||
<b>AI 关联新闻包</b>
|
||||
<div v-for="event in summaries" :key="event.id" class="my-2">
|
||||
<router-link :to="{ path: '/home', query: { summary: event.id } }">
|
||||
{{ event.title }}
|
||||
</router-link>
|
||||
<p>{{ event.result?.summary || '事件归并完成,等待二阶段总结' }}</p>
|
||||
<p v-if="event.error">
|
||||
更新失败:{{ event.error }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="!summaries.length">
|
||||
{{ failed ? '关联事件读取失败' : analysis?.state === 'failed' ? `初筛失败:${analysis.error}` : '暂无关联总结;新流程只处理启用后的新闻' }}
|
||||
<el-button v-if="failed" link :loading="loading" @click="load()">
|
||||
重试
|
||||
</el-button>
|
||||
</p>
|
||||
<p v-if="analysis?.result" class="mb-0 text-xs">
|
||||
不确定性:{{ analysis.result.uncertainty }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -89,7 +89,7 @@ onBeforeUnmount(() => media?.removeEventListener("change", updateMobile))
|
||||
· {{ item.risk.summary }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ item.risk?.state === 'failed' ? 'AI 分析失败' : item.risk?.state === 'processing' ? 'AI 分析中' : 'AI 尚未分析' }}
|
||||
{{ item.risk?.state === 'skipped' ? '非财经新闻,原文保留' : item.risk?.state === 'failed' ? 'AI 分析失败' : item.risk?.state === 'processing' ? 'AI 分析中' : '未纳入新流程或等待总结' }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,13 @@ import { renderMarkdown } from "@@/utils/markdown"
|
||||
const sessionScope = useSessionScope()
|
||||
|
||||
const nodes = ref<SkillNode[]>([])
|
||||
const scope = ref<"shared" | "news">("shared")
|
||||
async function changeScope(value: "shared" | "news") {
|
||||
if (dirty.value) await ElMessageBox.confirm("当前 Skill 有未保存的修改,切换后会丢失。继续?", "未保存", { type: "warning" })
|
||||
sessionScope.assertCurrent()
|
||||
scope.value = value
|
||||
selectedId.value = null
|
||||
}
|
||||
const selectedId = ref<number | null>(null)
|
||||
const selected = computed(() => nodes.value.find(n => n.id === selectedId.value) ?? null)
|
||||
|
||||
@@ -90,7 +97,7 @@ async function publishLatest() {
|
||||
const version = await saveDraft()
|
||||
if (!version || selectedId.value !== id || contentRequest !== contentVersion)
|
||||
return
|
||||
await ElMessageBox.confirm(`发布 v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
|
||||
await ElMessageBox.confirm(`发布 v${version.version_no}?发布后对应用途的下一次任务起使用此版本。`, "发布确认", { type: "warning" })
|
||||
sessionScope.assertCurrent()
|
||||
if (selectedId.value !== id || contentRequest !== contentVersion)
|
||||
return
|
||||
@@ -132,9 +139,16 @@ onMounted(loadTree)
|
||||
<div class="skills-workspace h-full flex flex-col gap-3 lg:flex-row">
|
||||
<el-card class="skills-tree max-h-56 w-full flex shrink-0 flex-col lg:max-h-none lg:w-64" body-class="min-h-0 flex-1" shadow="never">
|
||||
<template #header>
|
||||
Skills
|
||||
Skills(新闻专用规则独立生效)
|
||||
</template>
|
||||
<SkillTree :nodes="nodes" :selected-id="selectedId" editable @select="select" @changed="loadTree" />
|
||||
<el-radio-group :model-value="scope" @update:model-value="value => changeScope(value as 'shared' | 'news')" class="mb-2">
|
||||
<el-radio-button value="shared">
|
||||
Chat/交易
|
||||
</el-radio-button><el-radio-button value="news">
|
||||
新闻
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
<SkillTree :nodes="nodes.filter(n => n.scope === scope)" :scope="scope" :selected-id="selectedId" editable @select="select" @changed="loadTree" />
|
||||
</el-card>
|
||||
|
||||
<el-card class="min-w-0 flex flex-1 flex-col" body-class="min-h-0 flex-1 flex flex-col gap-3" shadow="never">
|
||||
|
||||
Reference in new issue
Block a user