Skip to content

feat(display): [mode=rich] opt-in Card 2.0 single-card turn + reply elapsed time footer#657

Open
AaronZ345 wants to merge 5 commits intochenhg5:mainfrom
AaronZ345:pr-feat/display-mode-rich-card-timing
Open

feat(display): [mode=rich] opt-in Card 2.0 single-card turn + reply elapsed time footer#657
AaronZ345 wants to merge 5 commits intochenhg5:mainfrom
AaronZ345:pr-feat/display-mode-rich-card-timing

Conversation

@AaronZ345
Copy link
Copy Markdown
Contributor

Summary

Today an agent turn produces many separate messages — thinking updates, tool invocations, tool results, final response — cluttering the chat and triggering multiple push notifications per turn. This PR packages #309 (Card 2.0 rich cards) together with three extensions so that one turn = one card:

  1. [display] mode opt-in gate — new config field. Default "legacy" preserves current upstream behavior, "rich" enables the Card 2.0 single-card flow. Users can A/B the two just by editing config and restarting.
  2. Agent reply elapsed time footer — cards show ⏱ 运行中 12.3 秒... while streaming and ⏱ 用时 1 分 23 秒 when complete, rendered as a separate div below the existing model/effort/workdir reply footer.
  3. Respect /quiet in Card 2.0 path — when display.thinking_messages / display.tool_messages are false, the rich card skips the "Thinking..." header and the tool-steps panel respectively, mirroring upstream's quiet semantics.

User benefit

  • Before: 5–10 separate messages per turn, each a push notification. Tool output, thinking, final reply all scatter across the chat.
  • After (with mode = "rich"): one card, one push. Tool steps live in a collapsible panel (auto-folded when done), markdown body streams in place, elapsed time visible throughout.
  • Backwards-compatible: default mode = "legacy" leaves every existing user untouched. Feature is 100% opt-in via a config flag.

Relation to #309 / #655

Testing

  • go build ./... passes
  • go test ./... passes
  • Author (@AaronZ345) has been running this fork in production for several sessions across 5 parallel feishu bots. Switching mode between rich and legacy via config + restart verified both paths work end-to-end.

Footprint

config/config.go, cmd/cc-connect/main.go, core/streaming.go, core/engine.go, platform/feishu/feishu.go + tests.

🤖 Generated with Claude Code

@chenhg5
Copy link
Copy Markdown
Owner

chenhg5 commented Apr 17, 2026

CI 失败 (lint):

core/streaming.go:419 - U1000: func (*streamPreview).getFullText is unused

请删除未使用的函数。

@AaronZ345 AaronZ345 force-pushed the pr-feat/display-mode-rich-card-timing branch from 6eb945f to 21efe31 Compare April 17, 2026 11:49
Copy link
Copy Markdown
Owner

@chenhg5 chenhg5 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Card 2.0 rich mode实现完整:

  • RichCardSupporter 接口设计良好,平台侧实现可选,对不支持的平台向后兼容
  • CardStatus (thinking/working/done/error) + elapsed time footer 提升 UX
  • ToolStep 结构化工具调用,streaming 时分段展示
  • legacy mode 保持原有行为,默认关闭
  • CI 全绿(lint 已修复)

这是一个较大的 feature,建议合并前确认 human 审核通过。

@AaronZ345 AaronZ345 force-pushed the pr-feat/display-mode-rich-card-timing branch from 4517b16 to 8b57889 Compare April 25, 2026 04:50
@AaronZ345 AaronZ345 closed this Apr 26, 2026
@AaronZ345 AaronZ345 deleted the pr-feat/display-mode-rich-card-timing branch April 26, 2026 06:34
@AaronZ345 AaronZ345 restored the pr-feat/display-mode-rich-card-timing branch April 26, 2026 06:38
@AaronZ345 AaronZ345 reopened this Apr 26, 2026
@AaronZ345 AaronZ345 force-pushed the pr-feat/display-mode-rich-card-timing branch from 97047ba to 5ca5ed3 Compare April 26, 2026 07:14
@AaronZ345 AaronZ345 force-pushed the pr-feat/display-mode-rich-card-timing branch from 5ca5ed3 to 18a6057 Compare April 27, 2026 16:33
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
…card fallback

Per Lark official icon library (https://open.feishu.cn/document/feishu-cards/enumerations-for-icons),
4 standard_icon tokens used in chenhg5#657's toolIconMap are not in the official enumeration:
  - terminal-two_outlined (Bash) -> code_outlined
  - file-open_outlined (Read) -> file-link-text_outlined
  - notes_outlined (Write) -> richtext_outlined
  - folder-open_outlined (Glob) -> creat-folder_outlined ("creat" is the official spelling)

Without this fix Lark client renders default placeholder icons or empty icon slots.

Also migrate fallback card config 'wide_screen_mode: true' (schema 1.0)
to 'width_mode: "default"' (schema 2.0) for self-consistency since the
card declares schema: "2.0".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
… (Phase A.5)

B018 Phase A.5 — implements true Lark client-side typewriter rendering for the
markdown body of chenhg5#657 Card 2.0 rich cards. Replaces chenhg5#657's full-card replace
on every EventText with per-element streaming via cardkit-v1.

Three-step send flow (replaces single-step Im.Message.Create):
  1. POST /open-apis/cardkit/v1/cards { type: card_json, data: <cardJSON> }
     -> card_id (numeric string, 14-day TTL)
  2. POST /open-apis/im/v1/messages with content
     {"type":"card","data":{"card_id":"..."}}
     -> message_id
  3. PUT /open-apis/cardkit/v1/cards/{card_id}/elements/main_text/content
     { content: <fullText>, sequence: <monotonic int> }  on each EventText

All HTTP calls use the lark Go SDK generic client.Post/Put with
larkcore.AccessTokenTypeTenant — no raw HTTP, no manual token cache, no
hardcoded domain (SDK auto-handles lark international vs feishu domestic).

Concurrency: feishuPreviewHandle.mu protects the sequence counter through HTTP
completion. Per-card serialization keeps sequence monotonic regardless of
concurrent EventText goroutines. Lark's 50 QPS per-element rate limit is well
above this throttle.

Throttle: cardkit-v1 path uses 200ms / 20 chars (5 updates/sec, smooth
typewriter); fallback to full-card Patch keeps original 1500ms / 30 chars
since Im.Message.Patch is heavier.

Fallback chain (turn never gets stuck):
  - Create Card Entity fails  -> SendPreviewStart falls through to inline
                                  card JSON (= original chenhg5#657 path); handle
                                  cardID stays empty
  - StreamRichCardText returns ErrNotSupported (no cardID) or any error
                              -> engine routes EventText to full-card Patch
                                 via UpdateMessage; cardID kept for next try
  - Reply API path (thread)   -> skips cardkit-v1 entirely (Reply doesn't
                                  document card_id reference support)

Schema validated end-to-end via lark-cli before commit:
  POST /cardkit/v1/cards               -> { code: 0, data.card_id: "..." }
  POST /im/v1/messages with type=card  -> { code: 0, data.message_id: "..." }
  PUT  .../elements/main_text/content  -> { code: 0 }

Files:
  core/streaming.go          + RichCardTextStreamer optional interface
  core/engine.go             EventText: prefer streamer; fallback to full Patch
                             on ErrNotSupported / error; new 200ms throttle
                             when streamer present, else 1500ms
  platform/feishu/feishu.go  + richCardMainTextElementID = "main_text"
                             buildRichCard: markdown element gets element_id
                             feishuPreviewHandle: + cardID, sequence (mu-guarded)
                             SendPreviewStart: two-step flow with fallback
                             + createCardEntity helper
                             + StreamRichCardText (impls RichCardTextStreamer)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
…abling typewriter on every @-mention)

Phase A.5 v1 conservatively skipped cardkit-v1 in Im.Message.Reply path, fearing
the Reply API might not accept the {type:card,data:{card_id}} content schema
(Lark docs only document it for Im.Message.Create). But shouldUseThreadOrReplyAPI()
returns true on every @-mention turn (rc.messageID != "" + reply-to-trigger
default), which is the dominant CCC usage. So cardkit-v1 streaming was effectively
DEAD on every actual user turn — the engine kept falling through to full-card
Patch (= original chenhg5#657 behavior, no typewriter).

Verified by direct Lark API call:
  POST /open-apis/im/v1/messages/{message_id}/reply
    body: {msg_type: interactive,
           content: '{"type":"card","data":{"card_id":"..."}}'}
  -> { code: 0, data.message_id: "..." }

Reply API DOES accept the same card_id reference content schema. Fix:
unconditionally try createCardEntity for rich card sends; let cardkit-v1
streaming flow through both Reply and Create paths.

Also bumped the create-card-entity-failure log from Debug to Info so we
can spot fallbacks at the default INFO log level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
…ard via unified statusFooter (方案 B)

The Phase A take-chenhg5#657-for-engine.go reset dropped chenhg5#774's footer rendering on the
rich card path. Phase B reintroduces it cleanly through the planned uniform
'statusFooter string' interface so elapsed time, claudecode model/ctx status,
and workdir all flow through the same composition pipeline -- no duplicate
elapsed-time logic on platform side, no chenhg5#657-vs-chenhg5#774 conflict.

Interface refactor:
  RichCardSupporter.BuildRichCard(..., elapsed time.Duration)
                                                ^ removed
                                  (..., statusFooter string)
                                                ^ added
  Multi-line, '\n' separated. Empty string = footer hidden.

Engine adds composeRichStatusFooter() helper (3 lines, each toggleable):
  line 1: ⏱ <i18n elapsed>      (subject to e.replyFooterEnabled)
  line 2: model · effort · ctx   (subject to e.showContextIndicator)
  line 3: <workdir>             (subject to e.showWorkdirIndicator)

Engine adds formatElapsed(d, streaming, lang) with ZH / EN paths:
  ZH streaming: "⏱ 运行中 12.3 秒..."
  ZH done:      "⏱ 用时 1 分 23 秒"
  EN streaming: "⏱ Running for 12.3s..."
  EN done:      "⏱ Elapsed 1m 23s"
  ja/es fall back to EN format -- promote to MsgKey i18n later if needed.

Feishu renderer:
  - drops the local elapsed/footerMap branch (engine owns formatting now)
  - splits incoming statusFooter on '\n', renders each line as its own
    markdown element with text_size:notation + text_color:grey
  - inserts <hr> between body markdown and footer for visual separation
  - dead formatElapsedCN helper removed

All 8 engine BuildRichCard call sites updated to the new signature; feishu
Platform.BuildRichCard wrapper signature updated; buildRichCard() inner
renderer signature updated.

Per-project [display].mode override deferred to a follow-up commit; the
existing global [display].mode keeps working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
…card fallback

Per Lark official icon library (https://open.feishu.cn/document/feishu-cards/enumerations-for-icons),
4 standard_icon tokens used in chenhg5#657's toolIconMap are not in the official enumeration:
  - terminal-two_outlined (Bash) -> code_outlined
  - file-open_outlined (Read) -> file-link-text_outlined
  - notes_outlined (Write) -> richtext_outlined
  - folder-open_outlined (Glob) -> creat-folder_outlined ("creat" is the official spelling)

Without this fix Lark client renders default placeholder icons or empty icon slots.

Also migrate fallback card config 'wide_screen_mode: true' (schema 1.0)
to 'width_mode: "default"' (schema 2.0) for self-consistency since the
card declares schema: "2.0".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
… (Phase A.5)

B018 Phase A.5 — implements true Lark client-side typewriter rendering for the
markdown body of chenhg5#657 Card 2.0 rich cards. Replaces chenhg5#657's full-card replace
on every EventText with per-element streaming via cardkit-v1.

Three-step send flow (replaces single-step Im.Message.Create):
  1. POST /open-apis/cardkit/v1/cards { type: card_json, data: <cardJSON> }
     -> card_id (numeric string, 14-day TTL)
  2. POST /open-apis/im/v1/messages with content
     {"type":"card","data":{"card_id":"..."}}
     -> message_id
  3. PUT /open-apis/cardkit/v1/cards/{card_id}/elements/main_text/content
     { content: <fullText>, sequence: <monotonic int> }  on each EventText

All HTTP calls use the lark Go SDK generic client.Post/Put with
larkcore.AccessTokenTypeTenant — no raw HTTP, no manual token cache, no
hardcoded domain (SDK auto-handles lark international vs feishu domestic).

Concurrency: feishuPreviewHandle.mu protects the sequence counter through HTTP
completion. Per-card serialization keeps sequence monotonic regardless of
concurrent EventText goroutines. Lark's 50 QPS per-element rate limit is well
above this throttle.

Throttle: cardkit-v1 path uses 200ms / 20 chars (5 updates/sec, smooth
typewriter); fallback to full-card Patch keeps original 1500ms / 30 chars
since Im.Message.Patch is heavier.

Fallback chain (turn never gets stuck):
  - Create Card Entity fails  -> SendPreviewStart falls through to inline
                                  card JSON (= original chenhg5#657 path); handle
                                  cardID stays empty
  - StreamRichCardText returns ErrNotSupported (no cardID) or any error
                              -> engine routes EventText to full-card Patch
                                 via UpdateMessage; cardID kept for next try
  - Reply API path (thread)   -> skips cardkit-v1 entirely (Reply doesn't
                                  document card_id reference support)

Schema validated end-to-end via lark-cli before commit:
  POST /cardkit/v1/cards               -> { code: 0, data.card_id: "..." }
  POST /im/v1/messages with type=card  -> { code: 0, data.message_id: "..." }
  PUT  .../elements/main_text/content  -> { code: 0 }

Files:
  core/streaming.go          + RichCardTextStreamer optional interface
  core/engine.go             EventText: prefer streamer; fallback to full Patch
                             on ErrNotSupported / error; new 200ms throttle
                             when streamer present, else 1500ms
  platform/feishu/feishu.go  + richCardMainTextElementID = "main_text"
                             buildRichCard: markdown element gets element_id
                             feishuPreviewHandle: + cardID, sequence (mu-guarded)
                             SendPreviewStart: two-step flow with fallback
                             + createCardEntity helper
                             + StreamRichCardText (impls RichCardTextStreamer)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
…abling typewriter on every @-mention)

Phase A.5 v1 conservatively skipped cardkit-v1 in Im.Message.Reply path, fearing
the Reply API might not accept the {type:card,data:{card_id}} content schema
(Lark docs only document it for Im.Message.Create). But shouldUseThreadOrReplyAPI()
returns true on every @-mention turn (rc.messageID != "" + reply-to-trigger
default), which is the dominant CCC usage. So cardkit-v1 streaming was effectively
DEAD on every actual user turn — the engine kept falling through to full-card
Patch (= original chenhg5#657 behavior, no typewriter).

Verified by direct Lark API call:
  POST /open-apis/im/v1/messages/{message_id}/reply
    body: {msg_type: interactive,
           content: '{"type":"card","data":{"card_id":"..."}}'}
  -> { code: 0, data.message_id: "..." }

Reply API DOES accept the same card_id reference content schema. Fix:
unconditionally try createCardEntity for rich card sends; let cardkit-v1
streaming flow through both Reply and Create paths.

Also bumped the create-card-entity-failure log from Debug to Info so we
can spot fallbacks at the default INFO log level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 28, 2026
…ard via unified statusFooter (方案 B)

The Phase A take-chenhg5#657-for-engine.go reset dropped chenhg5#774's footer rendering on the
rich card path. Phase B reintroduces it cleanly through the planned uniform
'statusFooter string' interface so elapsed time, claudecode model/ctx status,
and workdir all flow through the same composition pipeline -- no duplicate
elapsed-time logic on platform side, no chenhg5#657-vs-chenhg5#774 conflict.

Interface refactor:
  RichCardSupporter.BuildRichCard(..., elapsed time.Duration)
                                                ^ removed
                                  (..., statusFooter string)
                                                ^ added
  Multi-line, '\n' separated. Empty string = footer hidden.

Engine adds composeRichStatusFooter() helper (3 lines, each toggleable):
  line 1: ⏱ <i18n elapsed>      (subject to e.replyFooterEnabled)
  line 2: model · effort · ctx   (subject to e.showContextIndicator)
  line 3: <workdir>             (subject to e.showWorkdirIndicator)

Engine adds formatElapsed(d, streaming, lang) with ZH / EN paths:
  ZH streaming: "⏱ 运行中 12.3 秒..."
  ZH done:      "⏱ 用时 1 分 23 秒"
  EN streaming: "⏱ Running for 12.3s..."
  EN done:      "⏱ Elapsed 1m 23s"
  ja/es fall back to EN format -- promote to MsgKey i18n later if needed.

Feishu renderer:
  - drops the local elapsed/footerMap branch (engine owns formatting now)
  - splits incoming statusFooter on '\n', renders each line as its own
    markdown element with text_size:notation + text_color:grey
  - inserts <hr> between body markdown and footer for visual separation
  - dead formatElapsedCN helper removed

All 8 engine BuildRichCard call sites updated to the new signature; feishu
Platform.BuildRichCard wrapper signature updated; buildRichCard() inner
renderer signature updated.

Per-project [display].mode override deferred to a follow-up commit; the
existing global [display].mode keeps working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
张彧 and others added 5 commits April 30, 2026 18:49
Based on upstream PR chenhg5#309 (Card 2.0 rich cards with collapsible panel and
streaming, which already contains PR chenhg5#306's header status colors). Resolves
15 conflict hunks by taking main as base and appending Card 2.0 renderer.

Changes:
- core/streaming.go: RichCardSupporter.BuildRichCard gains elapsed
  time.Duration parameter
- core/engine.go: hasRichCard path renders a single streaming card for the
  whole turn; non-RichCardSupporter platforms keep main's compact/legacy
  progress behavior unchanged
- platform/feishu/feishu.go: +~350 lines
  - collapsible tool-step panel with icons
  - thinking verb header, status-colored (blue/green/red)
  - streaming_mode card updates (1500ms / 30-char throttle via engine)
  - splitMarkdownByTables for long tables
  - SetPreviewStatus for header color patches
  - feishuPreviewHandle: +mu/status/lastContent
  - SendPreviewStart / UpdateMessage recognize pre-built card JSON
    (isCardJSON) to skip double-wrapping
- Timing footer: "⏱ 运行中 X 秒..." while streaming, "⏱ 用时 1 分 23 秒"
  on completion, appended as a separate div below main's reply footer
  (model · effort · workdir)

Scope: only platforms implementing RichCardSupporter (currently feishu)
get the new single-card experience. Other platforms unaffected.
The Card 2.0 rich card path used to bypass display.ThinkingMessages /
display.ToolMessages entirely, so /quiet had no effect on feishu: the
card still showed the Thinking... header and accumulated tool steps in
the collapsible panel.

Now:
- EventThinking: when display.ThinkingMessages is false, skip card
  creation. The card is created later by EventText (streaming markdown)
  or by EventResult (final completed card).
- EventToolUse: when display.ToolMessages is false, skip toolSteps
  append and card create/update. Final card from EventResult has
  empty toolSteps, so buildRichCard renders markdown-only (no panel).
…d 2.0

Gate the Card 2.0 hasRichCard path behind a config switch so each fork
user can pick between upstream behavior and the rich card experience
without recompiling.

- DisplayConfig.Mode (*string toml "mode") — "legacy" (default) or "rich"
- core.DisplayCfg.Mode string propagated via SetDisplayConfig
- engine.go: force hasRichCard=false when mode != "rich", so existing
  Card 2.0 branches are only reached when explicitly enabled
- EffectiveDisplay returns the resolved mode; invalid values fall back
  to "legacy"
- config.toml (user) set mode = "rich" to keep current behavior active
When an agent makes many tool calls in one turn (e.g. 50+ MCP queries),
buildRichCard packed every step into the collapsible_panel.elements,
causing two problems:

1. Lark client renders huge collapsible panels poorly — the JSON view
   showed mangled fields where later body elements appeared spliced
   into the last panel step's text.content.
2. The card payload approached/exceeded Feishu's ~30KB interactive card
   limit, at which point the API rejected the card or the client
   rendered it as a JSON dump.

Two minimal mitigations:

- Cap panel rows at 30. Excess steps collapse into a single
  "… and N more steps" row. Tool execution itself is unaffected; only
  the panel preview is condensed.
- After json.Marshal, if the card exceeds 28000 bytes, fall back to
  buildCardJSONWithStatus (markdown body only, no panel). Preserves the
  agent's reply text and status header even on degenerate turns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ch mode

Two related rich-mode bugs surfaced after Phase A.5 deployment.

1. NO_REPLY + rich mode -> card stuck in 'Working' forever

The rich path tracks the in-flight card via cardMessageID, an engine-local
handle independent of sp.previewMsgID (the streamPreview's own state). The
isSilent branch in EventResult only called sp.discard(), which deletes
sp.previewMsgID -- but in rich mode that handle is usually nil because the
card was created via starter.SendPreviewStart directly, not via the sp
pipeline. Result: the rich card was never deleted or transitioned to Done,
leaving a 'Working' (or 'Pondering') indicator visible to the user even
though the agent had decided to suppress its reply.

Fix: in the isSilent branch, also delete cardMessageID via PreviewCleaner.
NO_REPLY now truly leaves no visible trace.

2. Done emoji is noise in rich mode

The done reaction (green checkmark) was meant to signal 'agent finished' on
platforms where the streaming preview update was silent (Lark in-place
UpdateMessage doesn't ping the user, but the visible result is the same
text). In rich mode the card's header template flips from blue 'Working'
to green 'Done' on EventResult, which is a far more visible 'done' signal
than a separate emoji on the trigger message. The emoji becomes redundant
visual clutter on every turn.

Fix: skip AddDoneReaction when hasRichCard is true. Legacy mode behavior
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@AaronZ345 AaronZ345 force-pushed the pr-feat/display-mode-rich-card-timing branch from 303c8c2 to fecc642 Compare April 30, 2026 10:51
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 30, 2026
…card fallback

Per Lark official icon library (https://open.feishu.cn/document/feishu-cards/enumerations-for-icons),
4 standard_icon tokens used in chenhg5#657's toolIconMap are not in the official enumeration:
  - terminal-two_outlined (Bash) -> code_outlined
  - file-open_outlined (Read) -> file-link-text_outlined
  - notes_outlined (Write) -> richtext_outlined
  - folder-open_outlined (Glob) -> creat-folder_outlined ("creat" is the official spelling)

Without this fix Lark client renders default placeholder icons or empty icon slots.

Also migrate fallback card config 'wide_screen_mode: true' (schema 1.0)
to 'width_mode: "default"' (schema 2.0) for self-consistency since the
card declares schema: "2.0".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 30, 2026
… (Phase A.5)

B018 Phase A.5 — implements true Lark client-side typewriter rendering for the
markdown body of chenhg5#657 Card 2.0 rich cards. Replaces chenhg5#657's full-card replace
on every EventText with per-element streaming via cardkit-v1.

Three-step send flow (replaces single-step Im.Message.Create):
  1. POST /open-apis/cardkit/v1/cards { type: card_json, data: <cardJSON> }
     -> card_id (numeric string, 14-day TTL)
  2. POST /open-apis/im/v1/messages with content
     {"type":"card","data":{"card_id":"..."}}
     -> message_id
  3. PUT /open-apis/cardkit/v1/cards/{card_id}/elements/main_text/content
     { content: <fullText>, sequence: <monotonic int> }  on each EventText

All HTTP calls use the lark Go SDK generic client.Post/Put with
larkcore.AccessTokenTypeTenant — no raw HTTP, no manual token cache, no
hardcoded domain (SDK auto-handles lark international vs feishu domestic).

Concurrency: feishuPreviewHandle.mu protects the sequence counter through HTTP
completion. Per-card serialization keeps sequence monotonic regardless of
concurrent EventText goroutines. Lark's 50 QPS per-element rate limit is well
above this throttle.

Throttle: cardkit-v1 path uses 200ms / 20 chars (5 updates/sec, smooth
typewriter); fallback to full-card Patch keeps original 1500ms / 30 chars
since Im.Message.Patch is heavier.

Fallback chain (turn never gets stuck):
  - Create Card Entity fails  -> SendPreviewStart falls through to inline
                                  card JSON (= original chenhg5#657 path); handle
                                  cardID stays empty
  - StreamRichCardText returns ErrNotSupported (no cardID) or any error
                              -> engine routes EventText to full-card Patch
                                 via UpdateMessage; cardID kept for next try
  - Reply API path (thread)   -> skips cardkit-v1 entirely (Reply doesn't
                                  document card_id reference support)

Schema validated end-to-end via lark-cli before commit:
  POST /cardkit/v1/cards               -> { code: 0, data.card_id: "..." }
  POST /im/v1/messages with type=card  -> { code: 0, data.message_id: "..." }
  PUT  .../elements/main_text/content  -> { code: 0 }

Files:
  core/streaming.go          + RichCardTextStreamer optional interface
  core/engine.go             EventText: prefer streamer; fallback to full Patch
                             on ErrNotSupported / error; new 200ms throttle
                             when streamer present, else 1500ms
  platform/feishu/feishu.go  + richCardMainTextElementID = "main_text"
                             buildRichCard: markdown element gets element_id
                             feishuPreviewHandle: + cardID, sequence (mu-guarded)
                             SendPreviewStart: two-step flow with fallback
                             + createCardEntity helper
                             + StreamRichCardText (impls RichCardTextStreamer)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 30, 2026
…abling typewriter on every @-mention)

Phase A.5 v1 conservatively skipped cardkit-v1 in Im.Message.Reply path, fearing
the Reply API might not accept the {type:card,data:{card_id}} content schema
(Lark docs only document it for Im.Message.Create). But shouldUseThreadOrReplyAPI()
returns true on every @-mention turn (rc.messageID != "" + reply-to-trigger
default), which is the dominant CCC usage. So cardkit-v1 streaming was effectively
DEAD on every actual user turn — the engine kept falling through to full-card
Patch (= original chenhg5#657 behavior, no typewriter).

Verified by direct Lark API call:
  POST /open-apis/im/v1/messages/{message_id}/reply
    body: {msg_type: interactive,
           content: '{"type":"card","data":{"card_id":"..."}}'}
  -> { code: 0, data.message_id: "..." }

Reply API DOES accept the same card_id reference content schema. Fix:
unconditionally try createCardEntity for rich card sends; let cardkit-v1
streaming flow through both Reply and Create paths.

Also bumped the create-card-entity-failure log from Debug to Info so we
can spot fallbacks at the default INFO log level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 30, 2026
…ard via unified statusFooter (方案 B)

The Phase A take-chenhg5#657-for-engine.go reset dropped chenhg5#774's footer rendering on the
rich card path. Phase B reintroduces it cleanly through the planned uniform
'statusFooter string' interface so elapsed time, claudecode model/ctx status,
and workdir all flow through the same composition pipeline -- no duplicate
elapsed-time logic on platform side, no chenhg5#657-vs-chenhg5#774 conflict.

Interface refactor:
  RichCardSupporter.BuildRichCard(..., elapsed time.Duration)
                                                ^ removed
                                  (..., statusFooter string)
                                                ^ added
  Multi-line, '\n' separated. Empty string = footer hidden.

Engine adds composeRichStatusFooter() helper (3 lines, each toggleable):
  line 1: ⏱ <i18n elapsed>      (subject to e.replyFooterEnabled)
  line 2: model · effort · ctx   (subject to e.showContextIndicator)
  line 3: <workdir>             (subject to e.showWorkdirIndicator)

Engine adds formatElapsed(d, streaming, lang) with ZH / EN paths:
  ZH streaming: "⏱ 运行中 12.3 秒..."
  ZH done:      "⏱ 用时 1 分 23 秒"
  EN streaming: "⏱ Running for 12.3s..."
  EN done:      "⏱ Elapsed 1m 23s"
  ja/es fall back to EN format -- promote to MsgKey i18n later if needed.

Feishu renderer:
  - drops the local elapsed/footerMap branch (engine owns formatting now)
  - splits incoming statusFooter on '\n', renders each line as its own
    markdown element with text_size:notation + text_color:grey
  - inserts <hr> between body markdown and footer for visual separation
  - dead formatElapsedCN helper removed

All 8 engine BuildRichCard call sites updated to the new signature; feishu
Platform.BuildRichCard wrapper signature updated; buildRichCard() inner
renderer signature updated.

Per-project [display].mode override deferred to a follow-up commit; the
existing global [display].mode keeps working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AaronZ345 pushed a commit to AaronZ345/cc-connect that referenced this pull request Apr 30, 2026
…d recovery

The cherry-picked chenhg5#774 commit (reply footer refactor) re-introduced the
old TestStreamPreview_FreezeDeletesOnFinish expectations from main when
adding the statusFooter parameter to finish(), but the cherry-picked
chenhg5#795 commit (Lark icon + width_mode follow-up to chenhg5#657) had already
updated finish() with degraded-recovery behavior — it now attempts
UpdateMessage on the degraded preview and returns true on success
without deleting.

Restore the post-chenhg5#795 expectation: finish should return true when
recovery via UpdateMessage succeeds (mockCleanerPlatform embeds
mockUpdaterPlatform so the recovery path completes), and drop the
"expected 1 delete call" assertion that no longer applies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants