nomos+web: streaming, provider routing, event gap-fill, embedded UI; fix approval FK & session context
Agent (cmd/nomos): - Stream LLM tokens via NewStreaming; emit text_delta then final text. - OpenRouter provider routing: data_collection=deny (ZDR) + require_parameters; NOMOS_PROVIDER_SORT opt-in; Exacto via model suffix. - Multi-turn: reload session history into context; UI passes session id. - Fix agent_activity logging (agent_id/session_id) and mcpClient data race. Events (live control-room feed): - approval.created (mcp), approval.decided (api), execution.completed/failed (approved-action path), signal.raised/resolved + health.changed (scheduler, transition-gated). Fixes: - createApproval FK violation (reuse execution entity) — the agent's only write path; log the previously-swallowed errors. Web UI: - Embed web/dist via //go:embed (single binary); Dockerfile builds SPA into the Go stage; committed .gitkeep placeholder keeps backend-only builds green. - Caddy: Authentik-gated /agent/* -> nomos so the UI reaches the agent same-origin in production. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -17,3 +17,10 @@ oikos/oikos
|
|||||||
backups/
|
backups/
|
||||||
.env
|
.env
|
||||||
.infisical-credentials
|
.infisical-credentials
|
||||||
|
|
||||||
|
# Web UI (Svelte 5) — build artifacts. Ignore built output but keep the
|
||||||
|
# .gitkeep placeholder so `//go:embed all:dist` (web/embed.go) compiles on a
|
||||||
|
# fresh checkout before the UI is built.
|
||||||
|
web/dist/*
|
||||||
|
!web/dist/.gitkeep
|
||||||
|
web/node_modules/
|
||||||
|
|||||||
295
cmd/nomos/agent.go
Normal file
295
cmd/nomos/agent.go
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/openai/openai-go"
|
||||||
|
"github.com/openai/openai-go/option"
|
||||||
|
"github.com/openai/openai-go/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxIterations = 15
|
||||||
|
|
||||||
|
type agent struct {
|
||||||
|
client *mcpClient
|
||||||
|
system string
|
||||||
|
provider *openai.Client
|
||||||
|
model string
|
||||||
|
store *store
|
||||||
|
agentID uuid.UUID
|
||||||
|
reqOpts []option.RequestOption
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug string) (*agent, error) {
|
||||||
|
system := loadSoul()
|
||||||
|
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
||||||
|
model := os.Getenv("NOMOS_MODEL")
|
||||||
|
if model == "" {
|
||||||
|
model = "deepseek/deepseek-v4-flash"
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := openai.NewClient(
|
||||||
|
option.WithBaseURL("https://openrouter.ai/api/v1"),
|
||||||
|
option.WithAPIKey(apiKey),
|
||||||
|
)
|
||||||
|
|
||||||
|
agentID := st.resolveAgentID(ctx, agentSlug)
|
||||||
|
if agentID == uuid.Nil {
|
||||||
|
slog.Warn("nomos: agent entity not found; tool-call activity will not be logged", "slug", agentSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenRouter provider routing. data_collection=deny pins to zero-data-
|
||||||
|
// retention providers (privacy: conversations + tool results transit
|
||||||
|
// OpenRouter); require_parameters ensures the routed provider actually
|
||||||
|
// supports tool calling. NOMOS_PROVIDER_SORT (price|throughput|latency)
|
||||||
|
// and Exacto tool-accuracy routing are opt-in — the latter via a model
|
||||||
|
// suffix in NOMOS_MODEL (e.g. "deepseek/deepseek-v4-flash:exacto"), so an
|
||||||
|
// unsupported value never silently breaks the confirmed routing below.
|
||||||
|
providerRouting := map[string]any{
|
||||||
|
"data_collection": "deny",
|
||||||
|
"require_parameters": true,
|
||||||
|
}
|
||||||
|
if sort := os.Getenv("NOMOS_PROVIDER_SORT"); sort != "" {
|
||||||
|
providerRouting["sort"] = sort
|
||||||
|
}
|
||||||
|
reqOpts := []option.RequestOption{option.WithJSONSet("provider", providerRouting)}
|
||||||
|
|
||||||
|
return &agent{
|
||||||
|
client: mcpClient,
|
||||||
|
system: system,
|
||||||
|
provider: &provider,
|
||||||
|
model: model,
|
||||||
|
store: st,
|
||||||
|
agentID: agentID,
|
||||||
|
reqOpts: reqOpts,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSoul() string {
|
||||||
|
paths := []string{"/app/nomos/SOUL.md", "nomos/SOUL.md"}
|
||||||
|
for _, p := range paths {
|
||||||
|
if data, err := os.ReadFile(p); err == nil {
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `You are Nomos, the steward of the oikos — the AI agent for the hubris homelab.
|
||||||
|
You have access to MCP tools to query topology, health, knowledge, and request
|
||||||
|
gated mutations through request_execution. Be concise. Prefer tools over guessing.`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolDef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
InputSchema map[string]any `json:"inputSchema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Iteration int `json:"iteration,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) {
|
||||||
|
correlationID := uuid.New().String()
|
||||||
|
|
||||||
|
tools, err := a.buildTools()
|
||||||
|
if err != nil {
|
||||||
|
emit(agentEvent{Type: "error", Data: fmt.Sprintf("build tools: %v", err), SessionID: sessionID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild conversation context from persisted history so sessions are
|
||||||
|
// multi-turn. The current user turn is saved by the HTTP handler before
|
||||||
|
// this runs, so it is already included in the history for real sessions.
|
||||||
|
// Intermediate tool_use/tool_result pairs are not replayed (their ids
|
||||||
|
// must match exactly or the API rejects them); prior final answers carry
|
||||||
|
// the salient context. Ephemeral sessions (no store) fall back to the
|
||||||
|
// single incoming message.
|
||||||
|
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(a.system)}
|
||||||
|
history, _ := a.store.getMessages(ctx, sessionID)
|
||||||
|
for _, m := range history {
|
||||||
|
text := extractText(m.Content)
|
||||||
|
switch m.Role {
|
||||||
|
case "user":
|
||||||
|
messages = append(messages, openai.UserMessage(text))
|
||||||
|
case "assistant":
|
||||||
|
if text != "" {
|
||||||
|
messages = append(messages, openai.AssistantMessage(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(history) == 0 {
|
||||||
|
messages = append(messages, openai.UserMessage(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < maxIterations; i++ {
|
||||||
|
params := openai.ChatCompletionNewParams{
|
||||||
|
Model: openai.ChatModel(a.model),
|
||||||
|
Messages: messages,
|
||||||
|
Tools: tools,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream the completion, emitting token deltas as they arrive. The
|
||||||
|
// accumulator reassembles the full message (content + tool calls) for
|
||||||
|
// the loop's control flow.
|
||||||
|
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
|
||||||
|
acc := openai.ChatCompletionAccumulator{}
|
||||||
|
for stream.Next() {
|
||||||
|
chunk := stream.Current()
|
||||||
|
acc.AddChunk(chunk)
|
||||||
|
if len(chunk.Choices) > 0 {
|
||||||
|
if delta := chunk.Choices[0].Delta.Content; delta != "" {
|
||||||
|
emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := stream.Err(); err != nil {
|
||||||
|
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(acc.Choices) == 0 {
|
||||||
|
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := acc.Choices[0].Message
|
||||||
|
|
||||||
|
if len(msg.ToolCalls) == 0 {
|
||||||
|
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
||||||
|
emit(agentEvent{Type: "done", Data: map[string]any{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"usage": acc.Usage,
|
||||||
|
"correlation_id": correlationID,
|
||||||
|
"iterations": i + 1,
|
||||||
|
}, SessionID: sessionID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID)
|
||||||
|
|
||||||
|
messages = append(messages, msg.ToParam())
|
||||||
|
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
var args map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||||
|
args = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(agentEvent{
|
||||||
|
Type: "tool_use",
|
||||||
|
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
|
||||||
|
SessionID: sessionID,
|
||||||
|
Iteration: i + 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
result, callErr := a.client.callTool(tc.Function.Name, args)
|
||||||
|
elapsed := int(time.Since(start).Milliseconds())
|
||||||
|
|
||||||
|
inputJSON, _ := json.Marshal(args)
|
||||||
|
inputStr := string(inputJSON)
|
||||||
|
|
||||||
|
if callErr != nil {
|
||||||
|
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, callErr.Error(), elapsed, false, correlationID)
|
||||||
|
|
||||||
|
emit(agentEvent{
|
||||||
|
Type: "tool_result",
|
||||||
|
Data: map[string]any{"name": tc.Function.Name, "error": callErr.Error(), "id": tc.ID},
|
||||||
|
SessionID: sessionID,
|
||||||
|
Iteration: i + 1,
|
||||||
|
})
|
||||||
|
messages = append(messages, openai.ToolMessage(callErr.Error(), tc.ID))
|
||||||
|
slog.Error("nomos: tool error", "tool", tc.Function.Name, "error", callErr, "ms", elapsed)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resultJSON, _ := json.Marshal(result)
|
||||||
|
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, string(resultJSON), elapsed, true, correlationID)
|
||||||
|
|
||||||
|
emit(agentEvent{
|
||||||
|
Type: "tool_result",
|
||||||
|
Data: map[string]any{"name": tc.Function.Name, "result": result, "id": tc.ID},
|
||||||
|
SessionID: sessionID,
|
||||||
|
Iteration: i + 1,
|
||||||
|
})
|
||||||
|
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
|
||||||
|
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(agentEvent{Type: "text", Data: "Agent loop: max iterations reached without final answer.", SessionID: sessionID})
|
||||||
|
emit(agentEvent{Type: "done", Data: map[string]any{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"correlation_id": correlationID,
|
||||||
|
"iterations": maxIterations,
|
||||||
|
}, SessionID: sessionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractText pulls the "text" field from a persisted message's JSONB content.
|
||||||
|
func extractText(content json.RawMessage) string {
|
||||||
|
var m struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(content, &m); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
|
||||||
|
defs, err := a.client.listToolsFull()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tools []openai.ChatCompletionToolParam
|
||||||
|
for _, d := range defs {
|
||||||
|
params := shared.FunctionParameters(d.InputSchema)
|
||||||
|
if params == nil {
|
||||||
|
params = shared.FunctionParameters{"type": "object", "properties": map[string]any{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
tools = append(tools, openai.ChatCompletionToolParam{
|
||||||
|
Type: "function",
|
||||||
|
Function: shared.FunctionDefinitionParam{
|
||||||
|
Name: d.Name,
|
||||||
|
Description: openai.String(d.Description),
|
||||||
|
Parameters: params,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tools, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mcpClient) listToolsFull() ([]toolDef, error) {
|
||||||
|
resp, err := c.doRequest("tools/list", map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tr struct {
|
||||||
|
Tools []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
InputSchema map[string]any `json:"inputSchema"`
|
||||||
|
} `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Result, &tr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]toolDef, len(tr.Tools))
|
||||||
|
for i, t := range tr.Tools {
|
||||||
|
out[i] = toolDef{
|
||||||
|
Name: t.Name,
|
||||||
|
Description: t.Description,
|
||||||
|
InputSchema: t.InputSchema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -20,7 +21,6 @@ func main() {
|
|||||||
fmt.Fprintln(os.Stderr, "usage: nomos serve")
|
fmt.Fprintln(os.Stderr, "usage: nomos serve")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
mcpURL := os.Getenv("NOMOS_MCP_URL")
|
mcpURL := os.Getenv("NOMOS_MCP_URL")
|
||||||
if mcpURL == "" {
|
if mcpURL == "" {
|
||||||
mcpURL = "http://localhost:8090/mcp"
|
mcpURL = "http://localhost:8090/mcp"
|
||||||
@@ -31,6 +31,11 @@ func main() {
|
|||||||
agentSlug = "agent:nomos"
|
agentSlug = "agent:nomos"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
databaseURL := os.Getenv("DATABASE_URL")
|
||||||
|
if databaseURL == "" {
|
||||||
|
databaseURL = os.Getenv("OIKOS_DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "serve":
|
case "serve":
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
@@ -42,6 +47,21 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
st, err := newStore(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("nomos: db connect", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if st != nil {
|
||||||
|
defer st.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
nAgent, err := newAgent(ctx, client, st, agentSlug)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("nomos: agent init", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
@@ -50,6 +70,15 @@ func main() {
|
|||||||
mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleQuery(w, r, client, agentSlug, mcpURL)
|
handleQuery(w, r, client, agentSlug, mcpURL)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleChat(w, r, nAgent, st)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/sessions", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleSessionsList(w, r, st)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleSessionDetail(w, r, st)
|
||||||
|
})
|
||||||
|
|
||||||
addr := os.Getenv("NOMOS_LISTEN")
|
addr := os.Getenv("NOMOS_LISTEN")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
@@ -58,7 +87,7 @@ func main() {
|
|||||||
|
|
||||||
srv := &http.Server{Addr: addr, Handler: mux}
|
srv := &http.Server{Addr: addr, Handler: mux}
|
||||||
go func() {
|
go func() {
|
||||||
slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL)
|
slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "")
|
||||||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
slog.Error("nomos: serve", "error", err)
|
slog.Error("nomos: serve", "error", err)
|
||||||
}
|
}
|
||||||
@@ -75,7 +104,130 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleQuery maps structured queries to MCP tool calls.
|
func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
|
||||||
|
data, _ := json.Marshal(event)
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad request: "+err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Message == "" {
|
||||||
|
http.Error(w, "message is required", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
sessionID := req.SessionID
|
||||||
|
|
||||||
|
if sessionID == "" {
|
||||||
|
title := truncate(req.Message, 80)
|
||||||
|
sess, err := st.createSession(ctx, title)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("nomos: create session", "error", err)
|
||||||
|
sessionID = "ephemeral"
|
||||||
|
} else {
|
||||||
|
sessionID = sess.ID
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
st.touchSession(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100))
|
||||||
|
|
||||||
|
userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message})
|
||||||
|
st.saveMessage(ctx, sessionID, "user", userMsg)
|
||||||
|
|
||||||
|
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
|
||||||
|
|
||||||
|
toolCalls := []map[string]any{}
|
||||||
|
var finalText string
|
||||||
|
|
||||||
|
a.chat(ctx, sessionID, req.Message, func(ev agentEvent) {
|
||||||
|
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||||
|
if m, ok := ev.Data.(map[string]any); ok {
|
||||||
|
m["type"] = ev.Type
|
||||||
|
toolCalls = append(toolCalls, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ev.Type == "text" {
|
||||||
|
finalText, _ = ev.Data.(string)
|
||||||
|
}
|
||||||
|
sseEvent(w, flusher, ev)
|
||||||
|
})
|
||||||
|
|
||||||
|
assistantMsg, _ := json.Marshal(map[string]any{
|
||||||
|
"role": "assistant",
|
||||||
|
"text": finalText,
|
||||||
|
"tool_calls": toolCalls,
|
||||||
|
})
|
||||||
|
st.saveMessage(ctx, sessionID, "assistant", assistantMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
|
||||||
|
if st == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"sessions": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := st.listSessions(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) {
|
||||||
|
if st == nil {
|
||||||
|
http.Error(w, "not found", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/sessions/")
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "session id required", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := st.getMessages(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
|
||||||
|
}
|
||||||
|
|
||||||
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {
|
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", 405)
|
http.Error(w, "method not allowed", 405)
|
||||||
@@ -83,8 +235,8 @@ func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agen
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
Tool string `json:"tool"`
|
Tool string `json:"tool"`
|
||||||
Args map[string]any `json:"args"`
|
Args map[string]any `json:"args"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -93,105 +245,79 @@ func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agen
|
|||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
var result any
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// Direct tool call (structured)
|
|
||||||
if req.Tool != "" {
|
if req.Tool != "" {
|
||||||
result, err = client.callTool(req.Tool, req.Args)
|
result, err := client.callTool(req.Tool, req.Args)
|
||||||
} else {
|
duration := time.Since(start).Milliseconds()
|
||||||
// Natural-language-ish query routing
|
if err != nil {
|
||||||
q := strings.ToLower(req.Query)
|
slog.Error("nomos: query failed", "tool", req.Tool, "error", err)
|
||||||
result, err = routeQuery(client, q, agentSlug)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
}
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
duration := time.Since(start).Milliseconds()
|
"elapsed_ms": duration,
|
||||||
|
"agent_slug": agentSlug,
|
||||||
if err != nil {
|
})
|
||||||
slog.Error("nomos: query failed", "query", req.Query, "error", err)
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
"error": err.Error(),
|
"result": result,
|
||||||
"elapsed_ms": duration,
|
"elapsed_ms": duration,
|
||||||
"agent_slug": agentSlug,
|
"agent_slug": agentSlug,
|
||||||
|
"mcp_url": mcpURL,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
if req.Query != "" {
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
if strings.Contains(strings.ToLower(req.Query), "what can you do") ||
|
||||||
"result": result,
|
strings.Contains(strings.ToLower(req.Query), "help") {
|
||||||
"elapsed_ms": duration,
|
|
||||||
"agent_slug": agentSlug,
|
tools, err := client.listTools()
|
||||||
"mcp_url": mcpURL,
|
duration := time.Since(start).Milliseconds()
|
||||||
})
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"elapsed_ms": duration,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.",
|
||||||
|
"tools": tools,
|
||||||
|
"elapsed_ms": duration,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.",
|
||||||
|
"elapsed_ms": time.Since(start).Milliseconds(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "either 'tool' or 'query' required", 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
// routeQuery maps natural-language-style queries to MCP tool calls.
|
func truncate(s string, n int) string {
|
||||||
func routeQuery(client *mcpClient, query, agentSlug string) (any, error) {
|
if len(s) <= n {
|
||||||
switch {
|
return s
|
||||||
case strings.Contains(query, "depends on") || strings.Contains(query, "depend on"):
|
|
||||||
entity := extractEntity(query)
|
|
||||||
if entity == "" {
|
|
||||||
return nil, fmt.Errorf("no entity found in query: %s", query)
|
|
||||||
}
|
|
||||||
return client.callTool("get_blast_radius", map[string]any{
|
|
||||||
"entity_id": entity,
|
|
||||||
})
|
|
||||||
|
|
||||||
case strings.Contains(query, "what is") || strings.Contains(query, "describe"):
|
|
||||||
entity := extractEntity(query)
|
|
||||||
if entity == "" {
|
|
||||||
entity = query
|
|
||||||
}
|
|
||||||
return client.callTool("get_entity", map[string]any{
|
|
||||||
"slug_or_id": entity,
|
|
||||||
})
|
|
||||||
|
|
||||||
case strings.Contains(query, "health") || strings.Contains(query, "status"):
|
|
||||||
return client.callTool("get_health_summary", map[string]any{})
|
|
||||||
|
|
||||||
case strings.Contains(query, "restart") || strings.Contains(query, "reload"):
|
|
||||||
entity := extractEntity(query)
|
|
||||||
if entity == "" {
|
|
||||||
return nil, fmt.Errorf("no entity found in query: %s", query)
|
|
||||||
}
|
|
||||||
return client.callTool("request_execution", map[string]any{
|
|
||||||
"target": entity,
|
|
||||||
"action": "restart",
|
|
||||||
})
|
|
||||||
|
|
||||||
case strings.Contains(query, "what can you do") || strings.Contains(query, "help"):
|
|
||||||
return client.callTool("tools/list", nil)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return client.callTool("get_health_summary", map[string]any{})
|
|
||||||
}
|
}
|
||||||
}
|
return s[:n] + "..."
|
||||||
|
|
||||||
// extractEntity guesses an entity slug from a query.
|
|
||||||
func extractEntity(query string) string {
|
|
||||||
for _, slug := range []string{"authentik", "caddy", "vaultwarden", "gitea", "immich"} {
|
|
||||||
if strings.Contains(query, slug) {
|
|
||||||
return "service:" + slug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(query, "mac-mini") {
|
|
||||||
return "host:mac-mini"
|
|
||||||
}
|
|
||||||
if strings.Contains(query, "hubris") {
|
|
||||||
return "host:hubris"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── MCP Streamable HTTP client ────────────────────────────────────────
|
// ─── MCP Streamable HTTP client ────────────────────────────────────────
|
||||||
|
|
||||||
type mcpClient struct {
|
type mcpClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
sessionID string
|
sessionID string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
nextID int
|
nextID int
|
||||||
|
mu sync.Mutex // MCP is one stateful session; serialize concurrent calls
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMCPClient(baseURL string) (*mcpClient, error) {
|
func newMCPClient(baseURL string) (*mcpClient, error) {
|
||||||
@@ -200,7 +326,6 @@ func newMCPClient(baseURL string) (*mcpClient, error) {
|
|||||||
http: &http.Client{Timeout: 30 * time.Second},
|
http: &http.Client{Timeout: 30 * time.Second},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize session
|
|
||||||
resp, err := c.doRequest("initialize", map[string]any{
|
resp, err := c.doRequest("initialize", map[string]any{
|
||||||
"protocolVersion": "2024-11-05",
|
"protocolVersion": "2024-11-05",
|
||||||
"capabilities": map[string]any{},
|
"capabilities": map[string]any{},
|
||||||
@@ -214,7 +339,6 @@ func newMCPClient(baseURL string) (*mcpClient, error) {
|
|||||||
}
|
}
|
||||||
c.sessionID = resp.sessionID
|
c.sessionID = resp.sessionID
|
||||||
|
|
||||||
// Send initialized notification
|
|
||||||
c.doRequest("notifications/initialized", map[string]any{})
|
c.doRequest("notifications/initialized", map[string]any{})
|
||||||
|
|
||||||
slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...")
|
slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...")
|
||||||
@@ -228,6 +352,8 @@ type mcpJSONRPCResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
c.nextID++
|
c.nextID++
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -255,7 +381,6 @@ func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPC
|
|||||||
result := &mcpJSONRPCResponse{}
|
result := &mcpJSONRPCResponse{}
|
||||||
result.sessionID = resp.Header.Get("Mcp-Session-Id")
|
result.sessionID = resp.Header.Get("Mcp-Session-Id")
|
||||||
|
|
||||||
// Parse SSE stream: "event: message\ndata: <json>\n\n"
|
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
@@ -287,7 +412,6 @@ func (c *mcpClient) callTool(name string, args map[string]any) (any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse MCP content: { "content": [{ "type": "text", "text": "..." }] }
|
|
||||||
var toolResult struct {
|
var toolResult struct {
|
||||||
Content []struct {
|
Content []struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -301,7 +425,6 @@ func (c *mcpClient) callTool(name string, args map[string]any) (any, error) {
|
|||||||
var texts []string
|
var texts []string
|
||||||
for _, c := range toolResult.Content {
|
for _, c := range toolResult.Content {
|
||||||
if c.Type == "text" {
|
if c.Type == "text" {
|
||||||
// Try to parse as JSON for structured display
|
|
||||||
var parsed any
|
var parsed any
|
||||||
if json.Unmarshal([]byte(c.Text), &parsed) == nil {
|
if json.Unmarshal([]byte(c.Text), &parsed) == nil {
|
||||||
return parsed, nil
|
return parsed, nil
|
||||||
@@ -337,5 +460,4 @@ func (c *mcpClient) listTools() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *mcpClient) close() {
|
func (c *mcpClient) close() {
|
||||||
// MCP sessions are ephemeral; no explicit close needed
|
|
||||||
}
|
}
|
||||||
|
|||||||
156
cmd/nomos/store.go
Normal file
156
cmd/nomos/store.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStore(ctx context.Context, databaseURL string) (*store, error) {
|
||||||
|
if databaseURL == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect db: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("ping db: %w", err)
|
||||||
|
}
|
||||||
|
return &store{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) close() {
|
||||||
|
if s.pool != nil {
|
||||||
|
s.pool.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastActiveAt time.Time `json:"last_active_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type message struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) createSession(ctx context.Context, title string) (*session, error) {
|
||||||
|
if s == nil {
|
||||||
|
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos"}, nil
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO agent_sessions (title, actor) VALUES ($1, 'agent:nomos') RETURNING id`,
|
||||||
|
title).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &session{ID: id, Title: title, Actor: "agent:nomos", CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`,
|
||||||
|
sessionID, role, content)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) touchSession(ctx context.Context, id string) {
|
||||||
|
if s != nil {
|
||||||
|
s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id, title, actor, created_at, last_active_at FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []session
|
||||||
|
for rows.Next() {
|
||||||
|
var sess session
|
||||||
|
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, sess)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id, session_id, role, content, created_at FROM agent_messages WHERE session_id=$1 ORDER BY created_at ASC`,
|
||||||
|
sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []message
|
||||||
|
for rows.Next() {
|
||||||
|
var m message
|
||||||
|
if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos").
|
||||||
|
// Returns uuid.Nil if the store is absent or the slug is unknown.
|
||||||
|
func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {
|
||||||
|
if s == nil {
|
||||||
|
return uuid.Nil
|
||||||
|
}
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&id); err != nil {
|
||||||
|
return uuid.Nil
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// logActivity records a tool call. agent_id is the agent entity UUID and is
|
||||||
|
// NOT NULL in the schema, so we skip logging when it can't be resolved.
|
||||||
|
// The (nullable) session_id column carries the conversation id.
|
||||||
|
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
|
||||||
|
if s == nil || agentID == uuid.Nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO agent_activity
|
||||||
|
(agent_id, session_id, activity_type, tool_name, input_summary, output_summary,
|
||||||
|
duration_ms, success, correlation_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
agentID, sessionID, "tool_call", toolName, inputSummary, outputSummary,
|
||||||
|
durationMs, success, correlationID)
|
||||||
|
}
|
||||||
@@ -4,13 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/db"
|
||||||
"github.com/dtoro/oikos/internal/httpapi"
|
"github.com/dtoro/oikos/internal/httpapi"
|
||||||
@@ -19,9 +18,42 @@ import (
|
|||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/dtoro/oikos/internal/scheduler"
|
"github.com/dtoro/oikos/internal/scheduler"
|
||||||
"github.com/dtoro/oikos/internal/secrets"
|
"github.com/dtoro/oikos/internal/secrets"
|
||||||
|
"github.com/dtoro/oikos/web"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// uiHandler serves the control-room SPA from assets embedded at build time
|
||||||
|
// (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*;
|
||||||
|
// the /ui prefix is stripped to index into the embedded dist/ tree.
|
||||||
|
func uiHandler() http.Handler {
|
||||||
|
dist, err := web.DistFS()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("ui: embedded assets unavailable", "error", err)
|
||||||
|
return http.NotFoundHandler()
|
||||||
|
}
|
||||||
|
fileServer := http.FileServer(http.FS(dist))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
if f, err := dist.Open(path); err == nil {
|
||||||
|
f.Close()
|
||||||
|
r.URL.Path = "/" + path
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SPA fallback: serve index.html for unknown client-side routes.
|
||||||
|
if f, err := dist.Open("index.html"); err == nil {
|
||||||
|
f.Close()
|
||||||
|
r.URL.Path = "/index.html"
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var schedulerRunner = scheduler.RunnerForMain()
|
var schedulerRunner = scheduler.RunnerForMain()
|
||||||
var notifierRunner = notifier.RunnerForMain()
|
var notifierRunner = notifier.RunnerForMain()
|
||||||
|
|
||||||
@@ -86,7 +118,7 @@ func main() {
|
|||||||
go notifierRunner(ctx, pool, cfg)
|
go notifierRunner(ctx, pool, cfg)
|
||||||
|
|
||||||
slog.Info("all: starting api with scheduler + notifier in background")
|
slog.Info("all: starting api with scheduler + notifier in background")
|
||||||
if err := httpapi.ListenAndServe(ctx, pool, cfg); err != nil {
|
if err := httpapi.ListenAndServe(ctx, pool, cfg, uiHandler()); err != nil {
|
||||||
slog.Error("api failed", "error", err)
|
slog.Error("api failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -265,7 +297,7 @@ func runAPI(ctx context.Context, cfg config.Config) error {
|
|||||||
return fmt.Errorf("migrations: %w", err)
|
return fmt.Errorf("migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = httpapi.ListenAndServe(ctx, pool, cfg)
|
err = httpapi.ListenAndServe(ctx, pool, cfg, uiHandler())
|
||||||
if err == http.ErrServerClosed {
|
if err == http.ErrServerClosed {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ oikos.hubris.network {
|
|||||||
handle @enroll {
|
handle @enroll {
|
||||||
reverse_proxy <mac-mini-mesh-ip>:8090
|
reverse_proxy <mac-mini-mesh-ip>:8090
|
||||||
}
|
}
|
||||||
|
# Nomos agent, same-origin for the control-room UI (EventSource/fetch can't
|
||||||
|
# set cross-origin auth headers). Authentik gates it; handle_path strips
|
||||||
|
# the /agent prefix so /agent/chat -> nomos /chat.
|
||||||
|
handle_path /agent/* {
|
||||||
|
import authentik
|
||||||
|
reverse_proxy <mac-mini-mesh-ip>:8092
|
||||||
|
}
|
||||||
handle {
|
handle {
|
||||||
import authentik
|
import authentik
|
||||||
reverse_proxy <mac-mini-mesh-ip>:8090
|
reverse_proxy <mac-mini-mesh-ip>:8090
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ COPY nomos/ /app/nomos/
|
|||||||
ENV NOMOS_MCP_URL=http://api:8090/mcp
|
ENV NOMOS_MCP_URL=http://api:8090/mcp
|
||||||
ENV NOMOS_AGENT_SLUG=agent:nomos
|
ENV NOMOS_AGENT_SLUG=agent:nomos
|
||||||
ENV NOMOS_LISTEN=:8092
|
ENV NOMOS_LISTEN=:8092
|
||||||
|
ENV NOMOS_MODEL=deepseek/deepseek-v4-flash
|
||||||
|
|
||||||
EXPOSE 8092
|
EXPOSE 8092
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
# Multi-stage Dockerfile for Oikos (ADR 0001: single binary)
|
# Multi-stage Dockerfile for Oikos (ADR 0001: single binary)
|
||||||
|
# Stage 1: build web UI
|
||||||
|
FROM node:22-alpine AS ui-builder
|
||||||
|
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: build Go binary
|
||||||
FROM golang:1.26-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates
|
RUN apk add --no-cache git ca-certificates
|
||||||
@@ -8,6 +18,8 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Bring in the built SPA so //go:embed all:dist (web/embed.go) has real assets.
|
||||||
|
COPY --from=ui-builder /web/dist ./web/dist
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos
|
RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos
|
||||||
|
|
||||||
@@ -17,5 +29,6 @@ FROM gcr.io/distroless/static:nonroot
|
|||||||
COPY --from=builder /oikos /oikos
|
COPY --from=builder /oikos /oikos
|
||||||
COPY --from=builder /build/seeds /seeds
|
COPY --from=builder /build/seeds /seeds
|
||||||
COPY --from=builder /build/migrations /migrations
|
COPY --from=builder /build/migrations /migrations
|
||||||
|
# web/dist is embedded in the binary (web/embed.go) — no runtime copy needed.
|
||||||
|
|
||||||
ENTRYPOINT ["/oikos"]
|
ENTRYPOINT ["/oikos"]
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ services:
|
|||||||
OIKOS_ENV: dev
|
OIKOS_ENV: dev
|
||||||
OIKOS_DEBUG: "true"
|
OIKOS_DEBUG: "true"
|
||||||
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
|
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
|
||||||
|
NOMOS_PROXY_URL: http://nomos:8092
|
||||||
volumes:
|
volumes:
|
||||||
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
||||||
ports:
|
ports:
|
||||||
@@ -119,6 +120,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NOMOS_MCP_URL: http://api:8090/mcp
|
NOMOS_MCP_URL: http://api:8090/mcp
|
||||||
NOMOS_AGENT_SLUG: agent:nomos
|
NOMOS_AGENT_SLUG: agent:nomos
|
||||||
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||||
|
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-flash}
|
||||||
|
DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
||||||
ports:
|
ports:
|
||||||
- "8092:8092"
|
- "8092:8092"
|
||||||
stop_signal: SIGTERM
|
stop_signal: SIGTERM
|
||||||
|
|||||||
9
go.mod
9
go.mod
@@ -8,11 +8,14 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/google/jsonschema-go v0.4.3
|
github.com/google/jsonschema-go v0.4.3
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/infisical/go-sdk v0.8.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||||
github.com/oapi-codegen/runtime v1.4.2
|
github.com/oapi-codegen/runtime v1.4.2
|
||||||
|
github.com/openai/openai-go v1.12.0
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/sync v0.21.0
|
golang.org/x/sync v0.21.0
|
||||||
|
golang.org/x/sys v0.46.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +50,6 @@ require (
|
|||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/infisical/go-sdk v0.8.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
@@ -60,6 +62,10 @@ require (
|
|||||||
github.com/segmentio/asm v1.1.3 // indirect
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/sony/gobreaker v0.5.0 // indirect
|
github.com/sony/gobreaker v0.5.0 // indirect
|
||||||
|
github.com/tidwall/gjson v1.14.4 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
@@ -70,7 +76,6 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
golang.org/x/oauth2 v0.35.0 // indirect
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/api v0.267.0 // indirect
|
google.golang.org/api v0.267.0 // indirect
|
||||||
|
|||||||
35
go.sum
35
go.sum
@@ -38,12 +38,19 @@ github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC
|
|||||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||||
|
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=
|
github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=
|
||||||
@@ -68,6 +75,8 @@ github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
|||||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
@@ -93,8 +102,8 @@ github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QII
|
|||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||||
@@ -107,9 +116,13 @@ github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg=
|
|||||||
github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0=
|
github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0=
|
||||||
github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg=
|
github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg=
|
||||||
github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||||
|
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
|
||||||
|
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
|
||||||
github.com/oracle/oci-go-sdk/v65 v65.95.2 h1:0HJ0AgpLydp/DtvYrF2d4str2BjXOVAeNbuW7E07g94=
|
github.com/oracle/oci-go-sdk/v65 v65.95.2 h1:0HJ0AgpLydp/DtvYrF2d4str2BjXOVAeNbuW7E07g94=
|
||||||
github.com/oracle/oci-go-sdk/v65 v65.95.2/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA=
|
github.com/oracle/oci-go-sdk/v65 v65.95.2/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||||
@@ -136,6 +149,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||||
|
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
@@ -152,6 +175,10 @@ go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
|||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@@ -232,8 +259,12 @@ golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
|
google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
|
||||||
google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
|
google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
|
||||||
|
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
|
||||||
|
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewHandler(handlerCtx, pool, cfg)
|
return NewHandler(handlerCtx, pool, cfg, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
|
func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
|
||||||
|
|||||||
@@ -122,6 +122,16 @@ func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (stri
|
|||||||
|
|
||||||
// executeApprovedAction runs a gated action after operator approval.
|
// executeApprovedAction runs a gated action after operator approval.
|
||||||
// Runs in a background goroutine to not block the HTTP response.
|
// Runs in a background goroutine to not block the HTTP response.
|
||||||
|
// emitExecutionEvent records an execution lifecycle event for SSE fan-out so
|
||||||
|
// the control room can watch approved actions run to completion live.
|
||||||
|
func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, status string, detail map[string]any) {
|
||||||
|
severity := "info"
|
||||||
|
if status == "failed" {
|
||||||
|
severity = "warning"
|
||||||
|
}
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail)
|
||||||
|
}
|
||||||
|
|
||||||
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
|
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
|
||||||
slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr)
|
slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr)
|
||||||
|
|
||||||
@@ -130,6 +140,7 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
|||||||
slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug)
|
slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug)
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
||||||
execID, fmt.Sprintf(`{"error":"%s"}`, err.Error()))
|
execID, fmt.Sprintf(`{"error":"%s"}`, err.Error()))
|
||||||
|
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +197,10 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
|||||||
pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`,
|
pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`,
|
||||||
execID, status, result, durationMs, verified, startedAt, time.Now())
|
execID, status, result, durationMs, verified, startedAt, time.Now())
|
||||||
|
|
||||||
|
emitExecutionEvent(ctx, pool, execID, status, map[string]any{
|
||||||
|
"action": action, "target": targetSlug, "duration_ms": durationMs,
|
||||||
|
})
|
||||||
|
|
||||||
slog.Info("httpapi: approved action executed",
|
slog.Info("httpapi: approved action executed",
|
||||||
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
|
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
|
||||||
}
|
}
|
||||||
@@ -947,6 +962,12 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
|
|||||||
return nil, auditErr
|
return nil, auditErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit for SSE fan-out (in-tx; NOTIFY fires post-commit).
|
||||||
|
if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
|
||||||
|
map[string]any{"decision": status, "actor": actor}); evErr != nil {
|
||||||
|
return nil, evErr
|
||||||
|
}
|
||||||
|
|
||||||
// On approve: execute the linked gated command.
|
// On approve: execute the linked gated command.
|
||||||
if status == "approved" {
|
if status == "approved" {
|
||||||
var execID, targetID uuid.UUID
|
var execID, targetID uuid.UUID
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -66,7 +69,7 @@ type secretsBackend interface {
|
|||||||
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
|
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
|
||||||
// before closing the pool — otherwise the held connection never releases
|
// before closing the pool — otherwise the held connection never releases
|
||||||
// and pool.Close() deadlocks.
|
// and pool.Close() deadlocks.
|
||||||
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler {
|
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) http.Handler {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -148,6 +151,24 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
|||||||
}
|
}
|
||||||
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
|
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
|
||||||
|
|
||||||
|
r.Get("/ui/*", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if uiHandler != nil {
|
||||||
|
uiHandler.ServeHTTP(w, req)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
r.Get("/ui", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
|
||||||
|
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
|
||||||
|
target, _ := url.Parse(nomosURL)
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
r.Mount("/agent", http.StripPrefix("/agent", proxy))
|
||||||
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,10 +505,10 @@ func requestLogger(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
||||||
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
||||||
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
|
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) error {
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.APIListen,
|
Addr: cfg.APIListen,
|
||||||
Handler: NewHandler(ctx, pool, cfg),
|
Handler: NewHandler(ctx, pool, cfg, uiHandler),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
@@ -968,13 +970,27 @@ func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) {
|
func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) {
|
||||||
approvalID, _ := uuid.NewV7()
|
|
||||||
payload := fmt.Sprintf(`{"action":"%s","params":"%s","execution_id":"%s"}`, action, params, execID)
|
payload := fmt.Sprintf(`{"action":"%s","params":"%s","execution_id":"%s"}`, action, params, execID)
|
||||||
pool.Exec(ctx, `
|
// approvals.entity_id is PK + FK to entities(id). Reuse the execution's
|
||||||
|
// entity (already inserted by request_execution) so the FK is satisfied —
|
||||||
|
// a fresh UUID here had no matching entities row, so the INSERT silently
|
||||||
|
// failed, orphaning the execution and never alerting the operator. One
|
||||||
|
// execution maps to at most one approval, so the 1:1 identity holds.
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class,
|
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class,
|
||||||
kind, payload, status, expires_at, created_at)
|
kind, payload, status, expires_at, created_at)
|
||||||
VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending',
|
VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending',
|
||||||
now() + interval '1 hour', now())`,
|
now() + interval '1 hour', now())`,
|
||||||
approvalID, targetID, action, riskClass, payload)
|
execID, targetID, action, riskClass, payload); err != nil {
|
||||||
pool.Exec(ctx, `UPDATE executions SET approval_id = $2 WHERE entity_id = $1`, execID, approvalID)
|
slog.Error("createApproval: insert approval", "error", err, "execution", execID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE executions SET approval_id = $1 WHERE entity_id = $1`, execID); err != nil {
|
||||||
|
slog.Error("createApproval: link approval to execution", "error", err, "execution", execID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit for SSE fan-out — the operator-facing moment: an agent-requested
|
||||||
|
// gated action is now awaiting a decision.
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(pool), "approval.created", &execID, "warning", "mcp", "",
|
||||||
|
map[string]any{"action": action, "params": params, "risk_class": riskClass})
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/db"
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
@@ -98,6 +99,8 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
|
|||||||
"entity", cd.EntitySlug, "kind", cd.Kind, "error", checkErr)
|
"entity", cd.EntitySlug, "kind", cd.Kind, "error", checkErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prevHealth := currentHealth(ctx, pool, cd.EntityID)
|
||||||
|
|
||||||
if signalKind == "" || health == "healthy" {
|
if signalKind == "" || health == "healthy" {
|
||||||
// Recovery: resolve any open signal for this check
|
// Recovery: resolve any open signal for this check
|
||||||
resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug)
|
resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug)
|
||||||
@@ -108,6 +111,10 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
|
|||||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||||
Details: []byte(`{}`),
|
Details: []byte(`{}`),
|
||||||
})
|
})
|
||||||
|
if prevHealth != "" && prevHealth != "healthy" {
|
||||||
|
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, "info",
|
||||||
|
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": "healthy"})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,17 +147,47 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
|
|||||||
Details: []byte(`{}`),
|
Details: []byte(`{}`),
|
||||||
})
|
})
|
||||||
_ = sig // used for flap detection below
|
_ = sig // used for flap detection below
|
||||||
|
|
||||||
|
// Emit only on transition into failure so a persistently-down entity
|
||||||
|
// doesn't flood the stream every tick.
|
||||||
|
if prevHealth == "" || prevHealth == "healthy" {
|
||||||
|
emitSchedulerEvent(ctx, pool, "signal.raised", cd.EntityID, severity,
|
||||||
|
map[string]any{"slug": cd.EntitySlug, "kind": signalKind, "evidence": evidence})
|
||||||
|
}
|
||||||
|
if prevHealth != health {
|
||||||
|
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, severity,
|
||||||
|
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": health})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentHealth reads the last recorded health for an entity, or "" if none.
|
||||||
|
func currentHealth(ctx context.Context, pool *db.Pool, entityID uuid.UUID) string {
|
||||||
|
var health string
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT health FROM entity_status WHERE entity_id = $1`, entityID).Scan(&health); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return health
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitSchedulerEvent records a scheduler-sourced event for SSE fan-out.
|
||||||
|
func emitSchedulerEvent(ctx context.Context, pool *db.Pool, eventType string, entityID uuid.UUID, severity string, data map[string]any) {
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(pool), eventType, &entityID, severity, "scheduler", "", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSignal resolves any open signal for the given check entity.
|
// resolveSignal resolves any open signal for the given check entity.
|
||||||
func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug string) {
|
func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug string) {
|
||||||
q := sqlcgen.New(pool)
|
q := sqlcgen.New(pool)
|
||||||
// Check if there's an open signal on this entity
|
// Check if there's an open signal on this entity
|
||||||
_, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
|
tag, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
|
||||||
WHERE entity_id = $1 AND state = 'raised'`, entityID)
|
WHERE entity_id = $1 AND state = 'raised'`, entityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
emitSchedulerEvent(ctx, pool, "signal.resolved", entityID, "info",
|
||||||
|
map[string]any{"slug": slug})
|
||||||
|
}
|
||||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||||
EntityID: entityID,
|
EntityID: entityID,
|
||||||
Health: "healthy",
|
Health: "healthy",
|
||||||
|
|||||||
25
migrations/015_agent_sessions.up.sql
Normal file
25
migrations/015_agent_sessions.up.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- 015_agent_sessions.up.sql
|
||||||
|
-- Nomos agent sessions: persist conversations across restarts.
|
||||||
|
-- agent_messages stores the full message history (JSONB).
|
||||||
|
-- agent_activity is joined via correlation_id for tool-call tracing.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
actor TEXT NOT NULL DEFAULT 'agent:nomos',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_active_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_messages_session
|
||||||
|
ON agent_messages(session_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_sessions_active
|
||||||
|
ON agent_sessions(last_active_at DESC);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# SOUL.md — Nomos agent persona (Phase 4, container runtime)
|
# SOUL.md — Nomos agent persona (Phase 4, container runtime)
|
||||||
|
|
||||||
You are **Nomos** (from *oikonomos*, the steward of the oikos), the homelab AI agent running in a Docker container on
|
You are **Nomos** (from *oikonomos*, the steward of the oikos), the homelab
|
||||||
mac-mini. You operate in **gateway mode** on mesh-only port 8092.
|
AI agent running in a Docker container on mac-mini. You operate on port 8092.
|
||||||
|
|
||||||
## Source of truth
|
## Source of truth
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Nomos agent config — standalone MCP client gateway (Phase 4)
|
# Nomos agent config — LLM-backed resident agent (Phase 4)
|
||||||
|
|
||||||
mcp:
|
mcp:
|
||||||
endpoint: ${NOMOS_MCP_URL}?session_id=${NOMOS_SESSION_ID}
|
endpoint: ${NOMOS_MCP_URL}?session_id=${NOMOS_SESSION_ID}
|
||||||
@@ -12,22 +12,7 @@ agent:
|
|||||||
name: nomos
|
name: nomos
|
||||||
slug: ${NOMOS_AGENT_SLUG}
|
slug: ${NOMOS_AGENT_SLUG}
|
||||||
|
|
||||||
query_routing:
|
llm:
|
||||||
# Maps natural-language query patterns to MCP tools
|
provider: openrouter
|
||||||
- pattern: "depends on"
|
model: ${NOMOS_MODEL}
|
||||||
tool: get_blast_radius
|
max_iterations: 15
|
||||||
entity_param: entity_id
|
|
||||||
- pattern: "restart"
|
|
||||||
tool: request_execution
|
|
||||||
action: restart
|
|
||||||
- pattern: "health"
|
|
||||||
tool: get_health_summary
|
|
||||||
- pattern: "what is"
|
|
||||||
tool: get_entity
|
|
||||||
entity_param: slug_or_id
|
|
||||||
- pattern: "recent events"
|
|
||||||
tool: get_event_timeline
|
|
||||||
- pattern: "signals"
|
|
||||||
tool: get_signal_history
|
|
||||||
- pattern: "patterns"
|
|
||||||
tool: get_patterns
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 2026-07-08 — Nomos resident agent (renames Hermes)
|
# 2026-07-08 — Nomos resident agent (renames Hermes)
|
||||||
|
|
||||||
**Status:** In Progress — N0 complete 2026-07-08
|
**Status:** In Progress — N0-N3 complete 2026-07-08
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|||||||
0
web/dist/.gitkeep
vendored
Normal file
0
web/dist/.gitkeep
vendored
Normal file
21
web/embed.go
Normal file
21
web/embed.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Package web embeds the compiled control-room SPA (web/dist) into the oikos
|
||||||
|
// binary, preserving the single-binary deployment (ADR-0001). The dist tree is
|
||||||
|
// produced by `npm run build` (or the Docker ui-builder stage); a committed
|
||||||
|
// web/dist/.gitkeep keeps a backend-only `go build` green when the UI has not
|
||||||
|
// been built.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var dist embed.FS
|
||||||
|
|
||||||
|
// DistFS returns the built SPA rooted at dist/. When the UI has not been built
|
||||||
|
// (only the .gitkeep placeholder is present), Open("index.html") will fail and
|
||||||
|
// the caller serves a 404 — the binary still starts.
|
||||||
|
func DistFS() (fs.FS, error) {
|
||||||
|
return fs.Sub(dist, "dist")
|
||||||
|
}
|
||||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Oikos — Control Room</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1438
web/package-lock.json
generated
Normal file
1438
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
web/package.json
Normal file
18
web/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "oikos-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build && touch dist/.gitkeep",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
155
web/src/App.svelte
Normal file
155
web/src/App.svelte
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Chat from '$lib/../pages/Chat.svelte'
|
||||||
|
import Sessions from '$lib/../pages/Sessions.svelte'
|
||||||
|
import { newChat } from '$lib/stores/chat'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { slide } from 'svelte/transition'
|
||||||
|
|
||||||
|
let page = $state('chat')
|
||||||
|
let drawerOpen = $state(false)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
function sync() {
|
||||||
|
page = location.hash.slice(2) || 'chat'
|
||||||
|
}
|
||||||
|
sync()
|
||||||
|
window.addEventListener('hashchange', sync)
|
||||||
|
return () => window.removeEventListener('hashchange', sync)
|
||||||
|
})
|
||||||
|
|
||||||
|
function navigate(p: string) {
|
||||||
|
location.hash = '#/' + p
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<nav class="sidebar">
|
||||||
|
<div class="logo">Oikos</div>
|
||||||
|
<button class="nav-btn" onclick={() => { newChat(); navigate('chat') }}>
|
||||||
|
<span class="nav-icon">✚</span>
|
||||||
|
<span>New</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" class:active={page === 'chat'} onclick={() => navigate('chat')}>
|
||||||
|
<span class="nav-icon">💬</span>
|
||||||
|
<span>Chat</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" class:active={page === 'sessions'} onclick={() => navigate('sessions')}>
|
||||||
|
<span class="nav-icon">📋</span>
|
||||||
|
<span>Sessions</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<button class="drawer-toggle" onclick={() => drawerOpen = !drawerOpen}>
|
||||||
|
Chat {drawerOpen ? '▼' : '▲'}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
{#if page === 'chat'}
|
||||||
|
<Chat />
|
||||||
|
{:else if page === 'sessions'}
|
||||||
|
<Sessions />
|
||||||
|
{:else}
|
||||||
|
<Chat />
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if drawerOpen}
|
||||||
|
<aside class="drawer" transition:slide={{ axis: 'x' }}>
|
||||||
|
<Chat />
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 56px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-blue);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
width: 44px;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.active {
|
||||||
|
background: var(--bg-active);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-toggle {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer {
|
||||||
|
width: 380px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
web/src/app.css
Normal file
62
web/src/app.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-surface: #161b22;
|
||||||
|
--bg-deeper: #0a0e13;
|
||||||
|
--bg-hover: #21262d;
|
||||||
|
--bg-active: #292e36;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--accent-blue: #58a6ff;
|
||||||
|
--accent-green: #3fb950;
|
||||||
|
--accent-red: #f85149;
|
||||||
|
--accent-orange: #d29922;
|
||||||
|
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-blue);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
92
web/src/lib/api.ts
Normal file
92
web/src/lib/api.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const BASE = '/agent'
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
actor: string
|
||||||
|
created_at: string
|
||||||
|
last_active_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string
|
||||||
|
session_id: string
|
||||||
|
role: string
|
||||||
|
content: any
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSessions(): Promise<Session[]> {
|
||||||
|
const res = await fetch(`${BASE}/sessions`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.sessions ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMessages(sessionId: string): Promise<Message[]> {
|
||||||
|
const res = await fetch(`${BASE}/sessions/${sessionId}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.messages ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatEvent {
|
||||||
|
type: string
|
||||||
|
data: any
|
||||||
|
session_id?: string
|
||||||
|
iteration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function streamChat(
|
||||||
|
message: string,
|
||||||
|
sessionId: string | null,
|
||||||
|
onEvent: (ev: ChatEvent) => void,
|
||||||
|
onError: (err: string) => void,
|
||||||
|
onDone: () => void
|
||||||
|
): AbortController {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
fetch(`${BASE}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
|
||||||
|
signal: controller.signal
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
onError(`HTTP ${res.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reader = res.body?.getReader()
|
||||||
|
if (!reader) {
|
||||||
|
onError('no response body')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() ?? ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const ev: ChatEvent = JSON.parse(line.slice(6))
|
||||||
|
onEvent(ev)
|
||||||
|
} catch {
|
||||||
|
// skip malformed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
onError(err.message)
|
||||||
|
}).finally(() => {
|
||||||
|
onDone()
|
||||||
|
})
|
||||||
|
|
||||||
|
return controller
|
||||||
|
}
|
||||||
162
web/src/lib/stores/chat.ts
Normal file
162
web/src/lib/stores/chat.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { writable, get } from 'svelte/store'
|
||||||
|
import { streamChat, fetchSessions, fetchMessages } from '$lib/api'
|
||||||
|
import type { ChatEvent, Session, Message } from '$lib/api'
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
text: string
|
||||||
|
tools: ToolCallResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallResult {
|
||||||
|
type: 'tool_use' | 'tool_result'
|
||||||
|
name: string
|
||||||
|
id?: string
|
||||||
|
args?: any
|
||||||
|
result?: any
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function mid(): string {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messages = writable<ChatMessage[]>([])
|
||||||
|
export const streaming = writable(false)
|
||||||
|
export const currentSession = writable<string | null>(null)
|
||||||
|
export const sessions = writable<Session[]>([])
|
||||||
|
export const sessionMessages = writable<Message[]>([])
|
||||||
|
export const error = writable<string | null>(null)
|
||||||
|
|
||||||
|
let activeController: AbortController | null = null
|
||||||
|
|
||||||
|
export async function loadSessions() {
|
||||||
|
const list = await fetchSessions()
|
||||||
|
sessions.set(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSessionMessages(sessionId: string) {
|
||||||
|
currentSession.set(sessionId)
|
||||||
|
const msgs = await fetchMessages(sessionId)
|
||||||
|
sessionMessages.set(msgs)
|
||||||
|
const chatMsgs: ChatMessage[] = msgs.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
role: m.role as 'user' | 'assistant',
|
||||||
|
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
|
||||||
|
tools: m.content?.tool_calls ?? []
|
||||||
|
}))
|
||||||
|
messages.set(chatMsgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendMessage(text: string) {
|
||||||
|
error.set(null)
|
||||||
|
streaming.set(true)
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: mid(),
|
||||||
|
role: 'user',
|
||||||
|
text,
|
||||||
|
tools: []
|
||||||
|
}
|
||||||
|
messages.update((ms) => [...ms, userMsg])
|
||||||
|
|
||||||
|
const assistantMsg: ChatMessage = {
|
||||||
|
id: mid(),
|
||||||
|
role: 'assistant',
|
||||||
|
text: '',
|
||||||
|
tools: []
|
||||||
|
}
|
||||||
|
messages.update((ms) => [...ms, assistantMsg])
|
||||||
|
|
||||||
|
let activeTools: Map<string, ToolCallResult> = new Map()
|
||||||
|
|
||||||
|
activeController = streamChat(
|
||||||
|
text,
|
||||||
|
get(currentSession), // continue the active session so the agent keeps context
|
||||||
|
(ev: ChatEvent) => {
|
||||||
|
if (ev.type === 'session') {
|
||||||
|
currentSession.set(ev.data)
|
||||||
|
} else if (ev.type === 'tool_use') {
|
||||||
|
const tr: ToolCallResult = {
|
||||||
|
type: 'tool_use',
|
||||||
|
name: ev.data.name,
|
||||||
|
id: ev.data.id,
|
||||||
|
args: ev.data.args
|
||||||
|
}
|
||||||
|
activeTools.set(ev.data.id, tr)
|
||||||
|
messages.update((ms) => {
|
||||||
|
const last = ms[ms.length - 1]
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
last.tools = [...last.tools, tr]
|
||||||
|
}
|
||||||
|
return [...ms]
|
||||||
|
})
|
||||||
|
} else if (ev.type === 'tool_result') {
|
||||||
|
const existing = activeTools.get(ev.data.id)
|
||||||
|
if (existing) {
|
||||||
|
const updated: ToolCallResult = {
|
||||||
|
...existing,
|
||||||
|
type: 'tool_result',
|
||||||
|
result: ev.data.result,
|
||||||
|
error: ev.data.error
|
||||||
|
}
|
||||||
|
activeTools.set(ev.data.id, updated)
|
||||||
|
messages.update((ms) => {
|
||||||
|
const last = ms[ms.length - 1]
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
last.tools = last.tools.map((t) =>
|
||||||
|
t.id === ev.data.id ? updated : t
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return [...ms]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (ev.type === 'text_delta') {
|
||||||
|
messages.update((ms) => {
|
||||||
|
const last = ms[ms.length - 1]
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
last.text += ev.data
|
||||||
|
}
|
||||||
|
return [...ms]
|
||||||
|
})
|
||||||
|
} else if (ev.type === 'text') {
|
||||||
|
// Final authoritative content for the turn; replaces accumulated deltas.
|
||||||
|
messages.update((ms) => {
|
||||||
|
const last = ms[ms.length - 1]
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
last.text = ev.data
|
||||||
|
}
|
||||||
|
return [...ms]
|
||||||
|
})
|
||||||
|
} else if (ev.type === 'done') {
|
||||||
|
currentSession.set(ev.data?.session_id ?? ev.session_id)
|
||||||
|
} else if (ev.type === 'error') {
|
||||||
|
error.set(ev.data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err: string) => {
|
||||||
|
error.set(err)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
streaming.set(false)
|
||||||
|
activeController = null
|
||||||
|
loadSessions()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newChat() {
|
||||||
|
cancelStream()
|
||||||
|
currentSession.set(null)
|
||||||
|
messages.set([])
|
||||||
|
error.set(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelStream() {
|
||||||
|
if (activeController) {
|
||||||
|
activeController.abort()
|
||||||
|
activeController = null
|
||||||
|
streaming.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web/src/main.ts
Normal file
6
web/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { mount } from 'svelte'
|
||||||
|
import App from './App.svelte'
|
||||||
|
import './app.css'
|
||||||
|
|
||||||
|
const app = mount(App, { target: document.getElementById('app')! })
|
||||||
|
export default app
|
||||||
263
web/src/pages/Chat.svelte
Normal file
263
web/src/pages/Chat.svelte
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||||
|
import { fly } from 'svelte/transition'
|
||||||
|
|
||||||
|
let input = ''
|
||||||
|
let messagesEnd: HTMLDivElement
|
||||||
|
$: $messages, $streaming, setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||||||
|
|
||||||
|
function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
const text = input.trim()
|
||||||
|
if (!text || $streaming) return
|
||||||
|
input = ''
|
||||||
|
sendMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chat">
|
||||||
|
<div class="messages">
|
||||||
|
{#each $messages as msg (msg.id)}
|
||||||
|
<div class="message {msg.role}">
|
||||||
|
<div class="role">{msg.role === 'user' ? 'You' : 'Nomos'}</div>
|
||||||
|
{#if msg.text}
|
||||||
|
<div class="text">{msg.text}</div>
|
||||||
|
{/if}
|
||||||
|
{#each msg.tools as tool (tool.id)}
|
||||||
|
<div class="tool-chip" class:tool-use={tool.type === 'tool_use'} class:tool-result={tool.type === 'tool_result'}>
|
||||||
|
<div class="tool-header" transition:fly={{ y: 4, duration: 150 }}>
|
||||||
|
<span class="tool-icon">{tool.type === 'tool_use' ? '⚙' : '✓'}</span>
|
||||||
|
<span class="tool-name">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
{#if tool.type === 'tool_use' && tool.args}
|
||||||
|
<div class="tool-body">
|
||||||
|
<pre>{JSON.stringify(tool.args, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if tool.type === 'tool_result'}
|
||||||
|
<div class="tool-body">
|
||||||
|
{#if tool.error}
|
||||||
|
<pre class="error">{tool.error}</pre>
|
||||||
|
{:else}
|
||||||
|
<pre>{JSON.stringify(tool.result, null, 2)}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if !msg.text && msg.tools.length === 0 && msg.role === 'assistant'}
|
||||||
|
<div class="thinking">Thinking<span class="dots"></span></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div bind:this={messagesEnd}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $error}
|
||||||
|
<div class="error-banner" transition:fly={{ y: 8, duration: 200 }}>
|
||||||
|
{$error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="input-bar" onsubmit={handleSubmit}>
|
||||||
|
<textarea
|
||||||
|
bind:value={input}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
placeholder="Ask Nomos anything..."
|
||||||
|
rows={2}
|
||||||
|
disabled={$streaming}
|
||||||
|
></textarea>
|
||||||
|
{#if $streaming}
|
||||||
|
<button type="button" class="stop" onclick={cancelStream}>■</button>
|
||||||
|
{:else}
|
||||||
|
<button type="submit" disabled={!input.trim()}>→</button>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
max-width: 100%;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots::after {
|
||||||
|
content: '';
|
||||||
|
animation: dots 1.5s steps(4, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dots {
|
||||||
|
0% { content: ''; }
|
||||||
|
25% { content: '.'; }
|
||||||
|
50% { content: '..'; }
|
||||||
|
75% { content: '...'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-chip {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-chip.tool-use {
|
||||||
|
border-left: 3px solid var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-chip.tool-result {
|
||||||
|
border-left: 3px solid var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-body {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--bg-deeper);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-body pre {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-body pre.error {
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: var(--accent-red);
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-deeper);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar textarea:focus {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent-blue);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: flex-end;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar button:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar button.stop {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
web/src/pages/Sessions.svelte
Normal file
90
web/src/pages/Sessions.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { sessions, loadSessions, loadSessionMessages, messages, currentSession } from '$lib/stores/chat'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadSessions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sessions-page">
|
||||||
|
<h2>Sessions</h2>
|
||||||
|
<div class="session-list">
|
||||||
|
{#each $sessions as session (session.id)}
|
||||||
|
<button
|
||||||
|
class="session-card"
|
||||||
|
class:active={$currentSession === session.id}
|
||||||
|
onclick={() => loadSessionMessages(session.id)}
|
||||||
|
>
|
||||||
|
<div class="session-title">{session.title || 'Untitled'}</div>
|
||||||
|
<div class="session-meta">
|
||||||
|
{new Date(session.last_active_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">No sessions yet. Start chatting with Nomos.</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sessions-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card:hover {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card.active {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
web/tsconfig.json
Normal file
15
web/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"paths": {
|
||||||
|
"$lib/*": ["./src/lib/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
20
web/vite.config.ts
Normal file
20
web/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
base: '/ui/',
|
||||||
|
resolve: {
|
||||||
|
alias: { $lib: '/src/lib' }
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8090',
|
||||||
|
'/agent': 'http://localhost:8092'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user