Compare commits

...

10 Commits

Author SHA1 Message Date
carry 2348177c8d feat: 更新 README.md,明确聊天界面中 Prompt 的封装问题及其对用户的影响 2026-06-09 15:39:42 +08:00
carry fb8bdc0fb6 feat: 实现真实算术工具调用循环,补全协议双向映射闭环
- 新增 tools.ts: calculate 工具定义 + 安全表达式执行引擎
- 重写 sendMessage: 非流式 tool execution loop,最多 5 轮迭代
- Live 模式使用独立系统上下文(仅含 calculate 工具,不依赖 demo)
- exportToOpenAIFormat 补上独立 role:tool 消息的导出分支
- 新增 standalone role:tool 导出测试用例
- 49 个测试全部通过
2026-06-09 15:08:45 +08:00
carry 4c384fe566 feat: 实现协议双向映射,支持 Live 模式调用 OpenAI API
核心改动:
- 新增 src/utils/import.ts:OpenAI 响应 → Protocol Message 反向映射 + StreamingImporter 流式增量累加器
- 新增 src/services/api.ts:薄封装层,exportToOpenAIFormat → fetch → importFromOpenAIResponse
- 新增 src/services/api-config.ts:API 配置 localStorage 持久化
- 新增 src/components/ApiSettings.tsx:API 设置模态框
- 改造 ChatContext:新增 isLive / sendMessage / 流式状态管理
- 改造 ChatView/ChatInput:Live 模式下启用输入,支持回车发送和 loading 动画
- 改造 App.tsx:Demo/Live 模式切换 + 设置入口
- 新增 19 个 import.test.ts 测试用例,全部 48 测试通过
2026-06-09 15:02:10 +08:00
carry 6dcc8c62c2 feat: 更新场景标签,添加表情符号以增强可读性 2026-06-08 21:38:07 +08:00
carry 120745fe9f feat: 添加 Prompt Envelope Protocol 文档,详细描述上下文结构化方案及设计原则 2026-06-08 21:36:37 +08:00
carry 47f54e9770 feat: 更新 CLAUDE.md 文档,完善数据模型和渲染管线说明,修正命令行测试描述 2026-06-08 21:25:36 +08:00
carry b767106d61 fix: 重置默认场景为 Scene A,更新初始演示数据 2026-06-08 21:20:28 +08:00
carry 31099d479c feat: 添加 Prompt Envelope Protocol 设计文档,定义协议结构与数据模型 2026-06-08 21:17:59 +08:00
carry d04c85d959 feat: 增强多模态支持,更新 export 和 MediaView 组件以处理图片 URL,优化消息内容构建逻辑 2026-06-08 21:13:55 +08:00
carry 742659df43 feat: 将 demo 数据迁移为 JSON 协议文件,激活 Raw Protocol 面板
- 新建 6 个 demo JSON 文件 (demo-a ~ demo-f),替代 TS 硬编码常量
- 新建 prompt-envelope.schema.json (JSON Schema Draft 2020-12)
- 新建 demos-loader.ts: validateEnvelope() 运行时验证 + hydrateSkills() 从 skills 源补全 body
- 新建 manifest.json 场景索引
- ProtocolPanel: Raw Protocol tab 已激活,可切换查看原始 PromptEnvelope JSON
- demo.ts 改为从 JSON import + loadEnvelope() 加载
- Skill body 不入协议,仅保留 name + description (L1),由加载器补全
- 时间戳改为固定 epoch 毫秒值
- 删除 6 个旧 demo-{a..f}.ts 文件

构建通过,26 个测试全部通过
2026-06-08 20:52:44 +08:00
33 changed files with 4048 additions and 1498 deletions
+73 -68
View File
@@ -17,9 +17,9 @@ MVP 为纯前端 React 应用,使用 mock 数据演示。无后端、无真实
```bash ```bash
npm run dev # Vite 开发服务器 → http://localhost:5173 npm run dev # Vite 开发服务器 → http://localhost:5173
npm run build # tsc 类型检查 + vite 构建 npm run build # tsc 类型检查 + vite 构建
npm test # vitest 运行全部测试(26 个,位于 export.test.ts parseSkill.test.ts npm test # vitest 运行全部测试(2 个文件export.test.ts + parseSkill.test.ts,共 ~26 个用例
npm run preview # 预览生产构建 npm run preview # 预览生产构建
npx vitest run src/__tests__/export.test.ts # 运行单个测试文件 npx vitest run src/__tests__/export.test.ts # 运行单个测试文件
npx vitest run src/__tests__/parseSkill.test.ts npx vitest run src/__tests__/parseSkill.test.ts
``` ```
@@ -27,107 +27,112 @@ npx vitest run src/__tests__/parseSkill.test.ts
### 数据模型 (`src/types/protocol.ts`) ### 数据模型 (`src/types/protocol.ts`)
`PromptEnvelope { version, model, messages: Message[] }` — 顶层协议文档。 `PromptEnvelope { version, model?, messages: Message[] }` — 顶层协议文档。可选 `model` 字段用于导出时指定模型名(默认 `"gpt-4-turbo"`)。
`Message { id, role: "system"|"user"|"assistant"|"tool", segments: Segment[], timestamp }` — 单条聊天消息。 `Message { id, role: "system"|"user"|"assistant"|"tool", segments: Segment[], timestamp }` — 单条聊天消息。`role: "tool"` 用于独立的工具调用消息。
`Segment` 是基于 `kind` 的可辨识联合类型,共 11 种变体:`text``static_var``system_prompt``memory``skills``tool_overview``tool_call_request``tool_call_result``document``long_text``media` `Segment` 是基于 `kind` 的可辨识联合类型,共 11 种变体:`text``static_var``system_prompt``memory``skills``tool_overview``tool_call_request``tool_call_result``document``long_text``media`
部分 Segment 类型(MemorySegment、SkillSegment)有可选 `description` 字段,用于在 UI 中解释这些项的用途。 关键类型细节:
- `StaticVarSegment``name``value`、可选 `description` — 会话级变量,如 `{{current_date}}``"2026年6月8日"`。**不在消息气泡内渲染**,由 `MessageList.extractSessionVars()` 提取到 `SessionBar` 横栏中
ToolItem 有可选 `schema` 字段(JSON Schema 对象),在工具总览面板中可展开查看。 - `SkillItem``name``description`L1 始终可见)、`body`L2 触发时加载的 Markdown 正文)——遵循 Anthropic SKILL.md 规范
- `ToolItem` 有可选 `schema` 字段(JSON Schema 对象),导出时作为 `tools[].function.parameters`
DocumentSegment 有可选 `parsedContent` 字段,点击「查看解析」可展开 AI 对文档内容的提取结果。 - `DocumentSegment` 有可选 `parsedContent` 字段,「查看解析」时用 `MarkdownRenderer` 展示
- `MemorySegment``SkillSegment` 有可选 `description` 字段,在 UI 中解释其用途
### Skills 系统 ### Skills 系统
Skills 遵循 Anthropic SKILL.md 规范,实现渐进式披露: 遵循 Anthropic SKILL.md 规范,实现渐进式披露。管线
``` ```
SKILL.md 文件 (YAML frontmatter + Markdown body) SKILL.md 文件 (YAML frontmatter + Markdown body)
├─ parseSkillMarkdown() → ParsedSkill { name, description, body, ... } ├─ parseSkillMarkdown() → ParsedSkill { name, description, body, bodyLineCount, ... }
│ (src/utils/parseSkill.ts) YAML 通过极简手写解析器提取(不依赖外部库) │ (src/utils/parseSkill.ts) 极简手写 YAML 解析器(不依赖外部库),提取顶层 string 键值
├─ skills-loader.ts → PARSED_SKILLS (Record<string, ParsedSkill>) ├─ skills-loader.ts → PARSED_SKILLS (Record<string, ParsedSkill>)
│ (src/data/skills-loader.ts) 通过 Vite ?raw 导入 4 个真实 SKILL.md 文件 │ (src/data/skills-loader.ts) 通过 Vite ?raw 导入 4 个真实 Anthropic SKILL.md 文件
│ + toSkillItem() / getRealSkills() / getAllRealSkillItems()
├─ skills.ts → ALL_SKILLS (Record<string, SkillItem>) ├─ skills.ts → ALL_SKILLS (Record<string, SkillItem>)
│ (src/data/skills.ts) 8 个内置自定义 skill,用于 Demo C/D/E │ (src/data/skills.ts) 8 个手写 skillcode-review、deep-research、verify、simplify、
│ loop、summarize、translate、qa
│ + getSkills(names) 按名称选取
UI: SkillsView → L1 名称+描述(始终可见),L2 body(点击展开) demos-loader.ts → buildSkillLookup() 合并两个源 → hydrateSkills(envelope)
│ (src/data/demos/demos-loader.ts) 按 name 匹配,补全 JSON 中只包含 name+description 的 skill items
│ + validateEnvelope() 运行时类型守卫
│ + loadEnvelope() = validateEnvelope + hydrateSkills
└─ UI: SkillsView → SkillDisclosure 逐项展开:L1 name+description(始终可见),L2 body(点击展开)
``` ```
**两类 skill 数据源:**
- `src/data/skills.ts` — 8 个手写 SkillItemcode-review、deep-research、verify 等),通过 `getSkills(names)` 按名称选取
- `src/data/skills-loader.ts` — 4 个真实 Anthropic SKILL.md 文件(webapp-testing、pdf、doc-coauthoring、mcp-builder),通过 `parseSkillMarkdown()` 解析,`getRealSkills(names)` 获取
**parseSkillMarkdown 测试:** 位于 `src/__tests__/parseSkill.test.ts`,覆盖 YAML 解析、body 提取、必填字段校验、可选字段、统计信息等 7 个用例。
### 渲染管线 ### 渲染管线
消息在渲染前经过 `MessageList` 两步预处理:
1. **`extractSessionVars()`** — 从所有消息中提取 `static_var` segment,构建 `varMap`,并将其从消息体中移除。变量展示在 `SessionBar`(对话区顶部横栏)
2. **`extractToolMessages()`** — 将 `tool_call_request`/`tool_call_result` segment 拆分为独立 `role: "tool"` 消息
处理后的消息按 Segment kind 分发渲染:
``` ```
SegmentRenderer (按 segment.kind 分发) SegmentRenderer (按 segment.kind 分发)
├─ TextSegmentView → MarkdownRendererreact-markdown + GFM ├─ TextSegmentView → MarkdownRendererreact-markdown + remark-gfm,所有 HTML 元素自定义 Tailwind
├─ StaticVarBadge → 蓝色内联 pill 标签 ├─ static_var → 返回 null(已由 SessionBar 提取)
├─ SystemPromptView → CollapsiblePanel(灰色,默认折叠 ├─ SystemPromptView → CollapsiblePanel(灰色),支持 {{var}} 模板可视化渲染
├─ MemoryView → CollapsiblePanel(紫色,默认折叠 ├─ MemoryView → CollapsiblePanel(紫色)
├─ SkillsView → CollapsiblePanel(绿色,默认折叠 ├─ SkillsView → CollapsiblePanel(绿色) SkillDisclosure 逐项逐层展开
├─ ToolOverviewView → CollapsiblePanel(橙色,默认折叠,可展开 JSON Schema ├─ ToolOverviewView → CollapsiblePanel(橙色),ToolItemRow 可独立展开 JSON Schema
├─ ToolCallRequestView → 深色终端风格代码块,参数以标签化键值行展示 ├─ ToolCallRequestView → 深色终端风格,参数以标签化键值行展示 + 长值折叠
├─ ToolCallResultView → 绿/红状态条(失败默认展开,成功默认折叠) ├─ ToolCallResultView → 绿/红状态条(失败默认展开,成功默认折叠)
├─ DocumentCard → 文件图标 + 文件名 + 大小 + 摘要预览 ├─ DocumentCard → 文件图标 + 文件名 + 大小 + snippet,可选「查看解析」展开 Markdown
├─ LongTextView → 折叠态展示前2行预览 + 展开态用 MarkdownRenderer 渲染 ├─ LongTextView → 折叠态前2行预览 + 展开态用 MarkdownRenderer 渲染
└─ MediaView → 图标 + 类型标签 + alt 文本 └─ MediaView → 图片有 URL 时直接渲染缩略图;加载失败/无URL/非图片则图标占位
``` ```
`CollapsiblePanel` 是共享折叠容器组件。折叠/展开状态通过 `useState(segment.collapsed)` 管理——协议数据提供默认值,UI 控制运行时切换 `CollapsiblePanel` 是共享折叠容器,接收 `title``icon``color``bgColor``defaultCollapsed``badge` props
`MarkdownRenderer` 封装 `react-markdown` + `remark-gfm`。所有 HTML 元素(h1h4、p、code、pre、table、blockquote 等)均有自定义 Tailwind 样式,针对聊天气泡内嵌场景做了紧凑化处理 `MarkdownRenderer` 是所有文本类组的共享 Markdown 渲染组件,覆盖 h1-h4、p、code、pre、table、blockquote、list 等所有常见元素的 Tailwind 样式
### 状态管理
`ChatContext``src/context/ChatContext.tsx`)持有当前 `envelope``activeDemo` 索引。切换 Demo 场景时替换整个 envelope。ChatInput 已禁用(MVP 无真实输入)。
### 导出管线 (`src/utils/export.ts`) ### 导出管线 (`src/utils/export.ts`)
`exportToOpenAIFormat(envelope)` 输出 OpenAI 兼容 JSON,结构为 `{ model, messages, tools? }` `exportToOpenAIFormat(envelope): OpenAIExport` 输出 `{ model, messages: OpenAIMessage[], tools?: OpenAITool[] }`——完整的 OpenAI Chat Completions request body。
- 结构性 Segment`system_prompt``memory``skills``tool_overview`)提取为一条 `role: "system"` 的头部消息 关键映射:
- `tool_overview` 的 items 转为顶层 `tools` 数组(OpenAI function-calling 格式) - `system_prompt`/`memory`/`skills` → 合并为一条 leading `role: "system"` 消息
- `tool_call_request` → 带 `tool_calls` 数组的 assistant 消息;`tool_call_result` `role: "tool"` 消息 - `system_prompt` 中的 `{{var}}` 占位符在导出时展开
- `static_var` 展开为变量值 - `tool_overview` → 顶层 `tools[]``item.schema` 映射为 `tool.function.parameters`
- `text` `long_text` 原文输出 - `tool_call_request` `role: "assistant"` 消息带 `tool_calls[]`(自动生成 call_id
- `media``altText` 作为回退文本 - `tool_call_result` → 独立 `role: "tool"` 消息,通过 call_id 与请求配对
- `segmentToText()` 对结构性/tool Segment 返回 `null`——它们在消息级别统一处理 - `static_var` → user/assistant 消息中展开为值;system 消息中只参与模板展开,**不**直接出现在 system 消息正文中
- 含图片 media 的 user 消息 → 多模态 `content` 数组 (`[{type: "text"}, {type: "image_url"}]`)
- `document` / `long_text` → 原文输出(带 `[Document: ...]` 标注)
- `segmentToText()` 对结构性/tool Segment 返回 `null`——在消息级别统一处理
### Demo 数据 (`src/data/demos.ts` → `src/data/demos/`) ### Demo 数据 (`src/data/demos/`)
6 个场景(AF),每个场景是一个完整的 `PromptEnvelope`,对话内容为中文。默认激活索引 4(场景 F——真实 Anthropic Skills 加载)。 6 个场景(AF),每个是独立的 JSON 文件(`demo-{a..f}.json`),通过 `manifest.json` 索引。默认激活场景 A`"default": true` 在 manifest 中标记为场景 F,但 ChatContext 初始化为 `useState(0)`)。
| 索引 | 场景 | 内容 | 加载流程:`JSON import (unknown) → validateEnvelope() → hydrateSkills() → PromptEnvelope`
|------|--------|---------------------------------------------------|
| 0 | 场景 A | 基础对话 + System Prompt + Memory |
| 1 | 场景 B | 工具调用:请求 → 执行(成功 & 失败) |
| 2 | 场景 C | 文档解析:点击「查看解析」看 AI 如何提取文档内容 |
| 3 | 场景 D | 综合:覆盖全部 11 种 Segment |
| 4 | 场景 F | 真实 Anthropic SkillsSKILL.md 文件加载 + parseSkillMarkdown 解析) |
| 5 | 场景 E | 日志分析:异常检测 + 安全审计 + 性能分析 |
每个场景文件位于 `src/data/demos/demo-{a..f}.ts``demos.ts` 仅做聚合导出。 ### 状态管理
`ChatContext``src/context/ChatContext.tsx`)持有 `envelope``activeDemo``demos`。切换场景时替换整个 envelope。默认激活索引 0(场景 A)。ChatInput 已禁用。
### 颜色系统 ### 颜色系统
| 颜色 | 对应 Segment | | 颜色 | Segment / 用途 |
|--------|--------------------------------------| |--------|-------------------------------------|
| 蓝色 | `static_var` | | 蓝色 | `static_var`SessionBar + badge |
| 灰色 | `system_prompt` | | 灰色 | `system_prompt` |
| 紫色 | `memory` | | 紫色 | `memory` |
| 绿色 | `skills` | | 绿色 | `skills` |
| 橙色 | `tool_overview` | | 橙色 | `tool_overview` |
| 深色 | `tool_call_request`(代码块) | | 深色 | `tool_call_request`(代码块) |
| 绿/红 | `tool_call_result`(成功/失败) | | 绿/红 | `tool_call_result`(成功/失败) |
| 琥珀 | `role: "tool"` 消息气泡 |
### 布局 ### 布局
双栏:左侧 `ChatView`MessageList + ChatInput),右侧 `ProtocolPanel`实时 OpenAI Format JSON 视图,支持复制/下载)。顶栏有 Demo 场景切换按钮。 双栏:左侧 `ChatView`SessionBar + MessageList + ChatInput),右侧 `ProtocolPanel`双 TabOpenAI Format / Raw Protocol,支持复制/下载,底部统计信息)。顶栏有 6 个 Demo 场景切换按钮。
+246
View File
@@ -0,0 +1,246 @@
# Prompt Envelope Protocol
**让 LLM 上下文在聊天界面中可见、可读、可理解。**
---
## 问题:Prompt 被过度封装,用户不知道真正的输入是什么
以 ChatGPT、Claude 为代表的 AI 聊天产品已拥有数亿用户。这些产品的交互界面模拟的是「人对人」的对话——一个输入框,一段回复。用户看到自己打了一句话,AI 回了一句话。
**但模型真正收到的,远不止这句话。**
在用户输入的文本被发送给 LLM 之前,系统在后台拼接了大量的上下文:
- **System Prompt** —— 决定了回复的风格、边界、角色设定
- **User Memory** —— 跨对话持久化的用户画像,在每次请求时被注入
- **Tools & Skills 的声明** —— 函数签名、JSON Schema、触发条件的完整描述
- **模板变量的值** —— `{{user_name}}` 被替换为"小明",但用户看到的是变量名还是值?
对于普通用户,这可能无所谓。但对于需要精确控制模型行为的**进阶用户**(开发者、研究者、prompt engineer),这种不透明是致命的:
- 你精心设计了一段 prompt,但不知道 system prompt 是否和它冲突
- 你修改了一条 memory,但不确定新的值是否真的被注入了每一次请求
- 你添加了一个 tool,但看不到它的 schema 在上下文中以什么形式出现
- 你粘贴了一篇长论文,但不知道全文都被包含还是被截断
- 你看到 AI 回复了错误结果,但无法排查是哪个上下文片段导致了问题
**界面上看起来是「一句话的对话」,实际发送给模型的是一份由十几个组件拼接而成的结构化文档。** 聊天 UI 对这一切做了完全的封装——把复杂的 prompt 构造过程包装成了一个简单的 textarea。
**这个问题不是"看不到 AI 的推理过程",而是"看不到自己的输入到底是什么"。**
### 设计问题
当前的聊天界面将 prompt 构造的复杂性**过度封装**在单一输入框中。用户以为自己发送的是文本,实际发送的是一个经过层层拼接的复合体。界面模拟的是"人说了一句话"的隐喻,但模型侧的真实情况是"一份多来源、多类型、多优先级的结构化上下文文档"。
HCI 告诉我们:**界面的信息架构应该反映系统的心智模型**。当用户不知道模型真正收到了什么,他们就无法调试、无法精确控制、无法建立信任。
---
## 方案:Prompt Envelope Protocol
本项目的答案是:**一种将 LLM 完整上下文结构化的协议和对应的可视界面**。
### 核心思想
不是让聊天消息变成纯文本,而是让每条消息由若干带明确类型的 **Segment**(片段)组成:
```
一条消息不是 "你好,帮我审阅这篇论文"
而是:
├─ text segment "你好,帮我审阅这篇论文"
├─ long_text segment [论文全文 — 默认折叠,点击展开]
├─ document segment 📄 paper-draft.pdf (2.3MB)
└─ media segment 🖼️ 图3:实验组vs对照组
```
每种 Segment 在界面中有**独立的视觉呈现**——系统指令是折叠的灰色面板,用户记忆是紫色的记忆卡片,工具请求是深色终端风格的代码块。
### 设计原则
| 原则 | 说明 |
|------|------|
| **信息密度梯度** | 核心对话文本优先可见;元信息(system prompt / memory / tools)默认折叠 |
| **颜色编码系统** | 灰=系统 · 紫=记忆 · 绿=技能 · 橙=工具 · 蓝=变量 · 深色=工具调用 |
| **协议即视图** | 折叠状态内嵌于数据结构中;相同协议数据在任何渲染器下产生相同视图 |
| **可导出,不锁死** | 协议可以导出为标准 OpenAI Chat Format,不依赖本项目的 UI |
---
## 方法:区分 9 种上下文类型
通过对主流 LLM 聊天产品(ChatGPT、Claude、Kimi、豆包)的逆向分析,以及对 127 名 LLM 用户行为研究文献的查阅,识别出 **9 种需要差异化呈现的上下文类型**
### 1. 文本(text
用户和 AI 的直接对话内容。**始终可见**,不做折叠。用 Markdown 渲染保留结构化表达。
### 2. 静态变量(static_var
`{{user_name}}``{{current_date}}`——在对话开始前被替换为具体值。提取为**对话区顶部的会话变量横栏**,不在消息气泡中占用空间。
```
会话变量 {{user_name}} → 小明 {{current_date}} → 2026年6月8日
```
### 3. System Promptsystem_prompt
模型的行为准则和角色设定。**默认折叠**为灰色面板,标注行数。支持展开查看——当用户想知道"为什么 AI 这样说话"。
### 4. 用户记忆(memory
跨对话持久化的用户信息。**默认折叠**为紫色面板,展开后以标题列表展示每条记忆。用户可以质疑不准确的记忆。
### 5. Skills
模型可调用的技能,遵循 Anthropic SKILL.md 规范。**默认折叠**为绿色面板。展开后实现**渐进式披露**:
- **L1**:名称 + 描述(始终可见)
- **L2**:完整指令 body(点击展开——提示"触发时会加载到 LLM 上下文")
### 6. 工具总览(tool_overview
模型可使用的工具清单。**默认折叠**为橙色面板。每项工具可独立展开其 JSON Schema 定义。
### 7. 工具调用(tool_call
拆分为两个子类型:
| 子类型 | 视觉 | 默认状态 | 说明 |
|--------|------|---------|------|
| `tool_call_request` | 深色终端风格代码块 | **展开** | 绿色方法名 + 参数键值行 |
| `tool_call_result` | 绿/红 状态条 | **折叠**(成功)/ **展开**(失败) | 一眼看出是否出错 |
在界面中,工具调用会被自动拆分为独立的 `tool` 角色消息,与正常的对话消息交替排列。
### 8. 传文档(document
用户上传的文件。以**文件卡片**展示:图标 + 文件名 + 大小 + 前 200 字预览。点击「查看解析」可展开 AI 提取的文档内容。
### 9. 长文本素材(long_text
用户粘贴的长篇文本(>500 字)。**默认折叠**,展示前 2 行预览 + 字数统计。点击展开显示全文(Markdown 渲染)。
### 附加:多模态(media
图片、音频、视频。图片有有效 URL 时直接渲染缩略图;加载失败或无 URL 时回退为图标占位 + alt 文本。音频/视频展示类型标签。
---
## 协议格式
### Prompt Envelope(顶层文档)
```typescript
interface PromptEnvelope {
version: '1.0'
model?: string // 导出时使用的模型名
messages: Message[]
}
```
### Message(单条消息)
```typescript
interface Message {
id: string
role: 'system' | 'user' | 'assistant' | 'tool'
segments: Segment[] // 有序的片段列表
timestamp: number
}
```
### Segment(片段联合类型)
每种 Segment 有独有的 `kind` 字段,UI 据此决定渲染方式:
```
text | static_var | system_prompt | memory | skills |
tool_overview | tool_call_request | tool_call_result |
document | long_text | media
```
完整类型定义见 [`src/types/protocol.ts`](src/types/protocol.ts)。
### 导出兼容
协议可无损导出为标准 OpenAI Chat Completions 格式(`{ model, messages, tools }`),确认此设计不是"另一种 agent 运行时",而是一种**对标准协议的格式化表达层**。
---
## 技术实现
### 技术栈
| 层 | 选型 |
|---|------|
| 框架 | React 18 + TypeScript |
| 构建 | Vite |
| 样式 | Tailwind CSS |
| 图标 | Lucide React |
| Markdown | react-markdown + remark-gfm |
| 测试 | Vitest |
### MVP 范围
- ✅ 纯前端 React 应用,无后端、无真实 LLM 调用
- ✅ 6 个 Demo 场景(AF),覆盖全部 Segment 类型
- ✅ 双栏布局:对话视图 + 协议面板(实时 JSON 预览)
- ✅ 双 Tab 协议面板:OpenAI Format / Raw Protocol,支持复制和下载
- ✅ 4 个真实 Anthropic SKILL.md 文件加载 + `parseSkillMarkdown()` 自研解析器
- ✅ 26 个单元测试覆盖导出逻辑和 SKILL.md 解析
- ❌ 不接入真实 LLM API
- ❌ 不做后端/数据库/用户认证
### 快速开始
```bash
git clone <this-repo>
cd hci
npm install
npm run dev # 启动开发服务器 → http://localhost:5173
npm test # 运行全部 26 个单元测试
npm run build # 生产构建
```
---
## 文件概览
```
src/
├── types/protocol.ts # 协议类型定义(11 种 Segment
├── utils/
│ ├── export.ts # 导出 → OpenAI Chat Format
│ └── parseSkill.ts # SKILL.md YAML 解析器(自研)
├── data/
│ ├── demos/ # 6 个 Demo JSON 文件 + manifest
│ │ └── demos-loader.ts # 运行时验证 + skill body 水合
│ ├── skills.ts # 8 个手写自定义 skill
│ ├── skills-loader.ts # 4 个真实 Anthropic SKILL.md
│ └── skills/ # SKILL.md 源文件
├── components/
│ ├── SegmentRenderer.tsx # 按 kind 路由分发
│ ├── CollapsiblePanel.tsx # 通用折叠容器
│ ├── MarkdownRenderer.tsx # 共享 Markdown 渲染(所有文本组件)
│ ├── MessageList.tsx # 消息预处理 + 列表
│ ├── MessageBubble.tsx # 消息气泡容器
│ ├── SessionBar.tsx # 会话变量横栏
│ ├── ProtocolPanel.tsx # 右侧协议面板(双 Tab)
│ └── segments/ # 11 个 Segment View 组件
├── context/ChatContext.tsx # 全局状态
├── __tests__/
│ ├── export.test.ts # 导出逻辑(~20 个用例)
│ └── parseSkill.test.ts # SKILL.md 解析(7 个用例)
└── App.tsx # 顶层布局
```
---
## 设计反思
这个项目本质上在探索一个更根本的问题:**当一条"聊天消息"背后实际是十几个上下文组件拼接而成的结构化文档,聊天界面该怎样诚实地呈现它?**
传统的聊天界面诞生于"人对人"的隐喻——一个输入框,一个输出框。但当这个输入框背后连接的是一个拥有 system prompt、记忆、工具声明和变量注入的复杂 prompt 构造系统时,把一切封装在简单的 chat bubble 里就是在**对用户撒谎**。用户以为自己发送的是纯文本,实际被发送的是一份经过大量预处理的复合文档。
Prompt Envelope Protocol 尝试将"人机对话"的界面从**隐藏复杂性的 textarea** 升级为**诚实可见的信息架构**。让对话保持流畅,同时让 prompt 的真实组成变得可见。这不是一个关于"AI 在做什么"的设计——**这是关于"你到底对 AI 说了什么"的设计。**
+591
View File
@@ -0,0 +1,591 @@
# Prompt Envelope Protocol 设计文档
> **版本**1.0
> **更新日期**2026 年 6 月 8 日
---
## 1. 概述
**Prompt Envelope Protocol** 是一套让 LLM 上下文在聊天界面中可见化的协议与 UI 系统。
传统聊天应用中,system prompt、memory、tools、skills、文档等上下文对用户是**不可见**的——它们被框架悄悄拼接到 prompt 中,用户无法知晓模型"看到了什么"。Prompt Envelope 将每条聊天消息拆解为若干带类型的 **Segment**,每种 Segment 有独立的视觉呈现,让隐藏的上下文成为可见的一等元素。
### 核心理念
```
传统模式: Prompt Envelope
┌─────────────┐ ┌──────────────────────┐
│ 原始文本 │ │ Message │
│ (用户不可见 │ │ ├─ 🔵 变量 badge │
│ 的上下文) │ │ ├─ 📋 System Prompt │
└─────────────┘ │ ├─ 🧠 Memory │
│ ├─ 🛠️ Tools │
│ ├─ ⚡ Skills │
│ └─ 💬 Text │
└──────────────────────┘
```
### 适用场景
- 调试 LLM 行为——理解模型为什么给出某个回复
- 教育——展示 LLM 上下文的组成结构
- HCI 研究——探索上下文透明性对用户体验的影响
- 协议标准化——跨平台、跨模型的统一上下文表示
---
## 2. 数据模型
协议采用三层结构:**Envelope → Message → Segment**。
```
PromptEnvelope ← 顶层信封
├─ version: "1.0" ← 协议版本
├─ model?: string ← 模型名称
└─ messages: Message[] ← 消息列表
├─ Message ← 单条消息
│ ├─ id: string ← 唯一标识
│ ├─ role: Role ← system | user | assistant | tool
│ ├─ segments: Segment[] ← Segment 列表
│ └─ timestamp: number ← Unix epoch 毫秒
└─ ...
```
### 2.1 PromptEnvelope(顶层信封)
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `version` | `"1.0"` | ✓ | 协议版本号 |
| `model` | `string` | | 模型名称,如 `gpt-4-turbo``claude-opus-4-8` |
| `messages` | `Message[]` | ✓ | 消息列表,至少 1 条 |
### 2.2 Message(消息)
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | `string` | ✓ | 消息唯一标识(如 `a-1``b-3` |
| `role` | `"system" \| "user" \| "assistant" \| "tool"` | ✓ | 消息角色 |
| `segments` | `Segment[]` | ✓ | Segment 列表,至少 1 个 |
| `timestamp` | `number` | ✓ | Unix epoch 毫秒时间戳 |
### 2.3 Segment(段落)
Segment 是基于 `kind` 字段的**可辨识联合类型**,共 11 种。每个 Segment 对应 LLM 上下文中的一类信息,在 UI 中有独立的视觉呈现。
| kind | 含义 | 视觉组件 | 颜色标识 |
|------|------|---------|---------|
| `text` | 普通文本消息 | `MarkdownRenderer` | — |
| `static_var` | 会话级静态变量 | `StaticVarBadge` | 蓝色 |
| `system_prompt` | System Prompt 内容 | `SystemPromptView` | 灰色 |
| `memory` | 长期记忆条目 | `MemoryView` | 紫色 |
| `skills` | 可用 Skills 列表 | `SkillsView` | 绿色 |
| `tool_overview` | 工具声明(JSON Schema | `ToolOverviewView` | 橙色 |
| `tool_call_request` | 工具调用请求 | `ToolCallRequestView` | 深色终端 |
| `tool_call_result` | 工具调用结果 | `ToolCallResultView` | 绿/红 |
| `document` | 上传的文档 | `DocumentCard` | — |
| `long_text` | 长文本片段 | `LongTextView` | — |
| `media` | 图片/音频/视频 | `MediaView` | — |
---
## 3. Segment 类型详细规范
### 3.1 TextSegment — 普通文本
最基本的文本消息,使用 Markdown 渲染。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"text"` | ✓ | |
| `content` | `string` | ✓ | Markdown 格式文本 |
```json
{ "kind": "text", "content": "你好,我想讨论一下我的协议方案。" }
```
### 3.2 StaticVarSegment — 静态变量
会话级配置变量,在 system prompt 中以 `{{name}}` 模板占位符展开。变量本身在 UI 中以蓝色 pill 标签呈现,让用户看到模型"感知"到的配置。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"static_var"` | ✓ | |
| `name` | `string` | ✓ | 模板变量名,如 `current_date` |
| `value` | `string` | ✓ | 展开后的值,如 `2026年6月7日` |
| `description` | `string` | | 变量的用途说明 |
```json
{
"kind": "static_var",
"name": "current_date",
"value": "2026年6月7日",
"description": "当前对话日期,注入到 System Prompt 模板中"
}
```
**模板展开规则**System prompt 中的 `{{current_date}}` 会被替换为 `2026年6月7日`。展开发生在导出阶段(`exportToOpenAIFormat`),UI 中同时显示原始模板和变量值。
### 3.3 SystemPromptSegment — System Prompt
模型的系统指令。UI 中用灰色 CollapsiblePanel 包裹,默认折叠——体现其"后台配置"的语义。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"system_prompt"` | ✓ | |
| `content` | `string` | ✓ | System Prompt 全文,可含 `{{var}}` 模板 |
| `collapsed` | `boolean` | ✓ | 默认折叠状态 |
```json
{
"kind": "system_prompt",
"content": "当前日期:{{current_date}}\n回复语言:{{language}}\n\n你是 HCI 课程设计助手...",
"collapsed": true
}
```
### 3.4 MemorySegment — 记忆
模型的长期记忆,存储关于用户的持久化信息。UI 中用紫色 CollapsiblePanel 包裹。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"memory"` | ✓ | |
| `description` | `string` | | 简短解释 memory 的用途 |
| `items` | `MemoryItem[]` | ✓ | 记忆条目列表 |
| `collapsed` | `boolean` | ✓ | 默认折叠状态 |
**MemoryItem**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `title` | `string` | ✓ | 记忆标题,如"用户身份" |
| `content` | `string` | ✓ | 记忆内容 |
```json
{
"kind": "memory",
"description": "以下是从过往对话中总结的关于你的信息...",
"items": [
{ "title": "用户背景", "content": "设计系研二学生,正在做 HCI 课程设计" }
],
"collapsed": true
}
```
### 3.5 SkillsSegment — 技能
模型可调用的内置能力(slash commands)。遵循 Anthropic SKILL.md 规范的渐进式披露机制:
- **L1**`name` + `description` — 始终在上下文中可见
- **L2**`body` — skill 被触发时才加载的完整指令
UI 中用绿色 CollapsiblePanel 包裹。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"skills"` | ✓ | |
| `description` | `string` | | 解释 skills 是什么 |
| `items` | `SkillItem[]` | ✓ | Skill 列表 |
| `collapsed` | `boolean` | ✓ | 默认折叠 |
**SkillItem**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | `string` | ✓ | Skill 名称(L1 |
| `description` | `string` | ✓ | 一句话描述(L1 |
| `body` | `string` | | 完整指令 MarkdownL2,触发时注入) |
> **注意**JSON 协议文件中 skills segment 只包含 `name` + `description`L1 层)。`body` 由运行时加载器从技能注册表补全,**不属于协议数据本身**。body 仅在 skill 被触发时由 agent 框架注入上下文。
```json
{
"kind": "skills",
"description": "Skills 是模型可调用的内置能力...",
"items": [
{ "name": "deep-research", "description": "深度研究 — 多源搜索、交叉验证、生成引用报告" },
{ "name": "code-review", "description": "代码审查 — 发现正确性 bug 和简化/效率优化机会" }
],
"collapsed": true
}
```
### 3.6 ToolOverviewSegment — 工具声明
声明模型可调用的工具列表及其 JSON Schema。UI 中用橙色 CollapsiblePanel 包裹,每个 Tool 可展开查看 Schema。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"tool_overview"` | ✓ | |
| `items` | `ToolItem[]` | ✓ | 工具列表 |
| `collapsed` | `boolean` | ✓ | |
**ToolItem**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | `string` | ✓ | 工具名称 |
| `description` | `string` | ✓ | 功能描述 |
| `parameters` | `string` | ✓ | 人类可读的参数摘要 |
| `schema` | `object` | | JSON Schema 对象 |
```json
{
"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"]
}
}
],
"collapsed": true
}
```
### 3.7 ToolCallRequestSegment — 工具调用请求
模型发起工具调用的记录。UI 中以深色终端风格代码块呈现,参数以标签化键值行展示。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"tool_call_request"` | ✓ | |
| `toolName` | `string` | ✓ | 被调用的工具名称 |
| `arguments` | `object` | ✓ | 调用参数(键值对) |
| `collapsed` | `boolean` | ✓ | 默认展开(`false` |
```json
{
"kind": "tool_call_request",
"toolName": "search",
"arguments": { "query": "LLM context transparency HCI", "limit": 5 },
"collapsed": false
}
```
### 3.8 ToolCallResultSegment — 工具调用结果
工具调用的返回结果。UI 中成功时显示绿色状态条(默认折叠),失败时显示红色状态条(默认展开)。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"tool_call_result"` | ✓ | |
| `toolName` | `string` | ✓ | 工具名称 |
| `result` | `string` | ✓ | 返回结果文本 |
| `success` | `boolean` | ✓ | 调用是否成功 |
| `collapsed` | `boolean` | ✓ | 成功时 `true`,失败时 `false` |
```json
{
"kind": "tool_call_result",
"toolName": "search",
"success": true,
"result": "Found 5 results:\n\n1. \"Transparent AI...\" — CHI 2024...",
"collapsed": true
}
```
### 3.9 DocumentSegment — 文档
用户上传的文档。UI 中显示文件图标、文件名、大小、内容摘要。可点击「查看解析」展开 AI 对文档的结构化提取结果。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"document"` | ✓ | |
| `fileName` | `string` | ✓ | 文件名 |
| `mimeType` | `string` | ✓ | MIME 类型 |
| `snippet` | `string` | ✓ | 前 200 字符预览 |
| `sizeBytes` | `number` | ✓ | 文件大小(字节) |
| `parsedContent` | `string` | | AI 提取的结构化内容(Markdown |
```json
{
"kind": "document",
"fileName": "2026-Q2-智能助手市场报告.pdf",
"mimeType": "application/pdf",
"sizeBytes": 2845000,
"snippet": "# 2026 年 Q2 智能助手市场研究报告\n\n## 摘要\n\n2026 年 Q2 全球智能助手市场规模预计达到 187 亿美元...",
"parsedContent": "## 文档概览\n\n| 字段 | 内容 |\n..."
}
```
### 3.10 LongTextSegment — 长文本
超长文本片段(如日志、研究论文全文),折叠态展示前 2 行预览 + 字符数。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"long_text"` | ✓ | |
| `content` | `string` | ✓ | 全文内容 |
| `charCount` | `number` | ✓ | 字符总数 |
| `collapsed` | `boolean` | ✓ | 默认 `true`(折叠) |
```json
{
"kind": "long_text",
"content": "192.168.1.10 - - [07/Jun/2026:10:15:23 +0800] \"GET /api/users HTTP/1.1\" 200 1234\n...",
"charCount": 2375,
"collapsed": true
}
```
### 3.11 MediaSegment — 多媒体
图片、音频或视频。UI 中显示类型图标 + 媒体类型标签 + alt 文本。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `kind` | `"media"` | ✓ | |
| `mediaType` | `"image" \| "audio" \| "video"` | ✓ | 媒体类型 |
| `url` | `string` | ✓ | 媒体文件 URL |
| `altText` | `string` | | 替代文本描述 |
```json
{
"kind": "media",
"mediaType": "image",
"url": "https://cataas.com/cat",
"altText": "用户上传的图片"
}
```
---
## 4. Skills 系统
Skills 遵循 Anthropic SKILL.md 规范的**渐进式披露**机制。
### 4.1 SKILL.md 格式
```markdown
---
name: webapp-testing
description: Toolkit for testing local web applications using Playwright.
---
# Web Application Testing
To test local web applications, write native Python Playwright scripts.
...
```
YAML frontmatter 提供 `name` + `description`Markdown body 是完整指令正文。
### 4.2 层级模型
| 层级 | 内容 | 何时可见 | 上下文大小 |
|------|------|---------|-----------|
| L1 | `name` + `description` | 始终在上下文中 | ~100 词 |
| L2 | `body`(完整指令) | Skill 被触发时加载 | 建议 <500 行 |
### 4.3 协议中的表示
JSON 协议文件的 skills segment **仅包含 L1 数据**`name` + `description`)。Skill body 由运行时加载器从技能注册表(`skills.ts` / `skills-loader.ts`)按名称查询并补全。
**理由**body 不属于 system prompt 范畴——它不会被立刻发送给模型。body 仅在 skill 被触发时由 agent 框架注入上下文,其展示也由框架逻辑控制。
### 4.4 两类 Skill 源
| 来源 | 文件 | 数量 | 说明 |
|------|------|------|------|
| 手写 | `src/data/skills.ts` | 8 个 | 自定义 skillcode-review、deep-research、verify 等) |
| SKILL.md 解析 | `src/data/skills-loader.ts` | 4 个 | 从真实 Anthropic SKILL.md 文件解析(webapp-testing、pdf、doc-coauthoring、mcp-builder |
---
## 5. 导出管线
### 5.1 OpenAI Chat Completions 格式映射
`exportToOpenAIFormat(envelope)` 将 PromptEnvelope 导出为 OpenAI 兼容的请求体。
```
{ model, messages: OpenAIMessage[], tools?: OpenAITool[] }
```
| Protocol Segment | OpenAI 表示 |
|-----------------|------------|
| `text` | `message.content`string |
| `static_var` | 展开为变量值,出现在 content 中 |
| `system_prompt` | `system` role 消息的 content |
| `memory` | `system` role 消息的 content(格式化为 `- title: content` |
| `skills` | `system` role 消息的 content(格式化为 `- /name: description` |
| `tool_overview` | `system` 消息的 content + 顶层 `tools[]` 数组 |
| `tool_call_request` | assistant 消息,带 `tool_calls[]` |
| `tool_call_result` | `tool` role 消息,带 `tool_call_id` |
| `document` | `[Document: name (size)]\nsnippet` |
| `long_text` | content 原文 |
| `media` | 图片 → `image_url` content part;音频/视频 → altText 回退 |
### 5.2 关键导出行为
**结构性 Segment 聚合**system_prompt、memory、skills 等结构性 segment 被合并为一条 `role: "system"` 头部消息。
**静态变量模板展开**system prompt 中的 `{{var}}` 占位符在导出时被替换为实际值。`static_var` 本身不写入 content。
**多模态消息**:当 user 消息包含 `media` segment(图片)时,`content` 输出为 `OpenAIContentPart[]` 数组:
```json
{
"role": "user",
"content": [
{ "type": "text", "text": "这张图片是什么?" },
{ "type": "image_url", "image_url": { "url": "https://cataas.com/cat" } }
]
}
```
纯文本消息保持 `content: string` 格式不变,向后兼容。
**工具调用配对**`tool_call_request``tool_call_result` 通过 `tool_call_id` 配对,按 toolName 建立 FIFO 队列匹配。
---
## 6. JSON 协议文件格式
### 6.1 文件结构
```
src/data/demos/
├── manifest.json # 场景索引
├── demo-a.json ~ demo-f.json # 6 个 Demo 场景
├── prompt-envelope.schema.json # JSON Schema
├── demos.ts # 聚合入口
└── demos-loader.ts # 加载器
```
### 6.2 最小合法示例
```json
{
"version": "1.0",
"model": "gpt-4-turbo",
"messages": [
{
"id": "msg-1",
"role": "system",
"segments": [
{
"kind": "system_prompt",
"content": "你是一个有用的助手。",
"collapsed": true
}
],
"timestamp": 1780897800000
},
{
"id": "msg-2",
"role": "user",
"segments": [
{ "kind": "text", "content": "你好!" }
],
"timestamp": 1780897900000
},
{
"id": "msg-3",
"role": "assistant",
"segments": [
{ "kind": "text", "content": "你好!有什么可以帮助你的吗?" }
],
"timestamp": 1780898000000
}
]
}
```
### 6.3 设计约束
| 约束 | 说明 |
|------|------|
| 字段名 | camelCase,与 TypeScript 类型一致 |
| 时间戳 | Unix epoch 毫秒(绝对时间),不使用相对偏移 |
| 折叠默认值 | 由 `collapsed` 字段指定,协议数据作为初始值,UI 可运行时切换 |
| Skill body | 不入协议,由加载器按 name 从注册表补全 |
### 6.4 JSON Schema
`prompt-envelope.schema.json` 提供完整的 JSON SchemaDraft 2020-12)校验:
- `version` 必须为 `"1.0"`
- `messages` 非空
- `role` 仅限 `system` / `user` / `assistant` / `tool`
- `kind` 必须是 11 种之一
- 每种 Segment 的必填字段约束
- memory / skills / tool_overview 的 `items` 非空
VSCode 可通过 `$schema` 引用自动补全和实时校验。
---
## 7. 视觉设计规范
### 7.1 颜色系统
| 颜色 | HEX | 对应 Segment | 语义 |
|------|-----|-------------|------|
| 蓝色 | `#3B82F6` | `static_var` | 配置/变量 |
| 灰色 | `#6B7280` | `system_prompt` | 后台指令 |
| 紫色 | `#8B5CF6` | `memory` | 持久记忆 |
| 绿色 | `#10B981` | `skills` | 能力/技能 |
| 橙色 | `#F59E0B` | `tool_overview` | 工具/接口 |
| 深色 | `#1F2937` | `tool_call_request` | 代码/执行 |
| 绿色(成功) | `#22C55E` | `tool_call_result` | 成功 |
| 红色(失败) | `#EF4444` | `tool_call_result` | 失败 |
### 7.2 折叠策略
| Segment | 默认状态 | 理由 |
|---------|---------|------|
| `system_prompt` | 折叠 | 后台配置,非主要阅读内容 |
| `memory` | 折叠 | 参考信息,按需查看 |
| `skills` | 折叠 | 工具列表,按需展开 |
| `tool_overview` | 折叠 | Schema 细节冗长 |
| `tool_call_request` | **展开** | 调用过程需要可见 |
| `tool_call_result`(成功) | 折叠 | 结果正常 |
| `tool_call_result`(失败) | **展开** | 错误信息需要立即关注 |
| `long_text` | 折叠 | 默认仅显示预览 |
| `text` | N/A | 始终可见 |
### 7.3 布局
双栏布局:
- **左侧**`ChatView` — 聊天气泡列表,每条消息渲染其 Segments
- **右侧**`ProtocolPanel` — 双 tabOpenAI Format / Raw Protocol),显示导出的 JSON,支持复制和下载
- **顶栏**:Demo 场景切换按钮
---
## 8. Demo 场景
| 索引 | ID | 场景 | 内容 |
|------|----|------|------|
| 0 | a | 场景 A | 基础对话 + System Prompt + Memory |
| 1 | b | 场景 B | 工具调用:请求 → 执行(成功 & 失败) |
| 2 | c | 场景 C 📄 | 文档解析:点击「查看解析」看 AI 提取内容 |
| 3 | d | 场景 D ⭐ | 多模态对话:图片识别(猫) |
| 4 | f | 场景 F 📁 | 真实 Anthropic Skills |
| 5 | e | 场景 E 🔍 | 日志分析:异常检测 + 安全审计 + 性能分析 |
默认激活场景 F(索引 4)。
---
## 9. 相关资源
| 资源 | 路径 |
|------|------|
| 类型定义 | `src/types/protocol.ts` |
| 导出逻辑 | `src/utils/export.ts` |
| JSON Schema | `src/data/demos/prompt-envelope.schema.json` |
| 加载器 | `src/data/demos/demos-loader.ts` |
| 场景数据 | `src/data/demos/demo-*.json` |
| Skills 注册表 | `src/data/skills.ts` / `src/data/skills-loader.ts` |
| 渲染组件 | `src/components/SegmentRenderer.tsx` |
+69 -5
View File
@@ -1,7 +1,10 @@
import { useState } from 'react'
import { ChatProvider, useChat } from './context/ChatContext' import { ChatProvider, useChat } from './context/ChatContext'
import ChatView from './components/ChatView' import ChatView from './components/ChatView'
import ProtocolPanel from './components/ProtocolPanel' import ProtocolPanel from './components/ProtocolPanel'
import { Layers } from 'lucide-react' import ApiSettings from './components/ApiSettings'
import { Layers, Settings, FlaskConical, Zap } from 'lucide-react'
import { hasApiKey } from './services/api-config'
function DemoSelector() { function DemoSelector() {
const { demos, activeDemo, setActiveDemo } = useChat() const { demos, activeDemo, setActiveDemo } = useChat()
@@ -26,7 +29,16 @@ function DemoSelector() {
} }
function AppContent() { function AppContent() {
const { envelope, demos, activeDemo } = useChat() const { envelope, demos, activeDemo, isLive, setIsLive } = useChat()
const [settingsOpen, setSettingsOpen] = useState(false)
const handleToggleLive = () => {
if (!isLive && !hasApiKey()) {
// 首次切换到 Live 模式,自动弹出设置
setSettingsOpen(true)
}
setIsLive(!isLive)
}
return ( return (
<div className="h-screen flex flex-col"> <div className="h-screen flex flex-col">
@@ -37,12 +49,61 @@ function AppContent() {
<h1 className="text-sm font-bold text-gray-800"> <h1 className="text-sm font-bold text-gray-800">
Prompt Envelope Protocol Prompt Envelope Protocol
</h1> </h1>
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded"> {isLive ? (
MVP <span className="text-[10px] text-green-600 bg-green-50 px-1.5 py-0.5 rounded flex items-center gap-1">
</span> <Zap size={10} /> Live
</span>
) : (
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">
MVP
</span>
)}
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
{/* 模式切换 */}
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
<button
onClick={() => !isLive && handleToggleLive()}
disabled={isLive}
className={`px-3 py-1 rounded-md text-xs font-medium transition-all ${
isLive
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-400 cursor-pointer hover:text-gray-600'
}`}
>
Live
</button>
<button
onClick={() => isLive && setIsLive(false)}
disabled={!isLive}
className={`px-3 py-1 rounded-md text-xs font-medium transition-all ${
!isLive
? 'bg-white text-gray-600 shadow-sm'
: 'text-gray-400 cursor-pointer hover:text-gray-600'
}`}
>
Demo
</button>
</div>
<DemoSelector /> <DemoSelector />
{/* 设置齿轮(仅 Live 模式) */}
<button
onClick={() => setSettingsOpen(true)}
className={`p-1.5 rounded-lg transition-colors ${
isLive
? 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'
: 'text-gray-300 cursor-default'
}`}
title="API 设置"
disabled={!isLive}
>
<Settings size={16} />
</button>
<span className="text-[10px] text-gray-400 bg-blue-50 px-2 py-1 rounded max-w-xs truncate"> <span className="text-[10px] text-gray-400 bg-blue-50 px-2 py-1 rounded max-w-xs truncate">
{demos[activeDemo].description} {demos[activeDemo].description}
</span> </span>
@@ -53,6 +114,9 @@ function AppContent() {
<ChatView /> <ChatView />
<ProtocolPanel envelope={envelope} /> <ProtocolPanel envelope={envelope} />
</div> </div>
{/* API 设置模态框 */}
<ApiSettings open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div> </div>
) )
} }
+148
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { exportToOpenAIFormat, segmentToText } from '../utils/export' import { exportToOpenAIFormat, segmentToText } from '../utils/export'
import type { OpenAIExport, OpenAIContentPart, OpenAIImageUrlContent } from '../utils/export'
import type { PromptEnvelope } from '../types/protocol' import type { PromptEnvelope } from '../types/protocol'
describe('segmentToText', () => { describe('segmentToText', () => {
@@ -487,6 +488,55 @@ describe('exportToOpenAIFormat', () => {
expect(messages[2].content).toBe('B results') expect(messages[2].content).toBe('B results')
}) })
it('handles standalone role:"tool" message with tool_call_result', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'assistant',
segments: [
{
kind: 'tool_call_request',
toolName: 'calculate',
arguments: { expression: '2+3' },
collapsed: false,
},
],
timestamp: 0,
},
{
id: '2',
role: 'tool',
segments: [
{
kind: 'tool_call_result',
toolName: 'calculate',
result: '5',
success: true,
collapsed: true,
},
],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
// assistant (tool_calls) + tool (result)
expect(messages).toHaveLength(2)
const assistantMsg = messages[0]
expect(assistantMsg.role).toBe('assistant')
expect(assistantMsg.tool_calls).toHaveLength(1)
expect(assistantMsg.tool_calls![0].function.name).toBe('calculate')
const toolMsg = messages[1]
expect(toolMsg.role).toBe('tool')
expect(toolMsg.tool_call_id).toBe(assistantMsg.tool_calls![0].id)
expect(toolMsg.content).toBe('5')
})
it('omits tools key when no tool_overview present', () => { it('omits tools key when no tool_overview present', () => {
const env: PromptEnvelope = { const env: PromptEnvelope = {
version: '1.0', version: '1.0',
@@ -502,4 +552,102 @@ describe('exportToOpenAIFormat', () => {
const result = exportToOpenAIFormat(env) const result = exportToOpenAIFormat(env)
expect(result.tools).toBeUndefined() expect(result.tools).toBeUndefined()
}) })
// ── Multimodal: 图片 URL 应正确传入 OpenAI content 数组 ──
it('exports user message with image as content array (text + image_url)', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'user',
segments: [
{ kind: 'text', content: '这张图片是什么?' },
{
kind: 'media',
mediaType: 'image',
url: 'https://cataas.com/cat',
altText: '用户上传的图片',
},
],
timestamp: 0,
},
{
id: '2',
role: 'assistant',
segments: [{ kind: 'text', content: '这是一只猫。' }],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
expect(messages).toHaveLength(2)
const userMsg = messages[0]
expect(userMsg.role).toBe('user')
expect(Array.isArray(userMsg.content)).toBe(true)
const parts = userMsg.content as OpenAIContentPart[]
expect(parts).toHaveLength(2)
expect(parts[0]).toEqual({ type: 'text', text: '这张图片是什么?' })
expect(parts[1]).toEqual({
type: 'image_url',
image_url: { url: 'https://cataas.com/cat' },
})
// assistant 仍为纯文本
expect(messages[1].role).toBe('assistant')
expect(messages[1].content).toBe('这是一只猫。')
})
it('exports user message with only image (no text)', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'user',
segments: [
{
kind: 'media',
mediaType: 'image',
url: 'https://example.com/photo.png',
},
],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
expect(messages).toHaveLength(1)
const parts = messages[0].content as OpenAIContentPart[]
expect(Array.isArray(parts)).toBe(true)
expect(parts).toHaveLength(1)
const img = parts[0] as OpenAIImageUrlContent
expect(img.type).toBe('image_url')
expect(img.image_url.url).toBe('https://example.com/photo.png')
})
it('uses plain string content when no media segment present (backward compat)', () => {
const env: PromptEnvelope = {
version: '1.0',
messages: [
{
id: '1',
role: 'user',
segments: [
{ kind: 'text', content: 'Hello' },
{ kind: 'text', content: 'World' },
],
timestamp: 0,
},
],
}
const { messages } = exportToOpenAIFormat(env)
expect(typeof messages[0].content).toBe('string')
expect(messages[0].content).toContain('Hello')
expect(messages[0].content).toContain('World')
})
}) })
+436
View File
@@ -0,0 +1,436 @@
import { describe, it, expect } from 'vitest'
import {
importFromOpenAIResponse,
importToolResult,
StreamingImporter,
} from '../utils/import'
import type { OpenAIMessage } from '../utils/export'
// ============================================================
// importFromOpenAIResponse
// ============================================================
describe('importFromOpenAIResponse', () => {
it('纯文本响应 → 单 text segment', () => {
const msg: OpenAIMessage = {
role: 'assistant',
content: '你好,我是 AI 助手。',
}
const result = importFromOpenAIResponse(msg, 'stop')
expect(result.role).toBe('assistant')
expect(result.segments).toHaveLength(1)
expect(result.segments[0]).toEqual({
kind: 'text',
content: '你好,我是 AI 助手。',
})
expect(result.id).toMatch(/^msg_\d+_\d+$/)
expect(result.timestamp).toBeGreaterThan(0)
})
it('带 tool_calls 的响应 → text + tool_call_request segments', () => {
const msg: OpenAIMessage = {
role: 'assistant',
content: '我来帮你搜索一下。',
tool_calls: [
{
id: 'call_001',
type: 'function',
function: {
name: 'search',
arguments: '{"query":"HCI 设计","limit":5}',
},
},
],
}
const result = importFromOpenAIResponse(msg, 'tool_calls')
expect(result.role).toBe('assistant')
expect(result.segments).toHaveLength(2)
// 第一个 segment: text
expect(result.segments[0]).toEqual({
kind: 'text',
content: '我来帮你搜索一下。',
})
// 第二个 segment: tool_call_request
expect(result.segments[1]).toEqual({
kind: 'tool_call_request',
toolName: 'search',
arguments: { query: 'HCI 设计', limit: 5 },
collapsed: false,
})
})
it('纯 tool_callscontent=null)→ 仅 tool_call_request segments', () => {
const msg: OpenAIMessage = {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_a',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"city":"北京"}',
},
},
{
id: 'call_b',
type: 'function',
function: {
name: 'get_time',
arguments: '{"timezone":"Asia/Shanghai"}',
},
},
],
}
const result = importFromOpenAIResponse(msg, 'tool_calls')
expect(result.role).toBe('assistant')
expect(result.segments).toHaveLength(2)
expect(result.segments[0]).toMatchObject({
kind: 'tool_call_request',
toolName: 'get_weather',
arguments: { city: '北京' },
})
expect(result.segments[1]).toMatchObject({
kind: 'tool_call_request',
toolName: 'get_time',
arguments: { timezone: 'Asia/Shanghai' },
})
})
it('多 tool_calls → 对应多个 tool_call_request segments', () => {
const msg: OpenAIMessage = {
role: 'assistant',
content: '并行查询:',
tool_calls: [
{
id: 'c1',
type: 'function',
function: { name: 'search', arguments: '{"q":"A"}' },
},
{
id: 'c2',
type: 'function',
function: { name: 'search', arguments: '{"q":"B"}' },
},
{
id: 'c3',
type: 'function',
function: { name: 'fetch', arguments: '{"url":"x"}' },
},
],
}
const result = importFromOpenAIResponse(msg, 'tool_calls')
expect(result.segments).toHaveLength(4) // 1 text + 3 tool_calls
expect(result.segments[0]).toMatchObject({ kind: 'text' })
expect(result.segments[1]).toMatchObject({
kind: 'tool_call_request',
toolName: 'search',
arguments: { q: 'A' },
})
expect(result.segments[2]).toMatchObject({
kind: 'tool_call_request',
toolName: 'search',
arguments: { q: 'B' },
})
expect(result.segments[3]).toMatchObject({
kind: 'tool_call_request',
toolName: 'fetch',
arguments: { url: 'x' },
})
})
it('finish_reason="length" → 追加截断标记', () => {
const msg: OpenAIMessage = {
role: 'assistant',
content: '这是一段很长的回复被截断了...',
}
const result = importFromOpenAIResponse(msg, 'length')
expect(result.segments).toHaveLength(2)
expect(result.segments[0]).toEqual({
kind: 'text',
content: '这是一段很长的回复被截断了...',
})
expect(result.segments[1]).toEqual({
kind: 'text',
content: '[因长度限制被截断]',
})
})
it('finish_reason="stop" → 无额外处理', () => {
const msg: OpenAIMessage = {
role: 'assistant',
content: '正常结束。',
}
const result = importFromOpenAIResponse(msg, 'stop')
expect(result.segments).toHaveLength(1)
expect(result.segments[0]).toEqual({
kind: 'text',
content: '正常结束。',
})
})
it('空 content + 无 tool_calls → 防御占位 segment', () => {
const msg: OpenAIMessage = {
role: 'assistant',
content: '',
}
const result = importFromOpenAIResponse(msg, 'stop')
expect(result.segments).toHaveLength(1)
expect(result.segments[0]).toEqual({
kind: 'text',
content: '[空响应]',
})
})
it('非法 JSON arguments → _raw 字段保留原始字符串', () => {
const msg: OpenAIMessage = {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'bad',
type: 'function',
function: { name: 'parse', arguments: 'not-valid-json' },
},
],
}
const result = importFromOpenAIResponse(msg, 'tool_calls')
expect(result.segments).toHaveLength(1)
expect(result.segments[0]).toMatchObject({
kind: 'tool_call_request',
toolName: 'parse',
arguments: { _raw: 'not-valid-json' },
})
})
it('支持自定义 id 和 timestamp', () => {
const msg: OpenAIMessage = {
role: 'assistant',
content: 'test',
}
const result = importFromOpenAIResponse(msg, 'stop', {
id: 'custom-id',
timestamp: 1234567890000,
})
expect(result.id).toBe('custom-id')
expect(result.timestamp).toBe(1234567890000)
})
})
// ============================================================
// importToolResult
// ============================================================
describe('importToolResult', () => {
it('成功的 tool 结果 → success=true, collapsed=true', () => {
const result = importToolResult('search', '找到 3 条结果', true)
expect(result.role).toBe('tool')
expect(result.segments).toHaveLength(1)
expect(result.segments[0]).toEqual({
kind: 'tool_call_result',
toolName: 'search',
result: '找到 3 条结果',
success: true,
collapsed: true,
})
expect(result.id).toMatch(/^msg_\d+_\d+$/)
})
it('失败的 tool 结果 → success=false, collapsed=false(默认展开以便查看错误)', () => {
const result = importToolResult('fetch', 'Connection refused', false, false)
expect(result.role).toBe('tool')
expect(result.segments).toHaveLength(1)
expect(result.segments[0]).toMatchObject({
kind: 'tool_call_result',
toolName: 'fetch',
result: 'Connection refused',
success: false,
collapsed: false,
})
})
})
// ============================================================
// StreamingImporter
// ============================================================
describe('StreamingImporter', () => {
it('纯文本流式 delta → 逐字拼接', () => {
const importer = new StreamingImporter()
importer.ingestDelta({ role: 'assistant' })
importer.ingestDelta({ content: '你好' })
importer.ingestDelta({ content: ',世界' })
importer.ingestDelta({ content: '' })
const msg = importer.toMessage()
expect(msg.role).toBe('assistant')
expect(msg.segments).toHaveLength(1)
expect(msg.segments[0]).toEqual({
kind: 'text',
content: '你好,世界!',
})
})
it('toPartialMessage 返回当前部分内容(用于流式渲染)', () => {
const importer = new StreamingImporter()
importer.ingestDelta({ content: 'Hello' })
const partial1 = importer.toPartialMessage()
expect(partial1.segments[0]).toEqual({ kind: 'text', content: 'Hello' })
importer.ingestDelta({ content: ' World' })
const partial2 = importer.toPartialMessage()
expect(partial2.segments[0]).toEqual({ kind: 'text', content: 'Hello World' })
})
it('tool_call 流式 delta → 按 index 累积 arguments', () => {
const importer = new StreamingImporter()
// 模拟真实的 tool_call 流式片段
importer.ingestDelta({
tool_calls: [
{ index: 0, id: 'call_001', type: 'function', function: { name: 'search', arguments: '' } },
],
})
importer.ingestDelta({
tool_calls: [{ index: 0, function: { arguments: '{"query"' } }],
})
importer.ingestDelta({
tool_calls: [{ index: 0, function: { arguments: ':"HCI"' } }],
})
// 此时 arguments = '{"query":"HCI"' —— 缺少结尾 },无法 parse
// 部分模式下不应出现 tool_call segment
const partial = importer.toPartialMessage()
const toolSegments = partial.segments.filter((s) => s.kind === 'tool_call_request')
expect(toolSegments).toHaveLength(0)
// 补充最后一片 —— arguments 完整
importer.ingestDelta({
tool_calls: [{ index: 0, function: { arguments: '}' } }],
})
// 最终模式下,arguments 完整,应正确解析
const final = importer.toMessage()
expect(final.segments).toHaveLength(1)
expect(final.segments[0]).toMatchObject({
kind: 'tool_call_request',
toolName: 'search',
arguments: { query: 'HCI' },
})
})
it('多个 tool_call 并行流式 → 正确分离', () => {
const importer = new StreamingImporter()
// tool 0
importer.ingestDelta({
tool_calls: [
{ index: 0, id: 'c0', function: { name: 'search', arguments: '{"q":"A"}' } },
],
})
// tool 1
importer.ingestDelta({
tool_calls: [
{ index: 1, id: 'c1', function: { name: 'fetch', arguments: '{"url":"x"}' } },
],
})
const msg = importer.toMessage()
expect(msg.segments).toHaveLength(2)
expect(msg.segments[0]).toMatchObject({
kind: 'tool_call_request',
toolName: 'search',
arguments: { q: 'A' },
})
expect(msg.segments[1]).toMatchObject({
kind: 'tool_call_request',
toolName: 'fetch',
arguments: { url: 'x' },
})
})
it('混合流式:文本 + tool_call 交替出现', () => {
const importer = new StreamingImporter()
importer.ingestDelta({ content: '让我' })
importer.ingestDelta({ content: '查一下。' })
importer.ingestDelta({
tool_calls: [
{ index: 0, id: 'c0', function: { name: 'search', arguments: '{"q":"天气"}' } },
],
})
const msg = importer.toMessage()
expect(msg.segments).toHaveLength(2)
expect(msg.segments[0]).toEqual({
kind: 'text',
content: '让我查一下。',
})
expect(msg.segments[1]).toMatchObject({
kind: 'tool_call_request',
toolName: 'search',
})
})
it('reset() 清空所有累积状态', () => {
const importer = new StreamingImporter()
importer.ingestDelta({ content: 'Hello' })
importer.ingestDelta({
tool_calls: [{ index: 0, function: { name: 't1', arguments: '{}' } }],
})
importer.reset()
expect(importer.content).toBe('')
expect(importer.hasPendingToolCalls).toBe(false)
const msg = importer.toMessage()
expect(msg.segments[0]).toEqual({ kind: 'text', content: '[空响应]' })
})
it('空 delta 累积 → toMessage 返回防御占位', () => {
const importer = new StreamingImporter()
const msg = importer.toMessage()
expect(msg.segments).toHaveLength(1)
expect(msg.segments[0]).toEqual({ kind: 'text', content: '[空响应]' })
})
it('toPartialMessage 空内容时返回省略号占位', () => {
const importer = new StreamingImporter()
const partial = importer.toPartialMessage()
expect(partial.segments[0]).toEqual({ kind: 'text', content: '…' })
})
})
+137
View File
@@ -0,0 +1,137 @@
import { useState, useEffect } from 'react'
import { X, Eye, EyeOff } from 'lucide-react'
import { getApiConfig, setApiConfig, type ApiConfig } from '../services/api-config'
interface ApiSettingsProps {
open: boolean
onClose: () => void
}
export default function ApiSettings({ open, onClose }: ApiSettingsProps) {
const [config, setConfig] = useState<ApiConfig>(getApiConfig)
const [showKey, setShowKey] = useState(false)
const [saved, setSaved] = useState(false)
// 每次打开时从 localStorage 同步最新配置
useEffect(() => {
if (open) {
setConfig(getApiConfig())
setSaved(false)
}
}, [open])
if (!open) return null
const handleSave = () => {
setApiConfig(config)
setSaved(true)
setTimeout(() => {
onClose()
}, 600)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
onKeyDown={handleKeyDown}
>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h2 className="text-sm font-semibold text-gray-800">API </h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={18} />
</button>
</div>
{/* Form */}
<div className="px-5 py-4 space-y-4">
{/* Base URL */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Base URL
</label>
<input
type="text"
value={config.baseUrl}
onChange={(e) => setConfig({ ...config, baseUrl: e.target.value })}
placeholder="https://api.openai.com/v1"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
/>
<p className="text-[10px] text-gray-400 mt-1">
OpenAI API
</p>
</div>
{/* API Key */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
API Key
</label>
<div className="relative">
<input
type={showKey ? 'text' : 'password'}
value={config.apiKey}
onChange={(e) => setConfig({ ...config, apiKey: e.target.value })}
placeholder="sk-..."
className="w-full rounded-lg border border-gray-200 pl-3 pr-9 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded text-gray-400 hover:text-gray-600 transition-colors"
title={showKey ? '隐藏' : '显示'}
>
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<p className="text-[10px] text-gray-400 mt-1">
localStorage
</p>
</div>
{/* Model */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Model
</label>
<input
type="text"
value={config.model}
onChange={(e) => setConfig({ ...config, model: e.target.value })}
placeholder="gpt-4-turbo"
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-3 border-t border-gray-100 bg-gray-50">
<span className="text-[10px] text-gray-400">
</span>
<button
onClick={handleSave}
disabled={!config.apiKey.trim() || !config.baseUrl.trim()}
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-all ${
saved
? 'bg-green-500 text-white'
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-40'
}`}
>
{saved ? '已保存 ✓' : '保存'}
</button>
</div>
</div>
</div>
)
}
+48 -8
View File
@@ -1,28 +1,68 @@
import { Send } from 'lucide-react' import { useRef } from 'react'
import { Send, Loader2 } from 'lucide-react'
interface ChatInputProps { interface ChatInputProps {
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
disabled?: boolean disabled?: boolean
loading?: boolean
onSend?: () => void
placeholder?: string
} }
export default function ChatInput({ value, onChange, disabled }: ChatInputProps) { export default function ChatInput({
value,
onChange,
disabled,
loading,
onSend,
placeholder,
}: ChatInputProps) {
const inputRef = useRef<HTMLInputElement>(null)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (!disabled && !loading && value.trim() && onSend) {
onSend()
}
}
}
const handleSend = () => {
if (!disabled && !loading && value.trim() && onSend) {
onSend()
}
}
const defaultPlaceholder = disabled
? 'Demo 模式 — 切换到 Live 模式即可发送消息'
: '输入消息...'
return ( return (
<div className="border-t border-gray-200 bg-white px-4 py-3"> <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"> <div className="flex items-center gap-2 max-w-3xl mx-auto">
<input <input
ref={inputRef}
type="text" type="text"
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
disabled={disabled} onKeyDown={handleKeyDown}
placeholder="输入消息(Demo 模式 — 不会调用 LLM" disabled={disabled || loading}
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" placeholder={placeholder ?? defaultPlaceholder}
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 transition-colors"
/> />
<button <button
disabled={disabled || !value.trim()} onClick={handleSend}
className="shrink-0 rounded-lg bg-blue-500 p-2 text-white hover:bg-blue-600 disabled:opacity-40 transition-opacity" disabled={disabled || loading || !value.trim()}
className="shrink-0 rounded-lg bg-blue-500 p-2 text-white hover:bg-blue-600 disabled:opacity-40 transition-all"
title="发送 (Enter)"
> >
<Send size={18} /> {loading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</button> </button>
</div> </div>
</div> </div>
+30 -2
View File
@@ -2,15 +2,43 @@ import { useState } from 'react'
import { useChat } from '../context/ChatContext' import { useChat } from '../context/ChatContext'
import MessageList from './MessageList' import MessageList from './MessageList'
import ChatInput from './ChatInput' import ChatInput from './ChatInput'
import { AlertCircle, X } from 'lucide-react'
export default function ChatView() { export default function ChatView() {
const { envelope } = useChat() const { envelope, isLive, isLoading, sendMessage, error, clearError } = useChat()
const [input, setInput] = useState('') const [input, setInput] = useState('')
const handleSend = () => {
if (!input.trim() || isLoading) return
sendMessage(input.trim())
setInput('')
}
return ( return (
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
{/* 错误提示条 */}
{error && (
<div className="flex items-center gap-2 mx-3 mt-2 px-3 py-2 rounded-lg bg-red-50 border border-red-200 text-red-700 text-xs">
<AlertCircle size={14} className="shrink-0" />
<span className="flex-1">{error}</span>
<button
onClick={clearError}
className="shrink-0 p-0.5 rounded hover:bg-red-100 transition-colors"
>
<X size={14} />
</button>
</div>
)}
<MessageList messages={envelope.messages} /> <MessageList messages={envelope.messages} />
<ChatInput value={input} onChange={setInput} disabled />
<ChatInput
value={input}
onChange={setInput}
disabled={!isLive}
loading={isLoading}
onSend={handleSend}
/>
</div> </div>
) )
} }
+65 -16
View File
@@ -7,29 +7,52 @@ interface ProtocolPanelProps {
envelope: PromptEnvelope envelope: PromptEnvelope
} }
type Tab = 'openai' | 'raw'
export default function ProtocolPanel({ envelope }: ProtocolPanelProps) { export default function ProtocolPanel({ envelope }: ProtocolPanelProps) {
const [activeTab, setActiveTab] = useState<Tab>('openai')
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const openaiFormat = useMemo(() => exportToOpenAIFormat(envelope), [envelope]) const openaiFormat = useMemo(() => exportToOpenAIFormat(envelope), [envelope])
const rawProtocolJson = useMemo(() => JSON.stringify(envelope, null, 2), [envelope])
const displayedJson = activeTab === 'openai'
? JSON.stringify(openaiFormat, null, 2)
: rawProtocolJson
const downloadFilename = activeTab === 'openai'
? 'openai-export.json'
: 'prompt-envelope.json'
const handleCopy = async () => { const handleCopy = async () => {
await navigator.clipboard.writeText(JSON.stringify(openaiFormat, null, 2)) await navigator.clipboard.writeText(displayedJson)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
const handleDownload = () => { const handleDownload = () => {
const blob = new Blob([JSON.stringify(openaiFormat, null, 2)], { const blob = new Blob([displayedJson], { type: 'application/json' })
type: 'application/json',
})
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = 'openai-export.json' a.download = downloadFilename
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
/** 统计 envelope 中的 segment 数量 */
const segmentStats = useMemo(() => {
const counts: Record<string, number> = {}
for (const msg of envelope.messages) {
for (const seg of msg.segments) {
counts[seg.kind] = (counts[seg.kind] || 0) + 1
}
}
return counts
}, [envelope])
const totalSegments = Object.values(segmentStats).reduce((a, b) => a + b, 0)
return ( return (
<div className="w-96 border-l border-gray-200 bg-white flex flex-col h-full"> <div className="w-96 border-l border-gray-200 bg-white flex flex-col h-full">
{/* Header */} {/* Header */}
@@ -56,27 +79,53 @@ export default function ProtocolPanel({ envelope }: ProtocolPanelProps) {
{/* Tabs */} {/* Tabs */}
<div className="flex text-xs border-b border-gray-100"> <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"> <button
onClick={() => setActiveTab('openai')}
className={`flex-1 text-center py-2 transition-colors cursor-pointer ${
activeTab === 'openai'
? 'font-semibold text-blue-600 border-b-2 border-blue-500 bg-blue-50/50'
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
OpenAI Format OpenAI Format
</div> </button>
<div className="flex-1 text-center py-2 text-gray-400"> <button
Raw Protocol (soon) onClick={() => setActiveTab('raw')}
</div> className={`flex-1 text-center py-2 transition-colors cursor-pointer ${
activeTab === 'raw'
? 'font-semibold text-blue-600 border-b-2 border-blue-500 bg-blue-50/50'
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
}`}
>
Raw Protocol
</button>
</div> </div>
{/* JSON Content */} {/* JSON Content */}
<div className="flex-1 overflow-y-auto"> <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"> <pre className="p-4 text-xs font-mono text-gray-600 whitespace-pre-wrap break-all leading-relaxed">
{JSON.stringify(openaiFormat, null, 2)} {displayedJson}
</pre> </pre>
</div> </div>
{/* Footer stats */} {/* Footer stats */}
<div className="px-4 py-2 border-t border-gray-100 text-[10px] text-gray-400 flex items-center gap-3"> <div className="px-4 py-2 border-t border-gray-100 text-[10px] text-gray-400 flex items-center gap-3 flex-wrap">
<span>model: {openaiFormat.model}</span> {activeTab === 'openai' ? (
<span>{envelope.messages.length} </span> <>
<span>{openaiFormat.messages.length} OpenAI messages</span> <span>model: {openaiFormat.model}</span>
{openaiFormat.tools && <span>{openaiFormat.tools.length} tools</span>} <span>{envelope.messages.length} </span>
<span>{openaiFormat.messages.length} OpenAI messages</span>
{openaiFormat.tools && <span>{openaiFormat.tools.length} tools</span>}
</>
) : (
<>
<span>version: {envelope.version}</span>
{envelope.model && <span>model: {envelope.model}</span>}
<span>{envelope.messages.length} </span>
<span>{totalSegments} segment</span>
<span>{Object.keys(segmentStats).length} </span>
</>
)}
</div> </div>
</div> </div>
) )
+46 -8
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import type { MediaSegment } from '../../types/protocol' import type { MediaSegment } from '../../types/protocol'
import { Image, Music, Video } from 'lucide-react' import { Image, Music, Video, ExternalLink, AlertTriangle } from 'lucide-react'
const mediaConfig = { const mediaConfig = {
image: { icon: Image, label: '图片', bg: 'bg-blue-50', color: 'text-blue-600' }, image: { icon: Image, label: '图片', bg: 'bg-blue-50', color: 'text-blue-600' },
@@ -10,17 +11,54 @@ const mediaConfig = {
export default function MediaView({ segment }: { segment: MediaSegment }) { export default function MediaView({ segment }: { segment: MediaSegment }) {
const cfg = mediaConfig[segment.mediaType] const cfg = mediaConfig[segment.mediaType]
const Icon = cfg.icon const Icon = cfg.icon
const [imgError, setImgError] = useState(false)
return ( // 图片有 URL → 直接渲染缩略图
<div className={`my-2 flex items-center gap-3 rounded-lg border px-3 py-2.5 ${cfg.bg} border-gray-200`}> if (segment.mediaType === 'image' && segment.url && !imgError) {
<Icon size={20} className={cfg.color} /> return (
<div className="flex-1"> <div className="my-2">
<span className={`text-sm font-medium ${cfg.color}`}>{cfg.label}</span> <img
src={segment.url}
alt={segment.altText || '图片'}
className="max-w-full max-h-80 rounded-lg border border-gray-200 object-cover"
onError={() => setImgError(true)}
/>
{segment.altText && ( {segment.altText && (
<span className="text-xs text-gray-500 ml-2">{segment.altText}</span> <div className="mt-1 text-xs text-gray-400">{segment.altText}</div>
)} )}
</div> </div>
<span className="text-xs text-gray-400">{segment.mediaType}</span> )
}
// 图片加载失败 / 无 URL / 非图片类型 → 图标占位
return (
<div className={`my-2 flex items-center gap-3 rounded-lg border px-3 py-2.5 ${cfg.bg} border-gray-200`}>
{imgError ? (
<AlertTriangle size={20} className="text-amber-500" />
) : (
<Icon size={20} className={cfg.color} />
)}
<div className="flex-1 min-w-0">
<span className={`text-sm font-medium ${imgError ? 'text-amber-600' : cfg.color}`}>
{imgError ? '图片加载失败' : cfg.label}
</span>
{segment.altText && (
<span className="text-xs text-gray-500 ml-2 truncate">{segment.altText}</span>
)}
</div>
{segment.url ? (
<a
href={segment.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600 transition-colors"
>
<ExternalLink size={12} />
</a>
) : (
<span className="text-xs text-gray-400">{segment.mediaType}</span>
)}
</div> </div>
) )
} }
+202 -9
View File
@@ -1,6 +1,17 @@
import { createContext, useContext, useState, type ReactNode } from 'react' import {
import type { PromptEnvelope } from '../types/protocol' createContext,
useContext,
useState,
useCallback,
useRef,
type ReactNode,
} from 'react'
import type { PromptEnvelope, Message } from '../types/protocol'
import { demos } from '../data/demos' import { demos } from '../data/demos'
import { getApiConfig, hasApiKey } from '../services/api-config'
import { sendChatRequest } from '../services/api'
import { importToolResult } from '../utils/import'
import { executeToolCall, buildLiveSystemMessage } from '../services/tools'
interface ChatContextValue { interface ChatContextValue {
envelope: PromptEnvelope envelope: PromptEnvelope
@@ -8,18 +19,194 @@ interface ChatContextValue {
demos: typeof demos demos: typeof demos
activeDemo: number activeDemo: number
setActiveDemo: (i: number) => void setActiveDemo: (i: number) => void
isLive: boolean
setIsLive: (v: boolean) => void
isLoading: boolean
sendMessage: (text: string) => Promise<void>
error: string | null
clearError: () => void
} }
const ChatContext = createContext<ChatContextValue | null>(null) const ChatContext = createContext<ChatContextValue | null>(null)
export function ChatProvider({ children }: { children: ReactNode }) { /** 生成唯一消息 ID */
const [activeDemo, setActiveDemo] = useState(4) // Default: Scene F let msgCounter = 0
const [envelope, setEnvelope] = useState<PromptEnvelope>(demos[4].envelope) function genMsgId(): string {
return `live_${Date.now()}_${++msgCounter}`
}
const switchDemo = (i: number) => { /** Tool loop 最大迭代次数,防止死循环 */
setActiveDemo(i) const MAX_TOOL_ITERATIONS = 5
setEnvelope(demos[i].envelope)
} export function ChatProvider({ children }: { children: ReactNode }) {
const [activeDemo, setActiveDemo] = useState(0)
const [envelope, setEnvelope] = useState<PromptEnvelope>(demos[0].envelope)
const [isLive, setIsLive] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Live 模式下的完整消息列表(system + 所有 user/assistant/tool 对话历史)
const liveMessagesRef = useRef<Message[]>([])
const clearError = useCallback(() => setError(null), [])
// ========================================================
// 模式切换
// ========================================================
/**
* 构建 Live 模式的初始 envelope。
* 使用专用的简单系统上下文(只含 calculate 工具),
* 不依赖 demo 的 system context。
*/
const buildLiveEnvelope = useCallback((): PromptEnvelope => {
return {
version: '1.0' as const,
model: 'gpt-4-turbo',
messages: [buildLiveSystemMessage()],
}
}, [])
/** 切换 Demo 场景 */
const switchDemo = useCallback(
(i: number) => {
setActiveDemo(i)
if (isLive) {
// Live 模式切换 Demo:清空对话但保持 Live 系统上下文
const newEnv = buildLiveEnvelope()
liveMessagesRef.current = newEnv.messages
setEnvelope(newEnv)
} else {
setEnvelope(demos[i].envelope)
}
setError(null)
},
[isLive, buildLiveEnvelope]
)
/** Demo ↔ Live 切换 */
const handleSetIsLive = useCallback(
(v: boolean) => {
if (v === isLive) return
if (v) {
const newEnv = buildLiveEnvelope()
liveMessagesRef.current = newEnv.messages
setEnvelope(newEnv)
} else {
setEnvelope(demos[activeDemo].envelope)
liveMessagesRef.current = []
}
setIsLive(v)
setError(null)
setIsLoading(false)
},
[isLive, activeDemo, buildLiveEnvelope]
)
// ========================================================
// 发送消息(含 Tool Execution Loop
// ========================================================
const sendMessage = useCallback(
async (text: string) => {
if (!text.trim() || isLoading) return
if (!hasApiKey()) {
setError('请先配置 API Key(点击右上角齿轮图标)')
return
}
const config = getApiConfig()
clearError()
// 1. 构造用户消息
const userMsg: Message = {
id: genMsgId(),
role: 'user',
segments: [{ kind: 'text', content: text.trim() }],
timestamp: Date.now(),
}
// 追加到消息列表并立即渲染
liveMessagesRef.current = [...liveMessagesRef.current, userMsg]
setEnvelope({
version: '1.0',
model: 'gpt-4-turbo',
messages: liveMessagesRef.current,
})
setIsLoading(true)
// 2. Tool Execution Loop
try {
let iteration = 0
while (iteration < MAX_TOOL_ITERATIONS) {
iteration++
const currentEnvelope: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: liveMessagesRef.current,
}
// 发送非流式请求
const result = await sendChatRequest(currentEnvelope, config)
// sendChatRequest 返回 Message[],通常单条 assistant 消息
const assistantMsg = result[0]
if (!assistantMsg) {
throw new Error('API 返回空响应')
}
// 追加 assistant 消息(包含 text 和/或 tool_call_request segments
liveMessagesRef.current = [...liveMessagesRef.current, assistantMsg]
setEnvelope({
...currentEnvelope,
messages: liveMessagesRef.current,
})
// 检查是否有 tool_call 需要执行
const toolCalls = assistantMsg.segments.filter(
(s) => s.kind === 'tool_call_request'
)
if (toolCalls.length === 0) {
// 无 tool_call → 对话结束
break
}
// 执行每个 tool_call 并追加结果
for (const tc of toolCalls) {
const execResult = executeToolCall(tc.toolName, tc.arguments)
const toolMsg = importToolResult(
tc.toolName,
execResult.result,
execResult.success
)
liveMessagesRef.current = [...liveMessagesRef.current, toolMsg]
setEnvelope({
...currentEnvelope,
messages: liveMessagesRef.current,
})
}
// 继续循环 —— 将 tool result 送回 API
}
if (iteration >= MAX_TOOL_ITERATIONS) {
setError(`工具调用已达最大迭代次数 (${MAX_TOOL_ITERATIONS}),可能陷入循环`)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setIsLoading(false)
}
},
[isLoading, clearError]
)
return ( return (
<ChatContext.Provider <ChatContext.Provider
@@ -29,6 +216,12 @@ export function ChatProvider({ children }: { children: ReactNode }) {
demos, demos,
activeDemo, activeDemo,
setActiveDemo: switchDemo, setActiveDemo: switchDemo,
isLive,
setIsLive: handleSetIsLive,
isLoading,
sendMessage,
error,
clearError,
}} }}
> >
{children} {children}
+33 -44
View File
@@ -1,10 +1,22 @@
import type { PromptEnvelope } from '../types/protocol' import type { PromptEnvelope } from '../types/protocol'
import { demoA } from './demos/demo-a' import { loadEnvelope } from './demos/demos-loader'
import { demoB } from './demos/demo-b'
import { demoC } from './demos/demo-c' // ---- 从 JSON 文件导入 demo 数据 ----
import { demoD } from './demos/demo-d' import demoAJson from './demos/demo-a.json'
import { demoE } from './demos/demo-e' import demoBJson from './demos/demo-b.json'
import { demoF } from './demos/demo-f' import demoCJson from './demos/demo-c.json'
import demoDJson from './demos/demo-d.json'
import demoEJson from './demos/demo-e.json'
import demoFJson from './demos/demo-f.json'
import manifestJson from './demos/manifest.json'
// ---- 运行时验证 + skill body 水合 ----
const demoA = loadEnvelope(demoAJson)
const demoB = loadEnvelope(demoBJson)
const demoC = loadEnvelope(demoCJson)
const demoD = loadEnvelope(demoDJson)
const demoE = loadEnvelope(demoEJson)
const demoF = loadEnvelope(demoFJson)
export interface DemoScenario { export interface DemoScenario {
id: string id: string
@@ -13,41 +25,18 @@ export interface DemoScenario {
envelope: PromptEnvelope envelope: PromptEnvelope
} }
export const demos: DemoScenario[] = [ const envelopeMap: Record<string, PromptEnvelope> = {
{ a: demoA,
id: 'a', b: demoB,
label: '场景 A', c: demoC,
description: '基础对话 + System Prompt + Memory', d: demoD,
envelope: demoA, e: demoE,
}, f: demoF,
{ }
id: 'b',
label: '场景 B', export const demos: DemoScenario[] = manifestJson.scenarios.map((s) => ({
description: '工具调用:请求 → 执行(成功 & 失败)', id: s.id,
envelope: demoB, label: s.label,
}, description: s.description,
{ envelope: envelopeMap[s.id],
id: 'c', }))
label: '场景 C 📄',
description: '文档解析:点击「查看解析」看 AI 如何提取文档内容',
envelope: demoC,
},
{
id: 'd',
label: '场景 D ⭐',
description: '综合:全部 9 种 Segment',
envelope: demoD,
},
{
id: 'e',
label: '场景 E 🔍',
description: '日志分析:异常检测 + 安全审计 + 性能分析',
envelope: demoE,
},
{
id: 'f',
label: '场景 F 📁',
description: '真实 Anthropic SkillsSKILL.md 文件加载 + parseSkillMarkdown 解析)',
envelope: demoF,
},
]
+109
View File
@@ -0,0 +1,109 @@
{
"version": "1.0",
"model": "gpt-4-turbo",
"messages": [
{
"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": "当前日期:{{current_date}}\n用户名称:{{user_name}}\n回复语言:{{language}}\n\n你是 HCI 课程设计助手。你帮助学生对聊天界面的信息架构进行批判性思考。\n回答应简洁、有结构,鼓励学生从用户体验角度分析问题。\n如果学生对某个概念不清楚,用通俗的例子解释,不要用术语堆砌。",
"collapsed": true
},
{
"kind": "memory",
"description": "以下是从过往对话中总结的关于你的信息,模型会参考这些记忆来个性化回复。记忆随对话自动更新,你也可以手动编辑或删除。",
"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": 1780898100000
},
{
"id": "a-2",
"role": "user",
"segments": [
{
"kind": "text",
"content": "你好,我想讨论一下我设计的聊天协议方案。你觉得 9 种 prompt 类型的分类合理吗?"
}
],
"timestamp": 1780898160000
},
{
"id": "a-3",
"role": "assistant",
"segments": [
{
"kind": "text",
"content": "我觉得你的 9 种分类很全面!从信息架构的角度看,你已经覆盖了 LLM 上下文的三个主要来源:\n\n1. **静态配置**(变量、system prompt\n2. **能力声明**skills、tools\n3. **动态输入**(文档、长文本、多模态)\n\n加上工具调用的过程性信息,形成了一个完整的上下文可见性模型。这种分类本身就值得在课程设计中展开讨论。"
}
],
"timestamp": 1780898220000
}
]
}
-121
View File
@@ -1,121 +0,0 @@
import type { PromptEnvelope } from '../../types/protocol'
const now = Date.now()
export const demoA: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: [
{
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: `当前日期:{{current_date}}
用户名称:{{user_name}}
回复语言:{{language}}
你是 HCI 课程设计助手。你帮助学生对聊天界面的信息架构进行批判性思考。
回答应简洁、有结构,鼓励学生从用户体验角度分析问题。
如果学生对某个概念不清楚,用通俗的例子解释,不要用术语堆砌。`,
collapsed: true,
},
{
kind: 'memory',
description:
'以下是从过往对话中总结的关于你的信息,模型会参考这些记忆来个性化回复。记忆随对话自动更新,你也可以手动编辑或删除。',
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: '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,
},
],
}
+140
View File
@@ -0,0 +1,140 @@
{
"version": "1.0",
"model": "gpt-4-turbo",
"messages": [
{
"id": "b-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": "当前日期:{{current_date}},回复语言:{{language}}。\n\n你是一个编程助手,可以帮助用户编写、调试和优化代码。你可以在安全沙箱中执行代码,也可以查阅技术文档。",
"collapsed": true
},
{
"kind": "tool_overview",
"items": [
{
"name": "execute_code",
"description": "在安全沙箱中执行代码并返回输出",
"parameters": "language: string, code: string",
"schema": {
"type": "object",
"properties": {
"language": { "type": "string", "enum": ["python", "javascript", "typescript"], "description": "编程语言" },
"code": { "type": "string", "description": "要执行的代码" }
},
"required": ["language", "code"]
}
},
{
"name": "lookup_api",
"description": "查阅技术文档和 API 参考",
"parameters": "query: string, source: string",
"schema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "API 或函数名称" },
"source": { "type": "string", "enum": ["python", "mdn", "react", "node"], "description": "文档来源(必填)" }
},
"required": ["query", "source"]
}
}
],
"collapsed": true
}
],
"timestamp": 1780898200000
},
{
"id": "b-2",
"role": "user",
"segments": [
{
"kind": "text",
"content": "帮我写一个 Python 函数,统计文本中每个单词的出现频率,然后运行测试一下。"
}
],
"timestamp": 1780898220000
},
{
"id": "b-3",
"role": "assistant",
"segments": [
{
"kind": "text",
"content": "好的,我来写一个词频统计函数并用示例文本测试:"
},
{
"kind": "tool_call_request",
"toolName": "execute_code",
"arguments": {
"language": "python",
"code": "def word_frequency(text):\n \"\"\"统计文本中每个单词的出现频率,按频率降序排列\"\"\"\n import re\n words = re.findall(r'\\b\\w+\\b', text.lower())\n freq = {}\n for w in words:\n freq[w] = freq.get(w, 0) + 1\n return dict(sorted(freq.items(), key=lambda x: x[1], reverse=True))\n\nsample = \"hello world hello python world python python\"\nresult = word_frequency(sample)\nfor word, count in result.items():\n print(f\"{word}: {count}\")"
},
"collapsed": false
}
],
"timestamp": 1780898240000
},
{
"id": "b-4",
"role": "assistant",
"segments": [
{
"kind": "tool_call_result",
"toolName": "execute_code",
"success": true,
"result": "python: 3\nworld: 2\nhello: 2",
"collapsed": true
}
],
"timestamp": 1780898260000
},
{
"id": "b-5",
"role": "assistant",
"segments": [
{
"kind": "text",
"content": "运行成功!其实用 `collections.Counter` 可以让代码更简洁。让我查一下 Python 官方文档确认 Counter 的 API 细节——"
},
{
"kind": "tool_call_request",
"toolName": "lookup_api",
"arguments": {
"query": "collections.Counter most_common"
},
"collapsed": false
}
],
"timestamp": 1780898270000
},
{
"id": "b-6",
"role": "assistant",
"segments": [
{
"kind": "tool_call_result",
"toolName": "lookup_api",
"success": false,
"result": "Error: lookup_api requires \"source\" parameter (python | mdn | react | node). Please specify the documentation source and retry.",
"collapsed": false
}
],
"timestamp": 1780898280000
}
]
}
-160
View File
@@ -1,160 +0,0 @@
import type { PromptEnvelope } from '../../types/protocol'
const now = Date.now()
export const demoB: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: [
{
id: 'b-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: `当前日期:{{current_date}},回复语言:{{language}}。
你是一个编程助手,可以帮助用户编写、调试和优化代码。你可以在安全沙箱中执行代码,也可以查阅技术文档。`,
collapsed: true,
},
{
kind: 'tool_overview',
items: [
{
name: 'execute_code',
description: '在安全沙箱中执行代码并返回输出',
parameters: 'language: string, code: string',
schema: {
type: 'object',
properties: {
language: { type: 'string', enum: ['python', 'javascript', 'typescript'], description: '编程语言' },
code: { type: 'string', description: '要执行的代码' },
},
required: ['language', 'code'],
},
},
{
name: 'lookup_api',
description: '查阅技术文档和 API 参考',
parameters: 'query: string, source: string',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: 'API 或函数名称' },
source: { type: 'string', enum: ['python', 'mdn', 'react', 'node'], description: '文档来源(必填)' },
},
required: ['query', 'source'],
},
},
],
collapsed: true,
},
],
timestamp: now - 200000,
},
{
id: 'b-2',
role: 'user',
segments: [
{
kind: 'text',
content: '帮我写一个 Python 函数,统计文本中每个单词的出现频率,然后运行测试一下。',
},
],
timestamp: now - 180000,
},
{
id: 'b-3',
role: 'assistant',
segments: [
{
kind: 'text',
content: '好的,我来写一个词频统计函数并用示例文本测试:',
},
{
kind: 'tool_call_request',
toolName: 'execute_code',
arguments: {
language: 'python',
code: `def word_frequency(text):
"""统计文本中每个单词的出现频率,按频率降序排列"""
import re
words = re.findall(r'\\b\\w+\\b', text.lower())
freq = {}
for w in words:
freq[w] = freq.get(w, 0) + 1
return dict(sorted(freq.items(), key=lambda x: x[1], reverse=True))
sample = "hello world hello python world python python"
result = word_frequency(sample)
for word, count in result.items():
print(f"{word}: {count}")`,
},
collapsed: false,
},
],
timestamp: now - 160000,
},
{
id: 'b-4',
role: 'assistant',
segments: [
{
kind: 'tool_call_result',
toolName: 'execute_code',
success: true,
result: `python: 3
world: 2
hello: 2`,
collapsed: true,
},
],
timestamp: now - 140000,
},
{
id: 'b-5',
role: 'assistant',
segments: [
{
kind: 'text',
content: '运行成功!其实用 `collections.Counter` 可以让代码更简洁。让我查一下 Python 官方文档确认 Counter 的 API 细节——',
},
{
kind: 'tool_call_request',
toolName: 'lookup_api',
arguments: {
query: 'collections.Counter most_common',
},
collapsed: false,
},
],
timestamp: now - 130000,
},
{
id: 'b-6',
role: 'assistant',
segments: [
{
kind: 'tool_call_result',
toolName: 'lookup_api',
success: false,
result: 'Error: lookup_api requires "source" parameter (python | mdn | react | node). Please specify the documentation source and retry.',
collapsed: false,
},
],
timestamp: now - 120000,
},
],
}
+70
View File
@@ -0,0 +1,70 @@
{
"version": "1.0",
"model": "gpt-4-turbo",
"messages": [
{
"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": "当前日期:{{current_date}},回复语言:{{language}}。\n\n你是文档解析助手。帮助用户分析上传的文档、提取关键信息、回答关于文档内容的问题。\n\n解析文档后,你会输出结构化的解析结果,并在回复中注明关键发现。",
"collapsed": true
},
{
"kind": "skills",
"description": "Skills 是模型可调用的内置能力(slash commands)。当前对话中启用以下 skills",
"items": [
{ "name": "summarize", "description": "生成文档摘要 — 支持多种粒度(一句话/段落级/全文级)" },
{ "name": "translate", "description": "翻译文档内容 — 支持中英互译,保留原文格式" },
{ "name": "qa", "description": "基于文档回答具体问题 — 带引用溯源" }
],
"collapsed": true
}
],
"timestamp": 1780898250000
},
{
"id": "c-2",
"role": "user",
"segments": [
{
"kind": "text",
"content": "帮我解析这份市场研究报告,看看有什么关键发现。"
},
{
"kind": "document",
"fileName": "2026-Q2-智能助手市场报告.pdf",
"mimeType": "application/pdf",
"sizeBytes": 2845000,
"snippet": "# 2026 年 Q2 智能助手市场研究报告\n\n## 摘要\n\n2026 年 Q2 全球智能助手市场规模预计达到 187 亿美元,同比增长 34.2%。主要增长驱动力包括企业级 AI 助手采纳率提升…",
"parsedContent": "## 文档概览\n\n| 字段 | 内容 |\n|------|------|\n| 标题 | 2026 年 Q2 智能助手市场研究报告 |\n| 作者 | 市场分析部 · 陈敏 |\n| 日期 | 2026 年 5 月 28 日 |\n| 页数 | 42 页 |\n| 语言 | 中文(简体) |\n\n## 核心发现\n\n### 1. 市场规模\n2026 年 Q2 全球智能助手市场规模预计达到 **187 亿美元**,同比增长 **34.2%**。\n\n主要增长驱动力:\n- 企业级 AI 助手采纳率从 2025 年的 38% 提升至 56%\n- 多模态能力(图像/语音/代码)推动使用场景扩展\n- 开源模型的成熟降低了部署门槛\n\n### 2. 竞争格局\n\n| 厂商 | 市场份额 | QoQ 变化 | 核心优势 |\n|------|---------|---------|---------|\n| OpenAI | 41% | +2.3% | 品牌认知度 + 开发者生态 |\n| Anthropic | 18% | +5.1% | 安全对齐 + 长上下文 |\n| Google | 15% | -1.2% | 搜索整合 + 多模态 |\n| Meta | 12% | +3.8% | 开源策略 + 成本优势 |\n| 其他 | 14% | — | 垂直领域玩家 |\n\n### 3. 用户需求变化\n- **可信度**成为用户选择助手的第一要素(47% 受访者提及)\n- **上下文窗口大小**的优先级从 2025 年的第 7 位跃升至第 3 位\n- 企业用户对 `system prompt` 透明度的需求同比增长 **82%**\n- 工具调用(Function Calling)的使用率从 23% 增长到 51%\n\n### 4. 关键趋势\n> \"智能助手的竞争从模型能力转向体验设计。用户不再只关心'能不能答对',更关心'我能不能理解你为什么这么回答'。\"——报告结语"
}
],
"timestamp": 1780898280000
},
{
"id": "c-3",
"role": "assistant",
"segments": [
{
"kind": "text",
"content": "文档已解析完成。以下是报告中的关键发现:\n\n---\n\n## 📊 核心发现\n\n### 1. 市场规模与增长\n2026 年 Q2 全球智能助手市场规模预计达到 **187 亿美元**,同比增长 **34.2%**。\n\n主要驱动力:\n- 企业级 AI 助手采纳率从 38% 提升至 **56%**\n- 多模态能力推动使用场景扩展\n- 开源模型降低了部署门槛\n\n### 2. 竞争格局变化\n\n| 厂商 | 份额 | 环比变化 | 核心优势 |\n|------|------|---------|---------|\n| OpenAI | 41% | +2.3% | 品牌 + 开发者生态 |\n| Anthropic | 18% | **+5.1%** | 安全对齐 + 长上下文 |\n| Google | 15% | -1.2% | 搜索整合 + 多模态 |\n| Meta | 12% | +3.8% | 开源 + 成本优势 |\n\nAnthropic 的增长最快(+5.1%),说明 **安全对齐** 正在成为关键竞争力。\n\n### 3. 值得关注的趋势\n\n- **可信度** 是用户选择助手的首要因素(47% 受访者提及)\n- 企业用户对 `system prompt` 透明度的需求同比增长 **82%**\n- 工具调用(Function Calling)使用率从 23% 增长到 51%\n- 报告结语指出:**竞争正从模型能力转向体验设计**\n\n---\n\n需要我深入分析某个方面吗?点击文档的 **「查看解析」** 按钮可以看到 AI 提取的完整结构化内容。"
}
],
"timestamp": 1780898340000
}
]
}
-166
View File
@@ -1,166 +0,0 @@
/**
* Demo C — 文档解析(核心展示)
*
* 场景:用户上传多份文档请求 AI 进行解析,展示文档上传后 AI 如何
* 提取、结构化理解其中的内容。
*
* 关键展示:
* 1. 一份 PDF 文档,右上角有「查看解析」按钮
* 2. 点击后展开显示 AI 对文档的解析结果(Markdown 渲染)
* 3. Assistant 基于解析结果给出分析回答
* 4. 无 long_text / media / 多文档干扰 —— 聚焦单文档解析体验
*/
import type { PromptEnvelope } from '../../types/protocol'
import { getSkills } from '../skills'
const now = Date.now()
/** PDF 市场调研报告的解析结果 */
const marketReportParsed = `## 文档概览
| 字段 | 内容 |
|------|------|
| 标题 | 2026 年 Q2 智能助手市场研究报告 |
| 作者 | 市场分析部 · 陈敏 |
| 日期 | 2026 年 5 月 28 日 |
| 页数 | 42 页 |
| 语言 | 中文(简体) |
## 核心发现
### 1. 市场规模
2026 年 Q2 全球智能助手市场规模预计达到 **187 亿美元**,同比增长 **34.2%**。
主要增长驱动力:
- 企业级 AI 助手采纳率从 2025 年的 38% 提升至 56%
- 多模态能力(图像/语音/代码)推动使用场景扩展
- 开源模型的成熟降低了部署门槛
### 2. 竞争格局
| 厂商 | 市场份额 | QoQ 变化 | 核心优势 |
|------|---------|---------|---------|
| OpenAI | 41% | +2.3% | 品牌认知度 + 开发者生态 |
| Anthropic | 18% | +5.1% | 安全对齐 + 长上下文 |
| Google | 15% | -1.2% | 搜索整合 + 多模态 |
| Meta | 12% | +3.8% | 开源策略 + 成本优势 |
| 其他 | 14% | — | 垂直领域玩家 |
### 3. 用户需求变化
- **可信度**成为用户选择助手的第一要素(47% 受访者提及)
- **上下文窗口大小**的优先级从 2025 年的第 7 位跃升至第 3 位
- 企业用户对 \`system prompt\` 透明度的需求同比增长 **82%**
- 工具调用(Function Calling)的使用率从 23% 增长到 51%
### 4. 关键趋势
> "智能助手的竞争从模型能力转向体验设计。用户不再只关心'能不能答对',更关心'我能不能理解你为什么这么回答'。"——报告结语`
export const demoC: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: [
// --- System message ---
{
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: `当前日期:{{current_date}},回复语言:{{language}}。
你是文档解析助手。帮助用户分析上传的文档、提取关键信息、回答关于文档内容的问题。
解析文档后,你会输出结构化的解析结果,并在回复中注明关键发现。`,
collapsed: true,
},
{
kind: 'skills',
description:
'Skills 是模型可调用的内置能力(slash commands)。当前对话中启用以下 skills',
items: getSkills(['summarize', 'translate', 'qa']),
collapsed: true,
},
],
timestamp: now - 150000,
},
// --- User 上传文档请求解析 ---
{
id: 'c-2',
role: 'user',
segments: [
{
kind: 'text',
content: '帮我解析这份市场研究报告,看看有什么关键发现。',
},
{
kind: 'document',
fileName: '2026-Q2-智能助手市场报告.pdf',
mimeType: 'application/pdf',
sizeBytes: 2_845_000,
snippet: '# 2026 年 Q2 智能助手市场研究报告\n\n## 摘要\n\n2026 年 Q2 全球智能助手市场规模预计达到 187 亿美元,同比增长 34.2%。主要增长驱动力包括企业级 AI 助手采纳率提升…',
parsedContent: marketReportParsed,
},
],
timestamp: now - 120000,
},
// --- Assistant 综合回复 ---
{
id: 'c-3',
role: 'assistant',
segments: [
{
kind: 'text',
content: `文档已解析完成。以下是报告中的关键发现:
---
## 📊 核心发现
### 1. 市场规模与增长
2026 年 Q2 全球智能助手市场规模预计达到 **187 亿美元**,同比增长 **34.2%**。
主要驱动力:
- 企业级 AI 助手采纳率从 38% 提升至 **56%**
- 多模态能力推动使用场景扩展
- 开源模型降低了部署门槛
### 2. 竞争格局变化
| 厂商 | 份额 | 环比变化 | 核心优势 |
|------|------|---------|---------|
| OpenAI | 41% | +2.3% | 品牌 + 开发者生态 |
| Anthropic | 18% | **+5.1%** | 安全对齐 + 长上下文 |
| Google | 15% | -1.2% | 搜索整合 + 多模态 |
| Meta | 12% | +3.8% | 开源 + 成本优势 |
Anthropic 的增长最快(+5.1%),说明 **安全对齐** 正在成为关键竞争力。
### 3. 值得关注的趋势
- **可信度** 是用户选择助手的首要因素(47% 受访者提及)
- 企业用户对 \`system prompt\` 透明度的需求同比增长 **82%**
- 工具调用(Function Calling)使用率从 23% 增长到 51%
- 报告结语指出:**竞争正从模型能力转向体验设计**
---
需要我深入分析某个方面吗?点击文档的 **「查看解析」** 按钮可以看到 AI 提取的完整结构化内容。`,
},
],
timestamp: now - 60000,
},
],
}
+58
View File
@@ -0,0 +1,58 @@
{
"version": "1.0",
"model": "gpt-4o",
"messages": [
{
"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": "system_prompt",
"content": "当前日期:{{current_date}}\n回复语言:{{language}}\n\n你是一个多模态助手,支持图文理解。用户可以在消息中附带图片,你能够直接理解图片内容并做出回复。",
"collapsed": true
}
],
"timestamp": 1780897800000
},
{
"id": "d-2",
"role": "user",
"segments": [
{
"kind": "text",
"content": "这张图片是什么?"
},
{
"kind": "media",
"mediaType": "image",
"url": "https://cataas.com/cat",
"altText": "用户上传的图片"
}
],
"timestamp": 1780897900000
},
{
"id": "d-3",
"role": "assistant",
"segments": [
{
"kind": "text",
"content": "这是一只猫。"
}
],
"timestamp": 1780897950000
}
]
}
-273
View File
@@ -1,273 +0,0 @@
import type { PromptEnvelope } from '../../types/protocol'
import { getSkills } from '../skills'
const now = Date.now()
const comprehensiveArticleContent = `在过去的18个月里,我们对127名LLM聊天产品用户进行了纵向研究。研究分为三个阶段:基线观察期(3个月)、干预实验期(6个月)、后续跟踪期(9个月)。在干预实验期,我们为实验组用户提供了一套上下文可视化工具,包括:system prompt查看面板、memory编辑界面、tool call实时展示和long text折叠功能。对照组使用标准的聊天界面。
实验组在以下维度上表现出显著改善:
- 任务完成率提升 23.4%p < 0.01
- 纠错响应时间缩短 41.7%p < 0.001
- 用户信任度评分从 3.2/5 提升至 4.1/5
- 对话轮次平均减少 2.8 轮(更高效的信息交换)
这些结果表明上下文透明性不仅仅是"nice to have"的设计细节,而是直接影响LLM交互效率的关键因素。特别是在以下场景中效果最为显著:
1. 长文档分析:用户能够看到哪些文档片段被模型引用
2. 多工具调用:工具链的可视化帮助用户理解推理过程
3. 跨会话任务:memory可见性减少重复说明
我们建议将上下文透明性作为LLM聊天产品的基础设计原则,而非可选特性。`
export const demoD: PromptEnvelope = {
version: '1.0',
model: 'gpt-4-turbo',
messages: [
// --- System message with all structural segments ---
{
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: `当前日期:{{current_date}}
用户:{{user_name}}
回复语言:{{language}}
知识截止:{{knowledge_cutoff}}
你是 Claude,一个 HCI 研究助手。你的角色是帮助学生批判性地思考聊天界面的设计问题。
核心原则:
- 鼓励从用户体验角度分析,而非技术实现角度
- 用具体例子说明抽象概念
- 如果学生的方案有改进空间,以提问的方式引导而非直接批评
- 始终记住你拥有工具调用、skills 和跨对话 memory 能力,但不必每次都全部用到`,
collapsed: true,
},
{
kind: 'memory',
description:
'以下是从过往对话中总结的关于你的信息,模型会参考这些记忆来个性化回复。记忆随对话自动更新,你也可以手动编辑或删除。',
items: [
{
title: '用户身份',
content: '小明,设计系研二,HCI 方向。正在做课程设计项目。',
},
{
title: '项目背景',
content: '设计一个透明化 LLM 上下文的聊天协议。已确定了 9 种 prompt 类型的分类方案。',
},
{
title: '沟通偏好',
content: '喜欢用图示和表格辅助理解。反感过度术语化。需要看到具体例子。',
},
{
title: '上次进度',
content: '用户已确认了 MVP 范围:Web 应用,数据协议+视觉规范,可导出 OpenAI Format。',
},
],
collapsed: true,
},
{
kind: 'skills',
description:
'Skills 是模型可调用的内置能力(slash commands)。输入 / 开头即可触发,无需等待模型推理。当前对话中可用以下 skills:',
items: getSkills(['deep-research', 'code-review', 'verify', 'simplify', 'loop']),
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: '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,
},
],
}
+148
View File
@@ -0,0 +1,148 @@
{
"version": "1.0",
"model": "claude-opus-4-8",
"messages": [
{
"id": "e-1",
"role": "system",
"segments": [
{
"kind": "static_var",
"name": "current_date",
"value": "2026年6月7日",
"description": "当前对话日期"
},
{
"kind": "static_var",
"name": "server_name",
"value": "production-api-01",
"description": "目标服务器名称"
},
{
"kind": "static_var",
"name": "analysis_depth",
"value": "全面(异常检测 + 安全审计 + 性能分析)",
"description": "日志分析的范围深度"
},
{
"kind": "system_prompt",
"content": "当前日期:{{current_date}}\n目标服务器:{{server_name}}\n分析范围:{{analysis_depth}}\n\n你是日志分析专家助手。你的核心能力:\n\n1. **异常检测** — 识别错误状态码(4xx/5xx)、请求突增、响应时间异常\n2. **安全审计** — 发现暴力破解尝试、路径探测、SQL 注入尝试\n3. **性能分析** — 统计端点响应分布、慢请求排查、热点资源识别\n4. **趋势报告** — 按时间窗口聚合,生成可读性高的分析摘要\n\n分析原则:\n- 优先发现模式而非孤立事件\n- 区分误报和真正的问题\n- 每个发现标注严重等级和置信度\n- 在工具调用中执行计算分析,回复中给出解读",
"collapsed": true
},
{
"kind": "memory",
"description": "以下是你对用户的了解,参考这些记录来提供个性化的日志分析服务。",
"items": [
{ "title": "用户身份", "content": "小陈,后端开发工程师,负责生产环境的运维监控。" },
{ "title": "系统环境", "content": "Kubernetes 集群上的微服务架构,API 网关使用 Nginx。生产环境有三台节点。" },
{ "title": "分析偏好", "content": "偏好先看整体概览再深入细节。对安全相关告警特别敏感,需要高亮标注。" },
{ "title": "上次分析记录", "content": "上周分析过同一台服务器的日志,发现了一个定时任务导致的 503 抖动。" }
],
"collapsed": true
},
{
"kind": "skills",
"description": "以下是可用的技能助手,通过 /skill_name 触发:",
"items": [
{ "name": "summarize", "description": "生成文档摘要 — 支持多种粒度(一句话/段落级/全文级)" },
{ "name": "qa", "description": "基于文档回答具体问题 — 带引用溯源" },
{ "name": "translate", "description": "翻译文档内容 — 支持中英互译,保留原文格式" }
],
"collapsed": true
},
{
"kind": "tool_overview",
"items": [
{
"name": "run_code",
"description": "在沙箱中执行 Python/Shell 代码进行日志解析和统计",
"parameters": "language: string, code: string",
"schema": {
"type": "object",
"properties": {
"language": { "type": "string", "enum": ["python", "bash"], "description": "执行语言" },
"code": { "type": "string", "description": "要执行的代码" }
},
"required": ["language", "code"]
}
},
{
"name": "search_knowledge",
"description": "搜索已知的漏洞模式和安全公告",
"parameters": "query: string",
"schema": {
"type": "object",
"properties": { "query": { "type": "string", "description": "搜索关键词" } },
"required": ["query"]
}
}
],
"collapsed": true
}
],
"timestamp": 1780897800000
},
{
"id": "e-2",
"role": "user",
"segments": [
{
"kind": "text",
"content": "生产环境 API 服务器刚刚的访问日志,帮我看一下有没有异常。我自己扫了一遍,感觉有几个可疑的 IP 在尝试访问 /admin 路径,但不太确定是不是误报。另外 /health 端点偶尔返回 503 的情况也需要你分析一下。"
},
{
"kind": "long_text",
"content": "192.168.1.10 - - [07/Jun/2026:10:15:23 +0800] \"GET /api/users HTTP/1.1\" 200 1234 \"-\" \"curl/7.88.1\"\n192.168.1.20 - - [07/Jun/2026:10:15:25 +0800] \"POST /api/login HTTP/1.1\" 302 0 \"-\" \"Mozilla/5.0\"\n10.0.0.5 - - [07/Jun/2026:10:15:28 +0800] \"GET /health HTTP/1.1\" 200 2 \"-\" \"Kubernetes/1.28\"\n192.168.1.10 - - [07/Jun/2026:10:16:01 +0800] \"GET /api/orders HTTP/1.1\" 200 8921 \"-\" \"curl/7.88.1\"\n203.0.113.42 - - [07/Jun/2026:10:16:05 +0800] \"GET /admin HTTP/1.1\" 403 189 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:06 +0800] \"GET /admin/config HTTP/1.1\" 404 153 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:07 +0800] \"POST /admin/login HTTP/1.1\" 401 198 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:08 +0800] \"POST /admin/login HTTP/1.1\" 401 198 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:09 +0800] \"POST /admin/login HTTP/1.1\" 401 198 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:10 +0800] \"POST /admin/login HTTP/1.1\" 401 198 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:11 +0800] \"POST /admin/login HTTP/1.1\" 401 198 \"-\" \"python-requests/2.31.0\"\n192.168.1.30 - - [07/Jun/2026:10:17:00 +0800] \"GET /api/products?page=1 HTTP/1.1\" 200 15420 \"-\" \"Mozilla/5.0\"\n192.168.1.30 - - [07/Jun/2026:10:17:02 +0800] \"GET /api/products?page=2 HTTP/1.1\" 200 14893 \"-\" \"Mozilla/5.0\"\n192.168.1.30 - - [07/Jun/2026:10:17:04 +0800] \"GET /api/products?page=3 HTTP/1.1\" 200 15102 \"-\" \"Mozilla/5.0\"\n198.51.100.7 - - [07/Jun/2026:10:18:30 +0800] \"GET / HTTP/1.1\" 200 5678 \"-\" \"Mozilla/5.0\"\n198.51.100.7 - - [07/Jun/2026:10:18:32 +0800] \"GET /static/css/main.css HTTP/1.1\" 200 28491 \"-\" \"Mozilla/5.0\"\n198.51.100.7 - - [07/Jun/2026:10:18:33 +0800] \"GET /static/js/app.js HTTP/1.1\" 200 128034 \"-\" \"Mozilla/5.0\"\n10.0.0.5 - - [07/Jun/2026:10:20:00 +0800] \"GET /health HTTP/1.1\" 200 2 \"-\" \"Kubernetes/1.28\"\n192.168.1.10 - - [07/Jun/2026:10:21:15 +0800] \"GET /api/users HTTP/1.1\" 200 1234 \"-\" \"curl/7.88.1\"\n192.168.1.10 - - [07/Jun/2026:10:21:16 +0800] \"GET /api/orders HTTP/1.1\" 200 8921 \"-\" \"curl/7.88.1\"\n192.168.1.10 - - [07/Jun/2026:10:21:17 +0800] \"GET /api/products HTTP/1.1\" 200 15420 \"-\" \"curl/7.88.1\"\n10.0.0.6 - - [07/Jun/2026:10:22:00 +0800] \"GET /health HTTP/1.1\" 503 57 \"-\" \"Kubernetes/1.28\"\n10.0.0.6 - - [07/Jun/2026:10:22:05 +0800] \"GET /health HTTP/1.1\" 503 57 \"-\" \"Kubernetes/1.28\"\n10.0.0.6 - - [07/Jun/2026:10:22:10 +0800] \"GET /health HTTP/1.1\" 503 57 \"-\" \"Kubernetes/1.28\"\n10.0.0.6 - - [07/Jun/2026:10:22:15 +0800] \"GET /health HTTP/1.1\" 200 2 \"-\" \"Kubernetes/1.28\"",
"charCount": 2375,
"collapsed": true
}
],
"timestamp": 1780897900000
},
{
"id": "e-3",
"role": "assistant",
"segments": [
{
"kind": "text",
"content": "我来分析这份日志。先快速概览一下整体情况:\n\n从格式看是标准的 Nginx combined 格式,时间窗口约 **10:1510:22**(约 7 分钟),共 **25 条**请求记录。几家不同的客户端:\n\n- **192.168.1.10** — 内部 API 调用(curl\n- **192.168.1.20** — 正常浏览器登录\n- **10.0.0.5** — K8s 健康检查\n- **192.168.1.30** — 浏览器浏览商品页面\n- **198.51.100.7** — 正常用户访问首页\n- **10.0.0.6** — 另一台节点的健康检查\n- **203.0.113.42** — ⚠️ 可疑 IP\n\n我先用 `run_code` 对请求做聚合统计,确认模式后再深入分析可疑流量。"
},
{
"kind": "tool_call_request",
"toolName": "run_code",
"arguments": {
"language": "python",
"code": "from collections import Counter, defaultdict\nimport re\n\nlog_text = \"\"\"192.168.1.10 - - [07/Jun/2026:10:15:23 +0800] \"GET /api/users HTTP/1.1\" 200 1234 \"-\" \"curl/7.88.1\"\n192.168.1.20 - - [07/Jun/2026:10:15:25 +0800] \"POST /api/login HTTP/1.1\" 302 0 \"-\" \"Mozilla/5.0\"\n10.0.0.5 - - [07/Jun/2026:10:15:28 +0800] \"GET /health HTTP/1.1\" 200 2 \"-\" \"Kubernetes/1.28\"\n192.168.1.10 - - [07/Jun/2026:10:16:01 +0800] \"GET /api/orders HTTP/1.1\" 200 8921 \"-\" \"curl/7.88.1\"\n203.0.113.42 - - [07/Jun/2026:10:16:05 +0800] \"GET /admin HTTP/1.1\" 403 189 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:06 +0800] \"GET /admin/config HTTP/1.1\" 404 153 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:07 +0800] \"POST /admin/login HTTP/1.1\" 401 198 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:08 +0800] \"POST /admin/login HTTP/1.1\" 401 198 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:09 +0800] \"POST /admin/login HTTP/1.1\" 401 198 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:10 +0800] \"POST /admin/login HTTP/1.1\" 401 198 \"-\" \"python-requests/2.31.0\"\n203.0.113.42 - - [07/Jun/2026:10:16:11 +0800] \"POST /admin/login HTTP/1.1\" 401 198 \"-\" \"python-requests/2.31.0\"\n192.168.1.30 - - [07/Jun/2026:10:17:00 +0800] \"GET /api/products?page=1 HTTP/1.1\" 200 15420 \"-\" \"Mozilla/5.0\"\n192.168.1.30 - - [07/Jun/2026:10:17:02 +0800] \"GET /api/products?page=2 HTTP/1.1\" 200 14893 \"-\" \"Mozilla/5.0\"\n192.168.1.30 - - [07/Jun/2026:10:17:04 +0800] \"GET /api/products?page=3 HTTP/1.1\" 200 15102 \"-\" \"Mozilla/5.0\"\n198.51.100.7 - - [07/Jun/2026:10:18:30 +0800] \"GET / HTTP/1.1\" 200 5678 \"-\" \"Mozilla/5.0\"\n198.51.100.7 - - [07/Jun/2026:10:18:32 +0800] \"GET /static/css/main.css HTTP/1.1\" 200 28491 \"-\" \"Mozilla/5.0\"\n198.51.100.7 - - [07/Jun/2026:10:18:33 +0800] \"GET /static/js/app.js HTTP/1.1\" 200 128034 \"-\" \"Mozilla/5.0\"\n10.0.0.5 - - [07/Jun/2026:10:20:00 +0800] \"GET /health HTTP/1.1\" 200 2 \"-\" \"Kubernetes/1.28\"\n192.168.1.10 - - [07/Jun/2026:10:21:15 +0800] \"GET /api/users HTTP/1.1\" 200 1234 \"-\" \"curl/7.88.1\"\n192.168.1.10 - - [07/Jun/2026:10:21:16 +0800] \"GET /api/orders HTTP/1.1\" 200 8921 \"-\" \"curl/7.88.1\"\n192.168.1.10 - - [07/Jun/2026:10:21:17 +0800] \"GET /api/products HTTP/1.1\" 200 15420 \"-\" \"curl/7.88.1\"\n10.0.0.6 - - [07/Jun/2026:10:22:00 +0800] \"GET /health HTTP/1.1\" 503 57 \"-\" \"Kubernetes/1.28\"\n10.0.0.6 - - [07/Jun/2026:10:22:05 +0800] \"GET /health HTTP/1.1\" 503 57 \"-\" \"Kubernetes/1.28\"\n10.0.0.6 - - [07/Jun/2026:10:22:10 +0800] \"GET /health HTTP/1.1\" 503 57 \"-\" \"Kubernetes/1.28\"\n10.0.0.6 - - [07/Jun/2026:10:22:15 +0800] \"GET /health HTTP/1.1\" 200 2 \"-\" \"Kubernetes/1.28\"\"\"\n\n# 解析日志\npattern = r'(\\S+) - - \\[(.+?)\\] \"(.+?)\" (\\d+) (\\d+) \".*?\" \"(.*?)\"'\nrecords = []\nfor line in log_text.strip().split('\\n'):\n m = re.match(pattern, line)\n if m:\n records.append({\n 'ip': m.group(1),\n 'time': m.group(2),\n 'request': m.group(3),\n 'status': int(m.group(4)),\n 'size': int(m.group(5)),\n 'ua': m.group(6)\n })\n\n# 1. IP 统计\nprint(\"=== 按 IP 请求量统计 ===\")\nip_counter = Counter(r['ip'] for r in records)\nfor ip, count in ip_counter.most_common():\n print(f\" {ip:20s} → {count} 次请求\")\n\n# 2. 状态码分布\nprint(\"\\n=== 状态码分布 ===\")\nstatus_counter = Counter(r['status'] for r in records)\nfor code, count in sorted(status_counter.items()):\n print(f\" {code} → {count} 次\")\n\n# 3. 可疑 IP 分析\nprint(\"\\n=== 可疑 IP: 203.0.113.42 的请求序列 ===\")\nfor r in records:\n if r['ip'] == '203.0.113.42':\n print(f\" [{r['time']}] {r['request']} → {r['status']}\")\n\n# 4. /health 端点分析\nprint(\"\\n=== /health 端点状态 ===\")\nfor r in records:\n if '/health' in r['request']:\n print(f\" {r['ip']:15s} [{r['time']}] → {r['status']} (UA: {r['ua']})\")\n\n# 5. 非 2xx 占比\ntotal = len(records)\nerrors = sum(1 for r in records if r['status'] >= 400)\nprint(f\"\\n=== 总结 ===\")\nprint(f\" 总请求数: {total}\")\nprint(f\" 错误请求: {errors} ({errors/total*100:.1f}%)\")\nprint(f\" 正常请求: {total - errors} ({(total-errors)/total*100:.1f}%)\")"
},
"collapsed": false
}
],
"timestamp": 1780897920000
},
{
"id": "e-4",
"role": "assistant",
"segments": [
{
"kind": "tool_call_result",
"toolName": "run_code",
"success": true,
"result": "=== 按 IP 请求量统计 ===\n 192.168.1.10 → 5 次请求\n 203.0.113.42 → 7 次请求\n 198.51.100.7 → 3 次请求\n 10.0.0.5 → 2 次请求\n 10.0.0.6 → 4 次请求\n 192.168.1.20 → 1 次请求\n 192.168.1.30 → 3 次请求\n\n=== 状态码分布 ===\n 200 → 14 次\n 302 → 1 次\n 401 → 5 次\n 403 → 1 次\n 404 → 1 次\n 503 → 3 次\n\n=== 可疑 IP: 203.0.113.42 的请求序列 ===\n [07/Jun/2026:10:16:05 +0800] GET /admin → 403\n [07/Jun/2026:10:16:06 +0800] GET /admin/config → 404\n [07/Jun/2026:10:16:07 +0800] POST /admin/login → 401\n [07/Jun/2026:10:16:08 +0800] POST /admin/login → 401\n [07/Jun/2026:10:16:09 +0800] POST /admin/login → 401\n [07/Jun/2026:10:16:10 +0800] POST /admin/login → 401\n [07/Jun/2026:10:16:11 +0800] POST /admin/login → 401\n\n=== /health 端点状态 ===\n 10.0.0.5 [07/Jun/2026:10:15:28 +0800] → 200 (UA: Kubernetes/1.28)\n 10.0.0.5 [07/Jun/2026:10:20:00 +0800] → 200 (UA: Kubernetes/1.28)\n 10.0.0.6 [07/Jun/2026:10:22:00 +0800] → 503 (UA: Kubernetes/1.28)\n 10.0.0.6 [07/Jun/2026:10:22:05 +0800] → 503 (UA: Kubernetes/1.28)\n 10.0.0.6 [07/Jun/2026:10:22:10 +0800] → 503 (UA: Kubernetes/1.28)\n 10.0.0.6 [07/Jun/2026:10:22:15 +0800] → 503 (UA: Kubernetes/1.28)\n\n=== 总结 ===\n 总请求数: 25\n 错误请求: 10 (40.0%)\n 非 2xx 响应占比高达 40%,\n 主要集中在两个异常模式上。",
"collapsed": false
}
],
"timestamp": 1780897940000
},
{
"id": "e-5",
"role": "assistant",
"segments": [
{
"kind": "text",
"content": "## 日志分析报告\n\n### 🔴 高危:暴力破解尝试\n\n**IP: 203.0.113.42**(来源:外部,UA: python-requests\n\n在 **10:16:0510:16:11** 的 6 秒内向 /admin 路径连续发送了 **7 次请求**,典型的暴力破解模式:\n\n| 时间 | 请求 | 状态 | 说明 |\n|------|------|------|------|\n| +0s | GET /admin | 403 | 探路——发现 /admin 存在 |\n| +1s | GET /admin/config | 404 | 试图访问敏感配置 |\n| +2~6s | POST /admin/login ×5 | 401 | 连续尝试登录——5 次均失败 |\n\n**建议措施:**\n- 🔒 立即对该 IP 加入 WAF 黑名单\n- 📋 检查 fail2ban 或类似自动封禁机制是否正常运行\n- 🚨 确认 admin 端点的访问控制策略(当前 403 正确拦截,但暴力破解仍消耗服务器资源)\n\n---\n\n### 🟡 中危:/health 端点间歇 503\n\n**节点 10.0.0.6** 在 **10:22:0010:22:10** 的 10 秒内连续返回 503,随后恢复 200。而 **10.0.0.5** 一直正常。\n\n这与上周分析中定时任务导致的 503 抖动模式一致。建议检查该节点上是否在整点前后有定时任务(如日志轮转、缓存预热等)。\n\n**建议措施:**\n- ⏰ 检查 10.0.0.6 的 cron 配置\n- 📊 添加更细粒度的 /health 监控(响应耗时 + 组件健康分别上报)\n- 🔄 考虑滚动重启使两个节点的健康检查窗口错开\n\n---\n\n### 🟢 常规观察\n\n- **192.168.1.10** — 内部 API 客户端,访问模式正常(用户/订单/商品查询)\n- **192.168.1.20** — 一次正常登录(POST /api/login → 302 重定向)\n- **198.51.100.7** — 正常用户访问,加载了首页 + CSS + JS\n- **192.168.1.30** — 分页浏览商品,行为正常\n- 无性能异常(无超时记录或慢请求)\n\n---\n\n### 📋 总结优先级\n\n| 优先级 | 问题 | 影响 |\n|--------|------|------|\n| P0 | 203.0.113.42 暴力破解 | 安全风险,立即封禁 |\n| P1 | 10.0.0.6 间歇 503 | 稳定性风险,排查定时任务 |\n| P3 | 整体 40% 非 2xx | 排除以上两者后正常 |\n\n需要我进一步分析某个方面吗?比如用 search_knowledge 查询 203.0.113.42 是否在已知攻击数据库中出现过?"
}
],
"timestamp": 1780897980000
}
]
}
-401
View File
@@ -1,401 +0,0 @@
/**
* Demo E — 日志分析(无文档传入)
*
* 场景:用户分享一段 Nginx 访问日志,请求 AI 助手帮忙分析
* 异常模式和潜在安全风险。
*
* 关键展示:
* 1. long_text 用于展示原始日志全文(折叠态仅显示前2行)
* 2. tool_call_request/result 展示日志分析工具的调用过程
* 3. static_var 注入服务器环境和分析参数
* 4. memory 记录用户的系统环境和分析偏好
* 5. 无 document/media 段 —— 突出"纯文本日志分析"场景
*/
import type { PromptEnvelope } from '../../types/protocol'
import { getSkills } from '../skills'
const now = Date.now()
const sampleLog = `192.168.1.10 - - [07/Jun/2026:10:15:23 +0800] "GET /api/users HTTP/1.1" 200 1234 "-" "curl/7.88.1"
192.168.1.20 - - [07/Jun/2026:10:15:25 +0800] "POST /api/login HTTP/1.1" 302 0 "-" "Mozilla/5.0"
10.0.0.5 - - [07/Jun/2026:10:15:28 +0800] "GET /health HTTP/1.1" 200 2 "-" "Kubernetes/1.28"
192.168.1.10 - - [07/Jun/2026:10:16:01 +0800] "GET /api/orders HTTP/1.1" 200 8921 "-" "curl/7.88.1"
203.0.113.42 - - [07/Jun/2026:10:16:05 +0800] "GET /admin HTTP/1.1" 403 189 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:06 +0800] "GET /admin/config HTTP/1.1" 404 153 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:07 +0800] "POST /admin/login HTTP/1.1" 401 198 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:08 +0800] "POST /admin/login HTTP/1.1" 401 198 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:09 +0800] "POST /admin/login HTTP/1.1" 401 198 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:10 +0800] "POST /admin/login HTTP/1.1" 401 198 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:11 +0800] "POST /admin/login HTTP/1.1" 401 198 "-" "python-requests/2.31.0"
192.168.1.30 - - [07/Jun/2026:10:17:00 +0800] "GET /api/products?page=1 HTTP/1.1" 200 15420 "-" "Mozilla/5.0"
192.168.1.30 - - [07/Jun/2026:10:17:02 +0800] "GET /api/products?page=2 HTTP/1.1" 200 14893 "-" "Mozilla/5.0"
192.168.1.30 - - [07/Jun/2026:10:17:04 +0800] "GET /api/products?page=3 HTTP/1.1" 200 15102 "-" "Mozilla/5.0"
198.51.100.7 - - [07/Jun/2026:10:18:30 +0800] "GET / HTTP/1.1" 200 5678 "-" "Mozilla/5.0"
198.51.100.7 - - [07/Jun/2026:10:18:32 +0800] "GET /static/css/main.css HTTP/1.1" 200 28491 "-" "Mozilla/5.0"
198.51.100.7 - - [07/Jun/2026:10:18:33 +0800] "GET /static/js/app.js HTTP/1.1" 200 128034 "-" "Mozilla/5.0"
10.0.0.5 - - [07/Jun/2026:10:20:00 +0800] "GET /health HTTP/1.1" 200 2 "-" "Kubernetes/1.28"
192.168.1.10 - - [07/Jun/2026:10:21:15 +0800] "GET /api/users HTTP/1.1" 200 1234 "-" "curl/7.88.1"
192.168.1.10 - - [07/Jun/2026:10:21:16 +0800] "GET /api/orders HTTP/1.1" 200 8921 "-" "curl/7.88.1"
192.168.1.10 - - [07/Jun/2026:10:21:17 +0800] "GET /api/products HTTP/1.1" 200 15420 "-" "curl/7.88.1"
10.0.0.6 - - [07/Jun/2026:10:22:00 +0800] "GET /health HTTP/1.1" 503 57 "-" "Kubernetes/1.28"
10.0.0.6 - - [07/Jun/2026:10:22:05 +0800] "GET /health HTTP/1.1" 503 57 "-" "Kubernetes/1.28"
10.0.0.6 - - [07/Jun/2026:10:22:10 +0800] "GET /health HTTP/1.1" 503 57 "-" "Kubernetes/1.28"
10.0.0.6 - - [07/Jun/2026:10:22:15 +0800] "GET /health HTTP/1.1" 200 2 "-" "Kubernetes/1.28"`
export const demoE: PromptEnvelope = {
version: '1.0',
model: 'claude-opus-4-8',
messages: [
// --- System message ---
{
id: 'e-1',
role: 'system',
segments: [
{
kind: 'static_var',
name: 'current_date',
value: '2026年6月7日',
description: '当前对话日期',
},
{
kind: 'static_var',
name: 'server_name',
value: 'production-api-01',
description: '目标服务器名称',
},
{
kind: 'static_var',
name: 'analysis_depth',
value: '全面(异常检测 + 安全审计 + 性能分析)',
description: '日志分析的范围深度',
},
{
kind: 'system_prompt',
content: `当前日期:{{current_date}}
目标服务器:{{server_name}}
分析范围:{{analysis_depth}}
你是日志分析专家助手。你的核心能力:
1. **异常检测** — 识别错误状态码(4xx/5xx)、请求突增、响应时间异常
2. **安全审计** — 发现暴力破解尝试、路径探测、SQL 注入尝试
3. **性能分析** — 统计端点响应分布、慢请求排查、热点资源识别
4. **趋势报告** — 按时间窗口聚合,生成可读性高的分析摘要
分析原则:
- 优先发现模式而非孤立事件
- 区分误报和真正的问题
- 每个发现标注严重等级和置信度
- 在工具调用中执行计算分析,回复中给出解读`,
collapsed: true,
},
{
kind: 'memory',
description:
'以下是你对用户的了解,参考这些记录来提供个性化的日志分析服务。',
items: [
{
title: '用户身份',
content: '小陈,后端开发工程师,负责生产环境的运维监控。',
},
{
title: '系统环境',
content: 'Kubernetes 集群上的微服务架构,API 网关使用 Nginx。生产环境有三台节点。',
},
{
title: '分析偏好',
content: '偏好先看整体概览再深入细节。对安全相关告警特别敏感,需要高亮标注。',
},
{
title: '上次分析记录',
content: '上周分析过同一台服务器的日志,发现了一个定时任务导致的 503 抖动。',
},
],
collapsed: true,
},
{
kind: 'skills',
description:
'以下是可用的技能助手,通过 /skill_name 触发:',
items: getSkills(['summarize', 'qa', 'translate']),
collapsed: true,
},
{
kind: 'tool_overview',
items: [
{
name: 'run_code',
description: '在沙箱中执行 Python/Shell 代码进行日志解析和统计',
parameters: 'language: string, code: string',
schema: {
type: 'object',
properties: {
language: { type: 'string', enum: ['python', 'bash'], description: '执行语言' },
code: { type: 'string', description: '要执行的代码' },
},
required: ['language', 'code'],
},
},
{
name: 'search_knowledge',
description: '搜索已知的漏洞模式和安全公告',
parameters: 'query: string',
schema: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词' },
},
required: ['query'],
},
},
],
collapsed: true,
},
],
timestamp: now - 600000,
},
// --- User 请求日志分析 ---
{
id: 'e-2',
role: 'user',
segments: [
{
kind: 'text',
content: '生产环境 API 服务器刚刚的访问日志,帮我看一下有没有异常。我自己扫了一遍,感觉有几个可疑的 IP 在尝试访问 /admin 路径,但不太确定是不是误报。另外 /health 端点偶尔返回 503 的情况也需要你分析一下。',
},
{
kind: 'long_text',
content: sampleLog,
charCount: sampleLog.length,
collapsed: true,
},
],
timestamp: now - 500000,
},
// --- Assistant 初始分析回复 ---
{
id: 'e-3',
role: 'assistant',
segments: [
{
kind: 'text',
content: `我来分析这份日志。先快速概览一下整体情况:
从格式看是标准的 Nginx combined 格式,时间窗口约 **10:1510:22**(约 7 分钟),共 **25 条**请求记录。几家不同的客户端:
- **192.168.1.10** — 内部 API 调用(curl
- **192.168.1.20** — 正常浏览器登录
- **10.0.0.5** — K8s 健康检查
- **192.168.1.30** — 浏览器浏览商品页面
- **198.51.100.7** — 正常用户访问首页
- **10.0.0.6** — 另一台节点的健康检查
- **203.0.113.42** — ⚠️ 可疑 IP
我先用 \`run_code\` 对请求做聚合统计,确认模式后再深入分析可疑流量。`,
},
{
kind: 'tool_call_request',
toolName: 'run_code',
arguments: {
language: 'python',
code: `from collections import Counter, defaultdict
import re
log_text = """192.168.1.10 - - [07/Jun/2026:10:15:23 +0800] "GET /api/users HTTP/1.1" 200 1234 "-" "curl/7.88.1"
192.168.1.20 - - [07/Jun/2026:10:15:25 +0800] "POST /api/login HTTP/1.1" 302 0 "-" "Mozilla/5.0"
10.0.0.5 - - [07/Jun/2026:10:15:28 +0800] "GET /health HTTP/1.1" 200 2 "-" "Kubernetes/1.28"
192.168.1.10 - - [07/Jun/2026:10:16:01 +0800] "GET /api/orders HTTP/1.1" 200 8921 "-" "curl/7.88.1"
203.0.113.42 - - [07/Jun/2026:10:16:05 +0800] "GET /admin HTTP/1.1" 403 189 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:06 +0800] "GET /admin/config HTTP/1.1" 404 153 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:07 +0800] "POST /admin/login HTTP/1.1" 401 198 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:08 +0800] "POST /admin/login HTTP/1.1" 401 198 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:09 +0800] "POST /admin/login HTTP/1.1" 401 198 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:10 +0800] "POST /admin/login HTTP/1.1" 401 198 "-" "python-requests/2.31.0"
203.0.113.42 - - [07/Jun/2026:10:16:11 +0800] "POST /admin/login HTTP/1.1" 401 198 "-" "python-requests/2.31.0"
192.168.1.30 - - [07/Jun/2026:10:17:00 +0800] "GET /api/products?page=1 HTTP/1.1" 200 15420 "-" "Mozilla/5.0"
192.168.1.30 - - [07/Jun/2026:10:17:02 +0800] "GET /api/products?page=2 HTTP/1.1" 200 14893 "-" "Mozilla/5.0"
192.168.1.30 - - [07/Jun/2026:10:17:04 +0800] "GET /api/products?page=3 HTTP/1.1" 200 15102 "-" "Mozilla/5.0"
198.51.100.7 - - [07/Jun/2026:10:18:30 +0800] "GET / HTTP/1.1" 200 5678 "-" "Mozilla/5.0"
198.51.100.7 - - [07/Jun/2026:10:18:32 +0800] "GET /static/css/main.css HTTP/1.1" 200 28491 "-" "Mozilla/5.0"
198.51.100.7 - - [07/Jun/2026:10:18:33 +0800] "GET /static/js/app.js HTTP/1.1" 200 128034 "-" "Mozilla/5.0"
10.0.0.5 - - [07/Jun/2026:10:20:00 +0800] "GET /health HTTP/1.1" 200 2 "-" "Kubernetes/1.28"
192.168.1.10 - - [07/Jun/2026:10:21:15 +0800] "GET /api/users HTTP/1.1" 200 1234 "-" "curl/7.88.1"
192.168.1.10 - - [07/Jun/2026:10:21:16 +0800] "GET /api/orders HTTP/1.1" 200 8921 "-" "curl/7.88.1"
192.168.1.10 - - [07/Jun/2026:10:21:17 +0800] "GET /api/products HTTP/1.1" 200 15420 "-" "curl/7.88.1"
10.0.0.6 - - [07/Jun/2026:10:22:00 +0800] "GET /health HTTP/1.1" 503 57 "-" "Kubernetes/1.28"
10.0.0.6 - - [07/Jun/2026:10:22:05 +0800] "GET /health HTTP/1.1" 503 57 "-" "Kubernetes/1.28"
10.0.0.6 - - [07/Jun/2026:10:22:10 +0800] "GET /health HTTP/1.1" 503 57 "-" "Kubernetes/1.28"
10.0.0.6 - - [07/Jun/2026:10:22:15 +0800] "GET /health HTTP/1.1" 200 2 "-" "Kubernetes/1.28"""
# 解析日志
pattern = r'(\\S+) - - \\[(.+?)\\] "(.+?)" (\\d+) (\\d+) ".*?" "(.*?)"'
records = []
for line in log_text.strip().split('\\n'):
m = re.match(pattern, line)
if m:
records.append({
'ip': m.group(1),
'time': m.group(2),
'request': m.group(3),
'status': int(m.group(4)),
'size': int(m.group(5)),
'ua': m.group(6)
})
# 1. IP 统计
print("=== 按 IP 请求量统计 ===")
ip_counter = Counter(r['ip'] for r in records)
for ip, count in ip_counter.most_common():
print(f" {ip:20s} → {count} 次请求")
# 2. 状态码分布
print("\\n=== 状态码分布 ===")
status_counter = Counter(r['status'] for r in records)
for code, count in sorted(status_counter.items()):
print(f" {code} → {count} 次")
# 3. 可疑 IP 分析
print("\\n=== 可疑 IP: 203.0.113.42 的请求序列 ===")
for r in records:
if r['ip'] == '203.0.113.42':
print(f" [{r['time']}] {r['request']} → {r['status']}")
# 4. /health 端点分析
print("\\n=== /health 端点状态 ===")
for r in records:
if '/health' in r['request']:
print(f" {r['ip']:15s} [{r['time']}] → {r['status']} (UA: {r['ua']})")
# 5. 非 2xx 占比
total = len(records)
errors = sum(1 for r in records if r['status'] >= 400)
print(f"\\n=== 总结 ===")
print(f" 总请求数: {total}")
print(f" 错误请求: {errors} ({errors/total*100:.1f}%)")
print(f" 正常请求: {total - errors} ({(total-errors)/total*100:.1f}%)")`,
},
collapsed: false,
},
],
timestamp: now - 480000,
},
// --- Tool 调用结果 ---
{
id: 'e-4',
role: 'assistant',
segments: [
{
kind: 'tool_call_result',
toolName: 'run_code',
success: true,
result: `=== 按 IP 请求量统计 ===
192.168.1.10 → 5 次请求
203.0.113.42 → 7 次请求
198.51.100.7 → 3 次请求
10.0.0.5 → 2 次请求
10.0.0.6 → 4 次请求
192.168.1.20 → 1 次请求
192.168.1.30 → 3 次请求
=== 状态码分布 ===
200 → 14 次
302 → 1 次
401 → 5 次
403 → 1 次
404 → 1 次
503 → 3 次
=== 可疑 IP: 203.0.113.42 的请求序列 ===
[07/Jun/2026:10:16:05 +0800] GET /admin → 403
[07/Jun/2026:10:16:06 +0800] GET /admin/config → 404
[07/Jun/2026:10:16:07 +0800] POST /admin/login → 401
[07/Jun/2026:10:16:08 +0800] POST /admin/login → 401
[07/Jun/2026:10:16:09 +0800] POST /admin/login → 401
[07/Jun/2026:10:16:10 +0800] POST /admin/login → 401
[07/Jun/2026:10:16:11 +0800] POST /admin/login → 401
=== /health 端点状态 ===
10.0.0.5 [07/Jun/2026:10:15:28 +0800] → 200 (UA: Kubernetes/1.28)
10.0.0.5 [07/Jun/2026:10:20:00 +0800] → 200 (UA: Kubernetes/1.28)
10.0.0.6 [07/Jun/2026:10:22:00 +0800] → 503 (UA: Kubernetes/1.28)
10.0.0.6 [07/Jun/2026:10:22:05 +0800] → 503 (UA: Kubernetes/1.28)
10.0.0.6 [07/Jun/2026:10:22:10 +0800] → 503 (UA: Kubernetes/1.28)
10.0.0.6 [07/Jun/2026:10:22:15 +0800] → 503 (UA: Kubernetes/1.28)
=== 总结 ===
总请求数: 25
错误请求: 10 (40.0%)
非 2xx 响应占比高达 40%,
主要集中在两个异常模式上。`,
collapsed: false,
},
],
timestamp: now - 460000,
},
// --- Assistant 综合报告 ---
{
id: 'e-5',
role: 'assistant',
segments: [
{
kind: 'text',
content: `## 日志分析报告
### 🔴 高危:暴力破解尝试
**IP: 203.0.113.42**(来源:外部,UA: python-requests
在 **10:16:0510:16:11** 的 6 秒内向 /admin 路径连续发送了 **7 次请求**,典型的暴力破解模式:
| 时间 | 请求 | 状态 | 说明 |
|------|------|------|------|
| +0s | GET /admin | 403 | 探路——发现 /admin 存在 |
| +1s | GET /admin/config | 404 | 试图访问敏感配置 |
| +2~6s | POST /admin/login ×5 | 401 | 连续尝试登录——5 次均失败 |
**建议措施:**
- 🔒 立即对该 IP 加入 WAF 黑名单
- 📋 检查 fail2ban 或类似自动封禁机制是否正常运行
- 🚨 确认 admin 端点的访问控制策略(当前 403 正确拦截,但暴力破解仍消耗服务器资源)
---
### 🟡 中危:/health 端点间歇 503
**节点 10.0.0.6** 在 **10:22:0010:22:10** 的 10 秒内连续返回 503,随后恢复 200。而 **10.0.0.5** 一直正常。
这与上周分析中定时任务导致的 503 抖动模式一致。建议检查该节点上是否在整点前后有定时任务(如日志轮转、缓存预热等)。
**建议措施:**
- ⏰ 检查 10.0.0.6 的 cron 配置
- 📊 添加更细粒度的 /health 监控(响应耗时 + 组件健康分别上报)
- 🔄 考虑滚动重启使两个节点的健康检查窗口错开
---
### 🟢 常规观察
- **192.168.1.10** — 内部 API 客户端,访问模式正常(用户/订单/商品查询)
- **192.168.1.20** — 一次正常登录(POST /api/login → 302 重定向)
- **198.51.100.7** — 正常用户访问,加载了首页 + CSS + JS
- **192.168.1.30** — 分页浏览商品,行为正常
- 无性能异常(无超时记录或慢请求)
---
### 📋 总结优先级
| 优先级 | 问题 | 影响 |
|--------|------|------|
| P0 | 203.0.113.42 暴力破解 | 安全风险,立即封禁 |
| P1 | 10.0.0.6 间歇 503 | 稳定性风险,排查定时任务 |
| P3 | 整体 40% 非 2xx | 排除以上两者后正常 |
需要我进一步分析某个方面吗?比如用 search_knowledge 查询 203.0.113.42 是否在已知攻击数据库中出现过?`,
},
],
timestamp: now - 420000,
},
],
}
+116
View File
@@ -0,0 +1,116 @@
{
"version": "1.0",
"model": "claude-opus-4-8",
"messages": [
{
"id": "f-1",
"role": "system",
"segments": [
{
"kind": "static_var",
"name": "current_date",
"value": "2026年6月7日",
"description": "当前对话日期"
},
{
"kind": "system_prompt",
"content": "你是 Claude,具备 Anthropic Skills 机制的 HCI 研究助手。\n\n你加载了以下从官方 skills 仓库的 SKILL.md 文件解析得到的 skills。\n每个 skill 的 YAML frontmattername + description)始终在你的上下文中可用;\n当 skill 被触发时,SKILL.md 的完整 body 会被加载。",
"collapsed": true
},
{
"kind": "skills",
"description": "以下 skills 来自 Anthropic 官方 skills 仓库,通过 parseSkillMarkdown() 从 SKILL.md 文件解析。name 和 description 取自 YAML frontmatterL1),body 作为完整指令(L2/L3)。",
"items": [
{ "name": "webapp-testing", "description": "Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs." },
{ "name": "pdf", "description": "Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill." },
{ "name": "doc-coauthoring", "description": "Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks." },
{ "name": "mcp-builder", "description": "Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK)." }
],
"collapsed": false
},
{
"kind": "tool_overview",
"items": [
{
"name": "run_skill",
"description": "执行一个 Skill",
"parameters": "skill_name: string, input: string",
"schema": {
"type": "object",
"properties": {
"skill_name": { "type": "string", "description": "Skill 名称" },
"input": { "type": "string", "description": "传递给 skill 的输入" }
},
"required": ["skill_name", "input"]
}
}
],
"collapsed": true
}
],
"timestamp": 1780898040000
},
{
"id": "f-2",
"role": "user",
"segments": [
{
"kind": "static_var",
"name": "user_name",
"value": "小明",
"description": "当前用户名"
},
{
"kind": "text",
"content": "我在做一个 HCI 课程项目——一个 React 聊天界面。本地 dev server 在 http://localhost:5173 运行。\n\n我想验证几个功能:\n1. 页面是否正常加载\n2. 场景切换按钮是否响应点击\n3. 右侧的 Protocol Panel 是否正确显示 JSON\n\n能帮我自动化测试一下吗?"
}
],
"timestamp": 1780898100000
},
{
"id": "f-3",
"role": "assistant",
"segments": [
{
"kind": "text",
"content": "你的测试需求非常适合用 **/webapp-testing** skill 来处理——它正是为本地 web 应用的 Playwright 自动化测试设计的。\n\n让我触发这个 skill 来生成测试脚本。它来自 Anthropic 官方 skills 仓库,通过 `parseSkillMarkdown()` 解析 SKILL.md 的 YAML frontmatter 和 Markdown body\n\n> **解析结构:**\n> - **L1** — `name: webapp-testing` + `description`(从 frontmatter 提取)\n> - **L2** — body 摘要(前 10 行 + 统计信息)\n> - **L3** — 完整 SKILL.md 原始内容(点击「查看原始 SKILL.md」展开)"
},
{
"kind": "tool_call_request",
"toolName": "run_skill",
"arguments": {
"skill_name": "webapp-testing",
"input": "Test a React chat app at http://localhost:5173. Verify: (1) page loads correctly, (2) demo scenario buttons respond to clicks, (3) the right-side Protocol Panel displays JSON correctly. Use Playwright with the reconnaissance-then-action pattern."
},
"collapsed": false
}
],
"timestamp": 1780898120000
},
{
"id": "f-4",
"role": "assistant",
"segments": [
{
"kind": "tool_call_result",
"toolName": "run_skill",
"success": true,
"result": "[/webapp-testing] SKILL.md 已加载\n以下完整 body 已注入上下文,agent 将基于这些指令执行:\n\n# Web Application Testing\n\nTo test local web applications, write native Python Playwright scripts.\n\n**Helper Scripts Available**:\n- `scripts/with_server.py` - Manages server lifecycle (supports multiple servers)\n\n**Always run scripts with `--help` first** to see usage.\n\n## Decision Tree: Choosing Your Approach\n\n```\nUser task → Is it static HTML?\n ├─ Yes → Read HTML file directly to identify selectors\n │ ├─ Success → Write Playwright script using selectors\n │ └─ Fails/Incomplete → Treat as dynamic (below)\n │\n └─ No (dynamic webapp) → Is the server already running?\n ├─ No → Run: python scripts/with_server.py --help\n │ Then use the helper + write simplified Playwright script\n │\n └─ Yes → Reconnaissance-then-action:\n 1. Navigate and wait for networkidle\n 2. Take screenshot or inspect DOM\n 3. Identify selectors from rendered state\n 4. Execute actions with discovered selectors\n```\n\n## Reconnaissance-Then-Action Pattern\n\n1. **Inspect rendered DOM** — take screenshot + page.content() + locate elements\n2. **Identify selectors** from inspection results\n3. **Execute actions** using discovered selectors\n\n(完整 body 共 96 行,详见上方 skills 面板 L3",
"collapsed": false
}
],
"timestamp": 1780898130000
},
{
"id": "f-5",
"role": "assistant",
"segments": [
{
"kind": "text",
"content": "## 测试结果:全部通过 ✅\n\n`/webapp-testing` skill 使用了 **reconnaissance-then-action** 模式:\n\n1. 先截图 + 检查 DOM 获取真实渲染状态\n2. 从渲染结果中识别选择器(而非猜测)\n3. 用发现的选择器执行操作\n\n### 关于 Skills 的 HCI 应用\n\n这个 demo 展示了 **Anthropic Skills 格式的关键优势**\n\n| 层级 | 内容 | HCI 意义 |\n|------|------|---------|\n| L1 | name + description | **识别**recognition over recall |\n| L2 | body 摘要 + 触发条件 | **评估**(用户判断 skill 是否适用) |\n| L3 | 完整 SKILL.md body | **理解**(透明化——用户可审阅完整指令) |\n\n这种设计直接对应 Shneiderman 的 \"Overview first, zoom and filter, then details-on-demand\" 信息可视化 mantra。"
}
],
"timestamp": 1780898160000
}
]
}
-210
View File
@@ -1,210 +0,0 @@
/**
* Demo F — 真实 Anthropic Skills(从 SKILL.md 解析)
*
* 场景:用户请求帮助完成网页应用的 Playwright 测试,
* Agent 检测到合适 skill 并建议触发 /webapp-testing。
*
* 关键展示:
* 1. Skills 使用了真实的 Anthropic SKILL.md 格式
* 2. parseSkillMarkdown() 解析 YAML frontmatter → SkillItem
* 3. SkillsView 渐进式披露 L1→L2→L3L3 显示原始 SKILL.md body
* 4. Agent 以工具调用方式触发 skill
* 5. 触发后 SKILL.md body 以 tool_call_result 返回并注入上下文(f-4
* 6. 对话中展示 static_var 展开(日期、用户身份)
*/
import type { PromptEnvelope } from '../../types/protocol'
import { getRealSkills } from '../skills-loader'
const now = Date.now()
export const demoF: PromptEnvelope = {
version: '1.0',
model: 'claude-opus-4-8',
messages: [
// --- System message: 加载真实 Anthropic skills ---
{
id: 'f-1',
role: 'system',
segments: [
{
kind: 'static_var',
name: 'current_date',
value: '2026年6月7日',
description: '当前对话日期',
},
{
kind: 'system_prompt',
content: `你是 Claude,具备 Anthropic Skills 机制的 HCI 研究助手。
你加载了以下从官方 skills 仓库的 SKILL.md 文件解析得到的 skills。
每个 skill 的 YAML frontmattername + description)始终在你的上下文中可用;
当 skill 被触发时,SKILL.md 的完整 body 会被加载。`,
collapsed: true,
},
{
kind: 'skills',
description:
'以下 skills 来自 Anthropic 官方 skills 仓库,通过 parseSkillMarkdown() 从 SKILL.md 文件解析。name 和 description 取自 YAML frontmatterL1),body 作为完整指令(L2/L3)。',
items: getRealSkills(['webapp-testing', 'pdf', 'doc-coauthoring', 'mcp-builder']),
collapsed: false, // 默认展开展示解析效果
},
{
kind: 'tool_overview',
items: [
{
name: 'run_skill',
description: '执行一个 Skill',
parameters: 'skill_name: string, input: string',
schema: {
type: 'object',
properties: {
skill_name: { type: 'string', description: 'Skill 名称' },
input: { type: 'string', description: '传递给 skill 的输入' },
},
required: ['skill_name', 'input'],
},
},
],
collapsed: true,
},
],
timestamp: now - 360000,
},
// --- User 提出测试需求 ---
{
id: 'f-2',
role: 'user',
segments: [
{
kind: 'static_var',
name: 'user_name',
value: '小明',
description: '当前用户名',
},
{
kind: 'text',
content: `我在做一个 HCI 课程项目——一个 React 聊天界面。本地 dev server 在 http://localhost:5173 运行。
我想验证几个功能:
1. 页面是否正常加载
2. 场景切换按钮是否响应点击
3. 右侧的 Protocol Panel 是否正确显示 JSON
能帮我自动化测试一下吗?`,
},
],
timestamp: now - 300000,
},
// --- Assistant 识别需求,触发 webapp-testing skill ---
{
id: 'f-3',
role: 'assistant',
segments: [
{
kind: 'text',
content: `你的测试需求非常适合用 **/webapp-testing** skill 来处理——它正是为本地 web 应用的 Playwright 自动化测试设计的。
让我触发这个 skill 来生成测试脚本。它来自 Anthropic 官方 skills 仓库,通过 \`parseSkillMarkdown()\` 解析 SKILL.md 的 YAML frontmatter 和 Markdown body
> **解析结构:**
> - **L1** — \`name: webapp-testing\` + \`description\`(从 frontmatter 提取)
> - **L2** — body 摘要(前 10 行 + 统计信息)
> - **L3** — 完整 SKILL.md 原始内容(点击「查看原始 SKILL.md」展开)`,
},
{
kind: 'tool_call_request',
toolName: 'run_skill',
arguments: {
skill_name: 'webapp-testing',
input: 'Test a React chat app at http://localhost:5173. Verify: (1) page loads correctly, (2) demo scenario buttons respond to clicks, (3) the right-side Protocol Panel displays JSON correctly. Use Playwright with the reconnaissance-then-action pattern.',
},
collapsed: false,
},
],
timestamp: now - 280000,
},
// --- Skill body 加载:SKILL.md 作为 tool 结果返回 ---
{
id: 'f-4',
role: 'assistant',
segments: [
{
kind: 'tool_call_result',
toolName: 'run_skill',
success: true,
result: `[/webapp-testing] SKILL.md 已加载
以下完整 body 已注入上下文,agent 将基于这些指令执行:
# Web Application Testing
To test local web applications, write native Python Playwright scripts.
**Helper Scripts Available**:
- \`scripts/with_server.py\` - Manages server lifecycle (supports multiple servers)
**Always run scripts with \`--help\` first** to see usage.
## Decision Tree: Choosing Your Approach
\`\`\`
User task → Is it static HTML?
├─ Yes → Read HTML file directly to identify selectors
│ ├─ Success → Write Playwright script using selectors
│ └─ Fails/Incomplete → Treat as dynamic (below)
└─ No (dynamic webapp) → Is the server already running?
├─ No → Run: python scripts/with_server.py --help
│ Then use the helper + write simplified Playwright script
└─ Yes → Reconnaissance-then-action:
1. Navigate and wait for networkidle
2. Take screenshot or inspect DOM
3. Identify selectors from rendered state
4. Execute actions with discovered selectors
\`\`\`
## Reconnaissance-Then-Action Pattern
1. **Inspect rendered DOM** — take screenshot + page.content() + locate elements
2. **Identify selectors** from inspection results
3. **Execute actions** using discovered selectors
(完整 body 共 96 行,详见上方 skills 面板 L3)`,
collapsed: false,
},
],
timestamp: now - 270000,
},
// --- Assistant 总结 ---
{
id: 'f-5',
role: 'assistant',
segments: [
{
kind: 'text',
content: `## 测试结果:全部通过 ✅
\`/webapp-testing\` skill 使用了 **reconnaissance-then-action** 模式:
1. 先截图 + 检查 DOM 获取真实渲染状态
2. 从渲染结果中识别选择器(而非猜测)
3. 用发现的选择器执行操作
### 关于 Skills 的 HCI 应用
这个 demo 展示了 **Anthropic Skills 格式的关键优势**
| 层级 | 内容 | HCI 意义 |
|------|------|---------|
| L1 | name + description | **识别**recognition over recall |
| L2 | body 摘要 + 触发条件 | **评估**(用户判断 skill 是否适用) |
| L3 | 完整 SKILL.md body | **理解**(透明化——用户可审阅完整指令) |
这种设计直接对应 Shneiderman 的 "Overview first, zoom and filter, then details-on-demand" 信息可视化 mantra。`,
},
],
timestamp: now - 240000,
},
],
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Demo JSON 加载器 —— 将 JSON 文件还原为类型安全的 PromptEnvelope。
*
* 职责:
* 1. validateEnvelope() — 运行时类型守卫,断言 JSON 结构符合协议
* 2. hydrateSkills() — 根据 skill name 从 skills.ts / skills-loader.ts 补全 body
*
* 加载流程:
* JSON import (unknown) → validateEnvelope() → PromptEnvelope → hydrateSkills() → 完整 PromptEnvelope
*/
import type { PromptEnvelope, Message, Segment, SkillItem } from '../../types/protocol'
import { ALL_SKILLS, getSkills } from '../skills'
import { PARSED_SKILLS, toSkillItem } from '../skills-loader'
// ============================================================
// 类型守卫
// ============================================================
const VALID_SEGMENT_KINDS = new Set([
'text',
'static_var',
'system_prompt',
'memory',
'skills',
'tool_overview',
'tool_call_request',
'tool_call_result',
'document',
'long_text',
'media',
])
const VALID_ROLES = new Set(['system', 'user', 'assistant', 'tool'])
function assert(cond: boolean, msg: string): asserts cond {
if (!cond) throw new Error(`[demos-loader] 验证失败: ${msg}`)
}
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
function validateSegment(seg: unknown, msgIdx: number): Segment {
assert(isObject(seg), `message[${msgIdx}] segment 不是对象`)
const s = seg as Record<string, unknown>
assert(
typeof s.kind === 'string' && VALID_SEGMENT_KINDS.has(s.kind),
`message[${msgIdx}] segment.kind "${String(s.kind)}" 无效(合法值: ${[...VALID_SEGMENT_KINDS].join(', ')}`
)
// 对于所有 segment 类型,基础的类型检查已经足够——详细的字段级验证由 JSON Schema 在开发时完成
// 这里只保证 kind 字段存在且有效,其余字段按原样信任(因为数据来自 JSON 文件,已在 VSCode 中被 Schema 校验)
return s as unknown as Segment
}
function validateMessage(msg: unknown, idx: number): Message {
assert(isObject(msg), `messages[${idx}] 不是对象`)
const m = msg as Record<string, unknown>
assert(typeof m.id === 'string', `messages[${idx}].id 必须是字符串`)
assert(
typeof m.role === 'string' && VALID_ROLES.has(m.role),
`messages[${idx}].role "${String(m.role)}" 无效`
)
assert(Array.isArray(m.segments), `messages[${idx}].segments 必须是数组`)
assert(m.segments.length > 0, `messages[${idx}].segments 不能为空`)
assert(typeof m.timestamp === 'number', `messages[${idx}].timestamp 必须是数字`)
return {
id: m.id as string,
role: m.role as Message['role'],
segments: m.segments.map((s, si) => validateSegment(s, idx)),
timestamp: m.timestamp as number,
}
}
/**
* 验证并类型断言一个 JSON 对象是否符合 PromptEnvelope 协议。
* 抛出带有描述性消息的 Error 如果格式不符。
*/
export function validateEnvelope(json: unknown): PromptEnvelope {
assert(isObject(json), '顶层必须是对象')
const env = json as Record<string, unknown>
assert(env.version === '1.0', `version 必须是 "1.0",实际为 "${String(env.version)}"`)
assert(Array.isArray(env.messages), 'messages 必须是数组')
assert(env.messages.length > 0, 'messages 不能为空')
const messages = env.messages.map((m, i) => validateMessage(m, i))
return {
version: '1.0',
model: typeof env.model === 'string' ? env.model : undefined,
messages,
}
}
// ============================================================
// Skills 水合(补全 body
// ============================================================
/** 合并所有 skill 源(手写 + SKILL.md 解析)为一个查询表 */
function buildSkillLookup(): Map<string, SkillItem> {
const map = new Map<string, SkillItem>()
// 手写 skillsskills.ts
for (const skill of Object.values(ALL_SKILLS)) {
map.set(skill.name, skill)
}
// 真实 SKILL.md 解析(skills-loader.ts
for (const parsed of Object.values(PARSED_SKILLS)) {
if (!map.has(parsed.name)) {
map.set(parsed.name, toSkillItem(parsed))
}
}
return map
}
/**
* 根据 skill name 从 skill 注册表补全 body。
* 处理所有 messages 中所有 skills segment 的 items。
* 如果某个 name 在注册表中找不到,body 保留为空字符串(生产环境不应发生此情况)。
*/
export function hydrateSkills(envelope: PromptEnvelope): PromptEnvelope {
const lookup = buildSkillLookup()
for (const msg of envelope.messages) {
for (const seg of msg.segments) {
if (seg.kind === 'skills') {
seg.items = seg.items.map((item: SkillItem) => {
const found = lookup.get(item.name)
if (found) {
return { ...item, body: found.body }
}
console.warn(`[demos-loader] skill "${item.name}" 在注册表中未找到,body 将为空`)
return { ...item, body: '' }
})
}
}
}
return envelope
}
/**
* 从 JSON 对象加载完整的 PromptEnvelope(验证 + skill 水合)。
* 这是推荐的外部入口函数。
*/
export function loadEnvelope(json: unknown): PromptEnvelope {
const envelope = validateEnvelope(json)
return hydrateSkills(envelope)
}
+43
View File
@@ -0,0 +1,43 @@
{
"version": "1.0",
"description": "Prompt Envelope Demo 场景索引",
"scenarios": [
{
"id": "a",
"file": "demo-a.json",
"label": "场景 A 💬",
"description": "基础对话 + System Prompt + Memory"
},
{
"id": "b",
"file": "demo-b.json",
"label": "场景 B 🔧",
"description": "工具调用:请求 → 执行(成功 & 失败)"
},
{
"id": "c",
"file": "demo-c.json",
"label": "场景 C 📄",
"description": "文档解析:点击「查看解析」看 AI 如何提取文档内容"
},
{
"id": "d",
"file": "demo-d.json",
"label": "场景 D ⭐",
"description": "综合:覆盖全部 11 种 Segment"
},
{
"id": "f",
"file": "demo-f.json",
"label": "场景 F 📁",
"description": "真实 Anthropic SkillsSKILL.md 文件加载 + parseSkillMarkdown 解析)",
"default": true
},
{
"id": "e",
"file": "demo-e.json",
"label": "场景 E 🔍",
"description": "日志分析:异常检测 + 安全审计 + 性能分析"
}
]
}
+205
View File
@@ -0,0 +1,205 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hci-demo.local/prompt-envelope.schema.json",
"title": "Prompt Envelope Protocol",
"description": "Prompt Envelope 协议格式 — 消息由若干带类型的 Segment 组成,每种 Segment 有独立的视觉呈现。",
"type": "object",
"required": ["version", "messages"],
"properties": {
"version": {
"type": "string",
"const": "1.0",
"description": "协议版本号"
},
"model": {
"type": "string",
"description": "导出时使用的模型名称"
},
"messages": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/Message" }
}
},
"$defs": {
"Message": {
"type": "object",
"required": ["id", "role", "segments", "timestamp"],
"properties": {
"id": { "type": "string" },
"role": {
"type": "string",
"enum": ["system", "user", "assistant", "tool"]
},
"segments": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/Segment" }
},
"timestamp": {
"type": "number",
"description": "Unix epoch 毫秒"
}
}
},
"Segment": {
"oneOf": [
{ "$ref": "#/$defs/TextSegment" },
{ "$ref": "#/$defs/StaticVarSegment" },
{ "$ref": "#/$defs/SystemPromptSegment" },
{ "$ref": "#/$defs/MemorySegment" },
{ "$ref": "#/$defs/SkillsSegment" },
{ "$ref": "#/$defs/ToolOverviewSegment" },
{ "$ref": "#/$defs/ToolCallRequestSegment" },
{ "$ref": "#/$defs/ToolCallResultSegment" },
{ "$ref": "#/$defs/DocumentSegment" },
{ "$ref": "#/$defs/LongTextSegment" },
{ "$ref": "#/$defs/MediaSegment" }
]
},
"TextSegment": {
"type": "object",
"required": ["kind", "content"],
"properties": {
"kind": { "type": "string", "const": "text" },
"content": { "type": "string" }
}
},
"StaticVarSegment": {
"type": "object",
"required": ["kind", "name", "value"],
"properties": {
"kind": { "type": "string", "const": "static_var" },
"name": { "type": "string", "description": "模板变量名" },
"value": { "type": "string", "description": "展开后的值" },
"description": { "type": "string", "description": "简短说明" }
}
},
"SystemPromptSegment": {
"type": "object",
"required": ["kind", "content", "collapsed"],
"properties": {
"kind": { "type": "string", "const": "system_prompt" },
"content": { "type": "string" },
"collapsed": { "type": "boolean" }
}
},
"MemorySegment": {
"type": "object",
"required": ["kind", "items", "collapsed"],
"properties": {
"kind": { "type": "string", "const": "memory" },
"description": { "type": "string" },
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["title", "content"],
"properties": {
"title": { "type": "string" },
"content": { "type": "string" }
}
}
},
"collapsed": { "type": "boolean" }
}
},
"SkillsSegment": {
"type": "object",
"required": ["kind", "items", "collapsed"],
"properties": {
"kind": { "type": "string", "const": "skills" },
"description": { "type": "string" },
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["name", "description"],
"properties": {
"name": { "type": "string" },
"description": { "type": "string" }
}
}
},
"collapsed": { "type": "boolean" }
}
},
"ToolOverviewSegment": {
"type": "object",
"required": ["kind", "items", "collapsed"],
"properties": {
"kind": { "type": "string", "const": "tool_overview" },
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["name", "description", "parameters"],
"properties": {
"name": { "type": "string" },
"description": { "type": "string" },
"parameters": { "type": "string" },
"schema": { "type": "object" }
}
}
},
"collapsed": { "type": "boolean" }
}
},
"ToolCallRequestSegment": {
"type": "object",
"required": ["kind", "toolName", "arguments", "collapsed"],
"properties": {
"kind": { "type": "string", "const": "tool_call_request" },
"toolName": { "type": "string" },
"arguments": { "type": "object" },
"collapsed": { "type": "boolean" }
}
},
"ToolCallResultSegment": {
"type": "object",
"required": ["kind", "toolName", "result", "success", "collapsed"],
"properties": {
"kind": { "type": "string", "const": "tool_call_result" },
"toolName": { "type": "string" },
"result": { "type": "string" },
"success": { "type": "boolean" },
"collapsed": { "type": "boolean" }
}
},
"DocumentSegment": {
"type": "object",
"required": ["kind", "fileName", "mimeType", "snippet", "sizeBytes"],
"properties": {
"kind": { "type": "string", "const": "document" },
"fileName": { "type": "string" },
"mimeType": { "type": "string" },
"snippet": { "type": "string" },
"sizeBytes": { "type": "number" },
"parsedContent": { "type": "string" }
}
},
"LongTextSegment": {
"type": "object",
"required": ["kind", "content", "charCount", "collapsed"],
"properties": {
"kind": { "type": "string", "const": "long_text" },
"content": { "type": "string" },
"charCount": { "type": "number" },
"collapsed": { "type": "boolean" }
}
},
"MediaSegment": {
"type": "object",
"required": ["kind", "mediaType", "url"],
"properties": {
"kind": { "type": "string", "const": "media" },
"mediaType": { "type": "string", "enum": ["image", "audio", "video"] },
"url": { "type": "string" },
"altText": { "type": "string" }
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* API 配置管理 —— localStorage 持久化存储。
*
* 存储的字段:
* - baseUrl: API 端点地址(默认 OpenAI 官方)
* - apiKey: API 密钥
* - model: 模型名称(默认 envelope.model ?? 'gpt-4-turbo'
*/
const STORAGE_KEY = 'hci-api-config'
export interface ApiConfig {
baseUrl: string
apiKey: string
model: string
}
const DEFAULTS: ApiConfig = {
baseUrl: 'https://api.openai.com/v1',
apiKey: '',
model: 'gpt-4-turbo',
}
/**
* 从 localStorage 读取 API 配置。
* 若某项不存在,使用默认值填充。
*/
export function getApiConfig(): ApiConfig {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<ApiConfig>
return {
baseUrl: parsed.baseUrl || DEFAULTS.baseUrl,
apiKey: parsed.apiKey || DEFAULTS.apiKey,
model: parsed.model || DEFAULTS.model,
}
}
} catch {
// localStorage 不可用或数据损坏,回退默认值
}
return { ...DEFAULTS }
}
/**
* 保存 API 配置到 localStorage。
*/
export function setApiConfig(config: ApiConfig): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
} catch {
console.warn('[api-config] localStorage 写入失败')
}
}
/**
* 检查是否已配置 API Key。
*/
export function hasApiKey(): boolean {
const config = getApiConfig()
return config.apiKey.trim().length > 0
}
/**
* 清除已保存的 API 配置。
*/
export function clearApiConfig(): void {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// 静默
}
}
+231
View File
@@ -0,0 +1,231 @@
/**
* API 调用服务 —— 薄封装层,负责 HTTP 通信和格式转换。
*
* 数据流:
* envelope → exportToOpenAIFormat() → POST JSON → API
* API → SSE/JSON response → importFromOpenAIResponse() → Message[]
*
* 依赖:
* - src/utils/export.ts (Protocol → OpenAI)
* - src/utils/import.ts (OpenAI → Protocol)
* - src/services/api-config.ts (配置)
*/
import type { PromptEnvelope, Message } from '../types/protocol'
import type { ApiConfig } from './api-config'
import { exportToOpenAIFormat, type OpenAIExport, type OpenAIMessage } from '../utils/export'
import {
importFromOpenAIResponse,
StreamingImporter,
type OpenAIResponse,
type OpenAIStreamChunk,
} from '../utils/import'
// ============================================================
// 非流式请求
// ============================================================
/**
* 发送非流式 Chat Completion 请求。
* @returns 导入后的 Protocol Message 数组(通常只有一条 assistant 消息)
*/
export async function sendChatRequest(
envelope: PromptEnvelope,
config: ApiConfig
): Promise<Message[]> {
const body = buildRequestBody(envelope, config)
const response = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '无法读取错误详情')
let errorMsg: string
try {
const errJson = JSON.parse(errorText)
errorMsg = errJson.error?.message || errorText
} catch {
errorMsg = errorText || `HTTP ${response.status}`
}
throw new Error(`API 请求失败 (${response.status}): ${errorMsg}`)
}
const data: OpenAIResponse = await response.json()
const choice = data.choices?.[0]
if (!choice) {
throw new Error('API 响应中没有有效的 choices')
}
const msg = importFromOpenAIResponse(choice.message, choice.finish_reason, {
timestamp: Date.now(),
})
return [msg]
}
// ============================================================
// 流式请求
// ============================================================
export interface StreamCallbacks {
/** 每收到 text delta 时回调部分 Message(用于实时打字效果) */
onToken?: (partial: Message) => void
/** tool_call delta 变化时回调(用于展示即将执行的 tool) */
onToolCall?: (partial: Message) => void
/** 流式完成时回调最终 Message 数组 */
onDone: (final: Message[]) => void
/** 发生错误时回调 */
onError: (err: Error) => void
}
/**
* 发送流式 Chat Completion 请求(SSE)。
*
* 使用 StreamingImporter 逐 chunk 累积 delta
* 通过 onToken/onToolCall 回调部分 Message 供 UI 实时渲染。
*/
export async function sendChatRequestStream(
envelope: PromptEnvelope,
config: ApiConfig,
callbacks: StreamCallbacks
): Promise<void> {
const body = buildRequestBody(envelope, config)
body.stream = true
// 流式模式下需要 stream_options 来获取 usage(可选)
body.stream_options = { include_usage: true }
let response: Response
try {
response = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify(body),
})
} catch (err) {
callbacks.onError(
new Error(`网络请求失败: ${err instanceof Error ? err.message : String(err)}`)
)
return
}
if (!response.ok) {
const errorText = await response.text().catch(() => '无法读取错误详情')
let errorMsg: string
try {
const errJson = JSON.parse(errorText)
errorMsg = errJson.error?.message || errorText
} catch {
errorMsg = errorText || `HTTP ${response.status}`
}
callbacks.onError(new Error(`API 请求失败 (${response.status}): ${errorMsg}`))
return
}
const reader = response.body?.getReader()
if (!reader) {
callbacks.onError(new Error('响应体不可读(不支持流式)'))
return
}
const decoder = new TextDecoder()
const importer = new StreamingImporter()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// 按 SSE 协议解析:event 以 \n\n 为界
const lines = buffer.split('\n')
// 最后一个可能不完整,保留在 buffer 中
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith(':')) {
// 空行、注释行 —— 跳过
continue
}
if (trimmed.startsWith('data: ')) {
const dataStr = trimmed.slice(6)
// 结束标记
if (dataStr === '[DONE]') {
break
}
try {
const chunk: OpenAIStreamChunk = JSON.parse(dataStr)
const delta = chunk.choices?.[0]?.delta
if (delta) {
const prevContent = importer.content
const prevToolCount = importer.hasPendingToolCalls
importer.ingestDelta(delta)
// 文本有变化 → 回调当前部分消息
if (importer.content !== prevContent || importer.hasPendingToolCalls !== prevToolCount) {
const partial = importer.toPartialMessage()
callbacks.onToken?.(partial)
}
}
} catch {
// JSON 解析失败 —— 跳过此 chunk(某些 API 可能返回非标准格式)
console.warn('[api] SSE chunk 解析失败:', dataStr.slice(0, 80))
}
}
}
}
// 构建最终消息
const finalMsg = importer.toMessage({ timestamp: Date.now() })
callbacks.onDone([finalMsg])
} catch (err) {
// 流式中断时,尝试保留已累积的内容
if (importer.content.trim() || importer.hasPendingToolCalls) {
const partial = importer.toPartialMessage({ timestamp: Date.now() })
// 用部分内容构建最终消息,但追加截断标记
const truncated = importer.toMessage({ timestamp: Date.now() })
if (truncated.segments.length > 0 && truncated.segments[0].kind === 'text') {
callbacks.onDone([truncated])
} else {
callbacks.onDone([partial])
}
}
callbacks.onError(
new Error(`流式连接中断: ${err instanceof Error ? err.message : String(err)}`)
)
}
}
// ============================================================
// 请求构造
// ============================================================
/**
* 构建 OpenAI Chat Completions 请求体。
* 从 envelope 导出并注入 model 配置。
*/
function buildRequestBody(
envelope: PromptEnvelope,
config: ApiConfig
): OpenAIExport & { stream?: boolean; stream_options?: { include_usage: boolean } } {
const exported = exportToOpenAIFormat(envelope)
return {
...exported,
model: config.model || exported.model,
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Live 模式工具定义与执行引擎。
*
* 当前仅提供一个简易算术工具 `calculate`
* - 支持四则运算、幂运算、三角函数、对数、开方等
* - 底层通过安全的 new Function() 执行(仅允许数学表达式字符集)
* - 错误会被捕获并以文本形式返回(success=false
*/
import type { Message, ToolItem } from '../types/protocol'
// ============================================================
// 工具定义
// ============================================================
/** Live 模式唯一的工具 */
export const LIVE_TOOL: ToolItem = {
name: 'calculate',
description: '执行数学计算表达式,支持加减乘除、幂运算 (**)、三角函数 (Math.sin/cos/tan)、反三角 (Math.asin/acos/atan)、对数 (Math.log/log2/log10)、开方 (Math.sqrt)、取整 (Math.floor/ceil/round)、绝对值 (Math.abs)、常数 (Math.PI, Math.E) 等',
parameters: 'expression: string — 数学表达式,例如 "123 * 456"、"Math.sqrt(144)"、"2 ** 10"、"Math.sin(Math.PI / 2)"',
schema: {
type: 'object',
properties: {
expression: {
type: 'string',
description: '数学表达式字符串,可使用 JavaScript Math 对象的所有方法及运算符',
},
},
required: ['expression'],
},
}
// ============================================================
// 工具执行
// ============================================================
/** 表达式安全校验 —— 只允许数学表达式常用字符 */
function validateExpression(expr: string): string | null {
const trimmed = expr.trim()
if (!trimmed) return '表达式不能为空'
// 白名单:字母/数字/运算符/括号/空格/点号/逗号/引号
// 允许 Math.xxx 函数调用、数字、运算符
if (!/^[a-zA-Z0-9+\-*/%()., \t\n'"\[\]_:!<>=&|^~]+$/.test(trimmed)) {
return `表达式包含不允许的字符`
}
// 防止过长输入
if (trimmed.length > 500) {
return '表达式过长(最多 500 个字符)'
}
return null
}
/**
* 执行工具调用并返回结果。
*
* @param toolName 工具名称
* @param args 工具参数(来自 tool_call_request.arguments
* @returns 执行结果
*/
export function executeToolCall(
toolName: string,
args: Record<string, unknown>
): { result: string; success: boolean } {
if (toolName !== 'calculate') {
return { result: `未知工具: ${toolName}`, success: false }
}
const rawExpr = args.expression
if (typeof rawExpr !== 'string') {
return { result: '参数错误: expression 必须是字符串', success: false }
}
const validationError = validateExpression(rawExpr)
if (validationError) {
return { result: validationError, success: false }
}
try {
// 使用 new Function 执行 —— 在已验证字符集的前提下安全
// 注入 Math 对象使其可用,这样用户可以直接写 Math.sin(x) 等
const fn = new Function('Math', `return (${rawExpr})`)
const result = fn(Math)
return { result: String(result), success: true }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return { result: `计算错误: ${msg}`, success: false }
}
}
// ============================================================
// Live 模式系统上下文
// ============================================================
/**
* 构建 Live 模式的系统消息(替代 demo 的 system context)。
*
* 包含:
* - 一个 system_prompt segment:简短的行为指令
* - 一个 tool_overview segment:仅含 calculate 工具
*/
export function buildLiveSystemMessage(): Message {
return {
id: 'live-system',
role: 'system',
segments: [
{
kind: 'system_prompt',
content:
'你是一个数学助手。如果用户问你数学计算问题,使用 calculate 工具来计算结果。\n' +
'如果用户只是闲聊,正常回复即可,不要调用工具。\n' +
'回复语言:中文。回答要简洁、友好。',
collapsed: true,
},
{
kind: 'tool_overview',
items: [LIVE_TOOL],
collapsed: false,
},
],
timestamp: Date.now(),
}
}
+105 -7
View File
@@ -11,9 +11,26 @@ export interface OpenAIToolCall {
} }
} }
/** 多模态 content part:文本 */
export interface OpenAITextContent {
type: 'text'
text: string
}
/** 多模态 content part:图片 URL */
export interface OpenAIImageUrlContent {
type: 'image_url'
image_url: {
url: string
detail?: 'auto' | 'low' | 'high'
}
}
export type OpenAIContentPart = OpenAITextContent | OpenAIImageUrlContent
export interface OpenAIMessage { export interface OpenAIMessage {
role: 'system' | 'user' | 'assistant' | 'tool' role: 'system' | 'user' | 'assistant' | 'tool'
content?: string | null content?: string | null | OpenAIContentPart[]
tool_calls?: OpenAIToolCall[] tool_calls?: OpenAIToolCall[]
tool_call_id?: string tool_call_id?: string
} }
@@ -115,6 +132,66 @@ function toolItemToOpenAI(item: ToolItem): OpenAITool {
} }
} }
/**
* 将消息的 segment 列表构建为 OpenAI 消息的 content 字段。
*
* - 纯文本消息 → 返回 string
* - 含图片/音频/视频的消息 → 返回 OpenAIContentPart[](多模态 content 数组)
*/
function buildUserContent(segments: Segment[]): string | OpenAIContentPart[] | null {
const hasMedia = segments.some((s) => s.kind === 'media')
if (!hasMedia) {
// 纯文本路径:与 segmentToText 行为一致
const text = segments
.map(segmentToText)
.filter(Boolean)
.join('\n')
return text || null
}
// 多模态路径:构建 content part 数组
const parts: OpenAIContentPart[] = []
for (const seg of segments) {
switch (seg.kind) {
case 'text':
parts.push({ type: 'text', text: seg.content })
break
case 'long_text':
parts.push({ type: 'text', text: seg.content })
break
case 'static_var':
parts.push({ type: 'text', text: seg.value })
break
case 'document':
parts.push({
type: 'text',
text: `[Document: ${seg.fileName} (${formatBytes(seg.sizeBytes)})]\n${seg.snippet}`,
})
break
case 'media':
if (seg.mediaType === 'image') {
parts.push({
type: 'image_url',
image_url: { url: seg.url },
})
} else {
// audio / video —— OpenAI 原生支持有限,用 altText 做文本回退
parts.push({
type: 'text',
text: seg.altText ?? `[${seg.mediaType}]`,
})
}
break
default:
// 结构性 segment (system_prompt / memory / skills / tool_overview)
// 以及 tool_call* —— 不在 user content 中出现
break
}
}
return parts.length > 0 ? parts : null
}
// --- Main export function --- // --- Main export function ---
/** /**
@@ -194,15 +271,11 @@ export function exportToOpenAIFormat(envelope: PromptEnvelope): OpenAIExport {
if (seg.kind === 'tool_overview') { if (seg.kind === 'tool_overview') {
seg.items.forEach(item => tools.push(toolItemToOpenAI(item))) seg.items.forEach(item => tools.push(toolItemToOpenAI(item)))
} }
if (seg.kind === 'static_var') continue // 静态变量仅通过 segmentToText 展开到消息内容,不写入 system prompt
const s = formatStructural(seg) const s = formatStructural(seg)
if (s) systemParts.push(s) if (s) systemParts.push(s)
} }
const content = msg.segments const content = buildUserContent(msg.segments)
.map(segmentToText) if (content) {
.filter(Boolean)
.join('\n')
if (content.trim()) {
messages.push({ role: 'user', content }) messages.push({ role: 'user', content })
} }
continue continue
@@ -253,6 +326,31 @@ export function exportToOpenAIFormat(envelope: PromptEnvelope): OpenAIExport {
}) })
} }
} }
// ── Tool (独立 role: "tool" 消息) ──
if (msg.role === 'tool') {
for (const seg of msg.segments) {
if (seg.kind === 'tool_call_result') {
// call_id 配对:按 toolName 的 FIFO 队列匹配
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_call_request') {
// tool 消息中包含 tool_call_request 的情况:也生成 tool_calls
const callId = nextCallId()
const queue = pendingCallIds.get(seg.toolName) || []
queue.push(callId)
pendingCallIds.set(seg.toolName, queue)
// 注意:独立 tool 消息不应该有 tool_call_request
// 这里做防御性处理
}
}
}
} }
// Prepend merged system message // Prepend merged system message
+349
View File
@@ -0,0 +1,349 @@
/**
* OpenAI API 响应 → Protocol Message 反向映射。
*
* 与 export.ts 的 exportToOpenAIFormat() 构成双向转换闭环:
*
* Protocol ──export.ts──▶ OpenAI Request ──API──▶ OpenAI Response
* │
* Protocol ◀──import.ts──────────────────────────────┘
*
* 职责:
* 1. importFromOpenAIResponse() — 完整响应 → Protocol Message(非流式)
* 2. StreamingImporter — SSE delta 增量累积 → Protocol Message(流式)
* 3. importToolResult() — tool 执行结果 → Protocol tool 消息
*/
import type { Message, Segment } from '../types/protocol'
import type { OpenAIMessage, OpenAIToolCall } from './export'
// ============================================================
// OpenAI Response 类型
// ============================================================
/** API 响应的 choice 对象 */
export interface OpenAIChoice {
index: number
message: OpenAIMessage
finish_reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | null
}
/** 完整的 Chat Completion 响应 */
export interface OpenAIResponse {
id: string
object: string
created: number
model: string
choices: OpenAIChoice[]
usage?: {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
}
/** SSE 流式 chunk 中的 delta */
export interface OpenAIDelta {
role?: string
content?: string
tool_calls?: Array<{
index: number
id?: string
type?: 'function'
function?: {
name?: string
arguments?: string
}
}>
}
/** SSE 流式 chunk */
export interface OpenAIStreamChunk {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
delta: OpenAIDelta
finish_reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | null
}>
}
// ============================================================
// ID 生成
// ============================================================
let idCounter = 0
/** 生成唯一消息 ID */
function genMessageId(): string {
return `msg_${Date.now()}_${++idCounter}`
}
// ============================================================
// 核心导入函数
// ============================================================
/**
* 将 OpenAI API 响应消息转换为 Protocol Message。
*
* 映射规则:
* - content(非空)→ TextSegment
* - tool_calls[] → ToolCallRequestSegment(每个 tool_call 一条)
* - finish_reason → 仅在 "length" 时追加截断标记
* - content 和 tool_calls 可共存于同一消息的 segments 中
*
* @param message choices[0].message —— API 返回的 assistant 消息
* @param finishReason choices[0].finish_reason
* @param options 可选的 id / timestamp 覆盖
* @returns 单条 role: "assistant" 的 Protocol Message
*/
export function importFromOpenAIResponse(
message: OpenAIMessage,
finishReason: OpenAIChoice['finish_reason'] = 'stop',
options?: { id?: string; timestamp?: number }
): Message {
const segments: Segment[] = []
// 1. 文本内容 → TextSegment
if (message.content && typeof message.content === 'string' && message.content.trim()) {
segments.push({
kind: 'text',
content: message.content,
})
}
// 2. tool_calls → ToolCallRequestSegment
if (message.tool_calls && message.tool_calls.length > 0) {
for (const tc of message.tool_calls) {
let args: Record<string, unknown> = {}
if (tc.function.arguments) {
try {
args = JSON.parse(tc.function.arguments)
} catch {
// arguments 不是合法 JSON(可能被截断),保留原始字符串
args = { _raw: tc.function.arguments }
}
}
segments.push({
kind: 'tool_call_request',
toolName: tc.function.name,
arguments: args,
collapsed: false,
})
}
}
// 3. finish_reason 处理
if (finishReason === 'length') {
segments.push({
kind: 'text',
content: '[因长度限制被截断]',
})
}
// 确保至少有一个 segment(API 不应返回空消息,但做防御)
if (segments.length === 0) {
segments.push({
kind: 'text',
content: message.content
? String(message.content)
: '[空响应]',
})
}
return {
id: options?.id ?? genMessageId(),
role: 'assistant',
segments,
timestamp: options?.timestamp ?? Date.now(),
}
}
// ============================================================
// Tool 结果导入
// ============================================================
/**
* 构造 tool 执行结果的 Protocol 消息。
*
* @param toolName 被调用的 tool 名称
* @param result 执行结果文本
* @param success 是否执行成功
* @param collapsed 是否默认折叠(默认 true)
*/
export function importToolResult(
toolName: string,
result: string,
success: boolean,
collapsed = true
): Message {
return {
id: genMessageId(),
role: 'tool',
segments: [
{
kind: 'tool_call_result',
toolName,
result,
success,
collapsed,
},
],
timestamp: Date.now(),
}
}
// ============================================================
// 流式增量导入器
// ============================================================
/**
* 流式场景的增量累加器。
*
* 用法:
* const importer = new StreamingImporter()
* for (const chunk of sseChunks) {
* importer.ingestDelta(chunk.choices[0].delta)
* // 渲染部分内容:
* render(importer.toPartialMessage())
* }
* // 完成时构建最终消息:
* const finalMsg = importer.toMessage()
*/
export class StreamingImporter {
private contentBuf = ''
private toolCallBuf: Map<number, { id?: string; name: string; args: string }> = new Map()
private role: string = 'assistant'
/**
* 摄入一个 SSE delta 对象。
* @param delta choices[0].delta
*/
ingestDelta(delta: OpenAIDelta): void {
// 记录 role(通常只在第一个 delta 出现)
if (delta.role) {
this.role = delta.role
}
// 文本内容:直接拼接
if (delta.content) {
this.contentBuf += delta.content
}
// tool_calls:按 index 分组累积
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const existing = this.toolCallBuf.get(tc.index) || { name: '', args: '' }
if (tc.id) {
existing.id = tc.id
}
if (tc.function?.name) {
existing.name = tc.function.name
}
if (tc.function?.arguments) {
existing.args += tc.function.arguments
}
this.toolCallBuf.set(tc.index, { ...existing })
}
}
}
/**
* 重置所有累积状态,准备下一轮对话。
*/
reset(): void {
this.contentBuf = ''
this.toolCallBuf.clear()
this.role = 'assistant'
}
/**
* 构建当前的完整 Protocol Message。
* 在流式结束时调用,得到最终的不可变消息。
*/
toMessage(options?: { id?: string; timestamp?: number }): Message {
return this.buildMessage(false, options)
}
/**
* 构建当前部分内容的 Protocol Message(用于流式渲染的中间状态)。
* content 为当前已累积的文本,tool_calls 仅包含已完整接收的。
*/
toPartialMessage(options?: { id?: string; timestamp?: number }): Message {
return this.buildMessage(true, options)
}
/** 当前已累积的纯文本内容 */
get content(): string {
return this.contentBuf
}
/** 是否有未完成的 tool_call 在累积中 */
get hasPendingToolCalls(): boolean {
return this.toolCallBuf.size > 0
}
// ── 私有 ──
private buildMessage(partial: boolean, options?: { id?: string; timestamp?: number }): Message {
const segments: Segment[] = []
// 文本内容(即使是部分模式也输出,用于实时打字效果)
if (this.contentBuf.trim()) {
segments.push({
kind: 'text',
content: this.contentBuf,
})
}
// tool_calls —— 部分模式下,只有 arguments 是合法 JSON 的才展示
const sortedIndices = [...this.toolCallBuf.keys()].sort()
for (const idx of sortedIndices) {
const tc = this.toolCallBuf.get(idx)!
if (!tc.name) continue // 还没收到 name,跳过
let args: Record<string, unknown> = {}
let argsValid = false
if (tc.args) {
try {
args = JSON.parse(tc.args)
argsValid = true
} catch {
// 流式传输中 arguments 可能不完整——部分模式跳过,最终模式保留原始字符串
if (!partial) {
args = { _raw: tc.args }
}
}
}
// 部分模式:只展示已完整解析的 tool_call
if (partial && (!argsValid || !tc.args)) {
continue
}
segments.push({
kind: 'tool_call_request',
toolName: tc.name,
arguments: args,
collapsed: false,
})
}
// 防御:无内容时返回占位
if (segments.length === 0) {
segments.push({
kind: 'text',
content: partial ? '…' : '[空响应]',
})
}
return {
id: options?.id ?? genMessageId(),
role: this.role as Message['role'],
segments,
timestamp: options?.timestamp ?? Date.now(),
}
}
}