feat(dingtalk): per-frame wide-table grouping (stream keeps tables flowing while streaming)

This commit is contained in:
hermes
2026-09-18 11:55:45 +08:00
parent 92324b4242
commit dd229ec0ad
6 changed files with 117 additions and 50 deletions
+16 -12
View File
@@ -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 ""
}
}
+2 -2
View File
@@ -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 {
+12 -8
View File
@@ -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)
}
}
+20 -13
View File
@@ -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
@@ -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 <br> inside
// table cells with a full-width separator, and (2) rewrites any table that
@@ -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)
}
}
})
}