From dd229ec0ad741a3cf085b9cd7d0560ff42a3ebdd Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 18 Sep 2026 11:55:45 +0800 Subject: [PATCH] feat(dingtalk): per-frame wide-table grouping (stream keeps tables flowing while streaming) --- cmd/dobotai/card_tables.go | 28 ++++---- cmd/dobotai/main.go | 4 +- cmd/dobotai/main_test.go | 20 +++--- internal/channels/dingtalk/handler.go | 33 ++++++---- internal/channels/dingtalk/table_normalize.go | 18 ++++++ .../channels/dingtalk/table_normalize_test.go | 64 ++++++++++++++----- 6 files changed, 117 insertions(+), 50 deletions(-) diff --git a/cmd/dobotai/card_tables.go b/cmd/dobotai/card_tables.go index c875b5c..5a45e78 100644 --- a/cmd/dobotai/card_tables.go +++ b/cmd/dobotai/card_tables.go @@ -6,18 +6,22 @@ import ( "strings" ) -// cardWideTableGroupingDisabled reports whether the DingTalk final-frame -// normalizer must stop rewriting over-wide answer tables into grouped lists. -// Disabling it keeps real tables in the card, which is what the operator wants -// now that the PC client renders wide-screen cards; clients that still cannot -// lay out wide tables need the grouping, so the default stays enabled. -// DINGTALK_CARD_WIDE_TABLE_GROUP=off disables it. -func cardWideTableGroupingDisabled() bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_WIDE_TABLE_GROUP"))) { - case "off", "0", "false", "no": - log.Printf("DingTalk wide-table grouping disabled by configuration") - return true +// cardWideTableGroupingMode returns the configured handling of over-wide answer +// tables (see dingtalk.tableGroupingForMode): +// +// unset / on → grouped list on every frame (historical behaviour) +// off → real tables on every frame +// stream → grouped list while streaming, real table on the final frame +// +// The default stays "on" so an environment without the variable renders exactly +// as before; DINGTALK_CARD_WIDE_TABLE_GROUP selects the other modes. +func cardWideTableGroupingMode() string { + mode := strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_WIDE_TABLE_GROUP"))) + switch mode { + case "off", "0", "false", "no", "stream", "streaming", "partial": + log.Printf("DingTalk wide-table grouping mode=%s", mode) + return mode default: - return false + return "" } } diff --git a/cmd/dobotai/main.go b/cmd/dobotai/main.go index 109e397..8d24183 100644 --- a/cmd/dobotai/main.go +++ b/cmd/dobotai/main.go @@ -323,8 +323,8 @@ func main() { ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService}, LanguageResolver: settingsRuntime.repository, - OutboundRewriteTimeout: outboundImages.rewriteTimeout, - WideTableGroupingDisabled: cardWideTableGroupingDisabled(), + OutboundRewriteTimeout: outboundImages.rewriteTimeout, + WideTableGrouping: cardWideTableGroupingMode(), }, ) if err != nil { diff --git a/cmd/dobotai/main_test.go b/cmd/dobotai/main_test.go index 02ee5d2..e48b876 100644 --- a/cmd/dobotai/main_test.go +++ b/cmd/dobotai/main_test.go @@ -215,18 +215,22 @@ func TestOutboundImageBudgetAndCompressionDefaults(t *testing.T) { } } -func TestCardWideTableGroupingDisabledToggle(t *testing.T) { +func TestCardWideTableGroupingMode(t *testing.T) { t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "") - if cardWideTableGroupingDisabled() { - t.Fatal("wide-table grouping must default to enabled") + if got := cardWideTableGroupingMode(); got != "" { + t.Fatalf("unset must keep the historical mode, got %q", got) } t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "off") - if !cardWideTableGroupingDisabled() { - t.Fatal("DINGTALK_CARD_WIDE_TABLE_GROUP=off must disable wide-table grouping") + if got := cardWideTableGroupingMode(); got != "off" { + t.Fatalf("off must be passed through, got %q", got) } - t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "ON") - if cardWideTableGroupingDisabled() { - t.Fatal("any value other than off must keep grouping enabled") + t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", " STREAM ") + if got := cardWideTableGroupingMode(); got != "stream" { + t.Fatalf("stream must be normalised, got %q", got) + } + t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "on") + if got := cardWideTableGroupingMode(); got != "" { + t.Fatalf("on is the default and must not be forwarded as a mode, got %q", got) } } diff --git a/internal/channels/dingtalk/handler.go b/internal/channels/dingtalk/handler.go index 75a0ccf..a16ff2d 100644 --- a/internal/channels/dingtalk/handler.go +++ b/internal/channels/dingtalk/handler.go @@ -168,10 +168,11 @@ type HandlerOptions struct { // delivery timeout, otherwise the remaining images are dropped. Zero keeps // finalDeliveryTimeout. OutboundRewriteTimeout time.Duration - // WideTableGroupingDisabled stops the final-frame normalizer from rewriting - // over-wide tables into grouped lists. Leave false on clients whose cards - // cannot lay out wide tables. - WideTableGroupingDisabled bool + // WideTableGrouping selects how over-wide answer tables are adapted, per + // frame: "" or "on" groups on every frame (historical), "off" never groups, + // "stream" groups only while the answer is still streaming and keeps the + // real table on the final frame. See tableGroupingForMode. + WideTableGrouping string } type Handler struct { @@ -193,10 +194,12 @@ type Handler struct { // outboundRewriteTimeout applies to the image rewrite stage only; zero // falls back to finalDeliveryTimeout. outboundRewriteTimeout time.Duration - // wideTableGroupingDisabled keeps the final frame's tables as tables - // instead of rewriting over-wide ones into grouped lists. Used while the - // client renders wide tables itself (PC wide-screen cards). - wideTableGroupingDisabled bool + // groupTablesPartial / groupTablesFinal decide per frame whether an + // over-wide table becomes a grouped list. Grouped lists survive the + // streaming frames where a table is still incomplete; the final frame can + // keep the real table, which the PC wide-screen card lays out itself. + groupTablesPartial bool + groupTablesFinal bool } // NewHandler accepts at most one image downloader. A nil/omitted downloader @@ -228,6 +231,8 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o if reply == nil { return nil, errors.New("dingtalk handler: markdown sender is required") } + groupPartial, groupFinal := tableGroupingForMode(opts.WideTableGrouping) + return &Handler{ cfg: cfg, qa: qa, @@ -245,8 +250,9 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o cardUpdateInterval: 500 * time.Millisecond, finalDeliveryTimeout: defaultHTTPTimeout, - outboundRewriteTimeout: opts.OutboundRewriteTimeout, - wideTableGroupingDisabled: opts.WideTableGroupingDisabled, + outboundRewriteTimeout: opts.OutboundRewriteTimeout, + groupTablesPartial: groupPartial, + groupTablesFinal: groupFinal, }, nil } @@ -669,8 +675,7 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string, } func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string { - options := defaultTableNormalizeOptions() - options.groupWideTables = !h.wideTableGroupingDisabled + options := tableNormalizeOptions{groupWideTables: h.groupTablesFinal} if h.outboundRewriter == nil { return normalizeMarkdownTables(sanitizeDisplayAnswer(content), options) } @@ -691,7 +696,9 @@ func (h *Handler) sanitizeOutboundPartial(ctx context.Context, content string) s if ok { content = sanitizer.SanitizePartial(content) } - return h.rewriteCardImages(ctx, stableMarkdownPrefix(sanitizeDisplayAnswer(content)), false) + options := tableNormalizeOptions{groupWideTables: h.groupTablesPartial} + content = normalizeMarkdownTables(sanitizeDisplayAnswer(content), options) + return h.rewriteCardImages(ctx, stableMarkdownPrefix(content), false) } // rewriteCardImages converts image references in card content into DingTalk diff --git a/internal/channels/dingtalk/table_normalize.go b/internal/channels/dingtalk/table_normalize.go index 724ff95..e8f4ed2 100644 --- a/internal/channels/dingtalk/table_normalize.go +++ b/internal/channels/dingtalk/table_normalize.go @@ -23,6 +23,24 @@ func defaultTableNormalizeOptions() tableNormalizeOptions { return tableNormalizeOptions{groupWideTables: true} } +// tableGroupingForMode maps the configured mode to the two frames the card +// renders. An unknown or empty mode keeps the historical behaviour (group on +// every frame), so an unset environment never changes rendering. +// +// ""/"on" group on every frame +// "off" never group: real tables everywhere +// "stream" group while streaming, real table on the final frame +func tableGroupingForMode(mode string) (partial, final bool) { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "off", "0", "false", "no": + return false, false + case "stream", "streaming", "partial": + return true, false + default: + return true, true + } +} + // normalizeWideMarkdownTables makes final answers render reliably on DingTalk // cards without changing the knowledge itself. It (1) replaces
inside // table cells with a full-width separator, and (2) rewrites any table that diff --git a/internal/channels/dingtalk/table_normalize_test.go b/internal/channels/dingtalk/table_normalize_test.go index d8e4ccc..c0a7a5c 100644 --- a/internal/channels/dingtalk/table_normalize_test.go +++ b/internal/channels/dingtalk/table_normalize_test.go @@ -81,19 +81,45 @@ func TestNormalizeKeepsWideTableWhenGroupingDisabled(t *testing.T) { } } -// TestRewriteOutboundContentWideTableGroupingToggle proves the handler wires -// the switch through to the final frame, which is what the client sees. -func TestRewriteOutboundContentWideTableGroupingToggle(t *testing.T) { +func TestTableGroupingForMode(t *testing.T) { + cases := []struct { + mode string + wantPartial bool + wantFinal bool + }{ + {mode: "", wantPartial: true, wantFinal: true}, + {mode: "on", wantPartial: true, wantFinal: true}, + {mode: "weird", wantPartial: true, wantFinal: true}, + {mode: "off", wantPartial: false, wantFinal: false}, + {mode: "NO", wantPartial: false, wantFinal: false}, + {mode: "stream", wantPartial: true, wantFinal: false}, + {mode: " Stream ", wantPartial: true, wantFinal: false}, + } + for _, tc := range cases { + partial, final := tableGroupingForMode(tc.mode) + if partial != tc.wantPartial || final != tc.wantFinal { + t.Fatalf("tableGroupingForMode(%q) = (%v,%v), want (%v,%v)", tc.mode, partial, final, tc.wantPartial, tc.wantFinal) + } + } +} + +// TestWideTableGroupingPerFrame proves the switch reaches both frames the card +// renders: the streaming frames and the final one. The streaming path drops +// unstable table blocks, so with grouping off a partial frame simply carries no +// table at all. +func TestWideTableGroupingPerFrame(t *testing.T) { wide := "| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + strings.Repeat("J1: 180°/s (CR3A/5A/7A) ", 6) + " | CC262 |" + const tableMarker, groupMarker = "| 对比项 |", "- CR 系列:" cases := []struct { - name string - disabled bool - wantTable bool - wantDetail string + name string + mode string + partialWant [2]bool // [grouped, table] + finalWant [2]bool // [grouped, table] }{ - {name: "grouping enabled rewrites wide table", disabled: false, wantTable: false, wantDetail: "**关节最大速度**"}, - {name: "grouping disabled keeps wide table", disabled: true, wantTable: true, wantDetail: "| 对比项 |"}, + {name: "default groups every frame", mode: "", partialWant: [2]bool{true, false}, finalWant: [2]bool{true, false}}, + {name: "off keeps the table on the final frame", mode: "off", partialWant: [2]bool{false, false}, finalWant: [2]bool{false, true}}, + {name: "stream groups partial and keeps the final table", mode: "stream", partialWant: [2]bool{true, false}, finalWant: [2]bool{false, true}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -101,17 +127,25 @@ func TestRewriteOutboundContentWideTableGroupingToggle(t *testing.T) { TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"}, &fakeQA{}, &fakeReply{}, - HandlerOptions{WideTableGroupingDisabled: tc.disabled}, + HandlerOptions{WideTableGrouping: tc.mode}, ) if err != nil { t.Fatalf("NewHandlerWithOptions: %v", err) } - got := h.rewriteOutboundContent(context.Background(), wide) - if strings.Contains(got, "| 对比项 |") != tc.wantTable { - t.Fatalf("table present = %v, want %v, got %q", strings.Contains(got, "| 对比项 |"), tc.wantTable, got) + frames := map[string]struct { + got string + want [2]bool + }{ + "partial": {got: h.sanitizeOutboundPartial(context.Background(), wide), want: tc.partialWant}, + "final": {got: h.rewriteOutboundContent(context.Background(), wide), want: tc.finalWant}, } - if !strings.Contains(got, tc.wantDetail) { - t.Fatalf("missing %q in %q", tc.wantDetail, got) + for name, frame := range frames { + grouped := strings.Contains(frame.got, groupMarker) + table := strings.Contains(frame.got, tableMarker) + if grouped != frame.want[0] || table != frame.want[1] { + t.Fatalf("%s frame: grouped=%v table=%v, want grouped=%v table=%v, got %q", + name, grouped, table, frame.want[0], frame.want[1], frame.got) + } } }) }