mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +00:00
feat: Implement agent resummoning with UI retry, add provider verification, and introduce created_at timestamps to various tables.
This commit is contained in:
1 parent
48878848df
commit
16022d77be
17 files changed
+466
-51
No files matched your search
@@ -314,7 +314,7 @@ func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus,
|
||||
}
|
||||
|
||||
if stores != nil && stores.Providers != nil {
|
||||
providersH = httpapi.NewProvidersHandler(stores.Providers, token)
|
||||
providersH = httpapi.NewProvidersHandler(stores.Providers, token, providerReg)
|
||||
}
|
||||
|
||||
return agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH
|
||||
|
||||
@@ -21,6 +21,15 @@ var templateFiles = []string{
|
||||
HeartbeatFile,
|
||||
}
|
||||
|
||||
// ReadTemplate returns the content of an embedded template file.
|
||||
func ReadTemplate(name string) (string, error) {
|
||||
content, err := templateFS.ReadFile(filepath.Join("templates", name))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// EnsureWorkspaceFiles seeds template files into a workspace directory.
|
||||
// Only writes files that don't already exist (will not overwrite).
|
||||
// BOOTSTRAP.md is only seeded if the workspace is brand new (no AGENTS.md exists).
|
||||
|
||||
@@ -49,6 +49,7 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /v1/agents/{id}/shares", h.authMiddleware(h.handleShare))
|
||||
mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.authMiddleware(h.handleRevokeShare))
|
||||
mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.authMiddleware(h.handleRegenerate))
|
||||
mux.HandleFunc("POST /v1/agents/{id}/resummon", h.authMiddleware(h.handleResummon))
|
||||
}
|
||||
|
||||
func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -422,6 +423,50 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": "summoning"})
|
||||
}
|
||||
|
||||
// handleResummon re-runs SummonAgent from scratch using the original description.
|
||||
// Used when initial summoning failed (e.g. wrong model) and user wants to retry.
|
||||
func (h *AgentsHandler) handleResummon(w http.ResponseWriter, r *http.Request) {
|
||||
userID := store.UserIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ag, err := h.agents.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
if userID != "" && ag.OwnerID != userID {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can resummon agent"})
|
||||
return
|
||||
}
|
||||
if ag.Status == "summoning" {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is already being summoned"})
|
||||
return
|
||||
}
|
||||
if h.summoner == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "summoning not available"})
|
||||
return
|
||||
}
|
||||
|
||||
description := extractDescription(ag.OtherConfig)
|
||||
if description == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent has no description to resummon from"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.agents.Update(r.Context(), id, map[string]any{"status": "summoning"}); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
go h.summoner.SummonAgent(id, ag.Provider, ag.Model, description)
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": "summoning"})
|
||||
}
|
||||
|
||||
// extractDescription pulls the description string from other_config JSONB.
|
||||
func extractDescription(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
)
|
||||
|
||||
// handleVerifyProvider tests a provider+model combination with a minimal LLM call.
|
||||
//
|
||||
// POST /v1/providers/{id}/verify
|
||||
// Body: {"model": "anthropic/claude-sonnet-4"}
|
||||
// Response: {"valid": true} or {"valid": false, "error": "..."}
|
||||
func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if req.Model == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up provider record from DB to get the provider name
|
||||
p, err := h.store.GetProvider(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.providerReg == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "no provider registry available"})
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := h.providerReg.Get(p.Name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": "provider not registered: " + p.Name})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = provider.Chat(ctx, providers.ChatRequest{
|
||||
Messages: []providers.Message{
|
||||
{Role: "user", Content: "hi"},
|
||||
},
|
||||
Model: req.Model,
|
||||
Options: map[string]interface{}{
|
||||
"max_tokens": 1,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": friendlyVerifyError(err)})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true})
|
||||
}
|
||||
|
||||
// friendlyVerifyError extracts a human-readable message from provider errors.
|
||||
// Raw errors often contain JSON blobs like: `HTTP 400: minimax: {"type":"error","error":{"type":"bad_request_error","message":"unknown model ..."}}`
|
||||
func friendlyVerifyError(err error) string {
|
||||
msg := err.Error()
|
||||
|
||||
// Try to extract "message" field from embedded JSON
|
||||
if idx := strings.Index(msg, `"message"`); idx >= 0 {
|
||||
// Find the value after "message":
|
||||
rest := msg[idx:]
|
||||
// Look for :"<value>"
|
||||
start := strings.Index(rest, `:`)
|
||||
if start >= 0 {
|
||||
rest = strings.TrimLeft(rest[start+1:], " ")
|
||||
if len(rest) > 0 && rest[0] == '"' {
|
||||
rest = rest[1:]
|
||||
if end := strings.Index(rest, `"`); end >= 0 {
|
||||
extracted := rest[:end]
|
||||
if extracted != "" {
|
||||
return extracted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: strip "HTTP NNN: provider: " prefix for cleaner display
|
||||
if idx := strings.LastIndex(msg, ": "); idx >= 0 && idx < len(msg)-2 {
|
||||
suffix := msg[idx+2:]
|
||||
// If the remainder still looks like JSON, just say "invalid model"
|
||||
if strings.HasPrefix(suffix, "{") {
|
||||
return "Model not recognized by provider"
|
||||
}
|
||||
return suffix
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
@@ -7,18 +7,20 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// ProvidersHandler handles LLM provider CRUD endpoints (managed mode).
|
||||
type ProvidersHandler struct {
|
||||
store store.ProviderStore
|
||||
token string
|
||||
store store.ProviderStore
|
||||
token string
|
||||
providerReg *providers.Registry
|
||||
}
|
||||
|
||||
// NewProvidersHandler creates a handler for provider management endpoints.
|
||||
func NewProvidersHandler(s store.ProviderStore, token string) *ProvidersHandler {
|
||||
return &ProvidersHandler{store: s, token: token}
|
||||
func NewProvidersHandler(s store.ProviderStore, token string, providerReg *providers.Registry) *ProvidersHandler {
|
||||
return &ProvidersHandler{store: s, token: token, providerReg: providerReg}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all provider management routes on the given mux.
|
||||
@@ -32,6 +34,9 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
|
||||
// Model listing (proxied to upstream provider API)
|
||||
mux.HandleFunc("GET /v1/providers/{id}/models", h.auth(h.handleListProviderModels))
|
||||
|
||||
// Provider + model verification (pre-flight check)
|
||||
mux.HandleFunc("POST /v1/providers/{id}/verify", h.auth(h.handleVerifyProvider))
|
||||
}
|
||||
|
||||
func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
+48
-17
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -48,9 +49,9 @@ func NewAgentSummoner(agents store.AgentStore, providerReg *providers.Registry,
|
||||
// SummonAgent generates context files from a natural language description.
|
||||
// Meant to be called as a goroutine: go summoner.SummonAgent(...)
|
||||
// On success: stores generated files and sets agent status to "active".
|
||||
// On failure: keeps template files (already seeded) and sets status to "active".
|
||||
// On failure: keeps template files (already seeded) and sets status to "summon_failed".
|
||||
func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, description string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.emitEvent(agentID, "started", "", "")
|
||||
@@ -60,7 +61,8 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
|
||||
slog.Warn("summoning: LLM generation failed, falling back to templates",
|
||||
"agent", agentID, "error", err)
|
||||
s.emitEvent(agentID, "failed", "", err.Error())
|
||||
s.setAgentStatus(ctx, agentID, "active")
|
||||
// Use fresh context — the original may have timed out, but we still need to update status.
|
||||
s.setAgentStatus(context.Background(), agentID, "summon_failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -75,7 +77,7 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc
|
||||
// Reads existing files, sends them + edit instructions to LLM, stores results.
|
||||
// Synchronous — caller should run in goroutine if needed.
|
||||
func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, editPrompt string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.emitEvent(agentID, "started", "", "")
|
||||
@@ -85,7 +87,7 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model,
|
||||
if err != nil {
|
||||
slog.Warn("summoning: failed to read existing files", "agent", agentID, "error", err)
|
||||
s.emitEvent(agentID, "failed", "", err.Error())
|
||||
s.setAgentStatus(ctx, agentID, "active")
|
||||
s.setAgentStatus(context.Background(), agentID, "summon_failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,7 +97,8 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model,
|
||||
if err != nil {
|
||||
slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err)
|
||||
s.emitEvent(agentID, "failed", "", err.Error())
|
||||
s.setAgentStatus(ctx, agentID, "active")
|
||||
// Use fresh context — the original may have timed out, but we still need to update status.
|
||||
s.setAgentStatus(context.Background(), agentID, "summon_failed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,20 +201,45 @@ func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg
|
||||
}
|
||||
|
||||
// buildCreatePrompt constructs the system + user prompt for initial file generation.
|
||||
// Includes the full template files as reference so the LLM preserves core operational structure.
|
||||
func (s *AgentSummoner) buildCreatePrompt(description string) string {
|
||||
return fmt.Sprintf(`You are setting up a new AI assistant. Based on the description below, generate the content for these context files. Write in the same language as the description.
|
||||
// Load templates as reference material
|
||||
templates := make(map[string]string)
|
||||
for _, name := range summoningFiles {
|
||||
content, err := bootstrap.ReadTemplate(name)
|
||||
if err != nil {
|
||||
slog.Warn("summoning: failed to read template for prompt", "file", name, "error", err)
|
||||
continue
|
||||
}
|
||||
templates[name] = content
|
||||
}
|
||||
|
||||
<description>
|
||||
%s
|
||||
</description>
|
||||
var sb strings.Builder
|
||||
sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate customized content for each context file.\n\n")
|
||||
|
||||
Generate each file inside XML tags. Follow the format and spirit of each file type:
|
||||
fmt.Fprintf(&sb, "<description>\n%s\n</description>\n\n", description)
|
||||
|
||||
- **SOUL.md**: Who the agent IS. Core personality traits, communication style, boundaries, and values. Written in first person, like a character sheet. Not a job description — a soul.
|
||||
- **IDENTITY.md**: Quick identity card. Name, Creature (what kind of entity?), Vibe (how they come across), Emoji (signature), Avatar (leave blank).
|
||||
- **AGENTS.md**: Operating instructions. How the agent should behave each session, handle memory, manage safety, work in groups. Keep the core structure from the template but personalize for this agent's purpose.
|
||||
- **TOOLS.md**: Notes about tools relevant to this agent's role. What environment-specific details matter.
|
||||
- **HEARTBEAT.md**: Periodic tasks (if the agent should check things proactively). Leave minimal/empty if not applicable.
|
||||
sb.WriteString("Below are the DEFAULT TEMPLATES for each file. Use them as the foundation — preserve the core structure and operational rules, but customize the content to match the agent's purpose and personality.\n\n")
|
||||
|
||||
sb.WriteString("<templates>\n")
|
||||
for _, name := range summoningFiles {
|
||||
if content, ok := templates[name]; ok {
|
||||
fmt.Fprintf(&sb, "<file name=%q>\n%s\n</file>\n", name, content)
|
||||
}
|
||||
}
|
||||
sb.WriteString("</templates>\n\n")
|
||||
|
||||
sb.WriteString(`IMPORTANT — Language rule: You MUST write ALL file content in the SAME LANGUAGE as the <description> above. If the description is in Vietnamese, write in Vietnamese. If in English, write in English. The templates below are in English — translate and adapt them to match the description's language. Only keep technical terms (file names, code, commands) in English.
|
||||
|
||||
Instructions for each file:
|
||||
|
||||
- **SOUL.md**: Rewrite to reflect this agent's unique personality, values, communication style, and boundaries. Keep the spirit of the template (genuine helpfulness, opinions, resourcefulness) but make it specific to this agent's role.
|
||||
- **IDENTITY.md**: Fill in the identity card fields (Name, Creature, Vibe, Emoji) based on the description. Leave Avatar blank.
|
||||
- **AGENTS.md**: This is CRITICAL — you MUST preserve the core operational sections (First Run, Every Session, Memory, Safety, External vs Internal, Group Chats, Heartbeats). Customize the content within each section to fit the agent's purpose, but do NOT remove any section. The operational structure is required for the system to function.
|
||||
- **TOOLS.md**: Customize with tool notes relevant to this agent's role. Keep the structure.
|
||||
- **HEARTBEAT.md**: Add periodic tasks if relevant to the agent's role. Leave minimal if not applicable.
|
||||
|
||||
Generate each file inside XML tags:
|
||||
|
||||
<file name="SOUL.md">
|
||||
(generate here)
|
||||
@@ -227,7 +255,9 @@ Generate each file inside XML tags. Follow the format and spirit of each file ty
|
||||
</file>
|
||||
<file name="HEARTBEAT.md">
|
||||
(generate here)
|
||||
</file>`, description)
|
||||
</file>`)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildEditPrompt constructs the prompt for editing existing files.
|
||||
@@ -242,6 +272,7 @@ func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, e
|
||||
}
|
||||
sb.WriteString("</current_files>\n\n")
|
||||
fmt.Fprintf(&sb, "<edit_instructions>\n%s\n</edit_instructions>\n\n", editPrompt)
|
||||
sb.WriteString("IMPORTANT — Language rule: Write ALL content in the SAME LANGUAGE as the existing files above. If the current files are in Vietnamese, write in Vietnamese. Only keep technical terms (file names, code, commands) in English.\n\n")
|
||||
sb.WriteString("Generate updated files. Only include files that need changes. Keep the same XML format:\n\n")
|
||||
sb.WriteString("<file name=\"SOUL.md\">\n(updated content, or omit if unchanged)\n</file>\n")
|
||||
sb.WriteString("...\n")
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE agent_context_files DROP COLUMN IF EXISTS created_at;
|
||||
ALTER TABLE config_secrets DROP COLUMN IF EXISTS created_at;
|
||||
ALTER TABLE cron_run_logs DROP COLUMN IF EXISTS created_at;
|
||||
ALTER TABLE embedding_cache DROP COLUMN IF EXISTS created_at;
|
||||
ALTER TABLE memory_chunks DROP COLUMN IF EXISTS created_at;
|
||||
ALTER TABLE memory_documents DROP COLUMN IF EXISTS created_at;
|
||||
ALTER TABLE user_context_files DROP COLUMN IF EXISTS created_at;
|
||||
ALTER TABLE user_agent_overrides DROP COLUMN IF EXISTS created_at;
|
||||
ALTER TABLE user_agent_overrides DROP COLUMN IF EXISTS updated_at;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Add created_at to tables that only have updated_at (or no timestamp at all).
|
||||
-- Existing rows get NOW() as default (close enough for backfill).
|
||||
|
||||
ALTER TABLE agent_context_files ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE config_secrets ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE cron_run_logs ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE embedding_cache ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE memory_chunks ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE memory_documents ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE user_context_files ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE user_agent_overrides ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE user_agent_overrides ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW();
|
||||
@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Bot, Star } from "lucide-react";
|
||||
import { Bot, Star, RotateCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: AgentData;
|
||||
onClick: () => void;
|
||||
onResummon?: () => void;
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function AgentCard({ agent, onClick }: AgentCardProps) {
|
||||
export function AgentCard({ agent, onClick, onResummon }: AgentCardProps) {
|
||||
const displayName = agent.display_name
|
||||
|| (UUID_RE.test(agent.agent_key) ? "Unnamed Agent" : agent.agent_key);
|
||||
|
||||
@@ -42,6 +44,10 @@ export function AgentCard({ agent, onClick }: AgentCardProps) {
|
||||
<Badge variant="outline" className="shrink-0 animate-pulse border-violet-400 text-violet-600 dark:text-violet-400">
|
||||
Summoning...
|
||||
</Badge>
|
||||
) : agent.status === "summon_failed" ? (
|
||||
<Badge variant="destructive" className="shrink-0">
|
||||
Failed
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={agent.status === "active" ? "success" : "secondary"} className="shrink-0">
|
||||
{agent.status}
|
||||
@@ -64,6 +70,20 @@ export function AgentCard({ agent, onClick }: AgentCardProps) {
|
||||
{(agent.context_window / 1000).toFixed(0)}K ctx
|
||||
</span>
|
||||
)}
|
||||
{agent.status === "summon_failed" && onResummon && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="ml-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onResummon();
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Resummon
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,6 +22,7 @@ import type { AgentData } from "@/types/agent";
|
||||
import { slugify, isValidSlug } from "@/lib/slug";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
|
||||
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
|
||||
import { AGENT_PRESETS } from "./agent-presets";
|
||||
|
||||
interface AgentCreateDialogProps {
|
||||
@@ -49,6 +50,17 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
[enabledProviders, provider],
|
||||
);
|
||||
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
|
||||
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
|
||||
|
||||
// Reset verification when provider or model changes
|
||||
useEffect(() => {
|
||||
resetVerify();
|
||||
}, [provider, model, resetVerify]);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!selectedProviderId || !model.trim()) return;
|
||||
await verify(selectedProviderId, model.trim());
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!agentKey.trim()) return;
|
||||
@@ -84,7 +96,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -145,13 +157,32 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Model *</Label>
|
||||
<Combobox
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name }))}
|
||||
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
|
||||
/>
|
||||
{provider && !modelsLoading && models.length === 0 && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Combobox
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name }))}
|
||||
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 px-3"
|
||||
disabled={!selectedProviderId || !model.trim() || verifying}
|
||||
onClick={handleVerify}
|
||||
>
|
||||
{verifying ? "..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
{verifyResult && (
|
||||
<p className={`text-xs ${verifyResult.valid ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
|
||||
</p>
|
||||
)}
|
||||
{!verifyResult && provider && !modelsLoading && models.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">This provider doesn't list models — type the model ID manually.</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -218,9 +249,13 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!displayName.trim() || !agentKey.trim() || !isValidSlug(agentKey) || !provider.trim() || !model.trim() || loading}>
|
||||
{loading ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
{loading ? (
|
||||
<Button disabled>Creating...</Button>
|
||||
) : (
|
||||
<Button onClick={handleCreate} disabled={!displayName.trim() || !agentKey.trim() || !isValidSlug(agentKey) || !provider.trim() || !model.trim() || !verifyResult?.valid}>
|
||||
Create
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -7,6 +8,7 @@ import { AgentGeneralTab } from "./agent-general-tab";
|
||||
import { AgentConfigTab } from "./agent-config-tab";
|
||||
import { AgentFilesTab } from "./agent-files-tab";
|
||||
import { AgentSharesTab } from "./agent-shares-tab";
|
||||
import { SummoningModal } from "../summoning-modal";
|
||||
import { DeferredSpinner } from "@/components/shared/loading-skeleton";
|
||||
|
||||
interface AgentDetailPageProps {
|
||||
@@ -31,8 +33,14 @@ function agentSubtitle(agent: { display_name?: string; agent_key: string; id: st
|
||||
}
|
||||
|
||||
export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent } =
|
||||
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, refresh } =
|
||||
useAgentDetail(agentId);
|
||||
const [summoningOpen, setSummoningOpen] = useState(false);
|
||||
|
||||
const handleRegenerate = async (prompt: string) => {
|
||||
await regenerateAgent(prompt);
|
||||
setSummoningOpen(true);
|
||||
};
|
||||
|
||||
if (loading || !agent) {
|
||||
return (
|
||||
@@ -64,8 +72,8 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
{agent.is_default && (
|
||||
<Star className="h-4 w-4 shrink-0 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
<Badge variant={agent.status === "active" ? "success" : "secondary"}>
|
||||
{agent.status}
|
||||
<Badge variant={agent.status === "active" ? "success" : agent.status === "summon_failed" ? "destructive" : "secondary"}>
|
||||
{agent.status === "summon_failed" ? "Summon Failed" : agent.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
||||
@@ -110,7 +118,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
files={files}
|
||||
onGetFile={getFile}
|
||||
onSetFile={setFile}
|
||||
onRegenerate={regenerateAgent}
|
||||
onRegenerate={handleRegenerate}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -119,6 +127,14 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<SummoningModal
|
||||
open={summoningOpen}
|
||||
onOpenChange={setSummoningOpen}
|
||||
agentId={agentId}
|
||||
agentName={title}
|
||||
onCompleted={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,10 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate
|
||||
const canEdit = !isPredefined || isOwner;
|
||||
|
||||
// Hide MEMORY.json — memory is managed by the memory system, not context files
|
||||
const displayFiles = files.filter((f) => f.name !== "MEMORY.json");
|
||||
// Hide BOOTSTRAP.md for predefined agents — it's a per-user onboarding file, not agent-level
|
||||
const displayFiles = files.filter((f) =>
|
||||
f.name !== "MEMORY.json" && !(isPredefined && f.name === "BOOTSTRAP.md"),
|
||||
);
|
||||
const allMissing = displayFiles.length > 0 && displayFiles.every((f) => f.missing);
|
||||
|
||||
// USER.md is per-user at runtime — not editable from the agent admin UI for predefined agents
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { Save, Copy, Check, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
|
||||
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
|
||||
interface AgentGeneralTabProps {
|
||||
@@ -35,6 +36,21 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
[enabledProviders, provider],
|
||||
);
|
||||
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
|
||||
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
|
||||
|
||||
// Track whether provider/model changed from saved values
|
||||
const llmChanged = provider !== agent.provider || model !== agent.model;
|
||||
|
||||
// Reset verification when provider or model changes
|
||||
useEffect(() => {
|
||||
resetVerify();
|
||||
}, [provider, model, resetVerify]);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!selectedProviderId || !model.trim()) return;
|
||||
await verify(selectedProviderId, model.trim());
|
||||
};
|
||||
|
||||
const [contextWindow, setContextWindow] = useState(agent.context_window || 200000);
|
||||
const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations || 20);
|
||||
const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace);
|
||||
@@ -118,6 +134,9 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
{status === "summon_failed" && (
|
||||
<SelectItem value="summon_failed" disabled>Summon Failed</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -163,12 +182,33 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">Model</Label>
|
||||
<Combobox
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name }))}
|
||||
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Combobox
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={models.map((m) => ({ value: m.id, label: m.name }))}
|
||||
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
|
||||
/>
|
||||
</div>
|
||||
{llmChanged && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 px-3"
|
||||
disabled={!selectedProviderId || !model.trim() || verifying}
|
||||
onClick={handleVerify}
|
||||
>
|
||||
{verifying ? "..." : "Check"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{verifyResult && (
|
||||
<p className={`text-xs ${verifyResult.valid ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -238,7 +278,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
<Check className="h-3.5 w-3.5" /> Saved
|
||||
</span>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Button onClick={handleSave} disabled={saving || (llmChanged && !verifyResult?.valid)}>
|
||||
{!saving && <Save className="h-4 w-4" />}
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CardSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { useAgents } from "./hooks/use-agents";
|
||||
import { AgentCard } from "./agent-card";
|
||||
import { AgentCreateDialog } from "./agent-create-dialog";
|
||||
@@ -19,6 +20,7 @@ import { usePagination } from "@/hooks/use-pagination";
|
||||
export function AgentsPage() {
|
||||
const { id: detailId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const http = useHttp();
|
||||
const { agents, loading, createAgent, deleteAgent, refresh } = useAgents();
|
||||
const showSkeleton = useDeferredLoading(loading && agents.length === 0);
|
||||
|
||||
@@ -27,6 +29,15 @@ export function AgentsPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [summoningAgent, setSummoningAgent] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const handleResummon = async (agent: { id: string; display_name?: string; agent_key: string }) => {
|
||||
try {
|
||||
await http.post(`/v1/agents/${agent.id}/resummon`);
|
||||
setSummoningAgent({ id: agent.id, name: agent.display_name || agent.agent_key });
|
||||
} catch {
|
||||
// error handled by http hook
|
||||
}
|
||||
};
|
||||
|
||||
// Show detail view if route has :id
|
||||
if (detailId) {
|
||||
return (
|
||||
@@ -104,6 +115,7 @@ export function AgentsPage() {
|
||||
navigate(`/agents/${agent.id}`);
|
||||
}
|
||||
}}
|
||||
onResummon={() => handleResummon(agent)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
|
||||
interface SummoningModalProps {
|
||||
@@ -31,9 +33,11 @@ export function SummoningModal({
|
||||
agentName,
|
||||
onCompleted,
|
||||
}: SummoningModalProps) {
|
||||
const http = useHttp();
|
||||
const [generatedFiles, setGeneratedFiles] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<"summoning" | "completed" | "failed">("summoning");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
@@ -70,6 +74,20 @@ export function SummoningModal({
|
||||
|
||||
useWsEvent("agent.summoning", handleSummoningEvent);
|
||||
|
||||
const handleRetry = async () => {
|
||||
setRetrying(true);
|
||||
try {
|
||||
await http.post(`/v1/agents/${agentId}/resummon`);
|
||||
setGeneratedFiles([]);
|
||||
setStatus("summoning");
|
||||
setErrorMsg("");
|
||||
} catch {
|
||||
// stay in failed state
|
||||
} finally {
|
||||
setRetrying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md" overlayTransparent onInteractOutside={(e) => { if (status === "summoning") e.preventDefault(); }}>
|
||||
@@ -187,9 +205,15 @@ export function SummoningModal({
|
||||
|
||||
{status === "summoning" && (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
This usually takes 10-30 seconds. Please wait...
|
||||
This usually takes few minutes. Please wait...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === "failed" && (
|
||||
<Button variant="outline" size="sm" onClick={handleRetry} disabled={retrying}>
|
||||
{retrying ? "Retrying..." : "Retry"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
|
||||
interface VerifyResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useProviderVerify() {
|
||||
const http = useHttp();
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [result, setResult] = useState<VerifyResult | null>(null);
|
||||
|
||||
const verify = useCallback(
|
||||
async (providerId: string, model: string) => {
|
||||
setVerifying(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await http.post<VerifyResult>(
|
||||
`/v1/providers/${providerId}/verify`,
|
||||
{ model },
|
||||
);
|
||||
setResult(res);
|
||||
return res;
|
||||
} catch (err) {
|
||||
const r: VerifyResult = {
|
||||
valid: false,
|
||||
error: err instanceof Error ? err.message : "Verification failed",
|
||||
};
|
||||
setResult(r);
|
||||
return r;
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
},
|
||||
[http],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => setResult(null), []);
|
||||
|
||||
return { verify, verifying, result, reset };
|
||||
}
|
||||
Reference in new issue
Block a user