feat(app): add cancellable turn lifecycle
This commit is contained in:
@@ -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<HTMLTextAreaElement>('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<HTMLTextAreaElement>('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");
|
||||
|
||||
+93
-12
@@ -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" ? "继续中…" : "继续" }}
|
||||
</button>
|
||||
<button
|
||||
class="send-button"
|
||||
type="submit"
|
||||
:disabled="busy || !playerView.canContinue || draft.trim().length === 0"
|
||||
>
|
||||
{{ busy && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
|
||||
{{ turnInFlight && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="turn-status" aria-live="polite">
|
||||
<span v-if="busy">故事正在回应,请稍候。</span>
|
||||
<span v-else-if="turnError">请求未完成:{{ turnError }}</span>
|
||||
</p>
|
||||
<section class="turn-status" aria-live="polite" aria-label="回合状态">
|
||||
<div v-if="turnInFlight" class="turn-status-card turn-status-card--progress">
|
||||
<span>
|
||||
<strong>{{ cancellingTurn ? "正在停止本轮" : "故事正在回应" }}</strong>
|
||||
<small>
|
||||
{{
|
||||
cancellingTurn
|
||||
? "等待引擎确认;未完成的回合不会写入故事。"
|
||||
: turnNotice ?? "只有完整回应通过校验后,才会写入当前线路。"
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
class="cancel-turn-button"
|
||||
type="button"
|
||||
:disabled="cancellingTurn"
|
||||
@click="cancelActiveTurn"
|
||||
>
|
||||
{{ cancellingTurn ? "停止中…" : "停止生成" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="turnFailure" class="turn-status-card turn-status-card--failure">
|
||||
<span>
|
||||
<strong>{{ turnFailure.title }}</strong>
|
||||
<small>{{ turnFailure.message }} {{ turnFailure.noCommitMessage }}</small>
|
||||
</span>
|
||||
<button
|
||||
v-if="turnFailure.retryable"
|
||||
class="retry-turn-button"
|
||||
type="button"
|
||||
:disabled="!canRetryTurn"
|
||||
@click="retryTurn"
|
||||
>
|
||||
重试本轮
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="turnNotice" class="turn-status-card turn-status-card--notice">
|
||||
<span>{{ turnNotice }}</span>
|
||||
</div>
|
||||
<div v-else-if="turnError" class="turn-status-card turn-status-card--failure">
|
||||
<span>操作未完成:{{ turnError }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="statusline">
|
||||
<span>{{ playerView.nodeId }}</span>
|
||||
@@ -501,13 +561,34 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
||||
{{ model.providerName ?? model.providerId }} · {{ model.modelName ?? model.modelId }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="busy || !selectedLappValue || lappSettings.mode === 'demo'"
|
||||
@click="saveLappSelection"
|
||||
<div class="settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="busy || !selectedLappValue || lappSettings.mode === 'demo'"
|
||||
@click="saveLappSelection"
|
||||
>
|
||||
应用模型
|
||||
</button>
|
||||
<button
|
||||
class="connection-test-button"
|
||||
type="button"
|
||||
:disabled="busy || !canTestLappConnection"
|
||||
@click="testLappConnection"
|
||||
>
|
||||
{{ testingLappConnection ? "测试中…" : "测试当前连接" }}
|
||||
</button>
|
||||
</div>
|
||||
<small class="connection-privacy">
|
||||
测试只确认当前模型能否完成最小请求,不读取、展示或保存 API Key。
|
||||
</small>
|
||||
<p
|
||||
v-if="lappTestStatus"
|
||||
class="connection-result"
|
||||
:data-tone="lappTestStatus.tone"
|
||||
role="status"
|
||||
>
|
||||
{{ busy ? "正在应用…" : "应用模型" }}
|
||||
</button>
|
||||
{{ lappTestStatus.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -21,11 +21,13 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
cancelTurn,
|
||||
forkBranch,
|
||||
getBranchList,
|
||||
getLappSettings,
|
||||
submitTurn,
|
||||
switchBranch,
|
||||
testLappConnection,
|
||||
updateLappSettings
|
||||
} from "./bridge";
|
||||
|
||||
@@ -46,6 +48,7 @@ describe("Tauri bridge", () => {
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends only TurnRequest to the submit_turn command", async () => {
|
||||
@@ -62,6 +65,48 @@ describe("Tauri bridge", () => {
|
||||
expect(invokeMock).toHaveBeenCalledWith("submit_turn", { request });
|
||||
});
|
||||
|
||||
it("uses narrow commands for cancellation and a credential-free connection test", async () => {
|
||||
const connectionRequest: UpdateLappSettingsRequest = {
|
||||
providerId: "provider",
|
||||
modelId: "model"
|
||||
};
|
||||
const connectionResult = {
|
||||
ok: true,
|
||||
providerId: connectionRequest.providerId,
|
||||
modelId: connectionRequest.modelId,
|
||||
message: "connected",
|
||||
diagnosticCode: null
|
||||
};
|
||||
invokeMock.mockResolvedValueOnce(true).mockResolvedValueOnce(connectionResult);
|
||||
|
||||
await expect(cancelTurn(request.actionId)).resolves.toBe(true);
|
||||
await expect(testLappConnection(connectionRequest)).resolves.toEqual(connectionResult);
|
||||
expect(invokeMock.mock.calls).toEqual([
|
||||
["cancel_turn", { actionId: request.actionId }],
|
||||
["test_lapp_connection", { request: connectionRequest }]
|
||||
]);
|
||||
});
|
||||
|
||||
it("deterministically discards a cancelled browser-preview result", async () => {
|
||||
delete window.__TAURI_INTERNALS__;
|
||||
vi.useFakeTimers();
|
||||
const pending = submitTurn(request, initialView as PlayerView);
|
||||
const rejection = expect(pending).rejects.toMatchObject({
|
||||
code: "cancelled",
|
||||
retryable: true
|
||||
});
|
||||
|
||||
await expect(cancelTurn(request.actionId)).resolves.toBe(true);
|
||||
await vi.runAllTimersAsync();
|
||||
await rejection;
|
||||
await expect(
|
||||
testLappConnection({ providerId: "browser-preview", modelId: "deterministic-demo" })
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
diagnosticCode: "browser_preview"
|
||||
});
|
||||
});
|
||||
|
||||
it("sends only the typed fork request to the fork_branch command", async () => {
|
||||
const forkRequest: ForkBranchRequest = {
|
||||
storyId: initialView.storyId,
|
||||
|
||||
+65
-1
@@ -19,6 +19,17 @@ import type {
|
||||
|
||||
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
|
||||
|
||||
interface LappConnectionTestResult {
|
||||
ok: boolean;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
message: string;
|
||||
diagnosticCode: string | null;
|
||||
}
|
||||
|
||||
const browserActiveTurns = new Set<string>();
|
||||
const browserCancelledTurns = new Set<string>();
|
||||
|
||||
function isTauri(): boolean {
|
||||
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||
}
|
||||
@@ -127,16 +138,69 @@ export async function updateLappSettings(
|
||||
return invoke<LappSettings>("update_lapp_settings", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests only the currently applied LAPP model. The desktop command owns profile
|
||||
* and credential access; no secret or provider response crosses this boundary.
|
||||
*/
|
||||
export async function testLappConnection(
|
||||
request: UpdateLappSettingsRequest
|
||||
): Promise<LappConnectionTestResult> {
|
||||
if (!isTauri()) {
|
||||
await Promise.resolve();
|
||||
return {
|
||||
ok: true,
|
||||
providerId: request.providerId,
|
||||
modelId: request.modelId,
|
||||
message: "browser preview is deterministic",
|
||||
diagnosticCode: "browser_preview"
|
||||
};
|
||||
}
|
||||
|
||||
return invoke<LappConnectionTestResult>("test_lapp_connection", { request });
|
||||
}
|
||||
|
||||
export async function submitTurn(
|
||||
request: TurnRequest,
|
||||
currentView: PlayerView
|
||||
): Promise<TurnResult> {
|
||||
if (!isTauri()) {
|
||||
return submitDemoTurn(request, currentView);
|
||||
browserActiveTurns.add(request.actionId);
|
||||
try {
|
||||
const result = await submitDemoTurn(request, currentView);
|
||||
if (browserCancelledTurns.has(request.actionId)) {
|
||||
throw {
|
||||
code: "cancelled",
|
||||
message: "browser preview turn was cancelled",
|
||||
retryable: true
|
||||
};
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
browserActiveTurns.delete(request.actionId);
|
||||
browserCancelledTurns.delete(request.actionId);
|
||||
}
|
||||
}
|
||||
return invoke<TurnResult>("submit_turn", { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests cancellation of an uncommitted turn. The original submit_turn
|
||||
* promise remains authoritative: it must settle as either a complete commit or
|
||||
* a structured `cancelled` failure.
|
||||
*/
|
||||
export async function cancelTurn(actionId: string): Promise<boolean> {
|
||||
if (!isTauri()) {
|
||||
if (browserActiveTurns.has(actionId)) {
|
||||
browserCancelledTurns.add(actionId);
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
}
|
||||
await Promise.resolve();
|
||||
return false;
|
||||
}
|
||||
return invoke<boolean>("cancel_turn", { actionId });
|
||||
}
|
||||
|
||||
export async function forkBranch(
|
||||
request: ForkBranchRequest,
|
||||
currentView: PlayerView
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
describeConnectionDiagnostic,
|
||||
describeConnectionFailure,
|
||||
describeOperationFailure,
|
||||
describeTurnFailure
|
||||
} from "./errors";
|
||||
|
||||
describe("player-safe failure copy", () => {
|
||||
it("offers an immediate retry only for retryable turn failures", () => {
|
||||
const retryable = describeTurnFailure({
|
||||
code: "provider_unavailable",
|
||||
message: "upstream secret diagnostic",
|
||||
retryable: true
|
||||
});
|
||||
const invalid = describeTurnFailure({
|
||||
code: "invalid_input",
|
||||
message: "private validation detail",
|
||||
retryable: false
|
||||
});
|
||||
|
||||
expect(retryable.retryable).toBe(true);
|
||||
expect(retryable.noCommitMessage).toContain("没有写入故事");
|
||||
expect(invalid.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it("does not echo provider or hidden-check diagnostics", () => {
|
||||
const privateDetail = "roll=97 target=42 api_key=do-not-show";
|
||||
const turn = describeTurnFailure({
|
||||
code: "invalid_model_output",
|
||||
message: privateDetail,
|
||||
retryable: true
|
||||
});
|
||||
|
||||
expect(JSON.stringify(turn)).not.toContain(privateDetail);
|
||||
expect(describeOperationFailure({ message: privateDetail })).not.toContain(privateDetail);
|
||||
expect(describeConnectionFailure({ message: privateDetail })).not.toContain(privateDetail);
|
||||
expect(describeConnectionDiagnostic("VAULT_CREDENTIAL_NOT_FOUND")).not.toContain(privateDetail);
|
||||
});
|
||||
|
||||
it("does not suggest blindly retrying a stale branch", () => {
|
||||
const failure = describeTurnFailure({
|
||||
code: "stale_node",
|
||||
retryable: true
|
||||
});
|
||||
|
||||
expect(failure.retryable).toBe(false);
|
||||
expect(failure.message).toContain("重新打开");
|
||||
});
|
||||
|
||||
it("uses connection diagnostic codes without displaying provider text", () => {
|
||||
expect(describeConnectionDiagnostic("WAIT_TIMEOUT")).toContain("超时");
|
||||
expect(describeConnectionDiagnostic("VAULT_CREDENTIAL_NOT_FOUND")).toContain("LAPP Vault");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
export interface PlayerTurnFailure {
|
||||
code: string;
|
||||
title: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
noCommitMessage: string;
|
||||
}
|
||||
|
||||
interface CommandFailureShape {
|
||||
code?: unknown;
|
||||
retryable?: unknown;
|
||||
}
|
||||
|
||||
interface FailureCopy {
|
||||
title: string;
|
||||
message: string;
|
||||
retryableByDefault: boolean;
|
||||
supportsImmediateRetry: boolean;
|
||||
}
|
||||
|
||||
const NO_COMMIT_MESSAGE = "本轮没有写入故事,当前节点保持不变。";
|
||||
|
||||
const TURN_FAILURE_COPY: Record<string, FailureCopy> = {
|
||||
cancelled: {
|
||||
title: "已停止生成",
|
||||
message: "这次回应已被完整丢弃。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
timed_out: {
|
||||
title: "等待模型超时",
|
||||
message: "模型没有及时完成回应,可以重试本轮。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
provider_unavailable: {
|
||||
title: "模型暂时不可用",
|
||||
message: "请检查模型设置或网络连接,也可以稍后重试。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
invalid_model_output: {
|
||||
title: "模型回应无法使用",
|
||||
message: "回应未通过完整性校验,可以重新生成本轮。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
storage_unavailable: {
|
||||
title: "存档暂时不可用",
|
||||
message: "故事未能安全保存,请稍后重试。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
internal: {
|
||||
title: "本轮未能完成",
|
||||
message: "应用遇到临时问题,可以重试本轮。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
turn_in_progress: {
|
||||
title: "已有一轮正在生成",
|
||||
message: "请等待正在进行的回合结束,再重试本轮。",
|
||||
retryableByDefault: true,
|
||||
supportsImmediateRetry: true
|
||||
},
|
||||
stale_node: {
|
||||
title: "故事线路已经变化",
|
||||
message: "当前画面不是最新节点,请重新打开对应线路后再行动。",
|
||||
retryableByDefault: false,
|
||||
supportsImmediateRetry: false
|
||||
},
|
||||
invalid_input: {
|
||||
title: "这次行动无法提交",
|
||||
message: "请调整输入后再试。",
|
||||
retryableByDefault: false,
|
||||
supportsImmediateRetry: false
|
||||
}
|
||||
};
|
||||
|
||||
function commandFailure(reason: unknown): CommandFailureShape | null {
|
||||
if (typeof reason !== "object" || reason === null) return null;
|
||||
return reason as CommandFailureShape;
|
||||
}
|
||||
|
||||
function normalizedCode(reason: unknown): string {
|
||||
const code = commandFailure(reason)?.code;
|
||||
return typeof code === "string" && code.trim() ? code.trim().toLowerCase() : "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a backend failure into player-safe copy.
|
||||
*
|
||||
* The backend message is intentionally not echoed: provider diagnostics may
|
||||
* contain implementation details that do not belong in PlayerView-facing UI.
|
||||
*/
|
||||
export function describeTurnFailure(reason: unknown): PlayerTurnFailure {
|
||||
const failure = commandFailure(reason);
|
||||
const code = normalizedCode(reason);
|
||||
const copy = TURN_FAILURE_COPY[code] ?? {
|
||||
title: "请求没有完成",
|
||||
message: "本轮没有产生可用的回应。",
|
||||
retryableByDefault: false,
|
||||
supportsImmediateRetry: true
|
||||
};
|
||||
const retryable =
|
||||
copy.supportsImmediateRetry &&
|
||||
(typeof failure?.retryable === "boolean" ? failure.retryable : copy.retryableByDefault);
|
||||
|
||||
return {
|
||||
code,
|
||||
title: copy.title,
|
||||
message: copy.message,
|
||||
retryable,
|
||||
noCommitMessage: NO_COMMIT_MESSAGE
|
||||
};
|
||||
}
|
||||
|
||||
export function describeOperationFailure(reason: unknown): string {
|
||||
switch (normalizedCode(reason)) {
|
||||
case "timed_out":
|
||||
return "操作等待超时,请稍后重试。";
|
||||
case "provider_unavailable":
|
||||
return "当前模型不可用,请检查模型设置。";
|
||||
case "storage_unavailable":
|
||||
return "故事存档暂时不可用,请稍后重试。";
|
||||
case "stale_node":
|
||||
return "故事线路已经变化,请重新打开对应线路。";
|
||||
case "invalid_input":
|
||||
return "提交的内容无法使用,请检查后重试。";
|
||||
default:
|
||||
return "操作没有完成,请稍后重试。";
|
||||
}
|
||||
}
|
||||
|
||||
export function describeConnectionFailure(reason: unknown): string {
|
||||
switch (normalizedCode(reason)) {
|
||||
case "timed_out":
|
||||
return "连接测试超时,请检查网络后重试。";
|
||||
case "provider_unavailable":
|
||||
return "当前模型无法连接,请检查 LAPP profile 与网络。";
|
||||
case "invalid_input":
|
||||
return "当前模型配置不完整,请先应用可用模型。";
|
||||
default:
|
||||
return "连接测试没有完成;没有读取或显示任何凭据。";
|
||||
}
|
||||
}
|
||||
|
||||
export function describeConnectionDiagnostic(code: string | null): string {
|
||||
switch (code?.toUpperCase()) {
|
||||
case "ENV_SECRET_MISSING":
|
||||
case "VAULT_BACKEND_UNAVAILABLE":
|
||||
case "VAULT_CREDENTIAL_NOT_FOUND":
|
||||
case "VAULT_BINDING_MISMATCH":
|
||||
case "VAULT_ACCESS_DENIED":
|
||||
return "LAPP profile 引用的凭据当前不可用,请在 LAPP Vault 中检查配置。";
|
||||
case "WAIT_TIMEOUT":
|
||||
return "最小请求等待超时,请检查网络后重试。";
|
||||
case "HTTP_REQUEST_FAILED":
|
||||
case "HTTP_STATUS":
|
||||
return "供应商连接失败,请检查网络与模型服务状态。";
|
||||
case "INVALID_RESPONSE":
|
||||
return "模型返回了无法识别的响应,请确认所选协议与模型兼容。";
|
||||
default:
|
||||
return "当前模型没有完成最小请求,请检查 LAPP profile 与网络。";
|
||||
}
|
||||
}
|
||||
+195
-34
@@ -1,4 +1,4 @@
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import type {
|
||||
AppInfo,
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@contracts";
|
||||
|
||||
import {
|
||||
cancelTurn as cancelRuntimeTurn,
|
||||
forkBranch as forkRuntimeBranch,
|
||||
getAppInfo,
|
||||
getBranchList,
|
||||
@@ -18,22 +19,30 @@ import {
|
||||
getDemoPlayerView,
|
||||
getLappSettings,
|
||||
renameBranch as renameRuntimeBranch,
|
||||
submitTurn as submitRuntimeTurn,
|
||||
switchBranch as switchRuntimeBranch,
|
||||
updateLappSettings,
|
||||
submitTurn as submitRuntimeTurn
|
||||
testLappConnection as testRuntimeLappConnection,
|
||||
updateLappSettings
|
||||
} from "./bridge";
|
||||
import {
|
||||
describeConnectionDiagnostic,
|
||||
describeConnectionFailure,
|
||||
describeOperationFailure,
|
||||
describeTurnFailure,
|
||||
type PlayerTurnFailure
|
||||
} from "./errors";
|
||||
|
||||
function errorMessage(reason: unknown): string {
|
||||
if (reason instanceof Error) return reason.message;
|
||||
if (
|
||||
typeof reason === "object" &&
|
||||
reason !== null &&
|
||||
"message" in reason &&
|
||||
typeof reason.message === "string"
|
||||
) {
|
||||
return reason.message;
|
||||
}
|
||||
return String(reason);
|
||||
type TurnPhase = "idle" | "submitting" | "cancelling";
|
||||
|
||||
interface TurnAttempt {
|
||||
request: TurnRequest;
|
||||
sourceView: PlayerView;
|
||||
intent: TurnIntent;
|
||||
}
|
||||
|
||||
export interface LappTestStatus {
|
||||
tone: "success" | "error" | "preview";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function useDemo() {
|
||||
@@ -46,22 +55,35 @@ export function useDemo() {
|
||||
const error = ref<string | null>(null);
|
||||
const busy = ref(false);
|
||||
const turnError = ref<string | null>(null);
|
||||
const turnFailure = ref<PlayerTurnFailure | null>(null);
|
||||
const turnNotice = ref<string | null>(null);
|
||||
const turnPhase = ref<TurnPhase>("idle");
|
||||
const lastSubmittedIntent = ref<TurnIntent | null>(null);
|
||||
const testingLappConnection = ref(false);
|
||||
const lappTestStatus = ref<LappTestStatus | null>(null);
|
||||
const branchViews = new Map<string, PlayerView>();
|
||||
let activeTurn: TurnAttempt | null = null;
|
||||
let retryableTurn: TurnAttempt | null = null;
|
||||
|
||||
const turnInFlight = computed(() => turnPhase.value !== "idle");
|
||||
const cancellingTurn = computed(() => turnPhase.value === "cancelling");
|
||||
const canRetryTurn = computed(
|
||||
() => !busy.value && Boolean(retryableTurn && turnFailure.value?.retryable)
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
[appInfo.value, pack.value, playerView.value, branchList.value, lappSettings.value] =
|
||||
await Promise.all([
|
||||
getAppInfo(),
|
||||
getDemoPackSummary(),
|
||||
getDemoPlayerView(),
|
||||
getBranchList(),
|
||||
getLappSettings()
|
||||
]);
|
||||
getAppInfo(),
|
||||
getDemoPackSummary(),
|
||||
getDemoPlayerView(),
|
||||
getBranchList(),
|
||||
getLappSettings()
|
||||
]);
|
||||
if (playerView.value) branchViews.set(playerView.value.branchId, playerView.value);
|
||||
} catch (reason) {
|
||||
error.value = errorMessage(reason);
|
||||
error.value = describeOperationFailure(reason);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -84,28 +106,89 @@ export function useDemo() {
|
||||
input: intent === "continue" ? "" : normalizedInput
|
||||
};
|
||||
|
||||
await runTurn({ request, sourceView: currentView, intent });
|
||||
}
|
||||
|
||||
async function runTurn(attempt: TurnAttempt): Promise<void> {
|
||||
if (busy.value) return;
|
||||
|
||||
busy.value = true;
|
||||
turnError.value = null;
|
||||
lastSubmittedIntent.value = intent;
|
||||
turnFailure.value = null;
|
||||
turnNotice.value = null;
|
||||
retryableTurn = null;
|
||||
turnPhase.value = "submitting";
|
||||
lastSubmittedIntent.value = attempt.intent;
|
||||
activeTurn = attempt;
|
||||
|
||||
try {
|
||||
const result = await submitRuntimeTurn(request, currentView);
|
||||
const result = await submitRuntimeTurn(attempt.request, attempt.sourceView);
|
||||
const cancellationWasRequested = cancellingTurn.value;
|
||||
playerView.value = result.playerView;
|
||||
branchViews.set(result.playerView.branchId, result.playerView);
|
||||
updateBranchHead(result.playerView);
|
||||
turnNotice.value = cancellationWasRequested
|
||||
? "本轮在停止请求生效前已经完整完成,并已安全写入故事。"
|
||||
: null;
|
||||
} catch (reason) {
|
||||
turnError.value = errorMessage(reason);
|
||||
const failure = describeTurnFailure(reason);
|
||||
turnFailure.value = failure;
|
||||
retryableTurn = failure.retryable ? attempt : null;
|
||||
} finally {
|
||||
if (activeTurn === attempt) activeTurn = null;
|
||||
turnPhase.value = "idle";
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelActiveTurn(): Promise<void> {
|
||||
const attempt = activeTurn;
|
||||
if (!attempt || turnPhase.value !== "submitting") return;
|
||||
|
||||
turnPhase.value = "cancelling";
|
||||
turnNotice.value = null;
|
||||
try {
|
||||
const accepted = await cancelRuntimeTurn(attempt.request.actionId);
|
||||
if (!accepted && activeTurn === attempt) {
|
||||
turnPhase.value = "submitting";
|
||||
turnNotice.value = "停止请求没有抢在提交前生效;正在等待本轮的最终结果。";
|
||||
}
|
||||
} catch (reason) {
|
||||
if (activeTurn === attempt) {
|
||||
turnPhase.value = "submitting";
|
||||
turnNotice.value = `${describeOperationFailure(reason)} 当前回合仍在等待完成。`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function retryTurn(): Promise<void> {
|
||||
const attempt = retryableTurn;
|
||||
const currentView = playerView.value;
|
||||
if (!attempt || !currentView || busy.value) return;
|
||||
|
||||
if (
|
||||
currentView.storyId !== attempt.request.storyId ||
|
||||
currentView.branchId !== attempt.request.branchId ||
|
||||
currentView.nodeId !== attempt.request.expectedNodeId
|
||||
) {
|
||||
retryableTurn = null;
|
||||
turnFailure.value = describeTurnFailure({
|
||||
code: "stale_node",
|
||||
retryable: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the original action id: desktop retries can therefore be idempotent.
|
||||
await runTurn(attempt);
|
||||
}
|
||||
|
||||
async function forkBranch(sourceNodeId: string): Promise<void> {
|
||||
const currentView = playerView.value;
|
||||
if (!currentView || busy.value || sourceNodeId === currentView.nodeId) return;
|
||||
|
||||
busy.value = true;
|
||||
turnError.value = null;
|
||||
clearTurnFeedback();
|
||||
lastSubmittedIntent.value = null;
|
||||
|
||||
try {
|
||||
@@ -142,7 +225,7 @@ export function useDemo() {
|
||||
};
|
||||
}
|
||||
} catch (reason) {
|
||||
turnError.value = errorMessage(reason);
|
||||
turnError.value = describeOperationFailure(reason);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -154,7 +237,7 @@ export function useDemo() {
|
||||
if (!currentView || !branches || busy.value || branchId === currentView.branchId) return;
|
||||
|
||||
busy.value = true;
|
||||
turnError.value = null;
|
||||
clearTurnFeedback();
|
||||
lastSubmittedIntent.value = null;
|
||||
branchViews.set(currentView.branchId, currentView);
|
||||
|
||||
@@ -178,7 +261,7 @@ export function useDemo() {
|
||||
}))
|
||||
};
|
||||
} catch (reason) {
|
||||
turnError.value = errorMessage(reason);
|
||||
turnError.value = describeOperationFailure(reason);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -190,14 +273,14 @@ export function useDemo() {
|
||||
if (!branches || busy.value || !normalized) return;
|
||||
|
||||
busy.value = true;
|
||||
turnError.value = null;
|
||||
clearTurnFeedback();
|
||||
try {
|
||||
branchList.value = await renameRuntimeBranch(
|
||||
{ storyId: branches.storyId, branchId, name: normalized },
|
||||
branches
|
||||
);
|
||||
} catch (reason) {
|
||||
turnError.value = errorMessage(reason);
|
||||
turnError.value = describeOperationFailure(reason);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -206,16 +289,83 @@ export function useDemo() {
|
||||
async function selectLappModel(providerId: string, modelId: string): Promise<void> {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
turnError.value = null;
|
||||
lappTestStatus.value = null;
|
||||
try {
|
||||
lappSettings.value = await updateLappSettings({ providerId, modelId });
|
||||
} catch (reason) {
|
||||
turnError.value = errorMessage(reason);
|
||||
lappTestStatus.value = {
|
||||
tone: "error",
|
||||
message: describeOperationFailure(reason)
|
||||
};
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testLappConnection(): Promise<void> {
|
||||
const settings = lappSettings.value;
|
||||
if (!settings || busy.value) return;
|
||||
if (
|
||||
settings.mode !== "demo" &&
|
||||
(!settings.selectedProviderId || !settings.selectedModelId)
|
||||
) {
|
||||
lappTestStatus.value = {
|
||||
tone: "error",
|
||||
message: "请先应用一个支持聊天与工具调用的模型。"
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
testingLappConnection.value = true;
|
||||
lappTestStatus.value = null;
|
||||
try {
|
||||
const request =
|
||||
settings.mode === "demo"
|
||||
? { providerId: "browser-preview", modelId: "deterministic-demo" }
|
||||
: {
|
||||
providerId: settings.selectedProviderId!,
|
||||
modelId: settings.selectedModelId!
|
||||
};
|
||||
const result = await testRuntimeLappConnection(request);
|
||||
if (result.diagnosticCode === "browser_preview") {
|
||||
lappTestStatus.value = {
|
||||
tone: "preview",
|
||||
message: "浏览器演示路径正常;真实 LAPP 连接需在桌面端测试。"
|
||||
};
|
||||
} else if (result.ok) {
|
||||
lappTestStatus.value = {
|
||||
tone: "success",
|
||||
message: "连接测试通过,当前模型可用于新的故事回合。"
|
||||
};
|
||||
} else {
|
||||
lappTestStatus.value = {
|
||||
tone: "error",
|
||||
message: describeConnectionDiagnostic(result.diagnosticCode)
|
||||
};
|
||||
}
|
||||
} catch (reason) {
|
||||
lappTestStatus.value = {
|
||||
tone: "error",
|
||||
message: describeConnectionFailure(reason)
|
||||
};
|
||||
} finally {
|
||||
testingLappConnection.value = false;
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLappTestStatus(): void {
|
||||
lappTestStatus.value = null;
|
||||
}
|
||||
|
||||
function clearTurnFeedback(): void {
|
||||
turnError.value = null;
|
||||
turnFailure.value = null;
|
||||
turnNotice.value = null;
|
||||
retryableTurn = null;
|
||||
}
|
||||
|
||||
function currentHistoryLabel(view: PlayerView): string {
|
||||
return view.history.find((node) => node.isCurrent)?.label ?? view.sceneTitle;
|
||||
}
|
||||
@@ -243,17 +393,28 @@ export function useDemo() {
|
||||
appInfo,
|
||||
branchList,
|
||||
busy,
|
||||
canRetryTurn,
|
||||
cancellingTurn,
|
||||
error,
|
||||
lastSubmittedIntent,
|
||||
loading,
|
||||
lappSettings,
|
||||
lappTestStatus,
|
||||
loading,
|
||||
pack,
|
||||
playerView,
|
||||
testingLappConnection,
|
||||
turnError,
|
||||
turnFailure,
|
||||
turnInFlight,
|
||||
turnNotice,
|
||||
cancelActiveTurn,
|
||||
clearLappTestStatus,
|
||||
forkBranch,
|
||||
renameBranch,
|
||||
retryTurn,
|
||||
selectLappModel,
|
||||
submitTurn,
|
||||
switchBranch,
|
||||
turnError
|
||||
testLappConnection
|
||||
};
|
||||
}
|
||||
|
||||
+120
-8
@@ -431,15 +431,86 @@ textarea:focus-visible {
|
||||
|
||||
.turn-status {
|
||||
position: absolute;
|
||||
right: 18%;
|
||||
bottom: 18px;
|
||||
z-index: 14;
|
||||
right: 7%;
|
||||
bottom: 108px;
|
||||
z-index: 16;
|
||||
width: min(620px, 72%);
|
||||
margin: 0;
|
||||
color: rgb(241 196 178 / 72%);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.turn-status-card {
|
||||
display: flex;
|
||||
min-height: 54px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 10px 12px 10px 16px;
|
||||
color: rgb(245 241 234 / 78%);
|
||||
font-size: 12px;
|
||||
background: rgb(11 14 23 / 94%);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 11px;
|
||||
box-shadow: 0 12px 34px rgb(0 0 0 / 32%);
|
||||
backdrop-filter: blur(16px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.turn-status-card > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.turn-status-card strong {
|
||||
color: rgb(247 243 237 / 88%);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.turn-status-card small {
|
||||
color: rgb(245 241 234 / 48%);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.turn-status-card--progress {
|
||||
border-color: rgb(184 205 222 / 24%);
|
||||
}
|
||||
|
||||
.turn-status-card--failure {
|
||||
border-color: rgb(231 167 167 / 32%);
|
||||
}
|
||||
|
||||
.turn-status-card--notice {
|
||||
min-height: 40px;
|
||||
color: rgb(211 224 231 / 72%);
|
||||
}
|
||||
|
||||
.turn-status-card button {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: rgb(245 241 234 / 78%);
|
||||
background: rgb(255 255 255 / 5%);
|
||||
border: 1px solid rgb(255 255 255 / 14%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.turn-status-card button:hover:not(:disabled) {
|
||||
color: #f4c8b5;
|
||||
border-color: rgb(231 184 164 / 38%);
|
||||
}
|
||||
|
||||
.turn-status-card button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.turn-status-card .retry-turn-button {
|
||||
color: #241b1a;
|
||||
background: #e7b8a4;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.statusline {
|
||||
position: absolute;
|
||||
right: 8%;
|
||||
@@ -687,7 +758,8 @@ textarea:focus-visible {
|
||||
|
||||
.branch-heading,
|
||||
.branch-actions,
|
||||
.settings-status {
|
||||
.settings-status,
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -733,7 +805,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.branch-actions button,
|
||||
.settings-card button {
|
||||
.settings-actions button {
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
color: rgb(245 241 234 / 72%);
|
||||
@@ -743,7 +815,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.branch-actions button:disabled,
|
||||
.settings-card button:disabled {
|
||||
.settings-actions button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.42;
|
||||
}
|
||||
@@ -778,6 +850,46 @@ textarea:focus-visible {
|
||||
background: #f3f0eb;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-actions .connection-test-button {
|
||||
color: #eac0ae;
|
||||
border-color: rgb(231 184 164 / 24%);
|
||||
}
|
||||
|
||||
.settings-card .connection-privacy {
|
||||
color: rgb(245 241 234 / 34%);
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.settings-card .connection-result {
|
||||
padding: 10px 12px;
|
||||
color: rgb(211 224 231 / 72%);
|
||||
font-size: 12px;
|
||||
background: rgb(123 164 190 / 8%);
|
||||
border: 1px solid rgb(123 164 190 / 18%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.settings-card .connection-result[data-tone="success"] {
|
||||
color: #b9dac6;
|
||||
background: rgb(118 184 143 / 8%);
|
||||
border-color: rgb(118 184 143 / 20%);
|
||||
}
|
||||
|
||||
.settings-card .connection-result[data-tone="error"] {
|
||||
color: #e7b0aa;
|
||||
background: rgb(205 113 113 / 8%);
|
||||
border-color: rgb(205 113 113 / 20%);
|
||||
}
|
||||
|
||||
.settings-status span[data-mode="unavailable"] {
|
||||
color: #e7a7a7;
|
||||
border-color: rgb(231 167 167 / 28%);
|
||||
|
||||
Reference in New Issue
Block a user