Added code for backend glue
This commit is contained in:
9
backend/Makefile
Normal file
9
backend/Makefile
Normal 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
114
backend/backend.go
Normal 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
53
backend/cmd/main.go
Normal 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
98
backend/design.md
Normal 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
22
backend/go.mod
Normal 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
53
backend/go.sum
Normal 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=
|
||||
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
|
||||
}
|
||||
40
backend/migrations/0001_init.sql
Normal file
40
backend/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)
|
||||
);
|
||||
66
backend/migrations/init.sql
Normal file
66
backend/migrations/init.sql
Normal 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
BIN
backend/mind.db
Normal file
Binary file not shown.
17
backend/src/#repo.go#
Normal file
17
backend/src/#repo.go#
Normal 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
1
backend/src/.#repo.go
Symbolic link
@@ -0,0 +1 @@
|
||||
peisongxiao@PeisongXiao.4764:1760154185
|
||||
126
backend/src/repo.go
Normal file
126
backend/src/repo.go
Normal 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)
|
||||
);
|
||||
Reference in New Issue
Block a user