mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
feat: Introduce agent summoning flow with a dedicated modal and updated bootstrap process for predefined agents.
This commit is contained in:
1 parent
abaaf8fc83
commit
08ced252b2
35 files changed
+1142
-147
No files matched your search
+3
-1
@@ -34,4 +34,6 @@ ui/web/pnpm-lock.yaml
|
||||
tmp
|
||||
.claude/
|
||||
|
||||
deploy-*.sh
|
||||
deploy-*.sh
|
||||
|
||||
docs/technical-diaries
|
||||
+10
-15
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
"",
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,7 +6,7 @@ export function slugify(input: string): string {
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
.replace(/^-+/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.`,
|
||||
},
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 @@
|
||||
{"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
|
||||
Reference in new issue
Block a user