diff --git a/docs/IM集成开发文档.md b/docs/IM集成开发文档.md index 1632e8ca..117ae1c1 100644 --- a/docs/IM集成开发文档.md +++ b/docs/IM集成开发文档.md @@ -773,8 +773,9 @@ type ReplyMessage struct { │ · EventBus 订阅 │ │ · 300ms 批量刷新 │ │ · 工具事件展示 │ -│ · SendStreamChunk │ -│ · EndStream │ +│ · UpdateStreamContent │ +│ · FinalizeStream │ +│ · EndStream │ └────────────────────┘ │ ▼ @@ -833,11 +834,15 @@ type Adapter interface { ```go type StreamSender interface { StartStream(ctx context.Context, incoming *IncomingMessage) (streamID string, err error) - SendStreamChunk(ctx context.Context, incoming *IncomingMessage, streamID string, content string) error + UpdateStreamContent(ctx context.Context, incoming *IncomingMessage, streamID string, fullContent string) error + FinalizeStream(ctx context.Context, incoming *IncomingMessage, streamID string, finalContent string) error EndStream(ctx context.Context, incoming *IncomingMessage, streamID string) error } ``` +- `UpdateStreamContent`:流式过程中用**当前可见全文**替换消息(replace 语义,非增量追加) +- `FinalizeStream`:结束前最后一次替换,通常为折叠思考/工具进度后的最终展示(多为纯答案) + 实现此接口后,Service 会自动路由到流式模式。渠道配置 `output_mode: "full"` 可强制关闭。 ### im.FileDownloader — 文件下载 (可选) @@ -949,14 +954,17 @@ StartStream: 1. POST /cardkit/v1/cards → 创建卡片实体 (streaming_mode: true) 2. POST /im/v1/messages → 发送卡片消息到聊天 -SendStreamChunk: +UpdateStreamContent: 3. PUT /cardkit/v1/cards/{id}/elements/{eid}/content → 更新元素内容 (累积全文) +FinalizeStream: + 4. PUT /cardkit/v1/cards/{id}/elements/{eid}/content → 最终可见内容(通常为纯答案) + EndStream: - 4. PATCH /cardkit/v1/cards/{id}/settings → 设置 streaming_mode: false + 5. PATCH /cardkit/v1/cards/{id}/settings → 设置 streaming_mode: false ``` -每次 `SendStreamChunk` 发送的是**累积全文**而非增量,由 `feishuStreamState` 跟踪完整内容和严格递增的 `sequence` 序号。 +每次 `UpdateStreamContent` / `FinalizeStream` 发送的是**累积全文**而非增量,由 `feishuStreamState` 跟踪完整内容和严格递增的 `sequence` 序号。 **Think 块处理:** 流式输出中的 `...` 块会被转换为飞书 Markdown 引用块格式: @@ -1017,14 +1025,17 @@ Slack 的流式输出基于消息更新 (chat.update) 实现: StartStream: 1. POST /chat.postMessage → 发送初始消息,获取 ts (timestamp) -SendStreamChunk: +UpdateStreamContent: 2. POST /chat.update → 根据 ts 更新消息内容 (累积全文) +FinalizeStream: + 3. POST /chat.update → 最终可见内容替换 + EndStream: - 3. 无需特殊操作 + 4. 无需特殊操作 ``` -每次 `SendStreamChunk` 发送的是**累积全文**而非增量。 +每次 `UpdateStreamContent` / `FinalizeStream` 发送的是**累积全文**而非增量。 #### 源码文件 @@ -1079,14 +1090,17 @@ Telegram 的流式输出基于消息编辑 (editMessageText) 实现: StartStream: 1. POST sendMessage → 发送初始 "正在思考..." 消息,获取 message_id -SendStreamChunk: - 2. POST editMessageText → 根据 message_id 更新消息内容(累积全文) +UpdateStreamContent: + 2. POST editMessageText → 根据 message_id 更新消息内容(累积全文,纯文本) + +FinalizeStream: + 3. POST editMessageText → 最终可见内容替换(与流式阶段相同,纯文本) EndStream: - 3. POST editMessageText → 最终更新,启用 Markdown 解析 + 4. 清理流状态 ``` -每次 `SendStreamChunk` 发送的是**累积全文**而非增量,最小编辑间隔 500ms(避免触发 Telegram 速率限制)。 +每次 `UpdateStreamContent` / `FinalizeStream` 发送的是**累积全文**而非增量,最小编辑间隔 500ms(避免触发 Telegram 速率限制)。中间态与终态均使用纯文本,避免 Markdown 解析失败。 **Think 块处理:** 流式输出中的 `...` 块会被转换为 Telegram 引用块格式: @@ -1156,14 +1170,17 @@ LongConnClient ══Stream══▶ 钉钉 Stream 服务 StartStream: 1. POST /v1.0/card/instances/createAndDeliver → 创建并投递 AI 卡片 -SendStreamChunk: +UpdateStreamContent: 2. PUT /v1.0/card/streaming → 流式更新卡片内容(累积全文) +FinalizeStream: + 3. PUT /v1.0/card/streaming → 最终可见内容替换 + EndStream: - 3. PUT /v1.0/card/streaming (isFinalize=true) → 标记流式结束 + 4. PUT /v1.0/card/streaming (isFinalize=true) → 标记流式结束 ``` -每次 `SendStreamChunk` 发送的是**累积全文**(`isFull: true`),最小更新间隔 500ms。 +每次 `UpdateStreamContent` / `FinalizeStream` 发送的是**累积全文**(`isFull: true`),最小更新间隔 500ms。 **无卡片模板时的降级策略:** 未配置 `card_template_id` 时,流式内容在内存中累积,`EndStream` 时一次性通过 `sessionWebhook` 或 OpenAPI 发送完整回复。 @@ -1216,11 +1233,14 @@ Mattermost 服务器 ──HTTP POST──▶ /api/v1/im/callback/{channel_id} StartStream: 1. POST /api/v4/posts → 创建占位帖(如「正在思考...」),得到 post id -SendStreamChunk: +UpdateStreamContent: 2. PUT /api/v4/posts/{post_id}/patch → Patch message 字段(累积全文) +FinalizeStream: + 3. PUT /api/v4/posts/{post_id}/patch → 最终可见内容替换 + EndStream: - 3. 最后一次 Patch,与 SendStreamChunk 相同逻辑 + 4. 清理流状态 ``` 流式刷新间隔由 Service 侧 `streamFlushInterval`(300ms)批量合并,以降低编辑频率、减轻 API 压力。 diff --git a/internal/im/service.go b/internal/im/service.go index 59989cd0..a1d81fce 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -196,6 +196,7 @@ func holdbackCutoff(chunk string) int { func formatIMOutboundAnswer(ctx context.Context, raw string, tenant *types.Tenant, defaultFileSvc interfaces.FileService) string { return cleanIMContent(ctx, FormatIMDisplayContent(raw, StreamDisplayFinal), tenant, defaultFileSvc) } + // cleanIMContent applies all IM-specific content transformations: // 1. Collapse XML blocks back to plain markdown // 2. Strip and citation tags @@ -1825,20 +1826,19 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, eventBus := event.NewEventBus() var ( - bufMu sync.Mutex - pipelineInner strings.Builder // quick QA: RAG pipeline steps - reasoningInner strings.Builder // quick QA: model reasoning_content - agentInner strings.Builder // agent: retracted text + thoughts + tools + bufMu sync.Mutex + reasoningInner streamSection // quick QA: model reasoning_content + agentInner streamSection // agent: retracted text + thoughts agentLiveAnswer strings.Builder // agent: optimistic answer before tool retract - answerOuter strings.Builder // final answer for display persistence - answerBuilder strings.Builder // answer persisted to DB - qaErr error - done = make(chan struct{}) - completeDone = make(chan struct{}) - closeOnce sync.Once - completeOnce sync.Once - agentDone bool - assistantMsg *types.Message + answerOuter strings.Builder // final answer for display persistence + answerBuilder strings.Builder // answer persisted to DB + qaErr error + done = make(chan struct{}) + completeDone = make(chan struct{}) + closeOnce sync.Once + completeOnce sync.Once + agentDone bool + assistantMsg *types.Message seenToolCalls = make(map[string]bool) agentToolIdx = make(map[string]int) @@ -1847,40 +1847,35 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, agentToolSteps []IMToolStep pipelineToolSteps []IMToolStep - sectionLastNewline = true - streamedAny bool + streamedAny bool ) closeDone := func() { closeOnce.Do(func() { close(done) }) } closeComplete := func() { completeOnce.Do(func() { close(completeDone) }) } - sectionWrite := func(b *strings.Builder, s string) { + agentWrite := func(s string) { if s == "" { return } - b.WriteString(s) - sectionLastNewline = s[len(s)-1] == '\n' + agentInner.write(s) streamedAny = true } - - sectionEnsureNewlineBefore := func(b *strings.Builder) { - if !sectionLastNewline { - b.WriteByte('\n') - sectionLastNewline = true + reasoningWrite := func(s string) { + if s == "" { + return } + reasoningInner.write(s) + streamedAny = true } - agentWrite := func(s string) { sectionWrite(&agentInner, s) } - reasoningWrite := func(s string) { sectionWrite(&reasoningInner, s) } - // retractAgentLiveAnswer moves the optimistic answer into the think block (Web: superseded preamble). retractAgentLiveAnswer := func() { if agentLiveAnswer.Len() == 0 { return } - if agentInner.Len() > 0 { - sectionEnsureNewlineBefore(&agentInner) + if agentInner.text.Len() > 0 { + agentInner.ensureNewlineBefore() } - sectionWrite(&agentInner, agentLiveAnswer.String()) + agentInner.write(agentLiveAnswer.String()) agentLiveAnswer.Reset() } @@ -1891,10 +1886,9 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, } return IMStreamParts{ Mode: mode, - PipelineInner: pipelineInner.String(), PipelineToolSteps: pipelineToolSteps, - ReasoningInner: reasoningInner.String(), - AgentInner: agentInner.String(), + ReasoningInner: reasoningInner.text.String(), + AgentInner: agentInner.text.String(), AgentToolSteps: agentToolSteps, LiveAnswer: agentLiveAnswer.String(), Answer: answerOuter.String(), @@ -1910,7 +1904,10 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, bufMu.Lock() if useAgent && !agentDone { - sectionWrite(&agentLiveAnswer, data.Content) + if data.Content != "" { + agentLiveAnswer.WriteString(data.Content) + streamedAny = true + } } else { answerOuter.WriteString(data.Content) answerBuilder.WriteString(data.Content) @@ -1960,13 +1957,7 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, bufMu.Lock() agentDone = true applyIMCompleteDataToMessage(assistantMsg, data) - if data.FinalAnswer != "" { - answerBuilder.Reset() - answerBuilder.WriteString(data.FinalAnswer) - answerOuter.Reset() - answerOuter.WriteString(data.FinalAnswer) - agentLiveAnswer.Reset() - } else if answerBuilder.Len() == 0 && agentLiveAnswer.Len() > 0 { + if answerBuilder.Len() == 0 && agentLiveAnswer.Len() > 0 { answerBuilder.WriteString(agentLiveAnswer.String()) answerOuter.WriteString(agentLiveAnswer.String()) } else if answerBuilder.Len() == 0 && answerOuter.Len() > 0 { @@ -2120,7 +2111,7 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, ticker := time.NewTicker(streamFlushInterval) defer ticker.Stop() - flush := func(final bool, phase StreamDisplayPhase) { + flush := func() { bufMu.Lock() parts := getStreamParts() agentRunning := useAgent && !agentDone @@ -2131,10 +2122,8 @@ func (s *Service) handleMessageStream(ctx context.Context, msg *IncomingMessage, return } - if !final { - if cut := holdbackCutoff(displaySource); cut < len(displaySource) { - displaySource = displaySource[:cut] - } + if cut := holdbackCutoff(displaySource); cut < len(displaySource) { + displaySource = displaySource[:cut] } display := cleanIMContent(ctx, displaySource, tenant, s.defaultFileSvc) @@ -2147,7 +2136,7 @@ loop: for { select { case <-ticker.C: - flush(false, StreamDisplayIntermediate) + flush() case <-done: break loop case <-qaCtx.Done(): @@ -2158,8 +2147,8 @@ loop: if useAgent { select { case <-completeDone: - case <-time.After(500 * time.Millisecond): - logger.Warnf(ctx, "[IM] Timed out waiting for agent complete event: session=%s", session.ID) + case <-qaCtx.Done(): + logger.Warnf(ctx, "[IM] QA context ended before agent complete event: session=%s", session.ID) } } @@ -2304,9 +2293,6 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin } answerMu.Lock() applyIMCompleteDataToMessage(assistantMsg, data) - if answerBuilder.Len() == 0 && data.FinalAnswer != "" { - answerBuilder.WriteString(data.FinalAnswer) - } answerMu.Unlock() closeComplete() return nil @@ -2351,8 +2337,8 @@ func (s *Service) runQA(ctx context.Context, session *types.Session, query strin if useAgent { select { case <-completeDone: - case <-time.After(500 * time.Millisecond): - logger.Warnf(ctx, "[IM] Timed out waiting for agent complete event: session=%s", session.ID) + case <-ctx.Done(): + logger.Warnf(ctx, "[IM] QA context ended before agent complete event: session=%s", session.ID) } } case <-ctx.Done(): @@ -2772,7 +2758,6 @@ func (s *Service) streamSmartReply(ctx context.Context, chatModel chat.Chat, str // Flush loop with batching (same pattern as handleMessageStream) var ( bufMu sync.Mutex - buf strings.Builder streamRaw strings.Builder done = make(chan struct{}) ) @@ -2782,7 +2767,6 @@ func (s *Service) streamSmartReply(ctx context.Context, chatModel chat.Chat, str for resp := range streamCh { if resp.Content != "" { bufMu.Lock() - buf.WriteString(resp.Content) streamRaw.WriteString(resp.Content) bufMu.Unlock() } @@ -2794,7 +2778,6 @@ func (s *Service) streamSmartReply(ctx context.Context, chatModel chat.Chat, str pushStream := func(phase StreamDisplayPhase) { bufMu.Lock() - buf.Reset() raw := streamRaw.String() bufMu.Unlock() if raw == "" { diff --git a/internal/im/stream_display_test.go b/internal/im/stream_display_test.go index c6cd6e72..b1ec92ec 100644 --- a/internal/im/stream_display_test.go +++ b/internal/im/stream_display_test.go @@ -109,19 +109,28 @@ func TestStreamDisplayPipeline_agentScenario_redGreen(t *testing.T) { func TestStreamDisplayPipeline_quickQA_redGreen(t *testing.T) { during := IMStreamParts{ - Mode: IMStreamModeQuickQA, - PipelineInner: "⏳ 问题理解\n✅ 问题理解\n⏳ 知识库检索\n", + Mode: IMStreamModeQuickQA, + PipelineToolSteps: []IMToolStep{ + {ToolName: "query_understand", Success: true}, + {ToolName: "knowledge_search", Pending: true, Arguments: map[string]any{"query": "test"}}, + }, } done := IMStreamParts{ - Mode: IMStreamModeQuickQA, - PipelineInner: "⏳ 问题理解\n✅ 问题理解\n⏳ 知识库检索\n", - Answer: "答案是 B。", + Mode: IMStreamModeQuickQA, + PipelineToolSteps: []IMToolStep{ + {ToolName: "query_understand", Success: true}, + {ToolName: "knowledge_search", Success: true, Arguments: map[string]any{"query": "test"}}, + }, + Answer: "答案是 B。", } intermediate := FormatIMIntermediateFromParts(during, false) if intermediate == "" { t.Fatal("quick QA should show pipeline progress while streaming") } + if !strings.Contains(intermediate, "问题理解") { + t.Fatalf("quick QA pipeline should show query_understand step, got: %q", intermediate) + } if strings.Contains(intermediate, "思考过程") { t.Fatalf("quick QA pipeline should not use agent think header, got: %q", intermediate) } diff --git a/internal/im/stream_lifecycle_test.go b/internal/im/stream_lifecycle_test.go new file mode 100644 index 00000000..d92b4465 --- /dev/null +++ b/internal/im/stream_lifecycle_test.go @@ -0,0 +1,166 @@ +package im + +import ( + "context" + "strings" + "testing" +) + +// imStreamDisplayState mirrors the display buffers in handleMessageStream for lifecycle tests. +type imStreamDisplayState struct { + useAgent bool + agentDone bool + agentInner streamSection + agentLiveAnswer strings.Builder + answerOuter strings.Builder + agentToolSteps []IMToolStep + agentToolIdx map[string]int + pipelineToolSteps []IMToolStep + pipelineIdx map[string]int +} + +func newIMStreamDisplayState(useAgent bool) *imStreamDisplayState { + return &imStreamDisplayState{ + useAgent: useAgent, + agentToolIdx: make(map[string]int), + pipelineIdx: make(map[string]int), + } +} + +func (s *imStreamDisplayState) retractAgentLiveAnswer() { + if s.agentLiveAnswer.Len() == 0 { + return + } + if s.agentInner.text.Len() > 0 { + s.agentInner.ensureNewlineBefore() + } + s.agentInner.write(s.agentLiveAnswer.String()) + s.agentLiveAnswer.Reset() +} + +func (s *imStreamDisplayState) parts() IMStreamParts { + mode := IMStreamModeQuickQA + if s.useAgent { + mode = IMStreamModeAgent + } + return IMStreamParts{ + Mode: mode, + PipelineToolSteps: s.pipelineToolSteps, + AgentInner: s.agentInner.text.String(), + AgentToolSteps: s.agentToolSteps, + LiveAnswer: s.agentLiveAnswer.String(), + Answer: s.answerOuter.String(), + } +} + +func (s *imStreamDisplayState) intermediate() string { + return FormatIMIntermediateFromParts(s.parts(), s.useAgent && !s.agentDone) +} + +func (s *imStreamDisplayState) final() string { + return FormatIMFinalFromParts(s.parts()) +} + +// TestIMStreamLifecycle_agentToolRetract simulates handleMessageStream display assembly: +// live answer → tool retract → tool result → final answer → complete. +func TestIMStreamLifecycle_agentToolRetract(t *testing.T) { + state := newIMStreamDisplayState(true) + + state.agentLiveAnswer.WriteString("好的,让我先搜索知识库。") + liveOnly := state.intermediate() + if liveOnly != "好的,让我先搜索知识库。" { + t.Fatalf("live answer phase = %q", liveOnly) + } + if strings.Contains(liveOnly, "思考过程") { + t.Fatal("think header must not appear before tool retract") + } + + state.retractAgentLiveAnswer() + upsertIMToolStep(&state.agentToolSteps, state.agentToolIdx, "tool-1", func(step *IMToolStep) { + step.ToolName = "grep_chunks" + step.Pending = true + }) + duringTools := state.intermediate() + if !strings.Contains(duringTools, "思考过程") { + t.Fatalf("after retract should show think block, got: %q", duringTools) + } + if !strings.Contains(duringTools, "好的,让我先搜索知识库") { + t.Fatalf("retracted preamble missing, got: %q", duringTools) + } + + upsertIMToolStep(&state.agentToolSteps, state.agentToolIdx, "tool-1", func(step *IMToolStep) { + step.ToolName = "grep_chunks" + step.Pending = false + step.Success = true + }) + state.agentLiveAnswer.WriteString("根据检索结果,《文明6》是回合制策略游戏。") + withLiveAnswer := state.intermediate() + if !strings.Contains(withLiveAnswer, "根据检索结果") { + t.Fatalf("live answer after tools missing, got: %q", withLiveAnswer) + } + + state.agentDone = true + state.answerOuter.WriteString("《文明6》是回合制策略游戏。") + final := state.final() + if !strings.Contains(final, "《文明6》是回合制策略游戏。") { + t.Fatalf("final answer missing, got: %q", final) + } + + rec := &recordingStreamSender{} + ctx := context.Background() + incoming := &IncomingMessage{Platform: PlatformWeCom, UserID: "u1"} + streamID, err := rec.StartStream(ctx, incoming) + if err != nil { + t.Fatalf("StartStream: %v", err) + } + if err := rec.UpdateStreamContent(ctx, incoming, streamID, duringTools); err != nil { + t.Fatalf("UpdateStreamContent: %v", err) + } + if err := rec.FinalizeStream(ctx, incoming, streamID, final); err != nil { + t.Fatalf("FinalizeStream: %v", err) + } + if err := rec.EndStream(ctx, incoming, streamID); err != nil { + t.Fatalf("EndStream: %v", err) + } + + chunks, finalized, ended := rec.snapshot() + if !ended { + t.Fatal("stream should end") + } + if len(chunks) != 1 { + t.Fatalf("expected 1 intermediate update, got %d", len(chunks)) + } + if chunks[0] == finalized { + t.Fatal("intermediate and final should differ") + } +} + +// TestIMStreamLifecycle_quickQAPipeline verifies quick-QA pipeline steps collapse to answer-only final. +func TestIMStreamLifecycle_quickQAPipeline(t *testing.T) { + state := newIMStreamDisplayState(false) + + upsertIMToolStep(&state.pipelineToolSteps, state.pipelineIdx, "qu-1", func(step *IMToolStep) { + step.ToolName = "query_understand" + step.Pending = false + step.Success = true + }) + upsertIMToolStep(&state.pipelineToolSteps, state.pipelineIdx, "ks-1", func(step *IMToolStep) { + step.ToolName = "knowledge_search" + step.Pending = true + step.Arguments = map[string]any{"query": "文明6"} + }) + + intermediate := state.intermediate() + if intermediate == "" { + t.Fatal("pipeline progress should be visible") + } + if strings.Contains(intermediate, "思考过程") { + t.Fatalf("quick QA must not use agent think header, got: %q", intermediate) + } + + state.answerOuter.WriteString("答案是 B。") + final := state.final() + if final != "答案是 B。" { + t.Fatalf("final = %q, want answer only", final) + } +} diff --git a/internal/im/stream_section.go b/internal/im/stream_section.go new file mode 100644 index 00000000..5ad0166e --- /dev/null +++ b/internal/im/stream_section.go @@ -0,0 +1,24 @@ +package im + +import "strings" + +// streamSection tracks newline state for one IM stream text buffer. +type streamSection struct { + lastCharNewline bool + text strings.Builder +} + +func (s *streamSection) write(str string) { + if str == "" { + return + } + s.text.WriteString(str) + s.lastCharNewline = str[len(str)-1] == '\n' +} + +func (s *streamSection) ensureNewlineBefore() { + if !s.lastCharNewline { + s.text.WriteByte('\n') + s.lastCharNewline = true + } +} diff --git a/internal/im/telegram/adapter.go b/internal/im/telegram/adapter.go index 29ddb85f..4e32618e 100644 --- a/internal/im/telegram/adapter.go +++ b/internal/im/telegram/adapter.go @@ -438,7 +438,7 @@ func (a *Adapter) FinalizeStream(ctx context.Context, incoming *im.IncomingMessa msgID := state.msgID state.mu.Unlock() - if err := a.editMessage(ctx, chatID, msgID, finalContent, "Markdown"); err != nil { + if err := a.editMessage(ctx, chatID, msgID, finalContent, ""); err != nil { logger.Warnf(ctx, "[Telegram] Failed to finalize stream: %v", err) } return nil diff --git a/internal/im/think.go b/internal/im/think.go index 6989f100..30f9cd5e 100644 --- a/internal/im/think.go +++ b/internal/im/think.go @@ -55,7 +55,6 @@ type IMStreamParts struct { Mode IMStreamMode // Quick-QA (RagPipelineProgress on Web): - PipelineInner string // non-tool pipeline narrative (if any) PipelineToolSteps []IMToolStep // query_understand / knowledge_search rows ReasoningInner string // model reasoning_content — separate "思考" section @@ -73,8 +72,7 @@ func agentThinkContent(parts IMStreamParts) string { } func quickQAPipelineContent(parts IMStreamParts) string { - toolLines := renderIMToolSteps(parts.PipelineToolSteps, FormatIMRagPipelineLine) - return mergeIMNarrativeAndTools(parts.PipelineInner, toolLines) + return renderIMToolSteps(parts.PipelineToolSteps, FormatIMRagPipelineLine) } // RAGThinkingStyle matches Web RagPipelineProgress thinking row (agent.think). diff --git a/internal/im/wecom/longconn.go b/internal/im/wecom/longconn.go index 7afa539e..addd6b41 100644 --- a/internal/im/wecom/longconn.go +++ b/internal/im/wecom/longconn.go @@ -380,7 +380,7 @@ func (c *LongConnClient) connectAndRun(ctx context.Context) error { _ = conn.Close() // NOTE: streamBufs is intentionally NOT cleared on reconnect. // Active streams survive reconnections — the WeCom replace-based - // protocol means the next SendStreamChunk will resend the full + // protocol means the next UpdateStreamContent will resend the full // accumulated content on the new connection. EndStream always // cleans up the buffer, so there is no memory leak. }()