UI 调整

This commit is contained in:
asypost committed 2026-11-06 21:02:24 +08:00
1 parent 19ed404da6
commit f8f118adcb
1 file changed
+477 -127
+477 -127
View File
@@ -1,9 +1,10 @@
<script setup>
import { MarkdownHooks } from "vuemarkik";
import CodeBlock from "./CodeBlock.vue";
import rehypeShiki from "@shikijs/rehype";
import remarkGfm from "remark-gfm";
import { defineProps } from "vue";
import { computed } from "vue";
const props = defineProps({
userMessage: {
@@ -31,13 +32,24 @@ const rehypePlugins = [
defaultLanguage: "markdown",
langs: [
"javascript",
"typescript",
"html",
"css",
"json",
"python",
"kotlin",
"java",
"sql",
"bash",
"markdown",
"haskell",
"json",
"text",
],
langAlias: { card: "text" },
langAlias: {
card: "text",
sh: "bash",
shell: "bash",
},
lazy: true,
themes: {
light: "github-light",
@@ -46,128 +58,243 @@ const rehypePlugins = [
},
],
];
const userContents = computed(() =>
(props.userMessage?.content ?? []).filter(
item => item.type === "text" && item.text
)
);
const hasAssistantMessages = computed(
() => props.assistantMessages.length > 0
);
const hasActivity = computed(() =>
props.assistantMessages.some(message =>
(message.content ?? []).some(
item => item.type !== "text"
)
)
);
const isThinking = computed(
() => !hasAssistantMessages.value
);
function activityLabel(content) {
switch (content.type) {
case "think":
return "正在思考";
case "tool_call":
return "正在调用工具";
case "tool_result":
return content.error
? "工具执行失败"
: "工具执行完成";
case "error":
return "发生错误";
default:
return content.type;
}
}
function activityIcon(content) {
switch (content.type) {
case "think":
return "🧠";
case "tool_call":
return "⚙️";
case "tool_result":
return content.error ? "⚠️" : "✓";
case "error":
return "⚠️";
default:
return "ℹ️";
}
}
function activityClass(content) {
if (content.type === "error" || content.error) {
return "activity-error";
}
if (content.type === "tool_call" || content.type === "think") {
return "activity-running";
}
return "activity-completed";
}
</script>
<template>
<!-- 用户消息:左侧头像,右侧气泡 -->
<div class="message-row user-row">
<div class="avatar user-avatar">我</div>
<div class="conversation">
<!-- 用户消息 -->
<div class="message-row user-row">
<div class="message-content">
<div class="bubble">
<div class="user-bubble">
<template
v-for="(content, index) in userContents"
:key="index"
>
<MarkdownHooks
:text="userMessage.content"
:text="content.text"
:components="components"
:rehype-plugins="rehypePlugins"
:remark-plugins="remarkPlugins"
security-mode="trusted"
/>
</template>
</div>
</div>
<div class="avatar user-avatar">我</div>
</div>
<!-- AI 消息 -->
<div v-if="assistantMessages.length === 0" class="message-row assistant-row">
<div class="message-row assistant-row">
<div class="avatar assistant-avatar">
<span>AI</span>
</div>
<div class="message-content">
<div class="activity-item">
<span class="activity-icon">🧠</span>
<span>正在思考...</span>
</div>
</div>
<div class="avatar assistant-avatar">AI</div>
</div>
<div class="assistant-bubble">
<div v-else class="message-row assistant-row">
<div class="message-content">
<div class="bubble">
<template v-for="message in assistantMessages" :key="message.id">
<!-- 过程消息 -->
<div v-if="message.type !== 'text'" class="activity-item">
<template v-if="message.type === 'tool_call'">
<span class="activity-icon">🔧</span>
<span>正在调用工具</span>
<span class="activity-name">{{ message.tool_name }}</span>
</template>
<template v-else-if="message.type === 'think'">
<span class="activity-icon">🧠</span>
<span>正在思考...</span>
</template>
<template v-else-if="message.type === 'tool_result'">
<!-- 执行过程 -->
<div
v-if="hasActivity"
class="activity-list"
>
<template
v-for="(message, messageIndex) in assistantMessages"
:key="message.id ?? messageIndex"
>
<template
v-for="(content, contentIndex) in (message.content ?? [])"
:key="content.id ?? `${messageIndex}-${contentIndex}`"
>
<div
v-if="content.type !== 'text'"
class="activity-item"
:class="activityClass(content)"
>
<span class="activity-icon">
{{ message.status === 'failed' ? '⚠️' : '✓' }}
{{ activityIcon(content) }}
</span>
<span>
{{ message.status === 'failed' ? '工具执行失败' : '工具执行完成' }}
</span>
<span class="activity-name">{{ message.tool_name }}</span>
</template>
<template v-else>
<span class="activity-icon">ℹ️</span>
<span>{{ message.type }}</span>
<span class="activity-label">
{{ activityLabel(content) }}
</span>
<span
v-if="content.name"
class="activity-name"
>
{{ content.name }}
</span>
<span
v-if="content.type === 'error' && content.text"
class="activity-detail"
>
{{ content.text }}
</span>
</div>
</template>
</template>
</div>
<!-- 正式回答 -->
<template v-else>
<hr></hr>
<div
v-if="hasAssistantMessages"
class="assistant-answer"
>
<template
v-for="(message, messageIndex) in assistantMessages"
:key="message.id ?? messageIndex"
>
<template
v-for="(content, contentIndex) in (message.content ?? [])"
:key="content.id ?? `${messageIndex}-${contentIndex}`"
>
<MarkdownHooks
:text="message.content"
v-if="content.type === 'text' && content.text"
:text="content.text"
:components="components"
:rehype-plugins="rehypePlugins"
:remark-plugins="remarkPlugins"
security-mode="trusted"
/>
</template>
</template>
</div>
<!-- 思考状态 -->
<div
v-if="isThinking"
class="thinking-indicator"
>
<span class="thinking-dot"></span>
<span class="thinking-dot"></span>
<span class="thinking-dot"></span>
<span class="thinking-text">
{{ hasAssistantMessages ? "正在生成回答" : "正在思考" }}
</span>
</div>
<div class="avatar assistant-avatar">AI</div>
</div>
</div>
</div>
</div>
</template>
<style>
<style scoped>
.conversation {
display: flex;
flex-direction: column;
gap: 24px;
margin-bottom: 32px;
gap: 28px;
width: 100%;
padding: 12px 0 32px;
}
/* 消息行 */
/* 消息布局 */
.message-row {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
min-width: 0;
}
/* 用户消息靠左 */
.user-row {
justify-content: flex-start;
justify-content: flex-end;
}
/* AI 消息靠右 */
.assistant-row {
justify-content: flex-end;
justify-content: flex-start;
}
/* 头像 */
.avatar {
flex: 0 0 38px;
width: 38px;
height: 38px;
border-radius: 50%;
width: 36px;
height: 36px;
flex: 0 0 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
border-radius: 50%;
font-size: 12px;
font-weight: 600;
user-select: none;
}
.user-avatar {
@@ -177,100 +304,323 @@ const rehypePlugins = [
.assistant-avatar {
color: #fff;
background: #2563eb;
background: linear-gradient(145deg, #3b82f6, #2563eb);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.2);
}
/* 消息内容 */
.message-content {
max-width: 78%;
min-width: 0;
max-width: min(82%, 860px);
display: flex;
flex-direction: column;
gap: 10px;
}
/* 消息气泡 */
.bubble {
padding: 12px 16px;
border-radius: 12px;
line-height: 1.6;
/* 用户气泡 */
.user-bubble {
padding: 10px 15px;
border-radius: 14px 3px 14px 14px;
background: #eaf2ff;
color: #26364b;
line-height: 1.7;
overflow-wrap: anywhere;
min-width: 40px;
}
.user-row .bubble {
background: #e8f1ff;
border-top-left-radius: 3px;
}
.assistant-row .bubble {
background: #f4f5f7;
border-top-right-radius: 3px;
}
/* Markdown 内容 */
.bubble :deep(p:first-child) {
margin-top: 0;
}
.bubble :deep(p:last-child) {
margin-bottom: 0;
}
.bubble :deep(pre) {
overflow-x: auto;
padding: 12px;
border-radius: 8px;
white-space: pre;
}
.bubble :deep(table) {
/* AI 回复气泡 */
.assistant-bubble {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
min-width: 0;
box-sizing: border-box;
padding: 14px 18px;
border: 1px solid var(--chat-assistant-border, #edf0f4);
border-radius: 3px 14px 14px 14px;
background: var(--chat-assistant-bubble, #f4f6f9);
color: var(--chat-text, #303133);
line-height: 1.85;
overflow-wrap: anywhere;
}
.assistant-answer {
color: var(--chat-text, #303133);
font-size: 14px;
line-height: 1.85;
overflow-wrap: anywhere;
}
.bubble :deep(th),
.bubble :deep(td) {
border: 1px solid #dcdfe6;
padding: 8px 12px;
text-align: left;
}
.bubble :deep(th) {
background: #f5f7fa;
}
.bubble :deep(tr:nth-child(even)) {
background: #fafafa;
/* 思考和工具执行状态 */
.activity-list {
display: flex;
flex-direction: column;
gap: 7px;
padding: 10px 12px;
border-left: 2px solid #e2e8f0;
margin-bottom: 8px;
}
.activity-item {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
margin: 1px 0;
border-radius: 4px;
color: #84909f;
flex-wrap: wrap;
gap: 7px;
min-height: 20px;
font-size: 12px;
line-height: 1.3;
line-height: 1.5;
color: #84909f;
}
.activity-icon {
flex-shrink: 0;
font-size: 12px;
flex: 0 0 15px;
text-align: center;
}
.activity-label {
color: #7b8795;
}
.activity-name {
padding: 1px 7px;
border-radius: 4px;
background: #f1f5f9;
color: #64748b;
font-family: monospace;
background: #eef1f5;
padding: 0 4px;
border-radius: 3px;
font-size: 11px;
}
/* 过程消息与正式回答之间的间距 */
.activity-item + p,
.activity-item + pre {
margin-top: 12px;
.activity-detail {
width: 100%;
padding-left: 22px;
color: #dc5555;
overflow-wrap: anywhere;
}
.activity-running .activity-icon {
color: #3b82f6;
}
.activity-completed .activity-icon {
color: #16a36a;
}
.activity-error .activity-icon,
.activity-error .activity-label {
color: #dc5555;
}
/* 思考动画 */
.thinking-indicator {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 0;
color: #94a3b8;
font-size: 12px;
}
.thinking-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #94a3b8;
animation: thinking 1.2s infinite ease-in-out;
}
.thinking-dot:nth-child(2) {
animation-delay: 0.15s;
}
.thinking-dot:nth-child(3) {
animation-delay: 0.3s;
}
.thinking-text {
margin-left: 4px;
}
@keyframes thinking {
0%, 60%, 100% {
opacity: 0.35;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-3px);
}
}
/* Markdown */
.assistant-answer :deep(p),
.user-bubble :deep(p) {
margin: 0 0 12px;
}
.assistant-answer :deep(p:last-child),
.user-bubble :deep(p:last-child) {
margin-bottom: 0;
}
.assistant-answer :deep(h1),
.assistant-answer :deep(h2),
.assistant-answer :deep(h3) {
margin: 22px 0 12px;
line-height: 1.45;
font-weight: 600;
color: var(--chat-heading, #1f2937);
}
.assistant-answer :deep(h1) {
font-size: 21px;
}
.assistant-answer :deep(h2) {
font-size: 18px;
}
.assistant-answer :deep(h3) {
font-size: 16px;
}
.assistant-answer :deep(ul),
.assistant-answer :deep(ol),
.user-bubble :deep(ul),
.user-bubble :deep(ol) {
padding-left: 24px;
margin: 8px 0 14px;
}
.assistant-answer :deep(li),
.user-bubble :deep(li) {
padding: 2px 0;
}
.assistant-answer :deep(blockquote) {
margin: 12px 0;
padding: 8px 14px;
border-left: 3px solid #93b4e8;
background: #f6f8fb;
color: #64748b;
border-radius: 0 6px 6px 0;
}
.assistant-answer :deep(hr) {
border: none;
border-top: 1px solid #e5e7eb;
margin: 20px 0;
}
/* 代码块 */
.assistant-answer :deep(pre),
.user-bubble :deep(pre) {
max-width: 100%;
overflow-x: auto;
padding: 14px;
margin: 12px 0;
border: 1px solid #e5e7eb;
border-radius: 9px;
font-size: 13px;
line-height: 1.6;
white-space: pre;
}
.assistant-answer :deep(:not(pre) > code),
.user-bubble :deep(:not(pre) > code) {
padding: 2px 5px;
border-radius: 4px;
background: #f1f5f9;
color: #db5a5a;
font-family: monospace;
font-size: 0.9em;
}
/* 表格 */
.assistant-answer :deep(table),
.user-bubble :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
font-size: 13px;
line-height: 1.6;
}
.assistant-answer :deep(th),
.assistant-answer :deep(td),
.user-bubble :deep(th),
.user-bubble :deep(td) {
border: 1px solid #e2e8f0;
padding: 8px 12px;
text-align: left;
}
.assistant-answer :deep(th),
.user-bubble :deep(th) {
background: #f5f7fa;
font-weight: 600;
}
.assistant-answer :deep(tr:nth-child(even)),
.user-bubble :deep(tr:nth-child(even)) {
background: #fafbfc;
}
/* 链接 */
.assistant-answer :deep(a),
.user-bubble :deep(a) {
color: #2563eb;
text-decoration: none;
}
.assistant-answer :deep(a:hover),
.user-bubble :deep(a:hover) {
text-decoration: underline;
}
/* 图片 */
.assistant-answer :deep(img),
.user-bubble :deep(img) {
max-width: 100%;
height: auto;
border-radius: 8px;
}
/* 移动端 */
@media (max-width: 640px) {
.conversation {
gap: 22px;
}
.avatar {
width: 30px;
height: 30px;
flex-basis: 30px;
font-size: 11px;
}
.message-row {
gap: 8px;
}
.message-content {
max-width: calc(100% - 38px);
}
.user-bubble {
padding: 9px 12px;
}
.assistant-bubble {
padding: 10px 12px;
}
.assistant-answer {
font-size: 14px;
}
.assistant-answer :deep(table),
.user-bubble :deep(table) {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
</style>