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:
authored and GitHub committed 2026-03-04 07:50:47 +07:00
1 parent 2029450eee
commit ca05aba6ea
3 files changed
+91 -18

No files matched your search

+10
View File
@@ -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.
+57 -1
View File
@@ -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,
+24 -17
View File
@@ -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>
))}