refactor: 静态变量提到对话外 + System Prompt 模板展开可视化

- 新建 SessionBar:会话变量独立于消息气泡,显示在对话顶部
- 重写 SystemPromptView:解析 {{var}} 占位符并内联展示模板→变量映射
- 重构 MessageList:提取 static_var 到 varMap,过滤后传入气泡
- 更新 SegmentRenderer + MessageBubble:传递 varMap 到 SystemPromptView
- 更新所有 Demo:static_var 从 user 消息迁移到 system 消息,使用真实会话配置(current_date、language、knowledge_cutoff)
- 更新导出逻辑:system 消息中收集 static_var 并在模板中展开 {{var}}
- 更新测试:新增模板展开用例,18 tests pass
This commit is contained in:
carry
2026-06-07 14:44:29 +08:00
parent 483b1a7f39
commit 92ecb139ad
10 changed files with 359 additions and 49 deletions
+56 -2
View File
@@ -233,8 +233,62 @@ describe('exportToOpenAIFormat', () => {
],
}
const { messages } = exportToOpenAIFormat(env)
expect(messages[0].content).toContain('小明')
expect(messages[0].content).toContain('says hello')
// static_var 被提取到 system 消息中作为配置行
expect(messages[0].role).toBe('system')
expect(messages[0].content).toContain('user_name = 小明')
// 用户消息中 static_var 展开为值
expect(messages[1].role).toBe('user')
expect(messages[1].content).toContain('小明')
expect(messages[1].content).toContain('says hello')
})
it('expands static_vars in system_prompt template via {{var}} substitution', () => {
const env: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: [
{
id: '0',
role: 'system',
segments: [
{
kind: 'static_var',
name: 'current_date',
value: '2026年6月7日',
},
{
kind: 'static_var',
name: 'language',
value: '中文',
},
{
kind: 'system_prompt',
content: '今天是 {{current_date}}。请用 {{language}} 回复。',
collapsed: true,
},
],
timestamp: 0,
},
{
id: '1',
role: 'user',
segments: [{ kind: 'text', content: '你好' }],
timestamp: 0,
},
],
}
const result = exportToOpenAIFormat(env)
expect(result.messages).toHaveLength(2)
const sysMsg = result.messages[0]
expect(sysMsg.role).toBe('system')
// {{var}} 模板应被展开为实际值
expect(sysMsg.content).toContain('current_date = 2026年6月7日')
expect(sysMsg.content).toContain('language = 中文')
expect(sysMsg.content).toContain('今天是 2026年6月7日。')
expect(sysMsg.content).toContain('请用 中文 回复。')
// 不应残留原始模板占位符
expect(sysMsg.content).not.toContain('{{current_date}}')
expect(sysMsg.content).not.toContain('{{language}}')
})
it('emits tool_call_request as assistant message with tool_calls', () => {
+4 -2
View File
@@ -10,9 +10,11 @@ const roleConfig = {
interface MessageBubbleProps {
message: Message
/** 会话级变量映射表,用于 system_prompt 中的 {{var}} 模板展开展示 */
varMap?: Record<string, string>
}
export default function MessageBubble({ message }: MessageBubbleProps) {
export default function MessageBubble({ message, varMap = {} }: MessageBubbleProps) {
const cfg = roleConfig[message.role]
const Icon = cfg.icon
const isUser = message.role === 'user'
@@ -33,7 +35,7 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
{/* Segments */}
<div className="space-y-0.5">
{message.segments.map((seg, i) => (
<SegmentRenderer key={i} segment={seg} />
<SegmentRenderer key={i} segment={seg} varMap={varMap} />
))}
</div>
+51 -4
View File
@@ -1,12 +1,53 @@
import type { Message } from '../types/protocol'
import { useMemo } from 'react'
import type { Message, StaticVarSegment } from '../types/protocol'
import SessionBar from './SessionBar'
import MessageBubble from './MessageBubble'
interface MessageListProps {
messages: Message[]
}
/**
* 从所有 system 消息中提取 static_var 片段,
* 构建会话变量映射表并从消息体中移除这些片段。
*/
function extractSessionVars(messages: Message[]): {
variables: StaticVarSegment[]
varMap: Record<string, string>
cleanedMessages: Message[]
} {
const variables: StaticVarSegment[] = []
const varMap: Record<string, string> = {}
const cleanedMessages = messages.map((msg) => {
const staticVars: StaticVarSegment[] = []
const remaining = msg.segments.filter((seg) => {
if (seg.kind === 'static_var') {
staticVars.push(seg)
return false // 从消息体中移除
}
return true
})
// 收集变量
for (const v of staticVars) {
variables.push(v)
varMap[v.name] = v.value
}
return { ...msg, segments: remaining }
})
return { variables, varMap, cleanedMessages }
}
export default function MessageList({ messages }: MessageListProps) {
if (messages.length === 0) {
const { variables, varMap, cleanedMessages } = useMemo(
() => extractSessionVars(messages),
[messages]
)
if (cleanedMessages.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-gray-300 text-sm">
Demo
@@ -15,10 +56,16 @@ export default function MessageList({ messages }: MessageListProps) {
}
return (
<div className="flex-1 flex flex-col min-h-0">
{/* 会话变量横栏 —— 在对话气泡之外 */}
<SessionBar variables={variables} />
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
{cleanedMessages.map((msg) => (
<MessageBubble key={msg.id} message={msg} varMap={varMap} />
))}
</div>
</div>
)
}
+8 -4
View File
@@ -1,6 +1,5 @@
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'
@@ -13,19 +12,24 @@ import MediaView from './segments/MediaView'
interface SegmentRendererProps {
segment: Segment
/** 会话级变量映射表,传递给 SystemPromptView 用于展示 {{var}} 模板渲染结果 */
varMap?: Record<string, string>
}
/**
* Route a Segment to its correct view component based on `kind`.
* static_var 不在此处渲染 —— 已被 MessageList 提取到 SessionBar 中。
*/
export default function SegmentRenderer({ segment }: SegmentRendererProps) {
export default function SegmentRenderer({ segment, varMap = {} }: SegmentRendererProps) {
switch (segment.kind) {
case 'text':
return <TextSegmentView segment={segment} />
case 'static_var':
return <StaticVarBadge segment={segment} />
// static_var 已由 MessageList 提取到对话外部的 SessionBar 中,
// 不作为气泡内元素渲染。若此处仍有残留,静默跳过。
return null
case 'system_prompt':
return <SystemPromptView segment={segment} />
return <SystemPromptView segment={segment} varMap={varMap} />
case 'memory':
return <MemoryView segment={segment} />
case 'skills':
+39
View File
@@ -0,0 +1,39 @@
import type { StaticVarSegment } from '../types/protocol'
import { Sliders } from 'lucide-react'
interface SessionBarProps {
variables: StaticVarSegment[]
}
/**
* 对话区顶部的会话变量横栏 —— 会话级别的配置变量,独立于任何消息。
* 这些变量在对话开始时被注入到 System Prompt 模板中。
*/
export default function SessionBar({ variables }: SessionBarProps) {
if (variables.length === 0) return null
return (
<div className="shrink-0 border-b border-gray-200 bg-gradient-to-r from-blue-50/60 to-white px-4 py-2">
<div className="flex items-center gap-2 flex-wrap">
{/* 标题 */}
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-blue-400 uppercase tracking-wider mr-1">
<Sliders size={12} />
</span>
{/* 变量列表 */}
{variables.map((v, i) => (
<span
key={i}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-white border border-blue-200 text-[11px] font-mono shadow-sm"
title={v.description}
>
<span className="text-blue-400">{'{{'}{v.name}{'}}'}</span>
<span className="text-gray-300"></span>
<span className="text-blue-700 font-medium">{v.value}</span>
</span>
))}
</div>
</div>
)
}
+11 -5
View File
@@ -1,13 +1,19 @@
import type { StaticVarSegment } from '../../types/protocol'
import { Variable } from 'lucide-react'
import { Sliders } from 'lucide-react'
/**
* 会话级静态变量 —— 这些变量在对话开始时被注入到 System Prompt 模板中展开。
* 例如:{{current_date}} → 2026年6月7日,{{language}} → 中文
*
* 视觉上呈现为紧凑的配置项卡片,一行展示所有会话级变量。
*/
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="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-blue-50 border border-blue-200 text-[11px] font-mono">
<Sliders size={11} className="text-blue-400 shrink-0" />
<span className="text-blue-400">{'{{'}{segment.name}{'}}'}</span>
<span className="text-blue-300"></span>
<span className="font-semibold">{segment.value}</span>
<span className="text-blue-300 mx-0.5">=</span>
<span className="text-blue-700 font-medium">{segment.value}</span>
</span>
)
}
+50 -4
View File
@@ -1,9 +1,55 @@
import type { SystemPromptSegment } from '../../types/protocol'
import type { SystemPromptSegment, StaticVarSegment } from '../../types/protocol'
import CollapsiblePanel from '../CollapsiblePanel'
import { Bot } from 'lucide-react'
export default function SystemPromptView({ segment }: { segment: SystemPromptSegment }) {
interface SystemPromptViewProps {
segment: SystemPromptSegment
varMap?: Record<string, string> // 来自会话变量的 name→value 映射
}
/**
* 将一个带 {{var}} 模板占位符的字符串解析为混合内容片段。
* 普通文本渲染为纯文本,{{var}} 渲染为带解析值的内联标签。
*/
function renderTemplate(content: string, varMap: Record<string, string>) {
// 按 {{...}} 分割,捕获分隔符
const parts = content.split(/(\{\{[^}]+\}\})/g)
return parts.map((part, i) => {
const match = part.match(/^\{\{([^}]+)\}\}$/)
if (!match) {
// 普通文本
return <span key={i}>{part}</span>
}
const varName = match[1].trim()
const resolved = varMap[varName]
return (
<span
key={i}
className="inline-flex items-baseline gap-0.5 px-1 rounded bg-blue-100/70 border border-blue-200 text-[11px] font-mono align-baseline"
title={resolved ? `模板变量 {{${varName}}} 已展开为 "${resolved}"` : `模板变量 {{${varName}}} —— 未找到对应值`}
>
<span className="text-blue-400">{'{{'}{varName}{'}}'}</span>
{resolved && (
<>
<span className="text-blue-300 text-[9px]"></span>
<span className="text-blue-700 font-semibold">{resolved}</span>
</>
)}
{!resolved && (
<span className="text-red-400 text-[9px] italic"></span>
)}
</span>
)
})
}
export default function SystemPromptView({ segment, varMap = {} }: SystemPromptViewProps) {
const lineCount = segment.content.split('\n').length
const hasVars = /\{\{[^}]+\}\}/.test(segment.content)
return (
<CollapsiblePanel
title="System Prompt"
@@ -11,10 +57,10 @@ export default function SystemPromptView({ segment }: { segment: SystemPromptSeg
color="border-gray-400 text-gray-600"
bgColor="bg-gray-50"
defaultCollapsed={segment.collapsed}
badge={`${lineCount}`}
badge={`${lineCount}${hasVars ? ' · 模板' : ''}`}
>
<pre className="text-xs text-gray-600 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
{segment.content}
{renderTemplate(segment.content, varMap)}
</pre>
</CollapsiblePanel>
)
+106 -22
View File
@@ -20,9 +20,31 @@ const demoA: PromptEnvelope = {
id: 'a-1',
role: 'system',
segments: [
{
kind: 'static_var',
name: 'current_date',
value: '2026年6月7日',
description: '当前对话日期,注入到 System Prompt 模板中',
},
{
kind: 'static_var',
name: 'language',
value: '中文(简体)',
description: '模型回复的首选语言',
},
{
kind: 'static_var',
name: 'user_name',
value: '小明',
description: '当前用户名称',
},
{
kind: 'system_prompt',
content: `你是 HCI 课程设计助手。你帮助学生对聊天界面的信息架构进行批判性思考。
content: `当前日期:{{current_date}}
用户名称:{{user_name}}
回复语言:{{language}}
你是 HCI 课程设计助手。你帮助学生对聊天界面的信息架构进行批判性思考。
回答应简洁、有结构,鼓励学生从用户体验角度分析问题。
如果学生对某个概念不清楚,用通俗的例子解释,不要用术语堆砌。`,
collapsed: true,
@@ -86,11 +108,6 @@ const demoA: PromptEnvelope = {
id: 'a-2',
role: 'user',
segments: [
{
kind: 'static_var',
name: 'user_name',
value: '小明',
},
{
kind: 'text',
content: '你好,我想讨论一下我设计的聊天协议方案。你觉得 9 种 prompt 类型的分类合理吗?',
@@ -124,10 +141,23 @@ const demoB: PromptEnvelope = {
id: 'b-1',
role: 'system',
segments: [
{
kind: 'static_var',
name: 'current_date',
value: '2026年6月7日',
description: '当前日期,SQL 查询中用于计算相对日期',
},
{
kind: 'static_var',
name: 'knowledge_cutoff',
value: '2026年1月',
description: '模型训练数据的截止时间',
},
{
kind: 'system_prompt',
content:
'你是一个数据分析助手,可以使用 Python 工具进行数据查询和可视化。',
content: `当前日期:{{current_date}}。知识截止:{{knowledge_cutoff}}。
你是一个数据分析助手,可以使用 Python 工具进行数据查询和可视化。`,
collapsed: true,
},
{
@@ -298,10 +328,23 @@ const demoC: PromptEnvelope = {
id: 'c-1',
role: 'system',
segments: [
{
kind: 'static_var',
name: 'current_date',
value: '2026年6月7日',
description: '当前日期',
},
{
kind: 'static_var',
name: 'language',
value: '中文(简体)',
description: '文档审阅的默认输出语言',
},
{
kind: 'system_prompt',
content:
'你是文档审阅助手。帮助用户分析长文档、提取要点、回答关于文档内容的问题。',
content: `当前日期:{{current_date}},回复语言:{{language}}。
你是文档审阅助手。帮助用户分析长文档、提取要点、回答关于文档内容的问题。`,
collapsed: true,
},
{
@@ -396,9 +439,38 @@ const demoD: PromptEnvelope = {
id: 'd-1',
role: 'system',
segments: [
{
kind: 'static_var',
name: 'current_date',
value: '2026年6月7日',
description: '当前对话日期',
},
{
kind: 'static_var',
name: 'language',
value: '中文(简体)',
description: '模型回复的首选语言',
},
{
kind: 'static_var',
name: 'knowledge_cutoff',
value: '2026年1月',
description: '模型训练数据截止日期',
},
{
kind: 'static_var',
name: 'user_name',
value: '小明',
description: '当前用户名称',
},
{
kind: 'system_prompt',
content: `你是 Claude,一个 HCI 研究助手。你的角色是帮助学生批判性地思考聊天界面的设计问题。
content: `当前日期:{{current_date}}
用户:{{user_name}}
回复语言:{{language}}
知识截止:{{knowledge_cutoff}}
你是 Claude,一个 HCI 研究助手。你的角色是帮助学生批判性地思考聊天界面的设计问题。
核心原则:
- 鼓励从用户体验角度分析,而非技术实现角度
@@ -538,11 +610,6 @@ const demoD: PromptEnvelope = {
id: 'd-2',
role: 'user',
segments: [
{
kind: 'static_var',
name: 'user_name',
value: '小明',
},
{
kind: 'text',
content: '你好!我在准备课程设计的文献综述部分。我找到了一篇相关的研究报告,帮我分析一下它是否可以支持我的论点。',
@@ -665,9 +732,31 @@ const demoE: PromptEnvelope = {
id: 'e-1',
role: 'system',
segments: [
{
kind: 'static_var',
name: 'current_date',
value: '2026年6月7日',
description: '当前对话日期',
},
{
kind: 'static_var',
name: 'language',
value: '中文(简体)',
description: '模型回复的首选语言',
},
{
kind: 'static_var',
name: 'knowledge_cutoff',
value: '2026年1月',
description: '模型训练数据截止日期',
},
{
kind: 'system_prompt',
content: `你是 HCI 课程设计助手,具备 Anthropic Skills 机制。
content: `当前日期:{{current_date}}
回复语言:{{language}}
知识截止:{{knowledge_cutoff}}
你是 HCI 课程设计助手,具备 Anthropic Skills 机制。
你有以下 skills 可用。用户输入以 / 开头的命令时会直接触发对应 skill。你也可以在分析用户意图后,主动建议合适的 skill。
@@ -764,11 +853,6 @@ const demoE: PromptEnvelope = {
id: 'e-2',
role: 'user',
segments: [
{
kind: 'static_var',
name: 'user_name',
value: '小明',
},
{
kind: 'text',
content: '我想深入了解 Anthropic Skills 的渐进式披露机制(Progressive Disclosure),作为我的 HCI 课程论文的案例研究对象。请帮我调研一下这个机制的设计原理、交互模式和学术界相关讨论。',
+8 -2
View File
@@ -38,10 +38,16 @@ export interface TextSegment {
content: string
}
/**
* 会话级静态变量 —— 在对话开始时注入到 System Prompt 模板中展开。
* 例如 {{current_date}} 在模板中展开为 "2026年6月7日"。
* 这些变量对用户可见,解释了模型"看到"的上下文配置。
*/
export interface StaticVarSegment {
kind: 'static_var'
name: string // e.g. "user_name"
value: string // e.g. "张三"
name: string // 模板变量名,e.g. "current_date"
value: string // 展开后的值,e.g. "2026年6月7日"
description?: string // 简短说明该变量的用途
}
export interface SystemPromptSegment {
+24 -2
View File
@@ -85,6 +85,8 @@ export function segmentToText(seg: Segment): string | null {
/** Render a structural segment into text for the system message */
function formatStructural(seg: Segment): string | null {
switch (seg.kind) {
case 'static_var':
return `${seg.name} = ${seg.value}`
case 'system_prompt':
return `[System Prompt]\n${seg.content}`
case 'memory':
@@ -148,14 +150,34 @@ export function exportToOpenAIFormat(envelope: PromptEnvelope): OpenAIExport {
for (const msg of envelope.messages) {
// ── System ──
if (msg.role === 'system') {
// 第一遍:收集 static_var 用于模板展开
const varMap: Record<string, string> = {}
for (const seg of msg.segments) {
if (seg.kind === 'static_var') {
varMap[seg.name] = seg.value
}
}
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 if (seg.kind === 'static_var') {
// 静态变量:导出为配置行
const s = formatStructural(seg)
if (s) contentParts.push(s)
} else if (seg.kind === 'system_prompt') {
// 展开模板中的 {{var}} 占位符
let expanded = seg.content
for (const [name, value] of Object.entries(varMap)) {
expanded = expanded.replace(
new RegExp(`\\{\\{${name}\\}\\}`, 'g'),
value
)
}
contentParts.push(expanded)
} else {
const t = segmentToText(seg)
if (t !== null) contentParts.push(t)