134 lines
3.9 KiB
TypeScript
134 lines
3.9 KiB
TypeScript
import React, { useEffect, useState } from 'react'
|
||
import { Globe, Monitor } from 'lucide-react'
|
||
import { cn } from '@/utils/cn'
|
||
import { useNovaKit } from '../../context/useNovaKit'
|
||
import { ImagePreview } from '@/components/ui/image-preview'
|
||
|
||
export interface BrowserUseActionProps {
|
||
/** 显示名称(action_name,如"正在浏览") */
|
||
name?: string
|
||
/** 参数数组,第一个通常是 URL */
|
||
arguments?: string[]
|
||
/** 工具原始输出(tool_output) */
|
||
toolOutput?: unknown
|
||
className?: string
|
||
}
|
||
|
||
interface BrowserUseOutput {
|
||
result?: {
|
||
clean_screenshot_path?: string
|
||
screenshot_path?: string
|
||
url?: string
|
||
title?: string
|
||
}
|
||
}
|
||
|
||
/**
|
||
* browser_use 工具调用专属渲染组件
|
||
* 对应原始 task-computer-use.tsx,去除了 Antd / UnoCSS / store 依赖
|
||
*/
|
||
function InnerBrowserUseAction({
|
||
name,
|
||
arguments: args,
|
||
toolOutput,
|
||
className,
|
||
}: BrowserUseActionProps) {
|
||
const { api } = useNovaKit()
|
||
const [screenshotUrl, setScreenshotUrl] = useState<string>()
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
const url = args?.[0] || ''
|
||
|
||
// 提取为字符串原始值作为 effect 依赖,只有路径真正变化才重新请求
|
||
const screenshotPath =
|
||
(toolOutput as BrowserUseOutput)?.result?.clean_screenshot_path ||
|
||
(toolOutput as BrowserUseOutput)?.result?.screenshot_path
|
||
|
||
// 加载截图,api 不作为依赖(Context 每次渲染都会返回新引用,加入会反复触发)
|
||
useEffect(() => {
|
||
if (!screenshotPath) return
|
||
|
||
let cancelled = false
|
||
|
||
queueMicrotask(() => {
|
||
if (cancelled) return
|
||
setLoading(true)
|
||
})
|
||
|
||
api
|
||
.getArtifactUrl?.({ path: screenshotPath, file_name: '', file_type: 'png' })
|
||
.then(res => {
|
||
if (cancelled) return
|
||
if (res?.data) setScreenshotUrl(res.data)
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
if (cancelled) return
|
||
setLoading(false)
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [screenshotPath])
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex flex-col rounded-xl border border-gray-200 overflow-hidden bg-white w-full max-w-[480px] mb-4',
|
||
className,
|
||
)}
|
||
>
|
||
{/* 标题栏 */}
|
||
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 border-b border-gray-100">
|
||
<Globe className="w-4 h-4 text-muted-foreground shrink-0" />
|
||
<div className="flex flex-col min-w-0 flex-1">
|
||
<span className="text-sm font-medium leading-tight">
|
||
{name || '正在浏览'}
|
||
</span>
|
||
{url && (
|
||
<span className="text-xs text-muted-foreground truncate">
|
||
{url}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 内容区 */}
|
||
<div
|
||
className={cn(
|
||
'relative bg-white',
|
||
screenshotUrl || loading ? 'pt-[56.25%]' : 'py-8',
|
||
)}
|
||
>
|
||
{loading && (
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div className="w-6 h-6 border-2 border-muted border-t-primary rounded-full animate-spin" />
|
||
</div>
|
||
)}
|
||
|
||
{screenshotUrl && !loading && (
|
||
<ImagePreview src={screenshotUrl} alt="browser screenshot">
|
||
<img
|
||
className="absolute inset-0 w-full h-full object-contain cursor-zoom-in"
|
||
src={screenshotUrl}
|
||
alt="browser screenshot"
|
||
/>
|
||
</ImagePreview>
|
||
)}
|
||
|
||
{!screenshotUrl && !loading && (
|
||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||
<Monitor className="w-8 h-8 opacity-40" />
|
||
<span className="text-xs">正在执行浏览器任务…</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export const BrowserUseAction = React.memo(InnerBrowserUseAction)
|
||
export default BrowserUseAction
|