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:
Algis Dumbris
2026-03-14 06:46:16 +02:00
co-authored by Claude Opus 4.6
parent f5ae132ef4
commit f12824cda9
10 changed files with 388 additions and 40 deletions
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+32
View File
@@ -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,
+11 -2
View File
@@ -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 != ""
+27 -2
View File
@@ -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)
}
}
+167
View File
@@ -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" }
+1 -1
View File
@@ -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,
+16 -3
View File
@@ -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>
+113 -27
View File
@@ -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">
+17 -3
View File
@@ -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>