How it works
What happens between typing a question and seeing an answer, and why the pieces are arranged the way they are.
The shape of it
browser ──── /api/* ────► FastAPI ────► your model
│ │
│ └────────► workspace store
│ (a local SQLite file)
│
└── remembers its anonymous workspace ids in localStorageA workspace is one row keyed by a UUID. The browser keeps that UUID in localStorage and sends it with every request; that is the whole identity model. Clearing site data or opening a different browser gets you a fresh, empty sheet. There is no sign-in.
You can keep several workbooks, and the same absence of sign-in decides how. The browser holds the list of ids and posts it to /api/workspaces to be summarised; there is deliberately no endpoint that lists the table, because on a shared deployment it would hand every visitor everyone else's sheets. The list is shared by every tab, in localStorage; which workbook is open is per tab, in sessionStorage, so two tabs can sit on two workbooks and a reload does not drag one onto the other.
The browser never talks to the store. Every read and write goes through the backend, so the SQLite file stays on the machine running it and is reachable only through the endpoints above. There is no second set of credentials, and nothing for the browser to hold.
A question, end to end
- Route it. The message is classified. Is this about the data, or conversation? Small models fail here first.
- Hydrate. The backend re-reads the workspace row and rebuilds an in-memory SQLite database from it. A warm instance keeps the parsed result, keyed by a hash of the rows, and skips the rebuild when nothing changed.
- Generate SQL. The model is given the schema and the question, and asked for read-only SQL. Markdown fences are stripped, models emit them regardless of instructions.
- Run it against the in-memory database.
- Write the answer. The rows go back to the model to be put into prose, and, when the shape suits it, a chart spec: type, axis key, series, rows.
- Render. The client draws the chart with Recharts. Any edit to the sheet itself goes through Univer, which is the spreadsheet this is all built around.
Why the backend is stateless
It used to hold the dataset in a module-level object, which works fine on one long-lived server and not at all on serverless: the filesystem is read-only apart from /tmp, that does not survive between invocations, and consecutive requests are not guaranteed to reach the same instance.
So the dataset lives in the workspace row and is re-read per request. Writing changes back through the same row means an edit the model makes (a deduplication, a filter) is picked up by the spreadsheet UI through its normal load path, with no extra syncing.
Reading is cheap; parsing and loading into SQLite is not. Hence the per-instance cache. Correctness never depends on it: the row is always fetched before the cache is consulted.
Simple and Complex
The dropdown next to the message box picks which of two routes a question takes. It changes how the answer is produced, not how hard the model tries.
| Mode | What happens |
|---|---|
| Simple | The steps above, once: the model is asked for one SQL query, it is run, and the rows come back as prose. One model call for the SQL and one for the answer. |
| Complex | A LangChain SQL agent instead: the model can look at the schema, run a query, read the result and decide to run another. Useful when one query cannot answer the question. |
What the model is and is not asked to do
Every call is a plain completion: invoke(prompt), read .content. There is no tool calling, no structured-output binding, no streaming, no async, and no embeddings or vector store anywhere. Structured replies are produced by asking for JSON and parsing what comes back.
That is a deliberate constraint, and it is why the provider layer is a table rather than an integration: the providers differ only in what they name their constructor arguments. It is also what makes modest local models viable. nothing here requires a model that can survive an agent loop.
Layout
backend/
main.py FastAPI routes
llm_providers.py the provider table
settings.py resolves provider + model from the environment
check_model.py tests whether your model can do the job
agent_services.py SQL generation, chart specs, conversation memory
data_handler.py file parsing, in-memory SQLite
capacity.py how big a sheet this will open
stores/
__init__.py the storage API
sqlite_store.py a local file
edi-frontend/
src/app/app/page.tsx the app, one route, at /app
src/app/(docs)/ these pages, at /
src/components/ spreadsheet, chat sidebar, charts
src/utils/api.ts every backend callHTTP API lists the endpoints.