ChatMessage.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. <script setup lang="ts">
  2. import { computed } from 'vue'
  3. import { Tag } from 'ant-design-vue'
  4. import { LoadingOutlined } from '@ant-design/icons-vue'
  5. import { renderMarkdown, formatTime } from '@/utils/markdown'
  6. import { getToolConfig, formatToolArgs, formatToolResult } from '@/utils/toolDisplay'
  7. import LobsterIcon from '@/assets/lobster.svg'
  8. // 消息段类型
  9. interface TextSegment {
  10. type: 'text'
  11. id: number
  12. content: string
  13. }
  14. interface ToolSegment {
  15. type: 'tool'
  16. id: number
  17. tool: string
  18. args: Record<string, unknown>
  19. result?: string
  20. status: 'running' | 'done' | 'error'
  21. }
  22. type MessageSegment = TextSegment | ToolSegment
  23. interface Message {
  24. id: number
  25. role: 'user' | 'assistant'
  26. content: string
  27. timestamp: Date
  28. segments?: MessageSegment[]
  29. }
  30. interface Props {
  31. message: Message
  32. assistantName?: string
  33. expandedTools?: Set<number>
  34. }
  35. interface Emits {
  36. (e: 'toggle-tool', toolId: number): void
  37. }
  38. const props = withDefaults(defineProps<Props>(), {
  39. assistantName: 'HelloClaw',
  40. expandedTools: () => new Set()
  41. })
  42. const emit = defineEmits<Emits>()
  43. // 切换工具折叠状态
  44. const toggleToolCollapse = (toolId: number) => {
  45. emit('toggle-tool', toolId)
  46. }
  47. // 检查工具是否展开
  48. const isToolExpanded = (toolId: number): boolean => {
  49. return props.expandedTools.has(toolId)
  50. }
  51. // 检查是否有可见内容
  52. const hasVisibleContent = computed(() => {
  53. if (!props.message.segments || props.message.segments.length === 0) {
  54. return !!props.message.content
  55. }
  56. for (const segment of props.message.segments) {
  57. if (segment.type === 'text' && segment.content) {
  58. return true
  59. }
  60. if (segment.type === 'tool' && !getToolConfig(segment.tool).hidden) {
  61. return true
  62. }
  63. }
  64. return false
  65. })
  66. </script>
  67. <template>
  68. <div v-if="hasVisibleContent" :class="['chat-message', message.role]">
  69. <!-- 头像 -->
  70. <div class="message-avatar">
  71. <img v-if="message.role === 'assistant'" :src="LobsterIcon" alt="HelloClaw" />
  72. <div v-else class="user-avatar">你</div>
  73. </div>
  74. <!-- 消息内容 -->
  75. <div class="message-content">
  76. <!-- 如果有分段,按分段显示 -->
  77. <template v-if="message.segments && message.segments.length > 0">
  78. <template v-for="segment in message.segments" :key="segment.id">
  79. <!-- 文本段 -->
  80. <div v-if="segment.type === 'text' && segment.content" class="message-bubble">
  81. <div
  82. class="message-text"
  83. v-html="renderMarkdown(segment.content)"
  84. ></div>
  85. </div>
  86. <!-- 工具调用段 - 只显示非隐藏的工具 -->
  87. <div
  88. v-if="segment.type === 'tool' && !getToolConfig(segment.tool).hidden"
  89. :class="['tool-card', segment.status]"
  90. >
  91. <div
  92. class="tool-header"
  93. @click="segment.status !== 'running' && toggleToolCollapse(segment.id)"
  94. >
  95. <span class="tool-icon">{{ getToolConfig(segment.tool).icon }}</span>
  96. <span class="tool-name">
  97. <template v-if="!isToolExpanded(segment.id)">使用了</template>
  98. {{ getToolConfig(segment.tool).name }}
  99. </span>
  100. <Tag v-if="segment.status === 'running'" color="processing" class="tool-tag">
  101. <LoadingOutlined /> 执行中
  102. </Tag>
  103. <Tag v-else-if="segment.status === 'done'" color="success" class="tool-tag">完成</Tag>
  104. <Tag v-else-if="segment.status === 'error'" color="error" class="tool-tag">失败</Tag>
  105. <span
  106. v-if="segment.status !== 'running'"
  107. class="collapse-indicator"
  108. >
  109. {{ isToolExpanded(segment.id) ? '▼' : '▶' }}
  110. </span>
  111. </div>
  112. <!-- 展开后显示入参和结果 -->
  113. <div v-if="isToolExpanded(segment.id)" class="tool-details">
  114. <!-- 入参 -->
  115. <div v-if="segment.args && Object.keys(segment.args).length > 0" class="tool-args">
  116. <div class="tool-detail-label">入参</div>
  117. <pre class="tool-detail-content">{{ formatToolArgs(segment.args) }}</pre>
  118. </div>
  119. <!-- 结果 -->
  120. <div v-if="segment.result" class="tool-result-wrapper">
  121. <div class="tool-detail-label">结果</div>
  122. <pre class="tool-detail-content">{{ formatToolResult(segment.result) }}</pre>
  123. </div>
  124. </div>
  125. </div>
  126. </template>
  127. </template>
  128. <!-- 如果没有分段,显示普通内容(历史消息) -->
  129. <div v-else-if="message.content" class="message-bubble">
  130. <div
  131. class="message-text"
  132. v-html="renderMarkdown(message.content)"
  133. ></div>
  134. </div>
  135. <!-- 消息元信息 -->
  136. <div class="message-meta">
  137. <span class="message-sender">
  138. {{ message.role === 'user' ? '你' : assistantName }}
  139. </span>
  140. <span class="message-time">{{ formatTime(message.timestamp) }}</span>
  141. </div>
  142. </div>
  143. </div>
  144. </template>
  145. <style scoped>
  146. .chat-message {
  147. display: flex;
  148. gap: 12px;
  149. max-width: 85%;
  150. }
  151. .chat-message.user {
  152. align-self: flex-end;
  153. flex-direction: row-reverse;
  154. }
  155. .chat-message.assistant {
  156. align-self: flex-start;
  157. }
  158. /* 头像 */
  159. .message-avatar {
  160. flex-shrink: 0;
  161. width: 36px;
  162. height: 36px;
  163. }
  164. .message-avatar img {
  165. width: 36px;
  166. height: 36px;
  167. border-radius: 8px;
  168. }
  169. .user-avatar {
  170. width: 36px;
  171. height: 36px;
  172. border-radius: 8px;
  173. background-color: var(--color-primary);
  174. color: #fff;
  175. font-size: 14px;
  176. font-weight: 600;
  177. display: flex;
  178. align-items: center;
  179. justify-content: center;
  180. }
  181. /* 消息内容 */
  182. .message-content {
  183. display: flex;
  184. flex-direction: column;
  185. gap: 2px;
  186. min-width: 0;
  187. }
  188. /* 消息气泡 */
  189. .message-bubble {
  190. display: inline-block;
  191. max-width: 100%;
  192. }
  193. .message-text {
  194. padding: 10px 14px;
  195. border-radius: 12px;
  196. background-color: var(--color-surface);
  197. box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
  198. line-height: 1.6;
  199. word-wrap: break-word;
  200. }
  201. .chat-message.user .message-text {
  202. background-color: var(--color-primary-light);
  203. border: 1px solid rgba(255, 92, 92, 0.2);
  204. }
  205. /* Markdown 样式 */
  206. .message-text :deep(p) {
  207. margin: 0;
  208. }
  209. .message-text :deep(p + p) {
  210. margin-top: 8px;
  211. }
  212. .message-text :deep(code) {
  213. background-color: rgba(0, 0, 0, 0.05);
  214. padding: 2px 6px;
  215. border-radius: 4px;
  216. font-size: 13px;
  217. }
  218. .message-text :deep(pre) {
  219. background-color: #1e1e1e;
  220. color: #d4d4d4;
  221. padding: 12px;
  222. border-radius: 8px;
  223. overflow-x: auto;
  224. margin: 8px 0;
  225. }
  226. .message-text :deep(pre code) {
  227. background-color: transparent;
  228. padding: 0;
  229. }
  230. .message-text :deep(ul),
  231. .message-text :deep(ol) {
  232. margin: 8px 0;
  233. padding-left: 20px;
  234. }
  235. .message-text :deep(blockquote) {
  236. border-left: 3px solid var(--color-primary);
  237. padding-left: 12px;
  238. margin: 8px 0;
  239. color: var(--color-text-secondary);
  240. }
  241. .message-text :deep(a) {
  242. color: var(--color-primary);
  243. text-decoration: underline;
  244. }
  245. /* 消息元信息 */
  246. .message-meta {
  247. display: flex;
  248. gap: 8px;
  249. align-items: center;
  250. margin-top: 4px;
  251. padding-left: 4px;
  252. }
  253. .message-sender {
  254. font-size: 12px;
  255. font-weight: 600;
  256. color: var(--color-text);
  257. }
  258. .message-time {
  259. font-size: 11px;
  260. color: var(--color-text-secondary);
  261. }
  262. /* 工具调用卡片 */
  263. .tool-card {
  264. background: var(--color-surface);
  265. border: 1px solid var(--color-border);
  266. border-radius: 8px;
  267. padding: 8px 12px;
  268. font-size: 13px;
  269. transition: all 0.2s ease;
  270. margin-top: 8px;
  271. }
  272. /* 执行中状态 - 龙虾红主题 */
  273. .tool-card.running {
  274. border-color: var(--color-primary);
  275. background: var(--color-primary-light);
  276. }
  277. .tool-card.running .tool-icon,
  278. .tool-card.running .tool-name {
  279. color: var(--color-primary);
  280. }
  281. /* 完成状态 - 灰色调 */
  282. .tool-card.done {
  283. border-color: var(--color-border);
  284. background: var(--color-surface);
  285. }
  286. /* 失败状态 - 红色调 */
  287. .tool-card.error {
  288. border-color: var(--color-primary);
  289. background: #fff1f0;
  290. }
  291. .tool-card.error .tool-icon,
  292. .tool-card.error .tool-name {
  293. color: var(--color-primary);
  294. }
  295. .tool-header {
  296. display: flex;
  297. align-items: center;
  298. gap: 8px;
  299. cursor: pointer;
  300. user-select: none;
  301. }
  302. .tool-header:hover {
  303. opacity: 0.8;
  304. }
  305. .tool-icon {
  306. font-size: 14px;
  307. line-height: 1;
  308. }
  309. .tool-name {
  310. font-weight: 500;
  311. color: var(--color-text);
  312. flex: 1;
  313. }
  314. .tool-tag {
  315. font-size: 11px;
  316. padding: 0 6px;
  317. line-height: 18px;
  318. border-radius: 4px;
  319. }
  320. .collapse-indicator {
  321. font-size: 10px;
  322. color: var(--color-text-secondary);
  323. margin-left: auto;
  324. transition: transform 0.2s ease;
  325. }
  326. /* 工具详情区域 */
  327. .tool-details {
  328. margin-top: 10px;
  329. padding-top: 10px;
  330. border-top: 1px dashed var(--color-border);
  331. }
  332. .tool-args,
  333. .tool-result-wrapper {
  334. margin-bottom: 8px;
  335. }
  336. .tool-result-wrapper:last-child {
  337. margin-bottom: 0;
  338. }
  339. .tool-detail-label {
  340. font-size: 11px;
  341. color: var(--color-text-secondary);
  342. margin-bottom: 4px;
  343. font-weight: 500;
  344. }
  345. .tool-detail-content {
  346. margin: 0;
  347. padding: 8px;
  348. background: rgba(0, 0, 0, 0.02);
  349. border-radius: 4px;
  350. font-size: 12px;
  351. color: var(--color-text);
  352. max-height: 150px;
  353. overflow-y: auto;
  354. white-space: pre-wrap;
  355. word-break: break-word;
  356. font-family: ui-monospace, 'SF Mono', Monaco, 'Andale Mono', monospace;
  357. }
  358. </style>