From 749eb2f5b041a21b63135afc72d769c90df6bcdb Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 14 Oct 2026 08:30:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=8E=E4=BD=9C=E5=93=81?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=B9=B6=E5=88=97=E7=9A=84=E7=9B=B4=E8=81=8A?= =?UTF-8?q?=EF=BC=8C=E5=A4=9A=E6=A8=A1=E5=9E=8B=E6=8C=89=E5=88=97=E5=AF=B9?= =?UTF-8?q?=E7=85=A7=E5=B9=B6=E5=8F=AF=E9=80=89=E5=AE=9A=E5=9F=BA=E5=87=86?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务启动时顺带拉取开放路由价目,用户目录同时预留生图工作流。 Co-authored-by: Cursor --- docs/direct-chat.md | 42 ++ src/config/user-data-dir.ts | 8 + src/direct-chat/index.ts | 3 + src/direct-chat/store.ts | 489 +++++++++++++ src/direct-chat/turn.ts | 377 ++++++++++ src/direct-chat/types.ts | 179 +++++ src/log.ts | 1 + src/server/direct-chat-handlers.ts | 443 +++++++++++ src/server/web-server.ts | 48 +- tests/direct-chat.test.ts | 232 ++++++ web/chat.css | 767 ++++++++++++++++++++ web/chat.html | 176 +++++ web/chat.js | 1088 ++++++++++++++++++++++++++++ web/libraries.html | 1 + web/modules.html | 1 + 15 files changed, 3854 insertions(+), 1 deletion(-) create mode 100644 docs/direct-chat.md create mode 100644 src/direct-chat/index.ts create mode 100644 src/direct-chat/store.ts create mode 100644 src/direct-chat/turn.ts create mode 100644 src/direct-chat/types.ts create mode 100644 src/server/direct-chat-handlers.ts create mode 100644 tests/direct-chat.test.ts create mode 100644 web/chat.css create mode 100644 web/chat.html create mode 100644 web/chat.js diff --git a/docs/direct-chat.md b/docs/direct-chat.md new file mode 100644 index 0000000..47fcf8f --- /dev/null +++ b/docs/direct-chat.md @@ -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` diff --git a/src/config/user-data-dir.ts b/src/config/user-data-dir.ts index c056065..e006a47 100644 --- a/src/config/user-data-dir.ts +++ b/src/config/user-data-dir.ts @@ -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 }); } diff --git a/src/direct-chat/index.ts b/src/direct-chat/index.ts new file mode 100644 index 0000000..24e2141 --- /dev/null +++ b/src/direct-chat/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./store.js"; +export * from "./turn.js"; diff --git a/src/direct-chat/store.ts b/src/direct-chat/store.ts new file mode 100644 index 0000000..4ec0053 --- /dev/null +++ b/src/direct-chat/store.ts @@ -0,0 +1,489 @@ +/** + * 直聊持久化:{用户数据}/direct-chats/ + * shells/.json + * conversations/.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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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 { + 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; + 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 }; +} diff --git a/src/direct-chat/turn.ts b/src/direct-chat/turn.ts new file mode 100644 index 0000000..bcb9160 --- /dev/null +++ b/src/direct-chat/turn.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/direct-chat/types.ts b/src/direct-chat/types.ts new file mode 100644 index 0000000..e64a5f8 --- /dev/null +++ b/src/direct-chat/types.ts @@ -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; +} diff --git a/src/log.ts b/src/log.ts index 104f4d1..cf75b93 100644 --- a/src/log.ts +++ b/src/log.ts @@ -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)}`; } diff --git a/src/server/direct-chat-handlers.ts b/src/server/direct-chat-handlers.ts new file mode 100644 index 0000000..c779d63 --- /dev/null +++ b/src/server/direct-chat-handlers.ts @@ -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 { + 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): 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 { + 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; + 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; + 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 & { + 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; + } +} diff --git a/src/server/web-server.ts b/src/server/web-server.ts index 77a27e3..7b13512 100644 --- a/src/server/web-server.ts +++ b/src/server/web-server.ts @@ -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 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)}`, + ); + }); }); diff --git a/tests/direct-chat.test.ts b/tests/direct-chat.test.ts new file mode 100644 index 0000000..adcd924 --- /dev/null +++ b/tests/direct-chat.test.ts @@ -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" }, + ]); + }); +}); diff --git a/web/chat.css b/web/chat.css new file mode 100644 index 0000000..ba72e8e --- /dev/null +++ b/web/chat.css @@ -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; + } +} diff --git a/web/chat.html b/web/chat.html new file mode 100644 index 0000000..3695b12 --- /dev/null +++ b/web/chat.html @@ -0,0 +1,176 @@ + + + + + + 直聊 · Writing Agent + + + + + + + + +
+ Writing Agent + +
+ +
+ + +
+
+
+
+

临时会话

+

未选存档时即快速对话

+
+
+ + +
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+ + +
+ + +
+

保存临时会话

+ + +
+ + +
+
+
+ + +
+

新增窗口

+

选一个 API 配置,中间多开一列对照窗;发送时各列同时回复。

+ +
+ + +
+
+
+ + + + diff --git a/web/chat.js b/web/chat.js new file mode 100644 index 0000000..2d11d53 --- /dev/null +++ b/web/chat.js @@ -0,0 +1,1088 @@ +const TEMP_STORAGE_KEY = "wa-direct-chat-temps-v2"; +const MAX_TEMPS = 2; +const EMPTY_SLOTS = { + systemPrompt: "", + loreBefore: "", + seedHistory: "", + loreAfter: "", + postTurn: "", +}; + +const $ = (id) => document.getElementById(id); + +const els = { + pathList: $("chat-path-list"), + railList: $("chat-rail-list"), + room: $("chat-room"), + roomTitle: $("chat-room-title"), + roomSub: $("chat-room-sub"), + arena: $("chat-arena"), + input: $("chat-input"), + status: $("chat-status"), + shellEditor: $("shell-editor"), + contextSub: $("context-sub"), + btnNewShell: $("btn-new-shell"), + btnSaveTemp: $("btn-save-temp"), + btnNewConv: $("btn-new-conv"), + btnAddWindow: $("btn-add-window"), + btnSend: $("btn-send"), + btnSaveShell: $("btn-save-shell"), + shellName: $("shell-name"), + shellSystem: $("shell-system"), + shellLoreBefore: $("shell-lore-before"), + shellSeedHistory: $("shell-seed-history"), + shellLoreAfter: $("shell-lore-after"), + shellPostTurn: $("shell-post-turn"), + dialogPromote: $("dialog-promote"), + formPromote: $("form-promote"), + promoteShellName: $("promote-shell-name"), + promoteConvName: $("promote-conv-name"), + dialogAddWindow: $("dialog-add-window"), + formAddWindow: $("form-add-window"), + addWindowProfile: $("add-window-profile"), +}; + +/** @type {"root"|"shell"|"conversation"} */ +let navLevel = "root"; +/** @type {string|null} */ +let activeShellId = null; +/** @type {string|null} */ +let activeConversationId = null; +/** @type {boolean} */ +let activeIsTemp = false; +/** @type {any} */ +let activeShell = null; +/** @type {any[]} */ +let activeWindows = []; +/** @type {any[]} */ +let activeTurns = []; +/** @type {any[]} */ +let shells = []; +/** @type {any[]} */ +let conversations = []; +/** @type {any[]} */ +let profileList = []; +let busy = false; + +async function api(path, options = {}) { + const res = await fetch(path, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { error: text || res.statusText }; + } + if (!res.ok) { + throw new Error(data?.error || res.statusText || `HTTP ${res.status}`); + } + return data; +} + +function loadTemps() { + try { + const raw = localStorage.getItem(TEMP_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map(normalizeTemp).filter(Boolean) : []; + } catch { + return []; + } +} + +function normalizeTemp(t) { + if (!t || typeof t !== "object") return null; + const windows = Array.isArray(t.windows) ? t.windows : []; + let turns = Array.isArray(t.turns) ? t.turns : []; + if (!turns.length && Array.isArray(t.messages) && t.messages.length) { + turns = migrateFlatMessages(t.messages, windows[0]?.id || "default"); + } + return { + id: t.id, + name: t.name || "临时会话", + slots: { ...EMPTY_SLOTS, ...(t.slots || {}) }, + windows, + turns, + createdAt: t.createdAt || nowIso(), + updatedAt: t.updatedAt || nowIso(), + }; +} + +function migrateFlatMessages(messages, windowId) { + const turns = []; + let pending = null; + for (const m of messages) { + if (m.role === "user") { + pending = { content: m.content, createdAt: m.createdAt }; + } else if (m.role === "assistant" && pending) { + turns.push({ + id: m.id || newId(), + user: pending, + replies: [ + { + windowId, + content: m.content, + thinking: m.thinking, + model: m.model, + contextTrace: m.contextTrace, + }, + ], + createdAt: m.createdAt, + }); + pending = null; + } + } + return turns; +} + +function saveTemps(list) { + const trimmed = list + .slice() + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) + .slice(0, MAX_TEMPS); + localStorage.setItem(TEMP_STORAGE_KEY, JSON.stringify(trimmed)); + return trimmed; +} + +function upsertTemp(temp) { + const list = loadTemps().filter((t) => t.id !== temp.id); + list.unshift(temp); + return saveTemps(list); +} + +function deleteTemp(id) { + return saveTemps(loadTemps().filter((t) => t.id !== id)); +} + +function newId() { + return crypto.randomUUID(); +} + +function nowIso() { + return new Date().toISOString(); +} + +function setStatus(text, isError = false) { + if (!text) { + els.status.hidden = true; + els.status.textContent = ""; + return; + } + els.status.hidden = false; + els.status.textContent = text; + els.status.classList.toggle("error", isError); +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function slotsFromEditor() { + return { + systemPrompt: els.shellSystem.value, + loreBefore: els.shellLoreBefore.value, + seedHistory: els.shellSeedHistory.value, + loreAfter: els.shellLoreAfter.value, + postTurn: els.shellPostTurn.value, + }; +} + +function fillEditor(shellLike) { + const s = shellLike || { name: "", ...EMPTY_SLOTS }; + els.shellName.value = s.name || ""; + els.shellSystem.value = s.systemPrompt || ""; + els.shellLoreBefore.value = s.loreBefore || ""; + els.shellSeedHistory.value = s.seedHistory || ""; + els.shellLoreAfter.value = s.loreAfter || ""; + els.shellPostTurn.value = s.postTurn || ""; +} + +function setContextEditorEnabled(enabled, subtitle) { + const fields = [ + els.shellName, + els.shellSystem, + els.shellLoreBefore, + els.shellSeedHistory, + els.shellLoreAfter, + els.shellPostTurn, + els.btnSaveShell, + ]; + for (const el of fields) el.disabled = !enabled; + if (els.contextSub) { + els.contextSub.textContent = + subtitle || (enabled ? "改完点应用" : "选择或新建后编辑"); + } +} + +function syncContextPanel() { + if (activeIsTemp) { + fillEditor(activeShell || { name: "临时会话", ...EMPTY_SLOTS }); + setContextEditorEnabled(true, "临时上下文 · 仅浏览器"); + return; + } + if (activeShell) { + fillEditor(activeShell); + setContextEditorEnabled(true, "持久上下文 · 点应用写入本机"); + return; + } + fillEditor({ name: "", ...EMPTY_SLOTS }); + setContextEditorEnabled(false, "选择或新建后编辑"); +} + +function windowLabel(w) { + return w.profileName || w.model || w.profileId || "窗口"; +} + +function ensureDefaultWindow() { + if (activeWindows.length) return; + const p = profileList.find((x) => x.hasKey) || profileList[0]; + if (!p) return; + activeWindows = [ + { + id: newId(), + profileId: p.id, + profileName: p.name, + model: p.model, + }, + ]; +} + +function persistActiveSessionMeta() { + if (activeIsTemp && activeConversationId) { + const temp = loadTemps().find((t) => t.id === activeConversationId); + if (!temp) return; + temp.windows = activeWindows; + temp.turns = activeTurns; + temp.slots = { ...EMPTY_SLOTS, ...(temp.slots || {}), ...slotsFromEditor() }; + temp.updatedAt = nowIso(); + upsertTemp(temp); + return; + } + if (!activeIsTemp && activeConversationId) { + api( + `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}`, + { + method: "PATCH", + body: JSON.stringify({ + windows: activeWindows, + turns: activeTurns, + }), + }, + ).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + } +} + +function renderPath() { + const items = []; + items.push( + navLevel === "root" && !activeIsTemp + ? `
  • 直聊
  • ` + : `
  • `, + ); + if (navLevel === "shell" || (navLevel === "conversation" && !activeIsTemp)) { + const shellLabel = activeShell?.name || "上下文"; + if (navLevel === "shell") { + items.push( + `
  • ${escapeHtml(shellLabel)}
  • `, + ); + } else { + items.push( + `
  • `, + ); + const convLabel = + conversations.find((c) => c.id === activeConversationId)?.name || "会话"; + items.push( + `
  • ${escapeHtml(convLabel)}
  • `, + ); + } + } else if (activeIsTemp) { + items.push( + `
  • 临时
  • `, + ); + } + els.pathList.innerHTML = items.join(""); +} + +function renderRootRail() { + const temps = loadTemps(); + const parts = []; + if (temps.length) { + parts.push(`

    快速对话(浏览器)

    `); + for (const t of temps) { + parts.push( + railItemHtml({ + id: t.id, + title: t.name || "临时会话", + meta: `${t.turns?.length || 0} 轮 · 临时`, + temp: true, + active: activeIsTemp && activeConversationId === t.id, + }), + ); + } + } + parts.push(`

    已保存上下文

    `); + if (!shells.length) { + parts.push(`

    还没有上下文壳

    `); + } + for (const s of shells) { + parts.push( + railItemHtml({ + id: s.id, + title: s.name, + meta: `${s.conversationCount} 个会话`, + temp: false, + active: !activeIsTemp && activeShellId === s.id, + }), + ); + } + els.railList.innerHTML = parts.join(""); +} + +function renderRail() { + if (navLevel === "root" || activeIsTemp) { + renderRootRail(); + return; + } + if (navLevel === "shell") { + const parts = []; + if (!conversations.length) { + parts.push(`

    还没有会话

    `); + } + for (const c of conversations) { + parts.push( + railItemHtml({ + id: c.id, + title: c.name, + meta: `${c.messageCount} 条消息`, + temp: false, + active: activeConversationId === c.id, + }), + ); + } + els.railList.innerHTML = parts.join(""); + return; + } + els.railList.innerHTML = conversations + .map((c) => + railItemHtml({ + id: c.id, + title: c.name, + meta: `${c.messageCount} 条消息`, + temp: false, + active: activeConversationId === c.id, + }), + ) + .join(""); +} + +function railItemHtml({ id, title, meta, temp, active }) { + return `
    + + +
    `; +} + +function renderArena() { + if (!els.arena) return; + if (!activeWindows.length) { + els.arena.innerHTML = `
    还没有对照窗。点右上「新增窗口」加入模型;发送后各列同时回复。
    `; + return; + } + + const multi = activeWindows.length > 1; + els.arena.innerHTML = activeWindows + .map((w) => { + const hasSelection = activeTurns.some((t) => t.selectedWindowId === w.id); + const messagesHtml = activeTurns.length + ? activeTurns + .map((turn) => { + const reply = turn.replies.find((r) => r.windowId === w.id); + const selected = turn.selectedWindowId === w.id; + const userHtml = `
    +
    你
    +
    ${escapeHtml(turn.user.content)}
    +
    `; + let assistantInner; + if (!reply) { + assistantInner = `

    本轮无回复

    `; + } else if (reply.error) { + assistantInner = `

    ${escapeHtml(reply.error)}

    `; + } else { + assistantInner = `${ + reply.thinking + ? `
    ${escapeHtml(reply.thinking)}
    ` + : "" + }
    ${escapeHtml(reply.content || "")}
    `; + } + const selectBtn = + multi && reply && !reply.error + ? `
    + +
    ` + : selected + ? `
    已保留
    ` + : ""; + return `${userHtml} +
    +
    助手${reply?.model ? `${escapeHtml(reply.model)}` : ""}
    + ${assistantInner} + ${selectBtn} +
    `; + }) + .join("") + : `

    在下方输入,各列会同时回复同一条消息。

    `; + + return `
    +
    +
    + ${escapeHtml(windowLabel(w))} + ${escapeHtml(w.model || w.profileId || "")} +
    +
    + ${ + activeWindows.length > 1 + ? `` + : "" + } +
    +
    +
    ${messagesHtml}
    +
    `; + }) + .join(""); + + // 各列滚到底 + for (const sc of els.arena.querySelectorAll(".chat-arena-col-scroll")) { + sc.scrollTop = sc.scrollHeight; + } +} + +function setComposerEnabled(enabled) { + els.input.disabled = !enabled; + els.btnSend.disabled = !enabled; + els.btnAddWindow.disabled = !enabled; +} + +function ensureDraftTemp() { + const temps = loadTemps(); + if (temps.length) return temps[0]; + ensureDefaultWindow(); + const temp = { + id: newId(), + name: "临时会话", + slots: { ...EMPTY_SLOTS }, + windows: activeWindows.length + ? activeWindows + : profileList[0] + ? [ + { + id: newId(), + profileId: profileList[0].id, + profileName: profileList[0].name, + model: profileList[0].model, + }, + ] + : [], + turns: [], + createdAt: nowIso(), + updatedAt: nowIso(), + }; + upsertTemp(temp); + return temp; +} + +function openDraftTemp() { + const temp = ensureDraftTemp(); + activeIsTemp = true; + activeConversationId = temp.id; + activeShellId = null; + activeShell = { name: temp.name || "临时会话", ...EMPTY_SLOTS, ...temp.slots }; + activeWindows = temp.windows?.length ? temp.windows : []; + ensureDefaultWindow(); + if (!temp.windows?.length && activeWindows.length) { + temp.windows = activeWindows; + upsertTemp(temp); + } + activeTurns = temp.turns || []; + navLevel = "conversation"; + els.roomTitle.textContent = temp.name || "临时会话"; + els.roomSub.textContent = "未选存档 · 快速对话(浏览器缓存,最多 2 条)"; + els.btnSaveTemp.hidden = false; + els.btnNewConv.hidden = true; + setComposerEnabled(true); + syncContextPanel(); + renderArena(); +} + +function syncRoomChrome() { + els.btnNewConv.hidden = !( + navLevel === "shell" || + (navLevel === "conversation" && !activeIsTemp) + ); + syncContextPanel(); + els.room.hidden = false; + + if (navLevel === "shell" && !activeIsTemp) { + els.roomTitle.textContent = activeShell?.name || "上下文"; + els.roomSub.textContent = "从左侧选会话,或点「新建会话」。"; + els.btnSaveTemp.hidden = true; + setComposerEnabled(false); + activeWindows = []; + activeTurns = []; + if (els.arena) { + els.arena.innerHTML = + `
    尚未打开会话。从左侧选会话,或点「新建会话」。
    `; + } + return; + } + + if (navLevel !== "conversation") { + openDraftTemp(); + return; + } + + const title = activeIsTemp + ? loadTemps().find((t) => t.id === activeConversationId)?.name || "临时会话" + : conversations.find((c) => c.id === activeConversationId)?.name || "会话"; + els.roomTitle.textContent = title; + els.roomSub.textContent = activeIsTemp + ? "未选存档 · 快速对话(浏览器缓存,最多 2 条)" + : `上下文:${activeShell?.name || "—"}`; + els.btnSaveTemp.hidden = !activeIsTemp; + setComposerEnabled(true); + renderArena(); +} + +async function refreshShells() { + const data = await api("/api/direct-chat/shells"); + shells = data.shells || []; +} + +async function refreshConversations(shellId) { + const data = await api( + `/api/direct-chat/shells/${encodeURIComponent(shellId)}`, + ); + activeShell = data.shell; + conversations = data.conversations || []; + fillEditor(activeShell); +} + +async function loadProfiles() { + try { + const data = await api("/api/profiles"); + profileList = (data.profiles || []).map((p) => ({ + id: p.id, + name: p.name, + model: p.model, + hasKey: Boolean(String(p.apiKey ?? "").trim()), + })); + } catch { + profileList = []; + } +} + +async function goRoot() { + activeShellId = null; + conversations = []; + await refreshShells(); + openDraftTemp(); + renderPath(); + renderRail(); +} + +async function openShell(shellId) { + activeIsTemp = false; + activeShellId = shellId; + activeConversationId = null; + activeTurns = []; + activeWindows = []; + navLevel = "shell"; + await refreshConversations(shellId); + renderPath(); + renderRail(); + syncRoomChrome(); +} + +async function openConversation(conversationId) { + const data = await api( + `/api/direct-chat/conversations/${encodeURIComponent(conversationId)}`, + ); + activeIsTemp = false; + activeConversationId = conversationId; + activeShellId = data.conversation.shellId; + activeShell = data.shell; + activeWindows = data.conversation.windows || []; + activeTurns = data.conversation.turns || []; + ensureDefaultWindow(); + navLevel = "conversation"; + if (activeShellId) { + const shellData = await api( + `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`, + ); + conversations = shellData.conversations || []; + activeShell = shellData.shell || activeShell; + } + renderPath(); + renderRail(); + syncRoomChrome(); +} + +function openTemp(tempId) { + const temp = loadTemps().find((t) => t.id === tempId); + if (!temp) return; + activeIsTemp = true; + activeConversationId = temp.id; + activeShellId = null; + activeShell = { name: temp.name || "临时会话", ...EMPTY_SLOTS, ...temp.slots }; + activeWindows = temp.windows || []; + activeTurns = temp.turns || []; + ensureDefaultWindow(); + navLevel = "conversation"; + renderPath(); + renderRail(); + syncRoomChrome(); +} + +async function createShellAndOpen() { + const name = window.prompt("上下文名称", "新上下文"); + if (name == null) return; + const data = await api("/api/direct-chat/shells", { + method: "POST", + body: JSON.stringify({ name: name.trim() || "新上下文", ...EMPTY_SLOTS }), + }); + await refreshShells(); + await openShell(data.shell.id); +} + +async function createConversationUnderShell() { + if (!activeShellId) return; + const name = window.prompt("会话名称", "新会话"); + if (name == null) return; + ensureDefaultWindow(); + const data = await api( + `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}/conversations`, + { + method: "POST", + body: JSON.stringify({ + name: name.trim() || "新会话", + windows: activeWindows, + turns: [], + }), + }, + ); + await openConversation(data.conversation.id); +} + +async function saveShellEditor() { + const slots = slotsFromEditor(); + const name = els.shellName.value.trim() || "未命名上下文"; + if (activeIsTemp && activeConversationId) { + const temps = loadTemps(); + const temp = temps.find((t) => t.id === activeConversationId); + if (!temp) return; + temp.name = name === "临时" ? temp.name : name; + temp.slots = slots; + temp.windows = activeWindows; + temp.turns = activeTurns; + temp.updatedAt = nowIso(); + upsertTemp(temp); + activeShell = { name: temp.name, ...slots }; + if (els.contextSub) els.contextSub.textContent = "已更新(仅浏览器)"; + setStatus("已更新临时上下文(仅浏览器)"); + return; + } + if (!activeShellId) return; + const data = await api( + `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`, + { + method: "PATCH", + body: JSON.stringify({ name, ...slots }), + }, + ); + activeShell = data.shell; + fillEditor(activeShell); + await refreshShells(); + renderPath(); + renderRail(); + if (els.contextSub) els.contextSub.textContent = "已保存"; + setStatus("上下文已保存"); +} + +async function sendMessage() { + if (busy) return; + const text = els.input.value.trim(); + if (!text) return; + if (navLevel !== "conversation" || !activeConversationId) { + setStatus("请先打开一个会话", true); + return; + } + ensureDefaultWindow(); + if (!activeWindows.length) { + setStatus("请先新增至少一个模型窗口", true); + return; + } + busy = true; + els.btnSend.disabled = true; + setStatus( + activeWindows.length > 1 + ? `发送中(${activeWindows.length} 个窗口并行)…` + : "发送中…", + ); + try { + if (activeIsTemp) { + const temp = loadTemps().find((t) => t.id === activeConversationId); + if (!temp) throw new Error("临时会话不存在"); + const slots = { ...EMPTY_SLOTS, ...temp.slots }; + const data = await api("/api/direct-chat/ephemeral/complete", { + method: "POST", + body: JSON.stringify({ + text, + ...slots, + windows: activeWindows, + turns: activeTurns, + }), + }); + const multi = data.multi; + if (!multi?.turn) throw new Error("未返回回合结果"); + activeTurns = [...activeTurns, multi.turn]; + temp.turns = activeTurns; + temp.windows = activeWindows; + temp.slots = slots; + temp.updatedAt = nowIso(); + upsertTemp(temp); + els.input.value = ""; + const failed = (multi.results || []).filter((r) => r.error); + setStatus( + failed.length + ? `${failed.length} 个窗口失败,其余已写入` + : "", + failed.length > 0, + ); + renderArena(); + renderRail(); + return; + } + + const data = await api( + `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}/messages`, + { + method: "POST", + body: JSON.stringify({ text, windows: activeWindows }), + }, + ); + activeTurns = data.conversation.turns || []; + activeWindows = data.conversation.windows || activeWindows; + activeShell = data.shell; + els.input.value = ""; + const failed = (data.multi?.results || []).filter((r) => r.error); + setStatus( + failed.length ? `${failed.length} 个窗口失败,其余已写入` : "", + failed.length > 0, + ); + renderArena(); + if (activeShellId) { + const shellData = await api( + `/api/direct-chat/shells/${encodeURIComponent(activeShellId)}`, + ); + conversations = shellData.conversations || []; + renderRail(); + } + } catch (err) { + setStatus(err instanceof Error ? err.message : String(err), true); + } finally { + busy = false; + els.btnSend.disabled = false; + } +} + +async function selectReply(turnId, windowId) { + const turn = activeTurns.find((t) => t.id === turnId); + if (!turn) return; + const next = + turn.selectedWindowId === windowId ? null : windowId; + turn.selectedWindowId = next; + activeTurns = activeTurns.map((t) => + t.id === turnId ? { ...t, selectedWindowId: next } : t, + ); + renderArena(); + + if (activeIsTemp) { + persistActiveSessionMeta(); + setStatus( + next + ? `已保留「${windowLabel(activeWindows.find((w) => w.id === next) || {})}」为后续共用基准` + : "已取消保留,各窗口继续用各自回复", + ); + return; + } + try { + const data = await api( + `/api/direct-chat/conversations/${encodeURIComponent(activeConversationId)}/turns/${encodeURIComponent(turnId)}/select`, + { + method: "POST", + body: JSON.stringify({ windowId: next }), + }, + ); + activeTurns = data.conversation.turns || activeTurns; + renderArena(); + setStatus( + next + ? `已保留「${windowLabel(activeWindows.find((w) => w.id === next) || {})}」为后续共用基准` + : "已取消保留,各窗口继续用各自回复", + ); + } catch (err) { + setStatus(err instanceof Error ? err.message : String(err), true); + } +} + +function openAddWindowDialog() { + const used = new Set(activeWindows.map((w) => w.profileId)); + const available = profileList.filter((p) => p.hasKey && !used.has(p.id)); + if (!available.length) { + setStatus( + used.size + ? "没有更多可用的 API 配置" + : "请先在设置里配置带 Key 的 API", + true, + ); + return; + } + els.addWindowProfile.innerHTML = available + .map( + (p) => + ``, + ) + .join(""); + els.dialogAddWindow.showModal(); +} + +els.formAddWindow.addEventListener("submit", (e) => { + const submitter = e.submitter; + if (submitter?.value === "cancel") return; + e.preventDefault(); + const profileId = els.addWindowProfile.value; + const p = profileList.find((x) => x.id === profileId); + if (!p) return; + if (activeWindows.some((w) => w.profileId === profileId)) { + setStatus("该配置已在窗口中", true); + return; + } + activeWindows = [ + ...activeWindows, + { + id: newId(), + profileId: p.id, + profileName: p.name, + model: p.model, + }, + ]; + persistActiveSessionMeta(); + els.dialogAddWindow.close(); + renderArena(); + setStatus(`已新增窗口:${p.name}`); +}); + +function removeWindow(windowId) { + if (activeWindows.length <= 1) { + setStatus("至少保留一个窗口", true); + return; + } + activeWindows = activeWindows.filter((w) => w.id !== windowId); + activeTurns = activeTurns.map((t) => ({ + ...t, + replies: t.replies.filter((r) => r.windowId !== windowId), + selectedWindowId: + t.selectedWindowId === windowId ? null : t.selectedWindowId, + })); + persistActiveSessionMeta(); + renderArena(); +} + +els.formPromote.addEventListener("submit", async (e) => { + const submitter = e.submitter; + if (submitter?.value === "cancel") return; + e.preventDefault(); + if (!activeIsTemp || !activeConversationId) return; + const temp = loadTemps().find((t) => t.id === activeConversationId); + if (!temp) return; + try { + const data = await api("/api/direct-chat/promote", { + method: "POST", + body: JSON.stringify({ + shell: { + name: els.promoteShellName.value.trim() || "临时上下文", + ...(temp.slots || EMPTY_SLOTS), + }, + conversationName: els.promoteConvName.value.trim() || "会话", + windows: temp.windows || activeWindows, + turns: temp.turns || activeTurns, + }), + }); + deleteTemp(temp.id); + els.dialogPromote.close(); + await openConversation(data.conversation.id); + setStatus("已保存为持久会话"); + } catch (err) { + setStatus(err instanceof Error ? err.message : String(err), true); + } +}); + +async function deleteRailItem(id, isTemp) { + if (!window.confirm("确定删除?")) return; + try { + if (isTemp) { + deleteTemp(id); + if (activeConversationId === id) await goRoot(); + else { + renderRail(); + renderPath(); + } + return; + } + if (navLevel === "root" || activeIsTemp) { + await api(`/api/direct-chat/shells/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (activeShellId === id) await goRoot(); + else + await refreshShells().then(() => { + renderRail(); + }); + return; + } + await api(`/api/direct-chat/conversations/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (activeConversationId === id) { + if (activeShellId) await openShell(activeShellId); + else await goRoot(); + } else if (activeShellId) { + await refreshConversations(activeShellId); + renderRail(); + } + } catch (err) { + setStatus(err instanceof Error ? err.message : String(err), true); + } +} + +// events +els.btnNewShell.addEventListener("click", () => { + createShellAndOpen().catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); +}); +els.btnNewConv.addEventListener("click", () => { + createConversationUnderShell().catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); +}); +els.btnSaveShell.addEventListener("click", () => { + saveShellEditor().catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); +}); +els.btnAddWindow.addEventListener("click", () => openAddWindowDialog()); +els.btnSaveTemp.addEventListener("click", () => { + if (!activeIsTemp || !activeConversationId) return; + const temp = loadTemps().find((t) => t.id === activeConversationId); + if (!temp) return; + els.promoteShellName.value = "临时上下文"; + els.promoteConvName.value = temp.name || "会话"; + els.dialogPromote.showModal(); +}); +els.btnSend.addEventListener("click", () => { + sendMessage().catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); +}); +els.input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage().catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + } +}); + +els.pathList.addEventListener("click", (e) => { + const btn = e.target.closest("button[data-nav]"); + if (!btn) return; + const nav = btn.getAttribute("data-nav"); + if (nav === "root") { + goRoot().catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + } else if (nav === "shell" && activeShellId) { + openShell(activeShellId).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + } +}); + +els.railList.addEventListener("click", (e) => { + const del = e.target.closest("[data-action='delete']"); + const open = e.target.closest("[data-action='open']"); + const row = e.target.closest(".chat-rail-item"); + if (!row) return; + const id = row.getAttribute("data-id"); + const isTemp = row.getAttribute("data-temp") === "1"; + if (del) { + deleteRailItem(id, isTemp).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + return; + } + if (!open) return; + if (isTemp) { + openTemp(id); + return; + } + if (activeIsTemp || navLevel === "root") { + openShell(id).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + return; + } + openConversation(id).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); +}); + +els.arena?.addEventListener("click", (e) => { + const removeBtn = e.target.closest("[data-action='remove-window']"); + if (removeBtn) { + const col = removeBtn.closest("[data-window-id]"); + const id = col?.getAttribute("data-window-id"); + if (id) removeWindow(id); + return; + } + const selectBtn = e.target.closest("[data-action='select-reply']"); + if (!selectBtn) return; + const turnId = selectBtn.getAttribute("data-turn-id"); + const windowId = selectBtn.getAttribute("data-window-id"); + if (turnId && windowId) { + selectReply(turnId, windowId).catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); + } +}); + +loadProfiles() + .then(() => goRoot()) + .catch((err) => + setStatus(err instanceof Error ? err.message : String(err), true), + ); diff --git a/web/libraries.html b/web/libraries.html index 5c016ec..afb5b00 100644 --- a/web/libraries.html +++ b/web/libraries.html @@ -19,6 +19,7 @@ Writing Agent