feat: 实时数据展示优化

This commit is contained in:
voocel
2026-03-17 09:50:32 +08:00
parent c913a49ffd
commit b23ac0fb6b
9 changed files with 527 additions and 40 deletions

216
app/stream_extract.go Normal file
View File

@@ -0,0 +1,216 @@
package app
import "strings"
// jsonFieldExtractor 从流式 JSON 碎片中提取指定字段的字符串值。
//
// LLM 流式生成 tool call 时参数是逐片段到达的OpenAI/Anthropic
// 或一次性到达的Gemini。本提取器用状态机逐字符扫描
// 检测到目标 key 后提取其字符串值,处理 JSON 转义。
type jsonFieldExtractor struct {
key string // 匹配目标,如 `"content"` 或 `"task"`
state extractState
matchPos int
escape bool
buf strings.Builder
}
type extractState int
const (
stateScan extractState = iota // 扫描,寻找目标 key
stateColon // 已匹配 key等冒号和开头引号
stateExtract // 提取字符串值中
)
func newFieldExtractor(fieldName string) *jsonFieldExtractor {
return &jsonFieldExtractor{key: `"` + fieldName + `"`}
}
// Feed 处理一段 delta返回提取到的文本可能为空
func (e *jsonFieldExtractor) Feed(delta string) string {
e.buf.Reset()
for _, r := range delta {
switch e.state {
case stateScan:
e.feedScan(r)
case stateColon:
e.feedColon(r)
case stateExtract:
e.feedExtract(r)
}
}
return e.buf.String()
}
func (e *jsonFieldExtractor) feedScan(r rune) {
if e.matchPos < len(e.key) && byte(r) == e.key[e.matchPos] {
e.matchPos++
if e.matchPos == len(e.key) {
e.state = stateColon
e.matchPos = 0
}
return
}
e.matchPos = 0
if byte(r) == e.key[0] {
e.matchPos = 1
}
}
func (e *jsonFieldExtractor) feedColon(r rune) {
switch r {
case ':', ' ', '\t':
// 跳过
case '"':
e.state = stateExtract
e.escape = false
default:
e.state = stateScan
e.matchPos = 0
if byte(r) == e.key[0] {
e.matchPos = 1
}
}
}
func (e *jsonFieldExtractor) feedExtract(r rune) {
if e.escape {
e.escape = false
switch r {
case 'n':
e.buf.WriteByte('\n')
case 't':
e.buf.WriteByte('\t')
case 'r':
e.buf.WriteByte('\r')
case '"', '\\', '/':
e.buf.WriteRune(r)
default:
e.buf.WriteByte('\\')
e.buf.WriteRune(r)
}
return
}
switch r {
case '\\':
e.escape = true
case '"':
e.state = stateScan
e.matchPos = 0
default:
e.buf.WriteRune(r)
}
}
// Reset 重置状态(新 LLM 消息轮次时调用)。
func (e *jsonFieldExtractor) Reset() {
e.state = stateScan
e.matchPos = 0
e.escape = false
}
// ThinkingSep 是思考文本与正文之间的分隔标记。
// streamFilter 在思考文本段前插入此标记TUI 据此切换渲染样式。
const ThinkingSep = "\x02"
// streamFilter 区分 SubAgent 的文本回复和 JSON 工具调用。
// 文本回复标记为思考内容(前缀 ThinkingSepJSON 工具调用只提取指定字段。
//
// 判断依据:遇到 { 进入 JSON 模式(追踪大括号深度),
// 深度归零后回到文本模式。
type streamFilter struct {
fieldExt *jsonFieldExtractor
mode filterMode
braceDepth int
inString bool // 在 JSON 字符串内(大括号不计数)
escJSON bool // JSON 字符串内的转义
thinking bool // 当前处于思考文本段
buf strings.Builder
}
type filterMode int
const (
filterText filterMode = iota // 文本回复,直接透传
filterJSON // JSON 工具调用,提取目标字段
)
func newStreamFilter(fieldName string) *streamFilter {
return &streamFilter{fieldExt: newFieldExtractor(fieldName)}
}
// Feed 处理一段 delta返回可展示文本。
// 文本回复直接输出JSON 中的目标字段值被提取输出;其余 JSON 结构丢弃。
func (f *streamFilter) Feed(delta string) string {
f.buf.Reset()
for _, r := range delta {
switch f.mode {
case filterText:
if r == '{' {
f.thinking = false
f.mode = filterJSON
f.braceDepth = 1
f.inString = false
f.escJSON = false
f.fieldExt.Reset()
f.feedExtractor(r)
} else {
if !f.thinking {
f.thinking = true
f.buf.WriteString(ThinkingSep)
}
f.buf.WriteRune(r)
}
case filterJSON:
f.feedExtractor(r)
f.trackBraces(r)
}
}
return f.buf.String()
}
// feedExtractor 将单个字符喂给 fieldExt提取结果写入 buf。
func (f *streamFilter) feedExtractor(r rune) {
if text := f.fieldExt.Feed(string(r)); text != "" {
f.buf.WriteString(text)
}
}
// trackBraces 追踪 JSON 大括号深度,深度归零时切回文本模式。
func (f *streamFilter) trackBraces(r rune) {
if f.escJSON {
f.escJSON = false
return
}
if f.inString {
switch r {
case '\\':
f.escJSON = true
case '"':
f.inString = false
}
return
}
switch r {
case '"':
f.inString = true
case '{':
f.braceDepth++
case '}':
f.braceDepth--
if f.braceDepth <= 0 {
f.mode = filterText
}
}
}
// Reset 重置状态。
func (f *streamFilter) Reset() {
f.mode = filterText
f.braceDepth = 0
f.inString = false
f.escJSON = false
f.thinking = false
f.fieldExt.Reset()
}