fix(sse): correct UTF-8 handling and chunking in SSE streaming\n\n- Updated splitContentIntoSegments to handle runes instead of bytes\n- Fixed buildWebUIStreamForwarder to send delta chunks instead of cumulative content\n- Ensures proper handling of multi-byte characters in SSE streams

This commit is contained in:
whlaoding
2026-03-14 01:41:51 +08:00
parent 60195f00a0
commit ea88e1dc18
7 changed files with 89 additions and 18 deletions

View File

@@ -27,6 +27,7 @@ Now supports mutually exclusive message channels:
- If `telegram`: set `TELEGRAM_BOT_TOKEN`, keep `FEISHU_*` empty.
- If `feishu`: set `FEISHU_APP_ID` and `FEISHU_APP_SECRET`, keep `TELEGRAM_BOT_TOKEN` empty.
- If `webui`: set `WEBUI_LISTEN_ADDR` (default `:8090`).
- Optional for `webui`: set `WEBUI_EXPOSE_REASONING=true` to expose `thought/tool_call/tool_result` events to frontend.
3. Set log level with `LOG_LEVEL=debug|info|warn|error`.
- To inspect full skill/tool execution content and detailed ReAct step traces, use `LOG_LEVEL=debug`.
4. Configure knowledge and reasoning:
@@ -95,6 +96,7 @@ Minimum env config in `workspace/agent_runtime/configs/env`:
```env
MESSAGE_CHANNEL=webui
WEBUI_LISTEN_ADDR=:8090
WEBUI_EXPOSE_REASONING=false
LLM_API_KEY=your_api_key
```
@@ -115,6 +117,7 @@ docker run --rm -d \
--env-file ./workspace/agent_runtime/configs/env \
-e MESSAGE_CHANNEL=webui \
-e WEBUI_LISTEN_ADDR=:8090 \
-e WEBUI_EXPOSE_REASONING=false \
-e AGENT_WORKSPACE_DIR=/app/workspace/agent_runtime \
-v "$(pwd)/workspace/agent_runtime:/app/workspace/agent_runtime" \
laodingbot:latest

BIN
bot Executable file

Binary file not shown.

View File

@@ -220,7 +220,7 @@ func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orc
return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text)
},
func(ctx context.Context, msg webui.IncomingMessage, callback webui.StreamEventCallback) (string, error) {
return engine.HandleMessageStream(ctx, msg.ChatID, msg.UserID, msg.Text, buildWebUIStreamForwarder(callback))
return engine.HandleMessageStream(ctx, msg.ChatID, msg.UserID, msg.Text, buildWebUIStreamForwarder(callback, cfg.WebUI.ExposeReasoning))
},
func(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
return engine.UploadAndCacheFiles(ctx, chatID, userID, files)
@@ -231,16 +231,54 @@ func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orc
}
}
func buildWebUIStreamForwarder(callback webui.StreamEventCallback) agent.StreamEventCallback {
func buildWebUIStreamForwarder(callback webui.StreamEventCallback, exposeReasoning bool) agent.StreamEventCallback {
const finalChunkRunes = 12
const finalChunkInterval = 25 * time.Millisecond
return func(event agent.StreamEvent) error {
if callback == nil {
return nil
}
switch event.Type {
case agent.StreamEventTypeFinal, agent.StreamEventTypeError:
case agent.StreamEventTypeThought, agent.StreamEventTypeToolCall, agent.StreamEventTypeToolResult:
if !exposeReasoning {
return nil
}
return callback(webui.StreamEvent{
Type: webui.StreamEventType(event.Type),
Type: webui.StreamEventType(event.Type),
Content: event.Content,
Step: event.Step,
ToolName: event.ToolName,
})
case agent.StreamEventTypeFinal:
runes := []rune(event.Content)
if len(runes) == 0 {
return callback(webui.StreamEvent{Type: webui.StreamEventTypeFinal, Content: "", Step: event.Step})
}
start := 0
for start < len(runes) {
end := start + finalChunkRunes
if end > len(runes) {
end = len(runes)
}
if err := callback(webui.StreamEvent{
Type: webui.StreamEventTypeFinal,
Content: string(runes[start:end]),
Step: event.Step,
}); err != nil {
return err
}
start = end
if start < len(runes) {
time.Sleep(finalChunkInterval)
}
}
return nil
case agent.StreamEventTypeError:
return callback(webui.StreamEvent{
Type: webui.StreamEventTypeError,
Content: event.Content,
Step: event.Step,
})
default:
return nil

View File

@@ -21,6 +21,7 @@ FEISHU_LISTEN_ADDR=:8080
FEISHU_EVENT_PATH=/feishu/events
WEBUI_LISTEN_ADDR=:8090
WEBUI_MAX_UPLOAD_MB=20
WEBUI_EXPOSE_REASONING=false
LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=

View File

@@ -57,13 +57,17 @@ data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2}
事件类型:
- `thought`: LLM 思考片段(可选透传)
- `tool_call`: 工具调用请求(可选透传)
- `tool_result`: 工具执行结果(可选透传)
- `final`: 最终回答
- `error`: 错误信息
说明:
- 当前 WebUI 默认只向前端返回 `final``error`
- 中间推理轨迹(如 `thought``tool_call``tool_result` 以及对应的 `step`)会写入服务端 `debug` 日志,不再直接返回给用户界面
- 默认情况下(`WEBUI_EXPOSE_REASONING=false`WebUI 只向前端返回 `final``error`
- 当设置 `WEBUI_EXPOSE_REASONING=true`WebUI 会额外透传 `thought``tool_call``tool_result` 事件
- 无论是否透传推理事件,`final` 都会分段累计推送,以便前端实现打字机效果。
## 4. 连接生命周期
@@ -78,6 +82,11 @@ data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2}
- 答案区: 显示最后一个 `final`
- 错误区: 显示 `error`
如果开启了 `WEBUI_EXPOSE_REASONING=true`,建议额外提供“思考面板”:
- 思考区: 渲染 `thought`
- 工具区: 渲染 `tool_call` / `tool_result`
建议状态机:
- `idle`: 初始状态
@@ -88,7 +97,7 @@ data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2}
## 6. TypeScript 对接示例 (fetch + ReadableStream)
```ts
type StreamEventType = 'final' | 'error';
type StreamEventType = 'thought' | 'tool_call' | 'tool_result' | 'final' | 'error';
interface StreamEvent {
type: StreamEventType;
@@ -165,7 +174,7 @@ export async function streamChat(
- step?: number
- tool_name?: string
5) 收到 final 视为本轮完成;收到 error 视为失败
6) 不要假设前端会收到 thought/tool_call/tool_result这些内部轨迹已改为服务端 debug 日志
6) 默认不要假设前端一定会收到 thought/tool_call/tool_result仅当 `WEBUI_EXPOSE_REASONING=true` 才会透传
你的改造要求:
1) 保留现有 UI 风格和组件结构,不做无关重构

View File

@@ -731,12 +731,16 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
// 推送思考过程事件
if completion.Content != "" {
if err := callback(StreamEvent{
Type: StreamEventTypeThought,
Content: completion.Content,
Step: step,
}); err != nil {
return "", fmt.Errorf("callback error: %w", err)
// 分割内容为逐步推送的片段
segments := splitContentIntoSegments(completion.Content, 50) // 每段50字符
for _, segment := range segments {
if err := callback(StreamEvent{
Type: StreamEventTypeThought,
Content: segment,
Step: step,
}); err != nil {
return "", fmt.Errorf("callback error: %w", err)
}
}
}
@@ -1568,3 +1572,17 @@ func sanitizeUserFacingAnswer(raw string) string {
}
return strings.TrimSpace(strings.Join(cleaned, "\n"))
}
// splitContentIntoSegments splits a string into smaller segments of the specified size (by rune count).
func splitContentIntoSegments(content string, segmentSize int) []string {
runes := []rune(content)
var segments []string
for start := 0; start < len(runes); start += segmentSize {
end := start + segmentSize
if end > len(runes) {
end = len(runes)
}
segments = append(segments, string(runes[start:end]))
}
return segments
}

View File

@@ -48,8 +48,9 @@ type FeishuConfig struct {
}
type WebUIConfig struct {
ListenAddr string
MaxUploadBytes int64
ListenAddr string
MaxUploadBytes int64
ExposeReasoning bool
}
type LLMConfig struct {
@@ -110,8 +111,9 @@ func Load() (Config, error) {
EventPath: defaultIfEmpty(os.Getenv("FEISHU_EVENT_PATH"), "/feishu/events"),
},
WebUI: WebUIConfig{
ListenAddr: defaultIfEmpty(os.Getenv("WEBUI_LISTEN_ADDR"), ":8090"),
MaxUploadBytes: int64(intFromEnv("WEBUI_MAX_UPLOAD_MB", 20)) * 1024 * 1024,
ListenAddr: defaultIfEmpty(os.Getenv("WEBUI_LISTEN_ADDR"), ":8090"),
MaxUploadBytes: int64(intFromEnv("WEBUI_MAX_UPLOAD_MB", 20)) * 1024 * 1024,
ExposeReasoning: boolFromEnv("WEBUI_EXPOSE_REASONING", false),
},
LLM: LLMConfig{
BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"),