Merge upstream/main: sync with sipeed/picoclaw
This commit is contained in:
commit
30b0dfdc04
54 changed files with 5103 additions and 930 deletions
9
go.mod
9
go.mod
|
|
@ -1,9 +1,9 @@
|
||||||
module github.com/sipeed/picoclaw
|
module github.com/sipeed/picoclaw
|
||||||
|
|
||||||
go 1.25.9
|
go 1.25.10
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/systray v1.12.0
|
fyne.io/systray v1.12.1
|
||||||
github.com/SevereCloud/vksdk/v3 v3.3.1
|
github.com/SevereCloud/vksdk/v3 v3.3.1
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||||
|
|
@ -22,7 +22,8 @@ require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/h2non/filetype v1.1.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/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/minio/selfupdate v0.6.0
|
github.com/minio/selfupdate v0.6.0
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.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/github/copilot-sdk/go v0.2.0
|
||||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // 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/grbit/go-json v0.11.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
|
|
||||||
14
go.sum
14
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=
|
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 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
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.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
|
||||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
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 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
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=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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=
|
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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ=
|
||||||
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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ type AgentLoop struct {
|
||||||
steering *steeringQueue
|
steering *steeringQueue
|
||||||
subagents *tools.SubagentManager
|
subagents *tools.SubagentManager
|
||||||
pendingSkills sync.Map
|
pendingSkills sync.Map
|
||||||
|
pendingStops sync.Map
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
// workerSem limits concurrent turn processing workers.
|
// workerSem limits concurrent turn processing workers.
|
||||||
|
|
@ -179,6 +180,10 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
phase: TurnPhaseSetup,
|
phase: TurnPhaseSetup,
|
||||||
}
|
}
|
||||||
if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded {
|
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
|
// Another turn is already active (or reserved) for this session — enqueue
|
||||||
if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{
|
if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
|
|
@ -242,6 +247,24 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID)
|
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)
|
al.runTurnWithSteering(ctx, m)
|
||||||
}(msg)
|
}(msg)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,12 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||||
return nil
|
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 {
|
if agent != nil && agent.ContextBuilder != nil {
|
||||||
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
|
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -340,5 +340,20 @@ func registerSharedTools(
|
||||||
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
|
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
|
||||||
logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,36 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
|
||||||
return
|
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 {
|
for al.pendingSteeringCountForScope(target.SessionKey) > 0 {
|
||||||
// Check for context cancellation between iterations
|
if err := ctx.Err(); err != nil {
|
||||||
if ctx.Err() != nil {
|
return finalResponse, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("agent", "Continuing queued steering after turn end",
|
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)
|
continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID)
|
||||||
if continueErr != nil {
|
if continueErr != nil {
|
||||||
logger.WarnCF("agent", "Failed to continue queued steering",
|
return finalResponse, continueErr
|
||||||
map[string]any{
|
|
||||||
"channel": target.Channel,
|
|
||||||
"chat_id": target.ChatID,
|
|
||||||
"error": continueErr.Error(),
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if continued == "" {
|
if continued == "" {
|
||||||
break
|
break
|
||||||
|
|
@ -75,10 +94,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
|
||||||
finalResponse = continued
|
finalResponse = continued
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish final response
|
return finalResponse, nil
|
||||||
if finalResponse != "" {
|
|
||||||
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) {
|
func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) {
|
||||||
|
|
|
||||||
122
pkg/agent/agent_stop.go
Normal file
122
pkg/agent/agent_stop.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -156,6 +156,18 @@ func (sq *steeringQueue) lenScope(scope string) int {
|
||||||
return len(sq.queues[normalizeSteeringScope(scope)])
|
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.
|
// setMode updates the steering mode.
|
||||||
func (sq *steeringQueue) setMode(mode SteeringMode) {
|
func (sq *steeringQueue) setMode(mode SteeringMode) {
|
||||||
sq.mu.Lock()
|
sq.mu.Lock()
|
||||||
|
|
@ -290,6 +302,13 @@ func (al *AgentLoop) pendingSteeringCountForScope(scope string) int {
|
||||||
return al.steering.lenScope(scope)
|
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(
|
func (al *AgentLoop) continueWithSteeringMessages(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
|
|
@ -511,6 +530,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error {
|
||||||
"initial_history_length": ts.initialHistoryLength,
|
"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
|
// IMPORTANT: Trigger cascading cancellation FIRST to stop all child SubTurns
|
||||||
// from adding more messages to the session. This prevents race conditions
|
// from adding more messages to the session. This prevents race conditions
|
||||||
// where rollback happens while children are still writing.
|
// where rollback happens while children are still writing.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
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.
|
// capturingMockProvider captures messages sent to Chat for inspection.
|
||||||
type capturingMockProvider struct {
|
type capturingMockProvider struct {
|
||||||
response string
|
response string
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,10 @@ type SubTurnConfig struct {
|
||||||
// Used by team tool to enforce token limits across all team members.
|
// Used by team tool to enforce token limits across all team members.
|
||||||
InitialTokenBudget *atomic.Int64
|
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 ======================
|
// ====================== Context Keys ======================
|
||||||
|
|
@ -232,6 +235,7 @@ func (s *AgentLoopSpawner) SpawnSubTurn(
|
||||||
Critical: cfg.Critical,
|
Critical: cfg.Critical,
|
||||||
Timeout: cfg.Timeout,
|
Timeout: cfg.Timeout,
|
||||||
MaxContextRunes: cfg.MaxContextRunes,
|
MaxContextRunes: cfg.MaxContextRunes,
|
||||||
|
TargetAgentID: cfg.TargetAgentID,
|
||||||
}
|
}
|
||||||
|
|
||||||
return spawnSubTurn(ctx, s.al, parentTS, agentCfg)
|
return spawnSubTurn(ctx, s.al, parentTS, agentCfg)
|
||||||
|
|
@ -314,8 +318,9 @@ func spawnSubTurn(
|
||||||
return nil, ErrDepthLimitExceeded
|
return nil, ErrDepthLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Config validation
|
// 2. Config validation: Model is required unless TargetAgentID is set
|
||||||
if cfg.Model == "" {
|
// (the target agent provides its own model).
|
||||||
|
if cfg.Model == "" && cfg.TargetAgentID == "" {
|
||||||
return nil, ErrInvalidSubTurnConfig
|
return nil, ErrInvalidSubTurnConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,13 +338,23 @@ func spawnSubTurn(
|
||||||
|
|
||||||
childID := al.generateSubTurnID()
|
childID := al.generateSubTurnID()
|
||||||
|
|
||||||
// Get the agent instance from parent, falling back to the default agent.
|
// Resolve the agent instance for the child turn.
|
||||||
// Wrap it in a shallow copy that uses an ephemeral (in-memory only) session store
|
// When TargetAgentID is set, look up that agent from the registry so the
|
||||||
// so that child turns never pollute or persist to the parent's session history.
|
// child runs with the target's workspace, model, tools, and system prompt.
|
||||||
baseAgent := parentTS.agent
|
// 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 {
|
if baseAgent == nil {
|
||||||
baseAgent = al.registry.GetDefaultAgent()
|
baseAgent = al.registry.GetDefaultAgent()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if baseAgent == nil {
|
if baseAgent == nil {
|
||||||
return nil, errors.New("parent turnState has no agent instance")
|
return nil, errors.New("parent turnState has no agent instance")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -2122,3 +2125,206 @@ func TestSubTurn_IndependentContext(t *testing.T) {
|
||||||
t.Log("✓ SubTurn completed successfully (independent context)")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
||||||
al.registerActiveTurn(ts)
|
al.registerActiveTurn(ts)
|
||||||
defer al.clearActiveTurn(ts)
|
defer al.clearActiveTurn(ts)
|
||||||
|
|
||||||
|
if al.takePendingStop(ts.sessionKey) {
|
||||||
|
_ = ts.requestHardAbort()
|
||||||
|
}
|
||||||
|
|
||||||
turnStatus := TurnEndStatusCompleted
|
turnStatus := TurnEndStatusCompleted
|
||||||
defer func() {
|
defer func() {
|
||||||
al.emitEvent(
|
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(
|
al.emitEvent(
|
||||||
runtimeevents.KindAgentTurnStart,
|
runtimeevents.KindAgentTurnStart,
|
||||||
ts.eventMeta("runTurn", "turn.start"),
|
ts.eventMeta("runTurn", "turn.start"),
|
||||||
|
|
|
||||||
|
|
@ -256,7 +256,10 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop
|
||||||
// Bind session store and capture initial history length for rollback logic
|
// Bind session store and capture initial history length for rollback logic
|
||||||
if agent != nil && agent.Sessions != nil {
|
if agent != nil && agent.Sessions != nil {
|
||||||
ts.session = agent.Sessions
|
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
|
return ts
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ Notes:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "elevenlabs-asr",
|
"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 |
|
| 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. |
|
| 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. |
|
| 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`.
|
1. **Preferred path**: resolve `voice.model_name` against `model_list`.
|
||||||
2. If that resolved model is:
|
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 OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber.
|
||||||
- an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`.
|
- 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.
|
3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries.
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ model_list:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "elevenlabs-asr",
|
"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` 接口。 |
|
| Whisper 接口模型 | `openai/whisper-1`、`groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 |
|
||||||
| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 |
|
| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 |
|
||||||
|
|
||||||
|
|
@ -142,7 +143,7 @@ PicoClaw 目前主要支持三种 ASR 路径:
|
||||||
|
|
||||||
1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。
|
1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。
|
||||||
2. 如果找到的模型属于以下类型:
|
2. 如果找到的模型属于以下类型:
|
||||||
- `elevenlabs/...`,则使用 ElevenLabs transcriber。
|
- `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。
|
||||||
- OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。
|
- OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。
|
||||||
- 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。
|
- 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。
|
||||||
3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。
|
3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const elevenLabsSupportedModelID = "scribe_v1"
|
||||||
|
|
||||||
|
func ElevenLabsSupportedModelID() string {
|
||||||
|
return elevenLabsSupportedModelID
|
||||||
|
}
|
||||||
|
|
||||||
type Transcriber interface {
|
type Transcriber interface {
|
||||||
Name() string
|
Name() string
|
||||||
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
|
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
|
||||||
|
|
@ -72,14 +78,23 @@ func whisperModelID(modelCfg *config.ModelConfig) string {
|
||||||
return ""
|
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 {
|
func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
|
||||||
if modelCfg == nil {
|
if modelCfg == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
if isElevenLabsTranscriptionModel(modelCfg) {
|
||||||
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
|
_, modelID := providers.ExtractProtocol(modelCfg)
|
||||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
|
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID)
|
||||||
}
|
}
|
||||||
if modelID := whisperModelID(modelCfg); modelID != "" {
|
if modelID := whisperModelID(modelCfg); modelID != "" {
|
||||||
return NewWhisperTranscriber(modelCfg)
|
return NewWhisperTranscriber(modelCfg)
|
||||||
|
|
@ -95,9 +110,9 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
if isElevenLabsTranscriptionModel(modelCfg) {
|
||||||
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
|
_, modelID := providers.ExtractProtocol(modelCfg)
|
||||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
|
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID)
|
||||||
}
|
}
|
||||||
if modelID := whisperModelID(modelCfg); modelID != "" {
|
if modelID := whisperModelID(modelCfg); modelID != "" {
|
||||||
return NewWhisperTranscriber(modelCfg)
|
return NewWhisperTranscriber(modelCfg)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,21 @@ func TestDetectTranscriber(t *testing.T) {
|
||||||
},
|
},
|
||||||
wantName: "elevenlabs",
|
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",
|
name: "voice model name alias selects whisper transcriber for groq",
|
||||||
cfg: &config.Config{
|
cfg: &config.Config{
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,24 @@ import (
|
||||||
type ElevenLabsTranscriber struct {
|
type ElevenLabsTranscriber struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
|
modelID string
|
||||||
httpClient *http.Client
|
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 != ""})
|
logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""})
|
||||||
|
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = "https://api.elevenlabs.io"
|
apiBase = "https://api.elevenlabs.io"
|
||||||
}
|
}
|
||||||
|
if modelID == "" || modelID != ElevenLabsSupportedModelID() {
|
||||||
|
modelID = ElevenLabsSupportedModelID()
|
||||||
|
}
|
||||||
|
|
||||||
return &ElevenLabsTranscriber{
|
return &ElevenLabsTranscriber{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: apiBase,
|
apiBase: apiBase,
|
||||||
|
modelID: modelID,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
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)
|
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)
|
return nil, fmt.Errorf("failed to write model_id field: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,14 @@ package asr
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,7 +18,7 @@ import (
|
||||||
var _ Transcriber = (*ElevenLabsTranscriber)(nil)
|
var _ Transcriber = (*ElevenLabsTranscriber)(nil)
|
||||||
|
|
||||||
func TestElevenLabsTranscriberName(t *testing.T) {
|
func TestElevenLabsTranscriberName(t *testing.T) {
|
||||||
tr := NewElevenLabsTranscriber("sk_test", "")
|
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
|
||||||
if got := tr.Name(); got != "elevenlabs" {
|
if got := tr.Name(); got != "elevenlabs" {
|
||||||
t.Errorf("Name() = %q, want %q", 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" {
|
if r.Header.Get("Xi-Api-Key") != "sk_test" {
|
||||||
t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key"))
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
|
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
|
||||||
Text: "hello from elevenlabs",
|
Text: "hello from elevenlabs",
|
||||||
|
|
@ -43,7 +76,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tr := NewElevenLabsTranscriber("sk_test", "")
|
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
|
||||||
tr.apiBase = srv.URL
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
resp, err := tr.Transcribe(context.Background(), audioPath)
|
resp, err := tr.Transcribe(context.Background(), audioPath)
|
||||||
|
|
@ -64,7 +97,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tr := NewElevenLabsTranscriber("sk_bad", "")
|
tr := NewElevenLabsTranscriber("sk_bad", "", "scribe_v1")
|
||||||
tr.apiBase = srv.URL
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
_, err := tr.Transcribe(context.Background(), audioPath)
|
_, err := tr.Transcribe(context.Background(), audioPath)
|
||||||
|
|
@ -74,10 +107,54 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("missing file", func(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"))
|
_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for missing file, got 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
package line
|
package line
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"errors"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -24,13 +22,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
lineAPIBase = "https://api.line.me/v2/bot"
|
lineContentEndpoint = "https://api-data.line.me/v2/bot/message/%s/content"
|
||||||
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"
|
|
||||||
lineReplyTokenMaxAge = 25 * time.Second
|
lineReplyTokenMaxAge = 25 * time.Second
|
||||||
|
|
||||||
// Limit request body to prevent memory exhaustion (DoS).
|
// Limit request body to prevent memory exhaustion (DoS).
|
||||||
|
|
@ -45,12 +37,11 @@ type replyTokenEntry struct {
|
||||||
|
|
||||||
// LINEChannel implements the Channel interface for LINE Official Account
|
// LINEChannel implements the Channel interface for LINE Official Account
|
||||||
// using the LINE Messaging API with HTTP webhook for receiving messages
|
// 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 {
|
type LINEChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config *config.LINESettings
|
config *config.LINESettings
|
||||||
infoClient *http.Client // for bot info lookups (short timeout)
|
client *messaging_api.MessagingApiAPI
|
||||||
apiClient *http.Client // for messaging API calls
|
|
||||||
botUserID string // Bot's user ID
|
botUserID string // Bot's user ID
|
||||||
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
||||||
botDisplayName string // Bot's display name for text-based mention detection
|
botDisplayName string // Bot's display name for text-based mention detection
|
||||||
|
|
@ -70,6 +61,14 @@ func NewLINEChannel(
|
||||||
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
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,
|
base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom,
|
||||||
channels.WithMaxMessageLength(5000),
|
channels.WithMaxMessageLength(5000),
|
||||||
channels.WithGroupTrigger(bc.GroupTrigger),
|
channels.WithGroupTrigger(bc.GroupTrigger),
|
||||||
|
|
@ -79,8 +78,7 @@ func NewLINEChannel(
|
||||||
return &LINEChannel{
|
return &LINEChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
infoClient: &http.Client{Timeout: 10 * time.Second},
|
client: client,
|
||||||
apiClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,11 +89,15 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
||||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
// Fetch bot profile to get bot's userId for mention detection
|
// 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{
|
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
c.botUserID = info.UserId
|
||||||
|
c.botBasicID = info.BasicId
|
||||||
|
c.botDisplayName = info.DisplayName
|
||||||
logger.InfoCF("line", "Bot info fetched", map[string]any{
|
logger.InfoCF("line", "Bot info fetched", map[string]any{
|
||||||
"bot_user_id": c.botUserID,
|
"bot_user_id": c.botUserID,
|
||||||
"basic_id": c.botBasicID,
|
"basic_id": c.botBasicID,
|
||||||
|
|
@ -108,39 +110,6 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
||||||
return nil
|
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.
|
// Stop gracefully stops the LINE channel.
|
||||||
func (c *LINEChannel) Stop(ctx context.Context) error {
|
func (c *LINEChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("line", "Stopping LINE channel")
|
logger.InfoC("line", "Stopping LINE channel")
|
||||||
|
|
@ -174,140 +143,70 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
var maxBytesErr *http.MaxBytesError
|
||||||
"error": err.Error(),
|
if errors.As(err, &maxBytesErr) {
|
||||||
})
|
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if int64(len(body)) > maxWebhookBodySize {
|
|
||||||
logger.WarnC("line", "Webhook request body too large, rejected")
|
logger.WarnC("line", "Webhook request body too large, rejected")
|
||||||
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
|
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
|
||||||
return
|
} else if errors.Is(err, webhook.ErrInvalidSignature) {
|
||||||
}
|
|
||||||
|
|
||||||
signature := r.Header.Get("X-Line-Signature")
|
|
||||||
if !c.verifySignature(body, signature) {
|
|
||||||
logger.WarnC("line", "Invalid webhook signature")
|
logger.WarnC("line", "Invalid webhook signature")
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
} else {
|
||||||
}
|
logger.ErrorCF("line", "Failed to parse webhook request", map[string]any{
|
||||||
|
|
||||||
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return 200 immediately, process events asynchronously
|
// Return 200 immediately, process events asynchronously
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
for _, event := range payload.Events {
|
for _, event := range cb.Events {
|
||||||
go c.processEvent(event)
|
go c.processEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifySignature validates the X-Line-Signature using HMAC-SHA256.
|
func (c *LINEChannel) processEvent(event webhook.EventInterface) {
|
||||||
func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
|
msgEvent, ok := event.(webhook.MessageEvent)
|
||||||
if signature == "" {
|
if !ok {
|
||||||
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" {
|
|
||||||
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
|
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
|
||||||
"type": event.Type,
|
"type": event.GetType(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
senderID := event.Source.UserID
|
senderID, chatID, sourceType := c.resolveSource(msgEvent.Source)
|
||||||
chatID := c.resolveChatID(event.Source)
|
isGroup := sourceType == "group" || sourceType == "room"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store reply token for later use
|
// Store reply token for later use
|
||||||
if event.ReplyToken != "" {
|
if msgEvent.ReplyToken != "" {
|
||||||
c.replyTokens.Store(chatID, replyTokenEntry{
|
c.replyTokens.Store(chatID, replyTokenEntry{
|
||||||
token: event.ReplyToken,
|
token: msgEvent.ReplyToken,
|
||||||
timestamp: time.Now(),
|
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 content string
|
||||||
var mediaPaths []string
|
var mediaPaths []string
|
||||||
|
var messageID string
|
||||||
scope := channels.BuildMediaScope("line", chatID, msg.ID)
|
var quoteToken string
|
||||||
|
var isMentioned bool
|
||||||
|
|
||||||
// Helper to register a local file with the media store
|
// 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 {
|
if store := c.GetMediaStore(); store != nil {
|
||||||
ref, err := store.Store(localPath, media.MediaMeta{
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
Source: "line",
|
Source: "line",
|
||||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
|
||||||
}, scope)
|
}, scope)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return ref
|
return ref
|
||||||
|
|
@ -316,37 +215,70 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
return localPath // fallback
|
return localPath // fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
switch msg.Type {
|
switch msg := msgEvent.Message.(type) {
|
||||||
case "text":
|
case webhook.TextMessageContent:
|
||||||
|
messageID = msg.Id
|
||||||
content = msg.Text
|
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
|
// Strip bot mention from text in group chats
|
||||||
if isGroup {
|
if isGroup {
|
||||||
content = c.stripBotMention(content, msg)
|
content = c.stripBotMention(content, msg)
|
||||||
}
|
}
|
||||||
case "image":
|
case webhook.ImageMessageContent:
|
||||||
localPath := c.downloadContent(msg.ID, "image.jpg")
|
messageID = msg.Id
|
||||||
if localPath != "" {
|
if msg.QuoteToken != "" {
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg"))
|
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]"
|
content = "[image]"
|
||||||
}
|
}
|
||||||
case "audio":
|
case webhook.AudioMessageContent:
|
||||||
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
messageID = msg.Id
|
||||||
if localPath != "" {
|
if localPath := c.downloadContent(msg.Id, "audio.m4a"); localPath != "" {
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a"))
|
scope := channels.BuildMediaScope("line", chatID, msg.Id)
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a", scope))
|
||||||
content = "[audio]"
|
content = "[audio]"
|
||||||
}
|
}
|
||||||
case "video":
|
case webhook.VideoMessageContent:
|
||||||
localPath := c.downloadContent(msg.ID, "video.mp4")
|
messageID = msg.Id
|
||||||
if localPath != "" {
|
if msg.QuoteToken != "" {
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4"))
|
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]"
|
content = "[video]"
|
||||||
}
|
}
|
||||||
case "file":
|
case webhook.FileMessageContent:
|
||||||
|
messageID = msg.Id
|
||||||
content = "[file]"
|
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]"
|
content = "[sticker]"
|
||||||
default:
|
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) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
|
|
@ -354,9 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// In group chats, apply unified group trigger filtering
|
// In group chats, apply unified group trigger filtering
|
||||||
isMentioned := false
|
|
||||||
if isGroup {
|
if isGroup {
|
||||||
isMentioned = c.isBotMentioned(msg)
|
|
||||||
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||||
if !respond {
|
if !respond {
|
||||||
logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
|
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{
|
metadata := map[string]string{
|
||||||
"platform": "line",
|
"platform": "line",
|
||||||
"source_type": event.Source.Type,
|
"source_type": sourceType,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("line", "Received message", map[string]any{
|
logger.DebugCF("line", "Received message", map[string]any{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"message_type": msg.Type,
|
"message_type": msgEvent.Message.GetType(),
|
||||||
"is_group": isGroup,
|
"is_group": isGroup,
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
@ -395,16 +325,16 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
ChatType: map[bool]string{true: "group", false: "direct"}[isGroup],
|
ChatType: map[bool]string{true: "group", false: "direct"}[isGroup],
|
||||||
SenderID: senderID,
|
SenderID: senderID,
|
||||||
MessageID: msg.ID,
|
MessageID: messageID,
|
||||||
Mentioned: isMentioned,
|
Mentioned: isMentioned,
|
||||||
Raw: metadata,
|
Raw: metadata,
|
||||||
}
|
}
|
||||||
if event.ReplyToken != "" {
|
if msgEvent.ReplyToken != "" {
|
||||||
inboundCtx.ReplyHandles = map[string]string{
|
inboundCtx.ReplyHandles = map[string]string{
|
||||||
"reply_token": event.ReplyToken,
|
"reply_token": msgEvent.ReplyToken,
|
||||||
}
|
}
|
||||||
if msg.QuoteToken != "" {
|
if quoteToken != "" {
|
||||||
inboundCtx.ReplyHandles["quote_token"] = msg.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.
|
// 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
|
// to text-based detection using the bot's display name, since LINE may
|
||||||
// not include userId in mentionees for Official Accounts.
|
// not include userId in mentionees for Official Accounts.
|
||||||
func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
|
func (c *LINEChannel) isBotMentioned(msg webhook.TextMessageContent) bool {
|
||||||
// Check mention metadata
|
|
||||||
if msg.Mention != nil {
|
if msg.Mention != nil {
|
||||||
for _, m := range msg.Mention.Mentionees {
|
for _, m := range msg.Mention.Mentionees {
|
||||||
if m.Type == "all" {
|
switch mentionee := m.(type) {
|
||||||
|
case webhook.AllMentionee:
|
||||||
|
return true
|
||||||
|
case webhook.UserMentionee:
|
||||||
|
if mentionee.IsSelf {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if c.botUserID != "" && m.UserID == c.botUserID {
|
if c.botUserID != "" && mentionee.UserId == c.botUserID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
// Check if mentionee text overlaps with bot display name
|
||||||
// Mention metadata exists with mentionees but bot not matched by userId.
|
if c.botDisplayName != "" && mentionee.Index >= 0 && mentionee.Length > 0 {
|
||||||
// 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 {
|
|
||||||
runes := []rune(msg.Text)
|
runes := []rune(msg.Text)
|
||||||
end := m.Index + m.Length
|
end := int(mentionee.Index) + int(mentionee.Length)
|
||||||
if end <= len(runes) {
|
if end <= len(runes) {
|
||||||
mentionText := string(runes[m.Index:end])
|
mentionText := string(runes[mentionee.Index:end])
|
||||||
if strings.Contains(mentionText, c.botDisplayName) {
|
if strings.Contains(mentionText, c.botDisplayName) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -454,30 +382,43 @@ func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripBotMention removes the @BotName mention text from the message.
|
// 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
|
stripped := false
|
||||||
|
|
||||||
// Try to strip using mention metadata indices
|
|
||||||
if msg.Mention != nil {
|
if msg.Mention != nil {
|
||||||
runes := []rune(text)
|
runes := []rune(text)
|
||||||
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
|
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
|
||||||
m := msg.Mention.Mentionees[i]
|
m := msg.Mention.Mentionees[i]
|
||||||
// Strip if userId matches OR if the mention text contains the bot display name
|
|
||||||
shouldStrip := false
|
shouldStrip := false
|
||||||
if c.botUserID != "" && m.UserID == c.botUserID {
|
var index, length int32
|
||||||
|
|
||||||
|
switch mentionee := m.(type) {
|
||||||
|
case webhook.UserMentionee:
|
||||||
|
index = mentionee.Index
|
||||||
|
length = mentionee.Length
|
||||||
|
if mentionee.IsSelf {
|
||||||
shouldStrip = true
|
shouldStrip = true
|
||||||
} else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 {
|
} else if c.botUserID != "" && mentionee.UserId == c.botUserID {
|
||||||
end := m.Index + m.Length
|
shouldStrip = true
|
||||||
|
} else if c.botDisplayName != "" && index >= 0 && length > 0 {
|
||||||
|
end := int(index) + int(length)
|
||||||
if end <= len(runes) {
|
if end <= len(runes) {
|
||||||
mentionText := string(runes[m.Index:end])
|
mentionText := string(runes[index:end])
|
||||||
if strings.Contains(mentionText, c.botDisplayName) {
|
if strings.Contains(mentionText, c.botDisplayName) {
|
||||||
shouldStrip = true
|
shouldStrip = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case webhook.AllMentionee:
|
||||||
|
// Don't strip @All mentions
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if shouldStrip {
|
if shouldStrip {
|
||||||
start := m.Index
|
start := int(index)
|
||||||
end := m.Index + m.Length
|
end := int(index) + int(length)
|
||||||
if start >= 0 && end <= len(runes) {
|
if start >= 0 && end <= len(runes) {
|
||||||
runes = append(runes[:start], runes[end:]...)
|
runes = append(runes[:start], runes[end:]...)
|
||||||
stripped = true
|
stripped = true
|
||||||
|
|
@ -497,16 +438,20 @@ func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
|
||||||
return strings.TrimSpace(text)
|
return strings.TrimSpace(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveChatID determines the chat ID from the event source.
|
// resolveSource extracts senderID, chatID, and source type from the event source.
|
||||||
// For group/room messages, use the group/room ID; for 1:1, use the user ID.
|
func (c *LINEChannel) resolveSource(source webhook.SourceInterface) (senderID, chatID, sourceType string) {
|
||||||
func (c *LINEChannel) resolveChatID(source lineSource) string {
|
switch src := source.(type) {
|
||||||
switch source.Type {
|
case webhook.GroupSource:
|
||||||
case "group":
|
return src.UserId, src.GroupId, "group"
|
||||||
return source.GroupID
|
case webhook.RoomSource:
|
||||||
case "room":
|
return src.UserId, src.RoomId, "room"
|
||||||
return source.RoomID
|
case webhook.UserSource:
|
||||||
|
return src.UserId, src.UserId, "user"
|
||||||
default:
|
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)
|
quoteToken = qt.(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textMsg := messaging_api.TextMessage{
|
||||||
|
Text: msg.Content,
|
||||||
|
QuoteToken: quoteToken,
|
||||||
|
}
|
||||||
|
|
||||||
// Try reply token first (free, valid for ~25 seconds)
|
// Try reply token first (free, valid for ~25 seconds)
|
||||||
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
|
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
|
||||||
tokenEntry := entry.(replyTokenEntry)
|
tokenEntry := entry.(replyTokenEntry)
|
||||||
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
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{
|
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
||||||
"chat_id": msg.ChatID,
|
"chat_id": msg.ChatID,
|
||||||
"quoted": quoteToken != "",
|
"quoted": quoteToken != "",
|
||||||
})
|
})
|
||||||
return nil, nil
|
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
|
// 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.
|
// 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)
|
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil {
|
textMsg := messaging_api.TextMessage{Text: caption}
|
||||||
return nil, err
|
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
|
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.
|
// StartTyping implements channels.TypingCapable using LINE's loading animation.
|
||||||
//
|
//
|
||||||
// NOTE: The LINE loading animation API only works for 1:1 chats.
|
// 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
|
return stop, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendLoading sends a loading animation indicator to the chat.
|
// classifySDKError maps an SDK HTTP response to the project's sentinel errors.
|
||||||
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
|
func classifySDKError(resp *http.Response, err error) error {
|
||||||
payload := map[string]any{
|
if resp != nil && resp.Body != nil {
|
||||||
"chatId": chatID,
|
resp.Body.Close()
|
||||||
"loadingSeconds": 60,
|
|
||||||
}
|
}
|
||||||
return c.callAPI(ctx, lineLoadingEndpoint, payload)
|
if err == nil {
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
if resp != nil {
|
||||||
|
return channels.ClassifySendError(resp.StatusCode, err)
|
||||||
|
}
|
||||||
|
return channels.ClassifyNetError(err)
|
||||||
|
}
|
||||||
|
|
||||||
// downloadContent downloads media content from the LINE API.
|
// sendLoading sends a loading animation indicator to the chat.
|
||||||
|
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
|
||||||
|
req := &messaging_api.ShowLoadingAnimationRequest{
|
||||||
|
ChatId: chatID,
|
||||||
|
LoadingSeconds: 60,
|
||||||
|
}
|
||||||
|
resp, _, err := c.client.WithContext(ctx).ShowLoadingAnimationWithHttpInfo(req)
|
||||||
|
return classifySDKError(resp, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadContent downloads media content from the LINE content API.
|
||||||
func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
||||||
url := fmt.Sprintf(lineContentEndpoint, messageID)
|
url := fmt.Sprintf(lineContentEndpoint, messageID)
|
||||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWebhookRejectsOversizedBody(t *testing.T) {
|
func TestWebhookRejectsOversizedBody(t *testing.T) {
|
||||||
ch := &LINEChannel{}
|
ch := &LINEChannel{config: &config.LINESettings{}}
|
||||||
|
|
||||||
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
||||||
|
|
@ -25,7 +25,7 @@ func TestWebhookRejectsOversizedBody(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
|
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
|
||||||
ch := &LINEChannel{}
|
ch := &LINEChannel{config: &config.LINESettings{}}
|
||||||
|
|
||||||
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
|
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
|
||||||
|
|
@ -40,7 +40,7 @@ func TestWebhookAcceptsMaxBodySize(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
|
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
|
||||||
ch := &LINEChannel{}
|
ch := &LINEChannel{config: &config.LINESettings{}}
|
||||||
|
|
||||||
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
||||||
|
|
@ -55,7 +55,7 @@ func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookRejectsNonPostMethod(t *testing.T) {
|
func TestWebhookRejectsNonPostMethod(t *testing.T) {
|
||||||
ch := &LINEChannel{}
|
ch := &LINEChannel{config: &config.LINESettings{}}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
|
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ func BuiltinDefinitions() []Definition {
|
||||||
return []Definition{
|
return []Definition{
|
||||||
startCommand(),
|
startCommand(),
|
||||||
helpCommand(),
|
helpCommand(),
|
||||||
|
stopCommand(),
|
||||||
showCommand(),
|
showCommand(),
|
||||||
listCommand(),
|
listCommand(),
|
||||||
useCommand(),
|
useCommand(),
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
|
||||||
if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") {
|
if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") {
|
||||||
t.Fatalf("/help reply missing /list usage, got %q", reply)
|
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 <skill> <message>") {
|
if !strings.Contains(reply, "/use <skill> <message>") {
|
||||||
if !strings.Contains(reply, "/use <skill> [message]") {
|
if !strings.Contains(reply, "/use <skill> [message]") {
|
||||||
t.Fatalf("/help reply missing /use usage, got %q", reply)
|
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) {
|
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
|
||||||
defs := BuiltinDefinitions()
|
defs := BuiltinDefinitions()
|
||||||
ex := NewExecutor(NewRegistry(defs), nil)
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
|
||||||
52
pkg/commands/cmd_stop.go
Normal file
52
pkg/commands/cmd_stop.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,12 @@ type ContextStats struct {
|
||||||
MessageCount int
|
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
|
// Runtime provides runtime dependencies to command handlers. It is constructed
|
||||||
// per-request by the agent loop so that per-request state (like session scope)
|
// per-request by the agent loop so that per-request state (like session scope)
|
||||||
// can coexist with long-lived callbacks (like GetModelInfo).
|
// can coexist with long-lived callbacks (like GetModelInfo).
|
||||||
|
|
@ -55,4 +61,5 @@ type Runtime struct {
|
||||||
SwitchChannel func(value string) error
|
SwitchChannel func(value string) error
|
||||||
ClearHistory func() error
|
ClearHistory func() error
|
||||||
ReloadConfig func() error
|
ReloadConfig func() error
|
||||||
|
StopActiveTurn func() (StopResult, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,19 +110,7 @@ func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) {
|
||||||
if provider := strings.TrimSpace(cfg.Provider); provider != "" {
|
if provider := strings.TrimSpace(cfg.Provider); provider != "" {
|
||||||
return NormalizeProvider(provider), model
|
return NormalizeProvider(provider), model
|
||||||
}
|
}
|
||||||
if model == "" {
|
return SplitModelProviderAndID(model, "openai")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveAPIBase returns the configured API base, or the protocol default when
|
// 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)
|
protocol, modelID := ExtractProtocol(cfg)
|
||||||
|
authMethod := strings.ToLower(strings.TrimSpace(cfg.AuthMethod))
|
||||||
|
|
||||||
userAgent := cfg.UserAgent
|
userAgent := cfg.UserAgent
|
||||||
if userAgent == "" {
|
if userAgent == "" {
|
||||||
|
|
@ -163,7 +152,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case "openai":
|
case "openai":
|
||||||
// OpenAI with OAuth/token auth (Codex-style)
|
// OpenAI with OAuth/token auth (Codex-style)
|
||||||
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
if authMethod == "oauth" || authMethod == "token" {
|
||||||
provider, err := createCodexAuthProvider()
|
provider, err := createCodexAuthProvider()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
|
|
@ -320,7 +309,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
return finalizeProviderFromConfig(provider, modelID, cfg)
|
return finalizeProviderFromConfig(provider, modelID, cfg)
|
||||||
|
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
if authMethod == "oauth" || authMethod == "token" {
|
||||||
// Use OAuth credentials from auth store
|
// Use OAuth credentials from auth store
|
||||||
provider, err := createClaudeAuthProvider()
|
provider, err := createClaudeAuthProvider()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -431,7 +420,7 @@ func finalizeProviderFromConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEmptyAPIKeyAllowed(protocol string) bool {
|
func isEmptyAPIKeyAllowed(protocol string) bool {
|
||||||
meta, ok := protocolMetaByName[protocol]
|
meta, ok := protocolMetaForName(protocol)
|
||||||
return ok && meta.emptyAPIKeyAllowed
|
return ok && meta.emptyAPIKeyAllowed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -451,9 +440,19 @@ func DefaultAPIBaseForProtocol(protocol string) string {
|
||||||
|
|
||||||
// getDefaultAPIBase returns the default API base URL for a given protocol.
|
// getDefaultAPIBase returns the default API base URL for a given protocol.
|
||||||
func getDefaultAPIBase(protocol string) string {
|
func getDefaultAPIBase(protocol string) string {
|
||||||
meta, ok := protocolMetaByName[protocol]
|
meta, ok := protocolMetaForName(protocol)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return meta.defaultAPIBase
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -101,6 +102,12 @@ func TestExtractProtocol(t *testing.T) {
|
||||||
wantProtocol: "",
|
wantProtocol: "",
|
||||||
wantModelID: "gpt-4o",
|
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",
|
name: "nil config",
|
||||||
wantProtocol: "",
|
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) {
|
func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-no-key",
|
ModelName: "test-no-key",
|
||||||
|
|
@ -619,8 +661,9 @@ func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-unknown",
|
ModelName: "test-unknown-provider",
|
||||||
Model: "unknown-protocol/model",
|
Provider: "unknown-protocol",
|
||||||
|
Model: "model",
|
||||||
}
|
}
|
||||||
cfg.SetAPIKey("test-key")
|
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) {
|
func TestCreateProviderFromConfig_NilConfig(t *testing.T) {
|
||||||
_, _, err := CreateProviderFromConfig(nil)
|
_, _, err := CreateProviderFromConfig(nil)
|
||||||
if err == 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) {
|
func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) {
|
||||||
var requestBody map[string]any
|
var requestBody map[string]any
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,13 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if idx := strings.Index(raw, "/"); idx > 0 {
|
provider, model := SplitModelProviderAndID(raw, defaultProvider)
|
||||||
provider := NormalizeProvider(raw[:idx])
|
|
||||||
model := strings.TrimSpace(raw[idx+1:])
|
|
||||||
if model == "" {
|
if model == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &ModelRef{Provider: provider, Model: model}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ModelRef{
|
return &ModelRef{
|
||||||
Provider: NormalizeProvider(defaultProvider),
|
Provider: provider,
|
||||||
Model: raw,
|
Model: model,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +48,8 @@ func NormalizeProvider(provider string) string {
|
||||||
return "zhipu"
|
return "zhipu"
|
||||||
case "google":
|
case "google":
|
||||||
return "gemini"
|
return "gemini"
|
||||||
|
case "google-antigravity":
|
||||||
|
return "antigravity"
|
||||||
case "alibaba-coding", "qwen-coding":
|
case "alibaba-coding", "qwen-coding":
|
||||||
return "coding-plan"
|
return "coding-plan"
|
||||||
case "alibaba-coding-anthropic":
|
case "alibaba-coding-anthropic":
|
||||||
|
|
@ -61,6 +58,14 @@ func NormalizeProvider(provider string) string {
|
||||||
return "qwen-intl"
|
return "qwen-intl"
|
||||||
case "dashscope-us":
|
case "dashscope-us":
|
||||||
return "qwen-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
|
return p
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,12 @@ func TestNormalizeProvider(t *testing.T) {
|
||||||
{"claude", "anthropic"},
|
{"claude", "anthropic"},
|
||||||
{"glm", "zhipu"},
|
{"glm", "zhipu"},
|
||||||
{"google", "gemini"},
|
{"google", "gemini"},
|
||||||
|
{"google-antigravity", "antigravity"},
|
||||||
{"groq", "groq"},
|
{"groq", "groq"},
|
||||||
|
{"azure-openai", "azure"},
|
||||||
|
{"claudecli", "claude-cli"},
|
||||||
|
{"codexcli", "codex-cli"},
|
||||||
|
{"copilot", "github-copilot"},
|
||||||
// Alibaba Coding Plan aliases
|
// Alibaba Coding Plan aliases
|
||||||
{"alibaba-coding", "coding-plan"},
|
{"alibaba-coding", "coding-plan"},
|
||||||
{"qwen-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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
181
pkg/providers/provider_catalog.go
Normal file
181
pkg/providers/provider_catalog.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
104
pkg/tools/delegate.go
Normal file
104
pkg/tools/delegate.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
300
pkg/tools/delegate_test.go
Normal file
300
pkg/tools/delegate_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ type SubTurnConfig struct {
|
||||||
ActualSystemPrompt string
|
ActualSystemPrompt string
|
||||||
InitialMessages []providers.Message
|
InitialMessages []providers.Message
|
||||||
InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget
|
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 {
|
type SubagentTask struct {
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
|
||||||
if modelCfg == nil {
|
if modelCfg == nil {
|
||||||
return false, fmt.Sprintf("default model %q is invalid", modelName), 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) {
|
if !hasModelConfiguration(modelCfg) {
|
||||||
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
|
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestLooksLikeGatewayCommandLine(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -47,6 +48,7 @@ var (
|
||||||
probeTCPServiceFunc = probeTCPService
|
probeTCPServiceFunc = probeTCPService
|
||||||
probeOllamaModelFunc = probeOllamaModel
|
probeOllamaModelFunc = probeOllamaModel
|
||||||
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
|
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
|
||||||
|
probeCommandAvailableFunc = probeCommandAvailable
|
||||||
modelProbeNowFunc = time.Now
|
modelProbeNowFunc = time.Now
|
||||||
modelProbeState = newModelProbeCacheState()
|
modelProbeState = newModelProbeCacheState()
|
||||||
)
|
)
|
||||||
|
|
@ -83,17 +85,23 @@ func (s *modelProbeCacheState) resetForTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasModelConfiguration(m *config.ModelConfig) bool {
|
func hasModelConfiguration(m *config.ModelConfig) bool {
|
||||||
|
protocol := modelProtocol(m)
|
||||||
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
||||||
apiKey := strings.TrimSpace(m.APIKey())
|
apiKey := strings.TrimSpace(m.APIKey())
|
||||||
|
|
||||||
if authMethod == "oauth" || authMethod == "token" {
|
if authMethod == "oauth" || authMethod == "token" {
|
||||||
if provider, ok := oauthProviderForModel(m); ok {
|
if configured, checked := hasStoredOAuthCredential(m); checked {
|
||||||
cred, err := oauthGetCredential(provider)
|
return configured
|
||||||
if err != nil || cred == nil {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if authMethod == "" && providerUsesImplicitOAuth(protocol) {
|
||||||
|
if configured, checked := hasStoredOAuthCredential(m); checked {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if providerUsesAmbientCredentials(protocol) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,6 +112,40 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
|
||||||
return apiKey != ""
|
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 {
|
func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary {
|
||||||
if !hasModelConfiguration(m) {
|
if !hasModelConfiguration(m) {
|
||||||
return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
|
return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
|
||||||
|
|
@ -180,8 +222,10 @@ func runLocalModelProbe(m *config.ModelConfig) bool {
|
||||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
||||||
case "github-copilot", "copilot":
|
case "github-copilot", "copilot":
|
||||||
return probeTCPServiceFunc(apiBase)
|
return probeTCPServiceFunc(apiBase)
|
||||||
case "claude-cli", "claudecli", "codex-cli", "codexcli":
|
case "claude-cli", "claudecli":
|
||||||
return true
|
return probeCommandAvailableFunc("claude")
|
||||||
|
case "codex-cli", "codexcli":
|
||||||
|
return probeCommandAvailableFunc("codex")
|
||||||
default:
|
default:
|
||||||
if hasLocalAPIBase(apiBase) {
|
if hasLocalAPIBase(apiBase) {
|
||||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
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 {
|
func modelProbeCacheKey(m *config.ModelConfig) string {
|
||||||
protocol, modelID := splitModel(m)
|
protocol, modelID := splitModel(m)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/audio/asr"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
|
@ -50,6 +51,179 @@ type modelResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
IsVirtual bool `json:"is_virtual"`
|
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.
|
// 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
|
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()
|
defaultModel := cfg.Agents.Defaults.GetModelName()
|
||||||
modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
|
modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
|
||||||
|
|
||||||
|
|
@ -101,6 +279,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
Status: modelStatuses[i].Status,
|
Status: modelStatuses[i].Status,
|
||||||
IsDefault: m.ModelName == defaultModel,
|
IsDefault: m.ModelName == defaultModel,
|
||||||
IsVirtual: m.IsVirtual(),
|
IsVirtual: m.IsVirtual(),
|
||||||
|
DefaultModelAllowed: defaultModelAllowedForModelConfig(m),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,6 +288,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
"models": models,
|
"models": models,
|
||||||
"total": len(models),
|
"total": len(models),
|
||||||
"default_model": defaultModel,
|
"default_model": defaultModel,
|
||||||
|
"provider_options": modelProviderOptionsForResponse(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,7 +314,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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)
|
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +332,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig)
|
cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig)
|
||||||
|
normalizeStoredModelProviders(cfg)
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
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
|
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)
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
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
|
// This keeps provider-omitted updates backward-compatible even when an
|
||||||
// older client edits the visible model ID.
|
// older client edits the visible model ID.
|
||||||
if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
|
if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
|
||||||
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
|
|
||||||
existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
|
existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
|
||||||
incomingModel := strings.TrimSpace(mc.Model)
|
incomingModel := strings.TrimSpace(mc.Model)
|
||||||
|
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
|
||||||
if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
|
if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
|
||||||
if incomingModel == existingModelID {
|
if incomingModel == existingModelID {
|
||||||
mc.Model = existingRawModel
|
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
|
cfg.ModelList[idx] = &mc.ModelConfig
|
||||||
|
normalizeStoredModelProviders(cfg)
|
||||||
|
|
||||||
logger.Debugf("update model config: %#v", mc.ModelConfig)
|
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)
|
http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest)
|
||||||
return
|
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
|
cfg.Agents.Defaults.ModelName = req.ModelName
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -18,32 +18,32 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@tabler/icons-react": "^3.40.0",
|
"@tabler/icons-react": "^3.43.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@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",
|
"@tanstack/react-router-devtools": "^1.166.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"i18next": "^26.0.7",
|
"i18next": "^26.0.10",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jotai": "^2.19.1",
|
"jotai": "^2.19.1",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.5",
|
"react": "19.2.5",
|
||||||
"react-dom": "19.2.5",
|
"react-dom": "19.2.5",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.6",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-textarea-autosize": "^8.5.9",
|
"react-textarea-autosize": "^8.5.9",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"shadcn": "^4.3.0",
|
"shadcn": "^4.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"wrap-ansi": "^10.0.0"
|
"wrap-ansi": "^10.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -61,11 +61,11 @@
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.6.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.59.0",
|
"typescript-eslint": "^8.59.1",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
796
web/frontend/pnpm-lock.yaml
generated
796
web/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -27,12 +27,24 @@ export interface ModelInfo {
|
||||||
status: "available" | "unconfigured" | "unreachable"
|
status: "available" | "unconfigured" | "unreachable"
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
is_virtual: 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 {
|
interface ModelsListResponse {
|
||||||
models: ModelInfo[]
|
models: ModelInfo[]
|
||||||
total: number
|
total: number
|
||||||
default_model: string
|
default_model: string
|
||||||
|
provider_options: ModelProviderOption[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelActionResponse {
|
interface ModelActionResponse {
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,9 @@ export function AppHeader() {
|
||||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
|
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
|
||||||
English
|
English
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => i18n.changeLanguage("pt-BR")}>
|
||||||
|
Português (Brasil)
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
|
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
|
||||||
简体中文
|
简体中文
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -57,11 +57,38 @@ export function AssistantMessage({
|
||||||
const formattedTimestamp =
|
const formattedTimestamp =
|
||||||
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = async () => {
|
||||||
navigator.clipboard.writeText(content).then(() => {
|
const markCopied = () => {
|
||||||
setIsCopied(true)
|
setIsCopied(true)
|
||||||
setTimeout(() => setIsCopied(false), 2000)
|
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
|
const collapsedLabel = isThought
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { IconLoader2 } from "@tabler/icons-react"
|
import { IconLoader2 } from "@tabler/icons-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,6 +17,13 @@ import {
|
||||||
} from "@/components/shared-form"
|
} from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -25,6 +36,15 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
|
import {
|
||||||
|
findProviderOption,
|
||||||
|
getProviderDefaultAPIBase,
|
||||||
|
getProviderDefaultAuthMethod,
|
||||||
|
getProviderLabel,
|
||||||
|
getSortedProviderOptions,
|
||||||
|
isProviderAuthMethodLocked,
|
||||||
|
} from "./provider-label"
|
||||||
|
|
||||||
interface AddForm {
|
interface AddForm {
|
||||||
modelName: string
|
modelName: string
|
||||||
provider: string
|
provider: string
|
||||||
|
|
@ -46,7 +66,7 @@ interface AddForm {
|
||||||
|
|
||||||
const EMPTY_ADD_FORM: AddForm = {
|
const EMPTY_ADD_FORM: AddForm = {
|
||||||
modelName: "",
|
modelName: "",
|
||||||
provider: "",
|
provider: "openai",
|
||||||
model: "",
|
model: "",
|
||||||
apiBase: "",
|
apiBase: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
|
|
@ -68,6 +88,7 @@ interface AddModelSheetProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
existingModelNames: string[]
|
existingModelNames: string[]
|
||||||
|
providerOptions: ModelProviderOption[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddModelSheet({
|
export function AddModelSheet({
|
||||||
|
|
@ -75,6 +96,7 @@ export function AddModelSheet({
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
existingModelNames,
|
existingModelNames,
|
||||||
|
providerOptions,
|
||||||
}: AddModelSheetProps) {
|
}: AddModelSheetProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
||||||
|
|
@ -88,6 +110,37 @@ export function AddModelSheet({
|
||||||
form.apiKey,
|
form.apiKey,
|
||||||
t("models.field.apiKeyPlaceholder"),
|
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 =
|
const isDirty =
|
||||||
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
|
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
|
||||||
|
|
||||||
|
|
@ -108,6 +161,9 @@ export function AddModelSheet({
|
||||||
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
|
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
|
||||||
errors.modelName = t("models.add.errorDuplicateModelName")
|
errors.modelName = t("models.add.errorDuplicateModelName")
|
||||||
}
|
}
|
||||||
|
if (!selectedProviderOption) {
|
||||||
|
errors.provider = t("models.field.providerInvalid")
|
||||||
|
}
|
||||||
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
||||||
setFieldErrors(errors)
|
setFieldErrors(errors)
|
||||||
return Object.keys(errors).length === 0
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!validate()) return
|
if (!validate()) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setServerError("")
|
setServerError("")
|
||||||
try {
|
try {
|
||||||
const modelName = form.modelName.trim()
|
const modelName = form.modelName.trim()
|
||||||
const provider = form.provider.trim()
|
|
||||||
const modelId = form.model.trim()
|
const modelId = form.model.trim()
|
||||||
await addModel({
|
await addModel({
|
||||||
model_name: modelName,
|
model_name: modelName,
|
||||||
provider: provider || undefined,
|
provider: form.provider.trim(),
|
||||||
model: modelId,
|
model: modelId,
|
||||||
api_base: form.apiBase.trim() || undefined,
|
api_base: form.apiBase.trim() || undefined,
|
||||||
api_key: form.apiKey.trim() || undefined,
|
api_key: form.apiKey.trim() || undefined,
|
||||||
proxy: form.proxy.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,
|
connect_mode: form.connectMode.trim() || undefined,
|
||||||
workspace: form.workspace.trim() || undefined,
|
workspace: form.workspace.trim() || undefined,
|
||||||
rpm: form.rpm ? Number(form.rpm) : undefined,
|
rpm: form.rpm ? Number(form.rpm) : undefined,
|
||||||
|
|
@ -208,12 +289,29 @@ export function AddModelSheet({
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.provider")}
|
label={t("models.field.provider")}
|
||||||
hint={t("models.field.providerHint")}
|
hint={t("models.field.providerHint")}
|
||||||
|
error={fieldErrors.provider}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Select
|
||||||
value={form.provider}
|
value={selectedProviderOption?.id}
|
||||||
onChange={setField("provider")}
|
onValueChange={setProvider}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={!!fieldErrors.provider}
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
placeholder={t("models.field.providerPlaceholder")}
|
placeholder={t("models.field.providerPlaceholder")}
|
||||||
/>
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{creatableProviderOptions.map((option) => (
|
||||||
|
<SelectItem key={option.id} value={option.id}>
|
||||||
|
{getProviderLabel(option.id)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|
@ -232,6 +330,7 @@ export function AddModelSheet({
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{!isOAuth && (
|
||||||
<Field label={t("models.field.apiKey")}>
|
<Field label={t("models.field.apiKey")}>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
|
|
@ -239,20 +338,30 @@ export function AddModelSheet({
|
||||||
placeholder={apiKeyPlaceholder}
|
placeholder={apiKeyPlaceholder}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
<Field label={t("models.field.apiBase")}>
|
<Field
|
||||||
|
label={t("models.field.apiBase")}
|
||||||
|
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.apiBase}
|
value={form.apiBase}
|
||||||
onChange={setField("apiBase")}
|
onChange={setField("apiBase")}
|
||||||
placeholder="https://api.example.com/v1"
|
placeholder={apiBasePlaceholder}
|
||||||
|
disabled={isOAuth}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("models.defaultOnSave.label")}
|
label={t("models.defaultOnSave.label")}
|
||||||
hint={t("models.defaultOnSave.description")}
|
hint={
|
||||||
|
defaultModelAllowed
|
||||||
|
? t("models.defaultOnSave.description")
|
||||||
|
: t("models.defaultOnSave.unsupportedProvider")
|
||||||
|
}
|
||||||
checked={setAsDefault}
|
checked={setAsDefault}
|
||||||
onCheckedChange={setSetAsDefault}
|
onCheckedChange={setSetAsDefault}
|
||||||
|
disabled={!defaultModelAllowed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdvancedSection>
|
<AdvancedSection>
|
||||||
|
|
@ -269,12 +378,17 @@ export function AddModelSheet({
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.authMethod")}
|
label={t("models.field.authMethod")}
|
||||||
hint={t("models.field.authMethodHint")}
|
hint={
|
||||||
|
authMethodLocked
|
||||||
|
? t("models.field.authMethodManagedHint")
|
||||||
|
: t("models.field.authMethodHint")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.authMethod}
|
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
|
||||||
onChange={setField("authMethod")}
|
onChange={setField("authMethod")}
|
||||||
placeholder="oauth"
|
placeholder="oauth"
|
||||||
|
disabled={authMethodLocked}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { IconLoader2 } from "@tabler/icons-react"
|
import { IconLoader2 } from "@tabler/icons-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,6 +18,13 @@ import {
|
||||||
} from "@/components/shared-form"
|
} from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -25,6 +37,15 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
|
import {
|
||||||
|
findProviderOption,
|
||||||
|
getProviderDefaultAPIBase,
|
||||||
|
getProviderDefaultAuthMethod,
|
||||||
|
getProviderLabel,
|
||||||
|
getSortedProviderOptions,
|
||||||
|
isProviderAuthMethodLocked,
|
||||||
|
} from "./provider-label"
|
||||||
|
|
||||||
interface EditForm {
|
interface EditForm {
|
||||||
provider: string
|
provider: string
|
||||||
modelId: string
|
modelId: string
|
||||||
|
|
@ -45,6 +66,7 @@ interface EditForm {
|
||||||
|
|
||||||
interface EditModelSheetProps {
|
interface EditModelSheetProps {
|
||||||
model: ModelInfo | null
|
model: ModelInfo | null
|
||||||
|
providerOptions: ModelProviderOption[]
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
|
|
@ -76,6 +98,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm {
|
||||||
|
|
||||||
export function EditModelSheet({
|
export function EditModelSheet({
|
||||||
model,
|
model,
|
||||||
|
providerOptions,
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
|
@ -102,6 +125,42 @@ export function EditModelSheet({
|
||||||
const [setAsDefault, setSetAsDefault] = useState(false)
|
const [setAsDefault, setSetAsDefault] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const initialForm = model ? buildInitialEditForm(model) : null
|
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 =
|
const isDirty =
|
||||||
model != null &&
|
model != null &&
|
||||||
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
|
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
|
||||||
|
|
@ -109,19 +168,56 @@ export function EditModelSheet({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model) {
|
if (model) {
|
||||||
setForm(buildInitialEditForm(model))
|
const initialForm = buildInitialEditForm(model)
|
||||||
setSetAsDefault(model.is_default)
|
const option = findProviderOption(initialForm.provider, providerOptions)
|
||||||
|
if (option?.auth_method_locked && !initialForm.authMethod) {
|
||||||
|
initialForm.authMethod = option.default_auth_method ?? ""
|
||||||
|
}
|
||||||
|
setForm(initialForm)
|
||||||
|
setSetAsDefault(model.is_default && model.default_model_allowed !== false)
|
||||||
setError("")
|
setError("")
|
||||||
}
|
}
|
||||||
}, [model])
|
}, [model, providerOptions])
|
||||||
|
|
||||||
const setField =
|
const setField =
|
||||||
(key: keyof EditForm) =>
|
(key: keyof EditForm) =>
|
||||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
if (error) {
|
||||||
|
setError("")
|
||||||
|
}
|
||||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!model) return
|
if (!model) return
|
||||||
|
if (!selectedProviderOption) {
|
||||||
|
setError(providerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!form.modelId.trim()) {
|
if (!form.modelId.trim()) {
|
||||||
setError(t("models.add.errorRequired"))
|
setError(t("models.add.errorRequired"))
|
||||||
return
|
return
|
||||||
|
|
@ -136,7 +232,9 @@ export function EditModelSheet({
|
||||||
api_base: form.apiBase || undefined,
|
api_base: form.apiBase || undefined,
|
||||||
api_key: form.apiKey || undefined,
|
api_key: form.apiKey || undefined,
|
||||||
proxy: form.proxy || undefined,
|
proxy: form.proxy || undefined,
|
||||||
auth_method: form.authMethod || undefined,
|
auth_method: authMethodLocked
|
||||||
|
? defaultAuthMethod || undefined
|
||||||
|
: form.authMethod || undefined,
|
||||||
connect_mode: form.connectMode || undefined,
|
connect_mode: form.connectMode || undefined,
|
||||||
workspace: form.workspace || undefined,
|
workspace: form.workspace || undefined,
|
||||||
rpm: form.rpm ? Number(form.rpm) : 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 hasSavedAPIKey = Boolean(model?.api_key)
|
||||||
const apiKeyPlaceholder = hasSavedAPIKey
|
const apiKeyPlaceholder = hasSavedAPIKey
|
||||||
? maskedSecretPlaceholder(
|
? maskedSecretPlaceholder(
|
||||||
|
|
@ -201,12 +299,36 @@ export function EditModelSheet({
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.provider")}
|
label={t("models.field.provider")}
|
||||||
hint={t("models.field.providerHint")}
|
hint={t("models.field.providerHint")}
|
||||||
|
error={providerError}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Select
|
||||||
value={form.provider}
|
value={selectedProviderOption?.id}
|
||||||
onChange={setField("provider")}
|
onValueChange={setProvider}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={!!providerError}
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
placeholder={t("models.field.providerPlaceholder")}
|
placeholder={t("models.field.providerPlaceholder")}
|
||||||
/>
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sortedProviderOptions.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option.id}
|
||||||
|
value={option.id}
|
||||||
|
disabled={
|
||||||
|
!option.create_allowed &&
|
||||||
|
option.id !== currentProviderID
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getProviderLabel(option.id)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|
@ -241,16 +363,23 @@ export function EditModelSheet({
|
||||||
<Input
|
<Input
|
||||||
value={form.apiBase}
|
value={form.apiBase}
|
||||||
onChange={setField("apiBase")}
|
onChange={setField("apiBase")}
|
||||||
placeholder="https://api.example.com/v1"
|
placeholder={apiBasePlaceholder}
|
||||||
disabled={isOAuth}
|
disabled={isOAuth}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("models.defaultOnSave.label")}
|
label={t("models.defaultOnSave.label")}
|
||||||
hint={t("models.defaultOnSave.description")}
|
hint={
|
||||||
|
willClearDefaultOnSave
|
||||||
|
? t("models.defaultOnSave.clearOnSave")
|
||||||
|
: defaultModelAllowed
|
||||||
|
? t("models.defaultOnSave.description")
|
||||||
|
: t("models.defaultOnSave.unsupportedProvider")
|
||||||
|
}
|
||||||
checked={setAsDefault}
|
checked={setAsDefault}
|
||||||
onCheckedChange={setSetAsDefault}
|
onCheckedChange={setSetAsDefault}
|
||||||
|
disabled={!defaultModelAllowed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdvancedSection>
|
<AdvancedSection>
|
||||||
|
|
@ -267,12 +396,17 @@ export function EditModelSheet({
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.authMethod")}
|
label={t("models.field.authMethod")}
|
||||||
hint={t("models.field.authMethodHint")}
|
hint={
|
||||||
|
authMethodLocked
|
||||||
|
? t("models.field.authMethodManagedHint")
|
||||||
|
: t("models.field.authMethodHint")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.authMethod}
|
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
|
||||||
onChange={setField("authMethod")}
|
onChange={setField("authMethod")}
|
||||||
placeholder="oauth"
|
placeholder="oauth"
|
||||||
|
disabled={authMethodLocked}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ export function ModelCard({
|
||||||
const status = model.status
|
const status = model.status
|
||||||
const statusLabel = t(`models.status.${status}`)
|
const statusLabel = t(`models.status.${status}`)
|
||||||
const canSetDefault =
|
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 setDefaultLabel = t("models.action.setDefault")
|
||||||
const setDefaultDisabledReason = (() => {
|
const setDefaultDisabledReason = (() => {
|
||||||
|
|
@ -45,6 +48,9 @@ export function ModelCard({
|
||||||
return t("models.action.setDefaultDisabled.unavailable")
|
return t("models.action.setDefaultDisabled.unavailable")
|
||||||
if (model.is_default) return t("models.action.setDefaultDisabled.isDefault")
|
if (model.is_default) return t("models.action.setDefaultDisabled.isDefault")
|
||||||
if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual")
|
if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual")
|
||||||
|
if (model.default_model_allowed === false) {
|
||||||
|
return t("models.action.setDefaultDisabled.unsupportedProvider")
|
||||||
|
}
|
||||||
return setDefaultLabel
|
return setDefaultLabel
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ import { useCallback, useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
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 { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
|
|
@ -12,41 +17,13 @@ import { refreshGatewayState } from "@/store/gateway"
|
||||||
import { AddModelSheet } from "./add-model-sheet"
|
import { AddModelSheet } from "./add-model-sheet"
|
||||||
import { DeleteModelDialog } from "./delete-model-dialog"
|
import { DeleteModelDialog } from "./delete-model-dialog"
|
||||||
import { EditModelSheet } from "./edit-model-sheet"
|
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"
|
import { ProviderSection } from "./provider-section"
|
||||||
|
|
||||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
|
||||||
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 {
|
interface ProviderGroup {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
|
|
@ -58,6 +35,9 @@ interface ProviderGroup {
|
||||||
export function ModelsPage() {
|
export function ModelsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [models, setModels] = useState<ModelInfo[]>([])
|
const [models, setModels] = useState<ModelInfo[]>([])
|
||||||
|
const [providerOptions, setProviderOptions] = useState<ModelProviderOption[]>(
|
||||||
|
[],
|
||||||
|
)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [fetchError, setFetchError] = useState("")
|
const [fetchError, setFetchError] = useState("")
|
||||||
|
|
||||||
|
|
@ -67,6 +47,7 @@ export function ModelsPage() {
|
||||||
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
|
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
|
const addDisabled = loading || providerOptions.length === 0
|
||||||
|
|
||||||
const fetchModels = useCallback(async () => {
|
const fetchModels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -79,6 +60,7 @@ export function ModelsPage() {
|
||||||
return a.model_name.localeCompare(b.model_name)
|
return a.model_name.localeCompare(b.model_name)
|
||||||
})
|
})
|
||||||
setModels(sorted)
|
setModels(sorted)
|
||||||
|
setProviderOptions(data.provider_options ?? [])
|
||||||
setFetchError("")
|
setFetchError("")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
|
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
|
||||||
|
|
@ -160,7 +142,12 @@ export function ModelsPage() {
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<PageHeader title={t("navigation.models")}>
|
<PageHeader title={t("navigation.models")}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={addDisabled}
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
>
|
||||||
<IconPlus className="size-4" />
|
<IconPlus className="size-4" />
|
||||||
{t("models.add.button")}
|
{t("models.add.button")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -213,6 +200,7 @@ export function ModelsPage() {
|
||||||
|
|
||||||
<EditModelSheet
|
<EditModelSheet
|
||||||
model={editingModel}
|
model={editingModel}
|
||||||
|
providerOptions={providerOptions}
|
||||||
open={editingModel !== null}
|
open={editingModel !== null}
|
||||||
onClose={() => setEditingModel(null)}
|
onClose={() => setEditingModel(null)}
|
||||||
onSaved={fetchModels}
|
onSaved={fetchModels}
|
||||||
|
|
@ -220,6 +208,7 @@ export function ModelsPage() {
|
||||||
|
|
||||||
<AddModelSheet
|
<AddModelSheet
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
|
providerOptions={providerOptions}
|
||||||
onClose={() => setAddOpen(false)}
|
onClose={() => setAddOpen(false)}
|
||||||
onSaved={fetchModels}
|
onSaved={fetchModels}
|
||||||
existingModelNames={models.map((model) => model.model_name)}
|
existingModelNames={models.map((model) => model.model_name)}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useMemo, useState } from "react"
|
||||||
|
|
||||||
const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
||||||
openai: "openai",
|
openai: "openai",
|
||||||
|
elevenlabs: "elevenlabs",
|
||||||
anthropic: "anthropic",
|
anthropic: "anthropic",
|
||||||
azure: "microsoftazure",
|
azure: "microsoftazure",
|
||||||
gemini: "googlegemini",
|
gemini: "googlegemini",
|
||||||
|
|
@ -21,6 +22,7 @@ const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
||||||
|
|
||||||
const PROVIDER_DOMAINS: Record<string, string> = {
|
const PROVIDER_DOMAINS: Record<string, string> = {
|
||||||
openai: "openai.com",
|
openai: "openai.com",
|
||||||
|
elevenlabs: "elevenlabs.io",
|
||||||
anthropic: "anthropic.com",
|
anthropic: "anthropic.com",
|
||||||
azure: "azure.com",
|
azure: "azure.com",
|
||||||
gemini: "gemini.google.com",
|
gemini: "gemini.google.com",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
|
import type { ModelProviderOption } from "@/api/models"
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<string, string> = {
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
openai: "OpenAI",
|
openai: "OpenAI",
|
||||||
|
bedrock: "AWS Bedrock",
|
||||||
|
elevenlabs: "ElevenLabs ASR",
|
||||||
anthropic: "Anthropic",
|
anthropic: "Anthropic",
|
||||||
|
"anthropic-messages": "Anthropic Messages",
|
||||||
azure: "Azure OpenAI",
|
azure: "Azure OpenAI",
|
||||||
gemini: "Google Gemini",
|
gemini: "Google Gemini",
|
||||||
deepseek: "DeepSeek",
|
deepseek: "DeepSeek",
|
||||||
|
"coding-plan": "Alibaba Coding Plan",
|
||||||
|
"coding-plan-anthropic": "Alibaba Coding Plan (Anthropic)",
|
||||||
"qwen-portal": "Qwen (阿里云)",
|
"qwen-portal": "Qwen (阿里云)",
|
||||||
"qwen-intl": "Qwen International",
|
"qwen-intl": "Qwen International",
|
||||||
|
"qwen-us": "Qwen US",
|
||||||
moonshot: "Moonshot (月之暗面)",
|
moonshot: "Moonshot (月之暗面)",
|
||||||
groq: "Groq",
|
groq: "Groq",
|
||||||
openrouter: "OpenRouter",
|
openrouter: "OpenRouter",
|
||||||
|
|
@ -15,8 +23,11 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||||
shengsuanyun: "ShengsuanYun (神算云)",
|
shengsuanyun: "ShengsuanYun (神算云)",
|
||||||
antigravity: "Google Code Assist",
|
antigravity: "Google Code Assist",
|
||||||
"github-copilot": "GitHub Copilot",
|
"github-copilot": "GitHub Copilot",
|
||||||
|
"claude-cli": "Claude CLI (local)",
|
||||||
|
"codex-cli": "Codex CLI (local)",
|
||||||
ollama: "Ollama (local)",
|
ollama: "Ollama (local)",
|
||||||
lmstudio: "LM Studio (local)",
|
lmstudio: "LM Studio (local)",
|
||||||
|
litellm: "LiteLLM",
|
||||||
mistral: "Mistral AI",
|
mistral: "Mistral AI",
|
||||||
avian: "Avian",
|
avian: "Avian",
|
||||||
vllm: "VLLM (local)",
|
vllm: "VLLM (local)",
|
||||||
|
|
@ -28,6 +39,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||||
minimax: "MiniMax",
|
minimax: "MiniMax",
|
||||||
longcat: "LongCat",
|
longcat: "LongCat",
|
||||||
modelscope: "ModelScope (魔搭社区)",
|
modelscope: "ModelScope (魔搭社区)",
|
||||||
|
novita: "Novita AI",
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_ALIASES: Record<string, string> = {
|
const PROVIDER_ALIASES: Record<string, string> = {
|
||||||
|
|
@ -40,6 +52,48 @@ const PROVIDER_ALIASES: Record<string, string> = {
|
||||||
"google-antigravity": "antigravity",
|
"google-antigravity": "antigravity",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PROVIDER_PRIORITY: Record<string, number> = {
|
||||||
|
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 {
|
export function getProviderKey(provider?: string): string {
|
||||||
const normalized = provider?.trim().toLowerCase()
|
const normalized = provider?.trim().toLowerCase()
|
||||||
if (!normalized) return "openai"
|
if (!normalized) return "openai"
|
||||||
|
|
@ -50,3 +104,45 @@ export function getProviderLabel(provider?: string): string {
|
||||||
const prefix = getProviderKey(provider)
|
const prefix = getProviderKey(provider)
|
||||||
return PROVIDER_LABELS[prefix] ?? prefix
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,26 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
const [defaultModelName, setDefaultModelName] = useState("")
|
const [defaultModelName, setDefaultModelName] = useState("")
|
||||||
const setDefaultRequestIdRef = useRef(0)
|
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 () => {
|
const loadModels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getModels()
|
const data = await getModels()
|
||||||
setModelList(data.models)
|
setModelList(data.models)
|
||||||
if (data.models.some((m) => m.model_name === data.default_model)) {
|
syncDefaultModelName(data.models, data.default_model)
|
||||||
setDefaultModelName(data.default_model)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail
|
// silently fail
|
||||||
}
|
}
|
||||||
}, [])
|
}, [syncDefaultModelName])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timerId = setTimeout(() => {
|
const timerId = setTimeout(() => {
|
||||||
|
|
@ -60,9 +69,7 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setModelList(data.models)
|
setModelList(data.models)
|
||||||
if (data.models.some((m) => m.model_name === data.default_model)) {
|
syncDefaultModelName(data.models, data.default_model)
|
||||||
setDefaultModelName(data.default_model)
|
|
||||||
}
|
|
||||||
const gateway = await refreshGatewayState({ force: true })
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
showSaveSuccessOrRestartToast(
|
showSaveSuccessOrRestartToast(
|
||||||
t,
|
t,
|
||||||
|
|
@ -75,30 +82,41 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
toast.error(err instanceof Error ? err.message : t("models.loadError"))
|
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(
|
const hasAvailableModels = useMemo(
|
||||||
() => modelList.some((m) => m.available),
|
() => defaultSelectableModels.some((m) => m.available),
|
||||||
[modelList],
|
[defaultSelectableModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
const oauthModels = useMemo(
|
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(
|
const localModels = useMemo(
|
||||||
() => modelList.filter((m) => m.available && isLocalModel(m)),
|
() => defaultSelectableModels.filter((m) => m.available && isLocalModel(m)),
|
||||||
[modelList],
|
[defaultSelectableModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiKeyModels = useMemo(
|
const apiKeyModels = useMemo(
|
||||||
() =>
|
() =>
|
||||||
modelList.filter(
|
defaultSelectableModels.filter(
|
||||||
(m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m),
|
(m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m),
|
||||||
),
|
),
|
||||||
[modelList],
|
[defaultSelectableModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import "dayjs/locale/en"
|
import "dayjs/locale/en"
|
||||||
|
import "dayjs/locale/pt-br"
|
||||||
import "dayjs/locale/zh-cn"
|
import "dayjs/locale/zh-cn"
|
||||||
import localizedFormat from "dayjs/plugin/localizedFormat"
|
import localizedFormat from "dayjs/plugin/localizedFormat"
|
||||||
import relativeTime from "dayjs/plugin/relativeTime"
|
import relativeTime from "dayjs/plugin/relativeTime"
|
||||||
|
|
@ -8,6 +9,7 @@ import LanguageDetector from "i18next-browser-languagedetector"
|
||||||
import { initReactI18next } from "react-i18next"
|
import { initReactI18next } from "react-i18next"
|
||||||
|
|
||||||
import en from "./locales/en.json"
|
import en from "./locales/en.json"
|
||||||
|
import ptBr from "./locales/pt-br.json"
|
||||||
import zh from "./locales/zh.json"
|
import zh from "./locales/zh.json"
|
||||||
|
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
|
|
@ -26,6 +28,9 @@ i18n
|
||||||
en: {
|
en: {
|
||||||
translation: en,
|
translation: en,
|
||||||
},
|
},
|
||||||
|
"pt-BR": {
|
||||||
|
translation: ptBr,
|
||||||
|
},
|
||||||
zh: {
|
zh: {
|
||||||
translation: zh,
|
translation: zh,
|
||||||
},
|
},
|
||||||
|
|
@ -41,6 +46,8 @@ i18n
|
||||||
i18n.on("languageChanged", (lng) => {
|
i18n.on("languageChanged", (lng) => {
|
||||||
if (lng.startsWith("zh")) {
|
if (lng.startsWith("zh")) {
|
||||||
dayjs.locale("zh-cn")
|
dayjs.locale("zh-cn")
|
||||||
|
} else if (lng.startsWith("pt")) {
|
||||||
|
dayjs.locale("pt-br")
|
||||||
} else {
|
} else {
|
||||||
dayjs.locale("en")
|
dayjs.locale("en")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,8 @@
|
||||||
"setting": "Setting as default...",
|
"setting": "Setting as default...",
|
||||||
"unavailable": "Cannot set unavailable model as default",
|
"unavailable": "Cannot set unavailable model as default",
|
||||||
"isDefault": "Already the default model",
|
"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": {
|
"deleteDisabled": {
|
||||||
"isDefault": "Cannot delete the default model"
|
"isDefault": "Cannot delete the default model"
|
||||||
|
|
@ -254,7 +255,9 @@
|
||||||
},
|
},
|
||||||
"defaultOnSave": {
|
"defaultOnSave": {
|
||||||
"label": "Default Model",
|
"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": {
|
"add": {
|
||||||
"button": "Add Model",
|
"button": "Add Model",
|
||||||
|
|
@ -265,7 +268,7 @@
|
||||||
"modelNameHint": "A short name used to identify this model in conversations.",
|
"modelNameHint": "A short name used to identify this model in conversations.",
|
||||||
"modelId": "Model Identifier",
|
"modelId": "Model Identifier",
|
||||||
"modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
|
"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.",
|
"errorRequired": "This field is required.",
|
||||||
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
||||||
"saveError": "Failed to add model",
|
"saveError": "Failed to add model",
|
||||||
|
|
@ -282,8 +285,9 @@
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"providerPlaceholder": "e.g. openai",
|
"providerPlaceholder": "Select a provider",
|
||||||
"providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.",
|
"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",
|
"apiBase": "API Base URL",
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
"apiKeyPlaceholder": "Enter your API key",
|
"apiKeyPlaceholder": "Enter your API key",
|
||||||
|
|
@ -292,6 +296,7 @@
|
||||||
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
|
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
|
||||||
"authMethod": "Auth Method",
|
"authMethod": "Auth Method",
|
||||||
"authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.",
|
"authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.",
|
||||||
|
"authMethodManagedHint": "This Provider manages its authentication mode automatically.",
|
||||||
"connectMode": "Connect Mode",
|
"connectMode": "Connect Mode",
|
||||||
"connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.",
|
"connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.",
|
||||||
"workspace": "Workspace Path",
|
"workspace": "Workspace Path",
|
||||||
|
|
|
||||||
753
web/frontend/src/i18n/locales/pt-br.json
Normal file
753
web/frontend/src/i18n/locales/pt-br.json
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -246,7 +246,8 @@
|
||||||
"setting": "正在设为默认...",
|
"setting": "正在设为默认...",
|
||||||
"unavailable": "无法将不可用的模型设为默认",
|
"unavailable": "无法将不可用的模型设为默认",
|
||||||
"isDefault": "该模型已是默认模型",
|
"isDefault": "该模型已是默认模型",
|
||||||
"isVirtual": "无法将虚拟模型设为默认"
|
"isVirtual": "无法将虚拟模型设为默认",
|
||||||
|
"unsupportedProvider": "该 Provider 仅用于 ASR,不能设为默认聊天模型"
|
||||||
},
|
},
|
||||||
"deleteDisabled": {
|
"deleteDisabled": {
|
||||||
"isDefault": "无法删除默认模型"
|
"isDefault": "无法删除默认模型"
|
||||||
|
|
@ -254,7 +255,9 @@
|
||||||
},
|
},
|
||||||
"defaultOnSave": {
|
"defaultOnSave": {
|
||||||
"label": "默认模型",
|
"label": "默认模型",
|
||||||
"description": "保存后自动将该模型设置为默认模型。"
|
"description": "保存后自动将该模型设置为默认模型。",
|
||||||
|
"unsupportedProvider": "该 Provider 可以保存在 model_list 中,但不能作为默认聊天模型使用。",
|
||||||
|
"clearOnSave": "保存这个仅用于 ASR 的模型后,会清除当前的默认聊天模型设置。"
|
||||||
},
|
},
|
||||||
"add": {
|
"add": {
|
||||||
"button": "添加模型",
|
"button": "添加模型",
|
||||||
|
|
@ -265,7 +268,7 @@
|
||||||
"modelNameHint": "用于在对话中识别此模型的简短名称。",
|
"modelNameHint": "用于在对话中识别此模型的简短名称。",
|
||||||
"modelId": "模型标识符",
|
"modelId": "模型标识符",
|
||||||
"modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o",
|
"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": "此字段为必填项。",
|
"errorRequired": "此字段为必填项。",
|
||||||
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
||||||
"saveError": "添加模型失败",
|
"saveError": "添加模型失败",
|
||||||
|
|
@ -282,8 +285,9 @@
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"providerPlaceholder": "例如 openai",
|
"providerPlaceholder": "请选择 Provider",
|
||||||
"providerHint": "可选。指定后,将以该值作为最终 provider,并将“模型标识符”字段解释为规范模型 ID。",
|
"providerHint": "请选择一个由后端 catalog 提供的 Provider;“模型标识符”字段会按该 Provider 的规范模型 ID 解释。",
|
||||||
|
"providerInvalid": "当前 Provider 无效,请重新选择一个受支持的 Provider。",
|
||||||
"apiBase": "API Base URL",
|
"apiBase": "API Base URL",
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
"apiKeyPlaceholder": "请输入 API Key",
|
"apiKeyPlaceholder": "请输入 API Key",
|
||||||
|
|
@ -292,6 +296,7 @@
|
||||||
"proxyHint": "可选。例如 http://127.0.0.1:7890",
|
"proxyHint": "可选。例如 http://127.0.0.1:7890",
|
||||||
"authMethod": "认证方式",
|
"authMethod": "认证方式",
|
||||||
"authMethodHint": "认证方式:oauth、token。留空表示使用 API Key 认证。",
|
"authMethodHint": "认证方式:oauth、token。留空表示使用 API Key 认证。",
|
||||||
|
"authMethodManagedHint": "该 Provider 的认证方式由系统自动管理。",
|
||||||
"connectMode": "连接模式",
|
"connectMode": "连接模式",
|
||||||
"connectModeHint": "CLI 型服务商的连接模式:stdio 或 grpc。",
|
"connectModeHint": "CLI 型服务商的连接模式:stdio 或 grpc。",
|
||||||
"workspace": "工作目录",
|
"workspace": "工作目录",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue