实现输入框指令解析、高亮与补全。
让玩家与人设等占位在发送前可预览并正确展开。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 个十面骰" },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("清晨,@玩家推开门。对@玩家点头。");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLTextAreaElement} input
|
||||||
|
* @param {{ getCatalog?: () => Promise<Array<{insert:string,label:string,hint:string}>> }} [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) => `<button type="button" class="directive-ac-item${
|
||||||
|
i === activeIndex ? " is-active" : ""
|
||||||
|
}" data-ac-index="${i}" role="option" aria-selected="${
|
||||||
|
i === activeIndex ? "true" : "false"
|
||||||
|
}">
|
||||||
|
<code>${escapeHtml(it.insert.trim())}</code>
|
||||||
|
<span class="directive-ac-hint">${escapeHtml(it.hint || it.label)}</span>
|
||||||
|
</button>`,
|
||||||
|
)
|
||||||
|
.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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, ">")
|
||||||
|
.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 = `<div class="directive-preview-tip-title">${escapeHtml(
|
||||||
|
title,
|
||||||
|
)}</div><div class="directive-preview-tip-body">${escapeHtml(body)}</div>`;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 += `<mark class="dir-hit${kindClass}" data-dir-hit="1" data-dir-title="${escapeHtml(
|
||||||
|
hit.previewTitle,
|
||||||
|
)}" data-dir-body="${escapeHtml(hit.previewBody)}">${escapeHtml(hit.raw)}</mark>`;
|
||||||
|
cursor = hit.end;
|
||||||
|
}
|
||||||
|
if (cursor < text.length) {
|
||||||
|
html += escapeHtml(text.slice(cursor));
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user