diff --git a/go.mod b/go.mod index a33f6c83b..ceb965479 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module github.com/sipeed/picoclaw -go 1.25.9 +go 1.25.10 require ( - fyne.io/systray v1.12.0 + fyne.io/systray v1.12.1 github.com/SevereCloud/vksdk/v3 v3.3.1 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 @@ -22,7 +22,8 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 - github.com/larksuite/oapi-sdk-go/v3 v3.5.4 + github.com/larksuite/oapi-sdk-go/v3 v3.6.1 + github.com/line/line-bot-sdk-go/v8 v8.19.0 github.com/mdp/qrterminal/v3 v3.2.1 github.com/minio/selfupdate v0.6.0 github.com/modelcontextprotocol/go-sdk v1.5.0 @@ -135,7 +136,7 @@ require ( github.com/github/copilot-sdk/go v0.2.0 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/jsonschema-go v0.4.2 + github.com/google/jsonschema-go v0.4.3 github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index c68d659e1..b3e204c08 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,8 @@ aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= -fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= +fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg= @@ -161,8 +161,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -198,10 +198,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0= -github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/larksuite/oapi-sdk-go/v3 v3.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ= +github.com/larksuite/oapi-sdk-go/v3 v3.6.1/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4= +github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index c69eb444f..248d371a8 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -60,6 +60,7 @@ type AgentLoop struct { steering *steeringQueue subagents *tools.SubagentManager pendingSkills sync.Map + pendingStops sync.Map mu sync.RWMutex // workerSem limits concurrent turn processing workers. @@ -179,6 +180,10 @@ func (al *AgentLoop) Run(ctx context.Context) error { phase: TurnPhaseSetup, } if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded { + if al.tryHandleStopCommand(ctx, msg, sessionKey) { + continue + } + // Another turn is already active (or reserved) for this session — enqueue if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{ Role: "user", @@ -242,6 +247,24 @@ func (al *AgentLoop) Run(ctx context.Context) error { defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID) } + if al.takePendingStop(sessionKey) { + al.activeTurnStates.Delete(sessionKey) + target := &continuationTarget{ + SessionKey: sessionKey, + Channel: m.Channel, + ChatID: m.ChatID, + } + continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target) + if continueErr != nil { + al.maybePublishError(ctx, m.Channel, m.ChatID, sessionKey, continueErr) + return + } + if continued != "" { + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, continued) + } + return + } + al.runTurnWithSteering(ctx, m) }(msg) diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go index a2ed068d6..ae0293d71 100644 --- a/pkg/agent/agent_command.go +++ b/pkg/agent/agent_command.go @@ -274,6 +274,12 @@ func (al *AgentLoop) buildCommandsRuntime( return nil }, } + rt.StopActiveTurn = func() (commands.StopResult, error) { + if opts == nil { + return commands.StopResult{}, fmt.Errorf("process options not available") + } + return al.stopActiveTurnForSession(opts.Dispatch.SessionKey) + } if agent != nil && agent.ContextBuilder != nil { rt.ListSkillNames = agent.ContextBuilder.ListSkillNames } diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go index 556b3097f..e59e21006 100644 --- a/pkg/agent/agent_init.go +++ b/pkg/agent/agent_init.go @@ -340,5 +340,20 @@ func registerSharedTools( } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) } + + // Register delegate tool for multi-agent setups. + // Auto-enabled when multiple agents exist. Delegation uses the SubTurn + // mechanism directly (not SubagentManager) and is independent of the + // subagent tool. + if len(registry.ListAgentIDs()) > 1 { + delegateTool := tools.NewDelegateTool() + delegateTool.SetSpawner(NewSubTurnSpawner(al)) + currentAgentID := agentID + delegateTool.SetSelfAgentID(currentAgentID) + delegateTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) + }) + agent.Tools.Register(delegateTool) + } } } diff --git a/pkg/agent/agent_steering.go b/pkg/agent/agent_steering.go index c674bcafa..9b136e7cd 100644 --- a/pkg/agent/agent_steering.go +++ b/pkg/agent/agent_steering.go @@ -44,11 +44,36 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb return } - // Drain steering queue using existing Continue mechanism + continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target) + if continueErr != nil { + logger.WarnCF("agent", "Failed to continue queued steering", + map[string]any{ + "channel": target.Channel, + "chat_id": target.ChatID, + "error": continueErr.Error(), + }) + } else if continued != "" { + finalResponse = continued + } + + // Publish final response + if finalResponse != "" { + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) + } +} + +func (al *AgentLoop) drainQueuedSteeringContinuations( + ctx context.Context, + target *continuationTarget, +) (string, error) { + if target == nil { + return "", nil + } + + finalResponse := "" for al.pendingSteeringCountForScope(target.SessionKey) > 0 { - // Check for context cancellation between iterations - if ctx.Err() != nil { - return + if err := ctx.Err(); err != nil { + return finalResponse, err } logger.InfoCF("agent", "Continuing queued steering after turn end", @@ -61,13 +86,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) if continueErr != nil { - logger.WarnCF("agent", "Failed to continue queued steering", - map[string]any{ - "channel": target.Channel, - "chat_id": target.ChatID, - "error": continueErr.Error(), - }) - break + return finalResponse, continueErr } if continued == "" { break @@ -75,10 +94,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb finalResponse = continued } - // Publish final response - if finalResponse != "" { - al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) - } + return finalResponse, nil } func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) { diff --git a/pkg/agent/agent_stop.go b/pkg/agent/agent_stop.go new file mode 100644 index 000000000..54cd51477 --- /dev/null +++ b/pkg/agent/agent_stop.go @@ -0,0 +1,122 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" +) + +func (al *AgentLoop) tryHandleStopCommand( + ctx context.Context, + msg bus.InboundMessage, + sessionKey string, +) bool { + cmdName, ok := commands.CommandName(msg.Content) + if !ok || cmdName != "stop" { + return false + } + + result, err := al.stopActiveTurnForSession(sessionKey) + + // This function is only called when loaded=true (another turn already + // claimed this session). If stopActiveTurnForSession found a pending + // placeholder but didn't stop it, that placeholder belongs to the other + // message's worker which hasn't started yet — arm a pending stop so the + // worker will bail when it checks before running. + if err == nil && !result.Stopped { + if ts := al.getActiveTurnState(sessionKey); ts != nil { + snap := ts.snapshot() + if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) { + al.markPendingStop(sessionKey) + result.Stopped = true + } + } + } + + reply := commands.FormatStopReply(result) + if err != nil { + reply = "Failed to stop task: " + err.Error() + } + + if al.channelManager != nil { + al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) + } + al.resetMessageToolRound(sessionKey) + al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, sessionKey, reply) + return true +} + +func (al *AgentLoop) stopActiveTurnForSession(sessionKey string) (commands.StopResult, error) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return commands.StopResult{}, fmt.Errorf("session key is required") + } + + result := commands.StopResult{} + cleared := al.clearSteeringMessagesForScope(sessionKey) + al.clearPendingSkills(sessionKey) + + ts := al.getActiveTurnState(sessionKey) + if ts == nil { + result.Stopped = cleared > 0 + return result, nil + } + + snap := ts.snapshot() + result.TaskName = snap.UserMessage + + if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) { + // A pending placeholder means this session is either idle (our own + // placeholder from the /stop command) or another message is queued but + // hasn't started yet. In both cases, we don't arm a pending stop here; + // the caller (tryHandleStopCommand) handles the "another message queued" + // case explicitly, since it knows loaded=true. + return result, nil + } + + if err := al.HardAbort(sessionKey); err != nil { + if al.getActiveTurnState(sessionKey) == nil { + result.Stopped = cleared > 0 + return result, nil + } + return commands.StopResult{}, err + } + + result.Stopped = true + return result, nil +} + +func (al *AgentLoop) markPendingStop(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.pendingStops.Store(sessionKey, struct{}{}) +} + +func (al *AgentLoop) takePendingStop(sessionKey string) bool { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return false + } + _, ok := al.pendingStops.LoadAndDelete(sessionKey) + return ok +} + +func (al *AgentLoop) resetMessageToolRound(sessionKey string) { + if strings.TrimSpace(sessionKey) == "" { + return + } + if registry := al.GetRegistry(); registry != nil { + if agent := registry.GetDefaultAgent(); agent != nil { + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) + } + } + } + } +} diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go index ba171fe5d..7bddbfc31 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -156,6 +156,18 @@ func (sq *steeringQueue) lenScope(scope string) int { return len(sq.queues[normalizeSteeringScope(scope)]) } +func (sq *steeringQueue) clearScope(scope string) int { + sq.mu.Lock() + defer sq.mu.Unlock() + + scope = normalizeSteeringScope(scope) + count := len(sq.queues[scope]) + if count > 0 { + delete(sq.queues, scope) + } + return count +} + // setMode updates the steering mode. func (sq *steeringQueue) setMode(mode SteeringMode) { sq.mu.Lock() @@ -290,6 +302,13 @@ func (al *AgentLoop) pendingSteeringCountForScope(scope string) int { return al.steering.lenScope(scope) } +func (al *AgentLoop) clearSteeringMessagesForScope(scope string) int { + if al.steering == nil { + return 0 + } + return al.steering.clearScope(scope) +} + func (al *AgentLoop) continueWithSteeringMessages( ctx context.Context, agent *AgentInstance, @@ -511,6 +530,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error { "initial_history_length": ts.initialHistoryLength, }) + // Cancel the active provider/tool turn contexts immediately so long-running + // execution stops as soon as possible on the root turn. + _ = ts.requestHardAbort() + // IMPORTANT: Trigger cascading cancellation FIRST to stop all child SubTurns // from adding more messages to the session. This prevents race conditions // where rollback happens while children are still writing. diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 25e06d7a2..813013649 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -840,6 +840,191 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { } } +func TestAgentLoop_Run_PendingStopStillContinuesQueuedFollowUp(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxParallelTurns: 1, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &lateSteeringProvider{ + firstCallStarted: make(chan struct{}), + releaseFirstCall: make(chan struct{}), + } + al := NewAgentLoop(cfg, msgBus, provider) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- al.Run(runCtx) + }() + defer func() { + cancelRun() + select { + case err := <-runErrCh: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for Run to stop") + } + }() + + blockerSessionKey := session.BuildOpaqueSessionKey("agent:main:test:blocker") + targetSessionKey := session.BuildOpaqueSessionKey("agent:main:test:target") + blockerCtx := bus.InboundContext{ + Channel: "test", + ChatID: "blocker-chat", + ChatType: "direct", + SenderID: "user1", + } + targetCtx := bus.InboundContext{ + Channel: "test", + ChatID: "target-chat", + ChatType: "direct", + SenderID: "user1", + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: blockerCtx, + Content: "block worker pool", + SessionKey: blockerSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(blocker) error = %v", err) + } + + select { + case <-provider.firstCallStarted: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for blocker turn to start") + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "skip this turn", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(target start) error = %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for { + ts := al.getActiveTurnState(targetSessionKey) + if ts != nil && strings.HasPrefix(ts.turnID, pendingTurnPrefix) { + break + } + if time.Now().After(deadline) { + t.Fatal("timeout waiting for pending placeholder") + } + time.Sleep(10 * time.Millisecond) + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "/stop", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(/stop) error = %v", err) + } + + deadline = time.Now().Add(2 * time.Second) + stopSeen := false + for !stopSeen { + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.ChatID == "target-chat" && outbound.Content == "Task stopped. Current task was canceled." { + stopSeen = true + } + case <-time.After(10 * time.Millisecond): + if time.Now().After(deadline) { + t.Fatal("timeout waiting for /stop reply") + } + } + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "run this instead", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(follow-up) error = %v", err) + } + + deadline = time.Now().Add(2 * time.Second) + for al.pendingSteeringCountForScope(targetSessionKey) == 0 { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for follow-up to enter scoped steering queue") + } + time.Sleep(10 * time.Millisecond) + } + + close(provider.releaseFirstCall) + + deadline = time.Now().Add(5 * time.Second) + followUpSeen := false + for !followUpSeen { + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.ChatID == "target-chat" && outbound.Content == "continued response" { + followUpSeen = true + } + case <-time.After(10 * time.Millisecond): + if time.Now().After(deadline) { + t.Fatal("timeout waiting for queued follow-up continuation") + } + } + } + + deadline = time.Now().Add(2 * time.Second) + for { + if al.GetActiveTurnBySession(targetSessionKey) == nil && + al.pendingSteeringCountForScope(targetSessionKey) == 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("timeout waiting for target session to go idle") + } + time.Sleep(10 * time.Millisecond) + } + + provider.mu.Lock() + calls := provider.calls + secondMessages := append([]providers.Message(nil), provider.secondCallMessages...) + provider.mu.Unlock() + + if calls != 2 { + t.Fatalf("expected 2 provider calls (blocker + continuation), got %d", calls) + } + + foundFollowUp := false + for _, msg := range secondMessages { + if msg.Role == "user" && msg.Content == "run this instead" { + foundFollowUp = true + } + if msg.Role == "user" && msg.Content == "skip this turn" { + t.Fatalf("unexpected canceled message in continuation context: %q", msg.Content) + } + } + if !foundFollowUp { + t.Fatal("expected queued follow-up to be processed after pending stop") + } +} + func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { @@ -1392,6 +1577,149 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { } } +func TestAgentLoop_StopCommand_AbortsActiveTurnAndClearsQueuedSteering(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolCallProvider{ + toolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "cancel_tool", + Function: &providers.FunctionCall{ + Name: "cancel_tool", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + }, + finalResp: "should not continue", + } + + al := NewAgentLoop(cfg, msgBus, provider) + started := make(chan struct{}) + al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started}) + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- al.Run(runCtx) + }() + defer func() { + cancelRun() + select { + case err := <-runErrCh: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for Run to stop") + } + }() + + baseMsg := testInboundMessage(bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + SessionKey: sessionKey, + }) + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "do work", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(start) error = %v", err) + } + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for interruptible tool to start") + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "follow up after cancel", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(follow-up) error = %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for al.pendingSteeringCountForScope(sessionKey) == 0 { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for follow-up message to enter steering queue") + } + time.Sleep(10 * time.Millisecond) + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "/stop", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(/stop) error = %v", err) + } + + select { + case outbound := <-msgBus.OutboundChan(): + want := "Task stopped. \"do work\" was canceled." + if outbound.Content != want { + t.Fatalf("stop reply = %q, want %q", outbound.Content, want) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for /stop reply") + } + + deadline = time.Now().Add(5 * time.Second) + for al.GetActiveTurnBySession(sessionKey) != nil { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for active turn to stop") + } + time.Sleep(10 * time.Millisecond) + } + + if got := al.pendingSteeringCountForScope(sessionKey); got != 0 { + t.Fatalf("expected cleared steering queue, got %d pending message(s)", got) + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("unexpected outbound after stop: %q", outbound.Content) + case <-time.After(300 * time.Millisecond): + } + + provider.mu.Lock() + calls := provider.calls + provider.mu.Unlock() + if calls != 1 { + t.Fatalf("expected provider to stop before follow-up turn, got %d calls", calls) + } +} + // capturingMockProvider captures messages sent to Chat for inspection. type capturingMockProvider struct { response string diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index 483c72842..86617d02f 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -174,7 +174,10 @@ type SubTurnConfig struct { // Used by team tool to enforce token limits across all team members. InitialTokenBudget *atomic.Int64 - // Can be extended with temperature, topP, etc. + // TargetAgentID, when set, runs the sub-turn as the specified agent. + // The target agent's workspace, model, tools, and system prompt are used + // instead of the caller's. If empty, the sub-turn runs as the parent agent. + TargetAgentID string } // ====================== Context Keys ====================== @@ -232,6 +235,7 @@ func (s *AgentLoopSpawner) SpawnSubTurn( Critical: cfg.Critical, Timeout: cfg.Timeout, MaxContextRunes: cfg.MaxContextRunes, + TargetAgentID: cfg.TargetAgentID, } return spawnSubTurn(ctx, s.al, parentTS, agentCfg) @@ -314,8 +318,9 @@ func spawnSubTurn( return nil, ErrDepthLimitExceeded } - // 2. Config validation - if cfg.Model == "" { + // 2. Config validation: Model is required unless TargetAgentID is set + // (the target agent provides its own model). + if cfg.Model == "" && cfg.TargetAgentID == "" { return nil, ErrInvalidSubTurnConfig } @@ -333,12 +338,22 @@ func spawnSubTurn( childID := al.generateSubTurnID() - // Get the agent instance from parent, falling back to the default agent. - // Wrap it in a shallow copy that uses an ephemeral (in-memory only) session store - // so that child turns never pollute or persist to the parent's session history. - baseAgent := parentTS.agent - if baseAgent == nil { - baseAgent = al.registry.GetDefaultAgent() + // Resolve the agent instance for the child turn. + // When TargetAgentID is set, look up that agent from the registry so the + // child runs with the target's workspace, model, tools, and system prompt. + // Otherwise fall back to the parent's agent (existing behavior). + var baseAgent *AgentInstance + if cfg.TargetAgentID != "" { + var ok bool + baseAgent, ok = al.registry.GetAgent(cfg.TargetAgentID) + if !ok { + return nil, fmt.Errorf("target agent %q not found in registry", cfg.TargetAgentID) + } + } else { + baseAgent = parentTS.agent + if baseAgent == nil { + baseAgent = al.registry.GetDefaultAgent() + } } if baseAgent == nil { return nil, errors.New("parent turnState has no agent instance") diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index 6a33892db..e9f557c82 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" + "strings" "sync" "testing" "time" @@ -2122,3 +2125,206 @@ func TestSubTurn_IndependentContext(t *testing.T) { t.Log("✓ SubTurn completed successfully (independent context)") } } + +// ====================== TargetAgentID Tests ====================== + +// modelRecordingProvider captures the model passed to Chat for test assertions. +type modelRecordingProvider struct { + mu sync.Mutex + lastModel string +} + +func (rp *modelRecordingProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + model string, + _ map[string]any, +) (*providers.LLMResponse, error) { + rp.mu.Lock() + rp.lastModel = model + rp.mu.Unlock() + return &providers.LLMResponse{Content: "Mock response"}, nil +} + +func (rp *modelRecordingProvider) GetDefaultModel() string { return "mock-model" } + +func (rp *modelRecordingProvider) getLastModel() string { + rp.mu.Lock() + defer rp.mu.Unlock() + return rp.lastModel +} + +// newMultiAgentLoop creates an AgentLoop with two named agents for testing +// cross-agent delegation via TargetAgentID. +func newMultiAgentLoop(t *testing.T, provider providers.LLMProvider) (*AgentLoop, func()) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "multiagent-test-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + + alphaDir := filepath.Join(tmpDir, "alpha") + betaDir := filepath.Join(tmpDir, "beta") + os.MkdirAll(alphaDir, 0o755) + os.MkdirAll(betaDir, 0o755) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "default-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + List: []config.AgentConfig{ + { + ID: "alpha", + Workspace: alphaDir, + Model: &config.AgentModelConfig{Primary: "model-alpha"}, + }, + { + ID: "beta", + Workspace: betaDir, + Model: &config.AgentModelConfig{Primary: "model-beta"}, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + + return al, func() { os.RemoveAll(tmpDir) } +} + +func TestSpawnSubTurn_TargetAgentID_UsesTargetAgent(t *testing.T) { + rp := &modelRecordingProvider{} + al, cleanup := newMultiAgentLoop(t, rp) + defer cleanup() + + alphaAgent, ok := al.registry.GetAgent("alpha") + if !ok { + t.Fatal("alpha agent not in registry") + } + + // Parent is alpha, target is beta + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + result, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + TargetAgentID: "beta", + SystemPrompt: "task for beta", + }) + if err != nil { + t.Fatalf("spawnSubTurn failed: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + + // The recording provider captures the model passed to Chat(). + // If TargetAgentID works correctly, the child turn should have + // used beta's model, not alpha's. + if got := rp.getLastModel(); got != "model-beta" { + t.Errorf("child turn used model %q, want %q", got, "model-beta") + } +} + +func TestSpawnSubTurn_TargetAgentID_NotFound(t *testing.T) { + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) + defer cleanup() + + alphaAgent, _ := al.registry.GetAgent("alpha") + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + _, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + TargetAgentID: "nonexistent", + SystemPrompt: "task", + }) + + if err == nil { + t.Fatal("expected error for nonexistent agent") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error should mention 'not found', got: %v", err) + } +} + +func TestSpawnSubTurn_TargetAgentID_EmptyModelAccepted(t *testing.T) { + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) + defer cleanup() + + alphaAgent, _ := al.registry.GetAgent("alpha") + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + // Model is empty but TargetAgentID is set — should NOT fail validation + result, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + Model: "", // intentionally empty + TargetAgentID: "beta", + SystemPrompt: "task for beta", + }) + if err != nil { + t.Fatalf("should accept empty Model when TargetAgentID is set, got: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestDelegateToolNotRegistered_SingleAgent(t *testing.T) { + // Single-agent setup: delegate should not be registered + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("default agent should exist") + } + if _, has := agent.Tools.Get("delegate"); has { + t.Error("delegate tool should not be registered in single-agent setup") + } +} + +func TestDelegateToolRegistered_MultiAgent(t *testing.T) { + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) + defer cleanup() + + // Both agents should have the delegate tool + for _, id := range []string{"alpha", "beta"} { + agent, ok := al.registry.GetAgent(id) + if !ok { + t.Fatalf("agent %q not found", id) + } + if _, has := agent.Tools.Get("delegate"); !has { + t.Errorf("agent %q should have delegate tool in multi-agent setup", id) + } + } +} diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go index ae6bd8c82..2826e662c 100644 --- a/pkg/agent/turn_coord.go +++ b/pkg/agent/turn_coord.go @@ -26,6 +26,10 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel al.registerActiveTurn(ts) defer al.clearActiveTurn(ts) + if al.takePendingStop(ts.sessionKey) { + _ = ts.requestHardAbort() + } + turnStatus := TurnEndStatusCompleted defer func() { al.emitEvent( @@ -40,6 +44,11 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel ) }() + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + al.emitEvent( runtimeevents.KindAgentTurnStart, ts.eventMeta("runTurn", "turn.start"), diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 85e7dd3c0..b769ebcd0 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -256,7 +256,10 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop // Bind session store and capture initial history length for rollback logic if agent != nil && agent.Sessions != nil { ts.session = agent.Sessions - ts.initialHistoryLength = len(agent.Sessions.GetHistory(opts.Dispatch.SessionKey)) + history := agent.Sessions.GetHistory(opts.Dispatch.SessionKey) + ts.initialHistoryLength = len(history) + ts.restorePointHistory = append([]providers.Message(nil), history...) + ts.restorePointSummary = agent.Sessions.GetSummary(opts.Dispatch.SessionKey) } return ts diff --git a/pkg/audio/asr/README.md b/pkg/audio/asr/README.md index 0477276dd..99d2a8c90 100644 --- a/pkg/audio/asr/README.md +++ b/pkg/audio/asr/README.md @@ -82,7 +82,8 @@ Notes: "model_list": [ { "model_name": "elevenlabs-asr", - "model": "elevenlabs/scribe_v1" + "provider": "elevenlabs", + "model": "scribe_v1" } ] } @@ -130,7 +131,7 @@ PicoClaw currently supports three main ASR routes: | Route | Example models | Behavior | | --- | --- | --- | -| ElevenLabs ASR | `elevenlabs/scribe_v1` | Uses the ElevenLabs transcription API. | +| ElevenLabs ASR | `provider: elevenlabs`, `model: scribe_v1` | Uses the ElevenLabs transcription API. | | Whisper endpoint models | `openai/whisper-1`, `groq/whisper-large-v3` | Uses an OpenAI-compatible `/audio/transcriptions` endpoint. | | Audio-capable chat models **(Under construction)** | `openai/gpt-4o-audio-preview`, `gemini/gemini-2.5-flash` | Sends audio to a multimodal chat model and asks it to transcribe. | @@ -142,7 +143,7 @@ If you are unsure which one to pick, choose Groq Whisper or ElevenLabs first. 1. **Preferred path**: resolve `voice.model_name` against `model_list`. 2. If that resolved model is: - - `elevenlabs/...`, PicoClaw uses the ElevenLabs transcriber. + - an `elevenlabs` provider model, PicoClaw uses the ElevenLabs transcriber. - an OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber. - an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`. 3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries. diff --git a/pkg/audio/asr/README.zh.md b/pkg/audio/asr/README.zh.md index 104116080..670698cb8 100644 --- a/pkg/audio/asr/README.zh.md +++ b/pkg/audio/asr/README.zh.md @@ -82,7 +82,8 @@ model_list: "model_list": [ { "model_name": "elevenlabs-asr", - "model": "elevenlabs/scribe_v1" + "provider": "elevenlabs", + "model": "scribe_v1" } ] } @@ -130,7 +131,7 @@ PicoClaw 目前主要支持三种 ASR 路径: | 路径 | 示例模型 | 行为说明 | | --- | --- | --- | -| ElevenLabs ASR | `elevenlabs/scribe_v1` | 使用 ElevenLabs 的语音转录接口。 | +| ElevenLabs ASR | `provider: elevenlabs`,`model: scribe_v1` | 使用 ElevenLabs 的语音转录接口。 | | Whisper 接口模型 | `openai/whisper-1`、`groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 | | 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 | @@ -142,7 +143,7 @@ PicoClaw 目前主要支持三种 ASR 路径: 1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。 2. 如果找到的模型属于以下类型: - - `elevenlabs/...`,则使用 ElevenLabs transcriber。 + - `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。 - OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。 - 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。 3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。 diff --git a/pkg/audio/asr/asr.go b/pkg/audio/asr/asr.go index 1482f40bb..a7c93e578 100644 --- a/pkg/audio/asr/asr.go +++ b/pkg/audio/asr/asr.go @@ -8,6 +8,12 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +const elevenLabsSupportedModelID = "scribe_v1" + +func ElevenLabsSupportedModelID() string { + return elevenLabsSupportedModelID +} + type Transcriber interface { Name() string Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) @@ -72,14 +78,23 @@ func whisperModelID(modelCfg *config.ModelConfig) string { return "" } +func isElevenLabsTranscriptionModel(modelCfg *config.ModelConfig) bool { + if modelCfg == nil || modelCfg.APIKey() == "" { + return false + } + + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "elevenlabs" +} + func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { if modelCfg == nil { return nil } - protocol, _ := providers.ExtractProtocol(modelCfg) - if protocol == "elevenlabs" && modelCfg.APIKey() != "" { - return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) + if isElevenLabsTranscriptionModel(modelCfg) { + _, modelID := providers.ExtractProtocol(modelCfg) + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID) } if modelID := whisperModelID(modelCfg); modelID != "" { return NewWhisperTranscriber(modelCfg) @@ -95,9 +110,9 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe return nil } - protocol, _ := providers.ExtractProtocol(modelCfg) - if protocol == "elevenlabs" && modelCfg.APIKey() != "" { - return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) + if isElevenLabsTranscriptionModel(modelCfg) { + _, modelID := providers.ExtractProtocol(modelCfg) + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID) } if modelID := whisperModelID(modelCfg); modelID != "" { return NewWhisperTranscriber(modelCfg) diff --git a/pkg/audio/asr/asr_test.go b/pkg/audio/asr/asr_test.go index 0970d69f4..f877b1198 100644 --- a/pkg/audio/asr/asr_test.go +++ b/pkg/audio/asr/asr_test.go @@ -46,6 +46,21 @@ func TestDetectTranscriber(t *testing.T) { }, wantName: "elevenlabs", }, + { + name: "explicit elevenlabs provider selects elevenlabs transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + }, + }, + wantName: "elevenlabs", + }, { name: "voice model name alias selects whisper transcriber for groq", cfg: &config.Config{ diff --git a/pkg/audio/asr/elevenlabs_transcriber.go b/pkg/audio/asr/elevenlabs_transcriber.go index 452b9512d..a89d62848 100644 --- a/pkg/audio/asr/elevenlabs_transcriber.go +++ b/pkg/audio/asr/elevenlabs_transcriber.go @@ -20,19 +20,24 @@ import ( type ElevenLabsTranscriber struct { apiKey string apiBase string + modelID string httpClient *http.Client } -func NewElevenLabsTranscriber(apiKey, apiBase string) *ElevenLabsTranscriber { +func NewElevenLabsTranscriber(apiKey, apiBase, modelID string) *ElevenLabsTranscriber { logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""}) if apiBase == "" { apiBase = "https://api.elevenlabs.io" } + if modelID == "" || modelID != ElevenLabsSupportedModelID() { + modelID = ElevenLabsSupportedModelID() + } return &ElevenLabsTranscriber{ apiKey: apiKey, apiBase: apiBase, + modelID: modelID, httpClient: &http.Client{ Timeout: 120 * time.Second, }, @@ -74,7 +79,7 @@ func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath st return nil, fmt.Errorf("failed to copy file content: %w", err) } - if err = writer.WriteField("model_id", "scribe_v1"); err != nil { + if err = writer.WriteField("model_id", t.modelID); err != nil { return nil, fmt.Errorf("failed to write model_id field: %w", err) } diff --git a/pkg/audio/asr/elevenlabs_transcriber_test.go b/pkg/audio/asr/elevenlabs_transcriber_test.go index fa80110be..bbc827578 100644 --- a/pkg/audio/asr/elevenlabs_transcriber_test.go +++ b/pkg/audio/asr/elevenlabs_transcriber_test.go @@ -3,10 +3,14 @@ package asr import ( "context" "encoding/json" + "io" + "mime" + "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -14,7 +18,7 @@ import ( var _ Transcriber = (*ElevenLabsTranscriber)(nil) func TestElevenLabsTranscriberName(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") if got := tr.Name(); got != "elevenlabs" { t.Errorf("Name() = %q, want %q", got, "elevenlabs") } @@ -35,6 +39,35 @@ func TestElevenLabsTranscribe(t *testing.T) { if r.Header.Get("Xi-Api-Key") != "sk_test" { t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key")) } + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotModelID string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error = %v", err) + } + if part.FormName() != "model_id" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll(part) error = %v", err) + } + gotModelID = strings.TrimSpace(string(body)) + } + if gotModelID != "scribe_v1" { + t.Fatalf("model_id = %q, want %q", gotModelID, "scribe_v1") + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(TranscriptionResponse{ Text: "hello from elevenlabs", @@ -43,7 +76,7 @@ func TestElevenLabsTranscribe(t *testing.T) { })) defer srv.Close() - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") tr.apiBase = srv.URL resp, err := tr.Transcribe(context.Background(), audioPath) @@ -64,7 +97,7 @@ func TestElevenLabsTranscribe(t *testing.T) { })) defer srv.Close() - tr := NewElevenLabsTranscriber("sk_bad", "") + tr := NewElevenLabsTranscriber("sk_bad", "", "scribe_v1") tr.apiBase = srv.URL _, err := tr.Transcribe(context.Background(), audioPath) @@ -74,10 +107,54 @@ func TestElevenLabsTranscribe(t *testing.T) { }) t.Run("missing file", func(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) if err == nil { t.Fatal("expected error for missing file, got nil") } }) + + t.Run("unsupported model falls back to scribe_v1", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotModelID string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error = %v", err) + } + if part.FormName() != "model_id" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll(part) error = %v", err) + } + gotModelID = strings.TrimSpace(string(body)) + } + if gotModelID != "scribe_v1" { + t.Fatalf("model_id = %q, want runtime fallback to %q", gotModelID, "scribe_v1") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"}) + })) + defer srv.Close() + + tr := NewElevenLabsTranscriber("sk_test", "", "unsupported-model") + tr.apiBase = srv.URL + + if _, err := tr.Transcribe(context.Background(), audioPath); err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + }) } diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 760506a31..d4d34211d 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -1,19 +1,17 @@ package line import ( - "bytes" "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" + "errors" "fmt" - "io" "net/http" "strings" "sync" "time" + "github.com/line/line-bot-sdk-go/v8/linebot/messaging_api" + "github.com/line/line-bot-sdk-go/v8/linebot/webhook" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -24,13 +22,7 @@ import ( ) const ( - lineAPIBase = "https://api.line.me/v2/bot" - lineDataAPIBase = "https://api-data.line.me/v2/bot" - lineReplyEndpoint = lineAPIBase + "/message/reply" - linePushEndpoint = lineAPIBase + "/message/push" - lineContentEndpoint = lineDataAPIBase + "/message/%s/content" - lineBotInfoEndpoint = lineAPIBase + "/info" - lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" + lineContentEndpoint = "https://api-data.line.me/v2/bot/message/%s/content" lineReplyTokenMaxAge = 25 * time.Second // Limit request body to prevent memory exhaustion (DoS). @@ -45,17 +37,16 @@ type replyTokenEntry struct { // LINEChannel implements the Channel interface for LINE Official Account // using the LINE Messaging API with HTTP webhook for receiving messages -// and REST API for sending messages. +// and the official LINE Bot SDK for sending messages. type LINEChannel struct { *channels.BaseChannel config *config.LINESettings - infoClient *http.Client // for bot info lookups (short timeout) - apiClient *http.Client // for messaging API calls - botUserID string // Bot's user ID - botBasicID string // Bot's basic ID (e.g. @216ru...) - botDisplayName string // Bot's display name for text-based mention detection - replyTokens sync.Map // chatID -> replyTokenEntry - quoteTokens sync.Map // chatID -> quoteToken (string) + client *messaging_api.MessagingApiAPI + botUserID string // Bot's user ID + botBasicID string // Bot's basic ID (e.g. @216ru...) + botDisplayName string // Bot's display name for text-based mention detection + replyTokens sync.Map // chatID -> replyTokenEntry + quoteTokens sync.Map // chatID -> quoteToken (string) ctx context.Context cancel context.CancelFunc } @@ -70,6 +61,14 @@ func NewLINEChannel( return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } + client, err := messaging_api.NewMessagingApiAPI( + cfg.ChannelAccessToken.String(), + messaging_api.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + ) + if err != nil { + return nil, fmt.Errorf("failed to create LINE messaging client: %w", err) + } + base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom, channels.WithMaxMessageLength(5000), channels.WithGroupTrigger(bc.GroupTrigger), @@ -79,8 +78,7 @@ func NewLINEChannel( return &LINEChannel{ BaseChannel: base, config: cfg, - infoClient: &http.Client{Timeout: 10 * time.Second}, - apiClient: &http.Client{Timeout: 30 * time.Second}, + client: client, }, nil } @@ -91,11 +89,15 @@ func (c *LINEChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) // Fetch bot profile to get bot's userId for mention detection - if err := c.fetchBotInfo(); err != nil { + info, err := c.client.WithContext(ctx).GetBotInfo() + if err != nil { logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{ "error": err.Error(), }) } else { + c.botUserID = info.UserId + c.botBasicID = info.BasicId + c.botDisplayName = info.DisplayName logger.InfoCF("line", "Bot info fetched", map[string]any{ "bot_user_id": c.botUserID, "basic_id": c.botBasicID, @@ -108,39 +110,6 @@ func (c *LINEChannel) Start(ctx context.Context) error { return nil } -// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API. -func (c *LINEChannel) fetchBotInfo() error { - req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String()) - - resp, err := c.infoClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("bot info API returned status %d", resp.StatusCode) - } - - var info struct { - UserID string `json:"userId"` - BasicID string `json:"basicId"` - DisplayName string `json:"displayName"` - } - if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { - return err - } - - c.botUserID = info.UserID - c.botBasicID = info.BasicID - c.botDisplayName = info.DisplayName - return nil -} - // Stop gracefully stops the LINE channel. func (c *LINEChannel) Stop(ctx context.Context) error { logger.InfoC("line", "Stopping LINE channel") @@ -174,140 +143,70 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { return } - body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1)) + // Limit body size to prevent memory exhaustion (DoS). + // ParseRequest reads r.Body internally via io.ReadAll; wrapping with + // MaxBytesReader ensures oversized payloads are rejected before full + // allocation. + r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodySize) + + cb, err := webhook.ParseRequest(c.config.ChannelSecret.String(), r) if err != nil { - logger.ErrorCF("line", "Failed to read request body", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Bad request", http.StatusBadRequest) - return - } - if int64(len(body)) > maxWebhookBodySize { - logger.WarnC("line", "Webhook request body too large, rejected") - http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) - return - } - - signature := r.Header.Get("X-Line-Signature") - if !c.verifySignature(body, signature) { - logger.WarnC("line", "Invalid webhook signature") - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - var payload struct { - Events []lineEvent `json:"events"` - } - if err := json.Unmarshal(body, &payload); err != nil { - logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Bad request", http.StatusBadRequest) + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + logger.WarnC("line", "Webhook request body too large, rejected") + http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) + } else if errors.Is(err, webhook.ErrInvalidSignature) { + logger.WarnC("line", "Invalid webhook signature") + http.Error(w, "Forbidden", http.StatusForbidden) + } else { + logger.ErrorCF("line", "Failed to parse webhook request", map[string]any{ + "error": err.Error(), + }) + http.Error(w, "Bad request", http.StatusBadRequest) + } return } // Return 200 immediately, process events asynchronously w.WriteHeader(http.StatusOK) - for _, event := range payload.Events { + for _, event := range cb.Events { go c.processEvent(event) } } -// verifySignature validates the X-Line-Signature using HMAC-SHA256. -func (c *LINEChannel) verifySignature(body []byte, signature string) bool { - if signature == "" { - return false - } - - mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret.String())) - mac.Write(body) - expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) - - return hmac.Equal([]byte(expected), []byte(signature)) -} - -// LINE webhook event types -type lineEvent struct { - Type string `json:"type"` - ReplyToken string `json:"replyToken"` - Source lineSource `json:"source"` - Message json.RawMessage `json:"message"` - Timestamp int64 `json:"timestamp"` -} - -type lineSource struct { - Type string `json:"type"` // "user", "group", "room" - UserID string `json:"userId"` - GroupID string `json:"groupId"` - RoomID string `json:"roomId"` -} - -type lineMessage struct { - ID string `json:"id"` - Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker" - Text string `json:"text"` - QuoteToken string `json:"quoteToken"` - Mention *struct { - Mentionees []lineMentionee `json:"mentionees"` - } `json:"mention"` - ContentProvider struct { - Type string `json:"type"` - } `json:"contentProvider"` -} - -type lineMentionee struct { - Index int `json:"index"` - Length int `json:"length"` - Type string `json:"type"` // "user", "all" - UserID string `json:"userId"` -} - -func (c *LINEChannel) processEvent(event lineEvent) { - if event.Type != "message" { +func (c *LINEChannel) processEvent(event webhook.EventInterface) { + msgEvent, ok := event.(webhook.MessageEvent) + if !ok { logger.DebugCF("line", "Ignoring non-message event", map[string]any{ - "type": event.Type, + "type": event.GetType(), }) return } - senderID := event.Source.UserID - chatID := c.resolveChatID(event.Source) - isGroup := event.Source.Type == "group" || event.Source.Type == "room" - - var msg lineMessage - if err := json.Unmarshal(event.Message, &msg); err != nil { - logger.ErrorCF("line", "Failed to parse message", map[string]any{ - "error": err.Error(), - }) - return - } + senderID, chatID, sourceType := c.resolveSource(msgEvent.Source) + isGroup := sourceType == "group" || sourceType == "room" // Store reply token for later use - if event.ReplyToken != "" { + if msgEvent.ReplyToken != "" { c.replyTokens.Store(chatID, replyTokenEntry{ - token: event.ReplyToken, + token: msgEvent.ReplyToken, timestamp: time.Now(), }) } - // Store quote token for quoting the original message in reply - if msg.QuoteToken != "" { - c.quoteTokens.Store(chatID, msg.QuoteToken) - } - var content string var mediaPaths []string - - scope := channels.BuildMediaScope("line", chatID, msg.ID) + var messageID string + var quoteToken string + var isMentioned bool // Helper to register a local file with the media store - storeMedia := func(localPath, filename string) string { + storeMedia := func(localPath, filename, scope string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ - Filename: filename, - Source: "line", - CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, + Filename: filename, + Source: "line", }, scope) if err == nil { return ref @@ -316,37 +215,70 @@ func (c *LINEChannel) processEvent(event lineEvent) { return localPath // fallback } - switch msg.Type { - case "text": + switch msg := msgEvent.Message.(type) { + case webhook.TextMessageContent: + messageID = msg.Id content = msg.Text + isMentioned = c.isBotMentioned(msg) + // Store quote token for quoting the original message in reply + if msg.QuoteToken != "" { + quoteToken = msg.QuoteToken + c.quoteTokens.Store(chatID, msg.QuoteToken) + } // Strip bot mention from text in group chats if isGroup { content = c.stripBotMention(content, msg) } - case "image": - localPath := c.downloadContent(msg.ID, "image.jpg") - if localPath != "" { - mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg")) + case webhook.ImageMessageContent: + messageID = msg.Id + if msg.QuoteToken != "" { + quoteToken = msg.QuoteToken + c.quoteTokens.Store(chatID, msg.QuoteToken) + } + if localPath := c.downloadContent(msg.Id, "image.jpg"); localPath != "" { + scope := channels.BuildMediaScope("line", chatID, msg.Id) + mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg", scope)) content = "[image]" } - case "audio": - localPath := c.downloadContent(msg.ID, "audio.m4a") - if localPath != "" { - mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a")) + case webhook.AudioMessageContent: + messageID = msg.Id + if localPath := c.downloadContent(msg.Id, "audio.m4a"); localPath != "" { + scope := channels.BuildMediaScope("line", chatID, msg.Id) + mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a", scope)) content = "[audio]" } - case "video": - localPath := c.downloadContent(msg.ID, "video.mp4") - if localPath != "" { - mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4")) + case webhook.VideoMessageContent: + messageID = msg.Id + if msg.QuoteToken != "" { + quoteToken = msg.QuoteToken + c.quoteTokens.Store(chatID, msg.QuoteToken) + } + if localPath := c.downloadContent(msg.Id, "video.mp4"); localPath != "" { + scope := channels.BuildMediaScope("line", chatID, msg.Id) + mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4", scope)) content = "[video]" } - case "file": + case webhook.FileMessageContent: + messageID = msg.Id content = "[file]" - case "sticker": + case webhook.LocationMessageContent: + messageID = msg.Id + content = "[location]" + if msg.Title != "" { + content = fmt.Sprintf("[location: %s]", msg.Title) + } + case webhook.StickerMessageContent: + messageID = msg.Id + if msg.QuoteToken != "" { + quoteToken = msg.QuoteToken + c.quoteTokens.Store(chatID, msg.QuoteToken) + } content = "[sticker]" default: - content = fmt.Sprintf("[%s]", msg.Type) + logger.DebugCF("line", "Ignoring unsupported message type", map[string]any{ + "type": msgEvent.Message.GetType(), + }) + return } if strings.TrimSpace(content) == "" { @@ -354,9 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { } // In group chats, apply unified group trigger filtering - isMentioned := false if isGroup { - isMentioned = c.isBotMentioned(msg) respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{ @@ -369,13 +299,13 @@ func (c *LINEChannel) processEvent(event lineEvent) { metadata := map[string]string{ "platform": "line", - "source_type": event.Source.Type, + "source_type": sourceType, } logger.DebugCF("line", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, - "message_type": msg.Type, + "message_type": msgEvent.Message.GetType(), "is_group": isGroup, "preview": utils.Truncate(content, 50), }) @@ -395,16 +325,16 @@ func (c *LINEChannel) processEvent(event lineEvent) { ChatID: chatID, ChatType: map[bool]string{true: "group", false: "direct"}[isGroup], SenderID: senderID, - MessageID: msg.ID, + MessageID: messageID, Mentioned: isMentioned, Raw: metadata, } - if event.ReplyToken != "" { + if msgEvent.ReplyToken != "" { inboundCtx.ReplyHandles = map[string]string{ - "reply_token": event.ReplyToken, + "reply_token": msgEvent.ReplyToken, } - if msg.QuoteToken != "" { - inboundCtx.ReplyHandles["quote_token"] = msg.QuoteToken + if quoteToken != "" { + inboundCtx.ReplyHandles["quote_token"] = quoteToken } } @@ -412,30 +342,28 @@ func (c *LINEChannel) processEvent(event lineEvent) { } // isBotMentioned checks if the bot is mentioned in the message. -// It first checks the mention metadata (userId match), then falls back +// It first checks the mention metadata (userId match or IsSelf), then falls back // to text-based detection using the bot's display name, since LINE may // not include userId in mentionees for Official Accounts. -func (c *LINEChannel) isBotMentioned(msg lineMessage) bool { - // Check mention metadata +func (c *LINEChannel) isBotMentioned(msg webhook.TextMessageContent) bool { if msg.Mention != nil { for _, m := range msg.Mention.Mentionees { - if m.Type == "all" { + switch mentionee := m.(type) { + case webhook.AllMentionee: return true - } - if c.botUserID != "" && m.UserID == c.botUserID { - return true - } - } - // Mention metadata exists with mentionees but bot not matched by userId. - // The bot IS likely mentioned (LINE includes mention struct when bot is @-ed), - // so check if any mentionee overlaps with bot display name in text. - if c.botDisplayName != "" { - for _, m := range msg.Mention.Mentionees { - if m.Index >= 0 && m.Length > 0 { + case webhook.UserMentionee: + if mentionee.IsSelf { + return true + } + if c.botUserID != "" && mentionee.UserId == c.botUserID { + return true + } + // Check if mentionee text overlaps with bot display name + if c.botDisplayName != "" && mentionee.Index >= 0 && mentionee.Length > 0 { runes := []rune(msg.Text) - end := m.Index + m.Length + end := int(mentionee.Index) + int(mentionee.Length) if end <= len(runes) { - mentionText := string(runes[m.Index:end]) + mentionText := string(runes[mentionee.Index:end]) if strings.Contains(mentionText, c.botDisplayName) { return true } @@ -454,30 +382,43 @@ func (c *LINEChannel) isBotMentioned(msg lineMessage) bool { } // stripBotMention removes the @BotName mention text from the message. -func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string { +func (c *LINEChannel) stripBotMention(text string, msg webhook.TextMessageContent) string { stripped := false - // Try to strip using mention metadata indices if msg.Mention != nil { runes := []rune(text) for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- { m := msg.Mention.Mentionees[i] - // Strip if userId matches OR if the mention text contains the bot display name shouldStrip := false - if c.botUserID != "" && m.UserID == c.botUserID { - shouldStrip = true - } else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 { - end := m.Index + m.Length - if end <= len(runes) { - mentionText := string(runes[m.Index:end]) - if strings.Contains(mentionText, c.botDisplayName) { - shouldStrip = true + var index, length int32 + + switch mentionee := m.(type) { + case webhook.UserMentionee: + index = mentionee.Index + length = mentionee.Length + if mentionee.IsSelf { + shouldStrip = true + } else if c.botUserID != "" && mentionee.UserId == c.botUserID { + shouldStrip = true + } else if c.botDisplayName != "" && index >= 0 && length > 0 { + end := int(index) + int(length) + if end <= len(runes) { + mentionText := string(runes[index:end]) + if strings.Contains(mentionText, c.botDisplayName) { + shouldStrip = true + } } } + case webhook.AllMentionee: + // Don't strip @All mentions + continue + default: + continue } + if shouldStrip { - start := m.Index - end := m.Index + m.Length + start := int(index) + end := int(index) + int(length) if start >= 0 && end <= len(runes) { runes = append(runes[:start], runes[end:]...) stripped = true @@ -497,16 +438,20 @@ func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string { return strings.TrimSpace(text) } -// resolveChatID determines the chat ID from the event source. -// For group/room messages, use the group/room ID; for 1:1, use the user ID. -func (c *LINEChannel) resolveChatID(source lineSource) string { - switch source.Type { - case "group": - return source.GroupID - case "room": - return source.RoomID +// resolveSource extracts senderID, chatID, and source type from the event source. +func (c *LINEChannel) resolveSource(source webhook.SourceInterface) (senderID, chatID, sourceType string) { + switch src := source.(type) { + case webhook.GroupSource: + return src.UserId, src.GroupId, "group" + case webhook.RoomSource: + return src.UserId, src.RoomId, "room" + case webhook.UserSource: + return src.UserId, src.UserId, "user" default: - return source.UserID + logger.WarnCF("line", "Unknown source type", map[string]any{ + "type": fmt.Sprintf("%T", source), + }) + return "", "", "unknown" } } @@ -523,23 +468,41 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri quoteToken = qt.(string) } + textMsg := messaging_api.TextMessage{ + Text: msg.Content, + QuoteToken: quoteToken, + } + // Try reply token first (free, valid for ~25 seconds) if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { tokenEntry := entry.(replyTokenEntry) if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { - if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil { + resp, _, err := c.client.WithContext(ctx).ReplyMessageWithHttpInfo(&messaging_api.ReplyMessageRequest{ + ReplyToken: tokenEntry.token, + Messages: []messaging_api.MessageInterface{&textMsg}, + }) + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + if err == nil { logger.DebugCF("line", "Message sent via Reply API", map[string]any{ "chat_id": msg.ChatID, "quoted": quoteToken != "", }) return nil, nil } - logger.DebugC("line", "Reply API failed, falling back to Push API") + logger.DebugCF("line", "Reply API failed, falling back to Push API", map[string]any{ + "error": err.Error(), + }) } } // Fall back to Push API - return nil, c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) + resp, _, err := c.client.WithContext(ctx).PushMessageWithHttpInfo(&messaging_api.PushMessageRequest{ + To: msg.ChatID, + Messages: []messaging_api.MessageInterface{&textMsg}, + }, "") + return nil, classifySDKError(resp, err) } // SendMedia implements the channels.MediaSender interface. @@ -564,46 +527,19 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) } - if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil { - return nil, err + textMsg := messaging_api.TextMessage{Text: caption} + resp, _, err := c.client.WithContext(ctx).PushMessageWithHttpInfo(&messaging_api.PushMessageRequest{ + To: msg.ChatID, + Messages: []messaging_api.MessageInterface{&textMsg}, + }, "") + if sdkErr := classifySDKError(resp, err); sdkErr != nil { + return nil, sdkErr } } return nil, nil } -// buildTextMessage creates a text message object, optionally with quoteToken. -func buildTextMessage(content, quoteToken string) map[string]string { - msg := map[string]string{ - "type": "text", - "text": content, - } - if quoteToken != "" { - msg["quoteToken"] = quoteToken - } - return msg -} - -// sendReply sends a message using the LINE Reply API. -func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error { - payload := map[string]any{ - "replyToken": replyToken, - "messages": []map[string]string{buildTextMessage(content, quoteToken)}, - } - - return c.callAPI(ctx, lineReplyEndpoint, payload) -} - -// sendPush sends a message using the LINE Push API. -func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error { - payload := map[string]any{ - "to": to, - "messages": []map[string]string{buildTextMessage(content, quoteToken)}, - } - - return c.callAPI(ctx, linePushEndpoint, payload) -} - // StartTyping implements channels.TypingCapable using LINE's loading animation. // // NOTE: The LINE loading animation API only works for 1:1 chats. @@ -649,48 +585,31 @@ func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), e return stop, nil } +// classifySDKError maps an SDK HTTP response to the project's sentinel errors. +func classifySDKError(resp *http.Response, err error) error { + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + if err == nil { + return nil + } + if resp != nil { + return channels.ClassifySendError(resp.StatusCode, err) + } + return channels.ClassifyNetError(err) +} + // sendLoading sends a loading animation indicator to the chat. func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error { - payload := map[string]any{ - "chatId": chatID, - "loadingSeconds": 60, + req := &messaging_api.ShowLoadingAnimationRequest{ + ChatId: chatID, + LoadingSeconds: 60, } - return c.callAPI(ctx, lineLoadingEndpoint, payload) + resp, _, err := c.client.WithContext(ctx).ShowLoadingAnimationWithHttpInfo(req) + return classifySDKError(resp, err) } -// callAPI makes an authenticated POST request to the LINE API. -func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error { - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal payload: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String()) - - resp, err := c.apiClient.Do(req) - if err != nil { - return channels.ClassifyNetError(err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err)) - } - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody))) - } - - return nil -} - -// downloadContent downloads media content from the LINE API. +// downloadContent downloads media content from the LINE content API. func (c *LINEChannel) downloadContent(messageID, filename string) string { url := fmt.Sprintf(lineContentEndpoint, messageID) return utils.DownloadFile(url, filename, utils.DownloadOptions{ diff --git a/pkg/channels/line/line_test.go b/pkg/channels/line/line_test.go index c5f4e9be2..83af04a0d 100644 --- a/pkg/channels/line/line_test.go +++ b/pkg/channels/line/line_test.go @@ -11,7 +11,7 @@ import ( ) func TestWebhookRejectsOversizedBody(t *testing.T) { - ch := &LINEChannel{} + ch := &LINEChannel{config: &config.LINESettings{}} oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) @@ -25,7 +25,7 @@ func TestWebhookRejectsOversizedBody(t *testing.T) { } func TestWebhookAcceptsMaxBodySize(t *testing.T) { - ch := &LINEChannel{} + ch := &LINEChannel{config: &config.LINESettings{}} body := bytes.Repeat([]byte("A"), maxWebhookBodySize) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) @@ -40,7 +40,7 @@ func TestWebhookAcceptsMaxBodySize(t *testing.T) { } func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) { - ch := &LINEChannel{} + ch := &LINEChannel{config: &config.LINESettings{}} oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) @@ -55,7 +55,7 @@ func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) { } func TestWebhookRejectsNonPostMethod(t *testing.T) { - ch := &LINEChannel{} + ch := &LINEChannel{config: &config.LINESettings{}} req := httptest.NewRequest(http.MethodGet, "/webhook", nil) rec := httptest.NewRecorder() diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index a7e401bb8..e268812a0 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -8,6 +8,7 @@ func BuiltinDefinitions() []Definition { return []Definition{ startCommand(), helpCommand(), + stopCommand(), showCommand(), listCommand(), useCommand(), diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index efd27fa00..bb9abe360 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -42,6 +42,9 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") { t.Fatalf("/help reply missing /list usage, got %q", reply) } + if !strings.Contains(reply, "/stop") { + t.Fatalf("/help reply missing /stop usage, got %q", reply) + } if !strings.Contains(reply, "/use ") { if !strings.Contains(reply, "/use [message]") { t.Fatalf("/help reply missing /use usage, got %q", reply) @@ -49,6 +52,59 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { } } +func TestBuiltinStop_UsesRuntimeStopper(t *testing.T) { + rt := &Runtime{ + StopActiveTurn: func() (StopResult, error) { + return StopResult{ + Stopped: true, + TaskName: "sync the long running job", + }, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/stop", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/stop: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Task stopped. \"sync the long running job\" was canceled." { + t.Fatalf("/stop reply=%q", reply) + } +} + +func TestBuiltinStop_NoActiveTask(t *testing.T) { + rt := &Runtime{ + StopActiveTurn: func() (StopResult, error) { + return StopResult{}, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/stop", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/stop: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "No active task to stop." { + t.Fatalf("/stop reply=%q, want no-active message", reply) + } +} + func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) { defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), nil) diff --git a/pkg/commands/cmd_stop.go b/pkg/commands/cmd_stop.go new file mode 100644 index 000000000..147688bdc --- /dev/null +++ b/pkg/commands/cmd_stop.go @@ -0,0 +1,52 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func stopCommand() Definition { + return Definition{ + Name: "stop", + Description: "Stop the current task", + Usage: "/stop", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.StopActiveTurn == nil { + return req.Reply(unavailableMsg) + } + + result, err := rt.StopActiveTurn() + if err != nil { + return req.Reply("Failed to stop task: " + err.Error()) + } + + return req.Reply(FormatStopReply(result)) + }, + } +} + +// FormatStopReply renders a user-facing reply for a stop request. +func FormatStopReply(result StopResult) string { + if !result.Stopped { + return "No active task to stop." + } + + taskName := compactStopTaskName(result.TaskName) + if taskName == "" { + return "Task stopped. Current task was canceled." + } + + return fmt.Sprintf("Task stopped. %q was canceled.", taskName) +} + +func compactStopTaskName(taskName string) string { + taskName = strings.Join(strings.Fields(strings.TrimSpace(taskName)), " ") + if taskName == "" { + return "" + } + if len(taskName) > 80 { + return taskName[:77] + "..." + } + return taskName +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index c17b7cf1c..b0327c863 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -36,6 +36,12 @@ type ContextStats struct { MessageCount int } +// StopResult describes the outcome of a stop request for the current session. +type StopResult struct { + Stopped bool + TaskName string +} + // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) // can coexist with long-lived callbacks (like GetModelInfo). @@ -55,4 +61,5 @@ type Runtime struct { SwitchChannel func(value string) error ClearHistory func() error ReloadConfig func() error + StopActiveTurn func() (StopResult, error) } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index a59e2de25..aa99d6d38 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -110,19 +110,7 @@ func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) { if provider := strings.TrimSpace(cfg.Provider); provider != "" { return NormalizeProvider(provider), model } - if model == "" { - return "", "" - } - - protocol, rest, found := strings.Cut(model, "/") - if !found { - return "openai", model - } - protocol = strings.TrimSpace(protocol) - if protocol == "" { - return "", strings.TrimSpace(rest) - } - return NormalizeProvider(protocol), strings.TrimSpace(rest) + return SplitModelProviderAndID(model, "openai") } // ResolveAPIBase returns the configured API base, or the protocol default when @@ -154,6 +142,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } protocol, modelID := ExtractProtocol(cfg) + authMethod := strings.ToLower(strings.TrimSpace(cfg.AuthMethod)) userAgent := cfg.UserAgent if userAgent == "" { @@ -163,7 +152,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err switch protocol { case "openai": // OpenAI with OAuth/token auth (Codex-style) - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if authMethod == "oauth" || authMethod == "token" { provider, err := createCodexAuthProvider() if err != nil { return nil, "", err @@ -320,7 +309,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return finalizeProviderFromConfig(provider, modelID, cfg) case "anthropic": - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if authMethod == "oauth" || authMethod == "token" { // Use OAuth credentials from auth store provider, err := createClaudeAuthProvider() if err != nil { @@ -431,7 +420,7 @@ func finalizeProviderFromConfig( } func isEmptyAPIKeyAllowed(protocol string) bool { - meta, ok := protocolMetaByName[protocol] + meta, ok := protocolMetaForName(protocol) return ok && meta.emptyAPIKeyAllowed } @@ -451,9 +440,19 @@ func DefaultAPIBaseForProtocol(protocol string) string { // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { - meta, ok := protocolMetaByName[protocol] + meta, ok := protocolMetaForName(protocol) if !ok { return "" } return meta.defaultAPIBase } + +func protocolMetaForName(protocol string) (protocolMeta, bool) { + if meta, ok := protocolMetaByName[protocol]; ok { + return meta, true + } + if meta, ok := attachedModelProviderMetaByName[protocol]; ok { + return meta.protocolMeta, true + } + return protocolMeta{}, false +} diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 3d3c30ce0..eb9b3d600 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) @@ -101,6 +102,12 @@ func TestExtractProtocol(t *testing.T) { wantProtocol: "", wantModelID: "gpt-4o", }, + { + name: "unknown prefix falls back to openai", + config: &config.ModelConfig{Model: "meta-llama/Llama-3.1-8B-Instruct"}, + wantProtocol: "openai", + wantModelID: "meta-llama/Llama-3.1-8B-Instruct", + }, { name: "nil config", wantProtocol: "", @@ -605,6 +612,41 @@ func TestCreateProviderFromConfig_CodexCLI(t *testing.T) { } } +func TestCreateProviderFromConfig_OpenAIMixedCaseAuthMethodUsesOAuthBranch(t *testing.T) { + origGetCredential := getCredential + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "openai" { + t.Fatalf("provider = %q, want %q", provider, "openai") + } + return &auth.AuthCredential{ + AccessToken: "test-token", + AccountID: "acct-test", + Provider: "openai", + AuthMethod: "oauth", + }, nil + } + t.Cleanup(func() { + getCredential = origGetCredential + }) + + cfg := &config.ModelConfig{ + ModelName: "test-openai-oauth", + Model: "openai/gpt-5.4", + AuthMethod: "OAuth", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "gpt-5.4" { + t.Errorf("modelID = %q, want %q", modelID, "gpt-5.4") + } +} + func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-no-key", @@ -619,8 +661,9 @@ func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { cfg := &config.ModelConfig{ - ModelName: "test-unknown", - Model: "unknown-protocol/model", + ModelName: "test-unknown-provider", + Provider: "unknown-protocol", + Model: "model", } cfg.SetAPIKey("test-key") @@ -630,6 +673,26 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { } } +func TestCreateProviderFromConfig_UnknownModelPrefixDefaultsToOpenAI(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-unknown-model-prefix", + Model: "meta-llama/Llama-3.1-8B-Instruct", + APIBase: "https://api.example.com/v1", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("modelID = %q, want full model ID", modelID) + } +} + func TestCreateProviderFromConfig_NilConfig(t *testing.T) { _, _, err := CreateProviderFromConfig(nil) if err == nil { @@ -889,6 +952,71 @@ func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { } } +func TestModelProviderOptions(t *testing.T) { + options := ModelProviderOptions() + if len(options) == 0 { + t.Fatal("ModelProviderOptions() returned no options") + } + + seen := make(map[string]ModelProviderOption, len(options)) + for _, option := range options { + seen[option.ID] = option + } + + if _, ok := seen["openai"]; !ok { + t.Fatal("openai option missing") + } + if option, ok := seen["openai"]; ok && !option.CreateAllowed { + t.Fatal("openai should be creatable") + } + if option, ok := seen["lmstudio"]; !ok { + t.Fatal("lmstudio option missing") + } else if !option.EmptyAPIKeyAllowed { + t.Fatal("lmstudio should allow empty API keys") + } + if option, ok := seen["anthropic"]; !ok { + t.Fatal("anthropic option missing") + } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { + t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") + } + if _, ok := seen["azure"]; !ok { + t.Fatal("azure option missing") + } + if option, ok := seen["bedrock"]; !ok { + t.Fatal("bedrock option missing") + } else if !option.CreateAllowed { + t.Fatal("bedrock should be creatable and defer credential/build errors to runtime") + } + if option, ok := seen["elevenlabs"]; !ok { + t.Fatal("elevenlabs option missing") + } else { + if option.DefaultAPIBase != "https://api.elevenlabs.io" { + t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") + } + if option.DefaultModelAllowed { + t.Fatal("elevenlabs should be ASR-only and therefore not allowed as a default chat model") + } + } + if option, ok := seen["antigravity"]; !ok { + t.Fatal("antigravity option missing") + } else { + if !option.CreateAllowed { + t.Fatal("antigravity should be creatable") + } + if option.DefaultAuthMethod != "oauth" { + t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") + } + if !option.AuthMethodLocked { + t.Fatal("antigravity auth method should be locked") + } + } + if option, ok := seen["github-copilot"]; !ok { + t.Fatal("github-copilot option missing") + } else if option.DefaultAPIBase != "localhost:4321" { + t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } +} + func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) { var requestBody map[string]any diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go index be9f63bc6..48e3fb4cb 100644 --- a/pkg/providers/model_ref.go +++ b/pkg/providers/model_ref.go @@ -17,18 +17,13 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef { return nil } - if idx := strings.Index(raw, "/"); idx > 0 { - provider := NormalizeProvider(raw[:idx]) - model := strings.TrimSpace(raw[idx+1:]) - if model == "" { - return nil - } - return &ModelRef{Provider: provider, Model: model} + provider, model := SplitModelProviderAndID(raw, defaultProvider) + if model == "" { + return nil } - return &ModelRef{ - Provider: NormalizeProvider(defaultProvider), - Model: raw, + Provider: provider, + Model: model, } } @@ -53,6 +48,8 @@ func NormalizeProvider(provider string) string { return "zhipu" case "google": return "gemini" + case "google-antigravity": + return "antigravity" case "alibaba-coding", "qwen-coding": return "coding-plan" case "alibaba-coding-anthropic": @@ -61,6 +58,14 @@ func NormalizeProvider(provider string) string { return "qwen-intl" case "dashscope-us": return "qwen-us" + case "azure-openai": + return "azure" + case "claudecli": + return "claude-cli" + case "codexcli": + return "codex-cli" + case "copilot": + return "github-copilot" } return p diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 040c511ba..9a164bf48 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -72,7 +72,12 @@ func TestNormalizeProvider(t *testing.T) { {"claude", "anthropic"}, {"glm", "zhipu"}, {"google", "gemini"}, + {"google-antigravity", "antigravity"}, {"groq", "groq"}, + {"azure-openai", "azure"}, + {"claudecli", "claude-cli"}, + {"codexcli", "codex-cli"}, + {"copilot", "github-copilot"}, // Alibaba Coding Plan aliases {"alibaba-coding", "coding-plan"}, {"qwen-coding", "coding-plan"}, @@ -131,3 +136,42 @@ func TestParseModelRef_DefaultProviderNormalization(t *testing.T) { t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider) } } + +func TestParseModelRef_UnknownPrefixFallsBackToDefaultProvider(t *testing.T) { + ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "openai" { + t.Fatalf("provider = %q, want openai", ref.Provider) + } + if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("model = %q, want full original model ID", ref.Model) + } +} + +func TestParseModelRef_UnknownPrefixPreservesEmptyDefaultProvider(t *testing.T) { + ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "" { + t.Fatalf("provider = %q, want empty", ref.Provider) + } + if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("model = %q, want full original model ID", ref.Model) + } +} + +func TestParseModelRef_KnownNonSelectableProvider(t *testing.T) { + ref := ParseModelRef("bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "bedrock" { + t.Fatalf("provider = %q, want bedrock", ref.Provider) + } + if ref.Model != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want preserved bedrock model ID", ref.Model) + } +} diff --git a/pkg/providers/provider_catalog.go b/pkg/providers/provider_catalog.go new file mode 100644 index 000000000..a9178cb81 --- /dev/null +++ b/pkg/providers/provider_catalog.go @@ -0,0 +1,181 @@ +package providers + +import ( + "sort" + "strings" +) + +// ModelProviderOption describes a canonical provider entry exposed to the Web UI. +type ModelProviderOption struct { + ID string `json:"id"` + DefaultAPIBase string `json:"default_api_base"` + EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"` + CreateAllowed bool `json:"create_allowed"` + DefaultModelAllowed bool `json:"default_model_allowed"` + DefaultAuthMethod string `json:"default_auth_method,omitempty"` + AuthMethodLocked bool `json:"auth_method_locked,omitempty"` +} + +type attachedModelProviderMeta struct { + protocolMeta + createAllowed bool + defaultModelAllowed bool + defaultAuthMethod string + authMethodLocked bool +} + +// attachedModelProviderMetaByName augments protocolMetaByName for provider +// families that are implemented in CreateProviderFromConfig but intentionally +// kept out of the core HTTP metadata map because they have special auth/runtime +// semantics. +var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{ + "azure": {createAllowed: true, defaultModelAllowed: true}, + "anthropic": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, + }, + "anthropic-messages": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, + }, + "bedrock": {createAllowed: true, defaultModelAllowed: true}, + "antigravity": { + createAllowed: true, + defaultModelAllowed: true, + defaultAuthMethod: "oauth", + authMethodLocked: true, + }, + "claude-cli": {createAllowed: true, defaultModelAllowed: true}, + "codex-cli": {createAllowed: true, defaultModelAllowed: true}, + "github-copilot": { + protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"}, + createAllowed: true, + defaultModelAllowed: true, + }, + // ElevenLabs is intentionally exposed only as an ASR-capable provider. It + // belongs in the shared model catalog because ASR is configured via + // model_list, but it must not be selectable as the default chat model. + "elevenlabs": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.elevenlabs.io"}, + createAllowed: true, + defaultModelAllowed: false, + }, +} + +// ModelProviderOptions returns the canonical provider catalog exposed to the Web UI. +func ModelProviderOptions() []ModelProviderOption { + optionsByID := make(map[string]ModelProviderOption, len(protocolMetaByName)+len(attachedModelProviderMetaByName)) + for provider := range protocolMetaByName { + if NormalizeProvider(provider) != provider { + continue + } + optionsByID[provider] = ModelProviderOption{ + ID: provider, + DefaultAPIBase: DefaultAPIBaseForProtocol(provider), + EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider), + CreateAllowed: true, + DefaultModelAllowed: true, + } + } + for provider, meta := range attachedModelProviderMetaByName { + if NormalizeProvider(provider) != provider { + continue + } + optionsByID[provider] = ModelProviderOption{ + ID: provider, + DefaultAPIBase: meta.defaultAPIBase, + EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed, + CreateAllowed: meta.createAllowed, + DefaultModelAllowed: meta.defaultModelAllowed, + DefaultAuthMethod: meta.defaultAuthMethod, + AuthMethodLocked: meta.authMethodLocked, + } + } + + options := make([]ModelProviderOption, 0, len(optionsByID)) + for _, option := range optionsByID { + options = append(options, option) + } + sort.Slice(options, func(i, j int) bool { + return options[i].ID < options[j].ID + }) + return options +} + +// IsSupportedModelProvider reports whether provider resolves to a provider ID +// returned by ModelProviderOptions. +func IsSupportedModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + _, ok := attachedModelProviderMetaByName[normalized] + return ok +} + +// IsCreatableModelProvider reports whether provider can be selected for a new +// model entry from the Web UI. +func IsCreatableModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + meta, ok := attachedModelProviderMetaByName[normalized] + return ok && meta.createAllowed +} + +// IsDefaultModelProvider reports whether provider can be used as the default +// chat model. Some providers such as ASR-only entries are intentionally +// exposed in model_list management but cannot drive the gateway default model. +func IsDefaultModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + meta, ok := attachedModelProviderMetaByName[normalized] + return ok && meta.defaultModelAllowed +} + +// SplitModelProviderAndID separates a legacy "provider/model" string into its +// effective provider and canonical model ID. Unknown prefixes are treated as +// part of the model ID and fall back to defaultProvider. +func SplitModelProviderAndID(model, defaultProvider string) (provider, modelID string) { + model = strings.TrimSpace(model) + if model == "" { + return "", "" + } + + provider, modelID = splitKnownProviderModel(model) + if provider != "" || modelID != "" { + return provider, modelID + } + + return NormalizeProvider(defaultProvider), model +} + +func splitKnownProviderModel(model string) (provider, modelID string) { + provider, modelID, found := strings.Cut(strings.TrimSpace(model), "/") + if !found { + return "", "" + } + provider = strings.TrimSpace(provider) + modelID = strings.TrimSpace(modelID) + if provider == "" { + return "", modelID + } + if !IsSupportedModelProvider(provider) { + return "", "" + } + return NormalizeProvider(provider), modelID +} diff --git a/pkg/tools/delegate.go b/pkg/tools/delegate.go new file mode 100644 index 000000000..dcde27718 --- /dev/null +++ b/pkg/tools/delegate.go @@ -0,0 +1,104 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/routing" +) + +// DelegateTool delegates a task to a specific named agent and waits for +// the result. Unlike spawn (async, fire-and-forget) or subagent (sync but +// generic), delegate targets a named agent and runs the task using that +// agent's own workspace, model, and tools. +type DelegateTool struct { + spawner SubTurnSpawner + allowlistCheck func(targetAgentID string) bool + selfAgentID string +} + +func NewDelegateTool() *DelegateTool { + return &DelegateTool{} +} + +func (t *DelegateTool) SetSpawner(spawner SubTurnSpawner) { + t.spawner = spawner +} + +func (t *DelegateTool) SetAllowlistChecker(check func(targetAgentID string) bool) { + t.allowlistCheck = check +} + +func (t *DelegateTool) SetSelfAgentID(id string) { + t.selfAgentID = id +} + +func (t *DelegateTool) Name() string { + return "delegate" +} + +func (t *DelegateTool) Description() string { + return "Delegate a task to another agent and wait for the result. " + + "Use this when another agent is better suited to handle a specific task " + + "based on their capabilities. The target agent runs with its own workspace, " + + "model, and tools." +} + +func (t *DelegateTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "agent_id": map[string]any{ + "type": "string", + "description": "The ID of the target agent to delegate the task to", + }, + "task": map[string]any{ + "type": "string", + "description": "Clear description of the task to delegate", + }, + }, + "required": []string{"agent_id", "task"}, + } +} + +func (t *DelegateTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + rawAgentID, _ := args["agent_id"].(string) + if strings.TrimSpace(rawAgentID) == "" { + return ErrorResult("agent_id is required and must be a non-empty string") + } + agentID := routing.NormalizeAgentID(rawAgentID) + + task, _ := args["task"].(string) + if strings.TrimSpace(task) == "" { + return ErrorResult("task is required and must be a non-empty string") + } + + if t.selfAgentID != "" && agentID == t.selfAgentID { + return ErrorResult("cannot delegate to self") + } + + if t.allowlistCheck != nil && !t.allowlistCheck(agentID) { + return ErrorResult(fmt.Sprintf("not allowed to delegate to agent %q", agentID)) + } + + if t.spawner == nil { + return ErrorResult("delegate tool not configured") + } + + result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{ + TargetAgentID: agentID, + SystemPrompt: task, + Async: false, + }) + if err != nil { + return ErrorResult(fmt.Sprintf("delegation to agent %q failed: %v", agentID, err)).WithError(err) + } + if result == nil { + return ErrorResult(fmt.Sprintf("delegation to agent %q returned no result", agentID)) + } + + result.ForLLM = fmt.Sprintf("[Response from agent %q]\n%s", agentID, result.ForLLM) + + return result +} diff --git a/pkg/tools/delegate_test.go b/pkg/tools/delegate_test.go new file mode 100644 index 000000000..729c524a7 --- /dev/null +++ b/pkg/tools/delegate_test.go @@ -0,0 +1,300 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// delegateMockSpawner records the config and returns a canned result. +type delegateMockSpawner struct { + lastCfg SubTurnConfig + result *ToolResult + err error +} + +func (m *delegateMockSpawner) SpawnSubTurn(_ context.Context, cfg SubTurnConfig) (*ToolResult, error) { + m.lastCfg = cfg + if m.err != nil { + return nil, m.err + } + if m.result != nil { + return m.result, nil + } + return &ToolResult{ + ForLLM: "completed: " + cfg.SystemPrompt, + ForUser: "completed", + }, nil +} + +func TestDelegateTool_Name(t *testing.T) { + tool := NewDelegateTool() + if tool.Name() != "delegate" { + t.Errorf("Name() = %q, want %q", tool.Name(), "delegate") + } +} + +func TestDelegateTool_Parameters(t *testing.T) { + tool := NewDelegateTool() + params := tool.Parameters() + + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("properties should be a map") + } + _, hasAgentID := props["agent_id"] + if !hasAgentID { + t.Error("agent_id parameter should exist") + } + _, hasTask := props["task"] + if !hasTask { + t.Error("task parameter should exist") + } + + required, ok := params["required"].([]string) + if !ok { + t.Fatal("required should be a string array") + } + if len(required) != 2 { + t.Fatalf("required should have 2 entries, got %d", len(required)) + } +} + +func TestDelegateTool_Execute_Success(t *testing.T) { + spawner := &delegateMockSpawner{} + tool := NewDelegateTool() + tool.SetSpawner(spawner) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "summarize the logs", + }) + + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, `[Response from agent "researcher"]`) { + t.Errorf("result should contain attribution, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "summarize the logs") { + t.Errorf("result should contain task output, got: %s", result.ForLLM) + } + + // Verify spawner received correct config + if spawner.lastCfg.TargetAgentID != "researcher" { + t.Errorf("TargetAgentID = %q, want %q", spawner.lastCfg.TargetAgentID, "researcher") + } + if spawner.lastCfg.Async { + t.Error("delegate should be synchronous (Async=false)") + } + if spawner.lastCfg.SystemPrompt != "summarize the logs" { + t.Errorf("SystemPrompt = %q, want %q", spawner.lastCfg.SystemPrompt, "summarize the logs") + } +} + +func TestDelegateTool_Execute_EmptyAgentID(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"missing", map[string]any{"task": "test"}}, + {"empty string", map[string]any{"agent_id": "", "task": "test"}}, + {"whitespace only", map[string]any{"agent_id": " ", "task": "test"}}, + {"wrong type", map[string]any{"agent_id": 123, "task": "test"}}, + } + + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tool.Execute(context.Background(), tt.args) + if !result.IsError { + t.Error("expected error for invalid agent_id") + } + if !strings.Contains(result.ForLLM, "agent_id is required") { + t.Errorf("error should mention agent_id, got: %s", result.ForLLM) + } + }) + } +} + +func TestDelegateTool_Execute_EmptyTask(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"missing", map[string]any{"agent_id": "a"}}, + {"empty string", map[string]any{"agent_id": "a", "task": ""}}, + {"whitespace only", map[string]any{"agent_id": "a", "task": "\t\n"}}, + } + + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tool.Execute(context.Background(), tt.args) + if !result.IsError { + t.Error("expected error for invalid task") + } + if !strings.Contains(result.ForLLM, "task is required") { + t.Errorf("error should mention task, got: %s", result.ForLLM) + } + }) + } +} + +func TestDelegateTool_Execute_PermissionDenied(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetAllowlistChecker(func(targetAgentID string) bool { + return targetAgentID == "allowed-agent" + }) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "forbidden-agent", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for denied agent") + } + if !strings.Contains(result.ForLLM, "not allowed to delegate") { + t.Errorf("error should mention permission, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_PermissionAllowed(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetAllowlistChecker(func(targetAgentID string) bool { + return targetAgentID == "allowed-agent" + }) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "allowed-agent", + "task": "test", + }) + + if result.IsError { + t.Errorf("expected success for allowed agent, got error: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NoSpawner(t *testing.T) { + tool := NewDelegateTool() + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "a", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error when spawner is nil") + } + if !strings.Contains(result.ForLLM, "not configured") { + t.Errorf("error should mention not configured, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SpawnerError(t *testing.T) { + spawner := &delegateMockSpawner{ + err: fmt.Errorf("context deadline exceeded"), + } + tool := NewDelegateTool() + tool.SetSpawner(spawner) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error when spawner fails") + } + if !strings.Contains(result.ForLLM, "delegation to agent") { + t.Errorf("error should mention delegation failure, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "context deadline exceeded") { + t.Errorf("error should propagate cause, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NoAllowlistCheck(t *testing.T) { + // When no allowlist checker is set, all agents are allowed + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "any-agent", + "task": "test", + }) + + if result.IsError { + t.Errorf("expected success without allowlist, got error: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NilResult(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&nilResultSpawner{}) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for nil result") + } + if !strings.Contains(result.ForLLM, "returned no result") { + t.Errorf("error should mention no result, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SelfDelegation(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetSelfAgentID("alpha") + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "alpha", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for self-delegation") + } + if !strings.Contains(result.ForLLM, "cannot delegate to self") { + t.Errorf("error should mention self-delegation, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SelfDelegation_Normalized(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetSelfAgentID("alpha") // stored normalized + + // Case-insensitive and whitespace variants should still be caught + variants := []string{"ALPHA", " Alpha ", " alpha "} + for _, v := range variants { + t.Run(v, func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": v, + "task": "test", + }) + if !result.IsError { + t.Errorf("agent_id=%q should be caught as self-delegation", v) + } + }) + } +} + +// nilResultSpawner always returns (nil, nil). +type nilResultSpawner struct{} + +func (m *nilResultSpawner) SpawnSubTurn(_ context.Context, _ SubTurnConfig) (*ToolResult, error) { + return nil, nil +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index ada89efb7..feeabe536 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -30,6 +30,7 @@ type SubTurnConfig struct { ActualSystemPrompt string InitialMessages []providers.Message InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget + TargetAgentID string // If set, run as this agent (its workspace, model, tools) } type SubagentTask struct { diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 67b055236..45f7e6912 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -382,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { if modelCfg == nil { return false, fmt.Sprintf("default model %q is invalid", modelName), nil } + if !defaultModelAllowedForModelConfig(modelCfg) { + return false, fmt.Sprintf("default model %q is not usable for chat", modelName), nil + } if !hasModelConfiguration(modelCfg) { return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 1d9352972..f383089a6 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -357,6 +357,44 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) { } } +func TestGatewayStartReady_RejectsASROnlyDefaultModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + cfg.Agents.Defaults.ModelName = "elevenlabs-asr" + + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatal("gatewayStartReady() ready = true, want false") + } + if reason != `default model "elevenlabs-asr" is not usable for chat` { + t.Fatalf( + "gatewayStartReady() reason = %q, want %q", + reason, + `default model "elevenlabs-asr" is not usable for chat`, + ) + } +} + func TestLooksLikeGatewayCommandLine(t *testing.T) { cases := []struct { name string diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index d262cf124..302231d80 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/url" + "os/exec" "strconv" "strings" "sync" @@ -47,6 +48,7 @@ var ( probeTCPServiceFunc = probeTCPService probeOllamaModelFunc = probeOllamaModel probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel + probeCommandAvailableFunc = probeCommandAvailable modelProbeNowFunc = time.Now modelProbeState = newModelProbeCacheState() ) @@ -83,17 +85,23 @@ func (s *modelProbeCacheState) resetForTest() { } func hasModelConfiguration(m *config.ModelConfig) bool { + protocol := modelProtocol(m) authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) apiKey := strings.TrimSpace(m.APIKey()) if authMethod == "oauth" || authMethod == "token" { - if provider, ok := oauthProviderForModel(m); ok { - cred, err := oauthGetCredential(provider) - if err != nil || cred == nil { - return false - } - return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "" + if configured, checked := hasStoredOAuthCredential(m); checked { + return configured } + } + + if authMethod == "" && providerUsesImplicitOAuth(protocol) { + if configured, checked := hasStoredOAuthCredential(m); checked { + return configured + } + } + + if providerUsesAmbientCredentials(protocol) { return true } @@ -104,6 +112,40 @@ func hasModelConfiguration(m *config.ModelConfig) bool { return apiKey != "" } +func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) { + provider, ok := oauthProviderForModel(m) + if !ok { + return false, false + } + cred, err := oauthGetCredential(provider) + if err != nil || cred == nil { + return false, true + } + return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "", true +} + +func providerUsesImplicitOAuth(protocol string) bool { + switch protocol { + case "antigravity", "google-antigravity": + return true + default: + return false + } +} + +func providerUsesAmbientCredentials(protocol string) bool { + switch protocol { + case "bedrock": + // Bedrock relies on the AWS SDK credential chain instead of an explicit + // API key stored in ModelConfig. We cannot reliably preflight every AWS + // credential source here, so avoid misclassifying valid environments as + // "unconfigured" and defer concrete credential failures to runtime. + return true + default: + return false + } +} + func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary { if !hasModelConfiguration(m) { return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured} @@ -180,8 +222,10 @@ func runLocalModelProbe(m *config.ModelConfig) bool { return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) case "github-copilot", "copilot": return probeTCPServiceFunc(apiBase) - case "claude-cli", "claudecli", "codex-cli", "codexcli": - return true + case "claude-cli", "claudecli": + return probeCommandAvailableFunc("claude") + case "codex-cli", "codexcli": + return probeCommandAvailableFunc("codex") default: if hasLocalAPIBase(apiBase) { return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) @@ -190,6 +234,11 @@ func runLocalModelProbe(m *config.ModelConfig) bool { } } +func probeCommandAvailable(command string) bool { + _, err := exec.LookPath(command) + return err == nil +} + func modelProbeCacheKey(m *config.ModelConfig) string { protocol, modelID := splitModel(m) diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 61eb235cb..8a66918f9 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -9,6 +9,7 @@ import ( "strings" "sync" + "github.com/sipeed/picoclaw/pkg/audio/asr" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -45,11 +46,184 @@ type modelResponse struct { ExtraBody map[string]any `json:"extra_body,omitempty"` CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Meta - Enabled bool `json:"enabled"` - Available bool `json:"available"` - Status string `json:"status"` - IsDefault bool `json:"is_default"` - IsVirtual bool `json:"is_virtual"` + Enabled bool `json:"enabled"` + Available bool `json:"available"` + Status string `json:"status"` + IsDefault bool `json:"is_default"` + IsVirtual bool `json:"is_virtual"` + DefaultModelAllowed bool `json:"default_model_allowed"` +} + +func normalizeStoredModelConfig(mc *config.ModelConfig) bool { + if mc == nil { + return false + } + + changed := false + model := strings.TrimSpace(mc.Model) + if model != mc.Model { + mc.Model = model + changed = true + } + provider := strings.TrimSpace(mc.Provider) + if provider != mc.Provider { + mc.Provider = provider + changed = true + } + authMethod := strings.ToLower(strings.TrimSpace(mc.AuthMethod)) + if authMethod != mc.AuthMethod { + mc.AuthMethod = authMethod + changed = true + } + + if provider != "" { + normalizedProvider := providers.NormalizeProvider(provider) + if providers.IsSupportedModelProvider(normalizedProvider) && normalizedProvider != provider { + mc.Provider = normalizedProvider + changed = true + } + if mc.Provider == "elevenlabs" { + if _, strippedModel, found := strings.Cut( + model, + "/", + ); found && + providers.NormalizeProvider(strings.TrimSpace(provider)) == "elevenlabs" { + strippedModel = strings.TrimSpace(strippedModel) + if strippedModel != "" && strippedModel != mc.Model { + mc.Model = strippedModel + changed = true + } + } + if strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() { + mc.Model = asr.ElevenLabsSupportedModelID() + changed = true + } + } + return changed + } + + effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai") + if effectiveProvider == "" { + return changed + } + if mc.Provider != effectiveProvider { + mc.Provider = effectiveProvider + changed = true + } + if mc.Model != modelID { + mc.Model = modelID + changed = true + } + return changed +} + +func normalizeIncomingModelConfig(mc *config.ModelConfig) { + if mc == nil { + return + } + + mc.Model = strings.TrimSpace(mc.Model) + mc.Provider = strings.TrimSpace(mc.Provider) + mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod)) + if mc.Provider == "" { + mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai") + } else { + mc.Provider = providers.NormalizeProvider(mc.Provider) + if mc.Provider == "elevenlabs" { + if _, strippedModel, found := strings.Cut(mc.Model, "/"); found { + strippedModel = strings.TrimSpace(strippedModel) + if strippedModel != "" { + mc.Model = strippedModel + } + } + } + } + if mc.Provider == "antigravity" && mc.AuthMethod == "" { + mc.AuthMethod = "oauth" + } +} + +func createAllowedForProvider(provider string) bool { + normalized := providers.NormalizeProvider(provider) + switch normalized { + case "bedrock": + // Bedrock currently authenticates through the AWS SDK credential chain + // (env vars, shared profiles, IAM roles, etc.), and this Web layer does + // not yet have a reliable preflight check for those credential sources. + // Keep it creatable in the catalog and let provider construction/runtime + // return the concrete AWS error when the environment is incomplete. + return true + case "claude-cli", "codex-cli": + return cliProviderCreateAllowedFromCurrentStatus(normalized) + default: + return providers.IsCreatableModelProvider(normalized) + } +} + +// cliProviderCreateAllowedFromCurrentStatus intentionally reuses the existing +// local model status pipeline so provider catalog gating follows the same CLI +// executable probe used by launcher readiness. +func cliProviderCreateAllowedFromCurrentStatus(provider string) bool { + status := modelConfigurationStatus(&config.ModelConfig{ + Provider: provider, + Model: provider, + }) + return status.Available +} + +func modelProviderOptionsForResponse() []providers.ModelProviderOption { + options := providers.ModelProviderOptions() + for i := range options { + options[i].CreateAllowed = createAllowedForProvider(options[i].ID) + } + return options +} + +func defaultModelAllowedForModelConfig(mc *config.ModelConfig) bool { + provider, _ := providers.ExtractProtocol(mc) + return providers.IsDefaultModelProvider(provider) +} + +func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error { + if mc == nil { + return fmt.Errorf("model config is required") + } + if err := mc.Validate(); err != nil { + return err + } + if strings.TrimSpace(mc.Provider) == "" { + return fmt.Errorf("provider is required") + } + if !providers.IsSupportedModelProvider(mc.Provider) { + return fmt.Errorf("provider %q is not supported", mc.Provider) + } + if mc.Provider == "elevenlabs" && strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() { + return fmt.Errorf("provider %q only supports model %q", mc.Provider, asr.ElevenLabsSupportedModelID()) + } + if !createAllowedForProvider(mc.Provider) { + if existing == nil { + return fmt.Errorf("provider %q is not available for new models", mc.Provider) + } + existingProvider, _ := providers.ExtractProtocol(existing) + if providers.NormalizeProvider(existingProvider) != mc.Provider { + return fmt.Errorf("provider %q is not available for selection", mc.Provider) + } + } + return nil +} + +func normalizeStoredModelProviders(cfg *config.Config) bool { + if cfg == nil { + return false + } + + changed := false + for _, model := range cfg.ModelList { + if normalizeStoredModelConfig(model) { + changed = true + } + } + return changed } // handleListModels returns all model_list entries with masked API keys. @@ -62,6 +236,10 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { return } + // Normalize legacy provider/model storage in memory so GET can round-trip + // through the current API shape without mutating the on-disk config. + normalizeStoredModelProviders(cfg) + defaultModel := cfg.Agents.Defaults.GetModelName() modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList)) @@ -101,14 +279,16 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { Status: modelStatuses[i].Status, IsDefault: m.ModelName == defaultModel, IsVirtual: m.IsVirtual(), + DefaultModelAllowed: defaultModelAllowedForModelConfig(m), }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "models": models, - "total": len(models), - "default_model": defaultModel, + "models": models, + "total": len(models), + "default_model": defaultModel, + "provider_options": modelProviderOptionsForResponse(), }) } @@ -134,7 +314,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { return } - if err = mc.Validate(); err != nil { + normalizeIncomingModelConfig(&mc.ModelConfig) + + if err = validateIncomingModelConfig(&mc.ModelConfig, nil); err != nil { http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) return } @@ -150,6 +332,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { } cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig) + normalizeStoredModelProviders(cfg) if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) @@ -200,11 +383,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { return } - if err = mc.Validate(); err != nil { - http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) - return - } - cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) @@ -253,9 +431,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { // This keeps provider-omitted updates backward-compatible even when an // older client edits the visible model ID. if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" { - existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model) incomingModel := strings.TrimSpace(mc.Model) + existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" { if incomingModel == existingModelID { mc.Model = existingRawModel @@ -272,7 +450,20 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } } + normalizeIncomingModelConfig(&mc.ModelConfig) + if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil { + http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) + return + } + if cfg.Agents.Defaults.ModelName == cfg.ModelList[idx].ModelName && + !defaultModelAllowedForModelConfig(&mc.ModelConfig) { + // Allow users to recover from legacy/invalid defaults by saving the model + // and clearing the default chat model reference in the same write. + cfg.Agents.Defaults.ModelName = "" + } + cfg.ModelList[idx] = &mc.ModelConfig + normalizeStoredModelProviders(cfg) logger.Debugf("update model config: %#v", mc.ModelConfig) @@ -372,6 +563,19 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest) return } + for _, m := range cfg.ModelList { + if m.ModelName == req.ModelName { + if !defaultModelAllowedForModelConfig(m) { + http.Error( + w, + fmt.Sprintf("Model %q cannot be used as the default chat model", req.ModelName), + http.StatusBadRequest, + ) + return + } + break + } + } cfg.Agents.Defaults.ModelName = req.ModelName diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index dd5ff6a54..0b1f04848 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" ) func resetModelProbeHooks(t *testing.T) { @@ -20,17 +21,46 @@ func resetModelProbeHooks(t *testing.T) { origTCPProbe := probeTCPServiceFunc origOllamaProbe := probeOllamaModelFunc origOpenAIProbe := probeOpenAICompatibleModelFunc + origCommandProbe := probeCommandAvailableFunc origNow := modelProbeNowFunc resetModelProbeCache() t.Cleanup(func() { probeTCPServiceFunc = origTCPProbe probeOllamaModelFunc = origOllamaProbe probeOpenAICompatibleModelFunc = origOpenAIProbe + probeCommandAvailableFunc = origCommandProbe modelProbeNowFunc = origNow resetModelProbeCache() }) } +func addModelAndLoadLatest(t *testing.T, configPath string, body string) *config.ModelConfig { + t.Helper() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(cfg.ModelList) == 0 { + t.Fatal("model_list should contain the newly added model") + } + + return cfg.ModelList[len(cfg.ModelList)-1] +} + func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -94,7 +124,8 @@ func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing }, } cfg.Agents.Defaults.ModelName = "openai-oauth" - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -113,7 +144,8 @@ func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { t.Fatalf("Unmarshal() error = %v", err) } @@ -181,14 +213,91 @@ func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) AuthMethod: "oauth", }} cfg.Agents.Defaults.ModelName = "claude-oauth" - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } - if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ + if setCredentialErr := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ AccessToken: "anthropic-token", Provider: oauthProviderAnthropic, AuthMethod: "oauth", + }); setCredentialErr != nil { + t.Fatalf("SetCredential() error = %v", setCredentialErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatalf("oauth model available = false, want true with stored credential") + } +} + +func TestHasModelConfiguration_OAuthWithoutMappedCredentialFallsBackToAPIKey(t *testing.T) { + noKey := &config.ModelConfig{ + Provider: "gemini", + Model: "gemini-2.5-flash", + AuthMethod: "oauth", + } + if hasModelConfiguration(noKey) { + t.Fatal("oauth model without credential mapping and api key should be unconfigured") + } + + withKey := &config.ModelConfig{ + Provider: "gemini", + Model: "gemini-2.5-flash", + AuthMethod: "oauth", + APIKeys: config.SimpleSecureStrings("gemini-key"), + } + if !hasModelConfiguration(withKey) { + t.Fatal("oauth model without credential mapping should fall back to api key configuration") + } +} + +func TestHandleListModels_AntigravityImplicitOAuthAvailability(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "gemini-flash", + Provider: "antigravity", + Model: "gemini-3-flash", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if err := auth.SetCredential(oauthProviderGoogleAntigravity, &auth.AuthCredential{ + AccessToken: "antigravity-token", + Provider: oauthProviderGoogleAntigravity, + AuthMethod: "oauth", }); err != nil { t.Fatalf("SetCredential() error = %v", err) } @@ -208,14 +317,158 @@ func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Available { - t.Fatalf("oauth model available = false, want true with stored credential") + t.Fatal("antigravity model available = false, want true with stored credential even without auth_method") + } +} + +func TestHandleListModels_BedrockUsesAmbientCredentialStatus(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Provider: "bedrock", + Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", + }} + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatal("bedrock model available = false, want true because Bedrock uses ambient AWS credentials") + } + if resp.Models[0].Status != modelStatusAvailable { + t.Fatalf("bedrock model status = %q, want %q", resp.Models[0].Status, modelStatusAvailable) + } +} + +func TestHandleListModels_CLIProvidersRequireInstalledCommands(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeCommandAvailableFunc = func(command string) bool { + switch command { + case "claude": + return false + case "codex": + return true + default: + return false + } + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "claude-cli-model", + Provider: "claude-cli", + Model: "claude-cli", + }, + { + ModelName: "codex-cli-model", + Provider: "codex-cli", + Model: "codex-cli", + }, + } + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + + modelsByName := make(map[string]modelResponse, len(resp.Models)) + for _, model := range resp.Models { + modelsByName[model.ModelName] = model + } + if model := modelsByName["claude-cli-model"]; model.Available || model.Status != modelStatusUnreachable { + t.Fatalf( + "claude-cli status = (%t, %q), want (%t, %q)", + model.Available, + model.Status, + false, + modelStatusUnreachable, + ) + } + if model := modelsByName["codex-cli-model"]; !model.Available || model.Status != modelStatusAvailable { + t.Fatalf( + "codex-cli status = (%t, %q), want (%t, %q)", + model.Available, + model.Status, + true, + modelStatusAvailable, + ) + } + + optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) + for _, option := range resp.ProviderOptions { + optionsByID[option.ID] = option + } + if option, ok := optionsByID["claude-cli"]; !ok { + t.Fatal("claude-cli provider option missing") + } else if option.CreateAllowed { + t.Fatal("claude-cli should not be creatable when the claude command is missing") + } + if option, ok := optionsByID["codex-cli"]; !ok { + t.Fatal("codex-cli provider option missing") + } else if !option.CreateAllowed { + t.Fatal("codex-cli should be creatable when the codex command is available") } } @@ -321,8 +574,8 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) @@ -508,6 +761,223 @@ func TestHandleAddModel_PersistsProvider(t *testing.T) { } } +func TestHandleAddModel_RejectsUnsupportedProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"bad-provider", + "provider":"not-supported", + "model":"gpt-4o-mini" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "not-supported" is not supported`) { + t.Fatalf("body = %q, want unsupported provider error", rec.Body.String()) + } +} + +func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"bedrock-claude", + "provider":"bedrock", + "model":"us.anthropic.claude-sonnet-4-20250514-v1:0" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if got := added.Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := added.Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want bedrock model ID", got) + } +} + +func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(updated.ModelList) != 2 { + t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList)) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") + } +} + +func TestHandleAddModel_NormalizesExplicitElevenLabsUnsupportedModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v2", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") + } +} + +func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeCommandAvailableFunc = func(command string) bool { + return false + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"claude-cli-model", + "provider":"claude-cli", + "model":"claude-cli" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "claude-cli" is not available for new models`) { + t.Fatalf("body = %q, want missing cli command error", rec.Body.String()) + } +} + +func TestHandleAddModel_DefaultsAntigravityToOAuth(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + added := addModelAndLoadLatest(t, configPath, `{ + "model_name":"gemini-flash", + "provider":"antigravity", + "model":"gemini-3-flash" + }`) + if got := added.AuthMethod; got != "oauth" { + t.Fatalf("auth_method = %q, want %q", got, "oauth") + } +} + +func TestHandleAddModel_NormalizesMixedCaseAuthMethod(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + added := addModelAndLoadLatest(t, configPath, `{ + "model_name":"openai-oauth", + "provider":"openai", + "model":"gpt-5.4", + "auth_method":"OAuth" + }`) + if got := added.AuthMethod; got != "oauth" { + t.Fatalf("auth_method = %q, want %q", got, "oauth") + } +} + func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -845,7 +1315,8 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { Provider: "openrouter", Model: "openrouter/auto", }} - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -864,7 +1335,8 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { @@ -878,6 +1350,55 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { } } +func TestHandleListModels_ExposesElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := resp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if resp.Models[0].DefaultModelAllowed { + t.Fatal("elevenlabs ASR model should not be allowed as the default chat model") + } +} + func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -940,11 +1461,230 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *test if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := updated.ModelList[0].Provider; got != "" { - t.Fatalf("provider = %q, want empty", got) + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") } - if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { - t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.4") + if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } +} + +func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + +func TestHandleUpdateModel_RoundTripsExplicitLegacyElevenLabsModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v2", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after GET normalization", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + +func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + cfg.Agents.Defaults.ModelName = "elevenlabs-asr" + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.Agents.Defaults.ModelName; got != "" { + t.Fatalf("default model = %q, want cleared default", got) + } +} + +func TestHandleAddModel_RejectsUnsupportedElevenLabsModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v2" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "elevenlabs" only supports model "scribe_v1"`) { + t.Fatalf("body = %q, want elevenlabs model validation error", rec.Body.String()) } } @@ -984,11 +1724,125 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModel if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := updated.ModelList[0].Provider; got != "" { - t.Fatalf("provider = %q, want empty", got) + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") } - if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.5" { - t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.5") + if got := updated.ModelList[0].Model; got != "openai/gpt-5.5" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.5") + } +} + +func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "legacy-openrouter", + Model: "openrouter/openai/gpt-5.4", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := resp.Models[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } + + optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) + for _, option := range resp.ProviderOptions { + optionsByID[option.ID] = option + } + if len(optionsByID) == 0 { + t.Fatal("provider_options should not be empty") + } + if option, ok := optionsByID["openai"]; !ok { + t.Fatal("openai provider option missing") + } else if option.DefaultAPIBase != "https://api.openai.com/v1" { + t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1") + } + if option, ok := optionsByID["anthropic"]; !ok { + t.Fatal("anthropic provider option missing") + } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { + t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") + } + if _, ok := optionsByID["azure"]; !ok { + t.Fatal("azure provider option missing") + } + if option, ok := optionsByID["github-copilot"]; !ok { + t.Fatal("github-copilot provider option missing") + } else if option.DefaultAPIBase != "localhost:4321" { + t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } + if option, ok := optionsByID["elevenlabs"]; !ok { + t.Fatal("elevenlabs provider option missing") + } else { + if option.DefaultAPIBase != "https://api.elevenlabs.io" { + t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") + } + if option.DefaultModelAllowed { + t.Fatal("elevenlabs should be marked as not allowed for default chat model selection") + } + } + if option, ok := optionsByID["lmstudio"]; !ok { + t.Fatal("lmstudio provider option missing") + } else if !option.EmptyAPIKeyAllowed { + t.Fatal("lmstudio should allow empty api keys") + } + if option, ok := optionsByID["bedrock"]; !ok { + t.Fatal("bedrock provider option missing") + } else if !option.CreateAllowed { + t.Fatal("bedrock should stay creatable and defer AWS credential failures to runtime") + } + if option, ok := optionsByID["antigravity"]; !ok { + t.Fatal("antigravity provider option missing") + } else { + if option.DefaultAuthMethod != "oauth" { + t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") + } + if !option.AuthMethodLocked { + t.Fatal("antigravity auth method should be locked") + } + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "" { + t.Fatalf("persisted provider = %q, want unchanged empty provider", got) + } + if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { + t.Fatalf("persisted model = %q, want unchanged legacy model", got) } } @@ -1036,6 +1890,115 @@ func TestHandleListModels_ReturnsProviderField(t *testing.T) { } } +func TestHandleListModels_PreservesKnownProviderInCatalog(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := resp.Models[0].Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want %q", got, "us.anthropic.claude-sonnet-4-20250514-v1:0") + } + foundBedrock := false + for _, option := range resp.ProviderOptions { + if option.ID == "bedrock" { + foundBedrock = true + if !option.CreateAllowed { + t.Fatal("bedrock should stay creatable in provider_options") + } + } + } + if !foundBedrock { + t.Fatal("bedrock should be included in provider_options for compatibility") + } +} + +func TestHandleUpdateModel_AllowsExistingBedrockProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Provider: "bedrock", + Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", + APIBase: "us-west-2", + }} + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"bedrock-claude", + "provider":"bedrock", + "model":"us.anthropic.claude-3-7-sonnet-20250219-v1:0", + "api_base":"us-east-1" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := updated.ModelList[0].Model; got != "us.anthropic.claude-3-7-sonnet-20250219-v1:0" { + t.Fatalf("model = %q, want updated bedrock model", got) + } + if got := updated.ModelList[0].APIBase; got != "us-east-1" { + t.Fatalf("api_base = %q, want %q", got, "us-east-1") + } +} + func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -1147,6 +2110,45 @@ func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) { } } +func TestHandleSetDefaultModel_RejectsElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{ + "model_name": "elevenlabs-asr" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "cannot be used as the default chat model") { + t.Fatalf("body = %q, want default chat model rejection", rec.Body.String()) + } +} + func TestMaskAPIKey(t *testing.T) { tests := []struct { name string diff --git a/web/frontend/package.json b/web/frontend/package.json index ab07b40a2..1b6821c33 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -18,32 +18,32 @@ }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", - "@tabler/icons-react": "^3.40.0", - "@tailwindcss/vite": "^4.2.2", + "@tabler/icons-react": "^3.43.0", + "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.99.0", - "@tanstack/react-router": "^1.168.23", + "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", "highlight.js": "^11.11.1", - "i18next": "^26.0.7", + "i18next": "^26.0.10", "i18next-browser-languagedetector": "^8.2.1", "jotai": "^2.19.1", "radix-ui": "^1.4.3", "react": "19.2.5", "react-dom": "19.2.5", - "react-i18next": "^17.0.4", + "react-i18next": "^17.0.6", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", - "shadcn": "^4.3.0", + "shadcn": "^4.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", "wrap-ansi": "^10.0.0" }, @@ -61,11 +61,11 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.5.0", + "globals": "^17.6.0", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.59.0", + "typescript-eslint": "^8.59.1", "vite": "^8.0.10" } } diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index cb5ca18de..8bcd65944 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -12,20 +12,20 @@ importers: specifier: ^5.2.8 version: 5.2.8 '@tabler/icons-react': - specifier: ^3.40.0 - version: 3.41.1(react@19.2.5) + specifier: ^3.43.0 + version: 3.43.0(react@19.2.5) '@tailwindcss/vite': - specifier: ^4.2.2 - version: 4.2.2(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^4.2.4 + version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) '@tanstack/react-query': specifier: ^5.99.0 version: 5.99.0(react@19.2.5) '@tanstack/react-router': - specifier: ^1.168.23 - version: 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.169.2 + version: 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-router-devtools': specifier: ^1.166.13 - version: 1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -39,8 +39,8 @@ importers: specifier: ^11.11.1 version: 11.11.1 i18next: - specifier: ^26.0.7 - version: 26.0.7(typescript@5.9.3) + specifier: ^26.0.10 + version: 26.0.10(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 @@ -57,8 +57,8 @@ importers: specifier: 19.2.5 version: 19.2.5(react@19.2.5) react-i18next: - specifier: ^17.0.4 - version: 17.0.4(i18next@26.0.7(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + specifier: ^17.0.6 + version: 17.0.6(i18next@26.0.10(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.5) @@ -78,8 +78,8 @@ importers: specifier: ^4.0.1 version: 4.0.1 shadcn: - specifier: ^4.3.0 - version: 4.3.0(@types/node@25.6.0)(typescript@5.9.3) + specifier: ^4.7.0 + version: 4.7.0(@types/node@25.6.0)(typescript@5.9.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -87,8 +87,8 @@ importers: specifier: ^3.5.0 version: 3.5.0 tailwindcss: - specifier: ^4.2.2 - version: 4.2.2 + specifier: ^4.2.4 + version: 4.2.4 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -98,13 +98,13 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) + version: 10.0.1(eslint@10.2.1(jiti@2.7.0)) '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.2.2) + version: 0.5.19(tailwindcss@4.2.4) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + version: 1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.3) @@ -119,25 +119,25 @@ importers: version: 19.2.3(@types/react@19.2.14) '@typescript-eslint/eslint-plugin': specifier: ^8.58.2 - version: 8.58.2(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + version: 8.58.2(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) eslint: specifier: ^10.2.1 - version: 10.2.1(jiti@2.6.1) + version: 10.2.1(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.2.1(jiti@2.6.1)) + version: 10.1.8(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-react-hooks: specifier: ^7.1.1 - version: 7.1.1(eslint@10.2.1(jiti@2.6.1)) + version: 7.1.1(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-react-refresh: specifier: ^0.5.2 - version: 0.5.2(eslint@10.2.1(jiti@2.6.1)) + version: 0.5.2(eslint@10.2.1(jiti@2.7.0)) globals: - specifier: ^17.5.0 - version: 17.5.0 + specifier: ^17.6.0 + version: 17.6.0 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -148,11 +148,11 @@ importers: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.59.0 - version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.59.1 + version: 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) vite: specifier: ^8.0.10 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) packages: @@ -160,8 +160,8 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} '@babel/core@7.29.0': @@ -180,8 +180,8 @@ packages: resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -243,6 +243,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} @@ -289,8 +294,8 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@dotenvx/dotenvx@1.61.0': - resolution: {integrity: sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ==} + '@dotenvx/dotenvx@1.65.0': + resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==} hasBin: true '@ecies/ciphers@0.2.6': @@ -547,8 +552,8 @@ packages: resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/confirm@6.0.11': - resolution: {integrity: sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA==} + '@inquirer/confirm@6.0.12': + resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -556,8 +561,8 @@ packages: '@types/node': optional: true - '@inquirer/core@11.1.8': - resolution: {integrity: sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==} + '@inquirer/core@11.1.9': + resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -604,8 +609,8 @@ packages: '@cfworker/json-schema': optional: true - '@mswjs/interceptors@0.41.3': - resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + '@mswjs/interceptors@0.41.8': + resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==} engines: {node: '>=18'} '@napi-rs/wasm-runtime@1.1.4': @@ -1451,77 +1456,77 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@tabler/icons-react@3.41.1': - resolution: {integrity: sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==} + '@tabler/icons-react@3.43.0': + resolution: {integrity: sha512-rXUuCQEeRbEk3lJxs3gwzdtaaITSwc/JUbp+AkqsGff5uBpzZw7eKPDk53xKoKLyjrbj82Ai4GuVG0kO89Jf5g==} peerDependencies: react: '>= 16' - '@tabler/icons@3.41.1': - resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==} + '@tabler/icons@3.43.0': + resolution: {integrity: sha512-qXwS17Op9jqr3Asvu31fejyw8+OnRDKH7oR8nQXyUgW1pI44ET8OKG9kssy+XIvvAIyej6gZdGmviNUn1VMfPw==} - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1532,20 +1537,20 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} engines: {node: '>= 20'} '@tailwindcss/typography@0.5.19': @@ -1553,8 +1558,8 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.2.2': - resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + '@tailwindcss/vite@4.2.4': + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -1582,8 +1587,8 @@ packages: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.168.23': - resolution: {integrity: sha512-+GblieDnutG6oipJJPNtRJjrWF8QTZEG/l0532+BngFkVK48oHNOcvIkSoAFYftK1egAwM7KBxXsb0Ou+X6/MQ==} + '@tanstack/react-router@1.169.2': + resolution: {integrity: sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1595,16 +1600,15 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.168.15': - resolution: {integrity: sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA==} - engines: {node: '>=20.19'} - hasBin: true - '@tanstack/router-core@1.168.7': resolution: {integrity: sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ==} engines: {node: '>=20.19'} hasBin: true + '@tanstack/router-core@1.169.2': + resolution: {integrity: sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==} + engines: {node: '>=20.19'} + '@tanstack/router-devtools-core@1.167.3': resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==} engines: {node: '>=20.19'} @@ -1736,16 +1740,16 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/eslint-plugin@8.59.0': - resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.0 + '@typescript-eslint/parser': ^8.59.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.0': - resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==} + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1757,8 +1761,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.0': - resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==} + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -1767,8 +1771,8 @@ packages: resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.59.0': - resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==} + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.58.2': @@ -1777,8 +1781,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.59.0': - resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==} + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -1790,8 +1794,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.0': - resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==} + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1801,8 +1805,8 @@ packages: resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.59.0': - resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==} + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.58.2': @@ -1811,8 +1815,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.59.0': - resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==} + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -1824,8 +1828,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.0': - resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==} + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1835,12 +1839,13 @@ packages: resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.59.0': - resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} @@ -1884,8 +1889,8 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -1935,8 +1940,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.17: - resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} engines: {node: '>=6.0.0'} hasBin: true @@ -1984,8 +1989,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001787: - resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2191,8 +2196,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.334: - resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} + electron-to-chromium@1.5.352: + resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2204,8 +2209,8 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} entities@6.0.1: @@ -2322,8 +2327,8 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} engines: {node: '>=18.0.0'} eventsource@3.0.7: @@ -2338,8 +2343,8 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - express-rate-limit@8.3.2: - resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} + express-rate-limit@8.5.1: + resolution: {integrity: sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -2370,8 +2375,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -2431,8 +2436,8 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} fsevents@2.3.3: @@ -2493,8 +2498,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globals@17.5.0: - resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} engines: {node: '>=18'} goober@2.1.18: @@ -2509,16 +2514,16 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.13.2: - resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + graphql@16.14.0: + resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} hast-util-from-parse5@8.0.3: @@ -2564,8 +2569,8 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hono@4.12.14: - resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} engines: {node: '>=16.9.0'} html-parse-stringify@3.0.1: @@ -2596,8 +2601,8 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@26.0.7: - resolution: {integrity: sha512-f7tL/iw0VQsx4nC5oNxBM2RjM8alNys5KzyiQTU6A9TI5TI89py4/Ez1cKFvHiLWsvzOXvuGUES+Kk/A2WiANQ==} + i18next@26.0.10: + resolution: {integrity: sha512-k3yGPAlWR2RdMYoVXJoDZDT87qeHIWKH7gVksdZMpRty7QX/D9QZeYGvN08KGbKHke9wn01eYT+EEsrqX/YTlw==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -2630,8 +2635,8 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2729,8 +2734,8 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isbot@5.1.39: - resolution: {integrity: sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==} + isbot@5.1.40: + resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} engines: {node: '>=18'} isexe@2.0.0: @@ -2743,12 +2748,12 @@ packages: javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.2: - resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} jotai@2.19.1: resolution: {integrity: sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==} @@ -2803,8 +2808,8 @@ packages: engines: {node: '>=6'} hasBin: true - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3106,8 +3111,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.13.4: - resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==} + msw@2.14.4: + resolution: {integrity: sha512-HVPZJ9Rx4nDCWhjNQ57lKQGSE+0zDHw0xWE2IN2rLOUTLkagEBWNlvWuKYNwG2pQWq96TMd8NiSK/6vO1udnWQ==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3125,6 +3130,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3141,8 +3151,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -3285,6 +3295,10 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -3405,8 +3419,8 @@ packages: peerDependencies: react: ^19.2.5 - react-i18next@17.0.4: - resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==} + react-i18next@17.0.6: + resolution: {integrity: sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==} peerDependencies: i18next: '>= 26.0.1' react: '>= 16.8.0' @@ -3515,8 +3529,8 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - rettime@0.11.7: - resolution: {integrity: sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} @@ -3563,8 +3577,8 @@ packages: peerDependencies: seroval: ^1.0 - seroval-plugins@1.5.2: - resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 @@ -3573,8 +3587,8 @@ packages: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} - seroval@1.5.2: - resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} serve-static@2.2.1: @@ -3587,8 +3601,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.3.0: - resolution: {integrity: sha512-7vhnBh2LVLyxOd1ZQWwXv7OATCnQcxdqc8FbZdNigZriNOwDsHklQmPpvPt1jcrFK5mzMI+cyuAYv8WzERx2Og==} + shadcn@4.7.0: + resolution: {integrity: sha512-70fwnesNrY1GgeD7Kdzn+3SsYeyfibm8immsA5L68+OusoPTvYF01oWExl8/latKpMpvVXcbgdbbE6VFBJQ38w==} hasBin: true shebang-command@2.0.0: @@ -3709,11 +3723,11 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} tiny-invariant@1.3.3: @@ -3723,11 +3737,11 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tldts-core@7.0.28: - resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} - tldts@7.0.28: - resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} hasBin: true to-regex-range@5.0.1: @@ -3776,16 +3790,16 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@5.5.0: - resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.59.0: - resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==} + typescript-eslint@8.59.1: + resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4025,8 +4039,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yocto-spinner@1.1.0: - resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==} + yocto-spinner@1.2.0: + resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==} engines: {node: '>=18.19'} yoctocolors@2.1.2: @@ -4061,7 +4075,7 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.3': {} '@babel/core@7.29.0': dependencies: @@ -4070,7 +4084,7 @@ snapshots: '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -4085,7 +4099,7 @@ snapshots: '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -4097,13 +4111,13 @@ snapshots: '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 @@ -4178,6 +4192,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -4200,7 +4218,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) @@ -4223,7 +4241,7 @@ snapshots: '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@babel/traverse@7.29.0': @@ -4231,7 +4249,7 @@ snapshots: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 @@ -4243,7 +4261,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@dotenvx/dotenvx@1.61.0': + '@dotenvx/dotenvx@1.65.0': dependencies: commander: 11.1.0 dotenv: 17.4.2 @@ -4254,7 +4272,7 @@ snapshots: object-treeify: 1.1.33 picomatch: 4.0.4 which: 4.0.0 - yocto-spinner: 1.1.0 + yocto-spinner: 1.2.0 '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': dependencies: @@ -4354,9 +4372,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.7.0))': dependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -4377,9 +4395,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.6.1))': + '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.7.0))': optionalDependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) '@eslint/object-schema@3.0.5': {} @@ -4407,9 +4425,9 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} - '@hono/node-server@1.19.14(hono@4.12.14)': + '@hono/node-server@1.19.14(hono@4.12.18)': dependencies: - hono: 4.12.14 + hono: 4.12.18 '@humanfs/core@0.19.1': {} @@ -4424,14 +4442,14 @@ snapshots: '@inquirer/ansi@2.0.5': {} - '@inquirer/confirm@6.0.11(@types/node@25.6.0)': + '@inquirer/confirm@6.0.12(@types/node@25.6.0)': dependencies: - '@inquirer/core': 11.1.8(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.6.0) '@inquirer/type': 4.0.5(@types/node@25.6.0) optionalDependencies: '@types/node': 25.6.0 - '@inquirer/core@11.1.8(@types/node@25.6.0)': + '@inquirer/core@11.1.9(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 2.0.5 '@inquirer/figures': 2.0.5 @@ -4470,18 +4488,18 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.14) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + '@hono/node-server': 1.19.14(hono@4.12.18) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.0.8 express: 5.2.1 - express-rate-limit: 8.3.2(express@5.2.1) - hono: 4.12.14 - jose: 6.2.2 + express-rate-limit: 8.5.1(express@5.2.1) + hono: 4.12.18 + jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -4490,7 +4508,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@mswjs/interceptors@0.41.3': + '@mswjs/interceptors@0.41.8': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -5343,85 +5361,85 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@tabler/icons-react@3.41.1(react@19.2.5)': + '@tabler/icons-react@3.43.0(react@19.2.5)': dependencies: - '@tabler/icons': 3.41.1 + '@tabler/icons': 3.43.0 react: 19.2.5 - '@tabler/icons@3.41.1': {} + '@tabler/icons@3.43.0': {} - '@tailwindcss/node@4.2.2': + '@tailwindcss/node@4.2.4': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 - jiti: 2.6.1 + enhanced-resolve: 5.21.0 + jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 - '@tailwindcss/oxide-android-arm64@4.2.2': + '@tailwindcss/oxide-android-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.2': + '@tailwindcss/oxide-darwin-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.2': + '@tailwindcss/oxide-darwin-x64@4.2.4': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.2': + '@tailwindcss/oxide-freebsd-x64@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.2': + '@tailwindcss/oxide-linux-x64-musl@4.2.4': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.2': + '@tailwindcss/oxide-wasm32-wasi@4.2.4': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': optional: true - '@tailwindcss/oxide@4.2.2': + '@tailwindcss/oxide@4.2.4': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)': + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 - '@tailwindcss/vite@4.2.2(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': dependencies: - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - tailwindcss: 4.2.2 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + tailwindcss: 4.2.4 + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) '@tanstack/history@1.161.6': {} @@ -5432,23 +5450,23 @@ snapshots: '@tanstack/query-core': 5.99.0 react: 19.2.5 - '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.168.15)(csstype@3.2.3) + '@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: - '@tanstack/router-core': 1.168.15 + '@tanstack/router-core': 1.169.2 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/history': 1.161.6 '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-core': 1.168.15 - isbot: 5.1.39 + '@tanstack/router-core': 1.169.2 + isbot: 5.1.40 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) @@ -5459,13 +5477,6 @@ snapshots: react-dom: 19.2.5(react@19.2.5) use-sync-external-store: 1.6.0(react@19.2.5) - '@tanstack/router-core@1.168.15': - dependencies: - '@tanstack/history': 1.161.6 - cookie-es: 3.1.1 - seroval: 1.5.2 - seroval-plugins: 1.5.2(seroval@1.5.2) - '@tanstack/router-core@1.168.7': dependencies: '@tanstack/history': 1.161.6 @@ -5473,9 +5484,16 @@ snapshots: seroval: 1.5.1 seroval-plugins: 1.5.1(seroval@1.5.1) - '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.168.15)(csstype@3.2.3)': + '@tanstack/router-core@1.169.2': dependencies: - '@tanstack/router-core': 1.168.15 + '@tanstack/history': 1.161.6 + cookie-es: 3.1.1 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.169.2 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) optionalDependencies: @@ -5494,7 +5512,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5510,8 +5528,8 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + '@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5519,7 +5537,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 ansis: 4.2.0 babel-dead-code-elimination: 1.0.12 @@ -5606,15 +5624,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.58.2 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5622,15 +5640,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/type-utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.0 - eslint: 10.2.1(jiti@2.6.1) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 10.2.1(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5638,31 +5656,31 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.0 + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 debug: 4.4.3 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) - '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) - '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -5673,38 +5691,38 @@ snapshots: '@typescript-eslint/types': 8.58.2 '@typescript-eslint/visitor-keys': 8.58.2 - '@typescript-eslint/scope-manager@8.59.0': + '@typescript-eslint/scope-manager@8.59.1': dependencies: - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/visitor-keys': 8.59.0 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.59.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.58.2 '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -5712,7 +5730,7 @@ snapshots: '@typescript-eslint/types@8.58.2': {} - '@typescript-eslint/types@8.59.0': {} + '@typescript-eslint/types@8.59.1': {} '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': dependencies: @@ -5729,12 +5747,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/visitor-keys': 8.59.0 + '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 debug: 4.4.3 minimatch: 10.2.5 semver: 7.7.4 @@ -5744,24 +5762,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - eslint: 10.2.1(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5771,17 +5789,17 @@ snapshots: '@typescript-eslint/types': 8.58.2 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.59.0': + '@typescript-eslint/visitor-keys@8.59.1': dependencies: - '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/types': 8.59.1 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) accepts@2.0.0: dependencies: @@ -5796,9 +5814,9 @@ snapshots: agent-base@7.1.4: {} - ajv-formats@3.0.1(ajv@8.18.0): + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: - ajv: 8.18.0 + ajv: 8.20.0 ajv@6.14.0: dependencies: @@ -5807,10 +5825,10 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.18.0: + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -5844,7 +5862,7 @@ snapshots: babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: @@ -5856,7 +5874,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.17: {} + baseline-browser-mapping@2.10.27: {} binary-extensions@2.3.0: {} @@ -5888,10 +5906,10 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.17 - caniuse-lite: 1.0.30001787 - electron-to-chromium: 1.5.334 - node-releases: 2.0.37 + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.352 + node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) bundle-name@4.1.0: @@ -5912,7 +5930,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001787: {} + caniuse-lite@1.0.30001792: {} ccount@2.0.1: {} @@ -6070,7 +6088,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.334: {} + electron-to-chromium@1.5.352: {} emoji-regex@10.6.0: {} @@ -6078,10 +6096,10 @@ snapshots: encodeurl@2.0.0: {} - enhanced-resolve@5.20.1: + enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.2 + tapable: 2.3.3 entities@6.0.1: {} @@ -6136,24 +6154,24 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.7.0)): dependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) - eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.7.0)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.6.1)): + eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.7.0)): dependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) eslint-scope@9.1.2: dependencies: @@ -6166,9 +6184,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.2.1(jiti@2.6.1): + eslint@10.2.1(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.5.5 @@ -6199,7 +6217,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -6227,11 +6245,11 @@ snapshots: etag@1.8.1: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.0.8: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.6 + eventsource-parser: 3.0.8 execa@5.1.1: dependencies: @@ -6260,10 +6278,10 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - express-rate-limit@8.3.2(express@5.2.1): + express-rate-limit@8.5.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.1.0 + ip-address: 10.2.0 express@5.2.1: dependencies: @@ -6320,7 +6338,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fast-wrap-ansi@0.2.0: dependencies: @@ -6382,10 +6400,10 @@ snapshots: fresh@2.0.0: {} - fs-extra@11.3.4: + fs-extra@11.3.5: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.2.0 + jsonfile: 6.2.1 universalify: 2.0.1 fsevents@2.3.3: @@ -6411,7 +6429,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -6442,7 +6460,7 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@17.5.0: {} + globals@17.6.0: {} goober@2.1.18(csstype@3.2.3): dependencies: @@ -6452,11 +6470,11 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.13.2: {} + graphql@16.14.0: {} has-symbols@1.1.0: {} - hasown@2.0.2: + hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -6563,7 +6581,7 @@ snapshots: highlight.js@11.11.1: {} - hono@4.12.14: {} + hono@4.12.18: {} html-parse-stringify@3.0.1: dependencies: @@ -6596,7 +6614,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@26.0.7(typescript@5.9.3): + i18next@26.0.10(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -6619,7 +6637,7 @@ snapshots: inline-style-parser@0.2.7: {} - ip-address@10.1.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -6682,7 +6700,7 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isbot@5.1.39: {} + isbot@5.1.40: {} isexe@2.0.0: {} @@ -6690,9 +6708,9 @@ snapshots: javascript-natural-sort@0.7.1: {} - jiti@2.6.1: {} + jiti@2.7.0: {} - jose@6.2.2: {} + jose@6.2.3: {} jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5): optionalDependencies: @@ -6723,7 +6741,7 @@ snapshots: json5@2.2.3: {} - jsonfile@6.2.0: + jsonfile@6.2.1: dependencies: universalify: 2.0.1 optionalDependencies: @@ -7203,24 +7221,24 @@ snapshots: ms@2.1.3: {} - msw@2.13.4(@types/node@25.6.0)(typescript@5.9.3): + msw@2.14.4(@types/node@25.6.0)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 6.0.11(@types/node@25.6.0) - '@mswjs/interceptors': 0.41.3 + '@inquirer/confirm': 6.0.12(@types/node@25.6.0) + '@mswjs/interceptors': 0.41.8 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.13.2 + graphql: 16.14.0 headers-polyfill: 5.0.1 is-node-process: 1.2.0 outvariant: 1.4.3 path-to-regexp: 6.3.0 picocolors: 1.1.1 - rettime: 0.11.7 + rettime: 0.11.11 statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.1 - type-fest: 5.5.0 + type-fest: 5.6.0 until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: @@ -7232,6 +7250,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.12: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -7244,7 +7264,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.37: {} + node-releases@2.0.38: {} normalize-path@3.0.0: {} @@ -7392,6 +7412,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + powershell-utils@0.1.0: {} prelude-ls@1.2.1: {} @@ -7505,11 +7531,11 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-i18next@17.0.4(i18next@26.0.7(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3): + react-i18next@17.0.6(i18next@26.0.10(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 26.0.7(typescript@5.9.3) + i18next: 26.0.10(typescript@5.9.3) react: 19.2.5 use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: @@ -7650,7 +7676,7 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rettime@0.11.7: {} + rettime@0.11.11: {} reusify@1.1.0: {} @@ -7719,13 +7745,13 @@ snapshots: dependencies: seroval: 1.5.1 - seroval-plugins@1.5.2(seroval@1.5.2): + seroval-plugins@1.5.4(seroval@1.5.4): dependencies: - seroval: 1.5.2 + seroval: 1.5.4 seroval@1.5.1: {} - seroval@1.5.2: {} + seroval@1.5.4: {} serve-static@2.2.1: dependencies: @@ -7740,13 +7766,13 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.3.0(@types/node@25.6.0)(typescript@5.9.3): + shadcn@4.7.0(@types/node@25.6.0)(typescript@5.9.3): dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@dotenvx/dotenvx': 1.61.0 + '@dotenvx/dotenvx': 1.65.0 '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.2 @@ -7757,15 +7783,15 @@ snapshots: diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.4 + fs-extra: 11.3.5 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.13.4(@types/node@25.6.0)(typescript@5.9.3) + msw: 2.14.4(@types/node@25.6.0)(typescript@5.9.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 - postcss: 8.5.10 + postcss: 8.5.14 postcss-selector-parser: 7.1.1 prompts: 2.4.2 recast: 0.23.11 @@ -7896,9 +7922,9 @@ snapshots: tailwind-merge@3.5.0: {} - tailwindcss@4.2.2: {} + tailwindcss@4.2.4: {} - tapable@2.3.2: {} + tapable@2.3.3: {} tiny-invariant@1.3.3: {} @@ -7907,11 +7933,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tldts-core@7.0.28: {} + tldts-core@7.0.30: {} - tldts@7.0.28: + tldts@7.0.30: dependencies: - tldts-core: 7.0.28 + tldts-core: 7.0.30 to-regex-range@5.0.1: dependencies: @@ -7921,7 +7947,7 @@ snapshots: tough-cookie@6.0.1: dependencies: - tldts: 7.0.28 + tldts: 7.0.30 trim-lines@3.0.1: {} @@ -7957,7 +7983,7 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@5.5.0: + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -7967,13 +7993,13 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.2.1(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8104,7 +8130,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -8115,7 +8141,7 @@ snapshots: '@types/node': 25.6.0 esbuild: 0.27.4 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 tsx: 4.21.0 void-elements@3.1.0: {} @@ -8173,7 +8199,7 @@ snapshots: yocto-queue@0.1.0: {} - yocto-spinner@1.1.0: + yocto-spinner@1.2.0: dependencies: yoctocolors: 2.1.2 diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 926bf8a0a..5bb275fde 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -27,12 +27,24 @@ export interface ModelInfo { status: "available" | "unconfigured" | "unreachable" is_default: boolean is_virtual: boolean + default_model_allowed?: boolean +} + +export interface ModelProviderOption { + id: string + default_api_base: string + empty_api_key_allowed: boolean + create_allowed: boolean + default_model_allowed: boolean + default_auth_method?: string + auth_method_locked?: boolean } interface ModelsListResponse { models: ModelInfo[] total: number default_model: string + provider_options: ModelProviderOption[] } interface ModelActionResponse { diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index 465d218be..700cc21e0 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -288,6 +288,9 @@ export function AppHeader() { i18n.changeLanguage("en")}> English + i18n.changeLanguage("pt-BR")}> + Português (Brasil) + i18n.changeLanguage("zh")}> 简体中文 diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 4ca1167c7..9b6a4876b 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -57,11 +57,38 @@ export function AssistantMessage({ const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" - const handleCopy = () => { - navigator.clipboard.writeText(content).then(() => { + const handleCopy = async () => { + const markCopied = () => { setIsCopied(true) setTimeout(() => setIsCopied(false), 2000) - }) + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(content) + markCopied() + return + } + } catch { + // HTTP 或受限环境下可能不支持 Clipboard API,继续走降级方案 + } + + const textArea = document.createElement("textarea") + textArea.value = content + textArea.setAttribute("readonly", "") + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + document.body.appendChild(textArea) + textArea.select() + + try { + const copied = document.execCommand("copy") + if (copied) { + markCopied() + } + } finally { + document.body.removeChild(textArea) + } } const collapsedLabel = isThought diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index 376c42263..e0f51596a 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -1,8 +1,12 @@ import { IconLoader2 } from "@tabler/icons-react" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { addModel, setDefaultModel } from "@/api/models" +import { + type ModelProviderOption, + addModel, + setDefaultModel, +} from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { @@ -13,6 +17,13 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Sheet, SheetContent, @@ -25,6 +36,15 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +import { + findProviderOption, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + getProviderLabel, + getSortedProviderOptions, + isProviderAuthMethodLocked, +} from "./provider-label" + interface AddForm { modelName: string provider: string @@ -46,7 +66,7 @@ interface AddForm { const EMPTY_ADD_FORM: AddForm = { modelName: "", - provider: "", + provider: "openai", model: "", apiBase: "", apiKey: "", @@ -68,6 +88,7 @@ interface AddModelSheetProps { onClose: () => void onSaved: () => void existingModelNames: string[] + providerOptions: ModelProviderOption[] } export function AddModelSheet({ @@ -75,6 +96,7 @@ export function AddModelSheet({ onClose, onSaved, existingModelNames, + providerOptions, }: AddModelSheetProps) { const { t } = useTranslation() const [form, setForm] = useState(EMPTY_ADD_FORM) @@ -88,6 +110,37 @@ export function AddModelSheet({ form.apiKey, t("models.field.apiKeyPlaceholder"), ) + const sortedProviderOptions = useMemo( + () => getSortedProviderOptions(providerOptions), + [providerOptions], + ) + const creatableProviderOptions = useMemo( + () => sortedProviderOptions.filter((option) => option.create_allowed), + [sortedProviderOptions], + ) + const selectedProviderOption = findProviderOption( + form.provider, + providerOptions, + ) + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + const defaultAuthMethod = getProviderDefaultAuthMethod( + form.provider, + providerOptions, + ) + const effectiveAuthMethod = ( + authMethodLocked ? defaultAuthMethod : form.authMethod + ) + .trim() + .toLowerCase() + const isOAuth = effectiveAuthMethod === "oauth" + const defaultModelAllowed = + selectedProviderOption?.default_model_allowed !== false + const apiBasePlaceholder = + getProviderDefaultAPIBase(form.provider, providerOptions) || + "https://api.example.com/v1" const isDirty = JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault @@ -108,6 +161,9 @@ export function AddModelSheet({ } else if (existingModelNames.some((name) => name.trim() === modelName)) { errors.modelName = t("models.add.errorDuplicateModelName") } + if (!selectedProviderOption) { + errors.provider = t("models.field.providerInvalid") + } if (!form.model.trim()) errors.model = t("models.add.errorRequired") setFieldErrors(errors) return Object.keys(errors).length === 0 @@ -122,22 +178,47 @@ export function AddModelSheet({ } } + const setProvider = (value: string) => { + setForm((f) => { + const previousOption = findProviderOption(f.provider, providerOptions) + const nextOption = findProviderOption(value, providerOptions) + let authMethod = f.authMethod + if (nextOption?.auth_method_locked) { + authMethod = nextOption.default_auth_method ?? "" + } else if ( + previousOption?.auth_method_locked && + f.authMethod === (previousOption.default_auth_method ?? "") + ) { + authMethod = "" + } + return { ...f, provider: value, authMethod } + }) + const nextOption = findProviderOption(value, providerOptions) + if (nextOption?.default_model_allowed === false) { + setSetAsDefault(false) + } + if (fieldErrors.provider) { + setFieldErrors((prev) => ({ ...prev, provider: undefined })) + } + } + const handleSave = async () => { if (!validate()) return setSaving(true) setServerError("") try { const modelName = form.modelName.trim() - const provider = form.provider.trim() const modelId = form.model.trim() await addModel({ model_name: modelName, - provider: provider || undefined, + provider: form.provider.trim(), model: modelId, api_base: form.apiBase.trim() || undefined, api_key: form.apiKey.trim() || undefined, proxy: form.proxy.trim() || undefined, - auth_method: form.authMethod.trim() || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod.trim() || undefined, connect_mode: form.connectMode.trim() || undefined, workspace: form.workspace.trim() || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -208,12 +289,29 @@ export function AddModelSheet({ - + - - setForm((f) => ({ ...f, apiKey: v }))} - placeholder={apiKeyPlaceholder} - /> - + {!isOAuth && ( + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={apiKeyPlaceholder} + /> + + )} - + @@ -269,12 +378,17 @@ export function AddModelSheet({ diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index d0810e6d6..82d3cf97f 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -1,8 +1,13 @@ import { IconLoader2 } from "@tabler/icons-react" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models" +import { + type ModelInfo, + type ModelProviderOption, + setDefaultModel, + updateModel, +} from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { @@ -13,6 +18,13 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Sheet, SheetContent, @@ -25,6 +37,15 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +import { + findProviderOption, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + getProviderLabel, + getSortedProviderOptions, + isProviderAuthMethodLocked, +} from "./provider-label" + interface EditForm { provider: string modelId: string @@ -45,6 +66,7 @@ interface EditForm { interface EditModelSheetProps { model: ModelInfo | null + providerOptions: ModelProviderOption[] open: boolean onClose: () => void onSaved: () => void @@ -76,6 +98,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm { export function EditModelSheet({ model, + providerOptions, open, onClose, onSaved, @@ -102,26 +125,99 @@ export function EditModelSheet({ const [setAsDefault, setSetAsDefault] = useState(false) const [error, setError] = useState("") const initialForm = model ? buildInitialEditForm(model) : null + const sortedProviderOptions = useMemo( + () => getSortedProviderOptions(providerOptions), + [providerOptions], + ) + const currentProviderID = model + ? (findProviderOption(model.provider, providerOptions)?.id ?? + model.provider?.trim().toLowerCase() ?? + "") + : "" + const selectedProviderOption = findProviderOption( + form.provider, + providerOptions, + ) + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + const defaultAuthMethod = getProviderDefaultAuthMethod( + form.provider, + providerOptions, + ) + const effectiveAuthMethod = ( + authMethodLocked ? defaultAuthMethod : form.authMethod + ) + .trim() + .toLowerCase() + const providerError = selectedProviderOption + ? "" + : t("models.field.providerInvalid") + const defaultModelAllowed = + selectedProviderOption?.default_model_allowed !== false + const willClearDefaultOnSave = + model?.is_default === true && defaultModelAllowed === false + const apiBasePlaceholder = + getProviderDefaultAPIBase(form.provider, providerOptions) || + "https://api.example.com/v1" const isDirty = model != null && (JSON.stringify(form) !== JSON.stringify(initialForm) || setAsDefault !== model.is_default) useEffect(() => { - if (model) { - setForm(buildInitialEditForm(model)) - setSetAsDefault(model.is_default) - setError("") + if (model) { + const initialForm = buildInitialEditForm(model) + const option = findProviderOption(initialForm.provider, providerOptions) + if (option?.auth_method_locked && !initialForm.authMethod) { + initialForm.authMethod = option.default_auth_method ?? "" } - }, [model]) + setForm(initialForm) + setSetAsDefault(model.is_default && model.default_model_allowed !== false) + setError("") + } + }, [model, providerOptions]) const setField = (key: keyof EditForm) => - (e: React.ChangeEvent) => + (e: React.ChangeEvent) => { + if (error) { + setError("") + } setForm((f) => ({ ...f, [key]: e.target.value })) + } + + const setProvider = (value: string) => { + if (error) { + setError("") + } + setForm((f) => { + const previousOption = findProviderOption(f.provider, providerOptions) + const nextOption = findProviderOption(value, providerOptions) + let authMethod = f.authMethod + if (nextOption?.auth_method_locked) { + authMethod = nextOption.default_auth_method ?? "" + } else if ( + previousOption?.auth_method_locked && + f.authMethod === (previousOption.default_auth_method ?? "") + ) { + authMethod = "" + } + return { ...f, provider: value, authMethod } + }) + const nextOption = findProviderOption(value, providerOptions) + if (nextOption?.default_model_allowed === false) { + setSetAsDefault(false) + } + } const handleSave = async () => { if (!model) return + if (!selectedProviderOption) { + setError(providerError) + return + } if (!form.modelId.trim()) { setError(t("models.add.errorRequired")) return @@ -136,7 +232,9 @@ export function EditModelSheet({ api_base: form.apiBase || undefined, api_key: form.apiKey || undefined, proxy: form.proxy || undefined, - auth_method: form.authMethod || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod || undefined, connect_mode: form.connectMode || undefined, workspace: form.workspace || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -172,7 +270,7 @@ export function EditModelSheet({ } } - const isOAuth = model?.auth_method === "oauth" + const isOAuth = effectiveAuthMethod === "oauth" const hasSavedAPIKey = Boolean(model?.api_key) const apiKeyPlaceholder = hasSavedAPIKey ? maskedSecretPlaceholder( @@ -201,12 +299,36 @@ export function EditModelSheet({ - + @@ -267,12 +396,17 @@ export function EditModelSheet({ diff --git a/web/frontend/src/components/models/model-card.tsx b/web/frontend/src/components/models/model-card.tsx index 44730bb57..e53fcdeca 100644 --- a/web/frontend/src/components/models/model-card.tsx +++ b/web/frontend/src/components/models/model-card.tsx @@ -36,7 +36,10 @@ export function ModelCard({ const status = model.status const statusLabel = t(`models.status.${status}`) const canSetDefault = - model.available && !model.is_default && !model.is_virtual + model.available && + !model.is_default && + !model.is_virtual && + model.default_model_allowed !== false const setDefaultLabel = t("models.action.setDefault") const setDefaultDisabledReason = (() => { @@ -45,6 +48,9 @@ export function ModelCard({ return t("models.action.setDefaultDisabled.unavailable") if (model.is_default) return t("models.action.setDefaultDisabled.isDefault") if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual") + if (model.default_model_allowed === false) { + return t("models.action.setDefaultDisabled.unsupportedProvider") + } return setDefaultLabel })() diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index 152c47585..df372b6b1 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -3,7 +3,12 @@ import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" -import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" +import { + type ModelInfo, + type ModelProviderOption, + getModels, + setDefaultModel, +} from "@/api/models" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" @@ -12,41 +17,13 @@ import { refreshGatewayState } from "@/store/gateway" import { AddModelSheet } from "./add-model-sheet" import { DeleteModelDialog } from "./delete-model-dialog" import { EditModelSheet } from "./edit-model-sheet" -import { getProviderKey, getProviderLabel } from "./provider-label" +import { + PROVIDER_PRIORITY, + getProviderKey, + getProviderLabel, +} from "./provider-label" import { ProviderSection } from "./provider-section" -const PROVIDER_PRIORITY: Record = { - volcengine: 0, - openai: 1, - gemini: 2, - anthropic: 3, - zhipu: 4, - deepseek: 5, - openrouter: 6, - "qwen-portal": 7, - "qwen-intl": 8, - moonshot: 9, - groq: 10, - "github-copilot": 11, - antigravity: 12, - nvidia: 13, - cerebras: 14, - shengsuanyun: 15, - venice: 16, - vivgrid: 17, - minimax: 18, - longcat: 19, - modelscope: 20, - mistral: 21, - avian: 22, - azure: 23, - ollama: 24, - vllm: 25, - lmstudio: 26, - zai: 27, - mimo: 28, -} - interface ProviderGroup { key: string label: string @@ -58,6 +35,9 @@ interface ProviderGroup { export function ModelsPage() { const { t } = useTranslation() const [models, setModels] = useState([]) + const [providerOptions, setProviderOptions] = useState( + [], + ) const [loading, setLoading] = useState(true) const [fetchError, setFetchError] = useState("") @@ -67,6 +47,7 @@ export function ModelsPage() { const [settingDefaultIndex, setSettingDefaultIndex] = useState( null, ) + const addDisabled = loading || providerOptions.length === 0 const fetchModels = useCallback(async () => { try { @@ -79,6 +60,7 @@ export function ModelsPage() { return a.model_name.localeCompare(b.model_name) }) setModels(sorted) + setProviderOptions(data.provider_options ?? []) setFetchError("") } catch (e) { setFetchError(e instanceof Error ? e.message : t("models.loadError")) @@ -160,7 +142,12 @@ export function ModelsPage() {
- @@ -213,6 +200,7 @@ export function ModelsPage() { setEditingModel(null)} onSaved={fetchModels} @@ -220,6 +208,7 @@ export function ModelsPage() { setAddOpen(false)} onSaved={fetchModels} existingModelNames={models.map((model) => model.model_name)} diff --git a/web/frontend/src/components/models/provider-icon.tsx b/web/frontend/src/components/models/provider-icon.tsx index 8d1cfe2c9..2ac728e76 100644 --- a/web/frontend/src/components/models/provider-icon.tsx +++ b/web/frontend/src/components/models/provider-icon.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react" const PROVIDER_ICON_SLUGS: Record = { openai: "openai", + elevenlabs: "elevenlabs", anthropic: "anthropic", azure: "microsoftazure", gemini: "googlegemini", @@ -21,6 +22,7 @@ const PROVIDER_ICON_SLUGS: Record = { const PROVIDER_DOMAINS: Record = { openai: "openai.com", + elevenlabs: "elevenlabs.io", anthropic: "anthropic.com", azure: "azure.com", gemini: "gemini.google.com", diff --git a/web/frontend/src/components/models/provider-label.ts b/web/frontend/src/components/models/provider-label.ts index 123640fe5..75eb81e53 100644 --- a/web/frontend/src/components/models/provider-label.ts +++ b/web/frontend/src/components/models/provider-label.ts @@ -1,11 +1,19 @@ +import type { ModelProviderOption } from "@/api/models" + const PROVIDER_LABELS: Record = { openai: "OpenAI", + bedrock: "AWS Bedrock", + elevenlabs: "ElevenLabs ASR", anthropic: "Anthropic", + "anthropic-messages": "Anthropic Messages", azure: "Azure OpenAI", gemini: "Google Gemini", deepseek: "DeepSeek", + "coding-plan": "Alibaba Coding Plan", + "coding-plan-anthropic": "Alibaba Coding Plan (Anthropic)", "qwen-portal": "Qwen (阿里云)", "qwen-intl": "Qwen International", + "qwen-us": "Qwen US", moonshot: "Moonshot (月之暗面)", groq: "Groq", openrouter: "OpenRouter", @@ -15,8 +23,11 @@ const PROVIDER_LABELS: Record = { shengsuanyun: "ShengsuanYun (神算云)", antigravity: "Google Code Assist", "github-copilot": "GitHub Copilot", + "claude-cli": "Claude CLI (local)", + "codex-cli": "Codex CLI (local)", ollama: "Ollama (local)", lmstudio: "LM Studio (local)", + litellm: "LiteLLM", mistral: "Mistral AI", avian: "Avian", vllm: "VLLM (local)", @@ -28,6 +39,7 @@ const PROVIDER_LABELS: Record = { minimax: "MiniMax", longcat: "LongCat", modelscope: "ModelScope (魔搭社区)", + novita: "Novita AI", } const PROVIDER_ALIASES: Record = { @@ -40,6 +52,48 @@ const PROVIDER_ALIASES: Record = { "google-antigravity": "antigravity", } +export const PROVIDER_PRIORITY: Record = { + volcengine: 0, + openai: 1, + gemini: 2, + anthropic: 3, + bedrock: 4, + elevenlabs: 5, + "anthropic-messages": 6, + zhipu: 7, + deepseek: 8, + openrouter: 9, + "qwen-portal": 10, + "qwen-intl": 11, + "qwen-us": 12, + moonshot: 13, + groq: 14, + "coding-plan": 15, + "coding-plan-anthropic": 16, + "github-copilot": 17, + antigravity: 18, + nvidia: 19, + cerebras: 20, + shengsuanyun: 21, + venice: 22, + vivgrid: 23, + minimax: 24, + longcat: 25, + modelscope: 26, + mistral: 27, + avian: 28, + novita: 29, + azure: 30, + litellm: 31, + ollama: 32, + vllm: 33, + lmstudio: 34, + "claude-cli": 35, + "codex-cli": 36, + zai: 37, + mimo: 38, +} + export function getProviderKey(provider?: string): string { const normalized = provider?.trim().toLowerCase() if (!normalized) return "openai" @@ -50,3 +104,45 @@ export function getProviderLabel(provider?: string): string { const prefix = getProviderKey(provider) return PROVIDER_LABELS[prefix] ?? prefix } + +export function findProviderOption( + provider: string | undefined, + options: ModelProviderOption[], +): ModelProviderOption | undefined { + const providerKey = getProviderKey(provider) + return options.find((option) => option.id === providerKey) +} + +export function getProviderDefaultAPIBase( + provider: string | undefined, + options: ModelProviderOption[], +): string { + return findProviderOption(provider, options)?.default_api_base ?? "" +} + +export function getSortedProviderOptions( + options: ModelProviderOption[], +): ModelProviderOption[] { + return [...options].sort((a, b) => { + const aPriority = PROVIDER_PRIORITY[a.id] ?? Number.MAX_SAFE_INTEGER + const bPriority = PROVIDER_PRIORITY[b.id] ?? Number.MAX_SAFE_INTEGER + if (aPriority !== bPriority) { + return aPriority - bPriority + } + return getProviderLabel(a.id).localeCompare(getProviderLabel(b.id)) + }) +} + +export function getProviderDefaultAuthMethod( + provider: string | undefined, + options: ModelProviderOption[], +): string { + return findProviderOption(provider, options)?.default_auth_method ?? "" +} + +export function isProviderAuthMethodLocked( + provider: string | undefined, + options: ModelProviderOption[], +): boolean { + return findProviderOption(provider, options)?.auth_method_locked === true +} diff --git a/web/frontend/src/hooks/use-chat-models.ts b/web/frontend/src/hooks/use-chat-models.ts index 337bea8db..98566f70f 100644 --- a/web/frontend/src/hooks/use-chat-models.ts +++ b/web/frontend/src/hooks/use-chat-models.ts @@ -27,17 +27,26 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { const [defaultModelName, setDefaultModelName] = useState("") const setDefaultRequestIdRef = useRef(0) + const syncDefaultModelName = useCallback( + (models: ModelInfo[], defaultModel: string) => { + if (models.some((m) => m.model_name === defaultModel)) { + setDefaultModelName(defaultModel) + return + } + setDefaultModelName("") + }, + [], + ) + const loadModels = useCallback(async () => { try { const data = await getModels() setModelList(data.models) - if (data.models.some((m) => m.model_name === data.default_model)) { - setDefaultModelName(data.default_model) - } + syncDefaultModelName(data.models, data.default_model) } catch { // silently fail } - }, []) + }, [syncDefaultModelName]) useEffect(() => { const timerId = setTimeout(() => { @@ -60,9 +69,7 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { } setModelList(data.models) - if (data.models.some((m) => m.model_name === data.default_model)) { - setDefaultModelName(data.default_model) - } + syncDefaultModelName(data.models, data.default_model) const gateway = await refreshGatewayState({ force: true }) showSaveSuccessOrRestartToast( t, @@ -75,30 +82,41 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { toast.error(err instanceof Error ? err.message : t("models.loadError")) } }, - [defaultModelName, t], + [defaultModelName, syncDefaultModelName, t], + ) + + const defaultSelectableModels = useMemo( + () => + modelList.filter( + (m) => m.default_model_allowed !== false && m.is_virtual !== true, + ), + [modelList], ) const hasAvailableModels = useMemo( - () => modelList.some((m) => m.available), - [modelList], + () => defaultSelectableModels.some((m) => m.available), + [defaultSelectableModels], ) const oauthModels = useMemo( - () => modelList.filter((m) => m.available && m.auth_method === "oauth"), - [modelList], + () => + defaultSelectableModels.filter( + (m) => m.available && m.auth_method === "oauth", + ), + [defaultSelectableModels], ) const localModels = useMemo( - () => modelList.filter((m) => m.available && isLocalModel(m)), - [modelList], + () => defaultSelectableModels.filter((m) => m.available && isLocalModel(m)), + [defaultSelectableModels], ) const apiKeyModels = useMemo( () => - modelList.filter( + defaultSelectableModels.filter( (m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m), ), - [modelList], + [defaultSelectableModels], ) return { diff --git a/web/frontend/src/i18n/index.ts b/web/frontend/src/i18n/index.ts index bdc1fe917..4da7b3f0d 100644 --- a/web/frontend/src/i18n/index.ts +++ b/web/frontend/src/i18n/index.ts @@ -1,5 +1,6 @@ import dayjs from "dayjs" import "dayjs/locale/en" +import "dayjs/locale/pt-br" import "dayjs/locale/zh-cn" import localizedFormat from "dayjs/plugin/localizedFormat" import relativeTime from "dayjs/plugin/relativeTime" @@ -8,6 +9,7 @@ import LanguageDetector from "i18next-browser-languagedetector" import { initReactI18next } from "react-i18next" import en from "./locales/en.json" +import ptBr from "./locales/pt-br.json" import zh from "./locales/zh.json" dayjs.extend(relativeTime) @@ -26,6 +28,9 @@ i18n en: { translation: en, }, + "pt-BR": { + translation: ptBr, + }, zh: { translation: zh, }, @@ -41,6 +46,8 @@ i18n i18n.on("languageChanged", (lng) => { if (lng.startsWith("zh")) { dayjs.locale("zh-cn") + } else if (lng.startsWith("pt")) { + dayjs.locale("pt-br") } else { dayjs.locale("en") } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 0a378c115..4559815f4 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -246,7 +246,8 @@ "setting": "Setting as default...", "unavailable": "Cannot set unavailable model as default", "isDefault": "Already the default model", - "isVirtual": "Cannot set virtual model as default" + "isVirtual": "Cannot set virtual model as default", + "unsupportedProvider": "This provider is ASR-only and cannot be the default chat model" }, "deleteDisabled": { "isDefault": "Cannot delete the default model" @@ -254,7 +255,9 @@ }, "defaultOnSave": { "label": "Default Model", - "description": "Automatically set this model as default after saving." + "description": "Automatically set this model as default after saving.", + "unsupportedProvider": "This provider can be saved in model_list, but it cannot be used as the default chat model.", + "clearOnSave": "Saving this ASR-only model will clear the current default chat model selection." }, "add": { "button": "Add Model", @@ -265,7 +268,7 @@ "modelNameHint": "A short name used to identify this model in conversations.", "modelId": "Model Identifier", "modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o", - "modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.", + "modelIdHint": "This field is sent as the canonical model ID for the selected Provider. If the model ID itself contains slashes, such as openai/gpt-5.4, it is preserved as-is instead of being split again.", "errorRequired": "This field is required.", "errorDuplicateModelName": "Model alias already exists. Please use a different name.", "saveError": "Failed to add model", @@ -282,8 +285,9 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "e.g. openai", - "providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.", + "providerPlaceholder": "Select a provider", + "providerHint": "Choose a Provider from the backend catalog. The Model Identifier field is interpreted as that Provider's canonical model ID.", + "providerInvalid": "The current Provider is invalid. Select a supported Provider.", "apiBase": "API Base URL", "apiKey": "API Key", "apiKeyPlaceholder": "Enter your API key", @@ -292,6 +296,7 @@ "proxyHint": "Optional. e.g. http://127.0.0.1:7890", "authMethod": "Auth Method", "authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.", + "authMethodManagedHint": "This Provider manages its authentication mode automatically.", "connectMode": "Connect Mode", "connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.", "workspace": "Workspace Path", diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json new file mode 100644 index 000000000..c091625bb --- /dev/null +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -0,0 +1,753 @@ +{ + "navigation": { + "chat": "Chat", + "model_group": "Modelos", + "models": "Modelos", + "credentials": "Credenciais", + "agent_group": "Agente", + "hub": "Hub", + "skills": "Skills", + "tools": "Ferramentas", + "services": "Serviços", + "channels_group": "Canais", + "show_more_channels": "Mais", + "show_less_channels": "Menos", + "config": "Configuração", + "logs": "Logs" + }, + "launcherLogin": { + "title": "Entrar", + "description": "Digite a senha do dashboard para continuar.", + "passwordLabel": "Senha", + "passwordPlaceholder": "Digite a senha", + "submit": "Entrar", + "errorInvalid": "Senha incorreta. Tente novamente.", + "errorNetwork": "Erro de rede. Tente novamente." + }, + "launcherSetup": { + "title": "Definir senha do dashboard", + "description": "Escolha uma senha para proteger o acesso a este dashboard. Você a usará toda vez que entrar.", + "passwordLabel": "Senha", + "passwordPlaceholder": "Pelo menos 8 caracteres", + "confirmLabel": "Confirmar senha", + "confirmPlaceholder": "Repita a senha", + "submit": "Definir senha", + "errorMismatch": "As senhas não coincidem.", + "errorNetwork": "Erro de rede. Tente novamente." + }, + "chat": { + "welcome": "Como posso te ajudar hoje?", + "welcomeDesc": "Pergunte sobre clima, configurações ou qualquer outra tarefa. Estou aqui para ajudar.", + "placeholder": "Inicie uma nova mensagem...", + "disabledPlaceholder": { + "gatewayUnknown": "Não é possível conversar: o status do Gateway ainda está sendo verificado. Aguarde e atualize a página ou reinicie o Launcher se necessário.", + "gatewayStarting": "Não é possível conversar: o Gateway está iniciando. Aguarde a inicialização concluir e tente novamente.", + "gatewayRestarting": "Não é possível conversar: o Gateway está reiniciando. Aguarde o reinício terminar.", + "gatewayStopping": "Não é possível conversar: o Gateway está parando. Aguarde até que pare e inicie o Gateway novamente.", + "gatewayStopped": "Não é possível conversar: o Gateway não está iniciado. Clique em Iniciar Gateway na barra superior e tente novamente.", + "gatewayError": "Não é possível conversar: o Gateway está em estado de erro. Verifique os logs e reinicie o Gateway ou o Launcher.", + "websocketConnecting": "Conectando ao serviço de chat... Aguarde.", + "websocketDisconnected": "Não é possível conversar: a conexão WebSocket está desconectada. Verifique a rede e o status do gateway, atualize a página ou reinicie o Launcher.", + "websocketError": "Não é possível conversar: a conexão WebSocket falhou. Verifique a rede e o status do gateway e tente novamente.", + "noDefaultModel": "Não é possível conversar: nenhum modelo padrão está selecionado. Defina um modelo padrão na página de Modelos." + }, + "newChat": "Novo Chat", + "notConnected": "O Gateway não está rodando. Inicie-o para conversar.", + "thinking": { + "step1": "Pensando...", + "step2": "Analisando sua solicitação...", + "step3": "Preparando resposta...", + "step4": "Quase lá..." + }, + "reasoningLabel": "Raciocínio", + "toolCallsLabel": "Chamadas de ferramentas", + "toolCallExplanationLabel": "Nota da chamada", + "toolCallFunctionLabel": "Resumo da chamada", + "showAssistantDetails": "Mostrar raciocínio e chamadas de ferramentas", + "toolLabel": "Ferramenta", + "history": "Histórico", + "noHistory": "Nenhum histórico de chat ainda", + "historyLoadFailed": "Falha ao carregar histórico de chat", + "historyOpenFailed": "Falha ao abrir este histórico de chat", + "loadingMore": "Carregando mais...", + "deleteSession": "Excluir sessão", + "messagesCount": "{{count}} mensagens", + "noModel": "Selecionar modelo", + "inputDisabled": { + "notConnected": "O Gateway não está rodando. Inicie-o para conversar.", + "noModel": "Nenhum modelo padrão configurado. Vá para a página de Modelos para definir um." + }, + "sendMessage": "Enviar mensagem", + "sendHint": "Pressione Enter para enviar\nShift + Enter para nova linha", + "contextTitle": "Contexto", + "contextDetail": "Ver Detalhes", + "attachImage": "Adicionar imagens", + "removeImage": "Remover imagem", + "uploadedImage": "Imagem enviada", + "invalidImage": "\"{{name}}\" não é um arquivo de imagem suportado.", + "imageTooLarge": "\"{{name}}\" excede o limite de {{size}}.", + "imageReadFailed": "Falha ao ler \"{{name}}\".", + "empty": { + "noConfiguredModel": "Nenhum Modelo Configurado", + "noConfiguredModelDescription": "Você precisa configurar pelo menos um modelo de IA com uma API Key antes de iniciar o chat.", + "goToModels": "Ir para Modelos", + "noSelectedModel": "Nenhum Modelo Selecionado", + "noSelectedModelDescription": "Você tem modelos configurados, mas nenhum está definido como padrão. Selecione um modelo antes de iniciar o chat.", + "notRunning": "Gateway Não Está Rodando", + "notRunningDescription": "Inicie o serviço de gateway para começar a conversar. Use o botão Iniciar Gateway na barra superior." + }, + "modelGroup": { + "apikey": "API Key", + "oauth": "OAuth", + "local": "Local" + } + }, + "header": { + "logout": { + "tooltip": "Sair", + "confirm": "Sair", + "description": "Tem certeza de que deseja sair do dashboard?" + }, + "gateway": { + "stopDialog": { + "title": "Parar o Serviço de Gateway?", + "description": "Tem certeza de que deseja parar o gateway? Isso desconectará suas sessões de chat ativas e interromperá a inferência.", + "confirm": "Parar Gateway" + }, + "action": { + "start": "Iniciar Gateway", + "stop": "Parar Gateway", + "restart": "Reiniciar Gateway" + }, + "status": { + "starting": "Iniciando Gateway...", + "restarting": "Reiniciando Gateway...", + "stopping": "Parando Gateway..." + }, + "restartRequired": "Alterações de configuração requerem reiniciar o gateway para ter efeito." + } + }, + "common": { + "cancel": "Cancelar", + "save": "Salvar", + "saving": "Salvando...", + "reset": "Redefinir", + "confirm": "Confirmar", + "saveChangesTitle": "Você tem alterações de configuração não salvas", + "restartRequiredTitle": "Reinício do gateway necessário", + "restartRequiredDesc": "A configuração mais recente de {{name}} foi salva. Reinicie o gateway para que tenha efeito." + }, + "labels": { + "loading": "Carregando..." + }, + "footer": { + "version": "Versão", + "commit": "Commit", + "build": "Build", + "version_unknown": "Desconhecido" + }, + "credentials": { + "description": "Gerencie credenciais OAuth e baseadas em token para os provedores suportados.", + "loading": "Carregando credenciais...", + "providers": { + "openai": { + "description": "Suporta OAuth via navegador, device code e login por token." + }, + "anthropic": { + "description": "Usa login por token para acesso ao Claude." + }, + "antigravity": { + "description": "Usa OAuth via navegador para o Google Cloud Code Assist." + } + }, + "status": { + "connected": "Conectado", + "needsRefresh": "Precisa atualizar", + "expired": "Expirado", + "notLoggedIn": "Não autenticado" + }, + "actions": { + "browser": "OAuth via Navegador", + "deviceCode": "Device Code", + "stopLoading": "Parar Carregamento", + "saveToken": "Salvar", + "logout": "Sair" + }, + "logoutDialog": { + "title": "Sair do provedor?", + "description": "Isso removerá sua credencial salva para {{provider}}." + }, + "fields": { + "openaiToken": "Token OpenAI", + "anthropicToken": "Token Anthropic" + }, + "labels": { + "account": "Conta", + "email": "Email", + "project": "Projeto" + }, + "errors": { + "loadFailed": "Falha ao carregar credenciais", + "flowFailed": "Falha ao verificar fluxo de autenticação", + "loginFailed": "Falha no login", + "logoutFailed": "Falha ao sair", + "invalidBrowserResponse": "Resposta de login do navegador inválida", + "invalidDeviceResponse": "Resposta de device code inválida", + "popupBlocked": "Não foi possível abrir uma nova aba. Permita popups e tente novamente." + }, + "flow": { + "current": "Status atual de autenticação", + "pending": "Aguardando autorização...", + "success": "Autenticação bem-sucedida", + "error": "Falha na autenticação", + "expired": "Sessão de autenticação expirada" + }, + "device": { + "title": "Login por Device do OpenAI", + "description": "Abra a página de verificação e digite o código abaixo. Esta página será atualizada automaticamente.", + "code": "Código do Usuário", + "url": "URL de Verificação", + "polling": "Verificando status do login...", + "open": "Abrir Página de Verificação" + } + }, + "models": { + "description": "Configure API Keys para provedores de IA. Apenas modelos configurados ficam disponíveis para o chat.", + "defaultChangeSuccess": "Modelo padrão atualizado.", + "unsavedPrompt": "Esta alteração ainda não foi salva. Salve para gravá-la na configuração do modelo.", + "restartHint": "Alterações na configuração de modelos só têm efeito após o gateway reiniciar.", + "loadError": "Falha ao carregar modelos", + "noDefaultHintPrefix": "Nenhum modelo padrão definido ainda. Clique em", + "noDefaultHintSuffix": "para definir um.", + "status": { + "available": "Disponível", + "unconfigured": "Não configurado", + "unreachable": "Serviço inacessível" + }, + "badge": { + "default": "Padrão", + "virtual": "Virtual" + }, + "action": { + "edit": "Editar API Key", + "setDefault": "Definir como padrão", + "delete": "Excluir modelo", + "setDefaultDisabled": { + "setting": "Definindo como padrão...", + "unavailable": "Não é possível definir um modelo indisponível como padrão", + "isDefault": "Já é o modelo padrão", + "isVirtual": "Não é possível definir um modelo virtual como padrão" + }, + "deleteDisabled": { + "isDefault": "Não é possível excluir o modelo padrão" + } + }, + "defaultOnSave": { + "label": "Modelo Padrão", + "description": "Definir automaticamente este modelo como padrão após salvar." + }, + "add": { + "button": "Adicionar Modelo", + "title": "Adicionar Modelo Customizado", + "description": "Adicione um endpoint de modelo nativo ou compatível com OpenAI.", + "modelName": "Apelido do Modelo", + "modelNamePlaceholder": "ex: meu-gpt4", + "modelNameHint": "Um nome curto usado para identificar este modelo nas conversas.", + "modelId": "Identificador do Modelo", + "modelIdPlaceholder": "ex: gpt-4o ou openai/gpt-4o", + "modelIdHint": "Se Provider não estiver especificado, valores como openai/gpt-4o são interpretados no formato provider/modelo. Se Provider estiver especificado, este campo é tratado como o ID canônico do modelo e não é parseado em busca de prefixo de provider.", + "errorRequired": "Este campo é obrigatório.", + "errorDuplicateModelName": "Apelido de modelo já existe. Use um nome diferente.", + "saveError": "Falha ao adicionar modelo", + "saveSuccess": "Modelo adicionado.", + "confirm": "Adicionar Modelo" + }, + "delete": { + "title": "Excluir Modelo?", + "description": "\"{{name}}\" será removido permanentemente da sua lista de modelos. Esta ação não pode ser desfeita.", + "confirm": "Excluir" + }, + "advanced": { + "toggle": "Opções avançadas" + }, + "field": { + "provider": "Provider", + "providerPlaceholder": "ex: openai", + "providerHint": "Opcional. Se especificado, este valor é usado como o provider efetivo, e Identificador do Modelo é interpretado como o ID canônico do modelo.", + "apiBase": "URL Base da API", + "apiKey": "API Key", + "apiKeyPlaceholder": "Digite sua API Key", + "apiKeyPlaceholderSet": "Deixe em branco para manter a chave existente", + "proxy": "Proxy HTTP", + "proxyHint": "Opcional. ex: http://127.0.0.1:7890", + "authMethod": "Método de Autenticação", + "authMethodHint": "Método de autenticação: oauth, token. Deixe em branco para autenticação por API Key.", + "connectMode": "Modo de Conexão", + "connectModeHint": "Modo de conexão para providers baseados em CLI: stdio ou grpc.", + "workspace": "Caminho do Workspace", + "workspaceHint": "Diretório de trabalho para providers baseados em CLI (ex: GitHub Copilot).", + "requestTimeout": "Timeout da Requisição (s)", + "requestTimeoutHint": "Tempo máximo em segundos para aguardar uma resposta. 0 = usar padrão.", + "rpm": "Limite de Taxa (RPM)", + "rpmHint": "Máximo de requisições por minuto. 0 = sem limite.", + "thinkingLevel": "Nível de Pensamento", + "thinkingLevelHint": "Orçamento de pensamento estendido: off, low, medium, high, xhigh, adaptive.", + "maxTokensField": "Campo de Max Tokens", + "maxTokensFieldHint": "Sobrescreve o nome do campo de max tokens na requisição, ex: max_completion_tokens.", + "extraBody": "Body Extra", + "extraBodyHint": "Campos JSON adicionais para injetar no body da requisição, ex: {\"reasoning_split\": true}.", + "customHeaders": "Headers Customizados", + "customHeadersHint": "Headers HTTP adicionais para injetar em cada requisição, ex: {\"X-Source\": \"coding-plan\"}." + }, + "edit": { + "title": "Configurar {{name}}", + "apiKeyHint": "Já existe uma chave definida. Deixe em branco para mantê-la inalterada.", + "oauthNote": "Este provider usa OAuth — não é necessária API Key.", + "saveError": "Falha ao salvar", + "saveSuccess": "Configuração do modelo salva." + } + }, + "channels": { + "loadError": "Falha ao carregar canais", + "name": { + "telegram": "Telegram", + "discord": "Discord", + "slack": "Slack", + "feishu": "Feishu", + "dingtalk": "DingTalk", + "line": "LINE", + "qq": "QQ", + "onebot": "OneBot", + "wecom": "WeCom", + "whatsapp": "WhatsApp", + "whatsapp_native": "WhatsApp Nativo", + "pico": "Web", + "maixcam": "MaixCam", + "matrix": "Matrix", + "irc": "IRC", + "weixin": "WeChat" + }, + "weixin": { + "bindTitle": "Vincular Conta do WeChat", + "bindDesc": "Escaneie o QR code com o WeChat para vincular sua conta pessoal.", + "bind": "Vincular WeChat", + "rebind": "Re-vincular", + "bound": "WeChat Vinculado", + "notBound": "Conta do WeChat ainda não vinculada.", + "generating": "Gerando QR code...", + "scanHint": "Abra o WeChat e escaneie o QR code", + "scanned": "Escaneado — confirme no WeChat", + "expired": "QR code expirado", + "retry": "Tentar Novamente", + "refresh": "Atualizar QR", + "errorGeneric": "Ocorreu um erro. Tente novamente." + }, + "wecom": { + "bindTitle": "Vincular WeCom", + "bindDesc": "Escaneie o QR code com o WeCom para vincular seu AI Bot.", + "bind": "Vincular WeCom", + "rebind": "Re-vincular", + "bound": "WeCom Vinculado", + "notBound": "AI Bot do WeCom ainda não vinculado.", + "generating": "Gerando QR code...", + "scanHint": "Abra o WeCom e escaneie o QR code", + "scanned": "Escaneado, confirme no WeCom", + "expired": "QR code expirado", + "retry": "Tentar Novamente", + "refresh": "Atualizar QR", + "errorGeneric": "Ocorreu um erro. Tente novamente." + }, + "field": { + "token": "Token do Bot", + "tokenPlaceholder": "Digite o token do bot", + "botToken": "Token do Bot", + "appToken": "App Token", + "appId": "App ID", + "appSecret": "App Secret", + "verificationToken": "Token de Verificação", + "encryptKey": "Chave de Criptografia", + "baseUrl": "URL Base da API", + "proxy": "Proxy HTTP", + "mentionOnly": "Apenas com Menção", + "typingEnabled": "Indicador de Digitação", + "placeholderEnabled": "Mensagem de Placeholder", + "placeholderText": "Texto do Placeholder", + "groupTriggerMentionOnly": "Apenas Menção em Grupo", + "groupTriggerPrefixes": "Prefixos de Trigger em Grupo", + "groupTriggerPrefixesPlaceholder": "ex: /, !, ?", + "randomReactionEmoji": "Emoji de Reação Aleatório", + "randomReactionEmojiPlaceholder": "ex: THUMBSUP, HEART, SMILE", + "isLark": "Lark (Internacional)", + "allowFrom": "Permitir De", + "allowFromPlaceholder": "ex: 123456, 789012", + "allowOrigins": "Origens Permitidas", + "allowOriginsPlaceholder": "ex: https://exemplo.com, http://localhost:5173", + "removeListItem": "Remover {{value}}", + "secretPlaceholder": "Digite o segredo", + "secretHintSet": "Já existe um valor definido. Deixe em branco para mantê-lo inalterado." + }, + "page": { + "notFound": "Canal \"{{name}}\" não é suportado.", + "saveSuccess": "Configuração do canal salva.", + "saveError": "Falha ao salvar configuração do canal", + "savePrompt": "Esta alteração ainda não foi salva. Salve para gravá-la na configuração do canal.", + "docLink": "Documentação", + "enableLabel": "Habilitar canal", + "restartRequiredTitle": "Reinício do gateway necessário", + "restartRequiredDesc": "A configuração mais recente de {{name}} foi salva. Reinicie o gateway para que tenha efeito." + }, + "form": { + "desc": { + "token": "Token de acesso do bot usado para conectar à API da plataforma.", + "botToken": "Token do bot usado para enviar e receber mensagens.", + "appToken": "App token usado para conexões em modo Socket.", + "appId": "ID único da aplicação usado para autenticação.", + "appSecret": "Segredo da aplicação usado para assinatura e autenticação.", + "verificationToken": "Token de verificação para callbacks de eventos.", + "encryptKey": "Chave de criptografia usada para descriptografar payloads de callback.", + "baseUrl": "URL base da API da plataforma. O endpoint oficial é usado por padrão.", + "proxy": "Endereço de proxy HTTP para acesso de rede de saída.", + "mentionOnly": "Responder apenas quando o bot for explicitamente mencionado em chats em grupo.", + "typingEnabled": "Exibir status de digitação enquanto o assistente está gerando uma resposta.", + "placeholderEnabled": "Habilitar mensagens de placeholder temporárias antes da resposta final ser enviada.", + "groupTriggerMentionOnly": "Em chats em grupo, responder apenas quando o bot for mencionado.", + "groupTriggerPrefixes": "Prefixos customizados de trigger para chats em grupo. Adicione itens um a um ou cole vários valores de uma vez.", + "randomReactionEmoji": "PicoClaw adiciona reações de emoji às mensagens dos usuários para confirmar recebimento. Exemplo: \"THUMBSUP\", \"HEART\", \"SMILE\". Deixe vazio para usar o emoji \"Pin\" padrão.", + "isLark": "Usar o domínio internacional do Lark (open.larksuite.com) em vez do domínio do Feishu (open.feishu.cn).", + "allowFrom": "IDs de usuário ou grupo permitidos. Adicione itens um a um ou cole vários valores de uma vez.", + "allowOrigins": "Domínios de origem permitidos. Adicione itens um a um ou cole vários valores de uma vez.", + "wsUrl": "URL do serviço WebSocket.", + "reconnectInterval": "Intervalo de reconexão após desconexão (segundos).", + "bridgeUrl": "URL do serviço de bridge.", + "sessionStorePath": "Caminho local para armazenamento de sessões.", + "useNative": "Se deve usar modo de cliente nativo.", + "host": "Endereço do host do serviço.", + "port": "Porta do serviço.", + "homeserver": "URL do homeserver Matrix.", + "userId": "ID de usuário da conta.", + "deviceId": "ID do dispositivo.", + "joinOnInvite": "Entrar automaticamente em salas quando convidado.", + "clientId": "Client ID usado para autenticação na plataforma.", + "corpId": "Corp ID corporativo.", + "agentId": "Agent ID da aplicação corporativa.", + "webhookUrl": "URL completa do webhook.", + "webhookHost": "Host de escuta do webhook.", + "webhookPort": "Porta de escuta do webhook.", + "webhookPath": "Caminho de rota do webhook.", + "replyTimeout": "Timeout de resposta em segundos.", + "maxSteps": "Número máximo de passos de processamento.", + "welcomeMessage": "Conteúdo da mensagem de boas-vindas para novas sessões.", + "allowTokenQuery": "Permitir token nos parâmetros de query da URL.", + "pingInterval": "Intervalo de heartbeat da conexão em segundos.", + "readTimeout": "Timeout de leitura em segundos.", + "writeTimeout": "Timeout de escrita em segundos.", + "maxConnections": "Número máximo de conexões concorrentes.", + "server": "Endereço do servidor IRC.", + "tls": "Se deve habilitar TLS.", + "nick": "Apelido do bot.", + "user": "Nome de usuário do IRC.", + "realName": "Nome real exibido.", + "channels": "Canais IRC para entrar.", + "requestCaps": "Lista de capabilities IRC requisitada na conexão.", + "maxBase64FileSizeMiB": "Tamanho máximo em MiB para converter arquivos locais em base64 antes do upload. 0 significa ilimitado. Aplica-se apenas a arquivos locais, não a uploads via URL.", + "genericField": "Usado para configurar {{field}}." + } + }, + "validation": { + "requiredField": "Este campo é obrigatório." + } + }, + "pages": { + "agent": { + "load_error": "Falha ao carregar informações de suporte do agente.", + "skills": { + "empty": "Nenhuma skill disponível no momento.", + "install_success": "{{name}} instalada.", + "install_error": "Falha ao instalar skill.", + "search_placeholder": "Pesquisar por nome, descrição ou registry", + "source_label": "Tipo", + "sort_label": "Ordenar", + "import": "Importar Skill", + "import_success": "Skill importada.", + "import_error": "Falha ao importar skill.", + "import_invalid_type": "Apenas arquivos de skill em Markdown ou ZIP são suportados.", + "import_invalid_size": "O arquivo de skill deve ter 1 MB ou menos.", + "import_constraints": "Importe um arquivo de skill em Markdown ou ZIP de até 1 MB", + "view": "Visualizar", + "delete": "Excluir", + "delete_title": "Excluir Skill?", + "delete_description": "\"{{name}}\" será removida das skills do workspace.", + "delete_confirm": "Excluir", + "delete_success": "Skill excluída.", + "delete_error": "Falha ao excluir skill.", + "viewer_title": "Conteúdo da Skill", + "viewer_description": "Leia aqui o conteúdo efetivo atual de SKILL.md.", + "load_detail_error": "Falha ao carregar conteúdo da skill.", + "no_description": "Nenhuma descrição fornecida.", + "no_results": "Nenhuma skill corresponde aos filtros atuais.", + "dropzone_title": "Importar para o Workspace", + "dropzone_description": "Arraste um arquivo de skill aqui ou escolha um do disco.", + "dropzone_label": "Solte um arquivo de skill aqui", + "dropzone_active": "Solte para importar esta skill", + "dropzone_release": "A skill será normalizada e salva no diretório de skills do workspace.", + "marketplace_title": "Descobrir Skills", + "marketplace_description": "Pesquise nos registries de skills e instale skills úteis neste workspace", + "marketplace_search_placeholder": "Pesquise capacidades como github, docker, database...", + "marketplace_search_action": "Pesquisar", + "marketplace_search_status": "Status da Pesquisa", + "marketplace_install_status": "Status da Instalação", + "marketplace_notice_title": "Aviso de Segurança", + "marketplace_notice_body": "Skills do registry são conteúdo de terceiros. Revise o autor, URL da página, instruções e qualquer código ou credencial requerida antes de instalar.", + "marketplace_status_disabled": "Desabilitado. Habilite a ferramenta correspondente na página de Ferramentas primeiro.", + "marketplace_status_enable_hint": "Habilite a ferramenta relacionada na página de Ferramentas primeiro.", + "marketplace_search_error": "Falha ao pesquisar registries.", + "marketplace_loading_results": "Pesquisando skills...", + "marketplace_loading_more": "Carregando mais skills...", + "marketplace_results_title": "{{count}} resultados para “{{query}}”", + "marketplace_results_hint": "Resultados do registry instalam no workspace atual.", + "marketplace_install_action": "Instalar", + "marketplace_installed": "Instalada", + "marketplace_view_installed": "Ver Local", + "marketplace_installed_hint": "Já disponível neste workspace como “{{name}}”.", + "marketplace_empty_results": "Nenhuma skill instalável encontrada para “{{query}}”.", + "marketplace_idle": "Pesquise por uma capacidade para descobrir skills instaláveis nos registries configurados.", + "marketplace_unavailable": "Pesquisa de registries indisponível no momento. Verifique a configuração das ferramentas de Skills.", + "sort": { + "name_asc": "Nome (A-Z)", + "name_desc": "Nome (Z-A)", + "source": "Tipo" + }, + "origin": { + "all": "Todos os Tipos", + "builtin": "Embutida", + "third_party": "Terceiros", + "manual": "Manual" + }, + "summary": { + "total": "Total de Skills" + }, + "detail_tabs": { + "preview": "Visualização", + "raw": "Bruto", + "meta": "Metadados" + }, + "metadata": { + "name": "Nome", + "description": "Descrição", + "registry": "Registry", + "url": "URL", + "version": "Versão Instalada", + "lines": "Quantidade de Linhas", + "characters": "Quantidade de Caracteres" + }, + "marketplace_installDisabled": { + "installing": "Instalando...", + "installed": "Já instalada", + "cannotInstall": "Não é possível instalar: ferramenta relacionada não está habilitada" + } + }, + "tools": { + "search_placeholder": "Pesquisar ferramentas...", + "no_results": "Nenhuma ferramenta corresponde aos seus critérios.", + "filter": { + "all": "Todos os Status", + "enabled": "Habilitada", + "disabled": "Desabilitada", + "blocked": "Bloqueada" + }, + "empty": "Nenhuma ferramenta disponível.", + "enable_success": "Ferramenta habilitada.", + "disable_success": "Ferramenta desabilitada.", + "toggle_error": "Falha ao atualizar estado da ferramenta.", + "library_title": "Biblioteca de Ferramentas", + "library_description": "Navegue e gerencie o conjunto de ferramentas disponíveis para seus agentes de IA.", + "web_search": { + "title": "Pesquisa Web", + "description": "Fornece capacidade de pesquisa web aos agentes para encontrar informações atualizadas do mundo real. Roteia automaticamente para o provedor ativo ideal.", + "unsaved_prompt": "Esta alteração ainda não foi salva. Salve para gravá-la na configuração de Pesquisa Web.", + "global_settings": "Geral", + "providers_config": "Integrações", + "load_error": "Falha ao carregar configuração de pesquisa web.", + "save": "Salvar Alterações", + "open_settings": "Abrir Configurações", + "save_success": "Configurações salvas com sucesso.", + "save_error": "Falha ao salvar configurações.", + "provider": "Provedor Principal", + "provider_description": "Selecione o provedor padrão a ser usado quando a ferramenta de pesquisa web atender a uma requisição.", + "proxy": "Proxy HTTPS", + "proxy_description": "Proxy HTTP/S global opcional para requisições web subjacentes.", + "prefer_native": "Preferir Pesquisa Nativa", + "prefer_native_hint": "Quando habilitado, o modelo pode usar sua capacidade de pesquisa nativa em vez da lista de provedores configurados.", + "provider_hint": "Habilite este provedor e preencha as configurações de conexão necessárias.", + "max_results": "Máx. de Resultados", + "base_url": "URL Base", + "base_url_placeholder": "Sobrescrita opcional do endpoint", + "api_key": "API Key / Token", + "api_key_placeholder": "Digite a API Key, deixe em branco para manter a chave original", + "none": "Indisponível" + }, + "status": { + "enabled": "Habilitada", + "disabled": "Desabilitada", + "blocked": "Bloqueada" + }, + "categories": { + "automation": "Automação", + "filesystem": "Sistema de Arquivos", + "web": "Web", + "communication": "Comunicação", + "skills": "Skills", + "agents": "Agentes", + "hardware": "Hardware", + "discovery": "Descoberta" + }, + "reasons": { + "requires_linux": "Esta ferramenta só funciona em hosts Linux com os arquivos de dispositivo necessários expostos.", + "requires_serial_platform": "Esta ferramenta atualmente suporta hosts Linux, macOS e Windows com portas seriais acessíveis.", + "requires_skills": "Habilite `tools.skills` antes que esta ferramenta de skill-registry possa ser usada.", + "requires_subagent": "Habilite `tools.subagent` antes que a ferramenta de spawn possa delegar trabalho.", + "requires_mcp_discovery": "Habilite `tools.mcp.discovery` antes que as ferramentas de descoberta MCP fiquem disponíveis.", + "requires_web_search_provider": "Configure ao menos um provedor externo de pesquisa web pronto para uso." + } + } + }, + "config": { + "load_error": "Falha ao carregar configuração. Atualize a página e tente novamente.", + "workspace": "Diretório do Workspace", + "workspace_hint": "Diretório base para operações de arquivo do agente.", + "restrict_workspace": "Restringir ao Workspace", + "restrict_workspace_hint": "Permitir operações de arquivo apenas dentro do workspace.", + "split_on_marker": "Modo Tagarela", + "split_on_marker_hint": "Dividir mensagens longas em várias curtas, como em uma conversa real.", + "tool_feedback_enabled": "Feedback de Ferramentas", + "tool_feedback_enabled_hint": "Enviar uma breve nota de execução no chat atual antes de cada ferramenta rodar.", + "tool_feedback_separate_messages": "Mensagens de Feedback Separadas", + "tool_feedback_separate_messages_hint": "Manter cada atualização de feedback de ferramenta como uma mensagem própria no chat em vez de reusar uma única mensagem de placeholder/progresso.", + "tool_feedback_max_args_length": "Tamanho do Preview de Args da Ferramenta", + "tool_feedback_max_args_length_hint": "Número máximo de caracteres exibidos em cada preview de argumento da ferramenta. Defina 0 para usar o padrão.", + "exec_enabled": "Permitir Comandos", + "exec_enabled_hint": "Habilita ou desabilita execução de comandos para o app. Quando desabilitado, nenhuma requisição de comando rodará.", + "allow_remote": "Permitir Comandos Remotos", + "allow_remote_hint": "Quando habilitado, sessões remotas ou contextos não locais também podem executar comandos. Quando desabilitado, a execução de comandos fica limitada a contextos locais seguros.", + "enable_deny_patterns": "Habilitar Lista Negra", + "enable_deny_patterns_hint": "Quando habilitado, o app bloqueia comandos que correspondam aos seus padrões perigosos embutidos e à lista negra customizada abaixo.", + "exec_timeout_seconds": "Timeout de Comando (segundos)", + "exec_timeout_seconds_hint": "Tempo máximo de execução para requisições de comando. Defina 0 para usar o timeout padrão.", + "custom_deny_patterns": "Lista Negra de Comandos", + "custom_deny_patterns_hint": "Adicione regras extras de bloqueio de comando, uma expressão regular por linha. Um comando que casar com qualquer regra aqui será bloqueado.", + "custom_allow_patterns": "Lista Branca de Comandos", + "custom_allow_patterns_hint": "Adicione regras extras de permissão de comando, uma expressão regular por linha. Um comando que casar com qualquer regra aqui pula a verificação da lista negra, mas outros limites de segurança ainda se aplicam.", + "custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b", + "pattern_detector_title": "Ferramenta de Detecção de Padrões", + "pattern_detector_hint": "Digite um comando para testar se ele casa com algum padrão da lista negra ou branca.", + "pattern_detector_input_placeholder": "Digite um comando para testar, ex: rm -rf /tmp", + "pattern_detector_test_button": "Testar", + "pattern_detector_result_allowed": "Permitido (corresponde à lista branca)", + "pattern_detector_result_blocked": "Bloqueado (corresponde à lista negra)", + "pattern_detector_result_no_match": "Sem correspondência (usará as regras padrão)", + "allow_shell_execution": "Permitir Comandos Agendados", + "allow_shell_execution_hint": "Permitir que tarefas agendadas executem comandos por padrão. Quando desabilitado, usuários precisam passar command_confirm=true para agendar uma tarefa de comando.", + "cron_exec_timeout": "Timeout de Comando Agendado (minutos)", + "cron_exec_timeout_hint": "Tempo máximo de execução para comandos agendados. Defina 0 para desabilitar o timeout.", + "max_tokens": "Max Tokens", + "max_tokens_hint": "Limite superior de tokens por resposta do modelo.", + "context_window": "Janela de Contexto", + "context_window_hint": "Capacidade do contexto de entrada do modelo em tokens. Deixe vazio para usar o padrão (4x max tokens).", + "max_tool_iterations": "Máx. de Iterações de Ferramenta", + "max_tool_iterations_hint": "Loops máximos de chamadas de ferramenta em uma única tarefa.", + "summarize_threshold": "Limite para Resumir Mensagens", + "summarize_threshold_hint": "Iniciar resumo após este número de mensagens.", + "summarize_token_percent": "Percentual de Token para Resumir", + "summarize_token_percent_hint": "Usado quando o resumo da conversa é acionado.", + "session_scope": "Escopo da Sessão", + "session_scope_hint": "Como o contexto do chat é isolado entre peers/canais.", + "session_scope_per_channel_peer": "Por Canal + Peer", + "session_scope_per_channel_peer_desc": "Contexto separado para cada usuário em cada canal.", + "session_scope_per_channel": "Por Canal", + "session_scope_per_channel_desc": "Um contexto compartilhado por canal.", + "session_scope_per_peer": "Por Peer", + "session_scope_per_peer_desc": "Um contexto por usuário entre canais.", + "session_scope_global": "Global", + "session_scope_global_desc": "Todas as mensagens compartilham um contexto global.", + "heartbeat_enabled": "Heartbeat", + "heartbeat_enabled_hint": "Enviar mensagens de heartbeat periódicas.", + "heartbeat_interval": "Intervalo do Heartbeat (minutos)", + "heartbeat_interval_hint": "Intervalo em minutos entre sinais de heartbeat.", + "devices_enabled": "Habilitar Dispositivos", + "devices_enabled_hint": "Habilitar integrações com dispositivos de hardware.", + "monitor_usb": "Monitorar USB", + "monitor_usb_hint": "Observar eventos de plug/unplug USB quando dispositivos estiverem habilitados.", + "autostart_label": "Iniciar no Login", + "autostart_hint": "Iniciar o PicoClaw Web automaticamente quando você fizer login.", + "autostart_unsupported": "Iniciar no login não é suportado nesta plataforma.", + "autostart_load_error": "Falha ao carregar status de iniciar no login.", + "server_port": "Porta do Serviço", + "server_port_hint": "Porta HTTP usada pelo PicoClaw Web.", + "launcher_section_hint": "Alterações nesta seção entram em vigor após o launcher reiniciar.", + "gateway_restart_hint": "Alterações nesta seção entram em vigor após o gateway reiniciar.", + "dashboard_password": "Senha de Login", + "dashboard_password_hint": "Defina uma nova senha de login.", + "dashboard_password_placeholder": "Pelo menos 8 caracteres", + "dashboard_password_confirm": "Confirmar Nova Senha", + "dashboard_password_confirm_hint": "Digite a nova senha de login novamente.", + "dashboard_password_confirm_placeholder": "Repita a senha", + "dashboard_password_required": "Digite e confirme a nova senha de login.", + "dashboard_password_mismatch": "As senhas de login não coincidem.", + "dashboard_password_min_length": "A senha de login deve ter pelo menos 8 caracteres.", + "lan_access": "Habilitar Acesso pela LAN", + "lan_access_hint": "Permitir acesso de outros dispositivos na sua rede local.", + "allowed_cidrs": "CIDRs de Rede Permitidos", + "allowed_cidrs_hint": "Apenas clientes destes intervalos CIDR podem acessar o serviço. Um por linha ou separados por vírgula. Deixe vazio para permitir todos.", + "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", + "sections": { + "agent": "Agente", + "runtime": "Runtime", + "exec": "Execução de Comandos", + "cron": "Tarefas Agendadas", + "launcher": "Launcher", + "devices": "Dispositivos" + }, + "open_raw": "Configuração Bruta", + "back_to_visual": "Configuração Visual", + "raw_json_title": "Configuração JSON Bruta", + "json_placeholder": "Digite uma configuração JSON válida...", + "save_success": "Configuração salva com sucesso.", + "save_error": "Falha ao salvar configuração.", + "reset_confirm_title": "Redefinir Alterações", + "reset_confirm_desc": "Tem certeza de que deseja redefinir suas alterações não salvas para o último estado salvo?", + "reset_success": "Alterações foram redefinidas para o último estado salvo.", + "invalid_json": "Formato JSON inválido.", + "format_success": "JSON formatado com sucesso.", + "format_error": "Formato JSON inválido.", + "format": "Formatar", + "unsaved_changes": "Você tem alterações não salvas." + }, + "logs": { + "log_level_error": "Falha ao atualizar nível de log.", + "clear": "Limpar logs", + "empty": "Aguardando logs..." + } + }, + "tour": { + "skip": "Pular tour", + "prev": "Anterior", + "next": "Próximo", + "finish": "Concluir", + "welcome": { + "title": "Bem-vindo ao PicoClaw", + "description": "PicoClaw é uma plataforma poderosa de assistente de IA. Vamos levar alguns segundos para te ajudar a concluir a configuração básica." + }, + "models": { + "title": "Configurar Modelos", + "description": "Clique no menu \"Modelos\" à esquerda para configurar API Keys dos provedores de IA. Apenas modelos configurados podem ser usados no chat." + }, + "gateway": { + "title": "Iniciar Gateway", + "description": "Após configurar modelos, clique no botão \"Iniciar Gateway\" no topo para começar a conversar com a IA." + }, + "docs": { + "title": "Ver Documentação", + "description": "Precisa de mais ajuda? Clique no botão de documentação no canto superior direito para ver guias detalhados e documentação de configuração." + } + } +} diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 59fda8636..527b5b04d 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -246,7 +246,8 @@ "setting": "正在设为默认...", "unavailable": "无法将不可用的模型设为默认", "isDefault": "该模型已是默认模型", - "isVirtual": "无法将虚拟模型设为默认" + "isVirtual": "无法将虚拟模型设为默认", + "unsupportedProvider": "该 Provider 仅用于 ASR,不能设为默认聊天模型" }, "deleteDisabled": { "isDefault": "无法删除默认模型" @@ -254,7 +255,9 @@ }, "defaultOnSave": { "label": "默认模型", - "description": "保存后自动将该模型设置为默认模型。" + "description": "保存后自动将该模型设置为默认模型。", + "unsupportedProvider": "该 Provider 可以保存在 model_list 中,但不能作为默认聊天模型使用。", + "clearOnSave": "保存这个仅用于 ASR 的模型后,会清除当前的默认聊天模型设置。" }, "add": { "button": "添加模型", @@ -265,7 +268,7 @@ "modelNameHint": "用于在对话中识别此模型的简短名称。", "modelId": "模型标识符", "modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o", - "modelIdHint": "未指定 Provider 时,诸如 openai/gpt-4o 的值将按 provider/model 格式解析。已指定 Provider 时,此字段将作为规范模型 ID 使用,不再解析其中的 provider 前缀。", + "modelIdHint": "此字段将作为所选 Provider 的规范模型 ID 使用。若模型标识符本身包含斜杠(如 openai/gpt-5.4),将作为完整 ID 保留,不会再次拆分 Provider。", "errorRequired": "此字段为必填项。", "errorDuplicateModelName": "模型别名已存在,请使用其他名称。", "saveError": "添加模型失败", @@ -282,8 +285,9 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "例如 openai", - "providerHint": "可选。指定后,将以该值作为最终 provider,并将“模型标识符”字段解释为规范模型 ID。", + "providerPlaceholder": "请选择 Provider", + "providerHint": "请选择一个由后端 catalog 提供的 Provider;“模型标识符”字段会按该 Provider 的规范模型 ID 解释。", + "providerInvalid": "当前 Provider 无效,请重新选择一个受支持的 Provider。", "apiBase": "API Base URL", "apiKey": "API Key", "apiKeyPlaceholder": "请输入 API Key", @@ -292,6 +296,7 @@ "proxyHint": "可选。例如 http://127.0.0.1:7890", "authMethod": "认证方式", "authMethodHint": "认证方式:oauth、token。留空表示使用 API Key 认证。", + "authMethodManagedHint": "该 Provider 的认证方式由系统自动管理。", "connectMode": "连接模式", "connectModeHint": "CLI 型服务商的连接模式:stdio 或 grpc。", "workspace": "工作目录",