From 92ecb139ad02a3a4c570c0812238b23ff47ee350 Mon Sep 17 00:00:00 2001 From: carry Date: Sun, 7 Jun 2026 14:44:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=9D=99=E6=80=81=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E6=8F=90=E5=88=B0=E5=AF=B9=E8=AF=9D=E5=A4=96=20+=20Sy?= =?UTF-8?q?stem=20Prompt=20=E6=A8=A1=E6=9D=BF=E5=B1=95=E5=BC=80=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 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 --- src/__tests__/export.test.ts | 58 ++++++++- src/components/MessageBubble.tsx | 6 +- src/components/MessageList.tsx | 59 ++++++++- src/components/SegmentRenderer.tsx | 12 +- src/components/SessionBar.tsx | 39 ++++++ src/components/segments/StaticVarBadge.tsx | 16 ++- src/components/segments/SystemPromptView.tsx | 54 +++++++- src/data/demos.ts | 128 +++++++++++++++---- src/types/protocol.ts | 10 +- src/utils/export.ts | 26 +++- 10 files changed, 359 insertions(+), 49 deletions(-) create mode 100644 src/components/SessionBar.tsx diff --git a/src/__tests__/export.test.ts b/src/__tests__/export.test.ts index 5e58caa..214ee75 100644 --- a/src/__tests__/export.test.ts +++ b/src/__tests__/export.test.ts @@ -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', () => { diff --git a/src/components/MessageBubble.tsx b/src/components/MessageBubble.tsx index 0e163e5..150a11f 100644 --- a/src/components/MessageBubble.tsx +++ b/src/components/MessageBubble.tsx @@ -10,9 +10,11 @@ const roleConfig = { interface MessageBubbleProps { message: Message + /** 会话级变量映射表,用于 system_prompt 中的 {{var}} 模板展开展示 */ + varMap?: Record } -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 */}
{message.segments.map((seg, i) => ( - + ))}
diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx index cd8fd5f..a425105 100644 --- a/src/components/MessageList.tsx +++ b/src/components/MessageList.tsx @@ -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 + cleanedMessages: Message[] +} { + const variables: StaticVarSegment[] = [] + const varMap: Record = {} + + 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 (
选择一个 Demo 场景开始 @@ -15,10 +56,16 @@ export default function MessageList({ messages }: MessageListProps) { } return ( -
- {messages.map((msg) => ( - - ))} +
+ {/* 会话变量横栏 —— 在对话气泡之外 */} + + + {/* 消息列表 */} +
+ {cleanedMessages.map((msg) => ( + + ))} +
) } diff --git a/src/components/SegmentRenderer.tsx b/src/components/SegmentRenderer.tsx index af4d2aa..ed4fc94 100644 --- a/src/components/SegmentRenderer.tsx +++ b/src/components/SegmentRenderer.tsx @@ -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 } /** * 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 case 'static_var': - return + // static_var 已由 MessageList 提取到对话外部的 SessionBar 中, + // 不作为气泡内元素渲染。若此处仍有残留,静默跳过。 + return null case 'system_prompt': - return + return case 'memory': return case 'skills': diff --git a/src/components/SessionBar.tsx b/src/components/SessionBar.tsx new file mode 100644 index 0000000..610421f --- /dev/null +++ b/src/components/SessionBar.tsx @@ -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 ( +
+
+ {/* 标题 */} + + + 会话变量 + + + {/* 变量列表 */} + {variables.map((v, i) => ( + + {'{{'}{v.name}{'}}'} + + {v.value} + + ))} +
+
+ ) +} diff --git a/src/components/segments/StaticVarBadge.tsx b/src/components/segments/StaticVarBadge.tsx index f29ed47..a05bfd5 100644 --- a/src/components/segments/StaticVarBadge.tsx +++ b/src/components/segments/StaticVarBadge.tsx @@ -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 ( - - + + {'{{'}{segment.name}{'}}'} - - {segment.value} + = + {segment.value} ) } diff --git a/src/components/segments/SystemPromptView.tsx b/src/components/segments/SystemPromptView.tsx index 88ac4ea..7c0ec5c 100644 --- a/src/components/segments/SystemPromptView.tsx +++ b/src/components/segments/SystemPromptView.tsx @@ -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 // 来自会话变量的 name→value 映射 +} + +/** + * 将一个带 {{var}} 模板占位符的字符串解析为混合内容片段。 + * 普通文本渲染为纯文本,{{var}} 渲染为带解析值的内联标签。 + */ +function renderTemplate(content: string, varMap: Record) { + // 按 {{...}} 分割,捕获分隔符 + const parts = content.split(/(\{\{[^}]+\}\})/g) + + return parts.map((part, i) => { + const match = part.match(/^\{\{([^}]+)\}\}$/) + if (!match) { + // 普通文本 + return {part} + } + + const varName = match[1].trim() + const resolved = varMap[varName] + + return ( + + {'{{'}{varName}{'}}'} + {resolved && ( + <> + + {resolved} + + )} + {!resolved && ( + 未解析 + )} + + ) + }) +} + +export default function SystemPromptView({ segment, varMap = {} }: SystemPromptViewProps) { const lineCount = segment.content.split('\n').length + const hasVars = /\{\{[^}]+\}\}/.test(segment.content) + return (
-        {segment.content}
+        {renderTemplate(segment.content, varMap)}
       
) diff --git a/src/data/demos.ts b/src/data/demos.ts index 564f458..43525c5 100644 --- a/src/data/demos.ts +++ b/src/data/demos.ts @@ -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 课程论文的案例研究对象。请帮我调研一下这个机制的设计原理、交互模式和学术界相关讨论。', diff --git a/src/types/protocol.ts b/src/types/protocol.ts index 706c7ef..2b46c42 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -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 { diff --git a/src/utils/export.ts b/src/utils/export.ts index ff99c10..ea17ce2 100644 --- a/src/utils/export.ts +++ b/src/utils/export.ts @@ -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 = {} + 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)