mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
feat(chat): refactor message rendering and introduce ToolCallItem component
This commit is contained in:
@@ -94,80 +94,9 @@
|
||||
:reasoning="msg.content.reasoning" :is-dark="isDark"
|
||||
:initial-expanded="isReasoningExpanded(index)" />
|
||||
|
||||
<!-- 遍历 message parts (保持顺序) -->
|
||||
<template v-for="(part, partIndex) in msg.content.message" :key="partIndex">
|
||||
<!-- iPython Tool Special Block -->
|
||||
<template v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0">
|
||||
<template v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id">
|
||||
<IPythonToolBlock v-if="isIPythonTool(toolCall)" :tool-call="toolCall" style="margin: 8px 0;"
|
||||
:is-dark="isDark"
|
||||
:initial-expanded="isIPythonToolExpanded(index, partIndex, tcIndex)" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Regular Tool Calls Block (for non-iPython tools) -->
|
||||
<div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.some(tc => !isIPythonTool(tc))"
|
||||
class="flex flex-col gap-2">
|
||||
<div class="font-medium opacity-70" style="font-size: 13px; margin-bottom: 16px;">{{ tm('actions.toolsUsed') }}</div>
|
||||
<ToolCallCard v-for="(toolCall, tcIndex) in part.tool_calls.filter(tc => !isIPythonTool(tc))"
|
||||
:key="toolCall.id" :tool-call="toolCall" :is-dark="isDark"
|
||||
:initial-expanded="isToolCallExpanded(index, partIndex, tcIndex)" />
|
||||
</div>
|
||||
|
||||
<!-- Text (Markdown) -->
|
||||
<MarkdownRender v-else-if="part.type === 'plain' && part.text && part.text.trim()"
|
||||
custom-id="message-list"
|
||||
:custom-html-tags="['ref']"
|
||||
:content="part.text" :typewriter="false" class="markdown-content"
|
||||
:is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
|
||||
|
||||
<!-- Image -->
|
||||
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
|
||||
<div class="embedded-image">
|
||||
<img :src="part.embedded_url" class="bot-embedded-image"
|
||||
@click="openImagePreview(part.embedded_url)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio -->
|
||||
<div v-else-if="part.type === 'record' && part.embedded_url" class="embedded-audio">
|
||||
<audio controls class="audio-player">
|
||||
<source :src="part.embedded_url" type="audio/wav">
|
||||
{{ t('messages.errors.browser.audioNotSupported') }}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<!-- Files -->
|
||||
<div v-else-if="part.type === 'file' && part.embedded_file" class="embedded-files">
|
||||
<div class="embedded-file">
|
||||
<a v-if="part.embedded_file.url" :href="part.embedded_file.url"
|
||||
:download="part.embedded_file.filename" class="file-link"
|
||||
:class="{ 'is-dark': isDark }" :style="isDark ? {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'var(--v-theme-secondary)'
|
||||
} : {}">
|
||||
<v-icon size="small" class="file-icon"
|
||||
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
|
||||
<span class="file-name">{{ part.embedded_file.filename }}</span>
|
||||
</a>
|
||||
<a v-else @click="downloadFile(part.embedded_file)"
|
||||
class="file-link file-link-download" :class="{ 'is-dark': isDark }"
|
||||
:style="isDark ? {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'var(--v-theme-secondary)'
|
||||
} : {}">
|
||||
<v-icon size="small" class="file-icon"
|
||||
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
|
||||
<span class="file-name">{{ part.embedded_file.filename }}</span>
|
||||
<v-icon v-if="downloadingFiles.has(part.embedded_file.attachment_id)"
|
||||
size="small" class="download-icon">mdi-loading mdi-spin</v-icon>
|
||||
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<MessagePartsRenderer :parts="msg.content.message" :is-dark="isDark"
|
||||
:current-time="currentTime" :downloading-files="downloadingFiles"
|
||||
@open-image-preview="openImagePreview" @download-file="downloadFile" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="message-actions" v-if="!msg.content.isLoading || index === messages.length - 1">
|
||||
@@ -250,14 +179,13 @@
|
||||
|
||||
<script>
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { MarkdownRender, enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
|
||||
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
|
||||
import 'markstream-vue/index.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'highlight.js/styles/github.css';
|
||||
import axios from 'axios';
|
||||
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
|
||||
import IPythonToolBlock from './message_list_comps/IPythonToolBlock.vue';
|
||||
import ToolCallCard from './message_list_comps/ToolCallCard.vue';
|
||||
import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue';
|
||||
import RefNode from './message_list_comps/RefNode.vue';
|
||||
import ActionRef from './message_list_comps/ActionRef.vue';
|
||||
|
||||
@@ -270,10 +198,8 @@ setCustomComponents('message-list', { ref: RefNode });
|
||||
export default {
|
||||
name: 'MessageList',
|
||||
components: {
|
||||
MarkdownRender,
|
||||
ReasoningBlock,
|
||||
IPythonToolBlock,
|
||||
ToolCallCard,
|
||||
MessagePartsRenderer,
|
||||
RefNode,
|
||||
ActionRef
|
||||
},
|
||||
@@ -319,8 +245,6 @@ export default {
|
||||
scrollTimer: null,
|
||||
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
|
||||
downloadingFiles: new Set(), // Track which files are being downloaded
|
||||
expandedToolCalls: new Set(), // Track which tool call cards are expanded
|
||||
expandedIPythonTools: new Set(), // Track which iPython tools are expanded
|
||||
elapsedTimeTimer: null, // Timer for updating elapsed time
|
||||
currentTime: Date.now() / 1000, // Current time for elapsed time calculation
|
||||
// 选中文本相关状态
|
||||
@@ -541,23 +465,6 @@ export default {
|
||||
return this.expandedReasoning.has(messageIndex);
|
||||
},
|
||||
|
||||
// Toggle iPython tool expansion state
|
||||
toggleIPythonTool(messageIndex, partIndex, toolCallIndex) {
|
||||
const key = `${messageIndex}-${partIndex}-${toolCallIndex}`;
|
||||
if (this.expandedIPythonTools.has(key)) {
|
||||
this.expandedIPythonTools.delete(key);
|
||||
} else {
|
||||
this.expandedIPythonTools.add(key);
|
||||
}
|
||||
// Force reactivity
|
||||
this.expandedIPythonTools = new Set(this.expandedIPythonTools);
|
||||
},
|
||||
|
||||
// Check if iPython tool is expanded
|
||||
isIPythonToolExpanded(messageIndex, partIndex, toolCallIndex) {
|
||||
return this.expandedIPythonTools.has(`${messageIndex}-${partIndex}-${toolCallIndex}`);
|
||||
},
|
||||
|
||||
// 下载文件
|
||||
async downloadFile(file) {
|
||||
if (!file.attachment_id) return;
|
||||
@@ -821,22 +728,6 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// Tool call related methods
|
||||
toggleToolCall(messageIndex, partIndex, toolCallIndex) {
|
||||
const key = `${messageIndex}-${partIndex}-${toolCallIndex}`;
|
||||
if (this.expandedToolCalls.has(key)) {
|
||||
this.expandedToolCalls.delete(key);
|
||||
} else {
|
||||
this.expandedToolCalls.add(key);
|
||||
}
|
||||
// Force reactivity
|
||||
this.expandedToolCalls = new Set(this.expandedToolCalls);
|
||||
},
|
||||
|
||||
isToolCallExpanded(messageIndex, partIndex, toolCallIndex) {
|
||||
return this.expandedToolCalls.has(`${messageIndex}-${partIndex}-${toolCallIndex}`);
|
||||
},
|
||||
|
||||
// Start timer for updating elapsed time
|
||||
startElapsedTimeTimer() {
|
||||
// Update every 12ms for sub-second precision, then every second after 1s
|
||||
@@ -898,18 +789,6 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// Format tool result for display
|
||||
formatToolResult(result) {
|
||||
if (!result) return '';
|
||||
// Try to parse as JSON for pretty formatting
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
},
|
||||
|
||||
// Get input tokens (input_other + input_cached)
|
||||
getInputTokens(tokenUsage) {
|
||||
if (!tokenUsage) return 0;
|
||||
@@ -943,11 +822,6 @@ export default {
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// Check if tool is iPython executor
|
||||
isIPythonTool(toolCall) {
|
||||
return toolCall.name === 'astrbot_execute_ipython' || toolCall.name === 'astrbot_execute_python';
|
||||
},
|
||||
|
||||
// Open refs sidebar
|
||||
openRefsSidebar(refs) {
|
||||
this.$emit('openRefs', refs);
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
<template>
|
||||
<div class="mb-3 mt-1.5">
|
||||
<div class="ipython-header" :class="{ 'expanded': isExpanded }" @click="toggleExpanded">
|
||||
<span class="ipython-label">
|
||||
{{ tm('actions.pythonCodeAnalysis') }}
|
||||
</span>
|
||||
<v-icon size="small" class="ipython-icon" :class="{ 'rotated': isExpanded }">
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</div>
|
||||
<div v-if="isExpanded" class="py-3 animate-fade-in">
|
||||
<div class="ipython-tool-block" :class="{ compact: !showHeader }">
|
||||
<div v-if="displayExpanded" class="py-3 animate-fade-in">
|
||||
<!-- Code Section -->
|
||||
<div class="code-section">
|
||||
<div v-if="shikiReady && code" class="code-highlighted"
|
||||
@@ -46,6 +38,14 @@ const props = defineProps({
|
||||
initialExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
forceExpanded: {
|
||||
type: Boolean,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
@@ -92,9 +92,12 @@ const highlightedCode = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
const displayExpanded = computed(() => {
|
||||
if (props.forceExpanded === null) {
|
||||
return isExpanded.value;
|
||||
}
|
||||
return props.forceExpanded;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -110,40 +113,13 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mb-3 {
|
||||
.ipython-tool-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mt-1\.5 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.ipython-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 20px;
|
||||
opacity: 0.7;
|
||||
transition: opacity;
|
||||
}
|
||||
|
||||
.ipython-header:hover,
|
||||
.ipython-header.expanded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ipython-label {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ipython-icon {
|
||||
margin-left: 6px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.ipython-icon.rotated {
|
||||
transform: rotate(90deg);
|
||||
.ipython-tool-block.compact {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.py-3 {
|
||||
@@ -160,6 +136,7 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-fallback {
|
||||
@@ -208,6 +185,10 @@ onMounted(async () => {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
:deep(.code-highlighted pre) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<template v-for="(renderPart, renderIndex) in getRenderParts(parts)" :key="renderPart.key">
|
||||
<!-- Grouped Tool Calls (consecutive tool_call parts) -->
|
||||
<div v-if="renderPart.type === 'tool_group'" class="tool-call-compact">
|
||||
<transition-group name="tool-call-item" tag="div" class="tool-call-items">
|
||||
<ToolCallItem v-for="(toolCall, tcIndex) in renderPart.toolCalls" :key="toolCall.id" :is-dark="isDark">
|
||||
<template #label="{ expanded }">
|
||||
<v-icon size="x-small" v-if="toolCall.name.includes('web_search') || toolCall.name.includes('tavily')">
|
||||
mdi-web
|
||||
</v-icon>
|
||||
<v-icon size="x-small" v-else-if="toolCall.name === 'astrbot_execute_shell'">
|
||||
mdi-console-line
|
||||
</v-icon>
|
||||
<v-icon size="x-small" v-else>
|
||||
mdi-wrench
|
||||
</v-icon>
|
||||
已使用 {{ toolCall.name }} 工具
|
||||
<span style="opacity: 0.6;">{{ toolCall.finished_ts ? formatDuration(toolCall.finished_ts -
|
||||
toolCall.ts) : getElapsedTime(toolCall.ts) }}</span>
|
||||
<v-icon size="x-small" class="tool-call-chevron" :class="{ rotated: expanded }">
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</template>
|
||||
<template #details>
|
||||
<div class="tool-call-detail-row">
|
||||
<span class="detail-label">ID:</span>
|
||||
<code class="detail-value">{{ toolCall.id }}</code>
|
||||
</div>
|
||||
<div class="tool-call-detail-row">
|
||||
<span class="detail-label">Args:</span>
|
||||
<pre class="detail-value detail-json">{{ formatToolArgs(toolCall.args) }}</pre>
|
||||
</div>
|
||||
<div v-if="toolCall.result" class="tool-call-detail-row">
|
||||
<span class="detail-label">Result:</span>
|
||||
<pre
|
||||
class="detail-value detail-json detail-result">{{ formatToolResult(toolCall.result) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</ToolCallItem>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<!-- iPython Tool Block -->
|
||||
<ToolCallItem v-else-if="renderPart.type === 'ipython'" :is-dark="isDark" style="margin: 8px 0 4px;">
|
||||
<template #label="{ expanded }">
|
||||
<v-icon size="x-small">
|
||||
mdi-code-json
|
||||
</v-icon>
|
||||
<span class="ipython-label">{{ tm('actions.pythonCodeAnalysis') }}</span>
|
||||
<span style="opacity: 0.6;">{{ renderPart.toolCall.finished_ts ?
|
||||
formatDuration(renderPart.toolCall.finished_ts -
|
||||
renderPart.toolCall.ts) : getElapsedTime(renderPart.toolCall.ts) }}</span>
|
||||
<v-icon size="small" class="ipython-icon" :class="{ rotated: expanded }">
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</template>
|
||||
<template #details>
|
||||
<IPythonToolBlock :tool-call="renderPart.toolCall" :is-dark="isDark" :show-header="false"
|
||||
:force-expanded="true" />
|
||||
</template>
|
||||
</ToolCallItem>
|
||||
|
||||
<!-- Text (Markdown) -->
|
||||
<MarkdownRender
|
||||
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
|
||||
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
|
||||
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
|
||||
|
||||
<!-- Image -->
|
||||
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
|
||||
<div class="embedded-image">
|
||||
<img :src="renderPart.part.embedded_url" class="bot-embedded-image"
|
||||
@click="emitOpenImage(renderPart.part.embedded_url)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio -->
|
||||
<div v-else-if="renderPart.part.type === 'record' && renderPart.part.embedded_url" class="embedded-audio">
|
||||
<audio controls class="audio-player">
|
||||
<source :src="renderPart.part.embedded_url" type="audio/wav">
|
||||
{{ t('messages.errors.browser.audioNotSupported') }}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<!-- Files -->
|
||||
<div v-else-if="renderPart.part.type === 'file' && renderPart.part.embedded_file" class="embedded-files">
|
||||
<div class="embedded-file">
|
||||
<a v-if="renderPart.part.embedded_file.url" :href="renderPart.part.embedded_file.url"
|
||||
:download="renderPart.part.embedded_file.filename" class="file-link" :class="{ 'is-dark': isDark }"
|
||||
:style="isDark ? {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'var(--v-theme-secondary)'
|
||||
} : {}">
|
||||
<v-icon size="small" class="file-icon"
|
||||
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
|
||||
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
|
||||
</a>
|
||||
<a v-else @click="emitDownloadFile(renderPart.part.embedded_file)" class="file-link file-link-download"
|
||||
:class="{ 'is-dark': isDark }" :style="isDark ? {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'var(--v-theme-secondary)'
|
||||
} : {}">
|
||||
<v-icon size="small" class="file-icon"
|
||||
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
|
||||
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
|
||||
<v-icon v-if="downloadingFiles?.has(renderPart.part.embedded_file.attachment_id)" size="small"
|
||||
class="download-icon">mdi-loading mdi-spin</v-icon>
|
||||
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { MarkdownRender } from 'markstream-vue';
|
||||
import IPythonToolBlock from './IPythonToolBlock.vue';
|
||||
import ToolCallItem from './ToolCallItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
parts: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
downloadingFiles: {
|
||||
type: Object,
|
||||
default: () => new Set()
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['open-image-preview', 'download-file']);
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const emitOpenImage = (url) => {
|
||||
emit('open-image-preview', url);
|
||||
};
|
||||
|
||||
const emitDownloadFile = (file) => {
|
||||
emit('download-file', file);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (seconds < 1) {
|
||||
return `${Math.round(seconds * 1000)}ms`;
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${minutes}m ${secs}s`;
|
||||
};
|
||||
|
||||
const getElapsedTime = (startTs) => {
|
||||
const elapsed = props.currentTime - startTs;
|
||||
return formatDuration(elapsed);
|
||||
};
|
||||
|
||||
const formatToolResult = (result) => {
|
||||
if (!result) return '';
|
||||
if (typeof result === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(result, null, 2);
|
||||
};
|
||||
|
||||
const formatToolArgs = (args) => {
|
||||
if (!args) return '';
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return args;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(args, null, 2);
|
||||
};
|
||||
|
||||
const isIPythonTool = (toolCall) => {
|
||||
return toolCall.name === 'astrbot_execute_ipython' || toolCall.name === 'astrbot_execute_python';
|
||||
};
|
||||
|
||||
const getRenderParts = (messageParts) => {
|
||||
if (!Array.isArray(messageParts)) return [];
|
||||
const rendered = [];
|
||||
let pendingToolCalls = [];
|
||||
let groupIndex = 0;
|
||||
|
||||
const flushPending = (endIndex) => {
|
||||
if (!pendingToolCalls.length) return;
|
||||
rendered.push({
|
||||
type: 'tool_group',
|
||||
toolCalls: pendingToolCalls,
|
||||
key: `tool-group-${groupIndex}-${endIndex}`
|
||||
});
|
||||
pendingToolCalls = [];
|
||||
groupIndex += 1;
|
||||
};
|
||||
|
||||
messageParts.forEach((part, idx) => {
|
||||
if (part?.type === 'tool_call' && Array.isArray(part.tool_calls) && part.tool_calls.length) {
|
||||
part.tool_calls.forEach((toolCall, tcIndex) => {
|
||||
if (isIPythonTool(toolCall)) {
|
||||
flushPending(idx - 1);
|
||||
rendered.push({
|
||||
type: 'ipython',
|
||||
toolCall,
|
||||
key: `ipython-${idx}-${tcIndex}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
pendingToolCalls.push(toolCall);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
flushPending(idx - 1);
|
||||
rendered.push({
|
||||
type: 'part',
|
||||
part,
|
||||
key: `part-${idx}`
|
||||
});
|
||||
});
|
||||
|
||||
flushPending(messageParts.length - 1);
|
||||
return rendered;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tool-call-compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.tool-call-group-title {
|
||||
font-size: 13px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tool-call-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-call-detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tool-call-detail-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--v-theme-secondaryText);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-primaryText);
|
||||
background-color: transparent;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
white-space: pre-wrap;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-result {
|
||||
max-height: 320px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tool-call-item-enter-active,
|
||||
.tool-call-item-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-call-item-enter-from,
|
||||
.tool-call-item-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.ipython-icon,
|
||||
.tool-call-chevron {
|
||||
margin-left: 6px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.ipython-icon.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tool-call-chevron.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="tool-call-item">
|
||||
<div class="tool-call-line" role="button" tabindex="0"
|
||||
@click="toggleExpanded"
|
||||
@keydown.enter="toggleExpanded"
|
||||
@keydown.space.prevent="toggleExpanded">
|
||||
<slot name="label" :expanded="isExpanded" />
|
||||
</div>
|
||||
<transition name="tool-call-fade">
|
||||
<div v-if="isExpanded" class="tool-call-inline-details" :class="{ 'is-dark': isDark }">
|
||||
<slot name="details" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tool-call-line {
|
||||
font-size: 14px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
opacity: 0.85;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.2s ease, opacity 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tool-call-line:hover {
|
||||
color: var(--v-theme-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tool-call-inline-details {
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px;
|
||||
border-left: 2px solid var(--v-theme-border);
|
||||
border-radius: 6px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.tool-call-inline-details.is-dark {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
border-left-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tool-call-fade-enter-active,
|
||||
.tool-call-fade-leave-active {
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.tool-call-fade-enter-from,
|
||||
.tool-call-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user