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
+66
View File
@@ -0,0 +1,66 @@
import { ChatProvider, useChat } from './context/ChatContext'
import ChatView from './components/ChatView'
import ProtocolPanel from './components/ProtocolPanel'
import { Layers } from 'lucide-react'
function DemoSelector() {
const { demos, activeDemo, setActiveDemo } = useChat()
return (
<div className="flex gap-1">
{demos.map((demo, i) => (
<button
key={demo.id}
onClick={() => setActiveDemo(i)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
i === activeDemo
? 'bg-blue-500 text-white shadow-sm'
: 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'
}`}
>
{demo.label}
</button>
))}
</div>
)
}
function AppContent() {
const { envelope, demos, activeDemo } = useChat()
return (
<div className="h-screen flex flex-col">
{/* Top bar */}
<header className="shrink-0 border-b border-gray-200 bg-white px-6 py-3 flex items-center gap-4">
<div className="flex items-center gap-2">
<Layers size={20} className="text-blue-500" />
<h1 className="text-sm font-bold text-gray-800">
Prompt Envelope Protocol
</h1>
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">
MVP
</span>
</div>
<div className="flex-1" />
<DemoSelector />
<span className="text-[10px] text-gray-400 bg-blue-50 px-2 py-1 rounded max-w-xs truncate">
{demos[activeDemo].description}
</span>
</header>
{/* Main content */}
<div className="flex-1 flex min-h-0">
<ChatView />
<ProtocolPanel envelope={envelope} />
</div>
</div>
)
}
export default function App() {
return (
<ChatProvider>
<AppContent />
</ChatProvider>
)
}
+452
View File
@@ -0,0 +1,452 @@
import { describe, it, expect } from 'vitest'
import { exportToOpenAIFormat, segmentToText } from '../utils/export'
import type { PromptEnvelope } from '../types/protocol'
describe('segmentToText', () => {
it('returns text content as-is', () => {
expect(segmentToText({ kind: 'text', content: 'Hello world' })).toBe('Hello world')
})
it('expands static variables to their value', () => {
expect(
segmentToText({ kind: 'static_var', name: 'user', value: 'Alice' })
).toBe('Alice')
})
it('returns null for structural segments', () => {
expect(
segmentToText({ kind: 'system_prompt', content: '...', collapsed: true })
).toBeNull()
expect(
segmentToText({ kind: 'memory', items: [{ title: 'x', content: 'y' }], collapsed: true })
).toBeNull()
expect(
segmentToText({ kind: 'skills', items: [], collapsed: true })
).toBeNull()
expect(
segmentToText({ kind: 'tool_overview', items: [], collapsed: true })
).toBeNull()
})
it('returns null for tool call segments (handled structurally)', () => {
expect(
segmentToText({
kind: 'tool_call_request',
toolName: 'search',
arguments: { q: 'test' },
collapsed: false,
})
).toBeNull()
expect(
segmentToText({
kind: 'tool_call_result',
toolName: 'search',
result: 'Found 3',
success: true,
collapsed: true,
})
).toBeNull()
})
it('returns long text as-is', () => {
expect(
segmentToText({ kind: 'long_text', content: 'a long article', charCount: 14, collapsed: true })
).toBe('a long article')
})
it('uses altText for media', () => {
expect(
segmentToText({ kind: 'media', mediaType: 'image', url: '', altText: 'a diagram' })
).toBe('a diagram')
})
})
describe('exportToOpenAIFormat', () => {
it('includes model in output (default or from envelope)', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'user',
segments: [{ kind: 'text', content: 'Hi' }],
timestamp: 0,
},
],
}
// Default model
expect(exportToOpenAIFormat(env).model).toBe('gpt-4-turbo')
// Custom model
const env2: PromptEnvelope = { ...env, model: 'claude-opus-4-8' }
expect(exportToOpenAIFormat(env2).model).toBe('claude-opus-4-8')
})
it('exports tool_overview as top-level tools array', () => {
const env: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: [
{
id: '0',
role: 'system',
segments: [
{
kind: 'tool_overview',
items: [
{
name: 'search',
description: 'Search the web',
parameters: 'query: string',
schema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
},
],
collapsed: true,
},
],
timestamp: 0,
},
{
id: '1',
role: 'user',
segments: [{ kind: 'text', content: 'Hi' }],
timestamp: 0,
},
],
}
const result = exportToOpenAIFormat(env)
expect(result.model).toBe('gpt-4-turbo')
expect(result.tools).toHaveLength(1)
expect(result.tools![0]).toEqual({
type: 'function',
function: {
name: 'search',
description: 'Search the web',
parameters: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
},
})
expect(result.messages).toHaveLength(2) // system + user
})
it('flattens a simple text-only conversation', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'user',
segments: [{ kind: 'text', content: 'Hi' }],
timestamp: 0,
},
{
id: '2',
role: 'assistant',
segments: [{ kind: 'text', content: 'Hello!' }],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
expect(messages).toHaveLength(2)
expect(messages[0]).toEqual({ role: 'user', content: 'Hi' })
expect(messages[1]).toEqual({ role: 'assistant', content: 'Hello!' })
})
it('collects system-message segments into a leading system message', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '0',
role: 'system',
segments: [
{ kind: 'system_prompt', content: 'Be helpful.', collapsed: true },
{
kind: 'memory',
items: [{ title: 'User', content: 'Prefers brevity' }],
collapsed: true,
},
],
timestamp: 0,
},
{
id: '1',
role: 'user',
segments: [{ kind: 'text', content: 'Hi' }],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
expect(messages).toHaveLength(2)
expect(messages[0].role).toBe('system')
expect(messages[0].content).toContain('Be helpful')
expect(messages[0].content).toContain('Prefers brevity')
expect(messages[1].role).toBe('user')
expect(messages[1].content).toBe('Hi')
})
it('pulls structural segments from user messages into system message', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'user',
segments: [
{ kind: 'system_prompt', content: 'Be concise.', collapsed: true },
{ kind: 'text', content: 'Hello' },
],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
expect(messages[0].role).toBe('system')
expect(messages[0].content).toContain('Be concise')
expect(messages[1].role).toBe('user')
expect(messages[1].content).toBe('Hello')
})
it('expands static_vars in output', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'user',
segments: [
{ kind: 'static_var', name: 'user_name', value: '小明' },
{ kind: 'text', content: ' says hello' },
],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
expect(messages[0].content).toContain('小明')
expect(messages[0].content).toContain('says hello')
})
it('emits tool_call_request as assistant message with tool_calls', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'assistant',
segments: [
{ kind: 'text', content: 'Let me search.' },
{
kind: 'tool_call_request',
toolName: 'search',
arguments: { q: 'hci' },
collapsed: false,
},
],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
expect(messages).toHaveLength(1)
const msg = messages[0]
expect(msg.role).toBe('assistant')
expect(msg.content).toBe('Let me search.')
expect(msg.tool_calls).toHaveLength(1)
expect(msg.tool_calls![0].type).toBe('function')
expect(msg.tool_calls![0].function.name).toBe('search')
expect(JSON.parse(msg.tool_calls![0].function.arguments)).toEqual({ q: 'hci' })
})
it('emits tool_call_result as separate tool-role message', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'assistant',
segments: [
{
kind: 'tool_call_request',
toolName: 'search',
arguments: { q: 'hci' },
collapsed: false,
},
],
timestamp: 0,
},
{
id: '2',
role: 'assistant',
segments: [
{
kind: 'tool_call_result',
toolName: 'search',
result: 'Found 3 results.',
success: true,
collapsed: true,
},
],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
expect(messages).toHaveLength(2)
const assistantMsg = messages[0]
expect(assistantMsg.role).toBe('assistant')
expect(assistantMsg.tool_calls).toHaveLength(1)
const callId = assistantMsg.tool_calls![0].id
const toolMsg = messages[1]
expect(toolMsg.role).toBe('tool')
expect(toolMsg.tool_call_id).toBe(callId)
expect(toolMsg.content).toBe('Found 3 results.')
})
it('handles a full tool call round-trip: request + result + text', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'assistant',
segments: [
{ kind: 'text', content: 'Searching...' },
{
kind: 'tool_call_request',
toolName: 'fetch',
arguments: { url: '/api' },
collapsed: false,
},
],
timestamp: 0,
},
{
id: '2',
role: 'assistant',
segments: [
{
kind: 'tool_call_result',
toolName: 'fetch',
result: '{"ok":true}',
success: true,
collapsed: true,
},
{ kind: 'text', content: 'Got it!' },
],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
// assistant (text + tool_calls) + tool + assistant (text)
expect(messages).toHaveLength(3)
expect(messages[0].role).toBe('assistant')
expect(messages[0].content).toBe('Searching...')
expect(messages[0].tool_calls).toHaveLength(1)
expect(messages[1].role).toBe('tool')
expect(messages[1].tool_call_id).toBe(messages[0].tool_calls![0].id)
expect(messages[2].role).toBe('assistant')
expect(messages[2].content).toBe('Got it!')
expect(messages[2].tool_calls).toBeUndefined()
})
it('handles multiple tool calls with correct ID pairing', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'assistant',
segments: [
{
kind: 'tool_call_request',
toolName: 'search',
arguments: { q: 'a' },
collapsed: false,
},
{
kind: 'tool_call_request',
toolName: 'fetch',
arguments: { url: '/b' },
collapsed: false,
},
],
timestamp: 0,
},
{
id: '2',
role: 'assistant',
segments: [
{
kind: 'tool_call_result',
toolName: 'search',
result: 'A results',
success: true,
collapsed: true,
},
{
kind: 'tool_call_result',
toolName: 'fetch',
result: 'B results',
success: true,
collapsed: true,
},
],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
// assistant (2 tool_calls) + tool (search) + tool (fetch)
expect(messages).toHaveLength(3)
const assistantMsg = messages[0]
expect(assistantMsg.role).toBe('assistant')
expect(assistantMsg.tool_calls).toHaveLength(2)
const searchId = assistantMsg.tool_calls![0].id
const fetchId = assistantMsg.tool_calls![1].id
expect(messages[1].role).toBe('tool')
expect(messages[1].tool_call_id).toBe(searchId)
expect(messages[1].content).toBe('A results')
expect(messages[2].role).toBe('tool')
expect(messages[2].tool_call_id).toBe(fetchId)
expect(messages[2].content).toBe('B results')
})
it('omits tools key when no tool_overview present', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'user',
segments: [{ kind: 'text', content: 'Hi' }],
timestamp: 0,
},
],
}
const result = exportToOpenAIFormat(env)
expect(result.tools).toBeUndefined()
})
})
+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>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
import type { PromptEnvelope } from '../types/protocol'
import { demos } from '../data/demos'
interface ChatContextValue {
envelope: PromptEnvelope
setEnvelope: (e: PromptEnvelope) => void
demos: typeof demos
activeDemo: number
setActiveDemo: (i: number) => void
}
const ChatContext = createContext<ChatContextValue | null>(null)
export function ChatProvider({ children }: { children: ReactNode }) {
const [activeDemo, setActiveDemo] = useState(3) // Start with comprehensive demo
const [envelope, setEnvelope] = useState<PromptEnvelope>(demos[3].envelope)
const switchDemo = (i: number) => {
setActiveDemo(i)
setEnvelope(demos[i].envelope)
}
return (
<ChatContext.Provider
value={{
envelope,
setEnvelope,
demos,
activeDemo,
setActiveDemo: switchDemo,
}}
>
{children}
</ChatContext.Provider>
)
}
export function useChat() {
const ctx = useContext(ChatContext)
if (!ctx) throw new Error('useChat must be used within ChatProvider')
return ctx
}
+624
View File
@@ -0,0 +1,624 @@
import type { PromptEnvelope } from '../types/protocol'
export interface DemoScenario {
id: string
label: string
description: string
envelope: PromptEnvelope
}
const now = Date.now()
// ============================================================
// Scenario A: Simple chat + system prompt + memory
// ============================================================
const demoA: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: [
{
id: 'a-1',
role: 'system',
segments: [
{
kind: 'system_prompt',
content: `你是 HCI 课程设计助手。你帮助学生对聊天界面的信息架构进行批判性思考。
回答应简洁、有结构,鼓励学生从用户体验角度分析问题。
如果学生对某个概念不清楚,用通俗的例子解释,不要用术语堆砌。`,
collapsed: true,
},
{
kind: 'memory',
items: [
{
title: '用户背景',
content: '设计系研二学生,正在做 HCI 课程设计,关注 LLM 交互的透明性',
},
{
title: '偏好',
content: '喜欢简洁的表达,反感术语堆砌。倾向用图示辅助理解。',
},
{
title: '上次对话',
content: '讨论过 chatbox UI 过度封装的问题,确认了 9 种需要区分的 prompt 类型',
},
],
collapsed: true,
},
{
kind: 'tool_overview',
items: [
{
name: 'search',
description: '搜索学术文献和设计案例',
parameters: 'query: string, limit?: number',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词' },
limit: { type: 'number', description: '返回结果数量上限' },
},
required: ['query'],
},
},
{
name: 'get_example',
description: '获取 UI 设计案例截图和说明',
parameters: 'topic: string, count?: number',
schema: {
type: 'object',
properties: {
topic: { type: 'string', description: '设计主题' },
count: { type: 'number', description: '案例数量' },
},
required: ['topic'],
},
},
],
collapsed: true,
},
],
timestamp: now - 300000,
},
{
id: 'a-2',
role: 'user',
segments: [
{
kind: 'static_var',
name: 'user_name',
value: '小明',
},
{
kind: 'text',
content: '你好,我想讨论一下我设计的聊天协议方案。你觉得 9 种 prompt 类型的分类合理吗?',
},
],
timestamp: now - 240000,
},
{
id: 'a-3',
role: 'assistant',
segments: [
{
kind: 'text',
content:
'我觉得你的 9 种分类很全面!从信息架构的角度看,你已经覆盖了 LLM 上下文的三个主要来源:\n\n1. **静态配置**(变量、system prompt\n2. **能力声明**skills、tools\n3. **动态输入**(文档、长文本、多模态)\n\n加上工具调用的过程性信息,形成了一个完整的上下文可见性模型。这种分类本身就值得在课程设计中展开讨论。',
},
],
timestamp: now - 180000,
},
],
}
// ============================================================
// Scenario B: Tool call flow (request → result)
// ============================================================
const demoB: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: [
{
id: 'b-1',
role: 'system',
segments: [
{
kind: 'system_prompt',
content:
'你是一个数据分析助手,可以使用 Python 工具进行数据查询和可视化。',
collapsed: true,
},
{
kind: 'tool_overview',
items: [
{
name: 'run_query',
description: '执行 SQL 查询',
parameters: 'sql: string',
schema: {
type: 'object',
properties: {
sql: { type: 'string', description: 'SQL 查询语句' },
},
required: ['sql'],
},
},
{
name: 'plot_chart',
description: '生成图表',
parameters: 'type: string, data: object, output_format?: string',
schema: {
type: 'object',
properties: {
type: { type: 'string', enum: ['line', 'bar', 'scatter'], description: '图表类型' },
title: { type: 'string', description: '图表标题' },
x_label: { type: 'string', description: 'X 轴标签' },
y_label: { type: 'string', description: 'Y 轴标签' },
data: { type: 'array', description: '数据点数组' },
output_format: { type: 'string', enum: ['png', 'svg'], description: '输出格式' },
},
required: ['type', 'data', 'output_format'],
},
},
{
name: 'fetch_data',
description: '获取远程数据',
parameters: 'url: string, format: string',
schema: {
type: 'object',
properties: {
url: { type: 'string', description: '数据 URL' },
format: { type: 'string', enum: ['json', 'csv'], description: '数据格式' },
},
required: ['url', 'format'],
},
},
],
collapsed: true,
},
],
timestamp: now - 200000,
},
{
id: 'b-2',
role: 'user',
segments: [
{
kind: 'text',
content: '帮我看看过去一周的用户注册数据,按天统计,用折线图展示趋势。',
},
],
timestamp: now - 180000,
},
{
id: 'b-3',
role: 'assistant',
segments: [
{
kind: 'text',
content: '好的,我先查询数据库获取注册数据。',
},
{
kind: 'tool_call_request',
toolName: 'run_query',
arguments: {
sql: `SELECT DATE(created_at) as date, COUNT(*) as count
FROM users
WHERE created_at >= DATE('now', '-7 days')
GROUP BY DATE(created_at)
ORDER BY date`,
},
collapsed: false,
},
],
timestamp: now - 160000,
},
{
id: 'b-4',
role: 'assistant',
segments: [
{
kind: 'tool_call_result',
toolName: 'run_query',
success: true,
result: `[
{"date": "2026-06-01", "count": 42},
{"date": "2026-06-02", "count": 38},
{"date": "2026-06-03", "count": 55},
{"date": "2026-06-04", "count": 47},
{"date": "2026-06-05", "count": 61},
{"date": "2026-06-06", "count": 73},
{"date": "2026-06-07", "count": 58}
]`,
collapsed: true,
},
],
timestamp: now - 140000,
},
{
id: 'b-5',
role: 'assistant',
segments: [
{
kind: 'text',
content: '查询成功。现在生成折线图——',
},
{
kind: 'tool_call_request',
toolName: 'plot_chart',
arguments: {
type: 'line',
title: 'Daily User Registrations (Past 7 Days)',
x_label: 'Date',
y_label: 'New Users',
data: [
{ date: '06-01', count: 42 },
{ date: '06-02', count: 38 },
{ date: '06-03', count: 55 },
{ date: '06-04', count: 47 },
{ date: '06-05', count: 61 },
{ date: '06-06', count: 73 },
{ date: '06-07', count: 58 },
],
},
collapsed: false,
},
],
timestamp: now - 130000,
},
{
id: 'b-6',
role: 'assistant',
segments: [
{
kind: 'tool_call_result',
toolName: 'plot_chart',
success: false,
result: 'Error: plot_chart requires "output_format" parameter (png | svg). Please retry with format specified.',
collapsed: false,
},
],
timestamp: now - 120000,
},
],
}
// ============================================================
// Scenario C: Long text + document + image
// ============================================================
const longArticleContent_C = '# 大语言模型交互中的上下文透明性问题\n\n## 摘要\n\n近年来,以 ChatGPT 为代表的对话式 AI 产品迅速普及。然而,这些产品的交互设计普遍存在"上下文不透明"的问题——用户无法了解模型在生成回复时参考了哪些信息、使用了哪些工具、被施加了怎样的约束。\n\n## 1. 问题定义\n\n当前的聊天界面(chatbox UI)将 LLM 的完整上下文封装在黑盒之中。用户看到的是一个"魔法对话框":输入问题,得到回答。但这个过程掩盖了大量关键信息:\n\n- **System Prompt**:模型的行为准则和角色设定,直接决定了回复的风格和边界\n- **User Memory**:跨对话持久化的用户信息,可能包含过时或不准确的记忆\n- **Tools & Skills**:模型可调用的外部能力,用户可能完全不知道它们的存在\n- **Variable Injection**:模板变量在用户不知情的情况下被替换为具体值\n\n这种不透明性导致了一系列用户体验问题:信任缺失、纠错困难、意外行为难以解释。\n\n## 2. 设计目标\n\n本文提出一套"Prompt Envelope Protocol"——一种结构化的 prompt 表达格式,使得:\n\n1. 每种上下文元素都有明确的视觉呈现\n2. 用户可以按需展开或折叠细节\n3. 协议可以无损导出为标准 API 格式\n4. 视觉语言保持简约,不增加认知负荷\n\n## 3. 核心设计原则\n\n### 3.1 信息密度梯度\n\n核心对话文本始终可见,元信息默认折叠。用户不会在每次对话中都被无关的 system prompt 和工具列表淹没。\n\n### 3.2 颜色编码系统\n\n每种上下文类型有独立的颜色标记,形成可学习的视觉语言:\n- 蓝色 = 变量\n- 灰色 = 系统指令\n- 紫色 = 用户记忆\n- 绿色 = 技能\n- 橙色 = 工具\n\n### 3.3 协议即视图\n\n折叠状态存储在协议数据中而非 UI 状态中。这意味着相同的协议数据在任何渲染器下都产生相同的视图——协议本身就是 view model。'
const demoC: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: [
{
id: 'c-1',
role: 'system',
segments: [
{
kind: 'system_prompt',
content:
'你是文档审阅助手。帮助用户分析长文档、提取要点、回答关于文档内容的问题。',
collapsed: true,
},
{
kind: 'skills',
items: [
{ name: 'summarize', description: '生成文档摘要' },
{ name: 'translate', description: '翻译文档内容' },
{ name: 'qa', description: '基于文档回答具体问题' },
],
collapsed: true,
},
],
timestamp: now - 150000,
},
{
id: 'c-2',
role: 'user',
segments: [
{
kind: 'text',
content: '帮我审阅这篇论文草稿,看看结构上有什么问题:',
},
{
kind: 'long_text',
content: longArticleContent_C,
charCount: longArticleContent_C.length,
collapsed: true,
},
{
kind: 'document',
fileName: 'hci-paper-draft.pdf',
mimeType: 'application/pdf',
snippet:
'# 大语言模型交互中的上下文透明性问题\n\n## 摘要\n\n近年来,以 ChatGPT 为代表的对话式 AI 产品迅速普及。然而,这些产品的交互设计普遍存在"上下文不透明"的问题——用户无法了解模型在生成回复时参考了哪些信息...',
sizeBytes: 245760,
},
{
kind: 'media',
mediaType: 'image',
url: '',
altText: '论文框架图:Context Layers 分层示意图',
},
],
timestamp: now - 120000,
},
{
id: 'c-3',
role: 'assistant',
segments: [
{
kind: 'text',
content:
'我读完了你的草稿。整体框架很清晰,但有几个建议:\n\n1. **第3节的设计原则**是目前最强的部分,可以考虑将这部分提前到第2节\n2. "信息密度梯度"这个概念很有原创性,建议在摘要中就提及\n3. 缺少用户研究部分——如果你有做可用性测试的计划,可以加一节"4. 评估方法"\n4. 颜色编码系统部分写得很好,可以加一个视觉化的表格来展示颜色-类型映射\n\n需要我展开其中某一点吗?',
},
],
timestamp: now - 60000,
},
],
}
// ============================================================
// Scenario D: Comprehensive — all 9 segments
// ============================================================
const comprehensiveArticleContent = '在过去的18个月里,我们对127名LLM聊天产品用户进行了纵向研究。研究分为三个阶段:基线观察期(3个月)、干预实验期(6个月)、后续跟踪期(9个月)。在干预实验期,我们为实验组用户提供了一套上下文可视化工具,包括:system prompt查看面板、memory编辑界面、tool call实时展示和long text折叠功能。对照组使用标准的聊天界面。\n\n实验组在以下维度上表现出显著改善:\n- 任务完成率提升 23.4%p < 0.01\n- 纠错响应时间缩短 41.7%p < 0.001\n- 用户信任度评分从 3.2/5 提升至 4.1/5\n- 对话轮次平均减少 2.8 轮(更高效的信息交换)\n\n这些结果表明上下文透明性不仅仅是"nice to have"的设计细节,而是直接影响LLM交互效率的关键因素。特别是在以下场景中效果最为显著:\n1. 长文档分析:用户能够看到哪些文档片段被模型引用\n2. 多工具调用:工具链的可视化帮助用户理解推理过程\n3. 跨会话任务:memory可见性减少重复说明\n\n我们建议将上下文透明性作为LLM聊天产品的基础设计原则,而非可选特性。'
const demoD: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: [
// --- System message with all structural segments ---
{
id: 'd-1',
role: 'system',
segments: [
{
kind: 'system_prompt',
content: `你是 Claude,一个 HCI 研究助手。你的角色是帮助学生批判性地思考聊天界面的设计问题。
核心原则:
- 鼓励从用户体验角度分析,而非技术实现角度
- 用具体例子说明抽象概念
- 如果学生的方案有改进空间,以提问的方式引导而非直接批评
- 始终记住你拥有工具调用、skills 和跨对话 memory 能力,但不必每次都全部用到`,
collapsed: true,
},
{
kind: 'memory',
items: [
{
title: '用户身份',
content: '小明,设计系研二,HCI 方向。正在做课程设计项目。',
},
{
title: '项目背景',
content: '设计一个透明化 LLM 上下文的聊天协议。已确定了 9 种 prompt 类型的分类方案。',
},
{
title: '沟通偏好',
content: '喜欢用图示和表格辅助理解。反感过度术语化。需要看到具体例子。',
},
{
title: '上次进度',
content: '用户已确认了 MVP 范围:Web 应用,数据协议+视觉规范,可导出 OpenAI Format。',
},
],
collapsed: true,
},
{
kind: 'skills',
items: [
{ name: 'deep-research', description: '深度研究 — 多源搜索、交叉验证、生成引用报告' },
{ name: 'code-review', description: '审查代码变更,发现正确性问题和简化机会' },
{ name: 'simplify', description: '审查代码的复用性、简洁性和效率,并应用修复' },
{ name: 'verify', description: '运行应用并观察行为来验证变更是否正确' },
],
collapsed: true,
},
{
kind: 'tool_overview',
items: [
{
name: 'search',
description: '搜索学术文献和设计案例',
parameters: 'query: string, limit?: number',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词' },
limit: { type: 'number', description: '返回结果数量' },
},
required: ['query'],
},
},
{
name: 'read_file',
description: '读取文件内容',
parameters: 'path: string',
schema: {
type: 'object',
properties: {
path: { type: 'string', description: '文件路径' },
},
required: ['path'],
},
},
{
name: 'fetch_url',
description: '获取网页内容并转为 markdown',
parameters: 'url: string',
schema: {
type: 'object',
properties: {
url: { type: 'string', description: '网页 URL' },
},
required: ['url'],
},
},
{
name: 'run_code',
description: '在沙箱中执行代码',
parameters: 'language: string, code: string',
schema: {
type: 'object',
properties: {
language: { type: 'string', enum: ['python', 'javascript', 'r'], description: '编程语言' },
code: { type: 'string', description: '代码内容' },
},
required: ['language', 'code'],
},
},
],
collapsed: true,
},
],
timestamp: now - 600000,
},
// --- User message 1 ---
{
id: 'd-2',
role: 'user',
segments: [
{
kind: 'static_var',
name: 'user_name',
value: '小明',
},
{
kind: 'text',
content: '你好!我在准备课程设计的文献综述部分。我找到了一篇相关的研究报告,帮我分析一下它是否可以支持我的论点。',
},
{
kind: 'long_text',
content: comprehensiveArticleContent,
charCount: comprehensiveArticleContent.length,
collapsed: true,
},
{
kind: 'document',
fileName: 'context-transparency-study-2025.pdf',
mimeType: 'application/pdf',
snippet:
'DOI: 10.1145/3613904.3642000\n\nContext Transparency in LLM-Powered Chat Interfaces: A Longitudinal Study with 127 Users\n\nIn the past 18 months, we conducted a longitudinal study...',
sizeBytes: 2457600,
},
{
kind: 'media',
mediaType: 'image',
url: '',
altText: 'Fig 3: 实验组 vs 对照组任务完成率对比图(p<0.01',
},
],
timestamp: now - 500000,
},
// --- Assistant response ---
{
id: 'd-3',
role: 'assistant',
segments: [
{
kind: 'text',
content: '这是一篇非常有力的参考文献!它恰恰支持了你的核心论点——上下文透明性是"基础设计原则,而非可选特性"。',
},
],
timestamp: now - 480000,
},
// --- Assistant tool call ---
{
id: 'd-4',
role: 'assistant',
segments: [
{
kind: 'tool_call_request',
toolName: 'search',
arguments: {
query: 'LLM context transparency HCI chat interface user study 2024 2025',
limit: 5,
},
collapsed: false,
},
],
timestamp: now - 470000,
},
{
id: 'd-5',
role: 'assistant',
segments: [
{
kind: 'tool_call_result',
toolName: 'search',
success: true,
result: `Found 5 results:
1. "Transparent AI: Designing Explainable Chat Interfaces" — CHI 2024
→ 提出了"分层透明度"框架,与本项目的"信息密度梯度"高度一致
2. "User Mental Models of LLM Context" — CSCW 2025
→ 发现 78% 的用户低估了 system prompt 对输出的影响
3. "Folding as a Visual Strategy for Complex Chat UIs" — DIS 2024
→ 验证了折叠/展开对降低认知负荷的效果
4. "Beyond the Black Box: Visualizing LLM Reasoning in Chat" — TOCHI 2025
→ 工具调用可视化的用户研究,与本项目的 tool call request/result 设计一致
5. "Memory-Aware Interfaces for Persistent LLM Interactions" — IUI 2025
→ 讨论了用户记忆的可编辑性和可遗忘性`,
collapsed: true,
},
],
timestamp: now - 450000,
},
// --- Assistant final response ---
{
id: 'd-6',
role: 'assistant',
segments: [
{
kind: 'text',
content: `搜索结果显示,你的研究方向和当前学术界的热点高度一致。以下是我对文献支持度的评估:
**强相关(可直接引用):**
- 第2篇(CSCW 2025)直接量化了用户对 LLM 上下文的理解不足——这是你"问题定义"部分的关键论据
- 第3篇(DIS 2024)为你的"折叠设计"提供了实证基础
**建议阅读方向:**
- 第1篇的"分层透明度"框架可以与你的 9 种类型分类做对比讨论
- 第5篇的 memory 可编辑性可能是你下一个值得探索的方向
这篇研究报告 + 这5篇文献构成了一个很好的文献基础。需要我帮你整理成引用格式吗?`,
},
],
timestamp: now - 400000,
},
],
}
export const demos: DemoScenario[] = [
{
id: 'a',
label: '场景 A',
description: '基础对话 + System Prompt + Memory',
envelope: demoA,
},
{
id: 'b',
label: '场景 B',
description: '工具调用:请求 → 执行(成功 & 失败)',
envelope: demoB,
},
{
id: 'c',
label: '场景 C',
description: '长文本 + 文档附件 + 多模态',
envelope: demoC,
},
{
id: 'd',
label: '场景 D ⭐',
description: '综合:全部 9 种 Segment',
envelope: demoD,
},
]
+22
View File
@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Smooth collapse animation */
.collapsible-content {
overflow: hidden;
transition: max-height 0.25s ease-in-out, opacity 0.2s ease;
}
.collapsible-content.collapsed {
max-height: 0;
opacity: 0;
}
.collapsible-content.expanded {
max-height: 2000px;
opacity: 1;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+123
View File
@@ -0,0 +1,123 @@
// ============================================================
// Prompt Envelope Protocol — Type Definitions
// ============================================================
// --- 顶层信封 ---
export interface PromptEnvelope {
version: '1.0'
model?: string // 导出时使用的 model 名称,如 'gpt-4-turbo'
messages: Message[]
}
// --- 单条消息 ---
export interface Message {
id: string
role: 'system' | 'user' | 'assistant'
segments: Segment[]
timestamp: number
}
// --- Segment 联合类型 ---
export type Segment =
| TextSegment
| StaticVarSegment
| SystemPromptSegment
| MemorySegment
| SkillSegment
| ToolOverviewSegment
| ToolCallRequestSegment
| ToolCallResultSegment
| DocumentSegment
| LongTextSegment
| MediaSegment
// --- 各 Segment 类型 ---
export interface TextSegment {
kind: 'text'
content: string
}
export interface StaticVarSegment {
kind: 'static_var'
name: string // e.g. "user_name"
value: string // e.g. "张三"
}
export interface SystemPromptSegment {
kind: 'system_prompt'
content: string
collapsed: boolean // default: true
}
export interface MemorySegment {
kind: 'memory'
items: MemoryItem[]
collapsed: boolean
}
export interface MemoryItem {
title: string
content: string
}
export interface SkillSegment {
kind: 'skills'
items: SkillItem[]
collapsed: boolean
}
export interface SkillItem {
name: string
description: string
}
export interface ToolOverviewSegment {
kind: 'tool_overview'
items: ToolItem[]
collapsed: boolean
}
export interface ToolItem {
name: string
description: string
parameters: string // 人类可读的参数摘要
schema?: Record<string, unknown> // JSON Schema — 导出时作为 tools[].function.parameters
}
export interface ToolCallRequestSegment {
kind: 'tool_call_request'
toolName: string
arguments: Record<string, unknown>
collapsed: boolean // default: false
}
export interface ToolCallResultSegment {
kind: 'tool_call_result'
toolName: string
result: string // 摘要文本
success: boolean
collapsed: boolean // default: true
}
export interface DocumentSegment {
kind: 'document'
fileName: string
mimeType: string
snippet: string // 前 200 字符预览
sizeBytes: number
}
export interface LongTextSegment {
kind: 'long_text'
content: string
charCount: number
collapsed: boolean // default: true
}
export interface MediaSegment {
kind: 'media'
mediaType: 'image' | 'audio' | 'video'
url: string
altText?: string
}
+249
View File
@@ -0,0 +1,249 @@
import type { PromptEnvelope, Segment, ToolItem } from '../types/protocol'
// --- OpenAI Chat Completions API request shape ---
export interface OpenAIToolCall {
id: string
type: 'function'
function: {
name: string
arguments: string // JSON-encoded
}
}
export interface OpenAIMessage {
role: 'system' | 'user' | 'assistant' | 'tool'
content?: string | null
tool_calls?: OpenAIToolCall[]
tool_call_id?: string
}
export interface OpenAITool {
type: 'function'
function: {
name: string
description: string
parameters: Record<string, unknown> // JSON Schema
}
}
/**
* The full OpenAI Chat Completions request body
* (minus stream, temperature etc. which are out of scope for MVP).
*/
export interface OpenAIExport {
model: string
messages: OpenAIMessage[]
tools?: OpenAITool[]
}
// --- Helpers ---
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`
}
/**
* Convert a "text-like" segment to its plain-text representation.
* Returns null for structural segments (system/memory/skills/tool_overview)
* and for tool_call segments (handled structurally in exportToOpenAIFormat).
*/
export function segmentToText(seg: Segment): string | null {
switch (seg.kind) {
case 'text':
return seg.content
case 'static_var':
return seg.value
case 'system_prompt':
case 'memory':
case 'skills':
case 'tool_overview':
return null
case 'tool_call_request':
case 'tool_call_result':
return null
case 'document':
return `[Document: ${seg.fileName} (${formatBytes(seg.sizeBytes)})]\n${seg.snippet}`
case 'long_text':
return seg.content
case 'media':
return seg.altText ?? `[${seg.mediaType}]`
default:
return null
}
}
/** Render a structural segment into text for the system message */
function formatStructural(seg: Segment): string | null {
switch (seg.kind) {
case 'system_prompt':
return `[System Prompt]\n${seg.content}`
case 'memory':
return `[Memory]\n${seg.items.map(i => `- ${i.title}: ${i.content}`).join('\n')}`
case 'skills':
return `[Skills]\n${seg.items.map(i => `- /${i.name}: ${i.description}`).join('\n')}`
case 'tool_overview':
return `[Available Tools]\n${seg.items.map(i => `- ${i.name}: ${i.description}`).join('\n')}`
default:
return null
}
}
/**
* Convert a ToolItem (from tool_overview) into an OpenAI Tool definition.
*/
function toolItemToOpenAI(item: ToolItem): OpenAITool {
return {
type: 'function',
function: {
name: item.name,
description: item.description,
parameters: (item.schema as Record<string, unknown>) ?? {
type: 'object',
properties: {},
},
},
}
}
// --- Main export function ---
/**
* Export a PromptEnvelope to an OpenAI Chat Completions request body.
*
* Mapping:
*
* Protocol Segment → OpenAI Representation
* ───────────────────────────────────────────────────────
* text → message.content
* static_var → expanded value in content
* system_prompt → system message content
* memory → system message content
* skills → system message content
* tool_overview → top-level tools[] array
* tool_call_request → assistant message with tool_calls[]
* tool_call_result → tool-role message with tool_call_id
* document → content with [Document: …] annotation
* long_text → content (full text)
* media → content with altText / [type] placeholder
*/
export function exportToOpenAIFormat(envelope: PromptEnvelope): OpenAIExport {
const messages: OpenAIMessage[] = []
const systemParts: string[] = []
const tools: OpenAITool[] = []
let callCounter = 0
const nextCallId = () => `call_${String(++callCounter).padStart(4, '0')}`
const pendingCallIds: Map<string, string[]> = new Map()
for (const msg of envelope.messages) {
// ── System ──
if (msg.role === 'system') {
const contentParts: string[] = []
for (const seg of msg.segments) {
if (seg.kind === 'tool_overview') {
// tool_overview → top-level tools array
seg.items.forEach(item => tools.push(toolItemToOpenAI(item)))
// Also add a text summary to the system message
const summary = formatStructural(seg)
if (summary) contentParts.push(summary)
} else {
const t = segmentToText(seg)
if (t !== null) contentParts.push(t)
const s = formatStructural(seg)
if (s && !t) contentParts.push(s)
}
}
const content = contentParts.filter(Boolean).join('\n')
if (content.trim()) {
systemParts.push(content)
}
continue
}
// ── User ──
if (msg.role === 'user') {
for (const seg of msg.segments) {
if (seg.kind === 'tool_overview') {
seg.items.forEach(item => tools.push(toolItemToOpenAI(item)))
}
const s = formatStructural(seg)
if (s) systemParts.push(s)
}
const content = msg.segments
.map(segmentToText)
.filter(Boolean)
.join('\n')
if (content.trim()) {
messages.push({ role: 'user', content })
}
continue
}
// ── Assistant ──
if (msg.role === 'assistant') {
const textParts: string[] = []
const toolCalls: OpenAIToolCall[] = []
for (const seg of msg.segments) {
if (seg.kind === 'tool_call_request') {
const callId = nextCallId()
const queue = pendingCallIds.get(seg.toolName) || []
queue.push(callId)
pendingCallIds.set(seg.toolName, queue)
toolCalls.push({
id: callId,
type: 'function',
function: {
name: seg.toolName,
arguments: JSON.stringify(seg.arguments),
},
})
} else if (seg.kind === 'tool_call_result') {
const queue = pendingCallIds.get(seg.toolName)
const callId = queue?.shift() || nextCallId()
messages.push({
role: 'tool',
tool_call_id: callId,
content: seg.result,
})
} else if (seg.kind === 'tool_overview') {
seg.items.forEach(item => tools.push(toolItemToOpenAI(item)))
} else {
const t = segmentToText(seg)
if (t) textParts.push(t)
}
}
if (textParts.length > 0 || toolCalls.length > 0) {
messages.push({
role: 'assistant',
content: textParts.length > 0 ? textParts.join('\n') : null,
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
})
}
}
}
// Prepend merged system message
if (systemParts.length > 0) {
messages.unshift({ role: 'system', content: systemParts.join('\n\n') })
}
return {
model: envelope.model ?? 'gpt-4-turbo',
messages,
...(tools.length > 0 ? { tools } : {}),
}
}