Compare commits
11 Commits
692b069b5b
...
andy-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e4a3eee7 | ||
| b0c6cfbf62 | |||
| e0a90c2ad7 | |||
| 5eefdcdb5b | |||
| 20e0ccf783 | |||
| fd46be63a0 | |||
| 83c1424a37 | |||
| c04536d5f6 | |||
| 2fdb1ece43 | |||
| 839f0c9107 | |||
| 29a451ab58 |
3
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
.venv/**/*
|
||||
|
||||
note.md
|
||||
start_llama_server.sh
|
||||
|
||||
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
|
||||
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);
|
||||
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
18
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
frontend/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
5899
frontend/package-lock.json
generated
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"next": "16.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.0"
|
||||
}
|
||||
}
|
||||
1
frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
BIN
frontend/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
42
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,42 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
32
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
141
frontend/src/app/page.module.css
Normal file
@@ -0,0 +1,141 @@
|
||||
.page {
|
||||
--background: #fafafa;
|
||||
--foreground: #fff;
|
||||
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
|
||||
--button-primary-hover: #383838;
|
||||
--button-secondary-hover: #f2f2f2;
|
||||
--button-secondary-border: #ebebeb;
|
||||
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-geist-sans);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
background-color: var(--foreground);
|
||||
padding: 120px 60px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
max-width: 320px;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 48px;
|
||||
letter-spacing: -2.4px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.intro p {
|
||||
max-width: 440px;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.intro a {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ctas a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 128px;
|
||||
border: 1px solid transparent;
|
||||
transition: 0.2s;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a.primary {
|
||||
background: var(--text-primary);
|
||||
color: var(--background);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
border-color: var(--button-secondary-border);
|
||||
}
|
||||
|
||||
/* Enable hover only on non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
a.primary:hover {
|
||||
background: var(--button-primary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
a.secondary:hover {
|
||||
background: var(--button-secondary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.main {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
letter-spacing: -1.92px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo {
|
||||
filter: invert();
|
||||
}
|
||||
|
||||
.page {
|
||||
--background: #000;
|
||||
--foreground: #000;
|
||||
|
||||
--text-primary: #ededed;
|
||||
--text-secondary: #999;
|
||||
|
||||
--button-primary-hover: #ccc;
|
||||
--button-secondary-hover: #1a1a1a;
|
||||
--button-secondary-border: #1a1a1a;
|
||||
}
|
||||
}
|
||||
66
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import Image from "next/image";
|
||||
import styles from "./page.module.css";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<main className={styles.main}>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className={styles.intro}>
|
||||
<h1>To get started, edit the page.tsx file.</h1>
|
||||
<p>
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.ctas}>
|
||||
<a
|
||||
className={styles.primary}
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className={styles.secondary}
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
frontend/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
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 |