feat: Prompt Envelope Protocol MVP
- 定义 11 种 Segment 类型(text, static_var, system_prompt, memory, skills, tool_overview, tool_call_request/result, document, long_text, media) - 每种 Segment 有独立的颜色编码和折叠交互 - 通用 CollapsiblePanel + SegmentRenderer 路由架构 - 4 个 Demo 场景覆盖全部 9 种上下文类型 - 导出为 OpenAI Chat Completions Format(model + messages + tools) - tool_overview -> 请求级 tools[](含 JSON Schema) - tool_call_request -> assistant.tool_calls[] - tool_call_result -> tool-role message(ID 配对) - 17 个单元测试全部通过 - React 18 + TypeScript + Vite + Tailwind CSS
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { Send } from 'lucide-react'
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function ChatInput({ value, onChange, disabled }: ChatInputProps) {
|
||||
return (
|
||||
<div className="border-t border-gray-200 bg-white px-4 py-3">
|
||||
<div className="flex items-center gap-2 max-w-3xl mx-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="输入消息(Demo 模式 — 不会调用 LLM)"
|
||||
className="flex-1 rounded-lg border border-gray-200 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 disabled:bg-gray-50 disabled:text-gray-400"
|
||||
/>
|
||||
<button
|
||||
disabled={disabled || !value.trim()}
|
||||
className="shrink-0 rounded-lg bg-blue-500 p-2 text-white hover:bg-blue-600 disabled:opacity-40 transition-opacity"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { useChat } from '../context/ChatContext'
|
||||
import MessageList from './MessageList'
|
||||
import ChatInput from './ChatInput'
|
||||
|
||||
export default function ChatView() {
|
||||
const { envelope } = useChat()
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<MessageList messages={envelope.messages} />
|
||||
<ChatInput value={input} onChange={setInput} disabled />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface CollapsiblePanelProps {
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
color: string // Tailwind border/text color class, e.g. 'border-purple-400 text-purple-700'
|
||||
bgColor: string // Tailwind bg class, e.g. 'bg-purple-50'
|
||||
defaultCollapsed: boolean
|
||||
badge?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function CollapsiblePanel({
|
||||
title,
|
||||
icon,
|
||||
color,
|
||||
bgColor,
|
||||
defaultCollapsed,
|
||||
badge,
|
||||
children,
|
||||
}: CollapsiblePanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border-l-4 ${color} ${bgColor} my-2`}>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span className={`text-sm font-semibold ${color} flex-1`}>{title}</span>
|
||||
{badge && (
|
||||
<span className="text-xs text-gray-400 bg-white/60 px-1.5 py-0.5 rounded">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-gray-400">
|
||||
{collapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={`collapsible-content ${collapsed ? 'collapsed' : 'expanded'}`}
|
||||
aria-hidden={collapsed}
|
||||
>
|
||||
<div className="px-3 pb-3 pt-0 text-sm">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Message } from '../types/protocol'
|
||||
import SegmentRenderer from './SegmentRenderer'
|
||||
import { User, Bot, Settings } from 'lucide-react'
|
||||
|
||||
const roleConfig = {
|
||||
system: { icon: Settings, label: 'SYSTEM', align: 'left', bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||
user: { icon: User, label: 'YOU', align: 'right', bg: 'bg-blue-50 border-blue-100', text: 'text-blue-600' },
|
||||
assistant: { icon: Bot, label: 'ASSISTANT', align: 'left', bg: 'bg-white border-gray-100', text: 'text-green-600' },
|
||||
}
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const cfg = roleConfig[message.role]
|
||||
const Icon = cfg.icon
|
||||
const isUser = message.role === 'user'
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} my-3`}>
|
||||
<div
|
||||
className={`max-w-[75%] rounded-xl border px-4 py-3 ${cfg.bg} ${isUser ? 'ml-12' : 'mr-12'}`}
|
||||
>
|
||||
{/* Role header */}
|
||||
<div className={`flex items-center gap-1.5 mb-2 ${cfg.text}`}>
|
||||
<Icon size={14} />
|
||||
<span className="text-[10px] font-bold tracking-widest uppercase">
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Segments */}
|
||||
<div className="space-y-0.5">
|
||||
{message.segments.map((seg, i) => (
|
||||
<SegmentRenderer key={i} segment={seg} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="mt-2 text-[10px] text-gray-300 text-right">
|
||||
{new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Message } from '../types/protocol'
|
||||
import MessageBubble from './MessageBubble'
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
export default function MessageList({ messages }: MessageListProps) {
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-300 text-sm">
|
||||
选择一个 Demo 场景开始
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { PromptEnvelope } from '../types/protocol'
|
||||
import { exportToOpenAIFormat } from '../utils/export'
|
||||
import { Code, Copy, Download, Check } from 'lucide-react'
|
||||
|
||||
interface ProtocolPanelProps {
|
||||
envelope: PromptEnvelope
|
||||
}
|
||||
|
||||
export default function ProtocolPanel({ envelope }: ProtocolPanelProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const openaiFormat = useMemo(() => exportToOpenAIFormat(envelope), [envelope])
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(openaiFormat, null, 2))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([JSON.stringify(openaiFormat, null, 2)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'openai-export.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-96 border-l border-gray-200 bg-white flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-100">
|
||||
<Code size={18} className="text-gray-500" />
|
||||
<span className="text-sm font-semibold text-gray-700 flex-1">
|
||||
Protocol View
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Download size={14} />
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex text-xs border-b border-gray-100">
|
||||
<div className="flex-1 text-center py-2 font-semibold text-blue-600 border-b-2 border-blue-500 bg-blue-50/50">
|
||||
OpenAI Format
|
||||
</div>
|
||||
<div className="flex-1 text-center py-2 text-gray-400">
|
||||
Raw Protocol (soon)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<pre className="p-4 text-xs font-mono text-gray-600 whitespace-pre-wrap break-all leading-relaxed">
|
||||
{JSON.stringify(openaiFormat, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Footer stats */}
|
||||
<div className="px-4 py-2 border-t border-gray-100 text-[10px] text-gray-400 flex items-center gap-3">
|
||||
<span>model: {openaiFormat.model}</span>
|
||||
<span>{envelope.messages.length} 条协议消息</span>
|
||||
<span>{openaiFormat.messages.length} OpenAI messages</span>
|
||||
{openaiFormat.tools && <span>{openaiFormat.tools.length} tools</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Segment } from '../types/protocol'
|
||||
import TextSegmentView from './segments/TextSegmentView'
|
||||
import StaticVarBadge from './segments/StaticVarBadge'
|
||||
import SystemPromptView from './segments/SystemPromptView'
|
||||
import MemoryView from './segments/MemoryView'
|
||||
import SkillsView from './segments/SkillsView'
|
||||
import ToolOverviewView from './segments/ToolOverviewView'
|
||||
import ToolCallRequestView from './segments/ToolCallRequestView'
|
||||
import ToolCallResultView from './segments/ToolCallResultView'
|
||||
import DocumentCard from './segments/DocumentCard'
|
||||
import LongTextView from './segments/LongTextView'
|
||||
import MediaView from './segments/MediaView'
|
||||
|
||||
interface SegmentRendererProps {
|
||||
segment: Segment
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Segment to its correct view component based on `kind`.
|
||||
*/
|
||||
export default function SegmentRenderer({ segment }: SegmentRendererProps) {
|
||||
switch (segment.kind) {
|
||||
case 'text':
|
||||
return <TextSegmentView segment={segment} />
|
||||
case 'static_var':
|
||||
return <StaticVarBadge segment={segment} />
|
||||
case 'system_prompt':
|
||||
return <SystemPromptView segment={segment} />
|
||||
case 'memory':
|
||||
return <MemoryView segment={segment} />
|
||||
case 'skills':
|
||||
return <SkillsView segment={segment} />
|
||||
case 'tool_overview':
|
||||
return <ToolOverviewView segment={segment} />
|
||||
case 'tool_call_request':
|
||||
return <ToolCallRequestView segment={segment} />
|
||||
case 'tool_call_result':
|
||||
return <ToolCallResultView segment={segment} />
|
||||
case 'document':
|
||||
return <DocumentCard segment={segment} />
|
||||
case 'long_text':
|
||||
return <LongTextView segment={segment} />
|
||||
case 'media':
|
||||
return <MediaView segment={segment} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DocumentSegment } from '../../types/protocol'
|
||||
import { FileText, FileImage, FileAudio, File } from 'lucide-react'
|
||||
|
||||
const mimeIcons: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
'text/': FileText,
|
||||
'image/': FileImage,
|
||||
'audio/': FileAudio,
|
||||
}
|
||||
|
||||
function getIcon(mimeType: string) {
|
||||
for (const [prefix, Icon] of Object.entries(mimeIcons)) {
|
||||
if (mimeType.startsWith(prefix)) return <Icon size={20} className="text-gray-500" />
|
||||
}
|
||||
return <File size={20} className="text-gray-500" />
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
export default function DocumentCard({ segment }: { segment: DocumentSegment }) {
|
||||
return (
|
||||
<div className="my-2 flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 hover:shadow-sm transition-shadow">
|
||||
<div className="shrink-0 mt-0.5">{getIcon(segment.mimeType)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-800 truncate">
|
||||
{segment.fileName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{formatBytes(segment.sizeBytes)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{segment.mimeType}</p>
|
||||
<p className="text-xs text-gray-500 mt-1.5 line-clamp-3 italic">
|
||||
{segment.snippet}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react'
|
||||
import type { LongTextSegment } from '../../types/protocol'
|
||||
import { ChevronDown, ChevronRight, Text } from 'lucide-react'
|
||||
|
||||
export default function LongTextView({ segment }: { segment: LongTextSegment }) {
|
||||
const [collapsed, setCollapsed] = useState(segment.collapsed)
|
||||
|
||||
// Show first ~2 lines when collapsed
|
||||
const preview = segment.content.split('\n').slice(0, 2).join('\n')
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-lg border border-gray-200 bg-white overflow-hidden">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Text size={14} className="text-gray-400" />
|
||||
<span className="text-xs text-gray-500 flex-1">
|
||||
{collapsed ? '长文本素材' : '长文本素材'} · {segment.charCount} 字
|
||||
</span>
|
||||
<span className="text-blue-500 text-xs">
|
||||
{collapsed ? '展开' : '收起'}
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed max-h-64 overflow-y-auto">
|
||||
{segment.content}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="px-3 pb-2">
|
||||
<p className="text-sm text-gray-400 whitespace-pre-wrap line-clamp-2 italic">
|
||||
{preview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MediaSegment } from '../../types/protocol'
|
||||
import { Image, Music, Video } from 'lucide-react'
|
||||
|
||||
const mediaConfig = {
|
||||
image: { icon: Image, label: '图片', bg: 'bg-blue-50', color: 'text-blue-600' },
|
||||
audio: { icon: Music, label: '音频', bg: 'bg-pink-50', color: 'text-pink-600' },
|
||||
video: { icon: Video, label: '视频', bg: 'bg-indigo-50', color: 'text-indigo-600' },
|
||||
}
|
||||
|
||||
export default function MediaView({ segment }: { segment: MediaSegment }) {
|
||||
const cfg = mediaConfig[segment.mediaType]
|
||||
const Icon = cfg.icon
|
||||
|
||||
return (
|
||||
<div className={`my-2 flex items-center gap-3 rounded-lg border px-3 py-2.5 ${cfg.bg} border-gray-200`}>
|
||||
<Icon size={20} className={cfg.color} />
|
||||
<div className="flex-1">
|
||||
<span className={`text-sm font-medium ${cfg.color}`}>{cfg.label}</span>
|
||||
{segment.altText && (
|
||||
<span className="text-xs text-gray-500 ml-2">{segment.altText}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{segment.mediaType}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MemorySegment } from '../../types/protocol'
|
||||
import CollapsiblePanel from '../CollapsiblePanel'
|
||||
import { Brain } from 'lucide-react'
|
||||
|
||||
export default function MemoryView({ segment }: { segment: MemorySegment }) {
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
title="User Memory"
|
||||
icon={<Brain size={16} />}
|
||||
color="border-purple-400 text-purple-700"
|
||||
bgColor="bg-purple-50"
|
||||
defaultCollapsed={segment.collapsed}
|
||||
badge={`${segment.items.length} 条记忆`}
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
{segment.items.map((item, i) => (
|
||||
<li key={i} className="bg-white/60 rounded px-2 py-1.5">
|
||||
<div className="text-xs font-semibold text-purple-800">{item.title}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{item.content}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsiblePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SkillSegment } from '../../types/protocol'
|
||||
import CollapsiblePanel from '../CollapsiblePanel'
|
||||
import { Zap } from 'lucide-react'
|
||||
|
||||
export default function SkillsView({ segment }: { segment: SkillSegment }) {
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
title="Skills"
|
||||
icon={<Zap size={16} />}
|
||||
color="border-green-400 text-green-700"
|
||||
bgColor="bg-green-50"
|
||||
defaultCollapsed={segment.collapsed}
|
||||
badge={`${segment.items.length} skills`}
|
||||
>
|
||||
<ul className="space-y-1.5">
|
||||
{segment.items.map((item, i) => (
|
||||
<li key={i} className="text-xs flex items-start gap-2">
|
||||
<span className="font-mono font-semibold text-green-800 shrink-0">
|
||||
/{item.name}
|
||||
</span>
|
||||
<span className="text-gray-500">{item.description}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsiblePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { StaticVarSegment } from '../../types/protocol'
|
||||
import { Variable } from 'lucide-react'
|
||||
|
||||
export default function StaticVarBadge({ segment }: { segment: StaticVarSegment }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-blue-100 text-blue-700 text-xs font-mono border border-blue-200">
|
||||
<Variable size={12} />
|
||||
<span className="text-blue-400">{'{{'}{segment.name}{'}}'}</span>
|
||||
<span className="text-blue-300">→</span>
|
||||
<span className="font-semibold">{segment.value}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SystemPromptSegment } from '../../types/protocol'
|
||||
import CollapsiblePanel from '../CollapsiblePanel'
|
||||
import { Bot } from 'lucide-react'
|
||||
|
||||
export default function SystemPromptView({ segment }: { segment: SystemPromptSegment }) {
|
||||
const lineCount = segment.content.split('\n').length
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
title="System Prompt"
|
||||
icon={<Bot size={16} />}
|
||||
color="border-gray-400 text-gray-600"
|
||||
bgColor="bg-gray-50"
|
||||
defaultCollapsed={segment.collapsed}
|
||||
badge={`${lineCount} 行`}
|
||||
>
|
||||
<pre className="text-xs text-gray-600 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
|
||||
{segment.content}
|
||||
</pre>
|
||||
</CollapsiblePanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { TextSegment } from '../../types/protocol'
|
||||
|
||||
export default function TextSegmentView({ segment }: { segment: TextSegment }) {
|
||||
return (
|
||||
<p className="text-gray-800 leading-relaxed whitespace-pre-wrap break-words">
|
||||
{segment.content}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ToolCallRequestSegment } from '../../types/protocol'
|
||||
import { Play } from 'lucide-react'
|
||||
|
||||
export default function ToolCallRequestView({
|
||||
segment,
|
||||
}: {
|
||||
segment: ToolCallRequestSegment
|
||||
}) {
|
||||
return (
|
||||
<div className="my-2 rounded-lg bg-gray-900 text-gray-100 overflow-hidden border border-gray-700">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-800">
|
||||
<Play size={14} className="text-green-400" />
|
||||
<span className="text-xs font-mono font-semibold text-green-400">
|
||||
{segment.toolName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">request</span>
|
||||
</div>
|
||||
<pre className="px-3 py-2 text-xs font-mono text-gray-300 overflow-x-auto">
|
||||
{JSON.stringify(segment.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react'
|
||||
import type { ToolCallResultSegment } from '../../types/protocol'
|
||||
import { CheckCircle, XCircle, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
export default function ToolCallResultView({
|
||||
segment,
|
||||
}: {
|
||||
segment: ToolCallResultSegment
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(segment.collapsed)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`my-2 rounded-lg border-l-4 overflow-hidden ${
|
||||
segment.success
|
||||
? 'border-green-400 bg-green-50'
|
||||
: 'border-red-400 bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:opacity-80"
|
||||
>
|
||||
{segment.success ? (
|
||||
<CheckCircle size={14} className="text-green-600" />
|
||||
) : (
|
||||
<XCircle size={14} className="text-red-600" />
|
||||
)}
|
||||
<span className="text-xs font-mono font-semibold text-gray-700">
|
||||
{segment.toolName}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${segment.success ? 'text-green-600' : 'text-red-600'}`}
|
||||
>
|
||||
{segment.success ? '成功' : '失败'}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-gray-400">
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={`collapsible-content ${collapsed ? 'collapsed' : 'expanded'}`}
|
||||
>
|
||||
<pre className="px-3 pb-2 text-xs font-mono text-gray-600 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{segment.result}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react'
|
||||
import type { ToolOverviewSegment } from '../../types/protocol'
|
||||
import CollapsiblePanel from '../CollapsiblePanel'
|
||||
import { Wrench, ChevronDown, ChevronRight, Braces } from 'lucide-react'
|
||||
|
||||
function ToolItemRow({ item }: { item: ToolOverviewSegment['items'][number] }) {
|
||||
const [showSchema, setShowSchema] = useState(false)
|
||||
|
||||
return (
|
||||
<li className="bg-white/60 rounded px-2 py-1.5">
|
||||
<div className="text-xs font-semibold text-orange-800 font-mono">
|
||||
{item.name}
|
||||
<span className="text-orange-400 font-normal">({item.parameters})</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{item.description}</div>
|
||||
{item.schema && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowSchema(!showSchema)}
|
||||
className="flex items-center gap-1 mt-1 text-[10px] text-orange-500 hover:text-orange-700 transition-colors"
|
||||
>
|
||||
<Braces size={10} />
|
||||
JSON Schema
|
||||
{showSchema ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
|
||||
</button>
|
||||
{showSchema && (
|
||||
<pre className="mt-1 text-[10px] text-gray-500 font-mono bg-gray-50 rounded p-1.5 overflow-x-auto max-h-32">
|
||||
{JSON.stringify(item.schema, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ToolOverviewView({ segment }: { segment: ToolOverviewSegment }) {
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
title="Available Tools"
|
||||
icon={<Wrench size={16} />}
|
||||
color="border-orange-400 text-orange-700"
|
||||
bgColor="bg-orange-50"
|
||||
defaultCollapsed={segment.collapsed}
|
||||
badge={`${segment.items.length} tools`}
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
{segment.items.map((item, i) => (
|
||||
<ToolItemRow key={i} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</CollapsiblePanel>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user