248 lines
6.0 KiB
TypeScript
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>;
|