feat: 富文本消息类型支持 — Markdown/代码块安全渲染 + 审查解析器
添加 review_parser.go 从 LLM 输出中提取 Markdown 和代码块,创建独立 ReviewMessage 类型 (markdown/code/search_result)。前端新增安全 Markdown 渲染器 (HTML 转义优先),代码块以深色背景+语言标签展示。Markdown/代码 类型禁止断句拆分,避免格式损坏。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -147,7 +147,10 @@ type ReviewMessageType string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
ReviewMessageAction ReviewMessageType = "action" // 动作消息 (括号内容)
|
ReviewMessageAction ReviewMessageType = "action" // 动作消息 (括号内容)
|
||||||
ReviewMessageChat ReviewMessageType = "chat" // 聊天消息 (引号/普通内容)
|
ReviewMessageChat ReviewMessageType = "chat" // 聊天消息 (普通文本)
|
||||||
|
ReviewMessageMarkdown ReviewMessageType = "markdown" // Markdown 格式内容 (标题/列表/表格/链接/粗斜体等)
|
||||||
|
ReviewMessageCode ReviewMessageType = "code" // 代码块 (带语言标识)
|
||||||
|
ReviewMessageSearchResult ReviewMessageType = "search_result" // 单条搜索结果
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReviewMessage 审查后的消息
|
// ReviewMessage 审查后的消息
|
||||||
@@ -155,6 +158,7 @@ type ReviewMessage struct {
|
|||||||
Type ReviewMessageType `json:"type"`
|
Type ReviewMessageType `json:"type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
DelayMs int `json:"delay_ms,omitempty"` // ms to wait before sending (0 = immediate)
|
DelayMs int `json:"delay_ms,omitempty"` // ms to wait before sending (0 = immediate)
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"` // 类型特定元数据 (code语言、搜索结果URL等)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Segment 语音片段
|
// Segment 语音片段
|
||||||
|
|||||||
@@ -470,80 +470,6 @@ func (o *Orchestrator) scheduleWithDelays(messages []model.ReviewMessage) []mode
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseReviewMessages 解析完整回复文本,拆分为带类型的消息
|
|
||||||
// 用于审查子会话的轻量版本(内联到 orchestrator 以减少一次子会话调度开销)
|
|
||||||
func parseReviewMessages(text string) []model.ReviewMessage {
|
|
||||||
if text == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var messages []model.ReviewMessage
|
|
||||||
|
|
||||||
// 简单状态机:逐行或按括号匹配提取(使用 rune 切片正确处理 Unicode)
|
|
||||||
remaining := text
|
|
||||||
for len(remaining) > 0 {
|
|
||||||
// 查找括号动作 (xxx)或 (xxx)
|
|
||||||
actionStart := -1 // byte 位置
|
|
||||||
actionEnd := -1 // byte 位置(括号之后)
|
|
||||||
actionContent := ""
|
|
||||||
|
|
||||||
runes := []rune(remaining)
|
|
||||||
for ri, r := range runes {
|
|
||||||
if r == '(' || r == '(' {
|
|
||||||
actionStart = len(string(runes[:ri]))
|
|
||||||
closeRune := ')'
|
|
||||||
if r == '(' {
|
|
||||||
closeRune = ')'
|
|
||||||
}
|
|
||||||
// 查找匹配的闭合括号
|
|
||||||
for rj := ri + 1; rj < len(runes); rj++ {
|
|
||||||
if runes[rj] == closeRune {
|
|
||||||
actionEnd = len(string(runes[:rj+1]))
|
|
||||||
actionContent = string(runes[ri+1 : rj])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if actionStart >= 0 {
|
|
||||||
// 括号前的普通文本
|
|
||||||
if actionStart > 0 {
|
|
||||||
prefix := strings.TrimSpace(remaining[:actionStart])
|
|
||||||
if prefix != "" {
|
|
||||||
messages = append(messages, splitChatByLines(model.ReviewMessageChat, prefix)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 括号内作为 action
|
|
||||||
content := strings.TrimSpace(actionContent)
|
|
||||||
if content != "" {
|
|
||||||
messages = append(messages, model.ReviewMessage{
|
|
||||||
Type: model.ReviewMessageAction,
|
|
||||||
Content: content,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
remaining = remaining[actionEnd:]
|
|
||||||
} else {
|
|
||||||
// 没有括号,剩余全部作为 chat
|
|
||||||
remaining = strings.TrimSpace(remaining)
|
|
||||||
if remaining != "" {
|
|
||||||
messages = append(messages, splitChatByLines(model.ReviewMessageChat, remaining)...)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(messages) == 0 && text != "" {
|
|
||||||
messages = append(messages, model.ReviewMessage{
|
|
||||||
Type: model.ReviewMessageChat,
|
|
||||||
Content: strings.TrimSpace(text),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitReviewLongMessage 将长消息按句子边界拆分为多条短消息
|
// splitReviewLongMessage 将长消息按句子边界拆分为多条短消息
|
||||||
func splitReviewLongMessage(msgType model.ReviewMessageType, text string) []model.ReviewMessage {
|
func splitReviewLongMessage(msgType model.ReviewMessageType, text string) []model.ReviewMessage {
|
||||||
const maxLen = 80 // 最大字符数(按 rune 计数)
|
const maxLen = 80 // 最大字符数(按 rune 计数)
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yourname/cyrene-ai/ai-core/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// codeBlockPattern matches fenced code blocks: ```lang\n...\n```
|
||||||
|
var codeBlockPattern = regexp.MustCompile("`{3}([^\n]*)\n([\\s\\S]*?)`{3}")
|
||||||
|
|
||||||
|
// markdownPatterns detects common Markdown syntax for auto-classification.
|
||||||
|
var markdownPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`^#{1,6}\s`), // headings
|
||||||
|
regexp.MustCompile(`\*\*[^*]+\*\*`), // bold
|
||||||
|
regexp.MustCompile(`(?<!\*)\*[^*]+\*(?!\*)`), // italic (single *)
|
||||||
|
regexp.MustCompile(`\[([^\]]+)\]\(([^\)]+)\)`), // links [text](url)
|
||||||
|
regexp.MustCompile(`^[\-\*]\s`), // unordered list
|
||||||
|
regexp.MustCompile(`^\d+\.\s`), // ordered list
|
||||||
|
regexp.MustCompile(`^>\s`), // blockquote
|
||||||
|
regexp.MustCompile(`^\|.*\|.*\|`), // table
|
||||||
|
regexp.MustCompile("`[^`]+`"), // inline code
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasMarkdownSyntax reports whether text contains Markdown formatting.
|
||||||
|
func hasMarkdownSyntax(text string) bool {
|
||||||
|
for _, p := range markdownPatterns {
|
||||||
|
if p.MatchString(text) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoDetectType returns the best message type for a text segment.
|
||||||
|
func autoDetectType(text string) model.ReviewMessageType {
|
||||||
|
if hasMarkdownSyntax(text) {
|
||||||
|
return model.ReviewMessageMarkdown
|
||||||
|
}
|
||||||
|
return model.ReviewMessageChat
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReviewMessages splits the assistant's full response into typed messages.
|
||||||
|
//
|
||||||
|
// Phases:
|
||||||
|
// 1. Extract fenced code blocks (```) → code type with language metadata.
|
||||||
|
// 2. For text between code blocks, run the bracket-action parser:
|
||||||
|
// (…) / (…) → action type.
|
||||||
|
// 3. Remaining text is auto-detected as markdown or chat.
|
||||||
|
// 4. Markdown and code messages are never sentence-split (keeps formatting intact).
|
||||||
|
func parseReviewMessages(text string) []model.ReviewMessage {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []model.ReviewMessage
|
||||||
|
|
||||||
|
// Phase 1: extract code blocks
|
||||||
|
codeMatches := codeBlockPattern.FindAllStringSubmatchIndex(text, -1)
|
||||||
|
type codeBlock struct {
|
||||||
|
start, end int
|
||||||
|
language string
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
var blocks []codeBlock
|
||||||
|
for _, m := range codeMatches {
|
||||||
|
blocks = append(blocks, codeBlock{
|
||||||
|
start: m[0],
|
||||||
|
end: m[1],
|
||||||
|
language: strings.TrimSpace(text[m[2]:m[3]]),
|
||||||
|
content: strings.TrimSpace(text[m[4]:m[5]]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: bracket-action parser on non-code text
|
||||||
|
processText := func(t string) {
|
||||||
|
remaining := t
|
||||||
|
for len(remaining) > 0 {
|
||||||
|
actionStart := -1
|
||||||
|
actionEnd := -1
|
||||||
|
actionContent := ""
|
||||||
|
|
||||||
|
runes := []rune(remaining)
|
||||||
|
for ri, r := range runes {
|
||||||
|
if r == '(' || r == '(' { // fullwidth (
|
||||||
|
actionStart = len(string(runes[:ri]))
|
||||||
|
closeRune := ')'
|
||||||
|
if r == '(' {
|
||||||
|
closeRune = ')' // fullwidth )
|
||||||
|
}
|
||||||
|
for rj := ri + 1; rj < len(runes); rj++ {
|
||||||
|
if runes[rj] == closeRune {
|
||||||
|
actionEnd = len(string(runes[:rj+1]))
|
||||||
|
actionContent = string(runes[ri+1 : rj])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if actionStart >= 0 {
|
||||||
|
if actionStart > 0 {
|
||||||
|
prefix := strings.TrimSpace(remaining[:actionStart])
|
||||||
|
if prefix != "" {
|
||||||
|
messages = append(messages, classifyText(autoDetectType(prefix), prefix)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(actionContent)
|
||||||
|
if content != "" {
|
||||||
|
messages = append(messages, model.ReviewMessage{
|
||||||
|
Type: model.ReviewMessageAction,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
remaining = remaining[actionEnd:]
|
||||||
|
} else {
|
||||||
|
remaining = strings.TrimSpace(remaining)
|
||||||
|
if remaining != "" {
|
||||||
|
messages = append(messages, classifyText(autoDetectType(remaining), remaining)...)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: interleave code blocks and parsed text
|
||||||
|
pos := 0
|
||||||
|
for _, cb := range blocks {
|
||||||
|
if cb.start > pos {
|
||||||
|
processText(text[pos:cb.start])
|
||||||
|
}
|
||||||
|
messages = append(messages, model.ReviewMessage{
|
||||||
|
Type: model.ReviewMessageCode,
|
||||||
|
Content: cb.content,
|
||||||
|
Metadata: map[string]any{"language": cb.language},
|
||||||
|
})
|
||||||
|
pos = cb.end
|
||||||
|
}
|
||||||
|
if pos < len(text) {
|
||||||
|
processText(text[pos:])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) == 0 && text != "" {
|
||||||
|
messages = append(messages, model.ReviewMessage{
|
||||||
|
Type: model.ReviewMessageChat,
|
||||||
|
Content: strings.TrimSpace(text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyText splits text by paragraph boundaries.
|
||||||
|
// markdown and code types are never sentence-split — they stay as complete blocks.
|
||||||
|
func classifyText(msgType model.ReviewMessageType, text string) []model.ReviewMessage {
|
||||||
|
switch msgType {
|
||||||
|
case model.ReviewMessageMarkdown, model.ReviewMessageCode:
|
||||||
|
return []model.ReviewMessage{{Type: msgType, Content: text}}
|
||||||
|
default:
|
||||||
|
return splitChatByLines(msgType, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-3
@@ -209,7 +209,7 @@ ws://<gateway>/ws/chat?token=<jwt>&session_id=<optional>&client_id=<optional>&de
|
|||||||
"text": "string (完整文本)",
|
"text": "string (完整文本)",
|
||||||
"content": "string (增量文本/完整内容)",
|
"content": "string (增量文本/完整内容)",
|
||||||
"role": "user|assistant|action|system",
|
"role": "user|assistant|action|system",
|
||||||
"msg_type": "chat|action|thinking|tool_progress|system_info (后端始终填充,前端无需自行解析)",
|
"msg_type": "chat|action|thinking|tool_progress|system_info|markdown|code (后端始终填充,前端无需自行解析)",
|
||||||
"session_id": "string",
|
"session_id": "string",
|
||||||
"error": "string (仅错误时)",
|
"error": "string (仅错误时)",
|
||||||
"timestamp": 1717000000000,
|
"timestamp": 1717000000000,
|
||||||
@@ -221,7 +221,7 @@ ws://<gateway>/ws/chat?token=<jwt>&session_id=<optional>&client_id=<optional>&de
|
|||||||
"system_info": { "level": "info|warning|error", "message": "string", "action": "string" },
|
"system_info": { "level": "info|warning|error", "message": "string", "action": "string" },
|
||||||
"notification": { "id": "string", "type": "info|warning|success|thinking|reminder", "title": "string", "body": "string", "timestamp": "string", "data": {} },
|
"notification": { "id": "string", "type": "info|warning|success|thinking|reminder", "title": "string", "body": "string", "timestamp": "string", "data": {} },
|
||||||
"multi_message": { "messages": [ { "index": 0, "content": "string", "msg_type": "chat|action|system_info" } ] },
|
"multi_message": { "messages": [ { "index": 0, "content": "string", "msg_type": "chat|action|system_info" } ] },
|
||||||
"review_messages": [ { "type": "action|chat", "content": "string", "delay_ms": 0 } ],
|
"review_messages": [ { "type": "action|chat|markdown|code|search_result", "content": "string", "delay_ms": 0, "metadata": { "language": "string (code 类型时)", "url": "string (search_result 类型时)" } } ],
|
||||||
"client_info": { "client_id": "string", "device_name": "string", "user_agent": "string" },
|
"client_info": { "client_id": "string", "device_name": "string", "user_agent": "string" },
|
||||||
"full_audio_url": "string",
|
"full_audio_url": "string",
|
||||||
"response_mode": "string"
|
"response_mode": "string"
|
||||||
@@ -255,9 +255,23 @@ ws://<gateway>/ws/chat?token=<jwt>&session_id=<optional>&client_id=<optional>&de
|
|||||||
> `msg_type` 可选值:
|
> `msg_type` 可选值:
|
||||||
> - `chat` — 普通聊天消息
|
> - `chat` — 普通聊天消息
|
||||||
> - `action` — 动作/旁白消息(如 `(昔涟轻轻推开窗户)`),前端以斜体灰色样式渲染
|
> - `action` — 动作/旁白消息(如 `(昔涟轻轻推开窗户)`),前端以斜体灰色样式渲染
|
||||||
|
> - `markdown` — Markdown 格式消息,前端使用安全渲染器转换后显示(支持标题、列表、表格、代码块、链接等)
|
||||||
|
> - `code` — 独立代码块消息,前端以深色背景 + 语言标签渲染,可包含 `metadata.language` 指明编程语言
|
||||||
> - `thinking` — 后台思考过程,前端显示为可折叠详情块
|
> - `thinking` — 后台思考过程,前端显示为可折叠详情块
|
||||||
> - `tool_progress` — 工具执行进度,前端显示进度条
|
> - `tool_progress` — 工具执行进度,前端显示进度条
|
||||||
> - `system_info` — 系统通知,前端居中显示为 toast 样式
|
> - `system_info` — 系统通知,前端居中显示为 toast 样式
|
||||||
|
>
|
||||||
|
> **`review` 消息类型详情:**
|
||||||
|
>
|
||||||
|
> 当 AI 回复包含多种格式内容(如工具调用结果中的 Markdown 文档或代码片段)时,后端解析器将回复拆分为多条 `review_messages`,每条独立指定 `type` 和 `content`:
|
||||||
|
>
|
||||||
|
> - `action` — 动作/旁白文本,不做 Markdown 渲染,禁止断句拆分
|
||||||
|
> - `chat` — 普通聊天文本,可按句长断句
|
||||||
|
> - `markdown` — Markdown 格式文本,禁止断句拆分,前端使用安全渲染器(先 HTML 转义再转换 Markdown 语法)
|
||||||
|
> - `code` — 代码块,禁止断句拆分,前端深色背景渲染,`metadata.language` 字段携带语言标识
|
||||||
|
> - `search_result` — 搜索工具调用结果摘要(后端内部使用,通常转为 `markdown` 或 `chat` 类型展示)
|
||||||
|
>
|
||||||
|
> Markdown 渲染器支持语法:标题 (h1-h6)、粗体/斜体、行内代码/围栏代码块、链接、图片、无序/有序列表、引用块、表格、水平线。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -358,7 +372,7 @@ Client Gateway Voice-Service
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"session_id": "string",
|
"session_id": "string",
|
||||||
"role": "user|assistant|system|action",
|
"role": "user|assistant|system|action",
|
||||||
"msg_type": "chat|action",
|
"msg_type": "chat|action|markdown|code",
|
||||||
"content": "string",
|
"content": "string",
|
||||||
"created_at": 1717000000000
|
"created_at": 1717000000000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
interface MarkdownRendererProps {
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight, secure Markdown renderer.
|
||||||
|
*
|
||||||
|
* 1. HTML-escapes all content first (prevents XSS).
|
||||||
|
* 2. Converts common Markdown syntax to HTML.
|
||||||
|
*
|
||||||
|
* Supported syntax:
|
||||||
|
* - Headings (# ## ### … ######)
|
||||||
|
* - Bold (**text**), Italic (*text*)
|
||||||
|
* - Inline code (`code`)
|
||||||
|
* - Fenced code blocks (```lang … ```)
|
||||||
|
* - Links [text](url) → target=_blank rel=noopener
|
||||||
|
* - Unordered lists (- or *)
|
||||||
|
* - Ordered lists (1. 2. …)
|
||||||
|
* - Blockquotes (> text)
|
||||||
|
* - Horizontal rules (--- or ***)
|
||||||
|
* - Tables (| col | col |)
|
||||||
|
* - Line breaks (two trailing spaces or blank line)
|
||||||
|
*/
|
||||||
|
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
||||||
|
const html = useMemo(() => renderMarkdown(content), [content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`markdown-body ${className}`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- converter ----
|
||||||
|
|
||||||
|
function renderMarkdown(text: string): string {
|
||||||
|
// 1. HTML-escape
|
||||||
|
let html = escapeHTML(text);
|
||||||
|
|
||||||
|
// 2. Fenced code blocks (before other transformations — they contain raw markdown chars)
|
||||||
|
html = html.replace(
|
||||||
|
/```(\w*)\n([\s\S]*?)```/g,
|
||||||
|
(_: string, lang: string, code: string) =>
|
||||||
|
`<pre><code class="language-${lang || 'text'}">${code.trim()}</code></pre>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Headings (after code blocks to avoid matching # comments in code)
|
||||||
|
html = html.replace(/^###### (.+)$/gm, '<h6>$1</h6>');
|
||||||
|
html = html.replace(/^##### (.+)$/gm, '<h5>$1</h5>');
|
||||||
|
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||||
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||||
|
|
||||||
|
// 4. Horizontal rules
|
||||||
|
html = html.replace(/^(---|\*\*\*)$/gm, '<hr />');
|
||||||
|
|
||||||
|
// 5. Blockquotes
|
||||||
|
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
||||||
|
|
||||||
|
// 6. Bold and italic
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
html = html.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// 7. Inline code (already escaped, just wrap)
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
||||||
|
|
||||||
|
// 8. Links [text](url) — open in new tab
|
||||||
|
html = html.replace(
|
||||||
|
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||||
|
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9. Images  — after links to avoid false match
|
||||||
|
html = html.replace(
|
||||||
|
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||||
|
'<img src="$2" alt="$1" loading="lazy" />'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 10. Tables — detect lines with at least two | separators
|
||||||
|
const lines = html.split('\n');
|
||||||
|
const result: string[] = [];
|
||||||
|
let inTable = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const isTableLine = /^\|.*\|.*\|/.test(line);
|
||||||
|
const isSepLine = /^\|[\s\-:|]+\|/.test(line);
|
||||||
|
|
||||||
|
if (isTableLine) {
|
||||||
|
if (!inTable) {
|
||||||
|
result.push('<table>');
|
||||||
|
inTable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSepLine) {
|
||||||
|
// Alignment separator row — skip rendering but use for alignment info
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = line
|
||||||
|
.replace(/^\|/, '')
|
||||||
|
.replace(/\|$/, '')
|
||||||
|
.split('|')
|
||||||
|
.map((c) => c.trim());
|
||||||
|
|
||||||
|
const isFirstRow = inTable && result[result.length - 1] === '<table>';
|
||||||
|
const tag = isFirstRow ? 'th' : 'td';
|
||||||
|
result.push(
|
||||||
|
'<tr>' + cells.map((c) => `<${tag}>${c}</${tag}>`).join('') + '</tr>'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (inTable) {
|
||||||
|
result.push('</table>');
|
||||||
|
inTable = false;
|
||||||
|
}
|
||||||
|
result.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inTable) {
|
||||||
|
result.push('</table>');
|
||||||
|
}
|
||||||
|
html = result.join('\n');
|
||||||
|
|
||||||
|
// 11. Unordered lists
|
||||||
|
html = html.replace(/^[\-\*] (.+)$/gm, '<li>$1</li>');
|
||||||
|
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
|
||||||
|
|
||||||
|
// 12. Ordered lists
|
||||||
|
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
|
||||||
|
// Wrap consecutive <li> blocks that aren't already inside a <ul>
|
||||||
|
// (This is a simplification — real markdown would need a proper parser)
|
||||||
|
|
||||||
|
// 13. Paragraphs: wrap remaining text lines in <p>, skip already-tagged lines
|
||||||
|
const finalLines = html.split('\n');
|
||||||
|
const out: string[] = [];
|
||||||
|
let para: string[] = [];
|
||||||
|
|
||||||
|
function flushPara() {
|
||||||
|
if (para.length > 0) {
|
||||||
|
out.push('<p>' + para.join('\n') + '</p>');
|
||||||
|
para = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const l of finalLines) {
|
||||||
|
const isBlock =
|
||||||
|
/^<(h[1-6]|ul|ol|li|table|pre|blockquote|hr|div|p|tr|th|td|thead|tbody|img)/.test(l) ||
|
||||||
|
/^<\/(ul|ol|table|blockquote|pre|div)>/.test(l) ||
|
||||||
|
l === '';
|
||||||
|
|
||||||
|
if (isBlock) {
|
||||||
|
flushPara();
|
||||||
|
if (l !== '') out.push(l);
|
||||||
|
} else {
|
||||||
|
para.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushPara();
|
||||||
|
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape HTML special characters. */
|
||||||
|
function escapeHTML(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useChatStore } from '@/store/chatStore';
|
|||||||
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
|
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
|
||||||
import type { MessageAttachment, MultiMessageItem, StreamSegment, MessageDisplayType } from '@/types/chat';
|
import type { MessageAttachment, MultiMessageItem, StreamSegment, MessageDisplayType } from '@/types/chat';
|
||||||
import { ImageLightbox } from './ImageLightbox';
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +17,7 @@ interface MessageBubbleProps {
|
|||||||
multiMessages?: MultiMessageItem[];
|
multiMessages?: MultiMessageItem[];
|
||||||
streamSegments?: StreamSegment[];
|
streamSegments?: StreamSegment[];
|
||||||
msgType?: MessageDisplayType;
|
msgType?: MessageDisplayType;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,12 +154,15 @@ export function MessageBubble({
|
|||||||
multiMessages,
|
multiMessages,
|
||||||
streamSegments,
|
streamSegments,
|
||||||
msgType,
|
msgType,
|
||||||
|
metadata,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const isUser = role === 'user';
|
const isUser = role === 'user';
|
||||||
const isAction = role === 'action' || msgType === 'action';
|
const isAction = role === 'action' || msgType === 'action';
|
||||||
const isThinking = msgType === 'thinking';
|
const isThinking = msgType === 'thinking';
|
||||||
const isToolProgress = msgType === 'tool_progress';
|
const isToolProgress = msgType === 'tool_progress';
|
||||||
const isSystemInfo = msgType === 'system_info';
|
const isSystemInfo = msgType === 'system_info';
|
||||||
|
const isMarkdown = msgType === 'markdown';
|
||||||
|
const isCode = msgType === 'code';
|
||||||
|
|
||||||
// 动作消息使用独立的渲染方式
|
// 动作消息使用独立的渲染方式
|
||||||
if (isAction) {
|
if (isAction) {
|
||||||
@@ -195,6 +200,45 @@ export function MessageBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 代码块 — 独立渲染,深色背景 + 语言标签
|
||||||
|
if (isCode) {
|
||||||
|
const lang = (metadata?.language as string) || '';
|
||||||
|
return (
|
||||||
|
<div className="flex px-4 py-0.5 gap-2 items-start group animate-fadeIn">
|
||||||
|
<div className="w-8 flex-shrink-0" />
|
||||||
|
<div className="max-w-[85%] w-full my-1 rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 bg-gray-900 dark:bg-gray-950 shadow-sm">
|
||||||
|
{lang && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-800 dark:bg-gray-900 border-b border-gray-700">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-gray-400 font-mono">{lang}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre className="px-4 py-3 overflow-x-auto">
|
||||||
|
<code className="text-xs md:text-sm text-gray-100 font-mono leading-relaxed whitespace-pre">{content}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-400 flex-shrink-0 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown — 在昔涟气泡内使用 MarkdownRenderer
|
||||||
|
if (isMarkdown) {
|
||||||
|
const time = new Date(timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return (
|
||||||
|
<div className="flex px-4 py-2 gap-2 items-end group">
|
||||||
|
<CyreneAvatar size="sm" className="flex-shrink-0 mb-0.5" />
|
||||||
|
<div className="max-w-[75%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed shadow-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-bl-md border border-pink-100 dark:border-pink-900">
|
||||||
|
<MarkdownRenderer content={content} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0 mb-1 hidden md:block md:opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
{time}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export function MessageList({
|
|||||||
isStreaming={msg.isStreaming}
|
isStreaming={msg.isStreaming}
|
||||||
attachments={msg.attachments}
|
attachments={msg.attachments}
|
||||||
msgType={msg.msgType}
|
msgType={msg.msgType}
|
||||||
|
metadata={msg.metadata}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -312,17 +312,19 @@ function handleServerMessage(msg: WSServerMessage) {
|
|||||||
|
|
||||||
case 'review':
|
case 'review':
|
||||||
// 审查子会话消息 — 后端返回带类型的 review_messages 列表
|
// 审查子会话消息 — 后端返回带类型的 review_messages 列表
|
||||||
|
// 支持类型: action, chat, markdown, code, search_result
|
||||||
if (msg.review_messages && msg.review_messages.length > 0) {
|
if (msg.review_messages && msg.review_messages.length > 0) {
|
||||||
// 逐条显示审查消息,action 类型使用动作消息样式,chat 类型使用普通聊天样式
|
|
||||||
for (const rm of msg.review_messages) {
|
for (const rm of msg.review_messages) {
|
||||||
|
const msgType = (rm.type as MessageDisplayType) || 'chat';
|
||||||
addMessage({
|
addMessage({
|
||||||
id: `review_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
id: `review_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
role: rm.type === 'action' ? 'action' : 'assistant',
|
role: rm.type === 'action' ? 'action' : 'assistant',
|
||||||
content: rm.content,
|
content: rm.content,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
msgType: rm.type === 'action' ? 'action' : 'chat',
|
msgType,
|
||||||
});
|
metadata: rm.metadata,
|
||||||
|
} as Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTyping(false);
|
setTyping(false);
|
||||||
|
|||||||
@@ -197,3 +197,55 @@
|
|||||||
.tts-playing {
|
.tts-playing {
|
||||||
animation: tts-wave 1s ease-in-out infinite;
|
animation: tts-wave 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Markdown 渲染样式 ===== */
|
||||||
|
.markdown-body h1 { font-size: 1.25rem; font-weight: 700; margin: 0.75rem 0 0.5rem; }
|
||||||
|
.markdown-body h2 { font-size: 1.1rem; font-weight: 700; margin: 0.75rem 0 0.4rem; }
|
||||||
|
.markdown-body h3 { font-size: 1rem; font-weight: 700; margin: 0.6rem 0 0.3rem; }
|
||||||
|
.markdown-body h4, .markdown-body h5, .markdown-body h6 { font-size: 0.95rem; font-weight: 600; margin: 0.5rem 0 0.25rem; }
|
||||||
|
.markdown-body p { margin: 0.3rem 0; }
|
||||||
|
.markdown-body ul, .markdown-body ol { margin: 0.3rem 0; padding-left: 1.5rem; }
|
||||||
|
.markdown-body li { margin: 0.15rem 0; }
|
||||||
|
.markdown-body ul li { list-style-type: disc; }
|
||||||
|
.markdown-body ol li { list-style-type: decimal; }
|
||||||
|
.markdown-body strong { font-weight: 700; color: inherit; }
|
||||||
|
.markdown-body em { font-style: italic; }
|
||||||
|
.markdown-body a { color: #ec4899; text-decoration: underline; word-break: break-all; }
|
||||||
|
.markdown-body a:hover { color: #be185d; }
|
||||||
|
.markdown-body blockquote {
|
||||||
|
border-left: 3px solid #f9a8d4;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.markdown-body hr { border: none; border-top: 1px solid #fce7f3; margin: 0.75rem 0; }
|
||||||
|
.markdown-body table { width: 100%; border-collapse: collapse; margin: 0.5rem 0; font-size: 0.85rem; }
|
||||||
|
.markdown-body th, .markdown-body td { border: 1px solid #fce7f3; padding: 0.35rem 0.6rem; text-align: left; }
|
||||||
|
.markdown-body th { background: #fdf2f8; font-weight: 600; }
|
||||||
|
.markdown-body img { max-width: 100%; height: auto; border-radius: 0.5rem; margin: 0.3rem 0; }
|
||||||
|
.markdown-body pre { background: #1e293b; border-radius: 0.5rem; padding: 0; margin: 0.5rem 0; overflow-x: auto; }
|
||||||
|
.markdown-body pre code { display: block; padding: 0.75rem 1rem; color: #e2e8f0; font-size: 0.8rem; line-height: 1.6; white-space: pre; }
|
||||||
|
.markdown-body code.inline-code {
|
||||||
|
background: #fce7f3;
|
||||||
|
color: #be185d;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
.markdown-body > *:first-child { margin-top: 0; }
|
||||||
|
.markdown-body > *:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* 深色模式 Markdown */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.markdown-body a { color: #f472b6; }
|
||||||
|
.markdown-body a:hover { color: #fbcfe8; }
|
||||||
|
.markdown-body blockquote { border-left-color: #9d174d; color: #9ca3af; }
|
||||||
|
.markdown-body th, .markdown-body td { border-color: #374151; }
|
||||||
|
.markdown-body th { background: #1f2937; }
|
||||||
|
.markdown-body hr { border-top-color: #374151; }
|
||||||
|
.markdown-body code.inline-code { background: #4a1942; color: #fbcfe8; }
|
||||||
|
.markdown-body pre { background: #0f172a; }
|
||||||
|
.markdown-body pre code { color: #e2e8f0; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
export type MessageRole = 'user' | 'assistant' | 'system' | 'action';
|
export type MessageRole = 'user' | 'assistant' | 'system' | 'action';
|
||||||
|
|
||||||
/** 消息显示类型 (区分聊天消息与动作消息) */
|
/** 消息显示类型 (区分聊天消息与动作消息) */
|
||||||
export type MessageDisplayType = 'chat' | 'action' | 'system' | 'thinking' | 'tool_progress' | 'system_info';
|
export type MessageDisplayType = 'chat' | 'action' | 'system' | 'thinking' | 'tool_progress' | 'system_info' | 'markdown' | 'code';
|
||||||
|
|
||||||
/** 对话模式 */
|
/** 对话模式 */
|
||||||
export type ChatMode = 'text' | 'voice_msg' | 'voice_assistant';
|
export type ChatMode = 'text' | 'voice_msg' | 'voice_assistant';
|
||||||
@@ -34,8 +34,9 @@ export interface MessageAttachment {
|
|||||||
|
|
||||||
/** 审查消息 (后端审查子会话输出的带类型消息) */
|
/** 审查消息 (后端审查子会话输出的带类型消息) */
|
||||||
export interface ReviewMessage {
|
export interface ReviewMessage {
|
||||||
type: 'action' | 'chat';
|
type: 'action' | 'chat' | 'markdown' | 'code' | 'search_result';
|
||||||
content: string;
|
content: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 客户端信息 (多端区分) */
|
/** 客户端信息 (多端区分) */
|
||||||
@@ -59,6 +60,8 @@ export interface Message {
|
|||||||
msgType?: MessageDisplayType;
|
msgType?: MessageDisplayType;
|
||||||
/** 消息来源客户端信息 (多端区分) */
|
/** 消息来源客户端信息 (多端区分) */
|
||||||
client_info?: ClientInfo;
|
client_info?: ClientInfo;
|
||||||
|
/** 类型特定元数据 (如 code 语言、search_result URL 等) */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IoT 设备类型定义 */
|
/** IoT 设备类型定义 */
|
||||||
|
|||||||
Reference in New Issue
Block a user