Files
aigc-home/calliope-web/src/lib/components/prompt-tools/state.svelte.ts
T

248 lines
6.0 KiB
TypeScript

import { onDestroy, untrack } from "svelte";
import { showToast, toast } from "$lib/toast";
export type Action = "translate" | "polish";
export type Direction = "auto" | "zh-en" | "en-zh";
export interface TransformResult {
action: Action;
direction: Exclude<Direction, "auto"> | null;
direction_source: "auto" | "explicit" | null;
result: string;
protected_fragments: string[];
model: {
id: string;
name: string;
model: string;
following_default: boolean;
};
elapsed_ms: number;
}
export function directionFor(text: string): Exclude<Direction, "auto"> {
const body = text
.replace(/<[^<>\n]{1,300}>/g, "")
.replace(/:\s*-?\d+(?:\.\d+)?/g, "");
return /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/.test(body)
? "zh-en"
: "en-zh";
}
/** One field owns its monotonic history, including edits that restore the same text. */
export function promptTool(
getText: () => string,
getContext: () => string,
setText: (s: string) => void,
label: string,
onOpen?: () => void,
) {
let state = $state<"idle" | "loading" | "result" | "stale" | "error">("idle");
let result = $state<TransformResult | null>(null);
let error = $state("");
let action = $state<Action>("translate");
let confirm = $state(false);
let textVersion = $state(0);
let contextVersion = $state(0);
let ticketTextVersion = $state(0);
let seq = 0;
let abort: AbortController | null = null;
let alive = true;
let observedText = untrack(getText);
let observedContext = untrack(getContext);
function close() {
++seq;
abort?.abort();
abort = null;
state = "idle";
confirm = false;
}
function cancel() {
close();
toast.info("已取消,原提示词未改动");
}
function edit() {
++textVersion;
confirm = false;
}
function sync() {
const context = getContext();
const text = getText();
if (context !== observedContext) {
const hadResult = state !== "idle";
observedContext = context;
++contextVersion;
close();
if (hadResult)
toast.info("已切换工作流或项目,旧结果已作废;提示词未改动");
}
if (text !== observedText) {
observedText = text;
edit();
}
}
$effect.pre(() => {
getText();
getContext();
untrack(sync);
});
onDestroy(() => {
alive = false;
++contextVersion;
close();
});
async function run(next: Action, direction: Direction = "auto") {
sync();
if (!getText().trim()) return;
if (state === "loading" && next === action) {
cancel();
return;
}
onOpen?.();
close();
action = next;
state = "loading";
error = "";
result = null;
const id = ++seq;
const textRev = textVersion,
contextRev = contextVersion;
const snapshot = getText(),
context = getContext();
ticketTextVersion = textRev;
const controller = new AbortController();
abort = controller;
try {
const response = await fetch("/api/prompt-tools/transform", {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
action: next,
text: snapshot,
...(next === "translate" ? { direction } : {}),
}),
});
const payload = await response.json();
sync();
if (
!alive ||
id !== seq ||
controller.signal.aborted ||
contextRev !== contextVersion ||
context !== getContext()
)
return;
if (textRev !== textVersion) {
state = "stale";
return;
}
if (!response.ok) {
const detail = payload.detail;
error =
typeof detail === "object" && detail
? `${detail.message ?? "处理失败"}${detail.model?.name ? " · " + detail.model.name : ""}`
: "处理失败,请重试";
state = "error";
return;
}
result = payload as TransformResult;
state = "result";
} catch (e) {
sync();
if (
!alive ||
id !== seq ||
controller.signal.aborted ||
contextRev !== contextVersion
)
return;
if (textRev !== textVersion) {
state = "stale";
return;
}
error =
e instanceof TypeError
? "无法连接工作台服务器,请检查连接后重试"
: "处理失败,请重试";
state = "error";
} finally {
if (id === seq) abort = null;
}
}
function replace() {
sync();
if (state !== "result" || !result) return;
if (textVersion !== ticketTextVersion && !confirm) {
confirm = true;
return;
}
const before = getText(),
output = result.result,
ctxRev = contextVersion,
ctx = getContext();
setText(output);
edit();
observedText = output;
const replaceVersion = textVersion;
close();
const expires = Date.now() + 5000;
showToast(`已替换${label}`, "success", 5000, {
label: "撤销",
run() {
sync();
if (
!alive ||
Date.now() > expires ||
textVersion !== replaceVersion ||
contextVersion !== ctxRev ||
getContext() !== ctx
) {
toast.info("内容已变化或已切换,未自动恢复");
return;
}
setText(before);
observedText = before;
edit();
},
});
}
return {
get state() {
return state;
},
get result() {
return result;
},
get error() {
return error;
},
get action() {
return action;
},
get confirm() {
return confirm;
},
get label() {
return label;
},
get changed() {
return textVersion !== ticketTextVersion;
},
get text() {
return getText();
},
get direction() {
return directionFor(getText());
},
run,
close,
cancel,
edit,
replace,
cancelConfirm() {
confirm = false;
},
};
}
export type PromptTool = ReturnType<typeof promptTool>;