mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-16 02:15:37 +00:00
feat(channels): add i18n for channel config fields and enable DM streaming by default
Add complete i18n support for channel config tab — field labels, help text, and select option translations for en/vi/zh. Enable DM streaming by default for Telegram and Slack channels.
This commit is contained in:
1 parent
b6df6c9286
commit
06b4eb912e
22 files changed
+800
-268
No files matched your search
@@ -100,9 +100,21 @@ type StreamingChannel interface {
|
||||
// Channels may choose to always stream for DMs while gating group streaming
|
||||
// behind config (e.g. Telegram uses sendMessageDraft for DMs).
|
||||
StreamEnabled(isGroup bool) bool
|
||||
OnStreamStart(ctx context.Context, chatID string) error
|
||||
OnChunkEvent(ctx context.Context, chatID string, fullText string) error
|
||||
OnStreamEnd(ctx context.Context, chatID string, finalText string) error
|
||||
// CreateStream creates a new per-run streaming handle for the given chatID.
|
||||
// The returned ChannelStream is stored on RunContext so each concurrent run
|
||||
// gets its own stream — eliminates the chatID-keyed sync.Map collision bug.
|
||||
// firstStream: true for the first stream in a run (may become reasoning lane —
|
||||
// must use message transport so it persists as a real message). false for
|
||||
// subsequent streams (answer lane — may use draft transport for stealth preview).
|
||||
CreateStream(ctx context.Context, chatID string, firstStream bool) (ChannelStream, error)
|
||||
// FinalizeStream is called after the stream has been stopped to hand off
|
||||
// the stream's messageID (if any) back to the channel's placeholder map
|
||||
// so that Send() can edit it with the final formatted response.
|
||||
FinalizeStream(ctx context.Context, chatID string, stream ChannelStream)
|
||||
// ReasoningStreamEnabled returns whether reasoning should be shown as a
|
||||
// separate message. Default: true. Channels that don't support lanes can
|
||||
// return false to skip reasoning routing.
|
||||
ReasoningStreamEnabled() bool
|
||||
}
|
||||
|
||||
// BlockReplyChannel is optionally implemented by channels that override
|
||||
|
||||
+174
-22
@@ -33,19 +33,63 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
|
||||
if sc, ok := ch.(StreamingChannel); ok {
|
||||
switch eventType {
|
||||
case protocol.AgentEventRunStarted:
|
||||
if err := sc.OnStreamStart(ctx, rc.ChatID); err != nil {
|
||||
stream, err := sc.CreateStream(ctx, rc.ChatID, true)
|
||||
if err != nil {
|
||||
slog.Debug("stream start failed", "channel", rc.ChannelName, "error", err)
|
||||
} else {
|
||||
rc.mu.Lock()
|
||||
rc.stream = stream
|
||||
rc.mu.Unlock()
|
||||
}
|
||||
case protocol.ChatEventThinking:
|
||||
// Accumulate thinking/reasoning content and route to the current stream.
|
||||
// The stream created on run.started becomes the "reasoning lane":
|
||||
// - DMs: edits the "Thinking..." placeholder with reasoning text
|
||||
// - Groups: edits a fresh message with reasoning text
|
||||
// When the first chunk arrives, this stream is stopped (reasoning message stays
|
||||
// visible) and a new stream is created for the answer lane.
|
||||
// Gated by ReasoningStreamEnabled() — channels can opt out (e.g. Slack).
|
||||
if !sc.ReasoningStreamEnabled() {
|
||||
break
|
||||
}
|
||||
content := extractPayloadString(payload, "content")
|
||||
if content != "" {
|
||||
rc.mu.Lock()
|
||||
rc.thinkingBuffer += content
|
||||
rc.hasThinking = true
|
||||
thinkText := rc.thinkingBuffer
|
||||
currentStream := rc.stream
|
||||
rc.mu.Unlock()
|
||||
if currentStream != nil {
|
||||
currentStream.Update(ctx, formatReasoningPreview(thinkText))
|
||||
}
|
||||
}
|
||||
case protocol.AgentEventToolCall:
|
||||
// Agent is executing a tool — mark tool phase so the next chunk
|
||||
// (new LLM iteration) resets the stream buffer.
|
||||
// Also clear the current DraftStream so the next iteration starts
|
||||
// a fresh streaming message (matching TS onAssistantMessageStart pattern).
|
||||
// Stop the current stream (reasoning or answer) and finalize only
|
||||
// the answer stream (reasoning messages stay visible).
|
||||
rc.mu.Lock()
|
||||
// Capture current state before resetting for next iteration.
|
||||
wasReasoningStream := rc.hasThinking && !rc.thinkingDone
|
||||
currentStream := rc.stream
|
||||
rc.stream = nil
|
||||
rc.inToolPhase = true
|
||||
rc.thinkingDone = false // allow new thinking in next iteration
|
||||
rc.thinkingBuffer = "" // reset thinking buffer for new iteration
|
||||
rc.hasThinking = false // new iteration starts fresh
|
||||
rc.tagParseSkipped = false // re-enable tag parsing for next iteration
|
||||
rc.mu.Unlock()
|
||||
if err := sc.OnStreamEnd(ctx, rc.ChatID, ""); err != nil {
|
||||
slog.Debug("stream tool-phase end failed", "channel", rc.ChannelName, "error", err)
|
||||
if currentStream != nil {
|
||||
if err := currentStream.Stop(ctx); err != nil {
|
||||
slog.Debug("stream tool-phase stop failed", "channel", rc.ChannelName, "error", err)
|
||||
}
|
||||
// Only finalize answer streams (hand off messageID to Send()).
|
||||
// Reasoning streams stay as visible messages — don't put their
|
||||
// messageID into placeholders or it would confuse Send().
|
||||
if !wasReasoningStream {
|
||||
sc.FinalizeStream(ctx, rc.ChatID, currentStream)
|
||||
}
|
||||
}
|
||||
|
||||
// Show tool status in streaming preview (edit placeholder with tool name).
|
||||
@@ -63,39 +107,130 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
|
||||
}
|
||||
case protocol.ChatEventChunk:
|
||||
// Accumulate chunk deltas into full text.
|
||||
// When entering a new LLM iteration (first chunk after tool.call),
|
||||
// reset the buffer so we don't concatenate text from previous iterations.
|
||||
content := extractPayloadString(payload, "content")
|
||||
if content != "" {
|
||||
rc.mu.Lock()
|
||||
if rc.inToolPhase {
|
||||
// New LLM iteration — reset buffer and start fresh stream
|
||||
needNewStream := rc.inToolPhase
|
||||
if needNewStream {
|
||||
rc.streamBuffer = ""
|
||||
rc.inToolPhase = false
|
||||
rc.mu.Unlock()
|
||||
// Create new DraftStream for this iteration
|
||||
if err := sc.OnStreamStart(ctx, rc.ChatID); err != nil {
|
||||
slog.Debug("stream restart failed", "channel", rc.ChannelName, "error", err)
|
||||
}
|
||||
rc.mu.Lock()
|
||||
}
|
||||
|
||||
// Fallback <think> tag parsing: for providers that embed thinking
|
||||
// in the content stream (DeepSeek-via-OpenRouter, Qwen, some Ollama models).
|
||||
// Only activates when no native ChatEventThinking was received.
|
||||
if !rc.hasThinking && !rc.thinkingDone && !rc.tagParseSkipped {
|
||||
candidate := rc.streamBuffer + content
|
||||
split := SplitThinkTags(candidate)
|
||||
if split.Thinking != "" {
|
||||
// Found think tags — commit to buffer and route to reasoning lane
|
||||
rc.streamBuffer = candidate
|
||||
rc.hasThinking = true
|
||||
rc.thinkingBuffer = split.Thinking
|
||||
thinkText := rc.thinkingBuffer
|
||||
currentStream := rc.stream
|
||||
if split.Partial {
|
||||
// Still inside <think> — update reasoning stream, wait for close
|
||||
rc.mu.Unlock()
|
||||
if currentStream != nil {
|
||||
currentStream.Update(ctx, formatReasoningPreview(thinkText))
|
||||
}
|
||||
break
|
||||
}
|
||||
// Tag closed — transition to answer
|
||||
rc.thinkingDone = true
|
||||
rc.streamBuffer = split.Answer
|
||||
reasoningStream := currentStream
|
||||
rc.mu.Unlock()
|
||||
|
||||
// Stop reasoning stream
|
||||
if reasoningStream != nil {
|
||||
_ = reasoningStream.Stop(ctx)
|
||||
}
|
||||
// Create answer stream
|
||||
stream, err := sc.CreateStream(ctx, rc.ChatID, false)
|
||||
if err != nil {
|
||||
slog.Debug("stream restart after think-tag failed", "channel", rc.ChannelName, "error", err)
|
||||
} else {
|
||||
rc.mu.Lock()
|
||||
rc.stream = stream
|
||||
rc.mu.Unlock()
|
||||
}
|
||||
// Update answer stream with extracted answer content
|
||||
if split.Answer != "" {
|
||||
rc.mu.Lock()
|
||||
currentStream = rc.stream
|
||||
rc.mu.Unlock()
|
||||
if currentStream != nil {
|
||||
currentStream.Update(ctx, split.Answer)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
// No think tags found — mark as skipped so we don't re-parse.
|
||||
// Don't commit to streamBuffer here — the normal flow below appends content.
|
||||
rc.tagParseSkipped = true
|
||||
}
|
||||
|
||||
// Reasoning→answer transition: first chunk after native thinking events.
|
||||
// Stop the reasoning stream (keep message visible) and create a
|
||||
// new stream for the answer lane.
|
||||
needTransition := rc.hasThinking && !rc.thinkingDone
|
||||
if needTransition {
|
||||
rc.thinkingDone = true
|
||||
rc.streamBuffer = "" // fresh answer buffer
|
||||
}
|
||||
reasoningStream := rc.stream
|
||||
rc.mu.Unlock()
|
||||
|
||||
// Finalize reasoning stream (stop editing, keep message)
|
||||
if needTransition && reasoningStream != nil {
|
||||
_ = reasoningStream.Stop(ctx)
|
||||
// Don't call FinalizeStream — reasoning messageID should NOT
|
||||
// go into placeholders. Send() must edit the answer message.
|
||||
}
|
||||
|
||||
// Create fresh stream for answer (or new tool iteration)
|
||||
if needNewStream || needTransition {
|
||||
stream, err := sc.CreateStream(ctx, rc.ChatID, false)
|
||||
if err != nil {
|
||||
slog.Debug("stream restart failed", "channel", rc.ChannelName, "error", err)
|
||||
} else {
|
||||
rc.mu.Lock()
|
||||
rc.stream = stream
|
||||
rc.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
rc.mu.Lock()
|
||||
rc.streamBuffer += content
|
||||
fullText := rc.streamBuffer
|
||||
currentStream := rc.stream
|
||||
rc.mu.Unlock()
|
||||
if err := sc.OnChunkEvent(ctx, rc.ChatID, fullText); err != nil {
|
||||
slog.Debug("stream chunk failed", "channel", rc.ChannelName, "error", err)
|
||||
if currentStream != nil {
|
||||
currentStream.Update(ctx, fullText)
|
||||
}
|
||||
}
|
||||
case protocol.AgentEventRunCompleted:
|
||||
rc.mu.Lock()
|
||||
finalText := rc.streamBuffer
|
||||
currentStream := rc.stream
|
||||
rc.stream = nil
|
||||
rc.mu.Unlock()
|
||||
if err := sc.OnStreamEnd(ctx, rc.ChatID, finalText); err != nil {
|
||||
slog.Debug("stream end failed", "channel", rc.ChannelName, "error", err)
|
||||
if currentStream != nil {
|
||||
if err := currentStream.Stop(ctx); err != nil {
|
||||
slog.Debug("stream end failed", "channel", rc.ChannelName, "error", err)
|
||||
}
|
||||
sc.FinalizeStream(ctx, rc.ChatID, currentStream)
|
||||
}
|
||||
case protocol.AgentEventRunFailed:
|
||||
// Clean up streaming state
|
||||
_ = sc.OnStreamEnd(ctx, rc.ChatID, "")
|
||||
// Clean up streaming state on failure
|
||||
rc.mu.Lock()
|
||||
currentStream := rc.stream
|
||||
rc.stream = nil
|
||||
rc.mu.Unlock()
|
||||
if currentStream != nil {
|
||||
_ = currentStream.Stop(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +414,23 @@ func formatToolStatus(toolName string) string {
|
||||
return "🔧 Running " + toolName + "..."
|
||||
}
|
||||
|
||||
// formatReasoningPreview formats accumulated thinking text for display as a
|
||||
// streaming reasoning message. Uses markdown italic prefix so channels that
|
||||
// convert markdown (Telegram, Slack) show "Reasoning:" in italics.
|
||||
// Truncated to 4096 runes (Telegram limit, rune-safe for CJK/emoji).
|
||||
func formatReasoningPreview(thinking string) string {
|
||||
if thinking == "" {
|
||||
return ""
|
||||
}
|
||||
const maxRunes = 4096
|
||||
text := "_Reasoning:_\n" + thinking
|
||||
runes := []rune(text)
|
||||
if len(runes) > maxRunes {
|
||||
text = string(runes[:maxRunes-3]) + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// resolveToolReactionStatus maps a tool name to a reaction status string.
|
||||
// Returns tool-specific statuses ("web", "coding") that activate existing
|
||||
// but previously unused reaction variants in channel implementations.
|
||||
|
||||
@@ -9,6 +9,20 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// ChannelStream is the per-run streaming handle stored on RunContext.
|
||||
// Each channel implementation returns a ChannelStream from CreateStream().
|
||||
// RunContext owns the stream so concurrent runs in the same group chat
|
||||
// each get their own stream — no sync.Map collision on chatID.
|
||||
type ChannelStream interface {
|
||||
// Update sends or edits the streaming message with the latest accumulated text.
|
||||
Update(ctx context.Context, text string)
|
||||
// Stop finalizes the stream (final edit/flush). Called on run.completed.
|
||||
Stop(ctx context.Context) error
|
||||
// MessageID returns the platform message ID of the streaming message (0 if none).
|
||||
// Used to hand the message back to Send() via the channel's placeholder map.
|
||||
MessageID() int
|
||||
}
|
||||
|
||||
// RunContext tracks an active agent run for streaming/reaction event forwarding.
|
||||
type RunContext struct {
|
||||
ChannelName string
|
||||
@@ -19,8 +33,13 @@ type RunContext struct {
|
||||
BlockReplyEnabled bool // whether block.reply delivery is enabled for this run (resolved at RegisterRun time)
|
||||
ToolStatusEnabled bool // whether tool name shows in streaming preview during tool execution
|
||||
mu sync.Mutex
|
||||
streamBuffer string // accumulated streaming text (chunks are deltas)
|
||||
inToolPhase bool // true after tool.call, reset on next chunk (new LLM iteration)
|
||||
streamBuffer string // accumulated streaming text (chunks are deltas)
|
||||
inToolPhase bool // true after tool.call, reset on next chunk (new LLM iteration)
|
||||
stream ChannelStream // per-run stream handle (replaces per-chat sync.Map in channel impls)
|
||||
thinkingBuffer string // accumulated thinking/reasoning text
|
||||
hasThinking bool // true if any thinking events received this iteration
|
||||
thinkingDone bool // true after first chunk arrives (reasoning→answer transition complete)
|
||||
tagParseSkipped bool // true after first chunk with no <think> tags (skip re-parsing)
|
||||
}
|
||||
|
||||
// Manager manages all registered channels, handling their lifecycle
|
||||
|
||||
@@ -38,7 +38,6 @@ type Channel struct {
|
||||
placeholders sync.Map // localKey -> placeholderTS
|
||||
dedup sync.Map // channel+ts -> time.Time
|
||||
threadParticip sync.Map // channelID+threadTS -> time.Time (auto-reply without @mention)
|
||||
streams sync.Map // localKey -> *streamState
|
||||
reactions sync.Map // chatID:messageID -> *reactionState
|
||||
pairingDebounce sync.Map // senderID -> time.Time
|
||||
approvedGroups sync.Map // channelID -> true
|
||||
@@ -243,17 +242,6 @@ func (c *Channel) sweepMaps() {
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
c.streams.Range(func(k, v any) bool {
|
||||
st := v.(*streamState)
|
||||
st.mu.Lock()
|
||||
stale := now.Sub(st.lastUpdate) > 10*time.Minute
|
||||
st.mu.Unlock()
|
||||
if stale {
|
||||
c.streams.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// eventLoop processes Socket Mode events.
|
||||
|
||||
@@ -8,12 +8,16 @@ import (
|
||||
"time"
|
||||
|
||||
slackapi "github.com/slack-go/slack"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
)
|
||||
|
||||
const streamThrottleInterval = 1000 * time.Millisecond
|
||||
|
||||
// streamState tracks per-chat streaming state.
|
||||
type streamState struct {
|
||||
// slackStream implements channels.ChannelStream for Slack.
|
||||
// It edits the placeholder "Thinking..." message as chunks arrive.
|
||||
type slackStream struct {
|
||||
api *slackapi.Client
|
||||
channelID string
|
||||
threadTS string
|
||||
msgTS string // placeholder message timestamp
|
||||
@@ -21,6 +25,49 @@ type streamState struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Update edits the placeholder with accumulated text, throttled to avoid rate limits.
|
||||
func (s *slackStream) Update(_ context.Context, fullText string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if time.Since(s.lastUpdate) < streamThrottleInterval {
|
||||
return
|
||||
}
|
||||
|
||||
formatted := markdownToSlackMrkdwn(fullText)
|
||||
if len(formatted) > maxMessageLen {
|
||||
formatted = formatted[:maxMessageLen] + "..."
|
||||
}
|
||||
|
||||
opts := []slackapi.MsgOption{slackapi.MsgOptionText(formatted, false)}
|
||||
_, _, _, err := s.api.UpdateMessage(s.channelID, s.msgTS, opts...)
|
||||
if err != nil {
|
||||
slog.Debug("slack stream chunk update failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.lastUpdate = time.Now()
|
||||
}
|
||||
|
||||
// Stop finalizes the stream. For Slack, Send() handles the final edit via the placeholder map,
|
||||
// so Stop() is a no-op here — FinalizeStream stores the msgTS into c.placeholders.
|
||||
func (s *slackStream) Stop(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MessageID returns 0 — Slack uses string timestamps, not int message IDs.
|
||||
// FinalizeStream handles the Slack-specific placeholder handoff via type assertion.
|
||||
func (s *slackStream) MessageID() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// MsgTS returns the Slack message timestamp (placeholder TS) for FinalizeStream handoff.
|
||||
func (s *slackStream) MsgTS() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.msgTS
|
||||
}
|
||||
|
||||
// StreamEnabled reports whether streaming is active for DMs or groups.
|
||||
func (c *Channel) StreamEnabled(isGroup bool) bool {
|
||||
if isGroup {
|
||||
@@ -29,90 +76,41 @@ func (c *Channel) StreamEnabled(isGroup bool) bool {
|
||||
return c.config.DMStream != nil && *c.config.DMStream
|
||||
}
|
||||
|
||||
// OnStreamStart is called when a new agent run starts.
|
||||
// CreateStream creates a per-run streaming handle for the given chatID.
|
||||
// Implements channels.StreamingChannel.
|
||||
// The placeholder "Thinking..." was already sent in handleMessage.
|
||||
func (c *Channel) OnStreamStart(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnChunkEvent edits the placeholder with accumulated text, throttled to avoid rate limits.
|
||||
func (c *Channel) OnChunkEvent(_ context.Context, chatID string, fullText string) error {
|
||||
stateVal, ok := c.streams.Load(chatID)
|
||||
if !ok {
|
||||
pTS, pOK := c.placeholders.Load(chatID)
|
||||
if !pOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
st := &streamState{
|
||||
func (c *Channel) CreateStream(_ context.Context, chatID string, _ bool) (channels.ChannelStream, error) {
|
||||
pTS, pOK := c.placeholders.Load(chatID)
|
||||
if !pOK {
|
||||
// No placeholder — stream will be a no-op
|
||||
return &slackStream{
|
||||
api: c.api,
|
||||
channelID: extractChannelID(chatID),
|
||||
msgTS: pTS.(string),
|
||||
}
|
||||
if threadTS := extractThreadTS(chatID); threadTS != "" {
|
||||
st.threadTS = threadTS
|
||||
}
|
||||
c.streams.Store(chatID, st)
|
||||
stateVal = st
|
||||
threadTS: extractThreadTS(chatID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
st := stateVal.(*streamState)
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
|
||||
if time.Since(st.lastUpdate) < streamThrottleInterval {
|
||||
return nil
|
||||
}
|
||||
|
||||
formatted := markdownToSlackMrkdwn(fullText)
|
||||
if len(formatted) > maxMessageLen {
|
||||
formatted = formatted[:maxMessageLen] + "..."
|
||||
}
|
||||
|
||||
opts := []slackapi.MsgOption{slackapi.MsgOptionText(formatted, false)}
|
||||
_, _, _, err := c.api.UpdateMessage(st.channelID, st.msgTS, opts...)
|
||||
if err != nil {
|
||||
slog.Debug("slack stream chunk update failed", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
st.lastUpdate = time.Now()
|
||||
return nil
|
||||
return &slackStream{
|
||||
api: c.api,
|
||||
channelID: extractChannelID(chatID),
|
||||
threadTS: extractThreadTS(chatID),
|
||||
msgTS: pTS.(string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OnStreamEnd sends final formatted text.
|
||||
func (c *Channel) OnStreamEnd(_ context.Context, chatID string, finalText string) error {
|
||||
if finalText == "" {
|
||||
c.streams.Delete(chatID)
|
||||
return nil
|
||||
// FinalizeStream stores the stream's placeholder TS back into c.placeholders so that
|
||||
// Send() can edit it with the properly formatted final response.
|
||||
// Implements channels.StreamingChannel.
|
||||
// ReasoningStreamEnabled returns false — Slack lane support is deferred to a separate PR.
|
||||
// Slack streaming uses thread replies which have different UX from Telegram in-place edit.
|
||||
func (c *Channel) ReasoningStreamEnabled() bool { return false }
|
||||
|
||||
func (c *Channel) FinalizeStream(_ context.Context, chatID string, stream channels.ChannelStream) {
|
||||
ss, ok := stream.(*slackStream)
|
||||
if !ok || ss.msgTS == "" {
|
||||
return
|
||||
}
|
||||
|
||||
channelID := extractChannelID(chatID)
|
||||
var msgTS, threadTS string
|
||||
|
||||
if stateVal, ok := c.streams.Load(chatID); ok {
|
||||
st := stateVal.(*streamState)
|
||||
msgTS = st.msgTS
|
||||
threadTS = st.threadTS
|
||||
} else if pTS, ok := c.placeholders.Load(chatID); ok {
|
||||
msgTS = pTS.(string)
|
||||
}
|
||||
|
||||
defer c.streams.Delete(chatID)
|
||||
|
||||
if msgTS == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
formatted := markdownToSlackMrkdwn(finalText)
|
||||
if len(formatted) <= maxMessageLen {
|
||||
_, _, _, err := c.api.UpdateMessage(channelID, msgTS, slackapi.MsgOptionText(formatted, false))
|
||||
return err
|
||||
}
|
||||
|
||||
// Final text exceeds limit -- update placeholder with first chunk, send rest as follow-ups
|
||||
first, rest := splitAtLimit(formatted, maxMessageLen)
|
||||
_, _, _, _ = c.api.UpdateMessage(channelID, msgTS, slackapi.MsgOptionText(first, false))
|
||||
return c.sendChunked(channelID, rest, threadTS)
|
||||
c.placeholders.Store(chatID, ss.msgTS)
|
||||
}
|
||||
|
||||
// extractChannelID gets the channel ID from a local_key.
|
||||
|
||||
@@ -29,7 +29,6 @@ type Channel struct {
|
||||
placeholders sync.Map // localKey string → messageID int
|
||||
stopThinking sync.Map // localKey string → *thinkingCancel
|
||||
typingCtrls sync.Map // localKey string → *typing.Controller
|
||||
streams sync.Map // localKey string → *DraftStream (streaming preview)
|
||||
reactions sync.Map // localKey string → *StatusReactionController
|
||||
pairingReplySent sync.Map // userID string → time.Time (debounce pairing replies)
|
||||
threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing)
|
||||
@@ -193,13 +192,9 @@ func (c *Channel) Start(ctx context.Context) error {
|
||||
// StreamEnabled reports whether streaming is active for the given chat type.
|
||||
// Controlled by separate dm_stream / group_stream config flags (both default false).
|
||||
//
|
||||
// DM streaming: edits "Thinking..." placeholder progressively via editMessageText.
|
||||
// DM streaming: uses sendMessageDraft (stealth preview) by default, falls back to
|
||||
// sendMessage+editMessageText if draft API is unavailable. Controlled by draft_transport config.
|
||||
// Group streaming: sends a new message, edits progressively, hands off to Send().
|
||||
//
|
||||
// Both are disabled by default. sendMessageDraft (draft transport) code exists in the
|
||||
// codebase but is not used pending Telegram client-side fixes:
|
||||
// - tdesktop#10315, bugs.telegram.org/c/561 — "reply to deleted message" artifacts
|
||||
// - openclaw/openclaw#7803, openclaw/openclaw#32180 — community tracking
|
||||
func (c *Channel) StreamEnabled(isGroup bool) bool {
|
||||
if isGroup {
|
||||
return c.config.GroupStream != nil && *c.config.GroupStream
|
||||
@@ -207,6 +202,25 @@ func (c *Channel) StreamEnabled(isGroup bool) bool {
|
||||
return c.config.DMStream != nil && *c.config.DMStream
|
||||
}
|
||||
|
||||
// draftTransportEnabled returns whether sendMessageDraft should be used for DM streaming.
|
||||
// Default: true (enabled). Uses stealth preview with no per-edit notifications.
|
||||
// Note: may cause "reply to deleted message" artifacts on some Telegram clients (tdesktop#10315).
|
||||
func (c *Channel) draftTransportEnabled() bool {
|
||||
if c.config.DraftTransport == nil {
|
||||
return true
|
||||
}
|
||||
return *c.config.DraftTransport
|
||||
}
|
||||
|
||||
// ReasoningStreamEnabled returns whether reasoning should be shown as a separate message.
|
||||
// Default: true. Set "reasoning_stream": false to hide reasoning (only show answer).
|
||||
func (c *Channel) ReasoningStreamEnabled() bool {
|
||||
if c.config.ReasoningStream == nil {
|
||||
return true
|
||||
}
|
||||
return *c.config.ReasoningStream
|
||||
}
|
||||
|
||||
// BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default).
|
||||
func (c *Channel) BlockReplyEnabled() *bool { return c.config.BlockReply }
|
||||
|
||||
|
||||
@@ -19,20 +19,22 @@ type telegramCreds struct {
|
||||
|
||||
// telegramInstanceConfig maps the non-secret config JSONB from the channel_instances table.
|
||||
type telegramInstanceConfig struct {
|
||||
APIServer string `json:"api_server,omitempty"`
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
DMPolicy string `json:"dm_policy,omitempty"`
|
||||
GroupPolicy string `json:"group_policy,omitempty"`
|
||||
RequireMention *bool `json:"require_mention,omitempty"`
|
||||
HistoryLimit int `json:"history_limit,omitempty"`
|
||||
DMStream *bool `json:"dm_stream,omitempty"`
|
||||
GroupStream *bool `json:"group_stream,omitempty"`
|
||||
ReactionLevel string `json:"reaction_level,omitempty"`
|
||||
MediaMaxMB int64 `json:"media_max_mb,omitempty"`
|
||||
MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // deprecated: use media_max_mb
|
||||
LinkPreview *bool `json:"link_preview,omitempty"`
|
||||
BlockReply *bool `json:"block_reply,omitempty"`
|
||||
AllowFrom []string `json:"allow_from,omitempty"`
|
||||
APIServer string `json:"api_server,omitempty"`
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
DMPolicy string `json:"dm_policy,omitempty"`
|
||||
GroupPolicy string `json:"group_policy,omitempty"`
|
||||
RequireMention *bool `json:"require_mention,omitempty"`
|
||||
HistoryLimit int `json:"history_limit,omitempty"`
|
||||
DMStream *bool `json:"dm_stream,omitempty"`
|
||||
GroupStream *bool `json:"group_stream,omitempty"`
|
||||
DraftTransport *bool `json:"draft_transport,omitempty"` // sendMessageDraft for DM streaming (default true)
|
||||
ReasoningStream *bool `json:"reasoning_stream,omitempty"` // show reasoning as separate message (default true)
|
||||
ReactionLevel string `json:"reaction_level,omitempty"`
|
||||
MediaMaxMB int64 `json:"media_max_mb,omitempty"`
|
||||
MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // deprecated: use media_max_mb
|
||||
LinkPreview *bool `json:"link_preview,omitempty"`
|
||||
BlockReply *bool `json:"block_reply,omitempty"`
|
||||
AllowFrom []string `json:"allow_from,omitempty"`
|
||||
}
|
||||
|
||||
// Factory creates a Telegram channel from DB instance data (no extra stores).
|
||||
@@ -89,9 +91,11 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage,
|
||||
GroupPolicy: ic.GroupPolicy,
|
||||
RequireMention: ic.RequireMention,
|
||||
HistoryLimit: ic.HistoryLimit,
|
||||
DMStream: ic.DMStream,
|
||||
GroupStream: ic.GroupStream,
|
||||
ReactionLevel: ic.ReactionLevel,
|
||||
DMStream: ic.DMStream,
|
||||
GroupStream: ic.GroupStream,
|
||||
DraftTransport: ic.DraftTransport,
|
||||
ReasoningStream: ic.ReasoningStream,
|
||||
ReactionLevel: ic.ReactionLevel,
|
||||
MediaMaxBytes: resolveMediaMaxBytes(ic),
|
||||
LinkPreview: ic.LinkPreview,
|
||||
BlockReply: ic.BlockReply,
|
||||
|
||||
@@ -410,20 +410,10 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
|
||||
_, thinkCancel := context.WithCancel(ctx)
|
||||
c.stopThinking.Store(localKey, &thinkingCancel{fn: thinkCancel})
|
||||
|
||||
// Send "Thinking..." placeholder for DMs.
|
||||
// The streaming system will edit this message progressively (editMessageText),
|
||||
// giving a smooth transition: "Thinking..." → streaming chunks → final formatted response.
|
||||
// Groups: no placeholder; response replies to the sender's message.
|
||||
if !isGroup {
|
||||
thinkMsg := tu.Message(chatIDObj, "Thinking...")
|
||||
if dmThreadID > 0 {
|
||||
thinkMsg.MessageThreadID = dmThreadID
|
||||
}
|
||||
pMsg, err := c.bot.SendMessage(ctx, thinkMsg)
|
||||
if err == nil {
|
||||
c.placeholders.Store(localKey, pMsg.MessageID)
|
||||
}
|
||||
}
|
||||
// No "Thinking..." placeholder — the DraftStream creates its own message
|
||||
// on the first streaming chunk (sendMessage on first flush).
|
||||
// This avoids "reply to deleted message" artifacts and is cleaner UX:
|
||||
// user sees typing indicator → first content appears directly.
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_id": fmt.Sprintf("%d", message.MessageID),
|
||||
|
||||
@@ -164,30 +164,29 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
|
||||
// Text-only message
|
||||
htmlContent := markdownToTelegramHTML(msg.Content)
|
||||
chunks := chunkHTML(htmlContent, telegramMaxMessageLen)
|
||||
|
||||
// Try to edit the placeholder message (either "Thinking..." or a DraftStream message).
|
||||
// If edit succeeds, we're done. If content is too long or edit fails, delete the
|
||||
// placeholder and fall through to send new chunked messages.
|
||||
// If a stream message exists (stored by FinalizeStream), edit the first chunk
|
||||
// into it instead of deleting. This prevents the message from vanishing
|
||||
// when HTML conversion makes content exceed the size limit.
|
||||
startChunk := 0
|
||||
if pID, ok := c.placeholders.Load(localKey); ok {
|
||||
c.placeholders.Delete(localKey)
|
||||
if len(htmlContent) <= telegramMaxMessageLen {
|
||||
if err := c.editMessage(ctx, chatID, pID.(int), htmlContent); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := c.editMessage(ctx, chatID, pID.(int), chunks[0]); err == nil {
|
||||
startChunk = 1 // first chunk edited into stream message
|
||||
} else {
|
||||
// Edit failed (message deleted externally, etc.) — delete and send all fresh
|
||||
_ = c.deleteMessage(ctx, chatID, pID.(int))
|
||||
}
|
||||
// Delete the placeholder since we'll send new message(s) instead
|
||||
_ = c.deleteMessage(ctx, chatID, pID.(int))
|
||||
}
|
||||
|
||||
// Chunk long messages to respect Telegram's limit.
|
||||
// TS ref: only reply to the first chunk (src/channels/plugins/outbound/telegram.ts).
|
||||
chunks := chunkHTML(htmlContent, telegramMaxMessageLen)
|
||||
for i, chunk := range chunks {
|
||||
// Send remaining chunks (or all chunks if no stream message was edited).
|
||||
for i := startChunk; i < len(chunks); i++ {
|
||||
replyTo := 0
|
||||
if i == 0 {
|
||||
replyTo = replyToMsgID // only first chunk replies to user's message
|
||||
}
|
||||
if err := c.sendHTML(ctx, chatID, chunk, replyTo, threadID); err != nil {
|
||||
if err := c.sendHTML(ctx, chatID, chunks[i], replyTo, threadID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -279,7 +278,10 @@ func (c *Channel) sendHTML(ctx context.Context, chatID int64, html string, reply
|
||||
tgMsg.MessageThreadID = sendThreadID
|
||||
}
|
||||
if replyTo > 0 {
|
||||
tgMsg.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo}
|
||||
tgMsg.ReplyParameters = &telego.ReplyParameters{
|
||||
MessageID: replyTo,
|
||||
AllowSendingWithoutReply: true, // don't fail if replied-to message was deleted
|
||||
}
|
||||
}
|
||||
|
||||
err := retrySend(ctx, "sendMessage", nil, func() error {
|
||||
@@ -321,7 +323,7 @@ func (c *Channel) sendPhoto(ctx context.Context, chatID telego.ChatID, filePath,
|
||||
params.MessageThreadID = sendThreadID
|
||||
}
|
||||
if replyTo > 0 {
|
||||
params.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo}
|
||||
params.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo, AllowSendingWithoutReply: true}
|
||||
}
|
||||
|
||||
err = retrySend(ctx, "sendPhoto", func() { file.Seek(0, 0) }, func() error {
|
||||
@@ -364,7 +366,7 @@ func (c *Channel) sendVideo(ctx context.Context, chatID telego.ChatID, filePath,
|
||||
params.MessageThreadID = sendThreadID
|
||||
}
|
||||
if replyTo > 0 {
|
||||
params.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo}
|
||||
params.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo, AllowSendingWithoutReply: true}
|
||||
}
|
||||
|
||||
err = retrySend(ctx, "sendVideo", func() { file.Seek(0, 0) }, func() error {
|
||||
@@ -407,7 +409,7 @@ func (c *Channel) sendAudio(ctx context.Context, chatID telego.ChatID, filePath,
|
||||
params.MessageThreadID = sendThreadID
|
||||
}
|
||||
if replyTo > 0 {
|
||||
params.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo}
|
||||
params.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo, AllowSendingWithoutReply: true}
|
||||
}
|
||||
|
||||
err = retrySend(ctx, "sendAudio", func() { file.Seek(0, 0) }, func() error {
|
||||
@@ -450,7 +452,7 @@ func (c *Channel) sendDocument(ctx context.Context, chatID telego.ChatID, filePa
|
||||
params.MessageThreadID = sendThreadID
|
||||
}
|
||||
if replyTo > 0 {
|
||||
params.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo}
|
||||
params.ReplyParameters = &telego.ReplyParameters{MessageID: replyTo, AllowSendingWithoutReply: true}
|
||||
}
|
||||
|
||||
err = retrySend(ctx, "sendDocument", func() { file.Seek(0, 0) }, func() error {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -89,10 +91,10 @@ type DraftStream struct {
|
||||
draftFailed bool // true = draft API rejected permanently, using message transport
|
||||
}
|
||||
|
||||
// newDraftStream creates a new streaming preview manager.
|
||||
// NewDraftStream creates a new streaming preview manager.
|
||||
// When useDraft is true, the stream will attempt to use sendMessageDraft (Bot API 9.3+)
|
||||
// and automatically fall back to sendMessage+editMessageText if the API rejects it.
|
||||
func newDraftStream(bot *telego.Bot, chatID int64, throttleMs int, messageThreadID int, useDraft bool) *DraftStream {
|
||||
func NewDraftStream(bot *telego.Bot, chatID int64, throttleMs int, messageThreadID int, useDraft bool) *DraftStream {
|
||||
throttle := defaultStreamThrottle
|
||||
if throttleMs > 0 {
|
||||
throttle = time.Duration(throttleMs) * time.Millisecond
|
||||
@@ -269,8 +271,8 @@ func (ds *DraftStream) UsedDraftTransport() bool {
|
||||
|
||||
// --- StreamingChannel implementation ---
|
||||
|
||||
// OnStreamStart prepares for streaming.
|
||||
// chatID here is the localKey (composite key with :topic:N suffix for forum topics).
|
||||
// CreateStream prepares a per-run streaming handle for the given chatID (localKey).
|
||||
// Implements channels.StreamingChannel.
|
||||
//
|
||||
// For DMs: seeds the stream with the "Thinking..." placeholder messageID so that
|
||||
// flush() uses editMessageText to update it progressively. This gives a smooth
|
||||
@@ -278,10 +280,10 @@ func (ds *DraftStream) UsedDraftTransport() bool {
|
||||
//
|
||||
// For groups: deletes the placeholder and lets the stream create its own message,
|
||||
// since group placeholders drift away as other messages arrive.
|
||||
func (c *Channel) OnStreamStart(ctx context.Context, chatID string) error {
|
||||
func (c *Channel) CreateStream(ctx context.Context, chatID string, firstStream bool) (channels.ChannelStream, error) {
|
||||
id, err := parseRawChatID(chatID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Look up thread ID stored during handleMessage
|
||||
@@ -292,68 +294,25 @@ func (c *Channel) OnStreamStart(ctx context.Context, chatID string) error {
|
||||
|
||||
isDM := id > 0
|
||||
|
||||
// Both DMs and groups use message transport (editMessageText).
|
||||
// sendMessageDraft (draft transport) is available in the codebase but disabled
|
||||
// because it causes "reply to deleted message" artifacts in Telegram clients.
|
||||
ds := newDraftStream(c.bot, id, 0, threadID, false)
|
||||
// Draft transport only for non-first streams (answer lane) in DMs.
|
||||
// First stream must use message transport because it may become the
|
||||
// reasoning lane — draft messages are ephemeral and would disappear
|
||||
// when the answer stream starts.
|
||||
useDraft := isDM && !firstStream && c.draftTransportEnabled()
|
||||
ds := NewDraftStream(c.bot, id, 0, threadID, useDraft)
|
||||
|
||||
if isDM {
|
||||
// DMs: seed the stream with the "Thinking..." placeholder messageID.
|
||||
// flush() will use editMessageText to update it progressively.
|
||||
if pID, ok := c.placeholders.Load(chatID); ok {
|
||||
c.placeholders.Delete(chatID)
|
||||
ds.messageID = pID.(int)
|
||||
slog.Info("stream: DM using placeholder for progressive edit", "chat_id", id, "message_id", pID.(int))
|
||||
} else {
|
||||
slog.Info("stream: DM starting stream (no placeholder found)", "chat_id", id)
|
||||
}
|
||||
} else {
|
||||
// Groups: delete placeholder — the stream creates its own message.
|
||||
if pID, ok := c.placeholders.Load(chatID); ok {
|
||||
c.placeholders.Delete(chatID)
|
||||
_ = c.deleteMessage(ctx, id, pID.(int))
|
||||
}
|
||||
slog.Info("stream: group using message transport", "chat_id", id)
|
||||
}
|
||||
// No placeholder seeding — DraftStream creates its own message on first flush().
|
||||
// This avoids "reply to deleted/non-existent message" artifacts.
|
||||
|
||||
c.streams.Store(chatID, ds)
|
||||
|
||||
return nil
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
// OnChunkEvent updates the streaming message with accumulated content.
|
||||
func (c *Channel) OnChunkEvent(ctx context.Context, chatID string, fullText string) error {
|
||||
|
||||
val, ok := c.streams.Load(chatID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
ds := val.(*DraftStream)
|
||||
ds.Update(ctx, fullText)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnStreamEnd finalizes the streaming preview.
|
||||
// Hands the stream's messageID back to the placeholders map so that Send()
|
||||
// FinalizeStream hands the stream's messageID back to the placeholders map so that Send()
|
||||
// can edit it with the properly formatted final response.
|
||||
func (c *Channel) OnStreamEnd(ctx context.Context, chatID string, _ string) error {
|
||||
val, ok := c.streams.Load(chatID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
ds := val.(*DraftStream)
|
||||
|
||||
// Mark stream as stopped (no more edits)
|
||||
ds.mu.Lock()
|
||||
ds.stopped = true
|
||||
msgID := ds.messageID
|
||||
ds.mu.Unlock()
|
||||
|
||||
c.streams.Delete(chatID)
|
||||
|
||||
if msgID != 0 {
|
||||
// Also stops any thinking animation for the chat.
|
||||
// Implements channels.StreamingChannel.
|
||||
func (c *Channel) FinalizeStream(ctx context.Context, chatID string, stream channels.ChannelStream) {
|
||||
if msgID := stream.MessageID(); msgID != 0 {
|
||||
// Hand off the stream message to Send() for final formatted edit.
|
||||
c.placeholders.Store(chatID, msgID)
|
||||
slog.Info("stream: ended, handing off to Send()", "chat_id", chatID, "message_id", msgID)
|
||||
@@ -366,6 +325,4 @@ func (c *Channel) OnStreamEnd(ctx context.Context, chatID string, _ string) erro
|
||||
}
|
||||
c.stopThinking.Delete(chatID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// thinkOpenRe matches opening think tags emitted by various models.
|
||||
// Covers: <think>, <thinking>, <thought>, <antThinking>, plus optional attributes.
|
||||
var thinkOpenRe = regexp.MustCompile(`(?i)<\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>`)
|
||||
|
||||
// thinkCloseRe matches closing think tags.
|
||||
var thinkCloseRe = regexp.MustCompile(`(?i)</\s*(?:think(?:ing)?|thought|antthinking)\s*>`)
|
||||
|
||||
// ThinkTagSplit holds the result of splitting think-tagged content.
|
||||
type ThinkTagSplit struct {
|
||||
Thinking string // content inside <think> tags (empty if no tags found)
|
||||
Answer string // content outside <think> tags
|
||||
Partial bool // true if an unclosed <think> tag is present (buffer for more)
|
||||
}
|
||||
|
||||
// SplitThinkTags extracts thinking content from <think>...</think> tags.
|
||||
// Returns empty Thinking if no tags are found. Handles multiple tag pairs
|
||||
// and accumulates all thinking/answer segments.
|
||||
func SplitThinkTags(text string) ThinkTagSplit {
|
||||
lower := strings.ToLower(text)
|
||||
// Fast path: no think-like tags at all
|
||||
if !strings.Contains(lower, "<think") &&
|
||||
!strings.Contains(lower, "<thought") &&
|
||||
!strings.Contains(lower, "<antthinking") {
|
||||
return ThinkTagSplit{Answer: text}
|
||||
}
|
||||
|
||||
var thinking, answer strings.Builder
|
||||
remaining := text
|
||||
|
||||
for remaining != "" {
|
||||
// Find opening tag
|
||||
openLoc := thinkOpenRe.FindStringIndex(remaining)
|
||||
if openLoc == nil {
|
||||
// No more opening tags — rest is answer
|
||||
answer.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
|
||||
// Content before opening tag is answer
|
||||
if openLoc[0] > 0 {
|
||||
answer.WriteString(remaining[:openLoc[0]])
|
||||
}
|
||||
|
||||
// Find closing tag after the opening tag
|
||||
afterOpen := remaining[openLoc[1]:]
|
||||
closeLoc := thinkCloseRe.FindStringIndex(afterOpen)
|
||||
if closeLoc == nil {
|
||||
// Unclosed tag — content is still arriving (streaming)
|
||||
thinking.WriteString(afterOpen)
|
||||
return ThinkTagSplit{
|
||||
Thinking: thinking.String(),
|
||||
Answer: answer.String(),
|
||||
Partial: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Content between open and close tags is thinking
|
||||
thinking.WriteString(afterOpen[:closeLoc[0]])
|
||||
remaining = afterOpen[closeLoc[1]:]
|
||||
}
|
||||
|
||||
return ThinkTagSplit{
|
||||
Thinking: thinking.String(),
|
||||
Answer: answer.String(),
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,11 @@ type TelegramConfig struct {
|
||||
GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled"
|
||||
RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true)
|
||||
HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 50, 0=disabled)
|
||||
DMStream *bool `json:"dm_stream,omitempty"` // enable streaming for DMs (default false) — edits placeholder progressively; disabled pending Telegram draft API fixes (tdesktop#10315)
|
||||
GroupStream *bool `json:"group_stream,omitempty"` // enable streaming for groups (default false) — sends new message, edits progressively
|
||||
ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — status emoji reactions
|
||||
DMStream *bool `json:"dm_stream,omitempty"` // enable streaming for DMs (default false) — edits placeholder progressively
|
||||
GroupStream *bool `json:"group_stream,omitempty"` // enable streaming for groups (default false) — sends new message, edits progressively
|
||||
DraftTransport *bool `json:"draft_transport,omitempty"` // use sendMessageDraft for DM streaming (default true) — stealth preview, no notifications per edit
|
||||
ReasoningStream *bool `json:"reasoning_stream,omitempty"` // show reasoning as separate message when provider emits thinking events (default true)
|
||||
ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — status emoji reactions
|
||||
MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max media download size in bytes (default 20MB)
|
||||
LinkPreview *bool `json:"link_preview,omitempty"` // enable URL previews in messages (default true)
|
||||
BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit)
|
||||
|
||||
@@ -153,6 +153,86 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"token": { "label": "Bot Token" },
|
||||
"bot_token": { "label": "Bot Token" },
|
||||
"app_token": { "label": "App-Level Token", "help": "App-Level Token with connections:write scope (required for Socket Mode)" },
|
||||
"user_token": { "label": "User Token (Optional)", "help": "Optional: User OAuth Token for custom bot identity. Leave empty to use default bot identity." },
|
||||
"app_id": { "label": "App ID" },
|
||||
"app_secret": { "label": "App Secret" },
|
||||
"encrypt_key": { "label": "Encrypt Key", "help": "For webhook mode" },
|
||||
"verification_token": { "label": "Verification Token", "help": "For webhook mode" },
|
||||
"webhook_secret": { "label": "Webhook Secret" },
|
||||
"bridge_url": { "label": "Bridge URL" },
|
||||
"api_server": { "label": "API Server URL", "help": "Custom Telegram Bot API server for large file uploads (up to 2GB). Leave empty for default." },
|
||||
"proxy": { "label": "HTTP Proxy", "help": "Route bot traffic through an HTTP proxy" },
|
||||
"dm_policy": { "label": "DM Policy" },
|
||||
"group_policy": { "label": "Group Policy" },
|
||||
"require_mention": { "label": "Require @mention in groups" },
|
||||
"history_limit": { "label": "Group History Limit", "help": "Max pending group messages for context (0 = disabled)" },
|
||||
"dm_stream": { "label": "DM Streaming", "help": "Stream response progressively in DMs" },
|
||||
"group_stream": { "label": "Group Streaming", "help": "Stream response progressively in groups" },
|
||||
"draft_transport": { "label": "Draft Preview", "help": "Use stealth draft preview for answer stream in DMs — no notification per edit (requires DM Streaming)" },
|
||||
"reasoning_stream": { "label": "Show Reasoning", "help": "Display AI thinking as a separate message before the answer (requires streaming)" },
|
||||
"reaction_level": { "label": "Reaction Level" },
|
||||
"media_max_mb": { "label": "Max Media Size (MB)" },
|
||||
"link_preview": { "label": "Link Preview" },
|
||||
"allow_from": { "label": "Allowed Users" },
|
||||
"block_reply": { "label": "Block Reply", "help": "Deliver intermediate text during tool iterations" },
|
||||
"domain": { "label": "Domain" },
|
||||
"connection_mode": { "label": "Connection Mode", "help": "Lark Global only supports Webhook" },
|
||||
"webhook_port": { "label": "Webhook Port", "help": "0 = share main gateway port (recommended)" },
|
||||
"webhook_path": { "label": "Webhook Path", "help": "Path on main server for Lark events" },
|
||||
"webhook_url": { "label": "Webhook URL" },
|
||||
"topic_session_mode": { "label": "Topic Session Mode", "help": "Use thread root_id for session isolation" },
|
||||
"render_mode": { "label": "Render Mode" },
|
||||
"text_chunk_limit": { "label": "Text Chunk Limit", "help": "Max characters per message" },
|
||||
"group_allow_from": { "label": "Group Allowed Users", "help": "Separate allowlist for group senders" },
|
||||
"native_stream": { "label": "Native Streaming (Agents & AI Apps)", "help": "Use Slack's ChatStreamer API for native streaming. Falls back to edit-in-place if unavailable." },
|
||||
"debounce_delay": { "label": "Debounce Delay (ms)", "help": "Milliseconds to wait before dispatching rapid messages. Set 0 to disable." },
|
||||
"thread_ttl": { "label": "Thread Participation TTL (hours)", "help": "Hours before bot stops auto-replying in threads it participated in. 0 = always require @mention." }
|
||||
},
|
||||
"fieldOptions": {
|
||||
"block_reply": {
|
||||
"inherit": "Inherit from gateway",
|
||||
"true": "Enabled",
|
||||
"false": "Disabled"
|
||||
},
|
||||
"dm_policy": {
|
||||
"pairing": "Pairing (require code)",
|
||||
"open": "Open (accept all)",
|
||||
"allowlist": "Allowlist only",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"group_policy": {
|
||||
"open": "Open (accept all)",
|
||||
"pairing": "Pairing (require approval)",
|
||||
"allowlist": "Allowlist only",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"reaction_level": {
|
||||
"off": "Off",
|
||||
"minimal": "Minimal",
|
||||
"full": "Full"
|
||||
},
|
||||
"domain": {
|
||||
"lark": "Lark (global) — webhook only",
|
||||
"feishu": "Feishu (China)"
|
||||
},
|
||||
"connection_mode": {
|
||||
"webhook": "Webhook",
|
||||
"websocket": "WebSocket (Feishu only)"
|
||||
},
|
||||
"topic_session_mode": {
|
||||
"disabled": "Disabled",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"render_mode": {
|
||||
"auto": "Auto",
|
||||
"raw": "Raw",
|
||||
"card": "Card"
|
||||
}
|
||||
},
|
||||
"groupOverrides": {
|
||||
"title": "Group & Topic Overrides",
|
||||
"hint": "Override channel defaults per group chat or forum topic. Use \"*\" as group ID for wildcard defaults.",
|
||||
@@ -164,6 +244,12 @@
|
||||
"topicLabel": "Topic: {{id}}",
|
||||
"addTopicPlaceholder": "Topic thread ID",
|
||||
"addTopic": "Add Topic",
|
||||
"fieldConfig": {
|
||||
"dm_stream": { "label": "DM Streaming", "help": "Stream response progressively in DMs" },
|
||||
"group_stream": { "label": "Group Streaming", "help": "Stream response progressively in groups" },
|
||||
"draft_transport": { "label": "Draft Preview", "help": "Use stealth draft preview for answer stream in DMs — no notification per edit (requires DM Streaming)" },
|
||||
"reasoning_stream": { "label": "Show Reasoning", "help": "Display AI thinking as a separate message before the answer (requires streaming)" }
|
||||
},
|
||||
"fields": {
|
||||
"groupPolicy": "Group Policy",
|
||||
"requireMention": "Require @mention",
|
||||
|
||||
@@ -282,7 +282,14 @@
|
||||
"channels.allowFrom": "Allow From",
|
||||
"channels.allowFromPlaceholder": "user_id_1, user_id_2, ...",
|
||||
"channels.dmStreaming": "DM Streaming",
|
||||
"channels.dmStreamingHint": "Progressive message updates in DMs",
|
||||
"channels.groupStreaming": "Group Streaming",
|
||||
"channels.groupStreamingHint": "Progressive message updates in groups",
|
||||
"channels.streaming": "Streaming",
|
||||
"channels.draftTransport": "Draft Preview",
|
||||
"channels.draftTransportHint": "Stealth streaming preview in DMs (no notifications per edit)",
|
||||
"channels.reasoningStream": "Show Reasoning",
|
||||
"channels.reasoningStreamHint": "Display AI thinking process as a separate message",
|
||||
"channels.reactionLevel": "Reaction Level",
|
||||
"channels.connectionMode": "Connection Mode"
|
||||
}
|
||||
@@ -153,6 +153,86 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"token": { "label": "Bot Token" },
|
||||
"bot_token": { "label": "Bot Token" },
|
||||
"app_token": { "label": "Token cấp ứng dụng", "help": "Token cấp ứng dụng với scope connections:write (bắt buộc cho Socket Mode)" },
|
||||
"user_token": { "label": "Token người dùng (Tùy chọn)", "help": "Tùy chọn: Token OAuth người dùng cho danh tính bot tùy chỉnh. Để trống để dùng danh tính bot mặc định." },
|
||||
"app_id": { "label": "App ID" },
|
||||
"app_secret": { "label": "App Secret" },
|
||||
"encrypt_key": { "label": "Khóa mã hóa", "help": "Cho chế độ webhook" },
|
||||
"verification_token": { "label": "Token xác minh", "help": "Cho chế độ webhook" },
|
||||
"webhook_secret": { "label": "Webhook Secret" },
|
||||
"bridge_url": { "label": "URL Bridge" },
|
||||
"api_server": { "label": "URL máy chủ API", "help": "Máy chủ API Bot Telegram tùy chỉnh cho tải file lớn (lên đến 2GB). Để trống cho mặc định." },
|
||||
"proxy": { "label": "Proxy HTTP", "help": "Định tuyến lưu lượng bot qua proxy HTTP" },
|
||||
"dm_policy": { "label": "Chính sách DM" },
|
||||
"group_policy": { "label": "Chính sách nhóm" },
|
||||
"require_mention": { "label": "Yêu cầu @đề cập trong nhóm" },
|
||||
"history_limit": { "label": "Giới hạn lịch sử nhóm", "help": "Số tin nhắn nhóm chờ tối đa cho ngữ cảnh (0 = tắt)" },
|
||||
"dm_stream": { "label": "Stream DM", "help": "Truyền phản hồi liên tục trong DM" },
|
||||
"group_stream": { "label": "Stream nhóm", "help": "Truyền phản hồi liên tục trong nhóm" },
|
||||
"draft_transport": { "label": "Xem trước nháp", "help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)" },
|
||||
"reasoning_stream": { "label": "Hiện suy luận", "help": "Hiển thị quá trình suy nghĩ của AI thành tin nhắn riêng trước câu trả lời (cần bật streaming)" },
|
||||
"reaction_level": { "label": "Mức độ reaction" },
|
||||
"media_max_mb": { "label": "Kích thước media tối đa (MB)" },
|
||||
"link_preview": { "label": "Xem trước liên kết" },
|
||||
"allow_from": { "label": "Người dùng được phép" },
|
||||
"block_reply": { "label": "Phản hồi khối", "help": "Gửi văn bản trung gian trong quá trình lặp công cụ" },
|
||||
"domain": { "label": "Tên miền" },
|
||||
"connection_mode": { "label": "Chế độ kết nối", "help": "Lark Global chỉ hỗ trợ Webhook" },
|
||||
"webhook_port": { "label": "Cổng webhook", "help": "0 = chia sẻ cổng gateway chính (khuyến nghị)" },
|
||||
"webhook_path": { "label": "Đường dẫn webhook", "help": "Đường dẫn trên máy chủ chính cho sự kiện Lark" },
|
||||
"webhook_url": { "label": "URL Webhook" },
|
||||
"topic_session_mode": { "label": "Chế độ phiên chủ đề", "help": "Dùng root_id của luồng để cách ly phiên" },
|
||||
"render_mode": { "label": "Chế độ hiển thị" },
|
||||
"text_chunk_limit": { "label": "Giới hạn ký tự", "help": "Số ký tự tối đa mỗi tin nhắn" },
|
||||
"group_allow_from": { "label": "Người dùng nhóm được phép", "help": "Danh sách cho phép riêng cho người gửi nhóm" },
|
||||
"native_stream": { "label": "Stream gốc (Agents & AI Apps)", "help": "Dùng API ChatStreamer của Slack cho stream gốc. Quay về chỉnh sửa tại chỗ nếu không khả dụng." },
|
||||
"debounce_delay": { "label": "Độ trễ debounce (ms)", "help": "Mili giây chờ trước khi gửi tin nhắn nhanh. Đặt 0 để tắt." },
|
||||
"thread_ttl": { "label": "TTL tham gia luồng (giờ)", "help": "Số giờ trước khi bot ngừng tự động trả lời trong các luồng đã tham gia. 0 = luôn yêu cầu @đề cập." }
|
||||
},
|
||||
"fieldOptions": {
|
||||
"block_reply": {
|
||||
"inherit": "Kế thừa từ gateway",
|
||||
"true": "Bật",
|
||||
"false": "Tắt"
|
||||
},
|
||||
"dm_policy": {
|
||||
"pairing": "Ghép nối (yêu cầu mã)",
|
||||
"open": "Mở (chấp nhận tất cả)",
|
||||
"allowlist": "Chỉ danh sách cho phép",
|
||||
"disabled": "Tắt"
|
||||
},
|
||||
"group_policy": {
|
||||
"open": "Mở (chấp nhận tất cả)",
|
||||
"pairing": "Ghép nối (yêu cầu phê duyệt)",
|
||||
"allowlist": "Chỉ danh sách cho phép",
|
||||
"disabled": "Tắt"
|
||||
},
|
||||
"reaction_level": {
|
||||
"off": "Tắt",
|
||||
"minimal": "Tối thiểu",
|
||||
"full": "Đầy đủ"
|
||||
},
|
||||
"domain": {
|
||||
"lark": "Lark (quốc tế) — chỉ webhook",
|
||||
"feishu": "Feishu (Trung Quốc)"
|
||||
},
|
||||
"connection_mode": {
|
||||
"webhook": "Webhook",
|
||||
"websocket": "WebSocket (chỉ Feishu)"
|
||||
},
|
||||
"topic_session_mode": {
|
||||
"disabled": "Tắt",
|
||||
"enabled": "Bật"
|
||||
},
|
||||
"render_mode": {
|
||||
"auto": "Tự động",
|
||||
"raw": "Thô",
|
||||
"card": "Thẻ"
|
||||
}
|
||||
},
|
||||
"groupOverrides": {
|
||||
"title": "Ghi đè nhóm & chủ đề",
|
||||
"hint": "Ghi đè mặc định channel theo cuộc trò chuyện nhóm hoặc chủ đề diễn đàn. Dùng \"*\" làm ID nhóm cho mặc định ký tự đại diện.",
|
||||
@@ -164,6 +244,12 @@
|
||||
"topicLabel": "Chủ đề: {{id}}",
|
||||
"addTopicPlaceholder": "ID luồng chủ đề",
|
||||
"addTopic": "Thêm chủ đề",
|
||||
"fieldConfig": {
|
||||
"dm_stream": { "label": "Stream DM", "help": "Truyền phản hồi liên tục trong DM" },
|
||||
"group_stream": { "label": "Stream Nhóm", "help": "Truyền phản hồi liên tục trong nhóm" },
|
||||
"draft_transport": { "label": "Xem trước nháp", "help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)" },
|
||||
"reasoning_stream": { "label": "Hiện suy luận", "help": "Hiển thị quá trình suy nghĩ của AI thành tin nhắn riêng trước câu trả lời (cần bật streaming)" }
|
||||
},
|
||||
"fields": {
|
||||
"groupPolicy": "Chính sách nhóm",
|
||||
"requireMention": "Yêu cầu @đề cập",
|
||||
|
||||
@@ -282,7 +282,14 @@
|
||||
"channels.allowFrom": "Cho phép từ",
|
||||
"channels.allowFromPlaceholder": "user_id_1, user_id_2, ...",
|
||||
"channels.dmStreaming": "Streaming tin nhắn riêng",
|
||||
"channels.dmStreamingHint": "Cập nhật tin nhắn liên tục trong DM",
|
||||
"channels.groupStreaming": "Streaming nhóm",
|
||||
"channels.groupStreamingHint": "Cập nhật tin nhắn liên tục trong nhóm",
|
||||
"channels.streaming": "Streaming",
|
||||
"channels.draftTransport": "Xem trước nháp",
|
||||
"channels.draftTransportHint": "Xem trước streaming trong DM (không thông báo mỗi lần chỉnh sửa)",
|
||||
"channels.reasoningStream": "Hiện suy luận",
|
||||
"channels.reasoningStreamHint": "Hiển thị quá trình suy nghĩ của AI thành tin nhắn riêng",
|
||||
"channels.reactionLevel": "Mức phản ứng",
|
||||
"channels.connectionMode": "Chế độ kết nối"
|
||||
}
|
||||
@@ -153,6 +153,86 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"token": { "label": "Bot Token" },
|
||||
"bot_token": { "label": "Bot Token" },
|
||||
"app_token": { "label": "应用级Token", "help": "具有 connections:write 权限的应用级Token(Socket Mode 必需)" },
|
||||
"user_token": { "label": "用户Token(可选)", "help": "可选:用于自定义Bot身份的用户OAuth Token。留空使用默认Bot身份。" },
|
||||
"app_id": { "label": "App ID" },
|
||||
"app_secret": { "label": "App Secret" },
|
||||
"encrypt_key": { "label": "加密密钥", "help": "用于Webhook模式" },
|
||||
"verification_token": { "label": "验证Token", "help": "用于Webhook模式" },
|
||||
"webhook_secret": { "label": "Webhook Secret" },
|
||||
"bridge_url": { "label": "Bridge URL" },
|
||||
"api_server": { "label": "API 服务器 URL", "help": "自定义 Telegram Bot API 服务器用于大文件上传(最大 2GB)。留空使用默认服务器。" },
|
||||
"proxy": { "label": "HTTP 代理", "help": "通过 HTTP 代理路由Bot流量" },
|
||||
"dm_policy": { "label": "私聊策略" },
|
||||
"group_policy": { "label": "群组策略" },
|
||||
"require_mention": { "label": "群组中需要 @提及" },
|
||||
"history_limit": { "label": "群组历史限制", "help": "上下文中最大待处理群组消息数(0 = 禁用)" },
|
||||
"dm_stream": { "label": "私聊流式传输", "help": "在私聊中逐步传输回复" },
|
||||
"group_stream": { "label": "群聊流式传输", "help": "在群聊中逐步传输回复" },
|
||||
"draft_transport": { "label": "草稿预览", "help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)" },
|
||||
"reasoning_stream": { "label": "显示推理", "help": "将AI思考过程显示为单独的消息(需启用流式传输)" },
|
||||
"reaction_level": { "label": "表情回应级别" },
|
||||
"media_max_mb": { "label": "最大媒体大小 (MB)" },
|
||||
"link_preview": { "label": "链接预览" },
|
||||
"allow_from": { "label": "允许的用户" },
|
||||
"block_reply": { "label": "分块回复", "help": "在工具迭代期间发送中间文本" },
|
||||
"domain": { "label": "域名" },
|
||||
"connection_mode": { "label": "连接模式", "help": "Lark 国际版仅支持 Webhook" },
|
||||
"webhook_port": { "label": "Webhook 端口", "help": "0 = 共享主网关端口(推荐)" },
|
||||
"webhook_path": { "label": "Webhook 路径", "help": "主服务器上 Lark 事件的路径" },
|
||||
"webhook_url": { "label": "Webhook URL" },
|
||||
"topic_session_mode": { "label": "话题会话模式", "help": "使用线程 root_id 进行会话隔离" },
|
||||
"render_mode": { "label": "渲染模式" },
|
||||
"text_chunk_limit": { "label": "文本分段限制", "help": "每条消息最大字符数" },
|
||||
"group_allow_from": { "label": "群组允许的用户", "help": "群组发送者的单独白名单" },
|
||||
"native_stream": { "label": "原生流式传输 (Agents & AI Apps)", "help": "使用 Slack 的 ChatStreamer API 进行原生流式传输。不可用时回退到原地编辑。" },
|
||||
"debounce_delay": { "label": "防抖延迟 (ms)", "help": "发送快速消息前等待的毫秒数。设为 0 禁用。" },
|
||||
"thread_ttl": { "label": "线程参与 TTL(小时)", "help": "Bot 停止自动回复已参与线程的小时数。0 = 始终需要 @提及。" }
|
||||
},
|
||||
"fieldOptions": {
|
||||
"block_reply": {
|
||||
"inherit": "继承网关设置",
|
||||
"true": "启用",
|
||||
"false": "禁用"
|
||||
},
|
||||
"dm_policy": {
|
||||
"pairing": "配对(需要验证码)",
|
||||
"open": "开放(接受所有)",
|
||||
"allowlist": "仅白名单",
|
||||
"disabled": "禁用"
|
||||
},
|
||||
"group_policy": {
|
||||
"open": "开放(接受所有)",
|
||||
"pairing": "配对(需要审批)",
|
||||
"allowlist": "仅白名单",
|
||||
"disabled": "禁用"
|
||||
},
|
||||
"reaction_level": {
|
||||
"off": "关闭",
|
||||
"minimal": "最小",
|
||||
"full": "全部"
|
||||
},
|
||||
"domain": {
|
||||
"lark": "Lark(国际版)— 仅 Webhook",
|
||||
"feishu": "飞书(中国版)"
|
||||
},
|
||||
"connection_mode": {
|
||||
"webhook": "Webhook",
|
||||
"websocket": "WebSocket(仅飞书)"
|
||||
},
|
||||
"topic_session_mode": {
|
||||
"disabled": "禁用",
|
||||
"enabled": "启用"
|
||||
},
|
||||
"render_mode": {
|
||||
"auto": "自动",
|
||||
"raw": "原始",
|
||||
"card": "卡片"
|
||||
}
|
||||
},
|
||||
"groupOverrides": {
|
||||
"title": "群组和话题覆盖",
|
||||
"hint": "按群组聊天或论坛话题覆盖Channel默认值。使用 「*」 作为群组 ID 表示通配符默认值。",
|
||||
@@ -164,6 +244,12 @@
|
||||
"topicLabel": "话题: {{id}}",
|
||||
"addTopicPlaceholder": "话题线程 ID",
|
||||
"addTopic": "添加话题",
|
||||
"fieldConfig": {
|
||||
"dm_stream": { "label": "私聊流式传输", "help": "在私聊中逐步传输回复" },
|
||||
"group_stream": { "label": "群聊流式传输", "help": "在群聊中逐步传输回复" },
|
||||
"draft_transport": { "label": "草稿预览", "help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)" },
|
||||
"reasoning_stream": { "label": "显示推理", "help": "将AI思考过程显示为单独的消息(需启用流式传输)" }
|
||||
},
|
||||
"fields": {
|
||||
"groupPolicy": "群组策略",
|
||||
"requireMention": "需要 @提及",
|
||||
|
||||
@@ -282,7 +282,14 @@
|
||||
"channels.allowFrom": "允许来自",
|
||||
"channels.allowFromPlaceholder": "user_id_1, user_id_2, ...",
|
||||
"channels.dmStreaming": "私信流式传输",
|
||||
"channels.dmStreamingHint": "私聊中逐步更新消息",
|
||||
"channels.groupStreaming": "群组流式传输",
|
||||
"channels.groupStreamingHint": "群聊中逐步更新消息",
|
||||
"channels.streaming": "流式传输",
|
||||
"channels.draftTransport": "草稿预览",
|
||||
"channels.draftTransportHint": "私聊中静默流式预览(每次编辑无通知)",
|
||||
"channels.reasoningStream": "显示推理",
|
||||
"channels.reasoningStreamHint": "将AI思考过程显示为单独的消息",
|
||||
"channels.reactionLevel": "反应级别",
|
||||
"channels.connectionMode": "连接模式"
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function ChannelConfigTab({ instance, onUpdate }: ChannelConfigTabProps)
|
||||
}, [webhookPath]);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="space-y-6">
|
||||
<ChannelFields
|
||||
fields={fields}
|
||||
values={values}
|
||||
|
||||
@@ -51,6 +51,9 @@ function FieldRenderer({
|
||||
isEdit?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("channels");
|
||||
// i18n: try "fieldConfig.<key>.label" / "fieldConfig.<key>.help", fall back to hardcoded schema string
|
||||
const label = t(`fieldConfig.${field.key}.label`, { defaultValue: field.label });
|
||||
const help = field.help ? t(`fieldConfig.${field.key}.help`, { defaultValue: field.help }) : "";
|
||||
const labelSuffix = field.required && !isEdit ? " *" : "";
|
||||
const editHint = isEdit && field.type === "password" ? ` ${t("form.credentialsHint")}` : "";
|
||||
|
||||
@@ -60,7 +63,7 @@ function FieldRenderer({
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={id}>
|
||||
{field.label}{labelSuffix}{editHint}
|
||||
{label}{labelSuffix}{editHint}
|
||||
</Label>
|
||||
<Input
|
||||
id={id}
|
||||
@@ -69,14 +72,14 @@ function FieldRenderer({
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{field.help && <p className="text-xs text-muted-foreground">{field.help}</p>}
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={id}>{field.label}{labelSuffix}</Label>
|
||||
<Label htmlFor={id}>{label}{labelSuffix}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
@@ -84,7 +87,7 @@ function FieldRenderer({
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : undefined}
|
||||
/>
|
||||
{field.help && <p className="text-xs text-muted-foreground">{field.help}</p>}
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -96,15 +99,15 @@ function FieldRenderer({
|
||||
checked={(value as boolean) ?? (field.defaultValue as boolean) ?? false}
|
||||
onCheckedChange={(v) => onChange(v)}
|
||||
/>
|
||||
<Label htmlFor={id}>{field.label}</Label>
|
||||
{field.help && <span className="text-xs text-muted-foreground ml-1">— {field.help}</span>}
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{help && <span className="text-xs text-muted-foreground ml-1">— {help}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{field.label}{labelSuffix}</Label>
|
||||
<Label>{label}{labelSuffix}</Label>
|
||||
<Select
|
||||
value={(value as string) ?? (field.defaultValue as string) ?? ""}
|
||||
onValueChange={(v) => onChange(v)}
|
||||
@@ -115,19 +118,19 @@ function FieldRenderer({
|
||||
<SelectContent>
|
||||
{field.options?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(`fieldOptions.${field.key}.${opt.value}`, { defaultValue: opt.label })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{field.help && <p className="text-xs text-muted-foreground">{field.help}</p>}
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "tags":
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={id}>{field.label}</Label>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={Array.isArray(value) ? (value as string[]).join("\n") : ""}
|
||||
@@ -139,7 +142,7 @@ function FieldRenderer({
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{field.help && <p className="text-xs text-muted-foreground">{field.help}</p>}
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -74,8 +74,10 @@ export const configSchema: Record<string, FieldDef[]> = {
|
||||
{ key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "pairing" },
|
||||
{ key: "require_mention", label: "Require @mention in groups", type: "boolean", defaultValue: true },
|
||||
{ key: "history_limit", label: "Group History Limit", type: "number", defaultValue: 50, help: "Max pending group messages for context (0 = disabled)" },
|
||||
{ key: "dm_stream", label: "DM Streaming", type: "boolean", defaultValue: false, help: "Edit placeholder progressively as LLM generates" },
|
||||
{ key: "group_stream", label: "Group Streaming", type: "boolean", defaultValue: false, help: "Send & edit message progressively in groups" },
|
||||
{ key: "dm_stream", label: "DM Streaming", type: "boolean", defaultValue: true, help: "Stream response progressively in DMs" },
|
||||
{ key: "group_stream", label: "Group Streaming", type: "boolean", defaultValue: false, help: "Stream response progressively in groups" },
|
||||
{ key: "draft_transport", label: "Draft Preview", type: "boolean", defaultValue: true, help: "Use stealth draft preview for answer stream in DMs — no notification per edit (requires DM Streaming)" },
|
||||
{ key: "reasoning_stream", label: "Show Reasoning", type: "boolean", defaultValue: true, help: "Display AI thinking as a separate message before the answer (requires streaming)" },
|
||||
{ key: "reaction_level", label: "Reaction Level", type: "select", options: [{ value: "off", label: "Off" }, { value: "minimal", label: "Minimal" }, { value: "full", label: "Full" }], defaultValue: "full" },
|
||||
{ key: "media_max_mb", label: "Max Media Size (MB)", type: "number", defaultValue: 20, help: "Default: 20 MB (cloud API). Increase when using local Bot API server." },
|
||||
{ key: "link_preview", label: "Link Preview", type: "boolean", defaultValue: true },
|
||||
@@ -95,7 +97,7 @@ export const configSchema: Record<string, FieldDef[]> = {
|
||||
{ key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "pairing", help: "How to handle messages from channels/groups" },
|
||||
{ key: "require_mention", label: "Require @mention in channels", type: "boolean", defaultValue: true, help: "Bot only responds when explicitly @mentioned in channels (recommended)" },
|
||||
{ key: "history_limit", label: "Group History Limit", type: "number", defaultValue: 50, help: "Max pending group messages for context (0 = disabled)" },
|
||||
{ key: "dm_stream", label: "DM Streaming", type: "boolean", defaultValue: false, help: "Progressively edit placeholder message as LLM generates (DMs)" },
|
||||
{ key: "dm_stream", label: "DM Streaming", type: "boolean", defaultValue: true, help: "Progressively edit placeholder message as LLM generates (DMs)" },
|
||||
{ key: "group_stream", label: "Group Streaming", type: "boolean", defaultValue: false, help: "Progressively edit placeholder message as LLM generates (channels)" },
|
||||
{ key: "native_stream", label: "Native Streaming (Agents & AI Apps)", type: "boolean", defaultValue: false, help: "Use Slack's ChatStreamer API for native streaming. Falls back to edit-in-place if unavailable." },
|
||||
{ key: "debounce_delay", label: "Debounce Delay (ms)", type: "number", defaultValue: 300, help: "Milliseconds to wait before dispatching rapid messages. Set 0 to disable." },
|
||||
|
||||
@@ -191,16 +191,53 @@ export function ChannelsSection({ data, onSave, saving }: Props) {
|
||||
|
||||
{/* Telegram-specific */}
|
||||
{ch === "telegram" && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={!!chData.dm_stream} onChange={(e) => updateChannel(ch, { dm_stream: e.target.checked })} />
|
||||
{t("channels.dmStreaming")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={!!chData.group_stream} onChange={(e) => updateChannel(ch, { group_stream: e.target.checked })} />
|
||||
{t("channels.groupStreaming")}
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t("channels.streaming")}</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-md border px-3 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("channels.dmStreaming")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("channels.dmStreamingHint")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!chData.dm_stream}
|
||||
onCheckedChange={(v) => updateChannel(ch, { dm_stream: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border px-3 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("channels.groupStreaming")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("channels.groupStreamingHint")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!chData.group_stream}
|
||||
onCheckedChange={(v) => updateChannel(ch, { group_stream: v })}
|
||||
/>
|
||||
</div>
|
||||
{!!chData.dm_stream && (
|
||||
<div className="rounded-md border px-3 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("channels.draftTransport")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("channels.draftTransportHint")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={chData.draft_transport !== false}
|
||||
onCheckedChange={(v) => updateChannel(ch, { draft_transport: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(!!chData.dm_stream || !!chData.group_stream) && (
|
||||
<div className="rounded-md border px-3 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("channels.reasoningStream")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("channels.reasoningStreamHint")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={chData.reasoning_stream !== false}
|
||||
onCheckedChange={(v) => updateChannel(ch, { reasoning_stream: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{chData.reaction_level !== undefined && (
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
Reference in new issue
Block a user