feat(app): add cancellable turn lifecycle
This commit is contained in:
@@ -73,6 +73,36 @@ describe("App", () => {
|
|||||||
wrapper.unmount();
|
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 () => {
|
it("finishes the playable before-dawn slice and exposes its settled records", async () => {
|
||||||
const wrapper = await mountLoadedApp();
|
const wrapper = await mountLoadedApp();
|
||||||
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
|
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
|
||||||
@@ -178,6 +208,22 @@ describe("App", () => {
|
|||||||
wrapper.unmount();
|
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 () => {
|
it("closes the active panel with Escape", async () => {
|
||||||
const wrapper = await mountLoadedApp();
|
const wrapper = await mountLoadedApp();
|
||||||
await wrapper.findAll(".top-actions button")[0].trigger("click");
|
await wrapper.findAll(".top-actions button")[0].trigger("click");
|
||||||
|
|||||||
+88
-7
@@ -9,17 +9,28 @@ const {
|
|||||||
appInfo,
|
appInfo,
|
||||||
branchList,
|
branchList,
|
||||||
busy,
|
busy,
|
||||||
|
canRetryTurn,
|
||||||
|
cancellingTurn,
|
||||||
error,
|
error,
|
||||||
lastSubmittedIntent,
|
lastSubmittedIntent,
|
||||||
loading,
|
loading,
|
||||||
lappSettings,
|
lappSettings,
|
||||||
|
lappTestStatus,
|
||||||
pack,
|
pack,
|
||||||
playerView,
|
playerView,
|
||||||
|
testingLappConnection,
|
||||||
|
turnFailure,
|
||||||
|
turnInFlight,
|
||||||
|
turnNotice,
|
||||||
|
cancelActiveTurn,
|
||||||
|
clearLappTestStatus,
|
||||||
forkBranch,
|
forkBranch,
|
||||||
renameBranch,
|
renameBranch,
|
||||||
|
retryTurn,
|
||||||
selectLappModel,
|
selectLappModel,
|
||||||
submitTurn,
|
submitTurn,
|
||||||
switchBranch,
|
switchBranch,
|
||||||
|
testLappConnection,
|
||||||
turnError
|
turnError
|
||||||
} = useDemo();
|
} = useDemo();
|
||||||
const draft = ref("");
|
const draft = ref("");
|
||||||
@@ -139,6 +150,7 @@ function setLappSelection(event: Event) {
|
|||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (target instanceof HTMLSelectElement) {
|
if (target instanceof HTMLSelectElement) {
|
||||||
lappSelection.value = target.value;
|
lappSelection.value = target.value;
|
||||||
|
clearLappTestStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +167,15 @@ async function saveLappSelection() {
|
|||||||
await selectLappModel(providerId, modelId);
|
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) {
|
function handleEscape(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape" && activePanel.value) {
|
if (event.key === "Escape" && activePanel.value) {
|
||||||
closePanel();
|
closePanel();
|
||||||
@@ -274,21 +295,60 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
:disabled="busy || !playerView.canContinue"
|
:disabled="busy || !playerView.canContinue"
|
||||||
@click="continueStory"
|
@click="continueStory"
|
||||||
>
|
>
|
||||||
{{ busy && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }}
|
{{ turnInFlight && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="send-button"
|
class="send-button"
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="busy || !playerView.canContinue || draft.trim().length === 0"
|
:disabled="busy || !playerView.canContinue || draft.trim().length === 0"
|
||||||
>
|
>
|
||||||
{{ busy && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
|
{{ turnInFlight && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="turn-status" aria-live="polite">
|
<section class="turn-status" aria-live="polite" aria-label="回合状态">
|
||||||
<span v-if="busy">故事正在回应,请稍候。</span>
|
<div v-if="turnInFlight" class="turn-status-card turn-status-card--progress">
|
||||||
<span v-else-if="turnError">请求未完成:{{ turnError }}</span>
|
<span>
|
||||||
</p>
|
<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">
|
<footer class="statusline">
|
||||||
<span>{{ playerView.nodeId }}</span>
|
<span>{{ playerView.nodeId }}</span>
|
||||||
@@ -501,13 +561,34 @@ onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
|
|||||||
{{ model.providerName ?? model.providerId }} · {{ model.modelName ?? model.modelId }}
|
{{ model.providerName ?? model.providerId }} · {{ model.modelName ?? model.modelId }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="settings-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="busy || !selectedLappValue || lappSettings.mode === 'demo'"
|
:disabled="busy || !selectedLappValue || lappSettings.mode === 'demo'"
|
||||||
@click="saveLappSelection"
|
@click="saveLappSelection"
|
||||||
>
|
>
|
||||||
{{ busy ? "正在应用…" : "应用模型" }}
|
应用模型
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
cancelTurn,
|
||||||
forkBranch,
|
forkBranch,
|
||||||
getBranchList,
|
getBranchList,
|
||||||
getLappSettings,
|
getLappSettings,
|
||||||
submitTurn,
|
submitTurn,
|
||||||
switchBranch,
|
switchBranch,
|
||||||
|
testLappConnection,
|
||||||
updateLappSettings
|
updateLappSettings
|
||||||
} from "./bridge";
|
} from "./bridge";
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ describe("Tauri bridge", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete window.__TAURI_INTERNALS__;
|
delete window.__TAURI_INTERNALS__;
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends only TurnRequest to the submit_turn command", async () => {
|
it("sends only TurnRequest to the submit_turn command", async () => {
|
||||||
@@ -62,6 +65,48 @@ describe("Tauri bridge", () => {
|
|||||||
expect(invokeMock).toHaveBeenCalledWith("submit_turn", { request });
|
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 () => {
|
it("sends only the typed fork request to the fork_branch command", async () => {
|
||||||
const forkRequest: ForkBranchRequest = {
|
const forkRequest: ForkBranchRequest = {
|
||||||
storyId: initialView.storyId,
|
storyId: initialView.storyId,
|
||||||
|
|||||||
+65
-1
@@ -19,6 +19,17 @@ import type {
|
|||||||
|
|
||||||
import { forkDemoBranch, submitDemoTurn } from "./turnAdapter";
|
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 {
|
function isTauri(): boolean {
|
||||||
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||||
}
|
}
|
||||||
@@ -127,16 +138,69 @@ export async function updateLappSettings(
|
|||||||
return invoke<LappSettings>("update_lapp_settings", { request });
|
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(
|
export async function submitTurn(
|
||||||
request: TurnRequest,
|
request: TurnRequest,
|
||||||
currentView: PlayerView
|
currentView: PlayerView
|
||||||
): Promise<TurnResult> {
|
): Promise<TurnResult> {
|
||||||
if (!isTauri()) {
|
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 });
|
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(
|
export async function forkBranch(
|
||||||
request: ForkBranchRequest,
|
request: ForkBranchRequest,
|
||||||
currentView: PlayerView
|
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 与网络。";
|
||||||
|
}
|
||||||
|
}
|
||||||
+188
-27
@@ -1,4 +1,4 @@
|
|||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppInfo,
|
AppInfo,
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
} from "@contracts";
|
} from "@contracts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
cancelTurn as cancelRuntimeTurn,
|
||||||
forkBranch as forkRuntimeBranch,
|
forkBranch as forkRuntimeBranch,
|
||||||
getAppInfo,
|
getAppInfo,
|
||||||
getBranchList,
|
getBranchList,
|
||||||
@@ -18,22 +19,30 @@ import {
|
|||||||
getDemoPlayerView,
|
getDemoPlayerView,
|
||||||
getLappSettings,
|
getLappSettings,
|
||||||
renameBranch as renameRuntimeBranch,
|
renameBranch as renameRuntimeBranch,
|
||||||
|
submitTurn as submitRuntimeTurn,
|
||||||
switchBranch as switchRuntimeBranch,
|
switchBranch as switchRuntimeBranch,
|
||||||
updateLappSettings,
|
testLappConnection as testRuntimeLappConnection,
|
||||||
submitTurn as submitRuntimeTurn
|
updateLappSettings
|
||||||
} from "./bridge";
|
} from "./bridge";
|
||||||
|
import {
|
||||||
|
describeConnectionDiagnostic,
|
||||||
|
describeConnectionFailure,
|
||||||
|
describeOperationFailure,
|
||||||
|
describeTurnFailure,
|
||||||
|
type PlayerTurnFailure
|
||||||
|
} from "./errors";
|
||||||
|
|
||||||
function errorMessage(reason: unknown): string {
|
type TurnPhase = "idle" | "submitting" | "cancelling";
|
||||||
if (reason instanceof Error) return reason.message;
|
|
||||||
if (
|
interface TurnAttempt {
|
||||||
typeof reason === "object" &&
|
request: TurnRequest;
|
||||||
reason !== null &&
|
sourceView: PlayerView;
|
||||||
"message" in reason &&
|
intent: TurnIntent;
|
||||||
typeof reason.message === "string"
|
|
||||||
) {
|
|
||||||
return reason.message;
|
|
||||||
}
|
}
|
||||||
return String(reason);
|
|
||||||
|
export interface LappTestStatus {
|
||||||
|
tone: "success" | "error" | "preview";
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDemo() {
|
export function useDemo() {
|
||||||
@@ -46,8 +55,21 @@ export function useDemo() {
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const turnError = ref<string | null>(null);
|
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 lastSubmittedIntent = ref<TurnIntent | null>(null);
|
||||||
|
const testingLappConnection = ref(false);
|
||||||
|
const lappTestStatus = ref<LappTestStatus | null>(null);
|
||||||
const branchViews = new Map<string, PlayerView>();
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -61,7 +83,7 @@ export function useDemo() {
|
|||||||
]);
|
]);
|
||||||
if (playerView.value) branchViews.set(playerView.value.branchId, playerView.value);
|
if (playerView.value) branchViews.set(playerView.value.branchId, playerView.value);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = errorMessage(reason);
|
error.value = describeOperationFailure(reason);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -84,28 +106,89 @@ export function useDemo() {
|
|||||||
input: intent === "continue" ? "" : normalizedInput
|
input: intent === "continue" ? "" : normalizedInput
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await runTurn({ request, sourceView: currentView, intent });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTurn(attempt: TurnAttempt): Promise<void> {
|
||||||
|
if (busy.value) return;
|
||||||
|
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
turnError.value = null;
|
turnError.value = null;
|
||||||
lastSubmittedIntent.value = intent;
|
turnFailure.value = null;
|
||||||
|
turnNotice.value = null;
|
||||||
|
retryableTurn = null;
|
||||||
|
turnPhase.value = "submitting";
|
||||||
|
lastSubmittedIntent.value = attempt.intent;
|
||||||
|
activeTurn = attempt;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await submitRuntimeTurn(request, currentView);
|
const result = await submitRuntimeTurn(attempt.request, attempt.sourceView);
|
||||||
|
const cancellationWasRequested = cancellingTurn.value;
|
||||||
playerView.value = result.playerView;
|
playerView.value = result.playerView;
|
||||||
branchViews.set(result.playerView.branchId, result.playerView);
|
branchViews.set(result.playerView.branchId, result.playerView);
|
||||||
updateBranchHead(result.playerView);
|
updateBranchHead(result.playerView);
|
||||||
|
turnNotice.value = cancellationWasRequested
|
||||||
|
? "本轮在停止请求生效前已经完整完成,并已安全写入故事。"
|
||||||
|
: null;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
turnError.value = errorMessage(reason);
|
const failure = describeTurnFailure(reason);
|
||||||
|
turnFailure.value = failure;
|
||||||
|
retryableTurn = failure.retryable ? attempt : null;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (activeTurn === attempt) activeTurn = null;
|
||||||
|
turnPhase.value = "idle";
|
||||||
busy.value = false;
|
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> {
|
async function forkBranch(sourceNodeId: string): Promise<void> {
|
||||||
const currentView = playerView.value;
|
const currentView = playerView.value;
|
||||||
if (!currentView || busy.value || sourceNodeId === currentView.nodeId) return;
|
if (!currentView || busy.value || sourceNodeId === currentView.nodeId) return;
|
||||||
|
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
turnError.value = null;
|
clearTurnFeedback();
|
||||||
lastSubmittedIntent.value = null;
|
lastSubmittedIntent.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -142,7 +225,7 @@ export function useDemo() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
turnError.value = errorMessage(reason);
|
turnError.value = describeOperationFailure(reason);
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -154,7 +237,7 @@ export function useDemo() {
|
|||||||
if (!currentView || !branches || busy.value || branchId === currentView.branchId) return;
|
if (!currentView || !branches || busy.value || branchId === currentView.branchId) return;
|
||||||
|
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
turnError.value = null;
|
clearTurnFeedback();
|
||||||
lastSubmittedIntent.value = null;
|
lastSubmittedIntent.value = null;
|
||||||
branchViews.set(currentView.branchId, currentView);
|
branchViews.set(currentView.branchId, currentView);
|
||||||
|
|
||||||
@@ -178,7 +261,7 @@ export function useDemo() {
|
|||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
turnError.value = errorMessage(reason);
|
turnError.value = describeOperationFailure(reason);
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -190,14 +273,14 @@ export function useDemo() {
|
|||||||
if (!branches || busy.value || !normalized) return;
|
if (!branches || busy.value || !normalized) return;
|
||||||
|
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
turnError.value = null;
|
clearTurnFeedback();
|
||||||
try {
|
try {
|
||||||
branchList.value = await renameRuntimeBranch(
|
branchList.value = await renameRuntimeBranch(
|
||||||
{ storyId: branches.storyId, branchId, name: normalized },
|
{ storyId: branches.storyId, branchId, name: normalized },
|
||||||
branches
|
branches
|
||||||
);
|
);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
turnError.value = errorMessage(reason);
|
turnError.value = describeOperationFailure(reason);
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
@@ -206,16 +289,83 @@ export function useDemo() {
|
|||||||
async function selectLappModel(providerId: string, modelId: string): Promise<void> {
|
async function selectLappModel(providerId: string, modelId: string): Promise<void> {
|
||||||
if (busy.value) return;
|
if (busy.value) return;
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
turnError.value = null;
|
lappTestStatus.value = null;
|
||||||
try {
|
try {
|
||||||
lappSettings.value = await updateLappSettings({ providerId, modelId });
|
lappSettings.value = await updateLappSettings({ providerId, modelId });
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
turnError.value = errorMessage(reason);
|
lappTestStatus.value = {
|
||||||
|
tone: "error",
|
||||||
|
message: describeOperationFailure(reason)
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
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 {
|
function currentHistoryLabel(view: PlayerView): string {
|
||||||
return view.history.find((node) => node.isCurrent)?.label ?? view.sceneTitle;
|
return view.history.find((node) => node.isCurrent)?.label ?? view.sceneTitle;
|
||||||
}
|
}
|
||||||
@@ -243,17 +393,28 @@ export function useDemo() {
|
|||||||
appInfo,
|
appInfo,
|
||||||
branchList,
|
branchList,
|
||||||
busy,
|
busy,
|
||||||
|
canRetryTurn,
|
||||||
|
cancellingTurn,
|
||||||
error,
|
error,
|
||||||
lastSubmittedIntent,
|
lastSubmittedIntent,
|
||||||
loading,
|
|
||||||
lappSettings,
|
lappSettings,
|
||||||
|
lappTestStatus,
|
||||||
|
loading,
|
||||||
pack,
|
pack,
|
||||||
playerView,
|
playerView,
|
||||||
|
testingLappConnection,
|
||||||
|
turnError,
|
||||||
|
turnFailure,
|
||||||
|
turnInFlight,
|
||||||
|
turnNotice,
|
||||||
|
cancelActiveTurn,
|
||||||
|
clearLappTestStatus,
|
||||||
forkBranch,
|
forkBranch,
|
||||||
renameBranch,
|
renameBranch,
|
||||||
|
retryTurn,
|
||||||
selectLappModel,
|
selectLappModel,
|
||||||
submitTurn,
|
submitTurn,
|
||||||
switchBranch,
|
switchBranch,
|
||||||
turnError
|
testLappConnection
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-8
@@ -431,15 +431,86 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.turn-status {
|
.turn-status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 18%;
|
right: 7%;
|
||||||
bottom: 18px;
|
bottom: 108px;
|
||||||
z-index: 14;
|
z-index: 16;
|
||||||
|
width: min(620px, 72%);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: rgb(241 196 178 / 72%);
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
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 {
|
.statusline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 8%;
|
right: 8%;
|
||||||
@@ -687,7 +758,8 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.branch-heading,
|
.branch-heading,
|
||||||
.branch-actions,
|
.branch-actions,
|
||||||
.settings-status {
|
.settings-status,
|
||||||
|
.settings-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -733,7 +805,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.branch-actions button,
|
.branch-actions button,
|
||||||
.settings-card button {
|
.settings-actions button {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgb(245 241 234 / 72%);
|
color: rgb(245 241 234 / 72%);
|
||||||
@@ -743,7 +815,7 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.branch-actions button:disabled,
|
.branch-actions button:disabled,
|
||||||
.settings-card button:disabled {
|
.settings-actions button:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.42;
|
opacity: 0.42;
|
||||||
}
|
}
|
||||||
@@ -778,6 +850,46 @@ textarea:focus-visible {
|
|||||||
background: #f3f0eb;
|
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"] {
|
.settings-status span[data-mode="unavailable"] {
|
||||||
color: #e7a7a7;
|
color: #e7a7a7;
|
||||||
border-color: rgb(231 167 167 / 28%);
|
border-color: rgb(231 167 167 / 28%);
|
||||||
|
|||||||
Reference in New Issue
Block a user