feat(web): add specialized UI for all built-in tool calls

Replace generic JSON display with dedicated components for search_memory,
search_inbox, web_fetch, send, react, get_contacts, email tools, browser
tools, subagent tools, and use_skill.
This commit is contained in:
Acbox
2026-03-11 17:59:58 +08:00
parent 1da251885d
commit 0ec211f3d0
12 changed files with 1112 additions and 0 deletions
+7
View File
@@ -145,6 +145,13 @@
"toolExecExit": "exit: {code}", "toolExecExit": "exit: {code}",
"toolExecError": "Error", "toolExecError": "Error",
"toolScheduleItems": "{count} items", "toolScheduleItems": "{count} items",
"toolMemoryResults": "{count} memories",
"toolInboxResults": "{count} messages",
"toolWebFetchPreview": "Preview",
"toolContactsCount": "{count} contacts",
"toolEmailCount": "{count} emails",
"toolEmailAccounts": "{count} accounts",
"toolSubagentCount": "{count} subagents",
"unknownUser": "{platform} User", "unknownUser": "{platform} User",
"files": "Files" "files": "Files"
}, },
+7
View File
@@ -141,6 +141,13 @@
"toolExecExit": "退出: {code}", "toolExecExit": "退出: {code}",
"toolExecError": "错误", "toolExecError": "错误",
"toolScheduleItems": "{count} 条", "toolScheduleItems": "{count} 条",
"toolMemoryResults": "{count} 条记忆",
"toolInboxResults": "{count} 条消息",
"toolWebFetchPreview": "预览",
"toolContactsCount": "{count} 个联系人",
"toolEmailCount": "{count} 封邮件",
"toolEmailAccounts": "{count} 个账户",
"toolSubagentCount": "{count} 个子代理",
"unknownUser": "{platform}用户", "unknownUser": "{platform}用户",
"files": "文件管理" "files": "文件管理"
}, },
@@ -27,6 +27,42 @@
v-else-if="scheduleTools.has(block.toolName)" v-else-if="scheduleTools.has(block.toolName)"
:block="block" :block="block"
/> />
<ToolCallMemory
v-else-if="block.toolName === 'search_memory'"
:block="block"
/>
<ToolCallInbox
v-else-if="block.toolName === 'search_inbox'"
: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 <ToolCallGeneric
v-else v-else
:block="block" :block="block"
@@ -42,6 +78,15 @@ import ToolCallList from './tool-call-list.vue'
import ToolCallExec from './tool-call-exec.vue' import ToolCallExec from './tool-call-exec.vue'
import ToolCallWebSearch from './tool-call-web-search.vue' import ToolCallWebSearch from './tool-call-web-search.vue'
import ToolCallSchedule from './tool-call-schedule.vue' import ToolCallSchedule from './tool-call-schedule.vue'
import ToolCallMemory from './tool-call-memory.vue'
import ToolCallInbox from './tool-call-inbox.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 ToolCallGeneric from './tool-call-generic.vue'
defineProps<{ defineProps<{
@@ -55,4 +100,21 @@ const scheduleTools = new Set([
'update_schedule', 'update_schedule',
'delete_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([
'query_subagent',
'list_subagents',
'delete_subagent',
])
</script> </script>
@@ -0,0 +1,109 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', 'window-maximize']"
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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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 { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/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>
@@ -0,0 +1,100 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', 'address-book']"
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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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 { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/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,195 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', 'envelope']"
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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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 { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/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>
@@ -0,0 +1,118 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', 'inbox']"
class="size-3 text-muted-foreground"
/>
<span
v-if="query"
class="text-xs truncate text-foreground"
>
{{ query }}
</span>
<span
v-else
class="font-mono font-medium text-xs text-muted-foreground"
>
search_inbox
</span>
<Badge
v-if="block.done && results.length"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolInboxResults', { 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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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"
>
<div class="flex items-center gap-2">
<span
v-if="item.header"
class="text-xs font-medium text-foreground truncate"
>
{{ item.header }}
</span>
<span class="text-[10px] text-muted-foreground shrink-0 ml-auto">
{{ item.created_at }}
</span>
</div>
<span class="text-xs text-muted-foreground line-clamp-2">
{{ item.content }}
</span>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/ui'
import type { ToolCallBlock } from '@/store/chat-list'
interface InboxResult {
id: string
source: string
header: string
content: string
is_read: boolean
created_at: 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<InboxResult[]>(() => {
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 InboxResult[] | undefined
return Array.isArray(items) ? items : []
})
</script>
@@ -0,0 +1,99 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', '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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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 { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/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>
@@ -0,0 +1,108 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<!-- send -->
<template v-if="block.toolName === 'send'">
<FontAwesomeIcon
:icon="['fas', 'paper-plane']"
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>
<FontAwesomeIcon
:icon="['fas', 'face-smile']"
class="size-3 text-muted-foreground"
/>
<span
v-if="emoji"
class="text-sm"
>
{{ 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 { Badge } from '@memoh/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>
@@ -0,0 +1,54 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', 'wand-magic-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 { Badge } from '@memoh/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>
@@ -0,0 +1,128 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', 'robot']"
class="size-3 text-muted-foreground"
/>
<!-- query_subagent -->
<template v-if="block.toolName === 'query_subagent'">
<span
v-if="name"
class="font-mono font-medium text-xs text-foreground"
>
{{ name }}
</span>
<span
v-if="query"
class="text-xs truncate text-muted-foreground"
:title="query"
>
{{ query }}
</span>
</template>
<!-- list_subagents / delete_subagent -->
<template v-else>
<span class="font-mono font-medium text-xs text-muted-foreground">
{{ block.toolName }}
</span>
<span
v-if="block.toolName === 'delete_subagent' && deleteId"
class="text-xs truncate text-foreground"
>
{{ deleteId }}
</span>
</template>
<Badge
v-if="block.done && block.toolName === 'list_subagents' && subagentCount !== null"
variant="secondary"
class="text-[10px] ml-auto shrink-0"
>
{{ $t('chat.toolSubagentCount', { count: subagentCount }) }}
</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>
<!-- query_subagent result -->
<Collapsible
v-if="block.done && block.toolName === 'query_subagent' && subagentResult"
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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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">{{ subagentResult }}</pre>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/ui'
import type { ToolCallBlock } from '@/store/chat-list'
const props = defineProps<{ block: ToolCallBlock }>()
const resultOpen = ref(false)
const name = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.name as string) ?? ''
})
const query = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.query as string) ?? ''
})
const deleteId = computed(() => {
const input = props.block.input as Record<string, unknown> | undefined
return (input?.id 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 subagentCount = computed(() => {
const r = resolveResult()
if (!r) return null
const items = r.items as unknown[] | undefined
return Array.isArray(items) ? items.length : null
})
const subagentResult = computed(() => {
const r = resolveResult()
return (r?.result as string) ?? ''
})
</script>
@@ -0,0 +1,125 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', '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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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 { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/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>