Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/live2d/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@
"@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",
"react-dom": "^19.2.6",
"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",
Expand Down
12 changes: 11 additions & 1 deletion packages/live2d/src/components/Live2dTips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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`
<div id="live2d-tips" class=${classMap(classes)}>
${unsafeHTML(this._message)}
${unsafeHTML(renderedMessage)}
</div>
`;
}
Expand Down
27 changes: 27 additions & 0 deletions packages/live2d/src/helpers/__tests__/createStreamMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
STREAM_MESSAGE_START_EVENT_NAME,
type StreamMessageStartEventDetail,
} from "@/live2d/events/stream-message";
import { describe, expect, it, vi } from "vitest";
import { createStreamMessage } from "../createStreamMessage";

describe("createStreamMessage", () => {
it("dispatches stream start with the inactivity timeout", () => {
const listener = vi.fn((event: Event) => {
const detail = (event as CustomEvent<StreamMessageStartEventDetail>)
.detail;
expect(detail).toEqual({
timeout: 1000,
});
});
window.addEventListener(STREAM_MESSAGE_START_EVENT_NAME, listener);

try {
createStreamMessage(1000, 2000);
} finally {
window.removeEventListener(STREAM_MESSAGE_START_EVENT_NAME, listener);
}

expect(listener).toHaveBeenCalledTimes(1);
});
});
85 changes: 85 additions & 0 deletions packages/live2d/src/helpers/__tests__/renderMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { hasMarkdownBlockElements, renderMarkdown } from "../renderMarkdown";

describe("renderMarkdown", () => {
it("renders common markdown blocks and inline marks", () => {
const html = renderMarkdown(
[
"## 标题",
"",
"这里有 **重点**、`code` 和 [链接](https://example.com?a=1&b=2)。",
"",
"- 第一项",
"- 第二项",
].join("\n"),
);

expect(html).toContain("<h2>标题</h2>");
expect(html).toContain("<strong>重点</strong>");
expect(html).toContain("<code>code</code>");
expect(html).toContain(
'<a href="https://example.com?a=1&amp;b=2" target="_blank" rel="noopener noreferrer">链接</a>',
);
expect(html).toContain("<ul>");
expect(html).toContain("<li>第一项</li>");
expect(html).toContain("<li>第二项</li>");
});

it("escapes raw html and unsafe links", () => {
const html = renderMarkdown(
"<img src=x onerror=alert(1)> [bad](javascript:alert(1))",
);

expect(html).toContain("&lt;img src=x onerror=alert(1)&gt;");
expect(html).toContain("[bad](javascript:alert(1))");
expect(html).not.toContain("<img");
expect(html).not.toContain('href="javascript:alert(1)"');
});

it("does not auto-link URL-like text", () => {
const html = renderMarkdown(
"日志标识 abc.def/very-long-value 不要变成链接",
);

expect(html).toContain("abc.def/very-long-value");
expect(html).not.toContain("<a ");
});

it("still renders explicit markdown links", () => {
expect(renderMarkdown("[Halo](https://www.halo.run)")).toContain(
'<a href="https://www.halo.run" target="_blank" rel="noopener noreferrer">Halo</a>',
);
});

it("renders fenced code as escaped code blocks", () => {
const html = renderMarkdown("```ts\nconst a = '<x>';\n```");

expect(html).toContain('<pre><code class="language-ts">');
expect(html).toContain("const a = '&lt;x&gt;';");
expect(html).not.toContain("<x>");
});

it("keeps soft line breaks inside paragraphs", () => {
expect(renderMarkdown("第一行\n第二行")).toContain("第一行<br>\n第二行");
});

it("normalizes collapsed chat markdown blocks", () => {
const html = renderMarkdown(
"总结一下叭!---##📚内容一览###📝文章:-**【Hello Halo】**—默认欢迎文章###📄页面:-**【关于】**—站点介绍",
);

expect(html).toContain("<hr>");
expect(html).toContain("<h2>📚内容一览</h2>");
expect(html).toContain("<h3>📝文章:</h3>");
expect(html).toContain(
"<li><strong>【Hello Halo】</strong>—默认欢迎文章</li>",
);
expect(html).toContain("<h3>📄页面:</h3>");
expect(html).toContain("<li><strong>【关于】</strong>—站点介绍</li>");
});

it("detects markdown block output", () => {
expect(hasMarkdownBlockElements(renderMarkdown("plain"))).toBe(true);
expect(hasMarkdownBlockElements("plain")).toBe(false);
});
});
59 changes: 59 additions & 0 deletions packages/live2d/src/helpers/renderMarkdown.ts
Original file line number Diff line number Diff line change
@@ -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;
};
77 changes: 77 additions & 0 deletions packages/live2d/src/styles/unocss.global.css
Original file line number Diff line number Diff line change
@@ -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);
}
Loading