refactor(web): unify tool-call rendering as inline rows with optional details

Replace 16 boxed per-tool components with a single inline row driven by a
central tool registry plus per-tool detail components. Tools without
specialized result rendering no longer expand (the fallback for unknown
tools still does). Write/edit are treated as Cursor-style inline diffs:
default-expanded with +N/-M line stats, the filename itself opens the file,
and the action verb is suppressed. Thinking blocks adopt the same inline
style — expanded while streaming, collapsed on reload.
This commit is contained in:
Acbox
2026-04-26 15:30:31 +08:00
parent cd8d58f799
commit 699ec5b950
40 changed files with 1800 additions and 1984 deletions
+8 -17
View File
@@ -120,23 +120,14 @@ src/
│ │ ├── thinking-block.vue # Collapsible thinking/reasoning block
│ │ ├── attachment-block.vue # Attachment grid (images, audio, files)
│ │ ├── media-gallery-lightbox.vue # Fullscreen media lightbox
│ │ ├── tool-call-block.vue # Generic tool call wrapper block
│ │ ├── tool-call-generic.vue # Generic tool call (name, status, JSON I/O)
│ │ ├── tool-call-list.vue # File listing tool display
│ │ ├── tool-call-read.vue # File read tool display
│ │ ├── tool-call-write.vue # File write tool display
│ │ ├── tool-call-edit.vue # File edit tool display
│ │ ├── tool-call-exec.vue # Command execution tool display
│ │ ├── tool-call-web-search.vue # Web search tool display
│ │ ├── tool-call-web-fetch.vue # Web fetch tool display
│ │ ├── tool-call-browser.vue # Browser action tool display
│ │ ├── tool-call-memory.vue # Memory read/write tool display
│ │ ├── tool-call-message.vue # Send message tool display
│ │ ├── tool-call-email.vue # Email tool display
│ │ ├── tool-call-schedule.vue # Schedule tool display
│ │ ├── tool-call-contacts.vue # Contacts tool display
│ │ ├── tool-call-subagent.vue # Sub-agent tool display
│ │ ├── tool-call-skill.vue # Skill activation tool display
│ │ ├── tool-call-block.vue # Tool call wrapper (renders inline component)
│ │ ├── tool-call-inline.vue # Inline tool call row: (icon) action target chevron
│ │ ├── tool-call-registry.ts # Tool name → display (icon, action, target, detail)
│ │ ├── tool-call-detail-exec.vue # Exec stdout/stderr/error detail
│ │ ├── tool-call-detail-edit.vue # Edit old/new diff detail
│ │ ├── tool-call-detail-spawn.vue # Spawn (subagent) task list + links
│ │ ├── tool-call-detail-image.vue # generate_image preview
│ │ ├── tool-call-detail-generic.vue # Generic input/result JSON detail
│ │ ├── schedule-trigger-block.vue # Schedule trigger display
│ │ └── heartbeat-trigger-block.vue # Heartbeat trigger display
│ ├── bots/ # Bot list + detail (tabs: overview, memory, channels, etc.)
+57 -18
View File
@@ -165,24 +165,63 @@
"historyParticipant": "Chats",
"historyObserved": "Observed Chats",
"readonly": "Read-only",
"toolDone": "Done",
"toolRunning": "Running",
"toolInput": "Input",
"toolResult": "Result",
"toolWriteContent": "Content",
"toolEditChanges": "Changes",
"toolSearchResults": "{count} results",
"toolSearchResultsLabel": "Results",
"toolExecOutput": "Output",
"toolExecExit": "exit: {code}",
"toolExecError": "Error",
"toolScheduleItems": "{count} items",
"toolMemoryResults": "{count} memories",
"toolWebFetchPreview": "Preview",
"toolContactsCount": "{count} contacts",
"toolEmailCount": "{count} emails",
"toolEmailAccounts": "{count} accounts",
"toolSpawnCount": "{count} tasks",
"tools": {
"read": "Read",
"write": "Write",
"edit": "Edit",
"list": "List",
"exec": "Run",
"bg_status": "Background",
"web_search": "Search the web",
"web_fetch": "Fetch",
"search_memory": "Search memory",
"send": "Send message",
"react": "React",
"react_remove": "Remove reaction",
"get_contacts": "List contacts",
"list_sessions": "List sessions",
"search_messages": "Search history",
"list_schedule": "List schedules",
"get_schedule": "Get schedule",
"create_schedule": "Create schedule",
"update_schedule": "Update schedule",
"delete_schedule": "Delete schedule",
"list_email_accounts": "List email accounts",
"send_email": "Send email",
"list_email": "List emails",
"read_email": "Read email",
"browser_action": "Browser",
"browser_observe": "Observe",
"browser_remote_session": "Remote browser",
"speak": "Speak",
"transcribe_audio": "Transcribe",
"generate_image": "Generate image",
"spawn": "Spawn {count} subagents",
"use_skill": "Use skill",
"generic": "Tool",
"detail": {
"openInFiles": "Open in files",
"input": "Input",
"result": "Result",
"noData": "No data",
"noOutput": "No output",
"noChanges": "No changes",
"noContent": "No content",
"noTasks": "No tasks",
"noImage": "No image",
"noResults": "No results",
"noPreview": "No preview",
"noContacts": "No contacts",
"noEmails": "No emails",
"noEmail": "No email",
"noAccounts": "No accounts",
"noSchedules": "No schedules",
"noSubject": "(no subject)",
"unnamedSchedule": "(unnamed)",
"editOld": "Old",
"editNew": "New"
}
},
"unknownUser": "{platform} User",
"files": "Files",
"clearMessages": "Clear Messages",
+57 -18
View File
@@ -161,24 +161,63 @@
"historyParticipant": "对话",
"historyObserved": "参与过的聊天",
"readonly": "只读",
"toolDone": "已完成",
"toolRunning": "运行中",
"toolInput": "入",
"toolResult": "结果",
"toolWriteContent": "内容",
"toolEditChanges": "变更",
"toolSearchResults": "{count} 条结果",
"toolSearchResultsLabel": "结果",
"toolExecOutput": "输出",
"toolExecExit": "退出: {code}",
"toolExecError": "错误",
"toolScheduleItems": "{count} 条",
"toolMemoryResults": "{count} 条记忆",
"toolWebFetchPreview": "预览",
"toolContactsCount": "{count} 个联系人",
"toolEmailCount": "{count} 封邮件",
"toolEmailAccounts": "{count} 个账户",
"toolSpawnCount": "{count} 个任务",
"tools": {
"read": "读取",
"write": "入",
"edit": "编辑",
"list": "列出",
"exec": "执行",
"bg_status": "后台任务",
"web_search": "搜索网络",
"web_fetch": "抓取",
"search_memory": "搜索记忆",
"send": "发送消息",
"react": "添加表情",
"react_remove": "移除表情",
"get_contacts": "查看联系人",
"list_sessions": "列出会话",
"search_messages": "搜索历史",
"list_schedule": "列出计划",
"get_schedule": "查看计划",
"create_schedule": "创建计划",
"update_schedule": "更新计划",
"delete_schedule": "删除计划",
"list_email_accounts": "列出邮箱账号",
"send_email": "发送邮件",
"list_email": "列出邮件",
"read_email": "阅读邮件",
"browser_action": "浏览器",
"browser_observe": "观察页面",
"browser_remote_session": "远程浏览器",
"speak": "朗读",
"transcribe_audio": "转录音频",
"generate_image": "生成图像",
"spawn": "派遣 {count} 个子代理",
"use_skill": "使用技能",
"generic": "调用工具",
"detail": {
"openInFiles": "在文件中打开",
"input": "输入",
"result": "结果",
"noData": "暂无数据",
"noOutput": "暂无输出",
"noChanges": "无变更",
"noContent": "无内容",
"noTasks": "暂无任务",
"noImage": "暂无图像",
"noResults": "暂无结果",
"noPreview": "暂无预览",
"noContacts": "暂无联系人",
"noEmails": "暂无邮件",
"noEmail": "暂无邮件",
"noAccounts": "暂无账号",
"noSchedules": "暂无计划",
"noSubject": "(无主题)",
"unnamedSchedule": "(未命名)",
"editOld": "原文",
"editNew": "新文"
}
},
"unknownUser": "{platform}用户",
"files": "文件管理",
"clearMessages": "清除消息",
@@ -1,41 +1,57 @@
<template>
<Collapsible v-model:open="isOpen">
<CollapsibleTrigger class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer group">
<div class="text-sm leading-relaxed">
<button
class="group flex items-center gap-1.5 w-full text-left transition-colors cursor-pointer py-0.5 text-muted-foreground hover:text-foreground"
@click="toggleOpen"
>
<Lightbulb class="size-3.5 shrink-0" />
<span
class="shrink-0"
:class="actionClass"
>{{ actionLabel }}</span>
<ChevronRight
class="size-3 transition-transform"
:class="{ 'rotate-90': isOpen }"
v-if="!open"
class="size-3.5 shrink-0 ml-auto opacity-60 group-hover:opacity-100"
/>
<span class="flex items-center gap-1.5">
<template v-if="streaming">
<LoaderCircle class="size-3 animate-spin" />
{{ $t('chat.thinkingInProgress') }}
</template>
<template v-else>
💭 {{ $t('chat.thinkingDone') }}
</template>
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div class="mt-2 ml-5 pl-3 border-l-2 border-muted text-xs text-muted-foreground">
<div
class="whitespace-pre-wrap leading-relaxed"
v-text="block.content"
/>
</div>
</CollapsibleContent>
</Collapsible>
<ChevronDown
v-else
class="size-3.5 shrink-0 ml-auto opacity-60 group-hover:opacity-100"
/>
</button>
<div
v-if="open"
class="mt-1 ml-5 border-l border-border pl-3 py-1"
>
<div
class="whitespace-pre-wrap text-xs text-muted-foreground leading-relaxed"
v-text="block.content"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ChevronRight, LoaderCircle } from 'lucide-vue-next'
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import { computed, ref } from 'vue'
import { ChevronDown, ChevronRight, Lightbulb } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import type { ThinkingBlock } from '@/store/chat-list'
defineProps<{
const props = defineProps<{
block: ThinkingBlock
streaming: boolean
}>()
const isOpen = ref(true)
const { t } = useI18n()
const open = ref(props.streaming)
const actionLabel = computed(() =>
props.streaming ? t('chat.thinkingInProgress') : t('chat.thinkingDone'),
)
const actionClass = computed(() => (props.streaming ? 'tool-shimmer-text' : ''))
function toggleOpen() {
open.value = !open.value
}
</script>
@@ -1,111 +1,12 @@
<template>
<ToolCallRead
v-if="block.toolName === 'read'"
:block="block"
/>
<ToolCallWrite
v-else-if="block.toolName === 'write'"
:block="block"
/>
<ToolCallEdit
v-else-if="block.toolName === 'edit'"
:block="block"
/>
<ToolCallList
v-else-if="block.toolName === 'list'"
:block="block"
/>
<ToolCallExec
v-else-if="block.toolName === 'exec'"
:block="block"
/>
<ToolCallWebSearch
v-else-if="block.toolName === 'web_search'"
:block="block"
/>
<ToolCallSchedule
v-else-if="scheduleTools.has(block.toolName)"
:block="block"
/>
<ToolCallMemory
v-else-if="block.toolName === 'search_memory'"
:block="block"
/>
<ToolCallWebFetch
v-else-if="block.toolName === 'web_fetch'"
:block="block"
/>
<ToolCallMessage
v-else-if="messageTools.has(block.toolName)"
:block="block"
/>
<ToolCallContacts
v-else-if="block.toolName === 'get_contacts'"
:block="block"
/>
<ToolCallEmail
v-else-if="emailTools.has(block.toolName)"
:block="block"
/>
<ToolCallBrowser
v-else-if="browserTools.has(block.toolName)"
:block="block"
/>
<ToolCallSubagent
v-else-if="subagentTools.has(block.toolName)"
:block="block"
/>
<ToolCallSkill
v-else-if="block.toolName === 'use_skill'"
:block="block"
/>
<ToolCallGeneric
v-else
:block="block"
/>
<ToolCallInline :block="block" />
</template>
<script setup lang="ts">
import type { ToolCallBlock } from '@/store/chat-list'
import ToolCallRead from './tool-call-read.vue'
import ToolCallWrite from './tool-call-write.vue'
import ToolCallEdit from './tool-call-edit.vue'
import ToolCallList from './tool-call-list.vue'
import ToolCallExec from './tool-call-exec.vue'
import ToolCallWebSearch from './tool-call-web-search.vue'
import ToolCallSchedule from './tool-call-schedule.vue'
import ToolCallMemory from './tool-call-memory.vue'
import ToolCallWebFetch from './tool-call-web-fetch.vue'
import ToolCallMessage from './tool-call-message.vue'
import ToolCallContacts from './tool-call-contacts.vue'
import ToolCallEmail from './tool-call-email.vue'
import ToolCallBrowser from './tool-call-browser.vue'
import ToolCallSubagent from './tool-call-subagent.vue'
import ToolCallSkill from './tool-call-skill.vue'
import ToolCallGeneric from './tool-call-generic.vue'
import ToolCallInline from './tool-call-inline.vue'
defineProps<{
block: ToolCallBlock
}>()
const scheduleTools = new Set([
'list_schedule',
'get_schedule',
'create_schedule',
'update_schedule',
'delete_schedule',
])
const messageTools = new Set(['send', 'react'])
const emailTools = new Set([
'send_email',
'list_email',
'read_email',
'list_email_accounts',
])
const browserTools = new Set(['browser_action', 'browser_observe'])
const subagentTools = new Set(['spawn'])
</script>
@@ -1,109 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<AppWindow class="size-3 text-muted-foreground" />
<span class="font-mono font-medium text-xs text-muted-foreground">
{{ actionLabel }}
</span>
<span
v-if="detail"
class="text-xs truncate text-foreground"
:title="detail"
>
{{ detail }}
</span>
<Badge
v-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<Collapsible
v-if="block.done && resultText"
v-model:open="resultOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': resultOpen }"
/>
{{ $t('chat.toolResult') }}
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-40 overflow-y-auto">{{ resultText }}</pre>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Check, LoaderCircle, AppWindow, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const resultOpen = ref(false)
const input = computed(() => props.block.input as Record<string, unknown> | undefined)
const actionLabel = computed(() => {
if (block.toolName === 'browser_action') {
return (input.value?.action as string) ?? 'browser_action'
}
return (input.value?.observe as string) ?? 'browser_observe'
})
const { block } = props
const detail = computed(() => {
const i = input.value
if (!i) return ''
return (i.url as string) ?? (i.selector as string) ?? ''
})
function resolveResult() {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const resultText = computed(() => {
const r = resolveResult()
if (!r) return ''
// Skip displaying base64 image data
if (r.content && Array.isArray(r.content)) {
const texts = (r.content as Array<Record<string, unknown>>)
.filter((c) => c.type === 'text')
.map((c) => c.text as string)
if (texts.length) return texts.join('\n')
}
const { content: _c, ...rest } = r
const display = Object.keys(rest).length ? rest : r
try {
return JSON.stringify(display, null, 2)
}
catch {
return String(r)
}
})
</script>
@@ -1,100 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<ContactRound class="size-3 text-muted-foreground" />
<span class="font-mono font-medium text-xs text-muted-foreground">
get_contacts
</span>
<Badge
v-if="block.done && contacts.length"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolContactsCount', { count: contacts.length }) }}
</Badge>
<Badge
v-else-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<Collapsible
v-if="block.done && contacts.length"
v-model:open="contactsOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': contactsOpen }"
/>
{{ $t('chat.toolSearchResultsLabel') }}
</CollapsibleTrigger>
<CollapsibleContent>
<div class="px-3 pb-2 space-y-1.5">
<div
v-for="(item, i) in contacts"
:key="i"
class="flex items-center gap-2 text-xs"
>
<span class="text-foreground truncate">
{{ item.display_name || item.username || item.target }}
</span>
<Badge
v-if="item.platform"
variant="outline"
class="text-[10px] shrink-0"
>
{{ item.platform }}
</Badge>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Check, LoaderCircle, ContactRound, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
interface Contact {
route_id: string
platform: string
conversation_type: string
target: string
display_name: string
username: string
}
const props = defineProps<{ block: ToolCallBlock }>()
const contactsOpen = ref(false)
const contacts = computed<Contact[]>(() => {
if (!props.block.done || !props.block.result) return []
const result = props.block.result as Record<string, unknown>
const sc = result.structuredContent as Record<string, unknown> | undefined
const items = (sc ?? result).contacts as Contact[] | undefined
return Array.isArray(items) ? items : []
})
</script>
@@ -0,0 +1,50 @@
<template>
<div class="space-y-1.5">
<pre
v-if="resultText"
class="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto rounded-sm bg-muted/30 px-2 py-1"
>{{ resultText }}</pre>
<p
v-else
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noOutput') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
function resolveResult(): Record<string, unknown> | null {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const resultText = computed(() => {
if (!props.block.done) return ''
const r = resolveResult()
if (!r) return ''
if (Array.isArray(r.content)) {
const texts = (r.content as Array<Record<string, unknown>>)
.filter((c) => c.type === 'text')
.map((c) => c.text as string)
.filter(Boolean)
if (texts.length) return texts.join('\n')
}
const { content: _content, ...rest } = r
void _content
const display = Object.keys(rest).length ? rest : r
try {
return JSON.stringify(display, null, 2)
} catch {
return String(r)
}
})
</script>
@@ -0,0 +1,56 @@
<template>
<div class="space-y-1.5">
<div
v-if="contacts.length"
class="space-y-1"
>
<div
v-for="(item, i) in contacts"
:key="item.route_id ?? i"
class="flex items-center gap-2 text-xs"
>
<span class="text-foreground truncate flex-1">
{{ item.display_name || item.username || item.target }}
</span>
<span
v-if="item.platform"
class="text-[10px] text-muted-foreground font-mono shrink-0 rounded bg-muted/30 px-1 py-0.5"
>
{{ item.platform }}
</span>
</div>
</div>
<p
v-else
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noContacts') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
interface Contact {
route_id?: string
platform?: string
conversation_type?: string
target?: string
display_name?: string
username?: string
}
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
const contacts = computed<Contact[]>(() => {
if (!props.block.done || !props.block.result) return []
const result = props.block.result as Record<string, unknown>
const sc = result.structuredContent as Record<string, unknown> | undefined
const items = (sc ?? result).contacts as Contact[] | undefined
return Array.isArray(items) ? items : []
})
</script>
@@ -0,0 +1,74 @@
<template>
<div class="space-y-1.5">
<div
v-if="hasChanges && shiki.loading.value"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<LoaderCircle class="size-3 animate-spin" />
</div>
<!-- eslint-disable vue/no-v-html -->
<div
v-else-if="hasChanges"
class="shiki-diff-container overflow-x-auto overflow-y-auto max-h-96 text-xs rounded-sm [&_pre]:bg-transparent! [&_pre]:p-2 [&_pre]:m-0 [&_code]:text-xs"
v-html="shiki.html.value"
/>
<!-- eslint-enable vue/no-v-html -->
<p
v-else
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noChanges') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { LoaderCircle } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
import { extractFilename, useShikiHighlighter } from '@/composables/useShikiHighlighter'
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
const shiki = useShikiHighlighter()
const filePath = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.path as string) ?? ''
})
const oldText = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.old_text as string) ?? ''
})
const newText = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.new_text as string) ?? ''
})
const hasChanges = computed(() => Boolean(oldText.value || newText.value))
onMounted(() => {
if (hasChanges.value) {
void shiki.highlightDiff(oldText.value, newText.value, extractFilename(filePath.value))
}
})
</script>
<style>
.shiki-diff-container .diff-block pre {
margin: 0 !important;
padding: 0.5rem 0.75rem !important;
background: transparent !important;
}
.shiki-diff-container .diff-remove {
background-color: oklch(0.55 0.12 25 / 0.12);
border-left: 3px solid oklch(0.55 0.12 25 / 0.5);
}
.shiki-diff-container .diff-add {
background-color: oklch(0.55 0.12 145 / 0.12);
border-left: 3px solid oklch(0.55 0.12 145 / 0.5);
}
</style>
@@ -0,0 +1,55 @@
<template>
<div class="space-y-1.5">
<div
v-if="accounts.length"
class="space-y-1"
>
<div
v-for="(item, i) in accounts"
:key="item.id ?? item.email ?? i"
class="flex items-center gap-2 text-xs"
>
<span class="text-foreground truncate flex-1">{{ item.email || item.id }}</span>
<span
v-if="item.provider"
class="text-[10px] text-muted-foreground font-mono shrink-0 rounded bg-muted/30 px-1 py-0.5"
>{{ item.provider }}</span>
</div>
</div>
<p
v-else
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noAccounts') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
interface AccountItem {
id?: string
email?: string
provider?: string
}
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
function resolveResult(): Record<string, unknown> | null {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const accounts = computed<AccountItem[]>(() => {
if (!props.block.done) return []
const r = resolveResult()
if (!r) return []
const items = r.accounts as AccountItem[] | undefined
return Array.isArray(items) ? items : []
})
</script>
@@ -0,0 +1,62 @@
<template>
<div class="space-y-1.5">
<div
v-if="emails.length"
class="space-y-1.5"
>
<div
v-for="(item, i) in emails"
:key="item.uid ?? i"
class="flex flex-col gap-0.5"
>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-foreground truncate flex-1">{{ item.subject || t('chat.tools.detail.noSubject') }}</span>
<span
v-if="item.received_at"
class="text-[10px] text-muted-foreground shrink-0"
>{{ item.received_at }}</span>
</div>
<span
v-if="item.from"
class="text-[10px] text-muted-foreground truncate"
>{{ item.from }}</span>
</div>
</div>
<p
v-else
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noEmails') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
interface EmailItem {
uid?: number
from?: string
subject?: string
received_at?: string
}
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
function resolveResult(): Record<string, unknown> | null {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const emails = computed<EmailItem[]>(() => {
if (!props.block.done) return []
const r = resolveResult()
if (!r) return []
const items = r.emails as EmailItem[] | undefined
return Array.isArray(items) ? items : []
})
</script>
@@ -0,0 +1,56 @@
<template>
<div class="space-y-1.5">
<div
v-if="subject"
class="text-xs font-medium text-foreground"
>
{{ subject }}
</div>
<div
v-if="from"
class="text-[10px] text-muted-foreground"
>
{{ from }}
</div>
<pre
v-if="body"
class="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto rounded-sm bg-muted/30 px-2 py-1"
>{{ body }}</pre>
<p
v-if="!subject && !from && !body"
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noEmail') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
function resolveResult(): Record<string, unknown> | null {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const subject = computed(() => {
const r = resolveResult()
return (r?.subject as string) ?? ''
})
const from = computed(() => {
const r = resolveResult()
return (r?.from as string) ?? ''
})
const body = computed(() => {
const r = resolveResult()
return (r?.body as string) ?? ''
})
</script>
@@ -0,0 +1,81 @@
<template>
<div class="space-y-1.5">
<pre
v-if="progressText"
class="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto rounded-sm bg-muted/30 px-2 py-1"
>{{ progressText }}</pre>
<pre
v-if="stdout"
class="text-xs text-foreground/80 overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto rounded-sm bg-muted/30 px-2 py-1"
>{{ stdout }}</pre>
<pre
v-if="stderr"
class="text-xs text-destructive/80 overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto rounded-sm bg-destructive/5 px-2 py-1"
>{{ stderr }}</pre>
<pre
v-if="errorText"
class="text-xs text-destructive overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto rounded-sm bg-destructive/5 px-2 py-1"
>{{ errorText }}</pre>
<p
v-if="!progressText && !stdout && !stderr && !errorText"
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noOutput') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
function resolveResult(): Record<string, unknown> | null {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
const sc = result.structuredContent as Record<string, unknown> | undefined
return sc ?? result
}
const stdout = computed(() => {
const r = resolveResult()
return (r?.stdout as string) ?? ''
})
const stderr = computed(() => {
const r = resolveResult()
return (r?.stderr as string) ?? ''
})
const errorText = computed(() => {
if (!props.block.result) return ''
const result = props.block.result as Record<string, unknown>
if (result.isError !== true) return ''
const content = result.content as Array<Record<string, unknown>> | undefined
if (!Array.isArray(content)) return ''
return content
.filter(c => c.type === 'text')
.map(c => c.text as string)
.join('\n')
})
const progressText = computed(() =>
(props.block.progress ?? [])
.map(item => formatProgress(item))
.filter(Boolean)
.join('\n'),
)
function formatProgress(val: unknown): string {
if (typeof val === 'string') return val
try {
return JSON.stringify(val, null, 2)
}
catch {
return String(val)
}
}
</script>
@@ -0,0 +1,71 @@
<template>
<div class="space-y-1.5">
<div v-if="hasInput">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-0.5">
{{ t('chat.tools.detail.input') }}
</div>
<pre class="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto rounded-sm bg-muted/30 px-2 py-1">{{ inputText }}</pre>
</div>
<div v-if="hasResult">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground/70 mb-0.5">
{{ t('chat.tools.detail.result') }}
</div>
<pre
class="text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto rounded-sm px-2 py-1"
:class="isError ? 'text-destructive bg-destructive/5' : 'text-muted-foreground bg-muted/30'"
>{{ resultText }}</pre>
</div>
<p
v-if="!hasInput && !hasResult"
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noData') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
function formatJson(val: unknown): string {
if (val == null) return ''
if (typeof val === 'string') return val
try {
return JSON.stringify(val, null, 2)
}
catch {
return String(val)
}
}
const inputText = computed(() => formatJson(props.block.input))
const isError = computed(() => {
if (!props.block.result) return false
const r = props.block.result as Record<string, unknown>
return r.isError === true
})
const resultText = computed(() => {
if (!props.block.result) return ''
const r = props.block.result as Record<string, unknown>
if (Array.isArray(r.content)) {
const texts = (r.content as Array<Record<string, unknown>>)
.filter(c => c.type === 'text')
.map(c => c.text as string)
.filter(Boolean)
if (texts.length) return texts.join('\n')
}
const sc = r.structuredContent
if (sc) return formatJson(sc)
return formatJson(r)
})
const hasInput = computed(() => Boolean(inputText.value))
const hasResult = computed(() => Boolean(resultText.value))
</script>
@@ -0,0 +1,61 @@
<template>
<div class="space-y-1.5">
<img
v-if="src"
:src="src"
:alt="prompt"
class="rounded-md border border-border max-w-xs max-h-64 object-contain"
>
<p
v-if="path"
class="text-xs text-muted-foreground font-mono truncate"
:title="path"
>
{{ path }}
</p>
<p
v-if="!src && !path"
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noImage') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
const prompt = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.prompt as string) ?? ''
})
function resolveResult(): Record<string, unknown> | null {
if (!props.block.result) return null
return props.block.result as Record<string, unknown>
}
const path = computed(() => {
const r = resolveResult()
if (!r) return ''
const sc = r.structuredContent as Record<string, unknown> | undefined
return ((sc ?? r).path as string) ?? ''
})
const src = computed(() => {
const r = resolveResult()
if (!r) return ''
const content = r.content as Array<Record<string, unknown>> | undefined
if (!Array.isArray(content)) return ''
const img = content.find(c => c.type === 'image')
if (!img) return ''
const data = img.data as string | undefined
const mime = (img.mimeType as string) || 'image/png'
return data ? `data:${mime};base64,${data}` : ''
})
</script>
@@ -0,0 +1,53 @@
<template>
<div class="space-y-1.5">
<div
v-if="results.length"
class="space-y-1.5"
>
<div
v-for="(item, i) in results"
:key="item.id ?? i"
class="flex items-start gap-2"
>
<span class="text-xs text-foreground whitespace-pre-wrap wrap-break-word flex-1">
{{ item.memory }}
</span>
<span
v-if="typeof item.score === 'number'"
class="text-[10px] text-muted-foreground font-mono shrink-0 rounded bg-muted/30 px-1 py-0.5"
>
{{ item.score.toFixed(2) }}
</span>
</div>
</div>
<p
v-else
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noResults') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
interface MemoryResult {
id?: string
memory: string
score?: number
}
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
const results = computed<MemoryResult[]>(() => {
if (!props.block.done || !props.block.result) return []
const result = props.block.result as Record<string, unknown>
const sc = result.structuredContent as Record<string, unknown> | undefined
const items = (sc ?? result).results as MemoryResult[] | undefined
return Array.isArray(items) ? items : []
})
</script>
@@ -0,0 +1,62 @@
<template>
<div class="space-y-1.5">
<div
v-if="items.length"
class="space-y-1"
>
<div
v-for="(item, i) in items"
:key="item.id ?? i"
class="flex flex-col gap-0.5 text-xs"
>
<div class="flex items-center gap-2">
<span class="text-foreground truncate flex-1">{{ item.name || item.id || t('chat.tools.detail.unnamedSchedule') }}</span>
<span
v-if="item.pattern"
class="text-[10px] text-muted-foreground font-mono shrink-0 rounded bg-muted/30 px-1 py-0.5"
>{{ item.pattern }}</span>
</div>
<span
v-if="item.prompt"
class="text-[10px] text-muted-foreground line-clamp-2"
>{{ item.prompt }}</span>
</div>
</div>
<p
v-else
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noSchedules') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
interface ScheduleItem {
id?: string
name?: string
pattern?: string
prompt?: string
}
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
function resolveResult(): Record<string, unknown> | null {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const items = computed<ScheduleItem[]>(() => {
if (!props.block.done) return []
const r = resolveResult()
if (!r) return []
const arr = r.items as ScheduleItem[] | undefined
return Array.isArray(arr) ? arr : []
})
</script>
@@ -0,0 +1,139 @@
<template>
<div class="space-y-1.5">
<div
v-if="tasks.length && !results.length"
class="space-y-1"
>
<div
v-for="(task, idx) in tasks"
:key="idx"
class="flex items-start gap-1.5 text-xs"
>
<span class="font-mono text-foreground shrink-0">#{{ idx + 1 }}</span>
<span
class="truncate text-muted-foreground"
:title="task"
>{{ task }}</span>
</div>
</div>
<div
v-if="results.length"
class="space-y-1"
>
<component
:is="result.session_id ? 'button' : 'div'"
v-for="(result, idx) in results"
:key="idx"
class="flex items-center gap-1.5 text-xs w-full text-left rounded-sm px-1 py-0.5 transition-colors"
:class="result.session_id ? 'cursor-pointer hover:bg-accent' : ''"
@click="result.session_id ? navigateToSession(result.session_id) : undefined"
>
<CircleCheck
v-if="result.success"
class="size-3 text-emerald-500 shrink-0"
/>
<CircleX
v-else
class="size-3 text-destructive shrink-0"
/>
<span class="font-mono text-foreground shrink-0">#{{ idx + 1 }}</span>
<span
v-if="result.task"
class="truncate text-muted-foreground"
:title="result.task"
>{{ result.task }}</span>
<ExternalLink
v-if="result.session_id"
class="size-3 text-muted-foreground/50 shrink-0 ml-auto"
/>
</component>
</div>
<div
v-if="hasDetailedResults"
class="space-y-1 pt-1 border-t border-border/50"
>
<div
v-for="(result, idx) in results"
:key="idx"
>
<pre
v-if="result.text"
class="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-32 overflow-y-auto rounded-sm bg-muted/30 px-2 py-1"
>{{ result.text }}</pre>
<p
v-if="result.error"
class="text-xs text-destructive"
>
{{ result.error }}
</p>
</div>
</div>
<p
v-if="!tasks.length && !results.length"
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noTasks') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { CircleCheck, CircleX, ExternalLink } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { useChatStore } from '@/store/chat-list'
import type { ToolCallBlock } from '@/store/chat-list'
interface SpawnTaskResult {
task?: string
session_id?: string
text?: string
success?: boolean
error?: string
}
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
const router = useRouter()
const chatStore = useChatStore()
const { currentBotId } = storeToRefs(chatStore)
const tasks = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
const t = input?.tasks
return Array.isArray(t) ? (t as string[]) : []
})
function resolveResult(): Record<string, unknown> | null {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const results = computed<SpawnTaskResult[]>(() => {
const r = resolveResult()
if (!r) return []
const items = r.results
return Array.isArray(items) ? (items as SpawnTaskResult[]) : []
})
const hasDetailedResults = computed(() =>
results.value.some(r => r.text || r.error),
)
function navigateToSession(sessionId: string) {
const botId = currentBotId.value
if (!botId || !sessionId) return
chatStore.selectSession(sessionId)
router.push({
name: 'chat',
params: { botId, sessionId },
})
}
</script>
@@ -0,0 +1,70 @@
<template>
<div class="space-y-1.5">
<div
v-if="format"
class="text-[10px] uppercase tracking-wide text-muted-foreground/70"
>
{{ format }}
</div>
<div
v-if="title"
class="text-xs font-medium text-foreground"
>
{{ title }}
</div>
<div
v-if="excerpt"
class="text-[11px] text-muted-foreground italic"
>
{{ excerpt }}
</div>
<pre
v-if="contentPreview"
class="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-48 overflow-y-auto rounded-sm bg-muted/30 px-2 py-1"
>{{ contentPreview }}</pre>
<p
v-if="!format && !title && !excerpt && !contentPreview"
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noPreview') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
function resolveResult(): Record<string, unknown> | null {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const format = computed(() => {
const r = resolveResult()
return (r?.format as string) ?? ''
})
const title = computed(() => {
const r = resolveResult()
return (r?.title as string) ?? ''
})
const excerpt = computed(() => {
const r = resolveResult()
return (r?.excerpt as string) ?? ''
})
const contentPreview = computed(() => {
const r = resolveResult()
if (!r) return ''
const content = (r.content as string) ?? (r.textContent as string) ?? ''
if (typeof content !== 'string') return ''
return content.length > 800 ? `${content.slice(0, 800)}` : content
})
</script>
@@ -0,0 +1,66 @@
<template>
<div class="space-y-1.5">
<div
v-if="results.length"
class="space-y-1.5"
>
<div
v-for="(item, i) in results"
:key="i"
class="flex flex-col gap-0.5"
>
<a
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline truncate"
:title="item.title"
>
{{ item.title }}
</a>
<span
v-if="item.url"
class="text-[10px] text-muted-foreground truncate"
:title="item.url"
>
{{ item.url }}
</span>
<span
v-if="item.description"
class="text-[10px] text-muted-foreground/80 line-clamp-2"
>
{{ item.description }}
</span>
</div>
</div>
<p
v-else
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noResults') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
interface SearchResult {
title: string
url: string
description?: string
}
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
const results = computed<SearchResult[]>(() => {
if (!props.block.done || !props.block.result) return []
const result = props.block.result as Record<string, unknown>
const sc = result.structuredContent as Record<string, unknown> | undefined
const items = (sc?.results ?? result.results) as SearchResult[] | undefined
return Array.isArray(items) ? items : []
})
</script>
@@ -0,0 +1,51 @@
<template>
<div class="space-y-1.5">
<div
v-if="content && shiki.loading.value"
class="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<LoaderCircle class="size-3 animate-spin" />
</div>
<!-- eslint-disable vue/no-v-html -->
<div
v-else-if="content"
class="shiki-container overflow-x-auto overflow-y-auto max-h-96 text-xs rounded-sm bg-muted/30 [&_pre]:bg-transparent! [&_pre]:p-2 [&_pre]:m-0 [&_code]:text-xs"
v-html="shiki.html.value"
/>
<!-- eslint-enable vue/no-v-html -->
<p
v-else
class="text-xs text-muted-foreground italic"
>
{{ t('chat.tools.detail.noContent') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { LoaderCircle } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
import { extractFilename, useShikiHighlighter } from '@/composables/useShikiHighlighter'
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
const shiki = useShikiHighlighter()
const filePath = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.path as string) ?? ''
})
const content = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.content as string) ?? ''
})
onMounted(() => {
if (content.value) {
void shiki.highlight(content.value, extractFilename(filePath.value))
}
})
</script>
@@ -1,124 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<SquarePen class="size-3 text-muted-foreground" />
<button
class="font-mono text-xs truncate hover:underline text-foreground cursor-pointer"
:title="filePath"
@click="handleOpenFile"
>
{{ filePath }}
</button>
<Badge
v-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<Collapsible
v-if="hasChanges"
v-model:open="diffOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': diffOpen }"
/>
{{ $t('chat.toolEditChanges') }}
</CollapsibleTrigger>
<CollapsibleContent>
<div
v-if="shiki.loading.value"
class="px-3 pb-2 text-xs text-muted-foreground"
>
<LoaderCircle class="size-3 animate-spin" />
</div>
<!-- eslint-disable vue/no-v-html -->
<div
v-else
class="shiki-diff-container overflow-x-auto text-xs [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:m-0 [&_code]:text-xs"
v-html="shiki.html.value"
/>
<!-- eslint-enable vue/no-v-html -->
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed, inject, watch } from 'vue'
import { Check, LoaderCircle, SquarePen, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
import { useShikiHighlighter, extractFilename } from '@/composables/useShikiHighlighter'
const props = defineProps<{ block: ToolCallBlock }>()
const openInFileManager = inject(openInFileManagerKey, undefined)
const shiki = useShikiHighlighter()
const diffOpen = ref(false)
const filePath = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.path as string) ?? ''
})
const oldText = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.old_text as string) ?? ''
})
const newText = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.new_text as string) ?? ''
})
const hasChanges = computed(() => oldText.value || newText.value)
watch(diffOpen, (open) => {
if (open && hasChanges.value && !shiki.html.value) {
void shiki.highlightDiff(oldText.value, newText.value, extractFilename(filePath.value))
}
})
function handleOpenFile() {
if (filePath.value && openInFileManager) {
openInFileManager(filePath.value, false)
}
}
</script>
<style>
.shiki-diff-container .diff-block pre {
margin: 0 !important;
padding: 0.5rem 0.75rem !important;
background: transparent !important;
}
.shiki-diff-container .diff-remove {
background-color: oklch(0.55 0.12 25 / 0.12);
border-left: 3px solid oklch(0.55 0.12 25 / 0.5);
}
.shiki-diff-container .diff-add {
background-color: oklch(0.55 0.12 145 / 0.12);
border-left: 3px solid oklch(0.55 0.12 145 / 0.5);
}
</style>
@@ -1,194 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<Mail class="size-3 text-muted-foreground" />
<!-- send_email -->
<template v-if="block.toolName === 'send_email'">
<span class="text-xs truncate text-foreground">
<span class="text-muted-foreground"></span> {{ to }}
</span>
<span
v-if="subject"
class="text-xs truncate text-muted-foreground"
:title="subject"
>
{{ subject }}
</span>
</template>
<!-- read_email -->
<template v-else-if="block.toolName === 'read_email'">
<span
v-if="readSubject"
class="text-xs truncate text-foreground"
>
{{ readSubject }}
</span>
<span
v-else
class="font-mono font-medium text-xs text-muted-foreground"
>
read_email
</span>
</template>
<!-- list_email / list_email_accounts -->
<template v-else>
<span class="font-mono font-medium text-xs text-muted-foreground">
{{ block.toolName }}
</span>
</template>
<!-- Badge -->
<Badge
v-if="block.done && block.toolName === 'list_email' && emailTotal !== null"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolEmailCount', { count: emailTotal }) }}
</Badge>
<Badge
v-else-if="block.done && block.toolName === 'list_email_accounts' && accountCount !== null"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolEmailAccounts', { count: accountCount }) }}
</Badge>
<Badge
v-else-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<!-- list_email collapsible -->
<Collapsible
v-if="block.done && block.toolName === 'list_email' && emails.length"
v-model:open="emailsOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': emailsOpen }"
/>
{{ $t('chat.toolSearchResultsLabel') }}
</CollapsibleTrigger>
<CollapsibleContent>
<div class="px-3 pb-2 space-y-1.5">
<div
v-for="(item, i) in emails"
:key="i"
class="flex flex-col gap-0.5"
>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-foreground truncate">{{ item.subject }}</span>
<span class="text-[10px] text-muted-foreground shrink-0 ml-auto">{{ item.received_at }}</span>
</div>
<span class="text-[10px] text-muted-foreground truncate">{{ item.from }}</span>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<!-- read_email collapsible -->
<Collapsible
v-if="block.done && block.toolName === 'read_email' && emailBody"
v-model:open="bodyOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': bodyOpen }"
/>
{{ $t('chat.toolWriteContent') }}
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">{{ emailBody }}</pre>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Check, LoaderCircle, Mail, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
interface EmailItem {
uid: number
from: string
subject: string
received_at: string
}
const props = defineProps<{ block: ToolCallBlock }>()
const emailsOpen = ref(false)
const bodyOpen = ref(false)
const to = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.to as string) ?? ''
})
const subject = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.subject as string) ?? ''
})
function resolveResult() {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const readSubject = computed(() => {
const r = resolveResult()
return (r?.subject as string) ?? ''
})
const emailTotal = computed(() => {
const r = resolveResult()
if (!r) return null
const total = r.total
return typeof total === 'number' ? total : null
})
const accountCount = computed(() => {
const r = resolveResult()
if (!r) return null
const accounts = r.accounts as unknown[] | undefined
return Array.isArray(accounts) ? accounts.length : null
})
const emails = computed<EmailItem[]>(() => {
const r = resolveResult()
if (!r) return []
const items = r.emails as EmailItem[] | undefined
return Array.isArray(items) ? items : []
})
const emailBody = computed(() => {
const r = resolveResult()
return (r?.body as string) ?? ''
})
</script>
@@ -1,163 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<Terminal class="size-3 text-muted-foreground" />
<span
class="font-mono text-xs truncate text-foreground"
:title="command"
>
$ {{ displayCommand }}
</span>
<Badge
v-if="block.done && exitCode !== null"
:variant="exitCode === 0 ? 'secondary' : 'destructive'"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolExecExit', { code: exitCode }) }}
</Badge>
<Badge
v-else-if="block.done && isError"
variant="destructive"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolExecError') }}
</Badge>
<Badge
v-else-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<Collapsible
v-if="hasOutput"
v-model:open="outputOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': outputOpen }"
/>
{{ $t('chat.toolExecOutput') }}
</CollapsibleTrigger>
<CollapsibleContent>
<pre
v-if="progressText"
class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all"
>{{ progressText }}</pre>
<pre
v-if="stdout"
class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all"
>{{ stdout }}</pre>
<pre
v-if="stderr"
class="px-3 pb-2 text-xs text-destructive/80 overflow-x-auto whitespace-pre-wrap break-all"
>{{ stderr }}</pre>
<pre
v-if="errorText"
class="px-3 pb-2 text-xs text-destructive overflow-x-auto whitespace-pre-wrap break-all"
>{{ errorText }}</pre>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Check, LoaderCircle, Terminal, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const outputOpen = ref(false)
const command = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.command as string) ?? ''
})
const displayCommand = computed(() => {
const lines = command.value.split('\n')
if (lines.length <= 1) return command.value
return lines[0] + ' ...'
})
function resolveResult() {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
const sc = result.structuredContent as Record<string, unknown> | undefined
return sc ?? result
}
const isError = computed(() => {
if (!props.block.result) return false
const result = props.block.result as Record<string, unknown>
return result.isError === true
})
const errorText = computed(() => {
if (!isError.value) return ''
const result = props.block.result as Record<string, unknown>
const content = result.content as Array<Record<string, unknown>> | undefined
if (!Array.isArray(content)) return ''
return content
.filter((c) => c.type === 'text')
.map((c) => c.text as string)
.join('\n')
})
const exitCode = computed(() => {
const r = resolveResult()
if (!r) return null
const code = r.exit_code
return typeof code === 'number' ? code : null
})
const stdout = computed(() => {
const r = resolveResult()
return (r?.stdout as string) ?? ''
})
const stderr = computed(() => {
const r = resolveResult()
return (r?.stderr as string) ?? ''
})
const progressText = computed(() =>
(props.block.progress ?? [])
.map((item) => formatProgress(item))
.filter(Boolean)
.join('\n'),
)
const hasOutput = computed(() =>
!!(progressText.value || (props.block.done && (stdout.value || stderr.value || errorText.value))),
)
function formatProgress(val: unknown): string {
if (typeof val === 'string') return val
try {
return JSON.stringify(val, null, 2)
} catch {
return String(val)
}
}
</script>
@@ -1,110 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<span class="font-mono font-medium text-xs">
{{ block.toolName }}
</span>
<Badge
v-if="block.done"
variant="secondary"
class="text-[10px] ml-auto"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<Collapsible
v-if="block.input"
v-model:open="inputOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': inputOpen }"
/>
{{ $t('chat.toolInput') }}
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all">{{ formatJson(block.input) }}</pre>
</CollapsibleContent>
</Collapsible>
<Collapsible
v-if="progressText"
v-model:open="progressOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full border-t border-muted">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': progressOpen }"
/>
{{ $t('chat.toolRunning') }}
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all">{{ progressText }}</pre>
</CollapsibleContent>
</Collapsible>
<Collapsible
v-if="block.done && block.result != null"
v-model:open="resultOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full border-t border-muted">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': resultOpen }"
/>
{{ $t('chat.toolResult') }}
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all">{{ formatJson(block.result) }}</pre>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Check, LoaderCircle, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
const inputOpen = ref(false)
const progressOpen = ref(true)
const resultOpen = ref(false)
const props = defineProps<{
block: ToolCallBlock
}>()
const progressText = computed(() =>
(props.block.progress ?? [])
.map(item => formatJson(item))
.filter(Boolean)
.join('\n'),
)
function formatJson(val: unknown): string {
if (typeof val === 'string') return val
try {
return JSON.stringify(val, null, 2)
} catch {
return String(val)
}
}
</script>
@@ -0,0 +1,188 @@
<template>
<div class="text-sm leading-relaxed">
<div
v-if="expandable"
role="button"
tabindex="0"
class="group flex items-center gap-1.5 w-full text-left transition-colors cursor-pointer py-0.5 select-none"
:class="rowClass"
@click="toggleOpen"
@keydown.enter.prevent="toggleOpen"
@keydown.space.prevent="toggleOpen"
>
<component
:is="display.icon"
class="size-3.5 shrink-0"
/>
<span
v-if="!display.hideAction"
class="shrink-0"
:class="actionClass"
>{{ actionLabel }}</span>
<button
v-if="display.target && canOpenInFiles"
class="font-mono truncate hover:underline cursor-pointer"
:class="targetClass"
:title="display.fullTarget || display.target"
@click.stop="handleOpenInFiles"
>
{{ display.target }}
</button>
<span
v-else-if="display.target"
class="font-mono truncate"
:class="targetClass"
:title="display.fullTarget || display.target"
>{{ display.target }}</span>
<span
v-if="display.diffAdd"
class="font-mono shrink-0 text-emerald-600 dark:text-emerald-500"
>+{{ display.diffAdd }}</span>
<span
v-if="display.diffRemove"
class="font-mono shrink-0 text-rose-600 dark:text-rose-500"
>-{{ display.diffRemove }}</span>
<span
v-if="display.errorSuffix"
class="font-mono shrink-0"
>{{ display.errorSuffix }}</span>
<ChevronRight
v-if="!open"
class="size-3.5 shrink-0 ml-auto opacity-60 group-hover:opacity-100"
/>
<ChevronDown
v-else
class="size-3.5 shrink-0 ml-auto opacity-60 group-hover:opacity-100"
/>
</div>
<div
v-else
class="flex items-center gap-1.5 w-full py-0.5"
:class="rowClass"
>
<component
:is="display.icon"
class="size-3.5 shrink-0"
/>
<span
v-if="!display.hideAction"
class="shrink-0"
:class="actionClass"
>{{ actionLabel }}</span>
<button
v-if="display.target && canOpenInFiles"
class="font-mono truncate hover:underline cursor-pointer"
:class="targetClass"
:title="display.fullTarget || display.target"
@click="handleOpenInFiles"
>
{{ display.target }}
</button>
<span
v-else-if="display.target"
class="font-mono truncate"
:class="targetClass"
:title="display.fullTarget || display.target"
>{{ display.target }}</span>
<span
v-if="display.diffAdd"
class="font-mono shrink-0 text-emerald-600 dark:text-emerald-500"
>+{{ display.diffAdd }}</span>
<span
v-if="display.diffRemove"
class="font-mono shrink-0 text-rose-600 dark:text-rose-500"
>-{{ display.diffRemove }}</span>
<span
v-if="display.errorSuffix"
class="font-mono shrink-0"
>{{ display.errorSuffix }}</span>
</div>
<div
v-if="expandable && open"
class="mt-1 ml-5 py-1 space-y-1.5"
>
<component
:is="display.detail"
v-if="display.detail"
:block="block"
/>
<ToolCallDetailGeneric
v-else
:block="block"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { ChevronDown, ChevronRight } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import type { ToolCallBlock } from '@/store/chat-list'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
import {
getToolDisplay,
isDirPathTool,
isFilePathTool,
} from './tool-call-registry'
import ToolCallDetailGeneric from './tool-call-detail-generic.vue'
const props = defineProps<{ block: ToolCallBlock }>()
const { t } = useI18n()
const openInFileManager = inject(openInFileManagerKey, undefined)
const display = computed(() => getToolDisplay(props.block))
const open = ref(getToolDisplay(props.block).defaultOpen === true)
const expandable = computed(
() => Boolean(display.value.detail) || display.value.expandable === true,
)
const actionLabel = computed(() => {
const key = `chat.tools.${display.value.actionKey}`
return t(key, display.value.actionParams ?? {})
})
const rowClass = computed(() => {
if (!expandable.value) {
return display.value.isError ? 'text-destructive' : 'text-muted-foreground'
}
return display.value.isError
? 'text-destructive hover:text-destructive/90'
: 'text-muted-foreground hover:text-foreground'
})
const targetClass = computed(() => {
if (!props.block.done) return 'tool-shimmer-text'
if (display.value.isError) return 'text-destructive'
return 'text-foreground/80'
})
const actionClass = computed(() => {
if (!props.block.done && !display.value.target) return 'tool-shimmer-text'
return ''
})
const filePath = computed(() => {
if (!isFilePathTool(props.block.toolName)) return ''
const input = props.block.input as Record<string, unknown> | undefined
return (input?.path as string) ?? ''
})
const canOpenInFiles = computed(
() => Boolean(filePath.value) && Boolean(openInFileManager),
)
function toggleOpen() {
open.value = !open.value
}
function handleOpenInFiles() {
if (!filePath.value || !openInFileManager) return
openInFileManager(filePath.value, isDirPathTool(props.block.toolName))
}
</script>
@@ -1,59 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<Folder class="size-3 text-muted-foreground" />
<button
class="font-mono text-xs truncate hover:underline text-foreground cursor-pointer"
:title="dirPath"
@click="handleOpenDir"
>
{{ dirPath }}
</button>
<Badge
v-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { Check, LoaderCircle, Folder } from 'lucide-vue-next'
import { Badge } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
const props = defineProps<{ block: ToolCallBlock }>()
const openInFileManager = inject(openInFileManagerKey, undefined)
const dirPath = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.path as string) ?? ''
})
function handleOpenDir() {
if (dirPath.value && openInFileManager) {
openInFileManager(dirPath.value, true)
}
}
</script>
@@ -1,99 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<Brain class="size-3 text-muted-foreground" />
<span class="text-xs truncate text-foreground">
{{ query }}
</span>
<Badge
v-if="block.done && results.length"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolMemoryResults', { count: results.length }) }}
</Badge>
<Badge
v-else-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<Collapsible
v-if="block.done && results.length"
v-model:open="resultsOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': resultsOpen }"
/>
{{ $t('chat.toolSearchResultsLabel') }}
</CollapsibleTrigger>
<CollapsibleContent>
<div class="px-3 pb-2 space-y-1.5">
<div
v-for="(item, i) in results"
:key="i"
class="flex items-start gap-2"
>
<span class="text-xs text-muted-foreground whitespace-pre-wrap break-all flex-1">{{ item.memory }}</span>
<Badge
variant="outline"
class="text-[10px] shrink-0"
>
{{ item.score.toFixed(2) }}
</Badge>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Check, LoaderCircle, Brain, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
interface MemoryResult {
id: string
memory: string
score: number
}
const props = defineProps<{ block: ToolCallBlock }>()
const resultsOpen = ref(false)
const query = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.query as string) ?? ''
})
const results = computed<MemoryResult[]>(() => {
if (!props.block.done || !props.block.result) return []
const result = props.block.result as Record<string, unknown>
const sc = result.structuredContent as Record<string, unknown> | undefined
const items = (sc ?? result).results as MemoryResult[] | undefined
return Array.isArray(items) ? items : []
})
</script>
@@ -1,106 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<!-- send -->
<template v-if="block.toolName === 'send'">
<Send class="size-3 text-muted-foreground" />
<span
v-if="platform || target"
class="text-xs truncate text-foreground"
:title="`${platform} → ${target}`"
>
<span
v-if="platform"
class="text-muted-foreground"
>{{ platform }}</span>
<span v-if="platform && target"> </span>
<span v-if="target">{{ target }}</span>
</span>
<span
v-if="text"
class="text-xs truncate text-muted-foreground"
:title="text"
>
{{ text }}
</span>
</template>
<!-- react -->
<template v-else>
<Smile class="size-3 text-muted-foreground" />
<span
v-if="emoji"
class="text-xs"
>
{{ emoji }}
</span>
<span
v-if="block.done && action"
class="text-xs text-muted-foreground"
>
{{ action }}
</span>
</template>
<Badge
v-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Check, LoaderCircle, Send, Smile } from 'lucide-vue-next'
import { Badge } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const platform = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.platform as string) ?? ''
})
const target = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.target as string) ?? ''
})
const text = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.text as string) ?? ''
})
const emoji = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.emoji as string) ?? ''
})
const action = computed(() => {
if (!props.block.done || !props.block.result) return ''
const result = props.block.result as Record<string, unknown>
const sc = result.structuredContent as Record<string, unknown> | undefined
return ((sc ?? result).action as string) ?? ''
})
</script>
@@ -1,59 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<FileText class="size-3 text-muted-foreground" />
<button
class="font-mono text-xs truncate hover:underline text-foreground cursor-pointer"
:title="filePath"
@click="handleOpenFile"
>
{{ filePath }}
</button>
<Badge
v-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { Check, LoaderCircle, FileText } from 'lucide-vue-next'
import { Badge } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
const props = defineProps<{ block: ToolCallBlock }>()
const openInFileManager = inject(openInFileManagerKey, undefined)
const filePath = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.path as string) ?? ''
})
function handleOpenFile() {
if (filePath.value && openInFileManager) {
openInFileManager(filePath.value, false)
}
}
</script>
@@ -0,0 +1,403 @@
import type { Component } from 'vue'
import {
AudioLines,
Brain,
Calendar,
CalendarCog,
CalendarMinus,
CalendarPlus,
Eye,
FilePen,
FilePlus2,
FileText,
FolderOpen,
Globe,
ImagePlus,
Inbox,
ListChecks,
Mail,
MailOpen,
MailPlus,
MessagesSquare,
MousePointerClick,
Plug,
Search,
SearchCheck,
Send,
Smile,
Sparkles,
SquareTerminal,
Users,
Volume2,
Workflow,
Wrench,
} from 'lucide-vue-next'
import type { ToolCallBlock } from '@/store/chat-list'
import ToolCallDetailBrowser from './tool-call-detail-browser.vue'
import ToolCallDetailContacts from './tool-call-detail-contacts.vue'
import ToolCallDetailEdit from './tool-call-detail-edit.vue'
import ToolCallDetailEmailAccounts from './tool-call-detail-email-accounts.vue'
import ToolCallDetailEmailList from './tool-call-detail-email-list.vue'
import ToolCallDetailEmailRead from './tool-call-detail-email-read.vue'
import ToolCallDetailExec from './tool-call-detail-exec.vue'
import ToolCallDetailImage from './tool-call-detail-image.vue'
import ToolCallDetailMemory from './tool-call-detail-memory.vue'
import ToolCallDetailSchedule from './tool-call-detail-schedule.vue'
import ToolCallDetailSpawn from './tool-call-detail-spawn.vue'
import ToolCallDetailWebFetch from './tool-call-detail-web-fetch.vue'
import ToolCallDetailWebSearch from './tool-call-detail-web-search.vue'
import ToolCallDetailWrite from './tool-call-detail-write.vue'
export interface ToolDisplay {
icon: Component
actionKey: string
actionParams?: Record<string, unknown>
target: string
fullTarget?: string
detail?: Component
isError?: boolean
errorSuffix?: string
expandable?: boolean
defaultOpen?: boolean
diffAdd?: number
diffRemove?: number
hideAction?: boolean
}
const FILE_PATH_TOOLS = new Set(['read', 'write', 'edit', 'list'])
export function isFilePathTool(toolName: string): boolean {
return FILE_PATH_TOOLS.has(toolName)
}
export function isDirPathTool(toolName: string): boolean {
return toolName === 'list'
}
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
}
function pickString(obj: Record<string, unknown>, ...keys: string[]): string {
for (const k of keys) {
const v = obj[k]
if (typeof v === 'string' && v.length > 0) return v
}
return ''
}
function truncate(s: string, max = 60): string {
if (!s) return ''
if (s.length <= max) return s
return `${s.slice(0, max)}`
}
function firstLine(s: string, max = 80): string {
if (!s) return ''
const idx = s.indexOf('\n')
const line = idx === -1 ? s : `${s.slice(0, idx)}`
return truncate(line, max)
}
function lineCount(s: string): number {
if (!s) return 0
return s.split('\n').length
}
function structured(block: ToolCallBlock): Record<string, unknown> {
const r = asObject(block.result)
const sc = asObject(r.structuredContent)
return Object.keys(sc).length > 0 ? sc : r
}
function execErrorState(block: ToolCallBlock): { isError: boolean; suffix: string } {
if (!block.done || !block.result) return { isError: false, suffix: '' }
const r = asObject(block.result)
const sc = structured(block)
const code = sc.exit_code
if (typeof code === 'number') {
if (code === 0) return { isError: false, suffix: '' }
return { isError: true, suffix: `(exit ${code})` }
}
if (r.isError === true) return { isError: true, suffix: '' }
return { isError: false, suffix: '' }
}
function hostnameOrUrl(url: string): string {
if (!url) return ''
try {
const parsed = new URL(url)
return parsed.hostname || url
}
catch {
return url
}
}
export function getToolDisplay(block: ToolCallBlock): ToolDisplay {
const input = asObject(block.input)
switch (block.toolName) {
case 'read': {
const path = pickString(input, 'path')
return { icon: FileText, actionKey: 'read', target: path }
}
case 'write': {
const path = pickString(input, 'path')
const content = pickString(input, 'content')
return {
icon: FilePlus2,
actionKey: 'write',
target: path,
detail: ToolCallDetailWrite,
defaultOpen: true,
diffAdd: lineCount(content),
hideAction: true,
}
}
case 'edit': {
const path = pickString(input, 'path')
const oldText = pickString(input, 'old_text')
const newText = pickString(input, 'new_text')
return {
icon: FilePen,
actionKey: 'edit',
target: path,
detail: ToolCallDetailEdit,
defaultOpen: true,
diffAdd: lineCount(newText),
diffRemove: lineCount(oldText),
hideAction: true,
}
}
case 'list': {
const path = pickString(input, 'path')
return { icon: FolderOpen, actionKey: 'list', target: path }
}
case 'exec': {
const cmd = pickString(input, 'command')
const { isError, suffix } = execErrorState(block)
return {
icon: SquareTerminal,
actionKey: 'exec',
target: firstLine(cmd, 80),
fullTarget: cmd,
detail: ToolCallDetailExec,
isError,
errorSuffix: suffix,
}
}
case 'bg_status': {
const action = pickString(input, 'action') || 'list'
return { icon: ListChecks, actionKey: 'bg_status', target: action }
}
case 'web_search': {
const query = pickString(input, 'query')
return {
icon: Search,
actionKey: 'web_search',
target: query ? `"${query}"` : '',
fullTarget: query,
detail: ToolCallDetailWebSearch,
}
}
case 'web_fetch': {
const url = pickString(input, 'url')
return {
icon: Globe,
actionKey: 'web_fetch',
target: hostnameOrUrl(url),
fullTarget: url,
detail: ToolCallDetailWebFetch,
}
}
case 'search_memory': {
const query = pickString(input, 'query')
return {
icon: Brain,
actionKey: 'search_memory',
target: query ? `"${query}"` : '',
fullTarget: query,
detail: ToolCallDetailMemory,
}
}
case 'send': {
const target = pickString(input, 'target')
const text = pickString(input, 'text', 'message')
const display = target || truncate(text, 60)
return {
icon: Send,
actionKey: 'send',
target: display,
fullTarget: text || target,
}
}
case 'react': {
const emoji = pickString(input, 'emoji')
const remove = input.remove === true
if (remove) {
return {
icon: Smile,
actionKey: 'react_remove',
target: pickString(input, 'message_id'),
}
}
return { icon: Smile, actionKey: 'react', target: emoji }
}
case 'get_contacts': {
return {
icon: Users,
actionKey: 'get_contacts',
target: pickString(input, 'platform'),
detail: ToolCallDetailContacts,
}
}
case 'list_sessions': {
const target = pickString(input, 'platform') || pickString(input, 'type')
return { icon: MessagesSquare, actionKey: 'list_sessions', target }
}
case 'search_messages': {
const keyword = pickString(input, 'keyword')
return {
icon: SearchCheck,
actionKey: 'search_messages',
target: keyword ? `"${keyword}"` : '',
fullTarget: keyword,
}
}
case 'list_schedule':
return { icon: Calendar, actionKey: 'list_schedule', target: '', detail: ToolCallDetailSchedule }
case 'get_schedule':
return { icon: Calendar, actionKey: 'get_schedule', target: pickString(input, 'id') }
case 'create_schedule':
return {
icon: CalendarPlus,
actionKey: 'create_schedule',
target: pickString(input, 'name'),
}
case 'update_schedule':
return {
icon: CalendarCog,
actionKey: 'update_schedule',
target: pickString(input, 'name', 'id'),
}
case 'delete_schedule':
return {
icon: CalendarMinus,
actionKey: 'delete_schedule',
target: pickString(input, 'id'),
}
case 'list_email_accounts':
return {
icon: Mail,
actionKey: 'list_email_accounts',
target: '',
detail: ToolCallDetailEmailAccounts,
}
case 'send_email': {
const subject = pickString(input, 'subject')
const to = pickString(input, 'to')
return {
icon: MailPlus,
actionKey: 'send_email',
target: subject || to,
fullTarget: subject ? `${to}${subject}` : to,
}
}
case 'list_email':
return {
icon: Inbox,
actionKey: 'list_email',
target: '',
detail: ToolCallDetailEmailList,
}
case 'read_email': {
const uid = input.uid
const target = uid != null ? `#${String(uid)}` : ''
return {
icon: MailOpen,
actionKey: 'read_email',
target,
detail: ToolCallDetailEmailRead,
}
}
case 'browser_action': {
const action = pickString(input, 'action')
const detail = pickString(input, 'url', 'selector', 'text', 'key')
const target = [action, detail].filter(Boolean).join(' ')
return {
icon: MousePointerClick,
actionKey: 'browser_action',
target,
detail: ToolCallDetailBrowser,
}
}
case 'browser_observe':
return {
icon: Eye,
actionKey: 'browser_observe',
target: pickString(input, 'observe'),
detail: ToolCallDetailBrowser,
}
case 'browser_remote_session':
return {
icon: Plug,
actionKey: 'browser_remote_session',
target: pickString(input, 'action'),
detail: ToolCallDetailBrowser,
}
case 'speak': {
const text = pickString(input, 'text')
return {
icon: Volume2,
actionKey: 'speak',
target: truncate(text, 60),
fullTarget: text,
}
}
case 'transcribe_audio': {
const target = pickString(
input,
'path',
'audio_path',
'file_path',
'url',
'audio_url',
)
return { icon: AudioLines, actionKey: 'transcribe_audio', target }
}
case 'generate_image': {
const prompt = pickString(input, 'prompt')
return {
icon: ImagePlus,
actionKey: 'generate_image',
target: truncate(prompt, 60),
fullTarget: prompt,
detail: ToolCallDetailImage,
}
}
case 'spawn': {
const tasks = Array.isArray(input.tasks) ? (input.tasks as unknown[]).length : 0
return {
icon: Workflow,
actionKey: 'spawn',
actionParams: { count: tasks },
target: '',
detail: ToolCallDetailSpawn,
}
}
case 'use_skill':
return {
icon: Sparkles,
actionKey: 'use_skill',
target: pickString(input, 'skillName'),
}
default:
return {
icon: Wrench,
actionKey: 'generic',
target: block.toolName,
expandable: true,
}
}
}
@@ -1,89 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<Clock class="size-3 text-muted-foreground" />
<span class="font-mono font-medium text-xs text-muted-foreground">
{{ block.toolName }}
</span>
<span
v-if="label"
class="text-xs truncate text-foreground"
:title="label"
>
{{ label }}
</span>
<Badge
v-if="block.done && isList && itemCount !== null"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolScheduleItems', { count: itemCount }) }}
</Badge>
<Badge
v-else-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Check, LoaderCircle, Clock } from 'lucide-vue-next'
import { Badge } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const isList = computed(() => props.block.toolName === 'list_schedule')
const label = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
if (!input) return ''
const name = input.name as string | undefined
const id = input.id as string | undefined
const pattern = input.pattern as string | undefined
switch (props.block.toolName) {
case 'create_schedule': {
const parts = [name, pattern].filter(Boolean)
return parts.join(' ')
}
case 'update_schedule':
return name ?? id ?? ''
case 'get_schedule':
case 'delete_schedule':
return id ?? ''
default:
return ''
}
})
const itemCount = computed(() => {
if (!props.block.done || !props.block.result) return null
const result = props.block.result as Record<string, unknown>
const sc = result.structuredContent as Record<string, unknown> | undefined
const items = (sc ?? result).items
if (Array.isArray(items)) return items.length
return null
})
</script>
@@ -1,55 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<Sparkles class="size-3 text-muted-foreground" />
<span
v-if="skillName"
class="text-xs truncate text-foreground"
>
{{ skillName }}
</span>
<span
v-else
class="font-mono font-medium text-xs text-muted-foreground"
>
use_skill
</span>
<Badge
v-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Check, LoaderCircle, Sparkles } from 'lucide-vue-next'
import { Badge } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const skillName = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.skillName as string) ?? ''
})
</script>
@@ -1,190 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<GitBranch class="size-3 text-violet-400" />
<span class="font-mono font-medium text-xs text-foreground">
spawn
</span>
<Badge
v-if="block.done && taskCount !== null"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolSpawnCount', { count: taskCount }) }}
</Badge>
<Badge
v-else-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<!-- Task list (only shown while running, before results are available) -->
<div
v-if="tasks.length && !results.length"
class="px-3 py-2 space-y-1"
>
<div
v-for="(task, idx) in tasks"
:key="idx"
class="text-xs text-muted-foreground truncate"
:title="task"
>
<span class="text-foreground font-mono mr-1.5">#{{ idx + 1 }}</span>
{{ task }}
</div>
</div>
<!-- Results (clickable to navigate to subagent session) -->
<div
v-if="block.done && results.length"
class="px-3 py-2 space-y-1"
>
<component
:is="result.session_id ? 'button' : 'div'"
v-for="(result, idx) in results"
:key="idx"
class="flex items-center gap-1.5 text-xs w-full text-left rounded-md px-1.5 py-1 -mx-1.5 transition-colors"
:class="result.session_id
? 'cursor-pointer hover:bg-accent'
: ''"
@click="result.session_id ? navigateToSession(result.session_id) : undefined"
>
<CircleCheck
v-if="result.success"
class="size-2.5 text-green-500 shrink-0"
/>
<CircleX
v-else
class="size-2.5 text-red-500 shrink-0"
/>
<span class="font-mono text-foreground shrink-0">#{{ idx + 1 }}</span>
<span
v-if="result.task"
class="truncate text-muted-foreground"
:title="result.task"
>
{{ result.task }}
</span>
<ExternalLink
v-if="result.session_id"
class="size-2.5 text-muted-foreground/50 shrink-0 ml-auto"
/>
</component>
</div>
<!-- Detailed results (collapsible) -->
<Collapsible
v-if="block.done && hasDetailedResults"
v-model:open="resultOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': resultOpen }"
/>
{{ $t('chat.toolResult') }}
</CollapsibleTrigger>
<CollapsibleContent>
<div class="px-3 pb-2 space-y-2">
<div
v-for="(result, idx) in results"
:key="idx"
class="text-xs"
>
<pre
v-if="result.text"
class="text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-32 overflow-y-auto pl-4"
>{{ result.text }}</pre>
<p
v-if="result.error"
class="text-red-500 pl-4"
>
{{ result.error }}
</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Check, LoaderCircle, GitBranch, ChevronRight, CircleCheck, CircleX, ExternalLink } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import { useRouter } from 'vue-router'
import { useChatStore } from '@/store/chat-list'
import { storeToRefs } from 'pinia'
import type { ToolCallBlock } from '@/store/chat-list'
interface SpawnTaskResult {
task?: string
session_id?: string
text?: string
success?: boolean
error?: string
}
const props = defineProps<{ block: ToolCallBlock }>()
const router = useRouter()
const chatStore = useChatStore()
const { currentBotId } = storeToRefs(chatStore)
const resultOpen = ref(false)
const tasks = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
const t = input?.tasks
return Array.isArray(t) ? (t as string[]) : []
})
const taskCount = computed(() => {
return tasks.value.length || null
})
function resolveResult(): Record<string, unknown> | null {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const results = computed<SpawnTaskResult[]>(() => {
const r = resolveResult()
if (!r) return []
const items = r.results
return Array.isArray(items) ? (items as SpawnTaskResult[]) : []
})
const hasDetailedResults = computed(() =>
results.value.some(r => r.text || r.error),
)
function navigateToSession(sessionId: string) {
const botId = currentBotId.value
if (!botId || !sessionId) return
chatStore.selectSession(sessionId)
router.push({
name: 'chat',
params: { botId, sessionId },
})
}
</script>
@@ -1,125 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<Globe class="size-3 text-muted-foreground" />
<a
v-if="url"
:href="url"
target="_blank"
rel="noopener noreferrer"
class="text-xs truncate text-primary hover:underline"
:title="url"
>
{{ url }}
</a>
<Badge
v-if="block.done && format"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ format }}
</Badge>
<Badge
v-else-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<Collapsible
v-if="block.done && preview"
v-model:open="previewOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': previewOpen }"
/>
{{ $t('chat.toolWebFetchPreview') }}
</CollapsibleTrigger>
<CollapsibleContent>
<div class="px-3 pb-2 space-y-1">
<div
v-if="title"
class="text-xs font-medium text-foreground"
>
{{ title }}
</div>
<div
v-if="excerpt"
class="text-[10px] text-muted-foreground italic"
>
{{ excerpt }}
</div>
<pre
v-if="contentPreview"
class="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all max-h-40 overflow-y-auto"
>{{ contentPreview }}</pre>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Check, LoaderCircle, Globe, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const previewOpen = ref(false)
const url = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.url as string) ?? ''
})
function resolveResult() {
if (!props.block.result) return null
const result = props.block.result as Record<string, unknown>
return (result.structuredContent as Record<string, unknown>) ?? result
}
const format = computed(() => {
const r = resolveResult()
return (r?.format as string) ?? ''
})
const title = computed(() => {
const r = resolveResult()
return (r?.title as string) ?? ''
})
const excerpt = computed(() => {
const r = resolveResult()
return (r?.excerpt as string) ?? ''
})
const contentPreview = computed(() => {
const r = resolveResult()
if (!r) return ''
const content = (r.content as string) ?? (r.textContent as string) ?? ''
return content.length > 500 ? `${content.slice(0, 500)}` : content
})
const preview = computed(() => title.value || excerpt.value || contentPreview.value)
</script>
@@ -1,107 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<Search class="size-3 text-muted-foreground" />
<span class="text-xs truncate text-foreground">
{{ query }}
</span>
<Badge
v-if="block.done && results.length"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolSearchResults', { count: results.length }) }}
</Badge>
<Badge
v-else-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<Collapsible
v-if="block.done && results.length"
v-model:open="resultsOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': resultsOpen }"
/>
{{ $t('chat.toolSearchResultsLabel') }}
</CollapsibleTrigger>
<CollapsibleContent>
<div class="px-3 pb-2 space-y-1.5">
<div
v-for="(item, i) in results"
:key="i"
class="flex flex-col gap-0.5"
>
<a
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline truncate"
:title="item.title"
>
{{ item.title }}
</a>
<span
class="text-[10px] text-muted-foreground truncate"
:title="item.url"
>
{{ item.url }}
</span>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Check, LoaderCircle, Search, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
interface SearchResult {
title: string
url: string
description?: string
}
const props = defineProps<{ block: ToolCallBlock }>()
const resultsOpen = ref(false)
const query = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.query as string) ?? ''
})
const results = computed<SearchResult[]>(() => {
if (!props.block.done || !props.block.result) return []
const result = props.block.result as Record<string, unknown>
const sc = result.structuredContent as Record<string, unknown> | undefined
const items = (sc?.results ?? result.results) as SearchResult[] | undefined
return Array.isArray(items) ? items : []
})
</script>
@@ -1,102 +0,0 @@
<template>
<div class="rounded-lg border bg-muted/30 text-xs overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<Check
v-if="block.done"
class="size-3 text-green-600 dark:text-green-400"
/>
<LoaderCircle
v-else
class="size-3 animate-spin text-muted-foreground"
/>
<SquarePen class="size-3 text-muted-foreground" />
<button
class="font-mono text-xs truncate hover:underline text-foreground cursor-pointer"
:title="filePath"
@click="handleOpenFile"
>
{{ filePath }}
</button>
<Badge
v-if="block.done"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolDone') }}
</Badge>
<Badge
v-else
variant="outline"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<Collapsible
v-if="content"
v-model:open="contentOpen"
>
<CollapsibleTrigger class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer w-full">
<ChevronRight
class="size-2.5 transition-transform"
:class="{ 'rotate-90': contentOpen }"
/>
{{ $t('chat.toolWriteContent') }}
</CollapsibleTrigger>
<CollapsibleContent>
<div
v-if="shiki.loading.value"
class="px-3 pb-2 text-xs text-muted-foreground"
>
<LoaderCircle class="size-3 animate-spin" />
</div>
<!-- eslint-disable vue/no-v-html -->
<div
v-else
class="shiki-container overflow-x-auto text-xs [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:m-0 [&_code]:text-xs"
v-html="shiki.html.value"
/>
<!-- eslint-enable vue/no-v-html -->
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed, inject, watch } from 'vue'
import { Check, LoaderCircle, SquarePen, ChevronRight } from 'lucide-vue-next'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memohai/ui'
import type { ToolCallBlock } from '@/store/chat-list'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
import { useShikiHighlighter, extractFilename } from '@/composables/useShikiHighlighter'
const props = defineProps<{ block: ToolCallBlock }>()
const openInFileManager = inject(openInFileManagerKey, undefined)
const shiki = useShikiHighlighter()
const contentOpen = ref(false)
const filePath = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.path as string) ?? ''
})
const content = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.content as string) ?? ''
})
watch(contentOpen, (open) => {
if (open && content.value && !shiki.html.value) {
void shiki.highlight(content.value, extractFilename(filePath.value))
}
})
function handleOpenFile() {
if (filePath.value && openInFileManager) {
openInFileManager(filePath.value, false)
}
}
</script>
+34
View File
@@ -15,3 +15,37 @@
line-height: 1.625;
}
}
@keyframes tool-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.tool-shimmer-text {
background-image: linear-gradient(
90deg,
var(--color-muted-foreground) 0%,
var(--color-foreground) 45%,
var(--color-foreground) 55%,
var(--color-muted-foreground) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: tool-shimmer 1.6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.tool-shimmer-text {
background-image: none;
-webkit-background-clip: border-box;
background-clip: border-box;
color: var(--color-muted-foreground);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
-11
View File
@@ -52,15 +52,4 @@ export default [
}],
},
},
{
files: [
'apps/web/src/pages/chat/components/tool-call-edit.vue',
'apps/web/src/pages/chat/components/tool-call-write.vue',
'apps/web/src/pages/home/components/tool-call-edit.vue',
'apps/web/src/pages/home/components/tool-call-write.vue',
],
rules: {
'vue/no-v-html': 'off',
},
},
]