Skip to content
Merged
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
38 changes: 35 additions & 3 deletions 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 All @@ -21,6 +25,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
export class Live2dTips extends UnoLitElement {
private static readonly DEFAULT_BOTTOM_OFFSET = 250;
private static readonly MODEL_OVERLAP_OFFSET = 20;
private static readonly OPACITY_TRANSITION_MS = 1000;

@consume({ context: configContext })
@property({ attribute: false })
Expand All @@ -34,8 +39,10 @@ export class Live2dTips extends UnoLitElement {
private _bottomOffset = Live2dTips.DEFAULT_BOTTOM_OFFSET;
private priority = -1;
private messageTimer: number | null = null;
private streamModeResetTimer: number | null = null;
private streamInactivityTimeout = 60_000;
// 流式消息模式标志
@state()
private isStreamMode = false;
private readonly onMessage = (event: Event) => {
this.handleMessage(event as SendMessageEvent);
Expand All @@ -60,6 +67,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 +90,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 All @@ -110,6 +123,7 @@ export class Live2dTips extends UnoLitElement {
window.removeEventListener("live2d:stream-message-stop", this.onStreamStop);
window.removeEventListener("live2d:model-layout", this.onModelLayout);
this.clearMessageTimer();
this.clearStreamModeResetTimer();
}

handleMessage(e: SendMessageEvent): void {
Expand All @@ -125,11 +139,13 @@ export class Live2dTips extends UnoLitElement {
return;
}
this.clearMessageTimer();
this.clearStreamModeResetTimer();
const message = randomSelection(text);
if (!isNotEmpty(message)) {
return;
}
this.priority = priority;
this.isStreamMode = false;
this._message = message;
this._isShow = true;
this.messageTimer = setTimeout(() => {
Expand All @@ -144,6 +160,7 @@ export class Live2dTips extends UnoLitElement {
handleStreamStart(e: StreamMessageStartEvent): void {
const { timeout } = e.detail;
const STREAM_PRIORITY = 99999;
this.clearStreamModeResetTimer();
this.priority = STREAM_PRIORITY;
this.isStreamMode = true;
this.streamInactivityTimeout = timeout;
Expand Down Expand Up @@ -186,7 +203,7 @@ export class Live2dTips extends UnoLitElement {
this.messageTimer = setTimeout(() => {
this._isShow = false;
this.priority = -1;
this.isStreamMode = false;
this.scheduleStreamModeReset();
}, showTimeout);
}

Expand Down Expand Up @@ -215,11 +232,26 @@ export class Live2dTips extends UnoLitElement {
() => {
this._isShow = false;
this.priority = -1;
this.isStreamMode = false;
this.scheduleStreamModeReset();
},
timeout ?? Number(this.config?.chunkTimeout || 60) * 1000,
);
}

private scheduleStreamModeReset(): void {
this.clearStreamModeResetTimer();
this.streamModeResetTimer = setTimeout(() => {
this.isStreamMode = false;
this.streamModeResetTimer = null;
}, Live2dTips.OPACITY_TRANSITION_MS);
}

private clearStreamModeResetTimer(): void {
if (this.streamModeResetTimer) {
clearTimeout(this.streamModeResetTimer);
this.streamModeResetTimer = null;
}
}
}

customElements.define("live2d-tips", Live2dTips);
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);
});
});
70 changes: 70 additions & 0 deletions packages/live2d/src/helpers/__tests__/renderMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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("detects markdown block output", () => {
expect(hasMarkdownBlockElements(renderMarkdown("plain"))).toBe(true);
expect(hasMarkdownBlockElements("plain")).toBe(false);
});
});
47 changes: 47 additions & 0 deletions packages/live2d/src/helpers/renderMarkdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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);
};

export const renderMarkdown = (value: string): string => markdown.render(value);

export const hasMarkdownBlockElements = (html: string): boolean => {
const match = html.trimStart().match(/^<([a-z0-9]+)/i);
return match ? BLOCK_TAGS.has(match[1]) : false;
};
75 changes: 75 additions & 0 deletions packages/live2d/src/styles/unocss.global.css
Original file line number Diff line number Diff line change
@@ -1 +1,76 @@
@unocss;

.live2d-tips-markdown {
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: inherit;
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(0, 0, 0, 0.18);
padding-left: 0.55rem;
color: inherit;
}

.live2d-tips-markdown code {
border-radius: 0.25rem;
background: rgba(255, 255, 255, 0.45);
padding: 0.05rem 0.25rem;
color: inherit;
font-size: 0.85em;
}

.live2d-tips-markdown pre {
overflow-x: auto;
border-radius: 0.4rem;
background: rgba(255, 255, 255, 0.45);
padding: 0.5rem;
}

.live2d-tips-markdown pre code {
background: transparent;
padding: 0;
}

.live2d-tips-markdown a {
color: inherit;
text-decoration: underline;
text-underline-offset: 0.15em;
}

.live2d-tips-markdown hr {
margin: 0.45rem 0;
border: 0;
border-top: 1px solid rgba(0, 0, 0, 0.16);
}
Loading
Loading