实现输入框指令解析、高亮与补全。

让玩家与人设等占位在发送前可预览并正确展开。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 01:14:20 +08:00
co-authored by Cursor
parent 073d56cd85
commit 8a22cdc37c
6 changed files with 799 additions and 0 deletions
+158
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* @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);
});
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Composer 指令高亮:textarea 下叠镜像层,悬停预览替换效果(QQ 式)。
*/
import {
findDirectiveHits,
renderDirectiveBackdropHtml,
} from "./directive-parse.js";
function escapeHtml(text) {
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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();
}
}
+170
View File
@@ -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;
}