feat: bound news AI concurrency with immediate task refill
Development CI / check-and-build (push) Successful in 2m35s
GitHub Git mirror / mirror (push) Successful in 5s
Development CI / deploy-development (push) Failing after 7s

This commit is contained in:
eezd committed 2026-11-01 12:29:03 +08:00
1 parent f1d060d475
commit cbd2618a20
15 files changed
+603 -129

No files matched your search

+2
View File
@@ -19,3 +19,5 @@ export const startReprocessApi = (data: Risk.ReprocessRange) => request<{ id: nu
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" })
export const saveRiskConcurrencyApi = (max_concurrency: number) => request<{ max_concurrency: number }>({ url: "/risk/settings/concurrency", method: "patch", data: { max_concurrency } })
+2
View File
@@ -43,6 +43,7 @@ export interface RiskEvent {
updated_at: string
}
export interface Settings {
max_concurrency: number
enabled: boolean
provider_id: number | null
model: string
@@ -79,6 +80,7 @@ export interface Briefing {
inputs: { kind?: "package" | "event", id: number, revision: number, category: string, tags: string[], result: RiskResult & { title: string } }[]
}
export interface Overview {
concurrency: { limit: number, running: number | null, queued: number, stages: Record<string, number>, updated_at: string | null }
briefing: Briefing | null
stage2: Record<string, number>
stage3?: { state: string, pending: boolean, heartbeat: string | null }
+26
View File
@@ -59,6 +59,7 @@ const showFailures = ref(false)
const loading = ref(false)
const failed = ref(false)
const saving = ref(false)
const concurrencySaving = ref(false)
let sequence = 0
let detailSequence = 0
let modelsSequence = 0
@@ -157,6 +158,19 @@ async function createTemplates() {
newsSkills.value = skills.filter(s => s.scope === "news" && s.kind === "skill")
ElMessage.success("默认新闻 Skills 已准备好,请前往 Skills 页面审阅并发布")
}
async function saveConcurrency() {
if (!settings.value || concurrencySaving.value) return
const version = user.sessionVersion
concurrencySaving.value = true
try {
await api.saveRiskConcurrencyApi(settings.value.max_concurrency)
if (version !== user.sessionVersion) return
ElMessage.success("并发上限已保存;降低上限时等待在途任务完成")
await load()
} finally {
if (version === user.sessionVersion) concurrencySaving.value = false
}
}
async function save(enable: boolean) {
if (!settings.value) return
const version = user.sessionVersion
@@ -207,6 +221,7 @@ watch(taskStage, () => {
if (showFailures.value) void openFailures()
})
watch(() => user.sessionVersion, () => {
concurrencySaving.value = false
thirdSequence++
secondModelsSequence++
secondModels.value = []
@@ -284,6 +299,10 @@ onBeforeUnmount(() => {
失败 {{ overview.coverage.failed }}
</el-button>
</p>
<p v-if="overview.concurrency">
AI 并发:{{ overview.concurrency.running ?? "未知" }} / {{ overview.concurrency.limit }} · 排队 {{ overview.concurrency.queued }}
<span v-for="(count, stage) in overview.concurrency.stages" :key="stage" class="ml-2">{{ ({ grouping: "分包", analysis: "研判", events: "事件", outlook: "阅读", history: "历史" } as Record<string, string>)[stage] }} {{ count }}</span>
</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 ? '等待聚合更新' : '已更新' }}
@@ -441,6 +460,13 @@ onBeforeUnmount(() => {
<router-link to="/skills">
编辑与发布 Skills
</router-link>
<el-form-item label="最大并发任务数">
<el-input-number v-model="settings.max_concurrency" :min="1" :precision="0" :step="1" :disabled="concurrencySaving" />
<el-button class="ml-2" :loading="concurrencySaving" @click="saveConcurrency">
保存并发上限
</el-button>
</el-form-item>
<p>所有新闻阶段共享名额,完成即补位;实时优先,历史使用空闲名额。测试并启用前请先暂停并等待在途任务完成。</p>
<el-alert type="info" :closable="false" title="测试并启用会分别调用分包、中级研判和高级阅读编排模型。首次启用后仅处理新发布或源站修订的新闻;暂停后恢复沿用原起点。" />
<p>当前状态:{{ settings.enabled ? '已启用' : '已关闭' }}</p>
<el-button :loading="saving" @click="save(false)">
+46
View File
@@ -836,3 +836,49 @@ test("重整理预览的旧会话完成不能解除新操作的忙碌状态", as
assert.equal(page.busy.value, false)
assert.equal(page.preview.value.articles, 2)
})
test("新闻并发独立保存保留模型草稿,重复点击和旧会话响应不影响新操作", async () => {
const user = vue.reactive({ sessionVersion: 1 })
const pending: ReturnType<typeof Promise.withResolvers>[] = []
const values: number[] = []
let reloads = 0
const load = moduleLoader({
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/settings": {},
"@/pinia/stores/user": { useUserStore: () => user },
"@/common/apis/risk": {
saveRiskConcurrencyApi: (value: number) => {
values.push(value)
const request = Promise.withResolvers()
pending.push(request)
return request.promise
},
getRiskOverviewApi: async () => {
reloads++
return { briefing: null }
},
listRiskEventsApi: async () => ({ items: [], total: 0 })
}
}, globals)
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
page.settings.value = { max_concurrency: 5, model: "unsaved-model" }
const first = page.saveConcurrency()
await page.saveConcurrency()
assert.deepEqual(values, [5])
pending[0].resolve({ max_concurrency: 5 })
await first
assert.equal(page.settings.value.model, "unsaved-model")
assert.equal(reloads, 1)
const old = page.saveConcurrency()
user.sessionVersion++
await vue.nextTick()
page.settings.value = { max_concurrency: 2, model: "new-session" }
const current = page.saveConcurrency()
pending[1].resolve({ max_concurrency: 5 })
await old
assert.equal(page.concurrencySaving.value, true)
assert.equal(page.settings.value.model, "new-session")
pending[2].resolve({ max_concurrency: 2 })
await current
assert.equal(page.concurrencySaving.value, false)
})