feat(dingtalk): make wide-table grouping switchable for wide-screen PC cards

DINGTALK_CARD_WIDE_TABLE_GROUP=off keeps over-wide answer tables as tables on the final frame (still replaces <br> inside cells) so the PC wide-screen card can lay them out itself.
This commit is contained in:
hermes
2026-09-18 11:45:55 +08:00
parent 882ac62433
commit 92324b4242
6 changed files with 138 additions and 8 deletions
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"log"
"os"
"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
default:
return false
}
}
+2 -1
View File
@@ -323,7 +323,8 @@ func main() {
ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService},
LanguageResolver: settingsRuntime.repository,
OutboundRewriteTimeout: outboundImages.rewriteTimeout,
OutboundRewriteTimeout: outboundImages.rewriteTimeout,
WideTableGroupingDisabled: cardWideTableGroupingDisabled(),
},
)
if err != nil {
+15
View File
@@ -215,6 +215,21 @@ func TestOutboundImageBudgetAndCompressionDefaults(t *testing.T) {
}
}
func TestCardWideTableGroupingDisabledToggle(t *testing.T) {
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "")
if cardWideTableGroupingDisabled() {
t.Fatal("wide-table grouping must default to enabled")
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "off")
if !cardWideTableGroupingDisabled() {
t.Fatal("DINGTALK_CARD_WIDE_TABLE_GROUP=off must disable wide-table grouping")
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "ON")
if cardWideTableGroupingDisabled() {
t.Fatal("any value other than off must keep grouping enabled")
}
}
func TestMainDoesNotExposeWebAdministrationOrSetupOnlyMode(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
+14 -3
View File
@@ -168,6 +168,10 @@ 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
}
type Handler struct {
@@ -189,6 +193,10 @@ 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
}
// NewHandler accepts at most one image downloader. A nil/omitted downloader
@@ -237,7 +245,8 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
cardUpdateInterval: 500 * time.Millisecond,
finalDeliveryTimeout: defaultHTTPTimeout,
outboundRewriteTimeout: opts.OutboundRewriteTimeout,
outboundRewriteTimeout: opts.OutboundRewriteTimeout,
wideTableGroupingDisabled: opts.WideTableGroupingDisabled,
}, nil
}
@@ -660,8 +669,10 @@ 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
if h.outboundRewriter == nil {
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(content))
return normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
}
timeout := h.outboundRewriteTimeout
if timeout <= 0 {
@@ -672,7 +683,7 @@ func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) st
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)))
return normalizeMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)), options)
}
func (h *Handler) sanitizeOutboundPartial(ctx context.Context, content string) string {
+23 -4
View File
@@ -10,6 +10,19 @@ import (
// (one character per line) in the streaming card renderer.
const maxTableCellVisualWidth = 32
// tableNormalizeOptions controls how a final answer is adapted to the card
// renderer. The zero value keeps the historical behaviour.
type tableNormalizeOptions struct {
// groupWideTables rewrites tables containing over-wide cells into grouped
// lists (bold row heading + "- header:value" lines). Disable it when the
// client lays out wide tables itself, so the answer keeps its real table.
groupWideTables bool
}
func defaultTableNormalizeOptions() tableNormalizeOptions {
return tableNormalizeOptions{groupWideTables: 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
@@ -17,6 +30,10 @@ const maxTableCellVisualWidth = 32
// followed by one "- header:value" line per column. Compact tables pass
// through unchanged. Tables inside code fences are never touched.
func normalizeWideMarkdownTables(content string) string {
return normalizeMarkdownTables(content, defaultTableNormalizeOptions())
}
func normalizeMarkdownTables(content string, options tableNormalizeOptions) string {
if !strings.Contains(content, "|") {
return content
}
@@ -36,7 +53,7 @@ func normalizeWideMarkdownTables(content string) string {
strings.HasPrefix(strings.TrimSpace(lines[end+1].text), "|") {
end++
}
out = append(out, normalizeTableBlock(lines[i:end+1])...)
out = append(out, normalizeTableBlock(lines[i:end+1], options)...)
i = end + 1
continue
}
@@ -46,7 +63,7 @@ func normalizeWideMarkdownTables(content string) string {
return strings.Join(out, "\n")
}
func normalizeTableBlock(rows []markdownStreamLine) []string {
func normalizeTableBlock(rows []markdownStreamLine, options tableNormalizeOptions) []string {
cells := make([][]string, 0, len(rows))
for idx, row := range rows {
if idx == 1 { // delimiter row
@@ -63,9 +80,11 @@ func normalizeTableBlock(rows []markdownStreamLine) []string {
}
}
header := cells[0]
if maxWidth <= maxTableCellVisualWidth {
if maxWidth <= maxTableCellVisualWidth || !options.groupWideTables {
// Compact tables keep their exact original bytes unless a cell carries
// <br>, which the card renderer mishandles inside cells.
// <br>, which the card renderer mishandles inside cells. The same path
// serves over-wide tables when grouping is disabled: the table stays a
// table and only <br> is replaced.
if !blockHasBreak(rows) {
rebuilt := make([]string, len(rows))
for i, row := range rows {
@@ -1,6 +1,7 @@
package dingtalk
import (
"context"
"strings"
"testing"
)
@@ -56,6 +57,66 @@ func TestNormalizeSkipsFencedTables(t *testing.T) {
}
}
func TestNormalizeKeepsWideTableWhenGroupingDisabled(t *testing.T) {
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
options := defaultTableNormalizeOptions()
options.groupWideTables = false
got := normalizeMarkdownTables(content, options)
if !strings.Contains(got, "| 对比项 | CR 系列 | CRA 系列 |") || !strings.Contains(got, "| **关节最大速度** |") {
t.Fatalf("wide table must stay a table when grouping is disabled, got %q", got)
}
if strings.Contains(got, "**关节最大速度**\n- CR 系列:") {
t.Fatalf("grouped list must not be produced when grouping is disabled, got %q", got)
}
if strings.Contains(got, "<br>") {
t.Fatalf("<br> must still be replaced inside cells, got %q", got)
}
if !strings.Contains(got, ";") {
t.Fatalf("cell line breaks must become the full-width separator, got %q", got)
}
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
t.Fatalf("surrounding text must be preserved, got %q", got)
}
}
// TestRewriteOutboundContentWideTableGroupingToggle proves the handler wires
// the switch through to the final frame, which is what the client sees.
func TestRewriteOutboundContentWideTableGroupingToggle(t *testing.T) {
wide := "| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " +
strings.Repeat("J1: 180°/s (CR3A/5A/7A) ", 6) + " | CC262 |"
cases := []struct {
name string
disabled bool
wantTable bool
wantDetail string
}{
{name: "grouping enabled rewrites wide table", disabled: false, wantTable: false, wantDetail: "**关节最大速度**"},
{name: "grouping disabled keeps wide table", disabled: true, wantTable: true, wantDetail: "| 对比项 |"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
&fakeQA{},
&fakeReply{},
HandlerOptions{WideTableGroupingDisabled: tc.disabled},
)
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)
}
if !strings.Contains(got, tc.wantDetail) {
t.Fatalf("missing %q in %q", tc.wantDetail, got)
}
})
}
}
func TestNormalizeHandlesEscapedPipesAndEmptyHeaders(t *testing.T) {
long := strings.Repeat("x", 40)
content := "| 项 | | \n| --- | --- |\n| a\\|b | " + long + " |"