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
+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>
)
}