Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,14 @@ export default defineConfig({
return path.resolve(buildOutDir, 'monacoeditorwork')
},
}),
vue(),
vue({
template: {
compilerOptions: {
// 将所有带短横线的标签名都视为自定义元素
isCustomElement: (tag) => tag.startsWith('ui-resource-renderer')
}
}
}),
svgLoader(),
vueDevTools()
],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"@iconify-json/vscode-icons": "^1.2.33",
"@iconify/vue": "^5.0.0",
"@lingual/i18n-check": "0.8.12",
"@mcp-ui/client": "^5.13.1",
"@pinia/colada": "^0.17.8",
"@prettier/plugin-oxc": "^0.0.4",
"@tailwindcss/typography": "^0.5.19",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ function exportToMarkdown(conversation: CONVERSATION, messages: Message[]): stri
}
lines.push('')
break
case 'mcp_ui_resource':
if (block.mcp_ui_resource) {
lines.push('### 🧩 MCP UI 资源')
lines.push('')
lines.push(
`资源: ${block.mcp_ui_resource.uri} (${block.mcp_ui_resource.mimeType ?? ''})`
)
lines.push('')
}
break
case 'image':
lines.push('### 🖼️ 图片')
lines.push('*[图片内容]*')
Expand Down Expand Up @@ -358,6 +368,17 @@ function exportToHtml(conversation: CONVERSATION, messages: Message[]): string {
})
)
break
case 'mcp_ui_resource':
if (block.mcp_ui_resource) {
blockLines.push(
...renderTemplate(templates.assistantContent, {
content: formatInlineHtml(
`MCP UI 资源: ${block.mcp_ui_resource.uri} (${block.mcp_ui_resource.mimeType ?? ''})`
)
})
)
}
break
case 'image':
blockLines.push(...renderTemplate(templates.assistantImage))
break
Expand Down Expand Up @@ -534,6 +555,13 @@ function exportToText(conversation: CONVERSATION, messages: Message[]): string {
}
lines.push('')
break
case 'mcp_ui_resource':
if (block.mcp_ui_resource) {
lines.push('[MCP UI 资源]')
lines.push(`${block.mcp_ui_resource.uri} (${block.mcp_ui_resource.mimeType ?? ''})`)
lines.push('')
}
break
case 'image':
lines.push('[图片内容]')
lines.push('')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export class LLMEventHandler {

if (tool_call_response_raw && tool_call === 'end') {
await this.toolCallHandler.processSearchResultsFromToolCall(state, msg, currentTime)
await this.toolCallHandler.processMcpUiResourcesFromToolCall(state, msg, currentTime)
}

if (tool_call) {
Expand Down
87 changes: 87 additions & 0 deletions src/main/presenter/threadPresenter/handlers/toolCallHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ interface PermissionRequestPayload {
}

export class ToolCallHandler {
private static readonly MCP_UI_MIME_TYPES = new Set([
'text/html',
'text/uri-list',
'application/vnd.mcp-ui.remote-dom'
])

private readonly messageManager: MessageManager
private readonly sqlitePresenter: ISQLitePresenter
private readonly searchingMessages: Set<string>
Expand Down Expand Up @@ -169,6 +175,87 @@ export class ToolCallHandler {
}
}

async processMcpUiResourcesFromToolCall(
state: GeneratingMessageState,
event: LLMAgentEventData,
currentTime: number
): Promise<boolean> {
if (event.tool_call !== 'end') {
return false
}

try {
const response = event.tool_call_response_raw as MCPToolResponse | null
const contentItems = Array.isArray(response?.content)
? (response.content as MCPContentItem[])
: []

const uiResourceItems = contentItems.filter((item): item is MCPResourceContent => {
if (item.type !== 'resource') {
return false
}
const uri = item.resource?.uri
const mimeType = item.resource?.mimeType || ''
return (
typeof uri === 'string' &&
uri.startsWith('ui://') &&
ToolCallHandler.MCP_UI_MIME_TYPES.has(mimeType)
)
})

if (uiResourceItems.length === 0) {
return false
}

const uiBlocks: AssistantMessageBlock[] = uiResourceItems
.map((item) => {
const resource = item.resource
if (!resource?.uri) {
return null
}

const mimeType = resource.mimeType || ''
if (!ToolCallHandler.MCP_UI_MIME_TYPES.has(mimeType)) {
return null
}
const typedMimeType = mimeType as
| 'text/html'
| 'text/uri-list'
| 'application/vnd.mcp-ui.remote-dom'

const meta = (
resource as MCPResourceContent['resource'] & { _meta?: Record<string, unknown> }
)._meta

return {
type: 'mcp_ui_resource',
status: 'success',
timestamp: currentTime,
mcp_ui_resource: {
uri: resource.uri,
mimeType: typedMimeType,
text: typeof resource.text === 'string' ? resource.text : undefined,
blob: typeof resource.blob === 'string' ? resource.blob : undefined,
_meta: meta && typeof meta === 'object' ? meta : undefined
}
}
})
.filter(Boolean) as AssistantMessageBlock[]

if (uiBlocks.length === 0) {
return false
}

this.finalizeLastBlock(state)
state.message.content.push(...uiBlocks)
await this.messageManager.editMessage(event.eventId, JSON.stringify(state.message.content))
return true
} catch (error) {
console.error('[ToolCallHandler] Error processing MCP UI resources from tool call:', error)
return false
}
}

async processSearchResultsFromToolCall(
state: GeneratingMessageState,
event: LLMAgentEventData,
Expand Down
184 changes: 184 additions & 0 deletions src/renderer/src/components/message/MessageBlockMcpUi.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<template>
<div class="my-1 w-full">
<div class="space-y-3 rounded-lg border bg-card p-3 text-card-foreground">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<Icon icon="lucide:panels-top-left" class="h-4 w-4 text-muted-foreground" />
<div class="min-w-0">
<p class="text-xs font-medium leading-tight text-foreground">
{{ t('chat.mcpUi.title') }}
</p>
<p v-if="resource?.uri" class="truncate text-[11px] text-muted-foreground">
{{ resource.uri }}
</p>
</div>
</div>
<Badge variant="secondary" class="h-6 px-2 text-[11px] leading-tight">
{{ t('chat.mcpUi.badge') }}
</Badge>
</div>

<div
v-if="errorMessage"
class="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive"
>
<Icon icon="lucide:alert-circle" class="h-4 w-4 shrink-0" />
<span class="leading-tight">{{ errorMessage }}</span>
</div>
<div v-else-if="!resourcePayload" class="text-xs text-muted-foreground">
{{ t('common.error.requestFailed') }}
</div>
<div v-else class="space-y-2">
<div class="overflow-hidden rounded-md border bg-muted/40">
<ui-resource-renderer
ref="rendererRef"
class="block w-full min-h-60"
:resource="resourcePayload"
/>
</div>
<div v-if="isLoading" class="flex items-center gap-2 text-xs text-muted-foreground">
<Icon icon="lucide:loader-2" class="h-4 w-4 animate-spin" />
<span>{{ t('common.loading') }}</span>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { nanoid } from 'nanoid'
import { Icon } from '@iconify/vue'
import { Badge } from '@shadcn/components/ui/badge'
import { useI18n } from 'vue-i18n'
import type { UIActionResult } from '@mcp-ui/client'
import { AssistantMessageBlock } from '@shared/chat'
import { usePresenter } from '@/composables/usePresenter'

const props = defineProps<{
block: AssistantMessageBlock
messageId?: string
threadId?: string
}>()

const { t } = useI18n()
const mcpPresenter = usePresenter('mcpPresenter')

const rendererRef = ref<HTMLElement | null>(null)
const isLoading = ref(false)
const errorMessage = ref<string | null>(null)
const payload = ref<string>('')

const resource = computed(() => props.block.mcp_ui_resource)

const resourcePayload = computed(() => payload.value)

watch(
() => resource.value,
(value) => {
errorMessage.value = null
if (!value?.uri || !value.mimeType) {
payload.value = ''
return
}
try {
payload.value = JSON.stringify(value)
} catch (error) {
console.error('[MessageBlockMcpUi] Failed to serialize MCP UI resource', error)
errorMessage.value = t('common.error.requestFailed')
payload.value = ''
}
},
{ immediate: true }
)

const handleUIAction = async (action?: UIActionResult | null): Promise<unknown> => {
if (!action) {
return null
}

if (action.type === 'tool') {
const toolName = action.payload?.toolName
if (!toolName) {
const toolError = new Error('Tool name missing in MCP UI action')
errorMessage.value = t('common.error.requestFailed')
throw toolError
}

isLoading.value = true
errorMessage.value = null
try {
const args = JSON.stringify(action.payload?.params ?? {})
const response = await mcpPresenter.callTool({
id: action.messageId || nanoid(),
type: 'function',
function: {
name: toolName,
arguments: args
}
})
return response?.rawData ?? response
} catch (error) {
console.error('[MessageBlockMcpUi] Failed to execute MCP UI tool', error)
errorMessage.value = t('common.error.requestFailed')
throw error
} finally {
isLoading.value = false
}
}

return null
}

const handleUIActionEvent = async (event: Event) => {
const detail = (event as CustomEvent<UIActionResult>).detail
try {
await handleUIAction(detail)
} catch {
// Response handling is managed through UI renderer; errors are already captured
}
}

watch(
() => rendererRef.value,
(element, previous) => {
previous?.removeEventListener('onUIAction', handleUIActionEvent as EventListener)
if (previous) {
;(
previous as unknown as { onUIAction?: (action: UIActionResult) => Promise<unknown> }
).onUIAction = undefined
}

if (element) {
element.addEventListener('onUIAction', handleUIActionEvent as EventListener)
;(
element as unknown as { onUIAction?: (action: UIActionResult) => Promise<unknown> }
).onUIAction = handleUIAction
}
}
)

onBeforeUnmount(() => {
const element = rendererRef.value
element?.removeEventListener('onUIAction', handleUIActionEvent as EventListener)
if (element) {
;(
element as unknown as { onUIAction?: (action: UIActionResult) => Promise<unknown> }
).onUIAction = undefined
}
})
</script>

<style>
ui-resource-renderer {
display: block;
width: 100%;
height: 425px;

& > div {
display: block;
width: 100%;
height: 425px;
}
}
</style>
7 changes: 7 additions & 0 deletions src/renderer/src/components/message/MessageItemAssistant.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@
:conversation-id="currentThreadId"
:block="block"
/>
<MessageBlockMcpUi
v-else-if="block.type === 'mcp_ui_resource'"
:block="block"
:message-id="currentMessage.id"
:thread-id="currentThreadId"
/>
<MessageBlockImage
v-else-if="block.type === 'image'"
:block="block"
Expand Down Expand Up @@ -128,6 +134,7 @@ import { Spinner } from '@shadcn/components/ui/spinner'
import MessageBlockAction from './MessageBlockAction.vue'
import { useI18n } from 'vue-i18n'
import MessageBlockImage from './MessageBlockImage.vue'
import MessageBlockMcpUi from './MessageBlockMcpUi.vue'

import {
Dialog,
Expand Down
Loading