增加与作品对话并列的直聊,多模型按列对照并可选定基准。
服务启动时顺带拉取开放路由价目,用户目录同时预留生图工作流。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# 直聊
|
||||
|
||||
与作品工作台「对话」并列的轻量多轮 LLM 面。请求始终由预设夹心包裹;不改动设置里的预设「试跑」。
|
||||
|
||||
## 两层
|
||||
|
||||
| 层 | 含义 | 落盘 |
|
||||
|----|------|------|
|
||||
| L1 上下文壳 | 系统提示、史前设定、种子历史、史后、尾注;可选绑定 `presetId`(缺省用全局选用预设) | `~/.writing-agent/direct-chats/shells/` |
|
||||
| L2 会话 | 该壳下的多轮消息;助手消息可带 `contextTrace` 供对比 | `.../conversations/` |
|
||||
| 临时会话 | 未选存档时的默认快速对话 | 仅浏览器 `localStorage`,最多 **2** 条;可「保存」升为持久 L1+L2 |
|
||||
|
||||
面包屑:`直聊 › {壳名\|临时} › {会话名}`。入口页:`/chat.html`。未选任何存档时中间即是快速对话,无单独「快速对话」按钮。
|
||||
|
||||
## 多模型窗口(竞技场)
|
||||
|
||||
- 标题栏「新增窗口」:中间按模型分栏(LMSYS Arena 式 side-by-side),每列独立对话流。
|
||||
- 同一用户句在各列各显一份;发送时所有列并行 completion。
|
||||
- 列内「保留此回复」:写入该轮 `selectedWindowId`;之后各列发下一轮时,本轮助手侧都以该回复进历史。
|
||||
- 未保留:各列继续用自己的回复拼 prior。
|
||||
|
||||
## 请求
|
||||
|
||||
- 槽位 → WorldInfoPack;`chatHistory` = 种子历史 + 已落定多轮 transcript;本轮用户输入进 turn。
|
||||
- 经 `PresetLlmProvider` 按选用/壳绑定预设夹心后发模型。
|
||||
- 无选用预设:拒绝发送。
|
||||
- 无 API Key:可只返回拼装结果。
|
||||
|
||||
## 多模型
|
||||
|
||||
助手消息保留 `contextTrace` 时,可对多个 API 配置并行重放,再「采用」写入 variants / branchIndex。临时会话需先保存再对比。
|
||||
|
||||
## API(节选)
|
||||
|
||||
- `GET/POST /api/direct-chat/shells`
|
||||
- `GET/PATCH/DELETE /api/direct-chat/shells/:id`
|
||||
- `POST /api/direct-chat/shells/:id/conversations`
|
||||
- `GET/PATCH/DELETE /api/direct-chat/conversations/:id`
|
||||
- `POST /api/direct-chat/conversations/:id/messages`
|
||||
- `POST /api/direct-chat/ephemeral/complete`
|
||||
- `POST /api/direct-chat/promote`
|
||||
- `POST .../messages/:mid/compare`、`.../compare/adopt`
|
||||
@@ -18,8 +18,16 @@ export function getBooksDir(): string {
|
||||
return path.join(getUserDataDir(), "books");
|
||||
}
|
||||
|
||||
export function getDirectChatsDir(): string {
|
||||
return path.join(getUserDataDir(), "direct-chats");
|
||||
}
|
||||
|
||||
export function ensureUserDataDirs(): void {
|
||||
mkdirSync(getPresetsDir(), { recursive: true });
|
||||
mkdirSync(getBooksDir(), { recursive: true });
|
||||
mkdirSync(path.join(getUserDataDir(), "stats"), { recursive: true });
|
||||
mkdirSync(path.join(getUserDataDir(), "image-workflows"), { recursive: true });
|
||||
mkdirSync(getDirectChatsDir(), { recursive: true });
|
||||
mkdirSync(path.join(getDirectChatsDir(), "shells"), { recursive: true });
|
||||
mkdirSync(path.join(getDirectChatsDir(), "conversations"), { recursive: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types.js";
|
||||
export * from "./store.js";
|
||||
export * from "./turn.js";
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* 直聊持久化:{用户数据}/direct-chats/
|
||||
* shells/<shellId>.json
|
||||
* conversations/<conversationId>.json
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDirectChatsDir } from "../config/user-data-dir.js";
|
||||
import {
|
||||
EMPTY_SHELL_SLOTS,
|
||||
countTurnMessages,
|
||||
migrateMessagesToTurns,
|
||||
type DirectChatConversation,
|
||||
type DirectChatConversationSummary,
|
||||
type DirectChatMessage,
|
||||
type DirectChatShell,
|
||||
type DirectChatShellSlots,
|
||||
type DirectChatShellSummary,
|
||||
type DirectChatTurn,
|
||||
type DirectChatWindow,
|
||||
} from "./types.js";
|
||||
|
||||
export { getDirectChatsDir };
|
||||
|
||||
function shellsDir(): string {
|
||||
return path.join(getDirectChatsDir(), "shells");
|
||||
}
|
||||
|
||||
function conversationsDir(): string {
|
||||
return path.join(getDirectChatsDir(), "conversations");
|
||||
}
|
||||
|
||||
export function ensureDirectChatDirs(): void {
|
||||
mkdirSync(shellsDir(), { recursive: true });
|
||||
mkdirSync(conversationsDir(), { recursive: true });
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
if (typeof v !== "string") return undefined;
|
||||
return v;
|
||||
}
|
||||
|
||||
function readJson(file: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, "utf8")) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonAtomic(file: string, data: unknown): void {
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
||||
renameSync(tmp, file);
|
||||
}
|
||||
|
||||
function shellPath(id: string): string {
|
||||
return path.join(shellsDir(), `${id}.json`);
|
||||
}
|
||||
|
||||
function conversationPath(id: string): string {
|
||||
return path.join(conversationsDir(), `${id}.json`);
|
||||
}
|
||||
|
||||
function parseShell(raw: unknown): DirectChatShell | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const id = asString(row.id)?.trim();
|
||||
const name = asString(row.name)?.trim();
|
||||
if (!id || !name) return null;
|
||||
const presetRaw = row.presetId;
|
||||
const presetId =
|
||||
presetRaw === null || presetRaw === undefined
|
||||
? null
|
||||
: typeof presetRaw === "string" && presetRaw.trim()
|
||||
? presetRaw.trim()
|
||||
: null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
systemPrompt: asString(row.systemPrompt) ?? "",
|
||||
loreBefore: asString(row.loreBefore) ?? "",
|
||||
seedHistory: asString(row.seedHistory) ?? "",
|
||||
loreAfter: asString(row.loreAfter) ?? "",
|
||||
postTurn: asString(row.postTurn) ?? "",
|
||||
presetId,
|
||||
createdAt: asString(row.createdAt) ?? nowIso(),
|
||||
updatedAt: asString(row.updatedAt) ?? nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMessage(raw: unknown): DirectChatMessage | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const id = asString(row.id)?.trim();
|
||||
const role = asString(row.role);
|
||||
const content = asString(row.content);
|
||||
if (!id || (role !== "user" && role !== "assistant") || content == null) {
|
||||
return null;
|
||||
}
|
||||
const msg: DirectChatMessage = {
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
createdAt: asString(row.createdAt) ?? nowIso(),
|
||||
};
|
||||
if (typeof row.thinking === "string" && row.thinking) msg.thinking = row.thinking;
|
||||
if (typeof row.model === "string" && row.model) msg.model = row.model;
|
||||
if (row.contextTrace && typeof row.contextTrace === "object") {
|
||||
msg.contextTrace = row.contextTrace as DirectChatMessage["contextTrace"];
|
||||
}
|
||||
if (Array.isArray(row.variants)) {
|
||||
msg.variants = row.variants as DirectChatMessage["variants"];
|
||||
}
|
||||
if (typeof row.branchIndex === "number" && Number.isFinite(row.branchIndex)) {
|
||||
msg.branchIndex = row.branchIndex;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function parseWindow(raw: unknown): DirectChatWindow | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const id = asString(row.id)?.trim();
|
||||
const profileId = asString(row.profileId)?.trim();
|
||||
if (!id || !profileId) return null;
|
||||
return {
|
||||
id,
|
||||
profileId,
|
||||
...(asString(row.profileName) ? { profileName: asString(row.profileName) } : {}),
|
||||
...(asString(row.model) ? { model: asString(row.model) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTurn(raw: unknown): DirectChatTurn | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const id = asString(row.id)?.trim();
|
||||
const userRaw = row.user;
|
||||
if (!id || !userRaw || typeof userRaw !== "object" || Array.isArray(userRaw)) {
|
||||
return null;
|
||||
}
|
||||
const userRow = userRaw as Record<string, unknown>;
|
||||
const userContent = asString(userRow.content);
|
||||
if (userContent == null) return null;
|
||||
const replies: DirectChatTurn["replies"] = [];
|
||||
if (Array.isArray(row.replies)) {
|
||||
for (const r of row.replies) {
|
||||
if (!r || typeof r !== "object" || Array.isArray(r)) continue;
|
||||
const rr = r as Record<string, unknown>;
|
||||
const windowId = asString(rr.windowId)?.trim();
|
||||
const content = asString(rr.content);
|
||||
if (!windowId || content == null) continue;
|
||||
replies.push({
|
||||
windowId,
|
||||
content,
|
||||
...(typeof rr.thinking === "string" && rr.thinking
|
||||
? { thinking: rr.thinking }
|
||||
: {}),
|
||||
...(typeof rr.model === "string" && rr.model ? { model: rr.model } : {}),
|
||||
...(typeof rr.error === "string" && rr.error ? { error: rr.error } : {}),
|
||||
...(rr.contextTrace && typeof rr.contextTrace === "object"
|
||||
? { contextTrace: rr.contextTrace as DirectChatMessage["contextTrace"] }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const selected =
|
||||
row.selectedWindowId === null
|
||||
? null
|
||||
: asString(row.selectedWindowId)?.trim() || undefined;
|
||||
return {
|
||||
id,
|
||||
user: {
|
||||
content: userContent,
|
||||
createdAt: asString(userRow.createdAt) ?? nowIso(),
|
||||
},
|
||||
replies,
|
||||
...(selected !== undefined ? { selectedWindowId: selected } : {}),
|
||||
createdAt: asString(row.createdAt) ?? nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseConversation(raw: unknown): DirectChatConversation | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const id = asString(row.id)?.trim();
|
||||
const shellId = asString(row.shellId)?.trim();
|
||||
const name = asString(row.name)?.trim();
|
||||
if (!id || !shellId || !name) return null;
|
||||
const messages: DirectChatMessage[] = [];
|
||||
if (Array.isArray(row.messages)) {
|
||||
for (const m of row.messages) {
|
||||
const parsed = parseMessage(m);
|
||||
if (parsed) messages.push(parsed);
|
||||
}
|
||||
}
|
||||
const windows: DirectChatWindow[] = [];
|
||||
if (Array.isArray(row.windows)) {
|
||||
for (const w of row.windows) {
|
||||
const parsed = parseWindow(w);
|
||||
if (parsed) windows.push(parsed);
|
||||
}
|
||||
}
|
||||
let turns: DirectChatTurn[] = [];
|
||||
if (Array.isArray(row.turns)) {
|
||||
for (const t of row.turns) {
|
||||
const parsed = parseTurn(t);
|
||||
if (parsed) turns.push(parsed);
|
||||
}
|
||||
}
|
||||
if (!turns.length && messages.length) {
|
||||
const wid = windows[0]?.id ?? "default";
|
||||
turns = migrateMessagesToTurns(messages, wid);
|
||||
if (!windows.length) {
|
||||
windows.push({ id: wid, profileId: "", profileName: "默认" });
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
shellId,
|
||||
name,
|
||||
messages,
|
||||
windows,
|
||||
turns,
|
||||
createdAt: asString(row.createdAt) ?? nowIso(),
|
||||
updatedAt: asString(row.updatedAt) ?? nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
export function listShells(): DirectChatShellSummary[] {
|
||||
ensureDirectChatDirs();
|
||||
const root = shellsDir();
|
||||
if (!existsSync(root)) return [];
|
||||
const out: DirectChatShellSummary[] = [];
|
||||
for (const name of readdirSync(root)) {
|
||||
if (!name.endsWith(".json")) continue;
|
||||
const shell = parseShell(readJson(path.join(root, name)));
|
||||
if (!shell) continue;
|
||||
const conversationCount = listConversations(shell.id).length;
|
||||
out.push({
|
||||
id: shell.id,
|
||||
name: shell.name,
|
||||
presetId: shell.presetId,
|
||||
conversationCount,
|
||||
updatedAt: shell.updatedAt,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getShell(id: string): DirectChatShell | null {
|
||||
const key = id.trim();
|
||||
if (!key) return null;
|
||||
ensureDirectChatDirs();
|
||||
return parseShell(readJson(shellPath(key)));
|
||||
}
|
||||
|
||||
export function createShell(input: {
|
||||
name?: string;
|
||||
slots?: Partial<DirectChatShellSlots>;
|
||||
presetId?: string | null;
|
||||
}): DirectChatShell {
|
||||
ensureDirectChatDirs();
|
||||
const ts = nowIso();
|
||||
const slots = { ...EMPTY_SHELL_SLOTS, ...input.slots };
|
||||
const shell: DirectChatShell = {
|
||||
id: randomUUID(),
|
||||
name: (input.name?.trim() || "未命名上下文") as string,
|
||||
...slots,
|
||||
presetId:
|
||||
input.presetId === undefined
|
||||
? null
|
||||
: input.presetId && input.presetId.trim()
|
||||
? input.presetId.trim()
|
||||
: null,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
writeJsonAtomic(shellPath(shell.id), shell);
|
||||
return shell;
|
||||
}
|
||||
|
||||
export function updateShell(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
DirectChatShell,
|
||||
| "name"
|
||||
| "systemPrompt"
|
||||
| "loreBefore"
|
||||
| "seedHistory"
|
||||
| "loreAfter"
|
||||
| "postTurn"
|
||||
| "presetId"
|
||||
>
|
||||
>,
|
||||
): DirectChatShell {
|
||||
const existing = getShell(id);
|
||||
if (!existing) throw new Error("上下文壳不存在");
|
||||
const next: DirectChatShell = {
|
||||
...existing,
|
||||
name:
|
||||
patch.name !== undefined
|
||||
? patch.name.trim() || existing.name
|
||||
: existing.name,
|
||||
systemPrompt:
|
||||
patch.systemPrompt !== undefined ? patch.systemPrompt : existing.systemPrompt,
|
||||
loreBefore:
|
||||
patch.loreBefore !== undefined ? patch.loreBefore : existing.loreBefore,
|
||||
seedHistory:
|
||||
patch.seedHistory !== undefined ? patch.seedHistory : existing.seedHistory,
|
||||
loreAfter:
|
||||
patch.loreAfter !== undefined ? patch.loreAfter : existing.loreAfter,
|
||||
postTurn: patch.postTurn !== undefined ? patch.postTurn : existing.postTurn,
|
||||
presetId:
|
||||
patch.presetId !== undefined
|
||||
? patch.presetId && patch.presetId.trim()
|
||||
? patch.presetId.trim()
|
||||
: null
|
||||
: existing.presetId,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
writeJsonAtomic(shellPath(next.id), next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function deleteShell(id: string): void {
|
||||
const shell = getShell(id);
|
||||
if (!shell) throw new Error("上下文壳不存在");
|
||||
for (const c of listConversations(shell.id)) {
|
||||
deleteConversation(c.id);
|
||||
}
|
||||
const file = shellPath(shell.id);
|
||||
if (existsSync(file)) unlinkSync(file);
|
||||
}
|
||||
|
||||
export function listConversations(
|
||||
shellId: string,
|
||||
): DirectChatConversationSummary[] {
|
||||
ensureDirectChatDirs();
|
||||
const key = shellId.trim();
|
||||
if (!key) return [];
|
||||
const root = conversationsDir();
|
||||
if (!existsSync(root)) return [];
|
||||
const out: DirectChatConversationSummary[] = [];
|
||||
for (const name of readdirSync(root)) {
|
||||
if (!name.endsWith(".json")) continue;
|
||||
const conv = parseConversation(readJson(path.join(root, name)));
|
||||
if (!conv || conv.shellId !== key) continue;
|
||||
out.push({
|
||||
id: conv.id,
|
||||
shellId: conv.shellId,
|
||||
name: conv.name,
|
||||
messageCount: countTurnMessages(conv.turns),
|
||||
updatedAt: conv.updatedAt,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getConversation(id: string): DirectChatConversation | null {
|
||||
const key = id.trim();
|
||||
if (!key) return null;
|
||||
ensureDirectChatDirs();
|
||||
return parseConversation(readJson(conversationPath(key)));
|
||||
}
|
||||
|
||||
export function createConversation(input: {
|
||||
shellId: string;
|
||||
name?: string;
|
||||
messages?: DirectChatMessage[];
|
||||
windows?: DirectChatWindow[];
|
||||
turns?: DirectChatTurn[];
|
||||
}): DirectChatConversation {
|
||||
const shell = getShell(input.shellId);
|
||||
if (!shell) throw new Error("上下文壳不存在");
|
||||
ensureDirectChatDirs();
|
||||
const ts = nowIso();
|
||||
const windows = Array.isArray(input.windows) ? input.windows : [];
|
||||
let turns = Array.isArray(input.turns) ? input.turns : [];
|
||||
const messages = Array.isArray(input.messages) ? input.messages : [];
|
||||
if (!turns.length && messages.length) {
|
||||
turns = migrateMessagesToTurns(messages, windows[0]?.id ?? "default");
|
||||
}
|
||||
const conv: DirectChatConversation = {
|
||||
id: randomUUID(),
|
||||
shellId: shell.id,
|
||||
name: (input.name?.trim() || "未命名会话") as string,
|
||||
messages,
|
||||
windows,
|
||||
turns,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
writeJsonAtomic(conversationPath(conv.id), conv);
|
||||
updateShell(shell.id, {});
|
||||
return conv;
|
||||
}
|
||||
|
||||
export function updateConversation(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<DirectChatConversation, "name" | "messages" | "windows" | "turns">
|
||||
>,
|
||||
): DirectChatConversation {
|
||||
const existing = getConversation(id);
|
||||
if (!existing) throw new Error("会话不存在");
|
||||
const next: DirectChatConversation = {
|
||||
...existing,
|
||||
name:
|
||||
patch.name !== undefined
|
||||
? patch.name.trim() || existing.name
|
||||
: existing.name,
|
||||
messages:
|
||||
patch.messages !== undefined ? patch.messages : existing.messages,
|
||||
windows: patch.windows !== undefined ? patch.windows : existing.windows,
|
||||
turns: patch.turns !== undefined ? patch.turns : existing.turns,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
writeJsonAtomic(conversationPath(next.id), next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function deleteConversation(id: string): void {
|
||||
const conv = getConversation(id);
|
||||
if (!conv) throw new Error("会话不存在");
|
||||
const file = conversationPath(conv.id);
|
||||
if (existsSync(file)) unlinkSync(file);
|
||||
}
|
||||
|
||||
export function saveConversation(conv: DirectChatConversation): DirectChatConversation {
|
||||
const next = { ...conv, updatedAt: nowIso() };
|
||||
ensureDirectChatDirs();
|
||||
writeJsonAtomic(conversationPath(next.id), next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 将临时会话升为持久:可选新建壳,或挂到已有壳下 */
|
||||
export function promoteTemporary(input: {
|
||||
shell?: {
|
||||
name?: string;
|
||||
slots?: Partial<DirectChatShellSlots>;
|
||||
presetId?: string | null;
|
||||
};
|
||||
shellId?: string;
|
||||
conversationName?: string;
|
||||
messages?: DirectChatMessage[];
|
||||
windows?: DirectChatWindow[];
|
||||
turns?: DirectChatTurn[];
|
||||
}): { shell: DirectChatShell; conversation: DirectChatConversation } {
|
||||
let shell: DirectChatShell;
|
||||
if (input.shellId) {
|
||||
const existing = getShell(input.shellId);
|
||||
if (!existing) throw new Error("上下文壳不存在");
|
||||
shell = existing;
|
||||
} else {
|
||||
shell = createShell({
|
||||
name: input.shell?.name,
|
||||
slots: input.shell?.slots,
|
||||
presetId: input.shell?.presetId,
|
||||
});
|
||||
}
|
||||
const conversation = createConversation({
|
||||
shellId: shell.id,
|
||||
name: input.conversationName,
|
||||
messages: input.messages,
|
||||
windows: input.windows,
|
||||
turns: input.turns,
|
||||
});
|
||||
return { shell, conversation };
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getApiProfile, profileToLlmConfig } from "../config/api-profiles.js";
|
||||
import {
|
||||
ensureActiveProfileDefault,
|
||||
loadAppSettings,
|
||||
resolveActiveProfile,
|
||||
} from "../config/settings.js";
|
||||
import {
|
||||
OpenAiCompatibleProvider,
|
||||
type ChatMessage,
|
||||
type LlmProvider,
|
||||
} from "../llm/client.js";
|
||||
import { wrapLlmForSession } from "../llm/preset-wrapper.js";
|
||||
import type { LlmTrackingContext } from "../llm/token-tracker.js";
|
||||
import { assemblePlayWorkerMessages } from "../preset/play-frame.js";
|
||||
import { getPreset, resolveActivePreset } from "../preset/store.js";
|
||||
import type { WorldInfoPack } from "../preset/world-info-pack.js";
|
||||
import { hasRealLlmConfig } from "../runtime/llm-factory.js";
|
||||
import {
|
||||
pruneContextTraces,
|
||||
type LlmContextTrace,
|
||||
} from "../types/context-trace.js";
|
||||
import type { PresetPackage } from "../types/preset.js";
|
||||
import {
|
||||
DEFAULT_DIRECT_CHAT_SYSTEM,
|
||||
buildPriorForWindow,
|
||||
type DirectChatAssistantReply,
|
||||
type DirectChatMessage,
|
||||
type DirectChatShellSlots,
|
||||
type DirectChatTurn,
|
||||
type DirectChatWindow,
|
||||
} from "./types.js";
|
||||
|
||||
export type DirectChatTurnInput = {
|
||||
message: string;
|
||||
slots: DirectChatShellSlots;
|
||||
/** 本会话已落定的多轮(不含本轮) */
|
||||
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
presetId?: string | null;
|
||||
/** 覆盖当前 API profile */
|
||||
profileId?: string | null;
|
||||
complete?: boolean;
|
||||
};
|
||||
|
||||
export type DirectChatTurnResult = {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
reply: string | null;
|
||||
reasoning: string | null;
|
||||
model?: string;
|
||||
completed: boolean;
|
||||
contextTrace?: LlmContextTrace;
|
||||
skipReason?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function optionalEntry(
|
||||
id: string,
|
||||
name: string,
|
||||
content: string,
|
||||
): WorldInfoPack["worldBookBefore"] {
|
||||
const text = content.trim();
|
||||
return text ? [{ id, name, content: text }] : [];
|
||||
}
|
||||
|
||||
function formatTranscript(
|
||||
prior: Array<{ role: "user" | "assistant"; content: string }>,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
for (const m of prior) {
|
||||
const text = m.content.trim();
|
||||
if (!text) continue;
|
||||
if (m.role === "user") parts.push(`【用户】\n${text}`);
|
||||
else parts.push(`【助手】\n${text}`);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
export function buildChatHistory(
|
||||
seedHistory: string,
|
||||
priorMessages: Array<{ role: "user" | "assistant"; content: string }>,
|
||||
): string {
|
||||
const seed = seedHistory.trim();
|
||||
const transcript = formatTranscript(priorMessages);
|
||||
if (seed && transcript) return `${seed}\n\n${transcript}`;
|
||||
return seed || transcript;
|
||||
}
|
||||
|
||||
export function worldInfoPackFromDirectChat(input: {
|
||||
message: string;
|
||||
slots: DirectChatShellSlots;
|
||||
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
}): WorldInfoPack {
|
||||
const history = buildChatHistory(
|
||||
input.slots.seedHistory,
|
||||
input.priorMessages,
|
||||
);
|
||||
return {
|
||||
worldBookBefore: optionalEntry(
|
||||
"direct-before",
|
||||
"直聊设定",
|
||||
input.slots.loreBefore,
|
||||
),
|
||||
history,
|
||||
worldBookAfter: optionalEntry(
|
||||
"direct-after",
|
||||
"本轮状态",
|
||||
input.slots.loreAfter,
|
||||
),
|
||||
turn: input.message.trim(),
|
||||
postTurn: optionalEntry("direct-post", "尾注", input.slots.postTurn),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDirectChatPreset(
|
||||
presetId?: string | null,
|
||||
): PresetPackage {
|
||||
if (presetId?.trim()) {
|
||||
const preset = getPreset(presetId.trim());
|
||||
if (!preset) throw new Error(`预设不存在:${presetId}`);
|
||||
return preset;
|
||||
}
|
||||
const active = resolveActivePreset(loadAppSettings().activePresetId);
|
||||
if (!active) {
|
||||
throw new Error("请先在设置中选用一条预设;直聊请求必须由预设包裹");
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
export function systemPromptForSlots(slots: DirectChatShellSlots): string {
|
||||
return slots.systemPrompt.trim() || DEFAULT_DIRECT_CHAT_SYSTEM;
|
||||
}
|
||||
|
||||
/** UI / 对照用的理想夹心 */
|
||||
export function assembleDirectChatMessages(
|
||||
preset: PresetPackage,
|
||||
input: {
|
||||
message: string;
|
||||
slots: DirectChatShellSlots;
|
||||
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
},
|
||||
): ChatMessage[] {
|
||||
return assemblePlayWorkerMessages({
|
||||
systemPrompt: systemPromptForSlots(input.slots),
|
||||
preset,
|
||||
pack: worldInfoPackFromDirectChat(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** 交给 PresetLlmProvider 的任务消息(未夹心) */
|
||||
export function buildDirectChatTaskMessages(input: {
|
||||
message: string;
|
||||
slots: DirectChatShellSlots;
|
||||
priorMessages: Array<{ role: "user" | "assistant"; content: string }>;
|
||||
}): ChatMessage[] {
|
||||
const pack = worldInfoPackFromDirectChat(input);
|
||||
const parts: string[] = [];
|
||||
for (const e of pack.worldBookBefore) {
|
||||
parts.push(`### ${e.name}\n\n${e.content}`);
|
||||
}
|
||||
if (pack.history.trim()) parts.push(pack.history.trim());
|
||||
for (const e of pack.worldBookAfter) {
|
||||
parts.push(`### ${e.name}\n\n${e.content}`);
|
||||
}
|
||||
for (const e of pack.postTurn) {
|
||||
parts.push(`### ${e.name}\n\n${e.content}`);
|
||||
}
|
||||
const ctx = parts.join("\n\n").trim();
|
||||
const user = ctx ? `${ctx}\n\n## 本轮输入\n${pack.turn}` : pack.turn;
|
||||
return [
|
||||
{ role: "system", content: systemPromptForSlots(input.slots) },
|
||||
{ role: "user", content: user },
|
||||
];
|
||||
}
|
||||
|
||||
function serializeMessages(
|
||||
messages: ChatMessage[],
|
||||
): Array<{ role: string; content: string }> {
|
||||
return messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: typeof m.content === "string" ? m.content : String(m.content ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
function createTrackedLlm(
|
||||
preset: PresetPackage,
|
||||
profileId: string | null | undefined,
|
||||
trackingRef: { current: LlmTrackingContext },
|
||||
): LlmProvider {
|
||||
if (profileId?.trim()) {
|
||||
const profile = getApiProfile(profileId.trim());
|
||||
if (!profile?.apiKey?.trim()) {
|
||||
throw new Error(`API 配置不可用:${profileId}`);
|
||||
}
|
||||
const inner = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
|
||||
return wrapLlmForSession(inner, trackingRef, () => preset);
|
||||
}
|
||||
ensureActiveProfileDefault();
|
||||
const profile = resolveActiveProfile();
|
||||
if (!profile?.apiKey?.trim()) {
|
||||
throw new Error("未配置 API Key");
|
||||
}
|
||||
const inner = new OpenAiCompatibleProvider(profileToLlmConfig(profile));
|
||||
return wrapLlmForSession(inner, trackingRef, () => preset);
|
||||
}
|
||||
|
||||
export async function runDirectChatTurn(
|
||||
input: DirectChatTurnInput,
|
||||
): Promise<DirectChatTurnResult> {
|
||||
const message = input.message.trim();
|
||||
if (!message) throw new Error("缺少本轮输入");
|
||||
|
||||
const preset = resolveDirectChatPreset(input.presetId);
|
||||
const turnInput = {
|
||||
message,
|
||||
slots: input.slots,
|
||||
priorMessages: input.priorMessages,
|
||||
};
|
||||
const assembled = serializeMessages(
|
||||
assembleDirectChatMessages(preset, turnInput),
|
||||
);
|
||||
|
||||
const wantComplete = input.complete !== false;
|
||||
if (!wantComplete) {
|
||||
return {
|
||||
messages: assembled,
|
||||
reply: null,
|
||||
reasoning: null,
|
||||
completed: false,
|
||||
skipReason: "未请求生成",
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasRealLlmConfig() && !input.profileId?.trim()) {
|
||||
return {
|
||||
messages: assembled,
|
||||
reply: null,
|
||||
reasoning: null,
|
||||
completed: false,
|
||||
skipReason: "未配置 API Key,只返回拼装后的上下文",
|
||||
};
|
||||
}
|
||||
|
||||
const trackingRef = { current: {} as LlmTrackingContext };
|
||||
try {
|
||||
const llm = createTrackedLlm(preset, input.profileId, trackingRef);
|
||||
const result = await llm.complete(buildDirectChatTaskMessages(turnInput), {
|
||||
caller: "direct-chat",
|
||||
});
|
||||
const contextTrace = trackingRef.current.pendingContextTrace;
|
||||
return {
|
||||
messages: assembled,
|
||||
reply: result.content,
|
||||
reasoning: result.reasoning ?? null,
|
||||
model: result.model,
|
||||
completed: true,
|
||||
...(contextTrace ? { contextTrace } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
messages: assembled,
|
||||
reply: null,
|
||||
reasoning: null,
|
||||
completed: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function appendTurnMessages(params: {
|
||||
prior: DirectChatMessage[];
|
||||
userText: string;
|
||||
reply: string;
|
||||
reasoning?: string | null;
|
||||
model?: string;
|
||||
contextTrace?: LlmContextTrace;
|
||||
keepTraceLatest?: number;
|
||||
}): DirectChatMessage[] {
|
||||
const ts = new Date().toISOString();
|
||||
const userMsg: DirectChatMessage = {
|
||||
id: randomUUID(),
|
||||
role: "user",
|
||||
content: params.userText,
|
||||
createdAt: ts,
|
||||
};
|
||||
const assistantMsg: DirectChatMessage = {
|
||||
id: randomUUID(),
|
||||
role: "assistant",
|
||||
content: params.reply,
|
||||
createdAt: ts,
|
||||
...(params.reasoning ? { thinking: params.reasoning } : {}),
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(params.contextTrace ? { contextTrace: params.contextTrace } : {}),
|
||||
};
|
||||
const keep =
|
||||
params.keepTraceLatest ?? loadAppSettings().contextTraceKeepLatest ?? 3;
|
||||
return pruneContextTraces([...params.prior, userMsg, assistantMsg], keep);
|
||||
}
|
||||
|
||||
export type DirectChatMultiTurnInput = {
|
||||
message: string;
|
||||
slots: DirectChatShellSlots;
|
||||
presetId?: string | null;
|
||||
windows: DirectChatWindow[];
|
||||
/** 已落定轮次(不含本轮) */
|
||||
turns: DirectChatTurn[];
|
||||
complete?: boolean;
|
||||
};
|
||||
|
||||
export type DirectChatMultiWindowResult = {
|
||||
windowId: string;
|
||||
profileId: string;
|
||||
reply: string | null;
|
||||
reasoning: string | null;
|
||||
model?: string;
|
||||
completed: boolean;
|
||||
contextTrace?: LlmContextTrace;
|
||||
skipReason?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type DirectChatMultiTurnResult = {
|
||||
results: DirectChatMultiWindowResult[];
|
||||
turn: DirectChatTurn;
|
||||
};
|
||||
|
||||
/** 同一用户输入,对各窗口并行 completion;各自 prior 按选定规则拼装 */
|
||||
export async function runDirectChatMultiTurn(
|
||||
input: DirectChatMultiTurnInput,
|
||||
): Promise<DirectChatMultiTurnResult> {
|
||||
const message = input.message.trim();
|
||||
if (!message) throw new Error("缺少本轮输入");
|
||||
if (!input.windows.length) throw new Error("至少需要一个模型窗口");
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const results = await Promise.all(
|
||||
input.windows.map(async (win) => {
|
||||
const prior = buildPriorForWindow(input.turns, win.id);
|
||||
const single = await runDirectChatTurn({
|
||||
message,
|
||||
slots: input.slots,
|
||||
priorMessages: prior,
|
||||
presetId: input.presetId,
|
||||
profileId: win.profileId,
|
||||
complete: input.complete,
|
||||
});
|
||||
return {
|
||||
windowId: win.id,
|
||||
profileId: win.profileId,
|
||||
reply: single.reply,
|
||||
reasoning: single.reasoning,
|
||||
model: single.model ?? win.model,
|
||||
completed: single.completed,
|
||||
...(single.contextTrace ? { contextTrace: single.contextTrace } : {}),
|
||||
...(single.skipReason ? { skipReason: single.skipReason } : {}),
|
||||
...(single.error ? { error: single.error } : {}),
|
||||
} satisfies DirectChatMultiWindowResult;
|
||||
}),
|
||||
);
|
||||
|
||||
const replies: DirectChatAssistantReply[] = results.map((r) => ({
|
||||
windowId: r.windowId,
|
||||
content: r.reply ?? "",
|
||||
...(r.reasoning ? { thinking: r.reasoning } : {}),
|
||||
...(r.model ? { model: r.model } : {}),
|
||||
...(r.error ? { error: r.error } : {}),
|
||||
...(r.contextTrace ? { contextTrace: r.contextTrace } : {}),
|
||||
}));
|
||||
|
||||
const turn: DirectChatTurn = {
|
||||
id: randomUUID(),
|
||||
user: { content: message, createdAt: ts },
|
||||
replies,
|
||||
createdAt: ts,
|
||||
};
|
||||
|
||||
return { results, turn };
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { LlmContextTrace } from "../types/context-trace.js";
|
||||
|
||||
/** L1:上下文壳(系统提示 / 设定 / 种子历史等) */
|
||||
export type DirectChatShellSlots = {
|
||||
/** 任务契约 / 系统提示(进 worldBookBefore 任务袋) */
|
||||
systemPrompt: string;
|
||||
/** 史前设定 */
|
||||
loreBefore: string;
|
||||
/** 种子历史(拼在多轮之前) */
|
||||
seedHistory: string;
|
||||
/** 史后状态 */
|
||||
loreAfter: string;
|
||||
/** 尾注 */
|
||||
postTurn: string;
|
||||
};
|
||||
|
||||
export type DirectChatShell = DirectChatShellSlots & {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 缺省用全局 activePresetId */
|
||||
presetId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/** 并列助手窗口(一个 API 配置一列) */
|
||||
export type DirectChatWindow = {
|
||||
id: string;
|
||||
profileId: string;
|
||||
profileName?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type DirectChatAssistantReply = {
|
||||
windowId: string;
|
||||
content: string;
|
||||
thinking?: string;
|
||||
model?: string;
|
||||
error?: string;
|
||||
contextTrace?: LlmContextTrace;
|
||||
};
|
||||
|
||||
/** 一轮:共用用户消息 + 各窗口助手回复 */
|
||||
export type DirectChatTurn = {
|
||||
id: string;
|
||||
user: { content: string; createdAt: string };
|
||||
replies: DirectChatAssistantReply[];
|
||||
/**
|
||||
* 若选定某窗口:之后所有窗口发下一轮时,本轮助手侧都以该回复进历史。
|
||||
* 未选定:各窗口继续用自己的回复。
|
||||
*/
|
||||
selectedWindowId?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** 旧版扁平消息(读盘兼容;新写入用 turns) */
|
||||
export type DirectChatMessageRole = "user" | "assistant";
|
||||
|
||||
export type DirectChatMessage = {
|
||||
id: string;
|
||||
role: DirectChatMessageRole;
|
||||
content: string;
|
||||
thinking?: string;
|
||||
createdAt: string;
|
||||
model?: string;
|
||||
contextTrace?: LlmContextTrace;
|
||||
variants?: Array<{
|
||||
content: string;
|
||||
thinking?: string;
|
||||
model?: string;
|
||||
profileId?: string;
|
||||
profileName?: string;
|
||||
}>;
|
||||
branchIndex?: number;
|
||||
};
|
||||
|
||||
/** L2:壳下的具体会话 */
|
||||
export type DirectChatConversation = {
|
||||
id: string;
|
||||
shellId: string;
|
||||
name: string;
|
||||
/** @deprecated 兼容旧数据;优先 turns */
|
||||
messages?: DirectChatMessage[];
|
||||
windows: DirectChatWindow[];
|
||||
turns: DirectChatTurn[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DirectChatShellSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
presetId: string | null;
|
||||
conversationCount: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DirectChatConversationSummary = {
|
||||
id: string;
|
||||
shellId: string;
|
||||
name: string;
|
||||
messageCount: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const EMPTY_SHELL_SLOTS: DirectChatShellSlots = {
|
||||
systemPrompt: "",
|
||||
loreBefore: "",
|
||||
seedHistory: "",
|
||||
loreAfter: "",
|
||||
postTurn: "",
|
||||
};
|
||||
|
||||
export const DEFAULT_DIRECT_CHAT_SYSTEM =
|
||||
"直聊:按上下文与历史继续对话。回答简洁、直接;用户未要求时不另起长篇设定。";
|
||||
|
||||
/** 把旧扁平 messages 收成单窗口 turns */
|
||||
export function migrateMessagesToTurns(
|
||||
messages: DirectChatMessage[],
|
||||
windowId = "default",
|
||||
): DirectChatTurn[] {
|
||||
const turns: DirectChatTurn[] = [];
|
||||
let pendingUser: { content: string; createdAt: string } | null = null;
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
pendingUser = { content: m.content, createdAt: m.createdAt };
|
||||
continue;
|
||||
}
|
||||
if (m.role === "assistant" && pendingUser) {
|
||||
turns.push({
|
||||
id: m.id || `turn_${turns.length}`,
|
||||
user: pendingUser,
|
||||
replies: [
|
||||
{
|
||||
windowId,
|
||||
content: m.content,
|
||||
thinking: m.thinking,
|
||||
model: m.model,
|
||||
contextTrace: m.contextTrace,
|
||||
},
|
||||
],
|
||||
createdAt: m.createdAt,
|
||||
});
|
||||
pendingUser = null;
|
||||
}
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为某个窗口拼 prior:用户消息共用;
|
||||
* 若该轮 selectedWindowId 有值则全员用选定回复,否则用本窗口自己的回复。
|
||||
*/
|
||||
export function buildPriorForWindow(
|
||||
turns: DirectChatTurn[],
|
||||
windowId: string,
|
||||
): Array<{ role: "user" | "assistant"; content: string }> {
|
||||
const out: Array<{ role: "user" | "assistant"; content: string }> = [];
|
||||
for (const turn of turns) {
|
||||
const userText = turn.user?.content?.trim();
|
||||
if (userText) out.push({ role: "user", content: userText });
|
||||
const selected = turn.selectedWindowId?.trim();
|
||||
const reply = selected
|
||||
? turn.replies.find((r) => r.windowId === selected)
|
||||
: turn.replies.find((r) => r.windowId === windowId);
|
||||
const text = reply?.content?.trim();
|
||||
if (text) out.push({ role: "assistant", content: text });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function countTurnMessages(turns: DirectChatTurn[]): number {
|
||||
let n = 0;
|
||||
for (const t of turns) {
|
||||
if (t.user?.content) n += 1;
|
||||
n += t.replies.filter((r) => r.content || r.error).length;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -218,6 +218,7 @@ export function labelCaller(caller: string | undefined): string {
|
||||
if (who === "dictate_agent") return "对话落盘";
|
||||
if (who.startsWith("listen_serial_")) return "听书连载";
|
||||
if (who === "preset-probe") return "预设探测";
|
||||
if (who === "direct-chat") return "直聊";
|
||||
if (who.startsWith("model-compare:")) {
|
||||
return `模型对比 ${who.slice("model-compare:".length)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
createConversation,
|
||||
createShell,
|
||||
deleteConversation,
|
||||
deleteShell,
|
||||
EMPTY_SHELL_SLOTS,
|
||||
getConversation,
|
||||
getShell,
|
||||
listConversations,
|
||||
listShells,
|
||||
promoteTemporary,
|
||||
runDirectChatMultiTurn,
|
||||
saveConversation,
|
||||
updateConversation,
|
||||
updateShell,
|
||||
type DirectChatMessage,
|
||||
type DirectChatShellSlots,
|
||||
type DirectChatTurn,
|
||||
type DirectChatWindow,
|
||||
} from "../direct-chat/index.js";
|
||||
import { listProfilesForCompare } from "./model-compare.js";
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, status: number, data: unknown): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function slotsFromBody(body: Record<string, unknown>): DirectChatShellSlots {
|
||||
return {
|
||||
systemPrompt:
|
||||
typeof body.systemPrompt === "string"
|
||||
? body.systemPrompt
|
||||
: EMPTY_SHELL_SLOTS.systemPrompt,
|
||||
loreBefore:
|
||||
typeof body.loreBefore === "string"
|
||||
? body.loreBefore
|
||||
: EMPTY_SHELL_SLOTS.loreBefore,
|
||||
seedHistory:
|
||||
typeof body.seedHistory === "string"
|
||||
? body.seedHistory
|
||||
: EMPTY_SHELL_SLOTS.seedHistory,
|
||||
loreAfter:
|
||||
typeof body.loreAfter === "string"
|
||||
? body.loreAfter
|
||||
: EMPTY_SHELL_SLOTS.loreAfter,
|
||||
postTurn:
|
||||
typeof body.postTurn === "string"
|
||||
? body.postTurn
|
||||
: EMPTY_SHELL_SLOTS.postTurn,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleDirectChatApi(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
pathname: string,
|
||||
): Promise<boolean> {
|
||||
if (!pathname.startsWith("/api/direct-chat")) return false;
|
||||
|
||||
try {
|
||||
if (req.method === "GET" && pathname === "/api/direct-chat/shells") {
|
||||
json(res, 200, { shells: listShells() });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/direct-chat/shells") {
|
||||
const body = JSON.parse(await readBody(req)) as Record<string, unknown>;
|
||||
const shell = createShell({
|
||||
name: typeof body.name === "string" ? body.name : undefined,
|
||||
slots: slotsFromBody(body),
|
||||
presetId:
|
||||
body.presetId === null
|
||||
? null
|
||||
: typeof body.presetId === "string"
|
||||
? body.presetId
|
||||
: undefined,
|
||||
});
|
||||
json(res, 201, { shell });
|
||||
return true;
|
||||
}
|
||||
|
||||
const shellMatch = pathname.match(
|
||||
/^\/api\/direct-chat\/shells\/([^/]+)(\/.*)?$/,
|
||||
);
|
||||
if (shellMatch) {
|
||||
const shellId = decodeURIComponent(shellMatch[1]!);
|
||||
const sub = shellMatch[2] ?? "";
|
||||
|
||||
if (req.method === "GET" && sub === "") {
|
||||
const shell = getShell(shellId);
|
||||
if (!shell) {
|
||||
json(res, 404, { error: "上下文壳不存在" });
|
||||
return true;
|
||||
}
|
||||
json(res, 200, {
|
||||
shell,
|
||||
conversations: listConversations(shellId),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(req.method === "PATCH" || req.method === "PUT") &&
|
||||
sub === ""
|
||||
) {
|
||||
const body = JSON.parse(await readBody(req)) as Record<string, unknown>;
|
||||
try {
|
||||
const shell = updateShell(shellId, {
|
||||
name: typeof body.name === "string" ? body.name : undefined,
|
||||
systemPrompt:
|
||||
typeof body.systemPrompt === "string"
|
||||
? body.systemPrompt
|
||||
: undefined,
|
||||
loreBefore:
|
||||
typeof body.loreBefore === "string" ? body.loreBefore : undefined,
|
||||
seedHistory:
|
||||
typeof body.seedHistory === "string"
|
||||
? body.seedHistory
|
||||
: undefined,
|
||||
loreAfter:
|
||||
typeof body.loreAfter === "string" ? body.loreAfter : undefined,
|
||||
postTurn:
|
||||
typeof body.postTurn === "string" ? body.postTurn : undefined,
|
||||
presetId:
|
||||
body.presetId === null
|
||||
? null
|
||||
: typeof body.presetId === "string"
|
||||
? body.presetId
|
||||
: undefined,
|
||||
});
|
||||
json(res, 200, { shell });
|
||||
} catch (err) {
|
||||
json(res, 404, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && sub === "") {
|
||||
try {
|
||||
deleteShell(shellId);
|
||||
json(res, 200, { ok: true });
|
||||
} catch (err) {
|
||||
json(res, 404, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && sub === "/conversations") {
|
||||
if (!getShell(shellId)) {
|
||||
json(res, 404, { error: "上下文壳不存在" });
|
||||
return true;
|
||||
}
|
||||
json(res, 200, { conversations: listConversations(shellId) });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && sub === "/conversations") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
name?: string;
|
||||
messages?: DirectChatMessage[];
|
||||
};
|
||||
try {
|
||||
const conversation = createConversation({
|
||||
shellId,
|
||||
name: body.name,
|
||||
messages: body.messages,
|
||||
});
|
||||
json(res, 201, { conversation });
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const convMatch = pathname.match(
|
||||
/^\/api\/direct-chat\/conversations\/([^/]+)(\/.*)?$/,
|
||||
);
|
||||
if (convMatch) {
|
||||
const conversationId = decodeURIComponent(convMatch[1]!);
|
||||
const sub = convMatch[2] ?? "";
|
||||
|
||||
if (req.method === "GET" && sub === "") {
|
||||
const conversation = getConversation(conversationId);
|
||||
if (!conversation) {
|
||||
json(res, 404, { error: "会话不存在" });
|
||||
return true;
|
||||
}
|
||||
const shell = getShell(conversation.shellId);
|
||||
json(res, 200, { conversation, shell });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(req.method === "PATCH" || req.method === "PUT") &&
|
||||
sub === ""
|
||||
) {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
name?: string;
|
||||
messages?: DirectChatMessage[];
|
||||
};
|
||||
try {
|
||||
const conversation = updateConversation(conversationId, {
|
||||
name: body.name,
|
||||
messages: body.messages,
|
||||
});
|
||||
json(res, 200, { conversation });
|
||||
} catch (err) {
|
||||
json(res, 404, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && sub === "") {
|
||||
try {
|
||||
deleteConversation(conversationId);
|
||||
json(res, 200, { ok: true });
|
||||
} 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;
|
||||
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;
|
||||
}
|
||||
try {
|
||||
const multi = await 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,
|
||||
complete: body.complete,
|
||||
});
|
||||
const updated = saveConversation({
|
||||
...conversation,
|
||||
windows,
|
||||
turns: [...conversation.turns, multi.turn],
|
||||
});
|
||||
json(res, 200, {
|
||||
conversation: updated,
|
||||
shell,
|
||||
multi,
|
||||
});
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const selectMatch = sub.match(/^\/turns\/([^/]+)\/select$/);
|
||||
if (selectMatch && req.method === "POST") {
|
||||
const turnId = decodeURIComponent(selectMatch[1]!);
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
windowId?: string | null;
|
||||
};
|
||||
const conversation = getConversation(conversationId);
|
||||
if (!conversation) {
|
||||
json(res, 404, { error: "会话不存在" });
|
||||
return true;
|
||||
}
|
||||
const windowId =
|
||||
body.windowId === null || body.windowId === undefined
|
||||
? null
|
||||
: String(body.windowId).trim() || null;
|
||||
if (windowId) {
|
||||
const turn = conversation.turns.find((t) => t.id === turnId);
|
||||
if (!turn?.replies.some((r) => r.windowId === windowId)) {
|
||||
json(res, 400, { error: "该窗口没有本轮回复" });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const turns = conversation.turns.map((t) =>
|
||||
t.id === turnId ? { ...t, selectedWindowId: windowId } : t,
|
||||
);
|
||||
const updated = saveConversation({ ...conversation, turns });
|
||||
json(res, 200, { conversation: updated });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === "POST" &&
|
||||
pathname === "/api/direct-chat/ephemeral/complete"
|
||||
) {
|
||||
const body = JSON.parse(await readBody(req)) as Record<string, unknown> & {
|
||||
text?: string;
|
||||
complete?: boolean;
|
||||
presetId?: string | null;
|
||||
windows?: DirectChatWindow[];
|
||||
turns?: DirectChatTurn[];
|
||||
};
|
||||
const text = typeof body.text === "string" ? body.text.trim() : "";
|
||||
if (!text) {
|
||||
json(res, 400, { error: "text 不能为空" });
|
||||
return true;
|
||||
}
|
||||
const windows = Array.isArray(body.windows) ? body.windows : [];
|
||||
const turns = Array.isArray(body.turns) ? body.turns : [];
|
||||
if (!windows.length) {
|
||||
json(res, 400, { error: "请先新增至少一个模型窗口" });
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const multi = await runDirectChatMultiTurn({
|
||||
message: text,
|
||||
slots: slotsFromBody(body),
|
||||
presetId:
|
||||
body.presetId === null
|
||||
? null
|
||||
: typeof body.presetId === "string"
|
||||
? body.presetId
|
||||
: undefined,
|
||||
windows,
|
||||
turns,
|
||||
complete: body.complete,
|
||||
});
|
||||
json(res, 200, { multi });
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/direct-chat/promote") {
|
||||
const body = JSON.parse(await readBody(req)) as {
|
||||
shellId?: string;
|
||||
shell?: {
|
||||
name?: string;
|
||||
systemPrompt?: string;
|
||||
loreBefore?: string;
|
||||
seedHistory?: string;
|
||||
loreAfter?: string;
|
||||
postTurn?: string;
|
||||
presetId?: string | null;
|
||||
};
|
||||
conversationName?: string;
|
||||
messages?: DirectChatMessage[];
|
||||
windows?: DirectChatWindow[];
|
||||
turns?: DirectChatTurn[];
|
||||
};
|
||||
try {
|
||||
const promoted = promoteTemporary({
|
||||
shellId: body.shellId,
|
||||
shell: body.shell
|
||||
? {
|
||||
name: body.shell.name,
|
||||
slots: {
|
||||
systemPrompt: body.shell.systemPrompt ?? "",
|
||||
loreBefore: body.shell.loreBefore ?? "",
|
||||
seedHistory: body.shell.seedHistory ?? "",
|
||||
loreAfter: body.shell.loreAfter ?? "",
|
||||
postTurn: body.shell.postTurn ?? "",
|
||||
},
|
||||
presetId: body.shell.presetId,
|
||||
}
|
||||
: undefined,
|
||||
conversationName: body.conversationName,
|
||||
messages: Array.isArray(body.messages) ? body.messages : [],
|
||||
windows: Array.isArray(body.windows) ? body.windows : [],
|
||||
turns: Array.isArray(body.turns) ? body.turns : [],
|
||||
});
|
||||
json(res, 201, promoted);
|
||||
} catch (err) {
|
||||
json(res, 400, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === "GET" &&
|
||||
pathname === "/api/direct-chat/compare-profiles"
|
||||
) {
|
||||
json(res, 200, { profiles: listProfilesForCompare() });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (err) {
|
||||
json(res, 500, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { sessionManager } from "./session-manager.js";
|
||||
import { handleSettingsApi } from "./settings-handlers.js";
|
||||
import { handleBooksApi } from "./book-handlers.js";
|
||||
import { handleStatsApi } from "./stats-handlers.js";
|
||||
import { handleDirectChatApi } from "./direct-chat-handlers.js";
|
||||
import { syncOpenRouterPrices, type PriceWatchItem } from "../stats/pricing.js";
|
||||
import { ensureConsoleUtf8, getRuntimeLogPath } from "../log.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -50,7 +52,7 @@ async function serveStatic(res: ServerResponse, filePath: string): Promise<void>
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
@@ -77,8 +79,13 @@ const server = createServer(async (req, res) => {
|
||||
"GET /api/skills",
|
||||
"GET /api/directors",
|
||||
"GET /api/modules",
|
||||
"GET /api/direct-chat/shells",
|
||||
"POST /api/direct-chat/ephemeral/complete",
|
||||
"GET /api/stats/tokens",
|
||||
"GET /api/stats/pricing",
|
||||
"GET /api/stats/pricing/openrouter",
|
||||
"POST /api/stats/pricing/openrouter",
|
||||
"GET /api/stats/pricing/notice",
|
||||
"POST /api/stats/pricing/sources",
|
||||
],
|
||||
});
|
||||
@@ -93,6 +100,10 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleDirectChatApi(req, res, url.pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleStatsApi(req, res, url.pathname, url.searchParams)) {
|
||||
return;
|
||||
}
|
||||
@@ -796,7 +807,42 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
function moneyBit(amount: number, currency: string): string {
|
||||
const symbol = currency === "CNY" ? "¥" : "$";
|
||||
const body = amount >= 1 ? amount.toFixed(2) : amount.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
|
||||
return `${symbol}${body}`;
|
||||
}
|
||||
|
||||
function watchLine(item: PriceWatchItem): string {
|
||||
if (item.status === "changed" && item.previous && item.current) {
|
||||
const currency = item.current.currency;
|
||||
return `${item.model} 变了:输入 ${moneyBit(item.previous.input, currency)} → ${moneyBit(item.current.input, currency)},输出 ${moneyBit(item.previous.output, currency)} → ${moneyBit(item.current.output, currency)} / 百万 token`;
|
||||
}
|
||||
if (item.status === "unchanged") return `${item.model} 价格没变`;
|
||||
if (item.status === "baseline") return `${item.model} 第一次记下单价,下次启动再比`;
|
||||
return `${item.model} 这次价目里没有`;
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`对话页 http://localhost:${PORT}`);
|
||||
console.log(`运行日志(UTF-8) ${getRuntimeLogPath()}`);
|
||||
void syncOpenRouterPrices()
|
||||
.then((notice) => {
|
||||
if (notice.error) {
|
||||
console.warn(`[价目] OpenRouter 拉取失败:${notice.error}`);
|
||||
return;
|
||||
}
|
||||
if (!notice.items.length) {
|
||||
console.log("[价目] OpenRouter 已更新。最近还没有调用记录,先不比价格。");
|
||||
return;
|
||||
}
|
||||
const changed = notice.items.some((item) => item.status === "changed");
|
||||
const log = changed ? console.warn : console.log;
|
||||
log(`[价目] ${notice.items.map(watchLine).join(";")}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
`[价目] OpenRouter 拉取异常:${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { importSillyTavernPreset } from "../src/preset/importer.js";
|
||||
import {
|
||||
appendTurnMessages,
|
||||
assembleDirectChatMessages,
|
||||
buildChatHistory,
|
||||
buildDirectChatTaskMessages,
|
||||
buildPriorForWindow,
|
||||
createConversation,
|
||||
createShell,
|
||||
DEFAULT_DIRECT_CHAT_SYSTEM,
|
||||
EMPTY_SHELL_SLOTS,
|
||||
getConversation,
|
||||
getShell,
|
||||
listConversations,
|
||||
listShells,
|
||||
promoteTemporary,
|
||||
resolveDirectChatPreset,
|
||||
runDirectChatTurn,
|
||||
} from "../src/direct-chat/index.js";
|
||||
|
||||
function samplePreset() {
|
||||
return importSillyTavernPreset({
|
||||
prompts: [
|
||||
{ identifier: "main", name: "Main", role: "system", content: "MAIN" },
|
||||
{
|
||||
identifier: "charDescription",
|
||||
name: "Desc",
|
||||
role: "system",
|
||||
marker: true,
|
||||
},
|
||||
{
|
||||
identifier: "chatHistory",
|
||||
name: "History",
|
||||
role: "system",
|
||||
marker: true,
|
||||
},
|
||||
{ identifier: "jailbreak", name: "PHI", role: "system", content: "PHI" },
|
||||
],
|
||||
prompt_order: [
|
||||
{
|
||||
character_id: 1,
|
||||
order: [
|
||||
{ identifier: "main", enabled: true },
|
||||
{ identifier: "charDescription", enabled: true },
|
||||
{ identifier: "chatHistory", enabled: true },
|
||||
{ identifier: "jailbreak", enabled: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
}).preset;
|
||||
}
|
||||
|
||||
describe("direct-chat history assembly", () => {
|
||||
it("joins seed history with prior turns", () => {
|
||||
expect(
|
||||
buildChatHistory("种子", [
|
||||
{ role: "user", content: "你好" },
|
||||
{ role: "assistant", content: "嗨" },
|
||||
]),
|
||||
).toBe("种子\n\n【用户】\n你好\n\n【助手】\n嗨");
|
||||
});
|
||||
|
||||
it("fills sandwich with seed + transcript + turn", () => {
|
||||
const messages = assembleDirectChatMessages(samplePreset(), {
|
||||
message: "继续",
|
||||
slots: {
|
||||
...EMPTY_SHELL_SLOTS,
|
||||
systemPrompt: "契约",
|
||||
loreBefore: "设定A",
|
||||
seedHistory: "旧史",
|
||||
},
|
||||
priorMessages: [{ role: "user", content: "上一句" }],
|
||||
});
|
||||
const contents = messages.map((m) => m.content);
|
||||
expect(contents.some((c) => String(c).includes("设定A"))).toBe(true);
|
||||
expect(contents.some((c) => String(c).includes("旧史"))).toBe(true);
|
||||
expect(contents.some((c) => String(c).includes("上一句"))).toBe(true);
|
||||
expect(contents).toContain("继续");
|
||||
expect(contents.some((c) => String(c).includes("契约"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses default system prompt when slot empty", () => {
|
||||
const task = buildDirectChatTaskMessages({
|
||||
message: "hi",
|
||||
slots: EMPTY_SHELL_SLOTS,
|
||||
priorMessages: [],
|
||||
});
|
||||
expect(task[0]?.content).toBe(DEFAULT_DIRECT_CHAT_SYSTEM);
|
||||
});
|
||||
});
|
||||
|
||||
describe("direct-chat store", () => {
|
||||
beforeEach(() => {
|
||||
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||
path.join(tmpdir(), "wa-direct-chat-"),
|
||||
);
|
||||
});
|
||||
|
||||
it("roundtrips shell and conversation", () => {
|
||||
const shell = createShell({
|
||||
name: "测试壳",
|
||||
slots: { ...EMPTY_SHELL_SLOTS, loreBefore: "世界" },
|
||||
});
|
||||
expect(getShell(shell.id)?.loreBefore).toBe("世界");
|
||||
const conv = createConversation({
|
||||
shellId: shell.id,
|
||||
name: "会话1",
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "user",
|
||||
content: "你好",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(listShells()[0]?.conversationCount).toBe(1);
|
||||
expect(listConversations(shell.id)).toHaveLength(1);
|
||||
expect(getConversation(conv.id)?.messages[0]?.content).toBe("你好");
|
||||
});
|
||||
|
||||
it("promotes temporary messages into persistent shell+conversation", () => {
|
||||
const { shell, conversation } = promoteTemporary({
|
||||
shell: { name: "从临时", slots: { loreBefore: "L" } },
|
||||
conversationName: "升格会话",
|
||||
messages: [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "问",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "答",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(shell.name).toBe("从临时");
|
||||
expect(shell.loreBefore).toBe("L");
|
||||
expect(conversation.name).toBe("升格会话");
|
||||
expect(conversation.messages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("direct-chat turn gating", () => {
|
||||
it("rejects when no preset is available", async () => {
|
||||
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
|
||||
path.join(tmpdir(), "wa-direct-chat-preset-"),
|
||||
);
|
||||
await expect(
|
||||
runDirectChatTurn({
|
||||
message: "你好",
|
||||
slots: EMPTY_SHELL_SLOTS,
|
||||
priorMessages: [],
|
||||
presetId: null,
|
||||
complete: false,
|
||||
}),
|
||||
).rejects.toThrow(/预设/);
|
||||
});
|
||||
|
||||
it("appends user/assistant and keeps contextTrace on latest", () => {
|
||||
const messages = appendTurnMessages({
|
||||
prior: [],
|
||||
userText: "问",
|
||||
reply: "答",
|
||||
contextTrace: {
|
||||
caller: "direct-chat",
|
||||
createdAt: new Date().toISOString(),
|
||||
messages: [{ role: "user", content: "x" }],
|
||||
charCount: 1,
|
||||
},
|
||||
keepTraceLatest: 3,
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[1]?.contextTrace?.caller).toBe("direct-chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDirectChatPreset", () => {
|
||||
it("throws for missing explicit preset id", () => {
|
||||
expect(() => resolveDirectChatPreset("missing-preset-xyz")).toThrow(
|
||||
/预设不存在/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPriorForWindow", () => {
|
||||
const turns = [
|
||||
{
|
||||
id: "t1",
|
||||
user: { content: "问1", createdAt: "1" },
|
||||
replies: [
|
||||
{ windowId: "a", content: "A1" },
|
||||
{ windowId: "b", content: "B1" },
|
||||
],
|
||||
createdAt: "1",
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
user: { content: "问2", createdAt: "2" },
|
||||
replies: [
|
||||
{ windowId: "a", content: "A2" },
|
||||
{ windowId: "b", content: "B2" },
|
||||
],
|
||||
selectedWindowId: "a",
|
||||
createdAt: "2",
|
||||
},
|
||||
];
|
||||
|
||||
it("uses each window's own reply when nothing selected", () => {
|
||||
expect(buildPriorForWindow([turns[0]!], "b")).toEqual([
|
||||
{ role: "user", content: "问1" },
|
||||
{ role: "assistant", content: "B1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("forces selected reply into every window's prior", () => {
|
||||
expect(buildPriorForWindow(turns, "b")).toEqual([
|
||||
{ role: "user", content: "问1" },
|
||||
{ role: "assistant", content: "B1" },
|
||||
{ role: "user", content: "问2" },
|
||||
{ role: "assistant", content: "A2" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+767
@@ -0,0 +1,767 @@
|
||||
.chat-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) minmax(260px, 320px);
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--st-border);
|
||||
background: color-mix(in srgb, var(--st-surface) 88%, var(--st-bg));
|
||||
}
|
||||
|
||||
.chat-path {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-path .path-bar-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.chat-path .path-bar-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-path .path-bar-item:not(:last-child)::after {
|
||||
content: "›";
|
||||
margin-left: 4px;
|
||||
color: var(--st-faint);
|
||||
}
|
||||
|
||||
.chat-path button,
|
||||
.chat-path .path-bar-current {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-path button:hover {
|
||||
color: var(--st-accent);
|
||||
}
|
||||
|
||||
.chat-path .path-bar-current {
|
||||
color: var(--st-text);
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat-rail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-rail-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-rail-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--st-text);
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.chat-rail-item:hover {
|
||||
background: var(--st-accent-soft);
|
||||
}
|
||||
|
||||
.chat-rail-item.active {
|
||||
border-color: color-mix(in srgb, var(--st-accent) 45%, var(--st-border));
|
||||
background: var(--st-accent-soft);
|
||||
}
|
||||
|
||||
.chat-rail-item-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-rail-item-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-rail-item-meta {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.chat-rail-item-del {
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--st-faint);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-rail-item-del:hover {
|
||||
color: var(--st-danger);
|
||||
}
|
||||
|
||||
.chat-rail-note {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--st-faint);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--st-bg);
|
||||
}
|
||||
|
||||
.chat-room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-room-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--st-border);
|
||||
background: var(--st-surface);
|
||||
}
|
||||
|
||||
.chat-room-titles h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.chat-room-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.chat-room-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-profile-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.chat-profile-label select {
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
border: 1px solid var(--st-border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--st-surface);
|
||||
color: var(--st-text);
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.chat-context {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--st-border);
|
||||
background: color-mix(in srgb, var(--st-surface) 92%, var(--st-bg));
|
||||
}
|
||||
|
||||
.chat-context-head {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 14px 8px;
|
||||
border-bottom: 1px solid var(--st-border);
|
||||
}
|
||||
|
||||
.chat-context-head h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.chat-context-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--st-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.chat-shell-editor {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px 16px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.chat-shell-editor label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.chat-shell-editor input,
|
||||
.chat-shell-editor textarea {
|
||||
border: 1px solid var(--st-border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--st-surface);
|
||||
color: var(--st-text);
|
||||
padding: 6px 8px;
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.chat-shell-editor input:disabled,
|
||||
.chat-shell-editor textarea:disabled {
|
||||
opacity: 0.65;
|
||||
background: color-mix(in srgb, var(--st-bg) 70%, var(--st-surface));
|
||||
}
|
||||
|
||||
.chat-shell-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* —— 模型竞技场:并排整列 chat pane —— */
|
||||
.chat-arena {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(280px, 1fr);
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
background: var(--st-bg);
|
||||
border-bottom: 1px solid var(--st-border);
|
||||
}
|
||||
|
||||
.chat-arena-empty {
|
||||
grid-column: 1 / -1;
|
||||
margin: auto;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--st-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.chat-arena-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--st-border);
|
||||
background: var(--st-surface);
|
||||
}
|
||||
|
||||
.chat-arena-col:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.chat-arena-col.is-selected {
|
||||
background: color-mix(in srgb, var(--st-accent-soft) 55%, var(--st-surface));
|
||||
}
|
||||
|
||||
.chat-arena-col-head {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--st-border);
|
||||
background: color-mix(in srgb, var(--st-bg) 55%, var(--st-surface));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chat-arena-col-titles {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-arena-col-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--st-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-arena-col-model {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: var(--st-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-arena-col-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-arena-col-actions .st-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-arena-col-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--st-border);
|
||||
background: var(--st-bg);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-bubble.user {
|
||||
align-self: stretch;
|
||||
background: color-mix(in srgb, var(--st-accent-soft) 70%, var(--st-bg));
|
||||
border-color: color-mix(in srgb, var(--st-accent) 28%, var(--st-border));
|
||||
}
|
||||
|
||||
.chat-bubble.assistant {
|
||||
align-self: stretch;
|
||||
background: var(--st-surface);
|
||||
}
|
||||
|
||||
.chat-bubble.assistant.is-selected {
|
||||
border-color: var(--st-accent);
|
||||
background: var(--st-accent-soft);
|
||||
}
|
||||
|
||||
.chat-bubble-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-select-btn {
|
||||
border: 1px solid var(--st-border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--st-surface);
|
||||
color: var(--st-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-select-btn:hover {
|
||||
border-color: var(--st-accent);
|
||||
background: var(--st-accent-soft);
|
||||
}
|
||||
|
||||
.chat-select-btn.is-on {
|
||||
border-color: var(--st-accent);
|
||||
background: var(--st-accent-soft);
|
||||
color: var(--st-text);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.chat-arena-col-empty {
|
||||
margin: auto;
|
||||
padding: 16px 8px;
|
||||
text-align: center;
|
||||
color: var(--st-faint);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-bubble-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.chat-bubble-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble-actions button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--st-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.chat-bubble-actions button:hover {
|
||||
color: var(--st-accent);
|
||||
}
|
||||
|
||||
.chat-bubble-thinking {
|
||||
margin: 0 0 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--st-bg) 80%, transparent);
|
||||
color: var(--st-muted);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
padding: 10px 16px 14px;
|
||||
border-top: 1px solid var(--st-border);
|
||||
background: var(--st-surface);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-input-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--st-border-strong);
|
||||
border-radius: 10px;
|
||||
padding: 4px 6px 4px 10px;
|
||||
background: var(--st-bg);
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.chat-input-shell:focus-within {
|
||||
border-color: var(--st-accent);
|
||||
box-shadow: 0 0 0 2px var(--st-accent-soft);
|
||||
}
|
||||
|
||||
.chat-input-shell textarea {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
max-height: min(42vh, 280px);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--st-text);
|
||||
padding: 6px 4px;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
.chat-input-shell textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-shell-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-btn-icon {
|
||||
box-sizing: border-box;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--st-border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--st-surface);
|
||||
color: var(--st-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-btn-icon:hover:not(:disabled) {
|
||||
border-color: var(--st-accent);
|
||||
background: var(--st-accent-soft);
|
||||
}
|
||||
|
||||
.chat-btn-icon:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-btn-icon-primary {
|
||||
background: var(--st-accent);
|
||||
border-color: var(--st-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-btn-icon-primary:hover:not(:disabled) {
|
||||
filter: brightness(0.96);
|
||||
background: var(--st-accent);
|
||||
border-color: var(--st-accent);
|
||||
}
|
||||
|
||||
.chat-send-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.chat-status.error {
|
||||
color: var(--st-danger);
|
||||
}
|
||||
|
||||
.st-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--st-border-strong);
|
||||
border-radius: 7px;
|
||||
background: var(--st-surface);
|
||||
color: var(--st-text);
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.st-btn:hover {
|
||||
border-color: var(--st-accent);
|
||||
background: var(--st-accent-soft);
|
||||
}
|
||||
|
||||
.st-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.st-btn-primary {
|
||||
border-color: color-mix(in srgb, var(--st-accent) 55%, var(--st-border-strong));
|
||||
background: var(--st-accent-soft);
|
||||
color: var(--st-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-dialog {
|
||||
border: 1px solid var(--st-border-strong);
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
background: var(--st-surface);
|
||||
color: var(--st-text);
|
||||
max-width: min(420px, 92vw);
|
||||
}
|
||||
|
||||
.chat-dialog-wide {
|
||||
max-width: min(720px, 94vw);
|
||||
width: 94vw;
|
||||
}
|
||||
|
||||
.chat-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.chat-dialog form,
|
||||
.chat-dialog > div {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-dialog h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.chat-dialog-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.chat-dialog label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.chat-dialog input {
|
||||
border: 1px solid var(--st-border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--st-bg);
|
||||
color: var(--st-text);
|
||||
padding: 6px 8px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.chat-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-compare-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-compare-list label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--st-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-compare-result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--st-border);
|
||||
}
|
||||
|
||||
.chat-compare-result-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-compare-card {
|
||||
border: 1px solid var(--st-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--st-bg);
|
||||
}
|
||||
|
||||
.chat-compare-card h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-compare-card pre {
|
||||
margin: 0 0 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.chat-shell {
|
||||
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr) minmax(220px, 280px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.chat-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chat-rail {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--st-border);
|
||||
max-height: 28vh;
|
||||
}
|
||||
|
||||
.chat-context {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--st-border);
|
||||
max-height: 36vh;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
min-height: 42vh;
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>直聊 · Writing Agent</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script src="/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/settings.css" />
|
||||
<link rel="stylesheet" href="/chat.css" />
|
||||
</head>
|
||||
<body class="settings-page chat-page">
|
||||
<header class="st-top">
|
||||
<a class="st-brand" href="/">Writing Agent</a>
|
||||
<nav class="st-nav" aria-label="主导航">
|
||||
<a href="/">对话</a>
|
||||
<a href="/chat.html" aria-current="page">直聊</a>
|
||||
<a href="/modules.html">技能</a>
|
||||
<a href="/libraries.html">库</a>
|
||||
<a href="/stats.html">Token</a>
|
||||
<a href="/settings.html">设置</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="st-shell chat-shell">
|
||||
<aside class="chat-rail" aria-label="直聊导航">
|
||||
<nav class="path-bar chat-path" id="chat-path" aria-label="位置">
|
||||
<ol class="path-bar-list" id="chat-path-list"></ol>
|
||||
</nav>
|
||||
<div class="chat-rail-actions">
|
||||
<button type="button" class="st-btn" id="btn-new-shell">新建上下文</button>
|
||||
<button type="button" class="st-btn" id="btn-new-conv" hidden>
|
||||
新建会话
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-rail-list" id="chat-rail-list"></div>
|
||||
<p class="chat-rail-note">
|
||||
未选存档即为快速对话(浏览器缓存,最多 2 条),可保存为持久会话。
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<main class="chat-main" id="chat-main">
|
||||
<section class="chat-room" id="chat-room">
|
||||
<header class="chat-room-head">
|
||||
<div class="chat-room-titles">
|
||||
<h2 id="chat-room-title">临时会话</h2>
|
||||
<p class="chat-room-sub" id="chat-room-sub">未选存档时即快速对话</p>
|
||||
</div>
|
||||
<div class="chat-room-actions">
|
||||
<button type="button" class="st-btn" id="btn-save-temp" hidden>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" class="st-btn" id="btn-add-window">
|
||||
新增窗口
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="chat-arena" id="chat-arena" aria-live="polite" aria-label="模型竞技场"></div>
|
||||
|
||||
<footer class="chat-composer">
|
||||
<p class="chat-status" id="chat-status" hidden></p>
|
||||
<div class="chat-input-shell">
|
||||
<textarea
|
||||
id="chat-input"
|
||||
rows="1"
|
||||
placeholder="输入消息,Enter 发送(所有窗口同时回复),Shift+Enter 换行"
|
||||
></textarea>
|
||||
<div class="chat-shell-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="chat-btn-icon chat-btn-icon-primary"
|
||||
id="btn-send"
|
||||
title="发送(Enter)"
|
||||
aria-label="发送"
|
||||
>
|
||||
<svg class="chat-send-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M1.4 2.1 14.6 8 1.4 13.9l.2-4.4L10 8 1.6 6.5l-.2-4.4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="chat-context" id="chat-context" aria-label="上下文配置">
|
||||
<header class="chat-context-head">
|
||||
<h2>上下文</h2>
|
||||
<p class="chat-context-sub" id="context-sub">选择或新建后编辑</p>
|
||||
</header>
|
||||
<div class="chat-shell-editor" id="shell-editor">
|
||||
<label>
|
||||
名称
|
||||
<input type="text" id="shell-name" disabled />
|
||||
</label>
|
||||
<label>
|
||||
系统提示
|
||||
<textarea
|
||||
id="shell-system"
|
||||
rows="3"
|
||||
placeholder="空则用默认直聊契约"
|
||||
disabled
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
史前设定
|
||||
<textarea id="shell-lore-before" rows="3" disabled></textarea>
|
||||
</label>
|
||||
<label>
|
||||
种子历史
|
||||
<textarea
|
||||
id="shell-seed-history"
|
||||
rows="4"
|
||||
placeholder="手填历史,会拼在多轮之前"
|
||||
disabled
|
||||
></textarea>
|
||||
</label>
|
||||
<label>
|
||||
史后状态
|
||||
<textarea id="shell-lore-after" rows="2" disabled></textarea>
|
||||
</label>
|
||||
<label>
|
||||
尾注
|
||||
<textarea id="shell-post-turn" rows="2" disabled></textarea>
|
||||
</label>
|
||||
<div class="chat-shell-editor-actions">
|
||||
<button type="button" class="st-btn st-btn-primary" id="btn-save-shell" disabled>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<dialog class="chat-dialog" id="dialog-promote">
|
||||
<form method="dialog" id="form-promote">
|
||||
<h3>保存临时会话</h3>
|
||||
<label>
|
||||
上下文名称
|
||||
<input type="text" id="promote-shell-name" value="临时上下文" required />
|
||||
</label>
|
||||
<label>
|
||||
会话名称
|
||||
<input type="text" id="promote-conv-name" value="会话" required />
|
||||
</label>
|
||||
<div class="chat-dialog-actions">
|
||||
<button type="submit" class="st-btn st-btn-primary" value="ok">保存</button>
|
||||
<button type="submit" class="st-btn" value="cancel">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog class="chat-dialog" id="dialog-add-window">
|
||||
<form method="dialog" id="form-add-window">
|
||||
<h3>新增窗口</h3>
|
||||
<p class="chat-dialog-hint">选一个 API 配置,中间多开一列对照窗;发送时各列同时回复。</p>
|
||||
<label>
|
||||
模型配置
|
||||
<select id="add-window-profile"></select>
|
||||
</label>
|
||||
<div class="chat-dialog-actions">
|
||||
<button type="submit" class="st-btn st-btn-primary" value="ok">添加</button>
|
||||
<button type="submit" class="st-btn" value="cancel">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1088
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
<a class="st-brand" href="/">Writing Agent</a>
|
||||
<nav class="st-nav" aria-label="主导航">
|
||||
<a href="/">对话</a>
|
||||
<a href="/chat.html">直聊</a>
|
||||
<a href="/modules.html">技能</a>
|
||||
<a href="/libraries.html" aria-current="page">库</a>
|
||||
<a href="/stats.html">Token</a>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<a class="st-brand" href="/">Writing Agent</a>
|
||||
<nav class="st-nav" aria-label="主导航">
|
||||
<a href="/">对话</a>
|
||||
<a href="/chat.html">直聊</a>
|
||||
<a href="/modules.html" aria-current="page">技能</a>
|
||||
<a href="/libraries.html">库</a>
|
||||
<a href="/stats.html">Token</a>
|
||||
|
||||
Reference in New Issue
Block a user