feat(app): add cancellable turn lifecycle

This commit is contained in:
Codex
2026-07-28 08:01:56 -04:00
parent 1f935a3206
commit 4831db1763
8 changed files with 786 additions and 55 deletions
+46
View File
@@ -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");
+88 -7
View File
@@ -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>
<div class="settings-actions">
<button
type="button"
:disabled="busy || !selectedLappValue || lappSettings.mode === 'demo'"
@click="saveLappSelection"
>
{{ busy ? "正在应用…" : "应用模型" }}
应用模型
</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"
>
{{ lappTestStatus.message }}
</p>
</div>
</div>
</aside>
+45
View File
@@ -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
View File
@@ -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
+56
View File
@@ -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");
});
});
+166
View File
@@ -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 与网络。";
}
}
+188 -27
View File
@@ -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;
type TurnPhase = "idle" | "submitting" | "cancelling";
interface TurnAttempt {
request: TurnRequest;
sourceView: PlayerView;
intent: TurnIntent;
}
return String(reason);
export interface LappTestStatus {
tone: "success" | "error" | "preview";
message: string;
}
export function useDemo() {
@@ -46,8 +55,21 @@ 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 {
@@ -61,7 +83,7 @@ export function useDemo() {
]);
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
View File
@@ -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%);