增加与作品对话并列的直聊,多模型按列对照并可选定基准。

服务启动时顺带拉取开放路由价目,用户目录同时预留生图工作流。

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