mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
fix(chat): show all active agents in chat dropdown (#48)
* fix(channels): start outbound dispatcher before channel check StartAll() returned early when no channels existed at boot, skipping the dispatchOutbound goroutine. Channels loaded later via Reload() assumed the dispatcher was running, causing outbound messages (agent responses) to never reach Telegram. Move dispatcher startup before the empty-channel early return so dynamically loaded channels always have a running consumer. * feat(ui): add LLM provider warning on overview page and ignore plans dir Show alert when no providers configured or all disabled, linking to provider settings. Add plans/ to .gitignore. * feat(onboard): add provider connectivity verification and placeholder seeding - Add onboard_verify.go: verify API keys via POST to chat/completions endpoint (401/403 = fatal, 400/422 = key valid, 5xx = warn) - Verify all configured providers before seeding in auto-onboard - Seed disabled placeholder providers (OpenRouter, Synthetic, AliCloud API/Sub) for UI discoverability after managed data seeding * fix: use model ID as display name in OpenAI-compatible provider list The `owned_by` field (e.g. "system") was incorrectly used as the model display name, causing all models to show as "system" in the UI dropdown for providers like AliCloud DashScope. * fix(chat): show all active agents in chat dropdown Chat agent selector showed "No agents available" because: - WS agents.list only returned in-memory router cache (empty in managed mode) - useEffect had stale [ws] dep that never re-fired after connect Frontend: switch agent-selector from WS to HTTP /v1/agents API with proper access control (ListAccessible). Backend: add store-backed agents.list for WS consumers + Router.IsRunning() helper. * fix(security): prevent agent list leaking on empty userID or store error - Return error instead of falling through to unfiltered router cache when userID is empty or DB query fails in managed mode - Add empty-string guard to isOwnerUser to prevent false owner match Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: ntduc <ntduc@cpp.ai.vn> Co-authored-by: viettranx <viettranx@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3 files changed
+91
-18
No files matched your search
@@ -130,6 +130,16 @@ func (r *Router) ListInfo() []AgentInfo {
|
||||
return infos
|
||||
}
|
||||
|
||||
// IsRunning checks if a specific agent is currently running (cached in router).
|
||||
func (r *Router) IsRunning(agentID string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if entry, ok := r.agents[agentID]; ok {
|
||||
return entry.agent.IsRunning()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Active Run Tracking (matching TS chat-abort.ts) ---
|
||||
|
||||
// ActiveRun tracks a running agent invocation so it can be aborted via chat.abort.
|
||||
|
||||
@@ -33,6 +33,19 @@ func NewAgentsMethods(agents *agent.Router, cfg *config.Config, cfgPath, workspa
|
||||
return &AgentsMethods{agents: agents, cfg: cfg, cfgPath: cfgPath, workspace: workspace, agentStore: agentStore, isManaged: isManaged, interceptor: interceptor}
|
||||
}
|
||||
|
||||
// isOwnerUser checks if the given user ID is in the configured owner IDs.
|
||||
func (m *AgentsMethods) isOwnerUser(userID string) bool {
|
||||
if userID == "" {
|
||||
return false
|
||||
}
|
||||
for _, id := range m.cfg.Gateway.OwnerIDs {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *AgentsMethods) Register(router *gateway.MethodRouter) {
|
||||
router.Register(protocol.MethodAgent, m.handleAgent)
|
||||
router.Register(protocol.MethodAgentWait, m.handleAgentWait)
|
||||
@@ -93,7 +106,50 @@ func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Clien
|
||||
}))
|
||||
}
|
||||
|
||||
func (m *AgentsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
func (m *AgentsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
// In managed mode, query the store so ALL accessible agents appear (not just router-cached ones).
|
||||
if m.isManaged && m.agentStore != nil {
|
||||
userID := client.UserID()
|
||||
if userID == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "user context required"))
|
||||
return
|
||||
}
|
||||
|
||||
var agents []store.AgentData
|
||||
var err error
|
||||
if m.isOwnerUser(userID) {
|
||||
agents, err = m.agentStore.List(ctx, "")
|
||||
} else {
|
||||
agents, err = m.agentStore.ListAccessible(ctx, userID)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("agents.list: store query failed", "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to list agents"))
|
||||
return
|
||||
}
|
||||
|
||||
infos := make([]map[string]interface{}, 0, len(agents))
|
||||
for _, a := range agents {
|
||||
if a.Status != store.AgentStatusActive {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, map[string]interface{}{
|
||||
"id": a.AgentKey,
|
||||
"name": a.DisplayName,
|
||||
"model": a.Model,
|
||||
"provider": a.Provider,
|
||||
"agentType": a.AgentType,
|
||||
"status": a.Status,
|
||||
"isRunning": m.agents.IsRunning(a.AgentKey),
|
||||
})
|
||||
}
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
|
||||
"agents": infos,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// Standalone mode: return router-cached agents.
|
||||
infos := m.agents.ListInfo()
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
|
||||
"agents": infos,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bot, ChevronDown } from "lucide-react";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { Methods } from "@/api/protocol";
|
||||
import type { AgentInfo } from "@/types/agent";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
|
||||
interface AgentSelectorProps {
|
||||
value: string;
|
||||
@@ -10,18 +10,23 @@ interface AgentSelectorProps {
|
||||
}
|
||||
|
||||
export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
||||
const ws = useWs();
|
||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||
const http = useHttp();
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
const [agents, setAgents] = useState<AgentData[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws.isConnected) return;
|
||||
ws.call<{ agents: AgentInfo[] }>(Methods.AGENTS_LIST)
|
||||
.then((res) => setAgents(res.agents ?? []))
|
||||
if (!connected) return;
|
||||
http
|
||||
.get<{ agents: AgentData[] }>("/v1/agents")
|
||||
.then((res) => {
|
||||
const active = (res.agents ?? []).filter((a) => a.status === "active");
|
||||
setAgents(active);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [ws]);
|
||||
}, [http, connected]);
|
||||
|
||||
const selected = agents.find((a) => a.id === value);
|
||||
const selected = agents.find((a) => a.agent_key === value);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -32,7 +37,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
||||
>
|
||||
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selected?.name ?? (value || "Select agent")}
|
||||
{selected?.display_name ?? selected?.agent_key ?? (value || "Select agent")}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -48,20 +53,22 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
key={agent.agent_key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(agent.id);
|
||||
onChange(agent.agent_key);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent ${
|
||||
agent.id === value ? "bg-accent" : ""
|
||||
agent.agent_key === value ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-left">{agent.name || agent.id}</span>
|
||||
{agent.isRunning && (
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="flex-1 truncate text-left">
|
||||
{agent.display_name || agent.agent_key}
|
||||
</span>
|
||||
{agent.is_default && (
|
||||
<span className="text-xs text-muted-foreground">default</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user