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:
carry
2026-06-07 13:44:36 +08:00
commit a9881eac26
34 changed files with 5833 additions and 0 deletions
+30
View File
@@ -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>
)
}
+16
View File
@@ -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>
)
}
+50
View File
@@ -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>
)
}
+50
View File
@@ -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>
)
}
+24
View File
@@ -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>
)
}
+83
View File
@@ -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>
)
}
+48
View File
@@ -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
}
}
+41
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
+26
View File
@@ -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>
)
}
+25
View File
@@ -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>
)
}
+27
View File
@@ -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>
)
}