feat: add Gemini embedding provider, agent registration UI, and UI polish
- Add Gemini embedding provider alongside OpenAI and Ollama - Improve agent registration form with API key display and MCP config - Polish dashboard, login page, and overall UI styling - Update CLAUDE.md with embedding environment variables - Add console command infrastructure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f5ae132ef4
commit
f12824cda9
@@ -65,7 +65,8 @@ make lint # Run linters
|
||||
| `SYNAPBUS_PORT` | HTTP server port | `8080` |
|
||||
| `SYNAPBUS_DATA_DIR` | Data directory (SQLite DB, attachments, vector index) | `./data` |
|
||||
| `SYNAPBUS_EMBEDDING_PROVIDER` | Embedding provider: `openai`, `gemini`, `ollama` | (none) |
|
||||
| `SYNAPBUS_EMBEDDING_API_KEY` | API key for embedding provider | (none) |
|
||||
| `OPENAI_API_KEY` | OpenAI API key for embeddings | (none) |
|
||||
| `GEMINI_API_KEY` | Google Gemini API key for embeddings | (none) |
|
||||
| `SYNAPBUS_OLLAMA_URL` | Ollama server URL | `http://localhost:11434` |
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -64,7 +64,8 @@ Agents interact with SynapBus entirely through MCP tools:
|
||||
| `SYNAPBUS_PORT` | HTTP server port | `8080` |
|
||||
| `SYNAPBUS_DATA_DIR` | Data directory | `./data` |
|
||||
| `SYNAPBUS_EMBEDDING_PROVIDER` | `openai` / `gemini` / `ollama` | (none) |
|
||||
| `SYNAPBUS_EMBEDDING_API_KEY` | Embedding API key | (none) |
|
||||
| `OPENAI_API_KEY` | OpenAI API key for embeddings | (none) |
|
||||
| `GEMINI_API_KEY` | Google Gemini API key for embeddings | (none) |
|
||||
| `SYNAPBUS_OLLAMA_URL` | Ollama server URL | `http://localhost:11434` |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -212,6 +212,22 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
channelStore := channels.NewSQLiteChannelStore(db.DB)
|
||||
channelService := channels.NewService(channelStore, msgService, tracer)
|
||||
|
||||
// Ensure #general channel exists
|
||||
_, err = channelService.CreateChannel(ctx, channels.CreateChannelRequest{
|
||||
Name: "general",
|
||||
Description: "General discussion",
|
||||
Type: "standard",
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
// Ignore "already exists" errors
|
||||
if !strings.Contains(err.Error(), "UNIQUE") && !strings.Contains(err.Error(), "already exists") {
|
||||
slog.Warn("failed to create #general channel", "error", err)
|
||||
}
|
||||
} else {
|
||||
slog.Info("created default #general channel")
|
||||
}
|
||||
|
||||
// Create swarm service (task auction + stigmergy)
|
||||
taskStore := channels.NewSQLiteTaskStore(db.DB)
|
||||
swarmService := channels.NewSwarmService(taskStore, channelStore, tracer)
|
||||
@@ -272,6 +288,22 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
var embPipeline *search.Pipeline
|
||||
var vectorIndex *search.VectorIndex
|
||||
|
||||
// Print available embedding providers
|
||||
providers := embedding.AvailableProviders(searchCfg.APIKey, searchCfg.OllamaURL)
|
||||
slog.Info("available embedding providers:")
|
||||
for _, p := range providers {
|
||||
status := "not configured"
|
||||
if p.Configured {
|
||||
status = "ready"
|
||||
}
|
||||
slog.Info(" embedding provider",
|
||||
"name", p.Name,
|
||||
"model", p.Model,
|
||||
"dimensions", p.Dimensions,
|
||||
"status", status,
|
||||
)
|
||||
}
|
||||
|
||||
if searchCfg.IsEnabled() {
|
||||
slog.Info("initializing semantic search",
|
||||
"provider", searchCfg.Provider,
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// Config holds configuration for the search subsystem.
|
||||
type Config struct {
|
||||
// Provider specifies the embedding provider: "openai", "ollama", or empty for none.
|
||||
// Provider specifies the embedding provider: "openai", "gemini", "ollama", or empty for none.
|
||||
Provider string
|
||||
// APIKey is the API key for the embedding provider (required for openai).
|
||||
APIKey string
|
||||
@@ -31,7 +31,7 @@ type Config struct {
|
||||
func LoadConfigFromEnv() Config {
|
||||
cfg := Config{
|
||||
Provider: os.Getenv("SYNAPBUS_EMBEDDING_PROVIDER"),
|
||||
APIKey: os.Getenv("SYNAPBUS_EMBEDDING_API_KEY"),
|
||||
APIKey: getAPIKey(),
|
||||
OllamaURL: os.Getenv("SYNAPBUS_OLLAMA_URL"),
|
||||
BatchSize: 10,
|
||||
WorkerCount: 1,
|
||||
@@ -59,6 +59,15 @@ func LoadConfigFromEnv() Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// getAPIKey returns the embedding API key, preferring OPENAI_API_KEY
|
||||
// over the legacy SYNAPBUS_EMBEDDING_API_KEY.
|
||||
func getAPIKey() string {
|
||||
if key := os.Getenv("OPENAI_API_KEY"); key != "" {
|
||||
return key
|
||||
}
|
||||
return os.Getenv("SYNAPBUS_EMBEDDING_API_KEY")
|
||||
}
|
||||
|
||||
// IsEnabled returns true if an embedding provider is configured.
|
||||
func (c Config) IsEnabled() bool {
|
||||
return c.Provider != ""
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
package embedding
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ProviderInfo describes an available embedding provider.
|
||||
type ProviderInfo struct {
|
||||
Name string
|
||||
Model string
|
||||
Dimensions int
|
||||
Configured bool
|
||||
}
|
||||
|
||||
// AvailableProviders returns info about all supported providers and whether
|
||||
// they have the required credentials configured.
|
||||
func AvailableProviders(apiKey, ollamaURL string) []ProviderInfo {
|
||||
geminiKey := os.Getenv("GEMINI_API_KEY")
|
||||
return []ProviderInfo{
|
||||
{Name: "openai", Model: openAIModel, Dimensions: openAIDimensions, Configured: apiKey != ""},
|
||||
{Name: "gemini", Model: geminiModel, Dimensions: geminiDimensions, Configured: geminiKey != ""},
|
||||
{Name: "ollama", Model: ollamaDefaultModel, Dimensions: ollamaDimensions, Configured: true},
|
||||
}
|
||||
}
|
||||
|
||||
// NewProvider creates an EmbeddingProvider based on the provider name.
|
||||
func NewProvider(provider, apiKey, ollamaURL string) (EmbeddingProvider, error) {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return NewOpenAIProvider(apiKey)
|
||||
case "gemini":
|
||||
geminiKey := os.Getenv("GEMINI_API_KEY")
|
||||
return NewGeminiProvider(geminiKey)
|
||||
case "ollama":
|
||||
return NewOllamaProvider(ollamaURL)
|
||||
case "":
|
||||
return nil, fmt.Errorf("no embedding provider specified")
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown embedding provider: %q (supported: openai, ollama)", provider)
|
||||
return nil, fmt.Errorf("unknown embedding provider: %q (supported: openai, gemini, ollama)", provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiModel = "text-embedding-004"
|
||||
geminiDimensions = 768
|
||||
geminiMaxTokens = 2048
|
||||
)
|
||||
|
||||
// GeminiProvider implements EmbeddingProvider using Google's text-embedding-004.
|
||||
type GeminiProvider struct {
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewGeminiProvider creates a new Gemini embedding provider.
|
||||
func NewGeminiProvider(apiKey string) (*GeminiProvider, error) {
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("gemini provider requires GEMINI_API_KEY to be set")
|
||||
}
|
||||
return &GeminiProvider{
|
||||
apiKey: apiKey,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type geminiRequest struct {
|
||||
Requests []geminiEmbedRequest `json:"requests"`
|
||||
}
|
||||
|
||||
type geminiEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Content geminiContent `json:"content"`
|
||||
TaskType string `json:"taskType,omitempty"`
|
||||
}
|
||||
|
||||
type geminiContent struct {
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type geminiResponse struct {
|
||||
Embeddings []geminiEmbeddingResult `json:"embeddings"`
|
||||
Error *geminiError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type geminiEmbeddingResult struct {
|
||||
Values []float32 `json:"values"`
|
||||
}
|
||||
|
||||
type geminiError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
results, err := p.EmbedBatch(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("gemini: empty response")
|
||||
}
|
||||
return results[0], nil
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
if len(texts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Truncate texts that are too long
|
||||
truncated := make([]string, len(texts))
|
||||
for i, t := range texts {
|
||||
maxChars := geminiMaxTokens * 4
|
||||
if len(t) > maxChars {
|
||||
truncated[i] = t[:maxChars]
|
||||
} else {
|
||||
truncated[i] = t
|
||||
}
|
||||
}
|
||||
|
||||
// Build batch request
|
||||
modelPath := "models/" + geminiModel
|
||||
requests := make([]geminiEmbedRequest, len(truncated))
|
||||
for i, t := range truncated {
|
||||
requests[i] = geminiEmbedRequest{
|
||||
Model: modelPath,
|
||||
Content: geminiContent{
|
||||
Parts: []geminiPart{{Text: t}},
|
||||
},
|
||||
TaskType: "RETRIEVAL_DOCUMENT",
|
||||
}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(geminiRequest{Requests: requests})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gemini: marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/%s:batchEmbedContents?key=%s", modelPath, p.apiKey)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gemini: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gemini: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gemini: read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusBadRequest:
|
||||
return nil, fmt.Errorf("gemini: invalid request (400): %s", string(respBody))
|
||||
case http.StatusForbidden:
|
||||
return nil, fmt.Errorf("gemini: invalid API key (403)")
|
||||
case http.StatusTooManyRequests:
|
||||
return nil, fmt.Errorf("gemini: rate limited (429)")
|
||||
default:
|
||||
return nil, fmt.Errorf("gemini: API error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
}
|
||||
|
||||
var result geminiResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("gemini: unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("gemini: %s: %s", result.Error.Status, result.Error.Message)
|
||||
}
|
||||
|
||||
if len(result.Embeddings) != len(texts) {
|
||||
return nil, fmt.Errorf("gemini: expected %d embeddings, got %d", len(texts), len(result.Embeddings))
|
||||
}
|
||||
|
||||
embeddings := make([][]float32, len(texts))
|
||||
for i, e := range result.Embeddings {
|
||||
embeddings[i] = e.Values
|
||||
}
|
||||
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Dimensions() int { return geminiDimensions }
|
||||
func (p *GeminiProvider) Name() string { return "gemini" }
|
||||
@@ -26,7 +26,7 @@ type OpenAIProvider struct {
|
||||
// NewOpenAIProvider creates a new OpenAI embedding provider.
|
||||
func NewOpenAIProvider(apiKey string) (*OpenAIProvider, error) {
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("openai provider requires SYNAPBUS_EMBEDDING_API_KEY to be set")
|
||||
return nil, fmt.Errorf("openai provider requires OPENAI_API_KEY (or SYNAPBUS_EMBEDDING_API_KEY) to be set")
|
||||
}
|
||||
return &OpenAIProvider{
|
||||
apiKey: apiKey,
|
||||
|
||||
@@ -137,9 +137,22 @@
|
||||
|
||||
{#if !loadingData && recentMessages.length === 0 && agentCount === 0}
|
||||
<div class="card p-10 text-center mt-5">
|
||||
<div class="w-12 h-12 mx-auto rounded-2xl bg-accent-purple/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
<div class="w-12 h-12 mx-auto rounded-2xl bg-gradient-to-br from-accent-purple/20 to-[#06b6d4]/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-7 h-7" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd" />
|
||||
<circle cx="6" cy="10" r="1.5" fill="#a78bfa" />
|
||||
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd" />
|
||||
<circle cx="12" cy="13" r="2" fill="#67e8f9" />
|
||||
<circle cx="5" cy="17" r="1.5" fill="#a78bfa" />
|
||||
<circle cx="19" cy="17" r="1.3" fill="#a78bfa" />
|
||||
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd" />
|
||||
<line x1="12" y1="5" x2="6" y2="10" stroke="#a78bfa" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="12" y1="5" x2="18" y2="9" stroke="#a78bfa" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="6" y1="10" x2="12" y2="13" stroke="#a78bfa" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="18" y1="9" x2="12" y2="13" stroke="#a78bfa" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="12" y1="13" x2="5" y2="17" stroke="#a78bfa" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="12" y1="13" x2="19" y2="17" stroke="#a78bfa" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="5" y1="17" x2="11" y2="20" stroke="#a78bfa" stroke-width="0.5" opacity="0.3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-text-primary font-display mb-2">Welcome to SynapBus</h3>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
let registering = $state(false);
|
||||
let registerError = $state('');
|
||||
let newApiKey = $state('');
|
||||
let copiedField = $state('');
|
||||
|
||||
async function loadAgents() {
|
||||
loadingData = true;
|
||||
@@ -49,9 +50,6 @@
|
||||
type: newType
|
||||
});
|
||||
newApiKey = res.api_key;
|
||||
newName = '';
|
||||
newDisplayName = '';
|
||||
newType = 'ai';
|
||||
await loadAgents();
|
||||
} catch (err: any) {
|
||||
registerError = err.message || 'Failed to register agent';
|
||||
@@ -59,12 +57,42 @@
|
||||
registering = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
showRegister = false;
|
||||
newApiKey = '';
|
||||
newName = '';
|
||||
newDisplayName = '';
|
||||
newType = 'ai';
|
||||
registerError = '';
|
||||
}
|
||||
|
||||
async function copyText(text: string, label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copiedField = label;
|
||||
setTimeout(() => (copiedField = ''), 2000);
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
let mcpConfig = $derived(newApiKey ? JSON.stringify({
|
||||
mcpServers: {
|
||||
synapbus: {
|
||||
url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080'}/mcp`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${newApiKey}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}, null, 2) : '');
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h1 class="text-xl font-bold text-text-primary font-display">Agents</h1>
|
||||
<button class="btn-primary flex items-center gap-1.5" onclick={() => { showRegister = !showRegister; newApiKey = ''; }}>
|
||||
<button class="btn-primary flex items-center gap-1.5" onclick={() => { if (showRegister) resetForm(); else showRegister = true; }}>
|
||||
{#if showRegister}
|
||||
Cancel
|
||||
{:else}
|
||||
@@ -77,29 +105,86 @@
|
||||
</div>
|
||||
|
||||
{#if showRegister}
|
||||
<form class="card p-5 mb-5" onsubmit={handleRegister}>
|
||||
<h3 class="font-semibold text-sm text-text-primary font-display mb-3">Register New Agent</h3>
|
||||
{#if registerError}
|
||||
<div class="mb-3 px-3 py-2 bg-accent-red/10 rounded text-xs text-accent-red">{registerError}</div>
|
||||
{/if}
|
||||
{#if newApiKey}
|
||||
<div class="mb-4 p-4 bg-accent-green/10 border border-accent-green/20 rounded-lg">
|
||||
<p class="text-xs font-semibold text-accent-green mb-2">Agent registered! Save this API key - it will not be shown again:</p>
|
||||
<code class="block p-3 bg-bg-primary rounded text-xs font-mono text-text-primary break-all select-all border border-border">{newApiKey}</code>
|
||||
{#if newApiKey}
|
||||
<!-- Success state: show credentials -->
|
||||
<div class="card p-5 mb-5">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-8 h-8 rounded-full bg-accent-green/20 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-accent-green" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm text-text-primary font-display">Agent "{newName}" registered</h3>
|
||||
<p class="text-xs text-text-secondary">Save the API key below — it won't be shown again.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-3">
|
||||
<input type="text" class="input" placeholder="Agent name (e.g. researcher-01)" bind:value={newName} />
|
||||
<input type="text" class="input" placeholder="Display name (optional)" bind:value={newDisplayName} />
|
||||
<select class="input" bind:value={newType}>
|
||||
<option value="ai">AI Agent</option>
|
||||
<option value="human">Human Agent</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-primary" disabled={registering}>
|
||||
{registering ? 'Registering...' : 'Register'}
|
||||
|
||||
<!-- API Key -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label class="text-xs font-medium text-text-secondary">API Key</label>
|
||||
<button
|
||||
class="text-xs text-text-secondary hover:text-text-primary transition-colors"
|
||||
onclick={() => copyText(newApiKey, 'key')}
|
||||
>
|
||||
{copiedField === 'key' ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<code class="block p-3 bg-bg-primary rounded text-xs font-mono text-accent-green break-all select-all border border-accent-green/20">{newApiKey}</code>
|
||||
</div>
|
||||
|
||||
<!-- MCP Config -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label class="text-xs font-medium text-text-secondary">MCP Configuration</label>
|
||||
<button
|
||||
class="text-xs text-text-secondary hover:text-text-primary transition-colors"
|
||||
onclick={() => copyText(mcpConfig, 'mcp')}
|
||||
>
|
||||
{copiedField === 'mcp' ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="p-3 bg-bg-primary rounded text-xs font-mono text-text-primary break-all select-all border border-border overflow-x-auto">{mcpConfig}</pre>
|
||||
<p class="text-[10px] text-text-secondary mt-1.5">Add this to your agent's MCP configuration (e.g. claude_desktop_config.json)</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn-primary w-full"
|
||||
onclick={resetForm}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- Registration form -->
|
||||
<form class="card p-5 mb-5" onsubmit={handleRegister}>
|
||||
<h3 class="font-semibold text-sm text-text-primary font-display mb-3">Register New Agent</h3>
|
||||
{#if registerError}
|
||||
<div class="mb-3 px-3 py-2 bg-accent-red/10 rounded text-xs text-accent-red">{registerError}</div>
|
||||
{/if}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="agent-name" class="block text-xs font-medium text-text-secondary mb-1">Agent name</label>
|
||||
<input id="agent-name" type="text" class="input" placeholder="e.g. researcher-01" bind:value={newName} required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="agent-display" class="block text-xs font-medium text-text-secondary mb-1">Display name <span class="text-text-secondary">(optional)</span></label>
|
||||
<input id="agent-display" type="text" class="input" placeholder="e.g. Research Agent" bind:value={newDisplayName} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="agent-type" class="block text-xs font-medium text-text-secondary mb-1">Type</label>
|
||||
<select id="agent-type" class="input" bind:value={newType}>
|
||||
<option value="ai">AI Agent</option>
|
||||
<option value="human">Human Agent</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full" disabled={registering || !newName.trim()}>
|
||||
{registering ? 'Registering...' : 'Register Agent'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if loadingData}
|
||||
@@ -116,9 +201,10 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if agentList.length === 0}
|
||||
<div class="card p-8 text-center text-text-secondary text-sm">
|
||||
No agents registered yet.
|
||||
{:else if agentList.length === 0 && !showRegister}
|
||||
<div class="card p-8 text-center">
|
||||
<p class="text-text-secondary text-sm mb-3">No agents registered yet.</p>
|
||||
<button class="btn-primary" onclick={() => (showRegister = true)}>Register your first agent</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -36,9 +36,23 @@
|
||||
<div class="w-full max-w-sm relative z-10">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 mx-auto rounded-2xl bg-accent-purple flex items-center justify-center mb-4">
|
||||
<svg class="w-9 h-9 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
<div class="w-16 h-16 mx-auto rounded-2xl bg-gradient-to-br from-accent-purple to-[#06b6d4] flex items-center justify-center mb-4">
|
||||
<svg class="w-9 h-9" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd" />
|
||||
<circle cx="6" cy="10" r="1.5" fill="#a78bfa" />
|
||||
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd" />
|
||||
<circle cx="12" cy="13" r="2" fill="#67e8f9" />
|
||||
<circle cx="5" cy="17" r="1.5" fill="#a78bfa" />
|
||||
<circle cx="19" cy="17" r="1.3" fill="#a78bfa" />
|
||||
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd" />
|
||||
<line x1="12" y1="5" x2="6" y2="10" stroke="white" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="12" y1="5" x2="18" y2="9" stroke="white" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="6" y1="10" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="18" y1="9" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="12" y1="13" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="12" y1="13" x2="19" y2="17" stroke="white" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="5" y1="17" x2="11" y2="20" stroke="white" stroke-width="0.5" opacity="0.5" />
|
||||
<line x1="6" y1="10" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-text-primary font-display">SynapBus</h1>
|
||||
|
||||
Reference in New Issue
Block a user