mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 06:17:41 +00:00
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:
1 parent
284c9cb2cc
commit
b1f6eadf85
27 files changed
+931
-46
No files matched your search
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 đủ.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "控制谁可以修改代理配置和文件。所有者始终拥有完全访问权限。",
|
||||
|
||||
@@ -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 })} →
|
||||
</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,
|
||||
|
||||
Reference in new issue
Block a user