feat(web): add tool message ui for built-in tools

This commit is contained in:
Acbox
2026-03-03 17:50:21 +08:00
parent 8f3e763fe4
commit f88827945f
18 changed files with 1009 additions and 78 deletions
+1
View File
@@ -17,6 +17,7 @@
"@memoh/sdk": "workspace:*",
"@memoh/ui": "workspace:*",
"@pinia/colada": "^0.21.1",
"@shikijs/transformers": "^4.0.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1",
@@ -253,6 +253,22 @@ watch(() => props.botId, () => {
onMounted(() => {
restoreFileFromUrl()
})
function navigateTo(dirPath: string) {
openFile.value = null
syncFileToUrl(null)
void loadDirectory(dirPath)
}
function openFileByPath(filePath: string) {
const name = filePath.split('/').pop() ?? ''
const dir = filePath.substring(0, filePath.lastIndexOf('/')) || '/'
openFile.value = { path: filePath, name, isDir: false }
syncFileToUrl(filePath)
void loadDirectory(dir)
}
defineExpose({ navigateTo, openFileByPath })
</script>
<template>
@@ -0,0 +1,94 @@
import { ref } from 'vue'
import { getLanguageByFilename } from '@/components/file-manager/utils'
import type { HighlighterGeneric, BundledLanguage, BundledTheme } from 'shiki'
type Highlighter = HighlighterGeneric<BundledLanguage, BundledTheme>
let highlighterPromise: Promise<Highlighter> | null = null
const loadedLangs = new Set<string>(['plaintext'])
async function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = import('shiki').then((m) =>
m.createHighlighter({ themes: ['github-dark', 'github-light'], langs: [] }),
)
}
return highlighterPromise
}
async function ensureLang(hl: Highlighter, lang: string) {
if (loadedLangs.has(lang)) return
try {
await hl.loadLanguage(lang as BundledLanguage)
loadedLangs.add(lang)
} catch {
loadedLangs.add(lang)
}
}
export function useShikiHighlighter() {
const html = ref('')
const loading = ref(false)
async function highlight(code: string, filename: string) {
loading.value = true
try {
const lang = getLanguageByFilename(filename)
const hl = await getHighlighter()
await ensureLang(hl, lang)
html.value = hl.codeToHtml(code, {
lang: loadedLangs.has(lang) ? lang : 'plaintext',
themes: { light: 'github-light', dark: 'github-dark' },
})
} catch {
html.value = `<pre>${escapeHtml(code)}</pre>`
} finally {
loading.value = false
}
}
async function highlightDiff(oldText: string, newText: string, filename: string) {
loading.value = true
try {
const lang = getLanguageByFilename(filename)
const hl = await getHighlighter()
await ensureLang(hl, lang)
const effectiveLang = loadedLangs.has(lang) ? lang : 'plaintext'
const themes = { light: 'github-light', dark: 'github-dark' }
const oldHtml = oldText
? hl.codeToHtml(oldText, { lang: effectiveLang, themes })
: ''
const newHtml = newText
? hl.codeToHtml(newText, { lang: effectiveLang, themes })
: ''
html.value =
(oldHtml ? `<div class="diff-block diff-remove">${oldHtml}</div>` : '') +
(newHtml ? `<div class="diff-block diff-add">${newHtml}</div>` : '')
} catch {
html.value = `<pre>${escapeHtml(`- ${oldText}\n+ ${newText}`)}</pre>`
} finally {
loading.value = false
}
}
return { html, loading, highlight, highlightDiff }
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export function resolveLanguage(filename: string): string {
return getLanguageByFilename(filename)
}
export function extractFilename(path: string): string {
return path.split('/').pop() ?? path
}
+8
View File
@@ -136,6 +136,14 @@
"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",
"unknownUser": "{platform} User",
"files": "Files"
},
+8
View File
@@ -132,6 +132,14 @@
"toolRunning": "运行中",
"toolInput": "输入",
"toolResult": "结果",
"toolWriteContent": "内容",
"toolEditChanges": "变更",
"toolSearchResults": "{count} 条结果",
"toolSearchResultsLabel": "结果",
"toolExecOutput": "输出",
"toolExecExit": "退出: {code}",
"toolExecError": "错误",
"toolScheduleItems": "{count} 条",
"unknownUser": "{platform}用户",
"files": "文件管理"
},
@@ -188,6 +188,7 @@
</SheetHeader>
<FileManager
v-if="currentBotId"
ref="fileManagerRef"
:bot-id="currentBotId"
:sync-url="false"
class="flex-1 min-h-0"
@@ -198,7 +199,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { ref, computed, watch, nextTick, onMounted, provide } from 'vue'
import { Textarea, Button, Avatar, AvatarImage, AvatarFallback, Badge, InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupTextarea, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@memoh/ui'
import { useChatStore } from '@/store/chat-list'
import { storeToRefs } from 'pinia'
@@ -206,12 +207,26 @@ import MessageItem from './message-item.vue'
import MediaGalleryLightbox from './media-gallery-lightbox.vue'
import FileManager from '@/components/file-manager/index.vue'
import { useMediaGallery } from '../composables/useMediaGallery'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
import type { ChatAttachment } from '@/composables/api/useChat'
const chatStore = useChatStore()
const fileInput = ref<HTMLInputElement | null>(null)
const pendingFiles = ref<File[]>([])
const fileManagerOpen = ref(false)
const fileManagerRef = ref<InstanceType<typeof FileManager> | null>(null)
provide(openInFileManagerKey, (path: string, isDir = false) => {
fileManagerOpen.value = true
nextTick(() => {
if (!fileManagerRef.value) return
if (isDir) {
fileManagerRef.value.navigateTo(path)
} else {
fileManagerRef.value.openFileByPath(path)
}
})
})
const {
messages,
streaming,
@@ -1,87 +1,58 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<!-- Header -->
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
<ToolCallRead
v-if="block.toolName === 'read'"
:block="block"
/>
<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
<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"
/>
<ToolCallGeneric
v-else
variant="outline"
class="text-[10px] ml-auto"
>
{{ $t('chat.toolRunning') }}
</Badge>
</div>
<!-- Input (collapsible) -->
<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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
class="size-2.5 transition-transform"
:class="{ 'rotate-90': inputOpen }"
:block="block"
/>
{{ $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>
<!-- Result (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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
class="size-2.5 transition-transform"
:class="{ 'rotate-90': resultOpen }"
/>
{{ $t('chat.toolResult') }}
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all">{{ formatJson(block.result) }}</pre>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/ui'
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 ToolCallGeneric from './tool-call-generic.vue'
defineProps<{
block: ToolCallBlock
}>()
const inputOpen = ref(false)
const resultOpen = ref(false)
function formatJson(val: unknown): string {
if (typeof val === 'string') return val
try {
return JSON.stringify(val, null, 2)
} catch {
return String(val)
}
}
const scheduleTools = new Set([
'list_schedule',
'get_schedule',
'create_schedule',
'update_schedule',
'delete_schedule',
])
</script>
@@ -0,0 +1,125 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', 'pen-to-square']"
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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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"
>
<FontAwesomeIcon
:icon="['fas', 'spinner']"
class="size-3 animate-spin"
/>
</div>
<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"
/>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed, inject, watch } from 'vue'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/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>
@@ -0,0 +1,143 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', '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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
class="size-2.5 transition-transform"
:class="{ 'rotate-90': outputOpen }"
/>
{{ $t('chat.toolExecOutput') }}
</CollapsibleTrigger>
<CollapsibleContent>
<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 { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/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 hasOutput = computed(() =>
props.block.done && (stdout.value || stderr.value || errorText.value),
)
</script>
@@ -0,0 +1,84 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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="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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
class="size-2.5 transition-transform"
:class="{ 'rotate-90': resultOpen }"
/>
{{ $t('chat.toolResult') }}
</CollapsibleTrigger>
<CollapsibleContent>
<pre class="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-all">{{ formatJson(block.result) }}</pre>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/ui'
import type { ToolCallBlock } from '@/store/chat-list'
defineProps<{
block: ToolCallBlock
}>()
const inputOpen = ref(false)
const resultOpen = ref(false)
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,58 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', '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 { Badge } from '@memoh/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>
@@ -0,0 +1,58 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', 'file-lines']"
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 { Badge } from '@memoh/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,88 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', '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 { Badge } from '@memoh/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>
@@ -0,0 +1,107 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', 'magnifying-glass']"
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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
class="size-2.5 transition-transform"
:class="{ 'rotate-90': resultsOpen }"
/>
{{ $t('chat.toolSearchResultsLabel') }}
</CollapsibleTrigger>
<CollapsibleContent>
<div class="px-3 pb-2 space-y-1.5">
<div
v-for="(item, i) in results"
:key="i"
class="flex flex-col gap-0.5"
>
<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 { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/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>
@@ -0,0 +1,102 @@
<template>
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
<FontAwesomeIcon
:icon="['fas', block.done ? 'check' : 'spinner']"
class="size-3"
:class="block.done ? 'text-green-600 dark:text-green-400' : 'animate-spin text-muted-foreground'"
/>
<FontAwesomeIcon
:icon="['fas', 'pen']"
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">
<FontAwesomeIcon
:icon="['fas', 'chevron-right']"
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"
>
<FontAwesomeIcon
:icon="['fas', 'spinner']"
class="size-3 animate-spin"
/>
</div>
<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"
/>
</CollapsibleContent>
</Collapsible>
</div>
</template>
<script setup lang="ts">
import { ref, computed, inject, watch } from 'vue'
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/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>
@@ -0,0 +1,5 @@
import type { InjectionKey } from 'vue'
export type OpenInFileManager = (path: string, isDir?: boolean) => void
export const openInFileManagerKey: InjectionKey<OpenInFileManager> = Symbol('openInFileManager')
+5
View File
@@ -11,6 +11,11 @@
}
}
.dark .shiki,
.dark .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
}
@layer utilities{
+43
View File
@@ -315,6 +315,9 @@ importers:
'@pinia/colada':
specifier: ^0.21.1
version: 0.21.2(pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3))
'@shikijs/transformers':
specifier: ^4.0.1
version: 4.0.1
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
@@ -1914,6 +1917,10 @@ packages:
'@shikijs/core@3.21.0':
resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==}
'@shikijs/core@4.0.1':
resolution: {integrity: sha512-vWvqi9JNgz1dRL9Nvog5wtx7RuNkf7MEPl2mU/cyUUxJeH1CAr3t+81h8zO8zs7DK6cKLMoU9TvukWIDjP4Lzg==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@2.5.0':
resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==}
@@ -1935,6 +1942,10 @@ packages:
'@shikijs/monaco@3.21.0':
resolution: {integrity: sha512-vYUBk5e89vlJbrGuLXaL+gQNGRVYUQOTEeWOnWb89ZL4hwDOBdbPftm0m8cPuD6H6HOdw+C48rGnATuMmGTaJg==}
'@shikijs/primitive@4.0.1':
resolution: {integrity: sha512-ns0hHZc5eWZuvuIEJz2pTx3Qecz0aRVYumVQJ8JgWY2tq/dH8WxdcVM49Fc2NsHEILNIT6vfdW9MF26RANWiTA==}
engines: {node: '>=20'}
'@shikijs/themes@2.5.0':
resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==}
@@ -1944,12 +1955,20 @@ packages:
'@shikijs/transformers@2.5.0':
resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==}
'@shikijs/transformers@4.0.1':
resolution: {integrity: sha512-oE46W2eHpvD06+C0MBthd2YrDM6cktvJDFl764tOEXxfr3dAJhxMc0uNZ2tQXp+bkMgl4E7IL88Mj9RnSqiayw==}
engines: {node: '>=20'}
'@shikijs/types@2.5.0':
resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==}
'@shikijs/types@3.21.0':
resolution: {integrity: sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==}
'@shikijs/types@4.0.1':
resolution: {integrity: sha512-EaygPEn57+jJ76mw+nTLvIpJMAcMPokFbrF8lufsZP7Ukk+ToJYEcswN1G0e49nUZAq7aCQtoeW219A8HK1ZOw==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -6584,6 +6603,14 @@ snapshots:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/core@4.0.1':
dependencies:
'@shikijs/primitive': 4.0.1
'@shikijs/types': 4.0.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@2.5.0':
dependencies:
'@shikijs/types': 2.5.0
@@ -6620,6 +6647,12 @@ snapshots:
'@shikijs/types': 3.21.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/primitive@4.0.1':
dependencies:
'@shikijs/types': 4.0.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/themes@2.5.0':
dependencies:
'@shikijs/types': 2.5.0
@@ -6633,6 +6666,11 @@ snapshots:
'@shikijs/core': 2.5.0
'@shikijs/types': 2.5.0
'@shikijs/transformers@4.0.1':
dependencies:
'@shikijs/core': 4.0.1
'@shikijs/types': 4.0.1
'@shikijs/types@2.5.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
@@ -6643,6 +6681,11 @@ snapshots:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/types@4.0.1':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@sinclair/typebox@0.34.47': {}