mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(web): add tool message ui for built-in tools
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
"@memoh/sdk": "workspace:*",
|
"@memoh/sdk": "workspace:*",
|
||||||
"@memoh/ui": "workspace:*",
|
"@memoh/ui": "workspace:*",
|
||||||
"@pinia/colada": "^0.21.1",
|
"@pinia/colada": "^0.21.1",
|
||||||
|
"@shikijs/transformers": "^4.0.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
|
|||||||
@@ -253,6 +253,22 @@ watch(() => props.botId, () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
restoreFileFromUrl()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLanguage(filename: string): string {
|
||||||
|
return getLanguageByFilename(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractFilename(path: string): string {
|
||||||
|
return path.split('/').pop() ?? path
|
||||||
|
}
|
||||||
@@ -136,6 +136,14 @@
|
|||||||
"toolRunning": "Running",
|
"toolRunning": "Running",
|
||||||
"toolInput": "Input",
|
"toolInput": "Input",
|
||||||
"toolResult": "Result",
|
"toolResult": "Result",
|
||||||
|
"toolWriteContent": "Content",
|
||||||
|
"toolEditChanges": "Changes",
|
||||||
|
"toolSearchResults": "{count} results",
|
||||||
|
"toolSearchResultsLabel": "Results",
|
||||||
|
"toolExecOutput": "Output",
|
||||||
|
"toolExecExit": "exit: {code}",
|
||||||
|
"toolExecError": "Error",
|
||||||
|
"toolScheduleItems": "{count} items",
|
||||||
"unknownUser": "{platform} User",
|
"unknownUser": "{platform} User",
|
||||||
"files": "Files"
|
"files": "Files"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -132,6 +132,14 @@
|
|||||||
"toolRunning": "运行中",
|
"toolRunning": "运行中",
|
||||||
"toolInput": "输入",
|
"toolInput": "输入",
|
||||||
"toolResult": "结果",
|
"toolResult": "结果",
|
||||||
|
"toolWriteContent": "内容",
|
||||||
|
"toolEditChanges": "变更",
|
||||||
|
"toolSearchResults": "{count} 条结果",
|
||||||
|
"toolSearchResultsLabel": "结果",
|
||||||
|
"toolExecOutput": "输出",
|
||||||
|
"toolExecExit": "退出: {code}",
|
||||||
|
"toolExecError": "错误",
|
||||||
|
"toolScheduleItems": "{count} 条",
|
||||||
"unknownUser": "{platform}用户",
|
"unknownUser": "{platform}用户",
|
||||||
"files": "文件管理"
|
"files": "文件管理"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -188,6 +188,7 @@
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<FileManager
|
<FileManager
|
||||||
v-if="currentBotId"
|
v-if="currentBotId"
|
||||||
|
ref="fileManagerRef"
|
||||||
:bot-id="currentBotId"
|
:bot-id="currentBotId"
|
||||||
:sync-url="false"
|
:sync-url="false"
|
||||||
class="flex-1 min-h-0"
|
class="flex-1 min-h-0"
|
||||||
@@ -198,7 +199,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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 { useChatStore } from '@/store/chat-list'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
@@ -206,12 +207,26 @@ import MessageItem from './message-item.vue'
|
|||||||
import MediaGalleryLightbox from './media-gallery-lightbox.vue'
|
import MediaGalleryLightbox from './media-gallery-lightbox.vue'
|
||||||
import FileManager from '@/components/file-manager/index.vue'
|
import FileManager from '@/components/file-manager/index.vue'
|
||||||
import { useMediaGallery } from '../composables/useMediaGallery'
|
import { useMediaGallery } from '../composables/useMediaGallery'
|
||||||
|
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
|
||||||
import type { ChatAttachment } from '@/composables/api/useChat'
|
import type { ChatAttachment } from '@/composables/api/useChat'
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const pendingFiles = ref<File[]>([])
|
const pendingFiles = ref<File[]>([])
|
||||||
const fileManagerOpen = ref(false)
|
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 {
|
const {
|
||||||
messages,
|
messages,
|
||||||
streaming,
|
streaming,
|
||||||
|
|||||||
@@ -1,87 +1,58 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="rounded-lg border bg-muted/30 text-sm overflow-hidden">
|
<ToolCallRead
|
||||||
<!-- Header -->
|
v-if="block.toolName === 'read'"
|
||||||
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50">
|
:block="block"
|
||||||
<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">
|
<ToolCallWrite
|
||||||
{{ block.toolName }}
|
v-else-if="block.toolName === 'write'"
|
||||||
</span>
|
:block="block"
|
||||||
<Badge
|
/>
|
||||||
v-if="block.done"
|
<ToolCallEdit
|
||||||
variant="secondary"
|
v-else-if="block.toolName === 'edit'"
|
||||||
class="text-[10px] ml-auto"
|
:block="block"
|
||||||
>
|
/>
|
||||||
{{ $t('chat.toolDone') }}
|
<ToolCallList
|
||||||
</Badge>
|
v-else-if="block.toolName === 'list'"
|
||||||
<Badge
|
: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
|
v-else
|
||||||
variant="outline"
|
:block="block"
|
||||||
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 }"
|
|
||||||
/>
|
/>
|
||||||
{{ $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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Badge, Collapsible, CollapsibleTrigger, CollapsibleContent } from '@memoh/ui'
|
|
||||||
import type { ToolCallBlock } from '@/store/chat-list'
|
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<{
|
defineProps<{
|
||||||
block: ToolCallBlock
|
block: ToolCallBlock
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const inputOpen = ref(false)
|
const scheduleTools = new Set([
|
||||||
const resultOpen = ref(false)
|
'list_schedule',
|
||||||
|
'get_schedule',
|
||||||
function formatJson(val: unknown): string {
|
'create_schedule',
|
||||||
if (typeof val === 'string') return val
|
'update_schedule',
|
||||||
try {
|
'delete_schedule',
|
||||||
return JSON.stringify(val, null, 2)
|
])
|
||||||
} catch {
|
|
||||||
return String(val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</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')
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .shiki,
|
||||||
|
.dark .shiki span {
|
||||||
|
color: var(--shiki-dark) !important;
|
||||||
|
background-color: var(--shiki-dark-bg) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities{
|
@layer utilities{
|
||||||
|
|||||||
Generated
+43
@@ -315,6 +315,9 @@ importers:
|
|||||||
'@pinia/colada':
|
'@pinia/colada':
|
||||||
specifier: ^0.21.1
|
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))
|
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':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.18
|
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))
|
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':
|
'@shikijs/core@3.21.0':
|
||||||
resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==}
|
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':
|
'@shikijs/engine-javascript@2.5.0':
|
||||||
resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==}
|
resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==}
|
||||||
|
|
||||||
@@ -1935,6 +1942,10 @@ packages:
|
|||||||
'@shikijs/monaco@3.21.0':
|
'@shikijs/monaco@3.21.0':
|
||||||
resolution: {integrity: sha512-vYUBk5e89vlJbrGuLXaL+gQNGRVYUQOTEeWOnWb89ZL4hwDOBdbPftm0m8cPuD6H6HOdw+C48rGnATuMmGTaJg==}
|
resolution: {integrity: sha512-vYUBk5e89vlJbrGuLXaL+gQNGRVYUQOTEeWOnWb89ZL4hwDOBdbPftm0m8cPuD6H6HOdw+C48rGnATuMmGTaJg==}
|
||||||
|
|
||||||
|
'@shikijs/primitive@4.0.1':
|
||||||
|
resolution: {integrity: sha512-ns0hHZc5eWZuvuIEJz2pTx3Qecz0aRVYumVQJ8JgWY2tq/dH8WxdcVM49Fc2NsHEILNIT6vfdW9MF26RANWiTA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@shikijs/themes@2.5.0':
|
'@shikijs/themes@2.5.0':
|
||||||
resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==}
|
resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==}
|
||||||
|
|
||||||
@@ -1944,12 +1955,20 @@ packages:
|
|||||||
'@shikijs/transformers@2.5.0':
|
'@shikijs/transformers@2.5.0':
|
||||||
resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==}
|
resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==}
|
||||||
|
|
||||||
|
'@shikijs/transformers@4.0.1':
|
||||||
|
resolution: {integrity: sha512-oE46W2eHpvD06+C0MBthd2YrDM6cktvJDFl764tOEXxfr3dAJhxMc0uNZ2tQXp+bkMgl4E7IL88Mj9RnSqiayw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@shikijs/types@2.5.0':
|
'@shikijs/types@2.5.0':
|
||||||
resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==}
|
resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==}
|
||||||
|
|
||||||
'@shikijs/types@3.21.0':
|
'@shikijs/types@3.21.0':
|
||||||
resolution: {integrity: sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==}
|
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':
|
'@shikijs/vscode-textmate@10.0.2':
|
||||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||||
|
|
||||||
@@ -6584,6 +6603,14 @@ snapshots:
|
|||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
hast-util-to-html: 9.0.5
|
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':
|
'@shikijs/engine-javascript@2.5.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/types': 2.5.0
|
'@shikijs/types': 2.5.0
|
||||||
@@ -6620,6 +6647,12 @@ snapshots:
|
|||||||
'@shikijs/types': 3.21.0
|
'@shikijs/types': 3.21.0
|
||||||
'@shikijs/vscode-textmate': 10.0.2
|
'@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':
|
'@shikijs/themes@2.5.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/types': 2.5.0
|
'@shikijs/types': 2.5.0
|
||||||
@@ -6633,6 +6666,11 @@ snapshots:
|
|||||||
'@shikijs/core': 2.5.0
|
'@shikijs/core': 2.5.0
|
||||||
'@shikijs/types': 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':
|
'@shikijs/types@2.5.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shikijs/vscode-textmate': 10.0.2
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
@@ -6643,6 +6681,11 @@ snapshots:
|
|||||||
'@shikijs/vscode-textmate': 10.0.2
|
'@shikijs/vscode-textmate': 10.0.2
|
||||||
'@types/hast': 3.0.4
|
'@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': {}
|
'@shikijs/vscode-textmate@10.0.2': {}
|
||||||
|
|
||||||
'@sinclair/typebox@0.34.47': {}
|
'@sinclair/typebox@0.34.47': {}
|
||||||
|
|||||||
Reference in New Issue
Block a user