Compare commits

19 Commits

Author SHA1 Message Date
6af58a7181 added .DS_Store to gitignore 2025-10-25 16:56:43 -04:00
d7301fcb22 fixed link issues with README.md 2025-10-21 14:36:37 -04:00
780493f2c4 fixed link issues with README.md 2025-10-21 14:35:20 -04:00
c026d6a293 fixed link issues with README.md 2025-10-21 14:34:08 -04:00
210d349ea3 added README for milestone-1 and the short video demo 2025-10-21 14:31:20 -04:00
833ce512a6 modified README for backend 2025-10-20 23:27:12 -04:00
9c816df616 moved sample db to milestone-1 2025-10-20 22:56:03 -04:00
3ab8b2bc86 added sample database 2025-10-20 22:46:28 -04:00
c22496493b added README with instructions on setting up the backend 2025-10-20 20:15:26 -04:00
b0c6cfbf62 fortified makefile 2025-10-14 02:28:44 -04:00
e0a90c2ad7 fortified makefile 2025-10-14 02:27:28 -04:00
5eefdcdb5b added make bin to gitignore 2025-10-14 02:26:32 -04:00
20e0ccf783 cleaned files 2025-10-14 02:25:47 -04:00
fd46be63a0 cleaned files 2025-10-14 02:25:04 -04:00
83c1424a37 added script for downloading qwen3-1.7b from huggingface 2025-10-14 02:22:16 -04:00
c04536d5f6 cleaned files 2025-10-14 02:01:57 -04:00
2fdb1ece43 minimal working version of backend and cli 2025-10-14 01:58:24 -04:00
839f0c9107 cleaned file tree structure 2025-10-13 19:23:41 -04:00
29a451ab58 Added code for backend glue 2025-10-13 19:20:24 -04:00
33 changed files with 1471 additions and 1 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.venv/**/*
**/.DS_Store

65
README.md Normal file
View File

@@ -0,0 +1,65 @@
# MIND - Modular Inference & Node Database
Version: Milestone-1
## Setup
This will create and setup a `SQLite` database if `mind.db` doesn't
exist under [`backend/`](backend/).
Also see [`backend/README.md`](backend/README.md).
### Getting a llama.cpp instance
`llama.cpp` is included as a third-party git submodule under
[`third_party`](third_party/).
A script for grabbing a Qwen 3 1.7B LLM model from HuggingFace is at
[`backend/get-qwen3-1.7b.sh`](backend/get-qwen3-1.7b.sh).
The default location for the `Go` layer of the backend to access the LLM
service is `localhost:8081`.
*Please consider adding the `-n <num-of-tokens-to-predict>` option to
avoid infinite generation on smaller models*
### Running the backend layer
Run `go run ./backend/main.go`, which will start a backend instance at
`localhost:8080`.
Note that if `mind.db` doesn't exist in the working directory, it will
be created and be initialized by the code in
[`backend/internal/db/migrations`](backend/internal/db/migrations).
### DB-related
All db-related operations (e.g. queries) are under
[`backend/internal/db`](backend/internal/db).
## Running tests
To run tests, we provide a simple CLI-based frontend client at
[`backend/cli.py`](backend/cli.py).
This will include all of the features supported or under development.
## Current features (and features under development)
A short video demo of basic functionality on the CLI can be found
under [`milestone-1/demo.mp4`](milestone-1/demo.mp4).
The specific APIs used are given in [`backend/design.md`](backend/design.md).
| Action | Command / Description |
|-----------------------------------|------------------------------------------------------------------------------|
| Start conversation | `\new <title> <owner_id>` |
| Commit (prompt/answer or node) | Free text → `/completion` or `\node <user\|assistant> <content> [parent_id]` |
| Fork branch | `\branch <name> [head_node_id]` |
| Delete branch or node | `\delbranch <name>` or `\delnode <node_id>` |
| Extract subset as new tree | `\extract <title> <node_id ...>` |
| Merge two branches | `\merge <left> <right> <left-first\|right-first>` |
| Detach branch to new conversation | `\detach <branch>` |
| Append / copy tree to another | `\append-tree <src_conv> <src_branch> [dst_conv [dst_branch]]` |
| List conversations | `\listconvs <owner_id>` |
| List branches | `\listbranches <conversation_id>` |
| Linearize current branch | `\linearize` |
| Debug llama-server directly | `\llama <prompt>` |
| Switch conversation / branch | `\useconv <id>` and `\usebranch <name>` |
| Show current user | `\who` |
For a more detailed experience, it's strongly recommended to run the
backend locally and trying out the CLI.

3
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
**/mind.db
**/*.gguf
bin/

9
backend/Makefile Normal file
View File

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

26
backend/README.md Normal file
View File

@@ -0,0 +1,26 @@
# MIND Backend
## Setup
Below will setup the backend including the `go` orchestration layer
and a `llama.cpp` inference server on `localhost:8081` and
`localhost:8080` for local testing.
### Building `llama.cpp`
See documentation for `llama.cpp` for details.
### Running `llama.cpp`
#### Getting a `GGUF` format model
Run `./backend/get-qwen3-1.7b.sh` to download the Qwen 3 1.7B model
from HuggingFace.
#### Running the inference server
Run `./llama-server -m <path-to-gguf-model> --port 8081` to run the
inference server at `localhost:8081`.
### Running the backend layer
Run `go run main.go`. This will run the backend layer at
`localhost:8080`.
## A simple CLI client
A simple CLI-based client can be found under `backend/cli.py`, which
will connect to the backend layer at `localhost:8080`.
Please use the `\help` command to view specific operations.

434
backend/cli.py Executable file
View File

@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""
MIND CLI (v0) — supports Ops 18 via REST calls to the Go backend.
Assumptions:
- API base: http://localhost:8080 (change with \\set api ...)
- llama.cpp server: http://localhost:8081 (change with \\set llama ...)
- Auth: dev token ("Bearer dev"). Adjust as needed.
Core usage model:
- Free text (no leading '\\') calls /completion (Op 2 helper) on the active conversation/branch.
- Backslash commands perform explicit ops.
Operations → Commands mapping
1) Start conversation → \\new <title> <owner_id>
2) Commit (prompt/answer or node)→ free text → /completion; or \\node <user|assistant> <content> [parent_id]
3) Fork branch → \\branch <name> [head_node_id]
4) Delete branch or node → \\delbranch <name> | \\delnode <node_id>
5) Extract subset as new tree → \\extract <title> <node_id ...>
6) Merge two branches → \\merge <left> <right> <left-first|right-first>
7) Detach branch to new convo → \\detach <branch>
8) Append/copy tree to another → \\append-tree <src_conv> <src_branch> [dst_conv [dst_branch]]
Helpful extras:
- \\listconvs <owner_id>
- \\listbranches <conversation_id>
- \\linearize (active conv/branch)
- \\llama <prompt> (debug llama-server directly)
- \\useconv <id> ; \\usebranch <name> ; \\who ; \\quit
"""
import json
import os
import shlex
import sys
from typing import Dict, Any, List
import requests
DEFAULT_API = os.environ.get("MIND_API", "http://localhost:8080")
DEFAULT_LLAMA = os.environ.get("LLAMA_API", "http://localhost:8081")
auth_headers = lambda: {"Authorization": "Bearer dev", "Content-Type": "application/json"}
class State:
def __init__(self):
self.api = DEFAULT_API.rstrip('/')
self.llama = DEFAULT_LLAMA.rstrip('/')
self.conversation_id = None # int
self.branch = None # str
def as_dict(self):
return {
"api": self.api,
"llama": self.llama,
"conversation_id": self.conversation_id,
"branch": self.branch,
}
def pretty(obj: Any) -> str:
try:
return json.dumps(obj, indent=2, ensure_ascii=False)
except Exception:
return str(obj)
def post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
r = requests.post(url, headers=auth_headers(), data=json.dumps(payload), timeout=60)
r.raise_for_status()
if r.text.strip() == "":
return {}
return r.json()
def delete_json(url: str, payload: Dict[str, Any] | None = None) -> Dict[str, Any]:
r = requests.delete(url, headers=auth_headers(), data=json.dumps(payload) if payload else None, timeout=60)
r.raise_for_status()
return r.json() if r.text.strip() else {}
def get_json(url: str, params: Dict[str, Any]) -> Dict[str, Any] | List[Any]:
r = requests.get(url, headers=auth_headers(), params=params, timeout=60)
r.raise_for_status()
ct = r.headers.get('Content-Type','')
if 'application/json' in ct:
return r.json()
return {"raw": r.text}
# ---------- Commands ----------
def cmd_help():
print(__doc__)
def cmd_set(st: State, args):
if len(args) < 2:
print("usage: \\set <api|llama> <URL>")
return
key, val = args[0], args[1]
if key == 'api':
st.api = val.rstrip('/')
elif key == 'llama':
st.llama = val.rstrip('/')
else:
print("unknown key; use 'api' or 'llama'")
return
print("ok:", pretty(st.as_dict()))
# (1) Start conversation
def cmd_new(st: State, args):
if len(args) < 2:
print("usage: \\new <title> <owner_id>")
return
title = args[0]
try:
owner_id = int(args[1])
except ValueError:
print("owner_id must be an integer")
return
out = post_json(f"{st.api}/conversations", {"title": title, "owner_id": owner_id})
cid = out.get("id")
st.conversation_id = cid
print("created conversation:", cid)
def cmd_listconvs(st: State, args):
if not args:
print("usage: \\listconvs <owner_id>")
return
owner_id = int(args[0])
out = get_json(f"{st.api}/conversations", {"owner_id": owner_id})
print(pretty(out))
# (3) Fork branch
def cmd_branch(st: State, args):
if st.conversation_id is None:
print("set a conversation first: \\useconv <id> or \\new <title> <owner_id>")
return
if len(args) < 1:
print("usage: \\branch <name> [head_node_id]")
return
name = args[0]
head = int(args[1]) if len(args) > 1 else 0
payload = {
"conversation_id": st.conversation_id,
"name": name,
"head_node_id": head
}
out = post_json(f"{st.api}/branches", payload)
st.branch = out.get("name", name)
print("active branch:", st.branch, pretty(out))
def cmd_usebranch(st: State, args):
if not args:
print("usage: \\usebranch <name>")
return
st.branch = args[0]
print("active branch:", st.branch)
def cmd_listbranches(st: State, args):
if not args:
if st.conversation_id is None:
print("usage: \\listbranches <conversation_id> (or set active with \\useconv)")
return
conv = st.conversation_id
else:
conv = int(args[0])
out = get_json(f"{st.api}/branches", {"conversation_id": conv})
print(pretty(out))
def cmd_useconv(st: State, args):
if not args:
print("usage: \\useconv <conversation_id>")
return
st.conversation_id = int(args[0])
print("active conversation:", st.conversation_id)
# (2) Commit helpers
def cmd_node(st: State, args):
"""Create a single node (explicit Op 2). usage: \\node <user|assistant> <content> [parent_id]"""
if st.conversation_id is None:
print("set active conversation first")
return
if len(args) < 2:
print("usage: \\node <user|assistant> <content> [parent_id]")
return
author = args[0]
content = args[1]
parent_id = int(args[2]) if len(args) > 2 else None
payload = {
"conversation_id": st.conversation_id,
"author_kind": author,
"content": content
}
if parent_id is not None:
payload["parent_id"] = parent_id
out = post_json(f"{st.api}/nodes", payload)
print(pretty(out))
def send_prompt(st: State, line: str):
# Free text → /completion (with auto-branch-create retry)
if st.conversation_id is None or not st.branch:
print("need active conversation and branch (\\new/\\useconv and \\branch/\\usebranch)")
return
payload = {
"conversation_id": st.conversation_id,
"branch": st.branch,
"prompt": line.strip(),
}
try:
out = post_json(f"{st.api}/completion", payload)
except requests.HTTPError as e:
body = e.response.text if e.response is not None else ""
# If branch doesn't exist yet, create it and retry once
if e.response is not None and e.response.status_code in (400, 404, 500) and "branch" in body.lower():
_ = post_json(f"{st.api}/branches", {
"conversation_id": st.conversation_id,
"name": st.branch,
"head_node_id": 0
})
out = post_json(f"{st.api}/completion", payload)
else:
raise
print("assistant:\n", out.get("answer"))
# (4) Delete branch / node
def cmd_delbranch(st: State, args):
if st.conversation_id is None:
print("set active conversation first")
return
if not args:
print("usage: \\delbranch <name>")
return
name = args[0]
out = delete_json(f"{st.api}/branches", {"conversation_id": st.conversation_id, "name": name})
if st.branch == name:
st.branch = None
print("deleted branch:", name, pretty(out))
def cmd_delnode(st: State, args):
if not args:
print("usage: \\delnode <node_id>")
return
node_id = int(args[0])
out = delete_json(f"{st.api}/nodes/{node_id}")
print(pretty(out))
# (5) Extract selected nodes to new conversation
def cmd_extract(st: State, args):
if len(args) < 2:
print("usage: \\extract <title> <node_id ...>")
return
title = args[0]
node_ids = [int(x) for x in args[1:]]
payload = {"conversation_id": st.conversation_id, "title": title, "node_ids": node_ids}
out = post_json(f"{st.api}/extract", payload)
new_cid = out.get("new_conversation_id")
print("extracted to conversation:", new_cid, pretty(out))
# (6) Merge two branches (concat order)
def cmd_merge(st: State, args):
if st.conversation_id is None:
print("set active conversation first")
return
if len(args) < 3:
print("usage: \\merge <left_branch> <right_branch> <left-first|right-first>")
return
left, right, order = args[0], args[1], args[2]
payload = {"conversation_id": st.conversation_id, "left_branch": left, "right_branch": right, "order": order}
out = post_json(f"{st.api}/merge", payload)
print("merge result:", pretty(out))
# (7) Detach branch → new conversation
def cmd_detach(st: State, args):
if st.conversation_id is None:
print("set active conversation first")
return
if not args:
print("usage: \\detach <branch>")
return
payload = {"conversation_id": st.conversation_id, "branch": args[0]}
out = post_json(f"{st.api}/detach", payload)
print("detached to:", pretty(out))
# (8) Append/copy tree
def cmd_append_tree(st: State, args):
if len(args) < 2:
print("usage: \\append-tree <src_conv> <src_branch> [dst_conv [dst_branch]]")
return
src_conv = int(args[0])
src_branch = args[1]
dst_conv = int(args[2]) if len(args) > 2 else st.conversation_id
dst_branch = args[3] if len(args) > 3 else st.branch
payload = {"src_conversation_id": src_conv, "src_branch": src_branch, "dst_conversation_id": dst_conv, "dst_branch": dst_branch}
out = post_json(f"{st.api}/append-tree", payload)
print("append-tree result:", pretty(out))
# Linearize & llama & misc
def cmd_linearize(st: State):
if st.conversation_id is None or not st.branch:
print("need active conversation and branch (\\useconv / \\usebranch)")
return
out = get_json(f"{st.api}/linearize", {"conversation_id": st.conversation_id, "branch": st.branch})
if isinstance(out, dict):
print("==== Transcript ====")
print(out.get("text", ""))
else:
print(pretty(out))
def cmd_llama(st: State, args):
if not args:
print("usage: \\llama <prompt>")
return
prompt = " ".join(args)
payload = {"prompt": prompt}
try:
r = requests.post(f"{st.llama}/completion", headers={"Content-Type": "application/json"}, data=json.dumps(payload), timeout=60)
r.raise_for_status()
data = r.json()
text = None
if isinstance(data, dict):
if 'completion' in data:
text = data['completion']
elif 'content' in data and isinstance(data['content'], list) and data['content']:
seg = data['content'][0]
if isinstance(seg, dict) and 'text' in seg:
text = seg['text']
print("llama says:", text or pretty(data))
except Exception as e:
print("llama error:", e)
def cmd_who(st: State):
print(pretty(st.as_dict()))
# ---------- REPL ----------
def repl():
st = State()
print("MIND CLI — type \\help for commands. Free text sends /completion.")
while True:
try:
line = input("> ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not line:
continue
if line.startswith("\\"):
parts = shlex.split(line[1:])
if not parts:
continue
cmd, *args = parts
cmd = cmd.lower()
try:
if cmd == 'help':
cmd_help()
elif cmd == 'set':
cmd_set(st, args)
elif cmd == 'new':
cmd_new(st, args)
elif cmd == 'listconvs':
cmd_listconvs(st, args)
elif cmd == 'listbranches':
cmd_listbranches(st, args)
elif cmd == 'useconv':
cmd_useconv(st, args)
elif cmd == 'branch':
cmd_branch(st, args)
elif cmd == 'usebranch':
cmd_usebranch(st, args)
elif cmd == 'node':
cmd_node(st, args)
elif cmd == 'delbranch':
cmd_delbranch(st, args)
elif cmd == 'delnode':
cmd_delnode(st, args)
elif cmd == 'extract':
cmd_extract(st, args)
elif cmd == 'merge':
cmd_merge(st, args)
elif cmd == 'detach':
cmd_detach(st, args)
elif cmd in ('append-tree', 'appendtree', 'append'):
cmd_append_tree(st, args)
elif cmd == 'linearize':
cmd_linearize(st)
elif cmd == 'llama':
cmd_llama(st, args)
elif cmd == 'who':
cmd_who(st)
elif cmd in ('quit', 'exit', 'q'):
break
else:
print("unknown command, try \\help")
except requests.HTTPError as e:
try:
print("HTTP", e.response.status_code, e.response.text)
except Exception:
print("HTTP error:", e)
except Exception as e:
print("error:", e)
else:
send_prompt(st, line)
if __name__ == '__main__':
repl()

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.

3
backend/get-qwen3-1.7b.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
link=$(curl https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-BF16.gguf?download=true | awk -F' ' '{print $4}')
curl "$link" > qwen3-1.7b.gguf

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,25 @@
package config
import (
"os"
)
type Config struct {
DSN string
Driver string // "mysql" or "sqlite"
JWTSecret string
Port string
LlamaURL string
}
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"),
LlamaURL: getenv("LLAMA_URL", "http://localhost:8081"),
}
}

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

114
backend/internal/db/repo.go Normal file
View File

@@ -0,0 +1,114 @@
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
}
func (r *Repo) DeleteBranch(ctx context.Context, convID int64, name string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM branches WHERE conversation_id=? AND name=?`, convID, name)
return err
}
// ListBranches returns all branches for a conversation, newest first.
func (r *Repo) ListBranches(ctx context.Context, convID int64) ([]models.Branch, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, conversation_id, name, head_node_id
FROM branches
WHERE conversation_id=?
ORDER BY id DESC`, convID)
if err != nil { return nil, err }
defer rows.Close()
var out []models.Branch
for rows.Next() {
var b models.Branch
if err := rows.Scan(&b.ID, &b.ConversationID, &b.Name, &b.HeadNodeID); err != nil {
return nil, err
}
out = append(out, b)
}
return out, nil
}

View File

@@ -0,0 +1,24 @@
package glue
import (
"context"
"mind/internal/models"
)
type ForkReq struct {
ConversationID int64 `json:"conversation_id"`
Name string `json:"name"`
HeadNodeID int64 `json:"head_node_id"`
}
func (g *Glue) ForkBranch(ctx context.Context, fr ForkReq) (models.Branch, error) {
return g.repo.CreateOrGetBranch(ctx, fr.ConversationID, fr.Name, fr.HeadNodeID)
}
func (g *Glue) DeleteBranch(ctx context.Context, conversationID int64, name string) error {
return g.repo.DeleteBranch(ctx, conversationID, name)
}
func (g *Glue) ListBranches(ctx context.Context, conversationID int64) ([]models.Branch, error) {
return g.repo.ListBranches(ctx, conversationID)
}

View File

@@ -0,0 +1,113 @@
package glue
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"time"
"mind/internal/config"
"mind/internal/db"
"mind/internal/models"
)
type Glue struct {
repo *db.Repo
cfg config.Config
}
// NewGlue now receives cfg
func NewGlue(r *db.Repo, cfg config.Config) *Glue { return &Glue{repo: r, cfg: cfg} }
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)
}
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"`
}
// llama request/response (support two common shapes)
type llamaReq struct {
Prompt string `json:"prompt"`
}
type llamaResp struct {
Content string `json:"content"`
}
func (g *Glue) callLlama(ctx context.Context, prompt string) (string, error) {
if g.cfg.LlamaURL == "" {
return "", errors.New("LLAMA_URL not set")
}
body, _ := json.Marshal(llamaReq{Prompt: prompt})
url := g.cfg.LlamaURL + "/completion"
httpc := &http.Client{Timeout: 45 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := httpc.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
dec := json.NewDecoder(resp.Body)
var a llamaResp
if err := dec.Decode(&a); err == nil && a.Content != "" {
return a.Content, nil
}
return "", errors.New("unexpected llama response")
}
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) 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) real llama call (fallback to stub if it fails)
answerText, err := g.callLlama(ctx, req.Prompt)
if err != nil {
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,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,98 @@
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}
// GET /branches?conversation_id=...
// DELETE /branches {conversation_id, name}
func (s *server) branches(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
var in struct {
ConversationID int64 `json:"conversation_id"`
Name string `json:"name"`
HeadNodeID int64 `json:"head_node_id"`
}
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(), glue.ForkReq{
ConversationID: in.ConversationID,
Name: in.Name,
HeadNodeID: in.HeadNodeID,
})
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
writeJSON(w, 200, b)
case http.MethodGet:
qs := r.URL.Query().Get("conversation_id")
if qs == "" { writeJSON(w, 400, map[string]string{"error":"conversation_id required"}); return }
convID, _ := strconv.ParseInt(qs, 10, 64)
out, err := s.glue.ListBranches(r.Context(), convID)
if err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}); return }
writeJSON(w, 200, out)
case http.MethodDelete:
var in struct {
ConversationID int64 `json:"conversation_id"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, 400, map[string]string{"error":"bad json"}); return
}
if err := s.glue.DeleteBranch(r.Context(), in.ConversationID, in.Name); err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()}); return
}
writeJSON(w, 200, map[string]string{"ok":"true"})
default:
w.WriteHeader(405)
}
}
// 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
}

53
backend/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, cfg)
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)
}

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

View File

Before

Width:  |  Height:  |  Size: 527 KiB

After

Width:  |  Height:  |  Size: 527 KiB

View File

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 416 KiB

View File

Before

Width:  |  Height:  |  Size: 653 KiB

After

Width:  |  Height:  |  Size: 653 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
milestone-1/demo.mp4 Normal file

Binary file not shown.

BIN
milestone-1/sample.db Normal file

Binary file not shown.