Initial commit
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import type { ChatMessage } from "../llm/client.js";
|
||||
import type { PresetPackage } from "../types/preset.js";
|
||||
|
||||
export type MarkerResolver = (identifier: string) => string | null;
|
||||
|
||||
const defaultMarkerResolver: MarkerResolver = () => null;
|
||||
|
||||
/**
|
||||
* 按 prompt_order 装配 preset 消息,插入在所有业务 prompt 之前。
|
||||
*/
|
||||
export function assemblePresetMessages(
|
||||
preset: PresetPackage,
|
||||
resolveMarker: MarkerResolver = defaultMarkerResolver,
|
||||
): ChatMessage[] {
|
||||
const promptById = new Map(preset.prompts.map((p) => [p.id, p]));
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
const ordered = [...preset.promptOrder].sort(
|
||||
(a, b) => a.orderIndex - b.orderIndex,
|
||||
);
|
||||
|
||||
for (const orderItem of ordered) {
|
||||
if (!orderItem.enabled) continue;
|
||||
const entry = promptById.get(orderItem.promptId);
|
||||
if (!entry || !entry.enabled) continue;
|
||||
|
||||
let content = entry.content.trim();
|
||||
if (!content && entry.marker) {
|
||||
const resolved = resolveMarker(entry.sourceIdentifier);
|
||||
if (resolved?.trim()) content = resolved.trim();
|
||||
}
|
||||
|
||||
if (!content) continue;
|
||||
|
||||
messages.push({
|
||||
role: entry.role,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function mergeMessages(
|
||||
presetMessages: ChatMessage[],
|
||||
taskMessages: ChatMessage[],
|
||||
): ChatMessage[] {
|
||||
return [...presetMessages, ...taskMessages];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PresetPackage, PresetPromptRole } from "../types/preset.js";
|
||||
|
||||
export type PresetEnabledEntryView = {
|
||||
orderIndex: number;
|
||||
id: string;
|
||||
name: string;
|
||||
role: PresetPromptRole;
|
||||
marker: boolean;
|
||||
content: string;
|
||||
/** 实际会注入 LLM 请求(有非空 content) */
|
||||
willInject: boolean;
|
||||
};
|
||||
|
||||
/** 按 prompt_order 列出所有启用条目及其内容 */
|
||||
export function listEnabledPresetEntries(
|
||||
preset: PresetPackage,
|
||||
): PresetEnabledEntryView[] {
|
||||
const promptById = new Map(preset.prompts.map((p) => [p.id, p]));
|
||||
const ordered = [...preset.promptOrder].sort(
|
||||
(a, b) => a.orderIndex - b.orderIndex,
|
||||
);
|
||||
const entries: PresetEnabledEntryView[] = [];
|
||||
|
||||
for (const orderItem of ordered) {
|
||||
if (!orderItem.enabled) continue;
|
||||
const entry = promptById.get(orderItem.promptId);
|
||||
if (!entry || !entry.enabled) continue;
|
||||
|
||||
const content = entry.content.trim();
|
||||
entries.push({
|
||||
orderIndex: orderItem.orderIndex,
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
role: entry.role,
|
||||
marker: entry.marker,
|
||||
content,
|
||||
willInject: content.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function countInjectingEntries(entries: PresetEnabledEntryView[]): number {
|
||||
return entries.filter((e) => e.willInject).length;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
GenerationParameters,
|
||||
PresetImportReport,
|
||||
PresetPackage,
|
||||
PresetPromptEntry,
|
||||
PresetPromptOrderItem,
|
||||
UnsupportedPresetSection,
|
||||
} from "../types/preset.js";
|
||||
|
||||
type StPrompt = {
|
||||
identifier?: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
role?: string;
|
||||
content?: string;
|
||||
marker?: boolean;
|
||||
system_prompt?: boolean;
|
||||
injection_position?: number;
|
||||
injection_depth?: number;
|
||||
injection_order?: number;
|
||||
forbid_overrides?: boolean;
|
||||
};
|
||||
|
||||
type StPromptOrderBlock = {
|
||||
character_id?: number;
|
||||
order?: Array<{ identifier: string; enabled: boolean }>;
|
||||
};
|
||||
|
||||
type SillyTavernPreset = {
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
min_p?: number;
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
repetition_penalty?: number;
|
||||
openai_max_context?: number;
|
||||
openai_max_tokens?: number;
|
||||
stream_openai?: boolean;
|
||||
reasoning_effort?: string;
|
||||
verbosity?: string;
|
||||
seed?: number;
|
||||
n?: number;
|
||||
prompts?: StPrompt[];
|
||||
prompt_order?: StPromptOrderBlock[];
|
||||
regex_scripts?: unknown;
|
||||
extensions?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u4e00-\u9fff-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function normalizeRole(role: string | undefined): PresetPromptEntry["role"] {
|
||||
if (role === "user" || role === "assistant" || role === "system") {
|
||||
return role;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function readGeneration(raw: SillyTavernPreset): GenerationParameters {
|
||||
return {
|
||||
temperature: raw.temperature,
|
||||
topP: raw.top_p,
|
||||
topK: raw.top_k,
|
||||
minP: raw.min_p,
|
||||
frequencyPenalty: raw.frequency_penalty,
|
||||
presencePenalty: raw.presence_penalty,
|
||||
repetitionPenalty: raw.repetition_penalty,
|
||||
maxContextTokens: raw.openai_max_context,
|
||||
maxOutputTokens: raw.openai_max_tokens,
|
||||
stream: raw.stream_openai,
|
||||
reasoningEffort: raw.reasoning_effort,
|
||||
verbosity: raw.verbosity,
|
||||
seed: raw.seed !== undefined && raw.seed >= 0 ? raw.seed : undefined,
|
||||
variants: raw.n,
|
||||
};
|
||||
}
|
||||
|
||||
function selectPromptOrder(
|
||||
raw: SillyTavernPreset,
|
||||
promptMap: Map<string, StPrompt>,
|
||||
): StPromptOrderBlock {
|
||||
const orders = raw.prompt_order ?? [];
|
||||
if (orders.length === 0) return { order: [] };
|
||||
if (orders.length === 1) return orders[0];
|
||||
|
||||
let best = orders[0];
|
||||
let bestScore = -1;
|
||||
for (const block of orders) {
|
||||
let score = 0;
|
||||
for (const item of block.order ?? []) {
|
||||
if (!item.enabled) continue;
|
||||
const prompt = promptMap.get(item.identifier);
|
||||
if (!prompt) continue;
|
||||
if (prompt.content?.trim()) score += 3;
|
||||
else if (prompt.marker) score += 0;
|
||||
else score += 1;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = block;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function collectUnsupported(raw: SillyTavernPreset): UnsupportedPresetSection[] {
|
||||
const unsupported: UnsupportedPresetSection[] = [];
|
||||
if (raw.regex_scripts) {
|
||||
unsupported.push({
|
||||
path: "regex_scripts",
|
||||
reason: "第一版不支持正则脚本",
|
||||
});
|
||||
}
|
||||
if (raw.extensions) {
|
||||
unsupported.push({
|
||||
path: "extensions",
|
||||
reason: "第一版不支持扩展脚本",
|
||||
});
|
||||
}
|
||||
return unsupported;
|
||||
}
|
||||
|
||||
export function importSillyTavernPreset(
|
||||
rawInput: unknown,
|
||||
options: { name?: string; id?: string } = {},
|
||||
): PresetImportReport {
|
||||
const raw = rawInput as SillyTavernPreset;
|
||||
const warnings: string[] = [];
|
||||
const stPrompts = raw.prompts ?? [];
|
||||
const promptMap = new Map<string, StPrompt>();
|
||||
|
||||
for (const p of stPrompts) {
|
||||
const id = p.identifier ?? randomUUID();
|
||||
if (promptMap.has(id)) {
|
||||
warnings.push(`重复 identifier: ${id}`);
|
||||
}
|
||||
promptMap.set(id, p);
|
||||
}
|
||||
|
||||
const selectedOrder = selectPromptOrder(raw, promptMap);
|
||||
if ((raw.prompt_order?.length ?? 0) > 1) {
|
||||
warnings.push(
|
||||
`检测到 ${raw.prompt_order!.length} 套 prompt_order,已自动选用启用内容最多的一套 (character_id=${selectedOrder.character_id ?? "?"})`,
|
||||
);
|
||||
}
|
||||
|
||||
const prompts: PresetPromptEntry[] = stPrompts.map((p) => {
|
||||
const sourceId = p.identifier ?? randomUUID();
|
||||
return {
|
||||
id: sourceId,
|
||||
name: p.name ?? sourceId,
|
||||
enabled: p.enabled !== false,
|
||||
role: normalizeRole(p.role),
|
||||
content: p.content ?? "",
|
||||
marker: Boolean(p.marker),
|
||||
sourceIdentifier: sourceId,
|
||||
injection: {
|
||||
position: p.injection_position,
|
||||
depth: p.injection_depth,
|
||||
order: p.injection_order,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const promptOrder: PresetPromptOrderItem[] = [];
|
||||
const missingIdentifiers: string[] = [];
|
||||
const orderItems = selectedOrder.order ?? [];
|
||||
|
||||
orderItems.forEach((item, index) => {
|
||||
if (!promptMap.has(item.identifier)) {
|
||||
missingIdentifiers.push(item.identifier);
|
||||
return;
|
||||
}
|
||||
promptOrder.push({
|
||||
promptId: item.identifier,
|
||||
enabled: item.enabled,
|
||||
orderIndex: index,
|
||||
});
|
||||
});
|
||||
|
||||
const referenced = new Set(promptOrder.map((o) => o.promptId));
|
||||
const unreferencedCount = prompts.filter(
|
||||
(p) => !referenced.has(p.id),
|
||||
).length;
|
||||
|
||||
const enabledCount = promptOrder.filter((o) => {
|
||||
if (!o.enabled) return false;
|
||||
const entry = prompts.find((p) => p.id === o.promptId);
|
||||
return entry?.enabled !== false;
|
||||
}).length;
|
||||
|
||||
const generation = readGeneration(raw);
|
||||
const generationFields = Object.entries(generation)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k]) => k);
|
||||
|
||||
const presetName =
|
||||
options.name?.trim() ||
|
||||
(typeof rawInput === "object" &&
|
||||
rawInput &&
|
||||
"name" in rawInput &&
|
||||
typeof (rawInput as { name: unknown }).name === "string"
|
||||
? (rawInput as { name: string }).name
|
||||
: "导入的预设");
|
||||
|
||||
const preset: PresetPackage = {
|
||||
id: options.id ?? `${slugify(presetName) || "preset"}-${randomUUID().slice(0, 8)}`,
|
||||
name: presetName,
|
||||
source: "sillytavern",
|
||||
prompts,
|
||||
promptOrder,
|
||||
generation,
|
||||
unsupported: collectUnsupported(raw),
|
||||
importedAt: new Date().toISOString(),
|
||||
raw: rawInput,
|
||||
};
|
||||
|
||||
return {
|
||||
preset,
|
||||
promptCount: prompts.length,
|
||||
enabledCount,
|
||||
unreferencedCount,
|
||||
missingIdentifiers,
|
||||
generationFields,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { PresetPackage } from "../types/preset.js";
|
||||
import { ensureUserDataDirs, getPresetsDir } from "../config/user-data-dir.js";
|
||||
import { importSillyTavernPreset } from "./importer.js";
|
||||
|
||||
function presetPath(id: string): string {
|
||||
return path.join(getPresetsDir(), `${id}.json`);
|
||||
}
|
||||
|
||||
export function listPresets(): PresetPackage[] {
|
||||
ensureUserDataDirs();
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(getPresetsDir()).filter((f) => f.endsWith(".json"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const presets: PresetPackage[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = readFileSync(path.join(getPresetsDir(), file), "utf8");
|
||||
presets.push(JSON.parse(raw) as PresetPackage);
|
||||
} catch {
|
||||
/* skip corrupt files */
|
||||
}
|
||||
}
|
||||
return presets.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
|
||||
}
|
||||
|
||||
export function getPreset(id: string): PresetPackage | null {
|
||||
try {
|
||||
const raw = readFileSync(presetPath(id), "utf8");
|
||||
return JSON.parse(raw) as PresetPackage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePreset(preset: PresetPackage): PresetPackage {
|
||||
ensureUserDataDirs();
|
||||
writeFileSync(presetPath(preset.id), JSON.stringify(preset, null, 2), "utf8");
|
||||
return preset;
|
||||
}
|
||||
|
||||
export function deletePreset(id: string): void {
|
||||
try {
|
||||
unlinkSync(presetPath(id));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function importAndSavePreset(
|
||||
raw: unknown,
|
||||
options: { name?: string } = {},
|
||||
) {
|
||||
const report = importSillyTavernPreset(raw, options);
|
||||
savePreset(report.preset);
|
||||
return report;
|
||||
}
|
||||
|
||||
export function resolveActivePreset(activePresetId: string | null): PresetPackage | null {
|
||||
if (!activePresetId) return null;
|
||||
return getPreset(activePresetId);
|
||||
}
|
||||
Reference in New Issue
Block a user