From 1bba3611934b59fae3f53840512f08878cb41018 Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Wed, 17 Jun 2026 16:33:21 +0800 Subject: [PATCH] feat: render Live2D chat responses as Markdown --- packages/live2d/package.json | 2 + packages/live2d/src/components/Live2dTips.tsx | 12 ++- .../__tests__/createStreamMessage.test.ts | 27 ++++++ .../helpers/__tests__/renderMarkdown.test.ts | 85 +++++++++++++++++++ packages/live2d/src/helpers/renderMarkdown.ts | 59 +++++++++++++ packages/live2d/src/styles/unocss.global.css | 77 +++++++++++++++++ pnpm-lock.yaml | 71 ++++++++++++++++ .../halo/live2d/agent/AgentToolService.java | 12 ++- .../live2d/agent/AgentToolServiceTest.java | 18 ++++ 9 files changed, 360 insertions(+), 3 deletions(-) create mode 100644 packages/live2d/src/helpers/__tests__/createStreamMessage.test.ts create mode 100644 packages/live2d/src/helpers/__tests__/renderMarkdown.test.ts create mode 100644 packages/live2d/src/helpers/renderMarkdown.ts diff --git a/packages/live2d/package.json b/packages/live2d/package.json index 3be2bc1..5c39e65 100644 --- a/packages/live2d/package.json +++ b/packages/live2d/package.json @@ -27,6 +27,7 @@ "@pixi/sound": "^6.0.1", "iconify-icon": "^3.0.2", "lit": "^3.3.3", + "markdown-it": "^14.2.0", "pixi.js": "^8.13.1", "query-string": "^9.3.1", "react": "^19.2.6", @@ -34,6 +35,7 @@ "untitled-pixi-live2d-engine": "^1.1.0" }, "devDependencies": { + "@types/markdown-it": "^14.1.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@unocss/postcss": "^66.6.8", diff --git a/packages/live2d/src/components/Live2dTips.tsx b/packages/live2d/src/components/Live2dTips.tsx index 6cddefe..b012695 100644 --- a/packages/live2d/src/components/Live2dTips.tsx +++ b/packages/live2d/src/components/Live2dTips.tsx @@ -10,6 +10,10 @@ import type { StreamMessageStartEvent, StreamMessageStopEvent, } from "@/live2d/events/stream-message"; +import { + hasMarkdownBlockElements, + renderMarkdown, +} from "@/live2d/helpers/renderMarkdown"; import { isNotEmpty } from "@/live2d/utils/isNotEmpty"; import { randomSelection } from "@/live2d/utils/randomSelection"; import { consume } from "@lit/context"; @@ -60,6 +64,11 @@ export class Live2dTips extends UnoLitElement { } render(): TemplateResult { + const renderedMessage = this.isStreamMode + ? renderMarkdown(this._message) + : this._message; + const isMarkdownBlock = + this.isStreamMode && hasMarkdownBlockElements(renderedMessage); const classes = { "animate-shake": true, "animate-delay-5s": true, @@ -78,13 +87,14 @@ export class Live2dTips extends UnoLitElement { "text-ellipsis": true, "transition-opacity-1000": true, "break-all": true, + "live2d-tips-markdown": isMarkdownBlock, "opacity-100": this._isShow, "opacity-0": !this._isShow, "select-none": true, }; return html`
code");
+ expect(html).toContain(
+ '链接',
+ );
+ expect(html).toContain("');
+ expect(html).toContain("const a = '<x>';");
+ expect(html).not.toContain("");
+ });
+
+ it("keeps soft line breaks inside paragraphs", () => {
+ expect(renderMarkdown("第一行\n第二行")).toContain("第一行
\n第二行");
+ });
+
+ it("normalizes collapsed chat markdown blocks", () => {
+ const html = renderMarkdown(
+ "总结一下叭!---##📚内容一览###📝文章:-**【Hello Halo】**—默认欢迎文章###📄页面:-**【关于】**—站点介绍",
+ );
+
+ expect(html).toContain("
");
+ expect(html).toContain("📚内容一览
");
+ expect(html).toContain("📝文章:
");
+ expect(html).toContain(
+ "- 【Hello Halo】—默认欢迎文章
",
+ );
+ expect(html).toContain("📄页面:
");
+ expect(html).toContain("- 【关于】—站点介绍
");
+ });
+
+ it("detects markdown block output", () => {
+ expect(hasMarkdownBlockElements(renderMarkdown("plain"))).toBe(true);
+ expect(hasMarkdownBlockElements("plain")).toBe(false);
+ });
+});
diff --git a/packages/live2d/src/helpers/renderMarkdown.ts b/packages/live2d/src/helpers/renderMarkdown.ts
new file mode 100644
index 0000000..fddfb5a
--- /dev/null
+++ b/packages/live2d/src/helpers/renderMarkdown.ts
@@ -0,0 +1,59 @@
+import MarkdownIt from "markdown-it";
+
+const BLOCK_TAGS = new Set([
+ "blockquote",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "hr",
+ "ol",
+ "p",
+ "pre",
+ "table",
+ "ul",
+]);
+
+const markdown = new MarkdownIt({
+ breaks: true,
+ html: false,
+ linkify: false,
+ typographer: false,
+});
+
+const defaultLinkOpenRenderer =
+ markdown.renderer.rules.link_open ??
+ ((tokens, idx, options, _env, self) =>
+ self.renderToken(tokens, idx, options));
+
+markdown.renderer.rules.link_open = (tokens, idx, options, env, self) => {
+ const token = tokens[idx];
+ const href = token.attrGet("href");
+ if (href && !markdown.validateLink(href)) {
+ token.attrSet("href", "");
+ }
+ token.attrSet("target", "_blank");
+ token.attrSet("rel", "noopener noreferrer");
+ return defaultLinkOpenRenderer(tokens, idx, options, env, self);
+};
+
+const normalizeChatMarkdown = (value: string): string =>
+ value
+ .replace(/\r\n?/g, "\n")
+ .replace(/([^\n])---(?=#{1,6})/g, "$1\n\n---\n\n")
+ .replace(/(^|\n)---(?=#{1,6})/g, "$1---\n\n")
+ .replace(/([^\n#])(#{1,6})(?=[^\s#])/g, "$1\n\n$2")
+ .replace(/(^|\n)(#{1,6})(?=[^\s#])/g, "$1$2 ")
+ .replace(/([::])-(?=(?:\*\*|【|[\p{L}\p{N}]))/gu, "$1\n- ")
+ .replace(/([^\n])-(?=\*\*)/g, "$1\n- ")
+ .replace(/(^|\n)-(?=\S)/g, "$1- ");
+
+export const renderMarkdown = (value: string): string =>
+ markdown.render(normalizeChatMarkdown(value));
+
+export const hasMarkdownBlockElements = (html: string): boolean => {
+ const match = html.trimStart().match(/^<([a-z0-9]+)/i);
+ return match ? BLOCK_TAGS.has(match[1]) : false;
+};
diff --git a/packages/live2d/src/styles/unocss.global.css b/packages/live2d/src/styles/unocss.global.css
index 4612250..fea32dd 100644
--- a/packages/live2d/src/styles/unocss.global.css
+++ b/packages/live2d/src/styles/unocss.global.css
@@ -1 +1,78 @@
@unocss;
+
+.live2d-tips-markdown {
+ max-height: min(18rem, 42vh);
+ overflow-y: auto;
+ text-align: left;
+ line-height: 1.55;
+ word-break: break-word;
+}
+
+.live2d-tips-markdown :is(p, ul, ol, blockquote, pre) {
+ margin: 0.25rem 0;
+}
+
+.live2d-tips-markdown :is(h1, h2, h3) {
+ margin: 0.25rem 0;
+ color: #6f4b27;
+ font-weight: 700;
+ line-height: 1.3;
+}
+
+.live2d-tips-markdown h1 {
+ font-size: 1rem;
+}
+
+.live2d-tips-markdown h2 {
+ font-size: 0.95rem;
+}
+
+.live2d-tips-markdown h3 {
+ font-size: 0.9rem;
+}
+
+.live2d-tips-markdown :is(ul, ol) {
+ padding-left: 1.15rem;
+}
+
+.live2d-tips-markdown li + li {
+ margin-top: 0.15rem;
+}
+
+.live2d-tips-markdown blockquote {
+ border-left: 3px solid rgba(139, 94, 52, 0.35);
+ padding-left: 0.55rem;
+ color: #765b44;
+}
+
+.live2d-tips-markdown code {
+ border-radius: 0.25rem;
+ background: rgba(255, 250, 244, 0.85);
+ padding: 0.05rem 0.25rem;
+ color: #7f3f22;
+ font-size: 0.85em;
+}
+
+.live2d-tips-markdown pre {
+ overflow-x: auto;
+ border-radius: 0.4rem;
+ background: rgba(255, 250, 244, 0.9);
+ padding: 0.5rem;
+}
+
+.live2d-tips-markdown pre code {
+ background: transparent;
+ padding: 0;
+}
+
+.live2d-tips-markdown a {
+ color: #b75f21;
+ text-decoration: underline;
+ text-underline-offset: 0.15em;
+}
+
+.live2d-tips-markdown hr {
+ margin: 0.45rem 0;
+ border: 0;
+ border-top: 1px solid rgba(139, 94, 52, 0.22);
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c3578cb..a4f19f8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
lit:
specifier: ^3.3.3
version: 3.3.3
+ markdown-it:
+ specifier: ^14.2.0
+ version: 14.2.0
pixi.js:
specifier: ^8.13.1
version: 8.18.1
@@ -51,6 +54,9 @@ importers:
specifier: ^1.1.0
version: 1.1.0(@pixi/sound@6.0.1(pixi.js@8.18.1))(pixi.js@8.18.1)
devDependencies:
+ '@types/markdown-it':
+ specifier: ^14.1.2
+ version: 14.1.2
'@types/react':
specifier: ^19.2.14
version: 19.2.14
@@ -708,6 +714,15 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+ '@types/linkify-it@5.0.0':
+ resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
+
+ '@types/markdown-it@14.1.2':
+ resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
+
+ '@types/mdurl@2.0.0':
+ resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
+
'@types/node@22.13.1':
resolution: {integrity: sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==}
@@ -895,6 +910,9 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -999,6 +1017,10 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
@@ -1213,6 +1235,9 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
+ linkify-it@5.0.1:
+ resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==}
+
lit-analyzer@2.0.3:
resolution: {integrity: sha512-XiAjnwVipNrKav7r3CSEZpWt+mwYxrhPRVC7h8knDmn/HWTzzWJvPe+mwBcL2brn4xhItAMzZhFC8tzzqHKmiQ==}
hasBin: true
@@ -1239,9 +1264,16 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ markdown-it@14.2.0:
+ resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==}
+ hasBin: true
+
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+ mdurl@2.0.0:
+ resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -1323,6 +1355,10 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
+ punycode.js@2.3.1:
+ resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
+ engines: {node: '>=6'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1504,6 +1540,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ uc.micro@2.1.0:
+ resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
+
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
@@ -2127,6 +2166,15 @@ snapshots:
'@types/estree@1.0.9': {}
+ '@types/linkify-it@5.0.0': {}
+
+ '@types/markdown-it@14.1.2':
+ dependencies:
+ '@types/linkify-it': 5.0.0
+ '@types/mdurl': 2.0.0
+
+ '@types/mdurl@2.0.0': {}
+
'@types/node@22.13.1':
dependencies:
undici-types: 6.20.0
@@ -2401,6 +2449,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ argparse@2.0.1: {}
+
assertion-error@2.0.1: {}
bidi-js@1.0.3:
@@ -2493,6 +2543,8 @@ snapshots:
emoji-regex@8.0.0: {}
+ entities@4.5.0: {}
+
entities@7.0.1: {}
entities@8.0.0: {}
@@ -2694,6 +2746,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
+ linkify-it@5.0.1:
+ dependencies:
+ uc.micro: 2.1.0
+
lit-analyzer@2.0.3:
dependencies:
'@vscode/web-custom-data': 0.4.13
@@ -2740,8 +2796,19 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ markdown-it@14.2.0:
+ dependencies:
+ argparse: 2.0.1
+ entities: 4.5.0
+ linkify-it: 5.0.1
+ mdurl: 2.0.0
+ punycode.js: 2.3.1
+ uc.micro: 2.1.0
+
mdn-data@2.27.1: {}
+ mdurl@2.0.0: {}
+
merge2@1.4.1: {}
micromatch@4.0.8:
@@ -2854,6 +2921,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ punycode.js@2.3.1: {}
+
punycode@2.3.1: {}
quansync@1.0.0: {}
@@ -3025,6 +3094,8 @@ snapshots:
typescript@5.7.3: {}
+ uc.micro@2.1.0: {}
+
ufo@1.6.4: {}
unconfig-core@7.5.0:
diff --git a/src/main/java/run/halo/live2d/agent/AgentToolService.java b/src/main/java/run/halo/live2d/agent/AgentToolService.java
index a84b133..e6a5904 100644
--- a/src/main/java/run/halo/live2d/agent/AgentToolService.java
+++ b/src/main/java/run/halo/live2d/agent/AgentToolService.java
@@ -14,6 +14,14 @@
@ConditionalOnHaloAiFoundation
@RequiredArgsConstructor
public class AgentToolService {
+ private static final String RESPONSE_FORMAT_PROMPT = "\n\n【回复格式】\n"
+ + "- 默认使用 Markdown 格式回复,普通聊天也可以只写自然文本。\n"
+ + "- 除非回复很短,否则每句话单独成行;不要把多句话连续写在同一段里。\n"
+ + "- 每一行只表达一个意思,完整表达后立即换行,让气泡内容更容易阅读。\n"
+ + "- 需要分点说明时优先使用 Markdown 列表,每个列表项单独成行,列表前后保留空行。\n"
+ + "- 多个列表、段落或不同主题之间使用空行分隔,避免把内容挤在同一段里。\n"
+ + "- 不要输出 HTML 标签。";
+
private final AgentToolNormalizer normalizer;
private final HaloAgentPresetToolService haloPresetToolService;
@@ -152,10 +160,10 @@ private void addHaloPresetTools(List tools, AgentSettings settin
public String appendCapabilityPrompt(String systemMessage, AgentToolSet toolSet) {
if (toolSet == null || !toolSet.agentEnabled() || toolSet.tools().isEmpty()) {
- return systemMessage + "\n\n【Agent 能力边界】\n"
+ return systemMessage + RESPONSE_FORMAT_PROMPT + "\n\n【Agent 能力边界】\n"
+ "当前站点未向访客开放 Agent 操作能力。你可以正常聊天,但不能承诺打开页面、提交内容或控制站点功能。";
}
- return systemMessage + "\n\n【Agent 能力】\n"
+ return systemMessage + RESPONSE_FORMAT_PROMPT + "\n\n【Agent 能力】\n"
+ "- 你可以在工具可用时协助访客执行已授权的站点操作。\n"
+ "- 只能调用当前已声明的工具;不要承诺未声明或未授权的能力。\n"
+ "- 执行评论、表单填写、页面定位等依赖当前页面结构的操作前,应先读取当前页面上下文;如果页面不具备对应能力,应如实说明。\n"
diff --git a/src/test/java/run/halo/live2d/agent/AgentToolServiceTest.java b/src/test/java/run/halo/live2d/agent/AgentToolServiceTest.java
index 3880b23..bd89722 100644
--- a/src/test/java/run/halo/live2d/agent/AgentToolServiceTest.java
+++ b/src/test/java/run/halo/live2d/agent/AgentToolServiceTest.java
@@ -21,6 +21,24 @@ void doesNotExposeToolsWhenAgentDisabled() {
assertThat(tools.tools()).isEmpty();
}
+ @Test
+ void appendsMarkdownResponseFormatPrompt() {
+ var service = new AgentToolService(normalizer, null);
+
+ var prompt = service.appendCapabilityPrompt("system", AgentToolSet.disabled());
+
+ assertThat(prompt)
+ .contains("【回复格式】")
+ .contains("默认使用 Markdown 格式回复")
+ .contains("每句话单独成行")
+ .contains("每一行只表达一个意思")
+ .contains("每个列表项单独成行")
+ .contains("列表前后保留空行")
+ .contains("多个列表、段落或不同主题之间使用空行分隔")
+ .contains("不要输出 HTML 标签")
+ .contains("【Agent 能力边界】");
+ }
+
@Test
void filtersAuthenticatedCustomToolsForAnonymousVisitors() throws Exception {
var service = new AgentToolService(normalizer, null);