From 4831db1763c2355ef2f12f67debc4890f4367540 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 08:01:17 -0400 Subject: [PATCH] feat(app): add cancellable turn lifecycle --- src/App.spec.ts | 46 +++++++++ src/App.vue | 105 ++++++++++++++++--- src/app/bridge.spec.ts | 45 ++++++++ src/app/bridge.ts | 66 +++++++++++- src/app/errors.spec.ts | 56 ++++++++++ src/app/errors.ts | 166 +++++++++++++++++++++++++++++ src/app/useDemo.ts | 229 +++++++++++++++++++++++++++++++++++------ src/styles/main.css | 128 +++++++++++++++++++++-- 8 files changed, 786 insertions(+), 55 deletions(-) create mode 100644 src/app/errors.spec.ts create mode 100644 src/app/errors.ts diff --git a/src/App.spec.ts b/src/App.spec.ts index b604ed1..f804ca6 100644 --- a/src/App.spec.ts +++ b/src/App.spec.ts @@ -73,6 +73,36 @@ describe("App", () => { wrapper.unmount(); }); + it("cancels an in-flight turn without committing and explicitly retries it", async () => { + const wrapper = await mountLoadedApp(); + const composer = wrapper.get('textarea[aria-label="自由输入"]'); + const startingNode = wrapper.get(".statusline span").text(); + + await composer.setValue("我先听听雨声。"); + await wrapper.get("form.composer").trigger("submit"); + expect(wrapper.get(".cancel-turn-button").text()).toBe("停止生成"); + expect(wrapper.get(".turn-status").text()).toContain("只有完整回应通过校验后"); + + await wrapper.get(".cancel-turn-button").trigger("click"); + expect(wrapper.get(".cancel-turn-button").text()).toBe("停止中…"); + + await vi.waitFor(() => { + expect(wrapper.get(".turn-status").text()).toContain("已停止生成"); + expect(wrapper.get(".turn-status").text()).toContain("本轮没有写入故事"); + }); + expect(wrapper.get(".statusline span").text()).toBe(startingNode); + expect(wrapper.find(".beat--action").exists()).toBe(false); + expect(wrapper.get(".retry-turn-button").text()).toBe("重试本轮"); + + await wrapper.get(".retry-turn-button").trigger("click"); + await vi.waitFor(() => { + expect(wrapper.get(".statusline span").text()).toBe("node_002"); + expect(wrapper.text()).toContain("我先听听雨声。"); + }); + expect(wrapper.find(".retry-turn-button").exists()).toBe(false); + wrapper.unmount(); + }); + it("finishes the playable before-dawn slice and exposes its settled records", async () => { const wrapper = await mountLoadedApp(); const composer = wrapper.get('textarea[aria-label="自由输入"]'); @@ -178,6 +208,22 @@ describe("App", () => { wrapper.unmount(); }); + it("offers a deterministic, credential-free LAPP connection check in preview", async () => { + const wrapper = await mountLoadedApp(); + await wrapper.get('button[aria-label="模型设置"]').trigger("click"); + + const testButton = wrapper.get(".connection-test-button"); + expect(testButton.attributes("disabled")).toBeUndefined(); + await testButton.trigger("click"); + await flushPromises(); + + const result = wrapper.get(".connection-result"); + expect(result.attributes("data-tone")).toBe("preview"); + expect(result.text()).toContain("真实 LAPP 连接需在桌面端测试"); + expect(wrapper.get("#settings-panel").text()).not.toMatch(/api[_-]?key\s*[:=]/i); + wrapper.unmount(); + }); + it("closes the active panel with Escape", async () => { const wrapper = await mountLoadedApp(); await wrapper.findAll(".top-actions button")[0].trigger("click"); diff --git a/src/App.vue b/src/App.vue index 92c9949..e342f67 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,17 +9,28 @@ const { appInfo, branchList, busy, + canRetryTurn, + cancellingTurn, error, lastSubmittedIntent, loading, lappSettings, + lappTestStatus, pack, playerView, + testingLappConnection, + turnFailure, + turnInFlight, + turnNotice, + cancelActiveTurn, + clearLappTestStatus, forkBranch, renameBranch, + retryTurn, selectLappModel, submitTurn, switchBranch, + testLappConnection, turnError } = useDemo(); const draft = ref(""); @@ -139,6 +150,7 @@ function setLappSelection(event: Event) { const target = event.target; if (target instanceof HTMLSelectElement) { lappSelection.value = target.value; + clearLappTestStatus(); } } @@ -155,6 +167,15 @@ async function saveLappSelection() { await selectLappModel(providerId, modelId); } +const canTestLappConnection = computed(() => { + const settings = lappSettings.value; + if (!settings) return false; + return ( + settings.mode === "demo" || + Boolean(settings.selectedProviderId && settings.selectedModelId) + ); +}); + function handleEscape(event: KeyboardEvent) { if (event.key === "Escape" && activePanel.value) { closePanel(); @@ -274,21 +295,60 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape)); :disabled="busy || !playerView.canContinue" @click="continueStory" > - {{ busy && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }} + {{ turnInFlight && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }} -

- 故事正在回应,请稍候。 - 请求未完成:{{ turnError }} -

+
+
+ + {{ cancellingTurn ? "正在停止本轮" : "故事正在回应" }} + + {{ + cancellingTurn + ? "等待引擎确认;未完成的回合不会写入故事。" + : turnNotice ?? "只有完整回应通过校验后,才会写入当前线路。" + }} + + + +
+
+ + {{ turnFailure.title }} + {{ turnFailure.message }} {{ turnFailure.noCommitMessage }} + + +
+
+ {{ turnNotice }} +
+
+ 操作未完成:{{ turnError }} +
+