Added code for backend glue

This commit is contained in:
2025-10-13 19:20:24 -04:00
parent 692b069b5b
commit 29a451ab58
25 changed files with 1063 additions and 0 deletions

9
backend/Makefile Normal file
View File

@@ -0,0 +1,9 @@
build:
mkdir -p bin && go build -o bin/mind ./cmd/mind
run: build
DB_DSN="user:pass@tcp(localhost:3306)/mind?parseTime=true" \
JWT_SECRET="devsecret" PORT=8080 ./bin/mind
migrate:
go run ./cmd/mind --migrate

114
backend/backend.go Normal file
View File

@@ -0,0 +1,114 @@
package db
import (
"fmt"
"context"
"database/sql"
)
type Repo struct{ DB *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{DB: db} }
// --- Conversations ---
const qCreateConversation = `
INSERT INTO conversations (owner_id, title) VALUES (?, ?);
`
func (r *Repo) CreateConversation(ctx context.Context, ownerID int64, title string) (int64, error) {
res, err := r.DB.ExecContext(ctx, qCreateConversation, ownerID, title)
if err != nil { return 0, err }
return res.LastInsertId()
}
const qListConversations = `
SELECT id, owner_id, title, created_at
FROM conversations
WHERE owner_id = ?
ORDER BY created_at DESC, id DESC;
`
func (r *Repo) ListConversations(ctx context.Context, ownerID int64) (*sql.Rows, error) {
return r.DB.QueryContext(ctx, qListConversations, ownerID)
}
// --- Nodes (commits) ---
const qCreateNode = `
INSERT INTO nodes (conversation_id, author_kind, content) VALUES (?, ?, ?);
`
func (r *Repo) CreateNode(ctx context.Context, convID int64, authorKind, content string) (int64, error) {
res, err := r.DB.ExecContext(ctx, qCreateNode, convID, authorKind, content)
if err != nil { return 0, err }
return res.LastInsertId()
}
// --- Branches ---
const qCreateBranch = `
INSERT INTO branches (conversation_id, name, head_node_id) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE head_node_id = VALUES(head_node_id);
`
func (r *Repo) CreateOrUpdateBranch(ctx context.Context, convID int64, name string, headNodeID int64) (int64, error) {
res, err := r.DB.ExecContext(ctx, qCreateBranch, convID, name, headNodeID)
if err != nil { return 0, err }
return res.LastInsertId()
}
const qGetBranch = `
SELECT id, conversation_id, name, head_node_id, created_at
FROM branches
WHERE conversation_id = ? AND name = ?
LIMIT 1;
`
func (r *Repo) GetBranch(ctx context.Context, convID int64, name string) *sql.Row {
return r.DB.QueryRowContext(ctx, qGetBranch, convID, name)
}
const qMoveBranchHead = `
UPDATE branches SET head_node_id = ? WHERE id = ?;
`
func (r *Repo) MoveBranchHead(ctx context.Context, branchID, newHead int64) error {
_, err := r.DB.ExecContext(ctx, qMoveBranchHead, newHead, branchID)
return err
}
// --- Edges (DAG) ---
const qCycleGuard = `
WITH RECURSIVE downchain AS (
SELECT e.child_id
FROM edges e
WHERE e.parent_id = ?
UNION ALL
SELECT e.child_id
FROM edges e
JOIN downchain d ON d.child_id = e.parent_id
)
SELECT 1 FROM downchain WHERE child_id = ? LIMIT 1;
`
const qInsertEdge = `INSERT IGNORE INTO edges (parent_id, child_id) VALUES (?, ?);`
func (r *Repo) LinkEdgeCycleSafe(ctx context.Context, tx *sql.Tx, parentID, childID int64) error {
// cycle check: is parent reachable from child already?
var one int
err := tx.QueryRowContext(ctx, qCycleGuard, childID, parentID).Scan(&one)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == nil {
// found a row -> would create a cycle
return fmt.Errorf("cycle detected: %d -> %d", parentID, childID)
}
_, err = tx.ExecContext(ctx, qInsertEdge, parentID, childID)
return err
}
// --- Ancestor step (latest parent) ---
const qLatestParent = `
SELECT p.id, p.conversation_id, p.author_kind, p.content, p.created_at
FROM edges e
JOIN nodes p ON p.id = e.parent_id
WHERE e.child_id = ?
ORDER BY p.created_at DESC, p.id DESC
LIMIT 1;
`
func (r *Repo) LatestParent(ctx context.Context, childID int64) *sql.Row {
return r.DB.QueryRowContext(ctx, qLatestParent, childID)
}

53
backend/cmd/main.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"mind/internal/config"
"mind/internal/db"
httpapi "mind/internal/http"
"mind/internal/logx"
"mind/internal/glue"
)
func main() {
cfg := config.Load()
logger := logx.New()
database, err := db.OpenAndMigrate(cfg)
if err != nil {
log.Fatalf("db init: %v", err)
}
repo := db.NewRepo(database)
g := glue.NewGlue(repo)
r := httpapi.NewRouter(logger, cfg, g)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
}
go func() {
logger.Infof("MIND v0 listening on :%s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Errorf("server error: %v", err)
}
}()
// graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}

98
backend/design.md Normal file
View File

@@ -0,0 +1,98 @@
# MIND - Modular Inference & Node Database - Server-Side Design
## High-Level Overview
### Inference Engine - `llama.cpp`
A modified version of `llama.cpp` that provides extra fields in its
completion API to specify the use of on-disk kv-cache. And also tells
the client where the new kv-cache blocks are located.
### Database - MySQL
This will store the information about users, the conversation
histories, and also the index to the kv-cache stored as chunks on
disk.
### Backend Server - Go Layer
This will provide the APIs used by the frontend, and will talk to the
inference engine so that it can load the correct chunks of kv-cache
into memory or reconstruct a conversation out of cache, and will
handle the life cycle of caches stored on disk.
It will also handle authentication (add-on feature).
### CLI Interface - Go/Python
This will provide a simple interface to access all the features
provided by the backend of ease of testing and prototyping.
## Supported APIs For the Backend
Note that all APIs will need to encode the owner of the node.
### `POST /conversations`
This will start a new conversation tree. The `go` backend should
handle the node creation.
### `GET /conversations`
This will return all the root nodes of the conversation trees, to
provide context for the user to switch conversation trees.
### `GET /tree`
This will return the DAG under root, or within a specified depth or
reversed depth from leaves, which would provide context for the user
to switch between branches on a given tree.
### `POST /branches`
Creates a new fork from a given commit.
### `GET /branches`
List the branches of related to the current branch. Can also specify
the maximum branch-off points to list.
### `POST /graft`
Attach a range of nodes from another conversation.
### `POST /detach`
Detaches a branch into a new conversation.
### `GET /linearize`
Reconstruct a linear conversation history from a branch or node.
### `POST /completion`
Trigger inference from the last node of a branch, creating two new
nodes, one for the prompt and one for the answer.
Note that this is for talking to the go backend, so the go backend
will be responsible for bookkeeping the kv-cache on disk, and the
frontend doesn't need to worry about it.
### `POST /login`
Logs into a certain user.
### `POST /logout`
Logs out a user.
### `GET /me`
Get the current user.
## Database-Specific
The database should keep track of reachability and the backend should
automatically remove orphaned nodes and caches.
It should also keep track of the DAG generated by the prompts and
answers and different root nodes.
## Cache
For a single user, the kv-cache on disk should only concern the
working node, that is, all of its parent nodes.
## Multiple users
### Authentication
JWT-based authentication and multi-user switching, all API calls
except for `POST /login` would require a token.
The default token will be given for earlier stages.
### Queuing
The go layer should also be responsible for keeping track of the
`llama.cpp` services availability and queue prompts in the case of
multiple users.

22
backend/go.mod Normal file
View File

@@ -0,0 +1,22 @@
module mind
go 1.25.1
require (
github.com/go-sql-driver/mysql v1.9.3
modernc.org/sqlite v1.39.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

53
backend/go.sum Normal file
View File

@@ -0,0 +1,53 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View 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
View 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
}

View 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)
);

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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...)
}

View 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
}

View 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)
);

View File

@@ -0,0 +1,66 @@
SET sql_mode = 'STRICT_ALL_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
-- USERS
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) NOT NULL UNIQUE,
pass_bcrypt VARBINARY(60) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- CONVERSATIONS (one tree-ish DAG per conversation)
CREATE TABLE IF NOT EXISTS conversations (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
owner_id BIGINT NOT NULL,
title VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_conversations_owner
FOREIGN KEY (owner_id) REFERENCES users(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
-- NODES = commits (plain numeric IDs). author_kind for display only.
CREATE TABLE IF NOT EXISTS nodes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
conversation_id BIGINT NOT NULL,
author_kind ENUM('user','assistant') NOT NULL,
content MEDIUMTEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_nodes_conversation
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
-- EDGES (parent -> child). Acyclic enforced in application.
CREATE TABLE IF NOT EXISTS edges (
parent_id BIGINT NOT NULL,
child_id BIGINT NOT NULL,
PRIMARY KEY (parent_id, child_id),
CONSTRAINT fk_edges_parent
FOREIGN KEY (parent_id) REFERENCES nodes(id)
ON DELETE CASCADE,
CONSTRAINT fk_edges_child
FOREIGN KEY (child_id) REFERENCES nodes(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
-- BRANCHES (named pointers to any node within a conversation)
CREATE TABLE IF NOT EXISTS branches (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
conversation_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL,
head_node_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_branch_name (conversation_id, name),
CONSTRAINT fk_branches_conversation
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
ON DELETE CASCADE,
CONSTRAINT fk_branches_head
FOREIGN KEY (head_node_id) REFERENCES nodes(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE INDEX idx_nodes_conv_created ON nodes (conversation_id, created_at, id);
CREATE INDEX idx_edges_child ON edges (child_id);
CREATE INDEX idx_edges_parent ON edges (parent_id);
CREATE INDEX idx_branches_conv_head ON branches (conversation_id, head_node_id);

BIN
backend/mind.db Normal file

Binary file not shown.

17
backend/src/#repo.go# Normal file
View File

@@ -0,0 +1,17 @@
# Directory: internal/db/db.go
# Directory: internal/db/repo.go
# Directory: internal/glue/conversations.go
# Directory: internal/glue/branches.go
# Directory: internal/glue/completion.go
# Directory: internal/glue/linearize.go
# Directory: internal/http/router.go
# Directory: internal/http/middleware.go
# Directory: migrations/0001_init.sql

1
backend/src/.#repo.go Symbolic link
View File

@@ -0,0 +1 @@
peisongxiao@PeisongXiao.4764:1760154185

126
backend/src/repo.go Normal file
View File

@@ -0,0 +1,126 @@
# Directory: internal/db/db.go
# Directory: internal/db/repo.go
# Directory: internal/glue/conversations.go
# Directory: internal/glue/branches.go
# Directory: internal/glue/completion.go
# Directory: internal/glue/linearize.go
# Directory: internal/http/router.go
# Directory: internal/http/middleware.go
package http
import "net/http"
func withCommon(next http.Handler) http.Handler {
return http.TimeoutHandler(next, 60_000_000_000, "timeout") // 60s
}
# Directory: internal/http/handlers.go
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)
}
# Directory: migrations/0001_init.sql
-- 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)
);