diff --git a/webui/app.go b/webui/app.go index 64fe937..d85c77f 100644 --- a/webui/app.go +++ b/webui/app.go @@ -25,6 +25,7 @@ import ( "github.com/donseba/go-htmx" fiber "github.com/gofiber/fiber/v2" + "github.com/gofiber/template/html/v2" ) type ( @@ -37,10 +38,13 @@ type ( func NewApp(opts ...Option) *App { config := NewConfig(opts...) + engine := html.NewFileSystem(http.FS(viewsfs), ".html") // Initialize a new Fiber app // Pass the engine to the Views - webapp := fiber.New(fiber.Config{}) + webapp := fiber.New(fiber.Config{ + Views: engine, + }) a := &App{ htmx: htmx.New(), @@ -245,9 +249,67 @@ func (a *App) ImportAgent(pool *state.AgentPool) func(c *fiber.Ctx) error { } } -// Chat provides a JSON-based API for chat functionality -// This is designed to work better with the React UI func (a *App) Chat(pool *state.AgentPool) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + payload := struct { + Message string `json:"message"` + }{} + + if err := c.BodyParser(&payload); err != nil { + return err + } + agentName := c.Params("name") + manager := pool.GetManager(agentName) + + query := strings.Clone(payload.Message) + if query == "" { + _, _ = c.Write([]byte("Please enter a message.")) + return nil + } + manager.Send( + sse.NewMessage( + chatDiv(query, "gray"), + ).WithEvent("messages")) + + go func() { + a := pool.GetAgent(agentName) + if a == nil { + xlog.Info("Agent not found in pool", c.Params("name")) + return + } + res := a.Ask( + coreTypes.WithText(query), + ) + if res.Error != nil { + xlog.Error("Error asking agent", "agent", agentName, "error", res.Error) + } else { + xlog.Info("we got a response from the agent", "agent", agentName, "response", res.Response) + } + manager.Send( + sse.NewMessage( + chatDiv(res.Response, "blue"), + ).WithEvent("messages")) + manager.Send( + sse.NewMessage( + disabledElement("inputMessage", false), // show again the input + ).WithEvent("message_status")) + + //result := `done` + // _, _ = w.Write([]byte(result)) + }() + + manager.Send( + sse.NewMessage( + loader() + disabledElement("inputMessage", true), + ).WithEvent("message_status")) + + return nil + } +} + +// ChatAPI provides a JSON-based API for chat functionality +// This is designed to work better with the React UI +func (a *App) ChatAPI(pool *state.AgentPool) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { // Parse the request body payload := struct { diff --git a/webui/elements.go b/webui/elements.go new file mode 100644 index 0000000..9a92b5b --- /dev/null +++ b/webui/elements.go @@ -0,0 +1,39 @@ +package webui + +import ( + "fmt" + "strings" + + elem "github.com/chasefleming/elem-go" + "github.com/chasefleming/elem-go/attrs" +) + +func chatDiv(content string, color string) string { + div := elem.Div(attrs.Props{ + // attrs.ID: "container", + attrs.Class: fmt.Sprintf("p-2 my-2 rounded bg-%s-600", color), + }, + elem.Raw(htmlIfy(content)), + ) + return div.Render() +} + +func loader() string { + return elem.Div(attrs.Props{ + attrs.Class: "loader", + }).Render() +} + +func disabledElement(id string, disabled bool) string { + return elem.Script(nil, + elem.If(disabled, + elem.Raw(`document.getElementById('`+id+`').disabled = true`), + elem.Raw(`document.getElementById('`+id+`').disabled = false`), + )).Render() +} + +func htmlIfy(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "\n", "
") + return s +} diff --git a/webui/public/css/styles.css b/webui/public/css/styles.css new file mode 100644 index 0000000..f466cbf --- /dev/null +++ b/webui/public/css/styles.css @@ -0,0 +1,714 @@ +:root { + --primary: #00ff95; + --secondary: #ff00b1; + --tertiary: #5e00ff; + --dark-bg: #111111; + --darker-bg: #0a0a0a; + --medium-bg: #222222; + --light-bg: #333333; + --neon-glow: 0 0 8px rgba(0, 255, 149, 0.7); + --pink-glow: 0 0 8px rgba(255, 0, 177, 0.7); + --purple-glow: 0 0 8px rgba(94, 0, 255, 0.7); +} + +/* Glitch effect animation */ +@keyframes glitch { + 0% { transform: translate(0); } + 20% { transform: translate(-2px, 2px); } + 40% { transform: translate(-2px, -2px); } + 60% { transform: translate(2px, 2px); } + 80% { transform: translate(2px, -2px); } + 100% { transform: translate(0); } +} + +/* Neon pulse animation */ +@keyframes neonPulse { + 0% { text-shadow: 0 0 7px var(--primary), 0 0 10px var(--primary); } + 50% { text-shadow: 0 0 15px var(--primary), 0 0 25px var(--primary); } + 100% { text-shadow: 0 0 7px var(--primary), 0 0 10px var(--primary); } +} + +/* Scanning line effect */ +@keyframes scanline { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100%); } +} + +body { + font-family: 'Outfit', sans-serif; + background-color: var(--dark-bg); + color: #ffffff; + padding: 20px; + position: relative; + overflow-x: hidden; + background-image: + radial-gradient(circle at 10% 20%, rgba(0, 255, 149, 0.05) 0%, transparent 20%), + radial-gradient(circle at 90% 80%, rgba(255, 0, 177, 0.05) 0%, transparent 20%), + radial-gradient(circle at 50% 50%, rgba(94, 0, 255, 0.05) 0%, transparent 30%), + linear-gradient(180deg, var(--darker-bg) 0%, var(--dark-bg) 100%); + background-attachment: fixed; +} + +body::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient( + transparent, + transparent 2px, + rgba(0, 0, 0, 0.1) 2px, + rgba(0, 0, 0, 0.1) 4px + ); + pointer-events: none; + z-index: 1000; + opacity: 0.3; +} + +body::after { + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--primary), var(--secondary)); + opacity: 0.7; + z-index: 1001; + animation: scanline 6s linear infinite; + pointer-events: none; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 700; +} + +h1 { + font-family: 'Permanent Marker', cursive; + color: var(--primary); + text-shadow: var(--neon-glow); + margin-bottom: 1rem; + position: relative; + animation: neonPulse 2s infinite; +} + +h1:hover { + animation: glitch 0.3s infinite; +} + +h2 { + font-size: 1.5rem; + color: var(--secondary); + text-shadow: var(--pink-glow); + margin-bottom: 0.5rem; +} + +.section-box { + background-color: rgba(17, 17, 17, 0.85); + border: 1px solid var(--primary); + padding: 25px; + margin-bottom: 20px; + border-radius: 6px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--primary), inset 0 0 20px rgba(0, 0, 0, 0.3); + position: relative; + overflow: hidden; +} + +.section-box::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--primary), var(--secondary), var(--tertiary), var(--primary)); + background-size: 200% 100%; + animation: gradientMove 3s linear infinite; +} + +@keyframes gradientMove { + 0% { background-position: 0% 50%; } + 100% { background-position: 100% 50%; } +} + +input, button, textarea, select { + width: 100%; + padding: 12px; + margin-top: 8px; + border-radius: 4px; + border: 1px solid var(--medium-bg); + background-color: var(--light-bg); + color: white; + transition: all 0.3s ease; +} + +input[type="text"], input[type="file"], textarea { + background-color: var(--light-bg); + border-left: 3px solid var(--primary); + color: white; +} + +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--primary); + box-shadow: var(--neon-glow); +} + +button { + background: linear-gradient(135deg, var(--tertiary), var(--secondary)); + color: white; + cursor: pointer; + border: none; + position: relative; + overflow: hidden; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + transition: all 0.3s ease; +} + +button::before { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: all 0.5s; +} + +button:hover { + transform: translateY(-3px); + box-shadow: 0 7px 14px rgba(0, 0, 0, 0.3), 0 0 10px rgba(94, 0, 255, 0.5); +} + +button:hover::before { + left: 100%; +} + +textarea { + height: 200px; + resize: vertical; +} + +/* Select styling */ +select { + appearance: none; + background-color: var(--light-bg); + border-left: 3px solid var(--tertiary); + color: white; + padding: 12px; + border-radius: 4px; + background-image: url('data:image/svg+xml;utf8,'); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 12px; + cursor: pointer; +} + +select:hover { + border-color: var(--secondary); + box-shadow: 0 0 0 1px var(--secondary); +} + +select:focus { + border-color: var(--tertiary); + box-shadow: var(--purple-glow); +} + +select { + overflow-y: auto; +} + +option { + background-color: var(--medium-bg); + color: white; + padding: 8px 10px; +} + +/* Custom Scrollbars */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--medium-bg); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(var(--primary), var(--secondary)); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--tertiary); +} + +/* Checkbox styling */ +.checkbox-custom { + position: relative; + display: inline-block; + width: 22px; + height: 22px; + margin: 5px; + cursor: pointer; + vertical-align: middle; +} + +.checkbox-custom input { + opacity: 0; + width: 0; + height: 0; +} + +.checkbox-custom .checkmark { + position: absolute; + top: 0; + left: 0; + height: 22px; + width: 22px; + background-color: var(--light-bg); + border-radius: 4px; + border: 1px solid var(--medium-bg); + transition: all 0.3s ease; +} + +.checkbox-custom:hover .checkmark { + border-color: var(--primary); + box-shadow: var(--neon-glow); +} + +.checkbox-custom input:checked ~ .checkmark { + background: linear-gradient(135deg, var(--primary), var(--tertiary)); + border-color: transparent; +} + +.checkbox-custom .checkmark:after { + content: ""; + position: absolute; + display: none; +} + +.checkbox-custom input:checked ~ .checkmark:after { + display: block; +} + +.checkbox-custom .checkmark:after { + left: 8px; + top: 4px; + width: 6px; + height: 12px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +/* Card styling */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.card-link { + text-decoration: none; + display: block; +} + +.card { + background: linear-gradient(145deg, rgba(34, 34, 34, 0.9), rgba(17, 17, 17, 0.9)); + border: 1px solid rgba(94, 0, 255, 0.2); + border-radius: 8px; + padding: 25px; + margin: 25px auto; + text-align: left; + width: 90%; + transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + position: relative; + overflow: hidden; +} + +.card::before { + content: ""; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 3px; + background: linear-gradient(90deg, var(--primary), var(--secondary), var(--tertiary)); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.4s ease-out; +} + +.card:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: 0 15px 30px rgba(0, 0, 0, 0.4), 0 0 15px rgba(94, 0, 255, 0.3); +} + +.card:hover::before { + transform: scaleX(1); +} + +.card h2 { + font-family: 'Outfit', sans-serif; + font-size: 1.5em; + font-weight: 600; + color: var(--primary); + margin-bottom: 0.8em; + position: relative; + display: inline-block; +} + +.card a { + color: var(--secondary); + transition: color 0.3s; + text-decoration: none; + position: relative; +} + +.card a:hover { + color: var(--primary); +} + +.card a::after { + content: ""; + position: absolute; + bottom: -2px; + left: 0; + width: 100%; + height: 1px; + background: var(--primary); + transform: scaleX(0); + transform-origin: right; + transition: transform 0.3s ease; +} + +.card a:hover::after { + transform: scaleX(1); + transform-origin: left; +} + +.card p { + color: #cccccc; + font-size: 1em; + line-height: 1.6; +} + +/* Button container */ +.button-container { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-bottom: 12px; +} + +/* Alert and Toast styling */ +.alert { + padding: 12px 15px; + border-radius: 4px; + margin: 15px 0; + display: none; + position: relative; + border-left: 4px solid; + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +.alert-success { + background-color: rgba(0, 255, 149, 0.1); + border-color: var(--primary); + color: var(--primary); +} + +.alert-error { + background-color: rgba(255, 0, 177, 0.1); + border-color: var(--secondary); + color: var(--secondary); +} + +.toast { + position: fixed; + top: 30px; + right: 30px; + max-width: 350px; + padding: 15px 20px; + border-radius: 6px; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5); + z-index: 2000; + opacity: 0; + transform: translateX(30px); + transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + display: flex; + align-items: center; +} + +.toast::before { + content: ""; + width: 20px; + height: 20px; + margin-right: 15px; + background-position: center; + background-repeat: no-repeat; + background-size: contain; +} + +.toast-success { + background: linear-gradient(135deg, rgba(0, 255, 149, 0.9), rgba(0, 255, 149, 0.7)); + color: #111111; + border-left: 4px solid var(--primary); +} + +.toast-success::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23111111'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E"); +} + +.toast-error { + background: linear-gradient(135deg, rgba(255, 0, 177, 0.9), rgba(255, 0, 177, 0.7)); + color: #ffffff; + border-left: 4px solid var(--secondary); +} + +.toast-error::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E"); +} + +.toast-visible { + opacity: 1; + transform: translateX(0); +} + +/* Action buttons */ +.action-btn { + background: var(--medium-bg); + color: white; + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 8px 15px; + border-radius: 4px; + cursor: pointer; + transition: all 0.3s ease; + font-weight: 500; + font-size: 0.9rem; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.action-btn i { + font-size: 1rem; +} + +.action-btn:hover { + transform: translateY(-2px); +} + +.start-btn { + background: linear-gradient(135deg, var(--primary), rgba(0, 255, 149, 0.7)); + color: #111111; + border: none; +} + +.start-btn:hover { + box-shadow: 0 0 15px rgba(0, 255, 149, 0.5); + background: var(--primary); +} + +.pause-btn { + background: linear-gradient(135deg, var(--tertiary), rgba(94, 0, 255, 0.7)); + color: white; + border: none; +} + +.pause-btn:hover { + box-shadow: 0 0 15px rgba(94, 0, 255, 0.5); + background: var(--tertiary); +} + +/* Badge styling */ +.badge { + display: inline-block; + padding: 3px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-primary { + background-color: var(--primary); + color: #111111; +} + +.badge-secondary { + background-color: var(--secondary); + color: white; +} + +.badge-tertiary { + background-color: var(--tertiary); + color: white; +} + +/* Data display tables */ +.data-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin: 20px 0; + border-radius: 6px; + overflow: hidden; +} + +.data-table th, .data-table td { + text-align: left; + padding: 12px 15px; + border-bottom: 1px solid var(--medium-bg); +} + +.data-table th { + background-color: rgba(94, 0, 255, 0.2); + color: var(--tertiary); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + font-size: 0.85rem; +} + +.data-table tr:last-child td { + border-bottom: none; +} + +.data-table tr:nth-child(odd) td { + background-color: rgba(17, 17, 17, 0.6); +} + +.data-table tr:nth-child(even) td { + background-color: rgba(34, 34, 34, 0.6); +} + +.data-table tr:hover td { + background-color: rgba(94, 0, 255, 0.1); +} + +/* Terminal-style code display */ +.code-terminal { + background-color: #0a0a0a; + border-radius: 6px; + padding: 15px; + font-family: 'Courier New', monospace; + color: #00ff95; + margin: 20px 0; + position: relative; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4); + overflow: hidden; +} + +.code-terminal::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 25px; + background: #222; + display: flex; + align-items: center; + padding: 0 10px; +} + +.code-terminal::after { + content: "• • •"; + position: absolute; + top: 0; + left: 12px; + height: 25px; + display: flex; + align-items: center; + color: #666; + font-size: 20px; + letter-spacing: -2px; +} + +.code-terminal pre { + margin-top: 25px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; +} + +.code-terminal .prompt { + color: var(--secondary); + user-select: none; +} + +/* User info badge */ +.user-info { + display: flex; + align-items: center; + background: linear-gradient(135deg, rgba(17, 17, 17, 0.8), rgba(34, 34, 34, 0.8)); + border: 1px solid var(--tertiary); + border-radius: 30px; + padding: 6px 15px; + margin: 10px 0; + font-size: 0.9rem; + box-shadow: var(--purple-glow); +} + +.user-info::before { + content: ""; + width: 10px; + height: 10px; + background-color: var(--primary); + border-radius: 50%; + margin-right: 10px; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(0, 255, 149, 0.7); } + 70% { box-shadow: 0 0 0 10px rgba(0, 255, 149, 0); } + 100% { box-shadow: 0 0 0 0 rgba(0, 255, 149, 0); } +} + +.timestamp { + margin-left: auto; + font-family: 'Courier New', monospace; + color: var(--secondary); +} + +/* Responsive design adjustments */ +@media (max-width: 768px) { + .container { + padding: 10px; + } + + .card { + width: 100%; + padding: 15px; + } + + .section-box { + padding: 15px; + } + + .button-container { + flex-direction: column; + } + + .toast { + top: 10px; + right: 10px; + left: 10px; + max-width: none; + } +} \ No newline at end of file diff --git a/webui/public/css/wizard.css b/webui/public/css/wizard.css new file mode 100644 index 0000000..e6ddff7 --- /dev/null +++ b/webui/public/css/wizard.css @@ -0,0 +1,254 @@ +/* Agent Form Wizard Styles */ +.agent-form-container { + display: flex; + gap: 2rem; + margin-bottom: 2rem; +} + +/* Wizard Sidebar */ +.wizard-sidebar { + width: 250px; + background: var(--surface); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 1.5rem 0; + flex-shrink: 0; +} + +.wizard-nav { + list-style: none; + padding: 0; + margin: 0; +} + +.wizard-nav-item { + padding: 12px 20px; + cursor: pointer; + transition: all 0.2s ease; + border-left: 4px solid transparent; + display: flex; + align-items: center; +} + +.wizard-nav-item i { + margin-right: 10px; + width: 20px; + text-align: center; +} + +.wizard-nav-item:hover { + background: rgba(var(--primary-rgb), 0.1); +} + +.wizard-nav-item.active { + background: rgba(var(--primary-rgb), 0.15); + border-left-color: var(--primary); + color: var(--primary); + font-weight: 600; +} + +/* Form Content Area */ +.form-content-area { + flex: 1; + padding: 1.5rem; + background: var(--surface); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.section-title { + font-size: 1.5rem; + margin-bottom: 1.5rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid rgba(var(--border-rgb), 0.5); + color: var(--text); +} + +.form-section { + display: none; +} + +.form-section.active { + display: block; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Improved input styles */ +.mb-4 { + margin-bottom: 1.5rem; +} + +.form-section label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text); +} + +.form-section input[type="text"], +.form-section input[type="number"], +.form-section textarea, +.form-section select { + width: 100%; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid rgba(var(--border-rgb), 0.8); + background-color: var(--input-bg); + color: var(--text); + font-size: 16px; + transition: border-color 0.2s ease; +} + +.form-section textarea { + min-height: 120px; + resize: vertical; +} + +.form-section input[type="text"]:focus, +.form-section input[type="number"]:focus, +.form-section textarea:focus, +.form-section select:focus { + border-color: var(--primary); + outline: none; + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2); +} + +/* Button Styles */ +.button-container { + margin: 1.5rem 0; +} + +.action-btn { + background: linear-gradient(135deg, var(--primary), var(--secondary)); + color: white; + border: none; + padding: 10px 16px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.action-btn i { + margin-right: 6px; +} + +.action-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.action-btn:active { + transform: translateY(0); +} +/* Navigation controls - improved layout */ +.wizard-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid rgba(var(--border-rgb), 0.5); +} + +.wizard-controls-left { + flex: 1; + text-align: left; +} + +.wizard-controls-center { + flex: 2; + text-align: center; +} + +.wizard-controls-right { + flex: 1; + text-align: right; +} + +.nav-btn { + background: var(--surface); + color: var(--text); + border: 1px solid rgba(var(--border-rgb), 0.8); + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; +} + +.nav-btn i { + margin-right: 6px; +} + +.nav-btn:last-child i { + margin-right: 0; + margin-left: 6px; +} + +.nav-btn:hover { + background: rgba(var(--primary-rgb), 0.1); +} + +.progress-indicator { + display: inline-block; + font-size: 0.9rem; + color: var(--text-muted); + font-weight: 500; + margin-top: 0.5rem; +} + +.progress-dots { + display: flex; + justify-content: center; + margin-bottom: 8px; + gap: 6px; +} + +.progress-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: rgba(var(--border-rgb), 0.4); + transition: all 0.2s ease; +} + +.progress-dot.active { + background-color: var(--primary); + transform: scale(1.2); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .wizard-controls { + flex-direction: column; + gap: 1rem; + } + + .wizard-controls-left, + .wizard-controls-center, + .wizard-controls-right { + width: 100%; + text-align: center; + } + + .progress-dots { + margin: 12px 0; + } +} \ No newline at end of file diff --git a/webui/public/dash.png b/webui/public/dash.png new file mode 100644 index 0000000..abc8dab Binary files /dev/null and b/webui/public/dash.png differ diff --git a/webui/public/dash2.png b/webui/public/dash2.png new file mode 100644 index 0000000..627a432 Binary files /dev/null and b/webui/public/dash2.png differ diff --git a/webui/public/js/agent-form.js b/webui/public/js/agent-form.js new file mode 100644 index 0000000..5c73698 --- /dev/null +++ b/webui/public/js/agent-form.js @@ -0,0 +1,564 @@ +// Common utility functions for agent forms +const AgentFormUtils = { + // Add dynamic component based on template + addDynamicComponent: function(sectionId, templateFunction, dataItems) { + const section = document.getElementById(sectionId); + const newIndex = section.getElementsByClassName(dataItems.className).length; + + // Generate HTML from template function + const newHtml = templateFunction(newIndex, dataItems); + + // Add to DOM + section.insertAdjacentHTML('beforeend', newHtml); + }, + + // Process form data into JSON structure + processFormData: function(formData) { + const jsonData = {}; + + // Process basic form fields + for (const [key, value] of formData.entries()) { + // Skip the array fields as they'll be processed separately + if (!key.includes('[') && !key.includes('].')) { + // Handle checkboxes + if (value === 'on') { + jsonData[key] = true; + } + // Handle numeric fields - specifically kb_results + else if (key === 'kb_results') { + // Convert to integer or default to 3 if empty + jsonData[key] = value ? parseInt(value, 10) : 3; + + // Check if the parse was successful + if (isNaN(jsonData[key])) { + showToast('Knowledge Base Results must be a number', 'error'); + return null; // Indicate validation error + } + } + // Handle other numeric fields if needed + else if (key === 'periodic_runs' && value) { + // Try to parse as number if it looks like one + const numValue = parseInt(value, 10); + if (!isNaN(numValue) && String(numValue) === value) { + jsonData[key] = numValue; + } else { + jsonData[key] = value; + } + } + else { + jsonData[key] = value; + } + } + } + + return jsonData; + }, + + // Process connectors from form + processConnectors: function(button) { + const connectors = []; + const connectorElements = document.querySelectorAll('.connector'); + + for (let i = 0; i < connectorElements.length; i++) { + const typeSelect = document.getElementById(`connectorType${i}`); + if (!typeSelect) { + showToast(`Error: Could not find connector type select for index ${i}`, 'error'); + button.innerHTML = button.getAttribute('data-original-text'); + button.disabled = false; + return null; // Validation failed + } + + const type = typeSelect.value; + if (!type) { + showToast(`Please select a connector type for connector ${i+1}`, 'error'); + button.innerHTML = button.getAttribute('data-original-text'); + button.disabled = false; + return null; // Validation failed + } + + // Get all config fields for this connector + const connector = { + type: type, + config: {} + }; + + // Find all config inputs for this connector + const configInputs = document.querySelectorAll(`[name^="connectors[${i}][config]"]`); + + // Check if we have a JSON textarea (fallback template) + const jsonTextarea = document.getElementById(`connectorConfig${i}`); + if (jsonTextarea && jsonTextarea.value) { + try { + // If it's a JSON textarea, parse it and use the result + const jsonConfig = JSON.parse(jsonTextarea.value); + // Convert the parsed JSON back to a string for the backend + connector.config = JSON.stringify(jsonConfig); + } catch (e) { + // If it's not valid JSON, use it as is + connector.config = jsonTextarea.value; + } + } else { + // Process individual form fields + configInputs.forEach(input => { + // Extract the key from the name attribute + // Format: connectors[0][config][key] + const keyMatch = input.name.match(/\[config\]\[([^\]]+)\]/); + if (keyMatch && keyMatch[1]) { + const key = keyMatch[1]; + // For checkboxes, set true/false based on checked state + if (input.type === 'checkbox') { + connector.config[key] = input.checked ? 'true' : 'false'; + } else { + connector.config[key] = input.value; + } + } + }); + + // Convert the config object to a JSON string for the backend + connector.config = JSON.stringify(connector.config); + } + + connectors.push(connector); + } + + return connectors; + }, + + // Process MCP servers from form + processMCPServers: function() { + const mcpServers = []; + const mcpElements = document.querySelectorAll('.mcp_server'); + + for (let i = 0; i < mcpElements.length; i++) { + const urlInput = document.getElementById(`mcpURL${i}`); + const tokenInput = document.getElementById(`mcpToken${i}`); + + if (urlInput && urlInput.value) { + const server = { + url: urlInput.value + }; + + // Add token if present + if (tokenInput && tokenInput.value) { + server.token = tokenInput.value; + } + + mcpServers.push(server); + } + } + + return mcpServers; + }, + + // Process actions from form + processActions: function(button) { + const actions = []; + const actionElements = document.querySelectorAll('.action'); + + for (let i = 0; i < actionElements.length; i++) { + const nameSelect = document.getElementById(`actionsName${i}`); + const configTextarea = document.getElementById(`actionsConfig${i}`); + + if (!nameSelect) { + showToast(`Error: Could not find action name select for index ${i}`, 'error'); + button.innerHTML = button.getAttribute('data-original-text'); + button.disabled = false; + return null; // Validation failed + } + + const name = nameSelect.value; + if (!name) { + showToast(`Please select an action type for action ${i+1}`, 'error'); + button.innerHTML = button.getAttribute('data-original-text'); + button.disabled = false; + return null; // Validation failed + } + + let config = {}; + if (configTextarea && configTextarea.value) { + try { + config = JSON.parse(configTextarea.value); + } catch (e) { + showToast(`Invalid JSON in action ${i+1} config: ${e.message}`, 'error'); + button.innerHTML = button.getAttribute('data-original-text'); + button.disabled = false; + return null; // Validation failed + } + } + + actions.push({ + name: name, + config: JSON.stringify(config) // Convert to JSON string for backend + }); + } + + return actions; + }, + + // Process prompt blocks from form + processPromptBlocks: function(button) { + const promptBlocks = []; + const promptElements = document.querySelectorAll('.prompt_block'); + + for (let i = 0; i < promptElements.length; i++) { + const nameSelect = document.getElementById(`promptName${i}`); + const configTextarea = document.getElementById(`promptConfig${i}`); + + if (!nameSelect) { + showToast(`Error: Could not find prompt block name select for index ${i}`, 'error'); + button.innerHTML = button.getAttribute('data-original-text'); + button.disabled = false; + return null; // Validation failed + } + + const name = nameSelect.value; + if (!name) { + showToast(`Please select a prompt block type for block ${i+1}`, 'error'); + button.innerHTML = button.getAttribute('data-original-text'); + button.disabled = false; + return null; // Validation failed + } + + let config = {}; + if (configTextarea && configTextarea.value) { + try { + config = JSON.parse(configTextarea.value); + } catch (e) { + showToast(`Invalid JSON in prompt block ${i+1} config: ${e.message}`, 'error'); + button.innerHTML = button.getAttribute('data-original-text'); + button.disabled = false; + return null; // Validation failed + } + } + + promptBlocks.push({ + name: name, + config: JSON.stringify(config) // Convert to JSON string for backend + }); + } + + return promptBlocks; + }, + + // Helper function to format config values (for edit form) + formatConfigValue: function(configElement, configValue) { + if (!configElement) return; + + // If configValue is an object, stringify it + if (typeof configValue === 'object' && configValue !== null) { + try { + configElement.value = JSON.stringify(configValue, null, 2); + } catch (e) { + console.error('Error stringifying config value:', e); + configElement.value = '{}'; + } + } + // If it's a string that looks like JSON, try to parse and pretty print it + else if (typeof configValue === 'string' && (configValue.startsWith('{') || configValue.startsWith('['))) { + try { + const parsed = JSON.parse(configValue); + configElement.value = JSON.stringify(parsed, null, 2); + } catch (e) { + // If it's not valid JSON, just use the string as is + configElement.value = configValue; + } + } + // Otherwise, just use the value as is + else { + configElement.value = configValue || ''; + } + }, + + // Helper function to set select value (with fallback if option doesn't exist) + setSelectValue: function(selectElement, value) { + if (!selectElement) return; + + // Check if the option exists + let optionExists = false; + for (let i = 0; i < selectElement.options.length; i++) { + if (selectElement.options[i].value === value) { + optionExists = true; + break; + } + } + + // Set the value if the option exists + if (optionExists) { + selectElement.value = value; + } else if (selectElement.options.length > 0) { + // Otherwise, select the first option + selectElement.selectedIndex = 0; + } + }, + + // Render connector form based on type + renderConnectorForm: function(index, type, config = {}) { + const formContainer = document.getElementById(`connectorFormContainer${index}`); + if (!formContainer) return; + + // Clear existing form + formContainer.innerHTML = ''; + + // Debug log to see what's happening + console.log(`Rendering connector form for type: ${type}`); + console.log(`Config for connector:`, config); + console.log(`Available templates:`, ConnectorTemplates ? Object.keys(ConnectorTemplates) : 'None'); + + // Ensure config is an object + let configObj = config; + if (typeof config === 'string') { + try { + configObj = JSON.parse(config); + } catch (e) { + console.error('Error parsing connector config string:', e); + configObj = {}; + } + } + + // If we have a template for this connector type in the global ConnectorTemplates object + if (ConnectorTemplates && type && ConnectorTemplates[type]) { + console.log(`Found template for ${type}`); + // Get the template result which contains HTML and setValues function + const templateResult = ConnectorTemplates[type](configObj, index); + + // Set the HTML content + formContainer.innerHTML = templateResult.html; + + // Call the setValues function to set input values safely + if (typeof templateResult.setValues === 'function') { + setTimeout(templateResult.setValues, 0); + } + } else { + console.log(`No template found for ${type}, using fallback`); + // Use the fallback template + if (ConnectorTemplates && ConnectorTemplates.fallback) { + const fallbackResult = ConnectorTemplates.fallback(configObj, index); + formContainer.innerHTML = fallbackResult.html; + + if (typeof fallbackResult.setValues === 'function') { + setTimeout(fallbackResult.setValues, 0); + } + } else { + // Fallback to generic JSON textarea if no fallback template + formContainer.innerHTML = ` +
+ + +
+ `; + + // Set the value safely after DOM is created + setTimeout(function() { + const configTextarea = document.getElementById(`connectorConfig${index}`); + if (configTextarea) { + if (typeof configObj === 'object' && configObj !== null) { + configTextarea.value = JSON.stringify(configObj, null, 2); + } else if (typeof config === 'string') { + configTextarea.value = config; + } + } + }, 0); + } + } + } +}; + +// HTML Templates for dynamic elements +const AgentFormTemplates = { + // Connector template + connectorTemplate: function(index, data) { + return ` +
+

Connector ${index + 1}

+
+ + +
+
+ +
+
Select a connector type to configure
+
+
+ +
+ `; + }, + + // MCP Server template + mcpServerTemplate: function(index, data) { + return ` +
+

MCP Server ${index + 1}

+
+ + +
+
+ + +
+ +
+ `; + }, + + // Action template + actionTemplate: function(index, data) { + return ` +
+

Action ${index + 1}

+
+ + +
+
+ + +
+ +
+ `; + }, + + // Prompt Block template + promptBlockTemplate: function(index, data) { + return ` +
+

Prompt Block ${index + 1}

+
+ + +
+
+ + +
+ +
+ `; + } +}; + +// Initialize form event listeners +function initAgentFormCommon(options = {}) { + // Add connector button + const addConnectorButton = document.getElementById('addConnectorButton'); + if (addConnectorButton) { + addConnectorButton.addEventListener('click', function() { + // Create options string + let optionsHtml = ''; + if (options.connectors) { + optionsHtml = options.connectors; + } + + // Add new connector form + AgentFormUtils.addDynamicComponent('connectorsSection', AgentFormTemplates.connectorTemplate, { + className: 'connector', + options: optionsHtml + }); + }); + } + + // Add MCP server button + const addMCPButton = document.getElementById('addMCPButton'); + if (addMCPButton) { + addMCPButton.addEventListener('click', function() { + // Add new MCP server form + AgentFormUtils.addDynamicComponent('mcpSection', AgentFormTemplates.mcpServerTemplate, { + className: 'mcp_server' + }); + }); + } + + // Add action button + const actionButton = document.getElementById('action_button'); + if (actionButton) { + actionButton.addEventListener('click', function() { + // Create options string + let optionsHtml = ''; + if (options.actions) { + optionsHtml = options.actions; + } + + // Add new action form + AgentFormUtils.addDynamicComponent('action_box', AgentFormTemplates.actionTemplate, { + className: 'action', + options: optionsHtml + }); + }); + } + + // Add prompt block button + const dynamicButton = document.getElementById('dynamic_button'); + if (dynamicButton) { + dynamicButton.addEventListener('click', function() { + // Create options string + let optionsHtml = ''; + if (options.promptBlocks) { + optionsHtml = options.promptBlocks; + } + + // Add new prompt block form + AgentFormUtils.addDynamicComponent('dynamic_box', AgentFormTemplates.promptBlockTemplate, { + className: 'prompt_block', + options: optionsHtml + }); + }); + } +} + +// Simple toast notification function +function showToast(message, type) { + // Check if toast container exists, if not create it + let toast = document.getElementById('toast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'toast'; + toast.className = 'toast'; + + const toastMessage = document.createElement('div'); + toastMessage.id = 'toast-message'; + toast.appendChild(toastMessage); + + document.body.appendChild(toast); + } + + const toastMessage = document.getElementById('toast-message'); + + // Set message + toastMessage.textContent = message; + + // Set type class + toast.className = 'toast'; + toast.classList.add(`toast-${type}`); + + // Show toast + toast.classList.add('show'); + + // Hide after 3 seconds + setTimeout(() => { + toast.classList.remove('show'); + }, 3000); +} diff --git a/webui/public/js/common.js b/webui/public/js/common.js new file mode 100644 index 0000000..d0cf5eb --- /dev/null +++ b/webui/public/js/common.js @@ -0,0 +1,44 @@ + // Function to show toast notifications with enhanced animation + function showToast(message, type) { + const toast = document.getElementById('toast'); + const toastMessage = document.getElementById('toast-message'); + + // Set message + toastMessage.textContent = message; + + // Set toast type (success/error) + toast.className = 'toast'; + toast.classList.add(type === 'success' ? 'toast-success' : 'toast-error'); + + // Show toast with enhanced animation + setTimeout(() => { + toast.classList.add('toast-visible'); + }, 100); + + // Hide toast after 3 seconds with animation + setTimeout(() => { + toast.classList.remove('toast-visible'); + + // Clean up after animation completes + setTimeout(() => { + toast.className = 'toast'; + }, 400); + }, 3000); +} + +// Function to create the glitch effect on headings +document.addEventListener('DOMContentLoaded', function() { + const headings = document.querySelectorAll('h1'); + + headings.forEach(heading => { + heading.addEventListener('mouseover', function() { + this.style.animation = 'glitch 0.3s infinite'; + }); + + heading.addEventListener('mouseout', function() { + this.style.animation = 'neonPulse 2s infinite'; + }); + }); + + +}); \ No newline at end of file diff --git a/webui/public/js/connector-templates.js b/webui/public/js/connector-templates.js new file mode 100644 index 0000000..f093989 --- /dev/null +++ b/webui/public/js/connector-templates.js @@ -0,0 +1,475 @@ +/** + * Connector Templates + * + * This file contains templates for all connector types supported by LocalAgent. + * Each template is a function that returns an HTML string for the connector's form. + * + * Note: We don't need to escape HTML in the value attributes because browsers + * handle these values safely when setting them via DOM properties after rendering. + */ + +/** + * Connector Templates + * Each function takes a config object and returns an HTML string + */ +const ConnectorTemplates = { + /** + * Telegram Connector Template + * @param {Object} config - Existing configuration values + * @param {Number} index - Connector index + * @returns {Object} HTML template and setValues function + */ + telegram: function(config = {}, index) { + // Return HTML without values in the template string + const html = ` +
+ + + Get this from @BotFather on Telegram +
+ `; + + // Function to set values after HTML is added to DOM to avoid XSS + const setValues = function() { + const input = document.getElementById(`telegramToken${index}`); + if (input) input.value = config.token || ''; + }; + + return { html, setValues }; + }, + + /** + * Slack Connector Template + * @param {Object} config - Existing configuration values + * @param {Number} index - Connector index + * @returns {Object} HTML template and setValues function + */ + slack: function(config = {}, index) { + // Return HTML without values in the template string + const html = ` +
+ + + App-level token starting with xapp- +
+ +
+ + + Bot token starting with xoxb- +
+ +
+ + + Channel ID where the bot will operate +
+ +
+ + + If checked, the bot will reply to all messages in the channel +
+ `; + + // Function to set values after HTML is added to DOM to avoid XSS + const setValues = function() { + const appTokenInput = document.getElementById(`slackAppToken${index}`); + const botTokenInput = document.getElementById(`slackBotToken${index}`); + const channelIDInput = document.getElementById(`slackChannelID${index}`); + const alwaysReplyInput = document.getElementById(`slackAlwaysReply${index}`); + + if (appTokenInput) appTokenInput.value = config.appToken || ''; + if (botTokenInput) botTokenInput.value = config.botToken || ''; + if (channelIDInput) channelIDInput.value = config.channelID || ''; + if (alwaysReplyInput) alwaysReplyInput.checked = config.alwaysReply === 'true'; + }; + + return { html, setValues }; + }, + + /** + * Discord Connector Template + * @param {Object} config - Existing configuration values + * @param {Number} index - Connector index + * @returns {Object} HTML template and setValues function + */ + discord: function(config = {}, index) { + // Return HTML without values in the template string + const html = ` +
+ + +
+ +
+ + +
+ `; + + // Function to set values after HTML is added to DOM + const setValues = function() { + const tokenInput = document.getElementById(`discordToken${index}`); + const channelIDInput = document.getElementById(`discordChannelID${index}`); + + if (tokenInput) tokenInput.value = config.token || ''; + if (channelIDInput) channelIDInput.value = config.defaultChannel || ''; + }; + + return { html, setValues }; + }, + + /** + * GitHub Issues Connector Template + * @param {Object} config - Existing configuration values + * @param {Number} index - Connector index + * @returns {Object} HTML template and setValues function + */ + 'github-issues': function(config = {}, index) { + // Return HTML without values in the template string + const html = ` +
+ + + Needs repo and read:org permissions +
+ +
+ + +
+ +
+ + +
+ +
+ + + If checked, the bot will reply to issues that have no replies yet +
+ +
+ + + How often to check for new issues (in seconds) +
+ `; + + // Function to set values after HTML is added to DOM to avoid XSS + const setValues = function() { + const tokenInput = document.getElementById(`githubIssuesToken${index}`); + const ownerInput = document.getElementById(`githubIssuesOwner${index}`); + const repoInput = document.getElementById(`githubIssuesRepo${index}`); + const replyIfNoRepliesInput = document.getElementById(`githubIssuesReplyIfNoReplies${index}`); + const pollIntervalInput = document.getElementById(`githubIssuesPollInterval${index}`); + + if (tokenInput) tokenInput.value = config.token || ''; + if (ownerInput) ownerInput.value = config.owner || ''; + if (repoInput) repoInput.value = config.repository || ''; + if (replyIfNoRepliesInput) replyIfNoRepliesInput.checked = config.replyIfNoReplies === 'true'; + if (pollIntervalInput) pollIntervalInput.value = config.pollInterval || '60'; + }; + + return { html, setValues }; + }, + + /** + * GitHub PRs Connector Template + * @param {Object} config - Existing configuration values + * @param {Number} index - Connector index + * @returns {Object} HTML template and setValues function + */ + 'github-prs': function(config = {}, index) { + // Return HTML without values in the template string + const html = ` +
+ + + Personal Access Token with repo permissions +
+ +
+ + +
+ +
+ + +
+ +
+ + + If checked, the bot will reply to pull requests that have no replies yet +
+ +
+ + + How often to check for new pull requests (in seconds) +
+ `; + + // Function to set values after HTML is added to DOM to avoid XSS + const setValues = function() { + const tokenInput = document.getElementById(`githubPRsToken${index}`); + const ownerInput = document.getElementById(`githubPRsOwner${index}`); + const repoInput = document.getElementById(`githubPRsRepo${index}`); + const replyIfNoRepliesInput = document.getElementById(`githubPRsReplyIfNoReplies${index}`); + const pollIntervalInput = document.getElementById(`githubPRsPollInterval${index}`); + + if (tokenInput) tokenInput.value = config.token || ''; + if (ownerInput) ownerInput.value = config.owner || ''; + if (repoInput) repoInput.value = config.repository || ''; + if (replyIfNoRepliesInput) replyIfNoRepliesInput.checked = config.replyIfNoReplies === 'true'; + if (pollIntervalInput) pollIntervalInput.value = config.pollInterval || '60'; + }; + + return { html, setValues }; + }, + + /** + * IRC Connector Template + * @param {Object} config - Existing configuration values + * @param {Number} index - Connector index + * @returns {Object} HTML template and setValues function + */ + irc: function(config = {}, index) { + // Return HTML without values in the template string + const html = ` +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + If checked, the bot will always reply to messages, even if they are not directed at it +
+ `; + + // Function to set values after HTML is added to DOM + const setValues = function() { + const serverInput = document.getElementById(`ircServer${index}`); + const portInput = document.getElementById(`ircPort${index}`); + const channelInput = document.getElementById(`ircChannel${index}`); + const nickInput = document.getElementById(`ircNick${index}`); + const alwaysReplyInput = document.getElementById(`ircAlwaysReply${index}`); + + if (serverInput) serverInput.value = config.server || ''; + if (portInput) portInput.value = config.port || '6667'; + if (channelInput) channelInput.value = config.channel || ''; + if (nickInput) nickInput.value = config.nickname || ''; + if (alwaysReplyInput) alwaysReplyInput.checked = config.alwaysReply === 'true'; + }; + + return { html, setValues }; + }, + + /** + * Twitter Connector Template + * @param {Object} config - Existing configuration values + * @param {Number} index - Connector index + * @returns {Object} HTML template and setValues function + */ + twitter: function(config = {}, index) { + // Return HTML without values in the template string + const html = ` +
+ + +
+ +
+ + + Username of your Twitter bot (with or without @) +
+ +
+ + + If checked, the bot will not enforce Twitter's character limit +
+ `; + + // Function to set values after HTML is added to DOM + const setValues = function() { + const tokenInput = document.getElementById(`twitterToken${index}`); + const botUsernameInput = document.getElementById(`twitterBotUsername${index}`); + const noCharLimitInput = document.getElementById(`twitterNoCharLimit${index}`); + + if (tokenInput) tokenInput.value = config.token || ''; + if (botUsernameInput) botUsernameInput.value = config.botUsername || ''; + if (noCharLimitInput) noCharLimitInput.checked = config.noCharacterLimit === 'true'; + }; + + return { html, setValues }; + }, + + /** + * Fallback template for any connector without a specific template + * @param {Object} config - Existing configuration values + * @param {Number} index - Connector index + * @returns {Object} HTML template and setValues function + */ + fallback: function(config = {}, index) { + // Convert config to a pretty-printed JSON string + let configStr = '{}'; + try { + if (typeof config === 'string') { + // If it's already a string, try to parse it first to pretty-print + configStr = JSON.stringify(JSON.parse(config), null, 2); + } else if (typeof config === 'object' && config !== null) { + configStr = JSON.stringify(config, null, 2); + } + } catch (e) { + console.error('Error formatting config:', e); + // If it's a string but not valid JSON, just use it as is + if (typeof config === 'string') { + configStr = config; + } + } + + // Return HTML without values in the template string + const html = ` +
+ + + Enter the connector configuration as a JSON object +
+ `; + + // Function to set values after HTML is added to DOM + const setValues = function() { + const configInput = document.getElementById(`connectorConfig${index}`); + + if (configInput) configInput.value = configStr; + }; + + return { html, setValues }; + } +}; diff --git a/webui/public/js/wizard.js b/webui/public/js/wizard.js new file mode 100644 index 0000000..6be5558 --- /dev/null +++ b/webui/public/js/wizard.js @@ -0,0 +1,139 @@ +/** + * Agent Form Wizard - Navigation and UI functionality + */ +document.addEventListener('DOMContentLoaded', function() { + // Check if the wizard exists on the page + const wizardSidebar = document.querySelector('.wizard-sidebar'); + if (!wizardSidebar) return; + + // Get all sections and nav items + const navItems = document.querySelectorAll('.wizard-nav-item'); + const sections = document.querySelectorAll('.form-section'); + const prevButton = document.getElementById('prevSection'); + const nextButton = document.getElementById('nextSection'); + const currentStepLabelEl = document.getElementById('currentStepLabel'); + const progressDotsContainer = document.getElementById('progressDots'); + + // Create progress dots + const totalSteps = sections.length; + + // Create dots for each section + if (progressDotsContainer) { + for (let i = 0; i < totalSteps; i++) { + const dot = document.createElement('div'); + dot.className = 'progress-dot'; + dot.setAttribute('data-index', i); + dot.addEventListener('click', () => setActiveSection(i)); + progressDotsContainer.appendChild(dot); + } + } + + // Get all progress dots + const progressDots = document.querySelectorAll('.progress-dot'); + + // Track current active section + let currentSectionIndex = 0; + + // Initialize + updateNavigation(); + + // Add click events to nav items + navItems.forEach((item, index) => { + item.addEventListener('click', () => { + setActiveSection(index); + }); + }); + + // Add click events to prev/next buttons + if (prevButton) { + prevButton.addEventListener('click', () => { + if (currentSectionIndex > 0) { + setActiveSection(currentSectionIndex - 1); + } + }); + } + + if (nextButton) { + nextButton.addEventListener('click', () => { + if (currentSectionIndex < sections.length - 1) { + setActiveSection(currentSectionIndex + 1); + } + }); + } + + /** + * Set the active section and update navigation + */ + function setActiveSection(index) { + // Remove active class from all sections and nav items + sections.forEach(section => section.classList.remove('active')); + navItems.forEach(item => item.classList.remove('active')); + progressDots.forEach(dot => dot.classList.remove('active')); + + // Add active class to current section, nav item, and dot + sections[index].classList.add('active'); + navItems[index].classList.add('active'); + if (progressDots[index]) { + progressDots[index].classList.add('active'); + } + + // Update current section index + currentSectionIndex = index; + + // Update navigation state + updateNavigation(); + + // Scroll to top of section + sections[index].scrollIntoView({behavior: 'smooth', block: 'start'}); + } + + /** + * Update navigation buttons and progress + */ + function updateNavigation() { + // Update section label + if (currentStepLabelEl && navItems[currentSectionIndex]) { + // Extract text content without the icon + const navText = navItems[currentSectionIndex].textContent.trim(); + currentStepLabelEl.textContent = navText; + } + + // Update prev/next buttons + if (prevButton) { + prevButton.disabled = currentSectionIndex === 0; + prevButton.style.opacity = currentSectionIndex === 0 ? 0.5 : 1; + } + + if (nextButton) { + nextButton.disabled = currentSectionIndex === sections.length - 1; + nextButton.style.opacity = currentSectionIndex === sections.length - 1 ? 0.5 : 1; + + // Change text for last step + if (currentSectionIndex === sections.length - 2) { + nextButton.innerHTML = 'Finish '; + } else { + nextButton.innerHTML = 'Next '; + } + } + } + + // Helper function to validate current section before proceeding + function validateCurrentSection() { + // Implement validation logic here based on the current section + // Return true if valid, false if not + return true; + } + + // Add to initAgentFormCommon function if it exists + if (typeof window.initAgentFormCommon === 'function') { + const originalInit = window.initAgentFormCommon; + + window.initAgentFormCommon = function(options) { + // Call the original initialization function + originalInit(options); + + // Now initialize the wizard navigation + setActiveSection(0); + }; + } +}); \ No newline at end of file diff --git a/webui/public/logo_1.png b/webui/public/logo_1.png new file mode 100644 index 0000000..117ccc6 Binary files /dev/null and b/webui/public/logo_1.png differ diff --git a/webui/routes.go b/webui/routes.go index efe2384..fa2ceae 100644 --- a/webui/routes.go +++ b/webui/routes.go @@ -21,6 +21,12 @@ import ( "github.com/mudler/LocalAgent/services" ) +//go:embed views/* +var viewsfs embed.FS + +//go:embed public/* +var embeddedFiles embed.FS + //go:embed react-ui/dist/* var reactUI embed.FS @@ -33,6 +39,12 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) { Browse: true, })) + /* webapp.Use("/public", filesystem.New(filesystem.Config{ + Root: http.FS(embeddedFiles), + PathPrefix: "public", + Browse: true, + })) + */ if len(app.config.ApiKeys) > 0 { kaConfig, err := GetKeyAuthConfig(app.config.ApiKeys) if err != nil || kaConfig == nil { @@ -41,9 +53,15 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) { webapp.Use(v2keyauth.New(*kaConfig)) } - webapp.Get("/", func(c *fiber.Ctx) error { - return c.Redirect("/app") - }) + /* webapp.Get("/", func(c *fiber.Ctx) error { + return c.Render("views/index", fiber.Map{ + "Agents": pool.List(), + "AgentCount": len(pool.List()), + "Actions": len(services.AvailableActions), + "Connectors": len(services.AvailableConnectors), + }) + }) + */ webapp.Use("/app", filesystem.New(filesystem.Config{ Root: http.FS(reactUI), PathPrefix: "react-ui/dist", @@ -59,6 +77,31 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) { return c.Send(indexHTML) }) + /* webapp.Get("/agents", func(c *fiber.Ctx) error { + statuses := map[string]bool{} + for _, a := range pool.List() { + agent := pool.GetAgent(a) + if agent == nil { + xlog.Error("Agent not found", "name", a) + continue + } + statuses[a] = !agent.Paused() + } + return c.Render("views/agents", fiber.Map{ + "Agents": pool.List(), + "Status": statuses, + }) + }) + */ + /* webapp.Get("/api/create", func(c *fiber.Ctx) error { + return c.Render("views/create", fiber.Map{ + "Actions": services.AvailableActions, + "Connectors": services.AvailableConnectors, + "PromptBlocks": services.AvailableBlockPrompts, + }) + }) + */ + // Define a route for the GET method on the root path '/' webapp.Get("/sse/:name", func(c *fiber.Ctx) error { m := pool.GetManager(c.Params("name")) if m == nil { @@ -69,18 +112,70 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) { return nil }) + /*webapp.Get("/status/:name", func(c *fiber.Ctx) error { + history := pool.GetStatusHistory(c.Params("name")) + if history == nil { + history = &state.Status{ActionResults: []types.ActionState{}} + } + // reverse history + + return c.Render("views/status", fiber.Map{ + "Name": c.Params("name"), + "History": Reverse(history.Results()), + }) + }) + + webapp.Get("/notify/:name", app.Notify(pool)) + webapp.Post("/chat/:name", app.Chat(pool)) + */ webapp.Post("/api/agent/create", app.Create(pool)) webapp.Delete("/api/agent/:name", app.Delete(pool)) webapp.Put("/api/agent/:name/pause", app.Pause(pool)) webapp.Put("/api/agent/:name/start", app.Start(pool)) - webapp.Post("/api/chat/:name", app.Chat(pool)) - webapp.Post("/api/notify/:name", app.Notify(pool)) + // Add JSON-based chat API endpoint + webapp.Post("/api/chat/:name", app.ChatAPI(pool)) conversationTracker := connectors.NewConversationTracker[string](app.config.ConversationStoreDuration) webapp.Post("/v1/responses", app.Responses(pool, conversationTracker)) + /* webapp.Get("/talk/:name", func(c *fiber.Ctx) error { + return c.Render("views/chat", fiber.Map{ + // "Character": agent.Character, + "Name": c.Params("name"), + }) + }) + + webapp.Get("/settings/:name", func(c *fiber.Ctx) error { + status := false + for _, a := range pool.List() { + if a == c.Params("name") { + status = !pool.GetAgent(a).Paused() + } + } + + return c.Render("views/settings", fiber.Map{ + "Name": c.Params("name"), + "Status": status, + "Actions": services.AvailableActions, + "Connectors": services.AvailableConnectors, + "PromptBlocks": services.AvailableBlockPrompts, + }) + }) + + webapp.Get("/actions-playground", func(c *fiber.Ctx) error { + return c.Render("views/actions", fiber.Map{}) + }) + + webapp.Get("/group-create", func(c *fiber.Ctx) error { + return c.Render("views/group-create", fiber.Map{ + "Actions": services.AvailableActions, + "Connectors": services.AvailableConnectors, + "PromptBlocks": services.AvailableBlockPrompts, + }) + }) + */ // New API endpoints for getting and updating agent configuration webapp.Get("/api/agent/:name/config", app.GetAgentConfig(pool)) webapp.Put("/api/agent/:name/config", app.UpdateAgentConfig(pool)) diff --git a/webui/views/actions.html b/webui/views/actions.html new file mode 100644 index 0000000..f4e2c33 --- /dev/null +++ b/webui/views/actions.html @@ -0,0 +1,291 @@ + + + + + + Actions Playground + {{template "views/partials/header"}} + + + {{template "views/partials/menu"}} + + +
+ +
+ +
+
+

Actions Playground

+

Test and execute actions directly from the UI

+
+ +
+

Select an Action

+
+ + +
+
+ + + + + + +
+ + + + + + \ No newline at end of file diff --git a/webui/views/agents.html b/webui/views/agents.html new file mode 100644 index 0000000..1781f4a --- /dev/null +++ b/webui/views/agents.html @@ -0,0 +1,332 @@ + + + + + + Agent List + {{template "views/partials/header"}} + + + + {{template "views/partials/menu"}} + + +
+ +
+ +
+
+

Agent List

+

Manage and interact with your AI agents

+
+ +
+ + Add New Agent + + +
+ + + +
+ {{ $status := .Status }} + {{ range .Agents }} +
+
+
+ +
+ +
+
+

{{.}}

+
+ + {{ if eq (index $status .) true }}Active{{ else }}Inactive{{ end }} + +
+ + + +
+ + + + +
+
+
+ {{ end }} +
+ +
+ + +
+ + +
+ + + + \ No newline at end of file diff --git a/webui/views/chat.html b/webui/views/chat.html new file mode 100644 index 0000000..92a86f0 --- /dev/null +++ b/webui/views/chat.html @@ -0,0 +1,91 @@ + + + + Smart Agent Interface + {{template "views/partials/header"}} + + + + {{template "views/partials/menu"}} +
+ + +
+

Talk to '{{.Name}}'

+
+ + +
+ +
+

Clients:

+
+ +
+
+
+ +
+

Status:

+
+ +
+
+
+
+
+ + +
+

Agent:

+
+ +
+
+
+ + +
+
+ +
+
+
+ +
+ + + + diff --git a/webui/views/create.html b/webui/views/create.html new file mode 100644 index 0000000..95413dc --- /dev/null +++ b/webui/views/create.html @@ -0,0 +1,154 @@ + + + + Create New Agent + {{template "views/partials/header"}} + + + + + + + {{template "views/partials/menu"}} +
+
+

Create New Agent

+ +
+ {{template "views/partials/agent-form" . }} + + +
+
+ + +
+ + + + +
+
+ + +
+ +
+ + + + diff --git a/webui/views/group-create.html b/webui/views/group-create.html new file mode 100644 index 0000000..f378e54 --- /dev/null +++ b/webui/views/group-create.html @@ -0,0 +1,600 @@ + + + + Create Agent Group + {{template "views/partials/header"}} + + + + + + + + {{template "views/partials/menu"}} +
+
+

Create Agent Group

+ + +
+
+
1
+
Generate Profiles
+
+
+
2
+
Review & Select
+
+
+
3
+
Configure Settings
+
+
+ + +
+

Generate Agent Profiles

+

Describe the group of agents you want to create. Be specific about their roles, relationships, and purpose.

+ +
+ +
+ +
+ +
+
+ + +
+ +

Generating agent profiles...

+
+ + +
+

Review & Select Agent Profiles

+

Select the agents you want to create. You can customize their details before creation.

+ +
+ +
+ +
+ +
+ +
+ + +
+
+ + +
+

Configure Common Settings

+

Configure common settings for all selected agents. These settings will be applied to each agent.

+ +
+ +
+ +
+

Basic Information from Profiles

+

The name, description, and system prompt for each agent will be taken from the profiles you selected in the previous step.

+
+
+ + +
+ {{template "views/partials/agent-form" . }} +
+
+ +
+ + +
+
+
+ + +
+ + + + +
+
+ + +
+ +
+ + + + diff --git a/webui/views/index.html b/webui/views/index.html new file mode 100644 index 0000000..ec6e144 --- /dev/null +++ b/webui/views/index.html @@ -0,0 +1,224 @@ + + + + Smart Assistant Dashboard + {{template "views/partials/header"}} + + + + {{template "views/partials/menu"}} +
+ + +
+ Company Logo +
+ +

LocalAgent

+ + +
+
+
{{.Actions}}
+
Available Actions
+
+
+
{{.Connectors}}
+
Available Connectors
+
+
+
{{ .AgentCount }}
+
Agents
+
+
+ +
+ + +
+

Agent List

+

View and manage your list of agents, including detailed profiles and statistics.

+
+
+ + + +
+

Create Agent

+

Create a new intelligent agent with custom behaviors, connectors, and actions.

+
+
+ + + +
+

Analytics

+

View performance metrics and insights from your agent operations.

+ Coming Soon +
+
+ + +
+

Settings

+

Configure system preferences and global settings for all agents.

+ Coming Soon +
+
+
+
+ + +
+ +
+ + + + \ No newline at end of file diff --git a/webui/views/login.html b/webui/views/login.html new file mode 100644 index 0000000..01a5f86 --- /dev/null +++ b/webui/views/login.html @@ -0,0 +1,215 @@ + + +{{template "views/partials/header" .}} + + +
+ + {{template "views/partials/menu" .}} + +
+ +
+
+
+ +
+
+ +
+
+

+ + Authorization Required + +

+

Please enter your access token to continue

+
+ +
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+ + Instance is token protected +
+

Current time (UTC): {{.CurrentDate}}

+
+
+
+
+ +
+ + + + + \ No newline at end of file diff --git a/webui/views/partials/agent-form.html b/webui/views/partials/agent-form.html new file mode 100644 index 0000000..42353ad --- /dev/null +++ b/webui/views/partials/agent-form.html @@ -0,0 +1,307 @@ +
+ +
+ +
+ + +
+ +
+

Basic Information

+ +
+ + {{ if .Name }} + + {{ else }} + + {{ end }} +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+
+ + +
+

Connectors

+ +
+ +
+ +
+ +
+
+ + +
+

Actions

+ +
+ +
+ +
+ +
+
+ + +
+

MCP Servers

+ +
+ +
+ +
+ +
+
+ + +
+

Memory Settings

+ +
+ +
+ +
+ + +
+ +
+ +
+ +
+ +
+
+ + +
+

Model Settings

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+

Prompts & Goals

+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+ + +
+

Advanced Settings

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+
+ + +
+
+ +
+ +
+
+ +
+
+ Basic Information +
+
+ +
+ +
+
\ No newline at end of file diff --git a/webui/views/partials/header.html b/webui/views/partials/header.html new file mode 100644 index 0000000..6c5b1a4 --- /dev/null +++ b/webui/views/partials/header.html @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/webui/views/partials/menu.html b/webui/views/partials/menu.html new file mode 100644 index 0000000..84eaaed --- /dev/null +++ b/webui/views/partials/menu.html @@ -0,0 +1,105 @@ + +
+ + \ No newline at end of file diff --git a/webui/views/settings.html b/webui/views/settings.html new file mode 100644 index 0000000..e3f2a11 --- /dev/null +++ b/webui/views/settings.html @@ -0,0 +1,462 @@ + + + + Agent settings {{.Name}} + {{template "views/partials/header"}} + + + + + + + {{template "views/partials/menu"}} + + +
+ +
+ +
+
+

Agent settings - {{.Name}}

+
+ +
+ + +
+

Edit Agent Configuration

+
+ + + {{template "views/partials/agent-form" .}} + + +
+
+ +
+

Agent Control

+
+ +
+
+ +
+

Export Data

+

Export your agent configuration for backup or transfer.

+ +
+ +
+

Danger Zone

+

Permanently delete this agent and all associated data. This action cannot be undone.

+ +
+ + +
+
+ + + + \ No newline at end of file diff --git a/webui/views/status.html b/webui/views/status.html new file mode 100644 index 0000000..8339c3f --- /dev/null +++ b/webui/views/status.html @@ -0,0 +1,61 @@ + + + + Smart Agent status + {{template "views/partials/header"}} + + + + {{template "views/partials/menu"}} +
+ +
+

{{.Name}}

+
+ +
+
+ {{ range .History }} + +
+

Agent:

+
+ Result: {{.Result}} Action: {{.Action}} Params: {{.Params}} Reasoning: {{.Reasoning}} +
+
+ {{end}} +
+
+ +