From d6318b639db1ecf7aa66f229b4052967d34f3366 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 18 Oct 2026 18:07:03 +0800 Subject: [PATCH] =?UTF-8?q?=E9=97=AE=E6=95=B0=E6=8D=AE=E9=A1=B5=E7=9A=84?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=88=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E4=BD=BF=E7=94=A8=E8=80=85=E4=B8=8D=E7=BB=99?= =?UTF-8?q?=E3=80=8C=E7=BB=A7=E7=BB=AD=E8=BF=90=E8=A1=8C=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E6=A0=87=E7=AD=BE=E9=A1=B5=E5=90=8C=E6=97=B6?= =?UTF-8?q?=E6=89=93=E5=BC=80=E5=81=9C=E5=9C=A8=E5=8D=8A=E8=B7=AF=E7=9A=84?= =?UTF-8?q?=E4=B8=80=E8=BD=AE=E6=97=B6=E5=90=8E=E5=88=B0=E7=9A=84=E9=82=A3?= =?UTF-8?q?=E4=B8=AA=E6=8E=A5=E7=9D=80=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用者(user 角色)续不了运行(服务端照 POST /api/runs/{id}/continue 的老规矩回 403):补救区不摆「继续运行」,服务重启打断的 那一轮说明里也不叫它去点,改说「如需整轮重来,请点击「重新运行本轮」」(lib/terms 的 CHAT_TURN_TEXT);重试本轮、重新运行本轮照常。 以前这个按钮对使用者也摆着,点了只弹一句 403。 - 两个标签页同时打开停在半路的一轮:都会请服务端接着收尾,服务端现在只起得来一次,后到的那个收到 409 chat_turn_busy 时不再说 「无法确认本轮的当前状态」,改成问一下这一轮现在怎样——在跑就从这一次尝试的第一条接着播,收了尾就照那一行摆。 - scripts/check-chat.mjs 新加两段:「使用者(user 角色)」(伪造 /api/auth/me 认出使用者:中断的、跑挂的都不给继续运行,一次续跑都 不发)、「两个标签页同时打开」(服务端只接着收尾一次,两边都收尾、答案一样、都不写库)。去掉修复回跑,这两段都会失败。 --- frontend/src/lib/terms.ts | 6 ++++ frontend/src/pages/ChatPage.tsx | 10 +++++-- frontend/src/store/chat.ts | 17 +++++++++++ scripts/check-chat.mjs | 53 ++++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8f01ebb6..1a05b4d2 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -768,6 +768,12 @@ export function runCostText(usd: number | null | undefined, unpriced: number, ap return `${(approx || unpriced > 0) && usd > 0 ? '≥ ' : ''}${formatCost(usd)}` } +/** 问数据的一轮在服务端跑(编排完善第 1 波 W1-a):使用者(user 角色)不能继续运行时,补救区里那句「怎么办」 */ +export const CHAT_TURN_TEXT = { + /** 服务重启打断的一轮:使用者没有「继续运行」,只说重新运行 */ + suspendedHintForUser: '如需整轮重来,请点击「重新运行本轮」。', +} as const + /** * 问数据一轮的花费:助手建图(含挑表、接着写、自查修正)+ 运行 + 复核(next-round 细案 01 做法 3)。以前只算运行那份, * 这一轮的花费是低报的 diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index d3790806..3f4c1a33 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -18,7 +18,7 @@ import { humanizeError } from '../lib/errors' import { explainRunError } from '../lib/explain' import { canLeave, leavePass } from '../lib/leave' import { formatNumber, formatTime } from '../lib/format' -import { CHAT_BLUEPRINT_TEXT, REJECTED_TEXT, TURN_COST_TEXT, runCostText } from '../lib/terms' +import { CHAT_BLUEPRINT_TEXT, CHAT_TURN_TEXT, REJECTED_TEXT, TURN_COST_TEXT, runCostText } from '../lib/terms' import { useCatalog, useCatalogListState, useDatasources, useOnReconnect, type CatalogListState } from '../store/catalog' import { useAtLeast } from '../store/auth' import { useBlueprint } from '../store/blueprint' @@ -665,6 +665,9 @@ function Remedies({ conversationId, turnId, last, onRephrase, readOnly }: { const { retryTurn, continueTurn, ask, recheck, setScope } = useChat.getState() const { cap, fallback } = useStepCap() const [resuming, setResuming] = useState(false) + // 继续运行只给搭建者及以上:使用者(user 角色)续不了运行(服务端照 POST /api/runs/{id}/continue 的老规矩回 403), + // 摆着只会点一下报一次错;重试本轮、重新运行本轮另起一次问数据运行,照常给 + const builder = useAtLeast('builder') if (!turn) return null const kind = remedyOf(turn) @@ -674,7 +677,7 @@ function Remedies({ conversationId, turnId, last, onRephrase, readOnly }: { && (!s.reason || s.reason === 'steps')) const current = fallback ? agentSteps(turn.graph, fallback) : null const target = current && cap ? Math.min(cap, Math.max(current * 2, current + 8)) : null - const canContinue = !!turn.run?.id && (kind === 'suspended' + const canContinue = builder && !!turn.run?.id && (kind === 'suspended' || (kind === 'failed' && turn.runStatus === 'failed' && turn.failure?.continuable !== false)) const offNote = readOnly ? IN_TRASH : busy ? '本对话仍有一轮在运行,请等待其结束' : undefined const off = busy || readOnly @@ -775,7 +778,8 @@ function Remedies({ conversationId, turnId, last, onRephrase, readOnly }: { } // 中断、取消、没核对上在流里不出红框(它们不是故障),为什么停、停了意味着什么在这里说 - const note = kind === 'suspended' || kind === 'unknown' ? turn.failure + const note = kind === 'suspended' && !builder && turn.failure ? { ...turn.failure, hint: CHAT_TURN_TEXT.suspendedHintForUser } + : kind === 'suspended' || kind === 'unknown' ? turn.failure : kind === 'cancelled' && turn.rejected ? { title: '', reason: REJECTED_TEXT.chatReason, hint: turn.rejected.sub } : kind === 'cancelled' ? { title: '', reason: turn.run ? '你已停止本轮,对应的运行也已一并取消。' : '你在搭建工作流时停止了本轮。' } : null diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index ff0e3a0e..cabac70e 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -1189,6 +1189,23 @@ async function resumeTurn(conversationId: string, turnId: string) { play(flow, turn.serverId, Math.max(0, (res.turn?.job?.attempt_seq ?? 1) - 1)) } catch (e) { if (!flow.alive()) return + // 别处(另一个标签页)刚请服务端接着收尾了这一轮:不再起一次,接着看它(服务端在跑就从这一次尝试的第一条播,收了尾就照那一行摆) + if (e instanceof ApiError && e.status === 409 && e.code === 'chat_turn_busy') { + try { + const res = await api.chatTurns.get(turn.serverId) + if (!flow.alive()) return + if (res.job && LIVE_JOB.has(res.job.status)) { + flow.patch(() => ({ hydrated: 'done', job: res.job })) + play(flow, turn.serverId, Math.max(0, res.job.attempt_seq - 1)) + return + } + if (res.turn && res.turn.status !== 'running') { + flow.patch((t) => ({ ...endPatch(t, { turn: res.turn }), hydrated: 'done' })) + return + } + } catch { /* 问不到就照下面说没核对上 */ } + if (!flow.alive()) return + } const said = errorMessage(e) const raw = humanizeError(e).raw const failure: Failure = { diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index c0abb8a6..ddd6238d 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -46,7 +46,9 @@ // 按新目录新出现几条错误级问题),看的人手上有说的是这个答案的召回待办时链到待办;没比对的答案照样写依据有变;做完的 // 对账卡上写「差在哪」、问说明有没有帮助(说明有用 / 看不懂)。 // 20. 一轮在服务端跑:实时流断了按收到的最后一个 seq 重连,补齐、不重复;刷新、关掉再打开,服务端那一轮照跑,页面从 -// 这一次尝试的第一条接着播;停止发给服务端的这一轮(建图中也停得下);发起、重试、继续运行的请求体照实。 +// 这一次尝试的第一条接着播;停止发给服务端的这一轮(建图中也停得下);发起、重试、继续运行的请求体照实; +// 使用者(user 角色)不给「继续运行」(服务端照 POST /api/runs/{id}/continue 的老规矩不让它续跑);两个标签页同时打开 +// 停在半路的一轮,服务端只接着收尾一次,后到的那个接着看同一轮。 // // 所有写请求都被拦在浏览器里,不碰任何库。 // 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时 @@ -2743,6 +2745,55 @@ for (const theme of THEMES) { await ctx.close() }) + await section('userrole', '使用者(user 角色):不给「继续运行」(服务端照老规矩不让使用者续跑),重试、重新运行本轮照常', async () => { + const { page, ctx, errors } = await open(theme) + // 开了登录、认出来是使用者、管理员开了问数据 + await page.route('**/api/auth/me', (route) => route.fulfill({ json: { mode: 'local', username: 'uma', display_name: 'uma', + role: 'user', groups: [], verified: true, user_can_chat: true } })) + const continuesBefore = log.continues.length + await goto(page, 'c0suspo') + await shows(page, '服务重启,本轮已中断') + const card = page.locator('[data-remedy="suspended"]') + await card.waitFor({ timeout: 6000 }).catch(() => {}) + const cardText = (await card.innerText().catch(() => '')).replace(/\n/g, ' / ') + check('中断的一轮:使用者看不到「继续运行」,说明里也不叫它去点', await card.getByRole('button', { name: '继续运行' }).count() === 0 + && !cardText.includes('继续运行') && cardText.includes('重新运行本轮'), cardText) + check('……「重新运行本轮」照常给', await card.getByRole('button', { name: '重新运行本轮' }).count() === 1) + await goto(page, 'c0histf') + await shows(page, '步数用满的那一轮') + await page.waitForTimeout(600) + const failed = page.locator('[data-turn="tf2"] + * [data-remedy="failed"]') + check('跑挂了的一轮:使用者也看不到「继续运行」,「重试本轮」照常给', + await page.getByRole('button', { name: '继续运行' }).count() === 0 + && await failed.getByRole('button', { name: /重试本轮|重新问一次/ }).count() === 1, + (await failed.innerText().catch(() => '')).replace(/\n/g, ' / ')) + check('一次续跑都没发', log.continues.length === continuesBefore) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await ctx.close() + }) + + await section('twotabs', '两个标签页同时打开停在半路的一轮:服务端只接着收尾一次,两边都接着看同一轮', async () => { + const a = await open(theme) + const b = await open(theme) + ctl.reviewDelay['run-late'] = 1200 + const startsBefore = log.turnStarts.length + const postsBefore = log.patches.length + await Promise.all([goto(a.page, 'c0lateq'), goto(b.page, 'c0lateq')]) + const done = (page) => page.waitForFunction(() => window.__chat.getState().byConversation.c0lateq?.[0]?.phase === 'done', + null, { timeout: 8000 }).then(() => true, () => false) + const [okA, okB] = await Promise.all([done(a.page), done(b.page)]) + check('两边都收尾了,答案一样', okA && okB && await shows(a.page, '补交付的答案:三条记录。') && await shows(b.page, '补交付的答案:三条记录。')) + const resumed = log.turnStarts.slice(startsBefore).filter((x) => x.mode === 'resume' && x.retry_of === 'tq1') + check('服务端只接着收尾了一次(后到的那个 409,改成接着看)', resumed.length === 1, `${resumed.length} 次`) + const phases = await Promise.all([a.page, b.page].map((p) => p.evaluate(() => window.__chat.getState().byConversation.c0lateq?.[0]?.phase))) + check('后到的那个不说「无法确认本轮的当前状态」', !phases.includes('unknown'), phases.join(',')) + check('两边都不写库(不 PATCH)', log.patches.length === postsBefore) + delete ctl.reviewDelay['run-late'] + check('没有运行时报错', a.errors.length === 0 && b.errors.length === 0, a.errors[0] ?? b.errors[0] ?? '') + await a.ctx.close() + await b.ctx.close() + }) + await section('steps', '历史轮次的执行过程:点开、收起', async () => { const { page, ctx, errors } = await open(theme) await goto(page, 'c0idleb')