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
This commit is contained in:
viettranx committed 2026-04-16 21:30:10 +07:00
1 parent 284c9cb2cc
commit b1f6eadf85
27 files changed
+931 -46

No files matched your search

+1 -1
View File
@@ -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 {
+3 -1
View File
@@ -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)
}
}
+3 -1
View File
@@ -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)
+36 -14
View File
@@ -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
}
+38 -1
View File
@@ -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
+1
View File
@@ -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
+33
View File
@@ -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)
+3
View File
@@ -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) ---
+39 -1
View File
@@ -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 {
+34 -3
View File
@@ -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))
}()
+3
View File
@@ -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) }
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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):.
-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self';" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss:; base-uri 'self';" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
+93 -16
View File
@@ -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<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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
<button
type="button"
onClick={handleFileSelect}
disabled={disabled || isBusy}
disabled={disabled || isBusy || voiceRecorder.isRecording}
title={t("attachFile")}
className="shrink-0 p-3 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 cursor-pointer"
>
<Paperclip className="h-4 w-4" />
</button>
{/* Textarea — no border, transparent bg */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={t("sendMessage")}
disabled={disabled}
rows={1}
className="flex-1 resize-none bg-transparent py-3 px-0 text-base md:text-sm placeholder:text-muted-foreground focus:outline-none disabled:opacity-50"
/>
{/* Voice record button - hidden when recording */}
{!voiceRecorder.isRecording && (
<button
type="button"
onClick={handleVoiceToggle}
disabled={disabled || isBusy}
title={t("recordVoice")}
className="shrink-0 p-3 text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:opacity-40"
>
<Mic className="h-4 w-4" />
</button>
)}
{/* Send / Stop buttons */}
{/* Textarea or Recording indicator */}
{voiceRecorder.isRecording ? (
<div className="flex-1 flex items-center gap-3 py-3 px-2">
{/* Waveform animation */}
<div className="flex items-center gap-0.5">
{[...Array(5)].map((_, i) => (
<span
key={i}
className="w-1 bg-destructive rounded-full animate-pulse"
style={{
height: `${12 + Math.sin(i * 0.8) * 8}px`,
animationDelay: `${i * 0.1}s`,
}}
/>
))}
</div>
<span className="text-sm font-medium tabular-nums">
{formatDuration(voiceRecorder.duration)}
</span>
</div>
) : (
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={t("sendMessage")}
disabled={disabled}
rows={1}
className="flex-1 resize-none bg-transparent py-3 px-0 text-base md:text-sm placeholder:text-muted-foreground focus:outline-none disabled:opacity-50"
/>
)}
{/* Send / Stop / Recording buttons */}
<div className="shrink-0 p-2 flex items-center gap-1">
{isBusy ? (
{voiceRecorder.isRecording ? (
<>
<button
type="button"
onClick={handleCancelRecording}
title={t("cancelRecording")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<X className="h-4 w-4" />
</button>
<button
type="button"
onClick={handleVoiceToggle}
title={t("stopRecording")}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
>
<Square className="h-3.5 w-3.5" />
</button>
</>
) : isBusy ? (
<>
<button
type="button"
+153
View File
@@ -0,0 +1,153 @@
import { useState, useRef, useCallback, useEffect } from "react";
export interface VoiceRecorderState {
isRecording: boolean;
isPaused: boolean;
duration: number; // seconds
error: string | null;
}
export interface VoiceRecorderResult extends VoiceRecorderState {
startRecording: () => Promise<void>;
stopRecording: () => Promise<Blob | null>;
pauseRecording: () => void;
resumeRecording: () => void;
cancelRecording: () => void;
}
const MIME_TYPE = "audio/webm;codecs=opus";
const FALLBACK_MIME = "audio/webm";
export function useVoiceRecorder(): VoiceRecorderResult {
const [state, setState] = useState<VoiceRecorderState>({
isRecording: false,
isPaused: false,
duration: 0,
error: null,
});
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const resolveRef = useRef<((blob: Blob | null) => void) | null>(null);
const cleanup = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
mediaRecorderRef.current = null;
chunksRef.current = [];
}, []);
const startRecording = useCallback(async () => {
try {
setState({ isRecording: false, isPaused: false, duration: 0, error: null });
cleanup();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
// Check supported MIME type
const mimeType = MediaRecorder.isTypeSupported(MIME_TYPE) ? MIME_TYPE : FALLBACK_MIME;
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: mimeType });
if (resolveRef.current) {
resolveRef.current(blob);
resolveRef.current = null;
}
cleanup();
setState((s) => ({ ...s, isRecording: false, isPaused: false }));
};
recorder.onerror = () => {
setState((s) => ({ ...s, error: "Recording failed" }));
cleanup();
};
recorder.start(100); // Collect data every 100ms
// Start duration timer
timerRef.current = setInterval(() => {
setState((s) => ({ ...s, duration: s.duration + 1 }));
}, 1000);
setState({ isRecording: true, isPaused: false, duration: 0, error: null });
} catch (err) {
const message = err instanceof Error ? err.message : "Microphone access denied";
setState({ isRecording: false, isPaused: false, duration: 0, error: message });
}
}, [cleanup]);
const stopRecording = useCallback((): Promise<Blob | null> => {
return new Promise((resolve) => {
if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") {
resolve(null);
return;
}
resolveRef.current = resolve;
mediaRecorderRef.current.stop();
});
}, []);
const pauseRecording = useCallback(() => {
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.pause();
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setState((s) => ({ ...s, isPaused: true }));
}
}, []);
const resumeRecording = useCallback(() => {
if (mediaRecorderRef.current?.state === "paused") {
mediaRecorderRef.current.resume();
timerRef.current = setInterval(() => {
setState((s) => ({ ...s, duration: s.duration + 1 }));
}, 1000);
setState((s) => ({ ...s, isPaused: false }));
}
}, []);
const cancelRecording = useCallback(() => {
if (resolveRef.current) {
resolveRef.current(null);
resolveRef.current = null;
}
cleanup();
setState({ isRecording: false, isPaused: false, duration: 0, error: null });
}, [cleanup]);
// Cleanup on unmount to prevent resource leaks
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
return {
...state,
startRecording,
stopRecording,
pauseRecording,
resumeRecording,
cancelRecording,
};
}
+21 -1
View File
@@ -169,7 +169,8 @@
"skills": "Skills",
"instances": "Instances",
"permissions": "Permissions",
"evolution": "Evolution"
"evolution": "Evolution",
"hooks": "Hooks"
},
"summonFailed": "Summon Failed",
"evolving": "Evolving",
@@ -970,6 +971,25 @@
"noLogs": "No heartbeat logs yet.",
"logsPagination": "{{from}}–{{to}} of {{total}}"
},
"hooks": {
"card": {
"title": "Hooks",
"loading": "Loading hooks...",
"empty": "No hooks configured",
"emptyDesc": "Hooks intercept agent lifecycle events like tool calls.",
"viewAll": "View all {{count}} hooks",
"addHook": "Add hook",
"nHooks": "{{count}} hooks"
},
"tab": {
"refresh": "Refresh",
"create": "Create Hook",
"emptyTitle": "No hooks configured",
"emptyDesc": "Hooks intercept agent lifecycle events like tool calls, session start/stop, and more.",
"createFirst": "Create your first hook",
"deleteConfirm": "Delete hook for \"{{event}}\"? This cannot be undone."
}
},
"permissions": {
"title": "Permissions",
"description": "Control who can modify agent config and files. Owner always has full access.",
+3
View File
@@ -81,6 +81,9 @@
"sendMessageTitle": "Send message",
"sendFollowUp": "Send follow-up message",
"attachFile": "Attach file",
"recordVoice": "Record voice message",
"stopRecording": "Stop recording",
"cancelRecording": "Cancel recording",
"thinking": "Thinking",
"thinkingStreaming": "Thinking...",
"toolArguments": "Arguments",
+22 -1
View File
@@ -170,7 +170,9 @@
"links": "Liên kết",
"skills": "Skill",
"instances": "Phiên bản người dùng",
"permissions": "Phân quyền"
"permissions": "Phân quyền",
"evolution": "Tiến Hóa",
"hooks": "Hooks"
},
"summonFailed": "Triệu hồi thất bại",
"evolving": "Đang tiến hóa",
@@ -954,6 +956,25 @@
"noLogs": "Chưa có nhật ký heartbeat.",
"logsPagination": "{{from}}–{{to}} / {{total}}"
},
"hooks": {
"card": {
"title": "Hooks",
"loading": "Đang tải hooks...",
"empty": "Chưa có hook nào",
"emptyDesc": "Hooks chặn các sự kiện trong vòng đời agent như gọi tool.",
"viewAll": "Xem tất cả {{count}} hooks",
"addHook": "Thêm hook",
"nHooks": "{{count}} hooks"
},
"tab": {
"refresh": "Làm mới",
"create": "Tạo Hook",
"emptyTitle": "Chưa có hook nào",
"emptyDesc": "Hooks chặn các sự kiện trong vòng đời agent như gọi tool, bắt đầu/kết thúc phiên, v.v.",
"createFirst": "Tạo hook đầu tiên",
"deleteConfirm": "Xóa hook cho \"{{event}}\"? Hành động này không thể hoàn tác."
}
},
"permissions": {
"title": "Quyền hạn",
"description": "Quản lý ai được phép thay đổi cấu hình agent và file. Chủ sở hữu luôn có quyền đầy đủ.",
+3
View File
@@ -83,6 +83,9 @@
"sendMessageTitle": "Gửi tin nhắn",
"sendFollowUp": "Gửi tin nhắn bổ sung",
"attachFile": "Đính kèm tệp",
"recordVoice": "Ghi âm tin nhắn",
"stopRecording": "Dừng ghi âm",
"cancelRecording": "Hủy ghi âm",
"skillActivated": "Đã kích hoạt",
"skillActivating": "Đang kích hoạt...",
"status": "Trạng thái",
+22 -1
View File
@@ -170,7 +170,9 @@
"links": "链接",
"skills": "Skill",
"instances": "用户实例",
"permissions": "权限"
"permissions": "权限",
"evolution": "进化",
"hooks": "钩子"
},
"summonFailed": "召唤失败",
"evolving": "进化中",
@@ -954,6 +956,25 @@
"noLogs": "暂无心跳日志。",
"logsPagination": "{{from}}–{{to}} / 共 {{total}}"
},
"hooks": {
"card": {
"title": "钩子",
"loading": "加载钩子中...",
"empty": "未配置钩子",
"emptyDesc": "钩子拦截代理生命周期事件,如工具调用。",
"viewAll": "查看全部 {{count}} 个钩子",
"addHook": "添加钩子",
"nHooks": "{{count}} 个钩子"
},
"tab": {
"refresh": "刷新",
"create": "创建钩子",
"emptyTitle": "未配置钩子",
"emptyDesc": "钩子拦截代理生命周期事件,如工具调用、会话开始/结束等。",
"createFirst": "创建第一个钩子",
"deleteConfirm": "删除 \"{{event}}\" 的钩子?此操作无法撤销。"
}
},
"permissions": {
"title": "权限管理",
"description": "控制谁可以修改代理配置和文件。所有者始终拥有完全访问权限。",
+3
View File
@@ -83,6 +83,9 @@
"sendMessageTitle": "发送消息",
"sendFollowUp": "发送后续消息",
"attachFile": "附加文件",
"recordVoice": "录制语音消息",
"stopRecording": "停止录制",
"cancelRecording": "取消录制",
"skillActivated": "已激活",
"skillActivating": "激活中...",
"status": "状态",
@@ -11,6 +11,7 @@ import { AgentFilesTab } from "./agent-files-tab";
import { AgentInstancesTab } from "./agent-instances-tab";
import { AgentPermissionsTab } from "./agent-permissions-tab";
import { AgentEvolutionTab } from "./evolution-tab/agent-evolution-tab";
import { AgentHooksTab } from "./agent-hooks-tab";
import { SummoningModal } from "../summoning-modal";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
@@ -42,6 +43,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
const [advancedOpen, setAdvancedOpen] = useState(false);
const [heartbeatOpen, setHeartbeatOpen] = useState(false);
const [promptOpen, setPromptOpen] = useState(false);
const [hooksCreateOpen, setHooksCreateOpen] = useState(false);
const handleResummon = async () => {
await resummonAgent();
@@ -79,6 +81,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
<TabsTrigger value="files">{t("detail.tabs.files")}</TabsTrigger>
<TabsTrigger value="permissions">{t("detail.tabs.permissions")}</TabsTrigger>
<TabsTrigger value="evolution">{t("detail.tabs.evolution")}</TabsTrigger>
<TabsTrigger value="hooks">{t("detail.tabs.hooks")}</TabsTrigger>
{agent.agent_type === "predefined" && (
<TabsTrigger value="instances">{t("detail.tabs.instances")}</TabsTrigger>
)}
@@ -91,6 +94,11 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
onUpdate={updateAgent}
heartbeat={hb}
onManageCodexPool={() => navigate(`/agents/${agent.id}/codex-pool`)}
onViewHooks={() => setActiveTab("hooks")}
onAddHook={() => {
setActiveTab("hooks");
setHooksCreateOpen(true);
}}
/>
</TabsContent>
@@ -117,6 +125,14 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
/>
</TabsContent>
<TabsContent value="hooks" className="mt-4">
<AgentHooksTab
agentId={agentId}
initialCreateOpen={hooksCreateOpen}
onCreateOpenChange={setHooksCreateOpen}
/>
</TabsContent>
{agent.agent_type === "predefined" && (
<TabsContent value="instances" className="mt-4">
<AgentInstancesTab agentId={agentId} />
@@ -0,0 +1,259 @@
import { useState, useEffect } from "react";
import { Webhook, Plus, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { EmptyState } from "@/components/shared/empty-state";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { toast } from "@/stores/use-toast-store";
import {
useHooksList, useDeleteHook, useToggleHook, useCreateHook, useUpdateHook,
type HookConfig,
} from "@/hooks/use-hooks";
import { HookListRow } from "@/pages/hooks/components/hook-list-row";
import { HookFormDialog } from "@/pages/hooks/components/hook-form-dialog";
import { HookTestPanel } from "@/pages/hooks/components/hook-test-panel";
import type { HookFormData } from "@/schemas/hooks.schema";
interface AgentHooksTabProps {
agentId: string;
initialCreateOpen?: boolean;
onCreateOpenChange?: (open: boolean) => void;
}
function parseHeaders(raw: string | undefined): Record<string, unknown> {
const trimmed = (raw ?? "").trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
throw new Error("headers must be a JSON object");
} catch (err) {
// eslint-disable-next-line preserve-caught-error -- JSON.parse error message already captured verbatim in thrown message
throw new Error(
"Invalid headers JSON: " + (err instanceof Error ? err.message : String(err)),
);
}
}
function buildConfig(data: HookFormData): Record<string, unknown> {
if (data.handler_type === "http") {
return {
url: data.url ?? "",
method: data.method ?? "POST",
headers: parseHeaders(data.headers),
body_template: data.body_template ?? "",
};
}
if (data.handler_type === "script") {
return { source: data.script_source ?? "" };
}
return {
prompt_template: data.prompt_template ?? "",
model: data.model ?? "haiku",
max_invocations_per_turn: data.max_invocations_per_turn ?? 5,
};
}
export function AgentHooksTab({ agentId, initialCreateOpen, onCreateOpenChange }: AgentHooksTabProps) {
const { t } = useTranslation("agents");
const { t: th } = useTranslation("hooks");
const [showCreate, setShowCreate] = useState(false);
const [editTarget, setEditTarget] = useState<HookConfig | null>(null);
const [deleteTarget, setDeleteTarget] = useState<HookConfig | null>(null);
const [testTarget, setTestTarget] = useState<HookConfig | null>(null);
// Handle initial create open from parent (overview card "Add hook" button)
useEffect(() => {
if (initialCreateOpen) {
setShowCreate(true);
onCreateOpenChange?.(false);
}
}, [initialCreateOpen, onCreateOpenChange]);
const { data: hooks = [], isPending: loading, isFetching: refreshing, refetch } = useHooksList({
agentId,
scope: "agent",
});
const spinning = useMinLoading(refreshing);
const createMutation = useCreateHook();
const updateMutation = useUpdateHook();
const deleteMutation = useDeleteHook();
const toggleMutation = useToggleHook();
const handleCreate = async (data: HookFormData) => {
let config: Record<string, unknown>;
try {
config = buildConfig(data);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
return;
}
// Pre-populate agent_ids with current agent if scope is agent
const agentIds = data.scope === "agent"
? Array.from(new Set([...(data.agent_ids ?? []), agentId]))
: data.agent_ids ?? [];
await createMutation.mutateAsync({
name: data.name ?? "",
agent_ids: agentIds,
event: data.event,
handler_type: data.handler_type,
scope: data.scope,
matcher: data.matcher || undefined,
if_expr: data.if_expr || undefined,
timeout_ms: data.timeout_ms,
on_timeout: data.on_timeout,
priority: data.priority,
enabled: data.enabled,
config,
});
};
const handleUpdate = async (data: HookFormData) => {
if (!editTarget) return;
let config: Record<string, unknown>;
try {
config = buildConfig(data);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
return;
}
await updateMutation.mutateAsync({
hookId: editTarget.id,
updates: {
name: data.name ?? "",
agent_ids: data.agent_ids ?? [],
event: data.event,
handler_type: data.handler_type,
scope: data.scope,
matcher: data.matcher || undefined,
if_expr: data.if_expr || undefined,
timeout_ms: data.timeout_ms,
on_timeout: data.on_timeout,
priority: data.priority,
enabled: data.enabled,
config,
},
});
setEditTarget(null);
};
// Default values for create form with agent pre-selected
const createInitial: Partial<HookConfig> = {
agent_ids: [agentId],
scope: "agent",
event: "pre_tool_use",
handler_type: "script",
timeout_ms: 5000,
on_timeout: "block",
priority: 100,
enabled: true,
};
if (loading && hooks.length === 0) {
return <TableSkeleton />;
}
if (hooks.length === 0) {
return (
<>
<EmptyState
icon={Webhook}
title={t("hooks.tab.emptyTitle")}
description={t("hooks.tab.emptyDesc")}
action={
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
<Plus className="h-3.5 w-3.5" /> {t("hooks.tab.createFirst")}
</Button>
}
/>
<HookFormDialog
open={showCreate}
onOpenChange={setShowCreate}
onSubmit={handleCreate}
initial={createInitial as HookConfig}
/>
</>
);
}
return (
<>
<div className="flex items-center justify-between gap-3 mb-4">
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} />
{t("hooks.tab.refresh")}
</Button>
<Button size="sm" onClick={() => setShowCreate(true)} className="gap-1">
<Plus className="h-3.5 w-3.5" /> {t("hooks.tab.create")}
</Button>
</div>
<div className="flex flex-col gap-2">
{hooks.map((hook) => (
<HookListRow
key={hook.id}
hook={hook}
onClick={() => setEditTarget(hook)}
onToggle={(enabled) => toggleMutation.mutate({ hookId: hook.id, enabled })}
onEdit={() => setEditTarget(hook)}
onDelete={() => setDeleteTarget(hook)}
onTest={() => setTestTarget(hook)}
/>
))}
</div>
<HookFormDialog
open={showCreate}
onOpenChange={setShowCreate}
onSubmit={handleCreate}
initial={createInitial as HookConfig}
/>
{editTarget && (
<HookFormDialog
open
onOpenChange={(o) => { if (!o) setEditTarget(null); }}
onSubmit={handleUpdate}
initial={editTarget}
/>
)}
{deleteTarget && (
<ConfirmDialog
open
onOpenChange={() => setDeleteTarget(null)}
title={th("actions.delete")}
description={t("hooks.tab.deleteConfirm", { event: deleteTarget.event })}
confirmLabel={th("actions.delete")}
variant="destructive"
onConfirm={async () => {
await deleteMutation.mutateAsync(deleteTarget.id);
setDeleteTarget(null);
}}
/>
)}
{testTarget && (
<Dialog open onOpenChange={(o) => { if (!o) setTestTarget(null); }}>
<DialogContent className="max-h-[90vh] flex flex-col max-sm:inset-0 max-sm:rounded-none sm:max-w-5xl lg:max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
{th("test.title")}
<span className="font-mono text-sm text-muted-foreground">{testTarget.event}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto -mx-4 px-4 sm:-mx-6 sm:px-6">
<HookTestPanel hook={testTarget} />
</div>
</DialogContent>
</Dialog>
)}
</>
);
}
@@ -14,6 +14,7 @@ import { OrchestrationSection } from "./overview-sections/orchestration-section"
import { CapabilitiesSection } from "./overview-sections/capabilities-section";
import { ChatGPTOAuthRoutingSummarySection } from "./overview-sections/chatgpt-oauth-routing-summary-section";
import { HeartbeatCard } from "./overview-sections/heartbeat-card";
import { HooksSummaryCard } from "./overview-sections/hooks-summary-card";
import { MemorySection } from "./config-sections";
import type { UseAgentHeartbeatReturn } from "../hooks/use-agent-heartbeat";
@@ -22,9 +23,11 @@ interface AgentOverviewTabProps {
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
heartbeat: UseAgentHeartbeatReturn;
onManageCodexPool: () => void;
onViewHooks: () => void;
onAddHook: () => void;
}
export function AgentOverviewTab({ agent, onUpdate, heartbeat, onManageCodexPool }: AgentOverviewTabProps) {
export function AgentOverviewTab({ agent, onUpdate, heartbeat, onManageCodexPool, onViewHooks, onAddHook }: AgentOverviewTabProps) {
const { t } = useTranslation("agents");
// Personality
@@ -161,6 +164,12 @@ export function AgentOverviewTab({ agent, onUpdate, heartbeat, onManageCodexPool
<HeartbeatCard heartbeat={heartbeat} />
<HooksSummaryCard
agentId={agent.id}
onViewAll={onViewHooks}
onAddHook={onAddHook}
/>
<SkillsSection agentId={agent.id} />
<PinnedSkillsSection agent={agent} onUpdate={onUpdate} />
@@ -0,0 +1,126 @@
import { useState, useMemo } from "react";
import { Webhook, ChevronDown, ChevronUp, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useHooksList } from "@/hooks/use-hooks";
interface HooksSummaryCardProps {
agentId: string;
onViewAll: () => void;
onAddHook: () => void;
}
const EVENT_COLORS: Record<string, string> = {
session_start: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
user_prompt_submit: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
pre_tool_use: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
post_tool_use: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
stop: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
subagent_start: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300",
subagent_stop: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300",
};
export function HooksSummaryCard({ agentId, onViewAll, onAddHook }: HooksSummaryCardProps) {
const { t } = useTranslation("agents");
const [expanded, setExpanded] = useState(false);
const { data: hooks = [], isPending } = useHooksList({ agentId, scope: "agent" });
const groupedByEvent = useMemo(() => {
const groups: Record<string, number> = {};
hooks.forEach((h) => {
groups[h.event] = (groups[h.event] || 0) + 1;
});
return Object.entries(groups)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
}, [hooks]);
const totalCount = hooks.length;
// Loading state
if (isPending) {
return (
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
{t("hooks.card.loading")}
</div>
</div>
);
}
// Empty state
if (totalCount === 0) {
return (
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Webhook className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{t("hooks.card.title")}</span>
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground">{t("hooks.card.empty")}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("hooks.card.emptyDesc")}</p>
<Button size="sm" variant="outline" onClick={onAddHook} className="mt-3 gap-1">
<Plus className="h-3.5 w-3.5" />
{t("hooks.card.addHook")}
</Button>
</div>
);
}
// Has hooks - collapsible
return (
<div className="rounded-lg border p-4 space-y-3">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<Webhook className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{t("hooks.card.title")}</span>
</div>
<div className="flex items-center gap-1.5">
<Badge variant="secondary" className="text-xs">
{totalCount}
</Badge>
{expanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
{expanded && (
<>
<div className="space-y-1.5 pt-1">
{groupedByEvent.map(([event, count]) => (
<div key={event} className="flex items-center justify-between text-xs">
<span
className={`inline-flex items-center rounded px-1.5 py-0.5 font-medium ${EVENT_COLORS[event] ?? "bg-muted text-muted-foreground"}`}
>
{event}
</span>
<span className="text-muted-foreground">
{t("hooks.card.nHooks", { count })}
</span>
</div>
))}
</div>
<Button
variant="link"
size="sm"
onClick={onViewAll}
className="h-auto p-0 text-xs"
>
{t("hooks.card.viewAll", { count: totalCount })} &rarr;
</Button>
</>
)}
</div>
);
}
@@ -41,9 +41,11 @@ export function CredentialsSection({ provider, draft, onUpdate, testConnection,
try {
// Build params from draft credentials — test with unsaved config
const cfg = draft[provider as keyof Pick<typeof draft, "openai" | "elevenlabs" | "minimax">];
// Don't send masked API key — backend will reject it
const apiKey = cfg?.api_key === "***" ? undefined : cfg?.api_key;
const params: TestConnectionParams = {
provider,
api_key: cfg?.api_key,
api_key: apiKey,
api_base: cfg?.api_base || cfg?.base_url,
voice_id: cfg?.voice_id || cfg?.voice,
model_id: cfg?.model_id || cfg?.model,