mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -145,6 +145,13 @@
|
||||
"toolExecExit": "exit: {code}",
|
||||
"toolExecError": "Error",
|
||||
"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",
|
||||
"files": "Files"
|
||||
},
|
||||
|
||||
@@ -141,6 +141,13 @@
|
||||
"toolExecExit": "退出: {code}",
|
||||
"toolExecError": "错误",
|
||||
"toolScheduleItems": "{count} 条",
|
||||
"toolMemoryResults": "{count} 条记忆",
|
||||
"toolInboxResults": "{count} 条消息",
|
||||
"toolWebFetchPreview": "预览",
|
||||
"toolContactsCount": "{count} 个联系人",
|
||||
"toolEmailCount": "{count} 封邮件",
|
||||
"toolEmailAccounts": "{count} 个账户",
|
||||
"toolSubagentCount": "{count} 个子代理",
|
||||
"unknownUser": "{platform}用户",
|
||||
"files": "文件管理"
|
||||
},
|
||||
|
||||
@@ -27,6 +27,42 @@
|
||||
v-else-if="scheduleTools.has(block.toolName)"
|
||||
: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
|
||||
v-else
|
||||
:block="block"
|
||||
@@ -42,6 +78,15 @@ 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 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'
|
||||
|
||||
defineProps<{
|
||||
@@ -55,4 +100,21 @@ const scheduleTools = new Set([
|
||||
'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([
|
||||
'query_subagent',
|
||||
'list_subagents',
|
||||
'delete_subagent',
|
||||
])
|
||||
</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>
|
||||
Reference in New Issue
Block a user