feat(gui): refactor chat panel with Markdown rendering and tool indicators (U3)

- ChatMessage: replace v-html with markdown-it, add ToolCallIndicator
- ChatInput: add ContextPill support for file/skill context
- ChatSidebar: make collapsible (default collapsed)
- Create ToolCallIndicator.vue with color-coded tool type badges
- Create ContextPill.vue for context references
- Replace all hardcoded colors with Design Token references
- Install markdown-it for Markdown rendering
This commit is contained in:
chiguyong 2026-06-13 02:38:37 +08:00
parent 9273612a5b
commit 6d5a08cb0c
8 changed files with 2948 additions and 114 deletions

File diff suppressed because it is too large Load Diff

View File

@ -14,11 +14,13 @@
"@vue-flow/controls": "^1.1.0",
"@vue-flow/core": "^1.41.0",
"ant-design-vue": "^4.2.0",
"markdown-it": "^14.2.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^5.1.0",
"echarts": "^6.1.0",
"typescript": "^5.6.0",

View File

@ -1,33 +1,51 @@
<template>
<div class="chat-input">
<a-textarea
v-model:value="inputText"
:placeholder="placeholder"
:auto-size="{ minRows: 1, maxRows: 4 }"
:disabled="disabled"
@pressEnter="handlePressEnter"
class="chat-input__textarea"
/>
<a-button
type="primary"
:disabled="!canSend"
:loading="disabled"
@click="handleSend"
class="chat-input__send"
>
<template #icon><SendOutlined /></template>
发送
</a-button>
<div v-if="contextPills.length > 0" class="chat-input__pills">
<ContextPill
v-for="(pill, idx) in contextPills"
:key="idx"
:label="pill.label"
:icon="pill.icon"
:removable="pill.removable"
@remove="removePill(idx)"
/>
</div>
<div class="chat-input__row">
<a-textarea
v-model:value="inputText"
:placeholder="placeholder"
:auto-size="{ minRows: 1, maxRows: 4 }"
:disabled="disabled"
@pressEnter="handlePressEnter"
class="chat-input__textarea"
/>
<a-button
type="primary"
:disabled="!canSend"
:loading="disabled"
@click="handleSend"
class="chat-input__send"
>
<template #icon><SendOutlined /></template>
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, type Component } from 'vue'
import { Input as AInput, Button as AButton } from 'ant-design-vue'
import { SendOutlined } from '@ant-design/icons-vue'
import ContextPill from './ContextPill.vue'
const ATextarea = AInput.TextArea
interface ContextPillData {
label: string
icon?: Component
removable?: boolean
}
interface IProps {
disabled?: boolean
placeholder?: string
@ -43,6 +61,7 @@ const emit = defineEmits<{
}>()
const inputText = ref('')
const contextPills = ref<ContextPillData[]>([])
const canSend = computed(() => {
return inputText.value.trim().length > 0 && !props.disabled
@ -56,20 +75,36 @@ function handleSend(): void {
}
function handlePressEnter(event: KeyboardEvent): void {
if (event.shiftKey) return // Shift+Enter for new line
if (event.shiftKey) return
event.preventDefault()
handleSend()
}
function removePill(idx: number): void {
contextPills.value.splice(idx, 1)
}
</script>
<style scoped>
.chat-input {
display: flex;
flex-direction: column;
padding: var(--space-2) var(--space-3);
background: var(--bg-primary);
border-top: 1px solid var(--border-color);
}
.chat-input__pills {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
padding-bottom: var(--space-2);
}
.chat-input__row {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
gap: var(--space-2);
}
.chat-input__textarea {

View File

@ -3,26 +3,38 @@
<div class="chat-message__avatar">
<a-avatar
v-if="message.role === 'assistant'"
:size="36"
style="background-color: #1677ff"
:size="32"
class="chat-message__avatar--assistant"
>
<template #icon><RobotOutlined /></template>
</a-avatar>
<a-avatar
v-else
:size="36"
style="background-color: #52c41a"
:size="32"
class="chat-message__avatar--user"
>
<template #icon><UserOutlined /></template>
</a-avatar>
</div>
<div class="chat-message__body">
<!-- Tool call indicators -->
<div v-if="toolCalls.length > 0" class="chat-message__tools">
<ToolCallIndicator
v-for="(tc, idx) in toolCalls"
:key="idx"
:type="tc.type"
:name="tc.name"
/>
</div>
<!-- Message content -->
<div class="chat-message__content" :class="[`chat-message__content--${message.role}`]">
<span v-html="renderedContent"></span>
<div v-if="message.role === 'assistant'" class="chat-message__markdown" v-html="renderedContent"></div>
<span v-else>{{ message.content }}</span>
<a-spin v-if="isLoading" size="small" class="chat-message__loading" />
</div>
<!-- Routing info -->
<div v-if="showRouting" class="chat-message__routing">
<a-tag color="blue">
<a-tag color="purple">
<ThunderboltOutlined /> {{ message.matched_skill }}
</a-tag>
<a-tag v-if="message.confidence !== undefined" color="green">
@ -41,9 +53,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownIt from 'markdown-it'
import { Avatar as AAvatar, Tag as ATag, Spin as ASpin } from 'ant-design-vue'
import { RobotOutlined, UserOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
import type { IChatMessage } from '@/api/types'
import ToolCallIndicator from './ToolCallIndicator.vue'
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
interface ToolCall {
type: string
name: string
}
interface IProps {
message: IChatMessage
@ -65,20 +90,32 @@ const formattedTime = computed(() => {
})
const renderedContent = computed(() => {
// Simple rendering: escape HTML and convert newlines
const escaped = props.message.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return escaped.replace(/\n/g, '<br/>')
if (!props.message.content) return ''
return md.render(props.message.content)
})
const toolCalls = computed<ToolCall[]>(() => {
// Extract tool calls from message metadata or content patterns
const calls: ToolCall[] = []
const content = props.message.content || ''
// Detect tool use patterns like [Read], [Edit], [Bash], [Write], [Search]
const toolPattern = /\[(Read|Edit|Bash|Write|Search|Grep|Glob)\]/g
let match
while ((match = toolPattern.exec(content)) !== null) {
const toolName = match[1].toLowerCase()
calls.push({ type: toolName, name: match[1] })
}
return calls
})
</script>
<style scoped>
.chat-message {
display: flex;
gap: 12px;
padding: 12px 16px;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
}
.chat-message--user {
@ -89,50 +126,111 @@ const renderedContent = computed(() => {
flex-shrink: 0;
}
.chat-message__avatar--assistant {
background: var(--gradient-brand) !important;
}
.chat-message__avatar--user {
background-color: var(--color-success) !important;
}
.chat-message__body {
max-width: 70%;
max-width: 75%;
display: flex;
flex-direction: column;
gap: 4px;
gap: var(--space-1);
}
.chat-message--user .chat-message__body {
align-items: flex-end;
}
.chat-message__tools {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.chat-message__content {
padding: 10px 14px;
border-radius: 12px;
line-height: 1.6;
font-size: 14px;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
line-height: var(--leading-normal);
font-size: var(--font-base);
word-break: break-word;
}
.chat-message__content--user {
background: #1677ff;
color: #ffffff;
border-bottom-right-radius: 4px;
background: var(--color-primary);
color: var(--text-inverse);
border-bottom-right-radius: var(--radius-sm);
}
.chat-message__content--assistant {
background: #ffffff;
color: #333333;
border: 1px solid #f0f0f0;
border-bottom-left-radius: 4px;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-bottom-left-radius: var(--radius-sm);
}
.chat-message__markdown {
overflow-x: auto;
}
.chat-message__markdown :deep(p) {
margin-bottom: var(--space-2);
}
.chat-message__markdown :deep(p:last-child) {
margin-bottom: 0;
}
.chat-message__markdown :deep(pre) {
background: var(--code-bg);
color: var(--code-fg);
padding: var(--space-3);
border-radius: var(--radius-md);
overflow-x: auto;
margin: var(--space-2) 0;
font-size: var(--font-sm);
}
.chat-message__markdown :deep(code) {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Consolas, monospace;
font-size: var(--font-sm);
}
.chat-message__markdown :deep(:not(pre) > code) {
background: var(--color-primary-light);
color: var(--color-primary);
padding: 1px var(--space-1);
border-radius: var(--radius-sm);
}
.chat-message__markdown :deep(ul),
.chat-message__markdown :deep(ol) {
padding-left: var(--space-5);
margin-bottom: var(--space-2);
}
.chat-message__markdown :deep(h1),
.chat-message__markdown :deep(h2),
.chat-message__markdown :deep(h3) {
margin-top: var(--space-3);
margin-bottom: var(--space-2);
}
.chat-message__loading {
margin-left: 8px;
margin-left: var(--space-2);
}
.chat-message__routing {
display: flex;
gap: 4px;
gap: var(--space-1);
flex-wrap: wrap;
}
.chat-message__time {
font-size: 12px;
color: #999999;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
</style>

View File

@ -1,31 +1,38 @@
<template>
<div class="chat-sidebar">
<div class="chat-sidebar__header">
<a-button type="primary" block @click="handleCreate">
<template #icon><PlusOutlined /></template>
新建对话
</a-button>
</div>
<div class="chat-sidebar__list">
<a-empty v-if="conversations.length === 0" description="暂无对话" />
<div
v-for="conv in conversations"
:key="conv.id"
class="chat-sidebar__item"
:class="{ 'chat-sidebar__item--active': conv.id === currentId }"
@click="handleSelect(conv.id)"
>
<MessageOutlined class="chat-sidebar__item-icon" />
<span class="chat-sidebar__item-title">{{ conv.title }}</span>
<span class="chat-sidebar__item-time">{{ formatRelativeTime(conv.updated_at) }}</span>
<div :class="['chat-sidebar', { 'chat-sidebar--collapsed': collapsed }]">
<div v-if="!collapsed" class="chat-sidebar__content">
<div class="chat-sidebar__header">
<a-button type="primary" block size="small" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新建对话
</a-button>
</div>
<div class="chat-sidebar__list">
<a-empty v-if="conversations.length === 0" description="暂无对话" :image-style="{ height: '40px' }" />
<div
v-for="conv in conversations"
:key="conv.id"
class="chat-sidebar__item"
:class="{ 'chat-sidebar__item--active': conv.id === currentId }"
@click="handleSelect(conv.id)"
>
<MessageOutlined class="chat-sidebar__item-icon" />
<span class="chat-sidebar__item-title">{{ conv.title }}</span>
<span class="chat-sidebar__item-time">{{ formatRelativeTime(conv.updated_at) }}</span>
</div>
</div>
</div>
<button class="chat-sidebar__toggle" @click="collapsed = !collapsed" :title="collapsed ? '展开' : '折叠'">
<RightOutlined v-if="collapsed" />
<LeftOutlined v-else />
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Button as AButton, Empty as AEmpty } from 'ant-design-vue'
import { PlusOutlined, MessageOutlined } from '@ant-design/icons-vue'
import { PlusOutlined, MessageOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'
import type { IConversation } from '@/api/types'
interface IProps {
@ -40,6 +47,8 @@ const emit = defineEmits<{
select: [id: string]
}>()
const collapsed = ref(true)
function handleCreate(): void {
emit('create')
}
@ -69,69 +78,102 @@ function formatRelativeTime(dateStr: string): string {
<style scoped>
.chat-sidebar {
width: 280px;
display: flex;
height: 100%;
background: #ffffff;
border-right: 1px solid #f0f0f0;
background: var(--bg-primary);
border-right: 1px solid var(--border-color);
transition: width var(--transition-normal);
}
.chat-sidebar--collapsed {
width: 32px;
}
.chat-sidebar:not(.chat-sidebar--collapsed) {
width: 240px;
}
.chat-sidebar__content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-sidebar__header {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
padding: var(--space-3);
border-bottom: 1px solid var(--border-color);
}
.chat-sidebar__list {
flex: 1;
overflow-y: auto;
padding: 8px;
padding: var(--space-2);
}
.chat-sidebar__item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.2s;
transition: background var(--transition-fast);
}
.chat-sidebar__item:hover {
background: #f5f5f5;
background: var(--bg-tertiary);
}
.chat-sidebar__item--active {
background: #e6f4ff;
background: var(--color-primary-light);
}
.chat-sidebar__item--active:hover {
background: #e6f4ff;
background: var(--color-primary-light);
}
.chat-sidebar__item-icon {
color: #999999;
font-size: 14px;
color: var(--text-placeholder);
font-size: var(--font-sm);
flex-shrink: 0;
}
.chat-sidebar__item--active .chat-sidebar__item-icon {
color: #1677ff;
color: var(--color-primary);
}
.chat-sidebar__item-title {
flex: 1;
font-size: 14px;
color: #333333;
font-size: var(--font-sm);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-sidebar__item-time {
font-size: 12px;
color: #999999;
font-size: var(--font-xs);
color: var(--text-placeholder);
flex-shrink: 0;
}
.chat-sidebar__toggle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 100%;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
flex-shrink: 0;
transition: all var(--transition-fast);
}
.chat-sidebar__toggle:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<span class="context-pill">
<component :is="icon" class="context-pill__icon" />
<span class="context-pill__label">{{ label }}</span>
<button v-if="removable" class="context-pill__remove" @click.stop="$emit('remove')">
<CloseOutlined />
</button>
</span>
</template>
<script setup lang="ts">
import { type Component } from 'vue'
import { CloseOutlined } from '@ant-design/icons-vue'
defineProps<{
label: string
icon?: Component
removable?: boolean
}>()
defineEmits<{
remove: []
}>()
</script>
<style scoped>
.context-pill {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
font-size: var(--font-xs);
color: var(--text-secondary);
max-width: 160px;
}
.context-pill__icon {
font-size: 10px;
color: var(--text-tertiary);
flex-shrink: 0;
}
.context-pill__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-pill__remove {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: none;
background: transparent;
color: var(--text-placeholder);
cursor: pointer;
border-radius: var(--radius-full);
flex-shrink: 0;
padding: 0;
transition: all var(--transition-fast);
}
.context-pill__remove:hover {
color: var(--color-error);
background: var(--color-error-light);
}
</style>

View File

@ -0,0 +1,79 @@
<template>
<span :class="['tool-call-indicator', `tool-call-indicator--${type}`]">
<component :is="icon" class="tool-call-indicator__icon" />
<span class="tool-call-indicator__label">{{ name }}</span>
</span>
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue'
import {
ReadOutlined,
EditOutlined,
CodeOutlined,
FileAddOutlined,
SearchOutlined,
FolderOpenOutlined,
} from '@ant-design/icons-vue'
const props = defineProps<{
type: string
name: string
}>()
const iconMap: Record<string, Component> = {
read: ReadOutlined,
edit: EditOutlined,
bash: CodeOutlined,
write: FileAddOutlined,
search: SearchOutlined,
grep: SearchOutlined,
glob: FolderOpenOutlined,
}
const icon = computed(() => iconMap[props.type] || CodeOutlined)
</script>
<style scoped>
.tool-call-indicator {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px var(--space-2);
border-radius: var(--radius-full);
font-size: 11px;
font-weight: var(--font-weight-medium);
line-height: 1.6;
}
.tool-call-indicator__icon {
font-size: 10px;
}
.tool-call-indicator--read {
background: var(--color-info-light);
color: var(--color-info);
}
.tool-call-indicator--edit {
background: var(--color-warning-light);
color: var(--color-warning);
}
.tool-call-indicator--bash {
background: var(--color-primary-light);
color: var(--color-primary);
}
.tool-call-indicator--write {
background: var(--color-success-light);
color: var(--color-success);
}
.tool-call-indicator--search,
.tool-call-indicator--grep,
.tool-call-indicator--glob {
background: var(--color-primary-light);
color: var(--color-primary);
}
</style>

View File

@ -119,7 +119,7 @@ function handleSend(message: string): void {
display: flex;
flex-direction: column;
overflow: hidden;
background: #f5f5f5;
background: var(--bg-secondary);
}
.chat-view__empty {
@ -132,7 +132,7 @@ function handleSend(message: string): void {
.chat-view__messages {
flex: 1;
overflow-y: auto;
padding: 16px 0;
padding: var(--space-4) 0;
}
.chat-view__welcome {
@ -141,45 +141,48 @@ function handleSend(message: string): void {
align-items: center;
justify-content: center;
height: 100%;
color: #999999;
color: var(--text-placeholder);
}
.chat-view__welcome-icon {
font-size: 48px;
color: #1677ff;
margin-bottom: 16px;
background: var(--gradient-brand);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: var(--space-4);
}
.chat-view__welcome h2 {
color: #333333;
font-size: 24px;
margin-bottom: 8px;
color: var(--text-primary);
font-size: var(--font-xl);
margin-bottom: var(--space-2);
}
.chat-view__welcome p {
font-size: 14px;
color: #999999;
font-size: var(--font-base);
color: var(--text-tertiary);
}
.chat-view__steps {
padding: 8px 16px;
margin: 0 16px;
background: #ffffff;
border-radius: 8px;
border: 1px solid #f0f0f0;
padding: var(--space-2) var(--space-4);
margin: 0 var(--space-4);
background: var(--bg-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
}
.chat-view__step {
display: flex;
align-items: center;
gap: 6px;
gap: var(--space-1);
padding: 2px 0;
font-size: 13px;
color: #666666;
font-size: var(--font-sm);
color: var(--text-secondary);
}
.chat-view__step-icon {
font-size: 10px;
color: #1677ff;
color: var(--color-primary);
}
</style>