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:
@@ -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
@@ -323,7 +323,8 @@ func main() {
|
||||
ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService},
|
||||
LanguageResolver: settingsRuntime.repository,
|
||||
|
||||
OutboundRewriteTimeout: outboundImages.rewriteTimeout,
|
||||
OutboundRewriteTimeout: outboundImages.rewriteTimeout,
|
||||
WideTableGroupingDisabled: cardWideTableGroupingDisabled(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 + " |"
|
||||
|
||||
Reference in New Issue
Block a user