预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
This commit is contained in:
@@ -1,20 +1,26 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
abortReroll,
|
||||
beginReroll,
|
||||
createConversation,
|
||||
createShell,
|
||||
deleteConversation,
|
||||
deleteShell,
|
||||
duplicateConversation,
|
||||
EMPTY_SHELL_SLOTS,
|
||||
getConversation,
|
||||
getShell,
|
||||
listConversations,
|
||||
listShells,
|
||||
mergeTurnReplies,
|
||||
promoteTemporary,
|
||||
runDirectChatMultiTurn,
|
||||
saveConversation,
|
||||
switchTurnVariant,
|
||||
updateConversation,
|
||||
updateShell,
|
||||
writeRerollReplies,
|
||||
type DirectChatAssistantReply,
|
||||
type DirectChatConversation,
|
||||
type DirectChatMessage,
|
||||
@@ -40,6 +46,27 @@ function json(res: ServerResponse, status: number, data: unknown): void {
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function readWindows(raw: unknown): DirectChatWindow[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const out: DirectChatWindow[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const id = typeof row.id === "string" ? row.id.trim() : "";
|
||||
const profileId = typeof row.profileId === "string" ? row.profileId.trim() : "";
|
||||
if (!id || !profileId) continue;
|
||||
const window: DirectChatWindow = { id, profileId };
|
||||
if (typeof row.profileName === "string" && row.profileName.trim()) {
|
||||
window.profileName = row.profileName.trim();
|
||||
}
|
||||
if (typeof row.model === "string" && row.model.trim()) {
|
||||
window.model = row.model.trim();
|
||||
}
|
||||
out.push(window);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wantsNdjson(req: IncomingMessage): boolean {
|
||||
return String(req.headers.accept || "").includes("application/x-ndjson");
|
||||
}
|
||||
@@ -68,11 +95,14 @@ function createStreamingSaver(
|
||||
windows: DirectChatWindow[],
|
||||
text: string,
|
||||
turnId: string,
|
||||
baseTurns?: DirectChatTurn[],
|
||||
) {
|
||||
const ts = new Date().toISOString();
|
||||
const replies: DirectChatAssistantReply[] = [];
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let closed = false;
|
||||
let produced = false;
|
||||
let turnsState = baseTurns ? structuredClone(baseTurns) : null;
|
||||
|
||||
const saveNow = () => {
|
||||
if (timer) {
|
||||
@@ -80,6 +110,19 @@ function createStreamingSaver(
|
||||
timer = null;
|
||||
}
|
||||
if (closed) return;
|
||||
if (turnsState) {
|
||||
turnsState = writeRerollReplies(
|
||||
turnsState,
|
||||
turnId,
|
||||
replies.map((r) => ({ ...r })),
|
||||
);
|
||||
saveConversation({
|
||||
...conversation,
|
||||
windows,
|
||||
turns: turnsState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const turn: DirectChatTurn = {
|
||||
id: turnId,
|
||||
user: { content: text, createdAt: ts },
|
||||
@@ -108,6 +151,7 @@ function createStreamingSaver(
|
||||
reply = { windowId: event.windowId, content: "" };
|
||||
replies.push(reply);
|
||||
}
|
||||
if (event.delta) produced = true;
|
||||
if (event.field === "thinking") {
|
||||
reply.thinking = `${reply.thinking || ""}${event.delta}`;
|
||||
} else {
|
||||
@@ -117,11 +161,18 @@ function createStreamingSaver(
|
||||
},
|
||||
onWindow(result: DirectChatMultiWindowResult) {
|
||||
const next = replyFromWindowResult(result);
|
||||
if (next.content || next.thinking || next.error) produced = true;
|
||||
const idx = replies.findIndex((r) => r.windowId === result.windowId);
|
||||
if (idx >= 0) replies[idx] = next;
|
||||
else replies.push(next);
|
||||
saveNow();
|
||||
},
|
||||
sawOutput() {
|
||||
return produced;
|
||||
},
|
||||
currentTurns() {
|
||||
return turnsState;
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
if (timer) {
|
||||
@@ -136,7 +187,10 @@ async function writeMultiStream(
|
||||
res: ServerResponse,
|
||||
run: (hooks: MultiStreamHooks) => Promise<DirectChatMultiTurnResult>,
|
||||
finish: (multi: DirectChatMultiTurnResult) => Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
): Promise<
|
||||
| { ok: true; multi: DirectChatMultiTurnResult }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/x-ndjson; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
@@ -152,6 +206,7 @@ async function writeMultiStream(
|
||||
});
|
||||
writeEvent({ type: "done", multi, ...finish(multi) });
|
||||
res.end();
|
||||
return { ok: true, multi };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
@@ -160,6 +215,7 @@ async function writeMultiStream(
|
||||
} catch {
|
||||
res.destroy();
|
||||
}
|
||||
return { ok: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +241,14 @@ function slotsFromBody(body: Record<string, unknown>): DirectChatShellSlots {
|
||||
typeof body.postTurn === "string"
|
||||
? body.postTurn
|
||||
: EMPTY_SHELL_SLOTS.postTurn,
|
||||
charName: typeof body.charName === "string" ? body.charName : undefined,
|
||||
exampleDialogue:
|
||||
typeof body.exampleDialogue === "string" ? body.exampleDialogue : undefined,
|
||||
charDescription:
|
||||
typeof body.charDescription === "string" ? body.charDescription : undefined,
|
||||
charPersonality:
|
||||
typeof body.charPersonality === "string" ? body.charPersonality : undefined,
|
||||
scenario: typeof body.scenario === "string" ? body.scenario : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -300,12 +364,16 @@ export async function handleDirectChatApi(
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
name?: string;
|
||||
messages?: DirectChatMessage[];
|
||||
windows?: DirectChatWindow[];
|
||||
turns?: DirectChatTurn[];
|
||||
};
|
||||
try {
|
||||
const conversation = createConversation({
|
||||
shellId,
|
||||
name: body.name,
|
||||
messages: body.messages,
|
||||
windows: readWindows(body.windows),
|
||||
turns: Array.isArray(body.turns) ? body.turns : undefined,
|
||||
});
|
||||
json(res, 201, { conversation });
|
||||
} catch (err) {
|
||||
@@ -342,11 +410,13 @@ export async function handleDirectChatApi(
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
name?: string;
|
||||
messages?: DirectChatMessage[];
|
||||
windows?: DirectChatWindow[];
|
||||
};
|
||||
try {
|
||||
const conversation = updateConversation(conversationId, {
|
||||
name: body.name,
|
||||
messages: body.messages,
|
||||
windows: readWindows(body.windows),
|
||||
});
|
||||
json(res, 200, { conversation });
|
||||
} catch (err) {
|
||||
@@ -369,6 +439,18 @@ export async function handleDirectChatApi(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/duplicate") {
|
||||
try {
|
||||
const conversation = duplicateConversation(conversationId);
|
||||
json(res, 201, { conversation });
|
||||
} catch (err) {
|
||||
json(res, 404, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/messages") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
text?: string;
|
||||
@@ -463,6 +545,278 @@ export async function handleDirectChatApi(
|
||||
return true;
|
||||
}
|
||||
|
||||
const rerollMatch = sub.match(/^\/turns\/([^/]+)\/reroll$/);
|
||||
if (rerollMatch && req.method === "POST") {
|
||||
const turnId = decodeURIComponent(rerollMatch[1]!);
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
text?: string;
|
||||
windows?: DirectChatWindow[];
|
||||
complete?: boolean;
|
||||
};
|
||||
const text = typeof body.text === "string" ? body.text.trim() : "";
|
||||
if (!text) {
|
||||
json(res, 400, { error: "text 不能为空" });
|
||||
return true;
|
||||
}
|
||||
const conversation = getConversation(conversationId);
|
||||
if (!conversation) {
|
||||
json(res, 404, { error: "会话不存在" });
|
||||
return true;
|
||||
}
|
||||
const shell = getShell(conversation.shellId);
|
||||
if (!shell) {
|
||||
json(res, 404, { error: "上下文壳不存在" });
|
||||
return true;
|
||||
}
|
||||
const windows =
|
||||
Array.isArray(body.windows) && body.windows.length
|
||||
? body.windows
|
||||
: conversation.windows;
|
||||
if (!windows.length) {
|
||||
json(res, 400, { error: "请先新增至少一个模型窗口" });
|
||||
return true;
|
||||
}
|
||||
const prepared = beginReroll(conversation.turns, turnId, text);
|
||||
if (!prepared) {
|
||||
json(res, 404, { error: "回合不存在" });
|
||||
return true;
|
||||
}
|
||||
const preparedConversation = saveConversation({
|
||||
...conversation,
|
||||
windows,
|
||||
turns: prepared.turns,
|
||||
});
|
||||
const saver = createStreamingSaver(
|
||||
preparedConversation,
|
||||
windows,
|
||||
text,
|
||||
turnId,
|
||||
prepared.turns,
|
||||
);
|
||||
const run = (hooks?: MultiStreamHooks) =>
|
||||
runDirectChatMultiTurn({
|
||||
message: text,
|
||||
slots: {
|
||||
systemPrompt: shell.systemPrompt,
|
||||
loreBefore: shell.loreBefore,
|
||||
seedHistory: shell.seedHistory,
|
||||
loreAfter: shell.loreAfter,
|
||||
postTurn: shell.postTurn,
|
||||
},
|
||||
presetId: shell.presetId,
|
||||
windows,
|
||||
turns: prepared.prior,
|
||||
complete: body.complete,
|
||||
turnId,
|
||||
onDelta: (event) => {
|
||||
saver.onDelta(event);
|
||||
hooks?.onDelta(event);
|
||||
},
|
||||
onWindow: (result) => {
|
||||
saver.onWindow(result);
|
||||
hooks?.onWindow(result);
|
||||
},
|
||||
});
|
||||
const restoreIfEmpty = () => {
|
||||
saver.close();
|
||||
if (saver.sawOutput()) return;
|
||||
const restored = abortReroll(prepared.turns, turnId);
|
||||
if (!restored) return;
|
||||
saveConversation({
|
||||
...preparedConversation,
|
||||
windows,
|
||||
turns: restored,
|
||||
});
|
||||
};
|
||||
try {
|
||||
if (wantsNdjson(req)) {
|
||||
const outcome = await writeMultiStream(res, run, (multi) => {
|
||||
saver.close();
|
||||
const turns = writeRerollReplies(
|
||||
saver.currentTurns() ?? prepared.turns,
|
||||
turnId,
|
||||
multi.turn.replies,
|
||||
);
|
||||
const updated = saveConversation({
|
||||
...preparedConversation,
|
||||
windows,
|
||||
turns,
|
||||
});
|
||||
return { conversation: updated, shell };
|
||||
});
|
||||
if (!outcome.ok) restoreIfEmpty();
|
||||
return true;
|
||||
}
|
||||
const multi = await run();
|
||||
saver.close();
|
||||
const turns = writeRerollReplies(
|
||||
saver.currentTurns() ?? prepared.turns,
|
||||
turnId,
|
||||
multi.turn.replies,
|
||||
);
|
||||
const updated = saveConversation({
|
||||
...preparedConversation,
|
||||
windows,
|
||||
turns,
|
||||
});
|
||||
json(res, 200, { conversation: updated, shell, multi });
|
||||
} catch (err) {
|
||||
restoreIfEmpty();
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const retryMatch = sub.match(/^\/turns\/([^/]+)\/retry$/);
|
||||
if (retryMatch && req.method === "POST") {
|
||||
const turnId = decodeURIComponent(retryMatch[1]!);
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
windowIds?: string[];
|
||||
complete?: boolean;
|
||||
};
|
||||
const requested = Array.isArray(body.windowIds)
|
||||
? body.windowIds.filter(
|
||||
(x): x is string => typeof x === "string" && Boolean(x.trim()),
|
||||
)
|
||||
: [];
|
||||
if (!requested.length) {
|
||||
json(res, 400, { error: "缺少 windowIds" });
|
||||
return true;
|
||||
}
|
||||
const conversation = getConversation(conversationId);
|
||||
if (!conversation) {
|
||||
json(res, 404, { error: "会话不存在" });
|
||||
return true;
|
||||
}
|
||||
const shell = getShell(conversation.shellId);
|
||||
if (!shell) {
|
||||
json(res, 404, { error: "上下文壳不存在" });
|
||||
return true;
|
||||
}
|
||||
const turnIndex = conversation.turns.findIndex((t) => t.id === turnId);
|
||||
const turn = turnIndex >= 0 ? conversation.turns[turnIndex] : undefined;
|
||||
if (!turn) {
|
||||
json(res, 404, { error: "回合不存在" });
|
||||
return true;
|
||||
}
|
||||
const text = turn.user.content.trim();
|
||||
if (!text) {
|
||||
json(res, 400, { error: "本轮缺少用户输入" });
|
||||
return true;
|
||||
}
|
||||
const wanted = new Set(requested);
|
||||
const windows = conversation.windows.filter((w) => wanted.has(w.id));
|
||||
if (!windows.length) {
|
||||
json(res, 400, { error: "没有可重试的窗口" });
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
// 关键:不调 beginReroll。重试只补选中的窗口,其余回复原地保留。
|
||||
const runRetry = (hooks?: Partial<MultiStreamHooks>) =>
|
||||
runDirectChatMultiTurn({
|
||||
message: text,
|
||||
slots: {
|
||||
systemPrompt: shell.systemPrompt,
|
||||
loreBefore: shell.loreBefore,
|
||||
seedHistory: shell.seedHistory,
|
||||
loreAfter: shell.loreAfter,
|
||||
postTurn: shell.postTurn,
|
||||
},
|
||||
presetId: shell.presetId,
|
||||
windows,
|
||||
turns: conversation.turns.slice(0, turnIndex),
|
||||
complete: body.complete,
|
||||
onDelta: hooks?.onDelta,
|
||||
onWindow: hooks?.onWindow,
|
||||
});
|
||||
|
||||
// 每完成一个窗口就落盘:多个窗口是并行的,先好的先写入,
|
||||
// 前端也能逐张刷新,不用等最慢的那个。
|
||||
const persistOne = (result: DirectChatMultiWindowResult) => {
|
||||
const latest = getConversation(conversationId);
|
||||
if (!latest) return;
|
||||
const reply: DirectChatAssistantReply = {
|
||||
windowId: result.windowId,
|
||||
content: result.reply ?? "",
|
||||
...(result.reasoning ? { thinking: result.reasoning } : {}),
|
||||
...(result.model ? { model: result.model } : {}),
|
||||
...(result.error ? { error: result.error } : {}),
|
||||
...(result.contextTrace
|
||||
? { contextTrace: result.contextTrace }
|
||||
: {}),
|
||||
};
|
||||
saveConversation({
|
||||
...latest,
|
||||
turns: mergeTurnReplies(latest.turns, turnId, [reply]),
|
||||
});
|
||||
};
|
||||
|
||||
if (wantsNdjson(req)) {
|
||||
const outcome = await writeMultiStream(
|
||||
res,
|
||||
(hooks) =>
|
||||
runRetry({
|
||||
onDelta: hooks.onDelta,
|
||||
onWindow: (result) => {
|
||||
persistOne(result);
|
||||
hooks.onWindow(result);
|
||||
},
|
||||
}),
|
||||
() => ({
|
||||
conversation: getConversation(conversationId) ?? conversation,
|
||||
shell,
|
||||
}),
|
||||
);
|
||||
if (!outcome.ok) return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const multi = await runRetry({
|
||||
onWindow: (result) => persistOne(result),
|
||||
});
|
||||
const latest = getConversation(conversationId) ?? conversation;
|
||||
const updated = saveConversation({
|
||||
...latest,
|
||||
turns: mergeTurnReplies(latest.turns, turnId, multi.turn.replies),
|
||||
});
|
||||
json(res, 200, { conversation: updated, shell, multi });
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const variantMatch = sub.match(/^\/turns\/([^/]+)\/variant$/);
|
||||
if (variantMatch && req.method === "POST") {
|
||||
const turnId = decodeURIComponent(variantMatch[1]!);
|
||||
const body = JSON.parse(await readBody(req)) as { direction?: string };
|
||||
if (body.direction !== "prev" && body.direction !== "next") {
|
||||
json(res, 400, { error: "direction 须为 prev 或 next" });
|
||||
return true;
|
||||
}
|
||||
const conversation = getConversation(conversationId);
|
||||
if (!conversation) {
|
||||
json(res, 404, { error: "会话不存在" });
|
||||
return true;
|
||||
}
|
||||
const turns = switchTurnVariant(
|
||||
conversation.turns,
|
||||
turnId,
|
||||
body.direction,
|
||||
);
|
||||
if (!turns) {
|
||||
json(res, 400, { error: "没有更多版本" });
|
||||
return true;
|
||||
}
|
||||
const updated = saveConversation({ ...conversation, turns });
|
||||
json(res, 200, { conversation: updated });
|
||||
return true;
|
||||
}
|
||||
|
||||
const selectMatch = sub.match(/^\/turns\/([^/]+)\/select$/);
|
||||
if (selectMatch && req.method === "POST") {
|
||||
const turnId = decodeURIComponent(selectMatch[1]!);
|
||||
|
||||
Reference in New Issue
Block a user