设置页接入用户角色管理与预设采样编辑。
并把指令目录接口一并挂上,供输入框补全拉取。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
|||||||
patchPresetEntries,
|
patchPresetEntries,
|
||||||
type PresetEntryPatch,
|
type PresetEntryPatch,
|
||||||
} from "../preset/entries.js";
|
} from "../preset/entries.js";
|
||||||
|
import { patchPresetGeneration } from "../preset/generation.js";
|
||||||
import { runPresetProbe } from "../preset/probe.js";
|
import { runPresetProbe } from "../preset/probe.js";
|
||||||
import {
|
import {
|
||||||
deletePreset,
|
deletePreset,
|
||||||
@@ -33,10 +34,21 @@ import {
|
|||||||
importAndSavePreset,
|
importAndSavePreset,
|
||||||
listPresets,
|
listPresets,
|
||||||
} from "../preset/store.js";
|
} from "../preset/store.js";
|
||||||
|
import type { GenerationParameters } from "../types/preset.js";
|
||||||
import {
|
import {
|
||||||
createLlmForPreset,
|
createLlmForPreset,
|
||||||
hasRealLlmConfig,
|
hasRealLlmConfig,
|
||||||
} from "../runtime/llm-factory.js";
|
} from "../runtime/llm-factory.js";
|
||||||
|
import {
|
||||||
|
createPersona,
|
||||||
|
deletePersona,
|
||||||
|
getActivePersona,
|
||||||
|
getCreationDefaultPersona,
|
||||||
|
listPersonas,
|
||||||
|
setActivePersonaId,
|
||||||
|
updatePersona,
|
||||||
|
} from "../persona/store.js";
|
||||||
|
import { listDirectiveCatalog } from "../directives/expand.js";
|
||||||
import { sessionManager } from "./session-manager.js";
|
import { sessionManager } from "./session-manager.js";
|
||||||
|
|
||||||
async function readBody(req: IncomingMessage): Promise<string> {
|
async function readBody(req: IncomingMessage): Promise<string> {
|
||||||
@@ -52,6 +64,16 @@ function json(res: ServerResponse, status: number, data: unknown): void {
|
|||||||
res.end(JSON.stringify(data));
|
res.end(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function personasPayload(extra: Record<string, unknown> = {}) {
|
||||||
|
const creation = getCreationDefaultPersona();
|
||||||
|
return {
|
||||||
|
personas: listPersonas(),
|
||||||
|
activeId: getActivePersona()?.id ?? null,
|
||||||
|
creationDefaultId: creation?.id ?? null,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleSettingsApi(
|
export async function handleSettingsApi(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
@@ -332,18 +354,25 @@ export async function handleSettingsApi(
|
|||||||
const body = JSON.parse(await readBody(req)) as {
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
entries?: PresetEntryPatch[];
|
entries?: PresetEntryPatch[];
|
||||||
entry?: PresetEntryPatch;
|
entry?: PresetEntryPatch;
|
||||||
|
generation?: GenerationParameters | Record<string, unknown>;
|
||||||
};
|
};
|
||||||
const patches = body.entries?.length
|
const patches = body.entries?.length
|
||||||
? body.entries
|
? body.entries
|
||||||
: body.entry
|
: body.entry
|
||||||
? [body.entry]
|
? [body.entry]
|
||||||
: [];
|
: [];
|
||||||
if (!patches.length) {
|
const hasGeneration = Object.prototype.hasOwnProperty.call(
|
||||||
json(res, 400, { error: "缺少 entries 或 entry" });
|
body,
|
||||||
|
"generation",
|
||||||
|
);
|
||||||
|
if (!patches.length && !hasGeneration) {
|
||||||
|
json(res, 400, { error: "缺少 entries、entry 或 generation" });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const next = patchPresetEntries(preset, patches);
|
let next = preset;
|
||||||
|
if (patches.length) next = patchPresetEntries(next, patches);
|
||||||
|
if (hasGeneration) next = patchPresetGeneration(next, body.generation);
|
||||||
const entries = listAllPresetEntries(next);
|
const entries = listAllPresetEntries(next);
|
||||||
const settings = loadAppSettings();
|
const settings = loadAppSettings();
|
||||||
let reloadedSessions = 0;
|
let reloadedSessions = 0;
|
||||||
@@ -412,5 +441,98 @@ export async function handleSettingsApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/directives/catalog" && req.method === "GET") {
|
||||||
|
json(res, 200, { items: listDirectiveCatalog() });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/personas" && req.method === "GET") {
|
||||||
|
const active = getActivePersona();
|
||||||
|
json(res, 200, {
|
||||||
|
...personasPayload(),
|
||||||
|
active,
|
||||||
|
creationDefault: getCreationDefaultPersona(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/api/personas" && req.method === "POST") {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
activate?: boolean;
|
||||||
|
creationDefault?: boolean;
|
||||||
|
};
|
||||||
|
const persona = createPersona({
|
||||||
|
name: body.name ?? "",
|
||||||
|
description: body.description,
|
||||||
|
activate: body.activate,
|
||||||
|
creationDefault: body.creationDefault,
|
||||||
|
});
|
||||||
|
json(res, 201, {
|
||||||
|
...personasPayload({ persona }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
json(res, 400, {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const personaMatch = pathname.match(
|
||||||
|
/^\/api\/personas\/([^/]+)(\/activate)?$/,
|
||||||
|
);
|
||||||
|
if (personaMatch) {
|
||||||
|
const id = decodeURIComponent(personaMatch[1]);
|
||||||
|
const activate = Boolean(personaMatch[2]);
|
||||||
|
|
||||||
|
if (activate && req.method === "POST") {
|
||||||
|
try {
|
||||||
|
const persona = setActivePersonaId(id);
|
||||||
|
json(res, 200, {
|
||||||
|
...personasPayload({ persona, activeId: persona.id }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
json(res, 404, {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT") {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(await readBody(req)) as {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
creationDefault?: boolean;
|
||||||
|
};
|
||||||
|
const persona = updatePersona(id, body);
|
||||||
|
json(res, 200, {
|
||||||
|
...personasPayload({ persona }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
json(res, msg.includes("不存在") ? 404 : 400, { error: msg });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
try {
|
||||||
|
deletePersona(id);
|
||||||
|
json(res, 200, {
|
||||||
|
...personasPayload({ ok: true }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
json(res, msg.includes("不存在") ? 404 : 400, { error: msg });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -521,6 +521,73 @@ code {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preset-gen-editor {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--st-surface);
|
||||||
|
border: 1px solid var(--st-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-gen-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--st-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-gen-hint {
|
||||||
|
color: var(--st-faint);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-gen-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-gen-grid .gen-field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-gen-grid .gen-field-check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-gen-grid .gen-field-check input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-gen-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.preset-gen-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.preset-gen-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-entries-panel {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.preset-entries-summary {
|
.preset-entries-summary {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--st-faint);
|
color: var(--st-faint);
|
||||||
@@ -1102,6 +1169,58 @@ code {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-dialog label.check-label {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--st-text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dialog label.check-label input {
|
||||||
|
width: auto;
|
||||||
|
margin-top: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dialog label.check-label .field-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-card-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--st-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-card-check input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--st-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-card-check:hover {
|
||||||
|
color: var(--st-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card.is-active {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: inset 3px 0 0 var(--st-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-config-card .config-card-actions {
|
||||||
|
padding-left: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-actions {
|
.dialog-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
+40
-1
@@ -22,6 +22,7 @@
|
|||||||
<aside class="st-rail" aria-label="设置分类">
|
<aside class="st-rail" aria-label="设置分类">
|
||||||
<p class="st-rail-label">设置</p>
|
<p class="st-rail-label">设置</p>
|
||||||
<button type="button" class="st-rail-item active" data-section="api">API</button>
|
<button type="button" class="st-rail-item active" data-section="api">API</button>
|
||||||
|
<button type="button" class="st-rail-item" data-section="persona">用户角色</button>
|
||||||
<button type="button" class="st-rail-item" data-section="preset">预设</button>
|
<button type="button" class="st-rail-item" data-section="preset">预设</button>
|
||||||
<button type="button" class="st-rail-item" data-section="storage">上下文</button>
|
<button type="button" class="st-rail-item" data-section="storage">上下文</button>
|
||||||
<p class="st-rail-note">保存在本机 <code>~/.writing-agent/</code>,明文、不同步。</p>
|
<p class="st-rail-note">保存在本机 <code>~/.writing-agent/</code>,明文、不同步。</p>
|
||||||
@@ -44,6 +45,19 @@
|
|||||||
<div id="profiles-list" class="card-list"></div>
|
<div id="profiles-list" class="card-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section hidden" id="section-persona">
|
||||||
|
<div class="settings-card storage-card">
|
||||||
|
<h3>用户角色</h3>
|
||||||
|
<p class="settings-help">
|
||||||
|
游玩时,对话里 <code>@玩家</code> / <code>{{user}}</code> 会换成当前选中角色的名字;
|
||||||
|
<code>@人设</code> 与预设的 persona 洞用其正文。可在输入框旁快速切换。
|
||||||
|
创作阶段不用当前选用,而用下方勾选的「创作默认」填入预设用户设定。
|
||||||
|
建议把创作默认的名称写成 <code>@玩家</code>:模型会按这个称呼写开场白,替换后仍是占位,进游玩再换成当前角色。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="personas-list" class="card-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-section hidden" id="section-preset">
|
<section class="settings-section hidden" id="section-preset">
|
||||||
<div id="presets-list" class="card-list"></div>
|
<div id="presets-list" class="card-list"></div>
|
||||||
<pre id="import-report" class="import-report" hidden></pre>
|
<pre id="import-report" class="import-report" hidden></pre>
|
||||||
@@ -158,7 +172,7 @@
|
|||||||
<option value="high">high · 增强</option>
|
<option value="high">high · 增强</option>
|
||||||
<option value="max">max · 深度</option>
|
<option value="max">max · 深度</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="field-hint">写入 reasoning_effort;「探测」始终用 low,与此项无关。带 function tools 的请求会强制 none(兼容 gpt-5.6-luna 等)。GLM 建议 low / high / max。</span>
|
<span class="field-hint">写入请求的思考参数。GLM-5.3:仅 low/high/max(none 会映射为 low),并附带 thinking.enabled;旧 GLM 只用 thinking、不发 reasoning_effort。探测始终用 low。带 function tools 时:非 GLM 强制 none;GLM-5.3 保留合法档。</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button type="button" id="profile-cancel" class="btn-secondary">取消</button>
|
<button type="button" id="profile-cancel" class="btn-secondary">取消</button>
|
||||||
@@ -168,6 +182,31 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="persona-dialog" class="profile-dialog">
|
||||||
|
<form id="persona-form" method="dialog">
|
||||||
|
<h3 id="persona-dialog-title">新增用户角色</h3>
|
||||||
|
<label>
|
||||||
|
名称
|
||||||
|
<input name="name" required placeholder="例如 林晚,或创作默认用 @玩家" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
人设
|
||||||
|
<textarea name="description" rows="6" placeholder="性格、外貌、背景……(可空)"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="check-label">
|
||||||
|
<input type="checkbox" name="creationDefault" />
|
||||||
|
<span>
|
||||||
|
作为创作默认用户设定
|
||||||
|
<span class="field-hint">创作阶段填入预设的用户设定洞。建议名称写成 @玩家,这样开场白会自然用该占位,进游玩再换成当前角色。</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" id="persona-cancel" class="btn-secondary">取消</button>
|
||||||
|
<button type="submit" class="btn-primary">保存并选用</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<input type="file" id="preset-file" accept=".json,application/json" hidden />
|
<input type="file" id="preset-file" accept=".json,application/json" hidden />
|
||||||
<script src="/settings.js" type="module"></script>
|
<script src="/settings.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+325
-10
@@ -2,9 +2,13 @@ let state = {
|
|||||||
settings: { activeProfileId: null, activePresetId: null },
|
settings: { activeProfileId: null, activePresetId: null },
|
||||||
profiles: [],
|
profiles: [],
|
||||||
presets: [],
|
presets: [],
|
||||||
|
personas: [],
|
||||||
|
activePersonaId: null,
|
||||||
|
creationDefaultId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
let editingProfileId = null;
|
let editingProfileId = null;
|
||||||
|
let editingPersonaId = null;
|
||||||
let activeSection = "api";
|
let activeSection = "api";
|
||||||
let lastImportedPresetId = null;
|
let lastImportedPresetId = null;
|
||||||
/** presetId → entries API payload */
|
/** presetId → entries API payload */
|
||||||
@@ -13,16 +17,21 @@ const expandedPresetEntries = new Map();
|
|||||||
const openPresetEntryId = new Map();
|
const openPresetEntryId = new Map();
|
||||||
|
|
||||||
const profilesListEl = document.getElementById("profiles-list");
|
const profilesListEl = document.getElementById("profiles-list");
|
||||||
|
const personasListEl = document.getElementById("personas-list");
|
||||||
const presetsListEl = document.getElementById("presets-list");
|
const presetsListEl = document.getElementById("presets-list");
|
||||||
const importReportEl = document.getElementById("import-report");
|
const importReportEl = document.getElementById("import-report");
|
||||||
const profileDialog = document.getElementById("profile-dialog");
|
const profileDialog = document.getElementById("profile-dialog");
|
||||||
const profileForm = document.getElementById("profile-form");
|
const profileForm = document.getElementById("profile-form");
|
||||||
const profileDialogTitle = document.getElementById("profile-dialog-title");
|
const profileDialogTitle = document.getElementById("profile-dialog-title");
|
||||||
|
const personaDialog = document.getElementById("persona-dialog");
|
||||||
|
const personaForm = document.getElementById("persona-form");
|
||||||
|
const personaDialogTitle = document.getElementById("persona-dialog-title");
|
||||||
const presetFileEl = document.getElementById("preset-file");
|
const presetFileEl = document.getElementById("preset-file");
|
||||||
const panelTitleEl = document.getElementById("panel-title");
|
const panelTitleEl = document.getElementById("panel-title");
|
||||||
const panelSubtitleEl = document.getElementById("panel-subtitle");
|
const panelSubtitleEl = document.getElementById("panel-subtitle");
|
||||||
const panelActionsEl = document.getElementById("panel-actions");
|
const panelActionsEl = document.getElementById("panel-actions");
|
||||||
const sectionApiEl = document.getElementById("section-api");
|
const sectionApiEl = document.getElementById("section-api");
|
||||||
|
const sectionPersonaEl = document.getElementById("section-persona");
|
||||||
const sectionPresetEl = document.getElementById("section-preset");
|
const sectionPresetEl = document.getElementById("section-preset");
|
||||||
const sectionStorageEl = document.getElementById("section-storage");
|
const sectionStorageEl = document.getElementById("section-storage");
|
||||||
const activeSettingsBarEl = document.getElementById("active-settings-bar");
|
const activeSettingsBarEl = document.getElementById("active-settings-bar");
|
||||||
@@ -43,9 +52,9 @@ const probeReplyEl = document.getElementById("probe-reply");
|
|||||||
|
|
||||||
const DEFAULT_PROBE_CONTEXT = {
|
const DEFAULT_PROBE_CONTEXT = {
|
||||||
message: "我推开门。",
|
message: "我推开门。",
|
||||||
loreBefore: "一座雨夜的港口旅馆。柜台点着油灯。",
|
loreBefore: "写作要求:对本轮输入做忠实扩写;只展开已给出的动作与信息,不另起情节。",
|
||||||
history: "店员:今晚只剩阁楼那间。\n你:好。",
|
history: "扩写范围仅限本轮输入,勿补前因后果或无关对话。",
|
||||||
loreAfter: "时间:深夜。地点:旅馆大厅。",
|
loreAfter: "",
|
||||||
postTurn: "",
|
postTurn: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,12 +103,16 @@ function renderActiveBar() {
|
|||||||
const preset = state.presets.find(
|
const preset = state.presets.find(
|
||||||
(p) => p.id === state.settings.activePresetId,
|
(p) => p.id === state.settings.activePresetId,
|
||||||
);
|
);
|
||||||
|
const persona = state.personas.find((p) => p.id === state.activePersonaId);
|
||||||
|
|
||||||
activeSettingsBarEl.innerHTML = `
|
activeSettingsBarEl.innerHTML = `
|
||||||
<span class="label">当前生效</span>
|
<span class="label">当前生效</span>
|
||||||
<span class="active-tag ${profile ? "" : "missing"}">
|
<span class="active-tag ${profile ? "" : "missing"}">
|
||||||
API: ${profile ? escapeHtml(`${profile.name} · ${profile.model}`) : "未选用"}
|
API: ${profile ? escapeHtml(`${profile.name} · ${profile.model}`) : "未选用"}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="active-tag ${persona ? "" : "missing"}">
|
||||||
|
角色: ${persona ? escapeHtml(persona.name) : "未选用"}
|
||||||
|
</span>
|
||||||
<span class="active-tag ${preset ? "" : "missing"}">
|
<span class="active-tag ${preset ? "" : "missing"}">
|
||||||
预设: ${preset ? escapeHtml(preset.name) : "未选用"}
|
预设: ${preset ? escapeHtml(preset.name) : "未选用"}
|
||||||
</span>`;
|
</span>`;
|
||||||
@@ -111,6 +124,7 @@ function switchSection(section) {
|
|||||||
el.classList.toggle("active", el.dataset.section === section);
|
el.classList.toggle("active", el.dataset.section === section);
|
||||||
});
|
});
|
||||||
sectionApiEl.classList.toggle("hidden", section !== "api");
|
sectionApiEl.classList.toggle("hidden", section !== "api");
|
||||||
|
sectionPersonaEl?.classList.toggle("hidden", section !== "persona");
|
||||||
sectionPresetEl.classList.toggle("hidden", section !== "preset");
|
sectionPresetEl.classList.toggle("hidden", section !== "preset");
|
||||||
sectionStorageEl?.classList.toggle("hidden", section !== "storage");
|
sectionStorageEl?.classList.toggle("hidden", section !== "storage");
|
||||||
renderPanelHeader();
|
renderPanelHeader();
|
||||||
@@ -127,6 +141,16 @@ function renderPanelHeader() {
|
|||||||
btn.textContent = "新增";
|
btn.textContent = "新增";
|
||||||
btn.addEventListener("click", () => openProfileDialog());
|
btn.addEventListener("click", () => openProfileDialog());
|
||||||
panelActionsEl.appendChild(btn);
|
panelActionsEl.appendChild(btn);
|
||||||
|
} else if (activeSection === "persona") {
|
||||||
|
panelTitleEl.textContent = "用户角色";
|
||||||
|
panelSubtitleEl.textContent =
|
||||||
|
"游玩用当前选用;创作用「创作默认」(建议名为 @玩家)";
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "btn-primary";
|
||||||
|
btn.textContent = "新增";
|
||||||
|
btn.addEventListener("click", () => openPersonaDialog());
|
||||||
|
panelActionsEl.appendChild(btn);
|
||||||
} else if (activeSection === "preset") {
|
} else if (activeSection === "preset") {
|
||||||
panelTitleEl.textContent = "预设";
|
panelTitleEl.textContent = "预设";
|
||||||
panelSubtitleEl.textContent =
|
panelSubtitleEl.textContent =
|
||||||
@@ -259,15 +283,140 @@ function renderPresets() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GENERATION_FIELDS = [
|
||||||
|
{ key: "temperature", label: "temperature", step: "0.01", placeholder: "如 1" },
|
||||||
|
{ key: "topP", label: "top P", step: "0.01", placeholder: "如 0.95" },
|
||||||
|
{ key: "topK", label: "top K", step: "1", placeholder: "可选" },
|
||||||
|
{ key: "minP", label: "min P", step: "0.01", placeholder: "可选" },
|
||||||
|
{
|
||||||
|
key: "frequencyPenalty",
|
||||||
|
label: "frequency penalty",
|
||||||
|
step: "0.01",
|
||||||
|
placeholder: "可选",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "presencePenalty",
|
||||||
|
label: "presence penalty",
|
||||||
|
step: "0.01",
|
||||||
|
placeholder: "可选",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "repetitionPenalty",
|
||||||
|
label: "repetition penalty",
|
||||||
|
step: "0.01",
|
||||||
|
placeholder: "可选",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "maxContextTokens",
|
||||||
|
label: "最大上下文",
|
||||||
|
step: "1",
|
||||||
|
placeholder: "输入预算",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "maxOutputTokens",
|
||||||
|
label: "最大输出",
|
||||||
|
step: "1",
|
||||||
|
placeholder: "如 60000",
|
||||||
|
},
|
||||||
|
{ key: "seed", label: "seed", step: "1", placeholder: "可选" },
|
||||||
|
{
|
||||||
|
key: "variants",
|
||||||
|
label: "n / variants",
|
||||||
|
step: "1",
|
||||||
|
placeholder: "可选",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function generationFieldValue(gen, key) {
|
||||||
|
const v = gen?.[key];
|
||||||
|
return v === undefined || v === null ? "" : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGenerationForm(gen) {
|
||||||
|
const g = gen ?? {};
|
||||||
|
const numberFields = GENERATION_FIELDS.map(
|
||||||
|
(f) => `
|
||||||
|
<label class="entry-field gen-field">
|
||||||
|
${escapeHtml(f.label)}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="entry-name-input"
|
||||||
|
data-gen-field="${escapeHtml(f.key)}"
|
||||||
|
step="${escapeHtml(f.step)}"
|
||||||
|
placeholder="${escapeHtml(f.placeholder)}"
|
||||||
|
value="${escapeHtml(generationFieldValue(g, f.key))}"
|
||||||
|
/>
|
||||||
|
</label>`,
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="preset-gen-editor">
|
||||||
|
<div class="preset-gen-head">
|
||||||
|
<strong>生成参数</strong>
|
||||||
|
<span class="preset-gen-hint">上半 · 采样与容量;空字段表示不发送</span>
|
||||||
|
</div>
|
||||||
|
<div class="preset-gen-grid">
|
||||||
|
${numberFields}
|
||||||
|
<label class="entry-field gen-field">
|
||||||
|
reasoning effort
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="entry-name-input"
|
||||||
|
data-gen-field="reasoningEffort"
|
||||||
|
placeholder="如 low / medium / high"
|
||||||
|
value="${escapeHtml(generationFieldValue(g, "reasoningEffort"))}"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="entry-field gen-field">
|
||||||
|
verbosity
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="entry-name-input"
|
||||||
|
data-gen-field="verbosity"
|
||||||
|
placeholder="可选"
|
||||||
|
value="${escapeHtml(generationFieldValue(g, "verbosity"))}"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="entry-field gen-field gen-field-check">
|
||||||
|
<span>stream</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-gen-field="stream"
|
||||||
|
${g.stream === true ? "checked" : ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="preset-gen-actions">
|
||||||
|
<button type="button" class="btn-primary" data-action="save-generation">保存生成参数</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectGenerationFromPanel(panel) {
|
||||||
|
const generation = {};
|
||||||
|
for (const input of panel.querySelectorAll("[data-gen-field]")) {
|
||||||
|
const key = input.dataset.genField;
|
||||||
|
if (!key) continue;
|
||||||
|
if (input.type === "checkbox") {
|
||||||
|
if (input.checked) generation.stream = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const raw = input.value.trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
if (key === "reasoningEffort" || key === "verbosity") {
|
||||||
|
generation[key] = raw;
|
||||||
|
} else {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (Number.isFinite(n)) generation[key] = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return generation;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPresetEntriesPanel(presetId, data) {
|
function renderPresetEntriesPanel(presetId, data) {
|
||||||
const panel = document.getElementById(`preset-entries-${presetId}`);
|
const panel = document.getElementById(`preset-entries-${presetId}`);
|
||||||
if (!panel || !data) return;
|
if (!panel || !data) return;
|
||||||
|
|
||||||
const gen = data.generation ?? {};
|
|
||||||
const genLines = Object.entries(gen)
|
|
||||||
.filter(([, v]) => v !== undefined && v !== null)
|
|
||||||
.map(([k, v]) => `${k}: ${v}`);
|
|
||||||
|
|
||||||
const openId = openPresetEntryId.get(presetId) ?? null;
|
const openId = openPresetEntryId.get(presetId) ?? null;
|
||||||
|
|
||||||
const entriesHtml = data.entries
|
const entriesHtml = data.entries
|
||||||
@@ -324,8 +473,8 @@ function renderPresetEntriesPanel(presetId, data) {
|
|||||||
|
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="preset-entries-inner" data-preset-id="${escapeHtml(presetId)}">
|
<div class="preset-entries-inner" data-preset-id="${escapeHtml(presetId)}">
|
||||||
${genLines.length ? `<div class="preset-gen-params"><strong>生成参数</strong> ${escapeHtml(genLines.join(" · "))}</div>` : ""}
|
${renderGenerationForm(data.generation)}
|
||||||
<p class="preset-entries-summary">共 ${data.entries.length} 条 · 启用 ${data.enabledCount ?? 0} · 注入 ${data.injectingCount ?? 0}。点条目展开编辑;启用开关即时保存。</p>
|
<p class="preset-entries-summary">下半 · 共 ${data.entries.length} 条 · 启用 ${data.enabledCount ?? 0} · 注入 ${data.injectingCount ?? 0}。点条目展开编辑;启用开关即时保存。</p>
|
||||||
${entriesHtml || '<p class="empty-hint">无条目</p>'}
|
${entriesHtml || '<p class="empty-hint">无条目</p>'}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -375,11 +524,36 @@ async function patchPresetEntry(presetId, patch) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function patchPresetGeneration(presetId, generation) {
|
||||||
|
const data = await api(`/api/presets/${encodeURIComponent(presetId)}/entries`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ generation }),
|
||||||
|
});
|
||||||
|
expandedPresetEntries.set(presetId, data);
|
||||||
|
renderPresets();
|
||||||
|
const n = data.reloadedSessions ?? 0;
|
||||||
|
showToast(
|
||||||
|
n > 0
|
||||||
|
? `生成参数已保存,并热更新 ${n} 个会话`
|
||||||
|
: "生成参数已保存(选用此预设后才会进入请求)",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
const data = await api("/api/settings");
|
const data = await api("/api/settings");
|
||||||
state.settings = data.settings;
|
state.settings = data.settings;
|
||||||
state.profiles = data.profiles;
|
state.profiles = data.profiles;
|
||||||
state.presets = data.presets;
|
state.presets = data.presets;
|
||||||
|
try {
|
||||||
|
const personasData = await api("/api/personas");
|
||||||
|
state.personas = personasData.personas ?? [];
|
||||||
|
state.activePersonaId = personasData.activeId ?? null;
|
||||||
|
state.creationDefaultId = personasData.creationDefaultId ?? null;
|
||||||
|
} catch {
|
||||||
|
state.personas = [];
|
||||||
|
state.activePersonaId = null;
|
||||||
|
}
|
||||||
if (contextTraceKeepEl) {
|
if (contextTraceKeepEl) {
|
||||||
contextTraceKeepEl.value = String(
|
contextTraceKeepEl.value = String(
|
||||||
state.settings.contextTraceKeepLatest ?? 5,
|
state.settings.contextTraceKeepLatest ?? 5,
|
||||||
@@ -387,9 +561,89 @@ async function loadAll() {
|
|||||||
}
|
}
|
||||||
renderActiveBar();
|
renderActiveBar();
|
||||||
renderProfiles();
|
renderProfiles();
|
||||||
|
renderPersonas();
|
||||||
renderPresets();
|
renderPresets();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPersonas() {
|
||||||
|
if (!personasListEl) return;
|
||||||
|
personasListEl.innerHTML = "";
|
||||||
|
if (!state.personas.length) {
|
||||||
|
personasListEl.innerHTML =
|
||||||
|
`<div class="empty-hint">还没有用户角色。点右上角「新增」。</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const p of state.personas) {
|
||||||
|
const active = p.id === state.activePersonaId;
|
||||||
|
const creationDefault = p.id === state.creationDefaultId;
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = `config-card persona-config-card${active ? " is-active" : ""}`;
|
||||||
|
const desc = (p.description || "").trim();
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="config-card-head">
|
||||||
|
<div>
|
||||||
|
<div class="config-card-title">${escapeHtml(p.name)}${
|
||||||
|
creationDefault ? `<span class="badge">创作默认</span>` : ""
|
||||||
|
}</div>
|
||||||
|
<div class="config-card-meta">${
|
||||||
|
desc
|
||||||
|
? escapeHtml(desc.slice(0, 80)) + (desc.length > 80 ? "…" : "")
|
||||||
|
: "(无人设正文)"
|
||||||
|
}</div>
|
||||||
|
</div>
|
||||||
|
${activateButtonHtml(active, p.id, "activate-persona")}
|
||||||
|
</div>
|
||||||
|
<div class="config-card-actions">
|
||||||
|
<label class="persona-card-check">
|
||||||
|
<input type="checkbox" data-action="creation-default" data-id="${escapeHtml(p.id)}"${
|
||||||
|
creationDefault ? " checked" : ""
|
||||||
|
} />
|
||||||
|
创作默认
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn-secondary" data-action="edit-persona" data-id="${escapeHtml(p.id)}">编辑</button>
|
||||||
|
<button type="button" class="btn-secondary danger-outline" data-action="delete-persona" data-id="${escapeHtml(p.id)}">删除</button>
|
||||||
|
</div>`;
|
||||||
|
personasListEl.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPersonaDialog(persona = null) {
|
||||||
|
editingPersonaId = persona?.id ?? null;
|
||||||
|
personaDialogTitle.textContent = persona ? "编辑用户角色" : "新增用户角色";
|
||||||
|
personaForm.name.value = persona?.name ?? "";
|
||||||
|
personaForm.description.value = persona?.description ?? "";
|
||||||
|
personaForm.creationDefault.checked = persona
|
||||||
|
? persona.id === state.creationDefaultId
|
||||||
|
: false;
|
||||||
|
personaDialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePersona() {
|
||||||
|
const payload = {
|
||||||
|
name: personaForm.name.value,
|
||||||
|
description: personaForm.description.value,
|
||||||
|
activate: true,
|
||||||
|
creationDefault: Boolean(personaForm.creationDefault?.checked),
|
||||||
|
};
|
||||||
|
if (editingPersonaId) {
|
||||||
|
await api(`/api/personas/${encodeURIComponent(editingPersonaId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
await api(`/api/personas/${encodeURIComponent(editingPersonaId)}/activate`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await api("/api/personas", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
personaDialog.close();
|
||||||
|
await loadAll();
|
||||||
|
showToast("用户角色已保存");
|
||||||
|
}
|
||||||
|
|
||||||
function openProfileDialog(profile = null) {
|
function openProfileDialog(profile = null) {
|
||||||
editingProfileId = profile?.id ?? null;
|
editingProfileId = profile?.id ?? null;
|
||||||
profileDialogTitle.textContent = profile ? "编辑 API 配置" : "新增 API 配置";
|
profileDialogTitle.textContent = profile ? "编辑 API 配置" : "新增 API 配置";
|
||||||
@@ -465,6 +719,62 @@ document.getElementById("profile-cancel").addEventListener("click", () => {
|
|||||||
profileDialog.close();
|
profileDialog.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("persona-cancel")?.addEventListener("click", () => {
|
||||||
|
personaDialog?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
personaForm?.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await savePersona();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
personasListEl?.addEventListener("change", async (e) => {
|
||||||
|
const input = e.target.closest("input[data-action='creation-default']");
|
||||||
|
if (!input) return;
|
||||||
|
const id = input.dataset.id;
|
||||||
|
try {
|
||||||
|
await api(`/api/personas/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ creationDefault: Boolean(input.checked) }),
|
||||||
|
});
|
||||||
|
await loadAll();
|
||||||
|
showToast(input.checked ? "已设为创作默认" : "已取消创作默认");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, true);
|
||||||
|
await loadAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
personasListEl?.addEventListener("click", async (e) => {
|
||||||
|
const btn = e.target.closest("[data-action]");
|
||||||
|
if (!btn) return;
|
||||||
|
if (btn.matches("input[data-action='creation-default']")) return;
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
try {
|
||||||
|
if (action === "activate-persona") {
|
||||||
|
await api(`/api/personas/${encodeURIComponent(id)}/activate`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
await loadAll();
|
||||||
|
showToast("用户角色已选用");
|
||||||
|
} else if (action === "edit-persona") {
|
||||||
|
openPersonaDialog(state.personas.find((p) => p.id === id));
|
||||||
|
} else if (action === "delete-persona") {
|
||||||
|
if (!confirm("删除这个用户角色?")) return;
|
||||||
|
await api(`/api/personas/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
|
await loadAll();
|
||||||
|
showToast("已删除");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("profile-save").addEventListener("click", () => {
|
document.getElementById("profile-save").addEventListener("click", () => {
|
||||||
saveProfile(false).catch((err) => showToast(err.message, true));
|
saveProfile(false).catch((err) => showToast(err.message, true));
|
||||||
});
|
});
|
||||||
@@ -573,6 +883,11 @@ presetsListEl.addEventListener("click", async (e) => {
|
|||||||
const content = article.querySelector('[data-field="content"]')?.value ?? "";
|
const content = article.querySelector('[data-field="content"]')?.value ?? "";
|
||||||
openPresetEntryId.set(presetId, id);
|
openPresetEntryId.set(presetId, id);
|
||||||
await patchPresetEntry(presetId, { id, name, content });
|
await patchPresetEntry(presetId, { id, name, content });
|
||||||
|
} else if (action === "save-generation") {
|
||||||
|
const panel = btn.closest(".preset-entries-inner");
|
||||||
|
const presetId = panel?.dataset.presetId;
|
||||||
|
if (!presetId) return;
|
||||||
|
await patchPresetGeneration(presetId, collectGenerationFromPanel(panel));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, true);
|
showToast(err.message, true);
|
||||||
|
|||||||
Reference in New Issue
Block a user