diff --git a/src/directives/expand.ts b/src/directives/expand.ts new file mode 100644 index 0000000..f8d113b --- /dev/null +++ b/src/directives/expand.ts @@ -0,0 +1,203 @@ +/** + * `@` 指令展开:匹配已注册指令即替换;未注册保留原文。 + * 身份类可复用于预设/开场白;骰子类仅在发送/进游玩时展开。 + */ + +import { executeChance } from "../skills/chance-tools.js"; + +export type DirectivePersona = { + name: string; + description?: string; +}; + +export type DirectiveContext = { + persona?: DirectivePersona | null; +}; + +export type ExpandOptions = { + /** 默认 true。预设装配时关掉,避免每次请求重掷。 */ + allowDice?: boolean; +}; + +export type DirectiveHit = { + raw: string; + kind: "persona_name" | "persona_desc" | "dice" | "st_user"; + replacement: string; +}; + +export type ExpandResult = { + text: string; + hits: DirectiveHit[]; + changed: boolean; +}; + +const NAME_ALIASES = new Set(["玩家", "user"]); +const DESC_ALIASES = new Set(["人设", "persona"]); + +/** `@r` + 可选个数 + `d` + 面数 + 可选加减 + 可选目标 */ +const DICE_AT_RE = + /^@r(\d*)d(\d+)([+-]\d+)?(?:\s+(\d+))?(?![A-Za-z0-9_])/i; + +/** 仅 ASCII 词符算续写;汉字紧跟仍是完整指令(全等匹配 `@玩家` 等)。 */ +const TOKEN_CONTINUATION = /^[@A-Za-z0-9_]/; + +function personaName(ctx: DirectiveContext): string { + const name = ctx.persona?.name?.trim(); + return name || "玩家"; +} + +function personaDesc(ctx: DirectiveContext): string { + const d = ctx.persona?.description?.trim(); + if (d) return d; + const name = ctx.persona?.name?.trim(); + return name ? `姓名:${name}` : ""; +} + +function formatDiceWithTarget( + expression: string, + target: number | undefined, +): string { + const result = executeChance({ op: "roll", expression }); + if (!result.ok) { + return result.summary; + } + const total = Number(result.detail.total); + const dice = Array.isArray(result.detail.dice) + ? (result.detail.dice as number[]) + : []; + const expr = String(result.detail.expression ?? expression); + if (target === undefined || !Number.isFinite(target)) { + if (dice.length <= 1) { + return `投掷结果为 ${total}(${expr})`; + } + return `投掷结果为 ${dice.join("+")} = ${total}(${expr})`; + } + const success = total <= target; + const verdict = success ? "成功" : "失败"; + return `投掷结果为 ${total},目标 ${target}(${verdict})`; +} + +function tryExpandAt( + rest: string, + ctx: DirectiveContext, + allowDice: boolean, +): { raw: string; hit: DirectiveHit } | null { + if (rest[0] !== "@") return null; + + for (const alias of NAME_ALIASES) { + const token = `@${alias}`; + if ( + rest.startsWith(token) && + !TOKEN_CONTINUATION.test(rest.slice(token.length)) + ) { + const replacement = personaName(ctx); + return { + raw: token, + hit: { raw: token, kind: "persona_name", replacement }, + }; + } + } + + for (const alias of DESC_ALIASES) { + const token = `@${alias}`; + if ( + rest.startsWith(token) && + !TOKEN_CONTINUATION.test(rest.slice(token.length)) + ) { + const replacement = personaDesc(ctx); + return { + raw: token, + hit: { raw: token, kind: "persona_desc", replacement }, + }; + } + } + + if (allowDice) { + const m = rest.match(DICE_AT_RE); + if (m) { + const count = m[1] ? m[1] : "1"; + const sides = m[2]!; + const mod = m[3] ?? ""; + const target = m[4] !== undefined ? Number(m[4]) : undefined; + const expression = `${count}d${sides}${mod}`; + const raw = m[0]!; + const replacement = formatDiceWithTarget(expression, target); + return { + raw, + hit: { raw, kind: "dice", replacement }, + }; + } + } + + return null; +} + +/** + * 展开文本中的 `@` 指令;可选兼容酒馆 `{{user}}`。 + */ +export function expandAtDirectives( + input: string, + ctx: DirectiveContext = {}, + options: ExpandOptions = {}, +): ExpandResult { + const allowDice = options.allowDice !== false; + const hits: DirectiveHit[] = []; + let i = 0; + let out = ""; + + while (i < input.length) { + if (input[i] === "@") { + const tried = tryExpandAt(input.slice(i), ctx, allowDice); + if (tried) { + out += tried.hit.replacement; + hits.push(tried.hit); + i += tried.raw.length; + continue; + } + } + + // 兼容 SillyTavern {{user}} + if (input.startsWith("{{user}}", i)) { + const replacement = personaName(ctx); + out += replacement; + hits.push({ raw: "{{user}}", kind: "st_user", replacement }); + i += "{{user}}".length; + continue; + } + + out += input[i]; + i += 1; + } + + return { + text: out, + hits, + changed: hits.length > 0, + }; +} + +/** 仅身份类(预设装配用,不掷骰)。 */ +export function expandIdentityDirectives( + input: string, + ctx: DirectiveContext = {}, +): ExpandResult { + return expandAtDirectives(input, ctx, { allowDice: false }); +} + +export type DirectiveCatalogItem = { + insert: string; + label: string; + hint: string; +}; + +/** 输入补全目录(静态)。 */ +export function listDirectiveCatalog(): DirectiveCatalogItem[] { + return [ + { insert: "@玩家", label: "玩家", hint: "当前用户角色名" }, + { insert: "@user", label: "user", hint: "同 @玩家" }, + { insert: "@人设", label: "人设", hint: "当前用户角色人设正文" }, + { insert: "@rd100 ", label: "rd100", hint: "百面骰;可接目标如 @rd100 30" }, + { insert: "@r1d20", label: "r1d20", hint: "二十面骰" }, + { insert: "@r3d10", label: "r3d10", hint: "3 个十面骰" }, + ]; +} diff --git a/tests/directive-parse-web.test.ts b/tests/directive-parse-web.test.ts new file mode 100644 index 0000000..f4bbe01 --- /dev/null +++ b/tests/directive-parse-web.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { findDirectiveHits } from "../web/directive-parse.js"; + +describe("findDirectiveHits (composer highlight)", () => { + it("marks @玩家 with persona preview (same as expand)", () => { + const hits = findDirectiveHits("且对@玩家没有因为", { + persona: { name: "林晚" }, + }); + expect(hits).toHaveLength(1); + expect(hits[0].raw).toBe("@玩家"); + expect(hits[0].previewBody).toBe("发送时 → 林晚"); + }); + + it("does not mark @玩家X (same as expand)", () => { + expect( + findDirectiveHits("@玩家X来了", { persona: { name: "林晚" } }), + ).toEqual([]); + }); + + it("marks dice without rolling", () => { + const hits = findDirectiveHits("检定 @rd100 30", {}); + expect(hits).toHaveLength(1); + expect(hits[0].kind).toBe("dice"); + expect(hits[0].raw).toBe("@rd100 30"); + expect(hits[0].previewBody).toContain("发送时掷"); + expect(hits[0].previewBody).toContain("点数届时生成"); + }); + + it("ignores unknown @token", () => { + expect(findDirectiveHits("写给@某人", { persona: { name: "A" } })).toEqual( + [], + ); + }); +}); diff --git a/tests/directives.test.ts b/tests/directives.test.ts new file mode 100644 index 0000000..aa1d73e --- /dev/null +++ b/tests/directives.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; +import { + expandAtDirectives, + expandIdentityDirectives, + listDirectiveCatalog, +} from "../src/directives/expand.js"; + +describe("expandAtDirectives", () => { + it("replaces @玩家 and @user with persona name", () => { + const r = expandAtDirectives("你好,@玩家;再见 @user。", { + persona: { name: "林晚" }, + }); + expect(r.text).toBe("你好,林晚;再见 林晚。"); + expect(r.hits.map((h) => h.kind)).toEqual([ + "persona_name", + "persona_name", + ]); + }); + + it("falls back to 玩家 when no persona", () => { + const r = expandAtDirectives("我是@玩家", {}); + expect(r.text).toBe("我是玩家"); + }); + + it("replaces @人设 with description", () => { + const r = expandAtDirectives("人设:@人设", { + persona: { name: "林晚", description: "冷静的调查员" }, + }); + expect(r.text).toBe("人设:冷静的调查员"); + }); + + it("compat {{user}}", () => { + const r = expandIdentityDirectives("对{{user}}说", { + persona: { name: "阿泽" }, + }); + expect(r.text).toBe("对阿泽说"); + }); + + it("leaves unknown @token alone", () => { + const r = expandAtDirectives("写给@某人 的信", { + persona: { name: "林晚" }, + }); + expect(r.text).toBe("写给@某人 的信"); + expect(r.changed).toBe(false); + }); + + it("does not treat @玩家X as @玩家", () => { + const r = expandAtDirectives("@玩家X来了", { + persona: { name: "林晚" }, + }); + expect(r.text).toBe("@玩家X来了"); + }); + + it("expands @玩家 when followed by Chinese", () => { + const r = expandAtDirectives("且对@玩家没有因为", { + persona: { name: "林晚" }, + }); + expect(r.text).toBe("且对林晚没有因为"); + }); + + it("rolls @rd100 with target", () => { + vi.spyOn(Math, "random").mockReturnValue(0.18); // → 19 on 1..100 + const r = expandAtDirectives("检定 @rd100 30", { + persona: { name: "林晚" }, + }); + expect(r.text).toBe("检定 投掷结果为 19,目标 30(成功)"); + vi.restoreAllMocks(); + }); + + it("rolls @r3d10 without target", () => { + const seq = [0.3, 0.6, 0.1]; + let i = 0; + vi.spyOn(Math, "random").mockImplementation(() => seq[i++] ?? 0); + const r = expandAtDirectives("伤害 @r3d10", {}); + expect(r.text).toMatch(/^伤害 投掷结果为 \d\+\d\+\d = \d+(3d10)$/); + vi.restoreAllMocks(); + }); + + it("identity expand skips dice", () => { + const r = expandIdentityDirectives("先 @rd100 50 再说", { + persona: { name: "林晚" }, + }); + expect(r.text).toBe("先 @rd100 50 再说"); + }); + + it("lists catalog", () => { + expect(listDirectiveCatalog().some((x) => x.insert === "@玩家")).toBe( + true, + ); + }); + + it("keeps @玩家 when the persona name is @玩家", () => { + const r = expandIdentityDirectives("清晨,@玩家推开门。对{{user}}点头。", { + persona: { name: "@玩家", description: "占位" }, + }); + expect(r.text).toBe("清晨,@玩家推开门。对@玩家点头。"); + }); +}); diff --git a/web/directive-autocomplete.js b/web/directive-autocomplete.js new file mode 100644 index 0000000..03dc829 --- /dev/null +++ b/web/directive-autocomplete.js @@ -0,0 +1,158 @@ +/** + * 输入框 `@` 指令自动补全。 + * 展开仍由服务端匹配替换;此处只负责插入文本。 + */ + +const DEFAULT_CATALOG = [ + { insert: "@玩家", label: "玩家", hint: "当前用户角色名" }, + { insert: "@user", label: "user", hint: "同 @玩家" }, + { insert: "@人设", label: "人设", hint: "当前用户角色人设" }, + { insert: "@rd100 ", label: "rd100", hint: "百面骰;可接目标 @rd100 30" }, + { insert: "@r1d20", label: "r1d20", hint: "二十面骰" }, + { insert: "@r3d10", label: "r3d10", hint: "3 个十面骰" }, +]; + +let cachedCatalog = null; + +export async function loadDirectiveCatalog() { + if (cachedCatalog) return cachedCatalog; + try { + const res = await fetch("/api/directives/catalog"); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data.items) && data.items.length) { + cachedCatalog = data.items; + return cachedCatalog; + } + } + } catch { + /* fallback */ + } + cachedCatalog = DEFAULT_CATALOG; + return cachedCatalog; +} + +function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * @param {HTMLTextAreaElement} input + * @param {{ getCatalog?: () => Promise> }} [opts] + */ +export function wireDirectiveAutocomplete(input, opts = {}) { + if (!input || input.dataset.directiveAc === "1") return; + input.dataset.directiveAc = "1"; + + const getCatalog = opts.getCatalog || loadDirectiveCatalog; + let popup = null; + let items = []; + let activeIndex = 0; + let triggerStart = -1; + + function close() { + popup?.remove(); + popup = null; + items = []; + triggerStart = -1; + } + + function applyItem(item) { + if (triggerStart < 0) return; + const val = input.value; + const cursor = input.selectionStart ?? val.length; + const before = val.slice(0, triggerStart); + const after = val.slice(cursor); + input.value = `${before}${item.insert}${after}`; + const pos = before.length + item.insert.length; + input.setSelectionRange(pos, pos); + input.dispatchEvent(new Event("input", { bubbles: true })); + close(); + input.focus(); + } + + function renderPopup() { + if (!popup) { + popup = document.createElement("div"); + popup.className = "directive-ac-popup"; + popup.setAttribute("role", "listbox"); + const shell = input.closest(".composer-input-shell") || input.parentElement; + shell?.appendChild(popup); + } + popup.innerHTML = items + .map( + (it, i) => ``, + ) + .join(""); + popup.querySelectorAll("[data-ac-index]").forEach((btn) => { + btn.addEventListener("mousedown", (e) => { + e.preventDefault(); + const idx = Number(btn.getAttribute("data-ac-index")); + if (items[idx]) applyItem(items[idx]); + }); + }); + } + + async function refresh() { + const val = input.value; + const cursor = input.selectionStart ?? 0; + const before = val.slice(0, cursor); + const m = before.match(/@([A-Za-z0-9_\u4e00-\u9fff]*)$/); + if (!m) { + close(); + return; + } + triggerStart = cursor - m[0].length; + const q = (m[1] || "").toLowerCase(); + const catalog = await getCatalog(); + items = catalog.filter((it) => { + const key = `${it.insert} ${it.label} ${it.hint}`.toLowerCase(); + return !q || key.includes(q) || it.insert.toLowerCase().includes(`@${q}`); + }); + if (!items.length) { + close(); + return; + } + activeIndex = 0; + renderPopup(); + } + + input.addEventListener("input", () => { + void refresh(); + }); + input.addEventListener("keydown", (e) => { + if (!popup || !items.length) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + activeIndex = (activeIndex + 1) % items.length; + renderPopup(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + activeIndex = (activeIndex - 1 + items.length) % items.length; + renderPopup(); + } else if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { + // Enter 仍交给发送;仅 Tab 插入补全,避免抢发送 + if (e.key === "Tab") { + e.preventDefault(); + applyItem(items[activeIndex]); + } + } else if (e.key === "Escape") { + e.preventDefault(); + close(); + } + }); + input.addEventListener("blur", () => { + setTimeout(close, 120); + }); +} diff --git a/web/directive-highlight.js b/web/directive-highlight.js new file mode 100644 index 0000000..095e512 --- /dev/null +++ b/web/directive-highlight.js @@ -0,0 +1,136 @@ +/** + * Composer 指令高亮:textarea 下叠镜像层,悬停预览替换效果(QQ 式)。 + */ + +import { + findDirectiveHits, + renderDirectiveBackdropHtml, +} from "./directive-parse.js"; + +function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function ensureTip() { + let tip = document.getElementById("directive-preview-tip"); + if (tip) return tip; + tip = document.createElement("div"); + tip.id = "directive-preview-tip"; + tip.className = "directive-preview-tip"; + tip.hidden = true; + tip.setAttribute("role", "tooltip"); + document.body.appendChild(tip); + return tip; +} + +function hideTip() { + const tip = document.getElementById("directive-preview-tip"); + if (!tip) return; + tip.hidden = true; + tip.innerHTML = ""; +} + +/** + * @param {HTMLTextAreaElement} input + * @param {{ getPersona?: () => { name?: string, description?: string } | null }} [opts] + */ +export function wireDirectiveHighlight(input, opts = {}) { + if (!input || input.dataset.directiveHl === "1") return; + input.dataset.directiveHl = "1"; + + const field = input.closest(".composer-input-field"); + const backdrop = field?.querySelector(".composer-input-backdrop"); + if (!field || !backdrop) return; + + const getPersona = opts.getPersona || (() => null); + let tipVisible = false; + + function syncEmptyClass() { + input.classList.toggle("is-empty", !String(input.value ?? "")); + } + + function refresh() { + const text = String(input.value ?? ""); + syncEmptyClass(); + const hits = findDirectiveHits(text, { persona: getPersona() }); + backdrop.innerHTML = renderDirectiveBackdropHtml(text, hits, escapeHtml); + // 末尾换行时 textarea 多出一行高度,镜像补一个 br 对齐 + if (text.endsWith("\n")) { + backdrop.appendChild(document.createElement("br")); + } + backdrop.scrollTop = input.scrollTop; + backdrop.scrollLeft = input.scrollLeft; + } + + function showTipFor(mark, clientX, clientY) { + const tip = ensureTip(); + const title = mark.getAttribute("data-dir-title") || ""; + const body = mark.getAttribute("data-dir-body") || ""; + tip.innerHTML = `
${escapeHtml( + title, + )}
${escapeHtml(body)}
`; + tip.hidden = false; + tipVisible = true; + + const pad = 10; + const rect = tip.getBoundingClientRect(); + let left = clientX + 12; + let top = clientY + 14; + if (left + rect.width > window.innerWidth - pad) { + left = Math.max(pad, clientX - rect.width - 12); + } + if (top + rect.height > window.innerHeight - pad) { + top = Math.max(pad, clientY - rect.height - 10); + } + tip.style.left = `${left}px`; + tip.style.top = `${top}px`; + } + + function hitTest(clientX, clientY) { + const prevTa = input.style.pointerEvents; + const prevBd = backdrop.style.pointerEvents; + input.style.pointerEvents = "none"; + backdrop.style.pointerEvents = "auto"; + const el = document.elementFromPoint(clientX, clientY); + input.style.pointerEvents = prevTa; + backdrop.style.pointerEvents = prevBd; + return el?.closest?.("[data-dir-hit]") || null; + } + + function onMove(e) { + const mark = hitTest(e.clientX, e.clientY); + if (mark) showTipFor(mark, e.clientX, e.clientY); + else if (tipVisible) hideTip(); + } + + function onLeave() { + if (tipVisible) { + tipVisible = false; + hideTip(); + } + } + + input.addEventListener("input", refresh); + input.addEventListener("change", refresh); + input.addEventListener("scroll", () => { + backdrop.scrollTop = input.scrollTop; + backdrop.scrollLeft = input.scrollLeft; + }); + input.addEventListener("mousemove", onMove); + input.addEventListener("mouseleave", onLeave); + // 选角色后可外部调用 + input._directiveHighlightRefresh = refresh; + + refresh(); +} + +/** 角色切换等场景手动刷新预览文案 */ +export function refreshDirectiveHighlight(input = document.getElementById("composer-input")) { + if (input && typeof input._directiveHighlightRefresh === "function") { + input._directiveHighlightRefresh(); + } +} diff --git a/web/directive-parse.js b/web/directive-parse.js new file mode 100644 index 0000000..4d4f56d --- /dev/null +++ b/web/directive-parse.js @@ -0,0 +1,170 @@ +/** + * 客户端指令识别(与 src/directives/expand.ts 匹配规则对齐)。 + * 仅用于输入框高亮 / 预览,不真正掷骰或替换。 + */ + +const NAME_ALIASES = ["玩家", "user"]; +const DESC_ALIASES = ["人设", "persona"]; +/** 与 expand.ts 同一规则:指令名全等;仅 ASCII 词符算续写。 */ +const DICE_AT_RE = + /^@r(\d*)d(\d+)([+-]\d+)?(?:\s+(\d+))?(?![A-Za-z0-9_])/i; +const TOKEN_TAIL = /^[@A-Za-z0-9_]/; + +/** + * @typedef {{ name?: string, description?: string } | null | undefined} DirectivePersona + * @typedef {{ + * start: number, + * end: number, + * raw: string, + * kind: "persona_name" | "persona_desc" | "dice" | "st_user", + * previewTitle: string, + * previewBody: string, + * }} DirectiveHitView + */ + +function personaName(persona) { + const name = persona?.name?.trim(); + return name || "玩家"; +} + +function personaDesc(persona) { + const d = persona?.description?.trim(); + if (d) return d; + const name = persona?.name?.trim(); + return name ? `姓名:${name}` : "(当前无人设)"; +} + +function truncate(text, max = 80) { + const t = String(text || "").trim(); + if (t.length <= max) return t; + return `${t.slice(0, max)}…`; +} + +/** + * @param {string} input + * @param {{ persona?: DirectivePersona }} [ctx] + * @returns {DirectiveHitView[]} + */ +export function findDirectiveHits(input, ctx = {}) { + const persona = ctx.persona; + /** @type {DirectiveHitView[]} */ + const hits = []; + let i = 0; + + while (i < input.length) { + if (input[i] === "@") { + const rest = input.slice(i); + let matched = null; + + for (const alias of NAME_ALIASES) { + const token = `@${alias}`; + if (rest.startsWith(token) && !TOKEN_TAIL.test(rest.slice(token.length))) { + const name = personaName(persona); + matched = { + start: i, + end: i + token.length, + raw: token, + kind: "persona_name", + previewTitle: token, + previewBody: `发送时 → ${name}`, + }; + break; + } + } + + if (!matched) { + for (const alias of DESC_ALIASES) { + const token = `@${alias}`; + if (rest.startsWith(token) && !TOKEN_TAIL.test(rest.slice(token.length))) { + matched = { + start: i, + end: i + token.length, + raw: token, + kind: "persona_desc", + previewTitle: token, + previewBody: `发送时 → ${truncate(personaDesc(persona))}`, + }; + break; + } + } + } + + if (!matched) { + const m = rest.match(DICE_AT_RE); + if (m) { + const count = m[1] ? m[1] : "1"; + const sides = m[2]; + const mod = m[3] ?? ""; + const target = m[4]; + const expression = `${count}d${sides}${mod}`; + const raw = m[0]; + const targetPart = target !== undefined ? `,目标 ${target}` : ""; + matched = { + start: i, + end: i + raw.length, + raw, + kind: "dice", + previewTitle: raw.trim(), + previewBody: `发送时掷 ${expression}${targetPart}(点数届时生成)`, + }; + } + } + + if (matched) { + hits.push(matched); + i = matched.end; + continue; + } + } + + if (input.startsWith("{{user}}", i)) { + const name = personaName(persona); + hits.push({ + start: i, + end: i + 8, + raw: "{{user}}", + kind: "st_user", + previewTitle: "{{user}}", + previewBody: `发送时 → ${name}`, + }); + i += 8; + continue; + } + + i += 1; + } + + return hits; +} + +/** + * @param {string} text + * @param {DirectiveHitView[]} hits + * @param {(s: string) => string} escapeHtml + */ +export function renderDirectiveBackdropHtml(text, hits, escapeHtml) { + if (!text) return ""; + if (!hits.length) return escapeHtml(text); + + let html = ""; + let cursor = 0; + for (const hit of hits) { + if (hit.start > cursor) { + html += escapeHtml(text.slice(cursor, hit.start)); + } + const kindClass = + hit.kind === "dice" + ? " dir-hit--dice" + : hit.kind === "persona_desc" + ? " dir-hit--desc" + : " dir-hit--name"; + html += `${escapeHtml(hit.raw)}`; + cursor = hit.end; + } + if (cursor < text.length) { + html += escapeHtml(text.slice(cursor)); + } + return html; +}