Added code for backend glue
This commit is contained in:
23
backend/internal/config/config.go
Normal file
23
backend/internal/config/config.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DSN string // e.g. mysql: "user:pass@tcp(127.0.0.1:3306)/mind?parseTime=true"; sqlite: "file:mind.db?_pragma=busy_timeout(5000)"
|
||||
Driver string // "mysql" or "sqlite"
|
||||
JWTSecret string
|
||||
Port string // default 8080
|
||||
}
|
||||
|
||||
func getenv(k, def string) string { v := os.Getenv(k); if v == "" { return def }; return v }
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
DSN: getenv("DB_DSN", "file:mind.db?_pragma=busy_timeout(5000)"),
|
||||
Driver: getenv("DB_DRIVER", "sqlite"),
|
||||
JWTSecret: getenv("JWT_SECRET", "devsecret"),
|
||||
Port: getenv("PORT", "8080"),
|
||||
}
|
||||
}
|
||||
47
backend/internal/db/db.go
Normal file
47
backend/internal/db/db.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"mind/internal/config"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
func OpenAndMigrate(cfg config.Config) (*sql.DB, error) {
|
||||
dsn := cfg.DSN
|
||||
drv := strings.ToLower(cfg.Driver)
|
||||
if drv != "mysql" && drv != "sqlite" {
|
||||
drv = "sqlite"
|
||||
}
|
||||
db, err := sql.Open(drv, dsn)
|
||||
if err != nil { return nil, err }
|
||||
if err := db.Ping(); err != nil { return nil, err }
|
||||
if err := runMigrations(db, drv); err != nil { return nil, err }
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func runMigrations(db *sql.DB, driver string) error {
|
||||
files, err := migrationFS.ReadDir("migrations")
|
||||
if err != nil { return err }
|
||||
for _, f := range files {
|
||||
b, err := migrationFS.ReadFile("migrations/"+f.Name())
|
||||
if err != nil { return err }
|
||||
sqlText := string(b)
|
||||
if driver == "sqlite" {
|
||||
// very minor compatibility tweak: drop ENUM
|
||||
sqlText = strings.ReplaceAll(sqlText, "ENUM('user','assistant')", "TEXT")
|
||||
}
|
||||
if _, err := db.Exec(sqlText); err != nil {
|
||||
return fmt.Errorf("migration %s: %w", f.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
40
backend/internal/db/migrations/0001_init.sql
Normal file
40
backend/internal/db/migrations/0001_init.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- users (minimal for v0)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email VARCHAR(255) UNIQUE,
|
||||
pass_bcrypt BLOB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- conversations
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL,
|
||||
title VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- nodes
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
author_kind ENUM('user','assistant') NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- edges (DAG)
|
||||
CREATE TABLE IF NOT EXISTS edges (
|
||||
parent_id INTEGER NOT NULL,
|
||||
child_id INTEGER NOT NULL,
|
||||
PRIMARY KEY(parent_id, child_id)
|
||||
);
|
||||
|
||||
-- branches (named pointers)
|
||||
CREATE TABLE IF NOT EXISTS branches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
head_node_id INTEGER NOT NULL,
|
||||
UNIQUE (conversation_id, name)
|
||||
);
|
||||
88
backend/internal/db/repo.go
Normal file
88
backend/internal/db/repo.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"mind/internal/models"
|
||||
)
|
||||
|
||||
type Repo struct { db *sql.DB }
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
// Conversations
|
||||
func (r *Repo) CreateConversation(ctx context.Context, ownerID int64, title string) (int64, error) {
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO conversations(owner_id,title) VALUES(?,?)`, ownerID, title)
|
||||
if err != nil { return 0, err }
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (r *Repo) ListConversations(ctx context.Context, ownerID int64) ([]models.Conversation, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, owner_id, title, created_at FROM conversations WHERE owner_id=? ORDER BY id DESC`, ownerID)
|
||||
if err != nil { return nil, err }
|
||||
defer rows.Close()
|
||||
var out []models.Conversation
|
||||
for rows.Next() {
|
||||
var c models.Conversation
|
||||
if err := rows.Scan(&c.ID, &c.OwnerID, &c.Title, &c.Created); err != nil { return nil, err }
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Nodes & Edges
|
||||
func (r *Repo) CreateNode(ctx context.Context, convID int64, authorKind, content string) (int64, error) {
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO nodes(conversation_id,author_kind,content) VALUES(?,?,?)`, convID, authorKind, content)
|
||||
if err != nil { return 0, err }
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (r *Repo) Link(ctx context.Context, parentID, childID int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO edges(parent_id,child_id) VALUES(?,?)`, parentID, childID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) GetNode(ctx context.Context, id int64) (models.Node, error) {
|
||||
var n models.Node
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id,conversation_id,author_kind,content,created_at FROM nodes WHERE id=?`, id).
|
||||
Scan(&n.ID, &n.ConversationID, &n.AuthorKind, &n.Content, &n.Created)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Branches
|
||||
func (r *Repo) CreateOrGetBranch(ctx context.Context, convID int64, name string, headNodeID int64) (models.Branch, error) {
|
||||
// Try insert
|
||||
_, _ = r.db.ExecContext(ctx, `INSERT INTO branches(conversation_id,name,head_node_id) VALUES(?,?,?)`, convID, name, headNodeID)
|
||||
return r.GetBranch(ctx, convID, name)
|
||||
}
|
||||
|
||||
func (r *Repo) GetBranch(ctx context.Context, convID int64, name string) (models.Branch, error) {
|
||||
var b models.Branch
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id,conversation_id,name,head_node_id FROM branches WHERE conversation_id=? AND name=?`, convID, name).
|
||||
Scan(&b.ID, &b.ConversationID, &b.Name, &b.HeadNodeID)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (r *Repo) MoveBranchHead(ctx context.Context, branchID, newHeadID int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE branches SET head_node_id=? WHERE id=?`, newHeadID, branchID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Latest-parent walk for linearize
|
||||
func (r *Repo) LatestParent(ctx context.Context, childID int64) (models.Node, bool, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT n.id,n.conversation_id,n.author_kind,n.content,n.created_at
|
||||
FROM edges e
|
||||
JOIN nodes n ON n.id=e.parent_id
|
||||
WHERE e.child_id=?
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 1`, childID)
|
||||
var n models.Node
|
||||
if err := row.Scan(&n.ID, &n.ConversationID, &n.AuthorKind, &n.Content, &n.Created); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) { return models.Node{}, false, nil }
|
||||
return models.Node{}, false, err
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
12
backend/internal/glue/branches.go
Normal file
12
backend/internal/glue/branches.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package glue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mind/internal/models"
|
||||
)
|
||||
|
||||
type ForkReq struct { ConversationID int64; Name string; HeadNodeID int64 }
|
||||
|
||||
func (g *Glue) ForkBranch(ctx context.Context, fr ForkReq) (models.Branch, error) {
|
||||
return g.repo.CreateOrGetBranch(ctx, fr.ConversationID, fr.Name, fr.HeadNodeID)
|
||||
}
|
||||
39
backend/internal/glue/completion.go
Normal file
39
backend/internal/glue/completion.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package glue
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type CompletionReq struct {
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
BranchName string `json:"branch"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
type CompletionResp struct {
|
||||
PromptNodeID int64 `json:"prompt_node_id"`
|
||||
AnswerNodeID int64 `json:"answer_node_id"`
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
|
||||
// For v0 we stub the answer as a simple echo with a prefix.
|
||||
func (g *Glue) AppendCompletion(ctx context.Context, req CompletionReq) (CompletionResp, error) {
|
||||
b, err := g.repo.GetBranch(ctx, req.ConversationID, req.BranchName)
|
||||
if err != nil { return CompletionResp{}, err }
|
||||
|
||||
// 1) create user prompt node
|
||||
promptID, err := g.repo.CreateNode(ctx, req.ConversationID, "user", req.Prompt)
|
||||
if err != nil { return CompletionResp{}, err }
|
||||
if err := g.repo.Link(ctx, b.HeadNodeID, promptID); err != nil { return CompletionResp{}, err }
|
||||
|
||||
// 2) create assistant answer node (stub)
|
||||
answerText := "(stub) You said: " + req.Prompt
|
||||
answerID, err := g.repo.CreateNode(ctx, req.ConversationID, "assistant", answerText)
|
||||
if err != nil { return CompletionResp{}, err }
|
||||
if err := g.repo.Link(ctx, promptID, answerID); err != nil { return CompletionResp{}, err }
|
||||
|
||||
// 3) move branch head
|
||||
if err := g.repo.MoveBranchHead(ctx, b.ID, answerID); err != nil { return CompletionResp{}, err }
|
||||
|
||||
return CompletionResp{PromptNodeID: promptID, AnswerNodeID: answerID, Answer: answerText}, nil
|
||||
}
|
||||
19
backend/internal/glue/conversations.go
Normal file
19
backend/internal/glue/conversations.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package glue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mind/internal/db"
|
||||
"mind/internal/models"
|
||||
)
|
||||
|
||||
type Glue struct { repo *db.Repo }
|
||||
|
||||
func NewGlue(r *db.Repo) *Glue { return &Glue{repo: r} }
|
||||
|
||||
func (g *Glue) CreateConversation(ctx context.Context, ownerID int64, title string) (int64, error) {
|
||||
return g.repo.CreateConversation(ctx, ownerID, title)
|
||||
}
|
||||
|
||||
func (g *Glue) ListConversations(ctx context.Context, ownerID int64) ([]models.Conversation, error) {
|
||||
return g.repo.ListConversations(ctx, ownerID)
|
||||
}
|
||||
41
backend/internal/glue/linearize.go
Normal file
41
backend/internal/glue/linearize.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package glue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mind/internal/models"
|
||||
)
|
||||
|
||||
type Linearized struct {
|
||||
Nodes []models.Node `json:"nodes"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (g *Glue) LinearizeByBranch(ctx context.Context, convID int64, branchName string) (Linearized, error) {
|
||||
b, err := g.repo.GetBranch(ctx, convID, branchName)
|
||||
if err != nil { return Linearized{}, err }
|
||||
return g.linearizeFromHead(ctx, b.HeadNodeID)
|
||||
}
|
||||
|
||||
func (g *Glue) linearizeFromHead(ctx context.Context, head int64) (Linearized, error) {
|
||||
var seq []models.Node
|
||||
// Walk parents by latest-created parent until none
|
||||
curID := head
|
||||
for {
|
||||
n, err := g.repo.GetNode(ctx, curID)
|
||||
if err != nil { return Linearized{}, err }
|
||||
seq = append(seq, n)
|
||||
p, ok, err := g.repo.LatestParent(ctx, curID)
|
||||
if err != nil { return Linearized{}, err }
|
||||
if !ok { break }
|
||||
curID = p.ID
|
||||
}
|
||||
// Reverse seq to root→head, and stitch text
|
||||
for i, j := 0, len(seq)-1; i < j; i, j = i+1, j-1 { seq[i], seq[j] = seq[j], seq[i] }
|
||||
var txt string
|
||||
for _, n := range seq {
|
||||
role := "[user]"
|
||||
if n.AuthorKind == "assistant" { role = "[assistant]" }
|
||||
txt += role + "\n" + n.Content + "\n\n"
|
||||
}
|
||||
return Linearized{Nodes: seq, Text: txt}, nil
|
||||
}
|
||||
60
backend/internal/http/handlers.go
Normal file
60
backend/internal/http/handlers.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"mind/internal/glue"
|
||||
)
|
||||
|
||||
// POST /conversations {"title":"demo","owner_id":1}
|
||||
// GET /conversations?owner_id=1
|
||||
func (s *server) conversations(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
var in struct { Title string `json:"title"`; OwnerID int64 `json:"owner_id"` }
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, 400, map[string]string{"error":"bad json"}); return }
|
||||
id, err := s.glue.CreateConversation(r.Context(), in.OwnerID, in.Title)
|
||||
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
|
||||
writeJSON(w, 200, map[string]any{"id": id})
|
||||
case http.MethodGet:
|
||||
ownerStr := r.URL.Query().Get("owner_id")
|
||||
owner, _ := strconv.ParseInt(ownerStr, 10, 64)
|
||||
out, err := s.glue.ListConversations(r.Context(), owner)
|
||||
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
|
||||
writeJSON(w, 200, out)
|
||||
default:
|
||||
w.WriteHeader(405)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /branches {conversation_id, name, head_node_id}
|
||||
func (s *server) branches(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost { w.WriteHeader(405); return }
|
||||
var in glue.ForkReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, 400, map[string]string{"error":"bad json"}); return }
|
||||
b, err := s.glue.ForkBranch(r.Context(), in)
|
||||
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
|
||||
writeJSON(w, 200, b)
|
||||
}
|
||||
|
||||
// POST /completion {conversation_id, branch, prompt}
|
||||
func (s *server) completion(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost { w.WriteHeader(405); return }
|
||||
var in glue.CompletionReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeJSON(w, 400, map[string]string{"error":"bad json"}); return }
|
||||
resp, err := s.glue.AppendCompletion(r.Context(), in)
|
||||
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
|
||||
writeJSON(w, 200, resp)
|
||||
}
|
||||
|
||||
// GET /linearize?conversation_id=..&branch=main
|
||||
func (s *server) linearize(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet { w.WriteHeader(405); return }
|
||||
convID, _ := strconv.ParseInt(r.URL.Query().Get("conversation_id"), 10, 64)
|
||||
branch := r.URL.Query().Get("branch")
|
||||
out, err := s.glue.LinearizeByBranch(r.Context(), convID, branch)
|
||||
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
7
backend/internal/http/middleware.go
Normal file
7
backend/internal/http/middleware.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package http
|
||||
|
||||
import "net/http"
|
||||
|
||||
func withCommon(next http.Handler) http.Handler {
|
||||
return http.TimeoutHandler(next, 60_000_000_000, "timeout") // 60s
|
||||
}
|
||||
39
backend/internal/http/router.go
Normal file
39
backend/internal/http/router.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"mind/internal/config"
|
||||
"mind/internal/glue"
|
||||
"mind/internal/logx"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
log *logx.Logger
|
||||
cfg config.Config
|
||||
glue *glue.Glue
|
||||
}
|
||||
|
||||
func NewRouter(l *logx.Logger, c config.Config, g *glue.Glue) http.Handler {
|
||||
s := &server{log: l, cfg: c, glue: g}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", s.health)
|
||||
mux.HandleFunc("/conversations", s.conversations)
|
||||
mux.HandleFunc("/branches", s.branches)
|
||||
mux.HandleFunc("/completion", s.completion)
|
||||
mux.HandleFunc("/linearize", s.linearize)
|
||||
return withCommon(mux)
|
||||
}
|
||||
|
||||
func (s *server) health(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Helpers
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
19
backend/internal/logx/log.go
Normal file
19
backend/internal/logx/log.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package logx
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
type Logger struct{}
|
||||
|
||||
func New() *Logger {
|
||||
return &Logger{}
|
||||
}
|
||||
|
||||
func (l *Logger) Infof(format string, v ...any) {
|
||||
log.Printf("INFO "+format, v...)
|
||||
}
|
||||
|
||||
func (l *Logger) Errorf(format string, v ...any) {
|
||||
log.Printf("ERROR "+format, v...)
|
||||
}
|
||||
30
backend/internal/models/models.go
Normal file
30
backend/internal/models/models.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
ID int64
|
||||
OwnerID int64
|
||||
Title string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
ID int64
|
||||
ConversationID int64
|
||||
AuthorKind string // 'user' | 'assistant'
|
||||
Content string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
type Branch struct {
|
||||
ID int64
|
||||
ConversationID int64
|
||||
Name string
|
||||
HeadNodeID int64
|
||||
}
|
||||
Reference in New Issue
Block a user