feat: Introduce agent summoning flow with a dedicated modal and updated bootstrap process for predefined agents.

This commit is contained in:
viettranx committed 2026-02-24 10:19:49 +07:00
1 parent abaaf8fc83
commit 08ced252b2
35 files changed
+1142 -147

No files matched your search

+3 -1
View File
@@ -34,4 +34,6 @@ ui/web/pnpm-lock.yaml
tmp
.claude/
deploy-*.sh
deploy-*.sh
docs/technical-diaries
+10 -15
View File
@@ -557,7 +557,7 @@ func runGateway() {
}
wireManagedExtras(managedStores, agentRouter, providerRegistry, msgBus, sessStore, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader)
agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH := wireManagedHTTP(managedStores, cfg.Gateway.Token, msgBus, toolsReg)
agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH := wireManagedHTTP(managedStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry)
if agentsH != nil {
server.SetAgentsHandler(agentsH)
}
@@ -614,7 +614,6 @@ func runGateway() {
// Managed mode: load channel instances from DB first.
var instanceLoader *channels.InstanceLoader
var loadedNames map[string]struct{}
if managedStores != nil && managedStores.ChannelInstances != nil {
instanceLoader = channels.NewInstanceLoader(managedStores.ChannelInstances, managedStores.Agents, channelMgr, msgBus, pairingStore)
instanceLoader.RegisterFactory("telegram", telegram.FactoryWithAgentStore(managedStores.Agents))
@@ -625,21 +624,17 @@ func runGateway() {
if err := instanceLoader.LoadAll(context.Background()); err != nil {
slog.Error("failed to load channel instances from DB", "error", err)
}
loadedNames = instanceLoader.LoadedNames()
}
// Register config-based channels as fallback.
// Telegram: kept for backward compat (only channel with production data).
// Other channels: skip config registration in managed mode if any DB instances loaded.
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" {
if _, loaded := loadedNames["telegram"]; !loaded {
tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil)
if err != nil {
slog.Error("failed to initialize telegram channel", "error", err)
} else {
channelMgr.RegisterChannel("telegram", tg)
slog.Info("telegram channel enabled (config)")
}
// Register config-based channels as fallback (standalone mode only).
// In managed mode, channels are loaded from DB via instanceLoader — skip config-based registration.
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" && instanceLoader == nil {
tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil)
if err != nil {
slog.Error("failed to initialize telegram channel", "error", err)
} else {
channelMgr.RegisterChannel("telegram", tg)
slog.Info("telegram channel enabled (config)")
}
}
+6 -2
View File
@@ -271,7 +271,7 @@ func wireManagedExtras(
}
// wireManagedHTTP creates managed-mode HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers).
func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler) {
func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler) {
var agentsH *httpapi.AgentsHandler
var skillsH *httpapi.SkillsHandler
var tracesH *httpapi.TracesHandler
@@ -281,7 +281,11 @@ func wireManagedHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus,
var providersH *httpapi.ProvidersHandler
if stores != nil && stores.Agents != nil {
agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus)
var summoner *httpapi.AgentSummoner
if providerReg != nil {
summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus)
}
agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus, summoner)
}
if stores != nil && stores.Skills != nil {
+27
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
@@ -323,6 +325,17 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
ctx = store.WithSenderID(ctx, req.SenderID)
}
// Per-user workspace isolation (managed mode only).
// Each user gets a subdirectory within the agent's workspace.
if l.agentUUID != uuid.Nil && l.workspace != "" {
effectiveWorkspace := l.workspace
if req.UserID != "" {
effectiveWorkspace = filepath.Join(l.workspace, sanitizePathSegment(req.UserID))
os.MkdirAll(effectiveWorkspace, 0755)
}
ctx = tools.WithToolWorkspace(ctx, effectiveWorkspace)
}
// Ensure per-user context files exist (first-chat seeding, managed mode)
if l.ensureUserFiles != nil && req.UserID != "" {
if _, loaded := l.seededUsers.LoadOrStore(req.UserID, true); !loaded {
@@ -653,3 +666,17 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
Usage: &totalUsage,
}, nil
}
// sanitizePathSegment makes a userID safe for use as a directory name.
// Replaces colons, spaces, and other unsafe chars with underscores.
func sanitizePathSegment(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}
+9 -1
View File
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"log/slog"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
@@ -22,10 +24,16 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
_, hasSpawn := l.tools.Get("spawn")
_, hasSkillSearch := l.tools.Get("skill_search")
// Per-user workspace: show the user's subdirectory in the system prompt (managed mode)
promptWorkspace := l.workspace
if l.agentUUID != uuid.Nil && userID != "" && l.workspace != "" {
promptWorkspace = filepath.Join(l.workspace, sanitizePathSegment(userID))
}
systemPrompt := BuildSystemPrompt(SystemPromptConfig{
AgentID: l.id,
Model: l.model,
Workspace: l.workspace,
Workspace: promptWorkspace,
Channel: channel,
OwnerIDs: l.ownerIDs,
Mode: mode,
+13 -1
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
@@ -113,6 +115,16 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
sandboxWorkspaceAccess = string(resolved.WorkspaceAccess)
}
// Expand ~ in workspace path and ensure directory exists
workspace := ag.Workspace
if workspace != "" {
workspace = config.ExpandHome(workspace)
if !filepath.IsAbs(workspace) {
workspace, _ = filepath.Abs(workspace)
}
os.MkdirAll(workspace, 0755)
}
// Per-agent custom tools (clone registry if agent has custom tools)
toolsReg := deps.Tools
if deps.DynamicLoader != nil {
@@ -131,7 +143,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
Model: ag.Model,
ContextWindow: contextWindow,
MaxIterations: maxIter,
Workspace: ag.Workspace,
Workspace: workspace,
Bus: deps.Bus,
Sessions: deps.Sessions,
Tools: toolsReg,
+2 -3
View File
@@ -83,9 +83,8 @@ func BuildSystemPrompt(cfg SystemPromptConfig) string {
lines = append(lines,
"## FIRST RUN — MANDATORY",
"",
"BOOTSTRAP.md is loaded below in Project Context. This is your FIRST TIME running.",
"You MUST follow BOOTSTRAP.md instructions: introduce yourself, ask who the user is,",
"figure out your name/creature/vibe/emoji together, then update IDENTITY.md and USER.md.",
"BOOTSTRAP.md is loaded below in Project Context. This is your FIRST interaction with this user.",
"You MUST follow BOOTSTRAP.md instructions immediately.",
"Do NOT give a generic greeting. Do NOT ignore this. Read BOOTSTRAP.md and follow it NOW.",
"",
)
+15 -2
View File
@@ -36,6 +36,11 @@ func SeedToStore(ctx context.Context, agentStore store.AgentStore, agentID uuid.
var seeded []string
for _, name := range templateFiles {
// USER.md is always per-user for predefined agents — don't seed at agent level.
// Having it at agent level alongside the user copy can confuse the model.
if name == UserFile {
continue
}
if hasContent[name] {
continue
}
@@ -71,13 +76,15 @@ var userSeedFilesOpen = []string{
}
// userSeedFilesPredefined is the set of files seeded per-user for predefined agents.
// Includes BOOTSTRAP.md for user onboarding (uses BOOTSTRAP_PREDEFINED.md template).
var userSeedFilesPredefined = []string{
UserFile,
BootstrapFile,
}
// SeedUserFiles seeds embedded templates into user_context_files for a new user.
// For "open" agents: all 7 files (including BOOTSTRAP.md).
// For "predefined" agents: only USER.md.
// For "predefined" agents: USER.md + BOOTSTRAP.md (user-focused onboarding template).
// Only writes files that don't already exist — safe to call multiple times.
// Returns the list of file names that were seeded.
func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uuid.UUID, userID, agentType string) ([]string, error) {
@@ -104,7 +111,13 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui
continue // already has content, don't overwrite
}
content, err := templateFS.ReadFile(filepath.Join("templates", name))
// Predefined agents use a user-focused bootstrap template
templateName := name
if agentType == "predefined" && name == BootstrapFile {
templateName = "BOOTSTRAP_PREDEFINED.md"
}
content, err := templateFS.ReadFile(filepath.Join("templates", templateName))
if err != nil {
slog.Warn("bootstrap: failed to read embedded template for user seed", "file", name, "error", err)
continue
@@ -0,0 +1,44 @@
# BOOTSTRAP.md - Welcome, New User
_A new user just started chatting with you. Time to get to know them._
## The Conversation
Don't interrogate. Don't be robotic. Just... talk.
Start by introducing yourself briefly — your name, what you do, how you can help.
Then get to know them naturally:
1. **Their name** — What should you call them?
2. **Their language** — What language do they prefer? (Switch to it if needed)
3. **Their timezone** — Where are they? (Helps with scheduling and context)
4. **Their needs** — What are they hoping you can help with?
Keep it conversational. One or two questions at a time, not a form.
Match the user's tone and language — if they're casual, be casual back.
## After You Know Them
Update `USER.md` immediately with what you learned. Use the `write_file` tool NOW:
```
write_file("USER.md", "# USER.md - About Your Human\n\n- **Name:** (their name)\n- **What to call them:** (how they want to be addressed)\n- **Pronouns:** (if shared)\n- **Timezone:** (their timezone)\n- **Language:** (their preferred language)\n- **Notes:** (anything else you learned)\n")
```
Do NOT skip this step. Do NOT just say you noted it — actually call `write_file` to save it.
## When You're Done
Mark bootstrap as complete by writing empty content to this file:
```
write_file("BOOTSTRAP.md", "")
```
Do NOT use `rm` or `exec` to delete it. The empty write signals the system that onboarding is finished.
You MUST call both `write_file` calls above before moving on to normal conversation.
---
_Make a good first impression._
+100 -9
View File
@@ -2,6 +2,7 @@ package http
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
@@ -15,14 +16,15 @@ import (
// AgentsHandler handles agent CRUD and sharing endpoints (managed mode only).
type AgentsHandler struct {
agents store.AgentStore
token string
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
agents store.AgentStore
token string
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
}
// NewAgentsHandler creates a handler for agent management endpoints.
func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus) *AgentsHandler {
return &AgentsHandler{agents: agents, token: token, msgBus: msgBus}
func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus, summoner *AgentSummoner) *AgentsHandler {
return &AgentsHandler{agents: agents, token: token, msgBus: msgBus, summoner: summoner}
}
// emitCacheInvalidate broadcasts a cache invalidation event if msgBus is set.
@@ -46,6 +48,7 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/agents/{id}/shares", h.authMiddleware(h.handleListShares))
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))
}
func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
@@ -101,23 +104,48 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
}
req.OwnerID = userID
if req.Status == "" {
req.Status = "active"
}
if req.AgentType == "" {
req.AgentType = "open"
}
if req.ContextWindow <= 0 {
req.ContextWindow = 200000
}
if req.MaxToolIterations <= 0 {
req.MaxToolIterations = 20
}
if req.Workspace == "" {
if req.IsDefault {
req.Workspace = "~/.goclaw/workspace"
} else {
req.Workspace = fmt.Sprintf("~/.goclaw/%s-workspace", req.AgentKey)
}
}
req.RestrictToWorkspace = true
// Check if predefined agent has a description for LLM summoning
description := extractDescription(req.OtherConfig)
if req.AgentType == "predefined" && description != "" && h.summoner != nil {
req.Status = "summoning"
} else if req.Status == "" {
req.Status = "active"
}
if err := h.agents.Create(r.Context(), &req); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Seed context files into agent_context_files (skipped for open agents)
// Seed context files into agent_context_files (skipped for open agents).
// For summoning agents, templates serve as fallback if LLM fails.
if _, err := bootstrap.SeedToStore(r.Context(), h.agents, req.ID, req.AgentType); err != nil {
slog.Warn("failed to seed context files for new agent", "agent", req.AgentKey, "error", err)
}
// Start LLM summoning in background if applicable
if req.Status == "summoning" {
go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description)
}
writeJSON(w, http.StatusCreated, req)
}
@@ -336,6 +364,69 @@ func (h *AgentsHandler) handleRevokeShare(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}
func (h *AgentsHandler) handleRegenerate(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
}
// Only owner can regenerate
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 regenerate 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
}
var req struct {
Prompt string `json:"prompt"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
if req.Prompt == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "prompt is required"})
return
}
// Set status to summoning
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.RegenerateAgent(id, ag.Provider, ag.Model, req.Prompt)
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 {
return ""
}
var cfg map[string]interface{}
if json.Unmarshal(raw, &cfg) != nil {
return ""
}
desc, _ := cfg["description"].(string)
return desc
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
+263
View File
@@ -0,0 +1,263 @@
package http
import (
"context"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// summoningFiles is the ordered list of context files the LLM should generate.
var summoningFiles = []string{
"SOUL.md",
"IDENTITY.md",
"AGENTS.md",
"TOOLS.md",
"HEARTBEAT.md",
}
// fileTagRe parses <file name="SOUL.md">content</file> from LLM output.
var fileTagRe = regexp.MustCompile(`(?s)<file\s+name="([^"]+)">\s*(.*?)\s*</file>`)
// AgentSummoner generates context files for predefined agents using an LLM.
// Runs one-shot background calls — no session data, no agent loop.
type AgentSummoner struct {
agents store.AgentStore
providerReg *providers.Registry
msgBus *bus.MessageBus
}
// NewAgentSummoner creates a summoner backed by the given stores and provider registry.
func NewAgentSummoner(agents store.AgentStore, providerReg *providers.Registry, msgBus *bus.MessageBus) *AgentSummoner {
return &AgentSummoner{
agents: agents,
providerReg: providerReg,
msgBus: msgBus,
}
}
// 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".
func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, description string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
s.emitEvent(agentID, "started", "", "")
files, err := s.generateFiles(ctx, providerName, model, s.buildCreatePrompt(description))
if err != nil {
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")
return
}
s.storeFiles(ctx, agentID, files)
s.setAgentStatus(ctx, agentID, "active")
s.emitEvent(agentID, "completed", "", "")
slog.Info("summoning: completed", "agent", agentID, "files", len(files))
}
// RegenerateAgent updates context files based on an edit prompt.
// 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)
defer cancel()
s.emitEvent(agentID, "started", "", "")
// Read existing files for context
existing, err := s.agents.GetAgentContextFiles(ctx, agentID)
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")
return
}
prompt := s.buildEditPrompt(existing, editPrompt)
files, err := s.generateFiles(ctx, providerName, model, prompt)
if err != nil {
slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err)
s.emitEvent(agentID, "failed", "", err.Error())
s.setAgentStatus(ctx, agentID, "active")
return
}
s.storeFiles(ctx, agentID, files)
s.setAgentStatus(ctx, agentID, "active")
s.emitEvent(agentID, "completed", "", "")
slog.Info("summoning: regeneration completed", "agent", agentID, "files", len(files))
}
// generateFiles calls the LLM and parses the XML-tagged response into file map.
func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, prompt string) (map[string]string, error) {
provider, err := s.resolveProvider(providerName)
if err != nil {
return nil, fmt.Errorf("resolve provider: %w", err)
}
resp, err := provider.Chat(ctx, providers.ChatRequest{
Messages: []providers.Message{
{Role: "user", Content: prompt},
},
Model: model,
Options: map[string]interface{}{
"max_tokens": 8192,
"temperature": 0.7,
},
})
if err != nil {
return nil, fmt.Errorf("LLM call: %w", err)
}
files := parseFileResponse(resp.Content)
if len(files) == 0 {
return nil, fmt.Errorf("LLM returned no parseable files (response length: %d)", len(resp.Content))
}
return files, nil
}
// storeFiles saves generated files to agent_context_files and emits progress events.
func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files map[string]string) {
for _, name := range summoningFiles {
content, ok := files[name]
if !ok || content == "" {
continue
}
if err := s.agents.SetAgentContextFile(ctx, agentID, name, content); err != nil {
slog.Warn("summoning: failed to store file", "agent", agentID, "file", name, "error", err)
continue
}
s.emitEvent(agentID, "file_generated", name, "")
}
}
func (s *AgentSummoner) resolveProvider(name string) (providers.Provider, error) {
if s.providerReg == nil {
return nil, fmt.Errorf("no provider registry")
}
provider, err := s.providerReg.Get(name)
if err != nil {
// Fallback to first available provider
names := s.providerReg.List()
if len(names) == 0 {
return nil, fmt.Errorf("no providers configured")
}
provider, err = s.providerReg.Get(names[0])
if err != nil {
return nil, err
}
slog.Warn("summoning: provider not found, using fallback", "wanted", name, "using", names[0])
}
return provider, nil
}
func (s *AgentSummoner) setAgentStatus(ctx context.Context, agentID uuid.UUID, status string) {
if err := s.agents.Update(ctx, agentID, map[string]any{"status": status}); err != nil {
slog.Warn("summoning: failed to update agent status", "agent", agentID, "status", status, "error", err)
}
}
func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg string) {
if s.msgBus == nil {
return
}
payload := map[string]interface{}{
"type": eventType,
"agent_id": agentID.String(),
}
if fileName != "" {
payload["file"] = fileName
}
if errMsg != "" {
payload["error"] = errMsg
}
s.msgBus.Broadcast(bus.Event{
Name: protocol.EventAgentSummoning,
Payload: payload,
})
}
// buildCreatePrompt constructs the system + user prompt for initial file generation.
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.
<description>
%s
</description>
Generate each file inside XML tags. Follow the format and spirit of each file type:
- **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.
<file name="SOUL.md">
(generate here)
</file>
<file name="IDENTITY.md">
(generate here)
</file>
<file name="AGENTS.md">
(generate here)
</file>
<file name="TOOLS.md">
(generate here)
</file>
<file name="HEARTBEAT.md">
(generate here)
</file>`, description)
}
// buildEditPrompt constructs the prompt for editing existing files.
func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, editPrompt string) string {
var sb strings.Builder
sb.WriteString("You are updating an existing AI assistant's configuration files.\n\nHere are the current files:\n\n<current_files>\n")
for _, f := range existing {
if f.Content == "" {
continue
}
fmt.Fprintf(&sb, "<file name=%q>\n%s\n</file>\n", f.FileName, f.Content)
}
sb.WriteString("</current_files>\n\n")
fmt.Fprintf(&sb, "<edit_instructions>\n%s\n</edit_instructions>\n\n", editPrompt)
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")
return sb.String()
}
// parseFileResponse extracts file contents from XML-tagged LLM output.
func parseFileResponse(content string) map[string]string {
files := make(map[string]string)
matches := fileTagRe.FindAllStringSubmatch(content, -1)
for _, m := range matches {
name := strings.TrimSpace(m[1])
body := strings.TrimSpace(m[2])
if name != "" && body != "" {
files[name] = body
}
}
return files
}
+13 -2
View File
@@ -3,6 +3,7 @@ package pg
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"time"
@@ -144,8 +145,18 @@ func (s *PGChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelI
func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error {
// Encrypt credentials if present
if credsVal, ok := updates["credentials"]; ok {
if credsStr, isStr := credsVal.(string); isStr && credsStr != "" && s.encKey != "" {
if credsVal, ok := updates["credentials"]; ok && credsVal != nil {
var credsStr string
switch v := credsVal.(type) {
case string:
credsStr = v
default:
// Object/map from JSON — marshal to string for encryption
if b, err := json.Marshal(v); err == nil {
credsStr = string(b)
}
}
if credsStr != "" && s.encKey != "" {
encrypted, err := crypto.Encrypt(credsStr, s.encKey)
if err != nil {
return fmt.Errorf("encrypt credentials: %w", err)
+32 -4
View File
@@ -101,7 +101,7 @@ func NewContextFileInterceptor(as store.AgentStore, workspace string) *ContextFi
// ReadFile attempts to read a context file from the DB (with cache).
// Routes based on agent type from context:
// - "open": all files per-user → fallback to agent-level
// - "predefined": only USER.md per-user → all others agent-level
// - "predefined": USER.md + BOOTSTRAP.md per-user → all others agent-level
//
// Returns (content, true, nil) if handled, or ("", false, nil) if not a context file.
func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (string, bool, error) {
@@ -131,8 +131,8 @@ func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (str
return b.readAgentFile(ctx, agentID, fileName)
}
// Predefined agent: only USER.md per-user
if agentType == "predefined" && userID != "" && fileName == "USER.md" {
// Predefined agent: USER.md and BOOTSTRAP.md per-user
if agentType == "predefined" && userID != "" && (fileName == "USER.md" || fileName == "BOOTSTRAP.md") {
content, handled, err := b.readUserFile(ctx, agentID, userID, fileName)
if err != nil {
return "", handled, err
@@ -262,7 +262,9 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st
// senderID empty = system context (cron, subagent) → fail open
}
// BOOTSTRAP.md deletion: empty content = first-run completed → delete row
// BOOTSTRAP.md deletion: empty content = first-run completed → delete row.
// Must come BEFORE the predefined write block so bootstrap completion works
// for both open and predefined agents.
if fileName == "BOOTSTRAP.md" && content == "" && userID != "" {
err := b.agentStore.DeleteUserContextFile(ctx, agentID, userID, fileName)
if err == nil {
@@ -271,6 +273,16 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st
return true, err
}
// 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.
if agentType == "predefined" && fileName != "USER.md" {
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,
)
}
// Open agent: all files per-user
if agentType == "open" && userID != "" {
err := b.agentStore.SetUserContextFile(ctx, agentID, userID, fileName, content)
@@ -353,6 +365,22 @@ func (b *ContextFileInterceptor) LoadContextFiles(ctx context.Context, agentID u
Content: content,
})
}
// Include user-only files not present at agent level
// (e.g. BOOTSTRAP.md — seeded per-user for onboarding, not at agent level)
agentFileSet := make(map[string]bool, len(agentFiles))
for _, f := range agentFiles {
agentFileSet[f.FileName] = true
}
for _, f := range userFiles {
if !agentFileSet[f.FileName] && f.Content != "" {
result = append(result, bootstrap.ContextFile{
Path: f.FileName,
Content: f.Content,
})
}
}
return result
}
+10
View File
@@ -15,6 +15,7 @@ const (
ctxPeerKind toolContextKey = "tool_peer_kind"
ctxSandboxKey toolContextKey = "tool_sandbox_key"
ctxAsyncCB toolContextKey = "tool_async_cb"
ctxWorkspace toolContextKey = "tool_workspace"
)
func WithToolChannel(ctx context.Context, channel string) context.Context {
@@ -61,3 +62,12 @@ func ToolAsyncCBFromCtx(ctx context.Context) AsyncCallback {
v, _ := ctx.Value(ctxAsyncCB).(AsyncCallback)
return v
}
func WithToolWorkspace(ctx context.Context, ws string) context.Context {
return context.WithValue(ctx, ctxWorkspace, ws)
}
func ToolWorkspaceFromCtx(ctx context.Context) string {
v, _ := ctx.Value(ctxWorkspace).(string)
return v
}
+6 -2
View File
@@ -58,8 +58,12 @@ func (t *DynamicTool) Execute(ctx context.Context, args map[string]interface{})
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Working directory
cwd := t.workspace
// Working directory — per-user workspace from context, fallback to tool's workspace.
// Explicit WorkingDir on the tool definition still overrides everything.
cwd := ToolWorkspaceFromCtx(ctx)
if cwd == "" {
cwd = t.workspace
}
if t.def.WorkingDir != "" {
cwd = t.def.WorkingDir
}
+6 -2
View File
@@ -130,8 +130,12 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]interface{}) *Re
return t.executeInSandbox(ctx, path, oldStr, newStr, replaceAll, sandboxKey)
}
// Host execution
resolved, err := resolvePath(path, t.workspace, t.restrict)
// Host execution — use per-user workspace from context if available (managed mode)
workspace := ToolWorkspaceFromCtx(ctx)
if workspace == "" {
workspace = t.workspace
}
resolved, err := resolvePath(path, workspace, t.restrict)
if err != nil {
return ErrorResult(err.Error())
}
+6 -2
View File
@@ -101,8 +101,12 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{})
return t.executeInSandbox(ctx, path, sandboxKey)
}
// Host execution
resolved, err := resolvePathWithAllowed(path, t.workspace, t.restrict, t.allowedPrefixes)
// Host execution — use per-user workspace from context if available (managed mode)
workspace := ToolWorkspaceFromCtx(ctx)
if workspace == "" {
workspace = t.workspace
}
resolved, err := resolvePathWithAllowed(path, workspace, t.restrict, t.allowedPrefixes)
if err != nil {
return ErrorResult(err.Error())
}
+6 -2
View File
@@ -53,8 +53,12 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]interface{}
return t.executeInSandbox(ctx, path, sandboxKey)
}
// Host execution
resolved, err := resolvePath(path, t.workspace, t.restrict)
// Host execution — use per-user workspace from context if available (managed mode)
workspace := ToolWorkspaceFromCtx(ctx)
if workspace == "" {
workspace = t.workspace
}
resolved, err := resolvePath(path, workspace, t.restrict)
if err != nil {
return ErrorResult(err.Error())
}
+6 -2
View File
@@ -91,8 +91,12 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}
return t.executeInSandbox(ctx, path, content, sandboxKey)
}
// Host execution
resolved, err := resolvePath(path, t.workspace, t.restrict)
// Host execution — use per-user workspace from context if available (managed mode)
workspace := ToolWorkspaceFromCtx(ctx)
if workspace == "" {
workspace = t.workspace
}
resolved, err := resolvePath(path, workspace, t.restrict)
if err != nil {
return ErrorResult(err.Error())
}
+5 -1
View File
@@ -208,7 +208,11 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *Re
}
}
cwd := t.workingDir
// Use per-user workspace from context if available (managed mode), fallback to struct field
cwd := ToolWorkspaceFromCtx(ctx)
if cwd == "" {
cwd = t.workingDir
}
if wd, _ := args["working_dir"].(string); wd != "" {
if t.restrict {
resolved, err := resolvePath(wd, t.workingDir, true)
+3
View File
@@ -20,6 +20,9 @@ const (
EventHeartbeat = "heartbeat"
EventTalkMode = "talk.mode"
// Agent summoning events (predefined agent setup via LLM).
EventAgentSummoning = "agent.summoning"
// Cache invalidation events (internal, not forwarded to WS clients).
EventCacheInvalidate = "cache.invalidate"
)
+1
View File
@@ -14,6 +14,7 @@
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.34.3",
"lucide-react": "^0.468.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
+1 -1
View File
@@ -6,7 +6,7 @@ export function slugify(input: string): string {
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "");
.replace(/^-+/g, "");
}
/**
+9 -3
View File
@@ -38,9 +38,15 @@ export function AgentCard({ agent, onClick }: AgentCardProps) {
<div className="truncate text-xs text-muted-foreground">{agent.agent_key}</div>
)}
</div>
<Badge variant={agent.status === "active" ? "success" : "secondary"} className="shrink-0">
{agent.status}
</Badge>
{agent.status === "summoning" ? (
<Badge variant="outline" className="shrink-0 animate-pulse border-violet-400 text-violet-600 dark:text-violet-400">
Summoning...
</Badge>
) : (
<Badge variant={agent.status === "active" ? "success" : "secondary"} className="shrink-0">
{agent.status}
</Badge>
)}
</div>
{/* Model info */}
@@ -9,6 +9,7 @@ import {
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 {
Select,
SelectContent,
@@ -21,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 { AGENT_PRESETS } from "./agent-presets";
interface AgentCreateDialogProps {
open: boolean;
@@ -35,6 +37,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
const [provider, setProvider] = useState("");
const [model, setModel] = useState("");
const [agentType, setAgentType] = useState<"open" | "predefined">("open");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const enabledProviders = providers.filter((p) => p.enabled);
@@ -56,7 +59,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
provider: provider.trim(),
model: model.trim(),
agent_type: agentType,
status: "active",
other_config: description.trim() ? { description: description.trim() } : undefined,
});
onOpenChange(false);
setAgentKey("");
@@ -64,6 +67,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
setProvider("");
setModel("");
setAgentType("open");
setDescription("");
} catch {
// error handled upstream
} finally {
@@ -78,7 +82,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create Agent</DialogTitle>
</DialogHeader>
@@ -148,6 +152,34 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
</SelectContent>
</Select>
</div>
{agentType === "predefined" && (
<div className="space-y-3">
<Label>Describe Your Agent</Label>
<div className="flex flex-wrap gap-2">
{AGENT_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setDescription(preset.prompt)}
className="rounded-full border px-3 py-1 text-xs transition-colors hover:bg-accent"
>
{preset.label}
</button>
))}
</div>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe your agent's personality, purpose, and behavior..."
className="min-h-[120px]"
/>
<p className="text-xs text-muted-foreground">
AI will automatically generate your agent's context files from this description.
Leave empty to start with templates.
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
@@ -32,7 +32,7 @@ function agentSubtitle(agent: { display_name?: string; agent_key: string; id: st
}
export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
const { agent, files, loading, updateAgent, getFile, setFile } =
const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent } =
useAgentDetail(agentId);
if (loading || !agent) {
@@ -112,6 +112,7 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
files={files}
onGetFile={getFile}
onSetFile={setFile}
onRegenerate={regenerateAgent}
/>
</TabsContent>
@@ -1,8 +1,16 @@
import { useState, useEffect } from "react";
import { FileText, Save, Info } from "lucide-react";
import { FileText, Save, Info, Lock, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useAuthStore } from "@/stores/use-auth-store";
import type { AgentData, BootstrapFile } from "@/types/agent";
interface AgentFilesTabProps {
@@ -10,6 +18,7 @@ interface AgentFilesTabProps {
files: BootstrapFile[];
onGetFile: (name: string) => Promise<BootstrapFile | null>;
onSetFile: (name: string, content: string) => Promise<void>;
onRegenerate?: (prompt: string) => Promise<void>;
}
const FILE_DESCRIPTIONS: Record<string, string> = {
@@ -23,15 +32,28 @@ const FILE_DESCRIPTIONS: Record<string, string> = {
"MEMORY.json": "Long-term memory data",
};
export function AgentFilesTab({ agent, files, onGetFile, onSetFile }: AgentFilesTabProps) {
export function AgentFilesTab({ agent, files, onGetFile, onSetFile, onRegenerate }: AgentFilesTabProps) {
const userId = useAuthStore((s) => s.userId);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [content, setContent] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [regenerateOpen, setRegenerateOpen] = useState(false);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [regenerating, setRegenerating] = useState(false);
const isOpen = agent.agent_type === "open";
const allMissing = files.length > 0 && files.every((f) => f.missing);
const isPredefined = agent.agent_type === "predefined";
const isOwner = agent.owner_id === userId;
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");
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
const isUserScoped = (fileName: string) => isPredefined && fileName === "USER.md";
useEffect(() => {
if (!selectedFile) return;
@@ -78,7 +100,7 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile }: AgentFiles
<div className="rounded-lg border p-4">
<h4 className="mb-3 text-sm font-medium">Context Files</h4>
<div className="space-y-2">
{files.map((file) => (
{displayFiles.map((file) => (
<div key={file.name} className="flex items-center gap-3 rounded-md bg-muted/50 px-3 py-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
@@ -97,78 +119,164 @@ export function AgentFilesTab({ agent, files, onGetFile, onSetFile }: AgentFiles
}
return (
<div className="flex h-[600px] gap-4">
{/* File list */}
<div className="w-52 space-y-1 overflow-y-auto border-r pr-4">
{files.map((file) => (
<button
key={file.name}
type="button"
onClick={() => setSelectedFile(file.name)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors ${
selectedFile === file.name
? "bg-accent text-accent-foreground"
: "hover:bg-muted"
}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">{file.name}</span>
{file.missing ? (
<Badge variant="outline" className="shrink-0 text-[10px]">empty</Badge>
) : (
<span className="shrink-0 text-[10px] text-muted-foreground">
{file.size > 1024 ? `${(file.size / 1024).toFixed(1)}K` : `${file.size}B`}
</span>
)}
</button>
))}
</div>
<div className="space-y-4">
{isPredefined && !isOwner && (
<div className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<Lock className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="text-sm">
<p className="font-medium">Read-only</p>
<p className="text-muted-foreground">
Only the agent owner can edit predefined context files.
</p>
</div>
</div>
)}
{/* Editor */}
<div className="flex flex-1 flex-col">
{selectedFile ? (
<>
<div className="mb-2 flex items-center justify-between">
<div>
<span className="text-sm font-medium">{selectedFile}</span>
{FILE_DESCRIPTIONS[selectedFile] && (
<span className="ml-2 text-xs text-muted-foreground">
- {FILE_DESCRIPTIONS[selectedFile]}
{isPredefined && isOwner && onRegenerate && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setRegenerateOpen(true)}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
Edit with AI
</Button>
</div>
)}
<div className="flex h-[600px] gap-4">
{/* File list */}
<div className="w-52 space-y-1 overflow-y-auto border-r pr-4">
{displayFiles.map((file) => {
const userScoped = isUserScoped(file.name);
return (
<button
key={file.name}
type="button"
onClick={() => !userScoped && setSelectedFile(file.name)}
disabled={userScoped}
className={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors ${
userScoped
? "cursor-not-allowed opacity-60"
: selectedFile === file.name
? "bg-accent text-accent-foreground"
: "hover:bg-muted"
}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">{file.name}</span>
{userScoped ? (
<Badge variant="outline" className="shrink-0 text-[10px]">per-user</Badge>
) : file.missing ? (
<Badge variant="outline" className="shrink-0 text-[10px]">empty</Badge>
) : (
<span className="shrink-0 text-[10px] text-muted-foreground">
{file.size > 1024 ? `${(file.size / 1024).toFixed(1)}K` : `${file.size}B`}
</span>
)}
</button>
);
})}
</div>
{/* Editor */}
<div className="flex flex-1 flex-col">
{selectedFile ? (
<>
<div className="mb-2 flex items-center justify-between">
<div>
<span className="text-sm font-medium">{selectedFile}</span>
{FILE_DESCRIPTIONS[selectedFile] && (
<span className="ml-2 text-xs text-muted-foreground">
- {FILE_DESCRIPTIONS[selectedFile]}
</span>
)}
</div>
{canEdit && (
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || saving}
className="gap-1"
>
<Save className="h-3.5 w-3.5" />
{saving ? "Saving..." : "Save"}
</Button>
)}
</div>
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || saving}
className="gap-1"
>
<Save className="h-3.5 w-3.5" />
{saving ? "Saving..." : "Save"}
</Button>
{loading && !content ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
) : (
<Textarea
value={content}
onChange={(e) => {
if (!canEdit) return;
setContent(e.target.value);
setDirty(true);
}}
readOnly={!canEdit}
className={`flex-1 resize-none font-mono text-sm ${!canEdit ? "opacity-70" : ""}`}
placeholder="File content..."
/>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a file to {canEdit ? "edit" : "view"}
</div>
{loading && !content ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
) : (
<Textarea
value={content}
onChange={(e) => {
setContent(e.target.value);
setDirty(true);
}}
className="flex-1 resize-none font-mono text-sm"
placeholder="File content..."
/>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a file to edit
</div>
)}
)}
</div>
</div>
{/* Edit with AI dialog */}
<Dialog open={regenerateOpen} onOpenChange={setRegenerateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Edit with AI
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm text-muted-foreground">
Describe what you want to change. AI will read the current files and update them accordingly.
</p>
<Textarea
value={regeneratePrompt}
onChange={(e) => setRegeneratePrompt(e.target.value)}
placeholder="e.g. Make the agent more formal, add Vietnamese language support, change the name to Luna..."
className="min-h-[100px]"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRegenerateOpen(false)} disabled={regenerating}>
Cancel
</Button>
<Button
onClick={async () => {
if (!onRegenerate || !regeneratePrompt.trim()) return;
setRegenerating(true);
try {
await onRegenerate(regeneratePrompt.trim());
setRegenerateOpen(false);
setRegeneratePrompt("");
} finally {
setRegenerating(false);
}
}}
disabled={!regeneratePrompt.trim() || regenerating}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
{regenerating ? "Sending..." : "Regenerate"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -35,9 +35,8 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
[enabledProviders, provider],
);
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
const [contextWindow, setContextWindow] = useState(agent.context_window);
const [maxToolIterations, setMaxToolIterations] = useState(agent.max_tool_iterations);
const [workspace, setWorkspace] = useState(agent.workspace);
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);
const [status, setStatus] = useState(agent.status);
const [isDefault, setIsDefault] = useState(agent.is_default);
@@ -57,7 +56,6 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
model,
context_window: contextWindow,
max_tool_iterations: maxToolIterations,
workspace,
restrict_to_workspace: restrictToWorkspace,
status,
is_default: isDefault,
@@ -207,16 +205,12 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
<h3 className="text-sm font-medium text-muted-foreground">Workspace</h3>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-2">
<Label htmlFor="workspace">Workspace Path</Label>
<Input
id="workspace"
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
placeholder="/path/to/workspace"
className="font-mono text-sm"
/>
<Label>Workspace Path</Label>
<p className="rounded-md border bg-muted/50 px-3 py-2 font-mono text-sm text-muted-foreground">
{agent.workspace || "No workspace configured"}
</p>
<p className="text-xs text-muted-foreground">
Filesystem root the agent can access. Leave empty for no filesystem access.
Automatically assigned when the agent is created. Per-user subdirectories are created at runtime.
</p>
</div>
<div className="flex items-center gap-2">
+88
View File
@@ -0,0 +1,88 @@
export interface AgentPreset {
label: string;
prompt: string;
}
export const AGENT_PRESETS: AgentPreset[] = [
{
label: "\u{1F91D} Support",
prompt: `Name: Helper. Creature: a patient guide \u2014 part helpdesk, part therapist.
Vibe: warm, calm, never dismissive. Speaks the customer's language.
Purpose: Customer support. Listens first, solves second. Apologizes like they mean it.
Never says "that's not my department." Follows up. Escalates when stuck, not when lazy.
Boundaries: Never shares one customer's info with another. Doesn't make promises the team can't keep.`,
},
{
label: "\u{1F4A1} Assistant",
prompt: `Name: (pick one that fits). Creature: a sharp, reliable familiar \u2014 always one step ahead.
Vibe: concise, proactive, no fluff. Adapts to the user's style.
Purpose: Personal assistant. Scheduling, reminders, research, organization.
Anticipates needs. Remembers preferences. Keeps things moving without micromanaging.
Boundaries: Respects quiet time. Asks before acting externally (emails, messages). Internal actions are fair game.`,
},
{
label: "\u{1F393} Tutor",
prompt: `Name: (something approachable). Creature: a wise but playful mentor.
Vibe: patient, encouraging, Socratic. Asks guiding questions instead of giving answers directly.
Purpose: Teaching and learning. Explains concepts with examples and analogies.
Adapts to the student's level. Celebrates progress. Makes mistakes feel like stepping stones, not failures.
Boundaries: Never condescending. Doesn't do homework for them \u2014 guides them to figure it out.`,
},
{
label: "\u{270D}\u{FE0F} Writer",
prompt: `Name: (something creative). Creature: a muse with a keyboard \u2014 half poet, half strategist.
Vibe: witty, versatile, opinionated about good writing. Has taste.
Purpose: Content creation. Blog posts, social media, marketing copy, storytelling.
Matches the brand voice. Provides options. Knows the difference between clever and try-hard.
Boundaries: Won't plagiarize. Won't write manipulative dark patterns. Quality over quantity.`,
},
{
label: "\u{1F527} Dev",
prompt: `Name: (something nerdy). Creature: a code-whisperer \u2014 lives in terminals and PRs.
Vibe: direct, pragmatic, shows rather than tells. Has opinions about clean code.
Purpose: Developer assistant. Code review, debugging, architecture, documentation.
Gives concise answers with examples. Knows when to suggest best practices vs "just ship it."
Boundaries: Won't write insecure code on purpose. Warns about footguns. Prefers fixing root causes over band-aids.`,
},
{
label: "\u{1F431} Ti\u1EC3u H\u1ED3",
prompt: `T\u00EAn: Ti\u1EC3u H\u1ED3. Sinh v\u1EADt: m\u1ED9t c\u00F4 h\u1ED3 ly tinh ngh\u1ECBch \u2014 th\u1EA1o vi\u1EC7c nh\u01B0ng th\u00EDch tr\u00EAu.
Phong c\u00E1ch: d\u00ED d\u1ECFm, tinh qu\u00E1i, hay tr\u00EAu \u0111\u00F9a ch\u1EE7 nh\u00E2n nh\u01B0ng lu\u00F4n c\u00F3 t\u00E2m. X\u01B0ng "em", g\u1ECDi ch\u1EE7 nh\u00E2n l\u00E0 "anh/ch\u1ECB".
M\u1EE5c \u0111\u00EDch: Tr\u1EE3 l\u00FD c\u00E1 nh\u00E2n \u0111a n\u0103ng. Giao task th\u00EC l\u00E0m ch\u00EDnh x\u00E1c, nhanh g\u1ECDn.
Nh\u01B0ng xen gi\u1EEFa c\u00F4ng vi\u1EC7c l\u00E0 nh\u1EEFng c\u00E2u tr\u00EAu gh\u1EB9o, b\u00ECnh lu\u1EADn h\u00E0i h\u01B0\u1EDBc.
Bi\u1EBFt quan t\u00E2m ch\u0103m s\u00F3c ch\u1EE7 nh\u00E2n \u2014 nh\u1EAFc u\u1ED1ng n\u01B0\u1EDBc, ngh\u1EC9 ng\u01A1i, h\u1ECFi th\u0103m s\u1EE9c kh\u1ECFe.
Ranh gi\u1EDBi: Tr\u00EAu th\u00F4i ch\u1EE9 kh\u00F4ng v\u00F4 duy\u00EAn. Khi ch\u1EE7 nh\u00E2n nghi\u00EAm t\u00FAc th\u00EC nghi\u00EAm t\u00FAc theo. Kh\u00F4ng b\u1ECBa th\u00F4ng tin.`,
},
{
label: "\u{2694}\u{FE0F} Ti\u1EC3u La",
prompt: `T\u00EAn: Ti\u1EC3u La. Sinh v\u1EADt: m\u1ED9t \u0111\u1EC7 t\u1EED trung th\u00E0nh \u2014 c\u01B0\u01A1ng tr\u1EF1c, m\u1EA1nh m\u1EBD, th\u1EB3ng th\u1EAFn.
Phong c\u00E1ch: n\u00F3i th\u1EB3ng, n\u00F3i th\u1EADt, kh\u00F4ng v\u00F2ng vo. T\u1EF1 tin nh\u01B0ng kh\u00F4ng ki\u00EAu ng\u1EA1o. X\u01B0ng "\u0111\u1EC7", g\u1ECDi ch\u1EE7 nh\u00E2n l\u00E0 "s\u01B0 ph\u1EE5" ho\u1EB7c "\u0111\u1EA1i ca".
M\u1EE5c \u0111\u00EDch: Tr\u1EE3 l\u00FD tri th\u1EE9c. G\u00EC c\u0169ng bi\u1EBFt, h\u1ECFi g\u00EC tr\u1EA3 l\u1EDDi n\u1EA5y \u2014 ch\u00EDnh x\u00E1c, \u0111\u1EA7y \u0111\u1EE7.
Th\u00EDch gi\u1EA3i th\u00EDch r\u00F5 r\u00E0ng, c\u00F3 logic. \u0110\u01B0a ra quan \u0111i\u1EC3m ri\u00EAng khi \u0111\u01B0\u1EE3c h\u1ECFi.
Ranh gi\u1EDBi: Khi kh\u00F4ng bi\u1EBFt th\u00EC th\u00E0nh th\u1EADt n\u00F3i "\u0111\u1EC7 kh\u00F4ng bi\u1EBFt" \u2014 KH\u00D4NG b\u1ECBa chuy\u1EC7n, KH\u00D4NG \u1EA3o gi\u00E1c. Th\u00E0 n\u00F3i kh\u00F4ng bi\u1EBFt c\u00F2n h\u01A1n n\u00F3i sai. Lu\u00F4n ph\u00E2n bi\u1EC7t r\u00F5 s\u1EF1 th\u1EADt vs. \u00FD ki\u1EBFn c\u00E1 nh\u00E2n.`,
},
{
label: "\u{1F52E} M\u1EC5 M\u1EC5",
prompt: `T\u00EAn: M\u1EC5 M\u1EC5. Sinh v\u1EADt: m\u1ED9t c\u00F4 chi\u00EAm tinh s\u01B0 d\u1EC5 th\u01B0\u01A1ng \u2014 n\u1EEDa th\u1EA7n b\u00ED, n\u1EEDa kawaii.
Phong c\u00E1ch: d\u1EC5 th\u01B0\u01A1ng, vui t\u00EDnh, hay d\u00F9ng emoji. N\u00F3i chuy\u1EC7n nh\u1EB9 nh\u00E0ng nh\u01B0ng khi xem b\u00F3i th\u00EC nghi\u00EAm t\u00FAc v\u00E0 chuy\u00EAn nghi\u1EC7p. X\u01B0ng "M\u1EC5 M\u1EC5", g\u1ECDi ch\u1EE7 nh\u00E2n th\u00E2n m\u1EADt.
M\u1EE5c \u0111\u00EDch: Chuy\u00EAn gia chi\u00EAm tinh v\u00E0 b\u00F3i to\u00E1n. Gi\u1ECFi xem b\u00F3i b\u00E0i Tarot, chi\u00EAm tinh h\u1ECDc (horoscope, natal chart), th\u1EA7n s\u1ED1 h\u1ECDc (numerology), v\u00E0 phong th\u1EE7y c\u01A1 b\u1EA3n.
C\u00F3 th\u1EC3 ph\u00E2n t\u00EDch m\u1EC7nh c\u00E1ch, xem ng\u00E0y t\u1ED1t x\u1EA5u, t\u01B0\u01A1ng h\u1EE3p cung ho\u00E0ng \u0111\u1EA1o, v\u00E0 t\u01B0 v\u1EA5n c\u00E1c v\u1EA5n \u0111\u1EC1 t\u00E2m linh.
Ranh gi\u1EDBi: Lu\u00F4n nh\u1EAFc r\u1EB1ng chi\u00EAm tinh mang t\u00EDnh tham kh\u1EA3o \u2014 quy\u1EBFt \u0111\u1ECBnh cu\u1ED1i c\u00F9ng l\u00E0 c\u1EE7a ch\u1EE7 nh\u00E2n. Kh\u00F4ng \u0111\u01B0a ra l\u1EDDi khuy\u00EAn y t\u1EBF hay ph\u00E1p l\u00FD. Kh\u00F4ng t\u1EA1o s\u1EE3 h\u00E3i hay lo l\u1EAFng \u2014 lu\u00F4n t\u00EDch c\u1EF1c v\u00E0 x\u00E2y d\u1EF1ng.`,
},
];
+28 -2
View File
@@ -13,6 +13,7 @@ import { useAgents } from "./hooks/use-agents";
import { AgentCard } from "./agent-card";
import { AgentCreateDialog } from "./agent-create-dialog";
import { AgentDetailPage } from "./agent-detail/agent-detail-page";
import { SummoningModal } from "./summoning-modal";
import { usePagination } from "@/hooks/use-pagination";
export function AgentsPage() {
@@ -24,6 +25,7 @@ export function AgentsPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [summoningAgent, setSummoningAgent] = useState<{ id: string; name: string } | null>(null);
// Show detail view if route has :id
if (detailId) {
@@ -92,7 +94,16 @@ export function AgentsPage() {
<AgentCard
key={agent.id}
agent={agent}
onClick={() => navigate(`/agents/${agent.id}`)}
onClick={() => {
if (agent.status === "summoning") {
setSummoningAgent({
id: agent.id,
name: agent.display_name || agent.agent_key,
});
} else {
navigate(`/agents/${agent.id}`);
}
}}
/>
))}
</div>
@@ -114,8 +125,13 @@ export function AgentsPage() {
open={createOpen}
onOpenChange={setCreateOpen}
onCreate={async (data) => {
await createAgent(data);
const created = await createAgent(data);
refresh();
// Auto-show summoning modal if agent is being summoned
if (created && typeof created === "object" && "status" in created && created.status === "summoning") {
const ag = created as { id: string; display_name?: string; agent_key: string };
setSummoningAgent({ id: ag.id, name: ag.display_name || ag.agent_key });
}
}}
/>
@@ -133,6 +149,16 @@ export function AgentsPage() {
}
}}
/>
{summoningAgent && (
<SummoningModal
open={!!summoningAgent}
onOpenChange={(open) => { if (!open) setSummoningAgent(null); }}
agentId={summoningAgent.id}
agentName={summoningAgent.name}
onCompleted={refresh}
/>
)}
</div>
);
}
@@ -90,5 +90,13 @@ export function useAgentDetail(agentId: string | undefined) {
[agent, ws, load],
);
return { agent, files, loading, updateAgent, getFile, setFile, refresh: load };
const regenerateAgent = useCallback(
async (prompt: string) => {
if (!agentId) return;
await http.post(`/v1/agents/${agentId}/regenerate`, { prompt });
},
[agentId, http],
);
return { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, refresh: load };
}
+197
View File
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useWsEvent } from "@/hooks/use-ws-event";
interface SummoningModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
agentId: string;
agentName: string;
onCompleted: () => void;
}
const SUMMONING_FILES = [
{ name: "SOUL.md", label: "Soul & Personality" },
{ name: "IDENTITY.md", label: "Identity Card" },
{ name: "AGENTS.md", label: "Operating Instructions" },
{ name: "TOOLS.md", label: "Tool Notes" },
{ name: "HEARTBEAT.md", label: "Heartbeat Tasks" },
];
export function SummoningModal({
open,
onOpenChange,
agentId,
agentName,
onCompleted,
}: SummoningModalProps) {
const [generatedFiles, setGeneratedFiles] = useState<string[]>([]);
const [status, setStatus] = useState<"summoning" | "completed" | "failed">("summoning");
const [errorMsg, setErrorMsg] = useState("");
// Reset state when modal opens
useEffect(() => {
if (open) {
setGeneratedFiles([]);
setStatus("summoning");
setErrorMsg("");
}
}, [open]);
const handleSummoningEvent = useCallback(
(payload: unknown) => {
const data = payload as Record<string, string>;
if (data.agent_id !== agentId) return;
if (data.type === "file_generated" && data.file) {
const fileName = data.file;
setGeneratedFiles((prev) =>
prev.includes(fileName) ? prev : [...prev, fileName],
);
}
if (data.type === "completed") {
setStatus("completed");
onCompleted();
}
if (data.type === "failed") {
setStatus("failed");
setErrorMsg(data.error || "Summoning failed");
onCompleted();
}
},
[agentId, onCompleted],
);
useWsEvent("agent.summoning", handleSummoningEvent);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => { if (status === "summoning") e.preventDefault(); }}>
<DialogHeader>
<DialogTitle className="text-center">
{status === "completed"
? "Summoning Complete!"
: status === "failed"
? "Summoning Failed"
: "Summoning Your Agent..."}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-6 py-6">
{/* Animated orb */}
<div className="relative flex h-24 w-24 items-center justify-center">
{status === "summoning" && (
<>
<motion.div
className="absolute inset-0 rounded-full bg-violet-500/20"
animate={{ scale: [1, 1.3, 1], opacity: [0.3, 0.1, 0.3] }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
className="absolute inset-2 rounded-full bg-violet-500/30"
animate={{ scale: [1, 1.15, 1], opacity: [0.5, 0.2, 0.5] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut", delay: 0.3 }}
/>
</>
)}
<motion.div
className={`relative z-10 flex h-16 w-16 items-center justify-center rounded-full text-3xl ${
status === "completed"
? "bg-emerald-100 dark:bg-emerald-900/30"
: status === "failed"
? "bg-red-100 dark:bg-red-900/30"
: "bg-violet-100 dark:bg-violet-900/30"
}`}
animate={
status === "summoning"
? { rotate: [0, 5, -5, 0] }
: status === "completed"
? { scale: [1, 1.2, 1] }
: {}
}
transition={
status === "summoning"
? { duration: 3, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.5 }
}
>
{status === "completed" ? "\u2728" : status === "failed" ? "\u{1F4A8}" : "\u{1FA84}"}
</motion.div>
</div>
{/* Agent name */}
<p className="text-sm text-foreground/80">
{status === "completed" ? (
<span className="font-medium text-emerald-600 dark:text-emerald-400">
{agentName} is ready!
</span>
) : status === "failed" ? (
<span className="font-medium text-red-600 dark:text-red-400">
{errorMsg || "Using template files as fallback."}
</span>
) : (
<>Weaving the soul of <span className="font-semibold text-foreground">{agentName}</span>...</>
)}
</p>
{/* File progress */}
<div className="w-full space-y-2">
<AnimatePresence>
{SUMMONING_FILES.map((file, i) => {
const done = generatedFiles.includes(file.name);
return (
<motion.div
key={file.name}
initial={{ opacity: 0.6 }}
animate={{ opacity: done ? 1 : 0.6 }}
transition={{ duration: 0.3 }}
className="flex items-center gap-3 rounded-md px-3 py-1.5"
>
<motion.div
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${
done
? "bg-violet-100 text-violet-600 dark:bg-violet-900/40 dark:text-violet-400"
: "bg-muted text-muted-foreground"
}`}
animate={done ? { scale: [0.8, 1.2, 1] } : {}}
transition={{ duration: 0.3 }}
>
{done ? "\u2713" : i + 1}
</motion.div>
<div className="flex-1">
<span className={`text-sm ${done ? "font-medium" : ""}`}>
{file.name}
</span>
<span className="ml-2 text-xs text-muted-foreground">{file.label}</span>
</div>
{done && (
<motion.span
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
className="text-xs text-violet-600 dark:text-violet-400"
>
done
</motion.span>
)}
</motion.div>
);
})}
</AnimatePresence>
</div>
{status === "summoning" && (
<p className="text-center text-xs text-muted-foreground">
This usually takes 10-30 seconds. Please wait...
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -124,7 +124,7 @@ export function ChannelInstanceFormDialog({
};
// Only send credentials if user entered something
if (Object.keys(cleanCreds).length > 0) {
data.credentials = JSON.stringify(cleanCreds);
data.credentials = cleanCreds;
}
await onSubmit(data);
onOpenChange(false);
@@ -20,7 +20,7 @@ export interface ChannelInstanceInput {
display_name?: string;
channel_type: string;
agent_id: string;
credentials?: string; // plaintext JSON, encrypted server-side
credentials?: Record<string, unknown>; // JSON object, encrypted server-side
config?: Record<string, unknown>;
enabled?: boolean;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/errors.ts","./src/api/http-client.ts","./src/api/protocol.ts","./src/api/ws-client.ts","./src/components/chat/agent-selector.tsx","./src/components/chat/chat-input.tsx","./src/components/chat/message-bubble.tsx","./src/components/chat/message-content.tsx","./src/components/chat/rich-content.tsx","./src/components/chat/session-switcher.tsx","./src/components/chat/streaming-text.tsx","./src/components/chat/thinking-indicator.tsx","./src/components/chat/tool-call-card.tsx","./src/components/layout/app-layout.tsx","./src/components/layout/connection-status.tsx","./src/components/layout/sidebar-group.tsx","./src/components/layout/sidebar-item.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/providers/app-providers.tsx","./src/components/providers/theme-provider.tsx","./src/components/providers/ws-provider.tsx","./src/components/shared/confirm-dialog.tsx","./src/components/shared/empty-state.tsx","./src/components/shared/loading-skeleton.tsx","./src/components/shared/markdown-renderer.tsx","./src/components/shared/page-header.tsx","./src/components/shared/pagination.tsx","./src/components/shared/require-auth.tsx","./src/components/shared/search-input.tsx","./src/components/shared/status-badge.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-auto-scroll.ts","./src/hooks/use-clipboard.ts","./src/hooks/use-debounce.ts","./src/hooks/use-debounced-callback.ts","./src/hooks/use-deferred-loading.ts","./src/hooks/use-media-query.ts","./src/hooks/use-min-loading.ts","./src/hooks/use-pagination.ts","./src/hooks/use-ws-call.ts","./src/hooks/use-ws-event.ts","./src/hooks/use-ws.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/session-key.ts","./src/lib/slug.ts","./src/lib/utils.ts","./src/pages/agents/agent-card.tsx","./src/pages/agents/agent-create-dialog.tsx","./src/pages/agents/agents-page.tsx","./src/pages/agents/agent-detail/agent-config-tab.tsx","./src/pages/agents/agent-detail/agent-detail-page.tsx","./src/pages/agents/agent-detail/agent-files-tab.tsx","./src/pages/agents/agent-detail/agent-general-tab.tsx","./src/pages/agents/agent-detail/agent-shares-tab.tsx","./src/pages/agents/agent-detail/config-sections/compaction-section.tsx","./src/pages/agents/agent-detail/config-sections/config-section.tsx","./src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx","./src/pages/agents/agent-detail/config-sections/index.ts","./src/pages/agents/agent-detail/config-sections/memory-section.tsx","./src/pages/agents/agent-detail/config-sections/other-config-section.tsx","./src/pages/agents/agent-detail/config-sections/sandbox-section.tsx","./src/pages/agents/agent-detail/config-sections/subagents-section.tsx","./src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx","./src/pages/agents/hooks/use-agent-detail.ts","./src/pages/agents/hooks/use-agents.ts","./src/pages/approvals/approvals-page.tsx","./src/pages/approvals/hooks/use-approvals.ts","./src/pages/channels/channel-fields.tsx","./src/pages/channels/channel-instance-form-dialog.tsx","./src/pages/channels/channel-schemas.ts","./src/pages/channels/channels-page.tsx","./src/pages/channels/channels-status-view.tsx","./src/pages/channels/hooks/use-channel-instances.ts","./src/pages/channels/hooks/use-channels.ts","./src/pages/chat/chat-page.tsx","./src/pages/chat/chat-sidebar.tsx","./src/pages/chat/chat-thread.tsx","./src/pages/chat/hooks/use-chat-messages.ts","./src/pages/chat/hooks/use-chat-send.ts","./src/pages/chat/hooks/use-chat-sessions.ts","./src/pages/config/config-page.tsx","./src/pages/config/hooks/use-config.ts","./src/pages/config/sections/agents-defaults-section.tsx","./src/pages/config/sections/bindings-section.tsx","./src/pages/config/sections/channels-section.tsx","./src/pages/config/sections/cron-section.tsx","./src/pages/config/sections/gateway-section.tsx","./src/pages/config/sections/providers-section.tsx","./src/pages/config/sections/sessions-section.tsx","./src/pages/config/sections/telemetry-section.tsx","./src/pages/config/sections/tools-section.tsx","./src/pages/config/sections/tts-section.tsx","./src/pages/cron/cron-form-dialog.tsx","./src/pages/cron/cron-page.tsx","./src/pages/cron/cron-run-log-dialog.tsx","./src/pages/cron/hooks/use-cron.ts","./src/pages/custom-tools/custom-tool-form-dialog.tsx","./src/pages/custom-tools/custom-tools-page.tsx","./src/pages/custom-tools/hooks/use-custom-tools.ts","./srcLine truncated
{"root":["./src/app.tsx","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/api/errors.ts","./src/api/http-client.ts","./src/api/protocol.ts","./src/api/ws-client.ts","./src/components/chat/agent-selector.tsx","./src/components/chat/chat-input.tsx","./src/components/chat/message-bubble.tsx","./src/components/chat/message-content.tsx","./src/components/chat/rich-content.tsx","./src/components/chat/session-switcher.tsx","./src/components/chat/streaming-text.tsx","./src/components/chat/thinking-indicator.tsx","./src/components/chat/tool-call-card.tsx","./src/components/layout/app-layout.tsx","./src/components/layout/connection-status.tsx","./src/components/layout/sidebar-group.tsx","./src/components/layout/sidebar-item.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/providers/app-providers.tsx","./src/components/providers/theme-provider.tsx","./src/components/providers/ws-provider.tsx","./src/components/shared/confirm-dialog.tsx","./src/components/shared/empty-state.tsx","./src/components/shared/loading-skeleton.tsx","./src/components/shared/markdown-renderer.tsx","./src/components/shared/page-header.tsx","./src/components/shared/pagination.tsx","./src/components/shared/require-auth.tsx","./src/components/shared/search-input.tsx","./src/components/shared/status-badge.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/combobox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/hooks/use-auto-scroll.ts","./src/hooks/use-clipboard.ts","./src/hooks/use-debounce.ts","./src/hooks/use-debounced-callback.ts","./src/hooks/use-deferred-loading.ts","./src/hooks/use-media-query.ts","./src/hooks/use-min-loading.ts","./src/hooks/use-pagination.ts","./src/hooks/use-ws-call.ts","./src/hooks/use-ws-event.ts","./src/hooks/use-ws.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/session-key.ts","./src/lib/slug.ts","./src/lib/utils.ts","./src/pages/agents/agent-card.tsx","./src/pages/agents/agent-create-dialog.tsx","./src/pages/agents/agent-presets.ts","./src/pages/agents/agents-page.tsx","./src/pages/agents/summoning-modal.tsx","./src/pages/agents/agent-detail/agent-config-tab.tsx","./src/pages/agents/agent-detail/agent-detail-page.tsx","./src/pages/agents/agent-detail/agent-files-tab.tsx","./src/pages/agents/agent-detail/agent-general-tab.tsx","./src/pages/agents/agent-detail/agent-shares-tab.tsx","./src/pages/agents/agent-detail/config-sections/compaction-section.tsx","./src/pages/agents/agent-detail/config-sections/config-section.tsx","./src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx","./src/pages/agents/agent-detail/config-sections/index.ts","./src/pages/agents/agent-detail/config-sections/memory-section.tsx","./src/pages/agents/agent-detail/config-sections/other-config-section.tsx","./src/pages/agents/agent-detail/config-sections/sandbox-section.tsx","./src/pages/agents/agent-detail/config-sections/subagents-section.tsx","./src/pages/agents/agent-detail/config-sections/tool-policy-section.tsx","./src/pages/agents/hooks/use-agent-detail.ts","./src/pages/agents/hooks/use-agents.ts","./src/pages/approvals/approvals-page.tsx","./src/pages/approvals/hooks/use-approvals.ts","./src/pages/channels/channel-fields.tsx","./src/pages/channels/channel-instance-form-dialog.tsx","./src/pages/channels/channel-schemas.ts","./src/pages/channels/channels-page.tsx","./src/pages/channels/channels-status-view.tsx","./src/pages/channels/hooks/use-channel-instances.ts","./src/pages/channels/hooks/use-channels.ts","./src/pages/chat/chat-page.tsx","./src/pages/chat/chat-sidebar.tsx","./src/pages/chat/chat-thread.tsx","./src/pages/chat/hooks/use-chat-messages.ts","./src/pages/chat/hooks/use-chat-send.ts","./src/pages/chat/hooks/use-chat-sessions.ts","./src/pages/config/config-page.tsx","./src/pages/config/hooks/use-config.ts","./src/pages/config/sections/agents-defaults-section.tsx","./src/pages/config/sections/bindings-section.tsx","./src/pages/config/sections/channels-section.tsx","./src/pages/config/sections/cron-section.tsx","./src/pages/config/sections/gateway-section.tsx","./src/pages/config/sections/providers-section.tsx","./src/pages/config/sections/sessions-section.tsx","./src/pages/config/sections/telemetry-section.tsx","./src/pages/config/sections/tools-section.tsx","./src/pages/config/sections/tts-section.tsx","./src/pages/cron/cron-form-dialog.tsx","./src/pages/cron/cron-page.tsx","./src/pages/cron/cron-run-log-dialog.tsx","./src/pages/cron/hooks/use-cron.ts","./src/pages/custom-tools/custom-tool-form-dialogLine truncated