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> <script setup>
import { MarkdownHooks } from "vuemarkik"; import { MarkdownHooks } from "vuemarkik";
import CodeBlock from "./CodeBlock.vue"; import CodeBlock from "./CodeBlock.vue";
import rehypeShiki from "@shikijs/rehype"; import rehypeShiki from "@shikijs/rehype";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { defineProps } from "vue"; import { computed } from "vue";
const props = defineProps({ const props = defineProps({
userMessage: { userMessage: {
@@ -31,13 +32,24 @@ const rehypePlugins = [
defaultLanguage: "markdown", defaultLanguage: "markdown",
langs: [ langs: [
"javascript", "javascript",
"typescript",
"html", "html",
"css",
"json",
"python",
"kotlin",
"java",
"sql",
"bash",
"markdown", "markdown",
"haskell", "haskell",
"json",
"text", "text",
], ],
langAlias: { card: "text" }, langAlias: {
card: "text",
sh: "bash",
shell: "bash",
},
lazy: true, lazy: true,
themes: { themes: {
light: "github-light", 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> </script>
<template> <template>
<!-- 用户消息:左侧头像,右侧气泡 --> <div class="conversation">
<div class="message-row user-row">
<div class="avatar user-avatar">我</div>
<!-- 用户消息 -->
<div class="message-row user-row">
<div class="message-content"> <div class="message-content">
<div class="bubble"> <div class="user-bubble">
<template
v-for="(content, index) in userContents"
:key="index"
>
<MarkdownHooks <MarkdownHooks
:text="userMessage.content" :text="content.text"
:components="components" :components="components"
:rehype-plugins="rehypePlugins" :rehype-plugins="rehypePlugins"
:remark-plugins="remarkPlugins" :remark-plugins="remarkPlugins"
security-mode="trusted" security-mode="trusted"
/> />
</template>
</div> </div>
</div> </div>
<div class="avatar user-avatar">我</div>
</div> </div>
<!-- AI 消息 --> <!-- 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="message-content">
<div class="activity-item"> <div class="assistant-bubble">
<span class="activity-icon">🧠</span>
<span>正在思考...</span>
</div>
</div>
<div class="avatar assistant-avatar">AI</div>
</div>
<div v-else class="message-row assistant-row"> <!-- 执行过程 -->
<div class="message-content"> <div
<div class="bubble"> v-if="hasActivity"
<template v-for="message in assistantMessages" :key="message.id"> class="activity-list"
>
<!-- 过程消息 --> <template
<div v-if="message.type !== 'text'" class="activity-item"> v-for="(message, messageIndex) in assistantMessages"
<template v-if="message.type === 'tool_call'"> :key="message.id ?? messageIndex"
<span class="activity-icon">🔧</span> >
<span>正在调用工具</span> <template
<span class="activity-name">{{ message.tool_name }}</span> v-for="(content, contentIndex) in (message.content ?? [])"
</template> :key="content.id ?? `${messageIndex}-${contentIndex}`"
>
<template v-else-if="message.type === 'think'"> <div
<span class="activity-icon">🧠</span> v-if="content.type !== 'text'"
<span>正在思考...</span> class="activity-item"
</template> :class="activityClass(content)"
>
<template v-else-if="message.type === 'tool_result'">
<span class="activity-icon"> <span class="activity-icon">
{{ message.status === 'failed' ? '⚠️' : '✓' }} {{ activityIcon(content) }}
</span> </span>
<span>
{{ message.status === 'failed' ? '工具执行失败' : '工具执行完成' }}
</span>
<span class="activity-name">{{ message.tool_name }}</span>
</template>
<template v-else> <span class="activity-label">
<span class="activity-icon">ℹ️</span> {{ activityLabel(content) }}
<span>{{ message.type }}</span> </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> </template>
</div> </div>
<!-- 正式回答 --> <!-- 正式回答 -->
<template v-else> <div
<hr></hr> 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 <MarkdownHooks
:text="message.content" v-if="content.type === 'text' && content.text"
:text="content.text"
:components="components" :components="components"
:rehype-plugins="rehypePlugins" :rehype-plugins="rehypePlugins"
:remark-plugins="remarkPlugins" :remark-plugins="remarkPlugins"
security-mode="trusted" security-mode="trusted"
/> />
</template> </template>
</template> </template>
</div> </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>
<div class="avatar assistant-avatar">AI</div> </div>
</div>
</div>
</div> </div>
</template> </template>
<style> <style scoped>
.conversation { .conversation {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 28px;
margin-bottom: 32px; width: 100%;
padding: 12px 0 32px;
} }
/* 消息行 */ /* 消息布局 */
.message-row { .message-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
width: 100%; width: 100%;
min-width: 0;
} }
/* 用户消息靠左 */
.user-row { .user-row {
justify-content: flex-start; justify-content: flex-end;
} }
/* AI 消息靠右 */
.assistant-row { .assistant-row {
justify-content: flex-end; justify-content: flex-start;
} }
/* 头像 */ /* 头像 */
.avatar { .avatar {
flex: 0 0 38px; width: 36px;
width: 38px; height: 36px;
height: 38px; flex: 0 0 36px;
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 13px; border-radius: 50%;
font-size: 12px;
font-weight: 600; font-weight: 600;
user-select: none;
} }
.user-avatar { .user-avatar {
@@ -177,100 +304,323 @@ const rehypePlugins = [
.assistant-avatar { .assistant-avatar {
color: #fff; color: #fff;
background: #2563eb; background: linear-gradient(145deg, #3b82f6, #2563eb);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.2);
} }
/* 消息内容 */ /* 消息内容 */
.message-content { .message-content {
max-width: 78%;
min-width: 0; min-width: 0;
max-width: min(82%, 860px);
display: flex;
flex-direction: column;
gap: 10px;
} }
/* 消息气泡 */ /* 用户气泡 */
.bubble { .user-bubble {
padding: 12px 16px; padding: 10px 15px;
border-radius: 12px; border-radius: 14px 3px 14px 14px;
line-height: 1.6; background: #eaf2ff;
color: #26364b;
line-height: 1.7;
overflow-wrap: anywhere; overflow-wrap: anywhere;
min-width: 40px;
} }
.user-row .bubble { /* AI 回复气泡 */
background: #e8f1ff; .assistant-bubble {
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) {
width: 100%; width: 100%;
border-collapse: collapse; min-width: 0;
margin: 12px 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; font-size: 14px;
line-height: 1.85;
overflow-wrap: anywhere;
} }
.bubble :deep(th), /* 思考和工具执行状态 */
.bubble :deep(td) { .activity-list {
border: 1px solid #dcdfe6; display: flex;
padding: 8px 12px; flex-direction: column;
text-align: left; gap: 7px;
} padding: 10px 12px;
border-left: 2px solid #e2e8f0;
.bubble :deep(th) { margin-bottom: 8px;
background: #f5f7fa;
}
.bubble :deep(tr:nth-child(even)) {
background: #fafafa;
} }
.activity-item { .activity-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; flex-wrap: wrap;
padding: 2px 6px; gap: 7px;
margin: 1px 0; min-height: 20px;
border-radius: 4px;
color: #84909f;
font-size: 12px; font-size: 12px;
line-height: 1.3; line-height: 1.5;
color: #84909f;
} }
.activity-icon { .activity-icon {
flex-shrink: 0; flex: 0 0 15px;
font-size: 12px; text-align: center;
}
.activity-label {
color: #7b8795;
} }
.activity-name { .activity-name {
padding: 1px 7px;
border-radius: 4px;
background: #f1f5f9;
color: #64748b; color: #64748b;
font-family: monospace; font-family: monospace;
background: #eef1f5; font-size: 11px;
padding: 0 4px;
border-radius: 3px;
} }
/* 过程消息与正式回答之间的间距 */ .activity-detail {
.activity-item + p, width: 100%;
.activity-item + pre { padding-left: 22px;
margin-top: 12px; 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> </style>