mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
+8
-17
@@ -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.)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user