From b1f6eadf85b254c7f40459bd2dc58f206b2db4d0 Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 16 Apr 2026 21:30:10 +0700 Subject: [PATCH] feat(channels): TTS auto-apply for Discord/Telegram/WhatsApp + UI enhancements Backend: - Add TTS auto-apply support to Discord, Telegram, WhatsApp channels - Improve text processing for TTS strip markdown - Wire TTS config handler to gateway server UI: - Add voice recorder hook for chat input - Add agent hooks tab with summary card - Update i18n for agents (en/vi/zh) - Minor CSP and credential section fixes --- cmd/gateway.go | 2 +- cmd/gateway_lifecycle.go | 4 +- cmd/gateway_methods.go | 4 +- internal/audio/text_processing.go | 50 +++- internal/channels/discord/discord.go | 39 ++- internal/channels/manager.go | 1 + internal/channels/telegram/send.go | 33 +++ internal/channels/telegram/stream.go | 3 + internal/channels/whatsapp/outbound.go | 40 ++- internal/gateway/methods/chat.go | 37 ++- internal/gateway/server.go | 3 + internal/http/tts_test_connection.go | 2 +- internal/providers/defaults.go | 2 +- ui/web/index.html | 2 +- ui/web/src/components/chat/chat-input.tsx | 109 ++++++-- ui/web/src/hooks/use-voice-recorder.ts | 153 +++++++++++ ui/web/src/i18n/locales/en/agents.json | 22 +- ui/web/src/i18n/locales/en/common.json | 3 + ui/web/src/i18n/locales/vi/agents.json | 23 +- ui/web/src/i18n/locales/vi/common.json | 3 + ui/web/src/i18n/locales/zh/agents.json | 23 +- ui/web/src/i18n/locales/zh/common.json | 3 + .../agents/agent-detail/agent-detail-page.tsx | 16 ++ .../agents/agent-detail/agent-hooks-tab.tsx | 259 ++++++++++++++++++ .../agent-detail/agent-overview-tab.tsx | 11 +- .../overview-sections/hooks-summary-card.tsx | 126 +++++++++ .../tts/sections/credentials-section.tsx | 4 +- 27 files changed, 931 insertions(+), 46 deletions(-) create mode 100644 ui/web/src/hooks/use-voice-recorder.ts create mode 100644 ui/web/src/pages/agents/agent-detail/agent-hooks-tab.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/overview-sections/hooks-summary-card.tsx diff --git a/cmd/gateway.go b/cmd/gateway.go index 4ca3e32c..2eb6e84a 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -378,7 +378,7 @@ func runGateway() { // Register all RPC methods server.SetLogTee(logTee) - pairingMethods, heartbeatMethods, chatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs) + pairingMethods, heartbeatMethods, chatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs, audioMgr) // Phase 3: Agent hooks RPC methods (hooks.list/create/update/delete/toggle/test/history). if hs, ok := pgStores.Hooks.(hooks.HookStore); ok && hs != nil { diff --git a/cmd/gateway_lifecycle.go b/cmd/gateway_lifecycle.go index 75e2ec81..755c8ba2 100644 --- a/cmd/gateway_lifecycle.go +++ b/cmd/gateway_lifecycle.go @@ -94,7 +94,9 @@ func (d *gatewayDeps) runLifecycle( return } if d.pgStores.ConfigSecrets != nil { - if secrets, err := d.pgStores.ConfigSecrets.GetAll(context.Background()); err == nil && len(secrets) > 0 { + // Use master tenant context to load global TTS secrets + masterCtx := store.WithTenantID(context.Background(), store.MasterTenantID) + if secrets, err := d.pgStores.ConfigSecrets.GetAll(masterCtx); err == nil && len(secrets) > 0 { updatedCfg.ApplyDBSecrets(secrets) } } diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 054fd8dd..c59d0c99 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -5,6 +5,7 @@ import ( "log/slog" "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/audio" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" @@ -13,11 +14,12 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/tools" ) -func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods) { +func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore, audioMgr *audio.Manager) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods) { router := server.Router() // Phase 1: Core methods chatMethods := methods.NewChatMethods(agents, sessStore, cfg, server.RateLimiter(), msgBus) + chatMethods.SetAudioManager(audioMgr) // Wire TTS auto-apply for WS responses chatMethods.Register(router) methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router) methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router) diff --git a/internal/audio/text_processing.go b/internal/audio/text_processing.go index 6c214628..a96809d7 100644 --- a/internal/audio/text_processing.go +++ b/internal/audio/text_processing.go @@ -2,25 +2,47 @@ package audio import "regexp" +// Pre-compiled regexes for performance (called per stream chunk). +var ( + mdFencedCodeRe = regexp.MustCompile("(?s)```[^`]*```") + mdInlineCodeRe = regexp.MustCompile("`([^`]+)`") + mdBoldStarRe = regexp.MustCompile(`\*\*([^*]+)\*\*`) + mdItalicStarRe = regexp.MustCompile(`\*([^*]+)\*`) + mdBoldUnderRe = regexp.MustCompile(`__([^_]+)__`) + mdItalicUnderRe = regexp.MustCompile(`_([^_]+)_`) + mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`) + mdHeadingRe = regexp.MustCompile(`(?m)^#+\s+`) + + ttsTextBlockRe = regexp.MustCompile(`(?s)\[\[tts:text\]\](.*?)\[\[/tts:text\]\]`) + ttsVoiceBlockRe = regexp.MustCompile(`(?s)\[\[tts\]\].*?\[\[/tts\]\]`) + ttsBareTagRe = regexp.MustCompile(`\[\[/?tts(?::[^\]]*)?\]\]`) +) + // stripMarkdown removes common markdown formatting so TTS reads prose, not // syntax characters. Preserves inner text of bold/italic/inline code/links. func stripMarkdown(text string) string { - text = regexp.MustCompile("(?s)```[^`]*```").ReplaceAllString(text, "") - text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1") - text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`__([^_]+)__`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`_([^_]+)_`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`(?m)^#+\s+`).ReplaceAllString(text, "") + text = mdFencedCodeRe.ReplaceAllString(text, "") + text = mdInlineCodeRe.ReplaceAllString(text, "$1") + text = mdBoldStarRe.ReplaceAllString(text, "$1") + text = mdItalicStarRe.ReplaceAllString(text, "$1") + text = mdBoldUnderRe.ReplaceAllString(text, "$1") + text = mdItalicUnderRe.ReplaceAllString(text, "$1") + text = mdLinkRe.ReplaceAllString(text, "$1") + text = mdHeadingRe.ReplaceAllString(text, "") return text } -// stripTtsDirectives removes [[tts...]] markup from text. -// `[[tts:text]]...[[/tts:text]]` blocks keep their inner content. -// Bare `[[tts]]` and `[[tts:something]]` tags are removed entirely. -func stripTtsDirectives(text string) string { - text = regexp.MustCompile(`(?s)\[\[tts:text\]\](.*?)\[\[/tts:text\]\]`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`\[\[tts(?::[^\]]*)?\]\]`).ReplaceAllString(text, "") +// StripTTSDirectives removes [[tts...]] markup from text. +// `[[tts:text]]...[[/tts:text]]` blocks keep their inner content (voice + text display). +// `[[tts]]...[[/tts]]` blocks are removed entirely including content (voice only, no text). +// Bare `[[tts:something]]` tags without closing are removed. +// Exported for use by channels TTS auto-apply. +func StripTTSDirectives(text string) string { + // 1. [[tts:text]]...[[/tts:text]] → keep inner content (transcript mode) + text = ttsTextBlockRe.ReplaceAllString(text, "$1") + // 2. [[tts]]...[[/tts]] → remove entirely including content (voice-only mode) + text = ttsVoiceBlockRe.ReplaceAllString(text, "") + // 3. Remove any remaining bare/unclosed tags + text = ttsBareTagRe.ReplaceAllString(text, "") return text } diff --git a/internal/channels/discord/discord.go b/internal/channels/discord/discord.go index 6d7178f2..795a47be 100644 --- a/internal/channels/discord/discord.go +++ b/internal/channels/discord/discord.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "sync" "time" @@ -130,7 +131,7 @@ func (c *Channel) Stop(_ context.Context) error { } // Send delivers an outbound message to a Discord channel. -func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) (err error) { +func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) (err error) { if !c.IsRunning() { return fmt.Errorf("discord bot not running") } @@ -166,6 +167,42 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) (err error) { content := msg.Content + // TTS auto-apply: convert [[tts]] tagged responses to voice + if c.audioMgr != nil && content != "" { + isVoiceInbound := msg.Metadata["is_voice_inbound"] == "true" + ttsResult, ttsErr := c.audioMgr.AutoApplyToText(ctx, content, "discord", isVoiceInbound, "") + if ttsErr != nil { + slog.Debug("discord: tts auto-apply error", "error", ttsErr) + } + if ttsResult != nil && ttsResult.AudioPath != "" { + // Send voice file via media API + if err := c.sendMediaMessage(channelID, "", []bus.MediaAttachment{{ + URL: ttsResult.AudioPath, + ContentType: ttsResult.AudioMime, + }}); err != nil { + slog.Warn("discord: tts auto-apply voice send failed, falling back to text", "error", err) + } else { + // Voice sent successfully + strippedText := strings.TrimSpace(ttsResult.Text) + if strippedText == "" { + // Voice-only: delete placeholder (no text to show) + if pID, ok := c.placeholders.LoadAndDelete(placeholderKey); ok { + if msgID, ok := pID.(string); ok { + _ = c.session.ChannelMessageDelete(channelID, msgID) + } + } + return nil + } + // Has remaining text: let normal flow handle placeholder edit + content = strippedText + } + } + // Update content with directives stripped (even if TTS not applied) + if ttsResult != nil { + content = ttsResult.Text + } + } + // Handle outbound media attachments: send files via Discord's file upload API. if len(msg.Media) > 0 { // Delete placeholder if present diff --git a/internal/channels/manager.go b/internal/channels/manager.go index 1c4c4133..4cc8f654 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -32,6 +32,7 @@ type RunContext struct { ChatID string MessageID string // platform message ID (string to support Feishu "om_xxx", Telegram "12345", etc.) Metadata map[string]string // outbound routing metadata (thread_id, local_key, group_id) + TenantID uuid.UUID // tenant scope for per-tenant TTS Streaming bool // whether run uses streaming (to avoid double-delivery of block replies) 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 diff --git a/internal/channels/telegram/send.go b/internal/channels/telegram/send.go index 76f3a2ca..0c8099d2 100644 --- a/internal/channels/telegram/send.go +++ b/internal/channels/telegram/send.go @@ -252,6 +252,39 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { return err } + // TTS auto-apply: convert [[tts]] tagged responses to voice + if c.audioMgr != nil && msg.Content != "" { + isVoiceInbound := msg.Metadata["is_voice_inbound"] == "true" + ttsResult, ttsErr := c.audioMgr.AutoApplyToText(ctx, msg.Content, "telegram", isVoiceInbound, "") + if ttsErr != nil { + slog.Debug("telegram: tts auto-apply error", "error", ttsErr) + } + if ttsResult != nil && ttsResult.AudioPath != "" { + // Send voice message instead of text + if err := c.sendVoice(ctx, tu.ID(chatID), ttsResult.AudioPath, "", replyToMsgID, threadID); err != nil { + slog.Warn("telegram: tts auto-apply voice send failed, falling back to text", "error", err) + } else { + // Voice sent successfully + strippedText := strings.TrimSpace(ttsResult.Text) + if strippedText == "" { + // Voice-only: delete placeholder (no text to show) + if pID, ok := c.placeholders.LoadAndDelete(localKey); ok { + if msgID, ok := pID.(int); ok && msgID > 0 { + _ = c.deleteMessage(ctx, chatID, msgID) + } + } + return nil + } + // Has remaining text: let normal flow handle placeholder edit + msg.Content = strippedText + } + } + // Update content with directives stripped (even if TTS not applied) + if ttsResult != nil { + msg.Content = ttsResult.Text + } + } + // Text-only message htmlContent := markdownToTelegramHTML(msg.Content) chunks := chunkHTML(htmlContent, telegramMaxMessageLen) diff --git a/internal/channels/telegram/stream.go b/internal/channels/telegram/stream.go index 6070b57d..ffcb6882 100644 --- a/internal/channels/telegram/stream.go +++ b/internal/channels/telegram/stream.go @@ -13,6 +13,7 @@ import ( "github.com/mymmrac/telego" tu "github.com/mymmrac/telego/telegoutil" + "github.com/nextlevelbuilder/goclaw/internal/audio" "github.com/nextlevelbuilder/goclaw/internal/channels" ) @@ -158,6 +159,8 @@ func (ds *DraftStream) flush(ctx context.Context) error { } text := ds.pending + // Strip TTS directives before displaying (they'll be processed by Send() later) + text = audio.StripTTSDirectives(text) htmlText := markdownToTelegramHTML(text) // --- Draft transport (sendMessageDraft) --- diff --git a/internal/channels/whatsapp/outbound.go b/internal/channels/whatsapp/outbound.go index 0f2f79d9..6112bc27 100644 --- a/internal/channels/whatsapp/outbound.go +++ b/internal/channels/whatsapp/outbound.go @@ -16,7 +16,7 @@ import ( ) // Send delivers an outbound message to WhatsApp via whatsmeow. -func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { +func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { if c.client == nil || !c.client.IsConnected() { return fmt.Errorf("whatsapp not connected") } @@ -26,6 +26,44 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { return fmt.Errorf("invalid whatsapp JID %q: %w", msg.ChatID, err) } + // TTS auto-apply: convert [[tts]] tagged responses to voice + if c.audioMgr != nil && msg.Content != "" { + isVoiceInbound := msg.Metadata["is_voice_inbound"] == "true" + ttsResult, ttsErr := c.audioMgr.AutoApplyToText(ctx, msg.Content, "whatsapp", isVoiceInbound, "") + if ttsErr != nil { + slog.Debug("whatsapp: tts auto-apply error", "error", ttsErr) + } + if ttsResult != nil && ttsResult.AudioPath != "" { + // Send audio as voice message + audioData, readErr := os.ReadFile(ttsResult.AudioPath) + if readErr == nil { + waMsg, buildErr := c.buildMediaMessage(audioData, ttsResult.AudioMime, "") + if buildErr == nil { + // Mark as voice message (PTT) for WhatsApp + if waMsg.AudioMessage != nil { + waMsg.AudioMessage.PTT = new(true) + } + if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { + slog.Warn("whatsapp: tts auto-apply voice send failed, falling back to text", "error", sendErr) + } else { + // Voice sent successfully, stop typing and return + if cancel, ok := c.typingCancel.LoadAndDelete(msg.ChatID); ok { + if fn, ok := cancel.(context.CancelFunc); ok { + fn() + } + } + go c.sendPresence(chatJID, types.ChatPresencePaused) + return nil + } + } + } + } + // Update content with directives stripped (even if TTS not applied) + if ttsResult != nil { + msg.Content = ttsResult.Text + } + } + // Send media attachments first. if len(msg.Media) > 0 { for i, m := range msg.Media { diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index 421d055b..40cef583 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -9,6 +9,7 @@ import ( "log/slog" "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/audio" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" @@ -30,12 +31,18 @@ type ChatMethods struct { rateLimiter *gateway.RateLimiter eventBus bus.EventPublisher postTurn tools.PostTurnProcessor + audioMgr *audio.Manager // for TTS auto-apply on WS responses (nil = disabled) } func NewChatMethods(agents *agent.Router, sess store.SessionStore, cfg *config.Config, rl *gateway.RateLimiter, eventBus bus.EventPublisher) *ChatMethods { return &ChatMethods{agents: agents, sessions: sess, cfg: cfg, rateLimiter: rl, eventBus: eventBus} } +// SetAudioManager sets the audio manager for TTS auto-apply on WS responses. +func (m *ChatMethods) SetAudioManager(mgr *audio.Manager) { + m.audioMgr = mgr +} + // SetPostTurnProcessor sets the post-turn processor for team task dispatch. func (m *ChatMethods) SetPostTurnProcessor(pt tools.PostTurnProcessor) { m.postTurn = pt @@ -321,16 +328,40 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re }() } + // TTS auto-apply: convert [[tts]] tagged responses to voice audio + content := result.Content + var ttsAudio *agent.MediaResult + if m.audioMgr != nil && content != "" { + // For WS, we don't have voice inbound info - use "tagged" mode only + ttsResult, _ := m.audioMgr.AutoApplyToText(runCtx, content, "ws", false, "") + if ttsResult != nil && ttsResult.AudioPath != "" { + // Include audio in media results + ttsAudio = &agent.MediaResult{ + Path: httpapi.SignMediaPath(ttsResult.AudioPath, httpapi.FileSigningKey()), + ContentType: ttsResult.AudioMime, + AsVoice: true, + } + content = ttsResult.Text // Use stripped text + } else if ttsResult != nil { + content = ttsResult.Text // Strip directives even if TTS not applied + } + } + resp := map[string]any{ "runId": result.RunID, - "content": result.Content, + "content": content, "usage": result.Usage, } if result.Thinking != "" { resp["thinking"] = result.Thinking } - if len(result.Media) > 0 { - resp["media"] = result.Media + // Combine existing media with TTS audio + mediaResults := result.Media + if ttsAudio != nil { + mediaResults = append([]agent.MediaResult{*ttsAudio}, mediaResults...) + } + if len(mediaResults) > 0 { + resp["media"] = mediaResults } client.SendResponse(protocol.NewOKResponse(req.ID, resp)) }() diff --git a/internal/gateway/server.go b/internal/gateway/server.go index de444d6e..cbfb79fc 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -515,6 +515,9 @@ func (s *Server) SetVoicesHandler(h *httpapi.VoicesHandler) { s.handlers = appen // SetTTSHandler sets the TTS synthesize handler. func (s *Server) SetTTSHandler(h *httpapi.TTSHandler) { s.handlers = append(s.handlers, h) } +// SetTTSConfigHandler sets the per-tenant TTS config handler. +func (s *Server) SetTTSConfigHandler(h *httpapi.TTSConfigHandler) { s.handlers = append(s.handlers, h) } + // SetVaultHandler sets the Knowledge Vault document handler. func (s *Server) SetVaultHandler(h *httpapi.VaultHandler) { s.handlers = append(s.handlers, h) } diff --git a/internal/http/tts_test_connection.go b/internal/http/tts_test_connection.go index 0cbee36d..ea24ee09 100644 --- a/internal/http/tts_test_connection.go +++ b/internal/http/tts_test_connection.go @@ -92,7 +92,7 @@ func (h *TTSHandler) handleTestConnection(w http.ResponseWriter, r *http.Request return } - if providersRequiringAPIKey[req.Provider] && req.APIKey == "" { + if providersRequiringAPIKey[req.Provider] && (req.APIKey == "" || req.APIKey == "***") { http.Error(w, fmt.Sprintf(`{"error":"api_key is required for %s"}`, req.Provider), http.StatusBadRequest) return } diff --git a/internal/providers/defaults.go b/internal/providers/defaults.go index 5d8ba5a6..29c8e616 100644 --- a/internal/providers/defaults.go +++ b/internal/providers/defaults.go @@ -27,7 +27,7 @@ const ( func NewDefaultTransport() *http.Transport { return &http.Transport{ Proxy: http.ProxyFromEnvironment, - ResponseHeaderTimeout: 60 * time.Second, // wait for first byte of response + ResponseHeaderTimeout: 180 * time.Second, // wait for first byte of response (3min for slow providers) IdleConnTimeout: 90 * time.Second, // close idle keep-alive connections TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, diff --git a/ui/web/index.html b/ui/web/index.html index 2b0e5358..624556e4 100644 --- a/ui/web/index.html +++ b/ui/web/index.html @@ -19,7 +19,7 @@ if the gateway is hosted on a different domain in production, add the explicit origin here rather than wildcard-allowing all http(s):. --> - + diff --git a/ui/web/src/components/chat/chat-input.tsx b/ui/web/src/components/chat/chat-input.tsx index 29a0b777..74780045 100644 --- a/ui/web/src/components/chat/chat-input.tsx +++ b/ui/web/src/components/chat/chat-input.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useCallback, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; -import { Send, Square, Paperclip, X } from "lucide-react"; +import { Send, Square, Paperclip, X, Mic } from "lucide-react"; +import { useVoiceRecorder } from "@/hooks/use-voice-recorder"; export interface AttachedFile { file: File; @@ -23,6 +24,29 @@ export function ChatInput({ onSend, onAbort, isBusy, disabled, files, onFilesCha const [value, setValue] = useState(""); const textareaRef = useRef(null); const fileInputRef = useRef(null); + const voiceRecorder = useVoiceRecorder(); + + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + const handleVoiceToggle = useCallback(async () => { + if (voiceRecorder.isRecording) { + const blob = await voiceRecorder.stopRecording(); + if (blob) { + const file = new File([blob], `voice-${Date.now()}.webm`, { type: blob.type }); + onFilesChange([...files, { file }]); + } + } else { + await voiceRecorder.startRecording(); + } + }, [voiceRecorder, files, onFilesChange]); + + const handleCancelRecording = useCallback(() => { + voiceRecorder.cancelRecording(); + }, [voiceRecorder]); const handleSend = useCallback(() => { if ((!value.trim() && files.length === 0) || disabled) return; @@ -109,29 +133,82 @@ export function ChatInput({ onSend, onAbort, isBusy, disabled, files, onFilesCha - {/* Textarea — no border, transparent bg */} -