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,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>HCI Chatbox — Context Transparency</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3522
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "hci-chatbox",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.400.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.3.4",
|
||||||
|
"vitest": "^1.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
+66
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user