122 lines
4.3 KiB
Vue
122 lines
4.3 KiB
Vue
<script setup lang="ts">
|
|
import { Bot, MessageSquareWarning, RotateCcw } from "@lucide/vue";
|
|
import MarkdownIt from "markdown-it";
|
|
import { computed } from "vue";
|
|
|
|
const props = defineProps<{
|
|
messageId: string;
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
reasoning?: string;
|
|
showReasoning?: boolean;
|
|
createdAt: string;
|
|
streaming?: boolean;
|
|
errorMessage?: string;
|
|
canRetry?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
retry: [];
|
|
feedback: [];
|
|
}>();
|
|
|
|
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
|
|
const parsed = computed(() => splitReasoning(props.content));
|
|
const answer = computed(() => {
|
|
if (props.role === "user") return props.content;
|
|
return parsed.value.answer.replace(/^\s+/, "");
|
|
});
|
|
const reasoning = computed(() => props.reasoning || parsed.value.reasoning);
|
|
const hasAnswer = computed(() => answer.value.trim().length > 0);
|
|
const renderedContent = computed(() => {
|
|
if (props.role !== "assistant") return answer.value;
|
|
return hasAnswer.value ? markdown.render(normalizeStandaloneHeadings(answer.value)) : "";
|
|
});
|
|
|
|
function normalizeStandaloneHeadings(content: string) {
|
|
return content.replace(/^([ \t]*)\*\*(.+)\*\*[ \t]*$/gm, (line, indent: string, title: string) => {
|
|
const normalizedTitle = title.trim();
|
|
if (!normalizedTitle || normalizedTitle.length > 48 || normalizedTitle.includes("**")) return line;
|
|
return `${indent}### ${normalizedTitle}`;
|
|
});
|
|
}
|
|
|
|
function splitReasoning(content: string) {
|
|
const lower = content.toLowerCase();
|
|
let answer = "";
|
|
let reasoning = "";
|
|
let cursor = 0;
|
|
let depth = 0;
|
|
while (cursor < content.length) {
|
|
const tagStart = content.indexOf("<", cursor);
|
|
if (tagStart === -1) {
|
|
if (depth) reasoning += content.slice(cursor);
|
|
else answer += content.slice(cursor);
|
|
break;
|
|
}
|
|
const text = content.slice(cursor, tagStart);
|
|
if (depth) reasoning += text;
|
|
else answer += text;
|
|
const tail = lower.slice(tagStart);
|
|
const openTag = tail.match(/^<think(?:\s[^>]*)?>/);
|
|
if (openTag) {
|
|
depth += 1;
|
|
cursor = tagStart + openTag[0].length;
|
|
continue;
|
|
}
|
|
const closeTag = tail.match(/^<\/think\s*>/);
|
|
if (closeTag) {
|
|
depth = Math.max(0, depth - 1);
|
|
cursor = tagStart + closeTag[0].length;
|
|
continue;
|
|
}
|
|
if ("<think".startsWith(tail) || "</think>".startsWith(tail)) break;
|
|
if (depth) reasoning += "<";
|
|
else answer += "<";
|
|
cursor = tagStart + 1;
|
|
}
|
|
return { answer, reasoning };
|
|
}
|
|
|
|
const displayTime = computed(() => {
|
|
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(props.createdAt) ? props.createdAt : `${props.createdAt}Z`;
|
|
const date = new Date(normalized);
|
|
if (Number.isNaN(date.getTime())) return "";
|
|
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(date);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<article class="chat-message" :class="role" :data-message-id="messageId">
|
|
<div v-if="role === 'assistant'" class="assistant-icon" aria-hidden="true">
|
|
<Bot :size="19" />
|
|
</div>
|
|
<div class="message-bubble">
|
|
<template v-if="role === 'assistant'">
|
|
<details v-if="showReasoning && reasoning.trim()" class="reasoning-panel" :open="streaming">
|
|
<summary>思考过程</summary>
|
|
<div class="reasoning-content">{{ reasoning }}</div>
|
|
</details>
|
|
<div v-if="renderedContent" class="message-content markdown-content" v-html="renderedContent"></div>
|
|
<div v-if="streaming && !renderedContent" class="generation-state">
|
|
<span></span><span></span><span></span>
|
|
思考中
|
|
</div>
|
|
<div v-if="errorMessage" class="message-error-state" role="alert">
|
|
<span>{{ errorMessage }}</span>
|
|
<button v-if="canRetry" type="button" :disabled="streaming" @click="emit('retry')">
|
|
<RotateCcw :size="15" aria-hidden="true" />
|
|
重试
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<div v-else class="message-content">{{ renderedContent }}</div>
|
|
<time v-if="displayTime" :datetime="createdAt">{{ displayTime }}</time>
|
|
<button v-if="role === 'assistant' && !streaming && !errorMessage && /^\d+$/.test(messageId)" type="button" class="message-feedback-button" @click="emit('feedback')">
|
|
<MessageSquareWarning :size="14" aria-hidden="true" />
|
|
反馈
|
|
</button>
|
|
</div>
|
|
</article>
|
|
</template>
|