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 }}
+
+
diff --git a/src/app/bridge.spec.ts b/src/app/bridge.spec.ts
index ae7edf8..bd9fe1e 100644
--- a/src/app/bridge.spec.ts
+++ b/src/app/bridge.spec.ts
@@ -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,
diff --git a/src/app/bridge.ts b/src/app/bridge.ts
index 3788189..d986a4d 100644
--- a/src/app/bridge.ts
+++ b/src/app/bridge.ts
@@ -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();
+const browserCancelledTurns = new Set();
+
function isTauri(): boolean {
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
}
@@ -127,16 +138,69 @@ export async function updateLappSettings(
return invoke("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 {
+ if (!isTauri()) {
+ await Promise.resolve();
+ return {
+ ok: true,
+ providerId: request.providerId,
+ modelId: request.modelId,
+ message: "browser preview is deterministic",
+ diagnosticCode: "browser_preview"
+ };
+ }
+
+ return invoke("test_lapp_connection", { request });
+}
+
export async function submitTurn(
request: TurnRequest,
currentView: PlayerView
): Promise {
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("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 {
+ if (!isTauri()) {
+ if (browserActiveTurns.has(actionId)) {
+ browserCancelledTurns.add(actionId);
+ await Promise.resolve();
+ return true;
+ }
+ await Promise.resolve();
+ return false;
+ }
+ return invoke("cancel_turn", { actionId });
+}
+
export async function forkBranch(
request: ForkBranchRequest,
currentView: PlayerView
diff --git a/src/app/errors.spec.ts b/src/app/errors.spec.ts
new file mode 100644
index 0000000..372a45e
--- /dev/null
+++ b/src/app/errors.spec.ts
@@ -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");
+ });
+});
diff --git a/src/app/errors.ts b/src/app/errors.ts
new file mode 100644
index 0000000..1f41b7d
--- /dev/null
+++ b/src/app/errors.ts
@@ -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 = {
+ 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 与网络。";
+ }
+}
diff --git a/src/app/useDemo.ts b/src/app/useDemo.ts
index 915dc26..3f00f73 100644
--- a/src/app/useDemo.ts
+++ b/src/app/useDemo.ts
@@ -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(null);
const busy = ref(false);
const turnError = ref(null);
+ const turnFailure = ref(null);
+ const turnNotice = ref(null);
+ const turnPhase = ref("idle");
const lastSubmittedIntent = ref(null);
+ const testingLappConnection = ref(false);
+ const lappTestStatus = ref(null);
const branchViews = new Map();
+ 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 {
+ 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 {
+ 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 {
+ 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 {
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 {
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 {
+ 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
};
}
diff --git a/src/styles/main.css b/src/styles/main.css
index 26551ad..0390703 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -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%);