mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +00:00
feat(agents): add self-evolution config and instances management for predefined agents
Self-Evolution: predefined agents can now optionally evolve their SOUL.md (communication style/tone only) when self_evolve is enabled in other_config. Identity, name, and operating instructions remain locked. Context propagation flows through LoopConfig → Loop → context.WithValue → interceptor carve-out. System prompt guides the agent on what it can/cannot evolve. Instances Tab: new HTTP endpoints and UI tab for viewing/editing per-user USER.md files on predefined agents. Includes owner-only access checks, fileName validation (USER.md only), and cache invalidation. UI: self-evolve toggle in General tab, create dialog, and setup wizard. Agent type and evolve/static badges with tooltip explanations on cards and detail header. TooltipProvider added to agents list and detail pages.
This commit is contained in:
1 parent
0f2737ce53
commit
ea185b3f6c
23 files changed
+689
-33
No files matched your search
@@ -176,6 +176,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
if l.agentType != "" {
|
||||
ctx = store.WithAgentType(ctx, l.agentType)
|
||||
}
|
||||
// Inject self-evolve flag for predefined agents that can update SOUL.md
|
||||
if l.selfEvolve {
|
||||
ctx = store.WithSelfEvolve(ctx, true)
|
||||
}
|
||||
// Inject original sender ID for group file writer permission checks
|
||||
if req.SenderID != "" {
|
||||
ctx = store.WithSenderID(ctx, req.SenderID)
|
||||
|
||||
@@ -85,6 +85,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
|
||||
SandboxEnabled: l.sandboxEnabled,
|
||||
SandboxContainerDir: l.sandboxContainerDir,
|
||||
SandboxWorkspaceAccess: l.sandboxWorkspaceAccess,
|
||||
SelfEvolve: l.selfEvolve,
|
||||
})
|
||||
|
||||
messages = append(messages, providers.Message{
|
||||
|
||||
@@ -98,6 +98,9 @@ type Loop struct {
|
||||
// Thinking level for extended thinking support
|
||||
thinkingLevel string
|
||||
|
||||
// Self-evolve: predefined agents can update SOUL.md through chat
|
||||
selfEvolve bool
|
||||
|
||||
// Group writer cache for system prompt injection
|
||||
groupWriterCache *store.GroupWriterCache
|
||||
|
||||
@@ -185,6 +188,9 @@ type LoopConfig struct {
|
||||
// Thinking level: "off", "low", "medium", "high" (from agent other_config)
|
||||
ThinkingLevel string
|
||||
|
||||
// Self-evolve: predefined agents can update SOUL.md (style/tone) through chat
|
||||
SelfEvolve bool
|
||||
|
||||
// Group writer cache for system prompt injection
|
||||
GroupWriterCache *store.GroupWriterCache
|
||||
|
||||
@@ -253,6 +259,7 @@ func NewLoop(cfg LoopConfig) *Loop {
|
||||
maxMessageChars: cfg.MaxMessageChars,
|
||||
builtinToolSettings: cfg.BuiltinToolSettings,
|
||||
thinkingLevel: cfg.ThinkingLevel,
|
||||
selfEvolve: cfg.SelfEvolve,
|
||||
groupWriterCache: cfg.GroupWriterCache,
|
||||
teamStore: cfg.TeamStore,
|
||||
mediaStore: cfg.MediaStore,
|
||||
|
||||
@@ -371,6 +371,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
|
||||
BuiltinToolSettings: builtinSettings,
|
||||
ThinkingLevel: ag.ParseThinkingLevel(),
|
||||
SelfEvolve: ag.ParseSelfEvolve(),
|
||||
GroupWriterCache: deps.GroupWriterCache,
|
||||
TeamStore: deps.TeamStore,
|
||||
MediaStore: deps.MediaStore,
|
||||
|
||||
@@ -42,6 +42,9 @@ type SystemPromptConfig struct {
|
||||
SandboxEnabled bool // exec tool runs inside Docker sandbox?
|
||||
SandboxContainerDir string // container-side workdir (e.g. "/workspace")
|
||||
SandboxWorkspaceAccess string // "none", "ro", "rw"
|
||||
|
||||
// Self-evolution: predefined agents can update SOUL.md (style/tone)
|
||||
SelfEvolve bool
|
||||
}
|
||||
|
||||
// coreToolSummaries maps tool names to one-line descriptions.
|
||||
@@ -106,6 +109,11 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string {
|
||||
// 3. ## Safety
|
||||
lines = append(lines, buildSafetySection()...)
|
||||
|
||||
// 3.5. ## Self-Evolution (predefined agents with self_evolve enabled)
|
||||
if cfg.SelfEvolve && cfg.AgentType == "predefined" {
|
||||
lines = append(lines, buildSelfEvolveSection()...)
|
||||
}
|
||||
|
||||
// 4. ## Skills (full only)
|
||||
// SkillsSummary non-empty → inline mode (XML list in prompt, TS-style)
|
||||
// SkillsSummary empty + HasSkillSearch → search mode (use skill_search tool)
|
||||
@@ -234,6 +242,28 @@ func buildSafetySection() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func buildSelfEvolveSection() []string {
|
||||
return []string{
|
||||
"## Self-Evolution",
|
||||
"",
|
||||
"You have self-evolution enabled. You may update your SOUL.md file to refine your communication style over time.",
|
||||
"",
|
||||
"What you CAN evolve in SOUL.md:",
|
||||
"- Tone, voice, and manner of speaking",
|
||||
"- Response style and formatting preferences",
|
||||
"- Vocabulary and phrasing patterns",
|
||||
"- Interaction patterns based on user feedback",
|
||||
"",
|
||||
"What you MUST NOT change:",
|
||||
"- Your name, identity, or contact information",
|
||||
"- Your core purpose or role",
|
||||
"- Any content in IDENTITY.md or AGENTS.md (these remain locked)",
|
||||
"",
|
||||
"Make changes incrementally. Only update SOUL.md when you notice clear patterns in user feedback or interaction style preferences.",
|
||||
"",
|
||||
}
|
||||
}
|
||||
|
||||
func buildSkillsSection(skillsSummary string, hasSkillSearch bool) []string {
|
||||
if skillsSummary != "" {
|
||||
// Inline mode: skills XML is in the prompt (like TS).
|
||||
|
||||
@@ -101,6 +101,9 @@ func (s *seedStubStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ s
|
||||
func (s *seedStubStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *seedStubStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
|
||||
@@ -95,6 +95,9 @@ func (s *createCaptureStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID
|
||||
func (s *createCaptureStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *createCaptureStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
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))
|
||||
mux.HandleFunc("GET /v1/agents/{id}/instances", h.authMiddleware(h.handleListInstances))
|
||||
mux.HandleFunc("GET /v1/agents/{id}/instances/{userID}/files", h.authMiddleware(h.handleGetInstanceFiles))
|
||||
mux.HandleFunc("PUT /v1/agents/{id}/instances/{userID}/files/{fileName}", h.authMiddleware(h.handleSetInstanceFile))
|
||||
}
|
||||
|
||||
func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// handleListInstances returns all user instances for a predefined agent.
|
||||
func (h *AgentsHandler) handleListInstances(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 && !h.isOwnerUser(userID) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can view instances"})
|
||||
return
|
||||
}
|
||||
|
||||
instances, err := h.agents.ListUserInstances(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"instances": instances})
|
||||
}
|
||||
|
||||
// handleGetInstanceFiles returns user context files for a specific instance.
|
||||
func (h *AgentsHandler) handleGetInstanceFiles(w http.ResponseWriter, r *http.Request) {
|
||||
callerID := 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
|
||||
}
|
||||
instanceUserID := r.PathValue("userID")
|
||||
if instanceUserID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing userID"})
|
||||
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 callerID != "" && ag.OwnerID != callerID && !h.isOwnerUser(callerID) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can view instance files"})
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.agents.GetUserContextFiles(r.Context(), id, instanceUserID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"files": files})
|
||||
}
|
||||
|
||||
// handleSetInstanceFile updates a user context file for a specific instance.
|
||||
func (h *AgentsHandler) handleSetInstanceFile(w http.ResponseWriter, r *http.Request) {
|
||||
callerID := 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
|
||||
}
|
||||
instanceUserID := r.PathValue("userID")
|
||||
fileName := r.PathValue("fileName")
|
||||
if instanceUserID == "" || fileName == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing userID or fileName"})
|
||||
return
|
||||
}
|
||||
|
||||
// Only USER.md can be edited via this endpoint — other files are managed by the agent itself.
|
||||
if fileName != "USER.md" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only USER.md can be edited via this endpoint"})
|
||||
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 callerID != "" && ag.OwnerID != callerID && !h.isOwnerUser(callerID) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can edit instance files"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.agents.SetUserContextFile(r.Context(), id, instanceUserID, fileName, payload.Content); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Invalidate caches so the agent picks up the change immediately
|
||||
h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String())
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
@@ -136,6 +136,21 @@ func (a *AgentData) ParseThinkingLevel() string {
|
||||
return cfg.ThinkingLevel
|
||||
}
|
||||
|
||||
// ParseSelfEvolve extracts self_evolve from other_config JSONB.
|
||||
// When true, predefined agents can update their SOUL.md (style/tone) through chat.
|
||||
func (a *AgentData) ParseSelfEvolve() bool {
|
||||
if len(a.OtherConfig) == 0 {
|
||||
return false
|
||||
}
|
||||
var cfg struct {
|
||||
SelfEvolve bool `json:"self_evolve"`
|
||||
}
|
||||
if json.Unmarshal(a.OtherConfig, &cfg) != nil {
|
||||
return false
|
||||
}
|
||||
return cfg.SelfEvolve
|
||||
}
|
||||
|
||||
// AgentShareData represents an agent share grant.
|
||||
type AgentShareData struct {
|
||||
BaseModel
|
||||
@@ -196,8 +211,9 @@ type AgentStore interface {
|
||||
GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*UserAgentOverrideData, error)
|
||||
SetUserOverride(ctx context.Context, override *UserAgentOverrideData) error
|
||||
|
||||
// User-agent profiles
|
||||
// User-agent profiles + instances
|
||||
GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (isNew bool, effectiveWorkspace string, err error)
|
||||
ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]UserInstanceData, error)
|
||||
|
||||
// Group file writers (allowlist for protected file edits in group chats)
|
||||
IsGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) (bool, error)
|
||||
@@ -207,6 +223,14 @@ type AgentStore interface {
|
||||
ListGroupFileWriterGroups(ctx context.Context, agentID uuid.UUID) ([]GroupWriterGroupInfo, error)
|
||||
}
|
||||
|
||||
// UserInstanceData represents a user instance for a predefined agent.
|
||||
type UserInstanceData struct {
|
||||
UserID string `json:"user_id"`
|
||||
FirstSeenAt *string `json:"first_seen_at,omitempty"`
|
||||
LastSeenAt *string `json:"last_seen_at,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
}
|
||||
|
||||
// GroupFileWriterData represents a group file writer entry.
|
||||
type GroupFileWriterData struct {
|
||||
UserID string `json:"user_id"`
|
||||
|
||||
@@ -19,6 +19,8 @@ const (
|
||||
// In group chats, UserIDKey is group-scoped but SenderIDKey preserves
|
||||
// the actual person who sent the message.
|
||||
SenderIDKey contextKey = "goclaw_sender_id"
|
||||
// SelfEvolveKey indicates whether a predefined agent can update its SOUL.md.
|
||||
SelfEvolveKey contextKey = "goclaw_self_evolve"
|
||||
)
|
||||
|
||||
// WithUserID returns a new context with the given user ID.
|
||||
@@ -72,3 +74,16 @@ func SenderIDFromContext(ctx context.Context) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithSelfEvolve returns a new context with the self-evolve flag.
|
||||
func WithSelfEvolve(ctx context.Context, v bool) context.Context {
|
||||
return context.WithValue(ctx, SelfEvolveKey, v)
|
||||
}
|
||||
|
||||
// SelfEvolveFromContext extracts the self-evolve flag from context. Returns false if not set.
|
||||
func SelfEvolveFromContext(ctx context.Context) bool {
|
||||
if v, ok := ctx.Value(SelfEvolveKey).(bool); ok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -110,6 +110,40 @@ func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid.
|
||||
return isInserted, ws, nil
|
||||
}
|
||||
|
||||
// --- User Instances ---
|
||||
|
||||
func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT p.user_id,
|
||||
TO_CHAR(p.first_seen_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS first_seen_at,
|
||||
TO_CHAR(p.last_seen_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS last_seen_at,
|
||||
COALESCE(fc.cnt, 0) AS file_count
|
||||
FROM user_agent_profiles p
|
||||
LEFT JOIN (
|
||||
SELECT user_id, COUNT(*) AS cnt
|
||||
FROM user_context_files
|
||||
WHERE agent_id = $1
|
||||
GROUP BY user_id
|
||||
) fc ON fc.user_id = p.user_id
|
||||
WHERE p.agent_id = $1
|
||||
ORDER BY p.last_seen_at DESC
|
||||
`, agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.UserInstanceData
|
||||
for rows.Next() {
|
||||
var d store.UserInstanceData
|
||||
if err := rows.Scan(&d.UserID, &d.FirstSeenAt, &d.LastSeenAt, &d.FileCount); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- User Overrides ---
|
||||
|
||||
func (s *PGAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*store.UserAgentOverrideData, error) {
|
||||
|
||||
@@ -287,13 +287,26 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st
|
||||
}
|
||||
|
||||
// Predefined agent: block writes to shared files (only USER.md allowed per-user).
|
||||
// This prevents any user from modifying the agent's identity/behavior via chat.
|
||||
// Exception: SOUL.md is allowed when self_evolve is enabled (style/tone evolution).
|
||||
if agentType == store.AgentTypePredefined && fileName != bootstrap.UserFile {
|
||||
return true, fmt.Errorf(
|
||||
"this file (%s) is part of the agent's predefined configuration and cannot be modified through chat. "+
|
||||
"Only the agent owner can edit it from the management dashboard.",
|
||||
fileName,
|
||||
allowSoulEvolve := fileName == bootstrap.SoulFile && store.SelfEvolveFromContext(ctx)
|
||||
if !allowSoulEvolve {
|
||||
return true, fmt.Errorf(
|
||||
"this file (%s) is part of the agent's predefined configuration and cannot be modified through chat. "+
|
||||
"Only the agent owner can edit it from the management dashboard.",
|
||||
fileName,
|
||||
)
|
||||
}
|
||||
// SOUL.md with self_evolve: write to agent-level (shared across all users)
|
||||
slog.Info("self-evolve: SOUL.md updated",
|
||||
"agent_id", agentID,
|
||||
"user_id", userID,
|
||||
)
|
||||
err := b.agentStore.SetAgentContextFile(ctx, agentID, fileName, content)
|
||||
if err == nil {
|
||||
b.InvalidateAgent(agentID)
|
||||
}
|
||||
return true, err
|
||||
}
|
||||
|
||||
// Open agent: all files per-user
|
||||
@@ -438,6 +451,11 @@ func (b *ContextFileInterceptor) hasBootstrapFile(ctx context.Context, agentID u
|
||||
return err == nil && content != ""
|
||||
}
|
||||
|
||||
// InvalidateUser clears the per-user cache for a specific agent+user combination.
|
||||
func (b *ContextFileInterceptor) InvalidateUser(agentID uuid.UUID, userID string) {
|
||||
b.invalidateUser(agentID, userID)
|
||||
}
|
||||
|
||||
func (b *ContextFileInterceptor) invalidateUser(agentID uuid.UUID, userID string) {
|
||||
b.userCache.Delete(context.Background(), agentID.String()+":"+userID)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ func (s *stubAgentStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _
|
||||
func (s *stubAgentStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAgentStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const queryKeys = {
|
||||
detail: (id: string) => ["agents", id] as const,
|
||||
files: (agentKey: string) => ["agents", agentKey, "files"] as const,
|
||||
links: (agentId: string) => ["agents", agentId, "links"] as const,
|
||||
instances: (agentId: string) => ["agents", agentId, "instances"] as const,
|
||||
},
|
||||
sessions: {
|
||||
all: ["sessions"] as const,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Bot, Star, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { Bot, Star, RotateCcw, Trash2, Sparkles } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
|
||||
interface AgentCardProps {
|
||||
@@ -15,6 +16,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
export function AgentCard({ agent, onClick, onResummon, onDelete }: AgentCardProps) {
|
||||
const displayName = agent.display_name
|
||||
|| (UUID_RE.test(agent.agent_key) ? "Unnamed Agent" : agent.agent_key);
|
||||
const selfEvolve = agent.agent_type === "predefined" && Boolean((agent.other_config as Record<string, unknown> | null)?.self_evolve);
|
||||
|
||||
// Show agent_key as subtitle only if there's a display_name and agent_key is meaningful
|
||||
const showSubtitle = agent.display_name && !UUID_RE.test(agent.agent_key);
|
||||
@@ -72,7 +74,34 @@ export function AgentCard({ agent, onClick, onResummon, onDelete }: AgentCardPro
|
||||
|
||||
{/* Bottom badges */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-[11px]">{agent.agent_type}</Badge>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="text-[11px]">{agent.agent_type}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[260px] text-xs">
|
||||
{agent.agent_type === "predefined"
|
||||
? "Shared context files (SOUL.md, IDENTITY.md, AGENTS.md) with per-user USER.md instances."
|
||||
: "Each user gets their own full set of context files."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{agent.agent_type === "predefined" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={selfEvolve ? "default" : "outline"}
|
||||
className={`text-[11px] ${selfEvolve ? "bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-900/30 dark:text-violet-300" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Sparkles className="mr-0.5 h-3 w-3" />
|
||||
{selfEvolve ? "Evolving" : "Static"}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px] text-xs">
|
||||
{selfEvolve
|
||||
? "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed."
|
||||
: "Agent style is static. Enable Self-Evolution in settings to allow style adaptation."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{agent.context_window > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{(agent.context_window / 1000).toFixed(0)}K ctx
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -40,6 +41,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
const [model, setModel] = useState("");
|
||||
const [agentType, setAgentType] = useState<"open" | "predefined">("open");
|
||||
const [description, setDescription] = useState("");
|
||||
const [selfEvolve, setSelfEvolve] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -75,13 +77,16 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const otherConfig: Record<string, unknown> = {};
|
||||
if (description.trim()) otherConfig.description = description.trim();
|
||||
if (selfEvolve) otherConfig.self_evolve = true;
|
||||
await onCreate({
|
||||
agent_key: agentKey.trim(),
|
||||
display_name: displayName.trim() || undefined,
|
||||
provider: provider.trim(),
|
||||
model: model.trim(),
|
||||
agent_type: agentType,
|
||||
other_config: description.trim() ? { description: description.trim() } : undefined,
|
||||
other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
setAgentKey("");
|
||||
@@ -91,6 +96,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
setModel("");
|
||||
setAgentType("open");
|
||||
setDescription("");
|
||||
setSelfEvolve(false);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create agent");
|
||||
@@ -252,6 +258,13 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
|
||||
AI will automatically generate your agent's context files from this description.
|
||||
Leave empty to start with templates.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="create-self-evolve" className="text-sm font-normal">Self-Evolution</Label>
|
||||
<p className="text-xs text-muted-foreground">Allow agent to evolve its style and tone over time via SOUL.md</p>
|
||||
</div>
|
||||
<Switch id="create-self-evolve" checked={selfEvolve} onCheckedChange={setSelfEvolve} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
|
||||
@@ -2,7 +2,8 @@ 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";
|
||||
import { ArrowLeft, Bot, Star, Trash2 } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ArrowLeft, Bot, Star, Trash2, Sparkles } from "lucide-react";
|
||||
import { useAgentDetail } from "../hooks/use-agent-detail";
|
||||
import { useAgents } from "../hooks/use-agents";
|
||||
import { AgentGeneralTab } from "./agent-general-tab";
|
||||
@@ -11,6 +12,7 @@ import { AgentFilesTab } from "./agent-files-tab";
|
||||
import { AgentSharesTab } from "./agent-shares-tab";
|
||||
import { AgentLinksTab } from "./agent-links-tab";
|
||||
import { AgentSkillsTab } from "./agent-skills-tab";
|
||||
import { AgentInstancesTab } from "./agent-instances-tab";
|
||||
import { SummoningModal } from "../summoning-modal";
|
||||
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
|
||||
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
|
||||
@@ -68,6 +70,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
const subtitle = agentSubtitle(agent);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start gap-4">
|
||||
@@ -94,7 +97,37 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
<span className="text-border">|</span>
|
||||
</>
|
||||
)}
|
||||
<Badge variant="outline" className="text-[11px]">{agent.agent_type}</Badge>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="text-[11px]">{agent.agent_type}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[260px] text-xs">
|
||||
{agent.agent_type === "predefined"
|
||||
? "Shared context files (SOUL.md, IDENTITY.md, AGENTS.md) with per-user USER.md instances."
|
||||
: "Each user gets their own full set of context files."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{agent.agent_type === "predefined" && (() => {
|
||||
const evolving = Boolean((agent.other_config as Record<string, unknown> | null)?.self_evolve);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={evolving ? "default" : "outline"}
|
||||
className={`text-[11px] ${evolving ? "bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-900/30 dark:text-violet-300" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Sparkles className="mr-0.5 h-3 w-3" />
|
||||
{evolving ? "Evolving" : "Static"}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[240px] text-xs">
|
||||
{evolving
|
||||
? "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed."
|
||||
: "Agent style is static. Enable Self-Evolution in General tab to allow style adaptation."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
{agent.provider && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
@@ -123,6 +156,9 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
<TabsTrigger value="shares">Shares</TabsTrigger>
|
||||
<TabsTrigger value="links">Links</TabsTrigger>
|
||||
<TabsTrigger value="skills">Skills</TabsTrigger>
|
||||
{agent.agent_type === "predefined" && (
|
||||
<TabsTrigger value="instances">Instances</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="mt-4">
|
||||
@@ -156,6 +192,12 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
<AgentSkillsTab agentId={agentId} />
|
||||
</TabsContent>
|
||||
|
||||
{agent.agent_type === "predefined" && (
|
||||
<TabsContent value="instances" className="mt-4">
|
||||
<AgentInstancesTab agentId={agentId} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -182,5 +224,6 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Save, Check, AlertCircle } from "lucide-react";
|
||||
import { Save, Check, AlertCircle, Sparkles, Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
import { IdentitySection, LlmConfigSection, WorkspaceSection } from "./general-sections";
|
||||
|
||||
@@ -27,6 +29,10 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
// Workspace
|
||||
const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace);
|
||||
|
||||
// Self-evolve (predefined agents only)
|
||||
const otherCfg = (agent.other_config ?? {}) as Record<string, unknown>;
|
||||
const [selfEvolve, setSelfEvolve] = useState(Boolean(otherCfg.self_evolve));
|
||||
|
||||
// Save state
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
@@ -41,6 +47,8 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
setSaveError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
// Merge self_evolve into other_config without losing other keys
|
||||
const updatedOtherConfig = { ...otherCfg, self_evolve: selfEvolve };
|
||||
await onUpdate({
|
||||
display_name: displayName,
|
||||
frontmatter: frontmatter || null,
|
||||
@@ -51,6 +59,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
restrict_to_workspace: restrictToWorkspace,
|
||||
status,
|
||||
is_default: isDefault,
|
||||
other_config: updatedOtherConfig,
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
@@ -99,6 +108,40 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
onRestrictChange={setRestrictToWorkspace}
|
||||
/>
|
||||
|
||||
{/* Self-Evolve (predefined agents only) */}
|
||||
{agent.agent_type === "predefined" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="h-4 w-4 text-violet-500" />
|
||||
<h3 className="text-sm font-medium">Self-Evolution</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="self-evolve" className="text-sm font-normal">
|
||||
Allow agent to evolve its communication style
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the agent can update its SOUL.md to refine tone, vocabulary, and response style based on interactions. Identity, name, and operating instructions remain locked.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="self-evolve"
|
||||
checked={selfEvolve}
|
||||
onCheckedChange={setSelfEvolve}
|
||||
/>
|
||||
</div>
|
||||
{selfEvolve && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-violet-200 bg-violet-50 px-3 py-2 text-xs text-violet-700 dark:border-violet-800 dark:bg-violet-950/30 dark:text-violet-300">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>Agent will evolve its style over time through SOUL.md updates. Only style and tone are affected — identity and workflow rules stay fixed.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save */}
|
||||
{saveError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Save, Check, AlertCircle, Users, FileText } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAgentInstances, type UserInstance } from "../hooks/use-agent-instances";
|
||||
|
||||
interface AgentInstancesTabProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
|
||||
const { instances, loading, saving, getFiles, setFile } = useAgentInstances(agentId);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [originalContent, setOriginalContent] = useState("");
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load USER.md when an instance is selected
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
let cancelled = false;
|
||||
setLoadingFiles(true);
|
||||
setError(null);
|
||||
getFiles(selected).then((files) => {
|
||||
if (cancelled) return;
|
||||
const userFile = files.find((f) => f.file_name === "USER.md");
|
||||
const c = userFile?.content ?? "";
|
||||
setContent(c);
|
||||
setOriginalContent(c);
|
||||
}).catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load files");
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoadingFiles(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [selected, getFiles]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selected) return;
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
await setFile(selected, "USER.md", content);
|
||||
setOriginalContent(content);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save");
|
||||
}
|
||||
};
|
||||
|
||||
const isDirty = content !== originalContent;
|
||||
|
||||
if (loading) {
|
||||
return <div className="py-8 text-center text-sm text-muted-foreground">Loading instances...</div>;
|
||||
}
|
||||
|
||||
if (instances.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-12 text-center">
|
||||
<Users className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No user instances yet.</p>
|
||||
<p className="text-xs text-muted-foreground/70">Instances are created when users interact with this agent.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4" style={{ minHeight: 400 }}>
|
||||
{/* Instance list */}
|
||||
<div className="w-64 shrink-0 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
<div className="px-2 pb-2 text-xs font-medium text-muted-foreground">
|
||||
{instances.length} instance{instances.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
{instances.map((inst) => (
|
||||
<InstanceRow
|
||||
key={inst.user_id}
|
||||
instance={inst}
|
||||
isSelected={selected === inst.user_id}
|
||||
onClick={() => setSelected(inst.user_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
{!selected ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
Select an instance to view and edit its USER.md
|
||||
</div>
|
||||
) : loadingFiles ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">USER.md</span>
|
||||
<span className="text-xs text-muted-foreground">— {selected}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
className="flex-1 font-mono text-sm"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="(empty)"
|
||||
style={{ minHeight: 300 }}
|
||||
/>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-sm text-success">
|
||||
<Check className="h-3.5 w-3.5" /> Saved
|
||||
</span>
|
||||
)}
|
||||
<Button onClick={handleSave} disabled={saving || !isDirty} size="sm">
|
||||
{!saving && <Save className="h-4 w-4" />}
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceRow({ instance, isSelected, onClick }: { instance: UserInstance; isSelected: boolean; onClick: () => void }) {
|
||||
const lastSeen = instance.last_seen_at ? formatRelative(instance.last_seen_at) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex w-full flex-col gap-0.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate font-mono text-xs">{instance.user_id}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{instance.file_count > 0 && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{instance.file_count} file{instance.file_count !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
{lastSeen && (
|
||||
<span className="text-[10px] text-muted-foreground">{lastSeen}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diff = now - d.getTime();
|
||||
if (diff < 60_000) return "just now";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Pagination } from "@/components/shared/pagination";
|
||||
import { CardSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
||||
import { useAgents } from "./hooks/use-agents";
|
||||
import { AgentCard } from "./agent-card";
|
||||
@@ -98,26 +99,28 @@ export function AgentsPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pageItems.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onClick={() => {
|
||||
if (agent.status === "summoning") {
|
||||
setSummoningAgent({
|
||||
id: agent.id,
|
||||
name: agent.display_name || agent.agent_key,
|
||||
});
|
||||
} else {
|
||||
navigate(`/agents/${agent.id}`);
|
||||
}
|
||||
}}
|
||||
onResummon={() => handleResummon(agent)}
|
||||
onDelete={() => setDeleteTarget(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pageItems.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onClick={() => {
|
||||
if (agent.status === "summoning") {
|
||||
setSummoningAgent({
|
||||
id: agent.id,
|
||||
name: agent.display_name || agent.agent_key,
|
||||
});
|
||||
} else {
|
||||
navigate(`/agents/${agent.id}`);
|
||||
}
|
||||
}}
|
||||
onResummon={() => handleResummon(agent)}
|
||||
onDelete={() => setDeleteTarget(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface UserInstance {
|
||||
user_id: string;
|
||||
first_seen_at?: string;
|
||||
last_seen_at?: string;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
export interface UserContextFile {
|
||||
agent_id: string;
|
||||
user_id: string;
|
||||
file_name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function useAgentInstances(agentId: string) {
|
||||
const http = useHttp();
|
||||
const queryClient = useQueryClient();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.agents.instances(agentId),
|
||||
queryFn: async () => {
|
||||
const res = await http.get<{ instances: UserInstance[] }>(`/v1/agents/${agentId}/instances`);
|
||||
return res.instances ?? [];
|
||||
},
|
||||
enabled: !!agentId,
|
||||
});
|
||||
|
||||
const getFiles = useCallback(
|
||||
async (userID: string): Promise<UserContextFile[]> => {
|
||||
const res = await http.get<{ files: UserContextFile[] }>(
|
||||
`/v1/agents/${agentId}/instances/${encodeURIComponent(userID)}/files`,
|
||||
);
|
||||
return res.files ?? [];
|
||||
},
|
||||
[agentId, http],
|
||||
);
|
||||
|
||||
const setFile = useCallback(
|
||||
async (userID: string, fileName: string, content: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await http.put(
|
||||
`/v1/agents/${agentId}/instances/${encodeURIComponent(userID)}/files/${encodeURIComponent(fileName)}`,
|
||||
{ content },
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instances(agentId) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[agentId, http, queryClient],
|
||||
);
|
||||
|
||||
return {
|
||||
instances: data ?? [],
|
||||
loading: isLoading,
|
||||
saving,
|
||||
getFiles,
|
||||
setFile,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { InfoTip } from "@/pages/setup/info-tip";
|
||||
@@ -30,6 +31,7 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
|
||||
const [agentKey, setAgentKey] = useState("goclaw");
|
||||
const [keyTouched, setKeyTouched] = useState(false);
|
||||
const [description, setDescription] = useState(DEFAULT_PROMPT);
|
||||
const [selfEvolve, setSelfEvolve] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -76,6 +78,9 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const otherConfig: Record<string, unknown> = {};
|
||||
if (description.trim()) otherConfig.description = description.trim();
|
||||
if (selfEvolve) otherConfig.self_evolve = true;
|
||||
const data: Partial<AgentData> = {
|
||||
agent_key: agentKey.trim(),
|
||||
display_name: displayName.trim() || undefined,
|
||||
@@ -83,7 +88,7 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
|
||||
model: model || "",
|
||||
agent_type: "predefined",
|
||||
is_default: true,
|
||||
other_config: description.trim() ? { description: description.trim() } : undefined,
|
||||
other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
|
||||
};
|
||||
|
||||
const result = await createAgent(data) as AgentData;
|
||||
@@ -196,6 +201,13 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Customize this prompt to shape your agent's personality and expertise.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="setup-self-evolve" className="text-sm font-normal">Self-Evolution</Label>
|
||||
<p className="text-xs text-muted-foreground">Allow agent to evolve its communication style over time via SOUL.md</p>
|
||||
</div>
|
||||
<Switch id="setup-self-evolve" checked={selfEvolve} onCheckedChange={setSelfEvolve} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
Reference in new issue
Block a user