| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- <script setup lang="ts">
- import { computed } from 'vue'
- import { Tag } from 'ant-design-vue'
- import { LoadingOutlined } from '@ant-design/icons-vue'
- import { renderMarkdown, formatTime } from '@/utils/markdown'
- import { getToolConfig, formatToolArgs, formatToolResult } from '@/utils/toolDisplay'
- import LobsterIcon from '@/assets/lobster.svg'
- // 消息段类型
- interface TextSegment {
- type: 'text'
- id: number
- content: string
- }
- interface ToolSegment {
- type: 'tool'
- id: number
- tool: string
- args: Record<string, unknown>
- result?: string
- status: 'running' | 'done' | 'error'
- }
- type MessageSegment = TextSegment | ToolSegment
- interface Message {
- id: number
- role: 'user' | 'assistant'
- content: string
- timestamp: Date
- segments?: MessageSegment[]
- }
- interface Props {
- message: Message
- assistantName?: string
- expandedTools?: Set<number>
- }
- interface Emits {
- (e: 'toggle-tool', toolId: number): void
- }
- const props = withDefaults(defineProps<Props>(), {
- assistantName: 'HelloClaw',
- expandedTools: () => new Set()
- })
- const emit = defineEmits<Emits>()
- // 切换工具折叠状态
- const toggleToolCollapse = (toolId: number) => {
- emit('toggle-tool', toolId)
- }
- // 检查工具是否展开
- const isToolExpanded = (toolId: number): boolean => {
- return props.expandedTools.has(toolId)
- }
- // 检查是否有可见内容
- const hasVisibleContent = computed(() => {
- if (!props.message.segments || props.message.segments.length === 0) {
- return !!props.message.content
- }
- for (const segment of props.message.segments) {
- if (segment.type === 'text' && segment.content) {
- return true
- }
- if (segment.type === 'tool' && !getToolConfig(segment.tool).hidden) {
- return true
- }
- }
- return false
- })
- </script>
- <template>
- <div v-if="hasVisibleContent" :class="['chat-message', message.role]">
- <!-- 头像 -->
- <div class="message-avatar">
- <img v-if="message.role === 'assistant'" :src="LobsterIcon" alt="HelloClaw" />
- <div v-else class="user-avatar">你</div>
- </div>
- <!-- 消息内容 -->
- <div class="message-content">
- <!-- 如果有分段,按分段显示 -->
- <template v-if="message.segments && message.segments.length > 0">
- <template v-for="segment in message.segments" :key="segment.id">
- <!-- 文本段 -->
- <div v-if="segment.type === 'text' && segment.content" class="message-bubble">
- <div
- class="message-text"
- v-html="renderMarkdown(segment.content)"
- ></div>
- </div>
- <!-- 工具调用段 - 只显示非隐藏的工具 -->
- <div
- v-if="segment.type === 'tool' && !getToolConfig(segment.tool).hidden"
- :class="['tool-card', segment.status]"
- >
- <div
- class="tool-header"
- @click="segment.status !== 'running' && toggleToolCollapse(segment.id)"
- >
- <span class="tool-icon">{{ getToolConfig(segment.tool).icon }}</span>
- <span class="tool-name">
- <template v-if="!isToolExpanded(segment.id)">使用了</template>
- {{ getToolConfig(segment.tool).name }}
- </span>
- <Tag v-if="segment.status === 'running'" color="processing" class="tool-tag">
- <LoadingOutlined /> 执行中
- </Tag>
- <Tag v-else-if="segment.status === 'done'" color="success" class="tool-tag">完成</Tag>
- <Tag v-else-if="segment.status === 'error'" color="error" class="tool-tag">失败</Tag>
- <span
- v-if="segment.status !== 'running'"
- class="collapse-indicator"
- >
- {{ isToolExpanded(segment.id) ? '▼' : '▶' }}
- </span>
- </div>
- <!-- 展开后显示入参和结果 -->
- <div v-if="isToolExpanded(segment.id)" class="tool-details">
- <!-- 入参 -->
- <div v-if="segment.args && Object.keys(segment.args).length > 0" class="tool-args">
- <div class="tool-detail-label">入参</div>
- <pre class="tool-detail-content">{{ formatToolArgs(segment.args) }}</pre>
- </div>
- <!-- 结果 -->
- <div v-if="segment.result" class="tool-result-wrapper">
- <div class="tool-detail-label">结果</div>
- <pre class="tool-detail-content">{{ formatToolResult(segment.result) }}</pre>
- </div>
- </div>
- </div>
- </template>
- </template>
- <!-- 如果没有分段,显示普通内容(历史消息) -->
- <div v-else-if="message.content" class="message-bubble">
- <div
- class="message-text"
- v-html="renderMarkdown(message.content)"
- ></div>
- </div>
- <!-- 消息元信息 -->
- <div class="message-meta">
- <span class="message-sender">
- {{ message.role === 'user' ? '你' : assistantName }}
- </span>
- <span class="message-time">{{ formatTime(message.timestamp) }}</span>
- </div>
- </div>
- </div>
- </template>
- <style scoped>
- .chat-message {
- display: flex;
- gap: 12px;
- max-width: 85%;
- }
- .chat-message.user {
- align-self: flex-end;
- flex-direction: row-reverse;
- }
- .chat-message.assistant {
- align-self: flex-start;
- }
- /* 头像 */
- .message-avatar {
- flex-shrink: 0;
- width: 36px;
- height: 36px;
- }
- .message-avatar img {
- width: 36px;
- height: 36px;
- border-radius: 8px;
- }
- .user-avatar {
- width: 36px;
- height: 36px;
- border-radius: 8px;
- background-color: var(--color-primary);
- color: #fff;
- font-size: 14px;
- font-weight: 600;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- /* 消息内容 */
- .message-content {
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
- }
- /* 消息气泡 */
- .message-bubble {
- display: inline-block;
- max-width: 100%;
- }
- .message-text {
- padding: 10px 14px;
- border-radius: 12px;
- background-color: var(--color-surface);
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
- line-height: 1.6;
- word-wrap: break-word;
- }
- .chat-message.user .message-text {
- background-color: var(--color-primary-light);
- border: 1px solid rgba(255, 92, 92, 0.2);
- }
- /* Markdown 样式 */
- .message-text :deep(p) {
- margin: 0;
- }
- .message-text :deep(p + p) {
- margin-top: 8px;
- }
- .message-text :deep(code) {
- background-color: rgba(0, 0, 0, 0.05);
- padding: 2px 6px;
- border-radius: 4px;
- font-size: 13px;
- }
- .message-text :deep(pre) {
- background-color: #1e1e1e;
- color: #d4d4d4;
- padding: 12px;
- border-radius: 8px;
- overflow-x: auto;
- margin: 8px 0;
- }
- .message-text :deep(pre code) {
- background-color: transparent;
- padding: 0;
- }
- .message-text :deep(ul),
- .message-text :deep(ol) {
- margin: 8px 0;
- padding-left: 20px;
- }
- .message-text :deep(blockquote) {
- border-left: 3px solid var(--color-primary);
- padding-left: 12px;
- margin: 8px 0;
- color: var(--color-text-secondary);
- }
- .message-text :deep(a) {
- color: var(--color-primary);
- text-decoration: underline;
- }
- /* 消息元信息 */
- .message-meta {
- display: flex;
- gap: 8px;
- align-items: center;
- margin-top: 4px;
- padding-left: 4px;
- }
- .message-sender {
- font-size: 12px;
- font-weight: 600;
- color: var(--color-text);
- }
- .message-time {
- font-size: 11px;
- color: var(--color-text-secondary);
- }
- /* 工具调用卡片 */
- .tool-card {
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- border-radius: 8px;
- padding: 8px 12px;
- font-size: 13px;
- transition: all 0.2s ease;
- margin-top: 8px;
- }
- /* 执行中状态 - 龙虾红主题 */
- .tool-card.running {
- border-color: var(--color-primary);
- background: var(--color-primary-light);
- }
- .tool-card.running .tool-icon,
- .tool-card.running .tool-name {
- color: var(--color-primary);
- }
- /* 完成状态 - 灰色调 */
- .tool-card.done {
- border-color: var(--color-border);
- background: var(--color-surface);
- }
- /* 失败状态 - 红色调 */
- .tool-card.error {
- border-color: var(--color-primary);
- background: #fff1f0;
- }
- .tool-card.error .tool-icon,
- .tool-card.error .tool-name {
- color: var(--color-primary);
- }
- .tool-header {
- display: flex;
- align-items: center;
- gap: 8px;
- cursor: pointer;
- user-select: none;
- }
- .tool-header:hover {
- opacity: 0.8;
- }
- .tool-icon {
- font-size: 14px;
- line-height: 1;
- }
- .tool-name {
- font-weight: 500;
- color: var(--color-text);
- flex: 1;
- }
- .tool-tag {
- font-size: 11px;
- padding: 0 6px;
- line-height: 18px;
- border-radius: 4px;
- }
- .collapse-indicator {
- font-size: 10px;
- color: var(--color-text-secondary);
- margin-left: auto;
- transition: transform 0.2s ease;
- }
- /* 工具详情区域 */
- .tool-details {
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px dashed var(--color-border);
- }
- .tool-args,
- .tool-result-wrapper {
- margin-bottom: 8px;
- }
- .tool-result-wrapper:last-child {
- margin-bottom: 0;
- }
- .tool-detail-label {
- font-size: 11px;
- color: var(--color-text-secondary);
- margin-bottom: 4px;
- font-weight: 500;
- }
- .tool-detail-content {
- margin: 0;
- padding: 8px;
- background: rgba(0, 0, 0, 0.02);
- border-radius: 4px;
- font-size: 12px;
- color: var(--color-text);
- max-height: 150px;
- overflow-y: auto;
- white-space: pre-wrap;
- word-break: break-word;
- font-family: ui-monospace, 'SF Mono', Monaco, 'Andale Mono', monospace;
- }
- </style>
|