diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..b481770 --- /dev/null +++ b/backend/Makefile @@ -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 diff --git a/backend/backend.go b/backend/backend.go new file mode 100644 index 0000000..fb77382 --- /dev/null +++ b/backend/backend.go @@ -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) +} diff --git a/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 0000000..a913128 --- /dev/null +++ b/backend/cmd/main.go @@ -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) +} diff --git a/backend/design.md b/backend/design.md new file mode 100644 index 0000000..b482ec8 --- /dev/null +++ b/backend/design.md @@ -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. diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..b3238b0 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..44a4b1e --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..6647377 --- /dev/null +++ b/backend/internal/config/config.go @@ -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"), + } +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..51a3afe --- /dev/null +++ b/backend/internal/db/db.go @@ -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 +} diff --git a/backend/internal/db/migrations/0001_init.sql b/backend/internal/db/migrations/0001_init.sql new file mode 100644 index 0000000..fb05826 --- /dev/null +++ b/backend/internal/db/migrations/0001_init.sql @@ -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) +); diff --git a/backend/internal/db/repo.go b/backend/internal/db/repo.go new file mode 100644 index 0000000..3a5a712 --- /dev/null +++ b/backend/internal/db/repo.go @@ -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 +} diff --git a/backend/internal/glue/branches.go b/backend/internal/glue/branches.go new file mode 100644 index 0000000..759385e --- /dev/null +++ b/backend/internal/glue/branches.go @@ -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) +} diff --git a/backend/internal/glue/completion.go b/backend/internal/glue/completion.go new file mode 100644 index 0000000..95dc19c --- /dev/null +++ b/backend/internal/glue/completion.go @@ -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 +} diff --git a/backend/internal/glue/conversations.go b/backend/internal/glue/conversations.go new file mode 100644 index 0000000..74f9c21 --- /dev/null +++ b/backend/internal/glue/conversations.go @@ -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) +} diff --git a/backend/internal/glue/linearize.go b/backend/internal/glue/linearize.go new file mode 100644 index 0000000..1fd0596 --- /dev/null +++ b/backend/internal/glue/linearize.go @@ -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 +} diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go new file mode 100644 index 0000000..4e5441f --- /dev/null +++ b/backend/internal/http/handlers.go @@ -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) +} diff --git a/backend/internal/http/middleware.go b/backend/internal/http/middleware.go new file mode 100644 index 0000000..942fa8d --- /dev/null +++ b/backend/internal/http/middleware.go @@ -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 +} diff --git a/backend/internal/http/router.go b/backend/internal/http/router.go new file mode 100644 index 0000000..15d0491 --- /dev/null +++ b/backend/internal/http/router.go @@ -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) +} diff --git a/backend/internal/logx/log.go b/backend/internal/logx/log.go new file mode 100644 index 0000000..728da6c --- /dev/null +++ b/backend/internal/logx/log.go @@ -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...) +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..918fe6d --- /dev/null +++ b/backend/internal/models/models.go @@ -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 +} diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql new file mode 100644 index 0000000..fb05826 --- /dev/null +++ b/backend/migrations/0001_init.sql @@ -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) +); diff --git a/backend/migrations/init.sql b/backend/migrations/init.sql new file mode 100644 index 0000000..db975a0 --- /dev/null +++ b/backend/migrations/init.sql @@ -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); diff --git a/backend/mind.db b/backend/mind.db new file mode 100644 index 0000000..ee85de9 Binary files /dev/null and b/backend/mind.db differ diff --git a/backend/src/#repo.go# b/backend/src/#repo.go# new file mode 100644 index 0000000..45c46ae --- /dev/null +++ b/backend/src/#repo.go# @@ -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 diff --git a/backend/src/.#repo.go b/backend/src/.#repo.go new file mode 120000 index 0000000..61b330a --- /dev/null +++ b/backend/src/.#repo.go @@ -0,0 +1 @@ +peisongxiao@PeisongXiao.4764:1760154185 \ No newline at end of file diff --git a/backend/src/repo.go b/backend/src/repo.go new file mode 100644 index 0000000..b33ad96 --- /dev/null +++ b/backend/src/repo.go @@ -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) +);