151 lines
5.2 KiB
Vue
151 lines
5.2 KiB
Vue
<template>
|
||
<div class="bubble" :class="msg.role">
|
||
<!-- 工具操作卡片 -->
|
||
<div v-if="msg.tools?.length" class="tools">
|
||
<ToolCard
|
||
v-for="(t, i) in msg.tools"
|
||
:key="i"
|
||
:tool="t"
|
||
@approve="$emit('approve', t)"
|
||
@reject="$emit('reject', t)"
|
||
/>
|
||
</div>
|
||
<div v-else-if="msg.approval" class="tools">
|
||
<ToolCard
|
||
:tool="{ tool: msg.approval.tool, args: msg.approval.args, command: msg.approval.command, status: 'approval' }"
|
||
@approve="$emit('approve', msg)"
|
||
@reject="$emit('reject', msg)"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 普通消息(Markdown) -->
|
||
<div v-if="msg.content" class="md" v-html="rendered" @click="onClick"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed } from "vue";
|
||
import { marked } from "marked";
|
||
import hljs from "../lib/hljs";
|
||
import ToolCard from "./ToolCard.vue";
|
||
|
||
export interface ToolEvent {
|
||
tool: string;
|
||
args?: any;
|
||
command?: string;
|
||
status: "approval" | "running" | "done" | "failed" | "denied";
|
||
result?: string;
|
||
}
|
||
|
||
interface Msg {
|
||
id: number;
|
||
role: "user" | "assistant" | "system";
|
||
content: string;
|
||
loading?: boolean;
|
||
tools?: ToolEvent[];
|
||
approval?: { tool: string; command: string; args: any; tool_call_id: string };
|
||
}
|
||
|
||
const props = defineProps<{ msg: Msg }>();
|
||
defineEmits<{ approve: [payload: any]; reject: [payload: any] }>();
|
||
|
||
const renderer = new marked.Renderer();
|
||
renderer.code = (code, infostring) => {
|
||
const lang = (infostring || "").trim().split(/\s+/)[0];
|
||
const highlighted =
|
||
lang && hljs.getLanguage(lang)
|
||
? hljs.highlight(code, { language: lang }).value
|
||
: hljs.highlightAuto(code).value;
|
||
return (
|
||
`<div class="code-block"><div class="code-head"><span class="code-lang">${lang}</span>` +
|
||
`<button class="copy-btn" data-code="${encodeURIComponent(code)}">复制</button></div>` +
|
||
`<pre><code class="hljs">${highlighted}</code></pre></div>`
|
||
);
|
||
};
|
||
|
||
marked.setOptions({
|
||
renderer,
|
||
breaks: true,
|
||
gfm: true,
|
||
});
|
||
|
||
const rendered = computed(() => {
|
||
if (!props.msg.content) return "";
|
||
try {
|
||
return marked.parse(props.msg.content) as string;
|
||
} catch {
|
||
return props.msg.content;
|
||
}
|
||
});
|
||
|
||
async function onClick(e: MouseEvent) {
|
||
const btn = (e.target as HTMLElement).closest(".copy-btn") as HTMLElement | null;
|
||
if (!btn) return;
|
||
const code = decodeURIComponent(btn.dataset.code || "");
|
||
try {
|
||
await navigator.clipboard.writeText(code);
|
||
btn.textContent = "已复制";
|
||
setTimeout(() => (btn.textContent = "复制"), 1500);
|
||
} catch {
|
||
/* 剪贴板不可用时忽略 */
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.bubble { font-size: 14.5px; line-height: 1.7; }
|
||
.bubble.user :deep(.md) {
|
||
background: var(--user-bubble); color: var(--user-text);
|
||
padding: 10px 14px; border-radius: 14px 14px 4px 14px;
|
||
word-break: break-word;
|
||
}
|
||
.bubble.assistant :deep(.md) {
|
||
background: var(--assistant-bubble); color: var(--text);
|
||
padding: 12px 16px; border-radius: 14px 14px 14px 4px;
|
||
border: 1px solid var(--border); box-shadow: var(--shadow);
|
||
word-break: break-word;
|
||
}
|
||
|
||
.tools { display: flex; flex-direction: column; gap: 8px; }
|
||
.bubble.user .tools { align-items: flex-end; }
|
||
.bubble.user .tools :deep(.tool-card) { width: 100%; max-width: 460px; }
|
||
|
||
/* Markdown 元素 */
|
||
.bubble :deep(p) { margin: 0.5em 0; }
|
||
.bubble :deep(p:first-child) { margin-top: 0; }
|
||
.bubble :deep(p:last-child) { margin-bottom: 0; }
|
||
.bubble :deep(h1), .bubble :deep(h2), .bubble :deep(h3) { margin: 0.6em 0 0.4em; line-height: 1.3; }
|
||
.bubble :deep(ul), .bubble :deep(ol) { margin: 0.4em 0; padding-left: 1.4em; }
|
||
.bubble :deep(li) { margin: 0.2em 0; }
|
||
.bubble :deep(a) { color: var(--primary); text-decoration: none; }
|
||
.bubble :deep(a:hover) { text-decoration: underline; }
|
||
.bubble :deep(code):not(pre code) {
|
||
background: var(--surface-2); padding: 1px 6px; border-radius: 5px;
|
||
font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.88em;
|
||
}
|
||
.bubble :deep(.code-block) { margin: 0.6em 0; border-radius: 10px; overflow: hidden; background: var(--code-bg); }
|
||
.bubble :deep(.code-head) {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 6px 12px; background: rgba(255, 255, 255, 0.06);
|
||
}
|
||
.bubble :deep(.code-lang) { font-size: 11px; color: rgba(255, 255, 255, 0.6); text-transform: lowercase; }
|
||
.bubble :deep(.copy-btn) {
|
||
background: rgba(255, 255, 255, 0.1); border: none; color: rgba(255, 255, 255, 0.8);
|
||
font-size: 11px; padding: 3px 10px; border-radius: 6px; cursor: pointer;
|
||
}
|
||
.bubble :deep(.copy-btn:hover) { background: rgba(255, 255, 255, 0.2); color: #fff; }
|
||
.bubble :deep(pre) {
|
||
background: var(--code-bg); color: var(--code-text);
|
||
padding: 14px 16px; overflow-x: auto; margin: 0;
|
||
}
|
||
.bubble :deep(pre code) { font-family: "SFMono-Regular", Consolas, monospace; font-size: 13px; line-height: 1.6; padding: 0; background: transparent; }
|
||
.bubble :deep(table) {
|
||
border-collapse: collapse; margin: 0.6em 0; font-size: 13px; width: 100%;
|
||
}
|
||
.bubble :deep(th), .bubble :deep(td) { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }
|
||
.bubble :deep(th) { background: var(--surface-2); font-weight: 600; }
|
||
.bubble :deep(blockquote) {
|
||
border-left: 3px solid var(--primary); margin: 0.5em 0; padding: 2px 12px; color: var(--text-dim);
|
||
}
|
||
</style>
|