Compare commits
12 Commits
692b069b5b
...
3ab8b2bc86
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ab8b2bc86 | |||
| c22496493b | |||
| b0c6cfbf62 | |||
| e0a90c2ad7 | |||
| 5eefdcdb5b | |||
| 20e0ccf783 | |||
| fd46be63a0 | |||
| 83c1424a37 | |||
| c04536d5f6 | |||
| 2fdb1ece43 | |||
| 839f0c9107 | |||
| 29a451ab58 |
3
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
**/mind.db
|
||||||
|
**/*.gguf
|
||||||
|
bin/
|
||||||
9
backend/Makefile
Normal 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
@@ -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`
|
||||||
|
In `$REPO/third_party/llama.cpp` run `make` to build.
|
||||||
|
|
||||||
|
### 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
@@ -0,0 +1,434 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
MIND CLI (v0) — supports Ops 1–8 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
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||||
25
backend/internal/config/config.go
Normal 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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
24
backend/internal/glue/branches.go
Normal 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)
|
||||||
|
}
|
||||||
113
backend/internal/glue/completion.go
Normal 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
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
98
backend/internal/http/handlers.go
Normal 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)
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
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
@@ -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/sample.db
Normal file
|
Before Width: | Height: | Size: 527 KiB After Width: | Height: | Size: 527 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 653 KiB After Width: | Height: | Size: 653 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |